mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-13 00:26:37 +08:00
优化续播
This commit is contained in:
@@ -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 {
|
||||
ItemId string `json:"ItemId"`
|
||||
ItemIDLower string `json:"itemId"`
|
||||
ID string `json:"Id"`
|
||||
IDLower string `json:"id"`
|
||||
PositionTicks int64 `json:"PositionTicks"`
|
||||
PositionLower int64 `json:"positionTicks"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
RunTimeLower int64 `json:"runTimeTicks"`
|
||||
}
|
||||
|
||||
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -25,16 +30,25 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
var req embyPlayingReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.ItemId == "" {
|
||||
req.ItemId = c.Query("ItemId")
|
||||
itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower)
|
||||
if itemID == "" {
|
||||
itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id"))
|
||||
}
|
||||
if req.PositionTicks == 0 {
|
||||
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
|
||||
pos := req.PositionTicks
|
||||
if pos == 0 {
|
||||
pos = req.PositionLower
|
||||
}
|
||||
if req.RunTimeTicks == 0 {
|
||||
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
|
||||
if pos == 0 {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -43,7 +57,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
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()})
|
||||
return
|
||||
}
|
||||
@@ -54,9 +68,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
clientInfo.DeviceName,
|
||||
clientInfo.Client,
|
||||
c.ClientIP(),
|
||||
req.ItemId,
|
||||
req.PositionTicks,
|
||||
req.RunTimeTicks,
|
||||
itemID,
|
||||
pos,
|
||||
runTime,
|
||||
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("/Items/Latest", embyLatestItemsHandler(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("/Users/:userId/Items/:id", embyUserItemByIDHandler(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("/items/latest", embyLatestItemsHandler(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("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
|
||||
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
||||
|
||||
@@ -41,7 +41,17 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
"SupportsRemoteControl": true,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -478,4 +478,3 @@ func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) {
|
||||
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 {
|
||||
m, ok := mIdx[r.MediaID]
|
||||
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
|
||||
}
|
||||
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"`
|
||||
Path string `gorm:"size:1024" json:"path"` // 相对根目录的路径
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -138,4 +138,3 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
items := make([]map[string]any, 0, len(p.IDs))
|
||||
for _, id := range p.IDs {
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
|
||||
// Item 单条目详情。
|
||||
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||
if e == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
@@ -174,17 +177,29 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
|
||||
// ResumeItems 列出有未完成播放进度的媒体。
|
||||
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
|
||||
}
|
||||
|
||||
// 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
|
||||
if err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false).
|
||||
Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil {
|
||||
Where("user_id = ? AND completed = ? AND position_ms > 0", p.UserID, false).
|
||||
Order("watched_at desc").Find(&hist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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))
|
||||
@@ -197,7 +212,7 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
||||
if len(localIDs) > 0 {
|
||||
var medias []model.Media
|
||||
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 {
|
||||
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))
|
||||
for _, h := range hist {
|
||||
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))
|
||||
continue
|
||||
}
|
||||
@@ -224,10 +242,23 @@ func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int)
|
||||
if err != nil || item == nil {
|
||||
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)
|
||||
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 {
|
||||
|
||||
@@ -87,6 +87,23 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string
|
||||
// runtimeTicks 缺失时回退到 media.DurationSec
|
||||
if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil {
|
||||
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
|
||||
|
||||
@@ -79,3 +79,32 @@ func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -78,4 +78,3 @@ func TestStartInstallRejectsConcurrent(t *testing.T) {
|
||||
svc.running = false
|
||||
svc.mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -93,4 +93,3 @@ func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||||
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
|
||||
return PruneImageCache(p.cacheDir, maxBytes)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
type PlaybackService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
remote *EmbyRemoteService
|
||||
}
|
||||
|
||||
// NewPlaybackService is the constructor.
|
||||
@@ -28,6 +29,14 @@ func NewPlaybackService(log *zap.Logger, repo *repository.Container) *PlaybackSe
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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 {
|
||||
media := m
|
||||
items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media})
|
||||
} else {
|
||||
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -231,4 +231,3 @@ func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
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.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.Profile = NewProfileService(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' }}
|
||||
>
|
||||
{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
|
||||
return (
|
||||
<div key={h.id} className="w-64 sm:w-72 shrink-0">
|
||||
|
||||
@@ -64,6 +64,8 @@ export function PlayerPage() {
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||
const [directOnly, setDirectOnly] = useState(false)
|
||||
const [resumePosition, setResumePosition] = useState(0)
|
||||
const [initialSeekDone, setInitialSeekDone] = useState(false)
|
||||
|
||||
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
|
||||
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
||||
@@ -254,7 +256,44 @@ export function PlayerPage() {
|
||||
return () => teardownHls(media.id, mode === 'hls')
|
||||
}, [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(() => {
|
||||
if (!media || !ref.current) return
|
||||
const video = ref.current
|
||||
@@ -273,6 +312,11 @@ export function PlayerPage() {
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', 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])
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import toast from 'react-hot-toast'
|
||||
import { historyAPI } from '../api/history'
|
||||
import { imageURL } from '../api/client'
|
||||
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 {
|
||||
if (!ms || ms <= 0) return '—'
|
||||
@@ -90,8 +91,12 @@ export function WatchHistoryPage() {
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((h) => {
|
||||
const m = h.media
|
||||
if (!m) return null
|
||||
const m: Media = h.media || ({
|
||||
id: h.media_id,
|
||||
title: isRemoteEmbyID(h.media_id) ? '远程媒体' : h.media_id,
|
||||
poster_url: '',
|
||||
updated_at: h.watched_at,
|
||||
} as Media)
|
||||
const progress =
|
||||
h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user