mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +08:00
优化
This commit is contained in:
@@ -231,12 +231,24 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// 不一致时把结果过滤成空集。
|
// 不一致时把结果过滤成空集。
|
||||||
seasonIndex = nil
|
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{
|
params := service.ItemsParams{
|
||||||
UserID: embyEffectiveUserID(c),
|
UserID: embyEffectiveUserID(c),
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
IncludeItemTypes: []string{"Episode"},
|
IncludeItemTypes: []string{"Episode"},
|
||||||
Recursive: true,
|
Recursive: true,
|
||||||
Limit: 500,
|
Limit: limit,
|
||||||
|
StartIndex: startIndex,
|
||||||
SeasonIndex: seasonIndex,
|
SeasonIndex: seasonIndex,
|
||||||
}
|
}
|
||||||
out, err := svc.Emby.Items(c.Request.Context(), params)
|
out, err := svc.Emby.Items(c.Request.Context(), params)
|
||||||
|
|||||||
@@ -21,6 +21,31 @@ import (
|
|||||||
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
|
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
|
||||||
// real clients send.
|
// real clients send.
|
||||||
func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
|
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()
|
t.Helper()
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
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 {
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
t.Fatalf("create library: %v", err)
|
t.Fatalf("create library: %v", err)
|
||||||
}
|
}
|
||||||
for _, m := range []model.Media{
|
for i := range rows {
|
||||||
{
|
rows[i].LibraryID = lib.ID
|
||||||
Base: model.Base{ID: "s1e1"},
|
if err := db.Create(&rows[i]).Error; err != nil {
|
||||||
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 {
|
|
||||||
t.Fatalf("create media: %v", err)
|
t.Fatalf("create media: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +111,34 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
|
|||||||
return router, secret, seriesID
|
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()
|
t.Helper()
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
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 {
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
t.Fatalf("decode %s: %v", path, err)
|
t.Fatalf("decode %s: %v", path, err)
|
||||||
}
|
}
|
||||||
ids := make([]string, 0, len(payload.Items))
|
return payload.Items, int(payload.TotalRecordCount)
|
||||||
for _, item := range payload.Items {
|
}
|
||||||
|
|
||||||
|
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)
|
id, _ := item["Id"].(string)
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
}
|
}
|
||||||
return ids, int(payload.TotalRecordCount)
|
return ids, total
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
|
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
|
||||||
@@ -174,29 +214,7 @@ func TestEmbyShowEpisodesHonoursSeasonQueryParam(t *testing.T) {
|
|||||||
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
|
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
|
||||||
router, secret, seriesID := embySeasonEpisodesRouter(t)
|
router, secret, seriesID := embySeasonEpisodesRouter(t)
|
||||||
|
|
||||||
seasonsPath := "/Shows/" + seriesID + "/Seasons"
|
season1ID := fetchSeasonID(t, router, secret, seriesID, 1)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
|
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
|
||||||
if len(ids) != 1 || ids[0] != "s1e1" {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,6 +155,10 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
|
|||||||
}
|
}
|
||||||
return rows[i].CreatedAt.Before(rows[j].CreatedAt)
|
return rows[i].CreatedAt.Before(rows[j].CreatedAt)
|
||||||
})
|
})
|
||||||
|
// 先折叠同集的多个版本再统计总数与分页,与 payloadsForMedia 内部保持同一步骤。
|
||||||
|
// 若按未折叠的行数报 TotalRecordCount(例如 S01E11 与 S01E11.5 两行折叠成一条),
|
||||||
|
// 客户端会认为还有一条没取到,反复请求下一页 —— 分集列表就会一直加载不出来。
|
||||||
|
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||||
total := len(rows)
|
total := len(rows)
|
||||||
items, err := e.payloadsForMedia(ctx, pageSlice(rows, p.StartIndex, p.Limit), p.UserID)
|
items, err := e.payloadsForMedia(ctx, pageSlice(rows, p.StartIndex, p.Limit), p.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ func applyLocalEpisodeMetadata(m *model.Media, local *LocalMetadata) {
|
|||||||
if local.EpisodeTitle != "" {
|
if local.EpisodeTitle != "" {
|
||||||
m.EpisodeTitle = local.EpisodeTitle
|
m.EpisodeTitle = local.EpisodeTitle
|
||||||
}
|
}
|
||||||
if local.SeasonNum > 0 || local.EpisodeNum > 0 {
|
// tvshow.nfo 里常带 scraper 写的 <season>-1</season> / <episode>-1</episode>
|
||||||
|
// 哨兵(表示「整剧级、不适用」)。负季号不是有效季号:一旦让它覆盖,该集会被
|
||||||
|
// 写成 -1,网页端按 seasonLabel(-1) 显示成「剧场版」。因此负值一律忽略,
|
||||||
|
// 保留文件名解析出来的季号(S00Exx 会被解析成第 0 季 = 特别篇)。
|
||||||
|
if local.SeasonNum >= 0 && (local.SeasonNum > 0 || local.EpisodeNum > 0) {
|
||||||
m.SeasonNum = local.SeasonNum
|
m.SeasonNum = local.SeasonNum
|
||||||
}
|
}
|
||||||
if local.EpisodeNum > 0 {
|
if local.EpisodeNum > 0 {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) {
|
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)
|
t.Fatalf("PosterURL = %q, want legacy .img poster %q", got.PosterURL, poster)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tvshow.nfo 里 scraper 写的 <season>-1</season>(整剧级"不适用"哨兵)不能覆盖
|
||||||
|
// 文件名解析出的季号:一旦覆盖,该集会被写成 -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user