Commit Graph

302 Commits

Author SHA1 Message Date
Miguel Palhas 5ba49edfd4 fix(dl): take stalled from Transmission
A zero download rate is not a stalled torrent: one between peers reads
zero for a poll or two and finishes fine, and at §9.8's 15s cadence that
flicker would raise the one chip reserved for a download that never
finishes. Transmission already decides this with its own stalled window;
carry isStalled and use it.
2026-08-26 12:27:42 +01:00
Miguel Palhas 49d23e071a feat(arr): expose live downloads 2026-08-26 12:25:15 +01:00
naps62-yolo adb61dda3b feat(api): extract an embedded track on demand (#261)
ci / web (push) Successful in 40s
ci / rust (push) Successful in 3m22s
e2e / e2e (push) Failing after 3m31s
Closes #260.
2026-08-26 12:08:42 +01:00
Miguel Palhas 656ca6c259 fix(api): skip mode-bit tests when they cannot bind
ci / web (push) Successful in 1m5s
ci / rust (push) Successful in 1m56s
e2e / e2e (push) Failing after 7m5s
Four tests force a filesystem failure by freezing a directory to 0o555.
The CI container runs as root, mode bits do not constrain root, and the
rename those tests expect to fail succeeds — main has been red on
a_failed_rename_leaves_the_row_alone since the move-on-root-change work
landed, with nextest's fail-fast hiding the other three.

The guard probes the filesystem rather than the uid: what the tests
depend on is the refusal, and a container can hold CAP_DAC_OVERRIDE
without being uid 0.
2026-08-26 08:35:26 +01:00
Miguel Palhas c9369d1c4f fix(arr): rewrite subtitle_files paths on relocate
relocate.rs gathered only media_files rows for a title/root move,
leaving subtitle_files rows pointing at the old folder. Sidecars
already ride along in the folder rename; only their rows were stale.

Tags each rewrite with its owning table (media_files or
subtitle_files) and updates both in the same transaction.
2026-08-25 18:10:52 +01:00
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 bd5b00b14a fix(api): create title move destination
ci / web (push) Successful in 34s
ci / rust (push) Failing after 1m48s
e2e / e2e (push) Successful in 1m49s
2026-08-25 13:01:16 +01:00
Miguel Palhas 5d80177622 Merge #227: say what a pack was abandoned for
Closes #227
2026-08-25 12:12:34 +01:00
Miguel Palhas 591cf27dc5 feat(web): say what a pack was abandoned for
A pack that hard-failed at import blacklisted its release, put every
episode back to missing and left the season reading 0/10, with nothing
on screen joining the two. Every fact was already recorded.

The blacklist now carries its reason out of the database: deck rows read
`blacklisted · size` instead of a bare `blacklisted`, and say whether the
policy turned the file down — relaxable for this title — or the release
itself failed, which a retry only repeats. A season whose pack was
abandoned says so on its row and above its deck, with the release name,
when it failed, and what it failed on. A row the blacklist no longer
answers for keeps rendering and claims no reason.

Two defects from the integration review of #211 sit in the same code and
are fixed here: a waived row threw away the rule it now carries and read
a bare `below policy`, and the empty-eligible count called every waived
row force-grabbable, since #211 gave those rows the rule `overridable`
reads.

Verified against a real browser: series detail, both season decks and
their buckets, at 1280 and 390 px.

Refs #227, #211
2026-08-25 12:11:46 +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 58a45fc98e Merge #211: let a waiver name the rule it relaxed
Closes #211
2026-08-25 11:47:25 +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 bbb6d2f4a4 feat(api): re-derive verdicts on a policy edit
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>
2026-08-25 11:40:32 +01:00
Miguel Palhas c4d4ade4da fix(api): normalise stored root paths
#243 normalised the incoming path but compared it against the value read
raw from the database, so a root stored with a trailing separator never
compared equal. Every edit of it -- a policy change included -- took the
relocation branch, where each planned destination is its own source and
the pre-check refuses. That root could not be edited at all.

`update` now normalises both sides, and hands `relocate_root` the
normalised stored value. `path_is_free` normalises the stored side in SQL
and `create` goes through it too, so `/mnt/x` and `/mnt/x/` cannot be two
roots for one directory -- the unique index compares raw strings and
cannot see that.

Migration 0031 strips the separator from rows already written. It skips
any row whose stripped form another row would also hold, rather than
tripping the unique index: a migration that cannot apply stops the daemon
booting, which is worse than two roots naming one directory.

Also from the same review: `undo` recorded only the leaf directory, so a
failed move into `/mnt/media-v2/tv/kids` left `tv` behind. It now records
every level `create_dir_all` materialised, deepest first, and still never
touches one that was already on disk.

Refs #244.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:28:35 +01:00
Miguel Palhas 43e65514ed fix(api): reclassify on root and policy changes
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>
2026-08-25 11:17:33 +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 6fa88b8f1f fix(api): trailing-slash self-conflict and stray relocation dir
A root path differing only by a trailing separator now normalises to
the same value on create and update, so PUT no longer treats a no-op
edit as a relocation whose destinations conflict with their own
sources.

A failed root-path move now removes the new root directory it created
for that move, but only when it created it — a directory that already
existed at the destination is left alone, matching the retry
guarantee relocate.rs documents.

Refs #243
2026-08-25 10:56:13 +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 50056a2bd9 style(api): reformat two error arms
Pre-existing rustfmt drift on the branch base; `just ci` fails on it
before reaching anything else.
2026-08-25 10:48:56 +01:00
Miguel Palhas 690eaeda5c fix(api): let a stranded folder be retried, not refused
Two findings from the integration review of this milestone, both caused
by two sessions editing the same code without seeing each other.

The retry that relocate.rs documents did not converge. The conflict
pre-check ran over every planned rename, including renames whose source
was already gone, and the skip for a missing source came after it. An
undo is best-effort, so a failed move can leave one folder at the
destination with its row still naming the source; every later attempt
then 409'd against the operator's own half-moved library and the only
way out was moving the folder back by hand. The pre-check now skips a
rename whose source is absent, which is what the perform loop already
did. Verified: the new test returns 409 without the change and 200 with.

ApiError::Filesystem rendered as "files not removed: {error}". That was
written for the delete lane; #228 and #236 then returned the same
variant for move failures, so a root path change with one unwritable
folder reported "files not removed" after an operation that removed
nothing. The variant now renders the caller's message and the two
delete lanes carry their own context.
2026-08-25 10:29:38 +01:00
Miguel Palhas efb47d64e7 feat(api): move title folders on root path change
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.
2026-08-25 10:20:37 +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 bce3d3823d feat(api): move title files on root change
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>
2026-08-25 09:55:18 +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 097e081f4d fix(arr): drop the forced flag from subtitle grab
§15 gives forced tracks no sidecar name and never lets them
satisfy a want; ranking already rejects every forced candidate
(#222). A grab endpoint accepting `forced: true` had nowhere
coherent to put the result, so the flag and its stale test are
gone (#233).
2026-08-25 08:42:56 +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 abad9b9cfd fix(arr): name the field, not a reply, in the 422
The backend's Malformed Display describes an answer that came back wrong.
Nothing is sent while validating a settings write, so only the reason
belongs in the message.
2026-08-25 08:33:47 +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 e8bc766d4b feat(arr): expose the OpenAI endpoint on /settings
Two more fields on the subtitle settings row, validated on write — a base
URL that does not parse is a 422 naming the field — and pushed into the
cell the running backend and the health lamp both read.
2026-08-25 08:29:59 +01:00
Miguel Palhas c6906bffae feat(arr): make the OpenAI endpoint a live setting
The base URL and model move into a cell the backend re-reads per request,
so an operator can repoint it without a restart. Migration 0028 adds the
two columns; DESIGN.md §15 calls both database rows. Construction never
depends on the API key — llama.cpp serves without one.
2026-08-25 08:29:45 +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 3122d5b0a0 feat(arr): refuse a grab for a satisfied language
The 409 on a second subtitle for one language now comes from §15's
invariant rather than from the sidecar filename, so a provider fetch is
refused even when the language is held by a `.mt.srt` the path check
cannot see. The message names the manual delete as the way to replace
it. `claim_path` stays: two `media_files` rows for one video still
derive the same name from different ids.
2026-08-25 08:28:56 +01:00
Miguel Palhas 29c31beceb feat(arr): one sidecar per language, in the schema
DESIGN.md §15 as amended: a language is satisfied by exactly one
sidecar, and no filename segment distinguishes forced from plain from
SDH. A unique index over sidecar rows says so; embedded rows keep their
own key, since several tracks for one language can legitimately coexist
inside a video.

Existing databases may hold a duplicate from a manual grab that beat the
API's path check, so the migration resolves them rather than failing: a
real subtitle beats a machine translation, and of two of the same kind
the newest wins. The files stay on disk for the manual delete to clean
up.

`record_file` no longer swallows every conflict — only the two that mean
"arr already knows this file".
2026-08-25 08:28:51 +01:00
Miguel Palhas 84dc5ba27b docs(arr): sharpen the unconfigured provider lamp
Said 'not configured'; the branch is specifically about missing bootstrap
credentials, and after #215 only a provider that needs them can reach it.
2026-08-25 06:51:11 +01:00
Miguel Palhas b73a58d1a4 Merge #225: cache the remote-command probe
Closes #225
2026-08-25 06:49:39 +01:00
Miguel Palhas 73d3f04398 fix(arr): cache the remote-command probe verdict
Reused for 5 minutes (#225) instead of running the configured
command on every /api/health poll.
2026-08-25 06:40:50 +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 228db06d83 Merge #221: give an expired candidate a typed error
Closes #221
2026-08-25 06:30:34 +01:00
Miguel Palhas 783a6ba760 Merge #224: clear subtitle attempts on language drop
Closes #224
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 be7fa87e74 feat(api): subtitle lamps in the health report 2026-08-25 06:04:30 +01:00
Miguel Palhas 8c5613b247 feat(arr): probe methods for subtitle providers and engines 2026-08-25 05:54:19 +01:00
Miguel Palhas 2357e72113 fix(api): give an expired subtitle candidate a typed error
A grab naming a stale candidate_id now fails as ApiError::SubtitleCandidateExpired (404, code candidate_expired) instead of the generic upstream 503 string the panel had to pattern-match for 'not found'.
2026-08-25 05:35:18 +01:00
Miguel Palhas 7b4def4516 style(arr): cargo fmt 2026-08-25 05:32:25 +01:00
Miguel Palhas 3cc9ab4aff fix(arr): clear subtitle attempts on language drop
Removing a language from wanted_languages left its subtitle_attempts
rows behind, resurrecting stale backoff on re-add (#224).
2026-08-25 05:32:08 +01:00