refactor: split emby compatibility tests

This commit is contained in:
ShukeBta
2026-06-24 16:11:44 +08:00
parent e56eed0758
commit e898605f3a
4 changed files with 604 additions and 581 deletions
-581
View File
@@ -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{})
+218
View File
@@ -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)
}
}
@@ -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)
}
}
+109
View File
@@ -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)
}
}