407 lines
12 KiB
Go
407 lines
12 KiB
Go
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)
|
|
}
|
|
}
|