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
}