diff --git a/internal/service/media_cloud_test.go b/internal/service/media_cloud_test.go new file mode 100644 index 0000000..7314207 --- /dev/null +++ b/internal/service/media_cloud_test.go @@ -0,0 +1,220 @@ +package service + +import ( + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + 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) + } + if err := repos.Media.Upsert(t.Context(), &model.Media{ + LibraryID: lib.ID, + Title: "剑来", + Path: "cloud://openlist/Anime/JianLai/Season 1/01.mkv", + STRMURL: "/api/cloud/play/openlist?ref=/Anime/JianLai/Season%201/01.mkv", + }); err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + if err := svc.DeleteLibrary(t.Context(), lib.ID); err != nil { + t.Fatal(err) + } + + var mediaCount int64 + if err := db.Unscoped().Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&mediaCount).Error; err != nil { + t.Fatal(err) + } + if mediaCount != 0 { + t.Fatalf("cloud mount media should be purged, count=%d", mediaCount) + } + recycle, err := svc.ListRecycleBin(t.Context(), 100) + if err != nil { + t.Fatal(err) + } + if len(recycle) != 0 { + t.Fatalf("cloud mount removal must not populate recycle bin: %#v", recycle) + } + var libCount int64 + if err := db.Unscoped().Model(&model.Library{}).Where("id = ?", lib.ID).Count(&libCount).Error; err != nil { + t.Fatal(err) + } + if libCount != 0 { + t.Fatalf("cloud mount library should be purged, count=%d", libCount) + } +} + +func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + path := "cloud://openlist/国漫/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" + if err := repos.DB.Create(&model.Media{ + LibraryID: "cloud-tv", + Title: "折腰", + Path: path, + SeasonNum: 1, + EpisodeNum: 1, + ScrapeStatus: "pending", + }).Error; err != nil { + t.Fatal(err) + } + if err := repos.Media.Upsert(t.Context(), &model.Media{ + LibraryID: "cloud-tv", + Title: "折腰", + Path: path, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 296753, + Year: 2025, + ScrapeStatus: "pending", + }); err != nil { + t.Fatal(err) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { + t.Fatal(err) + } + if got.TMDbID != 296753 || got.Year != 2025 || got.ScrapeStatus != "pending" { + t.Fatalf("pending cloud row was not backfilled correctly: tmdb=%d year=%d status=%q", got.TMDbID, got.Year, got.ScrapeStatus) + } +} + +func TestMediaUpsertCorrectsCloudExternalIDConflicts(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" + if err := repos.DB.Create(&model.Media{ + LibraryID: "cloud-tv", + Title: "折腰", + Path: path, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 220269, + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + if err := repos.Media.Upsert(t.Context(), &model.Media{ + LibraryID: "cloud-tv", + Title: "折腰", + Path: path, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 296753, + Year: 2025, + ScrapeStatus: "pending", + }); err != nil { + t.Fatal(err) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { + t.Fatal(err) + } + if got.TMDbID != 296753 || got.ScrapeStatus != "pending" { + t.Fatalf("cloud external id conflict was not corrected: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) + } +} + +func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + path := "cloud://openlist/动画电影/雄狮少年2 (2024) {tmdb-1154478}/雄狮少年2 (2024) - 2160p.WEB-DL.H.265.DDP 5.1-ADWeb.mp4" + if err := repos.DB.Create(&model.Media{ + LibraryID: "cloud-movie", + Title: "雄狮少年2 adweb", + Path: path, + ScrapeStatus: "no_match", + }).Error; err != nil { + t.Fatal(err) + } + container := &Container{Repo: repos, Log: zap.NewNop()} + repaired, err := container.RepairCloudPathMetadata(t.Context()) + if err != nil { + t.Fatal(err) + } + if repaired != 1 { + t.Fatalf("repaired = %d, want 1", repaired) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { + t.Fatal(err) + } + if got.TMDbID != 1154478 || got.Year != 2024 || got.Title != "雄狮少年2" || got.ScrapeStatus != "pending" { + t.Fatalf("placeholder was not repaired: title=%q tmdb=%d year=%d status=%q", got.Title, got.TMDbID, got.Year, got.ScrapeStatus) + } +} + +func TestRepairCloudPathMetadataCorrectsConflictingMatchedID(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" + if err := repos.DB.Create(&model.Media{ + LibraryID: "cloud-tv", + Title: "折腰", + Path: path, + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 220269, + ScrapeStatus: "matched", + }).Error; err != nil { + t.Fatal(err) + } + container := &Container{Repo: repos, Log: zap.NewNop()} + repaired, err := container.RepairCloudPathMetadata(t.Context()) + if err != nil { + t.Fatal(err) + } + if repaired != 1 { + t.Fatalf("repaired = %d, want 1", repaired) + } + var got model.Media + if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { + t.Fatal(err) + } + if got.TMDbID != 296753 || got.ScrapeStatus != "pending" { + t.Fatalf("conflicting matched id was not repaired: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) + } +} + +func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + media := model.Media{ + Base: model.Base{ID: "cloud-media"}, + Title: "网盘电影", + Path: "cloud://openlist/电影/Movie.mkv", + STRMURL: "/api/cloud/play/openlist?ref=/电影/Movie.mkv", + } + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + if err := svc.SoftDelete(t.Context(), media.ID); err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", media.ID).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("cloud media should be purged, count=%d", count) + } + recycle, err := svc.ListRecycleBin(t.Context(), 100) + if err != nil { + t.Fatal(err) + } + if len(recycle) != 0 { + t.Fatalf("cloud media removal must not populate recycle bin: %#v", recycle) + } +} diff --git a/internal/service/media_metadata_test.go b/internal/service/media_metadata_test.go new file mode 100644 index 0000000..24bf5c7 --- /dev/null +++ b/internal/service/media_metadata_test.go @@ -0,0 +1,48 @@ +package service + +import ( + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Name: "自采集", Path: "/media/custom", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + media := model.Media{Base: model.Base{ID: "custom-media"}, LibraryID: lib.ID, Title: "raw", Path: "/media/custom/raw.mp4", ScrapeStatus: "no_match"} + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + title := "手动标题" + overview := "手动简介" + season := 0 + episode := 1 + tmdbID := 12345 + nsfw := true + updated, err := svc.UpdateMetadata(t.Context(), media.ID, MediaMetadataUpdate{ + Title: &title, + Overview: &overview, + SeasonNum: &season, + EpisodeNum: &episode, + TMDbID: &tmdbID, + NSFW: &nsfw, + }) + if err != nil { + t.Fatalf("update metadata: %v", err) + } + if updated.Title != title || updated.Overview != overview || updated.ScrapeStatus != "matched" { + t.Fatalf("metadata not saved: %#v", updated) + } + if updated.SeasonNum != 0 || updated.EpisodeNum != 1 || updated.TMDbID != tmdbID || !updated.NSFW { + t.Fatalf("ids/episode metadata not saved: %#v", updated) + } +} diff --git a/internal/service/media_recycle_test.go b/internal/service/media_recycle_test.go new file mode 100644 index 0000000..1e5796d --- /dev/null +++ b/internal/service/media_recycle_test.go @@ -0,0 +1,88 @@ +package service + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "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 TestListRecycleBinPrunesOldRowsOverLimit(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + now := time.Now() + for i := 0; i < maxRecycleBinRecords+5; i++ { + deletedAt := now.Add(time.Duration(i) * time.Second) + media := model.Media{ + Base: model.Base{ + ID: fmt.Sprintf("media-%03d", i), + DeletedAt: gorm.DeletedAt{Time: deletedAt, Valid: true}, + }, + Title: fmt.Sprintf("Movie %03d", i), + Path: filepath.Join(t.TempDir(), fmt.Sprintf("Movie %03d.mkv", i)), + } + if err := db.Unscoped().Create(&media).Error; err != nil { + t.Fatal(err) + } + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + rows, err := svc.ListRecycleBin(t.Context(), 500) + if err != nil { + t.Fatal(err) + } + if len(rows) != maxRecycleBinRecords { + t.Fatalf("recycle rows = %d, want %d", len(rows), maxRecycleBinRecords) + } + var count int64 + if err := db.Unscoped().Model(&model.Media{}).Where("deleted_at IS NOT NULL").Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != maxRecycleBinRecords { + t.Fatalf("stored recycle rows = %d, want %d", count, maxRecycleBinRecords) + } + var oldCount int64 + if err := db.Unscoped().Model(&model.Media{}).Where("id IN ?", []string{"media-000", "media-001", "media-002", "media-003", "media-004"}).Count(&oldCount).Error; err != nil { + t.Fatal(err) + } + if oldCount != 0 { + t.Fatalf("oldest recycle rows were not pruned, count=%d", oldCount) + } +} + +func TestSoftDeleteInvalidatesMediaAndStatsCache(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + media := model.Media{ + Base: model.Base{ID: "local-media"}, + Title: "Cached Movie", + Path: filepath.Join(t.TempDir(), "Cached Movie.mkv"), + } + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop()) + cache.SetJSON(t.Context(), "media:list:test", map[string]string{"state": "stale"}, time.Minute) + cache.SetJSON(t.Context(), "stats:snapshot:base", map[string]int{"media": 1}, time.Minute) + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).SetRuntimeCache(cache) + if err := svc.SoftDelete(t.Context(), media.ID); err != nil { + t.Fatal(err) + } + + var mediaCache map[string]string + if cache.GetJSON(t.Context(), "media:list:test", &mediaCache) { + t.Fatal("soft delete should invalidate media cache") + } + var statsCache map[string]int + if cache.GetJSON(t.Context(), "stats:snapshot:base", &statsCache) { + t.Fatal("soft delete should invalidate stats cache") + } +} diff --git a/internal/service/media_test.go b/internal/service/media_test.go index 14d08a5..3eda67b 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -1,18 +1,9 @@ package service import ( - "fmt" "os" "path/filepath" "testing" - "time" - - "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 TestResolveAccessibleLibraryPathMapsConfiguredHostMediaDir(t *testing.T) { @@ -176,530 +167,3 @@ func TestResolveAccessibleMappedPathMapsWindowsDownloadVariants(t *testing.T) { }) } } - -func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - 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) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: lib.ID, - Title: "剑来", - Path: "cloud://openlist/Anime/JianLai/Season 1/01.mkv", - STRMURL: "/api/cloud/play/openlist?ref=/Anime/JianLai/Season%201/01.mkv", - }); err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - if err := svc.DeleteLibrary(t.Context(), lib.ID); err != nil { - t.Fatal(err) - } - - var mediaCount int64 - if err := db.Unscoped().Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&mediaCount).Error; err != nil { - t.Fatal(err) - } - if mediaCount != 0 { - t.Fatalf("cloud mount media should be purged, count=%d", mediaCount) - } - recycle, err := svc.ListRecycleBin(t.Context(), 100) - if err != nil { - t.Fatal(err) - } - if len(recycle) != 0 { - t.Fatalf("cloud mount removal must not populate recycle bin: %#v", recycle) - } - var libCount int64 - if err := db.Unscoped().Model(&model.Library{}).Where("id = ?", lib.ID).Count(&libCount).Error; err != nil { - t.Fatal(err) - } - if libCount != 0 { - t.Fatalf("cloud mount library should be purged, count=%d", libCount) - } -} - -func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T) { - local := model.Media{ - LibraryID: "local-tv", - Title: "折腰", - Path: "/media/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 296753, - SizeBytes: 100, - PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", - } - cloud := model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv", - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 296753, - SizeBytes: 200, - STRMURL: "/api/cloud/play/openlist?ref=/国产剧/折腰/01.mkv", - } - - grouped := groupMediaVersions([]model.Media{local, cloud}) - if len(grouped) != 1 { - t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped) - } - if len(grouped[0].Versions) != 2 { - t.Fatalf("versions len = %d, want 2: %#v", len(grouped[0].Versions), grouped[0].Versions) - } - if grouped[0].Media.Path != local.Path { - t.Fatalf("local version should remain primary, got %q want %q", grouped[0].Media.Path, local.Path) - } - if grouped[0].Versions[0].Path != local.Path || grouped[0].Versions[1].Path != cloud.Path { - t.Fatalf("versions should be ordered local before cloud, got %#v", grouped[0].Versions) - } -} - -func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) { - hd := model.Media{ - LibraryID: "movies", - Title: "Inception 2010 1080p BluRay x264", - Path: "/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", - Year: 2010, - SizeBytes: 100, - VideoCodec: "h264", - ScrapeStatus: "pending", - } - uhd := model.Media{ - LibraryID: "movies", - Title: "Inception 2010 2160p UHD BluRay x265", - Path: "/media/Movies/Inception.2010.2160p.UHD.BluRay.x265.mkv", - Year: 2010, - SizeBytes: 200, - VideoCodec: "hevc", - ScrapeStatus: "pending", - } - - grouped := groupMediaVersions([]model.Media{hd, uhd}) - if len(grouped) != 1 { - t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped) - } - if len(grouped[0].Versions) != 2 { - t.Fatalf("versions len = %d, want 2: %#v", len(grouped[0].Versions), grouped[0].Versions) - } - if grouped[0].Media.Path != uhd.Path { - t.Fatalf("larger 2160p version should be primary, got %q want %q", grouped[0].Media.Path, uhd.Path) - } -} - -func TestGroupMediaVersionsCleansCodecPunctuation(t *testing.T) { - web := model.Media{ - LibraryID: "movies", - Title: "Everything Everywhere All at Once 2022 WEB-DL H.265 DDP5.1", - Path: "/media/Movies/Everything.Everywhere.All.At.Once.2022.2160p.WEB-DL.H.265.DDP5.1-GRP.mkv", - SizeBytes: 200, - VideoCodec: "hevc", - ScrapeStatus: "pending", - } - bluray := model.Media{ - LibraryID: "movies", - Title: "Everything Everywhere All at Once 2022 BluRay x264 DTS", - Path: "/media/Movies/Everything.Everywhere.All.At.Once.2022.1080p.BluRay.x264.DTS-GRP.mkv", - SizeBytes: 100, - VideoCodec: "h264", - ScrapeStatus: "pending", - } - - grouped := groupMediaVersions([]model.Media{web, bluray}) - if len(grouped) != 1 { - t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped) - } - if len(grouped[0].Versions) != 2 { - t.Fatalf("versions len = %d, want 2: %#v", len(grouped[0].Versions), grouped[0].Versions) - } - if grouped[0].Media.Path != web.Path { - t.Fatalf("larger WEB-DL version should be primary, got %q want %q", grouped[0].Media.Path, web.Path) - } -} - -func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - now := time.Now() - rows := []model.Media{ - { - Base: model.Base{CreatedAt: now.Add(2 * time.Hour), UpdatedAt: now.Add(2 * time.Hour)}, - LibraryID: lib.ID, - Title: "Inception 2010 2160p UHD BluRay x265", - Path: "/media/movies/Inception.2010.2160p.UHD.BluRay.x265.mkv", - TMDbID: 27205, - Year: 2010, - Width: 3840, - Height: 2160, - SizeBytes: 200, - }, - { - Base: model.Base{CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)}, - LibraryID: lib.ID, - Title: "Inception 2010 1080p BluRay x264", - Path: "/media/movies/Inception.2010.1080p.BluRay.x264.mkv", - TMDbID: 27205, - Year: 2010, - Width: 1920, - Height: 1080, - SizeBytes: 100, - }, - { - Base: model.Base{CreatedAt: now, UpdatedAt: now}, - LibraryID: lib.ID, - Title: "The Matrix 1999 1080p BluRay", - Path: "/media/movies/The.Matrix.1999.1080p.BluRay.mkv", - TMDbID: 603, - Year: 1999, - SizeBytes: 90, - }, - } - if err := db.Create(&rows).Error; err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - page, total, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 1, 1, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 2 { - t.Fatalf("grouped total = %d, want 2", total) - } - if len(page) != 1 || len(page[0].Versions) != 2 { - t.Fatalf("first page should contain merged Inception versions, got %#v", page) - } - if page[0].Media.Path != rows[0].Path { - t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path) - } -} - -func TestSearchMediaVisiblePageGroupedPaginatesAfterVersionGrouping(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - now := time.Now() - rows := []model.Media{ - { - Base: model.Base{CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)}, - LibraryID: "movies", - Title: "Dune 2021 2160p WEB-DL H265", - Path: "/media/movies/Dune.2021.2160p.WEB-DL.H265.mkv", - TMDbID: 438631, - Year: 2021, - Width: 3840, - Height: 2160, - SizeBytes: 220, - }, - { - Base: model.Base{CreatedAt: now, UpdatedAt: now}, - LibraryID: "movies", - Title: "Dune 2021 1080p BluRay x264", - Path: "/media/movies/Dune.2021.1080p.BluRay.x264.mkv", - TMDbID: 438631, - Year: 2021, - Width: 1920, - Height: 1080, - SizeBytes: 120, - }, - } - if err := db.Create(&rows).Error; err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - page, total, err := svc.SearchMediaVisiblePageGrouped(t.Context(), "Dune", 1, 1, MediaVisibility{IncludeNSFW: true}) - if err != nil { - t.Fatal(err) - } - if total != 1 { - t.Fatalf("grouped total = %d, want 1", total) - } - if len(page) != 1 || len(page[0].Versions) != 2 { - t.Fatalf("search page should contain merged Dune versions, got %#v", page) - } - if page[0].Media.Path != rows[0].Path { - t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path) - } -} - -func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) { - db := newServiceTestDB(t, &model.Library{}, &model.Media{}) - repos := repository.New(db) - lib := model.Library{Name: "自采集", Path: "/media/custom", Type: "movie", Enabled: true} - if err := repos.Library.Create(t.Context(), &lib); err != nil { - t.Fatal(err) - } - media := model.Media{Base: model.Base{ID: "custom-media"}, LibraryID: lib.ID, Title: "raw", Path: "/media/custom/raw.mp4", ScrapeStatus: "no_match"} - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - title := "手动标题" - overview := "手动简介" - season := 0 - episode := 1 - tmdbID := 12345 - nsfw := true - updated, err := svc.UpdateMetadata(t.Context(), media.ID, MediaMetadataUpdate{ - Title: &title, - Overview: &overview, - SeasonNum: &season, - EpisodeNum: &episode, - TMDbID: &tmdbID, - NSFW: &nsfw, - }) - if err != nil { - t.Fatalf("update metadata: %v", err) - } - if updated.Title != title || updated.Overview != overview || updated.ScrapeStatus != "matched" { - t.Fatalf("metadata not saved: %#v", updated) - } - if updated.SeasonNum != 0 || updated.EpisodeNum != 1 || updated.TMDbID != tmdbID || !updated.NSFW { - t.Fatalf("ids/episode metadata not saved: %#v", updated) - } -} - -func TestMediaUpsertBackfillsExternalIDsForPendingCloudRows(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - path := "cloud://openlist/国漫/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" - if err := repos.DB.Create(&model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - ScrapeStatus: "pending", - }).Error; err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 296753, - Year: 2025, - ScrapeStatus: "pending", - }); err != nil { - t.Fatal(err) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 296753 || got.Year != 2025 || got.ScrapeStatus != "pending" { - t.Fatalf("pending cloud row was not backfilled correctly: tmdb=%d year=%d status=%q", got.TMDbID, got.Year, got.ScrapeStatus) - } -} - -func TestMediaUpsertCorrectsCloudExternalIDConflicts(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" - if err := repos.DB.Create(&model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 220269, - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - if err := repos.Media.Upsert(t.Context(), &model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 296753, - Year: 2025, - ScrapeStatus: "pending", - }); err != nil { - t.Fatal(err) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 296753 || got.ScrapeStatus != "pending" { - t.Fatalf("cloud external id conflict was not corrected: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) - } -} - -func TestRepairCloudPathMetadataBackfillsExistingPlaceholders(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - path := "cloud://openlist/动画电影/雄狮少年2 (2024) {tmdb-1154478}/雄狮少年2 (2024) - 2160p.WEB-DL.H.265.DDP 5.1-ADWeb.mp4" - if err := repos.DB.Create(&model.Media{ - LibraryID: "cloud-movie", - Title: "雄狮少年2 adweb", - Path: path, - ScrapeStatus: "no_match", - }).Error; err != nil { - t.Fatal(err) - } - container := &Container{Repo: repos, Log: zap.NewNop()} - repaired, err := container.RepairCloudPathMetadata(t.Context()) - if err != nil { - t.Fatal(err) - } - if repaired != 1 { - t.Fatalf("repaired = %d, want 1", repaired) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 1154478 || got.Year != 2024 || got.Title != "雄狮少年2" || got.ScrapeStatus != "pending" { - t.Fatalf("placeholder was not repaired: title=%q tmdb=%d year=%d status=%q", got.Title, got.TMDbID, got.Year, got.ScrapeStatus) - } -} - -func TestRepairCloudPathMetadataCorrectsConflictingMatchedID(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - path := "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv" - if err := repos.DB.Create(&model.Media{ - LibraryID: "cloud-tv", - Title: "折腰", - Path: path, - SeasonNum: 1, - EpisodeNum: 1, - TMDbID: 220269, - ScrapeStatus: "matched", - }).Error; err != nil { - t.Fatal(err) - } - container := &Container{Repo: repos, Log: zap.NewNop()} - repaired, err := container.RepairCloudPathMetadata(t.Context()) - if err != nil { - t.Fatal(err) - } - if repaired != 1 { - t.Fatalf("repaired = %d, want 1", repaired) - } - var got model.Media - if err := repos.DB.First(&got, "path = ?", path).Error; err != nil { - t.Fatal(err) - } - if got.TMDbID != 296753 || got.ScrapeStatus != "pending" { - t.Fatalf("conflicting matched id was not repaired: tmdb=%d status=%q", got.TMDbID, got.ScrapeStatus) - } -} - -func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - media := model.Media{ - Base: model.Base{ID: "cloud-media"}, - Title: "网盘电影", - Path: "cloud://openlist/电影/Movie.mkv", - STRMURL: "/api/cloud/play/openlist?ref=/电影/Movie.mkv", - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - if err := svc.SoftDelete(t.Context(), media.ID); err != nil { - t.Fatal(err) - } - var count int64 - if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", media.ID).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("cloud media should be purged, count=%d", count) - } - recycle, err := svc.ListRecycleBin(t.Context(), 100) - if err != nil { - t.Fatal(err) - } - if len(recycle) != 0 { - t.Fatalf("cloud media removal must not populate recycle bin: %#v", recycle) - } -} - -func TestListRecycleBinPrunesOldRowsOverLimit(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - now := time.Now() - for i := 0; i < maxRecycleBinRecords+5; i++ { - deletedAt := now.Add(time.Duration(i) * time.Second) - media := model.Media{ - Base: model.Base{ - ID: fmt.Sprintf("media-%03d", i), - DeletedAt: gorm.DeletedAt{Time: deletedAt, Valid: true}, - }, - Title: fmt.Sprintf("Movie %03d", i), - Path: filepath.Join(t.TempDir(), fmt.Sprintf("Movie %03d.mkv", i)), - } - if err := db.Unscoped().Create(&media).Error; err != nil { - t.Fatal(err) - } - } - - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) - rows, err := svc.ListRecycleBin(t.Context(), 500) - if err != nil { - t.Fatal(err) - } - if len(rows) != maxRecycleBinRecords { - t.Fatalf("recycle rows = %d, want %d", len(rows), maxRecycleBinRecords) - } - var count int64 - if err := db.Unscoped().Model(&model.Media{}).Where("deleted_at IS NOT NULL").Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != maxRecycleBinRecords { - t.Fatalf("stored recycle rows = %d, want %d", count, maxRecycleBinRecords) - } - var oldCount int64 - if err := db.Unscoped().Model(&model.Media{}).Where("id IN ?", []string{"media-000", "media-001", "media-002", "media-003", "media-004"}).Count(&oldCount).Error; err != nil { - t.Fatal(err) - } - if oldCount != 0 { - t.Fatalf("oldest recycle rows were not pruned, count=%d", oldCount) - } -} - -func TestSoftDeleteInvalidatesMediaAndStatsCache(t *testing.T) { - db := newServiceTestDB(t, &model.Media{}) - repos := repository.New(db) - media := model.Media{ - Base: model.Base{ID: "local-media"}, - Title: "Cached Movie", - Path: filepath.Join(t.TempDir(), "Cached Movie.mkv"), - } - if err := repos.DB.Create(&media).Error; err != nil { - t.Fatal(err) - } - - cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop()) - cache.SetJSON(t.Context(), "media:list:test", map[string]string{"state": "stale"}, time.Minute) - cache.SetJSON(t.Context(), "stats:snapshot:base", map[string]int{"media": 1}, time.Minute) - svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).SetRuntimeCache(cache) - if err := svc.SoftDelete(t.Context(), media.ID); err != nil { - t.Fatal(err) - } - - var mediaCache map[string]string - if cache.GetJSON(t.Context(), "media:list:test", &mediaCache) { - t.Fatal("soft delete should invalidate media cache") - } - var statsCache map[string]int - if cache.GetJSON(t.Context(), "stats:snapshot:base", &statsCache) { - t.Fatal("soft delete should invalidate stats cache") - } -} diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go new file mode 100644 index 0000000..89344fa --- /dev/null +++ b/internal/service/media_versions_test.go @@ -0,0 +1,220 @@ +package service + +import ( + "testing" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T) { + local := model.Media{ + LibraryID: "local-tv", + Title: "折腰", + Path: "/media/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv", + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 296753, + SizeBytes: 100, + PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg", + } + cloud := model.Media{ + LibraryID: "cloud-tv", + Title: "折腰", + Path: "cloud://openlist/国产剧/折腰 (2025) {tmdb-296753}/Season 1/折腰.S01E01.mkv", + SeasonNum: 1, + EpisodeNum: 1, + TMDbID: 296753, + SizeBytes: 200, + STRMURL: "/api/cloud/play/openlist?ref=/国产剧/折腰/01.mkv", + } + + grouped := groupMediaVersions([]model.Media{local, cloud}) + if len(grouped) != 1 { + t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped) + } + if len(grouped[0].Versions) != 2 { + t.Fatalf("versions len = %d, want 2: %#v", len(grouped[0].Versions), grouped[0].Versions) + } + if grouped[0].Media.Path != local.Path { + t.Fatalf("local version should remain primary, got %q want %q", grouped[0].Media.Path, local.Path) + } + if grouped[0].Versions[0].Path != local.Path || grouped[0].Versions[1].Path != cloud.Path { + t.Fatalf("versions should be ordered local before cloud, got %#v", grouped[0].Versions) + } +} + +func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) { + hd := model.Media{ + LibraryID: "movies", + Title: "Inception 2010 1080p BluRay x264", + Path: "/media/Movies/Inception.2010.1080p.BluRay.x264.mkv", + Year: 2010, + SizeBytes: 100, + VideoCodec: "h264", + ScrapeStatus: "pending", + } + uhd := model.Media{ + LibraryID: "movies", + Title: "Inception 2010 2160p UHD BluRay x265", + Path: "/media/Movies/Inception.2010.2160p.UHD.BluRay.x265.mkv", + Year: 2010, + SizeBytes: 200, + VideoCodec: "hevc", + ScrapeStatus: "pending", + } + + grouped := groupMediaVersions([]model.Media{hd, uhd}) + if len(grouped) != 1 { + t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped) + } + if len(grouped[0].Versions) != 2 { + t.Fatalf("versions len = %d, want 2: %#v", len(grouped[0].Versions), grouped[0].Versions) + } + if grouped[0].Media.Path != uhd.Path { + t.Fatalf("larger 2160p version should be primary, got %q want %q", grouped[0].Media.Path, uhd.Path) + } +} + +func TestGroupMediaVersionsCleansCodecPunctuation(t *testing.T) { + web := model.Media{ + LibraryID: "movies", + Title: "Everything Everywhere All at Once 2022 WEB-DL H.265 DDP5.1", + Path: "/media/Movies/Everything.Everywhere.All.At.Once.2022.2160p.WEB-DL.H.265.DDP5.1-GRP.mkv", + SizeBytes: 200, + VideoCodec: "hevc", + ScrapeStatus: "pending", + } + bluray := model.Media{ + LibraryID: "movies", + Title: "Everything Everywhere All at Once 2022 BluRay x264 DTS", + Path: "/media/Movies/Everything.Everywhere.All.At.Once.2022.1080p.BluRay.x264.DTS-GRP.mkv", + SizeBytes: 100, + VideoCodec: "h264", + ScrapeStatus: "pending", + } + + grouped := groupMediaVersions([]model.Media{web, bluray}) + if len(grouped) != 1 { + t.Fatalf("grouped len = %d, want 1: %#v", len(grouped), grouped) + } + if len(grouped[0].Versions) != 2 { + t.Fatalf("versions len = %d, want 2: %#v", len(grouped[0].Versions), grouped[0].Versions) + } + if grouped[0].Media.Path != web.Path { + t.Fatalf("larger WEB-DL version should be primary, got %q want %q", grouped[0].Media.Path, web.Path) + } +} + +func TestListMediaVisibleGroupedPaginatesAfterVersionGrouping(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + now := time.Now() + rows := []model.Media{ + { + Base: model.Base{CreatedAt: now.Add(2 * time.Hour), UpdatedAt: now.Add(2 * time.Hour)}, + LibraryID: lib.ID, + Title: "Inception 2010 2160p UHD BluRay x265", + Path: "/media/movies/Inception.2010.2160p.UHD.BluRay.x265.mkv", + TMDbID: 27205, + Year: 2010, + Width: 3840, + Height: 2160, + SizeBytes: 200, + }, + { + Base: model.Base{CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)}, + LibraryID: lib.ID, + Title: "Inception 2010 1080p BluRay x264", + Path: "/media/movies/Inception.2010.1080p.BluRay.x264.mkv", + TMDbID: 27205, + Year: 2010, + Width: 1920, + Height: 1080, + SizeBytes: 100, + }, + { + Base: model.Base{CreatedAt: now, UpdatedAt: now}, + LibraryID: lib.ID, + Title: "The Matrix 1999 1080p BluRay", + Path: "/media/movies/The.Matrix.1999.1080p.BluRay.mkv", + TMDbID: 603, + Year: 1999, + SizeBytes: 90, + }, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + page, total, err := svc.ListMediaVisibleGrouped(t.Context(), lib.ID, 1, 1, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if total != 2 { + t.Fatalf("grouped total = %d, want 2", total) + } + if len(page) != 1 || len(page[0].Versions) != 2 { + t.Fatalf("first page should contain merged Inception versions, got %#v", page) + } + if page[0].Media.Path != rows[0].Path { + t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path) + } +} + +func TestSearchMediaVisiblePageGroupedPaginatesAfterVersionGrouping(t *testing.T) { + db := newServiceTestDB(t, &model.Media{}) + repos := repository.New(db) + now := time.Now() + rows := []model.Media{ + { + Base: model.Base{CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)}, + LibraryID: "movies", + Title: "Dune 2021 2160p WEB-DL H265", + Path: "/media/movies/Dune.2021.2160p.WEB-DL.H265.mkv", + TMDbID: 438631, + Year: 2021, + Width: 3840, + Height: 2160, + SizeBytes: 220, + }, + { + Base: model.Base{CreatedAt: now, UpdatedAt: now}, + LibraryID: "movies", + Title: "Dune 2021 1080p BluRay x264", + Path: "/media/movies/Dune.2021.1080p.BluRay.x264.mkv", + TMDbID: 438631, + Year: 2021, + Width: 1920, + Height: 1080, + SizeBytes: 120, + }, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + page, total, err := svc.SearchMediaVisiblePageGrouped(t.Context(), "Dune", 1, 1, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatal(err) + } + if total != 1 { + t.Fatalf("grouped total = %d, want 1", total) + } + if len(page) != 1 || len(page[0].Versions) != 2 { + t.Fatalf("search page should contain merged Dune versions, got %#v", page) + } + if page[0].Media.Path != rows[0].Path { + t.Fatalf("primary version = %q, want %q", page[0].Media.Path, rows[0].Path) + } +}