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