fix media playback and library workflows

This commit is contained in:
ShukeBta
2026-06-21 12:53:52 +08:00
parent ada41e7078
commit efca3cbe69
26 changed files with 921 additions and 81 deletions
+3
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+12 -6
View File
@@ -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)
}
}
+3
View File
@@ -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"})
+5 -2
View File
@@ -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 {
+6 -7
View File
@@ -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 ""
}
+254
View File
@@ -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
}
+118
View File
@@ -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, "/")
}
+6
View File
@@ -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()})
+50 -6
View File
@@ -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) {
+47
View File
@@ -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 分钟的普通
+32 -4
View File
@@ -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 {
+96
View File
@@ -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}
+10
View File
@@ -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})
+6
View File
@@ -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) {
+31 -1
View File
@@ -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
+12
View File
@@ -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{
+5 -3
View File
@@ -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
}
+48
View File
@@ -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
}
+1 -1
View File
@@ -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),
}
+2 -2
View File
@@ -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="编辑"
>
<Save size={14} />
<Pencil size={14} />
</button>
<button
onClick={() => {
+5 -6
View File
@@ -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() {
>
<SidebarLink to="/admin" icon={<Settings size={16} />} label="媒体与用户" child />
<SidebarLink to="/sites" icon={<Globe size={16} />} label="站点管理" child />
<SidebarLink to="/notify-channels" icon={<Bell size={16} />} label="通知渠道" child />
<SidebarLink to="/notify-channels" icon={<MessageSquareText size={16} />} label="通知渠道" child />
<SidebarLink to="/assistant" icon={<Sparkles size={16} />} label="AI 会话" child />
<SidebarLink to="/license" icon={<KeySquare size={16} />} label="授权许可" child />
<SidebarLink to="/settings" icon={<Sliders size={16} />} label="系统设置" child />
@@ -510,7 +510,7 @@ export function Layout() {
</Link>
)}
{/* Notification alert bubble */}
{/* Notification channel settings */}
{isAdmin && (
<Link
to="/notify-channels"
@@ -518,8 +518,7 @@ export function Layout() {
aria-label="打开通知配置"
className="relative rounded-xl border border-gray-200 p-2.5 text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-all"
>
<Bell size={18} />
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-white animate-pulse" />
<MessageSquareText size={18} />
</Link>
)}
+6
View File
@@ -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(
<React.StrictMode>
+76 -23
View File
@@ -167,6 +167,7 @@ export function FileManagerPage() {
const [loading, setLoading] = useState(true)
const [recursive, setRecursive] = useState(false)
const [selected, setSelected] = useState<FileEntry | null>(null)
const [selectedPaths, setSelectedPaths] = useState<string[]>([])
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 || '未选择'}
</div>
</div>
{selectedPaths.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-primary-400/20 bg-primary-400/5 px-3 py-2 text-xs text-ink-100">
<span>已选择 {selectedPaths.length} 个项目用于整理。</span>
<button type="button" className="font-semibold text-brand-500 hover:text-brand-700" onClick={() => setSelectedPaths([])}>
清空选择
</button>
</div>
)}
<div className="grid gap-3 lg:grid-cols-[1.2fr_1fr_150px_150px]">
<label className="space-y-1">
@@ -940,6 +970,21 @@ export function FileManagerPage() {
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="w-10 py-2">
<input
type="checkbox"
aria-label="选择当前目录全部项目"
checked={data.entries.length > 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([])
}
}}
/>
</th>
<th className="py-2">名称</th>
<th>大小</th>
<th>修改时间</th>
@@ -948,7 +993,15 @@ export function FileManagerPage() {
</thead>
<tbody>
{data.entries.map((entry) => (
<tr key={entry.path} className={'border-t border-gray-200 transition hover:bg-gray-50 ' + (selected?.path === entry.path ? 'bg-primary-400/5' : '')}>
<tr key={entry.path} className={'border-t border-gray-200 transition hover:bg-gray-50 ' + (selected?.path === entry.path || selectedPaths.includes(entry.path) ? 'bg-primary-400/5' : '')}>
<td className="py-2">
<input
type="checkbox"
aria-label={`选择 ${entry.name}`}
checked={selectedPaths.includes(entry.path)}
onChange={(event) => toggleSelectedPath(entry, event.target.checked)}
/>
</td>
<td className="py-2 text-ink-600">
<button className="flex max-w-xl items-center gap-2 text-left" onClick={() => (entry.is_dir ? enter(entry) : choose(entry))} title={entry.path}>
{entry.is_dir ? <Folder size={16} className="text-brand-500" /> : <FileVideo size={16} className="text-ink-50" />}
+63 -4
View File
@@ -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<Library | null>(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 ? (
<div className="flex flex-wrap gap-2">
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
<Link to={`/play/${first.id}`} state={{ from: `${location.pathname}${location.search}` }} className="btn-primary inline-flex">
<Play size={16} fill="currentColor" />
从第一集开始播放
</Link>
@@ -550,6 +584,10 @@ export function LibraryPage() {
<FileText size={13} />
<span>{seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'}</span>
</button>
<button onClick={handleSeriesOrganize} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<FolderInput size={13} />
<span>{seriesToolBusy === 'organize' ? '整理中…' : '整理当前合集'}</span>
</button>
<button onClick={handleSeriesSoftDelete} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200">
<Trash2 size={13} />
<span>{seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'}</span>
@@ -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"
>
<Link to={`/play/${ep.id}`} className="flex min-w-0 flex-1 items-center gap-3">
<Link to={`/play/${ep.id}`} state={{ from: `${location.pathname}${location.search}` }} className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.backdrop_url || ep.poster_url ? (
<img
@@ -690,6 +728,27 @@ function formatCloudScanStatus(status: CloudScanStatus): string {
return `${stage}:目录 ${status.dirs ?? 0} · 已发现 ${status.discovered ?? 0} · 已入库 ${status.visited ?? 0} · 新增 ${status.added ?? 0} · 更新 ${status.updated ?? 0}${speedText}`
}
function seriesSourceRoot(episodes: Media[]): string {
const firstPath = episodes.find((item) => 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']
+23 -15
View File
@@ -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<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(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 (
<div className="relative -m-6 flex min-h-screen flex-col overflow-hidden bg-black md:-m-8">
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">