100 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5.5 ee7f05bf22 test: satisfy clippy assert_is_empty
ci / web (push) Successful in 22s
e2e / e2e (push) Failing after 54s
ci / rust (push) Successful in 1m58s
ci / image (push) Successful in 3m46s
Stable 1.99 added the lint and CI denies warnings, which failed the
build and skipped the deploy. Each flagged assert now uses the
assert_eq! form clippy suggests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 20:17:20 +01:00
Miguel PalhasandClaude Opus 5.5 1b6dae676a docs(design): record Master Control revision 2
ci / web (push) Successful in 20s
ci / rust (push) Failing after 1m13s
ci / image (push) Skipped
e2e / e2e (push) Failing after 51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 17:28:22 +01:00
Miguel PalhasandClaude Opus 5.5 52b11fda93 feat(web): one row primitive and a mobile shell
Every list now uses the row primitive with a fixed action column, so
actions never wrap. Rows use icons, headers use words. Status slots,
skeletons and a fixed hero grid stop the page jumping. Below 40rem a
bottom tab bar holds the four destinations. Tooltip-only meaning moves
to tap-to-explain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 17:28:22 +01:00
Miguel PalhasandClaude Opus 5.5 ce6d38d044 feat(web): mobile-first tokens and controls
44px controls, a separate 24px chip height, one row height, a gutter at
every width, min-width breakpoints, and the shared control and row
vocabulary in ui.ts. check-tokens now also rejects var() names that
nothing defines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 17:28:22 +01:00
Miguel PalhasandClaude 460b82f039 fix(api): name an unconfigured engine on /health
ci / web (push) Successful in 1m3s
e2e / e2e (push) Failing after 2m16s
ci / rust (push) Successful in 2m31s
ci / image (push) Successful in 3m7s
Fourth copy of the same wrong sentence, and the one an operator actually
reads: a compiled-in engine with no API key reported itself as
unavailable in the build.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-20 18:47:29 +01:00
Miguel PalhasandClaude 70ba6ac376 fix(subs): retry a capped language next day
ci / image (push) Successful in 3m47s
ci / web (push) Successful in 1m49s
e2e / e2e (push) Failing after 2m58s
ci / rust (push) Successful in 3m1s
A `capped` row was excluded from the work list outright, and nothing
ever moved it back, so a language that hit a daily allowance was never
attempted again. §15 says budgets drain a backlog over days, which only
holds if the cap expires.

`pending` now returns a capped row once the UTC day it was capped in has
passed — the same boundary `subtitle_budget_spend.day` uses. The day
rule is in the query rather than the caller because `limit` is a window
over the whole list: a hundred newest files capped this morning would
otherwise hold every slot and hide the older gaps until tomorrow.

Capping no longer bumps `attempts`. That counter is the failure ladder,
and a budget that refused to spend never asked a provider.

Two reporting fixes alongside, both found chasing the same bug:

- The subtitle queue dropped `last_failure` on a capped row, so all four
  limits that write `capped` rendered identically. The sentence is the
  only thing that tells a provider budget from a 429.
- A translation engine with no API key reported itself as "not compiled
  into this binary", sending the operator to rebuild an image that was
  already correct.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-20 18:43:08 +01:00
Miguel Palhas 3c6c07843e fix(web): keep viewport after file delete
ci / web (push) Successful in 1m39s
e2e / e2e (push) Failing after 2m41s
ci / rust (push) Successful in 5m41s
ci / image (push) Successful in 9m0s
2026-09-20 17:41:23 +01:00
Miguel Palhas 77ac295bdf feat(web): order satisfied titles last
ci / web (push) Successful in 42s
e2e / e2e (push) Failing after 1m12s
ci / rust (push) Successful in 1m18s
ci / image (push) Successful in 3m41s
The library hid satisfied titles behind a toggle, so a finished show was
gone from the view that lists it. Ordering says the same thing without
taking anything away.
2026-09-06 16:39:19 +01:00
Miguel Palhas 8699572be8 fix(web): let an episode deck say a sweep found nothing
ci / web (push) Successful in 59s
e2e / e2e (push) Failing after 1m40s
ci / rust (push) Successful in 3m22s
ci / image (push) Successful in 3m21s
The episode deck had no settled answer: only a season's pack state and a
movie's last_searched_at ended the watch, so an empty episode deck polled
for 150 s and then blamed a sweep that had already come back empty. The
episode's own last_searched_at is that stamp.
2026-09-06 15:31:17 +01:00
Miguel PalhasandClaude Opus 5 d97cdfd557 fix(subs): ask OpenSubtitles for SRT at download
ci / web (push) Successful in 35s
ci / rust (push) Successful in 1m44s
e2e / e2e (push) Failing after 1m51s
ci / image (push) Successful in 2m23s
A search entry often carries no `format`, so `format_of(None)` produced
`Other("")`, that was stashed at search time and handed back as the
download's format, and conversion had no parser to pick:

    4073669 could not be converted from  to SRT: no parser for ""

`POST /download` takes `sub_format`, and the API converts on its side, so
asking for srt makes the answer srt whatever the uploader posted. The
format guessed from the search entry is dead weight and goes with it.

The download mock matched only `file_id`, so it never exercised what the
real API returns.

Closes #272

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 10:10:06 +01:00
Miguel PalhasandClaude Opus 5 7bdbecd68a fix(subs): log in to OpenSubtitles with a JSON body
e2e / e2e (push) Failing after 2m1s
ci / rust (push) Successful in 2m4s
ci / image (push) Successful in 4m8s
ci / web (push) Successful in 56s
`login` posted the credentials as HTTP basic auth with no request body.
The API takes them as JSON, answers basic auth with a 401, and the lane
reported that back as "opensubtitles rejected the configured
credentials" — for correct credentials.

Search was unaffected and hid this: it authenticates with the Api-Key
header alone and never logs in, so candidates were found and only the
download failed.

`mount_login` matched on method and path, so the mock answered a token
to any request shape. It now matches the body.

Closes #271

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 09:57:58 +01:00
Miguel PalhasandClaude Opus 5 3a5e768029 fix(subs): search OpenSubtitles by the real id params
ci / web (push) Successful in 55s
e2e / e2e (push) Failing after 1m41s
ci / rust (push) Successful in 1m48s
ci / image (push) Successful in 2m22s
The id lane sent `tmdb_movie_id` and `tmdb_series_id`. Neither is a
parameter of this API, and unknown ones are ignored rather than refused,
so every search silently degraded to a `moviehash` lookup: one result for
a release someone had already hashed, none at all for anything else.

Two further requirements the API documents and answers a 301 to when
missed: parameters sorted by name, and language codes lowercase and
sorted.

The integration tests asserted the old names against a mock, which
answers whatever it is asked for. They pinned the bug rather than
catching it.

Closes #270

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 09:46:43 +01:00
Miguel PalhasandClaude Opus 5 6cd2902341 feat(subs): translate from an image-only release
ci / web (push) Successful in 1m0s
e2e / e2e (push) Failing after 3m17s
ci / rust (push) Successful in 4m0s
ci / image (push) Successful in 3m56s
A release whose only subtitle is a PGS or VobSub track was stuck both
ways: the track satisfied English so no English SRT was ever fetched,
and bitmaps can never feed a translator. §15 is amended to separate
satisfying viewing from providing a translation source, and to carve a
fetch made to obtain a source out of the no-upgrade rule.

Timings come from the disc rather than from alass guessing at the audio.
At import, each non-forced image track's packet timestamps are paired
show-to-clear into a cue skeleton and stored; the fetched source is then
aligned against that skeleton, and the translation made from it skips
the post-translation pass, which could only move disc-exact timings off.

Pairing is validated before it is trusted — even packet count, plausible
durations, sane density for the runtime — because PGS allows several
composition segments per subtitle and a slipped pairing is quietly half
a second out. A track that fails validation gets no skeleton and falls
back to aligning against the video, as does any file imported before
this: there is no backfill.

Closes #268

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:53:26 +01:00
Miguel PalhasandClaude Opus 5 015b90a458 fix(api): a deleted title takes its grabs with it
ci / web (push) Successful in 1m3s
e2e / e2e (push) Failing after 1m59s
ci / rust (push) Successful in 2m36s
ci / image (push) Successful in 2m20s
`grabs.infohash` is unique and grabs point at their target
polymorphically, so nothing cascaded them off a deleted movie or series.
An orphan holding an infohash then blocked the row a later grab of the
same release needed: the upsert only reclaims a `vanished` row, so the
insert did nothing while the new title was still flipped to
`downloading`. It never imported, and `/api/downloads` attributed the
torrent to a title that no longer existed.

Deletes now clear the grabs, and the upsert also reclaims a row whose
target is gone whatever its state — which heals the databases that
already have orphans. A row belonging to a live title stays off limits,
so the restart case still cannot let one title steal another's torrent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:34:58 +01:00
Miguel PalhasandClaude Opus 5 9ecbfde656 fix(dl): only log in when qBittorrent asks
ci / web (push) Successful in 32s
e2e / e2e (push) Failing after 1m56s
ci / rust (push) Successful in 2m0s
ci / image (push) Successful in 2m27s
Logging in before the first call turned a stale or wrong password into a
hard failure on calls that would have succeeded: an instance that bypasses
authentication for arr's address answers everything, and never needed the
credentials at all. Authentication is now established on the first 403.

The health probe was reading a 403 from `app/version` as healthy, on the
reasoning that a live daemon is all the lamp claims. That is exactly the
state arr cannot use, and it left the lamp green while every grab and every
reaper tick failed on rejected credentials. It now lists torrents through
the configured client, so the lamp fails when arr's own calls do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 18:42:43 +01:00
Miguel PalhasandClaude Opus 5 ab4f95b166 feat(dl): replace Transmission with qBittorrent
ci / web (push) Successful in 32s
ci / rust (push) Successful in 3m26s
e2e / e2e (push) Failing after 5m2s
ci / image (push) Successful in 4m1s
qBittorrent's add call answers `Ok.` and nothing else — no hash, no name,
no duplicate signal — so arr derives the v1 infohash from the magnet or the
`.torrent` bytes before the call and looks the torrent up by it. That also
drops Transmission's numeric torrent id: the hash is the only identity now.

The reaper needs "stopped because a share limit was reached", and
qBittorrent's state field cannot tell that apart from a hand-paused torrent.
So the ratio and idle counters are checked against the limits arr set, and a
torrent stopped by a global limit reads as still seeding rather than being
deleted.

The WebUI needs a login, so `ARR_QBITTORRENT_USERNAME` and
`ARR_QBITTORRENT_PASSWORD` join the env-only secrets; leaving both unset is
valid for an instance that whitelists arr's subnet.

Verified against qBittorrent 5 (WebAPI 2.15.1) in a container: it rejects
`setShareLimits` without `shareLimitAction`, which 4.x ignores, so it is
always sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 18:13:50 +01:00
Miguel PalhasandClaude Opus 5 afe167ca7b ci: enable the deploy trigger
ci / web (push) Successful in 59s
ci / rust (push) Successful in 1m47s
e2e / e2e (push) Failing after 2m7s
ci / image (push) Successful in 2m29s
Reverts the temporary docker-branch gating used to probe the runner, and
restores the deploy step now that the image build is verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 15:48:35 +01:00
Miguel PalhasandClaude Opus 5 a9848fddce ci: build the production image in CI
ci / web (push) Successful in 33s
ci / rust (push) Successful in 1m9s
ci / image (push) Successful in 4m59s
Dokploy built from source on the host, so every deploy paid a full cold
cargo build and the layer cache died with the builder. CI now builds and
pushes to the Gitea registry, and the deploy is a pull.

The Gitea push webhook fires before Actions starts, so autoDeploy on the
Dokploy application is off and this job triggers the deploy itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 15:37:50 +01:00
Miguel Palhas 4d347cd285 fix(db): restore migration 0025 to what was applied
ci / web (push) Successful in 1m8s
ci / rust (push) Successful in 2m17s
e2e / e2e (push) Failing after 2m41s
4cec2b6 edited an already-applied migration to drop podnapisi from the
subtitle_settings default. sqlx checksums migrations, so every boot since
has failed with "migration 25 was previously applied but has been
modified" — the daemon exits 1, swarm keeps the old task, and three
commits never reached production.

0033 already strips podnapisi from existing rows, and it runs after 0025
seeds row 1, so a fresh database still ends up with opensubtitles alone.
The edit changed nothing except the checksum.
2026-08-29 14:24:57 +01:00
Miguel Palhas dbce23f570 fix(web): count on-disk episodes, not just wanted
ci / web (push) Successful in 36s
ci / rust (push) Successful in 2m28s
e2e / e2e (push) Failing after 2m56s
Untracking a season clears `wanted` on every episode (§4.1) and leaves
the files where they were, so a season grabbed once and then untracked
rendered `0/0 eps` beside a column of green check glyphs. The same
counter could also read `10/1`, since the numerator was drawn from the
wanted set the denominator had already shrunk.

An episode now counts once it is wanted, on disk, or both, and the chip
is hidden rather than shown as `0/0` when a season or series accounts
for nothing. `Series.wanted_episodes` becomes `total_episodes`.
2026-08-29 12:38:26 +01:00
Miguel Palhas 706a0ec978 docs(design): refresh design.json sidecar
ci / web (push) Successful in 57s
ci / rust (push) Successful in 2m47s
e2e / e2e (push) Failing after 2m58s
status-channel didn't match the real --status-airing token, and the
score heat scale (issue 111), the available check-glyph exemption,
and the score chip component were undocumented. DESIGN.md untouched.
2026-08-26 23:23:08 +01:00
Miguel Palhas a2240debfd fix(web): drop redundant missing chip on episode rows
ci / web (push) Successful in 1m22s
e2e / e2e (push) Failing after 3m7s
ci / rust (push) Successful in 3m10s
The want button already only shows for state=="missing", so the
chip repeated the same signal. Other non-available states keep it.

Also fixes a pre-existing doc-markdown lint failure blocking CI.
2026-08-26 23:17:29 +01:00
Miguel Palhas 4cec2b6ed5 refactor(subs): remove podnapisi
ci / web (push) Successful in 30s
ci / rust (push) Failing after 1m58s
e2e / e2e (push) Failing after 2m15s
2026-08-26 22:58:01 +01:00
Miguel Palhas 3f4c1ade3b fix(web): tighten library poster grid
ci / web (push) Successful in 1m17s
ci / rust (push) Successful in 2m2s
e2e / e2e (push) Failing after 3m20s
2026-08-26 22:38:53 +01:00
Miguel Palhas 9d2b818c52 fix(web): drop the subtitles panel below the episode row
ci / web (push) Successful in 28s
ci / rust (push) Successful in 2m3s
e2e / e2e (push) Failing after 3m39s
The row and its disclosed panel were siblings in one wrapping flex line,
which only stacked them by accident of flex-basis: 100%. At phone width the
row is told not to wrap so the title truncates instead of pushing the
controls down — and the panel then had nowhere to go but beside the episode.

The row is now its own strip, .episode-line, with the panel a block under
it. Same pairing the movie file row already uses.
2026-08-26 20:12:21 +01:00
Miguel Palhas 5610e5976d fix(web): one language chip per file, not per track
ci / web (push) Successful in 35s
ci / rust (push) Successful in 1m45s
e2e / e2e (push) Failing after 3m25s
Commentary tracks carry the same language as the feature audio, so a file
row rendered `en` two or three times. The chips answer what a file can be
watched in; repeats say nothing the first said.
2026-08-26 18:42:40 +01:00
Miguel Palhas 58ad90951c fix(core): stop the size floor condemning a download
ci / web (push) Successful in 1m1s
ci / rust (push) Successful in 2m23s
e2e / e2e (push) Failing after 3m20s
The floor is a selection filter (§5.5): it keeps the ranking from picking
mud while better candidates are still on the table. Once a release is
grabbed there is nothing left to choose between, so a hard fail at import
condemns a file already on disk and blacklists the release under §5.7 —
over a number the indexer gave before the grab. Size is not post-download
evidence either; §5.6 gives ffprobe audio, HDR, codec and duration.

It bit packs hardest. Pre-grab measures a pack by its total over the
season length; import measures each file on its own, so one short episode
condemned the whole release and reopened the season. And §6.3 outlasts the
fix: a blacklisted release stays rejected however the operator later sets
allow_below_floor, with no delete path.

Post-download the floor now waives instead, whatever the overrides say.
The file imports and carries a waiver naming the floor it missed.
2026-08-26 18:34:57 +01:00
Miguel Palhas d3613c6081 Merge the Download visibility milestone
ci / web (push) Successful in 1m16s
ci / rust (push) Successful in 2m13s
e2e / e2e (push) Failing after 3m14s
Live download state, inline on the row that owns the item: DESIGN.md §9.8,
GET /api/downloads over arr's own grabs, chips on the movie, season and
episode rows, and a stale season import failure that now yields to a newer
attempt. Closes #262 #263 #264 #265 #266 #267.
2026-08-26 13:12:10 +01:00
Miguel Palhas 61d84ff51b Merge remote-tracking branch 'origin/issue/266-drop-status-chip' into blitz/download-visibility 2026-08-26 13:07:25 +01:00
Miguel Palhas 640bda3128 fix(web): show download chip on movie detail (#267) 2026-08-26 13:05:30 +01:00
Miguel Palhas 120416aabe fix(web): drop redundant status chip beside counts (#266) 2026-08-26 13:05:07 +01:00
Miguel Palhas 46554cfc75 fix(api): a vanished grab takes no headline
Superseding an import failure with any non-failed grab let a 'vanished'
one take the headline: the torrent has left Transmission, so the season
would show neither the failure nor any progress while the gap is still
there.
2026-08-26 12:55:52 +01:00
Miguel Palhas 42c1cd4dce Merge remote-tracking branch 'origin/issue/265-supersede-failure' into blitz/download-visibility 2026-08-26 12:55:20 +01:00
Miguel Palhas 77bf409c5d fix(arr): supersede stale import failure 2026-08-26 12:54:47 +01:00
Miguel Palhas eb21897c3d Merge remote-tracking branch 'origin/issue/264-download-chips' into blitz/download-visibility 2026-08-26 12:48:13 +01:00
Miguel Palhas a5b2131d4c feat(web): show live download state on rows (#264) 2026-08-26 12:47:19 +01:00
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 ad36a0aa83 Merge remote-tracking branch 'origin/issue/263-downloads-api' into blitz/download-visibility 2026-08-26 12:25:54 +01:00
Miguel Palhas 49d23e071a feat(arr): expose live downloads 2026-08-26 12:25:15 +01:00
Miguel Palhas 54438bb6ad docs(design): add the download surface as §9.8 2026-08-26 12:12:37 +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 425d154d54 Merge the Subtitles milestone
ci / web (push) Successful in 42s
ci / rust (push) Failing after 2m18s
e2e / e2e (push) Failing after 3m36s
DESIGN.md §15 end to end: provider search and download, embedded-track
extraction, translation behind four feature-gated backends, `alass` sync,
sidecar naming and the `.mt` machine-made segment, per-provider and
per-translator daily budgets, and the subtitle lane in /settings.

One sidecar per language, enforced in the schema (#222), so a forced track
never consumes the language's only slot. The OpenAI-compatible backend's
base URL and model are database rows, not bootstrap config (#220) — that
backend is any endpoint speaking the shape, `llama.cpp` included, and an
endpoint that needs no key is a valid configuration.

41 issues, plus #255 landing the provider reorder control on the shared
icon and control vocabulary.

`just ci` green: 778 tests. Migrations 0024, 0025 and 0027-0029 apply out
of order against a database that already has 0030-0032 — verified against
a database built from main's migration set, which is the shape production
is in.

CI's `rust` job is red on main independently of this merge; see #256.
2026-08-25 22:40:29 +01:00
Miguel Palhas 0eec97a7af Merge remote-tracking branch 'origin/main' into fix/255-provider-reorder
# Conflicts:
#	web/src/icons.ts
2026-08-25 22:34:55 +01:00
Miguel Palhas 7798cbc3e6 fix(arr): make the provider reorder honest
`visibility: hidden` on the disabled reorder button left a control in the
DOM that rendered as nothing. `disabled` already blocks the click and drops
it out of the tab order, so the hiding was decoration; the shared
`.control:disabled` colour says the same thing on screen, and the row does
not reflow because the hidden button reserved its box anyway.

Exposing it surfaced a second problem: `.control-quiet:hover` sits after
`.control:disabled` at the same specificity, so a disabled quiet control
lit accent-bright under the cursor and invited a click that does nothing.
Guarded with `:not(:disabled)` in the shared rule rather than on this one
control.

`refreshProviderEdges` found the buttons by `button:nth-of-type(1|2)`.
Position in the row is not a contract — a third button would have quietly
moved the edge logic onto the wrong control. They carry `data-move` now.

Closes #255
2026-08-25 22:33:40 +01:00
Miguel Palhas f3266a712b fix(web): keep episode rows on one line
ci / web (push) Successful in 45s
e2e / e2e (push) Successful in 1m44s
ci / rust (push) Failing after 2m1s
2026-08-25 22:04:34 +01:00
Miguel Palhas eeada0ef65 fix(web): keep season controls on one line
ci / web (push) Successful in 55s
e2e / e2e (push) Successful in 1m30s
ci / rust (push) Failing after 1m38s
2026-08-25 21:14:29 +01:00
Miguel Palhas 06f8a6f0ea fix(web): align the reserved delete slot
ci / web (push) Successful in 31s
e2e / e2e (push) Successful in 1m18s
ci / rust (push) Failing after 1m40s
2026-08-25 19:17:07 +01:00
Miguel Palhas dee7340a01 fix(web): steady action slot, red delete, clickable season
Closes #254
2026-08-25 19:06:25 +01:00
Miguel Palhas c9e44b9c03 fix(web): make the check chip's word reachable 2026-08-25 18:32:02 +01:00
Miguel Palhas e446da5858 Merge #253: row affordances, duplicate rows, check chip
Closes #253
2026-08-25 18:31:47 +01:00
Miguel PalhasandClaude Sonnet 5 4f2e443045 merge: catch up with blitz/feedback-3
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:28:30 +01:00
Miguel PalhasandClaude Sonnet 5 a97795a997 fix(web): show poster in library series rows
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:28:17 +01:00
Miguel PalhasandClaude Sonnet 5 17ad82f541 fix(web): drop duplicate tmdb hits from search
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:28:12 +01:00
Miguel PalhasandClaude Sonnet 5 73d56fe821 fix(web): style row-add like a control
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:27:37 +01:00
Miguel PalhasandClaude Sonnet 5 1bd482db2a fix(web): drop border on the title hero
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:27:21 +01:00
Miguel PalhasandClaude Sonnet 5 e157917797 fix(web): show available state as a check glyph
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:27:17 +01:00
Miguel Palhas eeaad52257 fix(web): draw a gear, not a sun 2026-08-25 18:19:19 +01:00
Miguel Palhas 4ba921b45a Merge #252: strip the rail to wordmark, warning, search, cog
Closes #252
2026-08-25 18:17:18 +01:00
Miguel Palhas a2b76bd634 feat(web): distill shell rail 2026-08-25 18:15:46 +01:00
Miguel Palhas 20a3b2847b Merge #251: adopt the shared icon and control vocabulary
Closes #251
2026-08-25 18:14:27 +01:00
Miguel Palhas 86af84be6d Merge #237: rewrite subtitle paths on relocate
Closes #237
2026-08-25 18:14:27 +01:00
Miguel Palhas a3186b2305 refactor(arr): chevron icons for provider order 2026-08-25 18:11:11 +01:00
Miguel Palhas 1afd8c1998 refactor(arr): subtitle chip takes shared delete
Drops the hand-rolled arm, disarm, timeout and label swap, and the local
12-grid close glyph that came with it.
2026-08-25 18:11:11 +01:00
Miguel Palhas 1e778afd18 feat(arr): compact armed delete and new glyphs
The subtitle chip needs the settings arm-then-confirm at chip scale: a
chip fits neither the control plate nor the word "confirm". The shared
control grows a compact variant that swaps the glyph instead, rather than
each view keeping a private copy of the idiom.
2026-08-25 18:11:06 +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 8947febbff fix(web): add series library selector
ci / web (push) Successful in 1m20s
e2e / e2e (push) Successful in 2m8s
ci / rust (push) Failing after 14m52s
2026-08-25 14:24:30 +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 90efeaa442 Merge milestone 'Feedback pass 2'
ci / web (push) Successful in 30s
ci / rust (push) Failing after 1m40s
e2e / e2e (push) Successful in 2m13s
17 issues: the operator's UI feedback (back button, icon-only controls,
one-line settings rows, plainer words), the root-move and root-path
relocation work, the attention queue's threshold, window, anchor and
liveness rule, reclassify on every action that changes an effective
policy, and the waived rule reaching the deck.

Gate green at 524 tests. Migrations 0030, 0031 and 0032 verified against
a database built through the production upgrade path; 0032's rebuild
preserves rows and children with foreign_key_check clean.
2026-08-25 12:17:01 +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 9e445d0398 Merge #240: reconcile design coherence in 5.7 and 9.5
Closes #240
2026-08-25 11:50:56 +01:00
Miguel Palhas 1e30c49a72 Merge #245: pack backoff runs from the failure
Closes #245
2026-08-25 11:50:39 +01:00
Miguel PalhasandClaude Opus 5 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 PalhasandClaude Sonnet 5 c78c73ec6c docs: reconcile design coherence issues in §5.7 and §9.5
§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>
2026-08-25 11:49:43 +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 de6c35cce3 Merge #232: plainer words for grab, blocked, deck
Closes #232
2026-08-25 11:43:55 +01:00
Miguel Palhas a6720f4c06 feat(web): plainer words for grab, blocked, deck
Three words the operator reads change; nothing underneath does. Waivers
are still written, recorded and served under their existing names, the
`blocked` column and flag keep theirs, and the release deck keeps its
name in DESIGN.md §9.3 and in the code.

- `waive + grab` reads `force grab`, and its accessible name says which
  rule the click relaxes. The `waived` bucket reads `below policy`, and
  so does the verdict chip on its rows — an operator can act on "below
  policy" and cannot act on the name the record keeps.
- A waived import already said what was relaxed for two rules; `size`
  joins them and the fallback names the rule rather than badging it
  `waived`.
- `blocked` reads `no targeted search`, everywhere the flag surfaces.
  §6.3 gives it one effect and a bare toggle hid it: the accessible name
  carries the RSS half at rest, and a note under the controls spells it
  out while the flag is on.
- The season and episode controls are already the #230 search icon; only
  their labels still said "deck". They now say what the click does.

`just ci` passes through the gate: 509 tests, biome, tsc, tokens.
Verified in a real browser (agent-browser) on the library, movie detail,
series detail, the episode release view, queues and settings, at 1440
and 390 wide — no horizontal scroll at either.

Refs #232
2026-08-25 11:43:29 +01:00
Miguel Palhas 9a413b10ed Merge #246: re-derive verdicts on a policy edit
Closes #246
2026-08-25 11:42:10 +01:00
Miguel PalhasandClaude Opus 5 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 c454b3ec11 Merge #244: normalise stored root paths
Closes #244
2026-08-25 11:29:13 +01:00
Miguel PalhasandClaude Opus 5 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 cfa0eaa77d Merge #231: settings rows on one line with icon actions
Closes #231
2026-08-25 11:21:35 +01:00
Miguel PalhasandClaude Fable 5 955a0f309c feat(web): settings rows on one line with icon actions
Each root and policy row is one line: identity left, chips at the right
edge, then pencil/trash icon controls — the episode-row idiom instead of
a two-line group. Every icon action's aria-label names the action and
the row (edit root /mnt/media/tv/main). Delete keeps the arm-then-confirm
behaviour via the shared armedDeleteIcon; the local text-button
armedDelete is gone with it. Under 46rem the row wraps like an episode
row: identity, chips full-width, actions keeping the right edge.

Closes #231

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:19:05 +01:00
Miguel PalhasandClaude Fable 5 df1af383a3 refactor(web): extract shared icon controls to module
The #230 icon block (glyph set, icon(), armedDeleteIcon()) moves from
main.ts to icons.ts so the settings rows (#231) can consume it without
an import cycle. The shared set is extended with one pencil glyph for
the rows' edit action — extension of the shared block, not a fork.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:19:05 +01:00
Miguel Palhas 7df83133b3 Merge #241: reclassify on root and policy changes
Closes #241
2026-08-25 11:18:51 +01:00
Miguel Palhas 5974d43785 Merge remote-tracking branch 'origin/blitz/feedback-2' into issue/241-reclassify-on-root-change 2026-08-25 11:17:47 +01:00
Miguel PalhasandClaude Fable 5 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 a42f3970d2 Merge #230: icon-only controls and readouts
Closes #230
2026-08-25 11:10:04 +01:00
Miguel Palhas 2a58a103d3 Merge #239: measure attention window from failure
Closes #239
2026-08-25 11:06:42 +01:00
Miguel PalhasandClaude Fable 5 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 070fd3d7ba Merge blitz/feedback-2: fmt fix and attention queue 2026-08-25 10:58:07 +01:00
Miguel Palhas 0a0c6359a4 Merge #243: trailing-slash self-conflict and stray relocation dir
Closes #243
2026-08-25 10:58:07 +01:00
Miguel PalhasandClaude Fable 5 0b4ded3fe9 feat(web): icon-only controls and readouts (#230)
Trash replaces the remove labels on title pages and season and
episode rows, a magnifier replaces the deck control, the season
on-disk readout carries a drive glyph before its 0/10, and the
TMDB, TVDB, IMDb and Rotten Tomatoes links carry drawn marks
shipped inline (wordmark badges plus a tomato), never remote
images. Every icon-only control keeps an aria-label naming the
action and the row or title it acts on, and the arm-then-confirm
delete speaks a visible amber "confirm" while armed, so the first
hit never destroys.

Shared CSS lives in one block in style.css headed
"icon-only controls (#230)" — .icon, .control-icon, .icon-mark —
for #231 to consume for the settings rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:58:07 +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 afc17ca34a Merge #238: queue only targets still waiting for a file
Closes #238
2026-08-25 10:50:56 +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 c962998a2b Merge #229: drop the back button, banner meets the rail
Closes #229
2026-08-25 10:42:05 +01:00