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
#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>
§9.5 restated the pre-#226 decision-bar rule without the 30-day window,
giving the document two versions of the same rule. Make it defer to
§5.7 instead.
§5.7 used two phrasings for the season-queuing predicate in one
paragraph (existence-of-file vs. state check) though both readers
implement the state check; picked the state phrasing throughout. Also
fixed the arithmetically confusing "seen twice ... third face"
sentence, and rewrapped the single unwrapped ~450-character line in
§7.4 to the document's ~78-column width.
Ref #240. Ran full `just ci` through the gate (517 tests, exit 0).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PUT /api/policies/{id} changed the rule every title under every root
pointing at the policy is judged by, and re-derived nothing, so §9.3's
deck and the daemon's grab gate kept reading verdicts computed under
rules that no longer existed.
Drives #241's walker from a policy id: root by root through
reclassify::root, so the skip rules and the leave-unchanged-rows-alone
rule stay in one place. A rename touches no rule and walks nothing.
Inline still holds at this width. Measured on a release build over 2000
titles and 10 000 stored releases across two roots sharing one policy:
0.36 s when no verdict moves, 2.7 s when all 10 000 do. DESIGN.md §5.1
now names four actions and carries those numbers.
just ci passed through the gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving a title to a root with a different policy, and pointing a root
at a different policy via PUT /api/roots/{id}, both changed the
effective policy without re-deriving stored verdicts — which §9.3's
deck and the daemon's manual-grab gate read. Both now run the same
reclassify the overrides path uses, inline in the request; §5.1 states
the contract, and relocate.rs no longer claims the move alone makes
the policy apply.
PUT /api/policies/{id} has the same gap one level up; noted on #241
for its own issue.
Closes#241
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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.
Changing a root's path rewrote the row and moved nothing, so every title
under it was mislocated at once. It now reuses the #228 mover: plan every
rename, refuse a destination that already exists, rewrite the media_files
rows in the same transaction as the row change.
The move is all or nothing. A root row carries one path, so a half-moved
library would have to describe both places; instead one folder that
cannot move puts back the ones that already did and leaves the root's
path alone, and the same request is the retry.
just ci ran clean through the gate: 498 tests passed.
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).
Changing a movie's or series' root previously rewrote root_id and left
the files behind, so the §7.4 layout stopped describing the disk and
the root's policy applied to a library the files were not in. Series
had no root control at all.
All roots share one ZFS dataset, so the move is a rename of the title
folder into the new root, never a copy — hardlinks and the seeding
torrent survive it (§7.3). Disk first, row second: a destination that
already holds the folder is a 409, a failed rename leaves the row
unchanged, and a title with nothing on disk moves with no filesystem
work. media_files rows are rewritten in the same transaction as the
root_id, and a successful move triggers the §7.5 Jellyfin refresh.
Issue #228
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Forced and SDH get no sidecar name of their own: one language, one
sidecar, and a forced track is ignored where a plain one exists. That
makes the database constraint fall out rather than needing a scheme.
The OpenAI-compatible backend's base URL and model become database rows.
It is not "OpenAI" — it is any endpoint speaking that shape, llama.cpp
included — so which one is in use is something to try and change, not a
property of the deployment. Only the API key stays in the environment.
A band's floor and target now read as a rate against a 45-minute
reference runtime, scaled by the series' per-episode runtime from
TMDB. Missing or zero runtime falls back to the reference, keeping
today's behaviour. Movies are explicitly unscaled. Closes the axis
question in #208; #209 implements it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the subtitle decisions settled during Subtitles milestone
planning: global wanted set (pt + en), embedded-track extraction,
providers, ranking, translation backends, sidecar naming, alass sync,
budgets, and stated non-goals. Points §13 item 9 at §15 and adds
arr-subs to the crate lists.
Closes-Issue: #183
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A release covering several episodes is measured by size divided by the
episode count, for the target penalty and the floor alike. An unknown
count divides by one, which fails toward rejecting rather than grabbing.
Refs #179
Untracking a season now clears its episodes' wanted flag, unified search
returns titles only, the library is the homepage and the signal chain moves
into settings, and the wordmark links home.
Refs #169
`resolution_pref` only gated eligibility, so every release was scored
against its own resolution's size band and a 23 GB 4K lost to an
at-target 1080p. Each step up the list is now worth `resolution_step`
points, seeded at 300: five gibibytes of 4K overshoot, so a 4K up to
27 GB wins and a bloated one still does not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design for a single-service replacement of Radarr/Sonarr: domain model,
policy engine, sourcing via Prowlarr Torznab, import pipeline, reconcile
loop, crate layout, CI gate and build order.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>