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(`

SSIS-001 手动成人标题

`)) 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) } }