diff --git a/internal/service/organizer_reclassify_test.go b/internal/service/organizer_reclassify_test.go new file mode 100644 index 0000000..3d726b2 --- /dev/null +++ b/internal/service/organizer_reclassify_test.go @@ -0,0 +1,327 @@ +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestOrganizeDirectoryReclassifiesExistingWrongCategoryMedia(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/tv" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{{ + "id": 292696, + "name": "莫离", + "original_name": "The First Jasmine", + "original_language": "zh", + "origin_country": []string{"CN"}, + "genre_ids": []int{18}, + "first_air_date": "2026-06-23", + }}, + }) + })) + defer upstream.Close() + + repos := newOrganizerTestRepo(t) + cfg := &config.Config{} + cfg.Organizer.SmartClassify = true + cfg.Secrets.TMDbAPIKey = "test-key" + cfg.Secrets.TMDbAPIProxy = upstream.URL + scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop())) + + root := t.TempDir() + srcRoot := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + sourceFile := filepath.Join(srcRoot, "欧美剧", "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", "The.First.Jasmine.S01E01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb.mkv") + writeOrgFile(t, sourceFile, "episode") + + euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} + domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &euusLib); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), &domesticLib); err != nil { + t.Fatal(err) + } + + wrongPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 01", "The First Jasmine - S01E01.mkv") + writeOrgFile(t, wrongPath, "existing") + if err := repos.DB.Create(&model.Media{ + LibraryID: euusLib.ID, + Title: "莫离", + OriginalName: "The First Jasmine", + Path: wrongPath, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 292696, + Languages: "zh", + Countries: "CN", + Genres: "剧情", + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(cfg, zap.NewNop(), repos) + organizer.SetScraper(scraper) + res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: srcRoot, + DestPath: euusLib.Path, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + want := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01.mkv") + if res.Reclassified != 1 || res.Organized != 0 || res.Skipped != 0 { + t.Fatalf("result = %+v, want reclassified=1 only", res) + } + if _, err := os.Stat(wrongPath); !os.IsNotExist(err) { + t.Fatalf("wrong category path should be moved away, stat err=%v", err) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("reclassified media missing at %q: %v; items=%#v", want, err, res.Items) + } + if _, err := os.Stat(sourceFile); err != nil { + t.Fatalf("source download should remain untouched: %v", err) + } + + var got model.Media + if err := repos.DB.First(&got, "path = ?", want).Error; err != nil { + t.Fatal(err) + } + if got.LibraryID != domesticLib.ID { + t.Fatalf("library_id = %q, want domestic library %q", got.LibraryID, domesticLib.ID) + } +} + +func TestOrganizeDirectoryReclassifiesScannedAnimeUsingDBMetadata(t *testing.T) { + repos := newOrganizerTestRepo(t) + cfg := &config.Config{} + cfg.Organizer.SmartClassify = true + + root := t.TempDir() + dest := filepath.Join(root, "media") + euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} + tvAnimeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "电视剧", "国漫"), Type: "tv", Enabled: true} + animeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "动漫", "国漫"), Type: "anime", Enabled: true} + for _, lib := range []*model.Library{&euusLib, &tvAnimeLib, &animeLib} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + + wrongPath := filepath.Join(euusLib.Path, "Blades Of The Guardians", "Season 2", "Blades Of The Guardians - S02E01-1080p.TX.WEB-DL.mkv") + writeOrgFile(t, wrongPath, "episode") + if err := repos.DB.Create(&model.Media{ + LibraryID: euusLib.ID, + Title: "镖人", + OriginalName: "Blades Of The Guardians", + Path: wrongPath, + SeasonNum: 2, + EpisodeNum: 1, + TMDbID: 107463, + Languages: "zh", + Countries: "CN", + Genres: "动画,动作冒险", + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(cfg, zap.NewNop(), repos) + res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: filepath.Join(euusLib.Path, "Blades Of The Guardians"), + DestPath: euusLib.Path, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + want := filepath.Join(animeLib.Path, "镖人", "Season 02", "镖人 - S02E01.mkv") + if res.Reclassified != 1 || res.Organized != 0 { + t.Fatalf("result = %+v, want scanned DB metadata reclassified only", res) + } + if _, err := os.Stat(wrongPath); !os.IsNotExist(err) { + t.Fatalf("wrong anime path should be moved away, stat err=%v", err) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("anime should move to physical anime library at %q: %v; items=%#v", want, err, res.Items) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", want).Error; err != nil { + t.Fatal(err) + } + if got.LibraryID != animeLib.ID { + t.Fatalf("library_id = %q, want anime library %q", got.LibraryID, animeLib.ID) + } +} + +func TestReclassifyMisclassifiedMediaMovesScannedAnimeToPhysicalAnimeLibrary(t *testing.T) { + repos := newOrganizerTestRepo(t) + cfg := &config.Config{} + cfg.Organizer.SmartClassify = true + + root := t.TempDir() + dest := filepath.Join(root, "media") + euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} + tvAnimeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "电视剧", "国漫"), Type: "tv", Enabled: true} + animeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "动漫", "国漫"), Type: "anime", Enabled: true} + for _, lib := range []*model.Library{&euusLib, &tvAnimeLib, &animeLib} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + + wrongPath := filepath.Join(euusLib.Path, "Blades Of The Guardians", "Season 2", "Blades Of The Guardians - S02E01.mkv") + writeOrgFile(t, wrongPath, "episode") + if err := repos.DB.Create(&model.Media{ + LibraryID: euusLib.ID, + Title: "镖人", + OriginalName: "Blades Of The Guardians", + Path: wrongPath, + SeasonNum: 2, + EpisodeNum: 1, + TMDbID: 107463, + Languages: "zh", + Countries: "CN", + Genres: "动画,动作冒险", + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(cfg, zap.NewNop(), repos) + res, err := organizer.ReclassifyMisclassifiedMedia(t.Context(), MediaCategoryReclassifyOptions{}) + if err != nil { + t.Fatalf("reclassify media: %v", err) + } + want := filepath.Join(animeLib.Path, "镖人", "Season 02", "镖人 - S02E01.mkv") + if res.Reclassified != 1 { + t.Fatalf("reclassified = %d, want 1; items=%#v errors=%#v", res.Reclassified, res.Items, res.Errors) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("bulk reclassify target missing at %q: %v", want, err) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", want).Error; err != nil { + t.Fatal(err) + } + if got.LibraryID != animeLib.ID { + t.Fatalf("library_id = %q, want anime library %q", got.LibraryID, animeLib.ID) + } +} + +func TestOrganizeDirectoryCleansWrongCategoryDuplicateWhenTargetExists(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/tv" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "results": []map[string]any{{ + "id": 292696, + "name": "莫离", + "original_name": "The First Jasmine", + "original_language": "zh", + "origin_country": []string{"CN"}, + "genre_ids": []int{18}, + "first_air_date": "2026-06-23", + }}, + }) + })) + defer upstream.Close() + + repos := newOrganizerTestRepo(t) + cfg := &config.Config{} + cfg.Organizer.SmartClassify = true + cfg.Secrets.TMDbAPIKey = "test-key" + cfg.Secrets.TMDbAPIProxy = upstream.URL + scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop())) + + root := t.TempDir() + srcRoot := filepath.Join(root, "downloads") + dest := filepath.Join(root, "media") + sourceFile := filepath.Join(srcRoot, "欧美剧", "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", "The.First.Jasmine.S01E01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb.mkv") + writeOrgFile(t, sourceFile, "episode") + + euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} + domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &euusLib); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), &domesticLib); err != nil { + t.Fatal(err) + } + + targetPath := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01.mkv") + wrongPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 01", "The First Jasmine - S01E01.mkv") + writeOrgFile(t, targetPath, "same-bytes") + writeOrgFile(t, wrongPath, "same-bytes") + if err := repos.DB.Create(&model.Media{ + LibraryID: domesticLib.ID, + Title: "莫离", + Path: targetPath, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 292696, + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(&model.Media{ + LibraryID: euusLib.ID, + Title: "莫离", + Path: wrongPath, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 292696, + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + + organizer := NewOrganizerService(cfg, zap.NewNop(), repos) + organizer.SetScraper(scraper) + res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ + SourcePath: srcRoot, + DestPath: euusLib.Path, + TransferMode: TransferCopy, + }) + if err != nil { + t.Fatalf("organize directory: %v", err) + } + if res.Reclassified != 1 || res.Organized != 0 || res.Skipped != 0 { + t.Fatalf("result = %+v, want reclassified=1 only", res) + } + if _, err := os.Stat(targetPath); err != nil { + t.Fatalf("canonical target should remain: %v", err) + } + if _, err := os.Stat(wrongPath); !os.IsNotExist(err) { + t.Fatalf("wrong category duplicate should be removed, stat err=%v", err) + } + var wrongRows int64 + if err := repos.DB.Model(&model.Media{}).Where("path = ?", wrongPath).Count(&wrongRows).Error; err != nil { + t.Fatal(err) + } + if wrongRows != 0 { + t.Fatalf("wrong DB rows = %d, want 0", wrongRows) + } + if _, err := os.Stat(sourceFile); err != nil { + t.Fatalf("source download should remain untouched: %v", err) + } +} diff --git a/internal/service/organizer_scrape_test.go b/internal/service/organizer_scrape_test.go index 4ea64fe..94a1a5a 100644 --- a/internal/service/organizer_scrape_test.go +++ b/internal/service/organizer_scrape_test.go @@ -279,318 +279,6 @@ func TestOrganizeDirectoryMetadataCategoryOverridesDownloadFolder(t *testing.T) } } -func TestOrganizeDirectoryReclassifiesExistingWrongCategoryMedia(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/tv" { - http.NotFound(w, r) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "results": []map[string]any{{ - "id": 292696, - "name": "莫离", - "original_name": "The First Jasmine", - "original_language": "zh", - "origin_country": []string{"CN"}, - "genre_ids": []int{18}, - "first_air_date": "2026-06-23", - }}, - }) - })) - defer upstream.Close() - - repos := newOrganizerTestRepo(t) - cfg := &config.Config{} - cfg.Organizer.SmartClassify = true - cfg.Secrets.TMDbAPIKey = "test-key" - cfg.Secrets.TMDbAPIProxy = upstream.URL - scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop())) - - root := t.TempDir() - srcRoot := filepath.Join(root, "downloads") - dest := filepath.Join(root, "media") - sourceFile := filepath.Join(srcRoot, "欧美剧", "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", "The.First.Jasmine.S01E01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb.mkv") - writeOrgFile(t, sourceFile, "episode") - - euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} - domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &euusLib); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), &domesticLib); err != nil { - t.Fatal(err) - } - - wrongPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 01", "The First Jasmine - S01E01.mkv") - writeOrgFile(t, wrongPath, "existing") - if err := repos.DB.Create(&model.Media{ - LibraryID: euusLib.ID, - Title: "莫离", - OriginalName: "The First Jasmine", - Path: wrongPath, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 292696, - Languages: "zh", - Countries: "CN", - Genres: "剧情", - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - - organizer := NewOrganizerService(cfg, zap.NewNop(), repos) - organizer.SetScraper(scraper) - res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ - SourcePath: srcRoot, - DestPath: euusLib.Path, - TransferMode: TransferCopy, - }) - if err != nil { - t.Fatalf("organize directory: %v", err) - } - want := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01.mkv") - if res.Reclassified != 1 || res.Organized != 0 || res.Skipped != 0 { - t.Fatalf("result = %+v, want reclassified=1 only", res) - } - if _, err := os.Stat(wrongPath); !os.IsNotExist(err) { - t.Fatalf("wrong category path should be moved away, stat err=%v", err) - } - if _, err := os.Stat(want); err != nil { - t.Fatalf("reclassified media missing at %q: %v; items=%#v", want, err, res.Items) - } - if _, err := os.Stat(sourceFile); err != nil { - t.Fatalf("source download should remain untouched: %v", err) - } - - var got model.Media - if err := repos.DB.First(&got, "path = ?", want).Error; err != nil { - t.Fatal(err) - } - if got.LibraryID != domesticLib.ID { - t.Fatalf("library_id = %q, want domestic library %q", got.LibraryID, domesticLib.ID) - } -} - -func TestOrganizeDirectoryReclassifiesScannedAnimeUsingDBMetadata(t *testing.T) { - repos := newOrganizerTestRepo(t) - cfg := &config.Config{} - cfg.Organizer.SmartClassify = true - - root := t.TempDir() - dest := filepath.Join(root, "media") - euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} - tvAnimeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "电视剧", "国漫"), Type: "tv", Enabled: true} - animeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "动漫", "国漫"), Type: "anime", Enabled: true} - for _, lib := range []*model.Library{&euusLib, &tvAnimeLib, &animeLib} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - wrongPath := filepath.Join(euusLib.Path, "Blades Of The Guardians", "Season 2", "Blades Of The Guardians - S02E01-1080p.TX.WEB-DL.mkv") - writeOrgFile(t, wrongPath, "episode") - if err := repos.DB.Create(&model.Media{ - LibraryID: euusLib.ID, - Title: "镖人", - OriginalName: "Blades Of The Guardians", - Path: wrongPath, - SeasonNum: 2, - EpisodeNum: 1, - TMDbID: 107463, - Languages: "zh", - Countries: "CN", - Genres: "动画,动作冒险", - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - - organizer := NewOrganizerService(cfg, zap.NewNop(), repos) - res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ - SourcePath: filepath.Join(euusLib.Path, "Blades Of The Guardians"), - DestPath: euusLib.Path, - TransferMode: TransferCopy, - }) - if err != nil { - t.Fatalf("organize directory: %v", err) - } - want := filepath.Join(animeLib.Path, "镖人", "Season 02", "镖人 - S02E01.mkv") - if res.Reclassified != 1 || res.Organized != 0 { - t.Fatalf("result = %+v, want scanned DB metadata reclassified only", res) - } - if _, err := os.Stat(wrongPath); !os.IsNotExist(err) { - t.Fatalf("wrong anime path should be moved away, stat err=%v", err) - } - if _, err := os.Stat(want); err != nil { - t.Fatalf("anime should move to physical anime library at %q: %v; items=%#v", want, err, res.Items) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", want).Error; err != nil { - t.Fatal(err) - } - if got.LibraryID != animeLib.ID { - t.Fatalf("library_id = %q, want anime library %q", got.LibraryID, animeLib.ID) - } -} - -func TestReclassifyMisclassifiedMediaMovesScannedAnimeToPhysicalAnimeLibrary(t *testing.T) { - repos := newOrganizerTestRepo(t) - cfg := &config.Config{} - cfg.Organizer.SmartClassify = true - - root := t.TempDir() - dest := filepath.Join(root, "media") - euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} - tvAnimeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "电视剧", "国漫"), Type: "tv", Enabled: true} - animeLib := model.Library{Name: "国漫", Path: filepath.Join(dest, "动漫", "国漫"), Type: "anime", Enabled: true} - for _, lib := range []*model.Library{&euusLib, &tvAnimeLib, &animeLib} { - if err := repos.Library.Create(t.Context(), lib); err != nil { - t.Fatal(err) - } - } - - wrongPath := filepath.Join(euusLib.Path, "Blades Of The Guardians", "Season 2", "Blades Of The Guardians - S02E01.mkv") - writeOrgFile(t, wrongPath, "episode") - if err := repos.DB.Create(&model.Media{ - LibraryID: euusLib.ID, - Title: "镖人", - OriginalName: "Blades Of The Guardians", - Path: wrongPath, - SeasonNum: 2, - EpisodeNum: 1, - TMDbID: 107463, - Languages: "zh", - Countries: "CN", - Genres: "动画,动作冒险", - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - - organizer := NewOrganizerService(cfg, zap.NewNop(), repos) - res, err := organizer.ReclassifyMisclassifiedMedia(t.Context(), MediaCategoryReclassifyOptions{}) - if err != nil { - t.Fatalf("reclassify media: %v", err) - } - want := filepath.Join(animeLib.Path, "镖人", "Season 02", "镖人 - S02E01.mkv") - if res.Reclassified != 1 { - t.Fatalf("reclassified = %d, want 1; items=%#v errors=%#v", res.Reclassified, res.Items, res.Errors) - } - if _, err := os.Stat(want); err != nil { - t.Fatalf("bulk reclassify target missing at %q: %v", want, err) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", want).Error; err != nil { - t.Fatal(err) - } - if got.LibraryID != animeLib.ID { - t.Fatalf("library_id = %q, want anime library %q", got.LibraryID, animeLib.ID) - } -} - -func TestOrganizeDirectoryCleansWrongCategoryDuplicateWhenTargetExists(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/tv" { - http.NotFound(w, r) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "results": []map[string]any{{ - "id": 292696, - "name": "莫离", - "original_name": "The First Jasmine", - "original_language": "zh", - "origin_country": []string{"CN"}, - "genre_ids": []int{18}, - "first_air_date": "2026-06-23", - }}, - }) - })) - defer upstream.Close() - - repos := newOrganizerTestRepo(t) - cfg := &config.Config{} - cfg.Organizer.SmartClassify = true - cfg.Secrets.TMDbAPIKey = "test-key" - cfg.Secrets.TMDbAPIProxy = upstream.URL - scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop())) - - root := t.TempDir() - srcRoot := filepath.Join(root, "downloads") - dest := filepath.Join(root, "media") - sourceFile := filepath.Join(srcRoot, "欧美剧", "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb", "The.First.Jasmine.S01E01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb.mkv") - writeOrgFile(t, sourceFile, "episode") - - euusLib := model.Library{Name: "欧美剧", Path: filepath.Join(dest, "电视剧", "欧美剧"), Type: "tv", Enabled: true} - domesticLib := model.Library{Name: "国产剧", Path: filepath.Join(dest, "电视剧", "国产剧"), Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &euusLib); err != nil { - t.Fatal(err) - } - if err := repos.Library.Create(t.Context(), &domesticLib); err != nil { - t.Fatal(err) - } - - targetPath := filepath.Join(domesticLib.Path, "莫离", "Season 01", "莫离 - S01E01.mkv") - wrongPath := filepath.Join(euusLib.Path, "The First Jasmine", "Season 01", "The First Jasmine - S01E01.mkv") - writeOrgFile(t, targetPath, "same-bytes") - writeOrgFile(t, wrongPath, "same-bytes") - if err := repos.DB.Create(&model.Media{ - LibraryID: domesticLib.ID, - Title: "莫离", - Path: targetPath, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 292696, - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - if err := repos.DB.Create(&model.Media{ - LibraryID: euusLib.ID, - Title: "莫离", - Path: wrongPath, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 292696, - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - - organizer := NewOrganizerService(cfg, zap.NewNop(), repos) - organizer.SetScraper(scraper) - res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{ - SourcePath: srcRoot, - DestPath: euusLib.Path, - TransferMode: TransferCopy, - }) - if err != nil { - t.Fatalf("organize directory: %v", err) - } - if res.Reclassified != 1 || res.Organized != 0 || res.Skipped != 0 { - t.Fatalf("result = %+v, want reclassified=1 only", res) - } - if _, err := os.Stat(targetPath); err != nil { - t.Fatalf("canonical target should remain: %v", err) - } - if _, err := os.Stat(wrongPath); !os.IsNotExist(err) { - t.Fatalf("wrong category duplicate should be removed, stat err=%v", err) - } - var wrongRows int64 - if err := repos.DB.Model(&model.Media{}).Where("path = ?", wrongPath).Count(&wrongRows).Error; err != nil { - t.Fatal(err) - } - if wrongRows != 0 { - t.Fatalf("wrong DB rows = %d, want 0", wrongRows) - } - if _, err := os.Stat(sourceFile); err != nil { - t.Fatalf("source download should remain untouched: %v", err) - } -} - func TestOrganizeDirectoryDoesNotScrapeByDownloadCategoryFolder(t *testing.T) { var queries []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/scanner_cloud_artwork_test.go b/internal/service/scanner_cloud_artwork_test.go new file mode 100644 index 0000000..288ad64 --- /dev/null +++ b/internal/service/scanner_cloud_artwork_test.go @@ -0,0 +1,385 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestScanCloudLibraryCachesFileLevelRemoteArtwork(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PROPFIND": + if r.URL.Path != "/dav/Movies" { + t.Fatalf("unexpected propfind path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusMultiStatus) + _, _ = w.Write([]byte(` + + /dav/Movies/ + /dav/Movies/Movie.mkvMovie.mkv4096 + /dav/Movies/Movie.nfoMovie.nfo128 + /dav/Movies/Movie.jpgMovie.jpg1024 +`)) + case http.MethodGet: + switch r.URL.Path { + case "/dav/Movies/Movie.nfo": + _, _ = w.Write([]byte(`Sidecar Movie2026`)) + case "/dav/Movies/Movie.jpg": + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("file-level-poster")) + default: + t.Fatalf("unexpected get path %s", r.URL.Path) + } + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) + repos := repository.New(db) + log := zap.NewNop() + storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "url": upstream.URL, + }, + }); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) + scanner.SetStorageConfig(storage) + imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) + scanner.SetImageProxy(imageProxy) + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan cloud: %v", err) + } + if res.Added != 1 || res.LocalMetadata != 1 { + t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) + } + var media model.Media + if err := repos.DB.First(&media).Error; err != nil { + t.Fatal(err) + } + if media.Title != "Sidecar Movie" || media.Year != 2026 { + t.Fatalf("metadata not applied: %#v", media) + } + if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FMovie.jpg" { + t.Fatalf("poster url = %q", media.PosterURL) + } + rec := httptest.NewRecorder() + if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Movie.jpg") { + t.Fatal("file-level cloud poster should be cached locally during scan before media is exposed") + } + if got := rec.Body.String(); got != "file-level-poster" { + t.Fatalf("cached poster body = %q", got) + } +} + +func TestScanCloudLibraryUsesArtworkReferencedByRemoteNFO(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PROPFIND": + if r.URL.Path != "/dav/Movies" { + t.Fatalf("unexpected propfind path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusMultiStatus) + _, _ = w.Write([]byte(` + + /dav/Movies/ + /dav/Movies/Movie.mkvMovie.mkv4096 + /dav/Movies/Movie.nfoMovie.nfo256 + /dav/Movies/Artwork.Custom.tbnArtwork.Custom.tbn1024 + /dav/Movies/Scene.Still.pngScene.Still.png1024 +`)) + case http.MethodGet: + switch r.URL.Path { + case "/dav/Movies/Movie.nfo": + _, _ = w.Write([]byte(`NFO Custom ArtworkArtwork.Custom.tbnScene.Still.png?version=1`)) + case "/dav/Movies/Artwork.Custom.tbn": + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("custom-poster")) + case "/dav/Movies/Scene.Still.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("custom-backdrop")) + default: + t.Fatalf("unexpected get path %s", r.URL.Path) + } + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) + repos := repository.New(db) + log := zap.NewNop() + storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "url": upstream.URL, + }, + }); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) + scanner.SetStorageConfig(storage) + imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) + scanner.SetImageProxy(imageProxy) + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan cloud: %v", err) + } + if res.Added != 1 || res.LocalMetadata != 1 { + t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) + } + var media model.Media + if err := repos.DB.First(&media).Error; err != nil { + t.Fatal(err) + } + if media.Title != "NFO Custom Artwork" { + t.Fatalf("metadata title = %q", media.Title) + } + if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FArtwork.Custom.tbn" { + t.Fatalf("poster url = %q", media.PosterURL) + } + if media.BackdropURL != "/api/img/cloud/openlist?ref=%2FMovies%2FScene.Still.png" { + t.Fatalf("backdrop url = %q", media.BackdropURL) + } + rec := httptest.NewRecorder() + if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Artwork.Custom.tbn") { + t.Fatal("NFO-referenced cloud poster should be cached locally during scan") + } + if got := rec.Body.String(); got != "custom-poster" { + t.Fatalf("cached poster body = %q", got) + } +} + +func TestScanCloudLibraryReadsRemoteNFOAndArtwork(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PROPFIND": + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusMultiStatus) + switch r.URL.Path { + case "/dav/Anime/JianLai": + _, _ = w.Write([]byte(` + + /dav/Anime/JianLai/ + /dav/Anime/JianLai/tvshow.nfotvshow.nfo64 + /dav/Anime/JianLai/poster.jpgposter.jpg1024 + /dav/Anime/JianLai/Season1/Season1 +`)) + case "/dav/Anime/JianLai/Season1": + _, _ = w.Write([]byte(` + + /dav/Anime/JianLai/Season1/ + /dav/Anime/JianLai/Season1/JianLai.S01E01.mkvJianLai.S01E01.mkv2048 + /dav/Anime/JianLai/Season1/JianLai.S01E01.nfoJianLai.S01E01.nfo128 +`)) + default: + t.Fatalf("unexpected propfind path %s", r.URL.Path) + } + case http.MethodGet: + switch r.URL.Path { + case "/dav/Anime/JianLai/tvshow.nfo": + _, _ = w.Write([]byte(`剑来2024天地有剑气`)) + case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo": + _, _ = w.Write([]byte(`剑来第一集11`)) + case "/dav/Anime/JianLai/poster.jpg": + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("cloud-poster-bytes")) + default: + t.Fatalf("unexpected get path %s", r.URL.Path) + } + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) + repos := repository.New(db) + log := zap.NewNop() + storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "url": upstream.URL, + }, + }); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) + scanner.SetStorageConfig(storage) + imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) + scanner.SetImageProxy(imageProxy) + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan cloud: %v", err) + } + if res.Added != 1 || res.LocalMetadata != 1 { + t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) + } + var media model.Media + if err := repos.DB.First(&media).Error; err != nil { + t.Fatal(err) + } + // 单集名(episode 「第一集」)不得写入 OriginalName(整剧原名/分组键)。 + // tvshow.nfo 未提供 originaltitle, 故 OriginalName 应为空。 + if media.Title != "剑来" || media.OriginalName != "" || media.Year != 2024 { + t.Fatalf("metadata not applied: %#v", media) + } + if media.SeasonNum != 1 || media.EpisodeNum != 1 { + t.Fatalf("episode numbers = %d/%d", media.SeasonNum, media.EpisodeNum) + } + if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" { + t.Fatalf("poster url = %q", media.PosterURL) + } + rec := httptest.NewRecorder() + if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") { + t.Fatal("cloud poster should be cached locally during scan before media is exposed") + } + if got := rec.Body.String(); got != "cloud-poster-bytes" { + t.Fatalf("cached poster body = %q", got) + } + if media.ScrapeStatus != "matched" { + t.Fatalf("scrape status = %q", media.ScrapeStatus) + } +} + +func TestScanCloudLibraryRefreshesExistingRemoteNFOAndArtwork(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "PROPFIND": + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusMultiStatus) + switch r.URL.Path { + case "/dav/Anime/JianLai": + _, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> +<d:multistatus xmlns:d="DAV:"> + <d:response><d:href>/dav/Anime/JianLai/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> + <d:response><d:href>/dav/Anime/JianLai/tvshow.nfo</d:href><d:propstat><d:prop><d:displayname>tvshow.nfo</d:displayname><d:getcontentlength>64</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> + <d:response><d:href>/dav/Anime/JianLai/poster.jpg</d:href><d:propstat><d:prop><d:displayname>poster.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> + <d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:displayname>Season1</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> +</d:multistatus>`)) + case "/dav/Anime/JianLai/Season1": + _, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> +<d:multistatus xmlns:d="DAV:"> + <d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> + <d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.mkv</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.mkv</d:displayname><d:getcontentlength>2048</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> + <d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> +</d:multistatus>`)) + default: + t.Fatalf("unexpected propfind path %s", r.URL.Path) + } + case http.MethodGet: + switch r.URL.Path { + case "/dav/Anime/JianLai/tvshow.nfo": + _, _ = w.Write([]byte(`<tvshow><title>剑来2024天地有剑气296753`)) + case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo": + _, _ = w.Write([]byte(`剑来第一集11`)) + case "/dav/Anime/JianLai/poster.jpg": + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("cloud-poster-bytes")) + default: + t.Fatalf("unexpected get path %s", r.URL.Path) + } + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer upstream.Close() + + db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) + repos := repository.New(db) + log := zap.NewNop() + storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "url": upstream.URL, + }, + }); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + + mediaPath := "cloud://openlist/Anime/JianLai/Season1/JianLai.S01E01.mkv" + old := model.Media{ + LibraryID: lib.ID, + Title: "JianLai.S01E01", + Path: mediaPath, + SizeBytes: 2048, + Container: "mkv", + PosterURL: "https://image.tmdb.org/t/p/w500/old.jpg", + STRMURL: BuildRelativeCloudPlayURL("openlist", "/Anime/JianLai/Season1/JianLai.S01E01.mkv"), + ScrapeStatus: "no_match", + } + if err := repos.Media.Upsert(t.Context(), &old); err != nil { + t.Fatal(err) + } + + scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) + scanner.SetStorageConfig(storage) + imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) + scanner.SetImageProxy(imageProxy) + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan cloud: %v", err) + } + if res.Updated != 1 || res.LocalMetadata != 1 { + t.Fatalf("scan result = %#v, want updated=1 local_metadata=1", res) + } + var media model.Media + if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil { + t.Fatal(err) + } + if media.Title != "剑来" || media.Year != 2024 || media.TMDbID != 296753 { + t.Fatalf("metadata not refreshed: %#v", media) + } + if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" { + t.Fatalf("poster url = %q", media.PosterURL) + } + if media.ScrapeStatus != "matched" { + t.Fatalf("scrape status = %q", media.ScrapeStatus) + } + rec := httptest.NewRecorder() + if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") { + t.Fatal("refreshed cloud poster should be cached locally during scan") + } + if got := rec.Body.String(); got != "cloud-poster-bytes" { + t.Fatalf("cached poster body = %q", got) + } +} diff --git a/internal/service/scanner_cloud_metadata_test.go b/internal/service/scanner_cloud_metadata_test.go index 88bf9f5..7364936 100644 --- a/internal/service/scanner_cloud_metadata_test.go +++ b/internal/service/scanner_cloud_metadata_test.go @@ -81,378 +81,6 @@ func TestScanCloudLibraryReadsRemoteSTRMTarget(t *testing.T) { } } -func TestScanCloudLibraryCachesFileLevelRemoteArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - if r.URL.Path != "/dav/Movies" { - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/Movie.mkvMovie.mkv4096 - /dav/Movies/Movie.nfoMovie.nfo128 - /dav/Movies/Movie.jpgMovie.jpg1024 -`)) - case http.MethodGet: - switch r.URL.Path { - case "/dav/Movies/Movie.nfo": - _, _ = w.Write([]byte(`Sidecar Movie2026`)) - case "/dav/Movies/Movie.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("file-level-poster")) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Title != "Sidecar Movie" || media.Year != 2026 { - t.Fatalf("metadata not applied: %#v", media) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FMovie.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Movie.jpg") { - t.Fatal("file-level cloud poster should be cached locally during scan before media is exposed") - } - if got := rec.Body.String(); got != "file-level-poster" { - t.Fatalf("cached poster body = %q", got) - } -} - -func TestScanCloudLibraryUsesArtworkReferencedByRemoteNFO(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - if r.URL.Path != "/dav/Movies" { - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - _, _ = w.Write([]byte(` - - /dav/Movies/ - /dav/Movies/Movie.mkvMovie.mkv4096 - /dav/Movies/Movie.nfoMovie.nfo256 - /dav/Movies/Artwork.Custom.tbnArtwork.Custom.tbn1024 - /dav/Movies/Scene.Still.pngScene.Still.png1024 -`)) - case http.MethodGet: - switch r.URL.Path { - case "/dav/Movies/Movie.nfo": - _, _ = w.Write([]byte(`NFO Custom ArtworkArtwork.Custom.tbnScene.Still.png?version=1`)) - case "/dav/Movies/Artwork.Custom.tbn": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("custom-poster")) - case "/dav/Movies/Scene.Still.png": - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write([]byte("custom-backdrop")) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · Movies", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - if media.Title != "NFO Custom Artwork" { - t.Fatalf("metadata title = %q", media.Title) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FMovies%2FArtwork.Custom.tbn" { - t.Fatalf("poster url = %q", media.PosterURL) - } - if media.BackdropURL != "/api/img/cloud/openlist?ref=%2FMovies%2FScene.Still.png" { - t.Fatalf("backdrop url = %q", media.BackdropURL) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Artwork.Custom.tbn") { - t.Fatal("NFO-referenced cloud poster should be cached locally during scan") - } - if got := rec.Body.String(); got != "custom-poster" { - t.Fatalf("cached poster body = %q", got) - } -} - -func TestScanCloudLibraryReadsRemoteNFOAndArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Anime/JianLai": - _, _ = w.Write([]byte(` - - /dav/Anime/JianLai/ - /dav/Anime/JianLai/tvshow.nfotvshow.nfo64 - /dav/Anime/JianLai/poster.jpgposter.jpg1024 - /dav/Anime/JianLai/Season1/Season1 -`)) - case "/dav/Anime/JianLai/Season1": - _, _ = w.Write([]byte(` - - /dav/Anime/JianLai/Season1/ - /dav/Anime/JianLai/Season1/JianLai.S01E01.mkvJianLai.S01E01.mkv2048 - /dav/Anime/JianLai/Season1/JianLai.S01E01.nfoJianLai.S01E01.nfo128 -`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - switch r.URL.Path { - case "/dav/Anime/JianLai/tvshow.nfo": - _, _ = w.Write([]byte(`剑来2024天地有剑气`)) - case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo": - _, _ = w.Write([]byte(`剑来第一集11`)) - case "/dav/Anime/JianLai/poster.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("cloud-poster-bytes")) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want added=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media).Error; err != nil { - t.Fatal(err) - } - // 单集名(episode 「第一集」)不得写入 OriginalName(整剧原名/分组键)。 - // tvshow.nfo 未提供 originaltitle, 故 OriginalName 应为空。 - if media.Title != "剑来" || media.OriginalName != "" || media.Year != 2024 { - t.Fatalf("metadata not applied: %#v", media) - } - if media.SeasonNum != 1 || media.EpisodeNum != 1 { - t.Fatalf("episode numbers = %d/%d", media.SeasonNum, media.EpisodeNum) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") { - t.Fatal("cloud poster should be cached locally during scan before media is exposed") - } - if got := rec.Body.String(); got != "cloud-poster-bytes" { - t.Fatalf("cached poster body = %q", got) - } - if media.ScrapeStatus != "matched" { - t.Fatalf("scrape status = %q", media.ScrapeStatus) - } -} - -func TestScanCloudLibraryRefreshesExistingRemoteNFOAndArtwork(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusMultiStatus) - switch r.URL.Path { - case "/dav/Anime/JianLai": - _, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> -<d:multistatus xmlns:d="DAV:"> - <d:response><d:href>/dav/Anime/JianLai/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/tvshow.nfo</d:href><d:propstat><d:prop><d:displayname>tvshow.nfo</d:displayname><d:getcontentlength>64</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/poster.jpg</d:href><d:propstat><d:prop><d:displayname>poster.jpg</d:displayname><d:getcontentlength>1024</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:displayname>Season1</d:displayname><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> -</d:multistatus>`)) - case "/dav/Anime/JianLai/Season1": - _, _ = w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?> -<d:multistatus xmlns:d="DAV:"> - <d:response><d:href>/dav/Anime/JianLai/Season1/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.mkv</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.mkv</d:displayname><d:getcontentlength>2048</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> - <d:response><d:href>/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo</d:href><d:propstat><d:prop><d:displayname>JianLai.S01E01.nfo</d:displayname><d:getcontentlength>128</d:getcontentlength><d:resourcetype/></d:prop></d:propstat></d:response> -</d:multistatus>`)) - default: - t.Fatalf("unexpected propfind path %s", r.URL.Path) - } - case http.MethodGet: - switch r.URL.Path { - case "/dav/Anime/JianLai/tvshow.nfo": - _, _ = w.Write([]byte(`<tvshow><title>剑来2024天地有剑气296753`)) - case "/dav/Anime/JianLai/Season1/JianLai.S01E01.nfo": - _, _ = w.Write([]byte(`剑来第一集11`)) - case "/dav/Anime/JianLai/poster.jpg": - w.Header().Set("Content-Type", "image/jpeg") - _, _ = w.Write([]byte("cloud-poster-bytes")) - default: - t.Fatalf("unexpected get path %s", r.URL.Path) - } - default: - t.Fatalf("unexpected method %s", r.Method) - } - })) - defer upstream.Close() - - db := newServiceTestDB(t, &model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}) - repos := repository.New(db) - log := zap.NewNop() - storage := NewStorageConfigService(log, repos, NewCryptoService("", log)) - if _, err := storage.Save(t.Context(), StorageInput{ - Type: "openlist", - Config: map[string]any{ - "url": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList · 国漫 · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - - mediaPath := "cloud://openlist/Anime/JianLai/Season1/JianLai.S01E01.mkv" - old := model.Media{ - LibraryID: lib.ID, - Title: "JianLai.S01E01", - Path: mediaPath, - SizeBytes: 2048, - Container: "mkv", - PosterURL: "https://image.tmdb.org/t/p/w500/old.jpg", - STRMURL: BuildRelativeCloudPlayURL("openlist", "/Anime/JianLai/Season1/JianLai.S01E01.mkv"), - ScrapeStatus: "no_match", - } - if err := repos.Media.Upsert(t.Context(), &old); err != nil { - t.Fatal(err) - } - - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - imageProxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}, log) - scanner.SetImageProxy(imageProxy) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Updated != 1 || res.LocalMetadata != 1 { - t.Fatalf("scan result = %#v, want updated=1 local_metadata=1", res) - } - var media model.Media - if err := repos.DB.First(&media, "path = ?", mediaPath).Error; err != nil { - t.Fatal(err) - } - if media.Title != "剑来" || media.Year != 2024 || media.TMDbID != 296753 { - t.Fatalf("metadata not refreshed: %#v", media) - } - if media.PosterURL != "/api/img/cloud/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg" { - t.Fatalf("poster url = %q", media.PosterURL) - } - if media.ScrapeStatus != "matched" { - t.Fatalf("scrape status = %q", media.ScrapeStatus) - } - rec := httptest.NewRecorder() - if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") { - t.Fatal("refreshed cloud poster should be cached locally during scan") - } - if got := rec.Body.String(); got != "cloud-poster-bytes" { - t.Fatalf("cached poster body = %q", got) - } -} - func TestScanCloudLibraryReadsMovieDirectoryNFOAndCleanTitleArtwork(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method {