Commit Graph

87 Commits

Author SHA1 Message Date
Miguel Palhas 34e5663920 Merge main into blitz/subtitles
Feedback pass 2 and the size-band work landed on main while this branch
was finishing. Brings them in ahead of the merge back.

# Conflicts:
#	crates/arr-api/src/movies.rs
#	crates/arr-api/src/state.rs
#	crates/arr-daemon/src/main.rs
#	web/src/main.ts
2026-08-25 17:53:18 +01:00
Miguel Palhas 1e30c49a72 Merge #245: pack backoff runs from the failure
Closes #245
2026-08-25 11:50:39 +01:00
Miguel Palhas 55373d228c fix: pack backoff runs from the failure
#239 moved §5.7's attention window to `failed_at` and left §6.2's pack
ladder on `grabbed_at`. A torrent that stalls for weeks before ffprobe
condemns it at import has elapsed the whole ladder the moment it fails,
so the pack lane retried a source that had just failed — the one thing
the backoff exists to prevent.

The ladder now measures from the failure, the same anchor and the same
column §5.7 reads, with `grabbed_at` as the fallback for rows written
before the column existed. All three sites read
`max(coalesce(failed_at, grabbed_at))`, so the `last_failed_at` alias
holds what its name says — including the one the season deck feeds into
`reopens_at` and `pack_retry_at`, which was showing a grab time under a
name §5.7 had redefined.

DESIGN.md §6.2 states the anchor the way §5.7 states its own.

Tests cover a pack grabbed 35 days ago and failed 10 minutes ago on the
targeted lane, the RSS lane and the season deck.

`just ci` through the gate: 519/519 tests pass, web checks clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:49:44 +01:00
Miguel Palhas a0dc07f085 feat(db): let a waiver name the rule it relaxed
`releases` forbade a rule name on anything but a rejection, so §9.3's
deck showed a bare `waived` beside rejections that each named their own,
and §5.7's "watchable but not what was asked" lost the half that says
what was not asked for. Since #210 that is the ordinary outcome of
waiving a size rejection, not a rare one.

0032 rebuilds the table with `CHECK (verdict != 'rejected' OR
rejected_rule IS NOT NULL)`, and the daemon and arr-api's
reclassification both store the waived rule. Existing rows keep NULL and
read as they do today.

`releases` is a parent — `grabs`, `movie_releases`, `episode_releases`
and `season_releases` point at it, three ON DELETE CASCADE — so the
rebuild runs `-- no-transaction` with foreign keys off around one
explicit transaction, per SQLite's own procedure. Verified against a
real database: the pre-0032 binary created and populated it, this build
migrated a copy, and every release row, child row and created_at came
through byte-identical with `PRAGMA foreign_key_check` clean.

Refs #211
2026-08-25 11:44:05 +01:00
Miguel Palhas 528aadf59c fix(daemon): measure attention window from failure
Closes the gap #239 describes: §5.7's 30-day window was filtered on
grabbed_at, so a torrent stalling past the window before hard-failing
at import never surfaced in the needs-a-decision queue. grabs gains
failed_at (migration 0030, backfilled from grabbed_at for existing
failed rows), the import tick stamps it on hard fail, and every window
query in the daemon notifier and the attention endpoint reads it.
§5.7 now states the anchor explicitly. §6.2's pack backoff stays on
grabbed_at deliberately; noted on the issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:05:59 +01:00
Miguel Palhas 6847d25cf5 feat: queue only targets still waiting for a file
The needs-a-decision queue had no liveness condition on the season lane
and none at all in the API reader, so a season pack that hard-failed
twice, fell back to per-episode grabbing exactly as §6.2 intends, and was
then fully acquired kept notifying for 30 days, and
`GET /api/queues/attention` listed titles the daemon never notified on.

DESIGN.md §5.7 now states the third face of the same rule alongside the
count and the window: a movie or an episode is queued while `wanted` and
not `available`; a season, holding no intent of its own (§4.1), while at
least one of its episodes is. Both readers apply it on all three lanes.

`just ci` passed through the gate.
2026-08-25 10:49:02 +01:00
Miguel Palhas 442ee3b022 Merge #226: require two recent failures to queue a season
Closes #226
2026-08-25 10:10:50 +01:00
Miguel Palhas ac0e80c044 feat: require two recent failures to queue a season
The season branch of the attention queue listed a season on one failed
grab of any age, so `GET /api/queues/attention` returned Rick and Morty
with every season it has and buried the one that needed attention.

Two changes, both stated in DESIGN.md §5.7:

- The season branch now enforces the same bar the episode branch does:
  two grabs that hard-failed on *different* releases.
- A failed grab counts toward the queue for 30 days
  (`arr_db::ATTENTION_WINDOW`). Nothing clears a `grabs` row, so without
  a window the queue only grows and can never be emptied. #181 gave the
  pack guard a backoff curve for the same reason; this is the queue's
  version of §6.2's "it never gives up entirely, it goes quiet". A
  season the operator dealt with stops failing and drops out; one still
  breaking keeps failing (the pack guard retries at worst weekly) and
  stays.

The window applies to all three hard-fail lanes — movie, episode and
season — because DESIGN.md states one rule for the queue, and to the
daemon's needs-a-decision notifier as well as the API, since both read
the same queue and a season-per-failure notification is the same noise
on a different channel. No schema change: `grabs.grabbed_at` already
carries the timestamp.

Gate: `just ci` green (486 tests).
2026-08-25 10:09:07 +01:00
Miguel Palhas c64c572781 refactor(arr): give arr-api its own jellyfin client
arr-daemon depends on arr-api, so a handler in arr-api can never
reach the daemon's private JellyfinClient. Move it into arr-api and
attach an instance to AppState, so a manual subtitle write can ask
for the same refresh import already does (#195).
2026-08-25 09:47:53 +01:00
Miguel Palhas 1126a52bbb Merge #222: one sidecar per language, enforced in the schema
Closes #222
2026-08-25 08:35:58 +01:00
Miguel Palhas 5aa914f81c refactor(arr): retire the OpenAI bootstrap keys
#216 added translate_openai_model as an explicit stopgap; the database row
replaces it, along with translate_openai_base_url. Only the API key stays
in the environment. deny_unknown_fields makes a config file still carrying
either one a parse error, so the move is visible rather than ignored.
2026-08-25 08:29:59 +01:00
Miguel Palhas d358c58844 test(arr): a forced candidate writes no sidecar
Ranking already rejects one (#185); this holds the loop to it at the end
of the pipeline, where the row would appear.
2026-08-25 08:28:56 +01:00
Miguel Palhas 5f1fffdd97 feat(arr): drop unused podnapisi credentials
Podnapisi's search and download are unauthenticated (#188), so the
ARR_PODNAPISI_USERNAME/PASSWORD fields were config nothing read. §10
keeps bootstrap config an honest list.
2026-08-25 06:40:38 +01:00
Miguel Palhas 5bc9022046 fix(arr): pass the podnapisi seam to the lamp probes
#200 branched before #205's ARR_PODNAPISI_URL seam was pushed, so its new
broken::SubtitleUpstreams call site was written against the three-argument
subtitle_providers. The merge was textually clean and did not build.
2026-08-25 06:31:27 +01:00
Miguel Palhas 8043ef614a Merge #200: lamp the subtitle upstreams
Closes #200
2026-08-25 06:30:34 +01:00
Miguel Palhas a4422e26e5 style(arr): formatting 2026-08-25 06:28:20 +01:00
Miguel Palhas 8c6d4ca577 feat(daemon): fold subtitle lamps into broken notifications 2026-08-25 06:12:22 +01:00
Miguel Palhas 5fc6854705 feat(arr): add an env-only Podnapisi URL seam
ARR_PODNAPISI_URL, same shape as the existing tmdb_url seam: a test
harness can point the provider at a wiremock fake without touching
DESIGN.md §10's config surface.
2026-08-25 05:25:25 +01:00
Miguel Palhas e0e7ddf6de docs(arr): correct the subtitle lane's comment
Said translation backends are passed empty; #216 wired them and #219 made
both callers share one set.
2026-08-25 04:55:06 +01:00
Miguel Palhas e338a76c66 fix(arr): wire the command timeout to the settings row 2026-08-25 03:15:33 +01:00
Miguel Palhas 3a7d80c295 feat(arr): spend the subtitle budget in the reconcile loop
Providers are charged one unit per download, claimed atomically
right before the call; a provider at its cap is skipped in favour of
the next-ranked candidate rather than failing the whole gap.
Translators are charged the source character count before
translating. Either cap is a queue state, same as a provider's own
429.
2026-08-25 02:40:09 +01:00
Miguel Palhas 7b4cff1874 fix(arr): give the reconcile loop its translators
#196 built SubtitleAction with an empty backend list, correctly: no
translation backend existed when it was written. #216 then built
translation_backends() and wired it into AppState, so the API can translate.
Merged, the reconcile loop still got Vec::new() and its translate step
reported "no engine" in every real deployment.

Neither branch was wrong alone; the gap only exists once both are in.
2026-08-25 02:21:21 +01:00
Miguel Palhas 4110555183 Merge #196: close the subtitle gap in the reconcile loop
Closes #196
2026-08-25 02:20:11 +01:00
Miguel Palhas c2ca25895f feat(arr): reconcile subtitle gaps in the daemon
Closes each unsatisfied wanted language per DESIGN.md §8/§15: embedded
tracks satisfy for free (recording them as #189 left to this issue),
then provider search + ranked fetch + alass sync + sidecar write, then
immediate machine translation — extracting a text-format embedded track
when that is the only source — and otherwise the reason lands on the
attempt row for the missing-subtitles queue.

Closes run as detached tasks because alass and translation outlive the
25 s reconcile action budget; every outcome is recorded in domain rows
first, so a crash converges on the next tick. Failures back off on the
same §6.2 curve as movie searches; a rate-limited provider is the
'capped' queue state; unreachable providers and translators fold into
the existing §9.5 broken notification, edge-triggered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 02:10:49 +01:00
Miguel Palhas e49bc2736d feat(arr): wire translation backends into daemon
Forward each translate-* feature from arr-daemon to arr-subs, add the
missing OpenAI model bootstrap key, and construct the compiled and
credentialed backends at startup so the translate endpoint stops
answering 503 unconditionally.
2026-08-25 02:01:55 +01:00
Miguel Palhas f1a58c0810 refactor(arr): split api_state out of run
Both #195 and #214 added a builder call to the AppState chain, which pushed
run past the too-many-lines limit. The chain grows a line per upstream the
API learns to talk to, so it gets its own function.
2026-08-25 01:50:17 +01:00
Miguel Palhas 999cb8b08c Merge #214: sync manually grabbed subtitles with alass
Closes #214

# Conflicts:
#	crates/arr-api/src/state.rs
#	crates/arr-daemon/src/main.rs
2026-08-25 01:49:20 +01:00
Miguel Palhas 910d28f639 refactor(arr): give arr-api its own jellyfin client
arr-daemon depends on arr-api, so a handler in arr-api can never
reach the daemon's private JellyfinClient. Move it into arr-api and
attach an instance to AppState, so a manual subtitle write can ask
for the same refresh import already does (#195).
2026-08-25 01:31:54 +01:00
Miguel Palhas f06e0e94bc feat(arr): sync manually grabbed subtitles with alass
The grab handler runs alass before recording the row, replacing the
sidecar with the synced text on acceptance and flagging it as
rejected otherwise (§15). Wires a Syncer into AppState, defaulting
to alass on PATH; the daemon binary points it at config.alass_path.
2026-08-25 01:24:09 +01:00
Miguel Palhas faa7a0c056 feat(arr): offer subtitle providers to the API
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>
2026-08-24 23:27:08 +01:00
Miguel Palhas 917aa4fa76 feat: scale size bands by episode runtime
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>
2026-08-24 22:45:55 +01:00
Miguel Palhas 2cee177940 Merge #198: configure subtitles via env and database
Closes #198
2026-08-24 22:36:12 +01:00
Miguel Palhas e852f42a9c feat(arr): name subtitle codecs and dispositions
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.
2026-08-24 22:32:01 +01:00
Miguel Palhas 17d40f3e71 feat(arr): add subtitle bootstrap config 2026-08-24 22:29:18 +01:00
Miguel Palhas 93c064701c Merge #182: name the season deck's real state
Closes #182
2026-08-24 21:27:42 +01:00
Miguel Palhas 1e03873209 feat(web): name the season deck's real state
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
2026-08-24 20:14:48 +01:00
Miguel Palhas 2286cba862 feat(core): score season packs per episode
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>
2026-08-24 20:10:23 +01:00
Miguel Palhas dc6c25f582 fix(daemon): retry season packs after backoff
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>
2026-08-24 19:51:28 +01:00
Miguel Palhas d8797e1996 feat(daemon): refresh metadata on add
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>
2026-08-24 18:12:10 +01:00
Miguel Palhas 8f7373d73d fix(core): auto-track skips the seeding refresh
A series' first metadata refresh reveals its whole back catalogue, and
apply_auto_track flagged every season as new — adding Rick and Morty
tracked S01-S09 and wanted 91 episodes (#160).

Per DESIGN.md 4.1 the rule applies from the second refresh onward.
The caller passes whether metadata_refreshed_at is set; arr-core stays
IO-free and decides. Wired through the daemon's daily refresh and the
API's create-season endpoint.
2026-08-24 11:40:44 +01:00
Miguel Palhas 08e3c7bce7 fix(meta): store unrated titles as NULL, not zero
TMDB reports vote_average: 0 where no rating exists (#156). Normalise
it to None at the arr-meta edge, like non_empty does for "", and let
the Option flow through the daemon refresh and API add paths so the
nullable columns from #145 do their job.
2026-08-23 22:21:57 +01:00
Miguel Palhas 8478c0f8a9 feat(db): store poster, backdrop and rating on titles
§9.6 keeps rich detail out of the database except for the three fields
pure-SQL views need. Adds poster_path, backdrop_path and vote_average to
movies and series, written by the daily metadata refresh in both lanes
and filled at add time from the TMDB response the create flows already
fetch.
2026-08-23 22:12:05 +01:00
Miguel Palhas 23f5781714 feat(meta): substitute TBA for unnamed episodes 2026-08-23 20:55:16 +01:00
Miguel Palhas 00ff4db26a style: cargo fmt 2026-08-23 19:35:07 +01:00
Miguel Palhas 409967eef6 feat(daemon): reconcile vanished seasons (#137) 2026-08-23 19:34:55 +01:00
Miguel Palhas a378b22306 Merge #134: satisfied titles leave the attention queue
Closes #134
2026-08-23 19:09:15 +01:00
Miguel Palhas a28b455fff fix: gate hard-fail attention on unsatisfied titles 2026-08-23 19:07:29 +01:00
Miguel Palhas 9df120b92a feat(daemon): match RSS results to wanted episodes
One feed pass now serves films and episodes alike. A
single-episode release grabs its open episode directly; a season
pack only grabs when season_grab_mode allows packs for that
season (§14, #117), and when it wins, the singles stand down.
Blocked series keep matching RSS (§6.3) and nothing here touches
the targeted-search backoff (§6.2).
2026-08-23 19:07:00 +01:00
Miguel Palhas 127338fd4d Merge #132: drain manual episode and season commands
Closes #132
2026-08-23 18:27:43 +01:00
Miguel Palhas 8a77049fdb feat(daemon): drain all manual command channels 2026-08-23 18:24:50 +01:00