优化续播

This commit is contained in:
truewhile
2026-09-01 23:16:11 +08:00
parent 3fe37e050b
commit b503fdee7a
57 changed files with 739 additions and 410 deletions
@@ -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
}
+25 -11
View File
@@ -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 {
+2
View File
@@ -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))
+11 -1
View File
@@ -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)
} }
-1
View File
@@ -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)
} }
} }
+12
View File
@@ -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{
-1
View File
@@ -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"` // 相对根目录的路径
} }
-2
View File
@@ -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
}) })
} }
-1
View File
@@ -138,4 +138,3 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
return result, nil return result, nil
} }
+4
View File
@@ -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 {
+38 -7
View File
@@ -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 {
+17
View File
@@ -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
+29
View File
@@ -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
} }
-1
View File
@@ -78,4 +78,3 @@ func TestStartInstallRejectsConcurrent(t *testing.T) {
svc.running = false svc.running = false
svc.mu.Unlock() svc.mu.Unlock()
} }
-1
View File
@@ -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 -2
View File
@@ -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
} }
-1
View File
@@ -231,4 +231,3 @@ func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
} }
return nil return nil
} }
+1 -1
View File
@@ -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)
+2 -1
View File
@@ -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">
+45 -1
View File
@@ -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])
+8 -3
View File
@@ -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 (