mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
18e6cb40fd
Co-authored-by: Cursor <cursoragent@cursor.com>
314 lines
9.8 KiB
Go
314 lines
9.8 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/truewhile/MeBox/internal/config"
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
)
|
|
|
|
func TestTheatricalTitleVariants(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want []string
|
|
}{
|
|
{
|
|
input: "名侦探柯南 剧场版01 引爆摩天楼",
|
|
want: []string{"名侦探柯南 引爆摩天楼", "名侦探柯南 剧场版01 引爆摩天楼"},
|
|
},
|
|
{
|
|
input: "名侦探柯南 剧场版26 黑铁的鱼影",
|
|
want: []string{"名侦探柯南 黑铁的鱼影", "名侦探柯南 剧场版26 黑铁的鱼影"},
|
|
},
|
|
{
|
|
input: "鬼灭之刃 剧场版 无限列车篇",
|
|
want: []string{"鬼灭之刃 无限列车篇", "鬼灭之刃 剧场版 无限列车篇"},
|
|
},
|
|
{
|
|
input: "航海王 The Movie 黄金之城",
|
|
want: []string{"航海王 The Movie 黄金之城"},
|
|
},
|
|
{
|
|
input: "普通动漫 第01集",
|
|
want: []string{"普通动漫 第01集"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := theatricalTitleVariants(tc.input)
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("theatricalTitleVariants(%q) = %v, want %v", tc.input, got, tc.want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tc.want[i] {
|
|
t.Fatalf("theatricalTitleVariants(%q)[%d] = %q, want %q", tc.input, i, got[i], tc.want[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMetadataMatchCompatibilityForTheatricalFeatures(t *testing.T) {
|
|
animeMatch := &Match{MediaType: "anime"}
|
|
|
|
if metadataMatchCompatibleWithType("movie", animeMatch) {
|
|
t.Fatal("ordinary movie lookups must not accept anime matches")
|
|
}
|
|
if !metadataMatchCompatibleWithTheatrical("movie", true, animeMatch) {
|
|
t.Fatal("theatrical movie lookup should accept an anime provider match")
|
|
}
|
|
if metadataMatchCompatibleWithTheatrical("movie", false, animeMatch) {
|
|
t.Fatal("non-theatrical movie lookup must not accept an anime provider match")
|
|
}
|
|
}
|
|
|
|
func TestMediaLooksLikeTheatricalFeatureIgnoresStaleEpisodeIdentity(t *testing.T) {
|
|
media := &model.Media{
|
|
Title: "未命名",
|
|
Path: `/media/anime/超人高校生们/剧场版/超人高校生们 剧场版.mkv`,
|
|
SeasonNum: 1,
|
|
EpisodeNum: 1,
|
|
}
|
|
if !mediaLooksLikeTheatricalFeature(media) {
|
|
t.Fatal("theatrical path should override stale persisted season/episode fields")
|
|
}
|
|
|
|
namedFolder := &model.Media{
|
|
Title: "Eiga Yurukyan 2022 Bluray",
|
|
Path: `/media/anime/摇曳露营△ (2018)/摇曳露营△ 剧场版 (2022)/Eiga.Yurukyan.2022.Bluray.mkv`,
|
|
}
|
|
if !mediaLooksLikeTheatricalFeature(namedFolder) {
|
|
t.Fatal("a named theatrical folder with a year should be detected from the full path")
|
|
}
|
|
|
|
realEpisode := &model.Media{
|
|
Title: "剧场版制作幕后",
|
|
Path: `/media/anime/超人高校生们/Season 01/超人高校生们.S01E01.mkv`,
|
|
SeasonNum: 1,
|
|
EpisodeNum: 1,
|
|
}
|
|
if mediaLooksLikeTheatricalFeature(realEpisode) {
|
|
t.Fatal("an actual episode marker in the path must remain episodic")
|
|
}
|
|
}
|
|
|
|
func TestScrapeQueryCandidatesForAnimeTheatricalMix(t *testing.T) {
|
|
lib := &model.Library{
|
|
Path: `/media/anime`,
|
|
Type: "anime",
|
|
}
|
|
|
|
// 1. 同目录下包含剧场版01
|
|
theatricalMedia := &model.Media{
|
|
Title: "名侦探柯南 剧场版01 引爆摩天楼",
|
|
Path: `/media/anime/名侦探柯南/名侦探柯南 剧场版01 引爆摩天楼.mkv`,
|
|
}
|
|
candidates := scrapeQueryCandidates(theatricalMedia, lib)
|
|
if len(candidates) == 0 {
|
|
t.Fatal("scrapeQueryCandidates returned no candidates for theatrical media")
|
|
}
|
|
if candidates[0] != "名侦探柯南 引爆摩天楼" {
|
|
t.Fatalf("first candidate for theatrical media = %q, want cleaned movie title '名侦探柯南 引爆摩天楼'; all=%v", candidates[0], candidates)
|
|
}
|
|
|
|
// 2. 剧场版放在以剧场版命名的子目录
|
|
subfolderTheatricalMedia := &model.Media{
|
|
Title: "无限列车篇",
|
|
Path: `/media/anime/鬼灭之刃/剧场版/无限列车篇.mkv`,
|
|
}
|
|
subCandidates := scrapeQueryCandidates(subfolderTheatricalMedia, lib)
|
|
if len(subCandidates) == 0 {
|
|
t.Fatal("scrapeQueryCandidates returned no candidates for subfolder theatrical media")
|
|
}
|
|
foundCombined := false
|
|
for _, c := range subCandidates {
|
|
if c == "鬼灭之刃 无限列车篇" {
|
|
foundCombined = true
|
|
break
|
|
}
|
|
}
|
|
if !foundCombined {
|
|
t.Fatalf("candidates for subfolder theatrical did not contain '鬼灭之刃 无限列车篇': %v", subCandidates)
|
|
}
|
|
|
|
// 3. 混合放置的普通剧集分集不应受剧场版影响
|
|
episodeMedia := &model.Media{
|
|
Title: "名侦探柯南 - S01E01",
|
|
Path: `/media/anime/名侦探柯南/名侦探柯南 - S01E01.mp4`,
|
|
SeasonNum: 1,
|
|
EpisodeNum: 1,
|
|
}
|
|
epCandidates := scrapeQueryCandidates(episodeMedia, lib)
|
|
if len(epCandidates) == 0 {
|
|
t.Fatal("scrapeQueryCandidates returned no candidates for episode media")
|
|
}
|
|
if epCandidates[0] != "名侦探柯南" {
|
|
t.Fatalf("first candidate for tv episode = %q, want series folder title '名侦探柯南'", epCandidates[0])
|
|
}
|
|
}
|
|
|
|
func TestEnrichOneAnimeMixedEpisodesAndTheatrical(t *testing.T) {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/search/tv":
|
|
q := r.URL.Query().Get("query")
|
|
if q == "鬼灭之刃" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"results": []map[string]any{{
|
|
"id": 85937,
|
|
"name": "鬼灭之刃",
|
|
"original_name": "鬼滅の刃",
|
|
"overview": "大正时期、日本...",
|
|
"first_air_date": "2019-04-06",
|
|
}},
|
|
})
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
|
|
case "/search/movie":
|
|
q := r.URL.Query().Get("query")
|
|
if q == "鬼灭之刃 无限列车篇" || q == "鬼灭之刃 剧场版 无限列车篇" {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"results": []map[string]any{{
|
|
"id": 635302,
|
|
"title": "鬼灭之刃 剧场版 无限列车篇",
|
|
"original_title": "劇場版「鬼滅の刃」無限列車編",
|
|
"overview": "在结束了蝴蝶屋的修业之后...",
|
|
"release_date": "2020-10-16",
|
|
}},
|
|
})
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}})
|
|
case "/tv/85937":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": 85937,
|
|
"name": "鬼灭之刃",
|
|
"original_name": "鬼滅の刃",
|
|
"first_air_date": "2019-04-06",
|
|
})
|
|
case "/movie/635302":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": 635302,
|
|
"title": "鬼灭之刃 剧场版 无限列车篇",
|
|
"original_title": "劇場版「鬼滅の刃」無限列車編",
|
|
"release_date": "2020-10-16",
|
|
})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
repos := newOrganizerTestRepo(t)
|
|
cfg := &config.Config{}
|
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
|
log := zap.NewNop()
|
|
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
|
|
|
|
lib := model.Library{Name: "动漫", Path: `/media/anime`, Type: "anime", Enabled: true}
|
|
if err := repos.DB.Create(&lib).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 1. 同一目录下的 TV 剧集
|
|
tvEpisode := model.Media{
|
|
LibraryID: lib.ID,
|
|
Title: "鬼灭之刃 S01E01",
|
|
Path: `/media/anime/鬼灭之刃/Season 01/鬼灭之刃 - S01E01.mkv`,
|
|
SeasonNum: 1,
|
|
EpisodeNum: 1,
|
|
ScrapeStatus: "pending",
|
|
}
|
|
if err := repos.DB.Create(&tvEpisode).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 2. 同一目录下的剧场版文件
|
|
movieMedia := model.Media{
|
|
LibraryID: lib.ID,
|
|
Title: "鬼灭之刃 剧场版 无限列车篇",
|
|
Path: `/media/anime/鬼灭之刃/鬼灭之刃 剧场版 无限列车篇.mkv`,
|
|
ScrapeStatus: "pending",
|
|
}
|
|
if err := repos.DB.Create(&movieMedia).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 刮削剧集
|
|
if err := scraper.EnrichOne(t.Context(), &tvEpisode); err != nil {
|
|
t.Fatalf("enrich tv episode: %v", err)
|
|
}
|
|
gotTV, err := repos.Media.FindByID(t.Context(), tvEpisode.ID)
|
|
if err != nil || gotTV == nil {
|
|
t.Fatalf("load tv episode: %v", err)
|
|
}
|
|
if gotTV.ScrapeStatus != "matched" || gotTV.TMDbID != 85937 {
|
|
t.Fatalf("tv episode scrape mismatch: status=%s, tmdb_id=%d", gotTV.ScrapeStatus, gotTV.TMDbID)
|
|
}
|
|
|
|
// 刮削剧场版
|
|
if err := scraper.EnrichOne(t.Context(), &movieMedia); err != nil {
|
|
t.Fatalf("enrich movie: %v", err)
|
|
}
|
|
gotMovie, err := repos.Media.FindByID(t.Context(), movieMedia.ID)
|
|
if err != nil || gotMovie == nil {
|
|
t.Fatalf("load movie: %v", err)
|
|
}
|
|
if gotMovie.ScrapeStatus != "matched" || gotMovie.TMDbID != 635302 {
|
|
t.Fatalf("theatrical movie scrape mismatch: status=%s, tmdb_id=%d, want 635302", gotMovie.ScrapeStatus, gotMovie.TMDbID)
|
|
}
|
|
if gotMovie.Title != "鬼灭之刃 剧场版 无限列车篇" {
|
|
t.Fatalf("theatrical movie title = %q, want '鬼灭之刃 剧场版 无限列车篇'", gotMovie.Title)
|
|
}
|
|
}
|
|
|
|
func TestClassifyAnimeTheatricalMovie(t *testing.T) {
|
|
categories := map[string]string{
|
|
"animation_movie": "动画电影",
|
|
"jp_anime": "日番",
|
|
"chinese_movie": "华语电影",
|
|
}
|
|
tests := []struct {
|
|
title string
|
|
mediaType string
|
|
want string
|
|
}{
|
|
{
|
|
title: "名侦探柯南 剧场版01 引爆摩天楼",
|
|
mediaType: "movie",
|
|
want: "动画电影",
|
|
},
|
|
{
|
|
title: "鬼灭之刃 剧场版 无限列车篇",
|
|
mediaType: "movie",
|
|
want: "动画电影",
|
|
},
|
|
{
|
|
title: "鬼灭之刃 S01E01",
|
|
mediaType: "tv",
|
|
want: "日番",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
input := mediaClassifyInput{
|
|
MediaType: tc.mediaType,
|
|
Title: tc.title,
|
|
Languages: []string{"JA"},
|
|
Countries: []string{"JP"},
|
|
Genres: []string{"Animation"},
|
|
}
|
|
got := classifyMediaCategory(input, categories)
|
|
if got != tc.want {
|
|
t.Fatalf("classifyMediaCategory(%q, %q) = %q, want %q", tc.title, tc.mediaType, got, tc.want)
|
|
}
|
|
}
|
|
}
|