mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
fix media playback and library workflows
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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()})
|
||||
|
||||
@@ -19,6 +19,8 @@ const (
|
||||
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.
|
||||
@@ -168,6 +170,8 @@ type Claims struct {
|
||||
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) {
|
||||
|
||||
@@ -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 分钟的普通
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -33,6 +33,8 @@ type Claims struct {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,15 +399,17 @@ 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,
|
||||
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,
|
||||
@@ -403,24 +417,32 @@ export function FileManagerPage() {
|
||||
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 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" />}
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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,10 +148,28 @@ export function PlayerPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
const target = media?.id || id
|
||||
navigate(target ? `/media/${target}` : '/', { replace: true })
|
||||
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
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user