diff --git a/cmd/server/logging.go b/cmd/server/logging.go index 6d32fd3..f53ada8 100644 --- a/cmd/server/logging.go +++ b/cmd/server/logging.go @@ -98,3 +98,41 @@ func logFilePaths(cfg *config.Config) (string, string, string) { } return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log") } + +// newEmbyCompatLogger 构建只写入 Emby 兼容日志文件的独立 Zap 实例。 +// 它不参与 app.log 的日志级别过滤,始终记录 INFO 及以上,确保成功请求也能 +// 用于还原客户端的接口调用顺序;轮转参数沿用 logging 配置。 +func newEmbyCompatLogger(cfg *config.Config) (*zap.Logger, func(), error) { + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + var encoder zapcore.Encoder + if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") { + encoder = zapcore.NewConsoleEncoder(encoderCfg) + } else { + encoder = zapcore.NewJSONEncoder(encoderCfg) + } + + writer, err := newRotatingFileWriter(embyCompatLogPath(cfg), cfg.Logging) + if err != nil { + return nil, nil, err + } + log := zap.New( + zapcore.NewCore(encoder, writer, zap.InfoLevel), + zap.AddCaller(), + zap.AddStacktrace(zapcore.ErrorLevel), + zap.ErrorOutput(zapcore.Lock(os.Stderr)), + ) + return log, func() { _ = writer.Close() }, nil +} + +func embyCompatLogPath(cfg *config.Config) string { + out := strings.TrimSpace(cfg.Logging.OutputPath) + if out == "" || strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") { + return filepath.Join(cfg.App.DataDir, "logs", "emby-compat.log") + } + if ext := filepath.Ext(out); ext != "" { + base := strings.TrimSuffix(out, ext) + return base + ".emby-compat" + ext + } + return filepath.Join(out, "emby-compat.log") +} diff --git a/cmd/server/logging_test.go b/cmd/server/logging_test.go index 1929f4d..99260cf 100644 --- a/cmd/server/logging_test.go +++ b/cmd/server/logging_test.go @@ -116,3 +116,34 @@ func TestRotatingFileWriterCapsFileSize(t *testing.T) { } _ = writer.Sync() } + +func TestEmbyCompatLoggerWritesDedicatedFile(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{} + cfg.App.DataDir = dir + cfg.Logging.Format = "json" + cfg.Logging.OutputPath = filepath.Join(dir, "logs") + cfg.Logging.EnableRotation = true + cfg.Logging.MaxSizeMB = 1 + cfg.Logging.MaxBackups = 2 + + log, closeFn, err := newEmbyCompatLogger(cfg) + if err != nil { + t.Fatal(err) + } + log.Info("emby request", zap.String("client", "Infuse")) + _ = log.Sync() + closeFn() + + path := filepath.Join(dir, "logs", "emby-compat.log") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "emby request") || !strings.Contains(string(data), "Infuse") { + t.Fatalf("dedicated Emby log missing request data: %s", data) + } + if _, err := os.Stat(filepath.Join(dir, "logs", "app.log")); !os.IsNotExist(err) { + t.Fatalf("Emby logger must not write app.log, stat err=%v", err) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index b3bf2bf..d8161a6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -59,11 +59,21 @@ func main() { } defer func() { _ = logger.Sync() }() + embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(cfg) + if err != nil { + logger.Fatal("Emby compatibility logger init failed", zap.Error(err)) + } + defer func() { + _ = embyCompatLogger.Sync() + closeEmbyCompatLogger() + }() + appVersion := effectiveVersion(version) logger.Info("starting MeBox", zap.String("version", appVersion), zap.Int("port", cfg.App.Port), zap.String("data_dir", cfg.App.DataDir), + zap.String("emby_compat_log", embyCompatLogPath(cfg)), ) // Ensure data / cache / web dirs exist. @@ -104,7 +114,7 @@ func main() { logger.Warn("seed admin failed", zap.Error(err)) } - router := buildRouter(cfg, logger, services) + router := buildRouter(cfg, logger, embyCompatLogger, services) serverMgr := newServerManager(cfg, logger, router) services.ReloadHTTPServer = serverMgr.Reload diff --git a/cmd/server/router.go b/cmd/server/router.go index a3ceb7a..fea078d 100644 --- a/cmd/server/router.go +++ b/cmd/server/router.go @@ -19,13 +19,16 @@ import ( "github.com/truewhile/MeBox/web" ) -func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine { +func buildRouter(cfg *config.Config, logger *zap.Logger, embyCompatLogger *zap.Logger, svc *service.Container) *gin.Engine { if !cfg.App.Debug { gin.SetMode(gin.ReleaseMode) } r := gin.New() r.Use(gin.Recovery()) r.Use(middleware.RequestLogger(logger)) + r.Use(middleware.EmbyCompatLogger(embyCompatLogger, func(path string) bool { + return !isFrontendLibraryRoute(path) && handler.IsEmbyPath(path) + })) if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 { logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.") } diff --git a/internal/handler/emby_mounted_resume_test.go b/internal/handler/emby_mounted_resume_test.go index 2643c93..05caebc 100644 --- a/internal/handler/emby_mounted_resume_test.go +++ b/internal/handler/emby_mounted_resume_test.go @@ -68,7 +68,7 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) { // 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks progressReq := httptest.NewRequest( http.MethodPost, - "/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000", + "/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000&playSessionId=remote-mount-1-2000000000000", nil, ) wProgress := httptest.NewRecorder() @@ -85,6 +85,9 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) { if hist.PositionMs != 30000 { t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs) } + if hist.SessionID != "remote-mount-1-2000000000000" || hist.SessionStartedAtMs != 2000000000000 { + t.Fatalf("unexpected playback session metadata: %#v", hist) + } // 2. 测试 Filters=IsResumable 能够包含该远程条目 resumableReq := httptest.NewRequest( diff --git a/internal/handler/emby_path_compat_test.go b/internal/handler/emby_path_compat_test.go new file mode 100644 index 0000000..16dfca6 --- /dev/null +++ b/internal/handler/emby_path_compat_test.go @@ -0,0 +1,32 @@ +package handler + +import "testing" + +func TestIsEmbyPath(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {path: "/emby", want: true}, + {path: "/emby/System/Info/Public", want: true}, + {path: "//emby//Items//1", want: true}, + {path: "/System/Info/Public", want: true}, + {path: "/Search/Hints", want: true}, + {path: "/Playback/BitrateTest", want: true}, + {path: "/api/unknown", want: false}, + {path: "/library/123e4567-e89b-12d3-a456-426614174000", want: true}, + {path: "/random/path", want: false}, + } + for _, tt := range tests { + if got := IsEmbyPath(tt.path); got != tt.want { + t.Errorf("IsEmbyPath(%q) = %v, want %v", tt.path, got, tt.want) + } + } +} + +func TestNormalizeEmbyPrefixedInternalAPIPath(t *testing.T) { + got, changed := NormalizeEmbyPath("/emby/api/Stream/123") + if !changed || got != "/emby/api/stream/123" { + t.Fatalf("NormalizeEmbyPath() = (%q, %v), want (/emby/api/stream/123, true)", got, changed) + } +} diff --git a/internal/handler/emby_path_normalize.go b/internal/handler/emby_path_normalize.go index 74808f6..a177afa 100644 --- a/internal/handler/emby_path_normalize.go +++ b/internal/handler/emby_path_normalize.go @@ -88,6 +88,92 @@ var embyStaticSegments = map[string]struct{}{ "embywebsocket": {}, } +var embyRootSegments = map[string]struct{}{ + "albums": {}, + "artists": {}, + "audio": {}, + "audiocodecs": {}, + "auth": {}, + "branding": {}, + "channels": {}, + "collections": {}, + "connect": {}, + "containers": {}, + "devices": {}, + "displaypreferences": {}, + "dlna": {}, + "encoding": {}, + "environment": {}, + "gamegenres": {}, + "games": {}, + "genres": {}, + "images": {}, + "items": {}, + "libraries": {}, + "library": {}, + "livestreams": {}, + "livetv": {}, + "localization": {}, + "movies": {}, + "musicgenres": {}, + "news": {}, + "notification": {}, + "notifications": {}, + "officialratings": {}, + "packages": {}, + "persons": {}, + "playback": {}, + "playlists": {}, + "plugins": {}, + "providers": {}, + "reports": {}, + "scheduledtasks": {}, + "search": {}, + "sessions": {}, + "shows": {}, + "songs": {}, + "studios": {}, + "subtitlecodecs": {}, + "sync": {}, + "system": {}, + "tags": {}, + "trailers": {}, + "user_usage_stats": {}, + "users": {}, + "videocodecs": {}, + "videos": {}, + "years": {}, +} + +func isEmbyRootSegment(segment string) bool { + if _, ok := embyRootSegments[segment]; ok { + return true + } + _, ok := embyStaticSegments[segment] + return ok +} + +// IsEmbyPath 判断路径是否属于 Emby/Jellyfin 兼容面的命名空间。 +// 除显式 /emby 前缀外,Emby 客户端也会直接请求客户端协议的根路径, +// 例如 /System/Info/Public、/Users/Public、/Sessions。 +func IsEmbyPath(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + path = multipleSlashesRE.ReplaceAllString(path, "/") + lower := strings.ToLower(path) + if lower == "/emby" || strings.HasPrefix(lower, "/emby/") { + return true + } + + segments := strings.Split(strings.Trim(path, "/"), "/") + if len(segments) == 0 || segments[0] == "" { + return false + } + return isEmbyRootSegment(strings.ToLower(segments[0])) +} + // NormalizeEmbyPath 规范化 Emby 请求路径: // 1. 折叠重复斜杠(如 //emby/ -> /emby/); // 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info); @@ -132,7 +218,7 @@ func NormalizeEmbyPath(p string) (string, bool) { // 检查第一段是否为 Emby 根路由关键字 firstLower := strings.ToLower(segments[0]) - if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" { + if !isEmbyRootSegment(firstLower) && firstLower != "api" { // 不是 Emby 相关路径,保持原样 return original, false } diff --git a/internal/handler/emby_playstate_handlers.go b/internal/handler/emby_playstate_handlers.go index eeed839..f3bb1e7 100644 --- a/internal/handler/emby_playstate_handlers.go +++ b/internal/handler/emby_playstate_handlers.go @@ -19,6 +19,8 @@ type embyPlayingReq struct { PositionLower int64 `json:"positionTicks"` RunTimeTicks int64 `json:"RunTimeTicks"` RunTimeLower int64 `json:"runTimeTicks"` + PlaySessionID string `json:"PlaySessionId"` + PlaySession string `json:"playSessionId"` } func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc { @@ -48,6 +50,11 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc { if runTime == 0 { runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64) } + playSessionID := embyFirstNonEmptyString( + req.PlaySessionID, + req.PlaySession, + firstQueryValue(c, "PlaySessionId", "playSessionId"), + ) if itemID == "" { c.Status(http.StatusOK) return @@ -57,7 +64,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc { c.Status(http.StatusUnauthorized) return } - if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil { + if err := svc.Emby.RecordProgressWithSession(c.Request.Context(), uid, itemID, pos, runTime, playSessionID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/internal/handler/playback.go b/internal/handler/playback.go index c4cec5c..0c414f0 100644 --- a/internal/handler/playback.go +++ b/internal/handler/playback.go @@ -15,9 +15,12 @@ import ( // ─── History ──────────────────────────────────────────────────────────────── type progressReq struct { - MediaID string `json:"media_id" binding:"required"` - PositionMs int64 `json:"position_ms"` - DurationMs int64 `json:"duration_ms"` + MediaID string `json:"media_id" binding:"required"` + PositionMs int64 `json:"position_ms"` + DurationMs int64 `json:"duration_ms"` + SessionID string `json:"session_id"` + SessionStartedAtMs int64 `json:"session_started_at_ms"` + Sequence int64 `json:"sequence"` } func recordProgressHandler(svc *service.Container) gin.HandlerFunc { @@ -28,9 +31,15 @@ func recordProgressHandler(svc *service.Container) gin.HandlerFunc { return } uid, _ := c.Get(middleware.CtxUserID) - if err := svc.Playback.RecordProgress( - c.Request.Context(), uid.(string), req.MediaID, req.PositionMs, req.DurationMs, - ); err != nil { + if err := svc.Playback.RecordProgressUpdate(c.Request.Context(), service.ProgressUpdate{ + UserID: toString(uid), + MediaID: req.MediaID, + PositionMs: req.PositionMs, + DurationMs: req.DurationMs, + SessionID: req.SessionID, + SessionStartedAtMs: req.SessionStartedAtMs, + Sequence: req.Sequence, + }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/internal/handler/playback_history_test.go b/internal/handler/playback_history_test.go new file mode 100644 index 0000000..56cbfce --- /dev/null +++ b/internal/handler/playback_history_test.go @@ -0,0 +1,65 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +func TestRecordProgressHandlerIgnoresStaleSessionSequence(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.PlaybackHistory{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + svc := &service.Container{ + Repo: repos, + Playback: service.NewPlaybackService(zap.NewNop(), repos), + } + + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(middleware.CtxUserID, "user-1") + c.Next() + }) + router.POST("/history", recordProgressHandler(svc)) + + post := func(body string) int { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/history", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w.Code + } + + if code := post(`{"media_id":"m-1","position_ms":90000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":2}`); code != http.StatusNoContent { + t.Fatalf("newer progress status = %d", code) + } + if code := post(`{"media_id":"m-1","position_ms":10000,"duration_ms":120000,"session_id":"s-1","session_started_at_ms":1000,"sequence":1}`); code != http.StatusNoContent { + t.Fatalf("stale progress status = %d", code) + } + + var got model.PlaybackHistory + if err := db.Where("user_id = ? AND media_id = ?", "user-1", "m-1").First(&got).Error; err != nil { + t.Fatal(err) + } + if got.PositionMs != 90_000 || got.Sequence != 2 { + t.Fatalf("stale handler request overwrote newer state: %#v", got) + } +} diff --git a/internal/middleware/emby_compat_logger.go b/internal/middleware/emby_compat_logger.go new file mode 100644 index 0000000..05f9321 --- /dev/null +++ b/internal/middleware/emby_compat_logger.go @@ -0,0 +1,284 @@ +package middleware + +import ( + "net/http" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// EmbyCompatLogger 将 Emby/Jellyfin 兼容面的请求独立记录,便于针对具体 +// 客户端、设备和未实现接口排查兼容问题。 +// +// isEmbyPath 由 handler 层提供,用于识别 /emby/* 以及无前缀根路径形式的 +// Emby API。除路径特征外,带 X-Emby-* / X-MediaBrowser-* 等客户端凭据的 +// 请求也会被识别,因此尚未实现且没有已知路径前缀的新接口也能被记录。 +func EmbyCompatLogger(log *zap.Logger, isEmbyPath func(string) bool) gin.HandlerFunc { + if log == nil { + return func(c *gin.Context) { c.Next() } + } + return func(c *gin.Context) { + if c == nil || c.Request == nil { + return + } + + requestPath := c.Request.URL.Path + pathMatched := isEmbyPath != nil && isEmbyPath(requestPath) + hasSignature := hasEmbyClientSignature(c) + if !pathMatched && !hasSignature { + c.Next() + return + } + + start := time.Now() + c.Next() + + status := c.Writer.Status() + route := c.FullPath() + // 根路径 Emby 命名空间很宽,可能与 SPA 路由重名。没有客户端标识且 + // 最终由 SPA 返回 200 时不应污染兼容日志;服务端路由或 404 仍记录。 + if !hasSignature && !hasExplicitEmbyPrefix(requestPath) && strings.TrimSpace(route) == "" && status < http.StatusBadRequest { + return + } + unimplemented := isUnimplementedEmbyRequest(c, route, status, hasSignature) + fields := embyCompatLogFields(c, requestPath, status, route, time.Since(start), unimplemented) + + switch { + case unimplemented: + log.Warn("emby API not implemented", fields...) + case status >= http.StatusBadRequest: + log.Warn("emby request failed", fields...) + default: + log.Info("emby request", fields...) + } + } +} + +func isUnimplementedEmbyRequest(c *gin.Context, route string, status int, hasSignature bool) bool { + if strings.TrimSpace(route) != "" || c.Request.Method == http.MethodOptions { + return false + } + if status == http.StatusNotFound { + return true + } + // 无前端 API 前缀的未知 Emby 路径可能被 SPA 兜底为 200 HTML。 + // 已识别为 Emby 客户端的这类响应同样说明兼容接口尚未实现。 + return hasSignature && status >= http.StatusOK && status < http.StatusMultipleChoices && + strings.Contains(strings.ToLower(c.Writer.Header().Get("Content-Type")), "text/html") +} + +func embyCompatLogFields(c *gin.Context, requestPath string, status int, route string, duration time.Duration, unimplemented bool) []zap.Field { + client := embyCompatClientInfo(c) + fields := []zap.Field{ + zap.String("method", c.Request.Method), + zap.String("path", requestPath), + zap.String("normalized_path", c.Request.URL.Path), + zap.String("route", route), + zap.Int("status", status), + zap.Duration("duration", duration), + zap.String("ip", c.ClientIP()), + zap.Bool("unimplemented", unimplemented), + } + if queryKeys := sortedQueryKeys(c); len(queryKeys) > 0 { + fields = append(fields, zap.Strings("query_keys", queryKeys)) + } + if client.Client != "" { + fields = append(fields, zap.String("client", client.Client)) + } + if client.Device != "" { + fields = append(fields, zap.String("device", client.Device)) + } + if client.DeviceID != "" { + fields = append(fields, zap.String("device_id", client.DeviceID)) + } + if client.Version != "" { + fields = append(fields, zap.String("client_version", client.Version)) + } + if client.UserID != "" { + fields = append(fields, zap.String("user_id", client.UserID)) + } + if userAgent := strings.TrimSpace(c.GetHeader("User-Agent")); userAgent != "" { + fields = append(fields, zap.String("user_agent", userAgent)) + } + if contentType := strings.TrimSpace(c.GetHeader("Content-Type")); contentType != "" { + fields = append(fields, zap.String("content_type", contentType)) + } + if errs := c.Errors.Errors(); len(errs) > 0 { + fields = append(fields, zap.Strings("errors", truncateStrings(errs, 8, 512))) + } + return fields +} + +func hasEmbyClientSignature(c *gin.Context) bool { + for _, name := range []string{ + "X-Emby-Token", + "X-MediaBrowser-Token", + "X-Emby-Authorization", + "X-MediaBrowser-Authorization", + "X-Emby-Client", + "X-MediaBrowser-Client", + "X-Emby-Device-Id", + "X-Emby-DeviceId", + "X-Emby-Device-Name", + "X-Emby-Version", + "X-Emby-UserId", + "X-MediaBrowser-Device-Id", + "X-MediaBrowser-DeviceId", + "X-MediaBrowser-Device-Name", + "X-MediaBrowser-Version", + "X-MediaBrowser-UserId", + } { + if strings.TrimSpace(c.GetHeader(name)) != "" { + return true + } + } + + auth := strings.ToLower(strings.TrimSpace(c.GetHeader("Authorization"))) + if strings.HasPrefix(auth, "emby ") || strings.HasPrefix(auth, "mediabrowser ") { + return true + } + + for _, key := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} { + if strings.TrimSpace(c.Query(key)) != "" { + return true + } + } + + ua := strings.ToLower(strings.TrimSpace(c.GetHeader("User-Agent"))) + for _, marker := range []string{ + "emby", "jellyfin", "infuse", "senplayer", "fileball", "vidhub", "hills", "rodelplayer", + } { + if strings.Contains(ua, marker) { + return true + } + } + return false +} + +type embyCompatClient struct { + Client string + Device string + DeviceID string + Version string + UserID string +} + +func embyCompatClientInfo(c *gin.Context) embyCompatClient { + auth := parseEmbyCompatAuthorization(firstNonEmptyString( + c.GetHeader("X-Emby-Authorization"), + c.GetHeader("X-MediaBrowser-Authorization"), + c.GetHeader("Authorization"), + )) + return embyCompatClient{ + Client: firstNonEmptyString( + c.GetHeader("X-Emby-Client"), + c.GetHeader("X-MediaBrowser-Client"), + c.Query("Client"), + c.Query("client"), + c.Query("X-Emby-Client"), + auth["client"], + ), + Device: firstNonEmptyString( + c.GetHeader("X-Emby-Device-Name"), + c.GetHeader("X-MediaBrowser-Device-Name"), + c.Query("Device"), + c.Query("DeviceName"), + c.Query("device"), + c.Query("deviceName"), + auth["device"], + ), + DeviceID: firstNonEmptyString( + c.GetHeader("X-Emby-Device-Id"), + c.GetHeader("X-Emby-DeviceId"), + c.GetHeader("X-MediaBrowser-Device-Id"), + c.GetHeader("X-MediaBrowser-DeviceId"), + c.Query("DeviceId"), + c.Query("DeviceID"), + c.Query("deviceId"), + c.Query("deviceID"), + auth["deviceid"], + ), + Version: firstNonEmptyString( + c.GetHeader("X-Emby-Version"), + c.GetHeader("X-MediaBrowser-Version"), + c.Query("Version"), + c.Query("version"), + auth["version"], + ), + UserID: firstNonEmptyString( + c.Query("UserId"), + c.Query("userId"), + auth["userid"], + ), + } +} + +func parseEmbyCompatAuthorization(raw string) map[string]string { + out := map[string]string{} + raw = strings.TrimSpace(raw) + if raw == "" { + return out + } + if len(raw) >= len("MediaBrowser ") && strings.EqualFold(raw[:len("MediaBrowser ")], "MediaBrowser ") { + raw = raw[len("MediaBrowser "):] + } else if len(raw) >= len("Emby ") && strings.EqualFold(raw[:len("Emby ")], "Emby ") { + raw = raw[len("Emby "):] + } + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + key, value, ok := strings.Cut(part, "=") + if !ok { + continue + } + key = strings.ToLower(strings.TrimSpace(key)) + value = strings.Trim(strings.TrimSpace(value), `"`) + if key != "" && value != "" { + out[key] = value + } + } + return out +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func sortedQueryKeys(c *gin.Context) []string { + query := c.Request.URL.Query() + keys := make([]string, 0, len(query)) + for key := range query { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func truncateStrings(values []string, maxItems, maxLen int) []string { + if len(values) > maxItems { + values = values[:maxItems] + } + out := make([]string, 0, len(values)) + for _, value := range values { + if len(value) > maxLen { + value = value[:maxLen] + "..." + } + out = append(out, value) + } + return out +} + +func hasExplicitEmbyPrefix(path string) bool { + lower := strings.ToLower(strings.TrimSpace(path)) + return lower == "/emby" || strings.HasPrefix(lower, "/emby/") +} diff --git a/internal/middleware/emby_compat_logger_test.go b/internal/middleware/emby_compat_logger_test.go new file mode 100644 index 0000000..b808bf6 --- /dev/null +++ b/internal/middleware/emby_compat_logger_test.go @@ -0,0 +1,124 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestEmbyCompatLoggerRecordsClientAndUnimplementedRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + core, logs := observer.New(zap.InfoLevel) + router := gin.New() + router.Use(EmbyCompatLogger(zap.New(core), func(path string) bool { + return strings.HasPrefix(path, "/emby/") || strings.HasPrefix(path, "/System/") + })) + router.GET("/emby/System/Info/Public", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ServerName": "MeBox"}) + }) + router.NoRoute(func(c *gin.Context) { + c.Status(http.StatusNotFound) + }) + + successReq := httptest.NewRequest(http.MethodGet, "/emby/System/Info/Public?api_key=do-not-log", nil) + successReq.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Infuse", Device="Apple TV", DeviceId="device-1", Version="8.2", UserId="user-1", Token="secret-token"`) + successResp := httptest.NewRecorder() + router.ServeHTTP(successResp, successReq) + if successResp.Code != http.StatusOK { + t.Fatalf("success status = %d, want 200", successResp.Code) + } + + missingReq := httptest.NewRequest(http.MethodPost, "/Search/Hints", nil) + missingReq.Header.Set("X-Emby-Token", "secret-token") + missingResp := httptest.NewRecorder() + router.ServeHTTP(missingResp, missingReq) + if missingResp.Code != http.StatusNotFound { + t.Fatalf("missing status = %d, want 404", missingResp.Code) + } + + entries := logs.All() + if len(entries) != 2 { + t.Fatalf("logged entries = %d, want 2: %#v", len(entries), entries) + } + + success := entries[0].ContextMap() + if success["path"] != "/emby/System/Info/Public" { + t.Fatalf("success path = %#v", success["path"]) + } + if success["client"] != "Infuse" || success["device"] != "Apple TV" || success["device_id"] != "device-1" { + t.Fatalf("client fields not parsed: %#v", success) + } + if success["unimplemented"] != false { + t.Fatalf("success unimplemented = %#v, want false", success["unimplemented"]) + } + if _, ok := success["token"]; ok { + t.Fatalf("token must not be logged: %#v", success) + } + if success["query_keys"] == nil { + t.Fatalf("query_keys missing: %#v", success) + } + if strings.Contains(entries[0].Message, "secret-token") { + t.Fatalf("token leaked in message: %q", entries[0].Message) + } + + missing := entries[1].ContextMap() + if entries[1].Message != "emby API not implemented" { + t.Fatalf("missing message = %q", entries[1].Message) + } + if missing["path"] != "/Search/Hints" || missing["unimplemented"] != true { + t.Fatalf("missing fields = %#v", missing) + } + if missing["method"] != http.MethodPost { + t.Fatalf("missing method = %#v", missing["method"]) + } +} + +func TestEmbyCompatLoggerSkipsUnrelatedRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + core, logs := observer.New(zap.InfoLevel) + router := gin.New() + router.Use(EmbyCompatLogger(zap.New(core), func(string) bool { return false })) + router.GET("/api/health", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + req.Header.Set("Authorization", "Bearer regular-token") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if logs.Len() != 0 { + t.Fatalf("unrelated request should not be logged: %#v", logs.All()) + } +} + +func TestEmbyCompatLoggerMarksSPAFallbackAsUnimplemented(t *testing.T) { + gin.SetMode(gin.TestMode) + core, logs := observer.New(zap.InfoLevel) + router := gin.New() + router.Use(EmbyCompatLogger(zap.New(core), func(string) bool { return false })) + router.NoRoute(func(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte("")) + }) + + req := httptest.NewRequest(http.MethodGet, "/NewEmbyFeature/Test", nil) + req.Header.Set("X-Emby-Token", "secret-token") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("logged entries = %d, want 1", len(entries)) + } + if entries[0].Message != "emby API not implemented" { + t.Fatalf("message = %q, want unimplemented warning", entries[0].Message) + } + if entries[0].ContextMap()["unimplemented"] != true { + t.Fatalf("unimplemented field missing: %#v", entries[0].ContextMap()) + } +} diff --git a/internal/model/playback_collection.go b/internal/model/playback_collection.go index 68ef3b2..5e27722 100644 --- a/internal/model/playback_collection.go +++ b/internal/model/playback_collection.go @@ -13,6 +13,12 @@ type PlaybackHistory struct { DurationMs int64 `json:"duration_ms"` WatchedAt time.Time `json:"watched_at"` Completed bool `json:"completed"` + + // 播放会话信息用于丢弃乱序到达的旧进度,避免旧请求把新的续播状态覆盖。 + // 旧客户端不提供这些字段时保持 0,继续沿用无条件 upsert 语义。 + SessionID string `gorm:"size:128;index" json:"-"` + SessionStartedAtMs int64 `gorm:"not null;default:0" json:"-"` + Sequence int64 `gorm:"not null;default:0" json:"-"` } // Favorite 将媒体项标记为给定用户的收藏。 diff --git a/internal/repository/history_repository.go b/internal/repository/history_repository.go index 5468ca7..6d3902f 100644 --- a/internal/repository/history_repository.go +++ b/internal/repository/history_repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "strings" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -13,24 +14,72 @@ import ( // upserts on (UserID, MediaID) so resume always reads the latest position. type HistoryRepository struct{ db *gorm.DB } -// Upsert atomically inserts/updates the resume position in a single statement, -// relying on the uniq_user_history composite unique index. Concurrent progress -// reports for the same (user, media) can no longer double-insert. +// Upsert atomically inserts/updates the resume position in a single statement. +// Callers without playback-session metadata keep the legacy last-write-wins +// semantics (for example an explicit "mark played" action). func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) error { - return r.db.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}}, - DoUpdates: clause.Assignments(map[string]any{ - "position_ms": h.PositionMs, - // 沿用旧语义:未知时长(0)不覆盖已记录的时长。 - "duration_ms": gorm.Expr( - "CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END", - h.DurationMs, h.DurationMs, + return r.upsert(ctx, h, false) +} + +// UpsertProgress writes a progress report only when it is newer than the row +// currently stored. Reports from an older playback session, or an older +// sequence in the same session, are ignored. Reports without session metadata +// fall back to Upsert for compatibility with older clients. +func (r *HistoryRepository) UpsertProgress(ctx context.Context, h *model.PlaybackHistory) error { + h.SessionID = strings.TrimSpace(h.SessionID) + if h.SessionID == "" || h.SessionStartedAtMs <= 0 { + return r.Upsert(ctx, h) + } + return r.upsert(ctx, h, true) +} + +func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error { + updates := playbackHistoryAssignments(h) + if versioned { + updates["session_id"] = h.SessionID + updates["session_started_at_ms"] = h.SessionStartedAtMs + updates["sequence"] = h.Sequence + } + + onConflict := clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}}, + DoUpdates: clause.Assignments(updates), + } + if versioned { + onConflict.Where = clause.Where{Exprs: []clause.Expression{ + clause.Or( + clause.Lt{ + Column: clause.Column{Name: "session_started_at_ms"}, + Value: h.SessionStartedAtMs, + }, + clause.And( + clause.Eq{ + Column: clause.Column{Name: "session_started_at_ms"}, + Value: h.SessionStartedAtMs, + }, + clause.Lte{ + Column: clause.Column{Name: "sequence"}, + Value: h.Sequence, + }, + ), ), - "watched_at": h.WatchedAt, - "completed": h.Completed, - "deleted_at": nil, - }), - }).Create(h).Error + }} + } + return r.db.WithContext(ctx).Clauses(onConflict).Create(h).Error +} + +func playbackHistoryAssignments(h *model.PlaybackHistory) map[string]any { + return map[string]any{ + "position_ms": h.PositionMs, + // 沿用旧语义:未知时长(0)不覆盖已记录的时长。 + "duration_ms": gorm.Expr( + "CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END", + h.DurationMs, h.DurationMs, + ), + "watched_at": h.WatchedAt, + "completed": h.Completed, + "deleted_at": nil, + } } // ListByUser returns the most recent history rows for the user. diff --git a/internal/repository/history_repository_test.go b/internal/repository/history_repository_test.go index 7a09c34..97b32e6 100644 --- a/internal/repository/history_repository_test.go +++ b/internal/repository/history_repository_test.go @@ -77,3 +77,105 @@ func TestHistoryUpsertKeepsDurationWhenUnknown(t *testing.T) { t.Fatalf("duration_ms=0 upsert must not clear stored duration, got %d", got.DurationMs) } } + +func TestHistoryUpsertProgressRejectsStaleReports(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := database.AutoMigrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := New(db) + ctx := t.Context() + watched := time.Now() + + first := &model.PlaybackHistory{ + UserID: "u-1", + MediaID: "m-1", + PositionMs: 30_000, + DurationMs: 120_000, + WatchedAt: watched, + SessionID: "session-1", + SessionStartedAtMs: 1_000, + Sequence: 1, + } + if err := repos.History.UpsertProgress(ctx, first); err != nil { + t.Fatalf("first progress: %v", err) + } + newer := &model.PlaybackHistory{ + UserID: "u-1", + MediaID: "m-1", + PositionMs: 90_000, + DurationMs: 120_000, + WatchedAt: watched.Add(time.Minute), + Completed: true, + SessionID: "session-1", + SessionStartedAtMs: 1_000, + Sequence: 2, + } + if err := repos.History.UpsertProgress(ctx, newer); err != nil { + t.Fatalf("newer progress: %v", err) + } + + stale := &model.PlaybackHistory{ + UserID: "u-1", + MediaID: "m-1", + PositionMs: 10_000, + DurationMs: 120_000, + WatchedAt: watched.Add(2 * time.Minute), + SessionID: "session-1", + SessionStartedAtMs: 1_000, + Sequence: 1, + } + if err := repos.History.UpsertProgress(ctx, stale); err != nil { + t.Fatalf("stale progress: %v", err) + } + var got model.PlaybackHistory + if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil { + t.Fatal(err) + } + if got.PositionMs != 90_000 || !got.Completed || got.Sequence != 2 { + t.Fatalf("stale report overwrote newer state: %#v", got) + } + + oldSession := &model.PlaybackHistory{ + UserID: "u-1", + MediaID: "m-1", + PositionMs: 5_000, + DurationMs: 120_000, + WatchedAt: watched.Add(3 * time.Minute), + SessionID: "session-0", + SessionStartedAtMs: 500, + Sequence: 99, + } + if err := repos.History.UpsertProgress(ctx, oldSession); err != nil { + t.Fatalf("old session progress: %v", err) + } + if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil { + t.Fatal(err) + } + if got.PositionMs != 90_000 || !got.Completed { + t.Fatalf("old session overwrote newer state: %#v", got) + } + + restart := &model.PlaybackHistory{ + UserID: "u-1", + MediaID: "m-1", + PositionMs: 1_000, + DurationMs: 120_000, + WatchedAt: watched.Add(4 * time.Minute), + SessionID: "session-2", + SessionStartedAtMs: 2_000, + Sequence: 1, + } + if err := repos.History.UpsertProgress(ctx, restart); err != nil { + t.Fatalf("restart progress: %v", err) + } + if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil { + t.Fatal(err) + } + if got.PositionMs != 1_000 || got.Completed || got.SessionID != "session-2" { + t.Fatalf("new playback session did not reset state: %#v", got) + } +} diff --git a/internal/service/emby_playback.go b/internal/service/emby_playback.go index 599ba7f..f067123 100644 --- a/internal/service/emby_playback.go +++ b/internal/service/emby_playback.go @@ -37,7 +37,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) if err := e.mergeRemoteUserData(ctx, userID, out); err != nil { return nil, err } - out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix()) + out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().UnixMilli()) return out, nil } m, err := e.playableMedia(ctx, mediaID, userID) @@ -46,7 +46,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) } return map[string]any{ "MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)), - "PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()), + "PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().UnixMilli()), }, nil } diff --git a/internal/service/emby_user_data.go b/internal/service/emby_user_data.go index 8a3a2fb..1851945 100644 --- a/internal/service/emby_user_data.go +++ b/internal/service/emby_user_data.go @@ -63,7 +63,15 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl } // RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。 +// 不携带 PlaySessionId 的旧调用仍保持兼容。 func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error { + return e.RecordProgressWithSession(ctx, userID, mediaID, positionTicks, runtimeTicks, "") +} + +// RecordProgressWithSession records an Emby progress update together with its +// PlaySessionId. The server-issued ID contains a millisecond timestamp, which +// lets the repository reject reports from an older playback session. +func (e *EmbyService) RecordProgressWithSession(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64, playSessionID string) error { pos := positionTicks / 10_000 dur := runtimeTicks / 10_000 if dur <= 0 { @@ -89,14 +97,16 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string } } } - completed := dur > 0 && pos >= dur*9/10 - err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{ - UserID: userID, - MediaID: mediaID, - PositionMs: pos, - DurationMs: dur, - WatchedAt: time.Now(), - Completed: completed, + playSessionID = strings.TrimSpace(playSessionID) + err := e.repo.History.UpsertProgress(ctx, &model.PlaybackHistory{ + UserID: userID, + MediaID: mediaID, + PositionMs: pos, + DurationMs: dur, + WatchedAt: time.Now(), + Completed: playbackProgressCompleted(pos, dur), + SessionID: playSessionID, + SessionStartedAtMs: embyPlaySessionStartedAtMs(playSessionID), }) if err == nil { e.invalidateEmbyItemsCache(ctx) @@ -104,6 +114,30 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string return err } +// embyPlaySessionStartedAtMs extracts the millisecond timestamp embedded in a +// MeBox-issued PlaySessionId. Older IDs used seconds, so normalize those too. +func embyPlaySessionStartedAtMs(playSessionID string) int64 { + playSessionID = strings.TrimSpace(playSessionID) + if playSessionID == "" { + return 0 + } + idx := strings.LastIndex(playSessionID, "-") + if idx < 0 || idx == len(playSessionID)-1 { + return 0 + } + value, err := strconv.ParseInt(playSessionID[idx+1:], 10, 64) + if err != nil || value <= 0 { + return 0 + } + if value >= 1_000_000_000 && value < 1_000_000_000_000 { + value *= 1000 + } + if value < 1_000_000_000_000 { + return 0 + } + return value +} + // mergeRemoteUserData applies the current MeBox user's locally recorded playback // and favourite state to remote Emby payloads. func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error { diff --git a/internal/service/playback.go b/internal/service/playback.go index 270ed34..c0f9ec3 100644 --- a/internal/service/playback.go +++ b/internal/service/playback.go @@ -41,24 +41,62 @@ func (p *PlaybackService) SetEmbyRemote(remote *EmbyRemoteService) *PlaybackServ // ─── History ──────────────────────────────────────────────────────────────── -// RecordProgress upserts the resume position for a (user, media) pair. A -// position within 30 seconds of the duration auto-flags the item as -// completed so the home page can hide it from "Continue Watching". +// ProgressUpdate is a playback-progress report. SessionID, SessionStartedAtMs +// and Sequence are optional for compatibility with legacy callers. +type ProgressUpdate struct { + UserID string + MediaID string + PositionMs int64 + DurationMs int64 + SessionID string + SessionStartedAtMs int64 + Sequence int64 +} + +// RecordProgress records an unversioned progress report. It is retained for +// older callers that do not provide playback-session metadata. func (p *PlaybackService) RecordProgress(ctx context.Context, userID, mediaID string, position, duration int64) error { - if userID == "" || mediaID == "" { - return errors.New("missing user or media") - } - dur := p.resolvePlaybackDuration(ctx, userID, mediaID, duration) - completed := dur > 0 && position >= dur-30_000 - h := &model.PlaybackHistory{ + return p.RecordProgressUpdate(ctx, ProgressUpdate{ UserID: userID, MediaID: mediaID, PositionMs: position, - DurationMs: dur, - WatchedAt: time.Now(), - Completed: completed, + DurationMs: duration, + }) +} + +// RecordProgressUpdate upserts the resume position for a (user, media) pair. +// When session metadata is present, stale reports are ignored so a delayed +// request cannot overwrite a newer position or completion state. +func (p *PlaybackService) RecordProgressUpdate(ctx context.Context, update ProgressUpdate) error { + if update.UserID == "" || update.MediaID == "" { + return errors.New("missing user or media") } - return p.repo.History.Upsert(ctx, h) + dur := p.resolvePlaybackDuration(ctx, update.UserID, update.MediaID, update.DurationMs) + h := &model.PlaybackHistory{ + UserID: update.UserID, + MediaID: update.MediaID, + PositionMs: update.PositionMs, + DurationMs: dur, + WatchedAt: time.Now(), + Completed: playbackProgressCompleted(update.PositionMs, dur), + SessionID: update.SessionID, + SessionStartedAtMs: update.SessionStartedAtMs, + Sequence: update.Sequence, + } + return p.repo.History.UpsertProgress(ctx, h) +} + +// playbackProgressCompleted deliberately requires a positive position. For +// very short clips the 90% threshold avoids marking a zero-second sample as +// finished; otherwise the last 30 seconds are treated as completed. +func playbackProgressCompleted(position, duration int64) bool { + if position <= 0 || duration <= 0 { + return false + } + if duration <= 30_000 { + return position*10 >= duration*9 + } + return position >= duration-30_000 } // GetProgress returns the saved resume row for one media item, or nil when absent. diff --git a/internal/service/playback_test.go b/internal/service/playback_test.go index 925c56c..0fb8f8e 100644 --- a/internal/service/playback_test.go +++ b/internal/service/playback_test.go @@ -91,3 +91,25 @@ func TestListFavouritesIncludesLocalAndRemoteIDs(t *testing.T) { t.Fatalf("expected local favourite first by created_at desc, got %#v", items[0]) } } + +func TestPlaybackProgressCompletedThreshold(t *testing.T) { + tests := []struct { + name string + position int64 + duration int64 + want bool + }{ + {name: "zero duration", position: 10_000, duration: 0, want: false}, + {name: "short clip early", position: 10_000, duration: 20_000, want: false}, + {name: "short clip near end", position: 18_000, duration: 20_000, want: true}, + {name: "movie before final window", position: 80_000, duration: 120_000, want: false}, + {name: "movie in final window", position: 95_000, duration: 120_000, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := playbackProgressCompleted(tt.position, tt.duration); got != tt.want { + t.Fatalf("playbackProgressCompleted(%d, %d) = %t, want %t", tt.position, tt.duration, got, tt.want) + } + }) + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 0ac2e04..203c60c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -174,6 +174,35 @@ export function stopHLSJob(mediaId: string): void { }).catch(() => undefined) } +// postPlaybackProgressKeepalive sends the final playback position without +// relying on an axios request surviving page navigation or tab close. +export function postPlaybackProgressKeepalive(payload: { + media_id: string + position_ms: number + duration_ms: number + session_id?: string + session_started_at_ms?: number + sequence?: number +}): void { + const headers: Record = { 'Content-Type': 'application/json' } + const token = useAuthStore.getState().token + if (token) headers.Authorization = `Bearer ${token}` + const activeProfileId = getActivePlayProfileId() + if (activeProfileId) { + headers['X-Play-Profile-ID'] = activeProfileId + const pinToken = getActivePlayProfilePinToken() + if (pinToken) headers['X-Play-Profile-PIN-Token'] = pinToken + } + void fetch('/api/history', { + method: 'POST', + credentials: 'same-origin', + keepalive: true, + cache: 'no-store', + headers, + body: JSON.stringify(payload), + }).catch(() => undefined) +} + // imageURL converts a remote poster URL into a same-origin proxy URL so it // can never be blocked by CORS / GFW. Empty strings pass through unchanged. export type ImageURLOptions = diff --git a/web/src/api/playback.ts b/web/src/api/playback.ts index 09007c2..f3abbc6 100644 --- a/web/src/api/playback.ts +++ b/web/src/api/playback.ts @@ -27,6 +27,15 @@ export interface ExternalPlayer { url: string } +export interface PlaybackProgressRequest { + media_id: string + position_ms: number + duration_ms: number + session_id?: string + session_started_at_ms?: number + sequence?: number +} + function publicOriginHeader() { if (typeof window === 'undefined' || !window.location?.origin) return undefined return { 'X-MeBox-Public-Origin': window.location.origin } @@ -38,14 +47,8 @@ export const playbackAPI = { .get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`) .then((r) => r.data), - recordProgress: (mediaId: string, positionMs: number, durationMs: number) => - api - .post('/history', { - media_id: mediaId, - position_ms: positionMs, - duration_ms: durationMs, - }) - .then((r) => r.data), + recordProgress: (payload: PlaybackProgressRequest) => + api.post('/history', payload).then((r) => r.data), recentHistory: () => api.get<{ items: HistoryItem[] }>('/history').then((r) => r.data.items), diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 8197bcd..e450743 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -4,7 +4,7 @@ import type Hls from 'hls.js' import toast from 'react-hot-toast' import { mediaAPI, libraryAPI } from '../api/library' -import { hlsURL, stopHLSJob, streamURL } from '../api/client' +import { hlsURL, postPlaybackProgressKeepalive, stopHLSJob, streamURL } from '../api/client' import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku' import { playbackAPI } from '../api/playback' import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles' @@ -48,6 +48,26 @@ import { mediaVersionsOf } from '../utils/mediaVersion' // // External subtitles next to the source file are auto-discovered and // attached as elements. +type PlaybackProgressSession = { + mediaId: string + id: string + startedAtMs: number + sequence: number +} + +function newPlaybackProgressSession(mediaId: string): PlaybackProgressSession { + const randomID = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + return { + mediaId, + id: `${mediaId}:${randomID}`, + startedAtMs: Date.now(), + sequence: 0, + } +} + export function PlayerPage() { const { id = '' } = useParams() const [params, setParams] = useSearchParams() @@ -57,6 +77,7 @@ export function PlayerPage() { const ref = useRef(null) const hlsRef = useRef(null) const lastSentRef = useRef(0) + const progressSessionRef = useRef(null) const directRetryRef = useRef(false) const retryingDirectRef = useRef(false) const fallbackTimerRef = useRef | null>(null) @@ -565,41 +586,73 @@ export function PlayerPage() { modeRef.current = mode }, [hlsStartSec, mode]) - // Persist resume position every 10 seconds while playing, and immediately upon pause/unmount. + // Persist resume position every 10 seconds while playing, and immediately upon + // pause/page hide/unmount. Bind after mediaId is available because + // PlayerVideoStage does not render the