diff --git a/internal/service/scanner_cloud_115_test.go b/internal/service/scanner_cloud_115_test.go new file mode 100644 index 0000000..a62d7eb --- /dev/null +++ b/internal/service/scanner_cloud_115_test.go @@ -0,0 +1,99 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestScan115CloudLibraryKeepsDisplayHierarchyAndSeasonCounts(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/files" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + switch r.URL.Query().Get("cid") { + case "100": + _, _ = w.Write([]byte(`{"state":true,"data":[ + {"cid":"s1","n":"Season 1","s":0}, + {"cid":"s2","n":"Season 2","s":0} + ]}`)) + case "s1": + _, _ = w.Write([]byte(`{"state":true,"data":[ + {"fid":"f101","n":"剑来 - S01E01.mkv","s":1001,"pc":"pick101"}, + {"fid":"f125","n":"剑来 - S01E25.mkv","s":1025,"pc":"pick125"} + ]}`)) + case "s2": + _, _ = w.Write([]byte(`{"state":true,"data":[ + {"fid":"f201","n":"剑来 - S02E01.mkv","s":2001,"pc":"pick201"}, + {"fid":"f204","n":"剑来 - S02E04.mkv","s":2004,"pc":"pick204"} + ]}`)) + default: + t.Fatalf("unexpected cid %q", r.URL.Query().Get("cid")) + } + })) + 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: "cloud115", + Config: map[string]any{ + "cookie": "UID=test", + "base": upstream.URL, + }, + }); err != nil { + t.Fatal(err) + } + lib := model.Library{Name: "115 · 国漫 · 剑来", Path: BuildCloudLibraryPath("cloud115", "100", "动漫/国漫/剑来"), 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) + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan cloud115: %v", err) + } + if res.Added != 4 { + t.Fatalf("scan result = %#v, want added=4", res) + } + var rows []model.Media + if err := repos.DB.Order("path").Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 4 { + t.Fatalf("media rows = %d, want 4", len(rows)) + } + want := map[string][2]int{ + "cloud://cloud115/动漫/国漫/剑来/Season 1/剑来 - S01E01.mkv": {1, 1}, + "cloud://cloud115/动漫/国漫/剑来/Season 1/剑来 - S01E25.mkv": {1, 25}, + "cloud://cloud115/动漫/国漫/剑来/Season 2/剑来 - S02E01.mkv": {2, 1}, + "cloud://cloud115/动漫/国漫/剑来/Season 2/剑来 - S02E04.mkv": {2, 4}, + } + for _, row := range rows { + seasonEpisode, ok := want[row.Path] + if !ok { + t.Fatalf("unexpected path %q", row.Path) + } + if row.Title != "剑来" { + t.Fatalf("title = %q, want 剑来", row.Title) + } + if row.SeasonNum != seasonEpisode[0] || row.EpisodeNum != seasonEpisode[1] { + t.Fatalf("%s season/episode = %d/%d, want %d/%d", row.Path, row.SeasonNum, row.EpisodeNum, seasonEpisode[0], seasonEpisode[1]) + } + if !strings.Contains(row.STRMURL, "ref=pick") { + t.Fatalf("115 playback should keep pickcode ref, got %q", row.STRMURL) + } + } +} diff --git a/internal/service/scanner_cloud_openlist_test.go b/internal/service/scanner_cloud_openlist_test.go new file mode 100644 index 0000000..0380725 --- /dev/null +++ b/internal/service/scanner_cloud_openlist_test.go @@ -0,0 +1,270 @@ +package service + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestScanCloudLibraryImportsRecursivePlayableMedia(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: "Movies", IsDir: true}, + {Name: "Root.Movie.2024.mkv", Size: 123}, + }, 2 + case "/Movies": + return []openListTestEntry{ + {Name: "Nested.Show.S01E02.mp4", Size: 456}, + }, 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) + } + lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", 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) + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan cloud: %v", err) + } + if res.Visited != 2 || res.Added != 2 { + t.Fatalf("scan result = %#v, want visited=2 added=2", res) + } + var rows []model.Media + if err := repos.DB.Order("path").Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("media rows = %d, want 2: %#v", len(rows), rows) + } + if rows[0].Path != "cloud://openlist/Movies/Nested.Show.S01E02.mp4" || !strings.Contains(rows[0].STRMURL, "ref=%2FMovies%2FNested.Show.S01E02.mp4") { + t.Fatalf("nested media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) + } + if rows[0].SeasonNum != 1 || rows[0].EpisodeNum != 2 { + t.Fatalf("nested episode metadata wrong: %#v", rows[0]) + } + if rows[1].Path != "cloud://openlist/Root.Movie.2024.mkv" || rows[1].STRMURL != "/api/cloud/play/openlist?ref=%2FRoot.Movie.2024.mkv" { + t.Fatalf("root media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) + } + + res, err = scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("rescan same cloud: %v", err) + } + if res.Added != 0 || res.Updated != 0 || res.Skipped != 2 { + t.Fatalf("same cloud rescan should skip unchanged rows, got %#v", res) + } + + empty = true + res, err = scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("rescan cloud: %v", err) + } + if res.Removed != 2 { + t.Fatalf("removed = %d, want 2", res.Removed) + } + if got := countMedia(t, repos); got != 0 { + t.Fatalf("media count after prune = %d, want 0", got) + } + var allRows int64 + if err := repos.DB.Unscoped().Model(&model.Media{}).Count(&allRows).Error; err != nil { + t.Fatal(err) + } + if allRows != 0 { + t.Fatalf("unscoped media count after cloud prune = %d, want 0", allRows) + } +} + +func TestScanOpenListCloudLibraryUsesAPIPaginationBeyondFirstPage(t *testing.T) { + const totalFiles = 125 + requestedPages := map[int]bool{} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/fs/list" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Fatalf("unexpected method %s", r.Method) + } + if r.Header.Get("Authorization") != "openlist-token" { + t.Fatalf("missing openlist token: %q", r.Header.Get("Authorization")) + } + var in struct { + Path string `json:"path"` + Page int `json:"page"` + PerPage int `json:"per_page"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + t.Fatalf("decode request: %v", err) + } + if in.Path != "/Movies" { + t.Fatalf("path = %q, want /Movies", in.Path) + } + if in.PerPage <= 100 { + t.Fatalf("per_page = %d, want API pagination larger than legacy 100", in.PerPage) + } + requestedPages[in.Page] = true + effectivePageSize := in.PerPage + if effectivePageSize > 100 { + effectivePageSize = 100 + } + start := (in.Page - 1) * effectivePageSize + content := []map[string]any{} + for idx := start; idx < totalFiles && idx < start+effectivePageSize; idx++ { + content = append(content, map[string]any{ + "name": fmt.Sprintf("Movie.%03d.mkv", idx+1), + "size": int64(1024 + idx), + "is_dir": false, + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 200, + "message": "success", + "data": map[string]any{ + "content": content, + "total": totalFiles, + }, + }) + })) + 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) + } + lib := model.Library{Name: "OpenList · Movies", Path: BuildCloudLibraryPath("openlist", "/Movies", "/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) + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan openlist: %v", err) + } + if res.Added != totalFiles { + t.Fatalf("scan result = %#v, want added=%d", res, totalFiles) + } + if got := countMedia(t, repos); got != totalFiles { + t.Fatalf("media count = %d, want %d", got, totalFiles) + } + if !requestedPages[1] || !requestedPages[2] { + t.Fatalf("expected pagination beyond the first 100 entries, got pages %#v", requestedPages) + } +} + +func TestScanCloudLibraryQueuesMissingExistingTrackMetadataBeforeNewFiles(t *testing.T) { + const newFiles = maxCloudMediaProbeQueuePerScan + 5 + upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { + if path != "/" { + t.Fatalf("unexpected openlist path %q", path) + } + entries := make([]openListTestEntry, 0, newFiles+1) + for i := 0; i < newFiles; i++ { + entries = append(entries, openListTestEntry{Name: fmt.Sprintf("New.Movie.%02d.mkv", i), Size: int64(1000 + i)}) + } + entries = append(entries, openListTestEntry{Name: "Existing.Show.S01E01.mkv", Size: 2048}) + return entries, len(entries) + }) + 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) + } + lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + existingPath := "cloud://openlist/Existing.Show.S01E01.mkv" + if err := repos.DB.Create(&model.Media{ + LibraryID: lib.ID, + Title: "Existing Show", + Path: existingPath, + SizeBytes: 2048, + Container: "mkv", + STRMURL: "/api/cloud/play/openlist?ref=%2FExisting.Show.S01E01.mkv", + SeasonNum: 1, + EpisodeNum: 1, + }).Error; err != nil { + t.Fatal(err) + } + scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), NewFFprobeService(&config.Config{}, log), nil) + scanner.storage = storage + + res, err := scanner.ScanLibrary(t.Context(), lib.ID) + if err != nil { + t.Fatalf("scan cloud: %v", err) + } + if res.Added != newFiles || res.Skipped != 1 { + t.Fatalf("scan result = %#v, want new files added and existing skipped", res) + } + foundExistingProbe := false + for { + select { + case task := <-scanner.cloudMediaProbeQueue: + if task.path == existingPath { + foundExistingProbe = true + } + default: + if !foundExistingProbe { + t.Fatal("existing media missing track metadata did not receive probe budget before new files") + } + return + } + } +} diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go index c0068e5..f18bcda 100644 --- a/internal/service/scanner_cloud_test.go +++ b/internal/service/scanner_cloud_test.go @@ -1,11 +1,6 @@ package service import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" "testing" "go.uber.org/zap" @@ -15,151 +10,6 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/repository" ) -type openListTestEntry struct { - Name string - Size int64 - IsDir bool -} - -func newOpenListAPIServer(t *testing.T, list func(path string, page, perPage int) ([]openListTestEntry, int)) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/list" { - t.Fatalf("unexpected openlist api request %s", r.URL.Path) - } - var in struct { - Path string `json:"path"` - Page int `json:"page"` - PerPage int `json:"per_page"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - t.Fatalf("decode openlist list request: %v", err) - } - if in.Path == "" { - in.Path = "/" - } - if in.Page <= 0 { - in.Page = 1 - } - if in.PerPage <= 0 { - in.PerPage = 500 - } - entries, total := list(in.Path, in.Page, in.PerPage) - content := make([]map[string]any, 0, len(entries)) - for _, entry := range entries { - content = append(content, map[string]any{ - "name": entry.Name, - "size": entry.Size, - "is_dir": entry.IsDir, - }) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "code": 200, - "message": "success", - "data": map[string]any{ - "content": content, - "total": total, - }, - }) - })) -} - -func TestScanCloudLibraryImportsRecursivePlayableMedia(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: "Movies", IsDir: true}, - {Name: "Root.Movie.2024.mkv", Size: 123}, - }, 2 - case "/Movies": - return []openListTestEntry{ - {Name: "Nested.Show.S01E02.mp4", Size: 456}, - }, 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) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", 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) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Visited != 2 || res.Added != 2 { - t.Fatalf("scan result = %#v, want visited=2 added=2", res) - } - var rows []model.Media - if err := repos.DB.Order("path").Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 2 { - t.Fatalf("media rows = %d, want 2: %#v", len(rows), rows) - } - if rows[0].Path != "cloud://openlist/Movies/Nested.Show.S01E02.mp4" || !strings.Contains(rows[0].STRMURL, "ref=%2FMovies%2FNested.Show.S01E02.mp4") { - t.Fatalf("nested media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) - } - if rows[0].SeasonNum != 1 || rows[0].EpisodeNum != 2 { - t.Fatalf("nested episode metadata wrong: %#v", rows[0]) - } - if rows[1].Path != "cloud://openlist/Root.Movie.2024.mkv" || rows[1].STRMURL != "/api/cloud/play/openlist?ref=%2FRoot.Movie.2024.mkv" { - t.Fatalf("root media path/strm wrong: path=%q strm=%q", rows[0].Path, rows[0].STRMURL) - } - - res, err = scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("rescan same cloud: %v", err) - } - if res.Added != 0 || res.Updated != 0 || res.Skipped != 2 { - t.Fatalf("same cloud rescan should skip unchanged rows, got %#v", res) - } - - empty = true - res, err = scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("rescan cloud: %v", err) - } - if res.Removed != 2 { - t.Fatalf("removed = %d, want 2", res.Removed) - } - if got := countMedia(t, repos); got != 0 { - t.Fatalf("media count after prune = %d, want 0", got) - } - var allRows int64 - if err := repos.DB.Unscoped().Model(&model.Media{}).Count(&allRows).Error; err != nil { - t.Fatal(err) - } - if allRows != 0 { - t.Fatalf("unscoped media count after cloud prune = %d, want 0", allRows) - } -} - func TestCloudLibraryPathParsing(t *testing.T) { typ, dir, ok := parseCloudLibraryPath("cloud://cloud115/abc%20123?ignored=1") if !ok || typ != "cloud115" || dir != "abc 123" { @@ -254,91 +104,6 @@ func TestInferCloudMountMediaType(t *testing.T) { } } -func TestScan115CloudLibraryKeepsDisplayHierarchyAndSeasonCounts(t *testing.T) { - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/files" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - switch r.URL.Query().Get("cid") { - case "100": - _, _ = w.Write([]byte(`{"state":true,"data":[ - {"cid":"s1","n":"Season 1","s":0}, - {"cid":"s2","n":"Season 2","s":0} - ]}`)) - case "s1": - _, _ = w.Write([]byte(`{"state":true,"data":[ - {"fid":"f101","n":"剑来 - S01E01.mkv","s":1001,"pc":"pick101"}, - {"fid":"f125","n":"剑来 - S01E25.mkv","s":1025,"pc":"pick125"} - ]}`)) - case "s2": - _, _ = w.Write([]byte(`{"state":true,"data":[ - {"fid":"f201","n":"剑来 - S02E01.mkv","s":2001,"pc":"pick201"}, - {"fid":"f204","n":"剑来 - S02E04.mkv","s":2004,"pc":"pick204"} - ]}`)) - default: - t.Fatalf("unexpected cid %q", r.URL.Query().Get("cid")) - } - })) - 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: "cloud115", - Config: map[string]any{ - "cookie": "UID=test", - "base": upstream.URL, - }, - }); err != nil { - t.Fatal(err) - } - lib := model.Library{Name: "115 · 国漫 · 剑来", Path: BuildCloudLibraryPath("cloud115", "100", "动漫/国漫/剑来"), 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) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud115: %v", err) - } - if res.Added != 4 { - t.Fatalf("scan result = %#v, want added=4", res) - } - var rows []model.Media - if err := repos.DB.Order("path").Find(&rows).Error; err != nil { - t.Fatal(err) - } - if len(rows) != 4 { - t.Fatalf("media rows = %d, want 4", len(rows)) - } - want := map[string][2]int{ - "cloud://cloud115/动漫/国漫/剑来/Season 1/剑来 - S01E01.mkv": {1, 1}, - "cloud://cloud115/动漫/国漫/剑来/Season 1/剑来 - S01E25.mkv": {1, 25}, - "cloud://cloud115/动漫/国漫/剑来/Season 2/剑来 - S02E01.mkv": {2, 1}, - "cloud://cloud115/动漫/国漫/剑来/Season 2/剑来 - S02E04.mkv": {2, 4}, - } - for _, row := range rows { - seasonEpisode, ok := want[row.Path] - if !ok { - t.Fatalf("unexpected path %q", row.Path) - } - if row.Title != "剑来" { - t.Fatalf("title = %q, want 剑来", row.Title) - } - if row.SeasonNum != seasonEpisode[0] || row.EpisodeNum != seasonEpisode[1] { - t.Fatalf("%s season/episode = %d/%d, want %d/%d", row.Path, row.SeasonNum, row.EpisodeNum, seasonEpisode[0], seasonEpisode[1]) - } - if !strings.Contains(row.STRMURL, "ref=pick") { - t.Fatalf("115 playback should keep pickcode ref, got %q", row.STRMURL) - } - } -} - func TestCloudSeriesTitlePrefersShowFolder(t *testing.T) { title, year := cloudSeriesTitleFromMediaPath("cloud://openlist/国产剧/紫川 (2024) {tmdb-247590}/Season 2/紫川.2024.S02E24.第24集.2160p.WEB-DL.H.265-ColorTV.mkv") if title != "紫川" || year != 2024 { @@ -365,165 +130,6 @@ func TestCloudMetadataNeedsRefreshWhenPathHintConflicts(t *testing.T) { } } -func TestScanOpenListCloudLibraryUsesAPIPaginationBeyondFirstPage(t *testing.T) { - const totalFiles = 125 - requestedPages := map[int]bool{} - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/fs/list" { - t.Fatalf("unexpected path %s", r.URL.Path) - } - if r.Method != http.MethodPost { - t.Fatalf("unexpected method %s", r.Method) - } - if r.Header.Get("Authorization") != "openlist-token" { - t.Fatalf("missing openlist token: %q", r.Header.Get("Authorization")) - } - var in struct { - Path string `json:"path"` - Page int `json:"page"` - PerPage int `json:"per_page"` - } - if err := json.NewDecoder(r.Body).Decode(&in); err != nil { - t.Fatalf("decode request: %v", err) - } - if in.Path != "/Movies" { - t.Fatalf("path = %q, want /Movies", in.Path) - } - if in.PerPage <= 100 { - t.Fatalf("per_page = %d, want API pagination larger than legacy 100", in.PerPage) - } - requestedPages[in.Page] = true - effectivePageSize := in.PerPage - if effectivePageSize > 100 { - effectivePageSize = 100 - } - start := (in.Page - 1) * effectivePageSize - content := []map[string]any{} - for idx := start; idx < totalFiles && idx < start+effectivePageSize; idx++ { - content = append(content, map[string]any{ - "name": fmt.Sprintf("Movie.%03d.mkv", idx+1), - "size": int64(1024 + idx), - "is_dir": false, - }) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "code": 200, - "message": "success", - "data": map[string]any{ - "content": content, - "total": totalFiles, - }, - }) - })) - 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) - } - lib := model.Library{Name: "OpenList · Movies", Path: BuildCloudLibraryPath("openlist", "/Movies", "/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) - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan openlist: %v", err) - } - if res.Added != totalFiles { - t.Fatalf("scan result = %#v, want added=%d", res, totalFiles) - } - if got := countMedia(t, repos); got != totalFiles { - t.Fatalf("media count = %d, want %d", got, totalFiles) - } - if !requestedPages[1] || !requestedPages[2] { - t.Fatalf("expected pagination beyond the first 100 entries, got pages %#v", requestedPages) - } -} - -func TestScanCloudLibraryQueuesMissingExistingTrackMetadataBeforeNewFiles(t *testing.T) { - const newFiles = maxCloudMediaProbeQueuePerScan + 5 - upstream := newOpenListAPIServer(t, func(path string, page, perPage int) ([]openListTestEntry, int) { - if path != "/" { - t.Fatalf("unexpected openlist path %q", path) - } - entries := make([]openListTestEntry, 0, newFiles+1) - for i := 0; i < newFiles; i++ { - entries = append(entries, openListTestEntry{Name: fmt.Sprintf("New.Movie.%02d.mkv", i), Size: int64(1000 + i)}) - } - entries = append(entries, openListTestEntry{Name: "Existing.Show.S01E01.mkv", Size: 2048}) - return entries, len(entries) - }) - 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) - } - lib := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - existingPath := "cloud://openlist/Existing.Show.S01E01.mkv" - if err := repos.DB.Create(&model.Media{ - LibraryID: lib.ID, - Title: "Existing Show", - Path: existingPath, - SizeBytes: 2048, - Container: "mkv", - STRMURL: "/api/cloud/play/openlist?ref=%2FExisting.Show.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - }).Error; err != nil { - t.Fatal(err) - } - scanner := NewScannerService(&config.Config{}, log, repos, NewHub(log), NewFFprobeService(&config.Config{}, log), nil) - scanner.storage = storage - - res, err := scanner.ScanLibrary(t.Context(), lib.ID) - if err != nil { - t.Fatalf("scan cloud: %v", err) - } - if res.Added != newFiles || res.Skipped != 1 { - t.Fatalf("scan result = %#v, want new files added and existing skipped", res) - } - foundExistingProbe := false - for { - select { - case task := <-scanner.cloudMediaProbeQueue: - if task.path == existingPath { - foundExistingProbe = true - } - default: - if !foundExistingProbe { - t.Fatal("existing media missing track metadata did not receive probe budget before new files") - } - return - } - } -} - func TestParseCloudArtworkURL(t *testing.T) { typ, ref, ok := ParseCloudArtworkURL("http://nas.local/api/cloud/play/openlist?ref=%2FAnime%2FJianLai%2Fposter.jpg") if !ok || typ != "openlist" || ref != "/Anime/JianLai/poster.jpg" { diff --git a/internal/service/scanner_cloud_test_helpers_test.go b/internal/service/scanner_cloud_test_helpers_test.go new file mode 100644 index 0000000..33504ba --- /dev/null +++ b/internal/service/scanner_cloud_test_helpers_test.go @@ -0,0 +1,58 @@ +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +type openListTestEntry struct { + Name string + Size int64 + IsDir bool +} + +func newOpenListAPIServer(t *testing.T, list func(path string, page, perPage int) ([]openListTestEntry, int)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/fs/list" { + t.Fatalf("unexpected openlist api request %s", r.URL.Path) + } + var in struct { + Path string `json:"path"` + Page int `json:"page"` + PerPage int `json:"per_page"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + t.Fatalf("decode openlist list request: %v", err) + } + if in.Path == "" { + in.Path = "/" + } + if in.Page <= 0 { + in.Page = 1 + } + if in.PerPage <= 0 { + in.PerPage = 500 + } + entries, total := list(in.Path, in.Page, in.PerPage) + content := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + content = append(content, map[string]any{ + "name": entry.Name, + "size": entry.Size, + "is_dir": entry.IsDir, + }) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 200, + "message": "success", + "data": map[string]any{ + "content": content, + "total": total, + }, + }) + })) +}