Files
silo-server/internal/metadata/service_test.go
T

1987 lines
58 KiB
Go

package metadata
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
// ---------------------------------------------------------------------------
// Fake repositories
// ---------------------------------------------------------------------------
// fakeItemRepo implements metadataItemRepo with an in-memory store.
type fakeItemRepo struct {
mu sync.Mutex
items map[string]*models.MediaItem
}
func newFakeItemRepo() *fakeItemRepo {
return &fakeItemRepo{items: make(map[string]*models.MediaItem)}
}
func (r *fakeItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
r.mu.Lock()
defer r.mu.Unlock()
if item, ok := r.items[contentID]; ok {
cp := *item
return &cp, nil
}
return nil, fmt.Errorf("item not found: %s", contentID)
}
func (r *fakeItemRepo) GetByExternalID(_ context.Context, tmdbID, imdbID, tvdbID, itemType string) (*models.MediaItem, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, item := range r.items {
if item.Type != itemType {
continue
}
if tmdbID != "" && item.TmdbID == tmdbID {
cp := *item
return &cp, nil
}
if imdbID != "" && item.ImdbID == imdbID {
cp := *item
return &cp, nil
}
if tvdbID != "" && item.TvdbID == tvdbID {
cp := *item
return &cp, nil
}
}
return nil, fmt.Errorf("not found")
}
func (r *fakeItemRepo) GetByTitleYearType(_ context.Context, title string, year int, itemType string) (*models.MediaItem, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, item := range r.items {
if item.Title == title && item.Year == year && item.Type == itemType {
cp := *item
return &cp, nil
}
}
return nil, fmt.Errorf("not found")
}
func (r *fakeItemRepo) Upsert(_ context.Context, item *models.MediaItem) error {
r.mu.Lock()
defer r.mu.Unlock()
cp := *item
r.items[item.ContentID] = &cp
return nil
}
func (r *fakeItemRepo) IncrementRefreshFailure(_ context.Context, contentID string) error {
r.mu.Lock()
defer r.mu.Unlock()
item, ok := r.items[contentID]
if !ok {
return fmt.Errorf("item not found: %s", contentID)
}
cp := *item
cp.RefreshFailures++
r.items[contentID] = &cp
return nil
}
func (r *fakeItemRepo) ReplacePeople(_ context.Context, _ string, _ []models.ItemPerson) error {
return nil
}
func (r *fakeItemRepo) ListUnmatchedByFolderAndPathPrefix(_ context.Context, _ int, _ string, _ int) ([]string, error) {
return nil, nil
}
type fakeRefreshDebtRepo struct {
mu sync.Mutex
debts map[string]*models.MetadataRefreshDebt
}
func newFakeRefreshDebtRepo() *fakeRefreshDebtRepo {
return &fakeRefreshDebtRepo{debts: make(map[string]*models.MetadataRefreshDebt)}
}
func fakeRefreshDebtKey(targetType, contentID string) string {
targetType = NormalizeRefreshTargetType(targetType)
if targetType == "" {
return ""
}
if targetType == RefreshTargetItem {
return contentID
}
return targetType + ":" + contentID
}
func (r *fakeRefreshDebtRepo) Get(ctx context.Context, contentID string) (*models.MetadataRefreshDebt, error) {
return r.GetTarget(ctx, RefreshTargetItem, contentID)
}
func (r *fakeRefreshDebtRepo) GetTarget(_ context.Context, targetType, contentID string) (*models.MetadataRefreshDebt, error) {
r.mu.Lock()
defer r.mu.Unlock()
debt, ok := r.debts[fakeRefreshDebtKey(targetType, contentID)]
if !ok {
return nil, ErrRefreshDebtNotFound
}
cp := *debt
return &cp, nil
}
func (r *fakeRefreshDebtRepo) UpsertDebt(ctx context.Context, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time) error {
return r.UpsertTargetDebt(ctx, RefreshTargetItem, contentID, priority, reasonMask, nextRefreshAt)
}
func (r *fakeRefreshDebtRepo) UpsertTargetDebt(_ context.Context, targetType, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
targetType = NormalizeRefreshTargetType(targetType)
key := fakeRefreshDebtKey(targetType, contentID)
if key == "" || contentID == "" || reasonMask == 0 {
return nil
}
r.debts[key] = &models.MetadataRefreshDebt{
TargetType: targetType,
ContentID: contentID,
Priority: priority,
ReasonMask: reasonMask,
NextRefreshAt: nextRefreshAt,
}
return nil
}
func (r *fakeRefreshDebtRepo) RequestDue(
ctx context.Context,
targetType string,
contentID string,
priority int,
reasonMask int64,
nextRefreshAt time.Time,
_ time.Duration,
) error {
return r.UpsertTargetDebt(ctx, targetType, contentID, priority, reasonMask, nextRefreshAt)
}
func (r *fakeRefreshDebtRepo) MarkFailure(
ctx context.Context,
contentID string,
priority int,
reasonMask int64,
nextRefreshAt time.Time,
attemptCount int,
lastError string,
) error {
return r.MarkTargetFailure(ctx, RefreshTargetItem, contentID, priority, reasonMask, nextRefreshAt, attemptCount, lastError)
}
func (r *fakeRefreshDebtRepo) MarkTargetFailure(
_ context.Context,
targetType string,
contentID string,
priority int,
reasonMask int64,
nextRefreshAt time.Time,
attemptCount int,
lastError string,
) error {
r.mu.Lock()
defer r.mu.Unlock()
targetType = NormalizeRefreshTargetType(targetType)
key := fakeRefreshDebtKey(targetType, contentID)
if key == "" || contentID == "" || reasonMask == 0 {
return nil
}
r.debts[key] = &models.MetadataRefreshDebt{
TargetType: targetType,
ContentID: contentID,
Priority: priority,
ReasonMask: reasonMask,
NextRefreshAt: nextRefreshAt,
AttemptCount: attemptCount,
LastError: lastError,
}
return nil
}
func (r *fakeRefreshDebtRepo) MarkSuccess(ctx context.Context, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time) error {
return r.MarkTargetSuccess(ctx, RefreshTargetItem, contentID, priority, reasonMask, nextRefreshAt)
}
func (r *fakeRefreshDebtRepo) MarkTargetSuccess(_ context.Context, targetType, contentID string, priority int, reasonMask int64, nextRefreshAt time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
targetType = NormalizeRefreshTargetType(targetType)
key := fakeRefreshDebtKey(targetType, contentID)
if key == "" || contentID == "" {
return nil
}
if reasonMask == 0 {
delete(r.debts, key)
return nil
}
r.debts[key] = &models.MetadataRefreshDebt{
TargetType: targetType,
ContentID: contentID,
Priority: priority,
ReasonMask: reasonMask,
NextRefreshAt: nextRefreshAt,
}
return nil
}
func (r *fakeRefreshDebtRepo) DeleteDebt(ctx context.Context, contentID string) error {
return r.DeleteTargetDebt(ctx, RefreshTargetItem, contentID)
}
func (r *fakeRefreshDebtRepo) DeleteTargetDebt(_ context.Context, targetType, contentID string) error {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.debts, fakeRefreshDebtKey(targetType, contentID))
return nil
}
// fakeFileRepo implements FileContentUpdater with an in-memory store.
type fakeFileRepo struct {
mu sync.Mutex
contentIDs map[int]string // fileID -> contentID
rootContent map[string]string // "folderID:rootPath" -> contentID
rootCandidates map[string][]string
rootCandidateStatus map[string]string
groupContent map[string]string // "folderID:version:key" -> contentID
groupFiles map[string][]*models.MediaFile
matchStamps map[int]time.Time // fileID -> match_attempted_at
claimUnmatchedCalls int
claimNonSeriesCalls int
claimMixedCalls int
}
func newFakeFileRepo() *fakeFileRepo {
return &fakeFileRepo{
contentIDs: make(map[int]string),
rootContent: make(map[string]string),
rootCandidates: make(map[string][]string),
rootCandidateStatus: make(map[string]string),
groupContent: make(map[string]string),
groupFiles: make(map[string][]*models.MediaFile),
matchStamps: make(map[int]time.Time),
}
}
func (r *fakeFileRepo) UpdateContentID(_ context.Context, fileID int, contentID string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.contentIDs[fileID] = contentID
return nil
}
func (r *fakeFileRepo) ReplaceContentID(_ context.Context, oldContentID, newContentID string) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
replaced := 0
for fileID, contentID := range r.contentIDs {
if contentID != oldContentID {
continue
}
r.contentIDs[fileID] = newContentID
replaced++
}
return replaced, nil
}
func (r *fakeFileRepo) FindContentIDByRootPath(_ context.Context, folderID int, rootPath, _ string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%s", folderID, rootPath)
if candidates := r.rootCandidates[key]; len(candidates) > 0 {
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(r.rootCandidateStatus[candidate]), "matched") {
return candidate, nil
}
}
return candidates[0], nil
}
if cid, ok := r.rootContent[key]; ok {
return cid, nil
}
return "", nil
}
func (r *fakeFileRepo) GetByContentID(_ context.Context, contentID string) ([]*models.MediaFile, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*models.MediaFile, 0)
for _, files := range r.groupFiles {
for _, file := range files {
if file == nil || r.contentIDs[file.ID] != contentID {
continue
}
cp := *file
cp.ContentID = contentID
out = append(out, &cp)
}
}
slices.SortFunc(out, func(a, b *models.MediaFile) int {
if a.ID < b.ID {
return -1
}
if a.ID > b.ID {
return 1
}
return 0
})
return out, nil
}
func (r *fakeFileRepo) FindContentIDByObservedRootPath(_ context.Context, folderID int, observedRootPath, _ string) (string, error) {
return r.FindContentIDByRootPath(context.Background(), folderID, observedRootPath, "")
}
func (r *fakeFileRepo) FindContentIDByGroupKey(_ context.Context, folderID int, groupKeyVersion int, contentGroupKey, _ string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%d:%s", folderID, groupKeyVersion, contentGroupKey)
if cid, ok := r.groupContent[key]; ok {
return cid, nil
}
return "", nil
}
func (r *fakeFileRepo) ListByGroupKey(_ context.Context, folderID int, groupKeyVersion int, contentGroupKey string) ([]*models.MediaFile, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%d:%s", folderID, groupKeyVersion, contentGroupKey)
files := r.groupFiles[key]
out := make([]*models.MediaFile, 0, len(files))
for _, file := range files {
if file == nil {
continue
}
cp := *file
out = append(out, &cp)
}
return out, nil
}
func (r *fakeFileRepo) ListByObservedRootPath(_ context.Context, folderID int, observedRootPath string) ([]*models.MediaFile, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*models.MediaFile, 0)
for _, files := range r.groupFiles {
for _, file := range files {
if file == nil || file.MediaFolderID != folderID || file.ObservedRootPath != observedRootPath {
continue
}
cp := *file
if cid, ok := r.contentIDs[file.ID]; ok {
cp.ContentID = cid
}
out = append(out, &cp)
}
}
slices.SortFunc(out, func(a, b *models.MediaFile) int {
return strings.Compare(a.FilePath, b.FilePath)
})
return out, nil
}
func (r *fakeFileRepo) UpdateContentIDByObservedRootPath(_ context.Context, folderID int, observedRootPath, contentID string) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
updated := 0
for _, files := range r.groupFiles {
for _, file := range files {
if file == nil || file.MediaFolderID != folderID || file.ObservedRootPath != observedRootPath {
continue
}
if existing := r.contentIDs[file.ID]; existing == contentID {
continue
}
r.contentIDs[file.ID] = contentID
updated++
}
}
r.rootContent[fmt.Sprintf("%d:%s", folderID, observedRootPath)] = contentID
return updated, nil
}
// setRootContent pre-seeds a root path -> content_id mapping for testing.
func (r *fakeFileRepo) setRootContent(folderID int, rootPath, contentID string) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%s", folderID, rootPath)
r.rootContent[key] = contentID
}
func (r *fakeFileRepo) setRootCandidates(folderID int, rootPath string, candidates map[string]string) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%s", folderID, rootPath)
r.rootCandidates[key] = r.rootCandidates[key][:0]
for contentID, status := range candidates {
r.rootCandidates[key] = append(r.rootCandidates[key], contentID)
r.rootCandidateStatus[contentID] = status
}
slices.Sort(r.rootCandidates[key])
}
func (r *fakeFileRepo) setGroupContent(folderID int, groupKeyVersion int, contentGroupKey, contentID string) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%d:%s", folderID, groupKeyVersion, contentGroupKey)
r.groupContent[key] = contentID
}
func (r *fakeFileRepo) setGroupFiles(folderID int, groupKeyVersion int, contentGroupKey string, files ...*models.MediaFile) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%d:%s", folderID, groupKeyVersion, contentGroupKey)
r.groupFiles[key] = append([]*models.MediaFile(nil), files...)
}
func (r *fakeFileRepo) MarkMatchAttempted(_ context.Context, fileID int) error {
r.mu.Lock()
defer r.mu.Unlock()
r.matchStamps[fileID] = time.Now()
return nil
}
func (r *fakeFileRepo) ClaimUnmatched(_ context.Context, limit int) ([]*models.MediaFile, error) {
r.mu.Lock()
r.claimUnmatchedCalls++
r.mu.Unlock()
return r.claimUnmatchedLocked(limit), nil
}
func (r *fakeFileRepo) ClaimUnmatchedByFolderAndPathPrefix(_ context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.claimUnmatchedCalls++
files := make([]*models.MediaFile, 0)
for _, groupFiles := range r.groupFiles {
for _, file := range groupFiles {
if file == nil || file.MediaFolderID != folderID {
continue
}
if cid := r.contentIDs[file.ID]; cid != "" {
continue
}
if pathPrefix != "" && file.FilePath != pathPrefix && !strings.HasPrefix(file.FilePath, pathPrefix+"/") {
continue
}
if !attemptBefore.IsZero() {
if stamp, ok := r.matchStamps[file.ID]; ok && !stamp.Before(attemptBefore) {
continue
}
}
cp := *file
files = append(files, &cp)
r.matchStamps[file.ID] = time.Now()
if limit > 0 && len(files) >= limit {
return files, nil
}
}
}
return files, nil
}
func (r *fakeFileRepo) ClaimUnmatchedNonSeries(_ context.Context, limit int) ([]*models.MediaFile, error) {
r.mu.Lock()
r.claimNonSeriesCalls++
r.mu.Unlock()
return r.claimUnmatchedLocked(limit), nil
}
func (r *fakeFileRepo) ClaimUnmatchedNonSeriesByFolderAndPathPrefix(_ context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.claimNonSeriesCalls++
files := make([]*models.MediaFile, 0)
for _, groupFiles := range r.groupFiles {
for _, file := range groupFiles {
if file == nil || file.MediaFolderID != folderID {
continue
}
if cid := r.contentIDs[file.ID]; cid != "" {
continue
}
if pathPrefix != "" && file.FilePath != pathPrefix && !strings.HasPrefix(file.FilePath, pathPrefix+"/") {
continue
}
if !attemptBefore.IsZero() {
if stamp, ok := r.matchStamps[file.ID]; ok && !stamp.Before(attemptBefore) {
continue
}
}
cp := *file
files = append(files, &cp)
r.matchStamps[file.ID] = time.Now()
if limit > 0 && len(files) >= limit {
return files, nil
}
}
}
return files, nil
}
func (r *fakeFileRepo) ClaimUnmatchedMixed(_ context.Context, limit int) ([]*models.MediaFile, error) {
r.mu.Lock()
r.claimMixedCalls++
r.mu.Unlock()
return r.claimUnmatchedLocked(limit), nil
}
func (r *fakeFileRepo) ClaimUnmatchedMixedByFolderAndPathPrefix(_ context.Context, folderID int, pathPrefix string, limit int, attemptBefore time.Time) ([]*models.MediaFile, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.claimMixedCalls++
files := make([]*models.MediaFile, 0)
for _, groupFiles := range r.groupFiles {
for _, file := range groupFiles {
if file == nil || file.MediaFolderID != folderID {
continue
}
if cid := r.contentIDs[file.ID]; cid != "" {
continue
}
if pathPrefix != "" && file.FilePath != pathPrefix && !strings.HasPrefix(file.FilePath, pathPrefix+"/") {
continue
}
if !attemptBefore.IsZero() {
if stamp, ok := r.matchStamps[file.ID]; ok && !stamp.Before(attemptBefore) {
continue
}
}
cp := *file
files = append(files, &cp)
r.matchStamps[file.ID] = time.Now()
if limit > 0 && len(files) >= limit {
return files, nil
}
}
}
return files, nil
}
func (r *fakeFileRepo) claimUnmatchedLocked(limit int) []*models.MediaFile {
r.mu.Lock()
defer r.mu.Unlock()
files := make([]*models.MediaFile, 0)
for _, groupFiles := range r.groupFiles {
for _, file := range groupFiles {
if file == nil {
continue
}
if cid := r.contentIDs[file.ID]; cid != "" {
continue
}
cp := *file
files = append(files, &cp)
r.matchStamps[file.ID] = time.Now()
if limit > 0 && len(files) >= limit {
return files
}
}
}
return files
}
func (r *fakeFileRepo) GetUnmatched(_ context.Context, _ int) ([]*models.MediaFile, error) {
return nil, nil
}
func (r *fakeFileRepo) GetUnmatchedByFolderAndPathPrefix(_ context.Context, _ int, _ string, _ int) ([]*models.MediaFile, error) {
return nil, nil
}
// fakeLibraryRepo implements metadataLibraryRepo.
type fakeLibraryRepo struct {
mu sync.Mutex
memberships map[string]time.Time // "contentID:folderID" -> first_seen_at
}
func newFakeLibraryRepo() *fakeLibraryRepo {
return &fakeLibraryRepo{memberships: make(map[string]time.Time)}
}
func (r *fakeLibraryRepo) Upsert(_ context.Context, contentID string, folderID int, firstSeenAt time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%s:%d", contentID, folderID)
if _, exists := r.memberships[key]; !exists {
r.memberships[key] = firstSeenAt
}
return nil
}
func (r *fakeLibraryRepo) hasMembership(contentID string, folderID int) bool {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%s:%d", contentID, folderID)
_, ok := r.memberships[key]
return ok
}
func (r *fakeLibraryRepo) GetFolderIDsForItem(_ context.Context, contentID string) ([]int, error) {
r.mu.Lock()
defer r.mu.Unlock()
var ids []int
prefix := contentID + ":"
for key := range r.memberships {
if !strings.HasPrefix(key, prefix) {
continue
}
var folderID int
if _, err := fmt.Sscanf(key, contentID+":%d", &folderID); err == nil {
ids = append(ids, folderID)
}
}
slices.Sort(ids)
return ids, nil
}
func (r *fakeLibraryRepo) GetDistinctMetadataLanguagesForItem(ctx context.Context, contentID string) ([]string, error) {
folderIDs, err := r.GetFolderIDsForItem(ctx, contentID)
if err != nil {
return nil, err
}
if len(folderIDs) == 0 {
return nil, nil
}
return []string{"en"}, nil
}
func (r *fakeLibraryRepo) CountFoldersForItem(ctx context.Context, contentID string) (int, error) {
folderIDs, err := r.GetFolderIDsForItem(ctx, contentID)
if err != nil {
return 0, err
}
return len(folderIDs), nil
}
type fakeMetadataFolderRepo struct {
folders map[int]*models.MediaFolder
}
func (r *fakeMetadataFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
if folder, ok := r.folders[id]; ok {
cp := *folder
return &cp, nil
}
return nil, fmt.Errorf("folder not found: %d", id)
}
// fakeRootClaimRepo implements metadataRootClaimRepo.
type fakeRootClaimRepo struct {
mu sync.Mutex
claims map[string]string // "folderID:rootPath" -> contentID
}
func newFakeRootClaimRepo() *fakeRootClaimRepo {
return &fakeRootClaimRepo{claims: make(map[string]string)}
}
func (r *fakeRootClaimRepo) Get(_ context.Context, folderID int, rootPath string) (*models.MediaItemRoot, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%s", folderID, rootPath)
if cid, ok := r.claims[key]; ok {
return &models.MediaItemRoot{
MediaFolderID: folderID,
CanonicalRootPath: rootPath,
ContentID: cid,
}, nil
}
return nil, nil
}
func (r *fakeRootClaimRepo) ClaimRoot(_ context.Context, folderID int, rootPath, contentID string) error {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%s", folderID, rootPath)
if _, exists := r.claims[key]; !exists {
r.claims[key] = contentID
}
return nil
}
type fakeGroupClaimRepo struct {
mu sync.Mutex
claims map[string]string
}
func newFakeGroupClaimRepo() *fakeGroupClaimRepo {
return &fakeGroupClaimRepo{claims: make(map[string]string)}
}
func (r *fakeGroupClaimRepo) Get(_ context.Context, folderID int, groupKeyVersion int, contentGroupKey string) (*models.MediaItemGroup, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%d:%s", folderID, groupKeyVersion, contentGroupKey)
if cid, ok := r.claims[key]; ok {
return &models.MediaItemGroup{
MediaFolderID: folderID,
GroupKeyVersion: groupKeyVersion,
ContentGroupKey: contentGroupKey,
ContentID: cid,
}, nil
}
return nil, nil
}
func (r *fakeGroupClaimRepo) ClaimGroup(_ context.Context, folderID int, groupKeyVersion int, contentGroupKey, contentID string) error {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%d:%s", folderID, groupKeyVersion, contentGroupKey)
if _, exists := r.claims[key]; !exists {
r.claims[key] = contentID
}
return nil
}
func (r *fakeGroupClaimRepo) ClaimAndRelinkFiles(_ context.Context, folderID int, groupKeyVersion int, contentGroupKey, contentID string) (int, error) {
if err := r.ClaimGroup(context.Background(), folderID, groupKeyVersion, contentGroupKey, contentID); err != nil {
return 0, err
}
return 1, nil
}
// fakeSkippedRootRepo implements metadataSkippedRootRepo.
type fakeSkippedRootRepo struct {
mu sync.Mutex
skipped map[string]models.SkippedMediaRoot // "folderID:rootPath" -> root
}
func newFakeSkippedRootRepo() *fakeSkippedRootRepo {
return &fakeSkippedRootRepo{skipped: make(map[string]models.SkippedMediaRoot)}
}
func (r *fakeSkippedRootRepo) Upsert(_ context.Context, root models.SkippedMediaRoot) error {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%s", root.MediaFolderID, root.RootPath)
r.skipped[key] = root
return nil
}
func (r *fakeSkippedRootRepo) Delete(_ context.Context, folderID int, rootPath string) error {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%s", folderID, rootPath)
delete(r.skipped, key)
return nil
}
type fakeScannedRootRepo struct {
mu sync.Mutex
roots map[string]*models.ScannedMediaRoot
}
type fakeScannedGroupRepo struct {
mu sync.Mutex
groups map[string]*models.ScannedMediaGroup
}
func newFakeScannedGroupRepo() *fakeScannedGroupRepo {
return &fakeScannedGroupRepo{groups: make(map[string]*models.ScannedMediaGroup)}
}
func (r *fakeScannedGroupRepo) Get(_ context.Context, folderID int, groupKeyVersion int, contentGroupKey string) (*models.ScannedMediaGroup, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%d:%s", folderID, groupKeyVersion, contentGroupKey)
group, ok := r.groups[key]
if !ok {
return nil, nil
}
cp := *group
return &cp, nil
}
func (r *fakeScannedGroupRepo) setGroup(group *models.ScannedMediaGroup) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%d:%s", group.MediaFolderID, group.GroupKeyVersion, group.ContentGroupKey)
cp := *group
r.groups[key] = &cp
}
func newFakeScannedRootRepo() *fakeScannedRootRepo {
return &fakeScannedRootRepo{roots: make(map[string]*models.ScannedMediaRoot)}
}
func (r *fakeScannedRootRepo) Get(_ context.Context, folderID int, rootPath string) (*models.ScannedMediaRoot, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%s", folderID, rootPath)
root, ok := r.roots[key]
if !ok {
return nil, nil
}
cp := *root
return &cp, nil
}
func (r *fakeScannedRootRepo) setRoot(root *models.ScannedMediaRoot) {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%s", root.MediaFolderID, root.RootPath)
cp := *root
r.roots[key] = &cp
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
type testHarness struct {
service *MetadataService
itemRepo *fakeItemRepo
fileRepo *fakeFileRepo
libraryRepo *fakeLibraryRepo
rootClaimRepo *fakeRootClaimRepo
groupClaimRepo *fakeGroupClaimRepo
skippedRootRepo *fakeSkippedRootRepo
scannedRootRepo *fakeScannedRootRepo
scannedGroupRepo *fakeScannedGroupRepo
}
func newTestHarness() *testHarness {
h := &testHarness{
itemRepo: newFakeItemRepo(),
fileRepo: newFakeFileRepo(),
libraryRepo: newFakeLibraryRepo(),
rootClaimRepo: newFakeRootClaimRepo(),
groupClaimRepo: newFakeGroupClaimRepo(),
skippedRootRepo: newFakeSkippedRootRepo(),
scannedRootRepo: newFakeScannedRootRepo(),
scannedGroupRepo: newFakeScannedGroupRepo(),
}
h.service = &MetadataService{
itemRepo: h.itemRepo,
fileRepo: h.fileRepo,
libraryRepo: h.libraryRepo,
rootClaimRepo: h.rootClaimRepo,
groupClaimRepo: h.groupClaimRepo,
skippedRootRepo: h.skippedRootRepo,
scannedRootRepo: h.scannedRootRepo,
scannedGroupRepo: h.scannedGroupRepo,
}
return h
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestRecordRefreshFailure_IncrementsDebtAttemptForManualRefresh(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
debts := newFakeRefreshDebtRepo()
h.service.refreshDebtRepo = debts
h.itemRepo.items["item-1"] = &models.MediaItem{
ContentID: "item-1",
Status: "matched",
}
debts.debts["item-1"] = &models.MetadataRefreshDebt{
ContentID: "item-1",
AttemptCount: 1,
NextRefreshAt: time.Now().UTC(),
}
h.service.recordRefreshFailure(ctx, "item-1", fmt.Errorf("provider failed"), true)
debt, err := debts.Get(ctx, "item-1")
if err != nil {
t.Fatalf("Get debt: %v", err)
}
if debt.AttemptCount != 2 {
t.Fatalf("manual failure attempt_count = %d, want 2", debt.AttemptCount)
}
if debt.LastError != "provider failed" {
t.Fatalf("last_error = %q, want provider failed", debt.LastError)
}
}
func TestRecordRefreshFailure_KeepsClaimedAttemptForScheduledRefresh(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
debts := newFakeRefreshDebtRepo()
h.service.refreshDebtRepo = debts
h.itemRepo.items["item-1"] = &models.MediaItem{
ContentID: "item-1",
Status: "matched",
}
debts.debts["item-1"] = &models.MetadataRefreshDebt{
ContentID: "item-1",
AttemptCount: 1,
NextRefreshAt: time.Now().UTC(),
}
h.service.recordRefreshFailure(ctx, "item-1", fmt.Errorf("provider failed"), false)
debt, err := debts.Get(ctx, "item-1")
if err != nil {
t.Fatalf("Get debt: %v", err)
}
if debt.AttemptCount != 1 {
t.Fatalf("scheduled failure attempt_count = %d, want 1", debt.AttemptCount)
}
if debt.LastError != "provider failed" {
t.Fatalf("last_error = %q, want provider failed", debt.LastError)
}
}
func TestSyncRefreshDebtForItemPreservesRequestedEpisodeDebt(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
debts := newFakeRefreshDebtRepo()
h.service.refreshDebtRepo = debts
rating := 7.5
h.itemRepo.items["series-1"] = &models.MediaItem{
ContentID: "series-1",
Type: "series",
Status: "matched",
Overview: "Complete",
PosterPath: "/poster.jpg",
BackdropPath: "/backdrop.jpg",
RatingTMDB: &rating,
EpisodeMetadataIncomplete: true,
}
debts.debts["series-1"] = &models.MetadataRefreshDebt{
TargetType: RefreshTargetItem,
ContentID: "series-1",
ReasonMask: RefreshDebtReasonEpisodeIncomplete,
AttemptCount: 1,
NextRefreshAt: time.Now().UTC(),
}
if err := h.service.syncRefreshDebtForItem(ctx, "series-1"); err != nil {
t.Fatalf("syncRefreshDebtForItem: %v", err)
}
debt, err := debts.Get(ctx, "series-1")
if err != nil {
t.Fatalf("Get debt: %v", err)
}
if !hasRefreshDebtReason(debt.ReasonMask, RefreshDebtReasonEpisodeIncomplete) {
t.Fatalf("reason mask = %d, want episode incomplete", debt.ReasonMask)
}
item := *h.itemRepo.items["series-1"]
item.EpisodeMetadataIncomplete = false
h.itemRepo.items["series-1"] = &item
if err := h.service.syncRefreshDebtForItem(ctx, "series-1"); err != nil {
t.Fatalf("syncRefreshDebtForItem complete: %v", err)
}
if _, err := debts.Get(ctx, "series-1"); !errors.Is(err, ErrRefreshDebtNotFound) {
t.Fatalf("Get debt after complete = %v, want ErrRefreshDebtNotFound", err)
}
}
func TestRequestStaleMetadataRefreshStartsOnDemandRefreshOnce(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
debts := newFakeRefreshDebtRepo()
h.service.refreshDebtRepo = debts
started := make(chan struct{})
release := make(chan struct{})
done := make(chan struct{})
var startedOnce sync.Once
var doneOnce sync.Once
var calls atomic.Int32
h.service.hooks.process = func(ctx context.Context, req ProcessRequest) (*ProcessResult, error) {
calls.Add(1)
startedOnce.Do(func() { close(started) })
select {
case <-release:
case <-ctx.Done():
return nil, ctx.Err()
}
doneOnce.Do(func() { close(done) })
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
}
if err := h.service.RequestStaleMetadataRefresh(ctx, RefreshTargetItem, "item-1"); err != nil {
t.Fatalf("RequestStaleMetadataRefresh first: %v", err)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("timed out waiting for on-demand refresh to start")
}
if err := h.service.RequestStaleMetadataRefresh(ctx, RefreshTargetItem, "item-1"); err != nil {
t.Fatalf("RequestStaleMetadataRefresh second: %v", err)
}
time.Sleep(25 * time.Millisecond)
if got := calls.Load(); got != 1 {
t.Fatalf("on-demand refresh calls = %d, want 1 while first is running", got)
}
close(release)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timed out waiting for on-demand refresh to complete")
}
}
// TestCreateOrFindSkeleton_NoFolderIDs verifies that createOrFindSkeleton
// creates a real item even when the parent directory has no embedded folder IDs
// (e.g. {tmdb-12345}). Before this change, such roots were skipped entirely.
func TestCreateOrFindSkeleton_NoFolderIDs(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
// File under a folder without embedded IDs — title/year only.
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Must have created a new item.
if !result.IsNew {
t.Fatal("expected IsNew=true for first file under a new root")
}
if result.ContentID == "" {
t.Fatal("expected a non-empty content_id")
}
// The item should exist in the repo with status "pending".
item, err := h.itemRepo.GetByID(ctx, result.ContentID)
if err != nil {
t.Fatalf("item not found in repo: %v", err)
}
if item.Status != "pending" {
t.Errorf("expected status=pending, got %q", item.Status)
}
// The file should be linked.
if cid, ok := h.fileRepo.contentIDs[file.ID]; !ok || cid != result.ContentID {
t.Errorf("file not linked to skeleton item: got %q", cid)
}
}
// TestCreateOrFindSkeleton_PendingItemGetsLibraryMembership verifies that
// library membership is created immediately for pending items, not just for
// matched items.
func TestCreateOrFindSkeleton_PendingItemGetsLibraryMembership(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Library membership must exist immediately after skeleton creation.
if !h.libraryRepo.hasMembership(result.ContentID, 10) {
t.Error("expected library membership for newly created pending item, but none found")
}
}
// TestCreateOrFindSkeleton_SecondMovieFileDoesNotReuseSameContentID verifies
// that movie files sharing a scanner content group stay separate until
// metadata confirms a real shared identity.
func TestCreateOrFindSkeleton_SecondMovieFileDoesNotReuseSameContentID(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file1 := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
ContentGroupKey: "v1|movie|inception|2010",
GroupKeyVersion: 1,
BaseTitle: "Inception",
BaseYear: 2010,
BaseType: "movie",
}
file2 := &models.MediaFile{
ID: 2,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.srt.mkv",
ContentGroupKey: "v1|movie|inception|2010",
GroupKeyVersion: 1,
BaseTitle: "Inception",
BaseYear: 2010,
BaseType: "movie",
}
result1, err := h.service.createOrFindSkeleton(ctx, file1, 10)
if err != nil {
t.Fatalf("first file: %v", err)
}
result2, err := h.service.createOrFindSkeleton(ctx, file2, 10)
if err != nil {
t.Fatalf("second file: %v", err)
}
if result2.ContentID == result1.ContentID {
t.Errorf("expected second movie file to get a new content_id, got reused %q",
result1.ContentID)
}
if !result2.IsNew {
t.Error("expected IsNew=true for second movie file under same content group")
}
if len(h.groupClaimRepo.claims) != 0 {
t.Fatalf("expected no movie group claims, got %d", len(h.groupClaimRepo.claims))
}
}
// TestCreateOrFindSkeleton_WithFolderIDs verifies that the happy-path with
// folder IDs still works correctly after the refactor.
func TestCreateOrFindSkeleton_WithFolderIDs(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010) {tmdb-27205}/Inception.mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.IsNew {
t.Fatal("expected IsNew=true")
}
if result.TmdbID != "27205" {
t.Errorf("expected tmdb_id=27205, got %q", result.TmdbID)
}
item, err := h.itemRepo.GetByID(ctx, result.ContentID)
if err != nil {
t.Fatalf("item not found: %v", err)
}
if item.TmdbID != "27205" {
t.Errorf("expected item tmdb_id=27205, got %q", item.TmdbID)
}
}
func TestCreateOrFindSkeleton_IDTaggedMovieFolderBeatsDivergentReleaseFilename(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeMetadataFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: true},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/The Expendables 4 {imdb-tt3291150} {tmdb-299054}/Expend4bles (2023) [Remux-1080p 8-bit AVC TrueHD Atmos 7.1]-CiNEPHiLES.mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantRoot := "/media/movies/The Expendables 4 {imdb-tt3291150} {tmdb-299054}"
if result.RootPath != wantRoot {
t.Fatalf("RootPath = %q, want %q", result.RootPath, wantRoot)
}
if result.TmdbID != "299054" || result.ImdbID != "tt3291150" {
t.Fatalf("provider IDs = (%q, %q), want tmdb 299054 and imdb tt3291150", result.TmdbID, result.ImdbID)
}
if _, exists := h.skippedRootRepo.skipped["10:/media/movies/The Expendables 4 {imdb-tt3291150} {tmdb-299054}/Expend4bles (2023) [Remux-1080p 8-bit AVC TrueHD Atmos 7.1]-CiNEPHiLES"]; exists {
t.Fatal("unexpected skipped-root record for synthetic filename stem path")
}
if _, exists := h.skippedRootRepo.skipped["10:"+wantRoot]; exists {
t.Fatal("unexpected skipped-root record for ID-tagged parent folder")
}
item, err := h.itemRepo.GetByID(ctx, result.ContentID)
if err != nil {
t.Fatalf("item not found: %v", err)
}
if item.TmdbID != "299054" || item.ImdbID != "tt3291150" {
t.Fatalf("item provider IDs = (%q, %q), want tmdb 299054 and imdb tt3291150", item.TmdbID, item.ImdbID)
}
}
func TestCreateOrFindSkeleton_TrustedFilenameIDBypassesAmbiguousScanner(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Predator (1987)/Predator Ultimate Hunter Edition (1987) {tmdb-106}.mkv",
GroupKeyVersion: 1,
ContentGroupKey: "v1|movie|predator|1987",
}
h.scannedGroupRepo.setGroup(&models.ScannedMediaGroup{
MediaFolderID: 10,
GroupKeyVersion: 1,
ContentGroupKey: "v1|movie|predator|1987",
BaseTitle: "Predator",
BaseYear: 1987,
InferredType: "movie",
State: "ambiguous",
})
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := result.ItemStatus, "pending"; got != want {
t.Fatalf("ItemStatus = %q, want %q", got, want)
}
if got, want := result.TmdbID, "106"; got != want {
t.Fatalf("TmdbID = %q, want %q", got, want)
}
item, err := h.itemRepo.GetByID(ctx, result.ContentID)
if err != nil {
t.Fatalf("item not found: %v", err)
}
if got, want := item.Status, "pending"; got != want {
t.Fatalf("item.Status = %q, want %q", got, want)
}
if got, want := item.TmdbID, "106"; got != want {
t.Fatalf("item.TmdbID = %q, want %q", got, want)
}
}
func TestCreateOrFindSkeleton_TrustedFilenameIDBeatsStaleFolderID(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "predator-106",
Status: "matched",
Title: "Predator",
Year: 1987,
Type: "movie",
TmdbID: "106",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Predator (1987) {tmdb-999}/Predator Ultimate Hunter Edition (1987) {tmdb-106}.mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := result.ContentID, "predator-106"; got != want {
t.Fatalf("ContentID = %q, want %q", got, want)
}
if result.IsNew {
t.Fatal("expected matched external-id reuse to keep IsNew=false")
}
if got := h.fileRepo.contentIDs[file.ID]; got != "predator-106" {
t.Fatalf("linked content_id = %q, want predator-106", got)
}
}
func TestCreateOrFindSkeleton_MoviesLibraryEpisodeShapedMovieStaysMovie(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeMetadataFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: true},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Type != "movie" {
t.Fatalf("Type = %q, want movie", result.Type)
}
if result.RootPath != "/media/movies/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}" {
t.Fatalf("RootPath = %q", result.RootPath)
}
if result.TmdbID != "588077" || result.ImdbID != "tt12261772" {
t.Fatalf("provider IDs = (%q, %q), want tmdb 588077 and imdb tt12261772", result.TmdbID, result.ImdbID)
}
item, err := h.itemRepo.GetByID(ctx, result.ContentID)
if err != nil {
t.Fatalf("item not found: %v", err)
}
if item.Type != "movie" {
t.Fatalf("item.Type = %q, want movie", item.Type)
}
if item.TmdbID != "588077" || item.ImdbID != "tt12261772" {
t.Fatalf("item provider IDs = (%q, %q), want tmdb 588077 and imdb tt12261772", item.TmdbID, item.ImdbID)
}
}
func TestCreateOrFindSkeleton_MixedLibraryEpisodeShapedMovieStaysMovie(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.service.folderRepo = &fakeMetadataFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "mixed", Enabled: true},
},
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Type != "movie" {
t.Fatalf("Type = %q, want movie", result.Type)
}
if result.RootPath != "/media/mixed/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}" {
t.Fatalf("RootPath = %q", result.RootPath)
}
}
// TestPendingItemLifecycle_UnmatchedTransition verifies that the worker
// correctly transitions a pending item to "unmatched" when enrichment fails.
func TestPendingItemLifecycle_UnmatchedTransition(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
// Pre-create an item with status "pending".
contentID := "test-content-123"
h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: contentID,
Status: "pending",
Title: "Test Movie",
Year: 2020,
Type: "movie",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
})
// Call updateItemStatus to simulate what the worker does on failure.
h.service.updateItemStatus(ctx, contentID, "unmatched")
item, err := h.itemRepo.GetByID(ctx, contentID)
if err != nil {
t.Fatalf("item not found: %v", err)
}
if item.Status != "unmatched" {
t.Errorf("expected status=unmatched, got %q", item.Status)
}
}
// TestCreateOrFindSkeleton_MovieIgnoresGroupClaimDedup verifies that scanner
// group claims do not merge movie files before metadata confirmation.
func TestCreateOrFindSkeleton_MovieIgnoresGroupClaimDedup(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
existingContentID := "existing-content-456"
_, err := h.groupClaimRepo.ClaimAndRelinkFiles(ctx, 10, 1, "v1|movie|inception|2010", existingContentID)
if err != nil {
t.Fatalf("claiming test content group: %v", err)
}
// Also pre-seed the item so upsertLibraryMembership can find it.
h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: existingContentID,
Status: "pending",
Title: "Inception",
Year: 2010,
Type: "movie",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
})
file := &models.MediaFile{
ID: 5,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.720p.mkv",
ContentGroupKey: "v1|movie|inception|2010",
GroupKeyVersion: 1,
BaseTitle: "Inception",
BaseYear: 2010,
BaseType: "movie",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ContentID == existingContentID {
t.Errorf("expected movie to ignore pre-confirmation group claim %q", existingContentID)
}
if !result.IsNew {
t.Error("expected IsNew=true when only a movie group claim exists")
}
}
func TestCreateOrFindSkeleton_MovieReusesMatchedGroupClaim(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
existingContentID := "existing-content-456"
_, err := h.groupClaimRepo.ClaimAndRelinkFiles(ctx, 10, 1, "v1|movie|inception|2010", existingContentID)
if err != nil {
t.Fatalf("claiming test content group: %v", err)
}
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: existingContentID,
Status: "matched",
Title: "Inception",
Year: 2010,
Type: "movie",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
file := &models.MediaFile{
ID: 5,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.720p.mkv",
CanonicalRootPath: "/media/movies/Inception (2010)",
ContentGroupKey: "v1|movie|inception|2010",
GroupKeyVersion: 1,
BaseTitle: "Inception",
BaseYear: 2010,
BaseType: "movie",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ContentID != existingContentID {
t.Fatalf("result.ContentID = %q, want %q", result.ContentID, existingContentID)
}
if result.IsNew {
t.Fatal("expected IsNew=false when reusing matched movie group claim")
}
}
func TestCreateOrFindSkeleton_MovieIgnoresRootClaimDedup(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
existingContentID := "existing-content-root"
if err := h.rootClaimRepo.ClaimRoot(ctx, 10, "/media/movies/Inception (2010)", existingContentID); err != nil {
t.Fatalf("claiming test root: %v", err)
}
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: existingContentID,
Status: "pending",
Title: "Inception",
Year: 2010,
Type: "movie",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
file := &models.MediaFile{
ID: 5,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.720p.mkv",
BaseTitle: "Inception",
BaseYear: 2010,
BaseType: "movie",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ContentID == existingContentID {
t.Errorf("expected movie to ignore pre-confirmation root claim %q", existingContentID)
}
if !result.IsNew {
t.Error("expected IsNew=true when only a movie root claim exists")
}
}
func TestCreateOrFindSkeleton_MovieSkipsPendingExternalIDDedup(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "pending-predator",
Status: "pending",
Title: "Predator",
Year: 1987,
Type: "movie",
TmdbID: "106",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Predator (1987) {tmdb-106}/Predator (1987).mkv",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ContentID == "pending-predator" {
t.Fatal("expected pending external-id movie item to be ignored")
}
if !result.IsNew {
t.Fatal("expected IsNew=true when only a pending external-id movie item exists")
}
}
func TestCreateOrFindSkeleton_MovieSkipsTitleYearDedup(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "pending-inception",
Status: "pending",
Title: "Inception",
Year: 2010,
Type: "movie",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/movies/Inception (2010)/Inception.mkv",
BaseTitle: "Inception",
BaseYear: 2010,
BaseType: "movie",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ContentID == "pending-inception" {
t.Fatal("expected movie title/year fallback dedup to be disabled")
}
if !result.IsNew {
t.Fatal("expected IsNew=true when only title/year movie match exists")
}
}
func TestCreateOrFindSkeleton_SeriesReusesObservedRootScopedItem(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
existingContentID := "series-root-shell"
h.fileRepo.setRootContent(10, "/media/shows/Example Show", existingContentID)
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: existingContentID,
Status: "pending",
Title: "Example Show",
Year: 2024,
Type: "series",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E02.mkv",
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
BaseTitle: "Example Show",
BaseYear: 2024,
BaseType: "series",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ContentID != existingContentID {
t.Fatalf("ContentID = %q, want %q", result.ContentID, existingContentID)
}
if result.IsNew {
t.Fatal("expected IsNew=false for same observed-root series shell reuse")
}
}
func TestCreateOrFindSkeleton_SeriesSkipsPendingExternalIDDedupAcrossRoots(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "pending-series",
Status: "pending",
Title: "Example Show",
Year: 2024,
Type: "series",
TmdbID: "12345",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show Dolby Vision {tmdb-12345}/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/shows/Example Show Dolby Vision {tmdb-12345}",
BaseTitle: "Example Show",
BaseYear: 2024,
BaseType: "series",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ContentID == "pending-series" {
t.Fatal("expected pending external-id series item to be ignored across roots")
}
if !result.IsNew {
t.Fatal("expected IsNew=true when only a pending external-id series item exists")
}
}
func TestCreateOrFindSkeleton_SeriesPrefersMatchedRootItemOverProvisionalShell(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "pending-root-shell",
Status: "pending",
Title: "Example Show",
Year: 2024,
Type: "series",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert pending shell: %v", err)
}
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "matched-series",
Status: "matched",
Title: "Example Show",
Year: 2024,
Type: "series",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert matched series: %v", err)
}
h.fileRepo.setRootCandidates(10, "/media/shows/Example Show", map[string]string{
"pending-root-shell": "pending",
"matched-series": "matched",
})
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show/Season 01/Example.Show.S01E03.mkv",
ObservedRootPath: "/media/shows/Example Show",
BaseTitle: "Example Show",
BaseYear: 2024,
BaseType: "series",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := result.ContentID, "matched-series"; got != want {
t.Fatalf("ContentID = %q, want %q", got, want)
}
if result.IsNew {
t.Fatal("expected IsNew=false when a matched same-root series already exists")
}
}
func TestCreateOrFindSkeleton_SeriesSkipsTitleYearDedupAcrossRoots(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
if err := h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: "pending-title-year-series",
Status: "pending",
Title: "Example Show",
Year: 2024,
Type: "series",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upsert existing item: %v", err)
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/shows/Example Show HDR/Season 01/Example.Show.S01E01.mkv",
ObservedRootPath: "/media/shows/Example Show HDR",
BaseTitle: "Example Show",
BaseYear: 2024,
BaseType: "series",
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ContentID == "pending-title-year-series" {
t.Fatal("expected series title/year fallback dedup to be disabled across roots")
}
if !result.IsNew {
t.Fatal("expected IsNew=true when only title/year series match exists")
}
}
func TestClaimConfirmedSeriesRootOwnership_ClaimsRootAndGroups(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
files := []*models.MediaFile{
{
ID: 1,
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
},
{
ID: 2,
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 1,
ContentGroupKey: "v1|series|example_show|2024",
},
{
ID: 3,
MediaFolderID: 10,
ObservedRootPath: "/media/shows/Example Show",
GroupKeyVersion: 2,
ContentGroupKey: "v2|series|example_show|2024",
},
}
h.service.claimConfirmedSeriesRootOwnership(ctx, 10, "/media/shows/Example Show", "matched-series", files)
if got := h.rootClaimRepo.claims["10:/media/shows/Example Show"]; got != "matched-series" {
t.Fatalf("root claim = %q, want matched-series", got)
}
if got := h.groupClaimRepo.claims["10:1:v1|series|example_show|2024"]; got != "matched-series" {
t.Fatalf("group claim v1 = %q, want matched-series", got)
}
if got := h.groupClaimRepo.claims["10:2:v2|series|example_show|2024"]; got != "matched-series" {
t.Fatalf("group claim v2 = %q, want matched-series", got)
}
if len(h.groupClaimRepo.claims) != 2 {
t.Fatalf("group claim count = %d, want 2", len(h.groupClaimRepo.claims))
}
}
func TestLocalProviderContextForContent_ScopesToRequestedLibrary(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.fileRepo.setGroupFiles(10, 1, "movie-a",
&models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/library-a/Movie/Movie.mkv",
ObservedRootPath: "/library-a/Movie",
},
)
h.fileRepo.setGroupFiles(20, 1, "movie-b",
&models.MediaFile{
ID: 2,
MediaFolderID: 20,
FilePath: "/library-b/Movie/Movie.mkv",
ObservedRootPath: "/library-b/Movie",
},
)
h.fileRepo.contentIDs[1] = "shared-content"
h.fileRepo.contentIDs[2] = "shared-content"
localCtx := h.service.localProviderContextForContent(ctx, "shared-content", 20)
if got, want := localCtx.filePath, "/library-b/Movie/Movie.mkv"; got != want {
t.Fatalf("filePath = %q, want %q", got, want)
}
if got, want := localCtx.representativeFilePath, "/library-b/Movie/Movie.mkv"; got != want {
t.Fatalf("representativeFilePath = %q, want %q", got, want)
}
if got, want := localCtx.observedRootPath, "/library-b/Movie"; got != want {
t.Fatalf("observedRootPath = %q, want %q", got, want)
}
if !slices.Equal(localCtx.allGroupFilePaths, []string{"/library-b/Movie/Movie.mkv"}) {
t.Fatalf("allGroupFilePaths = %v", localCtx.allGroupFilePaths)
}
if !slices.Equal(localCtx.primarySidecarSearchPaths, []string{"/library-b/Movie"}) {
t.Fatalf("primarySidecarSearchPaths = %v", localCtx.primarySidecarSearchPaths)
}
}
func TestCreateOrFindSkeleton_PrefersScannedGroupHints(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.scannedGroupRepo.setGroup(&models.ScannedMediaGroup{
MediaFolderID: 10,
GroupKeyVersion: 1,
ContentGroupKey: "v1|movie|bagman|2024",
State: "resolved",
InferredType: "movie",
BaseTitle: "Bagman",
BaseYear: 2024,
TypeConfidence: "high",
SampleObservedRootPath: "/media/movies/Wrapper Root",
})
file := &models.MediaFile{
ID: 9,
MediaFolderID: 10,
FilePath: "/media/movies/Wrapper Root/Bagman.2024.1080p.mkv",
ObservedRootPath: "/media/movies/Wrapper Root",
ContentGroupKey: "v1|movie|bagman|2024",
GroupKeyVersion: 1,
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ObservedRootPath != "/media/movies/Wrapper Root" {
t.Fatalf("ObservedRootPath = %q", result.ObservedRootPath)
}
if result.Title != "Bagman" || result.Year != 2024 {
t.Fatalf("scanned group hints not applied, got title=%q year=%d", result.Title, result.Year)
}
}
func TestCreateOrFindSkeleton_AmbiguousScannedGroupCreatesAmbiguousItem(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
h.scannedGroupRepo.setGroup(&models.ScannedMediaGroup{
MediaFolderID: 10,
GroupKeyVersion: 1,
ContentGroupKey: "v1|movie|unknown root|0000",
State: "ambiguous",
InferredType: "movie",
TypeConfidence: "low",
SampleObservedRootPath: "/media/mixed/Unknown Root",
})
file := &models.MediaFile{
ID: 10,
MediaFolderID: 10,
FilePath: "/media/mixed/Unknown Root/mystery.mkv",
ObservedRootPath: "/media/mixed/Unknown Root",
ContentGroupKey: "v1|movie|unknown root|0000",
GroupKeyVersion: 1,
}
result, err := h.service.createOrFindSkeleton(ctx, file, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ItemStatus != "ambiguous" {
t.Fatalf("ItemStatus = %q, want ambiguous", result.ItemStatus)
}
item, err := h.itemRepo.GetByID(ctx, result.ContentID)
if err != nil {
t.Fatalf("item not found: %v", err)
}
if item.Status != "ambiguous" {
t.Fatalf("item.Status = %q, want ambiguous", item.Status)
}
}