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(`
+
+ /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天地有剑气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(`
-
- /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天地有剑气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 {