diff --git a/internal/service/scanner_cloud_autocategory_test.go b/internal/service/scanner_cloud_autocategory_test.go new file mode 100644 index 0000000..4f7c300 --- /dev/null +++ b/internal/service/scanner_cloud_autocategory_test.go @@ -0,0 +1,244 @@ +package service + +import ( + "context" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestScanRootCloudLibraryCreatesAutoCategoryLibraries(t *testing.T) { + empty := false + upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { + if empty { + return nil, 0 + } + switch path { + case "/": + return []openListTestEntry{ + {Name: "电视剧", IsDir: true}, + {Name: "电影", IsDir: true}, + {Name: "国漫", IsDir: true}, + }, 3 + case "/电视剧": + return []openListTestEntry{{Name: "欧美剧", IsDir: true}}, 1 + case "/电视剧/欧美剧": + return []openListTestEntry{{Name: "The Show", IsDir: true}}, 1 + case "/电视剧/欧美剧/The Show": + return []openListTestEntry{{Name: "The.Show.S01E01.mkv", Size: 101}}, 1 + case "/电影": + return []openListTestEntry{{Name: "华语电影", IsDir: true}}, 1 + case "/电影/华语电影": + return []openListTestEntry{{Name: "Movie.2024.mkv", Size: 202}}, 1 + case "/国漫": + return []openListTestEntry{{Name: "剑来", IsDir: true}}, 1 + case "/国漫/剑来": + return []openListTestEntry{{Name: "剑来.S01E01.mkv", Size: 303}}, 1 + default: + t.Fatalf("unexpected openlist path %q", path) + return nil, 0 + } + }) + 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{ + "server": upstream.URL, + "token": "openlist-token", + }, + }); err != nil { + t.Fatal(err) + } + root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &root); err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) + scanner.SetStorageConfig(storage) + + res, err := scanner.ScanLibrary(t.Context(), root.ID) + if err != nil { + t.Fatalf("scan root cloud: %v", err) + } + if res.Visited != 3 || res.Added != 3 { + t.Fatalf("scan result = %#v, want visited=3 added=3", res) + } + + libs, err := repos.Library.List(t.Context()) + if err != nil { + t.Fatal(err) + } + byDisplayDir := map[string]model.Library{} + for _, lib := range libs { + if !CloudLibraryAutoCategory(lib) { + continue + } + info, ok := ParseCloudLibraryMount(lib.Path) + if !ok { + t.Fatalf("auto category path did not parse: %q", lib.Path) + } + byDisplayDir[info.DisplayDir] = lib + } + wantTypes := map[string]string{ + "电视剧/欧美剧": "tv", + "电影/华语电影": "movie", + "动漫/国漫": "anime", + } + for dir, wantType := range wantTypes { + lib, ok := byDisplayDir[dir] + if !ok { + t.Fatalf("missing auto category library %q; got %#v", dir, byDisplayDir) + } + if lib.Type != wantType { + t.Fatalf("auto category %s type = %s, want %s", dir, lib.Type, wantType) + } + } + + var rows []model.Media + if err := repos.DB.Order("path").Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 3 { + t.Fatalf("media rows = %d, want 3", len(rows)) + } + wantLibraries := map[string]string{ + "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv": byDisplayDir["电视剧/欧美剧"].ID, + "cloud://openlist/电影/华语电影/Movie.2024.mkv": byDisplayDir["电影/华语电影"].ID, + "cloud://openlist/国漫/剑来/剑来.S01E01.mkv": byDisplayDir["动漫/国漫"].ID, + } + for _, row := range rows { + if row.LibraryID != wantLibraries[row.Path] { + t.Fatalf("%s library_id = %s, want %s", row.Path, row.LibraryID, wantLibraries[row.Path]) + } + } + + res, err = scanner.ScanLibrary(t.Context(), root.ID) + if err != nil { + t.Fatalf("rescan root cloud: %v", err) + } + if res.Added != 0 || res.Updated != 0 || res.Skipped != 3 { + t.Fatalf("rescan should skip unchanged auto-category rows, got %#v", res) + } + libs, err = repos.Library.List(t.Context()) + if err != nil { + t.Fatal(err) + } + autoCount := 0 + for _, lib := range libs { + if CloudLibraryAutoCategory(lib) { + autoCount++ + } + } + if autoCount != 3 { + t.Fatalf("auto category library count after rescan = %d, want 3", autoCount) + } + + empty = true + res, err = scanner.ScanLibrary(t.Context(), root.ID) + if err != nil { + t.Fatalf("empty rescan root cloud: %v", err) + } + if res.Removed != 3 { + t.Fatalf("removed = %d, want 3", res.Removed) + } + if got := countMedia(t, repos); got != 0 { + t.Fatalf("media count after auto-category prune = %d, want 0", got) + } +} + +func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) { + var active int32 + var maxActive int32 + var releaseOnce sync.Once + release := make(chan struct{}) + upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { + switch path { + case "/": + return []openListTestEntry{ + {Name: "A", IsDir: true}, + {Name: "B", IsDir: true}, + }, 2 + case "/A", "/B": + cur := atomic.AddInt32(&active, 1) + defer atomic.AddInt32(&active, -1) + for { + prev := atomic.LoadInt32(&maxActive) + if cur <= prev || atomic.CompareAndSwapInt32(&maxActive, prev, cur) { + break + } + } + if cur >= 2 { + releaseOnce.Do(func() { close(release) }) + } + select { + case <-release: + case <-time.After(1500 * time.Millisecond): + t.Errorf("child directory requests were not concurrent") + return nil, 0 + } + id := strings.TrimPrefix(path, "/") + return []openListTestEntry{{Name: fmt.Sprintf("Movie.%s.mkv", id), Size: 123}}, 1 + default: + t.Errorf("unexpected openlist path %q", path) + return nil, 0 + } + }) + defer upstream.Close() + + db, err := gorm.Open(sqlite.Open("file:cloud_scan_concurrent?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}); err != nil { + t.Fatal(err) + } + 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{ + "server": upstream.URL, + "token": "openlist-token", + }, + }); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.App.CloudScanMaxConcurrent = 2 + scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, nil) + scanner.SetStorageConfig(storage) + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) + defer cancel() + + res, err := scanner.ScanLibrary(ctx, lib.ID) + if err != nil { + t.Fatalf("scan cloud: %v", err) + } + if got := atomic.LoadInt32(&maxActive); got < 2 { + t.Fatalf("max concurrent child lists = %d, want >= 2", got) + } + if res.Visited != 2 || res.Added != 2 { + t.Fatalf("scan result = %#v, want visited=2 added=2", res) + } +} diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go index 6de6fd6..c0068e5 100644 --- a/internal/service/scanner_cloud_test.go +++ b/internal/service/scanner_cloud_test.go @@ -1,20 +1,14 @@ package service import ( - "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" - "sync" - "sync/atomic" "testing" - "time" - "github.com/glebarez/sqlite" "go.uber.org/zap" - "gorm.io/gorm" "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/model" @@ -166,231 +160,6 @@ func TestScanCloudLibraryImportsRecursivePlayableMedia(t *testing.T) { } } -func TestScanRootCloudLibraryCreatesAutoCategoryLibraries(t *testing.T) { - empty := false - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - if empty { - return nil, 0 - } - switch path { - case "/": - return []openListTestEntry{ - {Name: "电视剧", IsDir: true}, - {Name: "电影", IsDir: true}, - {Name: "国漫", IsDir: true}, - }, 3 - case "/电视剧": - return []openListTestEntry{{Name: "欧美剧", IsDir: true}}, 1 - case "/电视剧/欧美剧": - return []openListTestEntry{{Name: "The Show", IsDir: true}}, 1 - case "/电视剧/欧美剧/The Show": - return []openListTestEntry{{Name: "The.Show.S01E01.mkv", Size: 101}}, 1 - case "/电影": - return []openListTestEntry{{Name: "华语电影", IsDir: true}}, 1 - case "/电影/华语电影": - return []openListTestEntry{{Name: "Movie.2024.mkv", Size: 202}}, 1 - case "/国漫": - return []openListTestEntry{{Name: "剑来", IsDir: true}}, 1 - case "/国漫/剑来": - return []openListTestEntry{{Name: "剑来.S01E01.mkv", Size: 303}}, 1 - default: - t.Fatalf("unexpected openlist path %q", path) - return nil, 0 - } - }) - 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{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &root); err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - - res, err := scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("scan root cloud: %v", err) - } - if res.Visited != 3 || res.Added != 3 { - t.Fatalf("scan result = %#v, want visited=3 added=3", res) - } - - libs, err := repos.Library.List(t.Context()) - if err != nil { - t.Fatal(err) - } - byDisplayDir := map[string]model.Library{} - for _, lib := range libs { - if !CloudLibraryAutoCategory(lib) { - continue - } - info, ok := ParseCloudLibraryMount(lib.Path) - if !ok { - t.Fatalf("auto category path did not parse: %q", lib.Path) - } - byDisplayDir[info.DisplayDir] = lib - } - wantTypes := map[string]string{ - "电视剧/欧美剧": "tv", - "电影/华语电影": "movie", - "动漫/国漫": "anime", - } - for dir, wantType := range wantTypes { - lib, ok := byDisplayDir[dir] - if !ok { - t.Fatalf("missing auto category library %q; got %#v", dir, byDisplayDir) - } - if lib.Type != wantType { - t.Fatalf("auto category %s type = %s, want %s", dir, lib.Type, wantType) - } - } - - var rows []model.Media - if err := repos.DB.Order("path").Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 3 { - t.Fatalf("media rows = %d, want 3", len(rows)) - } - wantLibraries := map[string]string{ - "cloud://openlist/电视剧/欧美剧/The Show/The.Show.S01E01.mkv": byDisplayDir["电视剧/欧美剧"].ID, - "cloud://openlist/电影/华语电影/Movie.2024.mkv": byDisplayDir["电影/华语电影"].ID, - "cloud://openlist/国漫/剑来/剑来.S01E01.mkv": byDisplayDir["动漫/国漫"].ID, - } - for _, row := range rows { - if row.LibraryID != wantLibraries[row.Path] { - t.Fatalf("%s library_id = %s, want %s", row.Path, row.LibraryID, wantLibraries[row.Path]) - } - } - - res, err = scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("rescan root cloud: %v", err) - } - if res.Added != 0 || res.Updated != 0 || res.Skipped != 3 { - t.Fatalf("rescan should skip unchanged auto-category rows, got %#v", res) - } - libs, err = repos.Library.List(t.Context()) - if err != nil { - t.Fatal(err) - } - autoCount := 0 - for _, lib := range libs { - if CloudLibraryAutoCategory(lib) { - autoCount++ - } - } - if autoCount != 3 { - t.Fatalf("auto category library count after rescan = %d, want 3", autoCount) - } - - empty = true - res, err = scanner.ScanLibrary(t.Context(), root.ID) - if err != nil { - t.Fatalf("empty rescan root cloud: %v", err) - } - if res.Removed != 3 { - t.Fatalf("removed = %d, want 3", res.Removed) - } - if got := countMedia(t, repos); got != 0 { - t.Fatalf("media count after auto-category prune = %d, want 0", got) - } -} - -func TestScanCloudLibraryListsChildDirectoriesConcurrently(t *testing.T) { - var active int32 - var maxActive int32 - var releaseOnce sync.Once - release := make(chan struct{}) - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - switch path { - case "/": - return []openListTestEntry{ - {Name: "A", IsDir: true}, - {Name: "B", IsDir: true}, - }, 2 - case "/A", "/B": - cur := atomic.AddInt32(&active, 1) - defer atomic.AddInt32(&active, -1) - for { - prev := atomic.LoadInt32(&maxActive) - if cur <= prev || atomic.CompareAndSwapInt32(&maxActive, prev, cur) { - break - } - } - if cur >= 2 { - releaseOnce.Do(func() { close(release) }) - } - select { - case <-release: - case <-time.After(1500 * time.Millisecond): - t.Errorf("child directory requests were not concurrent") - return nil, 0 - } - id := strings.TrimPrefix(path, "/") - return []openListTestEntry{{Name: fmt.Sprintf("Movie.%s.mkv", id), Size: 123}}, 1 - default: - t.Errorf("unexpected openlist path %q", path) - return nil, 0 - } - }) - defer upstream.Close() - - db, err := gorm.Open(sqlite.Open("file:cloud_scan_concurrent?mode=memory&cache=shared"), &gorm.Config{}) - if err != nil { - t.Fatal(err) - } - if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.Setting{}, &model.StorageConfig{}); err != nil { - t.Fatal(err) - } - 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{ - "server": upstream.URL, - "token": "openlist-token", - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - cfg := &config.Config{} - cfg.App.CloudScanMaxConcurrent = 2 - scanner := NewScannerService(cfg, log, repos, NewHub(log), nil, nil) - scanner.SetStorageConfig(storage) - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - - res, err := scanner.ScanLibrary(ctx, lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if got := atomic.LoadInt32(&maxActive); got < 2 { - t.Fatalf("max concurrent child lists = %d, want >= 2", got) - } - if res.Visited != 2 || res.Added != 2 { - t.Fatalf("scan result = %#v, want visited=2 added=2", res) - } -} - func TestCloudLibraryPathParsing(t *testing.T) { typ, dir, ok := parseCloudLibraryPath("cloud://cloud115/abc%20123?ignored=1") if !ok || typ != "cloud115" || dir != "abc 123" {