mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 13:56:37 +08:00
优化续播
This commit is contained in:
@@ -205,13 +205,13 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
updated.PopulateComputedFields()
|
updated.PopulateComputedFields()
|
||||||
c.JSON(http.StatusOK, updated)
|
c.JSON(http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminUpdateUserLibrariesReq struct {
|
type adminUpdateUserLibrariesReq struct {
|
||||||
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
|
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req adminUpdateUserLibrariesReq
|
var req adminUpdateUserLibrariesReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -262,7 +262,7 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
updated.PopulateComputedFields()
|
updated.PopulateComputedFields()
|
||||||
c.JSON(http.StatusOK, updated)
|
c.JSON(http.StatusOK, updated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
|
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
|
||||||
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
|
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
|
|||||||
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
|
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
|
||||||
t.Fatal("recent realtime user should not be deleted")
|
t.Fatal("recent realtime user should not be deleted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateUserLibraries(t *testing.T) {
|
func TestUpdateUserLibraries(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MMTL/internal/config"
|
||||||
|
"github.com/ShukeBta/MMTL/internal/middleware"
|
||||||
|
"github.com/ShukeBta/MMTL/internal/model"
|
||||||
|
"github.com/ShukeBta/MMTL/internal/repository"
|
||||||
|
"github.com/ShukeBta/MMTL/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repos := repository.New(db)
|
||||||
|
user := &model.User{
|
||||||
|
Base: model.Base{ID: "user-1"},
|
||||||
|
Username: "test_viewer",
|
||||||
|
PasswordHash: "x",
|
||||||
|
Role: "user",
|
||||||
|
Tier: "free",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
logger := zap.NewNop()
|
||||||
|
svc := &service.Container{
|
||||||
|
Repo: repos,
|
||||||
|
Emby: service.NewEmbyService(cfg, logger, repos),
|
||||||
|
Sessions: service.NewSessionTrackerService(logger),
|
||||||
|
Playback: service.NewPlaybackService(logger, repos),
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
// 注册带认证的路由,模拟已登录用户
|
||||||
|
router.Use(func(c *gin.Context) {
|
||||||
|
c.Set(middleware.CtxUserID, user.ID)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
router.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
|
||||||
|
router.GET("/Items", embyItemsHandler(svc))
|
||||||
|
router.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
|
||||||
|
router.GET("/Sessions", embySessionsHandler(svc))
|
||||||
|
|
||||||
|
remoteMediaID := service.EncodeEmbyRemoteID("mount-1", "remote-item-123")
|
||||||
|
|
||||||
|
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
|
||||||
|
progressReq := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
wProgress := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(wProgress, progressReq)
|
||||||
|
if wProgress.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("progress status = %d, body = %s", wProgress.Code, wProgress.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证已持久化到 PlaybackHistory
|
||||||
|
var hist model.PlaybackHistory
|
||||||
|
if err := db.Where("user_id = ? AND media_id = ?", user.ID, remoteMediaID).First(&hist).Error; err != nil {
|
||||||
|
t.Fatalf("playback history not saved: %v", err)
|
||||||
|
}
|
||||||
|
if hist.PositionMs != 30000 {
|
||||||
|
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 测试 Filters=IsResumable 能够包含该远程条目
|
||||||
|
resumableReq := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/Items?Filters=IsResumable",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
wResumable := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(wResumable, resumableReq)
|
||||||
|
if wResumable.Code != http.StatusOK {
|
||||||
|
t.Fatalf("items resumable status = %d, body = %s", wResumable.Code, wResumable.Body.String())
|
||||||
|
}
|
||||||
|
var resumableEnvelope map[string]any
|
||||||
|
if err := json.Unmarshal(wResumable.Body.Bytes(), &resumableEnvelope); err != nil {
|
||||||
|
t.Fatalf("decode resumable: %v", err)
|
||||||
|
}
|
||||||
|
// 因为没有配置真实的远程客户端连接,该远程条目在当前离线测试中不会 panic 崩溃,并且正常响应 Envelope
|
||||||
|
if resumableEnvelope["TotalRecordCount"] == nil {
|
||||||
|
t.Fatalf("missing TotalRecordCount in resumable envelope")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 测试 /Users/:userId/Items/Resume 别名路由
|
||||||
|
resumeAliasReq := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/Users/"+user.ID+"/Items/Resume",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
wResumeAlias := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(wResumeAlias, resumeAliasReq)
|
||||||
|
if wResumeAlias.Code != http.StatusOK {
|
||||||
|
t.Fatalf("resume alias status = %d, body = %s", wResumeAlias.Code, wResumeAlias.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 测试 /Sessions 返回 NowPlayingItem
|
||||||
|
sessionsReq := httptest.NewRequest(http.MethodGet, "/Sessions", nil)
|
||||||
|
wSessions := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(wSessions, sessionsReq)
|
||||||
|
if wSessions.Code != http.StatusOK {
|
||||||
|
t.Fatalf("sessions status = %d, body = %s", wSessions.Code, wSessions.Body.String())
|
||||||
|
}
|
||||||
|
var sessionsList []map[string]any
|
||||||
|
if err := json.Unmarshal(wSessions.Body.Bytes(), &sessionsList); err != nil {
|
||||||
|
t.Fatalf("decode sessions: %v", err)
|
||||||
|
}
|
||||||
|
if len(sessionsList) == 0 {
|
||||||
|
t.Fatalf("expected at least 1 session")
|
||||||
|
}
|
||||||
|
nowPlaying, ok := sessionsList[0]["NowPlayingItem"].(map[string]any)
|
||||||
|
if !ok || nowPlaying["Id"] != remoteMediaID {
|
||||||
|
t.Fatalf("expected NowPlayingItem with id %q, got %#v", remoteMediaID, sessionsList[0]["NowPlayingItem"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signMockToken(secret, userID string) string {
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"sub": userID,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
s, _ := token.SignedString([]byte(secret))
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -12,8 +12,13 @@ import (
|
|||||||
|
|
||||||
type embyPlayingReq struct {
|
type embyPlayingReq struct {
|
||||||
ItemId string `json:"ItemId"`
|
ItemId string `json:"ItemId"`
|
||||||
|
ItemIDLower string `json:"itemId"`
|
||||||
|
ID string `json:"Id"`
|
||||||
|
IDLower string `json:"id"`
|
||||||
PositionTicks int64 `json:"PositionTicks"`
|
PositionTicks int64 `json:"PositionTicks"`
|
||||||
|
PositionLower int64 `json:"positionTicks"`
|
||||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||||
|
RunTimeLower int64 `json:"runTimeTicks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
@@ -25,16 +30,25 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
var req embyPlayingReq
|
var req embyPlayingReq
|
||||||
_ = c.ShouldBindJSON(&req)
|
_ = c.ShouldBindJSON(&req)
|
||||||
if req.ItemId == "" {
|
itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower)
|
||||||
req.ItemId = c.Query("ItemId")
|
if itemID == "" {
|
||||||
|
itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id"))
|
||||||
}
|
}
|
||||||
if req.PositionTicks == 0 {
|
pos := req.PositionTicks
|
||||||
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
|
if pos == 0 {
|
||||||
|
pos = req.PositionLower
|
||||||
}
|
}
|
||||||
if req.RunTimeTicks == 0 {
|
if pos == 0 {
|
||||||
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
|
pos, _ = strconv.ParseInt(firstQueryValue(c, "PositionTicks", "positionTicks"), 10, 64)
|
||||||
}
|
}
|
||||||
if req.ItemId == "" {
|
runTime := req.RunTimeTicks
|
||||||
|
if runTime == 0 {
|
||||||
|
runTime = req.RunTimeLower
|
||||||
|
}
|
||||||
|
if runTime == 0 {
|
||||||
|
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
|
||||||
|
}
|
||||||
|
if itemID == "" {
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -43,7 +57,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.Status(http.StatusUnauthorized)
|
c.Status(http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks); err != nil {
|
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -54,9 +68,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
clientInfo.DeviceName,
|
clientInfo.DeviceName,
|
||||||
clientInfo.Client,
|
clientInfo.Client,
|
||||||
c.ClientIP(),
|
c.ClientIP(),
|
||||||
req.ItemId,
|
itemID,
|
||||||
req.PositionTicks,
|
pos,
|
||||||
req.RunTimeTicks,
|
runTime,
|
||||||
stopped)
|
stopped)
|
||||||
}
|
}
|
||||||
if svc.Device != nil && !stopped {
|
if svc.Device != nil && !stopped {
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
|
|||||||
auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
|
auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
|
||||||
auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
|
auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
|
||||||
auth.GET("/Items/Resume", embyResumeItemsHandler(svc))
|
auth.GET("/Items/Resume", embyResumeItemsHandler(svc))
|
||||||
|
auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
|
||||||
|
auth.GET("/UserItems/Resume", embyResumeItemsHandler(svc))
|
||||||
auth.GET("/Items/:id", embyItemByIDHandler(svc))
|
auth.GET("/Items/:id", embyItemByIDHandler(svc))
|
||||||
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
|
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
|
||||||
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
|
|||||||
auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc))
|
auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc))
|
||||||
auth.GET("/items/latest", embyLatestItemsHandler(svc))
|
auth.GET("/items/latest", embyLatestItemsHandler(svc))
|
||||||
auth.GET("/items/resume", embyResumeItemsHandler(svc))
|
auth.GET("/items/resume", embyResumeItemsHandler(svc))
|
||||||
|
auth.GET("/users/:userId/items/resume", embyResumeItemsHandler(svc))
|
||||||
|
auth.GET("/useritems/resume", embyResumeItemsHandler(svc))
|
||||||
auth.GET("/items/:id", embyItemByIDHandler(svc))
|
auth.GET("/items/:id", embyItemByIDHandler(svc))
|
||||||
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
|
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
|
||||||
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
||||||
|
|||||||
@@ -41,7 +41,17 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
"SupportsRemoteControl": true,
|
"SupportsRemoteControl": true,
|
||||||
}
|
}
|
||||||
if itemID != "" && sess.IsPlaying {
|
if itemID != "" && sess.IsPlaying {
|
||||||
row["NowPlayingItem"] = gin.H{"Id": itemID}
|
nowPlaying := gin.H{"Id": itemID}
|
||||||
|
if svc.Emby != nil {
|
||||||
|
if item, _ := svc.Emby.Item(c.Request.Context(), itemID, sess.UserID); item != nil {
|
||||||
|
for _, key := range []string{"Name", "Type", "RunTimeTicks", "PrimaryImageItemId", "ImageTags", "SeriesName", "SeasonName", "IndexNumber", "ParentIndexNumber"} {
|
||||||
|
if val, ok := item[key]; ok && val != nil {
|
||||||
|
nowPlaying[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row["NowPlayingItem"] = nowPlaying
|
||||||
}
|
}
|
||||||
out = append(out, row)
|
out = append(out, row)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
|||||||
api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
|
api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
|
||||||
api.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
api.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
||||||
return router, svc, cfg.Secrets.JWTSecret
|
return router, svc, cfg.Secrets.JWTSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||||
router, _, secret := newPlaybackScopeTestRouter(t)
|
router, _, secret := newPlaybackScopeTestRouter(t)
|
||||||
@@ -478,4 +478,3 @@ func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) {
|
|||||||
t.Fatalf("expected empty tracks array, got %v", payload.Tracks)
|
t.Fatalf("expected empty tracks array, got %v", payload.Tracks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,18 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
m, ok := mIdx[r.MediaID]
|
m, ok := mIdx[r.MediaID]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
||||||
|
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
|
||||||
|
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
|
||||||
|
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
|
||||||
|
out = append(out, gin.H{
|
||||||
|
"history": r,
|
||||||
|
"media": *rm,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, gin.H{
|
out = append(out, gin.H{
|
||||||
|
|||||||
@@ -140,4 +140,3 @@ type StrmDirCache struct {
|
|||||||
DirID string `gorm:"size:128;index:idx_strm_dir_cache,priority:2" json:"dir_id"`
|
DirID string `gorm:"size:128;index:idx_strm_dir_cache,priority:2" json:"dir_id"`
|
||||||
Path string `gorm:"size:1024" json:"path"` // 相对根目录的路径
|
Path string `gorm:"size:1024" json:"path"` // 相对根目录的路径
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ type Container struct {
|
|||||||
StrmDirCache *StrmDirCacheRepository
|
StrmDirCache *StrmDirCacheRepository
|
||||||
ScrapeTask *ScrapeTaskRepository
|
ScrapeTask *ScrapeTaskRepository
|
||||||
EmbyMount *EmbyMountRepository
|
EmbyMount *EmbyMountRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 将每个 repository 连接到单个 *gorm.DB。
|
// New 将每个 repository 连接到单个 *gorm.DB。
|
||||||
func New(db *gorm.DB) *Container {
|
func New(db *gorm.DB) *Container {
|
||||||
|
|||||||
@@ -821,5 +821,3 @@ func (r *StrmDirCacheRepository) DeleteBySyncPathID(ctx context.Context, syncPat
|
|||||||
return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error
|
return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,4 +138,3 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
|
|||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,9 +278,9 @@ func TestDanmakuSameBase(t *testing.T) {
|
|||||||
require.False(t, sameDanmakuBase("", "https://api.dandanplay.net"))
|
require.False(t, sameDanmakuBase("", "https://api.dandanplay.net"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchCommentWithFallback:配置源与官方同源时不重复请求;
|
// fetchCommentWithFallback:配置源与官方同源时不重复请求;
|
||||||
// 全失败时带出最后一跳错误。
|
// 全失败时带出最后一跳错误。
|
||||||
func TestDanmakuFetchCommentWithFallback(t *testing.T) {
|
func TestDanmakuFetchCommentWithFallback(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}))
|
}))
|
||||||
@@ -292,10 +292,10 @@ func TestDanmakuSameBase(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Empty(t, raw)
|
require.Empty(t, raw)
|
||||||
require.Equal(t, "auto", st)
|
require.Equal(t, "auto", st)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
|
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
|
||||||
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
|
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
|
||||||
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
|
||||||
|
|
||||||
// 官方服务同时提供 match 和 search:
|
// 官方服务同时提供 match 和 search:
|
||||||
@@ -343,7 +343,7 @@ func TestDanmakuSameBase(t *testing.T) {
|
|||||||
require.Equal(t, int64(99999), resManual.EpisodeID)
|
require.Equal(t, int64(99999), resManual.EpisodeID)
|
||||||
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
|
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
|
||||||
require.Contains(t, resManual.Raw, "手动搜索弹幕")
|
require.Contains(t, resManual.Raw, "手动搜索弹幕")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。
|
// Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。
|
||||||
func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) {
|
func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) {
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if containsEmbyFilter(p.Filters, "IsResumable") {
|
||||||
|
return e.resumableItems(ctx, p)
|
||||||
|
}
|
||||||
|
|
||||||
if len(p.IDs) > 0 {
|
if len(p.IDs) > 0 {
|
||||||
items := make([]map[string]any, 0, len(p.IDs))
|
items := make([]map[string]any, 0, len(p.IDs))
|
||||||
for _, id := range p.IDs {
|
for _, id := range p.IDs {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
|
|
||||||
// Item 单条目详情。
|
// Item 单条目详情。
|
||||||
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||||
|
if e == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
// 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。
|
// 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。
|
||||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||||
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||||
@@ -174,17 +177,29 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
|||||||
|
|
||||||
// ResumeItems 列出有未完成播放进度的媒体。
|
// ResumeItems 列出有未完成播放进度的媒体。
|
||||||
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
||||||
if limit <= 0 || limit > 100 {
|
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
|
||||||
limit = 20
|
}
|
||||||
|
|
||||||
|
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
|
||||||
|
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||||
|
if p.Limit <= 0 || p.Limit > 100 {
|
||||||
|
p.Limit = 50
|
||||||
}
|
}
|
||||||
|
if p.StartIndex < 0 {
|
||||||
|
p.StartIndex = 0
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.UserID) == "" {
|
||||||
|
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||||
|
}
|
||||||
|
|
||||||
var hist []model.PlaybackHistory
|
var hist []model.PlaybackHistory
|
||||||
if err := e.repo.DB.WithContext(ctx).
|
if err := e.repo.DB.WithContext(ctx).
|
||||||
Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false).
|
Where("user_id = ? AND completed = ? AND position_ms > 0", p.UserID, false).
|
||||||
Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil {
|
Order("watched_at desc").Find(&hist).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(hist) == 0 {
|
if len(hist) == 0 {
|
||||||
return map[string]any{"Items": []any{}, "TotalRecordCount": 0}, nil
|
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
localIDs := make([]string, 0, len(hist))
|
localIDs := make([]string, 0, len(hist))
|
||||||
@@ -197,7 +212,7 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
|||||||
if len(localIDs) > 0 {
|
if len(localIDs) > 0 {
|
||||||
var medias []model.Media
|
var medias []model.Media
|
||||||
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
|
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
|
||||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
|
||||||
if err := q.Find(&medias).Error; err != nil {
|
if err := q.Find(&medias).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -209,6 +224,9 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
|||||||
items := make([]map[string]any, 0, len(hist))
|
items := make([]map[string]any, 0, len(hist))
|
||||||
for _, h := range hist {
|
for _, h := range hist {
|
||||||
if m, ok := byID[h.MediaID]; ok {
|
if m, ok := byID[h.MediaID]; ok {
|
||||||
|
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
|
items = append(items, e.itemPayload(ctx, m, false, h.PositionMs))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -224,10 +242,23 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
|||||||
if err != nil || item == nil {
|
if err != nil || item == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if p.ParentID != "" {
|
||||||
|
parentID, _ := item["ParentId"].(string)
|
||||||
|
seriesID, _ := item["SeriesId"].(string)
|
||||||
|
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
|
item["UserData"] = mergedRemoteUserData(item["UserData"], &h)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
|
|
||||||
|
total := int64(len(items))
|
||||||
|
if p.StartIndex >= len(items) {
|
||||||
|
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||||
|
}
|
||||||
|
end := minInt(p.StartIndex+p.Limit, len(items))
|
||||||
|
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
|
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestRewriteSubtitleDeliveryURLsFallsBackIndexOne(t *testing.T) {
|
|||||||
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want {
|
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want {
|
||||||
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
|
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) {
|
func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) {
|
||||||
r := &EmbyRemoteService{}
|
r := &EmbyRemoteService{}
|
||||||
|
|||||||
@@ -87,6 +87,23 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
|
|||||||
// runtimeTicks 缺失时回退到 media.DurationSec
|
// runtimeTicks 缺失时回退到 media.DurationSec
|
||||||
if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil {
|
if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil {
|
||||||
dur = int64(m.DurationSec) * 1000
|
dur = int64(m.DurationSec) * 1000
|
||||||
|
} else if IsEmbyRemoteID(mediaID) {
|
||||||
|
// 远程挂载条目:尝试从既有历史记录或远程详情补齐时长
|
||||||
|
var oldHist model.PlaybackHistory
|
||||||
|
if err := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&oldHist).Error; err == nil && oldHist.DurationMs > 0 {
|
||||||
|
dur = oldHist.DurationMs
|
||||||
|
} else if e.remote != nil {
|
||||||
|
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||||
|
if mount, acct, _ := e.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil {
|
||||||
|
if item, _ := e.remote.RemoteItem(ctx, mount, acct, remoteID); item != nil {
|
||||||
|
if ticks, ok := item["RunTimeTicks"].(float64); ok && ticks > 0 {
|
||||||
|
dur = int64(ticks) / 10_000
|
||||||
|
} else if ticks, ok := item["RunTimeTicks"].(int64); ok && ticks > 0 {
|
||||||
|
dur = ticks / 10_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
completed := dur > 0 && pos >= dur*9/10
|
completed := dur > 0 && pos >= dur*9/10
|
||||||
|
|||||||
@@ -79,3 +79,32 @@ func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
|
|||||||
t.Fatalf("first item ID = %#v, want %q", items[0]["Id"], remoteID)
|
t.Fatalf("first item ID = %#v, want %q", items[0]["Id"], remoteID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) {
|
||||||
|
svc := newTestEmbyService(t)
|
||||||
|
remoteID := EncodeEmbyRemoteID("mount-test", "item-999")
|
||||||
|
user := &model.User{Username: "resume_test_user", Role: "user", Tier: "free", IsActive: true}
|
||||||
|
if err := svc.repo.User.Create(t.Context(), user); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先以有 runtimeTicks 写入首次进度
|
||||||
|
if err := svc.RecordProgress(t.Context(), user.ID, remoteID, 10_000_000, 100_000_000); err != nil {
|
||||||
|
t.Fatalf("first record progress: %v", err)
|
||||||
|
}
|
||||||
|
// 再次上报,但某些客户端此时发了 0 runtimeTicks
|
||||||
|
if err := svc.RecordProgress(t.Context(), user.ID, remoteID, 95_000_000, 0); err != nil {
|
||||||
|
t.Fatalf("second record progress: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hist model.PlaybackHistory
|
||||||
|
if err := svc.repo.DB.Where("user_id = ? AND media_id = ?", user.ID, remoteID).First(&hist).Error; err != nil {
|
||||||
|
t.Fatalf("find hist: %v", err)
|
||||||
|
}
|
||||||
|
if hist.DurationMs != 10_000 {
|
||||||
|
t.Fatalf("expected duration 10000ms, got %d", hist.DurationMs)
|
||||||
|
}
|
||||||
|
if !hist.Completed {
|
||||||
|
t.Fatalf("expected 95%% progress to be completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,4 +111,3 @@ func safeZipTarget(destRoot, name string) (string, error) {
|
|||||||
}
|
}
|
||||||
return targetAbs, nil
|
return targetAbs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,4 +78,3 @@ func TestStartInstallRejectsConcurrent(t *testing.T) {
|
|||||||
svc.running = false
|
svc.running = false
|
||||||
svc.mu.Unlock()
|
svc.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,4 +93,3 @@ func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
|||||||
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
|
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
|
||||||
return PruneImageCache(p.cacheDir, maxBytes)
|
return PruneImageCache(p.cacheDir, maxBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
type PlaybackService struct {
|
type PlaybackService struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
|
remote *EmbyRemoteService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPlaybackService is the constructor.
|
// NewPlaybackService is the constructor.
|
||||||
@@ -28,6 +29,14 @@ func NewPlaybackService(log *zap.Logger, repo *repository.Container) *PlaybackSe
|
|||||||
return &PlaybackService{log: log, repo: repo}
|
return &PlaybackService{log: log, repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetEmbyRemote wires the remote Emby service for hydrating mounted remote items.
|
||||||
|
func (p *PlaybackService) SetEmbyRemote(remote *EmbyRemoteService) *PlaybackService {
|
||||||
|
if p != nil {
|
||||||
|
p.remote = remote
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// ─── History ────────────────────────────────────────────────────────────────
|
// ─── History ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// RecordProgress upserts the resume position for a (user, media) pair. A
|
// RecordProgress upserts the resume position for a (user, media) pair. A
|
||||||
@@ -84,9 +93,19 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
|
|||||||
if m, ok := mediaByID[rows[i].MediaID]; ok {
|
if m, ok := mediaByID[rows[i].MediaID]; ok {
|
||||||
media := m
|
media := m
|
||||||
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
|
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
|
||||||
} else {
|
continue
|
||||||
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
|
|
||||||
}
|
}
|
||||||
|
if p.remote != nil && IsEmbyRemoteID(rows[i].MediaID) {
|
||||||
|
mountID, remoteID, _ := DecodeEmbyRemoteID(rows[i].MediaID)
|
||||||
|
if mount, acct, _ := p.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil {
|
||||||
|
if rm, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID); err == nil && rm != nil {
|
||||||
|
media := *rm
|
||||||
|
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,4 +231,3 @@ func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
|||||||
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
|
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
|
||||||
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
|
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
|
||||||
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
|
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
|
||||||
b.c.Playback = NewPlaybackService(b.log, b.repos)
|
b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote)
|
||||||
b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
|
b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
|
||||||
b.c.Profile = NewProfileService(b.log, b.repos)
|
b.c.Profile = NewProfileService(b.log, b.repos)
|
||||||
b.c.Audit = NewAuditService(b.log, b.repos)
|
b.c.Audit = NewAuditService(b.log, b.repos)
|
||||||
|
|||||||
@@ -575,7 +575,8 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] })
|
|||||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
>
|
>
|
||||||
{history.slice(0, 12).map((h) => {
|
{history.slice(0, 12).map((h) => {
|
||||||
const media = h.media!
|
const media = h.media
|
||||||
|
if (!media) return null
|
||||||
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||||
return (
|
return (
|
||||||
<div key={h.id} className="w-64 sm:w-72 shrink-0">
|
<div key={h.id} className="w-64 sm:w-72 shrink-0">
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export function PlayerPage() {
|
|||||||
const [playerError, setPlayerError] = useState('')
|
const [playerError, setPlayerError] = useState('')
|
||||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||||
const [directOnly, setDirectOnly] = useState(false)
|
const [directOnly, setDirectOnly] = useState(false)
|
||||||
|
const [resumePosition, setResumePosition] = useState(0)
|
||||||
|
const [initialSeekDone, setInitialSeekDone] = useState(false)
|
||||||
|
|
||||||
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
|
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
|
||||||
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
||||||
@@ -254,7 +256,44 @@ export function PlayerPage() {
|
|||||||
return () => teardownHls(media.id, mode === 'hls')
|
return () => teardownHls(media.id, mode === 'hls')
|
||||||
}, [hlsUnavailable, media, mode, params, setParams, teardownHls])
|
}, [hlsUnavailable, media, mode, params, setParams, teardownHls])
|
||||||
|
|
||||||
// Persist resume position every 10 seconds while playing.
|
// 自动拉取已有的播放进度并恢复播放位置
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return
|
||||||
|
setResumePosition(0)
|
||||||
|
setInitialSeekDone(false)
|
||||||
|
playbackAPI
|
||||||
|
.recentHistory()
|
||||||
|
.then((items) => {
|
||||||
|
const found = items.find((h) => h.media_id === id)
|
||||||
|
if (found && found.position_ms > 2000 && !found.completed) {
|
||||||
|
setResumePosition(found.position_ms / 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = ref.current
|
||||||
|
if (!video || !resumePosition || initialSeekDone) return
|
||||||
|
const applyResume = () => {
|
||||||
|
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
|
||||||
|
video.currentTime = resumePosition
|
||||||
|
setInitialSeekDone(true)
|
||||||
|
const m = Math.floor(resumePosition / 60)
|
||||||
|
const s = Math.floor(resumePosition % 60)
|
||||||
|
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
|
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (video.readyState >= 1) {
|
||||||
|
applyResume()
|
||||||
|
} else {
|
||||||
|
video.addEventListener('loadedmetadata', applyResume, { once: true })
|
||||||
|
return () => video.removeEventListener('loadedmetadata', applyResume)
|
||||||
|
}
|
||||||
|
}, [resumePosition, initialSeekDone])
|
||||||
|
|
||||||
|
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!media || !ref.current) return
|
if (!media || !ref.current) return
|
||||||
const video = ref.current
|
const video = ref.current
|
||||||
@@ -273,6 +312,11 @@ export function PlayerPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
video.removeEventListener('timeupdate', handler)
|
video.removeEventListener('timeupdate', handler)
|
||||||
video.removeEventListener('pause', handler)
|
video.removeEventListener('pause', handler)
|
||||||
|
const positionMs = Math.floor(video.currentTime * 1000)
|
||||||
|
const durationMs = Math.floor((video.duration || 0) * 1000)
|
||||||
|
if (positionMs > 0 && media) {
|
||||||
|
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [media])
|
}, [media])
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import toast from 'react-hot-toast'
|
|||||||
import { historyAPI } from '../api/history'
|
import { historyAPI } from '../api/history'
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import { confirmAction } from '../components/confirmAction'
|
import { confirmAction } from '../components/confirmAction'
|
||||||
import type { HistoryItem } from '../types'
|
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||||
|
import type { HistoryItem, Media } from '../types'
|
||||||
|
|
||||||
function fmtDuration(ms: number): string {
|
function fmtDuration(ms: number): string {
|
||||||
if (!ms || ms <= 0) return '—'
|
if (!ms || ms <= 0) return '—'
|
||||||
@@ -90,8 +91,12 @@ export function WatchHistoryPage() {
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{items.map((h) => {
|
{items.map((h) => {
|
||||||
const m = h.media
|
const m: Media = h.media || ({
|
||||||
if (!m) return null
|
id: h.media_id,
|
||||||
|
title: isRemoteEmbyID(h.media_id) ? '远程媒体' : h.media_id,
|
||||||
|
poster_url: '',
|
||||||
|
updated_at: h.watched_at,
|
||||||
|
} as Media)
|
||||||
const progress =
|
const progress =
|
||||||
h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user