diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 2631990..668d627 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -3,7 +3,6 @@ package service import ( "context" "testing" - "time" "go.uber.org/zap" @@ -13,586 +12,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) -func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(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) - } - for _, media := range []model.Media{ - { - Base: model.Base{ID: "ep-1"}, - LibraryID: lib.ID, - Title: "间谍过家家", - OriginalName: "SPY×FAMILY", - EpisodeTitle: "第 1 集", - Path: `F:\downloads\日番\剧集\间谍过家家\Season 02\间谍过家家 - S02E01.mkv`, - PosterURL: `F:\poster.jpg`, - SeasonNum: 2, - EpisodeNum: 1, - }, - { - Base: model.Base{ID: "ep-2"}, - LibraryID: lib.ID, - Title: "间谍过家家", - OriginalName: "SPY×FAMILY", - EpisodeTitle: "第 2 集", - Path: `F:\downloads\日番\剧集\间谍过家家\Season 02\间谍过家家 - S02E02.mkv`, - PosterURL: `F:\poster.jpg`, - SeasonNum: 2, - EpisodeNum: 2, - }, - } { - 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 { - t.Fatalf("expected one series card, got %#v", rootItems) - } - seriesID := rootItems[0]["Id"].(string) - if rootItems[0]["Type"] != "Series" || rootItems[0]["IsFolder"] != true || rootItems[0]["Name"] != "间谍过家家" { - t.Fatalf("unexpected series payload: %#v", rootItems[0]) - } - - seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: seriesID, Limit: 50}) - if err != nil { - t.Fatalf("series items: %v", err) - } - seasonItems := seasons["Items"].([]map[string]any) - if len(seasonItems) != 1 || seasonItems[0]["Type"] != "Season" || seasonItems[0]["IndexNumber"] != 2 { - t.Fatalf("unexpected seasons: %#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("season episodes: %v", err) - } - episodeItems := episodes["Items"].([]map[string]any) - if len(episodeItems) != 2 || episodeItems[0]["Type"] != "Episode" || episodeItems[0]["Name"] != "第 1 集" { - t.Fatalf("unexpected episodes: %#v", episodeItems) - } - if episodeItems[0]["SeriesId"] != seriesID || episodeItems[0]["ParentId"] != seasonItems[0]["Id"] { - t.Fatalf("episode hierarchy not linked: %#v", episodeItems[0]) - } - - latest, err := svc.LatestItems(t.Context(), "user-1", lib.ID, 10) - if err != nil { - t.Fatalf("latest items: %v", err) - } - if len(latest) != 1 || latest[0]["Type"] != "Series" { - t.Fatalf("latest should be grouped by series: %#v", latest) - } - - playback, err := svc.PlaybackInfo(t.Context(), seriesID, "user-1") - if err != nil { - t.Fatalf("series playback fallback: %v", err) - } - sources := playback["MediaSources"].([]map[string]any) - if sources[0]["Id"] != "ep-1" { - t.Fatalf("series playback should fall back to first episode: %#v", sources) - } - if sources[0]["DirectStreamUrl"] != "/Videos/ep-1/stream.mkv" { - t.Fatalf("playback should use Emby-compatible stream URL: %#v", sources[0]) - } -} - -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} - cloud := model.Library{Name: "OpenList · 国产电影", Path: BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := svc.repo.Library.Create(t.Context(), lib); err != nil { - t.Fatalf("create library: %v", err) - } - } - for _, media := range []model.Media{ - { - Base: model.Base{ID: "local-movie", CreatedAt: time.Now().Add(-time.Minute)}, - LibraryID: local.ID, - Title: "本地版本", - Path: `/media/国产电影/local.mkv`, - }, - { - Base: model.Base{ID: "cloud-movie", CreatedAt: time.Now()}, - LibraryID: cloud.ID, - Title: "云盘版本", - Path: `cloud://openlist/国产电影/cloud.mkv`, - }, - } { - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - } - - latest, err := svc.LatestItems(t.Context(), "user-1", local.ID, 10) - if err != nil { - t.Fatalf("latest items: %v", err) - } - if len(latest) != 2 { - t.Fatalf("latest items = %#v, want local and merged cloud media", latest) - } - if latest[0]["Id"] != "cloud-movie" || latest[1]["Id"] != "local-movie" { - t.Fatalf("latest order/items = %#v, want cloud then local", latest) - } -} - -func TestEmbyVirtualSeriesArtworkUsesListCache(t *testing.T) { - svc := newTestEmbyService(t) - lib := model.Library{Name: "番剧", Path: `/media/anime`, 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: "ep-1"}, - LibraryID: lib.ID, - Title: "剑来", - Path: `/media/anime/剑来/Season 01/剑来 - S01E01.mkv`, - PosterURL: `/poster.jpg`, - BackdropURL: `/backdrop.jpg`, - SeasonNum: 1, - 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) - } - items := root["Items"].([]map[string]any) - seriesID := items[0]["Id"].(string) - - cancelled, cancel := context.WithCancel(t.Context()) - cancel() - poster, err := svc.ImageURL(cancelled, seriesID, "Primary") - if err != nil { - t.Fatalf("image url from cache: %v", err) - } - if poster != "/poster.jpg" { - t.Fatalf("poster = %q, want cached poster", poster) - } - backdrop, err := svc.ImageURL(cancelled, seriesID, "Backdrop") - if err != nil { - t.Fatalf("backdrop url from cache: %v", err) - } - if backdrop != "/backdrop.jpg" { - t.Fatalf("backdrop = %q, want cached backdrop", backdrop) - } -} - -func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) { - svc := newTestEmbyService(t) - lib := model.Library{Name: "OpenList · 国漫", Path: `cloud://openlist/国漫`, Type: "anime", Enabled: true} - if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - for _, media := range []model.Media{ - { - Base: model.Base{ID: "cloud-ep-1"}, - LibraryID: lib.ID, - Title: "04", - Path: `cloud://openlist/国漫/剑来/第二季/04.mkv`, - SeasonNum: 2, - EpisodeNum: 4, - }, - { - Base: model.Base{ID: "cloud-ep-2"}, - LibraryID: lib.ID, - Title: "05", - Path: `cloud://openlist/国漫/剑来/第二季/05.mkv`, - SeasonNum: 2, - EpisodeNum: 5, - }, - } { - 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) - } - items := root["Items"].([]map[string]any) - if len(items) != 1 || items[0]["Type"] != "Series" || items[0]["Name"] != "剑来" { - t.Fatalf("cloud anime should be grouped as one series named 剑来, got %#v", items) - } -} - -func TestEmbyMovieLibrarySeasonNumbersStayMovies(t *testing.T) { - svc := newTestEmbyService(t) - lib := model.Library{Name: "动画电影", Path: `/media/movies/animation`, Type: "Movie", 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: "movie-with-episode-numbers"}, - LibraryID: lib.ID, - Title: "Movie Mistaken S01E01", - Path: `/media/movies/animation/Movie.Mistaken.S01E01.mkv`, - PosterURL: `/poster.jpg`, - SeasonNum: 1, - EpisodeNum: 1, - } - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - - out, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, IncludeItemTypes: []string{"Movie"}, Limit: 50}) - if err != nil { - t.Fatalf("items: %v", err) - } - items := out["Items"].([]map[string]any) - if len(items) != 1 { - t.Fatalf("movie library item filtered out by season numbers: %#v", out) - } - if items[0]["Type"] != "Movie" || items[0]["ParentId"] != lib.ID { - t.Fatalf("movie library item should stay Movie, got %#v", items[0]) - } - tags := items[0]["ImageTags"].(map[string]string) - if tags["Primary"] == "" { - t.Fatalf("movie poster should expose Primary image tag: %#v", items[0]) - } - - episodes, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, IncludeItemTypes: []string{"Episode"}, Limit: 50}) - if err != nil { - t.Fatalf("episode query: %v", err) - } - if len(episodes["Items"].([]map[string]any)) != 0 { - t.Fatalf("movie library should not expose movies as episodes, got %#v", episodes) - } - - item, err := svc.Item(t.Context(), media.ID, "user-1") - if err != nil { - t.Fatalf("item: %v", err) - } - if item["Type"] != "Movie" || item["ParentId"] != lib.ID { - t.Fatalf("direct item should stay Movie, got %#v", item) - } - - rootMovies, err := svc.Items(t.Context(), ItemsParams{IncludeItemTypes: []string{"Movie"}, Recursive: true, Limit: 50}) - if err != nil { - t.Fatalf("root movie query: %v", err) - } - rootItems := rootMovies["Items"].([]map[string]any) - if len(rootItems) != 1 || rootItems[0]["Id"] != media.ID || rootItems[0]["Type"] != "Movie" { - t.Fatalf("root movie query should include movie-library item despite season numbers, got %#v", rootItems) - } - - rootEpisodes, err := svc.Items(t.Context(), ItemsParams{IncludeItemTypes: []string{"Episode"}, Recursive: true, Limit: 50}) - if err != nil { - t.Fatalf("root episode query: %v", err) - } - if len(rootEpisodes["Items"].([]map[string]any)) != 0 { - t.Fatalf("root episode query should not expose movie-library item, got %#v", rootEpisodes) - } -} - -func TestEmbyMovieLibraryFiltersMisplacedSeriesPaths(t *testing.T) { - svc := newTestEmbyService(t) - lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true} - if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - show := model.Media{ - Base: model.Base{ID: "misplaced-show"}, - LibraryID: lib.ID, - Title: "错放剧集", - Path: `/media/movies/国产剧/错放剧集/Season 01/错放剧集 - S01E01.mkv`, - SeasonNum: 1, - EpisodeNum: 1, - } - movie := model.Media{ - Base: model.Base{ID: "movie"}, - LibraryID: lib.ID, - Title: "普通电影", - Path: `/media/movies/普通电影.2026.mkv`, - } - if err := svc.repo.DB.Create(&show).Error; err != nil { - t.Fatalf("create show: %v", err) - } - if err := svc.repo.DB.Create(&movie).Error; err != nil { - t.Fatalf("create movie: %v", err) - } - - out, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, IncludeItemTypes: []string{"Movie"}, Limit: 50}) - if err != nil { - t.Fatalf("items: %v", err) - } - items := out["Items"].([]map[string]any) - if len(items) != 1 || items[0]["Id"] != movie.ID { - t.Fatalf("movie library should filter misplaced series paths, got %#v", items) - } - - item, err := svc.Item(t.Context(), show.ID, "user-1") - if err != nil { - t.Fatalf("direct item: %v", err) - } - if item["Type"] != "Episode" { - t.Fatalf("misplaced series path should be typed as Episode directly, got %#v", item) - } -} - -// TestEmbyMovieLibraryGroupsEpisodicContentIntoSeries 验证方案 B: 电影类型库里 -// 混入的「剧集结构」内容(如整合成剧集的剧场版/合集动画, 路径含 Season/SxxE) -// 在常规浏览(未指定 IncludeItemTypes)时应聚成 Series 卡片, 而不是以散装单集 -// (Episode)漏出; 同库里真正的电影仍按 Movie 显示。两类并存于同一电影库视图。 -func TestEmbyMovieLibraryGroupsEpisodicContentIntoSeries(t *testing.T) { - svc := newTestEmbyService(t) - lib := model.Library{Name: "动画电影", Path: `/media/动画电影`, Type: "movie", Enabled: true} - if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { - t.Fatalf("create library: %v", err) - } - base := time.Now() - // 剧集结构内容: 同一部「高达剧场版」的两集, 单集 tmdb 各不相同(模拟 NFO 单集 id 污染)。 - rows := []model.Media{ - { - Base: model.Base{ID: "gundam-e13", CreatedAt: base.Add(2 * time.Minute)}, - LibraryID: lib.ID, - Title: "高达剧场版", - Path: `/media/动画电影/高达剧场版/Season 01/高达剧场版 - S01E13.mkv`, - PosterURL: `/poster.jpg`, - TMDbID: 4375419, - SeasonNum: 1, - EpisodeNum: 13, - }, - { - Base: model.Base{ID: "gundam-e14", CreatedAt: base.Add(3 * time.Minute)}, - LibraryID: lib.ID, - Title: "高达剧场版", - Path: `/media/动画电影/高达剧场版/Season 01/高达剧场版 - S01E14.mkv`, - PosterURL: `/poster.jpg`, - TMDbID: 4375461, - SeasonNum: 1, - EpisodeNum: 14, - }, - // 真正的电影。 - { - Base: model.Base{ID: "real-movie", CreatedAt: base.Add(1 * time.Minute)}, - LibraryID: lib.ID, - Title: "普通动画电影", - Path: `/media/动画电影/普通动画电影.2024.mkv`, - PosterURL: `/poster.jpg`, - }, - } - for i := range rows { - if err := svc.repo.DB.Create(&rows[i]).Error; err != nil { - t.Fatalf("create media: %v", err) - } - } - - out, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50}) - if err != nil { - t.Fatalf("items: %v", err) - } - items := out["Items"].([]map[string]any) - - var seriesCount, movieCount, episodeCount int - var seriesPayload map[string]any - for _, item := range items { - switch item["Type"] { - case "Series": - seriesCount++ - seriesPayload = item - case "Movie": - movieCount++ - case "Episode": - episodeCount++ - } - } - if episodeCount != 0 { - t.Fatalf("movie library must not leak flat episodes, got %d episode items: %#v", episodeCount, items) - } - if seriesCount != 1 { - t.Fatalf("episodic content should collapse into exactly one Series card, got %d: %#v", seriesCount, items) - } - if movieCount != 1 { - t.Fatalf("real movie should stay as one Movie item, got %d: %#v", movieCount, items) - } - // 两集 tmdb 不同, 但按路径剧名聚成同一 Series, 集数应为 2。 - if got := seriesPayload["RecursiveItemCount"]; got != 2 { - t.Fatalf("series should contain both episodes despite differing tmdb ids, got RecursiveItemCount=%v", got) - } - - // Series 卡片可下钻: 解析其 season → episodes。 - seriesID, _ := seriesPayload["Id"].(string) - if seriesID == "" { - t.Fatalf("series payload missing Id: %#v", seriesPayload) - } - drill, err := svc.Items(t.Context(), ItemsParams{ParentID: seriesID, Recursive: true, Limit: 50}) - if err != nil { - t.Fatalf("series drill-down: %v", err) - } - drillItems := drill["Items"].([]map[string]any) - if len(drillItems) != 2 { - t.Fatalf("series drill-down should list both episodes, got %#v", drillItems) - } -} - -func TestEmbyMergedLocalCloudMovieVersionsShareMediaSources(t *testing.T) { - svc := newTestEmbyService(t) - local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true} - cloud := model.Library{Name: "OpenList · 国产电影", Path: BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true} - for _, lib := range []*model.Library{&local, &cloud} { - if err := svc.repo.Library.Create(t.Context(), lib); err != nil { - t.Fatalf("create library: %v", err) - } - } - for _, media := range []model.Media{ - { - Base: model.Base{ID: "local-version", CreatedAt: time.Now()}, - LibraryID: local.ID, - Title: "流浪地球", - Year: 2019, - Path: `/media/国产电影/流浪地球.2019.1080p.mkv`, - Container: "mkv", - Width: 1920, - }, - { - Base: model.Base{ID: "cloud-version", CreatedAt: time.Now().Add(time.Minute)}, - LibraryID: cloud.ID, - Title: "流浪地球", - Year: 2019, - Path: `cloud://openlist/国产电影/流浪地球.2019.2160p.mkv`, - Container: "mkv", - STRMURL: "https://example.invalid/cloud", - Width: 3840, - }, - } { - if err := svc.repo.DB.Create(&media).Error; err != nil { - t.Fatalf("create media: %v", err) - } - } - - items, err := svc.Items(t.Context(), ItemsParams{ParentID: local.ID, IncludeItemTypes: []string{"Movie"}, Recursive: true, Limit: 10}) - if err != nil { - t.Fatalf("items: %v", err) - } - rows := items["Items"].([]map[string]any) - if len(rows) != 1 { - t.Fatalf("merged local/cloud versions should show as one item, got %#v", rows) - } - if rows[0]["Id"] != "local-version" { - t.Fatalf("local media should be the representative item, got %#v", rows[0]) - } - sources := rows[0]["MediaSources"].([]map[string]any) - if len(sources) != 2 { - t.Fatalf("merged item should expose two media sources, got %#v", sources) - } - - playback, err := svc.PlaybackInfo(t.Context(), "local-version", "user-1") - if err != nil { - t.Fatalf("playback: %v", err) - } - playSources := playback["MediaSources"].([]map[string]any) - if len(playSources) != 2 { - t.Fatalf("playback should expose local and cloud versions, got %#v", playSources) - } -} - func newTestEmbyService(t *testing.T) *EmbyService { t.Helper() db := newServiceTestDB(t, &model.Library{}, &model.Series{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}, &model.User{}, &model.Setting{}) diff --git a/internal/service/emby_movie_library_test.go b/internal/service/emby_movie_library_test.go new file mode 100644 index 0000000..be158e8 --- /dev/null +++ b/internal/service/emby_movie_library_test.go @@ -0,0 +1,218 @@ +package service + +import ( + "testing" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestEmbyMovieLibrarySeasonNumbersStayMovies(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "动画电影", Path: `/media/movies/animation`, Type: "Movie", 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: "movie-with-episode-numbers"}, + LibraryID: lib.ID, + Title: "Movie Mistaken S01E01", + Path: `/media/movies/animation/Movie.Mistaken.S01E01.mkv`, + PosterURL: `/poster.jpg`, + SeasonNum: 1, + EpisodeNum: 1, + } + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + out, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, IncludeItemTypes: []string{"Movie"}, Limit: 50}) + if err != nil { + t.Fatalf("items: %v", err) + } + items := out["Items"].([]map[string]any) + if len(items) != 1 { + t.Fatalf("movie library item filtered out by season numbers: %#v", out) + } + if items[0]["Type"] != "Movie" || items[0]["ParentId"] != lib.ID { + t.Fatalf("movie library item should stay Movie, got %#v", items[0]) + } + tags := items[0]["ImageTags"].(map[string]string) + if tags["Primary"] == "" { + t.Fatalf("movie poster should expose Primary image tag: %#v", items[0]) + } + + episodes, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, IncludeItemTypes: []string{"Episode"}, Limit: 50}) + if err != nil { + t.Fatalf("episode query: %v", err) + } + if len(episodes["Items"].([]map[string]any)) != 0 { + t.Fatalf("movie library should not expose movies as episodes, got %#v", episodes) + } + + item, err := svc.Item(t.Context(), media.ID, "user-1") + if err != nil { + t.Fatalf("item: %v", err) + } + if item["Type"] != "Movie" || item["ParentId"] != lib.ID { + t.Fatalf("direct item should stay Movie, got %#v", item) + } + + rootMovies, err := svc.Items(t.Context(), ItemsParams{IncludeItemTypes: []string{"Movie"}, Recursive: true, Limit: 50}) + if err != nil { + t.Fatalf("root movie query: %v", err) + } + rootItems := rootMovies["Items"].([]map[string]any) + if len(rootItems) != 1 || rootItems[0]["Id"] != media.ID || rootItems[0]["Type"] != "Movie" { + t.Fatalf("root movie query should include movie-library item despite season numbers, got %#v", rootItems) + } + + rootEpisodes, err := svc.Items(t.Context(), ItemsParams{IncludeItemTypes: []string{"Episode"}, Recursive: true, Limit: 50}) + if err != nil { + t.Fatalf("root episode query: %v", err) + } + if len(rootEpisodes["Items"].([]map[string]any)) != 0 { + t.Fatalf("root episode query should not expose movie-library item, got %#v", rootEpisodes) + } +} + +func TestEmbyMovieLibraryFiltersMisplacedSeriesPaths(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + show := model.Media{ + Base: model.Base{ID: "misplaced-show"}, + LibraryID: lib.ID, + Title: "错放剧集", + Path: `/media/movies/国产剧/错放剧集/Season 01/错放剧集 - S01E01.mkv`, + SeasonNum: 1, + EpisodeNum: 1, + } + movie := model.Media{ + Base: model.Base{ID: "movie"}, + LibraryID: lib.ID, + Title: "普通电影", + Path: `/media/movies/普通电影.2026.mkv`, + } + if err := svc.repo.DB.Create(&show).Error; err != nil { + t.Fatalf("create show: %v", err) + } + if err := svc.repo.DB.Create(&movie).Error; err != nil { + t.Fatalf("create movie: %v", err) + } + + out, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, IncludeItemTypes: []string{"Movie"}, Limit: 50}) + if err != nil { + t.Fatalf("items: %v", err) + } + items := out["Items"].([]map[string]any) + if len(items) != 1 || items[0]["Id"] != movie.ID { + t.Fatalf("movie library should filter misplaced series paths, got %#v", items) + } + + item, err := svc.Item(t.Context(), show.ID, "user-1") + if err != nil { + t.Fatalf("direct item: %v", err) + } + if item["Type"] != "Episode" { + t.Fatalf("misplaced series path should be typed as Episode directly, got %#v", item) + } +} + +// TestEmbyMovieLibraryGroupsEpisodicContentIntoSeries 验证方案 B: 电影类型库里 +// 混入的「剧集结构」内容(如整合成剧集的剧场版/合集动画, 路径含 Season/SxxE) +// 在常规浏览(未指定 IncludeItemTypes)时应聚成 Series 卡片, 而不是以散装单集 +// (Episode)漏出; 同库里真正的电影仍按 Movie 显示。两类并存于同一电影库视图。 +func TestEmbyMovieLibraryGroupsEpisodicContentIntoSeries(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "动画电影", Path: `/media/动画电影`, Type: "movie", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + base := time.Now() + // 剧集结构内容: 同一部「高达剧场版」的两集, 单集 tmdb 各不相同(模拟 NFO 单集 id 污染)。 + rows := []model.Media{ + { + Base: model.Base{ID: "gundam-e13", CreatedAt: base.Add(2 * time.Minute)}, + LibraryID: lib.ID, + Title: "高达剧场版", + Path: `/media/动画电影/高达剧场版/Season 01/高达剧场版 - S01E13.mkv`, + PosterURL: `/poster.jpg`, + TMDbID: 4375419, + SeasonNum: 1, + EpisodeNum: 13, + }, + { + Base: model.Base{ID: "gundam-e14", CreatedAt: base.Add(3 * time.Minute)}, + LibraryID: lib.ID, + Title: "高达剧场版", + Path: `/media/动画电影/高达剧场版/Season 01/高达剧场版 - S01E14.mkv`, + PosterURL: `/poster.jpg`, + TMDbID: 4375461, + SeasonNum: 1, + EpisodeNum: 14, + }, + // 真正的电影。 + { + Base: model.Base{ID: "real-movie", CreatedAt: base.Add(1 * time.Minute)}, + LibraryID: lib.ID, + Title: "普通动画电影", + Path: `/media/动画电影/普通动画电影.2024.mkv`, + PosterURL: `/poster.jpg`, + }, + } + for i := range rows { + if err := svc.repo.DB.Create(&rows[i]).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + + out, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50}) + if err != nil { + t.Fatalf("items: %v", err) + } + items := out["Items"].([]map[string]any) + + var seriesCount, movieCount, episodeCount int + var seriesPayload map[string]any + for _, item := range items { + switch item["Type"] { + case "Series": + seriesCount++ + seriesPayload = item + case "Movie": + movieCount++ + case "Episode": + episodeCount++ + } + } + if episodeCount != 0 { + t.Fatalf("movie library must not leak flat episodes, got %d episode items: %#v", episodeCount, items) + } + if seriesCount != 1 { + t.Fatalf("episodic content should collapse into exactly one Series card, got %d: %#v", seriesCount, items) + } + if movieCount != 1 { + t.Fatalf("real movie should stay as one Movie item, got %d: %#v", movieCount, items) + } + // 两集 tmdb 不同, 但按路径剧名聚成同一 Series, 集数应为 2。 + if got := seriesPayload["RecursiveItemCount"]; got != 2 { + t.Fatalf("series should contain both episodes despite differing tmdb ids, got RecursiveItemCount=%v", got) + } + + // Series 卡片可下钻: 解析其 season → episodes。 + seriesID, _ := seriesPayload["Id"].(string) + if seriesID == "" { + t.Fatalf("series payload missing Id: %#v", seriesPayload) + } + drill, err := svc.Items(t.Context(), ItemsParams{ParentID: seriesID, Recursive: true, Limit: 50}) + if err != nil { + t.Fatalf("series drill-down: %v", err) + } + drillItems := drill["Items"].([]map[string]any) + if len(drillItems) != 2 { + t.Fatalf("series drill-down should list both episodes, got %#v", drillItems) + } +} diff --git a/internal/service/emby_series_hierarchy_test.go b/internal/service/emby_series_hierarchy_test.go new file mode 100644 index 0000000..cfb4741 --- /dev/null +++ b/internal/service/emby_series_hierarchy_test.go @@ -0,0 +1,277 @@ +package service + +import ( + "context" + "testing" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(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) + } + for _, media := range []model.Media{ + { + Base: model.Base{ID: "ep-1"}, + LibraryID: lib.ID, + Title: "间谍过家家", + OriginalName: "SPY×FAMILY", + EpisodeTitle: "第 1 集", + Path: `F:\downloads\日番\剧集\间谍过家家\Season 02\间谍过家家 - S02E01.mkv`, + PosterURL: `F:\poster.jpg`, + SeasonNum: 2, + EpisodeNum: 1, + }, + { + Base: model.Base{ID: "ep-2"}, + LibraryID: lib.ID, + Title: "间谍过家家", + OriginalName: "SPY×FAMILY", + EpisodeTitle: "第 2 集", + Path: `F:\downloads\日番\剧集\间谍过家家\Season 02\间谍过家家 - S02E02.mkv`, + PosterURL: `F:\poster.jpg`, + SeasonNum: 2, + EpisodeNum: 2, + }, + } { + 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 { + t.Fatalf("expected one series card, got %#v", rootItems) + } + seriesID := rootItems[0]["Id"].(string) + if rootItems[0]["Type"] != "Series" || rootItems[0]["IsFolder"] != true || rootItems[0]["Name"] != "间谍过家家" { + t.Fatalf("unexpected series payload: %#v", rootItems[0]) + } + + seasons, err := svc.Items(t.Context(), ItemsParams{ParentID: seriesID, Limit: 50}) + if err != nil { + t.Fatalf("series items: %v", err) + } + seasonItems := seasons["Items"].([]map[string]any) + if len(seasonItems) != 1 || seasonItems[0]["Type"] != "Season" || seasonItems[0]["IndexNumber"] != 2 { + t.Fatalf("unexpected seasons: %#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("season episodes: %v", err) + } + episodeItems := episodes["Items"].([]map[string]any) + if len(episodeItems) != 2 || episodeItems[0]["Type"] != "Episode" || episodeItems[0]["Name"] != "第 1 集" { + t.Fatalf("unexpected episodes: %#v", episodeItems) + } + if episodeItems[0]["SeriesId"] != seriesID || episodeItems[0]["ParentId"] != seasonItems[0]["Id"] { + t.Fatalf("episode hierarchy not linked: %#v", episodeItems[0]) + } + + latest, err := svc.LatestItems(t.Context(), "user-1", lib.ID, 10) + if err != nil { + t.Fatalf("latest items: %v", err) + } + if len(latest) != 1 || latest[0]["Type"] != "Series" { + t.Fatalf("latest should be grouped by series: %#v", latest) + } + + playback, err := svc.PlaybackInfo(t.Context(), seriesID, "user-1") + if err != nil { + t.Fatalf("series playback fallback: %v", err) + } + sources := playback["MediaSources"].([]map[string]any) + if sources[0]["Id"] != "ep-1" { + t.Fatalf("series playback should fall back to first episode: %#v", sources) + } + if sources[0]["DirectStreamUrl"] != "/Videos/ep-1/stream.mkv" { + t.Fatalf("playback should use Emby-compatible stream URL: %#v", sources[0]) + } +} + +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 TestEmbyVirtualSeriesArtworkUsesListCache(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "番剧", Path: `/media/anime`, 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: "ep-1"}, + LibraryID: lib.ID, + Title: "剑来", + Path: `/media/anime/剑来/Season 01/剑来 - S01E01.mkv`, + PosterURL: `/poster.jpg`, + BackdropURL: `/backdrop.jpg`, + SeasonNum: 1, + 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) + } + items := root["Items"].([]map[string]any) + seriesID := items[0]["Id"].(string) + + cancelled, cancel := context.WithCancel(t.Context()) + cancel() + poster, err := svc.ImageURL(cancelled, seriesID, "Primary") + if err != nil { + t.Fatalf("image url from cache: %v", err) + } + if poster != "/poster.jpg" { + t.Fatalf("poster = %q, want cached poster", poster) + } + backdrop, err := svc.ImageURL(cancelled, seriesID, "Backdrop") + if err != nil { + t.Fatalf("backdrop url from cache: %v", err) + } + if backdrop != "/backdrop.jpg" { + t.Fatalf("backdrop = %q, want cached backdrop", backdrop) + } +} + +func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "OpenList · 国漫", Path: `cloud://openlist/国漫`, Type: "anime", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + for _, media := range []model.Media{ + { + Base: model.Base{ID: "cloud-ep-1"}, + LibraryID: lib.ID, + Title: "04", + Path: `cloud://openlist/国漫/剑来/第二季/04.mkv`, + SeasonNum: 2, + EpisodeNum: 4, + }, + { + Base: model.Base{ID: "cloud-ep-2"}, + LibraryID: lib.ID, + Title: "05", + Path: `cloud://openlist/国漫/剑来/第二季/05.mkv`, + SeasonNum: 2, + EpisodeNum: 5, + }, + } { + 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) + } + items := root["Items"].([]map[string]any) + if len(items) != 1 || items[0]["Type"] != "Series" || items[0]["Name"] != "剑来" { + t.Fatalf("cloud anime should be grouped as one series named 剑来, got %#v", items) + } +} diff --git a/internal/service/emby_versions_test.go b/internal/service/emby_versions_test.go new file mode 100644 index 0000000..73874a2 --- /dev/null +++ b/internal/service/emby_versions_test.go @@ -0,0 +1,109 @@ +package service + +import ( + "testing" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestEmbyLatestItemsIncludesMergedCloudMovieLibrary(t *testing.T) { + svc := newTestEmbyService(t) + local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true} + cloud := model.Library{Name: "OpenList · 国产电影", Path: BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true} + for _, lib := range []*model.Library{&local, &cloud} { + if err := svc.repo.Library.Create(t.Context(), lib); err != nil { + t.Fatalf("create library: %v", err) + } + } + for _, media := range []model.Media{ + { + Base: model.Base{ID: "local-movie", CreatedAt: time.Now().Add(-time.Minute)}, + LibraryID: local.ID, + Title: "本地版本", + Path: `/media/国产电影/local.mkv`, + }, + { + Base: model.Base{ID: "cloud-movie", CreatedAt: time.Now()}, + LibraryID: cloud.ID, + Title: "云盘版本", + Path: `cloud://openlist/国产电影/cloud.mkv`, + }, + } { + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + + latest, err := svc.LatestItems(t.Context(), "user-1", local.ID, 10) + if err != nil { + t.Fatalf("latest items: %v", err) + } + if len(latest) != 2 { + t.Fatalf("latest items = %#v, want local and merged cloud media", latest) + } + if latest[0]["Id"] != "cloud-movie" || latest[1]["Id"] != "local-movie" { + t.Fatalf("latest order/items = %#v, want cloud then local", latest) + } +} + +func TestEmbyMergedLocalCloudMovieVersionsShareMediaSources(t *testing.T) { + svc := newTestEmbyService(t) + local := model.Library{Name: "国产电影", Path: `/media/国产电影`, Type: "movie", Enabled: true} + cloud := model.Library{Name: "OpenList · 国产电影", Path: BuildCloudLibraryPath("openlist", "/国产电影", "/国产电影"), Type: "movie", Enabled: true} + for _, lib := range []*model.Library{&local, &cloud} { + if err := svc.repo.Library.Create(t.Context(), lib); err != nil { + t.Fatalf("create library: %v", err) + } + } + for _, media := range []model.Media{ + { + Base: model.Base{ID: "local-version", CreatedAt: time.Now()}, + LibraryID: local.ID, + Title: "流浪地球", + Year: 2019, + Path: `/media/国产电影/流浪地球.2019.1080p.mkv`, + Container: "mkv", + Width: 1920, + }, + { + Base: model.Base{ID: "cloud-version", CreatedAt: time.Now().Add(time.Minute)}, + LibraryID: cloud.ID, + Title: "流浪地球", + Year: 2019, + Path: `cloud://openlist/国产电影/流浪地球.2019.2160p.mkv`, + Container: "mkv", + STRMURL: "https://example.invalid/cloud", + Width: 3840, + }, + } { + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + + items, err := svc.Items(t.Context(), ItemsParams{ParentID: local.ID, IncludeItemTypes: []string{"Movie"}, Recursive: true, Limit: 10}) + if err != nil { + t.Fatalf("items: %v", err) + } + rows := items["Items"].([]map[string]any) + if len(rows) != 1 { + t.Fatalf("merged local/cloud versions should show as one item, got %#v", rows) + } + if rows[0]["Id"] != "local-version" { + t.Fatalf("local media should be the representative item, got %#v", rows[0]) + } + sources := rows[0]["MediaSources"].([]map[string]any) + if len(sources) != 2 { + t.Fatalf("merged item should expose two media sources, got %#v", sources) + } + + playback, err := svc.PlaybackInfo(t.Context(), "local-version", "user-1") + if err != nil { + t.Fatalf("playback: %v", err) + } + playSources := playback["MediaSources"].([]map[string]any) + if len(playSources) != 2 { + t.Fatalf("playback should expose local and cloud versions, got %#v", playSources) + } +}