package service import ( "os" "path/filepath" "testing" "github.com/truewhile/MeBox/internal/model" ) func TestReadLocalMetadataDropsResolutionArtifactEpisode(t *testing.T) { // 刮削曾把分辨率 1920x1080 误读成 S20E108 并回写进边车 NFO; // 重扫时必须忽略这个伪季集号,否则旧文件会把错误身份灌回 DB。 root := t.TempDir() showDir := filepath.Join(root, "彼得·格里尔的贤者时间") if err := os.MkdirAll(showDir, 0o755); err != nil { t.Fatal(err) } mediaPath := filepath.Join(showDir, "[UHA-WINGS][Peter Grill to Kenja no Jikan][01][BDRIP 1920x1080 HEVC-YUV420P10 FLAC].strm") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(nfoPath(mediaPath), []byte(` 第 108 集 彼得·格里尔的贤者时间 20 108 99080 `), 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, root, true) if err != nil { t.Fatal(err) } if got == nil { t.Fatal("metadata is nil") } if got.SeasonNum != 0 || got.EpisodeNum != 0 { t.Fatalf("resolution artifact season/episode not dropped: s=%d e=%d", got.SeasonNum, got.EpisodeNum) } if got.EpisodeTitle != "" { t.Fatalf("generated episode title not dropped: %q", got.EpisodeTitle) } if got.Title != "彼得·格里尔的贤者时间" { t.Fatalf("series title = %q, want 彼得·格里尔的贤者时间", got.Title) } } func TestReadLocalMovieMetadata(t *testing.T) { dir := t.TempDir() mediaPath := filepath.Join(dir, "Inception.2010.mkv") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } nfo := ` 盗梦空间 Inception 2010 梦境盗窃。 8.8 27205 科幻 动作 ` if err := os.WriteFile(nfoPath(mediaPath), []byte(nfo), 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, dir, false) if err != nil { t.Fatal(err) } if got == nil || got.Title != "盗梦空间" || got.OriginalName != "Inception" || got.Year != 2010 || got.TMDbID != 27205 { t.Fatalf("unexpected metadata: %+v", got) } if got.Genres != "科幻,动作" { t.Fatalf("genres = %q", got.Genres) } } func TestReadLocalEpisodeMetadataMergesShowAndEpisode(t *testing.T) { root := t.TempDir() showDir := filepath.Join(root, "Show") seasonDir := filepath.Join(showDir, "Season 02") if err := os.MkdirAll(seasonDir, 0o755); err != nil { t.Fatal(err) } mediaPath := filepath.Join(seasonDir, "Show - EP03.mkv") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`正确剧名2024123`), 0o644); err != nil { t.Fatal(err) } // 单集 NFO 携带【单集级】tmdb id(4375419)与单集名(第三集):二者都不得 // 覆盖整剧字段,否则同剧各集 id/原名互不相同会被拆成多张卡。 if err := os.WriteFile(nfoPath(mediaPath), []byte(`第三集23本集简介4375419`), 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, root, true) if err != nil { t.Fatal(err) } if got == nil || got.Title != "正确剧名" || got.SeasonNum != 2 || got.EpisodeNum != 3 { t.Fatalf("unexpected metadata: %+v", got) } // 单集名不得写入整剧原名(分组键)。 if got.OriginalName != "" { t.Fatalf("episode title must not pollute OriginalName, got %q", got.OriginalName) } if got.EpisodeTitle != "第三集" { t.Fatalf("episode title metadata = %q, want 第三集", got.EpisodeTitle) } // 单集级简介按集回填;整剧 tmdb 仍取 tvshow.nfo 的 123,单集 id 不得覆盖。 if got.Overview != "本集简介" || got.TMDbID != 123 { t.Fatalf("episode/show merge failed: %+v", got) } } func TestReadLocalEpisodeMetadataWithoutShowTitleDoesNotUseEpisodeTitleAsSeries(t *testing.T) { root := t.TempDir() showDir := filepath.Join(root, "哈哈哈哈哈") seasonDir := filepath.Join(showDir, "Season 06") if err := os.MkdirAll(seasonDir, 0o755); err != nil { t.Fatal(err) } mediaPath := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E11.mkv") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(nfoPath(mediaPath), []byte(`第 11 集6114375419`), 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, root, true) if err != nil { t.Fatal(err) } if got == nil { t.Fatal("metadata is nil") } if got.Title != "" { t.Fatalf("episode title must not become series title, got %q", got.Title) } if got.EpisodeTitle != "第 11 集" || got.SeasonNum != 6 || got.EpisodeNum != 11 { t.Fatalf("episode metadata not preserved: %+v", got) } if got.TMDbID != 0 { t.Fatalf("episode-level tmdb id must not become series tmdb id, got %d", got.TMDbID) } } func TestMergeEpisodeMetadataKeepsExistingSeasonWhenEpisodeNFOOmitsIt(t *testing.T) { dst := &LocalMetadata{Title: "哈哈哈哈哈", SeasonNum: 6} episodeDoc := &nfoDocument{Title: "第 11 集", Episode: 11} episode := metadataFromDoc(episodeDoc, "", true) mergeEpisodeMetadata(dst, episode, episodeDoc) if dst.SeasonNum != 6 || dst.EpisodeNum != 11 { t.Fatalf("episode NFO without season should keep parsed season, got %+v", dst) } } func TestReadLocalEpisodeMetadataIgnoresNoneNumericFields(t *testing.T) { root := t.TempDir() showDir := filepath.Join(root, "链锯人 总集篇 (2025)") seasonDir := filepath.Join(showDir, "Season 1") if err := os.MkdirAll(seasonDir, 0o755); err != nil { t.Fatal(err) } mediaPath := filepath.Join(seasonDir, "链锯人 总集篇 - S01E01.mkv") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`链锯人 总集篇2025`), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(nfoPath(mediaPath), []byte(`第 1 集NoneNoneNone`), 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, root, true) if err != nil { t.Fatal(err) } if got == nil || got.Title != "链锯人 总集篇" || got.Year != 2025 { t.Fatalf("unexpected metadata: %+v", got) } if got.SeasonNum != 0 || got.EpisodeNum != 0 || got.Rating != 0 { t.Fatalf("None numeric fields should parse as zero, got %+v", got) } } func TestReadLocalVarietyMetadataUsesLocalArtwork(t *testing.T) { root := t.TempDir() showDir := filepath.Join(root, "哈哈哈哈哈") seasonDir := filepath.Join(showDir, "Season 06") if err := os.MkdirAll(seasonDir, 0o755); err != nil { t.Fatal(err) } mediaPath := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17.mkv") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(showDir, "哈哈哈哈哈.nfo"), []byte(`哈哈哈哈哈综艺`), 0o644); err != nil { t.Fatal(err) } showPoster := filepath.Join(showDir, "poster.jpg") if err := os.WriteFile(showPoster, []byte("jpg"), 0o644); err != nil { t.Fatal(err) } episodeThumb := filepath.Join(seasonDir, "哈哈哈哈哈 - S06E17-thumb.jpg") if err := os.WriteFile(episodeThumb, []byte("jpg"), 0o644); err != nil { t.Fatal(err) } backdrop := filepath.Join(showDir, "fanart.jpg") if err := os.WriteFile(backdrop, []byte("jpg"), 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, root, true) if err != nil { t.Fatal(err) } if got == nil { t.Fatal("metadata is nil") } if got.Title != "哈哈哈哈哈" || got.Genres != "综艺" { t.Fatalf("unexpected metadata: %+v", got) } if got.PosterURL != showPoster { t.Fatalf("PosterURL = %q, want show poster %q, not episode thumb %q", got.PosterURL, showPoster, episodeThumb) } if got.BackdropURL != episodeThumb { t.Fatalf("BackdropURL = %q, want episode thumb %q, not series backdrop %q", got.BackdropURL, episodeThumb, backdrop) } } func TestReadLocalMetadataPrioritizesPosterOverThumbAndStills(t *testing.T) { root := t.TempDir() mediaPath := filepath.Join(root, "Movie.mkv") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } thumb := filepath.Join(root, "Movie-thumb.jpg") if err := os.WriteFile(thumb, []byte("jpg"), 0o644); err != nil { t.Fatal(err) } still := filepath.Join(root, "Movie-still.jpg") if err := os.WriteFile(still, []byte("jpg"), 0o644); err != nil { t.Fatal(err) } poster := filepath.Join(root, "poster.jpg") if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, root, false) if err != nil { t.Fatal(err) } if got == nil || got.PosterURL != poster { t.Fatalf("PosterURL = %q, want poster %q", got.PosterURL, poster) } } func TestReadLocalMetadataIgnoresActorAndStillArtworkOnly(t *testing.T) { root := t.TempDir() mediaPath := filepath.Join(root, "SSIS-001.mp4") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } for _, name := range []string{"actor.jpg", "sample.jpg", "fanart.jpg"} { if err := os.WriteFile(filepath.Join(root, name), []byte("jpg"), 0o644); err != nil { t.Fatal(err) } } got, err := ReadLocalMetadata(mediaPath, root, false) if err != nil { t.Fatal(err) } if got == nil || got.PosterURL != "" || got.BackdropURL == "" { t.Fatalf("expected backdrop-only metadata without actor/still poster, got %+v", got) } } func TestReadLocalMetadataWithoutNFOStillFindsArtwork(t *testing.T) { root := t.TempDir() mediaPath := filepath.Join(root, "Movie.mkv") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } poster := filepath.Join(root, "Movie-poster.jpg") if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, root, false) if err != nil { t.Fatal(err) } if got == nil || got.PosterURL != poster { t.Fatalf("unexpected artwork metadata: %+v", got) } } // TestReadLocalMetadataRecoversFromTruncatedShowNFO mirrors a real-world issue: // some anime tvshow.nfo files are truncated mid-URL (unexpected EOF). Before the // fix this discarded the whole series, leaving episodes pending with per-episode // titles and no poster. The recoverable fields (title/year) and the matching // episode NFO + local artwork must still be applied. func TestReadLocalMetadataRecoversFromTruncatedShowNFO(t *testing.T) { root := t.TempDir() showDir := filepath.Join(root, "夏日重现 (2022)") seasonDir := filepath.Join(showDir, "Season 1") if err := os.MkdirAll(seasonDir, 0o755); err != nil { t.Fatal(err) } mediaPath := filepath.Join(seasonDir, "S01E01.mkv") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } // tvshow.nfo cut off inside a URL, like the broken real files. truncated := ` 夏日重现 サマータイムレンダ 2022 听闻自己青梅竹马死讯。 https://image.tmdb.org/t/p/original/2koyWLm6iVn5OTEExTjKzVms5Iz.jpg https://image.tmdb.org/t/p/original/p2eZlGwd8OjkWpwD2hSoBiIlHBZ.jpg再见了夏日11`), 0o644); err != nil { t.Fatal(err) } // Local artwork next to the show folder. poster := filepath.Join(showDir, "poster.jpg") if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, root, true) if err != nil { t.Fatalf("ReadLocalMetadata returned error on truncated show NFO: %v", err) } if got == nil { t.Fatal("metadata is nil; truncated show NFO discarded the series") } // The recovered show title takes precedence over the episode title. if got.Title != "夏日重现" { t.Fatalf("Title = %q, want recovered show title 夏日重现", got.Title) } if got.Year != 2022 { t.Fatalf("Year = %d, want 2022", got.Year) } if got.EpisodeTitle != "再见了夏日" || got.SeasonNum != 1 || got.EpisodeNum != 1 { t.Fatalf("episode metadata not preserved: %+v", got) } // Prior to the fix the episode metadata was dropped entirely; the poster comes // from the local poster.jpg next to the show. if got.PosterURL != poster { t.Fatalf("PosterURL = %q, want local poster %q", got.PosterURL, poster) } // A truncated show NFO must not be treated as an authoritative match by // itself; since the episode NFO is valid we still mark it matched so the // recovered series title participates in grouping. if !got.HasNFO { t.Fatalf("HasNFO = false, want true (episode NFO is valid)") } } // TestReadLocalMetadataKeepsArtworkOnlyWhenNoUsableNFO verifies that when a show // NFO is truncated AND yields no recoverable fields, we still fall back to local // artwork instead of returning an error. func TestReadLocalMetadataArtworkFallbackOnGarbageShowNFO(t *testing.T) { root := t.TempDir() showDir := filepath.Join(root, "Some Show") seasonDir := filepath.Join(showDir, "Season 1") if err := os.MkdirAll(seasonDir, 0o755); err != nil { t.Fatal(err) } mediaPath := filepath.Join(seasonDir, "S01E01.mkv") if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil { t.Fatal(err) } // Severely truncated: no recoverable title/fields at all. if err := os.WriteFile(filepath.Join(showDir, "tvshow.nfo"), []byte(`半截`), 0o644); err != nil { t.Fatal(err) } // Episode-level backdrop sits next to the episode file. backdrop := filepath.Join(seasonDir, "S01E01-backdrop.jpg") if err := os.WriteFile(backdrop, []byte("jpg"), 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, root, true) if err != nil { t.Fatalf("ReadLocalMetadata should not error when NFO is unusable: %v", err) } if got == nil { t.Fatal("metadata is nil; artwork fallback missing") } if got.HasNFO { t.Fatalf("HasNFO = true, want false for unusable NFO") } if got.BackdropURL != backdrop { t.Fatalf("expected episode artwork fallback, got %+v", got) } } func TestReadLocalMetadataFindsLegacyImgPoster(t *testing.T) { root := t.TempDir() mediaPath := filepath.Join(root, "Jigokuraku.S01E14.mkv.strm") poster := filepath.Join(root, "Jigokuraku.S01E14-poster.img") if err := os.WriteFile(mediaPath, []byte("https://example.test/video"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(poster, testJPEG, 0o644); err != nil { t.Fatal(err) } got, err := ReadLocalMetadata(mediaPath, root, true) if err != nil { t.Fatal(err) } if got == nil || 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) } }) } }