package adminjob import ( "context" "errors" "fmt" "path/filepath" "strings" "testing" "github.com/Silo-Server/silo-server/internal/libraryingest" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/scanner" ) type itemRefreshTestFolderRepo struct { folder *models.MediaFolder err error } func (r *itemRefreshTestFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) { if r.err != nil { return nil, r.err } if r.folder == nil || r.folder.ID != id { return nil, errors.New("folder not found") } return r.folder, nil } func TestItemRefreshResolverBuildRequestAcceptsPathOutsideLibraryRoots(t *testing.T) { t.Parallel() resolver := &ItemRefreshResolver{ folderRepo: &itemRefreshTestFolderRepo{ folder: &models.MediaFolder{ ID: 3, Paths: []string{"/LibraryManager2/movies/popular_trending"}, }, }, } req, err := resolver.buildRequest( context.Background(), 3, "/srv/media/movies/4k/Example Movie (2026)", &ItemRefreshRequest{}, ) if err != nil { t.Fatalf("buildRequest() error = %v, want nil", err) } if req == nil { t.Fatal("buildRequest() request = nil, want non-nil") } if got, want := req.ScanPath, "/srv/media/movies/4k/Example Movie (2026)"; got != want { t.Fatalf("buildRequest() scan path = %q, want %q", got, want) } } func TestItemRefreshResolverBuildRequestAcceptsPathWithinLibraryRoots(t *testing.T) { t.Parallel() resolver := &ItemRefreshResolver{ folderRepo: &itemRefreshTestFolderRepo{ folder: &models.MediaFolder{ ID: 3, Paths: []string{"/srv/media/movies"}, }, }, } req, err := resolver.buildRequest( context.Background(), 3, "/srv/media/movies/4k/Example Movie (2026)", &ItemRefreshRequest{RequestedContentID: "119730834381996036"}, ) if err != nil { t.Fatalf("buildRequest() error = %v, want nil", err) } if req == nil { t.Fatal("buildRequest() request = nil, want non-nil") } if got, want := req.ScanFolderID, 3; got != want { t.Fatalf("buildRequest() folder = %d, want %d", got, want) } if got, want := req.ScanPath, "/srv/media/movies/4k/Example Movie (2026)"; got != want { t.Fatalf("buildRequest() scan path = %q, want %q", got, want) } } func TestItemRefreshResolverDeriveCompleteRefreshScope_UsesCanonicalRootForSeriesWithoutEpisodes(t *testing.T) { t.Parallel() resolver := &ItemRefreshResolver{ folderRepo: &itemRefreshTestFolderRepo{ folder: &models.MediaFolder{ ID: 7, Type: "series", }, }, } file := &models.MediaFile{ MediaFolderID: 7, FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E01.mkv", CanonicalRootPath: "/media/shows/Example Show", ContentID: "pending-series", BaseType: "series", } scanPath, canonicalRootPath, err := resolver.deriveCompleteRefreshScope(context.Background(), file) if err != nil { t.Fatalf("deriveCompleteRefreshScope() error = %v", err) } if got, want := scanPath, "/media/shows/Example Show"; got != want { t.Fatalf("scanPath = %q, want %q", got, want) } if got, want := canonicalRootPath, "/media/shows/Example Show"; got != want { t.Fatalf("canonicalRootPath = %q, want %q", got, want) } } type itemRefreshTestIngester struct { scanPath string result *libraryingest.Result } func (s *itemRefreshTestIngester) IngestSubtree(_ context.Context, _ *models.MediaFolder, subtreePath string) (*libraryingest.Result, error) { s.scanPath = subtreePath if s.result != nil { return s.result, nil } return &libraryingest.Result{ ScanResult: &scanner.ScanResult{}, MatchedFiles: 2, }, nil } type itemRefreshTestRefresher struct { targetType string contentID string folderID int } func (r *itemRefreshTestRefresher) RefreshItem(_ context.Context, contentID string) error { r.contentID = contentID return nil } func (r *itemRefreshTestRefresher) RefreshItemForLibrary(_ context.Context, contentID string, folderID int) error { return r.RefreshTargetForLibrary(context.Background(), "item", contentID, folderID) } func (r *itemRefreshTestRefresher) RefreshTargetForLibrary(_ context.Context, targetType, contentID string, folderID int) error { r.targetType = targetType r.contentID = contentID r.folderID = folderID return nil } type itemRefreshTestFileRepo struct { filesByPath []*models.MediaFile clearedPath string } func (r *itemRefreshTestFileRepo) GetByContentID(_ context.Context, _ string) ([]*models.MediaFile, error) { return nil, nil } func (r *itemRefreshTestFileRepo) GetByEpisodeID(_ context.Context, _ string) ([]*models.MediaFile, error) { return nil, nil } func (r *itemRefreshTestFileRepo) GetByFolderAndPathPrefix(_ context.Context, _ int, _ string) ([]*models.MediaFile, error) { return r.filesByPath, nil } func (r *itemRefreshTestFileRepo) ClearContentLinksByPathPrefix(_ context.Context, _ int, pathPrefix string) (int, error) { r.clearedPath = pathPrefix return 1, nil } type itemRefreshTestRootClaimRepo struct { deletedRoot string } func (r *itemRefreshTestRootClaimRepo) DeleteByFolderAndRoot(_ context.Context, _ int, rootPath string) error { r.deletedRoot = rootPath return nil } type itemRefreshTestGroupClaimRepo struct { deletedPath string } func (r *itemRefreshTestGroupClaimRepo) DeleteByFolderAndObservedPathPrefix(_ context.Context, _ int, pathPrefix string) error { r.deletedPath = pathPrefix return nil } type itemRefreshTestSkippedRootRepo struct { skipped map[string]models.SkippedMediaRoot } func newItemRefreshTestSkippedRootRepo() *itemRefreshTestSkippedRootRepo { return &itemRefreshTestSkippedRootRepo{skipped: make(map[string]models.SkippedMediaRoot)} } func (r *itemRefreshTestSkippedRootRepo) Upsert(_ context.Context, root models.SkippedMediaRoot) error { r.skipped[fmt.Sprintf("%d:%s", root.MediaFolderID, root.RootPath)] = root return nil } func (r *itemRefreshTestSkippedRootRepo) Delete(_ context.Context, folderID int, rootPath string) error { delete(r.skipped, fmt.Sprintf("%d:%s", folderID, rootPath)) return nil } func (r *itemRefreshTestSkippedRootRepo) DeleteMissingInScope(_ context.Context, folderID int, scopePath string, seenRoots []string) error { scopePath = filepath.Clean(scopePath) prefix := scopePath + string(filepath.Separator) seen := make(map[string]struct{}, len(seenRoots)) for _, root := range seenRoots { seen[root] = struct{}{} } for key, root := range r.skipped { if root.MediaFolderID != folderID { continue } if root.RootPath != scopePath && !strings.HasPrefix(root.RootPath, prefix) { continue } if _, ok := seen[root.RootPath]; ok { continue } delete(r.skipped, key) } return nil } type itemRefreshTestSeasonRepo struct { season *models.Season } func (r *itemRefreshTestSeasonRepo) GetByID(_ context.Context, _ string) (*models.Season, error) { return nil, errors.New("not implemented") } func (r *itemRefreshTestSeasonRepo) GetBySeriesAndNumber(_ context.Context, seriesID string, seasonNum int) (*models.Season, error) { if r.season != nil && r.season.SeriesID == seriesID && r.season.SeasonNumber == seasonNum { return r.season, nil } return nil, errors.New("season not found") } type itemRefreshTestEpisodeRepo struct { episode *models.Episode } func (r *itemRefreshTestEpisodeRepo) GetByID(_ context.Context, _ string) (*models.Episode, error) { return nil, errors.New("not implemented") } func (r *itemRefreshTestEpisodeRepo) GetBySeriesAndNumber(_ context.Context, seriesID string, seasonNum int, episodeNum int) (*models.Episode, error) { if r.episode != nil && r.episode.SeriesID == seriesID && r.episode.SeasonNumber == seasonNum && r.episode.EpisodeNumber == episodeNum { return r.episode, nil } return nil, errors.New("episode not found") } func (r *itemRefreshTestEpisodeRepo) ListBySeries(_ context.Context, _ string) ([]*models.Episode, error) { return nil, errors.New("not implemented") } func (r *itemRefreshTestEpisodeRepo) ListBySeason(_ context.Context, _ string, _ int) ([]*models.Episode, error) { return nil, errors.New("not implemented") } func (r *itemRefreshTestEpisodeRepo) ListBySeasonID(_ context.Context, _ string) ([]*models.Episode, error) { return nil, errors.New("not implemented") } func TestItemRefreshExecutorAllowsScanPathOutsideLibraryRoots(t *testing.T) { t.Parallel() folderRepo := &itemRefreshTestFolderRepo{ folder: &models.MediaFolder{ ID: 3, Enabled: true, Paths: []string{"/LibraryManager2/movies/popular_trending"}, }, } ingester := &itemRefreshTestIngester{} refresher := &itemRefreshTestRefresher{} fileRepo := &itemRefreshTestFileRepo{} rootClaimRepo := &itemRefreshTestRootClaimRepo{} groupClaimRepo := &itemRefreshTestGroupClaimRepo{} skippedRootRepo := newItemRefreshTestSkippedRootRepo() executor := NewItemRefreshExecutor(folderRepo, fileRepo, rootClaimRepo, groupClaimRepo, skippedRootRepo, nil, nil, ingester, refresher, nil, nil) result, err := executor.Execute(context.Background(), ItemRefreshRequest{ RequestedContentID: "119730834381996036", RefreshContentID: "119730834381996036", ScanFolderID: 3, ScanPath: "/srv/media/movies/4k/Example Movie (2026)", }, nil) if err != nil { t.Fatalf("Execute() error = %v, want nil", err) } if result == nil { t.Fatal("Execute() result = nil, want non-nil") } if got, want := ingester.scanPath, "/srv/media/movies/4k/Example Movie (2026)"; got != want { t.Fatalf("IngestSubtree() path = %q, want %q", got, want) } if got, want := refresher.contentID, "119730834381996036"; got != want { t.Fatalf("RefreshItem() content_id = %q, want %q", got, want) } if got, want := refresher.folderID, 3; got != want { t.Fatalf("RefreshItemForLibrary() folder_id = %d, want %d", got, want) } if got, want := result.MatchedFiles, 2; got != want { t.Fatalf("Execute() matched files = %d, want %d", got, want) } } func TestItemRefreshExecutorCompleteRefreshRebuildsAndMapsEpisodeTarget(t *testing.T) { t.Parallel() folderRepo := &itemRefreshTestFolderRepo{ folder: &models.MediaFolder{ ID: 7, Enabled: true, Paths: []string{"/media/mixed"}, }, } ingester := &itemRefreshTestIngester{} refresher := &itemRefreshTestRefresher{} fileRepo := &itemRefreshTestFileRepo{ filesByPath: []*models.MediaFile{ {ContentID: "new-series-id", FilePath: "/media/mixed/Show/Season 01/Show S01E03.mkv"}, }, } rootClaimRepo := &itemRefreshTestRootClaimRepo{} groupClaimRepo := &itemRefreshTestGroupClaimRepo{} episodeRepo := &itemRefreshTestEpisodeRepo{ episode: &models.Episode{ ContentID: "new-episode-id", SeriesID: "new-series-id", SeasonNumber: 1, EpisodeNumber: 3, }, } executor := NewItemRefreshExecutor( folderRepo, fileRepo, rootClaimRepo, groupClaimRepo, newItemRefreshTestSkippedRootRepo(), nil, episodeRepo, ingester, refresher, nil, nil, ) result, err := executor.Execute(context.Background(), ItemRefreshRequest{ RequestedContentID: "old-episode-id", RequestedType: "episode", RequestedSeasonNumber: 1, RequestedEpisodeNumber: 3, RefreshContentID: "old-series-id", ScanFolderID: 7, ScanPath: "/media/mixed/Show/Season 01", Mode: ItemRefreshModeComplete, CanonicalRootPath: "/media/mixed/Show", }, nil) if err != nil { t.Fatalf("Execute() error = %v, want nil", err) } if result == nil { t.Fatal("Execute() result = nil, want non-nil") } if got, want := fileRepo.clearedPath, "/media/mixed/Show/Season 01"; got != want { t.Fatalf("ClearContentLinksByPathPrefix() path = %q, want %q", got, want) } if got, want := rootClaimRepo.deletedRoot, "/media/mixed/Show"; got != want { t.Fatalf("DeleteByFolderAndRoot() root = %q, want %q", got, want) } if got, want := refresher.targetType, "episode"; got != want { t.Fatalf("RefreshTargetForLibrary() target_type = %q, want %q", got, want) } if got, want := refresher.contentID, "new-episode-id"; got != want { t.Fatalf("RefreshItem() content_id = %q, want %q", got, want) } if got, want := result.RefreshContentID, "new-episode-id"; got != want { t.Fatalf("result refresh_content_id = %q, want %q", got, want) } if got, want := result.DetailContentID, "new-episode-id"; got != want { t.Fatalf("result detail_content_id = %q, want %q", got, want) } }