* 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>
1021 lines
42 KiB
Go
1021 lines
42 KiB
Go
package metadata
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// waitForProcess blocks until the detached on-demand refresh has called
|
|
// Process, so a queued test does not leak a goroutine into the next one.
|
|
func waitForProcess(t *testing.T, started <-chan struct{}) {
|
|
t.Helper()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for the on-demand refresh to start")
|
|
}
|
|
}
|
|
|
|
// waitForOnDemandIdle blocks until no on-demand refresh holds an in-process
|
|
// claim. The claim is released in the detached goroutine's defer, slightly
|
|
// after Process returns, and a still-held claim silently drops the next
|
|
// refresh — so a test that queues twice has to wait for it.
|
|
func waitForOnDemandIdle(t *testing.T, s *MetadataService) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(time.Second)
|
|
for time.Now().Before(deadline) {
|
|
s.onDemandRefresh.mu.Lock()
|
|
running := len(s.onDemandRefresh.running)
|
|
s.onDemandRefresh.mu.Unlock()
|
|
if running == 0 {
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatal("timed out waiting for on-demand refresh claims to clear")
|
|
}
|
|
|
|
// RequestTrailersRefresh reaches the cooldown gate through a runtime type
|
|
// assertion on itemRepo, so a drift in the repository's signature would turn
|
|
// every request into an error instead of failing the build.
|
|
func TestItemRepositorySatisfiesTrailerRefreshGate(t *testing.T) {
|
|
var repo any = (*catalog.ItemRepository)(nil)
|
|
if _, ok := repo.(metadataTrailerRefreshRepo); !ok {
|
|
t.Fatal("*catalog.ItemRepository must satisfy metadataTrailerRefreshRepo")
|
|
}
|
|
}
|
|
|
|
func TestRequestTrailersRefreshQueuesThenReportsCooldown(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("first status = %q, want %q", outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
if outcome.NextAllowedAt != nil {
|
|
t.Fatalf("queued outcome must not carry next_allowed_at, got %v", outcome.NextAllowedAt)
|
|
}
|
|
waitForProcess(t, started)
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
// The second request inside the window loses the gate and reports when the
|
|
// next one may win: the stored timestamp plus the cooldown.
|
|
outcome, err = h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh second: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusCooldown {
|
|
t.Fatalf("second status = %q, want %q", outcome.Status, TrailerRefreshStatusCooldown)
|
|
}
|
|
if got := h.itemRepo.trailersReleaseCount(); got != 0 {
|
|
t.Fatalf("a successful refresh must keep the slot, released %d times", got)
|
|
}
|
|
if outcome.NextAllowedAt == nil {
|
|
t.Fatal("cooldown outcome must carry next_allowed_at")
|
|
}
|
|
want := now.Add(TrailerRefreshCooldown)
|
|
if !outcome.NextAllowedAt.Equal(want) {
|
|
t.Fatalf("next_allowed_at = %s, want %s", outcome.NextAllowedAt, want)
|
|
}
|
|
if got := h.itemRepo.trailersClaimCount(); got != 1 {
|
|
t.Fatalf("cooldown slot consumed %d times, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestRequestTrailersRefreshAllowsRetryAfterCooldownLapses(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
processed := make(chan string, 4)
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
processed <- req.ContentID
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("first request = %+v, err = %v", outcome, err)
|
|
}
|
|
select {
|
|
case <-processed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for the first on-demand refresh")
|
|
}
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
// One second past the window the gate opens again.
|
|
now = now.Add(TrailerRefreshCooldown + time.Second)
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh after cooldown: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("status after cooldown lapsed = %q, want %q", outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
select {
|
|
case <-processed:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for the second on-demand refresh")
|
|
}
|
|
if got := h.itemRepo.trailersClaimCount(); got != 2 {
|
|
t.Fatalf("cooldown slot consumed %d times, want 2", got)
|
|
}
|
|
}
|
|
|
|
// A library whose trailer_kinds allow-list is empty has remote videos turned
|
|
// off, so the request is answered "disabled" — and must not burn the item's
|
|
// weekly slot, or a user would be locked out for a week over a no-op.
|
|
func TestRequestTrailersRefreshDisabledDoesNotConsumeCooldownSlot(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
if err := h.libraryRepo.Upsert(ctx, "movie-1", 10, time.Now()); err != nil {
|
|
t.Fatalf("seed library membership: %v", err)
|
|
}
|
|
folder := &models.MediaFolder{ID: 10, Type: "movies", Enabled: true, TrailerKinds: nil}
|
|
h.service.folderRepo = &fakeMetadataFolderRepo{folders: map[int]*models.MediaFolder{10: folder}}
|
|
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
t.Errorf("disabled request must not start a refresh (content_id %s)", req.ContentID)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusDisabled {
|
|
t.Fatalf("status = %q, want %q", outcome.Status, TrailerRefreshStatusDisabled)
|
|
}
|
|
if got := h.itemRepo.trailersClaimCount(); got != 0 {
|
|
t.Fatalf("disabled request consumed the cooldown slot %d times, want 0", got)
|
|
}
|
|
|
|
// Re-enabling the library lets the very next request through, proving the
|
|
// slot really was untouched.
|
|
folder.TrailerKinds = []string{string(models.ExtraKindTrailer)}
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
outcome, err = h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh after re-enabling: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("status after re-enabling = %q, want %q", outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
waitForProcess(t, started)
|
|
}
|
|
|
|
// A nil allow-list is "allow all" — an unknown scope or a transient library
|
|
// lookup failure. It must not be mistaken for "disabled".
|
|
func TestRequestTrailersRefreshTreatsUnknownLibraryScopeAsAllowed(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
// The item has no library membership, so resolveAllowedVideoKinds returns
|
|
// nil rather than an empty map.
|
|
h.service.folderRepo = &fakeMetadataFolderRepo{folders: map[int]*models.MediaFolder{}}
|
|
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("status = %q, want %q", outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
waitForProcess(t, started)
|
|
}
|
|
|
|
func TestRequestTrailersRefreshPropagatesGateErrors(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
gateErr := errors.New("database is down")
|
|
h.itemRepo.trailersClaimErr = gateErr
|
|
|
|
if _, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); !errors.Is(err, gateErr) {
|
|
t.Fatalf("err = %v, want %v", err, gateErr)
|
|
}
|
|
// The failed gate call must not strand the in-process claim, or every
|
|
// later request for this item would be silently deduped away.
|
|
waitForOnDemandIdle(t, h.service)
|
|
}
|
|
|
|
// The weekly slot pays for work actually done. When the refresh it started
|
|
// fails — a provider outage, a timeout — the slot goes back so the viewer can
|
|
// retry now instead of waiting out a window in which nothing was fetched.
|
|
func TestRequestTrailersRefreshReleasesSlotWhenRefreshFails(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
|
|
return nil, errors.New("tmdb is unreachable")
|
|
}
|
|
|
|
released := h.itemRepo.expectTrailersRelease()
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("status = %q, want %q", outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
select {
|
|
case <-released:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for the failed refresh to release the cooldown slot")
|
|
}
|
|
if stored := h.itemRepo.trailersStoredAt("movie-1"); stored != nil {
|
|
t.Fatalf("failed refresh left the slot consumed until %s", stored)
|
|
}
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
// The very next request wins the gate again, with no clock movement.
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
outcome, err = h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("retry after a failed refresh: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("retry status = %q, want %q", outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
waitForProcess(t, started)
|
|
}
|
|
|
|
// A refresh that succeeds but turns up nothing keeps the slot: "no trailers
|
|
// exist for this title" is an answer, and re-asking providers weekly is the
|
|
// accepted cost ceiling.
|
|
func TestRequestTrailersRefreshKeepsSlotWhenRefreshFindsNothing(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
// A successful refresh that produced no videos is indistinguishable here
|
|
// from any other success: Process returns Updated, and no video rows were
|
|
// written.
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("request = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForProcess(t, started)
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
if got := h.itemRepo.trailersReleaseCount(); got != 0 {
|
|
t.Fatalf("successful refresh released the slot %d times, want 0", got)
|
|
}
|
|
if stored := h.itemRepo.trailersStoredAt("movie-1"); stored == nil {
|
|
t.Fatal("successful refresh must keep the slot consumed")
|
|
}
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("second request: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusCooldown {
|
|
t.Fatalf("status after a successful empty refresh = %q, want %q",
|
|
outcome.Status, TrailerRefreshStatusCooldown)
|
|
}
|
|
}
|
|
|
|
// The release is guarded on the timestamp the failing request wrote, so a
|
|
// release that lands after the window lapsed and a newer request claimed the
|
|
// slot must leave that newer claim alone — otherwise the late write would hand
|
|
// out a free extra refresh.
|
|
//
|
|
// The newer claim is taken against the gate directly rather than through
|
|
// RequestTrailersRefresh: the point under test is the timestamp guard, and
|
|
// driving it through the public API would only exercise the in-process dedup
|
|
// that TestRequestTrailersRefreshInFlightRefreshQueuesWithoutConsumingSlot
|
|
// already covers.
|
|
func TestRequestTrailersRefreshReleaseDoesNotClobberNewerClaim(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
// Hold the failing request's release until a newer claim is in place.
|
|
gate := make(chan struct{})
|
|
h.itemRepo.trailersReleaseGate = gate
|
|
|
|
failed := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
|
|
close(failed)
|
|
return nil, errors.New("tmdb is unreachable")
|
|
}
|
|
|
|
released := h.itemRepo.expectTrailersRelease()
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("first request = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForProcess(t, failed)
|
|
|
|
// The window lapses and a later request wins the gate afresh.
|
|
now = now.Add(TrailerRefreshCooldown + time.Second)
|
|
newClaimAt := now
|
|
claimed, claimedAt, err := h.itemRepo.TryClaimTrailersRefresh(ctx, "movie-1", TrailerRefreshCooldown)
|
|
if err != nil || !claimed || claimedAt == nil {
|
|
t.Fatalf("newer claim = %v, at = %v, err = %v", claimed, claimedAt, err)
|
|
}
|
|
|
|
// Only now does the first request's release land.
|
|
close(gate)
|
|
select {
|
|
case <-released:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for the late release")
|
|
}
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
stored := h.itemRepo.trailersStoredAt("movie-1")
|
|
if stored == nil {
|
|
t.Fatal("the late release cleared a slot claimed by a newer request")
|
|
}
|
|
if !stored.Equal(newClaimAt) {
|
|
t.Fatalf("stored timestamp = %s, want the newer claim %s", stored, newClaimAt)
|
|
}
|
|
}
|
|
|
|
// The in-process claim is shared with the detail view's stale-metadata nudge.
|
|
// A trailer request that arrives while an equivalent refresh is already running
|
|
// is answered "queued" — one really is running — without consuming the weekly
|
|
// slot, so a failure of that refresh still leaves the viewer able to retry.
|
|
func TestRequestTrailersRefreshInFlightRefreshQueuesWithoutConsumingSlot(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
// Hold an in-flight refresh open for the duration of the request under
|
|
// test, exactly as the item-detail path's nudge would.
|
|
inFlight := make(chan struct{})
|
|
entered := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(entered)
|
|
<-inFlight
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
h.service.startOnDemandMetadataRefresh(RefreshTargetItem, "movie-1")
|
|
waitForProcess(t, entered)
|
|
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("status = %q, want %q", outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
if got := h.itemRepo.trailersClaimCount(); got != 0 {
|
|
t.Fatalf("in-flight refresh consumed the cooldown slot %d times, want 0", got)
|
|
}
|
|
|
|
close(inFlight)
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
// The slot was untouched, so the next request still wins the gate.
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
outcome, err = h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("request after the in-flight refresh finished: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("status = %q, want %q", outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
if got := h.itemRepo.trailersClaimCount(); got != 1 {
|
|
t.Fatalf("cooldown slot consumed %d times, want 1", got)
|
|
}
|
|
waitForProcess(t, started)
|
|
}
|
|
|
|
// The disabled short-circuit returns before the in-process claim is taken, so
|
|
// it must not leave one behind either.
|
|
func TestRequestTrailersRefreshDisabledLeavesNoInProcessClaim(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
if err := h.libraryRepo.Upsert(ctx, "movie-1", 10, time.Now()); err != nil {
|
|
t.Fatalf("seed library membership: %v", err)
|
|
}
|
|
h.service.folderRepo = &fakeMetadataFolderRepo{folders: map[int]*models.MediaFolder{
|
|
10: {ID: 10, Type: "movies", Enabled: true, TrailerKinds: nil},
|
|
}}
|
|
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusDisabled {
|
|
t.Fatalf("request = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForOnDemandIdle(t, h.service)
|
|
}
|
|
|
|
// A cooldown answer takes and then hands back the in-process claim; leaking it
|
|
// would mute every subsequent refresh for the item until the process restarts.
|
|
func TestRequestTrailersRefreshCooldownLeavesNoInProcessClaim(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("first request = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForProcess(t, started)
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusCooldown {
|
|
t.Fatalf("second request = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForOnDemandIdle(t, h.service)
|
|
}
|
|
|
|
// A refresh whose item_videos write failed is a failure for this action even
|
|
// though the pipeline reports success: the cooldown is a budget for fetching
|
|
// trailers, and charging a week for trailers that were fetched but not stored
|
|
// would strand the viewer.
|
|
func TestRequestTrailersRefreshReleasesSlotWhenVideoPersistFails(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
// Stand in for mergeAndPersist: the pipeline succeeds overall while the
|
|
// videos write fails and is only logged, which is exactly the shape the
|
|
// observer exists to surface.
|
|
persistErr := errors.New("replace item videos: connection reset")
|
|
h.service.hooks.process = func(processCtx context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
reportVideoPersistFailure(processCtx, persistErr)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
released := h.itemRepo.expectTrailersRelease()
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("request = %+v, err = %v", outcome, err)
|
|
}
|
|
select {
|
|
case <-released:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for the slot to be released after a failed videos write")
|
|
}
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
if stored := h.itemRepo.trailersStoredAt("movie-1"); stored != nil {
|
|
t.Fatalf("slot must be free after a failed videos write, stored %s", stored)
|
|
}
|
|
// The viewer can retry immediately rather than waiting out the window.
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("retry = %+v, want an immediately queued retry, err = %v", outcome, err)
|
|
}
|
|
waitForProcess(t, started)
|
|
}
|
|
|
|
// The detached goroutine does not survive a restart, so winning the gate also
|
|
// records durable debt: a process that dies mid-refresh leaves work the refresh
|
|
// worker picks up instead of an item locked out for the window having fetched
|
|
// nothing.
|
|
func TestRequestTrailersRefreshRecordsDurableDebt(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
debts := newFakeRefreshDebtRepo()
|
|
h.service.refreshDebtRepo = debts
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("request = %+v, err = %v", outcome, err)
|
|
}
|
|
|
|
debt, err := debts.GetTarget(ctx, RefreshTargetItem, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("queued request must leave durable debt behind: %v", err)
|
|
}
|
|
if !hasRefreshDebtReason(debt.ReasonMask, RefreshDebtReasonTrailersRequested) {
|
|
t.Fatalf("reason mask = %d, want the trailers-requested reason set", debt.ReasonMask)
|
|
}
|
|
// Nothing is wrong with the item, so the row must not sit in a band that
|
|
// front-runs genuine debt.
|
|
if debt.Priority != refreshDebtPriority(0) {
|
|
t.Fatalf("priority = %d, want the default band %d", debt.Priority, refreshDebtPriority(0))
|
|
}
|
|
waitForProcess(t, started)
|
|
waitForOnDemandIdle(t, h.service)
|
|
}
|
|
|
|
// A cooldown answer performs no work, so it must not enqueue debt either —
|
|
// otherwise repeated polling from a client would keep an item permanently due.
|
|
func TestRequestTrailersRefreshCooldownRecordsNoDebt(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
debts := newFakeRefreshDebtRepo()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("first request = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForProcess(t, started)
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
// Wire the debt repo only now, so anything it records can only have come
|
|
// from the cooldown request below.
|
|
h.service.refreshDebtRepo = debts
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusCooldown {
|
|
t.Fatalf("second request = %+v, err = %v", outcome, err)
|
|
}
|
|
if _, err := debts.GetTarget(ctx, RefreshTargetItem, "movie-1"); !errors.Is(err, ErrRefreshDebtNotFound) {
|
|
t.Fatalf("a cooldown answer must not enqueue debt, got err = %v", err)
|
|
}
|
|
}
|
|
|
|
// A claim lost to a slot that keeps being freed underneath the repository is
|
|
// not a cooldown — there is no timestamp to report one with. Reporting it as
|
|
// queued matches the in-process-dedup answer: an equivalent refresh is running.
|
|
func TestRequestTrailersRefreshUndateableLostClaimReportsQueued(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
h.itemRepo.trailersClaimResult = &trailersClaimResult{}
|
|
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
t.Errorf("a lost claim must not start a refresh (content_id %s)", req.ContentID)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("status = %q, want %q", outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
if outcome.NextAllowedAt != nil {
|
|
t.Fatalf("an undateable lost claim must not carry next_allowed_at, got %v", outcome.NextAllowedAt)
|
|
}
|
|
waitForOnDemandIdle(t, h.service)
|
|
}
|
|
|
|
// "Disabled" means every containing library turned remote videos off. That
|
|
// claim cannot be made from a partially-resolved set: an unreadable library
|
|
// might be the one that enables trailers, so any lookup failure degrades the
|
|
// answer to unknown scope (allow-all) rather than a guess the viewer sees as
|
|
// "trailers are disabled for this library".
|
|
func TestRequestTrailersRefreshUnreadableLibraryIsNotDisabled(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
for _, folderID := range []int{10, 11} {
|
|
if err := h.libraryRepo.Upsert(ctx, "movie-1", folderID, time.Now()); err != nil {
|
|
t.Fatalf("seed library membership %d: %v", folderID, err)
|
|
}
|
|
}
|
|
// Folder 10 resolves with trailers off; folder 11 cannot be read at all.
|
|
h.service.folderRepo = &fakeMetadataFolderRepo{
|
|
folders: map[int]*models.MediaFolder{
|
|
10: {ID: 10, Type: "movies", Enabled: true, TrailerKinds: nil},
|
|
},
|
|
lookupErrs: map[int]error{11: errors.New("connection reset")},
|
|
}
|
|
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("status = %q, want %q — a failed library lookup is unknown scope, not disabled",
|
|
outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
waitForProcess(t, started)
|
|
waitForOnDemandIdle(t, h.service)
|
|
}
|
|
|
|
// A library that no longer exists is not a failure: it cannot be the one
|
|
// enabling trailers, so it is skipped and the remaining libraries still decide.
|
|
func TestRequestTrailersRefreshMissingLibraryStillReportsDisabled(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
for _, folderID := range []int{10, 11} {
|
|
if err := h.libraryRepo.Upsert(ctx, "movie-1", folderID, time.Now()); err != nil {
|
|
t.Fatalf("seed library membership %d: %v", folderID, err)
|
|
}
|
|
}
|
|
// Folder 11 is absent from the repo entirely (deleted library).
|
|
h.service.folderRepo = &fakeMetadataFolderRepo{
|
|
folders: map[int]*models.MediaFolder{
|
|
10: {ID: 10, Type: "movies", Enabled: true, TrailerKinds: nil},
|
|
},
|
|
}
|
|
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
t.Errorf("disabled request must not start a refresh (content_id %s)", req.ContentID)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusDisabled {
|
|
t.Fatalf("status = %q, want %q", outcome.Status, TrailerRefreshStatusDisabled)
|
|
}
|
|
if got := h.itemRepo.trailersClaimCount(); got != 0 {
|
|
t.Fatalf("disabled request consumed the cooldown slot %d times, want 0", got)
|
|
}
|
|
}
|
|
|
|
// An admin lock on the videos field makes mergeAndPersist skip the item_videos
|
|
// write, so a refresh started for one would "succeed" having saved nothing and
|
|
// charge the viewer a week for it. The preflight has to catch that before the
|
|
// slot is consumed.
|
|
func TestRequestTrailersRefreshLockedVideosDoesNotConsumeCooldownSlot(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{
|
|
ContentID: "movie-1",
|
|
Type: "movie",
|
|
Status: "matched",
|
|
LockedFields: []int{int(FieldVideos)},
|
|
}
|
|
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
t.Errorf("a videos-locked item must not start a refresh (content_id %s)", req.ContentID)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh: %v", err)
|
|
}
|
|
// Reuses "disabled" rather than a new status: clients treat unknown
|
|
// statuses as a dead end, and "trailers cannot be fetched for this item"
|
|
// is exactly what disabled already means to a viewer.
|
|
if outcome.Status != TrailerRefreshStatusDisabled {
|
|
t.Fatalf("status = %q, want %q", outcome.Status, TrailerRefreshStatusDisabled)
|
|
}
|
|
if got := h.itemRepo.trailersClaimCount(); got != 0 {
|
|
t.Fatalf("a videos-locked item consumed the cooldown slot %d times, want 0", got)
|
|
}
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
// Unlocking lets the very next request through, proving the slot was never
|
|
// touched.
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("request after unlocking = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForProcess(t, started)
|
|
}
|
|
|
|
// A lock on some *other* field says nothing about videos, so it must not block
|
|
// the action.
|
|
func TestRequestTrailersRefreshUnrelatedLockStillQueues(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{
|
|
ContentID: "movie-1",
|
|
Type: "movie",
|
|
Status: "matched",
|
|
LockedFields: []int{int(FieldOverview), int(FieldImages)},
|
|
}
|
|
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("RequestTrailersRefresh: %v", err)
|
|
}
|
|
if outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("status = %q, want %q", outcome.Status, TrailerRefreshStatusQueued)
|
|
}
|
|
waitForProcess(t, started)
|
|
}
|
|
|
|
// The recovery row is insurance against a process that dies mid-refresh, so it
|
|
// must not be claimable while the fast path could still be running: the refresh
|
|
// task calls RefreshScheduledTarget without consulting the in-process claim, so
|
|
// a due-now row would have the worker and the goroutine fetching the same item
|
|
// at once.
|
|
func TestRequestTrailersRefreshRecoveryDebtIsNotDueDuringTheFastPath(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
debts := newFakeRefreshDebtRepo()
|
|
h.service.refreshDebtRepo = debts
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
// Hold the refresh open so the debt row is observed exactly while the fast
|
|
// path is running — the window the reviewer's race lives in.
|
|
inFlight := make(chan struct{})
|
|
entered := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(entered)
|
|
<-inFlight
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("request = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForProcess(t, entered)
|
|
|
|
debt, err := debts.GetTarget(ctx, RefreshTargetItem, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("queued request must leave durable debt behind: %v", err)
|
|
}
|
|
if !debt.NextRefreshAt.After(time.Now().UTC().Add(metadataOnDemandRefreshTimeout)) {
|
|
t.Fatalf("recovery debt is due at %s, which is within the on-demand refresh window (%s) — "+
|
|
"the refresh worker could claim it alongside the running goroutine",
|
|
debt.NextRefreshAt, metadataOnDemandRefreshTimeout)
|
|
}
|
|
|
|
close(inFlight)
|
|
waitForOnDemandIdle(t, h.service)
|
|
}
|
|
|
|
// Once the fast path has done the work, the recovery row has nothing left to
|
|
// recover: leaving it would have the worker re-run a refresh that already
|
|
// happened as soon as the delay lapsed.
|
|
func TestRequestTrailersRefreshClearsRecoveryDebtOnSuccess(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
debts := newFakeRefreshDebtRepo()
|
|
h.service.refreshDebtRepo = debts
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
// Process here stands in for a refresh whose own debt sync did not run
|
|
// (hooks.process short-circuits processInternal), which is the case the
|
|
// settle step exists to cover.
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("request = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForProcess(t, started)
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
if _, err := debts.GetTarget(ctx, RefreshTargetItem, "movie-1"); !errors.Is(err, ErrRefreshDebtNotFound) {
|
|
t.Fatalf("a completed fast path must leave no recovery debt, got err = %v", err)
|
|
}
|
|
}
|
|
|
|
// Settling the recovery reason must not discard debt the item genuinely has:
|
|
// another reason in the mask means it still needs refreshing, and the queue
|
|
// should keep saying so.
|
|
func TestRequestTrailersRefreshSettleKeepsOtherDebtReasons(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
debts := newFakeRefreshDebtRepo()
|
|
h.service.refreshDebtRepo = debts
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
if err := debts.UpsertTargetDebt(ctx, RefreshTargetItem, "movie-1",
|
|
refreshDebtPriority(RefreshDebtReasonCoreMetadataIncomplete),
|
|
RefreshDebtReasonCoreMetadataIncomplete,
|
|
time.Now().UTC()); err != nil {
|
|
t.Fatalf("seed existing debt: %v", err)
|
|
}
|
|
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("request = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForProcess(t, started)
|
|
waitForOnDemandIdle(t, h.service)
|
|
|
|
debt, err := debts.GetTarget(ctx, RefreshTargetItem, "movie-1")
|
|
if err != nil {
|
|
t.Fatalf("pre-existing debt must survive the settle: %v", err)
|
|
}
|
|
if hasRefreshDebtReason(debt.ReasonMask, RefreshDebtReasonTrailersRequested) {
|
|
t.Fatalf("reason mask = %d, want the trailers-requested bit cleared", debt.ReasonMask)
|
|
}
|
|
if !hasRefreshDebtReason(debt.ReasonMask, RefreshDebtReasonCoreMetadataIncomplete) {
|
|
t.Fatalf("reason mask = %d, want the pre-existing core-metadata reason kept", debt.ReasonMask)
|
|
}
|
|
}
|
|
|
|
// The durable recovery is the path taken when the process that consumed a slot
|
|
// died mid-refresh. It runs in a worker that never saw the claim, so without an
|
|
// explicit adoption a failed recovery would leave the viewer blocked for the
|
|
// whole window having stored nothing — the exact gap the release hook closes on
|
|
// the fast path.
|
|
func TestRefreshScheduledTargetReleasesInheritedTrailerClaimOnFailure(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
debts := newFakeRefreshDebtRepo()
|
|
h.service.refreshDebtRepo = debts
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
// Stand in for the state a dead process left behind: the slot is consumed
|
|
// and the debt row carries the trailers-requested reason.
|
|
claimed, claimedAt, err := h.itemRepo.TryClaimTrailersRefresh(ctx, "movie-1", TrailerRefreshCooldown)
|
|
if err != nil || !claimed || claimedAt == nil {
|
|
t.Fatalf("seed claim = %v, at = %v, err = %v", claimed, claimedAt, err)
|
|
}
|
|
if err := debts.UpsertTargetDebt(ctx, RefreshTargetItem, "movie-1",
|
|
refreshDebtPriority(RefreshDebtReasonTrailersRequested),
|
|
RefreshDebtReasonTrailersRequested,
|
|
now); err != nil {
|
|
t.Fatalf("seed recovery debt: %v", err)
|
|
}
|
|
|
|
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
|
|
return nil, errors.New("tmdb is unreachable")
|
|
}
|
|
|
|
if err := h.service.RefreshScheduledTarget(ctx, RefreshTargetItem, "movie-1"); err == nil {
|
|
t.Fatal("the recovery refresh was expected to fail")
|
|
}
|
|
if stored := h.itemRepo.trailersStoredAt("movie-1"); stored != nil {
|
|
t.Fatalf("a failed recovery left the slot consumed until %s", stored)
|
|
}
|
|
|
|
// The viewer can retry immediately rather than waiting out a window in
|
|
// which nothing was ever fetched.
|
|
started := make(chan struct{})
|
|
h.service.hooks.process = func(_ context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
close(started)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
if outcome, err := h.service.RequestTrailersRefresh(ctx, "movie-1"); err != nil ||
|
|
outcome.Status != TrailerRefreshStatusQueued {
|
|
t.Fatalf("retry after a failed recovery = %+v, err = %v", outcome, err)
|
|
}
|
|
waitForProcess(t, started)
|
|
}
|
|
|
|
// A recovery whose videos write failed and was only logged is a failure for the
|
|
// cooldown's purposes too: the pipeline reports success while none of the
|
|
// trailers the week was charged for were stored.
|
|
func TestRefreshScheduledTargetReleasesInheritedClaimWhenVideoPersistFails(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
debts := newFakeRefreshDebtRepo()
|
|
h.service.refreshDebtRepo = debts
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
if claimed, _, err := h.itemRepo.TryClaimTrailersRefresh(ctx, "movie-1", TrailerRefreshCooldown); err != nil || !claimed {
|
|
t.Fatalf("seed claim = %v, err = %v", claimed, err)
|
|
}
|
|
if err := debts.UpsertTargetDebt(ctx, RefreshTargetItem, "movie-1",
|
|
refreshDebtPriority(RefreshDebtReasonTrailersRequested),
|
|
RefreshDebtReasonTrailersRequested,
|
|
now); err != nil {
|
|
t.Fatalf("seed recovery debt: %v", err)
|
|
}
|
|
|
|
persistErr := errors.New("replace item videos: connection reset")
|
|
h.service.hooks.process = func(processCtx context.Context, req ProcessRequest) (*ProcessResult, error) {
|
|
reportVideoPersistFailure(processCtx, persistErr)
|
|
return &ProcessResult{ContentID: req.ContentID, Updated: true}, nil
|
|
}
|
|
|
|
// The refresh itself succeeded, so the queue must still see a success.
|
|
if err := h.service.RefreshScheduledTarget(ctx, RefreshTargetItem, "movie-1"); err != nil {
|
|
t.Fatalf("RefreshScheduledTarget: %v", err)
|
|
}
|
|
if stored := h.itemRepo.trailersStoredAt("movie-1"); stored != nil {
|
|
t.Fatalf("slot must be free after a failed videos write, stored %s", stored)
|
|
}
|
|
}
|
|
|
|
// A scheduled refresh for an item nobody asked trailers for owes no release:
|
|
// the item may hold a claim from an unrelated in-flight request, and clearing
|
|
// it would hand out a free extra refresh.
|
|
func TestRefreshScheduledTargetLeavesUnrelatedTrailerClaimsAlone(t *testing.T) {
|
|
h := newTestHarness()
|
|
ctx := context.Background()
|
|
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
|
h.itemRepo.now = func() time.Time { return now }
|
|
debts := newFakeRefreshDebtRepo()
|
|
h.service.refreshDebtRepo = debts
|
|
h.itemRepo.items["movie-1"] = &models.MediaItem{ContentID: "movie-1", Type: "movie", Status: "matched"}
|
|
|
|
if claimed, _, err := h.itemRepo.TryClaimTrailersRefresh(ctx, "movie-1", TrailerRefreshCooldown); err != nil || !claimed {
|
|
t.Fatalf("seed claim = %v, err = %v", claimed, err)
|
|
}
|
|
// Debt without the trailers-requested reason: ordinary scheduled work.
|
|
if err := debts.UpsertTargetDebt(ctx, RefreshTargetItem, "movie-1",
|
|
refreshDebtPriority(RefreshDebtReasonCoreMetadataIncomplete),
|
|
RefreshDebtReasonCoreMetadataIncomplete,
|
|
now); err != nil {
|
|
t.Fatalf("seed debt: %v", err)
|
|
}
|
|
|
|
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
|
|
return nil, errors.New("tmdb is unreachable")
|
|
}
|
|
|
|
if err := h.service.RefreshScheduledTarget(ctx, RefreshTargetItem, "movie-1"); err == nil {
|
|
t.Fatal("the scheduled refresh was expected to fail")
|
|
}
|
|
if h.itemRepo.trailersStoredAt("movie-1") == nil {
|
|
t.Fatal("an unrelated scheduled refresh released a cooldown slot it does not own")
|
|
}
|
|
if got := h.itemRepo.trailersReleaseCount(); got != 0 {
|
|
t.Fatalf("unrelated refresh released the slot %d times, want 0", got)
|
|
}
|
|
}
|