diff --git a/internal/config/types.go b/internal/config/types.go index a8cdee5..2bfe35e 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -44,11 +44,11 @@ type TranscoderConfig struct { // AppConfig 保存运行时应用参数。 type AppConfig struct { - Port int `mapstructure:"port"` - Debug bool `mapstructure:"debug"` - Env string `mapstructure:"env"` - DataDir string `mapstructure:"data_dir"` - WebDir string `mapstructure:"web_dir"` + Port int `mapstructure:"port"` + Debug bool `mapstructure:"debug"` + Env string `mapstructure:"env"` + DataDir string `mapstructure:"data_dir"` + WebDir string `mapstructure:"web_dir"` // HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置 // SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。 HTTPSEnabled bool `mapstructure:"https_enabled"` @@ -59,7 +59,7 @@ type AppConfig struct { // SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。 SSLCertPath string `mapstructure:"ssl_cert_path"` // SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。 - SSLKeyPath string `mapstructure:"ssl_key_path"` + SSLKeyPath string `mapstructure:"ssl_key_path"` FFmpegPath string `mapstructure:"ffmpeg_path"` FFprobePath string `mapstructure:"ffprobe_path"` // FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes. diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 73fb690..db3cc44 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -408,13 +408,13 @@ func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) { _ = sqlDB2.Close() } }() - _, copied, err := copyModelTables(src2, dst, 2) - if err != nil { - t.Fatal(err) - } - if copied != 2 { - t.Fatalf("copied rows = %d, want 2", copied) - } + _, copied, err := copyModelTables(src2, dst, 2) + if err != nil { + t.Fatal(err) + } + if copied != 2 { + t.Fatalf("copied rows = %d, want 2", copied) + } var userCount int64 if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil { t.Fatal(err) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 5aa66fe..e61837a 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -192,77 +192,77 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if req.IsActive { - _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false) - } else { - _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true) - } - updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - updated.PopulateComputedFields() - c.JSON(http.StatusOK, updated) + if req.IsActive { + _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false) + } else { + _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true) } + updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + updated.PopulateComputedFields() + c.JSON(http.StatusOK, updated) } +} - type adminUpdateUserLibrariesReq struct { - AllowedLibraryIDs *[]string `json:"allowed_library_ids"` - } +type adminUpdateUserLibrariesReq struct { + AllowedLibraryIDs *[]string `json:"allowed_library_ids"` +} - func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req adminUpdateUserLibrariesReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - userID := c.Param("id") - user, err := svc.Repo.User.FindByID(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if user == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) - return - } +func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req adminUpdateUserLibrariesReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + userID := c.Param("id") + user, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if user == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } - var rawJSON string - if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 { - var cleanIDs []string - for _, id := range *req.AllowedLibraryIDs { - trimmed := strings.TrimSpace(id) - if trimmed != "" { - cleanIDs = append(cleanIDs, trimmed) - } - } - if len(cleanIDs) > 0 { - data, err := json.Marshal(cleanIDs) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - rawJSON = string(data) + var rawJSON string + if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 { + var cleanIDs []string + for _, id := range *req.AllowedLibraryIDs { + trimmed := strings.TrimSpace(id) + if trimmed != "" { + cleanIDs = append(cleanIDs, trimmed) } } - - updates := map[string]any{"allowed_library_ids": rawJSON} - if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return + if len(cleanIDs) > 0 { + data, err := json.Marshal(cleanIDs) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + rawJSON = string(data) } - - updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) - if err != nil || updated == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"}) - return - } - updated.PopulateComputedFields() - c.JSON(http.StatusOK, updated) } + + updates := map[string]any{"allowed_library_ids": rawJSON} + if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil || updated == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"}) + return + } + updated.PopulateComputedFields() + c.JSON(http.StatusOK, updated) } +} func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error { firstAdmin, err := svc.Repo.User.FirstAdmin(ctx) diff --git a/internal/handler/admin_test.go b/internal/handler/admin_test.go index 0690001..ecdeb4d 100644 --- a/internal/handler/admin_test.go +++ b/internal/handler/admin_test.go @@ -44,10 +44,10 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) { if w.Code != http.StatusConflict { t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) } - if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil { - t.Fatal("recent realtime user should not be deleted") - } + if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil { + t.Fatal("recent realtime user should not be deleted") } +} func TestUpdateUserLibraries(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/internal/handler/emby_mounted_resume_test.go b/internal/handler/emby_mounted_resume_test.go new file mode 100644 index 0000000..2194e51 --- /dev/null +++ b/internal/handler/emby_mounted_resume_test.go @@ -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 +} diff --git a/internal/handler/emby_mounts.go b/internal/handler/emby_mounts.go index 937c8cd..8b2f0f3 100644 --- a/internal/handler/emby_mounts.go +++ b/internal/handler/emby_mounts.go @@ -219,4 +219,4 @@ func reorderEmbyMountsHandler(svc *service.Container) gin.HandlerFunc { } c.JSON(http.StatusOK, gin.H{"ok": true}) } -} \ No newline at end of file +} diff --git a/internal/handler/emby_playstate_handlers.go b/internal/handler/emby_playstate_handlers.go index d8e528c..a0d283c 100644 --- a/internal/handler/emby_playstate_handlers.go +++ b/internal/handler/emby_playstate_handlers.go @@ -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 { diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go index f29590e..b6091c5 100644 --- a/internal/handler/emby_routes.go +++ b/internal/handler/emby_routes.go @@ -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)) diff --git a/internal/handler/emby_routes_lowercase.go b/internal/handler/emby_routes_lowercase.go index 9246b25..60beee8 100644 --- a/internal/handler/emby_routes_lowercase.go +++ b/internal/handler/emby_routes_lowercase.go @@ -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)) diff --git a/internal/handler/emby_sessions.go b/internal/handler/emby_sessions.go index d36ca8a..befa6fb 100644 --- a/internal/handler/emby_sessions.go +++ b/internal/handler/emby_sessions.go @@ -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) } diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index 235100b..cabe5d2 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -401,17 +401,17 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container, t.Fatal(err) } - router := gin.New() - api := router.Group("/api") - api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) - api.GET("/playback/:id/info", playbackInfoHandler(svc)) - api.GET("/playback/:id/external-url", externalURLHandler(svc)) - api.GET("/playback/:id/external-players", externalPlayersHandler(svc)) - api.GET("/stream/:id", streamHandler(svc)) - api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc)) - api.GET("/media/:id/subtitles", listSubtitlesHandler(svc)) - return router, svc, cfg.Secrets.JWTSecret - } + router := gin.New() + api := router.Group("/api") + api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) + api.GET("/playback/:id/info", playbackInfoHandler(svc)) + api.GET("/playback/:id/external-url", externalURLHandler(svc)) + api.GET("/playback/:id/external-players", externalPlayersHandler(svc)) + api.GET("/stream/:id", streamHandler(svc)) + api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc)) + api.GET("/media/:id/subtitles", listSubtitlesHandler(svc)) + return router, svc, cfg.Secrets.JWTSecret +} func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) { router, _, secret := newPlaybackScopeTestRouter(t) @@ -478,4 +478,3 @@ func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) { t.Fatalf("expected empty tracks array, got %v", payload.Tracks) } } - diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index c8df559..2f349a8 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -49,11 +49,11 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) { // Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts) admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc)) admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc)) - admin.GET("/emby/mounts", listEmbyMountsHandler(svc)) - admin.POST("/emby/mounts", createEmbyMountsHandler(svc)) - admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc)) - admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc)) - admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc)) + admin.GET("/emby/mounts", listEmbyMountsHandler(svc)) + admin.POST("/emby/mounts", createEmbyMountsHandler(svc)) + admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc)) + admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc)) + admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc)) admin.GET("/strm/accounts", listStrmAccountsHandler(svc)) admin.POST("/strm/accounts", createStrmAccountHandler(svc)) diff --git a/internal/handler/strm_routes_test.go b/internal/handler/strm_routes_test.go index 2775e85..5b4af18 100644 --- a/internal/handler/strm_routes_test.go +++ b/internal/handler/strm_routes_test.go @@ -50,19 +50,19 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) { "GET /api/admin/strm/downloads", "POST /api/admin/strm/downloads/:id/cancel", "POST /api/admin/strm/downloads/:id/retry", - "POST /api/admin/strm/downloads/clear-finished", - "POST /api/admin/strm/downloads/clear-canceled", - "POST /api/admin/strm/downloads/retry-failed", - "POST /api/admin/strm/downloads/cancel-pending", - "GET /api/admin/strm/uploads", - "POST /api/admin/strm/uploads/:id/cancel", - "POST /api/admin/strm/uploads/:id/retry", - "POST /api/admin/strm/uploads/clear-done", - "POST /api/admin/strm/uploads/clear-finished", - "POST /api/admin/strm/uploads/clear-canceled", - "POST /api/admin/strm/uploads/retry-failed", - "POST /api/admin/strm/uploads/cancel-pending", - "GET /api/strm/play/:provider/:file", + "POST /api/admin/strm/downloads/clear-finished", + "POST /api/admin/strm/downloads/clear-canceled", + "POST /api/admin/strm/downloads/retry-failed", + "POST /api/admin/strm/downloads/cancel-pending", + "GET /api/admin/strm/uploads", + "POST /api/admin/strm/uploads/:id/cancel", + "POST /api/admin/strm/uploads/:id/retry", + "POST /api/admin/strm/uploads/clear-done", + "POST /api/admin/strm/uploads/clear-finished", + "POST /api/admin/strm/uploads/clear-canceled", + "POST /api/admin/strm/uploads/retry-failed", + "POST /api/admin/strm/uploads/cancel-pending", + "GET /api/strm/play/:provider/:file", } { if !routes[want] { t.Fatalf("%s route is not registered", want) diff --git a/internal/handler/watch_history.go b/internal/handler/watch_history.go index a5e3e08..918df9a 100644 --- a/internal/handler/watch_history.go +++ b/internal/handler/watch_history.go @@ -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{ @@ -163,7 +175,7 @@ func historyDeleteHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or incomplete"}) return } - res := q.Unscoped().Delete(&model.PlaybackHistory{}) + res := q.Unscoped().Delete(&model.PlaybackHistory{}) if err := res.Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/model/emby_mount.go b/internal/model/emby_mount.go index 5fc6e1a..d957698 100644 --- a/internal/model/emby_mount.go +++ b/internal/model/emby_mount.go @@ -9,12 +9,12 @@ package model // EmbyMount 是远程 Emby 服务器上一个媒体库(View)的挂载配置。 type EmbyMount struct { Base - AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote) - RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id - RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余) - CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ... - Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」) - SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前 - ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理 - Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示 -} \ No newline at end of file + AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote) + RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id + RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余) + CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ... + Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」) + SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前 + ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理 + Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示 +} diff --git a/internal/model/library_media.go b/internal/model/library_media.go index 677325d..de46ec7 100644 --- a/internal/model/library_media.go +++ b/internal/model/library_media.go @@ -8,8 +8,8 @@ type Library struct { Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"` Enabled bool `gorm:"default:true" json:"enabled"` - SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前 - CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与) + SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前 + CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与) Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"` } diff --git a/internal/model/model.go b/internal/model/model.go index 36c02aa..4b3cf9f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -54,10 +54,10 @@ func AllModels() []interface{} { &StrmAccount{}, &StrmSyncPath{}, &StrmSyncRecord{}, - &StrmDownloadTask{}, - &StrmUploadTask{}, - &StrmDirCache{}, - &ScrapeTask{}, - &EmbyMount{}, - } + &StrmDownloadTask{}, + &StrmUploadTask{}, + &StrmDirCache{}, + &ScrapeTask{}, + &EmbyMount{}, + } } diff --git a/internal/model/scrape_task.go b/internal/model/scrape_task.go index 9df33e8..8c829e8 100644 --- a/internal/model/scrape_task.go +++ b/internal/model/scrape_task.go @@ -18,8 +18,8 @@ type ScrapeTask struct { LibraryName string `gorm:"size:128" json:"library_name"` MediaTitle string `gorm:"size:255;not null" json:"media_title"` MediaPath string `gorm:"size:1024;not null" json:"media_path"` - MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult - Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube + MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult + Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube MatchedTitle string `gorm:"size:255" json:"matched_title"` MatchedYear int `json:"matched_year"` PosterURL string `gorm:"size:1024" json:"poster_url"` diff --git a/internal/model/strm.go b/internal/model/strm.go index add4d67..67e90ad 100644 --- a/internal/model/strm.go +++ b/internal/model/strm.go @@ -38,17 +38,17 @@ type StrmSyncPath struct { RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录 LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录 // STRM 链接配置(空值继承全局 strm.* 设置) - StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url - VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext - MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext - ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过 - MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM - AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带 - DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕) - UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端 - DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录 - Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选) - EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步 + StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url + VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext + MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext + ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过 + MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM + AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带 + DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕) + UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端 + DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录 + Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选) + EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步 SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full Enabled bool `gorm:"default:true" json:"enabled"` LastSyncAt *time.Time `json:"last_sync_at"` @@ -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"` // 相对根目录的路径 } - diff --git a/internal/model/user.go b/internal/model/user.go index f5084f0..a24861c 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -23,8 +23,8 @@ type User struct { LastLoginAt *time.Time `json:"last_login_at,omitempty"` // AllowedLibraryIDs 存储管理员为该用户指定的受限可访问媒体库 ID 列表(JSON 字符串)。 // 为空时代表不限制(全库可访问)。 - AllowedLibraryIDs string `gorm:"type:text" json:"-"` - AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"` + AllowedLibraryIDs string `gorm:"type:text" json:"-"` + AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"` // ExpiredAt is the account expiry time. Nil means the account never // expires. When set and in the past, the account is treated as expired // (login blocked) until an admin or a redemption code renews it. diff --git a/internal/repository/emby_mount_repository.go b/internal/repository/emby_mount_repository.go index 0a1eac2..f80af31 100644 --- a/internal/repository/emby_mount_repository.go +++ b/internal/repository/emby_mount_repository.go @@ -124,4 +124,4 @@ func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID s return res.Error }) return count, err -} \ No newline at end of file +} diff --git a/internal/repository/favorite_repository.go b/internal/repository/favorite_repository.go index 40862ad..44ca84d 100644 --- a/internal/repository/favorite_repository.go +++ b/internal/repository/favorite_repository.go @@ -23,7 +23,7 @@ func (r *FavoriteRepository) Toggle(ctx context.Context, userID, mediaID string) if err != nil { return false, err } - return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error + return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error } // ListByUser returns all favourite media IDs for a user. diff --git a/internal/repository/repository.go b/internal/repository/repository.go index cc8d738..0cf8b79 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -30,11 +30,11 @@ type Container struct { StrmSyncPath *StrmSyncPathRepository StrmSyncRecord *StrmSyncRecordRepository StrmDownload *StrmDownloadTaskRepository - StrmUpload *StrmUploadTaskRepository - StrmDirCache *StrmDirCacheRepository - ScrapeTask *ScrapeTaskRepository + StrmUpload *StrmUploadTaskRepository + StrmDirCache *StrmDirCacheRepository + ScrapeTask *ScrapeTaskRepository EmbyMount *EmbyMountRepository - } +} // New 将每个 repository 连接到单个 *gorm.DB。 func New(db *gorm.DB) *Container { diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go index 29e5852..5b7f381 100644 --- a/internal/repository/strm_repository.go +++ b/internal/repository/strm_repository.go @@ -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 }) } - - diff --git a/internal/service/cache_cleanup.go b/internal/service/cache_cleanup.go index 8119177..bf83ec9 100644 --- a/internal/service/cache_cleanup.go +++ b/internal/service/cache_cleanup.go @@ -138,4 +138,3 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul return result, nil } - diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go index 9c4cdd1..8c30b5b 100644 --- a/internal/service/cloud/cloud.go +++ b/internal/service/cloud/cloud.go @@ -38,11 +38,11 @@ var ErrUnsupported = errors.New("unsupported cloud provider") // FileEntry is one item in a cloud directory listing. type FileEntry struct { - ID string `json:"id"` // provider-native file id - Name string `json:"name"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` - MTime int64 `json:"mtime,omitempty"` + ID string `json:"id"` // provider-native file id + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + MTime int64 `json:"mtime,omitempty"` // PickCode is 115-specific; other providers use ID directly. PickCode string `json:"pick_code,omitempty"` } diff --git a/internal/service/cloud/emby.go b/internal/service/cloud/emby.go index b2677ee..fb9cbc6 100644 --- a/internal/service/cloud/emby.go +++ b/internal/service/cloud/emby.go @@ -184,12 +184,12 @@ func (p *embyProvider) ensureToken(ctx context.Context) (string, error) { // embyItemSummary 目录浏览所需的最小 Emby 条目字段。 type embyItemSummary struct { - Id string `json:"Id"` - Name string `json:"Name"` - Type string `json:"Type"` - IsFolder bool `json:"IsFolder"` - ChildCount int `json:"ChildCount"` - RunTimeTicks int64 `json:"RunTimeTicks"` + Id string `json:"Id"` + Name string `json:"Name"` + Type string `json:"Type"` + IsFolder bool `json:"IsFolder"` + ChildCount int `json:"ChildCount"` + RunTimeTicks int64 `json:"RunTimeTicks"` } type embyItemListResponse struct { @@ -243,4 +243,4 @@ func (p *embyProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink u := p.apiBase() + "/Videos/" + url.PathEscape(fileRef) + "/stream" u += "?api_key=" + url.QueryEscape(token) + "&Static=true&MediaSourceId=" + url.QueryEscape(fileRef) return &DirectLink{URL: u, Headers: map[string]string{"X-Emby-Token": token}, Proxy: p.proxyPlay}, nil -} \ No newline at end of file +} diff --git a/internal/service/cloud/emby_test.go b/internal/service/cloud/emby_test.go index f17e693..3d15a4f 100644 --- a/internal/service/cloud/emby_test.go +++ b/internal/service/cloud/emby_test.go @@ -59,8 +59,8 @@ func TestEmbyProviderListViewsAndChildren(t *testing.T) { defer srv.Close() p, err := New(TypeEmbyRemote, map[string]any{ - "url": srv.URL, - "api_key": "fixed-token", + "url": srv.URL, + "api_key": "fixed-token", "remote_user_id": "user-9", }, srv.Client()) if err != nil { @@ -87,8 +87,8 @@ func TestEmbyProviderResolveDirectURLByDefault(t *testing.T) { defer srv.Close() p, err := New(TypeEmbyRemote, map[string]any{ - "url": srv.URL, - "api_key": "fixed-token", + "url": srv.URL, + "api_key": "fixed-token", "remote_user_id": "user-9", }, srv.Client()) if err != nil { @@ -115,10 +115,10 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) { defer srv.Close() p, err := New(TypeEmbyRemote, map[string]any{ - "url": srv.URL, - "api_key": "fixed-token", + "url": srv.URL, + "api_key": "fixed-token", "remote_user_id": "user-9", - "proxy_play": "true", + "proxy_play": "true", }, srv.Client()) if err != nil { t.Fatal(err) @@ -133,4 +133,4 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) { if link.URL == "" { t.Fatal("proxy link must still carry the remote URL") } -} \ No newline at end of file +} diff --git a/internal/service/danmaku_credentials.go b/internal/service/danmaku_credentials.go index 6b66984..f7adbe5 100644 --- a/internal/service/danmaku_credentials.go +++ b/internal/service/danmaku_credentials.go @@ -62,4 +62,4 @@ func xorDecode(hexStr string) string { func dandanplaySignature(appID, appSecret string, ts int64, path string) string { sum := sha256.Sum256([]byte(appID + strconv.FormatInt(ts, 10) + path + appSecret)) return base64.StdEncoding.EncodeToString(sum[:]) -} \ No newline at end of file +} diff --git a/internal/service/danmaku_credentials_test.go b/internal/service/danmaku_credentials_test.go index 05eca79..e5ed8e1 100644 --- a/internal/service/danmaku_credentials_test.go +++ b/internal/service/danmaku_credentials_test.go @@ -81,4 +81,4 @@ func TestDanmakuCredentialsSelection(t *testing.T) { require.False(t, ok) require.Empty(t, id) require.Empty(t, key) -} \ No newline at end of file +} diff --git a/internal/service/danmaku_match_test.go b/internal/service/danmaku_match_test.go index 7ca3448..4356da6 100644 --- a/internal/service/danmaku_match_test.go +++ b/internal/service/danmaku_match_test.go @@ -278,72 +278,72 @@ func TestDanmakuSameBase(t *testing.T) { require.False(t, sameDanmakuBase("", "https://api.dandanplay.net")) } - // fetchCommentWithFallback:配置源与官方同源时不重复请求; - // 全失败时带出最后一跳错误。 - func TestDanmakuFetchCommentWithFallback(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - t.Cleanup(srv.Close) +// fetchCommentWithFallback:配置源与官方同源时不重复请求; +// 全失败时带出最后一跳错误。 +func TestDanmakuFetchCommentWithFallback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) - svc := newDanmakuTestService(t) - ctx := context.Background() - raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484") - require.Error(t, err) - require.Empty(t, raw) - require.Equal(t, "auto", st) - } + svc := newDanmakuTestService(t) + ctx := context.Background() + raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484") + require.Error(t, err) + require.Empty(t, raw) + require.Equal(t, "auto", st) +} - // 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。 - func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) { - videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv") +// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。 +func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) { + videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv") - // 官方服务同时提供 match 和 search: - // match 会返回 episodeId=25484(动画A) - // search 会根据关键词返回 episodeId=99999(动画B) - mux := http.NewServeMux() - var matchCalled bool - mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) { - matchCalled = true - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`) - }) - mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`) - }) - mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/xml") - fmt.Fprint(w, `自动识别弹幕`) - }) - mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/xml") - fmt.Fprint(w, `手动搜索弹幕`) - }) - official := httptest.NewServer(mux) - t.Cleanup(official.Close) - overrideDanmakuOfficialBase(t, official.URL) + // 官方服务同时提供 match 和 search: + // match 会返回 episodeId=25484(动画A) + // search 会根据关键词返回 episodeId=99999(动画B) + mux := http.NewServeMux() + var matchCalled bool + mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) { + matchCalled = true + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`) + }) + mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`) + }) + mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `自动识别弹幕`) + }) + mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `手动搜索弹幕`) + }) + official := httptest.NewServer(mux) + t.Cleanup(official.Close) + overrideDanmakuOfficialBase(t, official.URL) - svc := newDanmakuTestService(t) - ctx := context.Background() - seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1) + svc := newDanmakuTestService(t) + ctx := context.Background() + seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1) - // 1) 默认自动识别:命中 Hash 识别 - resAuto, err := svc.Fetch(ctx, "mManual", "", "") - require.NoError(t, err) - require.True(t, matchCalled) - require.Equal(t, "hash", resAuto.MatchMode) - require.Equal(t, int64(25484), resAuto.EpisodeID) - require.Contains(t, resAuto.Raw, "自动识别弹幕") + // 1) 默认自动识别:命中 Hash 识别 + resAuto, err := svc.Fetch(ctx, "mManual", "", "") + require.NoError(t, err) + require.True(t, matchCalled) + require.Equal(t, "hash", resAuto.MatchMode) + require.Equal(t, int64(25484), resAuto.EpisodeID) + require.Contains(t, resAuto.Raw, "自动识别弹幕") - // 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B - resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "") - require.NoError(t, err) - require.Equal(t, "search", resManual.MatchMode) - require.Equal(t, int64(99999), resManual.EpisodeID) - require.Equal(t, "手动搜索动画B", resManual.AnimeTitle) - require.Contains(t, resManual.Raw, "手动搜索弹幕") - } + // 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B + resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "") + require.NoError(t, err) + require.Equal(t, "search", resManual.MatchMode) + require.Equal(t, int64(99999), resManual.EpisodeID) + require.Equal(t, "手动搜索动画B", resManual.AnimeTitle) + require.Contains(t, resManual.Raw, "手动搜索弹幕") +} // Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。 func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) { @@ -445,4 +445,4 @@ func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) { require.Equal(t, int64(7799), res.EpisodeID) require.Equal(t, "降级搜索番剧", res.AnimeTitle) require.Contains(t, res.Raw, "降级搜索弹幕") -} \ No newline at end of file +} diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index c87c125..790316c 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -206,94 +206,94 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID configured := strings.TrimRight(strings.TrimSpace(res.Source), "/") official := danmakuOfficialBase - // 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。 - if target := strings.TrimSpace(episodeID); target != "" { - raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target) - if err != nil { - s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err)) - return res, err - } - res.Raw, res.SourceType = raw, st - if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil { - res.EpisodeID = id - } - res.MatchMode = "manual" - return res, nil - } - - term, media, err := s.searchTerms(ctx, mediaID) + // 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。 + if target := strings.TrimSpace(episodeID); target != "" { + raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target) if err != nil { + s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err)) return res, err } - manualKeyword := strings.TrimSpace(keyword) != "" - if kw := strings.TrimSpace(keyword); kw != "" { - term.name = kw + res.Raw, res.SourceType = raw, st + if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil { + res.EpisodeID = id } - if strings.TrimSpace(term.name) == "" { + res.MatchMode = "manual" + return res, nil + } + + term, media, err := s.searchTerms(ctx, mediaID) + if err != nil { + return res, err + } + manualKeyword := strings.TrimSpace(keyword) != "" + if kw := strings.TrimSpace(keyword); kw != "" { + term.name = kw + } + if strings.TrimSpace(term.name) == "" { + return res, nil + } + + target := "" + + // 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。 + if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) { + if hash, ok := s.mediaHash(ctx, media); ok { + fileSize := media.SizeBytes + if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") { + fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小 + } + matchName := danmakuMatchFileName(media.Path) + if matchName == "" { + matchName = term.name + } + matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec) + if err != nil { + s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err)) + } else if len(matches) > 0 { + target = fmt.Sprintf("%d", matches[0].EpisodeID) + res.AnimeTitle = matches[0].AnimeTitle + res.EpisodeTitle = matches[0].EpisodeTitle + res.EpisodeID = matches[0].EpisodeID + res.MatchMode = "hash" + } + } + } + + // 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。 + if target == "" && !manualKeyword && media != nil && media.Path != "" { + if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name { + if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil && + len(candidates) == 1 && len(candidates[0].Episodes) > 0 { + target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID) + res.AnimeTitle = candidates[0].AnimeTitle + res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle + res.EpisodeID = candidates[0].Episodes[0].EpisodeID + res.MatchMode = "filename" + } + } + } + + // 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数, + // 多结果返回候选列表交给播放器(歧义处理)。 + if target == "" { + candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode) + if err != nil { + s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err)) + return res, err + } + if len(candidates) != 1 { + res.Candidates = candidates return res, nil } - - target := "" - - // 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。 - if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) { - if hash, ok := s.mediaHash(ctx, media); ok { - fileSize := media.SizeBytes - if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") { - fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小 - } - matchName := danmakuMatchFileName(media.Path) - if matchName == "" { - matchName = term.name - } - matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec) - if err != nil { - s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err)) - } else if len(matches) > 0 { - target = fmt.Sprintf("%d", matches[0].EpisodeID) - res.AnimeTitle = matches[0].AnimeTitle - res.EpisodeTitle = matches[0].EpisodeTitle - res.EpisodeID = matches[0].EpisodeID - res.MatchMode = "hash" - } - } - } - - // 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。 - if target == "" && !manualKeyword && media != nil && media.Path != "" { - if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name { - if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil && - len(candidates) == 1 && len(candidates[0].Episodes) > 0 { - target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID) - res.AnimeTitle = candidates[0].AnimeTitle - res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle - res.EpisodeID = candidates[0].Episodes[0].EpisodeID - res.MatchMode = "filename" - } - } - } - - // 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数, - // 多结果返回候选列表交给播放器(歧义处理)。 - if target == "" { - candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode) - if err != nil { - s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err)) - return res, err - } - if len(candidates) != 1 { - res.Candidates = candidates - return res, nil - } - if len(candidates[0].Episodes) == 0 { - return res, errors.New("no danmaku library found for this video") - } - target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID) - res.AnimeTitle = candidates[0].AnimeTitle - res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle - res.EpisodeID = candidates[0].Episodes[0].EpisodeID - res.MatchMode = "search" + if len(candidates[0].Episodes) == 0 { + return res, errors.New("no danmaku library found for this video") } + target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID) + res.AnimeTitle = candidates[0].AnimeTitle + res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle + res.EpisodeID = candidates[0].Episodes[0].EpisodeID + res.MatchMode = "search" + } raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target) if err != nil { @@ -787,7 +787,7 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode) } var out struct { - Success bool `json:"success"` + Success bool `json:"success"` Matches []danmakuMatch `json:"matches"` } if err := json.Unmarshal(raw, &out); err != nil { diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 6c12ac1..6e0b929 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -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 { diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index c78613f..f358baa 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -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 { diff --git a/internal/service/emby_remote_ids.go b/internal/service/emby_remote_ids.go index 342d39c..221f306 100644 --- a/internal/service/emby_remote_ids.go +++ b/internal/service/emby_remote_ids.go @@ -106,4 +106,4 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) { if items, ok := m["Items"]; ok { RewriteEmbyRemoteIDs(items, accountID) } -} \ No newline at end of file +} diff --git a/internal/service/emby_remote_ids_test.go b/internal/service/emby_remote_ids_test.go index 473f315..3dd256a 100644 --- a/internal/service/emby_remote_ids_test.go +++ b/internal/service/emby_remote_ids_test.go @@ -42,11 +42,11 @@ func TestDecodeEmbyRemoteIDRejectsLocalUUIDs(t *testing.T) { func TestRewriteEmbyRemoteIDs(t *testing.T) { payload := map[string]any{ - "Id": "item-1", - "ParentId": "folder-1", - "SeriesId": "series-1", - "SeasonId": "season-1", - "PrimaryImageItemId": "item-1", + "Id": "item-1", + "ParentId": "folder-1", + "SeriesId": "series-1", + "SeasonId": "season-1", + "PrimaryImageItemId": "item-1", "DisplayPreferencesId": "folder-1", "ImageTags": map[string]any{ "Primary": "item-1", @@ -58,8 +58,8 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) { // MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。 "MediaSources": []any{ map[string]any{ - "Id": "ms-9", - "DirectStreamUrl": "/Videos/item-1/stream", + "Id": "ms-9", + "DirectStreamUrl": "/Videos/item-1/stream", "MediaStreams": []any{ map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/Subtitles/2/Stream.srt"}, }, @@ -99,4 +99,4 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) { if ms["DirectStreamUrl"] != "/Videos/item-1/stream" { t.Fatalf("DirectStreamUrl must stay raw, got %v", ms["DirectStreamUrl"]) } -} \ No newline at end of file +} diff --git a/internal/service/emby_remote_playurl_test.go b/internal/service/emby_remote_playurl_test.go index 0c5e453..bded5a5 100644 --- a/internal/service/emby_remote_playurl_test.go +++ b/internal/service/emby_remote_playurl_test.go @@ -49,11 +49,11 @@ func TestRewriteSubtitleDeliveryURLsFallsBackIndexOne(t *testing.T) { } rewriteSubtitleDeliveryURLs(src, "/Videos/embyremote~acct-1~item-1", &EmbyRemoteConfig{}) streams := src["MediaStreams"].([]any) - want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream" - if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want { - t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want) - } + want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream" + if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want { + t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want) } +} func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) { r := &EmbyRemoteService{} @@ -96,4 +96,4 @@ func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) { if media.SizeBytes != 104857600 { t.Fatalf("size = %d, want 104857600", media.SizeBytes) } -} \ No newline at end of file +} diff --git a/internal/service/emby_user_data.go b/internal/service/emby_user_data.go index 835dbd7..c70af8c 100644 --- a/internal/service/emby_user_data.go +++ b/internal/service/emby_user_data.go @@ -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 diff --git a/internal/service/emby_user_data_test.go b/internal/service/emby_user_data_test.go index 43b6ab3..f97a217 100644 --- a/internal/service/emby_user_data_test.go +++ b/internal/service/emby_user_data_test.go @@ -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") + } +} diff --git a/internal/service/ffmpeg_install_archive.go b/internal/service/ffmpeg_install_archive.go index 1bf43fa..c87703b 100644 --- a/internal/service/ffmpeg_install_archive.go +++ b/internal/service/ffmpeg_install_archive.go @@ -111,4 +111,3 @@ func safeZipTarget(destRoot, name string) (string, error) { } return targetAbs, nil } - diff --git a/internal/service/ffmpeg_tools_test.go b/internal/service/ffmpeg_tools_test.go index 5e6488a..df56cc3 100644 --- a/internal/service/ffmpeg_tools_test.go +++ b/internal/service/ffmpeg_tools_test.go @@ -78,4 +78,3 @@ func TestStartInstallRejectsConcurrent(t *testing.T) { svc.running = false svc.mu.Unlock() } - diff --git a/internal/service/https_test.go b/internal/service/https_test.go index 6f443a3..4e56317 100644 --- a/internal/service/https_test.go +++ b/internal/service/https_test.go @@ -50,11 +50,11 @@ func TestResolveSSLMaterial(t *testing.T) { } cases := []struct { - name string + name string content string - path string - want string - err bool + path string + want string + err bool }{ {name: "content only", content: certPEM, want: certPEM}, {name: "path only", path: path, want: certPEM}, diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index 32f2581..8885c21 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -93,4 +93,3 @@ func (p *ImageProxy) Prune() (PruneImageCacheResult, error) { maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024 return PruneImageCache(p.cacheDir, maxBytes) } - diff --git a/internal/service/manual_scrape_test.go b/internal/service/manual_scrape_test.go index ca3326d..d5cb309 100644 --- a/internal/service/manual_scrape_test.go +++ b/internal/service/manual_scrape_test.go @@ -179,9 +179,9 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) { if len(results) != 1 || results[0].TMDbID != 27205 { t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries) } - if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" { - t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries) - } + if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" { + t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries) + } } func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) { diff --git a/internal/service/media_library.go b/internal/service/media_library.go index fd7a23e..39f9f56 100644 --- a/internal/service/media_library.go +++ b/internal/service/media_library.go @@ -117,10 +117,10 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error { if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil { return err } - if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil { - return err - } - return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error + if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil { + return err + } + return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error }) if err == nil { s.invalidateMediaCache(ctx) diff --git a/internal/service/organizer_directory_versions.go b/internal/service/organizer_directory_versions.go index bbc9926..a444ce5 100644 --- a/internal/service/organizer_directory_versions.go +++ b/internal/service/organizer_directory_versions.go @@ -229,9 +229,9 @@ func (o *OrganizerService) replaceVersions(ctx context.Context, src string, exis o.log.Warn("organize replace remove existing failed", zap.String("path", e), zap.Error(err)) } - if o.repo != nil && o.repo.DB != nil { - _ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error - } + if o.repo != nil && o.repo.DB != nil { + _ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error + } } // Move staged file + sidecars into the final path. if err := os.Rename(stage, dst); err != nil { diff --git a/internal/service/playback.go b/internal/service/playback.go index bf5762a..1ed4766 100644 --- a/internal/service/playback.go +++ b/internal/service/playback.go @@ -19,8 +19,9 @@ import ( // PlaybackService bundles history / favourite / playlist business logic. type PlaybackService struct { - log *zap.Logger - repo *repository.Container + 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 } diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go index 61035ab..b41220a 100644 --- a/internal/service/runtime_settings.go +++ b/internal/service/runtime_settings.go @@ -106,18 +106,18 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { cfg.App.SSLCert = value case "https.key": cfg.App.SSLKey = value - case "https.cert_path": - cfg.App.SSLCertPath = strings.TrimSpace(value) - case "https.key_path": - cfg.App.SSLKeyPath = strings.TrimSpace(value) - case "cache.images_max_size_mb": - if n, err := strconv.Atoi(value); err == nil { - if n < 0 { - n = 0 - } - cfg.Cache.ImagesMaxSizeMB = n + case "https.cert_path": + cfg.App.SSLCertPath = strings.TrimSpace(value) + case "https.key_path": + cfg.App.SSLKeyPath = strings.TrimSpace(value) + case "cache.images_max_size_mb": + if n, err := strconv.Atoi(value); err == nil { + if n < 0 { + n = 0 } + cfg.Cache.ImagesMaxSizeMB = n } + } } // ParseBoolSetting is the exported variant of parseBoolSetting for handlers diff --git a/internal/service/scheduler_local_jobs.go b/internal/service/scheduler_local_jobs.go index 32f9a24..d86288a 100644 --- a/internal/service/scheduler_local_jobs.go +++ b/internal/service/scheduler_local_jobs.go @@ -231,4 +231,3 @@ func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error { } return nil } - diff --git a/internal/service/service.go b/internal/service/service.go index 885d447..ae81a04 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -59,13 +59,13 @@ type Container struct { Device *DeviceService Cache *RuntimeCacheService Sessions *SessionTrackerService - RecognitionWords *RecognitionWordsService - Danmaku *DanmakuService - Strm *StrmService - Database *DatabaseAdminService - FFTools *FFmpegToolsService + RecognitionWords *RecognitionWordsService + Danmaku *DanmakuService + Strm *StrmService + Database *DatabaseAdminService + FFTools *FFmpegToolsService - stopCtx context.Context + stopCtx context.Context stopCancel context.CancelFunc // ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler @@ -110,12 +110,12 @@ func (c *Container) Boot() { c.Strm.Start(c.stopCtx) } - // 启动刮削队列后台消费者 - if c.Scraper != nil { - c.Scraper.Start(c.stopCtx) - } + // 启动刮削队列后台消费者 + if c.Scraper != nil { + c.Scraper.Start(c.stopCtx) + } - // Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。 + // Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。 // 每天触发一次评估;规则里的窗口可随机,不固定。 if c.Device != nil { go c.runInactivitySweeper(c.stopCtx) diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 1d6ba31..69411c6 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -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) diff --git a/internal/service/stream_normalize_test.go b/internal/service/stream_normalize_test.go index 775bb62..c0fe0a9 100644 --- a/internal/service/stream_normalize_test.go +++ b/internal/service/stream_normalize_test.go @@ -22,18 +22,18 @@ func TestNormalizeCloudPlayTarget(t *testing.T) { if parsed.IsAbs() || parsed.Host != "" { t.Fatalf("normalized target should be relative, got %q", got) } - if parsed.Query().Get("ref") != ref { - t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref")) - } + if parsed.Query().Get("ref") != ref { + t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref")) + } - strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123" - gotStrm := normalizeCloudPlayTarget(strmStale) - wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123" - if gotStrm != wantStrm { - t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm) - } + strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123" + gotStrm := normalizeCloudPlayTarget(strmStale) + wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123" + if gotStrm != wantStrm { + t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm) + } - // 非云盘播放 URL 保持原样(WebDAV/直链等)。 + // 非云盘播放 URL 保持原样(WebDAV/直链等)。 passthrough := "https://dav.example.com/media/file.mkv" if got := normalizeCloudPlayTarget(passthrough); got != passthrough { t.Fatalf("non-cloud target should pass through, got %q", got) diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go index ca46671..b4bc05a 100644 --- a/internal/service/strm_service.go +++ b/internal/service/strm_service.go @@ -91,7 +91,7 @@ type StrmService struct { oauthSessions map[string]*strm115AuthSession wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护) - downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数 + downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数 downloadSemOnce sync.Once } diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index fa006b3..2463a46 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -205,4 +205,4 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i defer f.Close() _, err = io.Copy(w, f) return err -} \ No newline at end of file +} diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx index c4aa643..b842000 100644 --- a/web/src/pages/HomePageSections.tsx +++ b/web/src/pages/HomePageSections.tsx @@ -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 (
diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 35253d9..9962a08 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -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]) diff --git a/web/src/pages/WatchHistoryPage.tsx b/web/src/pages/WatchHistoryPage.tsx index 4560ba0..4d8c3ef 100644 --- a/web/src/pages/WatchHistoryPage.tsx +++ b/web/src/pages/WatchHistoryPage.tsx @@ -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() {
{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 (