Files
silo-server/internal/metadata/service_test.go
2dedf3ff26 feat(metadata): user-triggered trailer refresh with weekly per-item cooldown (#531)
* feat(metadata): user-triggered trailer refresh with weekly per-item cooldown

Adds POST /api/v1/items/{id}/trailers/refresh so any viewer with access to a
movie or series can ask the server to fetch its remote trailers, bounded by a
one-week per-item cooldown enforced server-side.

The cooldown lives in a new nullable media_items.trailers_refresh_requested_at
column rather than the refresh debt queue, whose last_attempt_at evaporates on
success (MarkTargetSuccess deletes the row when the reason mask clears). The
gate is a single UPDATE that writes NOW() only when the stored timestamp is
NULL or older than the window, so concurrent viewers cannot both win it; a
losing caller reads the stored timestamp back to compute next_allowed_at.

MetadataService.RequestTrailersRefresh resolves the per-library trailer_kinds
allow-list first: a non-nil empty map means every containing library disabled
remote videos, which answers "disabled" without consuming the cooldown slot
(a nil map is allow-all and must not short-circuit). On winning the gate it
reuses startOnDemandMetadataRefresh, whose scheduled mode merges fill-empty,
so this non-admin trigger cannot clobber unlocked admin edits while found
videos still persist.

The handler checks item access before calling the service, so an unauthorized
caller can never burn an item's slot, and rejects non movie/series types since
those detail responses never carry videos. cooldown and disabled are expected
client-rendered states and answer 200; 429 is reserved for the per-user
in-memory limiter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): release trailer-refresh slot on failed refresh; resolve episode ids to 400

Three review findings on the viewer-facing trailer fetch.

The weekly per-item slot was consumed unconditionally on winning the gate,
but the refresh it started ran detached and only logged on failure — nothing
ever put the slot back. A brief TMDb outage therefore answered 202 queued,
failed 30s later, and then answered cooldown for seven days over work that
never happened. The repository gains an equality-guarded release
(trailers_refresh_requested_at = NULL only while it still equals the
timestamp this request wrote, so a later claim is never clobbered), and
TryClaimTrailersRefresh now RETURNINGs the timestamp it stored so a winner
holds the key to its own slot. startOnDemandMetadataRefresh splits into a
claim step and runOnDemandMetadataRefresh, which takes an optional failure
hook; only the trailer path passes one, so the existing callers are
unchanged. A timeout counts as failure. A refresh that succeeds but finds
nothing still keeps the slot — that semantics was chosen deliberately.

The in-process dedup claim (shared with the item-detail view's stale nudge)
silently dropped the start while the slot had already been consumed, so the
caller was told queued for a refresh that never began. It is now taken
before the durable slot: a request landing while an equivalent refresh is
already in flight reports queued without consuming the slot, which is both
honest and retryable if that refresh fails.

Real episode and season content IDs answered 404 rather than the contracted
400, because neither is a media_items row and GetByID queries media_items
alone. The handler now falls through to the same season/episode lookups
HandleTranslateOnView uses, authorizing through the parent series, so a
genuine episode ID reports unsupported-type and only unknown content 404s.
The type-check test no longer fabricates a MediaItem{Type: "episode"} row
that production never writes; it covers the types that do exist as
media_items rows, with the episode and season paths tested through the
lookups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): address PR review on the trailer refresh action

Six review findings on the viewer-facing trailer fetch, all verified against
the current code before changing anything.

Durable claim no longer rides the request context. A cancellation landing
after Postgres commits the gate UPDATE but before pgx returns would consume
the item's weekly slot with no refresh started and nothing holding the
timestamp needed to release it. The claim now runs on
context.WithoutCancel with its own deadline, mirroring the release.

The cooldown gate retries once when the follow-up read finds the slot free.
Classification spans two statements, so a concurrent failure-release can
land between them; the old code reported that as a cooldown with no
next_allowed_at while the slot was in fact free. A NULL read now retries the
claim, and the doubly-lost case answers "queued" (an equivalent refresh is
running) rather than an undateable cooldown.

A failed item_videos write now releases the slot. mergeAndPersist logs and
continues when the write fails, so the refresh reported success and the
viewer was locked out for a week having stored nothing. A context-scoped
observer, installed only by this action, surfaces that failure to the
existing release hook.

Winning the gate also records durable refresh debt, so a restart that kills
the detached goroutine leaves work the refresh worker picks up instead of a
consumed slot and no fetch. Uses a new reason bit rather than the generic
failure reason: nothing is wrong with the item, so it must not sit in the
failure band ahead of real debt or count as a failure in operator metrics.

Any library lookup failure now degrades the video-kind scope to unknown. An
item in two libraries where one resolved with trailers off and the other
could not be read reported "disabled" — a guess made on behalf of a library
that might be the one enabling trailers. A library that is genuinely gone is
still skipped.

Adds GET /api/v1/items/trailers/capability, following the existing
per-subsystem probe convention. The action route is registered conditionally,
so "this build has the feature" is not the same question as "this deployment
serves it", and a 404 on the POST is indistinguishable from a missing item.
The probe is registered unconditionally and answers refresh:false when
unwired.

Not changed: content-ID canonicalization mid-refresh stranding the cooldown
on the old row. The re-anchor path is manual-refresh only and this action
runs in scheduled mode, so only local-skeleton promotion can fire, and the
rename carries the timestamp and the debt row to the new id along with
everything else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(web): format overlays schema after merging main

The line came in over-length from main's card_overlays merge and the Web
CI format check runs prettier across all of src, not just changed files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): address second review round — recovery-debt lease, locked-videos preflight, shared limiter

The restart mitigation added in the first round reintroduced two of the
problems it was closing, and the reviewer was right to push again.

Lease the recovery debt behind the fast path. The row was enqueued due
now, so refresh_metadata could claim it while the detached goroutine was
still running the same refresh — RefreshScheduledTarget does not consult
the in-process claim, so both would fetch the item at once. It is now due
5 minutes out, comfortably past the 2-minute on-demand timeout, and the
goroutine settles the row on success so it fires only when the fast path
really did not finish. Settling clears just the trailers-requested bit,
keeping any real debt the item still carries.

Release the cooldown after a failed recovery. A recovery runs in a worker
that never saw the claim, so a failure left the viewer blocked for the
week having stored nothing. RefreshScheduledTarget now adopts the claim
when the debt row carries the trailers-requested reason, reading the
stored timestamp so the release stays equality-guarded, and hands the
slot back on the same failures the fast path's hook covers — including a
videos write that failed and was only logged.

Preflight the videos lock. locked_fields containing FieldVideos makes
mergeAndPersist skip the item_videos write, so the refresh "succeeded"
and kept the cooldown while never being able to save trailers. It now
answers disabled before consuming the claim; reusing that status rather
than adding one is deliberate, since clients treat an unknown status as a
dead end and "trailers cannot be fetched for this item" is what disabled
already means to a viewer.

Use the shared limiter. A private MemoryLimiter gave every instance an
independent per-user allowance on Redis deployments, and the per-item
cooldown cannot compensate — it bounds one item, while this budget bounds
how many distinct items a user can start refreshes for. The action now
takes the middleware's configured limiter, with namespaced keys, and
falls back to a private one only when rate limiting is off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 20:53:21 -04:00

2520 lines
74 KiB
Go

package metadata
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
const (
testPosterPath = "/poster.jpg"
testBackdropPath = "/backdrop.jpg"
testTMDBProvider = "tmdb"
testTVDBProvider = "tvdb"
testIMDBProvider = "imdb"
testMetaDBProvider = "metadb"
)
// ---------------------------------------------------------------------------
// Fake repositories
// ---------------------------------------------------------------------------
// fakeItemRepo implements metadataItemRepo with an in-memory store.
type fakeItemRepo struct {
mu sync.Mutex
items map[string]*models.MediaItem
// Trailer refresh cooldown state (metadataTrailerRefreshRepo).
trailersRequestedAt map[string]time.Time
trailersRequestedAtErr error
trailersClaims int
trailersClaimErr error
trailersClaimResult *trailersClaimResult
trailersReleases int
trailersReleased chan struct{}
trailersReleaseGate chan struct{}
now func() time.Time
}
// trailersClaimResult forces a fixed answer out of the cooldown gate, for the
// outcomes the in-memory model cannot reach on its own — notably the real
// repository's "lost the gate but the slot kept being freed" answer, which
// carries no timestamp.
type trailersClaimResult struct {
claimed bool
requestedAt *time.Time
}
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) Delete(_ context.Context, contentID string) ([]string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.items[contentID]; !ok {
return nil, catalog.ErrItemNotFound
}
delete(r.items, contentID)
return nil, 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
}
// TryClaimTrailersRefresh mirrors the SQL gate in *catalog.ItemRepository: the
// claim succeeds only when no timestamp is stored or the stored one predates
// the cooldown window, and either way the caller reads back the timestamp now
// stored in the column.
func (r *fakeItemRepo) TryClaimTrailersRefresh(_ context.Context, contentID string, cooldown time.Duration) (bool, *time.Time, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.items[contentID]; !ok {
return false, nil, catalog.ErrItemNotFound
}
now := time.Now().UTC()
if r.now != nil {
now = r.now()
}
if r.trailersClaimErr != nil {
return false, nil, r.trailersClaimErr
}
if forced := r.trailersClaimResult; forced != nil {
return forced.claimed, forced.requestedAt, nil
}
stored, ok := r.trailersRequestedAt[contentID]
if !ok || stored.Before(now.Add(-cooldown)) {
if r.trailersRequestedAt == nil {
r.trailersRequestedAt = make(map[string]time.Time)
}
r.trailersRequestedAt[contentID] = now
r.trailersClaims++
claimed := now
return true, &claimed, nil
}
blocked := stored
return false, &blocked, nil
}
// ReleaseTrailersRefreshClaim mirrors the equality-guarded UPDATE: a slot that
// has since been re-claimed by a newer request is left alone.
//
// trailersReleaseGate, when set, holds the release until the test closes it,
// which lets a test interleave a newer claim with a late-arriving release.
func (r *fakeItemRepo) ReleaseTrailersRefreshClaim(_ context.Context, contentID string, claimedAt time.Time) error {
r.mu.Lock()
gate := r.trailersReleaseGate
r.mu.Unlock()
if gate != nil {
<-gate
}
r.mu.Lock()
defer r.mu.Unlock()
r.trailersReleases++
if stored, ok := r.trailersRequestedAt[contentID]; ok && stored.Equal(claimedAt) {
delete(r.trailersRequestedAt, contentID)
}
if r.trailersReleased != nil {
close(r.trailersReleased)
r.trailersReleased = nil
}
return nil
}
// TrailersRefreshRequestedAt reads back the stored claim the way the durable
// recovery path does, so a refresh that inherits a claim can release it on the
// same key the original request wrote.
func (r *fakeItemRepo) TrailersRefreshRequestedAt(_ context.Context, contentID string) (*time.Time, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.items[contentID]; !ok {
return nil, catalog.ErrItemNotFound
}
if r.trailersRequestedAtErr != nil {
return nil, r.trailersRequestedAtErr
}
if stored, ok := r.trailersRequestedAt[contentID]; ok {
return &stored, nil
}
return nil, nil
}
// expectTrailersRelease arms a channel closed by the next
// ReleaseTrailersRefreshClaim, so a test can wait for the detached refresh's
// failure path instead of polling.
func (r *fakeItemRepo) expectTrailersRelease() chan struct{} {
r.mu.Lock()
defer r.mu.Unlock()
released := make(chan struct{})
r.trailersReleased = released
return released
}
func (r *fakeItemRepo) trailersClaimCount() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.trailersClaims
}
func (r *fakeItemRepo) trailersReleaseCount() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.trailersReleases
}
// trailersStoredAt reports the timestamp currently stored for the item, or nil
// when the slot is free.
func (r *fakeItemRepo) trailersStoredAt(contentID string) *time.Time {
r.mu.Lock()
defer r.mu.Unlock()
if stored, ok := r.trailersRequestedAt[contentID]; ok {
return &stored
}
return 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
}
// RequestDue mirrors the repository's merge semantics rather than overwriting:
// the real statement ORs the reason mask, keeps the greater priority and the
// earlier next_refresh_at. Callers reason about all three (a trailer request
// adds its reason to whatever debt an item already has, and must not push
// genuinely-due work out), so a fake that replaced the row would hide that.
func (r *fakeRefreshDebtRepo) RequestDue(
_ context.Context,
targetType string,
contentID string,
priority int,
reasonMask int64,
nextRefreshAt time.Time,
_ time.Duration,
) error {
r.mu.Lock()
defer r.mu.Unlock()
targetType = NormalizeRefreshTargetType(targetType)
key := fakeRefreshDebtKey(targetType, contentID)
if key == "" || contentID == "" || reasonMask == 0 {
return nil
}
if existing, ok := r.debts[key]; ok {
existing.ReasonMask |= reasonMask
existing.Priority = max(existing.Priority, priority)
if nextRefreshAt.Before(existing.NextRefreshAt) {
existing.NextRefreshAt = nextRefreshAt
}
return nil
}
r.debts[key] = &models.MetadataRefreshDebt{
TargetType: targetType,
ContentID: contentID,
Priority: priority,
ReasonMask: reasonMask,
NextRefreshAt: nextRefreshAt,
}
return nil
}
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
updateErrors map[int]error
afterUpdate func(fileID int, contentID string)
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),
updateErrors: make(map[int]error),
}
}
func (r *fakeFileRepo) UpdateContentID(_ context.Context, fileID int, contentID string) error {
r.mu.Lock()
if err := r.updateErrors[fileID]; err != nil {
r.mu.Unlock()
return err
}
r.contentIDs[fileID] = contentID
afterUpdate := r.afterUpdate
r.mu.Unlock()
if afterUpdate != nil {
afterUpdate(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
languages map[string][]string // contentID -> distinct metadata languages override
languagesErr map[string]error // contentID -> forced lookup error
}
func newFakeLibraryRepo() *fakeLibraryRepo {
return &fakeLibraryRepo{
memberships: make(map[string]time.Time),
languages: make(map[string][]string),
languagesErr: make(map[string]error),
}
}
// setMetadataLanguages overrides GetDistinctMetadataLanguagesForItem for one
// item, optionally forcing a lookup error.
func (r *fakeLibraryRepo) setMetadataLanguages(contentID string, languages []string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
r.languages[contentID] = languages
if err != nil {
r.languagesErr[contentID] = err
} else {
delete(r.languagesErr, contentID)
}
}
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) {
r.mu.Lock()
forcedErr := r.languagesErr[contentID]
override, hasOverride := r.languages[contentID]
r.mu.Unlock()
if forcedErr != nil {
return nil, forcedErr
}
if hasOverride {
return override, nil
}
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
// lookupErrs forces a transient failure for a folder that otherwise
// exists. Callers distinguish "this library is gone" from "this library
// could not be read", so the fake has to be able to produce both.
lookupErrs map[int]error
}
func (r *fakeMetadataFolderRepo) GetByID(_ context.Context, id int) (*models.MediaFolder, error) {
if err, ok := r.lookupErrs[id]; ok {
return nil, err
}
if folder, ok := r.folders[id]; ok {
cp := *folder
return &cp, nil
}
return nil, catalog.ErrFolderNotFound
}
// 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) UpsertObservedFile(_ context.Context, folderID int, rootPath, reason, sampleFilePath string) error {
r.mu.Lock()
defer r.mu.Unlock()
key := fmt.Sprintf("%d:%s", folderID, rootPath)
r.skipped[key] = models.SkippedMediaRoot{
MediaFolderID: folderID,
RootPath: rootPath,
Reason: reason,
SampleFilePath: sampleFilePath,
FileCount: 1,
}
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: testPosterPath,
BackdropPath: testBackdropPath,
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 TestSyncRefreshDebtForItemClearsDebtAfterSecondaryTMDBRejected(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
debts := newFakeRefreshDebtRepo()
staleIDs := newFakeStaleIDRepo()
h.service.refreshDebtRepo = debts
h.service.staleIDRepo = staleIDs
rating := 8.0
h.itemRepo.items["series-1"] = &models.MediaItem{
ContentID: "series-1",
Type: "series",
Status: "matched",
TvdbID: "405851",
Overview: "Complete",
PosterPath: testPosterPath,
BackdropPath: testBackdropPath,
RatingTMDB: &rating,
}
staleIDs.set("series-1", &models.StaleMediaID{
ContentID: "series-1", Provider: testTMDBProvider, ProviderID: "12236904",
})
debts.debts["series-1"] = &models.MetadataRefreshDebt{
TargetType: RefreshTargetItem, ContentID: "series-1", ReasonMask: RefreshDebtReasonStaleProviderID,
}
if err := h.service.syncRefreshDebtForItem(ctx, "series-1"); err != nil {
t.Fatalf("syncRefreshDebtForItem: %v", err)
}
if _, err := debts.Get(ctx, "series-1"); !errors.Is(err, ErrRefreshDebtNotFound) {
t.Fatalf("Get debt after rejected secondary tmdb = %v, want ErrRefreshDebtNotFound", err)
}
}
func TestSyncRefreshDebtForItemKeepsFailedCurrentProviderID(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
debts := newFakeRefreshDebtRepo()
staleIDs := newFakeStaleIDRepo()
h.service.refreshDebtRepo = debts
h.service.staleIDRepo = staleIDs
rating := 8.0
h.itemRepo.items["series-1"] = &models.MediaItem{
ContentID: "series-1",
Type: "series",
Status: "matched",
TvdbID: "405851",
Overview: "Complete",
PosterPath: testPosterPath,
BackdropPath: testBackdropPath,
RatingTMDB: &rating,
}
staleIDs.set("series-1", &models.StaleMediaID{
ContentID: "series-1", Provider: testTVDBProvider, ProviderID: "405851",
})
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, RefreshDebtReasonStaleProviderID) {
t.Fatalf("reason mask = %d, want stale provider ID", debt.ReasonMask)
}
}
func TestShouldReanchorProviderContentIDRequiresManualRefresh(t *testing.T) {
const anchoredID = "movie-tmdb-111"
tests := []struct {
name string
contentID string
isNew bool
mode RefreshMode
want bool
}{
{
name: "scheduled refresh",
contentID: anchoredID, mode: ModeScheduledRefresh,
},
{
name: "identify",
contentID: anchoredID, mode: ModeIdentify,
},
{
name: "manual refresh",
contentID: anchoredID, mode: ModeManualRefresh, want: true,
},
{
name: "new item never reanchors",
contentID: anchoredID, isNew: true, mode: ModeManualRefresh,
},
{
name: "local item",
contentID: "local-deadbeef", mode: ModeManualRefresh,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldReanchorProviderContentID(tt.contentID, tt.isNew, tt.mode); got != tt.want {
t.Fatalf("shouldReanchorProviderContentID() = %v, want %v", got, tt.want)
}
})
}
}
func TestRefreshSeriesEpisodeMetadataStateUsesActionableDebt(t *testing.T) {
now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC)
t.Run("provider numbered episode clears stale debt", func(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
episodes := newFakeEpisodeRepo()
debts := newFakeRefreshDebtRepo()
h.service.episodeRepo = episodes
h.service.refreshDebtRepo = debts
h.itemRepo.items["series-provider"] = &models.MediaItem{
ContentID: "series-provider",
Type: "series",
EpisodeMetadataIncomplete: true,
}
if err := episodes.Upsert(ctx, &models.Episode{
ContentID: "episode-provider",
SeriesID: "series-provider",
SeasonID: "season-provider",
SeasonNumber: 1,
EpisodeNumber: 1,
Title: "Episode 1",
TvdbID: "9607609",
MetadataSource: "provider",
}); err != nil {
t.Fatalf("seed provider episode: %v", err)
}
debts.debts[fakeRefreshDebtKey(RefreshTargetEpisode, "episode-provider")] = &models.MetadataRefreshDebt{
TargetType: RefreshTargetEpisode,
ContentID: "episode-provider",
ReasonMask: RefreshDebtReasonEpisodeIncomplete,
}
h.service.refreshSeriesEpisodeMetadataState(ctx, "series-provider", now)
item, err := h.itemRepo.GetByID(ctx, "series-provider")
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if item.EpisodeMetadataIncomplete {
t.Fatal("expected provider numbered episode to clear series incomplete flag")
}
if item.EpisodeMetadataLastCheckedAt == nil || !item.EpisodeMetadataLastCheckedAt.Equal(now) {
t.Fatalf("last checked at = %v, want %v", item.EpisodeMetadataLastCheckedAt, now)
}
if _, err := debts.GetTarget(ctx, RefreshTargetEpisode, "episode-provider"); !errors.Is(err, ErrRefreshDebtNotFound) {
t.Fatalf("provider episode debt after refresh = %v, want ErrRefreshDebtNotFound", err)
}
})
t.Run("scanner fallback episode creates debt", func(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
episodes := newFakeEpisodeRepo()
debts := newFakeRefreshDebtRepo()
h.service.episodeRepo = episodes
h.service.refreshDebtRepo = debts
h.itemRepo.items["series-fallback"] = &models.MediaItem{
ContentID: "series-fallback",
Type: "series",
}
if err := episodes.Upsert(ctx, &models.Episode{
ContentID: "episode-fallback",
SeriesID: "series-fallback",
SeasonID: "season-fallback",
SeasonNumber: 1,
EpisodeNumber: 1,
Title: "Episode 1",
MetadataSource: "scanner_fallback",
}); err != nil {
t.Fatalf("seed fallback episode: %v", err)
}
h.service.refreshSeriesEpisodeMetadataState(ctx, "series-fallback", now)
item, err := h.itemRepo.GetByID(ctx, "series-fallback")
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if !item.EpisodeMetadataIncomplete {
t.Fatal("expected fallback episode to keep series incomplete flag")
}
debt, err := debts.GetTarget(ctx, RefreshTargetEpisode, "episode-fallback")
if err != nil {
t.Fatalf("GetTarget fallback debt: %v", err)
}
if !hasRefreshDebtReason(debt.ReasonMask, RefreshDebtReasonEpisodeIncomplete) {
t.Fatalf("fallback debt reason mask = %d, want episode incomplete", debt.ReasonMask)
}
})
}
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)
}
}
func TestCreateOrFindSkeleton_DisabledFolderDoesNotCreateItem(t *testing.T) {
h := newTestHarness()
h.service.folderRepo = &fakeMetadataFolderRepo{
folders: map[int]*models.MediaFolder{
10: {ID: 10, Type: "movies", Enabled: false},
},
}
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.Fatal("expected disabled folder error")
}
if result != nil {
t.Fatalf("expected no result, got %+v", result)
}
if !strings.Contains(err.Error(), "disabled") {
t.Fatalf("expected disabled error, got %v", err)
}
if len(h.itemRepo.items) != 0 {
t.Fatalf("expected no items to be created, got %d", len(h.itemRepo.items))
}
if cid := h.fileRepo.contentIDs[file.ID]; cid != "" {
t.Fatalf("expected file to remain unlinked, got content_id %q", cid)
}
}
func TestCreateOrFindSkeleton_LinkFailureDeletesSkeleton(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
linkErr := errors.New("media file not found")
h.fileRepo.updateErrors[1] = linkErr
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.Fatal("expected link error")
}
if result != nil {
t.Fatalf("expected no result, got %+v", result)
}
if !errors.Is(err, linkErr) {
t.Fatalf("expected wrapped link error, got %v", err)
}
if len(h.itemRepo.items) != 0 {
t.Fatalf("expected skeleton item to be deleted, got %d items", len(h.itemRepo.items))
}
if len(h.libraryRepo.memberships) != 0 {
t.Fatalf("expected no library memberships, got %d", len(h.libraryRepo.memberships))
}
}
func TestCreateOrFindSkeleton_DisabledBeforeMembershipDeletesSkeleton(t *testing.T) {
h := newTestHarness()
ctx := context.Background()
folder := &models.MediaFolder{ID: 10, Type: "movies", Enabled: true}
h.service.folderRepo = &fakeMetadataFolderRepo{
folders: map[int]*models.MediaFolder{10: folder},
}
h.fileRepo.afterUpdate = func(_ int, contentID string) {
if contentID != "" {
folder.Enabled = false
}
}
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.Fatal("expected disabled folder error")
}
if result != nil {
t.Fatalf("expected no result, got %+v", result)
}
if !strings.Contains(err.Error(), "disabled") {
t.Fatalf("expected disabled error, got %v", err)
}
if len(h.itemRepo.items) != 0 {
t.Fatalf("expected skeleton item to be deleted, got %d items", len(h.itemRepo.items))
}
if len(h.libraryRepo.memberships) != 0 {
t.Fatalf("expected no library memberships, got %d", len(h.libraryRepo.memberships))
}
if cid := h.fileRepo.contentIDs[file.ID]; cid != "" {
t.Fatalf("expected file link to be cleared, got content_id %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)
}
}