mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 19:46:38 +08:00
fix media playback and library workflows
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user