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
+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
}