From cb539afd2af70959b45f7f0b3c88fd8bb95a0c19 Mon Sep 17 00:00:00 2001
From: truewhile <62226914+truewhile@users.noreply.github.com>
Date: Sun, 27 Sep 2026 13:04:28 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/handler/emby_items_handlers.go | 14 +-
.../handler/emby_show_episodes_season_test.go | 196 +++++++++++++-----
internal/service/emby_items_list.go | 4 +
internal/service/local_metadata_apply.go | 6 +-
internal/service/local_metadata_test.go | 47 +++++
5 files changed, 217 insertions(+), 50 deletions(-)
diff --git a/internal/handler/emby_items_handlers.go b/internal/handler/emby_items_handlers.go
index da74922..e111211 100644
--- a/internal/handler/emby_items_handlers.go
+++ b/internal/handler/emby_items_handlers.go
@@ -231,12 +231,24 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
// 不一致时把结果过滤成空集。
seasonIndex = nil
}
+ // Emby 客户端按 StartIndex/Limit 分页拉取分集,并在已收条目数小于
+ // TotalRecordCount 时继续请求下一页。忽略这两个参数会让客户端永远
+ // 停在第一页、反复重发同一请求(现象是分集列表一直加载不出来)。
+ limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "500"))
+ if limit <= 0 {
+ limit = 500
+ }
+ startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
+ if startIndex < 0 {
+ startIndex = 0
+ }
params := service.ItemsParams{
UserID: embyEffectiveUserID(c),
ParentID: parentID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
- Limit: 500,
+ Limit: limit,
+ StartIndex: startIndex,
SeasonIndex: seasonIndex,
}
out, err := svc.Emby.Items(c.Request.Context(), params)
diff --git a/internal/handler/emby_show_episodes_season_test.go b/internal/handler/emby_show_episodes_season_test.go
index a556ef5..4c2d24c 100644
--- a/internal/handler/emby_show_episodes_season_test.go
+++ b/internal/handler/emby_show_episodes_season_test.go
@@ -21,6 +21,31 @@ import (
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
// real clients send.
func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
+ t.Helper()
+ return embySeriesRouter(t, []model.Media{
+ {
+ Base: model.Base{ID: "s1e1"},
+ Title: "Test Show",
+ Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ Container: "mkv",
+ },
+ {
+ Base: model.Base{ID: "s2e1"},
+ Title: "Test Show",
+ Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
+ SeasonNum: 2,
+ EpisodeNum: 1,
+ Container: "mkv",
+ },
+ })
+}
+
+// embySeriesRouter builds an Emby-compatible router with a single tv library and
+// one series made of the given episode rows. Callers omit LibraryID: it is filled
+// in here once the library exists.
+func embySeriesRouter(t *testing.T, rows []model.Media) (*gin.Engine, string, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -48,27 +73,9 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
- for _, m := range []model.Media{
- {
- Base: model.Base{ID: "s1e1"},
- LibraryID: lib.ID,
- Title: "Test Show",
- Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
- SeasonNum: 1,
- EpisodeNum: 1,
- Container: "mkv",
- },
- {
- Base: model.Base{ID: "s2e1"},
- LibraryID: lib.ID,
- Title: "Test Show",
- Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
- SeasonNum: 2,
- EpisodeNum: 1,
- Container: "mkv",
- },
- } {
- if err := db.Create(&m).Error; err != nil {
+ for i := range rows {
+ rows[i].LibraryID = lib.ID
+ if err := db.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
@@ -104,7 +111,34 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
return router, secret, seriesID
}
-func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
+func fetchSeasonID(t *testing.T, router *gin.Engine, secret, seriesID string, index int) string {
+ t.Helper()
+ path := "/Shows/" + seriesID + "/Seasons"
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ var seasons struct {
+ Items []map[string]any `json:"Items"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
+ t.Fatalf("decode seasons: %v", err)
+ }
+ for _, s := range seasons.Items {
+ if got, ok := s["IndexNumber"].(float64); ok && int(got) == index {
+ if id, _ := s["Id"].(string); id != "" {
+ return id
+ }
+ }
+ }
+ t.Fatalf("season %d not found in %#v", index, seasons.Items)
+ return ""
+}
+
+func fetchEpisodeItems(t *testing.T, router *gin.Engine, secret, path string) ([]map[string]any, int) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
@@ -120,12 +154,18 @@ func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]s
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
- ids := make([]string, 0, len(payload.Items))
- for _, item := range payload.Items {
+ return payload.Items, int(payload.TotalRecordCount)
+}
+
+func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
+ t.Helper()
+ items, total := fetchEpisodeItems(t, router, secret, path)
+ ids := make([]string, 0, len(items))
+ for _, item := range items {
id, _ := item["Id"].(string)
ids = append(ids, id)
}
- return ids, int(payload.TotalRecordCount)
+ return ids, total
}
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
@@ -174,29 +214,7 @@ func TestEmbyShowEpisodesHonoursSeasonQueryParam(t *testing.T) {
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
router, secret, seriesID := embySeasonEpisodesRouter(t)
- seasonsPath := "/Shows/" + seriesID + "/Seasons"
- req := httptest.NewRequest(http.MethodGet, seasonsPath, nil)
- req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
- rec := httptest.NewRecorder()
- router.ServeHTTP(rec, req)
- if rec.Code != http.StatusOK {
- t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
- }
- var seasons struct {
- Items []map[string]any `json:"Items"`
- }
- if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
- t.Fatalf("decode seasons: %v", err)
- }
- var season1ID string
- for _, s := range seasons.Items {
- if index, ok := s["IndexNumber"].(float64); ok && int(index) == 1 {
- season1ID, _ = s["Id"].(string)
- }
- }
- if season1ID == "" {
- t.Fatalf("season 1 not found in %#v", seasons.Items)
- }
+ season1ID := fetchSeasonID(t, router, secret, seriesID, 1)
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
if len(ids) != 1 || ids[0] != "s1e1" {
@@ -209,3 +227,85 @@ func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
t.Fatalf("SeasonId+Season episodes = %#v, want [s1e1]", ids)
}
}
+
+// foldedDuplicateSeriesRows models a season where two files resolve to the same
+// episode number — 3月的狮子 S01E11 together with S01E11.5, which the episode
+// parser reads as S01E11 and stores as a second row for season 1 episode 2 here.
+func foldedDuplicateSeriesRows() []model.Media {
+ return []model.Media{
+ {
+ Base: model.Base{ID: "e1"},
+ Title: "三月的狮子",
+ Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E01.mkv",
+ SeasonNum: 1,
+ EpisodeNum: 1,
+ Container: "mkv",
+ },
+ {
+ Base: model.Base{ID: "e2"},
+ Title: "三月的狮子",
+ Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.mkv",
+ SeasonNum: 1,
+ EpisodeNum: 2,
+ Container: "mkv",
+ },
+ {
+ Base: model.Base{ID: "e2half"},
+ Title: "三月的狮子",
+ Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.5.mkv",
+ SeasonNum: 1,
+ EpisodeNum: 2,
+ Container: "mkv",
+ },
+ }
+}
+
+// TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent is the regression test
+// for the "第一季分集加载不出来" lock-up: two rows sharing one season/episode are
+// folded into a single Emby item with two MediaSources. TotalRecordCount used to
+// be computed before that fold, so clients that page until they have
+// TotalRecordCount items kept re-requesting the same page and never rendered the
+// season.
+func TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent(t *testing.T) {
+ router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
+ seasonID := fetchSeasonID(t, router, secret, seriesID, 1)
+
+ items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+seasonID)
+ if total != len(items) {
+ t.Fatalf("TotalRecordCount = %d but %d items returned, want them equal", total, len(items))
+ }
+ if total != 2 {
+ t.Fatalf("TotalRecordCount = %d, want 2 distinct episodes", total)
+ }
+ // The folded episode must still carry both files so clients can switch version.
+ versioned := 0
+ for _, item := range items {
+ if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 1 {
+ versioned++
+ }
+ }
+ if versioned != 1 {
+ t.Fatalf("items with multiple MediaSources = %d, want 1 (%#v)", versioned, items)
+ }
+}
+
+// TestEmbyShowEpisodesPagingAdvances pins that the client's StartIndex/Limit reach
+// the episode list. Ignoring them made every page identical, so a client paging
+// until it has TotalRecordCount items could never finish loading the season.
+func TestEmbyShowEpisodesPagingAdvances(t *testing.T) {
+ router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
+ base := "/Shows/" + seriesID + "/Episodes?Season=1"
+
+ first, total := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=0&Limit=1")
+ if total != 2 || len(first) != 1 {
+ t.Fatalf("page 1 = %#v (total=%d), want one item of two", first, total)
+ }
+ second, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=1&Limit=1")
+ if len(second) != 1 || second[0] == first[0] {
+ t.Fatalf("page 2 = %#v, want the remaining episode (page 1 = %#v)", second, first)
+ }
+ third, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=2&Limit=1")
+ if len(third) != 0 {
+ t.Fatalf("page 3 = %#v, want an empty page so clients stop paging", third)
+ }
+}
diff --git a/internal/service/emby_items_list.go b/internal/service/emby_items_list.go
index 963c9b9..df4435f 100644
--- a/internal/service/emby_items_list.go
+++ b/internal/service/emby_items_list.go
@@ -155,6 +155,10 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
}
return rows[i].CreatedAt.Before(rows[j].CreatedAt)
})
+ // 先折叠同集的多个版本再统计总数与分页,与 payloadsForMedia 内部保持同一步骤。
+ // 若按未折叠的行数报 TotalRecordCount(例如 S01E11 与 S01E11.5 两行折叠成一条),
+ // 客户端会认为还有一条没取到,反复请求下一页 —— 分集列表就会一直加载不出来。
+ rows = e.collapseMediaVersionRows(ctx, rows)
total := len(rows)
items, err := e.payloadsForMedia(ctx, pageSlice(rows, p.StartIndex, p.Limit), p.UserID)
if err != nil {
diff --git a/internal/service/local_metadata_apply.go b/internal/service/local_metadata_apply.go
index bd17d30..37502cb 100644
--- a/internal/service/local_metadata_apply.go
+++ b/internal/service/local_metadata_apply.go
@@ -68,7 +68,11 @@ func applyLocalEpisodeMetadata(m *model.Media, local *LocalMetadata) {
if local.EpisodeTitle != "" {
m.EpisodeTitle = local.EpisodeTitle
}
- if local.SeasonNum > 0 || local.EpisodeNum > 0 {
+ // tvshow.nfo 里常带 scraper 写的 -1 / -1
+ // 哨兵(表示「整剧级、不适用」)。负季号不是有效季号:一旦让它覆盖,该集会被
+ // 写成 -1,网页端按 seasonLabel(-1) 显示成「剧场版」。因此负值一律忽略,
+ // 保留文件名解析出来的季号(S00Exx 会被解析成第 0 季 = 特别篇)。
+ if local.SeasonNum >= 0 && (local.SeasonNum > 0 || local.EpisodeNum > 0) {
m.SeasonNum = local.SeasonNum
}
if local.EpisodeNum > 0 {
diff --git a/internal/service/local_metadata_test.go b/internal/service/local_metadata_test.go
index 320855e..39d957d 100644
--- a/internal/service/local_metadata_test.go
+++ b/internal/service/local_metadata_test.go
@@ -4,6 +4,8 @@ import (
"os"
"path/filepath"
"testing"
+
+ "github.com/truewhile/MeBox/internal/model"
)
func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) {
@@ -436,3 +438,48 @@ func TestReadLocalMetadataFindsLegacyImgPoster(t *testing.T) {
t.Fatalf("PosterURL = %q, want legacy .img poster %q", got.PosterURL, poster)
}
}
+
+// tvshow.nfo 里 scraper 写的 -1(整剧级"不适用"哨兵)不能覆盖
+// 文件名解析出的季号:一旦覆盖,该集会被写成 -1,网页端按 seasonLabel(-1) 显示成
+// 「剧场版」,Emby 端也只能落进特别篇。S00Exx 这类特别篇命名应保留第 0 季。
+func TestApplyLocalEpisodeMetadataIgnoresNegativeSeasonSentinel(t *testing.T) {
+ cases := []struct {
+ name string
+ parsed model.Media
+ local LocalMetadata
+ wantSeason int
+ wantEpisode int
+ }{
+ {
+ name: "show level sentinel keeps parsed season",
+ parsed: model.Media{SeasonNum: 2, EpisodeNum: 5},
+ local: LocalMetadata{SeasonNum: -1, EpisodeNum: 5, HasNFO: true},
+ wantSeason: 2,
+ wantEpisode: 5,
+ },
+ {
+ name: "special naming keeps season zero",
+ parsed: model.Media{SeasonNum: 0, EpisodeNum: 11},
+ local: LocalMetadata{SeasonNum: -1, EpisodeNum: 11, HasNFO: true},
+ wantSeason: 0,
+ wantEpisode: 11,
+ },
+ {
+ name: "positive episode season still applies",
+ parsed: model.Media{SeasonNum: 0, EpisodeNum: 11},
+ local: LocalMetadata{SeasonNum: 1, EpisodeNum: 11, HasNFO: true},
+ wantSeason: 1,
+ wantEpisode: 11,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ media := tc.parsed
+ local := tc.local
+ applyLocalMetadata(&media, &local)
+ if media.SeasonNum != tc.wantSeason || media.EpisodeNum != tc.wantEpisode {
+ t.Fatalf("season/episode = %d/%d, want %d/%d", media.SeasonNum, media.EpisodeNum, tc.wantSeason, tc.wantEpisode)
+ }
+ })
+ }
+}