Provider credentials are bootstrap config and never reach the database
(DESIGN.md §10), so which providers exist is settled once at startup;
which of them a search runs is the `providers_enabled` row the API reads
per request. OpenSubtitles.com cannot be called without a registered API
key, so without one it is not offered at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue #199, DESIGN.md §15 and §9.1. Lists what exists per media file and
per title, runs the enabled providers for one language and returns every
candidate with §9.3's verdict vocabulary — including the rejected ones
naming the rule that killed each — then grabs, translates and deletes.
Inline rather than 202-and-poll like the release deck: a subtitle search
is one or two HTTP calls and nothing persists its candidates, so there
is nothing to come back for. The cost is that a grab repeats the
`forced` and `sdh` facts the search reported, since the server does not
remember them.
Every write ends by marking the language satisfied, whether or not it is
in the wanted set. That is §15's "manual actions bypass the wanted-set
logic": the operator asking for Spanish gets Spanish, and the loop does
not then read it as a gap. A forced track is the exception §15 names — it
covers signs only — so it is recorded and satisfies nothing.
`alass` (#194) does not run yet: a fetched sidecar is recorded unsynced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DESIGN.md §15 reads satisfaction off the files, so a `satisfied` attempt
row whose sidecar was just deleted by hand is a stale claim that hides
the gap from the reconcile loop's work list. `unsatisfy` withdraws only
that claim: the attempt count and timestamp stay, because the backoff is
a fact about what providers were already asked and a delete does not
un-ask them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Podnapisi zip fixtures never reached the branch: a global gitignore
excludes *.zip, so git add skipped them silently and the worker's CI passed
against untracked files. Recovered, with a fixtures .gitignore that keeps
the next binary fixture from vanishing the same way.
Two providers landing in parallel also disagreed on names and on which
crate dependencies each integration-test target uses. PodnapisiProvider is
now Podnapisi, matching OpenSubtitles, and both test targets declare the
dependencies they do not use so the per-target lint stays quiet.
Searches by moviehash computed from the media file and by TMDB id
with season/episode for TV, ranks candidates through
arr_core::subs::rank, and downloads under a lazily-fetched user
token. The daily download cap (429/406) surfaces as Error::RateLimited
so the loop can show a queue state. Credentials come from config or
environment only; tests run against wiremock fixtures.
The concurrent subtitles milestone carries 0024_subtitles.sql and
0025_subtitle_settings.sql. Two migrations claiming version 24 do not
conflict in git — the filenames differ — so both would land on main and
sqlx would see a duplicate version. Renumbering here is the half that
does not depend on the other milestone acting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements #209 per §5.5 as amended by #208: a band's floor and target
are rates against a 45-minute reference runtime, scaled by the series'
minutes per episode. A missing or zero runtime applies the bands
unscaled, and movies are never scaled. The runtime is stored on the
series row (new migration), filled on add and by the metadata refresh,
which never blanks a known value against TMDB's frequently-empty
episode_run_time. Composes with #210: allow_below_floor waives against
the scaled floor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ffmpeg, spawned and left to die like ffprobe, maps one subtitle stream
and converts it to SRT under §15's sidecar name. Text formats become
legal translation sources; bitmap tracks never extract.
SubtitleTrack now carries the codec, split text formats from bitmap
ones per DESIGN.md §15, plus the forced and SDH dispositions ffprobe
reports. Without the forced flag a file carrying only a forced track
read as satisfied for that language.
The Backend trait plus everything the backends (#191-#193) share, so no
backend can skip it: chunking into character-budgeted batches, rejection
of replies whose cue count or numbering drifted, and reassembly onto the
original timings. Timing data never leaves arr; pt-PT and pt-BR are
distinct targets a backend must refuse rather than conflate.
Translation reassembles translated text onto original timings (§15), so
cues need a structured form. Parsing is tolerant of real files (CRLF,
BOM, missing indices, dot milliseconds); rendering is strict and
renumbers from 1.
A release below §5.5's floor was rejected with no way through, so a
policy wrong about one title left three Rick and Morty S09 packs
visible and none grabbable.
`allow_below_floor` relaxes the floor for one title into a soft fail,
never a pass: the release is waived, so automatic grabbing still skips
it and the import records a §5.7 waiver. The deck offers the one click
on a rejected row where the rule has an override, which is exactly what
§9.3's override is for.
Stored verdicts are re-derived when a title's overrides change — the
deck and the daemon's grab gate both read that column, so without it
the row the operator just acted on would keep reading `rejected`.
Closes#210
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#184 and #185 were built in parallel and their candidate types did not
meet. Ranking returned no identity for a candidate, so the winner of a
rank() could not be handed back to Provider::download -- rank() reorders,
so the caller could not recover it by position either.
RankedSubtitle now carries the index of the candidate in the slice it was
given, and Candidate::to_core is the one place the two shapes are mapped:
hash_match against a compared hash, sdh against hearing_impaired, group
against release_group, and the two optional tiebreakers defaulted to sort
last rather than block a candidate.
Recording a subtitle is idempotent on both keys the schema carries: the
sidecar path, and the language an embedded track satisfies, so a second
probe or a re-import converges instead of duplicating. Attempts are
upserted per (media file, language); the work list is every language that
is not satisfied, newest import first, which is the order §15 wants the
daily allowance spent in.
DESIGN.md §15 needs two shapes: what subtitles exist for a media file,
and what arr has tried per wanted language. An embedded track carries no
path — it is recorded because it satisfies a language, not because there
is a file — and the CHECK constraints tie provider, engine and path to
the origin so an impossible row cannot be written.
The skeleton the subtitles milestone hangs off: provider domain types, an
object-safe Provider trait, the crate's own error type, and the cargo
features the translation backends will sit behind. No provider, no
translation, no ranking — DESIGN.md §15 keeps ranking pure in arr-core.
An empty season deck was three truths wearing one message, and the one
it chose to blame was wrong: a season on the per-episode lane sat on
"sweeping indexers…" for the full wait and then blamed a backoff for a
pack search that was never going to run.
`GET /api/series/{id}/seasons/{n}/pack-state` says which lane the
season takes and why, from `season_grab_reason` in arr-core, plus the
failed-pack tally and when #181's window reopens. Seasons gain
`last_pack_search_at`, written only by a season-scoped sweep, so a
pack search that ran and found nothing is a settled answer rather than
a pending one.
The deck then says the true thing in each case, and a season held off
the pack lane by a failure offers the retry that waives its window.
Refs #182
A size band describes one episode (DESIGN.md §5.5), so both the target
penalty and the floor now compare a release's size divided by the number
of episodes it covers. The caller supplies the count — arr-core has no
IO. Movies and unknown counts divide by one, so movie scoring is
unchanged and pinned by test.
Closes#180
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A failed season-pack grab held the season off the pack lane forever:
pack_hard_failed was a bare EXISTS over failed grabs, so one bad
torrent disabled pack search for good, against §6.2's "it never gives
up entirely, it goes quiet".
The guard now rides the shared backoff curve (backoff_elapsed, 1h → 6h
→ 1d → 3d, capped 7d), counting failed pack grabs as attempts and
anchoring on the latest one's grabbed_at. Both the targeted and RSS
lanes agree. A manual season search waives the window outright — the
season deck is §6.2's escape hatch.
Closes#181.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Issue #155 removed the sqlx 0.8 rebuild workarounds by editing migrations
0007, 0014 and 0021 in place. Editing an applied migration changes its
checksum, and `migrate()` refuses to run when one no longer matches what
`_sqlx_migrations` recorded, so the daemon exited on startup against any
database that had already applied them — production included.
The sqlx 0.9 bump is the fix and survives: a new migration can carry
`-- no-transaction` so `PRAGMA foreign_keys = OFF` holds and a table
rebuild stops cascade-deleting its children. Only the retroactive cleanup
of migrations that already ran is reverted, along with #153's
`CHECK (title <> '')`, which rode on the 0021 edit and needs a migration
of its own rather than a rewrite of history.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With sqlx 0.9 honouring `-- no-transaction`, 0007 loses its
movie_releases_backup stash like 0014 did, and 0021 gains
the CHECK (title <> '') that #153 abandoned because the 0.8
migrator could not run a rebuild with foreign keys off. The
rebuild test now enters at migration 6 so the child links
ride through all three rebuilds, and a new test proves the
'' -> 'TBA' backfill and the rejection of new empty titles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The metadata lane runs daily, so a series added a moment ago showed no
seasons for up to 24 hours and a movie had no digital release date —
the field §6.2 gates targeted search on.
AppState now carries a MetadataCommand channel alongside the movie,
episode and season ones. Both create handlers send on it after the row
is committed, and a new daemon lane drains it. Its own task rather than
an arm of manual::run: a refresh against TMDB can take a while and must
not sit in front of an operator's manual search.
The add never waits on TMDB and never fails because of it. A refresh
that fails leaves metadata_refreshed_at NULL, which is what the daily
sweep already treats as due, so the title is retried rather than lost.
A command naming a title deleted in between finds no row and does
nothing. METADATA_INTERVAL is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sqlx-sqlite 0.8 ignored Migration.no_tx and wrapped every
migration in a transaction, where PRAGMA foreign_keys = OFF
is a no-op — so any table rebuild cascade-deleted children.
0.9 honours `-- no-transaction`, so 0014 drops its
movie_releases_backup / episode_releases_backup workaround
and runs the plain rebuild recipe with foreign keys off.
A migration test rebuilds both parents from a pre-0014
database and asserts the child link rows survive.
Closes#155
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>