1987 lines
58 KiB
Go
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)
|
|
}
|
|
}
|