diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go index 17aed6d..d005b5c 100644 --- a/internal/handler/cloud.go +++ b/internal/handler/cloud.go @@ -296,6 +296,9 @@ func cloudPlayHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"}) return } + if !enforceScopedCloudPlaybackToken(c, svc, typ, ref) { + return + } serveCloudResolvedLink(svc, c, typ, ref) } } diff --git a/internal/handler/emby.go b/internal/handler/emby.go index bc34ddd..edc839d 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -1086,7 +1086,7 @@ func embyAttachTokenToMediaSourcesMap(out map[string]any, token string) { func embyAttachTokenToMediaSources(sources []map[string]any, token string) { for _, source := range sources { - for _, key := range []string{"DirectStreamUrl", "TranscodingUrl", "Path"} { + for _, key := range []string{"DirectStreamUrl", "TranscodingUrl"} { raw, ok := source[key].(string) if !ok { continue diff --git a/internal/handler/emby_test.go b/internal/handler/emby_test.go index 01eeb86..f78d07a 100644 --- a/internal/handler/emby_test.go +++ b/internal/handler/emby_test.go @@ -1040,7 +1040,7 @@ func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) { } } -func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) { +func TestEmbyPlaybackInfoDoesNotExposeTokenInCloudPath(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { @@ -1099,8 +1099,11 @@ func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) { } source := body["MediaSources"].([]any)[0].(map[string]any) pathURL, _ := source["Path"].(string) - if !strings.HasPrefix(pathURL, "/api/stream/cloud-1") || !strings.Contains(pathURL, "api_key=") { - t.Fatalf("cloud Path should be tokenized stream URL, got %#v", source) + if pathURL != "/api/stream/cloud-1" { + t.Fatalf("cloud Path should stay as non-tokenized display stream URL, got %#v", source) + } + if strings.Contains(pathURL, "api_key=") || strings.Contains(pathURL, "token=") { + t.Fatalf("cloud Path must not expose auth key/token: %#v", source) } if strings.Contains(pathURL, "/api/cloud/play/") { t.Fatalf("cloud Path should not expose naked cloud play URL: %#v", source) @@ -1117,7 +1120,7 @@ func TestEmbyPlaybackInfoTokenizesCloudPath(t *testing.T) { } } -func TestEmbyItemsTokenizesEmbeddedCloudMediaSources(t *testing.T) { +func TestEmbyItemsDoNotExposeTokenInEmbeddedCloudPath(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { @@ -1180,8 +1183,11 @@ func TestEmbyItemsTokenizesEmbeddedCloudMediaSources(t *testing.T) { } source := items[0].(map[string]any)["MediaSources"].([]any)[0].(map[string]any) pathURL, _ := source["Path"].(string) - if !strings.HasPrefix(pathURL, "/api/stream/cloud-1") || !strings.Contains(pathURL, "api_key=") { - t.Fatalf("embedded cloud Path should be tokenized stream URL, got %#v", source) + if pathURL != "/api/stream/cloud-1" { + t.Fatalf("embedded cloud Path should stay as non-tokenized display stream URL, got %#v", source) + } + if strings.Contains(pathURL, "api_key=") || strings.Contains(pathURL, "token=") { + t.Fatalf("embedded cloud Path must not expose auth key/token: %#v", source) } } diff --git a/internal/handler/media.go b/internal/handler/media.go index 3938132..5b27eb9 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -337,6 +337,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } + if !enforceScopedPlaybackToken(c, m.ID) { + return + } err = svc.Stream.ServeFileWithCloudMode(c.Writer, c.Request, c.Param("id"), service.CloudPlaybackModeSTRM) if errors.Is(err, service.ErrMediaNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index c76c0c2..6603e77 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -138,12 +138,15 @@ func TestListMediaGroupsMultipleVersionsByDefault(t *testing.T) { if grouped.Total != 1 || len(grouped.Items) != 1 { t.Fatalf("grouped response total=%d len=%d body=%#v", grouped.Total, len(grouped.Items), grouped) } - if grouped.Items[0].ID != "movie-2160" { - t.Fatalf("primary id = %q, want highest quality version", grouped.Items[0].ID) + if grouped.Items[0].ID != "movie-1080" { + t.Fatalf("primary id = %q, want local version to remain primary", grouped.Items[0].ID) } if len(grouped.Items[0].Versions) != 2 { t.Fatalf("versions = %#v, want both versions", grouped.Items[0].Versions) } + if grouped.Items[0].Versions[0].ID != "movie-1080" || grouped.Items[0].Versions[1].ID != "movie-2160" { + t.Fatalf("versions should keep local before cloud: %#v", grouped.Items[0].Versions) + } raw := requestMediaList(t, svc, "/api/libraries/"+lib.ID+"/media?group_versions=0", lib.ID) if raw.Total != 2 || len(raw.Items) != 2 { diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go index 3e7e040..3ef7429 100644 --- a/internal/handler/playback_extra.go +++ b/internal/handler/playback_extra.go @@ -28,7 +28,7 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return } - token := externalPlaybackToken(c, svc) + token := externalPlaybackToken(c, svc, m.ID, m.DurationSec) profileQuery := externalProfileQuery(c) c.JSON(http.StatusOK, gin.H{ "media": m, @@ -73,7 +73,7 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return } - token := externalPlaybackToken(c, svc) + token := externalPlaybackToken(c, svc, m.ID, m.DurationSec) streamURL := absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c)) escapedStream := url.QueryEscape(streamURL) c.JSON(http.StatusOK, gin.H{ @@ -98,10 +98,9 @@ func externalURLHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return } - token := externalPlaybackToken(c, svc) + token := externalPlaybackToken(c, svc, m.ID, m.DurationSec) c.JSON(http.StatusOK, gin.H{ - "url": absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c)), - "token": token, + "url": absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)+externalProfileQuery(c)), }) } } @@ -125,13 +124,13 @@ func externalProfileQuery(c *gin.Context) string { return query } -func externalPlaybackToken(c *gin.Context, svc *service.Container) string { +func externalPlaybackToken(c *gin.Context, svc *service.Container, mediaID string, durationSec int) string { uid, _ := c.Get(middleware.CtxUserID) u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid)) if err != nil || u == nil { return "" } - token, err := svc.Auth.IssueToken(u) + token, err := svc.Auth.IssueExternalPlaybackToken(u, mediaID, durationSec) if err != nil { return "" } diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go new file mode 100644 index 0000000..db07390 --- /dev/null +++ b/internal/handler/playback_extra_test.go @@ -0,0 +1,254 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "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/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/middleware" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) { + router, svc, secret := newPlaybackScopeTestRouter(t) + loginToken := signedTestToken(t, secret) + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/external-url", nil) + req.Header.Set("Authorization", "Bearer "+loginToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + var payload struct { + URL string `json:"url"` + Token string `json:"token"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode: %v", err) + } + if payload.Token != "" { + t.Fatalf("external-url should not expose a separate token field") + } + if strings.Contains(payload.URL, loginToken) { + t.Fatalf("external url leaked the caller's login token") + } + streamURL, err := url.Parse(payload.URL) + if err != nil { + t.Fatalf("parse stream url: %v", err) + } + playToken := streamURL.Query().Get("token") + if playToken == "" { + t.Fatalf("external url missing playback token: %q", payload.URL) + } + claims := &service.Claims{} + parsed, err := jwt.ParseWithClaims(playToken, claims, func(*jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil || !parsed.Valid { + t.Fatalf("playback token did not parse: %v", err) + } + if claims.Purpose != service.ExternalPlaybackTokenPurpose || claims.MediaID != "media-1" || claims.UserID != "user-1" { + t.Fatalf("unexpected playback claims: %+v", claims) + } + ttl := time.Until(claims.ExpiresAt.Time) + wantTTL := service.ExternalPlaybackTokenDurationForMedia(2 * 60 * 60) + if ttl < wantTTL-time.Minute || ttl > wantTTL+time.Second { + t.Fatalf("playback token ttl = %v, want about %v", ttl, wantTTL) + } + + user, err := svc.Repo.User.FindByID(req.Context(), "user-1") + if err != nil || user == nil { + t.Fatalf("find user: %v", err) + } + accountToken, err := svc.Auth.IssueToken(user) + if err != nil { + t.Fatalf("issue account token: %v", err) + } + if playToken == accountToken { + t.Fatalf("playback token should not be the reusable account token") + } +} + +func TestScopedPlaybackTokenCannotStreamAnotherMedia(t *testing.T) { + router, svc, _ := newPlaybackScopeTestRouter(t) + user, err := svc.Repo.User.FindByID(t.Context(), "user-1") + if err != nil || user == nil { + t.Fatalf("find user: %v", err) + } + playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60) + if err != nil { + t.Fatalf("issue playback token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/stream/media-2?token="+url.QueryEscape(playToken), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } +} + +func TestScopedPlaybackTokenCannotRetargetCloudRef(t *testing.T) { + router, svc, _ := newPlaybackScopeTestRouter(t) + user, err := svc.Repo.User.FindByID(t.Context(), "user-1") + if err != nil || user == nil { + t.Fatalf("find user: %v", err) + } + playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60) + if err != nil { + t.Fatalf("issue playback token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/cloud/play/openlist?ref=other&media_id=media-1&token="+url.QueryEscape(playToken), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } +} + +func TestScopedPlaybackTokenCannotCallRegularAPI(t *testing.T) { + router, svc, _ := newPlaybackScopeTestRouter(t) + api := router.Group("/api") + api.Use(middleware.AuthRequired(svc.Cfg.Secrets.JWTSecret)) + api.GET("/me", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + user, err := svc.Repo.User.FindByID(t.Context(), "user-1") + if err != nil || user == nil { + t.Fatalf("find user: %v", err) + } + playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 2*60*60) + if err != nil { + t.Fatalf("issue playback token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/me?token="+url.QueryEscape(playToken), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } +} + +func TestExternalPlaybackTokenUsesUnknownDurationFallback(t *testing.T) { + _, svc, secret := newPlaybackScopeTestRouter(t) + user, err := svc.Repo.User.FindByID(t.Context(), "user-1") + if err != nil || user == nil { + t.Fatalf("find user: %v", err) + } + playToken, err := svc.Auth.IssueExternalPlaybackToken(user, "media-1", 0) + if err != nil { + t.Fatalf("issue playback token: %v", err) + } + claims := &service.Claims{} + parsed, err := jwt.ParseWithClaims(playToken, claims, func(*jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil || !parsed.Valid { + t.Fatalf("parse playback token: %v", err) + } + ttl := time.Until(claims.ExpiresAt.Time) + if ttl < service.ExternalPlaybackTokenUnknownDuration-time.Minute || ttl > service.ExternalPlaybackTokenUnknownDuration+time.Second { + t.Fatalf("unknown duration ttl = %v, want about %v", ttl, service.ExternalPlaybackTokenUnknownDuration) + } +} + +func TestExternalPlaybackTokenDurationIsCapped(t *testing.T) { + if got := service.ExternalPlaybackTokenDurationForMedia(int((72 * time.Hour).Seconds())); got != service.ExternalPlaybackTokenMaxDuration { + t.Fatalf("duration cap = %v, want %v", got, service.ExternalPlaybackTokenMaxDuration) + } +} + +func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container, string) { + t.Helper() + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate( + &model.User{}, + &model.UserPermission{}, + &model.RefreshToken{}, + &model.Setting{}, + &model.Library{}, + &model.Media{}, + &model.PlayProfile{}, + ); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + cfg := &config.Config{} + cfg.Secrets.JWTSecret = "playback-scope-secret" + log := zap.NewNop() + permissions := service.NewPermissionService(log, repos) + auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions) + svc := &service.Container{ + Cfg: cfg, + Repo: repos, + Auth: auth, + Media: service.NewMediaService(cfg, log, repos), + Stream: service.NewStreamService(cfg, log, repos, nil), + Permissions: permissions, + } + if err := repos.User.Create(t.Context(), &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "viewer", + Role: "admin", + Tier: "plus", + IsActive: true, + }); err != nil { + t.Fatal(err) + } + lib := model.Library{Base: model.Base{ID: "lib-1"}, Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} + if err := repos.DB.Create(&lib).Error; err != nil { + t.Fatal(err) + } + rows := []model.Media{ + { + Base: model.Base{ID: "media-1"}, + LibraryID: lib.ID, + Title: "Cloud 1", + Path: "cloud://openlist/Movies/Movie.mkv", + DurationSec: 2 * 60 * 60, + STRMURL: "/api/cloud/play/openlist?ref=/Movies/Movie.mkv", + }, + { + Base: model.Base{ID: "media-2"}, + LibraryID: lib.ID, + Title: "Cloud 2", + Path: "cloud://openlist/Movies/Other.mkv", + STRMURL: "/api/cloud/play/openlist?ref=/Movies/Other.mkv", + }, + } + if err := repos.DB.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + router := gin.New() + api := router.Group("/api") + api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) + api.GET("/playback/:id/external-url", externalURLHandler(svc)) + api.GET("/stream/:id", streamHandler(svc)) + api.GET("/cloud/play/:type", cloudPlayHandler(svc)) + return router, svc, cfg.Secrets.JWTSecret +} diff --git a/internal/handler/playback_scope.go b/internal/handler/playback_scope.go new file mode 100644 index 0000000..1fe5d05 --- /dev/null +++ b/internal/handler/playback_scope.go @@ -0,0 +1,118 @@ +package handler + +import ( + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/ShukeBta/MediaStationGo/internal/middleware" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func enforceScopedPlaybackToken(c *gin.Context, mediaID string) bool { + mediaID = strings.TrimSpace(mediaID) + purpose, _ := c.Get(middleware.CtxTokenPurpose) + if strings.TrimSpace(toString(purpose)) == "" { + return true + } + if strings.TrimSpace(toString(purpose)) != service.ExternalPlaybackTokenPurpose { + c.JSON(http.StatusForbidden, gin.H{"error": "playback token scope denied"}) + return false + } + tokenMediaID, _ := c.Get(middleware.CtxTokenMediaID) + if mediaID == "" || strings.TrimSpace(toString(tokenMediaID)) != mediaID { + c.JSON(http.StatusForbidden, gin.H{"error": "playback token media mismatch"}) + return false + } + return true +} + +func enforceScopedCloudPlaybackToken(c *gin.Context, svc *service.Container, typ, ref string) bool { + purpose, _ := c.Get(middleware.CtxTokenPurpose) + if strings.TrimSpace(toString(purpose)) == "" { + return true + } + if strings.TrimSpace(toString(purpose)) != service.ExternalPlaybackTokenPurpose { + c.JSON(http.StatusForbidden, gin.H{"error": "playback token scope denied"}) + return false + } + tokenMediaID, _ := c.Get(middleware.CtxTokenMediaID) + mediaID := strings.TrimSpace(toString(tokenMediaID)) + if mediaID == "" || strings.TrimSpace(c.Query("media_id")) != mediaID { + c.JSON(http.StatusForbidden, gin.H{"error": "playback token media mismatch"}) + return false + } + m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID) + if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { + c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) + return false + } + if !cloudPlaybackTargetMatchesMedia(m, typ, ref) { + c.JSON(http.StatusForbidden, gin.H{"error": "playback token target mismatch"}) + return false + } + return true +} + +func cloudPlaybackTargetMatchesMedia(m *model.Media, typ, ref string) bool { + if m == nil { + return false + } + if strmTyp, strmRef, ok := parseCloudPlaybackTarget(m.STRMURL); ok && + strings.EqualFold(strmTyp, typ) && sameCloudPlaybackRef(strmRef, ref) { + return true + } + pathTyp, pathRef, ok := parseCloudMediaPath(m.Path) + return ok && strings.EqualFold(pathTyp, typ) && sameCloudPlaybackRef(pathRef, ref) +} + +func parseCloudPlaybackTarget(raw string) (typ, ref string, ok bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", false + } + u, err := url.Parse(raw) + if err != nil { + return "", "", false + } + pathValue := strings.ToLower(strings.TrimRight(u.Path, "/")) + const prefix = "/api/cloud/play/" + idx := strings.LastIndex(pathValue, prefix) + if idx < 0 { + return "", "", false + } + typ = strings.TrimSpace(u.Path[idx+len(prefix):]) + ref = strings.TrimSpace(u.Query().Get("ref")) + return typ, ref, typ != "" && ref != "" +} + +func parseCloudMediaPath(raw string) (typ, ref string, ok bool) { + raw = strings.TrimSpace(raw) + if !strings.HasPrefix(strings.ToLower(raw), "cloud://") { + return "", "", false + } + rest := strings.TrimPrefix(raw, "cloud://") + parts := strings.SplitN(rest, "/", 2) + if len(parts) != 2 { + return "", "", false + } + typ = strings.TrimSpace(parts[0]) + ref = strings.TrimSpace(parts[1]) + return typ, ref, typ != "" && ref != "" +} + +func sameCloudPlaybackRef(a, b string) bool { + return normalizeCloudPlaybackRef(a) == normalizeCloudPlaybackRef(b) +} + +func normalizeCloudPlaybackRef(value string) string { + value = strings.TrimSpace(value) + if decoded, err := url.PathUnescape(value); err == nil { + value = decoded + } + value = strings.TrimSpace(value) + return strings.TrimLeft(value, "/") +} diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 5f5876b..c9541de 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -18,6 +18,9 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } + if !enforceScopedPlaybackToken(c, m.ID) { + return + } err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id")) if errors.Is(err, service.ErrMediaNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) @@ -45,6 +48,9 @@ func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } + if !enforceScopedPlaybackToken(c, m.ID) { + return + } err = svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 75c18a6..2766216 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -16,9 +16,11 @@ import ( // Context keys for values produced by the auth middleware. const ( - CtxUserID = "ctx_user_id" - CtxUserRole = "ctx_user_role" - CtxUserTier = "ctx_user_tier" + CtxUserID = "ctx_user_id" + CtxUserRole = "ctx_user_role" + CtxUserTier = "ctx_user_tier" + CtxTokenPurpose = "ctx_token_purpose" + CtxTokenMediaID = "ctx_token_media_id" ) // RequestLogger logs one structured line per request. @@ -165,9 +167,11 @@ func RateLimit(limiter *RateLimiter) gin.HandlerFunc { // Claims is the JWT payload we issue. type Claims struct { - UserID string `json:"uid"` - Role string `json:"role"` - Tier string `json:"tier,omitempty"` + UserID string `json:"uid"` + Role string `json:"role"` + Tier string `json:"tier,omitempty"` + Purpose string `json:"purpose,omitempty"` + MediaID string `json:"media_id,omitempty"` jwt.RegisteredClaims } @@ -191,13 +195,53 @@ func AuthRequired(secret string) gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "invalid token"}) return } + if !scopedTokenAllowedForRequest(c, claims) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"}) + return + } c.Set(CtxUserID, claims.UserID) c.Set(CtxUserRole, claims.Role) c.Set(CtxUserTier, claims.Tier) + c.Set(CtxTokenPurpose, claims.Purpose) + c.Set(CtxTokenMediaID, claims.MediaID) c.Next() } } +func scopedTokenAllowedForRequest(c *gin.Context, claims *Claims) bool { + if claims == nil || strings.TrimSpace(claims.Purpose) == "" { + return true + } + switch strings.TrimSpace(claims.Purpose) { + case "external_play": + return externalPlaybackTokenAllowedPath(c, strings.TrimSpace(claims.MediaID)) + default: + return false + } +} + +func externalPlaybackTokenAllowedPath(c *gin.Context, mediaID string) bool { + if c == nil || c.Request == nil || strings.TrimSpace(mediaID) == "" { + return false + } + pathValue := strings.Trim(c.Request.URL.Path, "/") + segments := strings.Split(pathValue, "/") + for i := range segments { + segments[i] = strings.TrimSpace(segments[i]) + } + if len(segments) >= 3 && strings.EqualFold(segments[0], "api") { + switch strings.ToLower(segments[1]) { + case "stream", "hls": + return segments[2] == mediaID + case "cloud": + return len(segments) >= 3 && + strings.EqualFold(segments[2], "play") && + strings.TrimSpace(c.Query("media_id")) == mediaID + } + } + return false +} + // AdminRequired must run AFTER AuthRequired; it enforces role == "admin". func AdminRequired() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/internal/service/auth.go b/internal/service/auth.go index ae52b8a..297643c 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -259,6 +259,53 @@ func (s *AuthService) IssueToken(u *model.User) (string, error) { return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) } +const ( + ExternalPlaybackTokenPurpose = "external_play" + ExternalPlaybackTokenMinDuration = 15 * time.Minute + ExternalPlaybackTokenGraceDuration = 30 * time.Minute + ExternalPlaybackTokenUnknownDuration = 6 * time.Hour + ExternalPlaybackTokenMaxDuration = 24 * time.Hour +) + +func ExternalPlaybackTokenDurationForMedia(durationSec int) time.Duration { + if durationSec <= 0 { + return ExternalPlaybackTokenUnknownDuration + } + duration := time.Duration(durationSec)*time.Second + ExternalPlaybackTokenGraceDuration + if duration < ExternalPlaybackTokenMinDuration { + return ExternalPlaybackTokenMinDuration + } + if duration > ExternalPlaybackTokenMaxDuration { + return ExternalPlaybackTokenMaxDuration + } + return duration +} + +// IssueExternalPlaybackToken signs a short-lived, media-scoped JWT for URLs +// that are handed to third-party players. It must not be accepted as a +// reusable account/session token for arbitrary media playback. +func (s *AuthService) IssueExternalPlaybackToken(u *model.User, mediaID string, durationSec int) (string, error) { + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return "", errors.New("media id required") + } + claims := Claims{ + UserID: u.ID, + Role: u.Role, + Tier: u.Tier, + Purpose: ExternalPlaybackTokenPurpose, + MediaID: mediaID, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ExternalPlaybackTokenDurationForMedia(durationSec))), + Issuer: "mediastationgo", + Subject: u.ID, + }, + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString([]byte(s.cfg.Secrets.JWTSecret)) +} + // EmbyTokenDuration 是第三方 Emby/Jellyfin 客户端访问令牌的有效期。 // Emby 协议没有 refresh token 机制——客户端登录一次后把 AccessToken // 长期保存并反复使用,直到用户主动登出。若给它们签发 60 分钟的普通 diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 09cdaf0..158c6e7 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -1031,10 +1031,12 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, } imageTags := map[string]string{} backdropTags := []string{} - if m.PosterURL != "" { + primaryArtwork := e.mediaPrimaryArtwork(ctx, m) + backdropArtwork := e.mediaBackdropArtwork(ctx, m) + if primaryArtwork != "" { imageTags["Primary"] = m.ID } - if m.BackdropURL != "" { + if backdropArtwork != "" { backdropTags = append(backdropTags, m.ID+"-bd") } @@ -1624,7 +1626,13 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin } m, err := e.repo.Media.FindByID(ctx, id) if err == nil && m != nil { - return pick(m.PosterURL, m.BackdropURL), nil + if e.mediaShouldBeEpisode(ctx, m) { + switch strings.ToLower(imageType) { + case "backdrop", "art": + return "", nil + } + } + return pick(e.mediaPrimaryArtwork(ctx, m), e.mediaBackdropArtwork(ctx, m)), nil } if err != nil { return "", err @@ -1637,6 +1645,26 @@ func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (strin return "", nil } +func (e *EmbyService) mediaPrimaryArtwork(ctx context.Context, m *model.Media) string { + if m == nil { + return "" + } + if e.mediaShouldBeEpisode(ctx, m) && strings.TrimSpace(m.BackdropURL) != "" { + return m.BackdropURL + } + return m.PosterURL +} + +func (e *EmbyService) mediaBackdropArtwork(ctx context.Context, m *model.Media) string { + if m == nil { + return "" + } + if e.mediaShouldBeEpisode(ctx, m) { + return "" + } + return m.BackdropURL +} + func (e *EmbyService) seriesIDForMedia(m *model.Media) string { if strings.TrimSpace(m.SeriesID) != "" { return m.SeriesID @@ -1645,7 +1673,7 @@ func (e *EmbyService) seriesIDForMedia(m *model.Media) string { } func (e *EmbyService) seasonIDForMedia(m *model.Media) string { - return seasonID(e.seriesIDForMedia(m), maxInt(m.SeasonNum, 1)) + return seasonID(e.seriesIDForMedia(m), m.SeasonNum) } func (e *EmbyService) seriesNameForMedia(m *model.Media) string { diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 5454054..3dfc80e 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -103,6 +103,102 @@ func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(t *testing.T) { } } +func TestEmbyItemsKeepSpecialsInSeasonZero(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "番剧", Path: `F:\downloads\日番`, Type: "anime", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + media := model.Media{ + Base: model.Base{ID: "sp-1"}, + LibraryID: lib.ID, + Title: "间谍过家家", + Path: `F:\downloads\日番\间谍过家家\Specials\间谍过家家 - S00E01.mkv`, + PosterURL: `F:\episode-still.jpg`, + SeasonNum: 0, + EpisodeNum: 1, + } + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50}) + if err != nil { + t.Fatalf("library items: %v", err) + } + rootItems := root["Items"].([]map[string]any) + if len(rootItems) != 1 || rootItems[0]["Type"] != "Series" { + t.Fatalf("expected one series card, got %#v", rootItems) + } + + seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: rootItems[0]["Id"].(string), Limit: 50}) + if err != nil { + t.Fatalf("series seasons: %v", err) + } + seasonItems := seasons["Items"].([]map[string]any) + if len(seasonItems) != 1 || seasonItems[0]["Type"] != "Season" || seasonItems[0]["IndexNumber"] != 0 || seasonItems[0]["Name"] != "特别篇" { + t.Fatalf("specials should be exposed as season zero: %#v", seasonItems) + } + + episodes, err := svc.Items(t.Context(), ItemsParams{ParentID: seasonItems[0]["Id"].(string), IncludeItemTypes: []string{"Episode"}, Recursive: true, Limit: 50}) + if err != nil { + t.Fatalf("special episodes: %v", err) + } + episodeItems := episodes["Items"].([]map[string]any) + if len(episodeItems) != 1 { + t.Fatalf("expected one special episode, got %#v", episodeItems) + } + if episodeItems[0]["ParentIndexNumber"] != 0 || episodeItems[0]["SeasonId"] != seasonItems[0]["Id"] || episodeItems[0]["ParentId"] != seasonItems[0]["Id"] { + t.Fatalf("special episode linked to wrong season: %#v season=%#v", episodeItems[0], seasonItems[0]) + } + if tags, ok := episodeItems[0]["ImageTags"].(map[string]string); !ok || tags["Primary"] != "sp-1" { + t.Fatalf("episode still should be exposed as Primary image: %#v", episodeItems[0]["ImageTags"]) + } +} + +func TestEmbyEpisodeStillIsPrimaryImageNotArt(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + media := model.Media{ + Base: model.Base{ID: "ep-still"}, + LibraryID: lib.ID, + Title: "间谍过家家", + Path: `/media/tv/间谍过家家/Season 02/间谍过家家 - S02E01.mkv`, + PosterURL: `https://image.example/show-poster.jpg`, + BackdropURL: `https://image.example/episode-still.jpg`, + SeasonNum: 2, + EpisodeNum: 1, + } + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + item := svc.itemPayload(t.Context(), &media, false, 0) + if tags, ok := item["ImageTags"].(map[string]string); !ok || tags["Primary"] != "ep-still" { + t.Fatalf("episode should expose a primary image tag: %#v", item["ImageTags"]) + } + if tags, ok := item["BackdropImageTags"].([]string); !ok || len(tags) != 0 { + t.Fatalf("episode still must not be exposed as art/backdrop: %#v", item["BackdropImageTags"]) + } + primary, err := svc.ImageURL(t.Context(), "ep-still", "Primary") + if err != nil { + t.Fatalf("primary image url: %v", err) + } + if primary != media.BackdropURL { + t.Fatalf("episode Primary image = %q, want still %q", primary, media.BackdropURL) + } + art, err := svc.ImageURL(t.Context(), "ep-still", "Art") + if err != nil { + t.Fatalf("art image url: %v", err) + } + if art == media.BackdropURL { + t.Fatalf("episode still must not be returned as Art image") + } +} + func TestEmbyLatestItemsIncludesMergedCloudMovieLibrary(t *testing.T) { svc := newTestEmbyService(t) local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true} diff --git a/internal/service/media.go b/internal/service/media.go index 5c015e4..4b30291 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -696,6 +696,11 @@ func normalizeMediaVersionText(value string) string { } func betterMediaVersion(candidate, current model.Media) bool { + candidateCloud := isCloudMediaVersion(candidate) + currentCloud := isCloudMediaVersion(current) + if candidateCloud != currentCloud { + return !candidateCloud + } candidatePixels := candidate.Width * candidate.Height currentPixels := current.Width * current.Height if candidatePixels != currentPixels { @@ -707,6 +712,11 @@ func betterMediaVersion(candidate, current model.Media) bool { return candidate.CreatedAt.After(current.CreatedAt) } +func isCloudMediaVersion(media model.Media) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") || + strings.Contains(strings.ToLower(strings.TrimSpace(media.STRMURL)), "/api/cloud/play/") +} + // SearchMedia performs a simple LIKE search across titles. func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) { return s.SearchMediaVisible(ctx, query, limit, MediaVisibility{IncludeNSFW: true}) diff --git a/internal/service/media_test.go b/internal/service/media_test.go index f858508..2ad0e3e 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -257,6 +257,12 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T if len(grouped[0].Versions) != 2 { t.Fatalf("versions len = %d, want 2: %#v", len(grouped[0].Versions), grouped[0].Versions) } + if grouped[0].Media.Path != local.Path { + t.Fatalf("local version should remain primary, got %q want %q", grouped[0].Media.Path, local.Path) + } + if grouped[0].Versions[0].Path != local.Path || grouped[0].Versions[1].Path != cloud.Path { + t.Fatalf("versions should be ordered local before cloud, got %#v", grouped[0].Versions) + } } func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) { diff --git a/internal/service/stream.go b/internal/service/stream.go index 3f1b300..863cf5a 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -148,11 +148,41 @@ func withAuthTokenForInternalRedirect(target string, r *http.Request, publicBase q := u.Query() if q.Get("token") == "" { q.Set("token", tok) - u.RawQuery = q.Encode() } + if q.Get("media_id") == "" && strings.HasPrefix(strings.ToLower(u.Path), "/api/cloud/play/") { + if mediaID := playbackMediaIDFromRequestPath(r.URL.Path); mediaID != "" { + q.Set("media_id", mediaID) + } + } + u.RawQuery = q.Encode() return u.String() } +func playbackMediaIDFromRequestPath(pathValue string) string { + pathValue = strings.TrimSpace(pathValue) + if pathValue == "" { + return "" + } + segments := strings.Split(strings.Trim(pathValue, "/"), "/") + lower := make([]string, len(segments)) + for i, segment := range segments { + lower[i] = strings.ToLower(segment) + } + var mediaID string + switch { + case len(segments) >= 3 && lower[0] == "api" && lower[1] == "stream": + mediaID = segments[2] + case len(segments) >= 4 && lower[0] == "emby" && lower[1] == "api" && lower[2] == "stream": + mediaID = segments[3] + case len(segments) >= 3 && lower[0] == "videos": + mediaID = segments[1] + } + if decoded, err := url.PathUnescape(mediaID); err == nil { + mediaID = decoded + } + return strings.TrimSpace(mediaID) +} + func absoluteInternalRedirect(target string, r *http.Request) string { if r == nil || target == "" || strings.HasPrefix(target, "//") { return target diff --git a/internal/service/stream_test.go b/internal/service/stream_test.go index efb28ca..ebf5cbc 100644 --- a/internal/service/stream_test.go +++ b/internal/service/stream_test.go @@ -58,6 +58,18 @@ func TestWithAuthTokenPropagatesToSameOriginAbsoluteInternalURL(t *testing.T) { } } +func TestWithAuthTokenAddsMediaIDToCloudPlaybackRedirect(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://media.example/api/stream/media-1?token=jwt123", nil) + got := withAuthTokenForInternalRedirect("/api/cloud/play/openlist?ref=abc", req, "") + u, err := url.Parse(got) + if err != nil { + t.Fatalf("parse: %v", err) + } + if u.Query().Get("token") != "jwt123" || u.Query().Get("media_id") != "media-1" { + t.Fatalf("cloud redirect should carry token and media_id, got %q", got) + } +} + func TestServeFileRedirectsInternalSTRMAsAbsoluteURLWithToken(t *testing.T) { repos := newStreamTestRepo(t) if err := repos.DB.Create(&model.Media{ diff --git a/internal/service/token_svc.go b/internal/service/token_svc.go index fb2f029..016e22e 100644 --- a/internal/service/token_svc.go +++ b/internal/service/token_svc.go @@ -30,9 +30,11 @@ const loginRefreshTokenStoreTimeout = 750 * time.Millisecond // Claims 是 JWT 载荷(复制自 middleware 以避免循环导入)。 type Claims struct { - UserID string `json:"uid"` - Role string `json:"role"` - Tier string `json:"tier,omitempty"` + UserID string `json:"uid"` + Role string `json:"role"` + Tier string `json:"tier,omitempty"` + Purpose string `json:"purpose,omitempty"` + MediaID string `json:"media_id,omitempty"` jwt.RegisteredClaims } diff --git a/web/public/artwork-cache-sw.js b/web/public/artwork-cache-sw.js new file mode 100644 index 0000000..1faa511 --- /dev/null +++ b/web/public/artwork-cache-sw.js @@ -0,0 +1,48 @@ +const ARTWORK_CACHE = 'mediastationgo-artwork-v1' +const STRIP_QUERY_KEYS = ['token', 'profile_id', 'profile_pin_token'] + +function isArtworkRequest(url) { + if (url.origin !== self.location.origin) return false + if (url.pathname === '/api/img') return true + return url.pathname.startsWith('/api/cloud/play/') +} + +function normalizedArtworkRequest(request) { + const url = new URL(request.url) + for (const key of STRIP_QUERY_KEYS) { + url.searchParams.delete(key) + } + return new Request(url.toString(), { + method: 'GET', + headers: { + Accept: request.headers.get('Accept') || 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + }, + credentials: 'same-origin', + mode: 'same-origin', + redirect: 'follow', + }) +} + +self.addEventListener('fetch', (event) => { + const request = event.request + if (request.method !== 'GET') return + + const url = new URL(request.url) + if (!isArtworkRequest(url)) return + + event.respondWith(cacheArtwork(request)) +}) + +async function cacheArtwork(request) { + const cache = await caches.open(ARTWORK_CACHE) + const cacheKey = normalizedArtworkRequest(request) + const cached = await cache.match(cacheKey) + if (cached) return cached + + const response = await fetch(request) + const contentType = response.headers.get('Content-Type') || '' + if (response.ok && contentType.toLowerCase().startsWith('image/')) { + await cache.put(cacheKey, response.clone()) + } + return response +} diff --git a/web/src/api/playback.ts b/web/src/api/playback.ts index e2dec76..9665d49 100644 --- a/web/src/api/playback.ts +++ b/web/src/api/playback.ts @@ -74,5 +74,5 @@ export const playbackAPI = { .then((r) => r.data), externalURL: (mediaId: string) => - api.get<{ url: string; token: string }>(`/playback/${mediaId}/external-url`).then((r) => r.data), + api.get<{ url: string }>(`/playback/${mediaId}/external-url`).then((r) => r.data), } diff --git a/web/src/components/APIConfigsPanel.tsx b/web/src/components/APIConfigsPanel.tsx index ece2b25..d7f4b34 100644 --- a/web/src/components/APIConfigsPanel.tsx +++ b/web/src/components/APIConfigsPanel.tsx @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useState } from 'react' import toast from 'react-hot-toast' -import { Eye, KeyRound, Save, Trash2, X } from 'lucide-react' +import { Eye, KeyRound, Pencil, Save, Trash2, X } from 'lucide-react' import { apiConfigsAPI, type APIConfig } from '../api/api_configs' import { confirmAction } from './ConfirmDialog' @@ -100,7 +100,7 @@ export function APIConfigsPanel() { className="rounded-lg p-1.5 text-ink-50 transition hover:bg-gray-50 hover:text-white" title="编辑" > - + { diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 533c4b3..b00925f 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -3,9 +3,9 @@ import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-do import { AnimatePresence, motion } from 'framer-motion' import toast from 'react-hot-toast' import { - Activity, Bell, Clock, CloudDownload, Compass, + Activity, Clock, CloudDownload, Compass, Cast, Globe, HardDrive, Heart, Home, Image, KeySquare, - ListMusic, LogOut, Rss, Search, Trash2, + ListMusic, LogOut, MessageSquareText, Rss, Search, Trash2, Settings, Sliders, Sparkles, UserCog, Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X } from 'lucide-react' @@ -310,7 +310,7 @@ export function Layout() { > } label="媒体与用户" child /> } label="站点管理" child /> - } label="通知渠道" child /> + } label="通知渠道" child /> } label="AI 会话" child /> } label="授权许可" child /> } label="系统设置" child /> @@ -510,7 +510,7 @@ export function Layout() { )} - {/* Notification alert bubble */} + {/* Notification channel settings */} {isAdmin && ( - - + )} diff --git a/web/src/main.tsx b/web/src/main.tsx index 403c965..97c27e5 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -7,6 +7,12 @@ import App from './App' import { GlobalEvents } from './components/GlobalEvents' import './index.css' +if ('serviceWorker' in navigator && import.meta.env.PROD) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/artwork-cache-sw.js').catch(() => undefined) + }) +} + // Application root: BrowserRouter + global toast container. ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/web/src/pages/FileManagerPage.tsx b/web/src/pages/FileManagerPage.tsx index 3c2b3b2..4dc17f3 100644 --- a/web/src/pages/FileManagerPage.tsx +++ b/web/src/pages/FileManagerPage.tsx @@ -167,6 +167,7 @@ export function FileManagerPage() { const [loading, setLoading] = useState(true) const [recursive, setRecursive] = useState(false) const [selected, setSelected] = useState(null) + const [selectedPaths, setSelectedPaths] = useState([]) const [folderName, setFolderName] = useState('') const [renameTo, setRenameTo] = useState('') const [destPath, setDestPath] = useState('') @@ -246,6 +247,7 @@ export function FileManagerPage() { useEffect(() => { setSelected(null) + setSelectedPaths([]) setRenameTo('') }, [path]) @@ -309,6 +311,15 @@ export function FileManagerPage() { if (e.is_dir) setDestPath(e.path) } + const toggleSelectedPath = (entry: FileEntry, checked: boolean) => { + setSelectedPaths((current) => { + if (checked) { + return current.includes(entry.path) ? current : [...current, entry.path] + } + return current.filter((item) => item !== entry.path) + }) + } + const createFolder = async () => { if (!currentDir || !folderName.trim()) return setBusy('mkdir') @@ -376,8 +387,9 @@ export function FileManagerPage() { } } - const organizeSource = selected?.path || currentDir - const organizeReady = Boolean(organizeSource && organizeDestPath.trim()) + const organizeSources = selectedPaths.length > 0 ? selectedPaths : [selected?.path || currentDir].filter(Boolean) + const organizeSource = organizeSources.length === 1 ? organizeSources[0] : `${organizeSources.length} 个已选项目` + const organizeReady = organizeSources.length > 0 && Boolean(organizeDestPath.trim()) const runManualOrganize = async (dryRun: boolean) => { if (!organizeReady) { @@ -387,40 +399,50 @@ export function FileManagerPage() { if (!dryRun) { const ok = await confirmAction({ title: '确认整理入库', - message: `来源:${organizeSource}\n目标:${organizeDestPath}\n方式:${organizeTransferMode}${scanAfter ? '\n整理完成后会扫描入库。' : ''}${scanAfter && scrapeAfter ? '\n扫描后会自动刮削。' : ''}`, + message: `来源:${organizeSources.join('\n')}\n目标:${organizeDestPath}\n方式:${organizeTransferMode}${scanAfter ? '\n整理完成后会扫描入库。' : ''}${scanAfter && scrapeAfter ? '\n扫描后会自动刮削。' : ''}`, confirmText: '开始整理', }) if (!ok) return } setOrganizeBusy(dryRun ? 'preview' : 'run') try { - const result = await toolsAPI.organizeDirectory({ - source_path: organizeSource, - dest_path: organizeDestPath.trim(), - transfer_mode: organizeTransferMode, - media_type: organizeMediaType === 'auto' ? undefined : organizeMediaType, - scan_after: !dryRun && scanAfter, - scrape_after: !dryRun && scanAfter && scrapeAfter, - library_id: !dryRun && scanAfter && organizeLibraryID ? organizeLibraryID : undefined, - dry_run: dryRun, - }) - setPreviewItems(result.items ?? []) - const replaced = result.replaced ?? 0 - const total = (result.organized ?? 0) + replaced + (result.skipped ?? 0) + (result.errors?.length ?? 0) + const results = [] + for (const sourcePath of organizeSources) { + results.push(await toolsAPI.organizeDirectory({ + source_path: sourcePath, + dest_path: organizeDestPath.trim(), + transfer_mode: organizeTransferMode, + media_type: organizeMediaType === 'auto' ? undefined : organizeMediaType, + scan_after: !dryRun && scanAfter, + scrape_after: !dryRun && scanAfter && scrapeAfter, + library_id: !dryRun && scanAfter && organizeLibraryID ? organizeLibraryID : undefined, + dry_run: dryRun, + })) + } + const preview = results.flatMap((result) => result.items ?? []) + setPreviewItems(preview) + const organized = results.reduce((sum, result) => sum + (result.organized ?? 0), 0) + const replaced = results.reduce((sum, result) => sum + (result.replaced ?? 0), 0) + const skipped = results.reduce((sum, result) => sum + (result.skipped ?? 0), 0) + const errors = results.flatMap((result) => result.errors ?? []) + const scans = results.flatMap((result) => result.scans ?? []) + const scrapes = results.flatMap((result) => result.scrapes ?? []) + const total = organized + replaced + skipped + errors.length if (total === 0) { - toast(`未发现可整理视频:${result.source_path || organizeSource}`, { - icon: '⚠️', + toast(`未发现可整理视频:${organizeSource}`, { + icon: '!', duration: 6000, }) return } if (dryRun) { - toast.success(`预览完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}`) + toast.success(`预览完成:新增 ${organized} · 替换 ${replaced} · 跳过 ${skipped}`) return } - const scanText = scanAfter ? formatScanSummary(result.scans ?? []) : '' - const scrapeText = scanAfter && scrapeAfter ? formatScrapeSummary(result.scrapes ?? []) : '' - toast.success(`整理完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}${scanText}${scrapeText}`) + const scanText = scanAfter ? formatScanSummary(scans) : '' + const scrapeText = scanAfter && scrapeAfter ? formatScrapeSummary(scrapes) : '' + toast.success(`整理完成:新增 ${organized} · 替换 ${replaced} · 跳过 ${skipped}${scanText}${scrapeText}`) + setSelectedPaths([]) refresh() } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '整理失败') @@ -745,6 +767,14 @@ export function FileManagerPage() { 来源:{organizeSource || '未选择'} + {selectedPaths.length > 0 && ( + + 已选择 {selectedPaths.length} 个项目用于整理。 + setSelectedPaths([])}> + 清空选择 + + + )} @@ -940,6 +970,21 @@ export function FileManagerPage() { + + 0 && data.entries.every((entry) => selectedPaths.includes(entry.path))} + onChange={(event) => { + const entries = data.entries ?? [] + if (event.target.checked) { + setSelectedPaths(entries.map((entry) => entry.path)) + } else { + setSelectedPaths([]) + } + }} + /> + 名称 大小 修改时间 @@ -948,7 +993,15 @@ export function FileManagerPage() { {data.entries.map((entry) => ( - + + + toggleSelectedPath(entry, event.target.checked)} + /> + (entry.is_dir ? enter(entry) : choose(entry))} title={entry.path}> {entry.is_dir ? : } diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 872ba3f..ecf7270 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { Link, useParams, useSearchParams } from 'react-router-dom' +import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' import toast from 'react-hot-toast' -import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2, Pencil } from 'lucide-react' +import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2, Pencil, FolderInput } from 'lucide-react' import { libraryAPI } from '../api/library' import { toolsAPI } from '../api/tools' @@ -23,6 +23,7 @@ import { MetadataEditDialog } from '../components/MetadataEditDialog' export function LibraryPage() { const { id = '' } = useParams() const [searchParams, setSearchParams] = useSearchParams() + const location = useLocation() const role = useAuthStore((s) => s.user?.role) const [library, setLibrary] = useState(null) @@ -320,6 +321,39 @@ export function LibraryPage() { runSeriesTool('nfo', '整剧 NFO 写出', (media) => recycleAPI.exportNFO(media.id)) } + const handleSeriesOrganize = async () => { + if (!selectedSeries || selectedSeriesEpisodes.length === 0 || !library) return + const source = seriesSourceRoot(selectedSeriesEpisodes) + if (!source || source.toLowerCase().startsWith('cloud://')) { + toast.error('当前合集不是本地文件夹,无法使用本地整理入库') + return + } + if (!(await confirmAction({ + title: '整理当前合集', + message: `来源:${source}\n目标:${library.path}\n将按当前媒体库类型整理整个文件夹。`, + confirmText: '开始整理', + }))) return + setSeriesToolBusy('organize') + try { + const result = await toolsAPI.organizeDirectory({ + source_path: source, + dest_path: library.path, + media_type: library.type || 'auto', + library_id: library.id, + scan_after: true, + scrape_after: true, + }) + const replaced = result.replaced ?? 0 + toast.success(`合集整理完成:新增 ${result.organized ?? 0} · 替换 ${replaced} · 跳过 ${result.skipped ?? 0}`) + reloadCurrentLibrary() + } catch (err: unknown) { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '合集整理失败' + toast.error(msg) + } finally { + setSeriesToolBusy('') + } + } + const handleSeriesSoftDelete = async () => { if (!selectedSeries || selectedSeriesEpisodes.length === 0) return if (!(await confirmAction({ @@ -517,7 +551,7 @@ export function LibraryPage() { const first = firstEps.length > 0 ? firstEps[0] : null return first ? ( - + 从第一集开始播放 @@ -550,6 +584,10 @@ export function LibraryPage() { {seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'} + + + {seriesToolBusy === 'organize' ? '整理中…' : '整理当前合集'} + {seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'} @@ -591,7 +629,7 @@ export function LibraryPage() { key={ep.id} className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover" > - + {ep.backdrop_url || ep.poster_url ? ( item.path)?.path ?? '' + if (!firstPath) return '' + const dir = dirname(firstPath) + const base = basename(dir) + if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ova|oad|特别篇|特別篇)$/i.test(base)) { + return dirname(dir) + } + return dir +} + +function dirname(value: string): string { + const index = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + return index > 0 ? value.slice(0, index) : '' +} + +function basename(value: string): string { + const index = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + return index >= 0 ? value.slice(index + 1) : value +} + function formatSize(bytes: number): string { if (!bytes || bytes <= 0) return '—' const units = ['B', 'KB', 'MB', 'GB', 'TB'] diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 352ea3f..7f3e6b6 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import type Hls from 'hls.js' import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react' import toast from 'react-hot-toast' @@ -10,6 +10,7 @@ import { playbackAPI } from '../api/playback' import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles' import { systemAPI } from '../api/system' import type { Media } from '../types' +import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries' type Mode = 'direct' | 'hls' @@ -28,6 +29,7 @@ export function PlayerPage() { const { id = '' } = useParams() const [params, setParams] = useSearchParams() const navigate = useNavigate() + const location = useLocation() const ref = useRef(null) const hlsRef = useRef(null) @@ -136,18 +138,6 @@ export function PlayerPage() { } }, [media]) - // ESC = back. - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - const target = media?.id || id - navigate(target ? `/media/${target}` : '/', { replace: true }) - } - } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [id, media?.id, navigate]) - const teardownHls = (mediaId?: string, stopServer = false) => { if (hlsRef.current) { hlsRef.current.destroy() @@ -158,11 +148,29 @@ export function PlayerPage() { } } - const goBack = () => { + const backTarget = () => { + const state = location.state as { from?: string } | null + if (state?.from) return state.from + if (media && isEpisodeLike(media) && media.library_id) { + return `/library/${encodeURIComponent(media.display_library_id || media.library_id)}?series=${encodeURIComponent(getSeriesKey(media))}` + } const target = media?.id || id - navigate(target ? `/media/${target}` : '/', { replace: true }) + return target ? `/media/${target}` : '/' } + const goBack = () => { + navigate(backTarget(), { replace: true }) + } + + // ESC = back. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') goBack() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [location.state, media, id]) + return (