package service import ( "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "sync/atomic" "testing" "time" "github.com/glebarez/sqlite" "go.uber.org/zap" "gorm.io/gorm" "github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) { scraper := &ScraperService{} match, err := scraper.manualRequestMatch(t.Context(), ManualScrapeRequest{ Source: "douban", Title: "手动选择的电影", DoubanID: "1234567", Year: 2026, }) if err != nil { t.Fatal(err) } if match.Title != "手动选择的电影" || match.DoubanID != "1234567" || match.Year != 2026 { t.Fatalf("fallback match = %#v", match) } } func TestManualAdultMatchUsesSelectedMetaTubeDetailsAndRealBackdrop(t *testing.T) { var detailCalls atomic.Int32 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/movies/AVE/94600" { http.NotFound(w, r) return } detailCalls.Add(1) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(struct { Data MetaTubeMovieInfo `json:"data"` }{ Data: MetaTubeMovieInfo{ ID: "94600", Number: "CWPBD-138", Title: "Selected title", Provider: "AVE", CoverURL: "https://example.com/poster.jpg", PreviewImages: []string{"https://example.com/backdrop.jpg"}, }, }) })) defer upstream.Close() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil { t.Fatal(err) } repos := repository.New(db) if err := repos.Setting.Set(t.Context(), "adult.scraper.engine", "metatube"); err != nil { t.Fatal(err) } if err := repos.Setting.Set(t.Context(), "adult.scraper.metatube_server", upstream.URL); err != nil { t.Fatal(err) } adult := NewAdultProvider(zap.NewNop(), nil, repos) scraper := &ScraperService{adult: adult} match, err := scraper.manualRequestMatch(t.Context(), ManualScrapeRequest{ Source: "adult", MediaType: "adult", Title: "CWPBD-138 Selected title", OriginalName: "CWPBD-138", PosterURL: "https://example.com/search-cover.jpg", BackdropURL: "https://example.com/search-cover.jpg", DoubanID: "94600", TheTVDBID: "AVE", NSFW: true, }) if err != nil { t.Fatal(err) } if detailCalls.Load() != 1 { t.Fatalf("selected MetaTube detail calls = %d, want 1", detailCalls.Load()) } wantPoster := upstream.URL + "/v1/images/primary/AVE/94600?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg" if match.PosterURL != wantPoster { t.Fatalf("selected poster = %q, want %q", match.PosterURL, wantPoster) } wantBackdrop := upstream.URL + "/v1/images/backdrop/AVE/94600?quality=90" if match.BackdropURL != wantBackdrop { t.Fatalf("backdrop = %q, want %q", match.BackdropURL, wantBackdrop) } } func TestParsePositiveIDStringAcceptsProviderPrefixes(t *testing.T) { cases := map[string]string{ "12345": "12345", "tmdb:12345": "12345", "[tmdbid-12345]": "12345", "douban:67890": "67890", "{douban=67890}": "67890", "thetvdb:24680": "24680", } for input, want := range cases { got, ok := parsePositiveIDString(input) if !ok || got != want { t.Fatalf("parsePositiveIDString(%q) = %q,%v; want %q,true", input, got, ok, want) } } if got, ok := parsePositiveIDString("tmdb:not-a-number"); ok || got != "" { t.Fatalf("parsePositiveIDString invalid = %q,%v; want empty,false", got, ok) } if got, ok := parseProviderIDString("[tmdbid-12345]", "douban"); ok || got != "" { t.Fatalf("parseProviderIDString provider mismatch = %q,%v; want empty,false", got, ok) } } func TestManualSearchReturnsTMDbCandidatePage(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Path != "/search/movie" { http.NotFound(w, r) return } _ = json.NewEncoder(w).Encode(map[string]any{ "results": []map[string]any{ { "id": 101, "title": "错误的同名电影", "poster_path": "/wrong.jpg", "release_date": "2021-01-01", "vote_average": 5.1, "genre_ids": []int{18}, "backdrop_path": "/wrong-backdrop.jpg", }, { "id": 202, "title": "正确的同名电影", "poster_path": "/right.jpg", "release_date": "2021-08-01", "vote_average": 8.2, "genre_ids": []int{28}, "backdrop_path": "/right-backdrop.jpg", }, }, }) })) defer upstream.Close() db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil { t.Fatal(err) } repos := repository.New(db) 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/movie", Type: "movie", Enabled: true} if err := repos.DB.Create(&lib).Error; err != nil { t.Fatal(err) } media := model.Media{LibraryID: lib.ID, Title: "同名电影", Path: "/media/movie/同名电影.mkv"} if err := repos.DB.Create(&media).Error; err != nil { t.Fatal(err) } results, err := scraper.ManualSearch(t.Context(), &media, "同名电影", "tmdb", "movie") if err != nil { t.Fatal(err) } if len(results) != 2 || results[0].TMDbID != 101 || results[1].TMDbID != 202 { t.Fatalf("manual TMDb candidates = %#v", results) } } func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) { var queries []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { queries = append(queries, r.URL.Query().Get("query")) w.Header().Set("Content-Type", "application/json") if r.URL.Path != "/search/movie" { http.NotFound(w, r) return } if !strings.EqualFold(r.URL.Query().Get("query"), "inception") { _ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}}) return } _ = json.NewEncoder(w).Encode(map[string]any{ "results": []map[string]any{{ "id": 27205, "title": "Inception", "overview": "A thief enters dreams.", "poster_path": "/inception.jpg", "release_date": "2010-07-16", "vote_average": 8.4, "original_title": "Inception", }}, }) })) defer upstream.Close() db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil { t.Fatal(err) } repos := repository.New(db) 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: "Movies", Path: `/media/movies`, Type: "movie", Enabled: true} if err := repos.DB.Create(&lib).Error; err != nil { t.Fatal(err) } media := model.Media{ LibraryID: lib.ID, Title: "00000", Path: `/media/movies/Inception (2010)/BDMV/STREAM/00000.m2ts`, } results, err := scraper.ManualSearch(t.Context(), &media, "00000", "tmdb", "movie") if err != nil { t.Fatal(err) } if len(results) != 1 || results[0].TMDbID != 27205 { t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries) } if len(queries) < 2 || queries[0] != "00000" || !strings.EqualFold(queries[len(queries)-1], "inception") { t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries) } } func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) { var paths []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/search/tv": _ = json.NewEncoder(w).Encode(map[string]any{"results": []any{}}) case "/search/movie": _ = json.NewEncoder(w).Encode(map[string]any{ "results": []map[string]any{{ "id": 808, "title": "正义女神", "poster_path": "/movie.jpg", "release_date": "2024-01-01", "vote_average": 7.1, "backdrop_path": "/movie-backdrop.jpg", }}, }) default: http.NotFound(w, r) } })) defer upstream.Close() db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil { t.Fatal(err) } repos := repository.New(db) 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/tv`, Type: "tv", Enabled: true} if err := repos.DB.Create(&lib).Error; err != nil { t.Fatal(err) } media := model.Media{LibraryID: lib.ID, Title: "正义女神", Path: `/media/tv/正义女神.mkv`} if err := repos.DB.Create(&media).Error; err != nil { t.Fatal(err) } results, err := scraper.ManualSearch(t.Context(), &media, "正义女神", "tmdb", "tv") if err != nil { t.Fatal(err) } if len(results) != 1 || results[0].TMDbID != 808 || results[0].MediaType != "movie" { t.Fatalf("manual TMDb fallback results=%#v, paths=%v", results, paths) } if len(paths) < 2 || paths[0] != "/search/tv" || paths[1] != "/search/movie" { t.Fatalf("tmdb search paths=%v, want tv first then movie fallback", paths) } } func TestManualSearchAnimeTheatricalPrefersTMDbMovie(t *testing.T) { var paths []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/search/movie": _ = json.NewEncoder(w).Encode(map[string]any{ "results": []map[string]any{{ "id": 635302, "title": "鬼灭之刃 剧场版 无限列车篇", "original_title": "劇場版「鬼滅の刃」無限列車編", "release_date": "2020-10-16", }}, }) case "/search/tv": _ = json.NewEncoder(w).Encode(map[string]any{ "results": []map[string]any{{ "id": 85937, "name": "鬼灭之刃", "original_name": "鬼滅の刃", "first_air_date": "2019-04-06", }}, }) default: http.NotFound(w, r) } })) defer upstream.Close() db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil { t.Fatal(err) } repos := repository.New(db) 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) } media := model.Media{ LibraryID: lib.ID, Title: "鬼灭之刃 剧场版 无限列车篇", Path: `/media/anime/鬼灭之刃/鬼灭之刃 剧场版 无限列车篇.mkv`, } if err := repos.DB.Create(&media).Error; err != nil { t.Fatal(err) } results, err := scraper.ManualSearch(t.Context(), &media, media.Title, "tmdb", "anime") if err != nil { t.Fatal(err) } if len(results) == 0 || results[0].TMDbID != 635302 || results[0].MediaType != "movie" { t.Fatalf("manual theatrical results=%#v, paths=%v", results, paths) } if len(paths) == 0 || paths[0] != "/search/movie" { t.Fatalf("TMDb search paths=%v, want movie first", paths) } for _, path := range paths { if path == "/search/tv" { t.Fatalf("manual theatrical search unexpectedly queried TV after finding movie: paths=%v", paths) } } } func TestManualSearchAllProvidersTMDbNumericIDTriesMovieAndTVNamespaces(t *testing.T) { var paths []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/movie/12345": http.NotFound(w, r) case "/tv/12345": _ = json.NewEncoder(w).Encode(map[string]any{ "id": 12345, "name": "数字 ID 剧集", "original_name": "Numeric ID Show", "overview": "Matched from TV namespace.", "poster_path": "/tv.jpg", "first_air_date": "2025-01-01", "vote_average": 7.8, }) default: http.NotFound(w, r) } })) defer upstream.Close() db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil { t.Fatal(err) } repos := repository.New(db) 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/movie`, Type: "movie", Enabled: true} if err := repos.DB.Create(&lib).Error; err != nil { t.Fatal(err) } media := model.Media{LibraryID: lib.ID, Title: "待匹配", Path: `/media/movie/raw-provider-id.mkv`} if err := repos.DB.Create(&media).Error; err != nil { t.Fatal(err) } results, err := scraper.ManualSearch(t.Context(), &media, "12345", "all", "movie") if err != nil { t.Fatal(err) } if len(results) != 1 || results[0].TMDbID != 12345 || results[0].MediaType != "tv" { t.Fatalf("manual TMDb numeric results=%#v, paths=%v", results, paths) } if len(paths) < 2 || paths[0] != "/movie/12345" || paths[1] != "/tv/12345" { t.Fatalf("tmdb numeric paths=%v, want movie then tv", paths) } } func TestManualSearchTMDbProviderIDUsesIDLookupOnly(t *testing.T) { var paths []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/movie/1208850": _ = json.NewEncoder(w).Encode(map[string]any{ "id": 1208850, "title": "多拉特行动", "original_title": "Malbatt: Misi Bakara", "overview": "ID matched movie.", "poster_path": "/malbatt.jpg", "release_date": "2024-01-11", "vote_average": 6.9, }) default: http.NotFound(w, r) } })) defer upstream.Close() db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil { t.Fatal(err) } repos := repository.New(db) 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/movie`, Type: "movie", Enabled: true} if err := repos.DB.Create(&lib).Error; err != nil { t.Fatal(err) } media := model.Media{LibraryID: lib.ID, Title: "待匹配", Path: `/media/movie/raw-provider-id-skip-adult.mkv`} if err := repos.DB.Create(&media).Error; err != nil { t.Fatal(err) } results, err := scraper.ManualSearch(t.Context(), &media, "[tmdbid-1208850]", "all", "movie") if err != nil { t.Fatal(err) } if len(results) != 1 || results[0].TMDbID != 1208850 || results[0].Title != "多拉特行动" { t.Fatalf("manual TMDb provider-id results=%#v, paths=%v", results, paths) } for _, path := range paths { if strings.HasPrefix(path, "/search/") { t.Fatalf("provider-id search should not use fuzzy search; paths=%v", paths) } } if len(paths) == 0 || paths[0] != "/movie/1208850" { t.Fatalf("tmdb provider-id paths=%v, want /movie/1208850 first", paths) } } func TestManualSearchAllProvidersProviderIDSkipsAdultSource(t *testing.T) { var tmdbPaths []string tmdbUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tmdbPaths = append(tmdbPaths, r.URL.Path) w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/movie/1208850": _ = json.NewEncoder(w).Encode(map[string]any{ "id": 1208850, "title": "多拉特行动", "original_title": "Malbatt: Misi Bakara", "release_date": "2024-01-11", }) default: http.NotFound(w, r) } })) defer tmdbUpstream.Close() var adultCalls atomic.Int32 adultUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { adultCalls.Add(1) http.NotFound(w, r) })) defer adultUpstream.Close() db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) if err != nil { t.Fatal(err) } if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}, &model.APIConfig{}); err != nil { t.Fatal(err) } repos := repository.New(db) cfg := &config.Config{} cfg.Secrets.TMDbAPIKey = "test-key" cfg.Secrets.TMDbAPIProxy = tmdbUpstream.URL log := zap.NewNop() apiConfig := NewAPIConfigService(log, repos, NewCryptoService("", log)) adultBaseURL := adultUpstream.URL if _, err := apiConfig.Update(t.Context(), "adult", APIConfigPatch{BaseURL: &adultBaseURL}); err != nil { t.Fatal(err) } scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log), NewAdultProvider(log, apiConfig)) lib := model.Library{Name: "电影", Path: `/media/movie`, Type: "movie", Enabled: true} if err := repos.DB.Create(&lib).Error; err != nil { t.Fatal(err) } media := model.Media{LibraryID: lib.ID, Title: "待匹配", Path: `/media/movie/raw.mkv`} if err := repos.DB.Create(&media).Error; err != nil { t.Fatal(err) } results, err := scraper.ManualSearch(t.Context(), &media, "[tmdbid-1208850]", "all", "movie") if err != nil { t.Fatal(err) } if len(results) != 1 || results[0].TMDbID != 1208850 || results[0].Source != "tmdb" { t.Fatalf("manual provider-id results=%#v, tmdb paths=%v", results, tmdbPaths) } if calls := adultCalls.Load(); calls != 0 { t.Fatalf("adult provider was called %d times for explicit tmdb id", calls) } } func TestManualTMDbCandidatesSkipOtherProviderIDs(t *testing.T) { var paths []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paths = append(paths, r.URL.Path) http.NotFound(w, r) })) defer upstream.Close() cfg := &config.Config{} cfg.Secrets.TMDbAPIKey = "test-key" cfg.Secrets.TMDbAPIProxy = upstream.URL log := zap.NewNop() scraper := NewScraperService(cfg, log, nil, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log)) if got := scraper.manualTMDbCandidates(t.Context(), "{douban=36941123}", 0, "movie"); len(got) != 0 { t.Fatalf("tmdb candidates for douban id = %#v, want none", got) } if len(paths) != 0 { t.Fatalf("tmdb should not be called for douban id hint, paths=%v", paths) } } func TestManualSearchIncludesAdultProvider(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/search": w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(`SSIS-001 手动候选`)) case "/v/ssis001": w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(`
`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}, &model.APIConfig{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
apiConfig := NewAPIConfigService(zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
baseURL := upstream.URL
if _, err := apiConfig.Update(t.Context(), "adult", APIConfigPatch{BaseURL: &baseURL}); err != nil {
t.Fatal(err)
}
log := zap.NewNop()
scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log), NewAdultProvider(log, apiConfig))
lib := model.Library{Name: "成人", Path: "/media/adult", Type: "movie", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{LibraryID: lib.ID, Title: "SSIS-001", OriginalName: "SSIS-001", Path: "/media/adult/SSIS-001.mkv"}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
results, err := scraper.ManualSearch(t.Context(), &media, "SSIS-001", "adult", "adult")
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Source != "adult" || results[0].MediaType != "adult" || !results[0].NSFW || results[0].OriginalName != "SSIS-001" {
t.Fatalf("manual adult candidates = %#v", results)
}
}
func TestApplyManualMatchSavesSelectedCloudMatchWhenDetailsSlow(t *testing.T) {
oldTimeout := tmdbDetailsTimeout
tmdbDetailsTimeout = 20 * time.Millisecond
defer func() { tmdbDetailsTimeout = oldTimeout }()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/movie/77" {
http.NotFound(w, r)
return
}
select {
case <-r.Context().Done():
return
case <-time.After(time.Second):
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 77,
"title": "Slow Details",
})
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
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: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "bad cloud title",
Path: "cloud://openlist/Movies/Bad.Title.2026.mkv",
ScrapeStatus: "pending",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
start := time.Now()
if _, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{
Source: "manual",
MediaType: "movie",
Title: "Correct Cloud Movie",
TMDbID: 77,
Year: 2026,
}); err != nil {
t.Fatal(err)
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("manual apply waited for optional details: %s", elapsed)
}
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if got.Title != "Correct Cloud Movie" || got.ScrapeStatus != "matched" || got.TMDbID != 77 {
t.Fatalf("manual cloud match was not saved: title=%q status=%q tmdb=%d", got.Title, got.ScrapeStatus, got.TMDbID)
}
}
func TestApplyManualMovieMatchClearsEpisodeMarkers(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
log := zap.NewNop()
scraper := NewScraperService(&config.Config{}, log, repos, nil, nil, nil, nil, NewHub(log))
lib := model.Library{Name: "欧美剧", Path: "/media/tv/euus", Type: "tv", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "错误剧集标题",
Path: "/media/tv/euus/Dune/Season 01/Dune - S01E202.mkv",
SeasonNum: 1,
EpisodeNum: 202,
EpisodeTitle: "第 202 集",
SeriesID: "series:wrong",
TMDbID: 999999,
TheTVDBID: "888",
ScrapeStatus: "matched",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
if _, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{
Source: "manual",
MediaType: "movie",
Title: "Dune",
Year: 2021,
}); err != nil {
t.Fatal(err)
}
var got model.Media
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
t.Fatal(err)
}
if got.SeasonNum != 0 || got.EpisodeNum != 0 || got.EpisodeTitle != "" || got.SeriesID != "" {
t.Fatalf("episode markers were not cleared: %#v", got)
}
if got.TMDbID != 0 || got.TheTVDBID != "" {
t.Fatalf("stale external IDs were not cleared for manual movie fallback: tmdb=%d thetvdb=%q", got.TMDbID, got.TheTVDBID)
}
}
func TestApplyManualTVMatchRebuildsEpisodeIdentityFromPath(t *testing.T) {
var requestedEpisodePath string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/tv/69367":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 69367,
"name": "路人女主的养成方法",
"original_name": "冴えない彼女の育てかた",
"overview": "整剧简介",
"poster_path": "/show.jpg",
"backdrop_path": "/show-backdrop.jpg",
"first_air_date": "2015-01-09",
"vote_average": 7.0,
})
case "/tv/69367/season/0/episode/1":
requestedEpisodePath = r.URL.Path
_ = json.NewEncoder(w).Encode(map[string]any{
"name": "爱与青春的杀必死回",
"overview": "特别篇简介",
"still_path": "/special.jpg",
"air_date": "2015-01-07",
"vote_average": 7.5,
"runtime": 24,
})
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
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))
root := t.TempDir()
lib := model.Library{Name: "动漫", Path: root, Type: "anime", Enabled: true}
if err := repos.DB.Create(&lib).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: lib.ID,
Title: "旧标题",
Path: filepath.Join(root, "路人女主的养成方法 (2015)", "Season 1", "S01E00 - 爱与青春的杀必死回.strm"),
SeasonNum: 1,
EpisodeNum: 1,
EpisodeTitle: "错误百出的序曲",
SeriesID: "stale-series",
TMDbID: 111,
BangumiID: 222,
ScrapeStatus: "matched",
}
if err := repos.DB.Create(&media).Error; err != nil {
t.Fatal(err)
}
got, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{
Source: "tmdb",
MediaType: "tv",
Title: "路人女主的养成方法",
TMDbID: 69367,
})
if err != nil {
t.Fatal(err)
}
if requestedEpisodePath != "/tv/69367/season/0/episode/1" {
t.Fatalf("episode details path = %q, want season-zero special", requestedEpisodePath)
}
if got.SeasonNum != 0 || got.EpisodeNum != 1 || got.EpisodeTitle != "爱与青春的杀必死回" {
t.Fatalf("rebuilt episode identity = S%02dE%02d %q", got.SeasonNum, got.EpisodeNum, got.EpisodeTitle)
}
if got.SeriesID != "" || got.TMDbID != 69367 || got.BangumiID != 0 {
t.Fatalf("stale scrape identity survived manual rematch: %#v", got)
}
}