Compare commits

...

229 Commits

Author SHA1 Message Date
Miguel Palhas 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 Palhas 7bdbecd68a fix(subs): log in to OpenSubtitles with a JSON body
ci / web (push) Successful in 56s
e2e / e2e (push) Failing after 2m1s
ci / rust (push) Successful in 2m4s
ci / image (push) Successful in 4m8s
`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 Palhas 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 Palhas 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 Palhas 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 Palhas 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 Palhas 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 Palhas 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 Palhas 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
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
naps62-yolo dfa4656509 fix(infra): ship alass and the translation backends (#259)
ci / web (push) Successful in 1m29s
ci / rust (push) Successful in 2m34s
e2e / e2e (push) Failing after 3m57s
2026-08-26 09:51:09 +01:00
naps62-yolo bd5131f424 fix(web): collapse subtitles into one row chip (#258)
ci / web (push) Successful in 32s
ci / rust (push) Successful in 2m26s
e2e / e2e (push) Failing after 3m58s
2026-08-26 08:58:14 +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 Palhas 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 Palhas 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 Palhas 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 Palhas 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 Palhas 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 Palhas 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 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 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 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 c454b3ec11 Merge #244: normalise stored root paths
Closes #244
2026-08-25 11:29:13 +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 cfa0eaa77d Merge #231: settings rows on one line with icon actions
Closes #231
2026-08-25 11:21:35 +01:00
Miguel Palhas 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 Palhas 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 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 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 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 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 Palhas 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
Miguel Palhas 815a072ef0 fix(web): drop back button from movie and series pages (#229)
The rail, browser back, and Esc already cover navigation — the back
button was a third way to do what two other things already do, and it
cost the page its first line.

- Remove #movie-back and #series-back buttons and their event listeners
- Focus lands on the title element (tabindex=-1) instead of the removed
  button on open
- TV releases back button is untouched
- Hero banner now meets the rail with zero top padding on movie/series
- Esc and parent-route behaviour unchanged
- Deep link fallback for TV releases uses #nav-library instead of the
  removed #series-back
2026-08-25 10:40:37 +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 d23ae0ebcf Merge #236: move title folders on root path change
Closes #236
2026-08-25 10:21:01 +01:00
Miguel Palhas d7e9f710e0 Merge #233: stop offering a forced subtitle grab
Closes #233
2026-08-25 08:47:00 +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 2c5cd85499 Merge #220: configure the OpenAI-compatible endpoint and model
Closes #220
2026-08-25 08:35:54 +01:00
Miguel Palhas a45d8ff86a style(arr): give the OpenAI endpoint its own row
Five fields wrapped the model onto a line of its own and clipped the base
URL placeholder. Paired under one label, the way the translator budgets
already are, and the base URL gets the width it needs.
2026-08-25 08:33:47 +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 e467bd47fb feat(arr): add the OpenAI endpoint fields to /settings
Shown only when openai is among available_engines. Placeholders say the
base URL is optional and what a local one looks like.
2026-08-25 08:29:59 +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 ab001b512f docs(arr): settle the two open §15 questions
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.
2026-08-25 08:17:36 +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 60257f3177 Merge #223: offer subtitle delete in the UI
Closes #223
2026-08-25 06:49:39 +01:00
Miguel Palhas b73a58d1a4 Merge #225: cache the remote-command probe
Closes #225
2026-08-25 06:49:39 +01:00
Miguel Palhas 80101e789b Merge #215: drop the unused Podnapisi credentials
Closes #215
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 687c0c106f feat(arr): delete affordance on subtitle chips
Adds a two-click delete icon to each present-subtitle chip, naming
the sidecar in its title/aria-label. Wires the existing DELETE
/api/subtitles/{id} endpoint and reuses the panel's refresh() so
chips and the manual panel repaint after a delete (#223).
2026-08-25 06:40:26 +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 a836967e32 feat(web): subtitle lane under the signal chain 2026-08-25 06:24:32 +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
Miguel Palhas baea1dddbb Merge #204: add the subtitle section to settings
Closes #204
2026-08-25 05:26:19 +01:00
Miguel Palhas fe00220c5b Merge #205: cover the subtitle path in arr-e2e
Closes #205
2026-08-25 05:26:19 +01:00
Miguel Palhas bc43084f74 Merge #217: sync translated subtitles with alass
Closes #217
2026-08-25 05:26:19 +01:00
Miguel Palhas 71c161eff7 ci(arr): install alass for the e2e workflow
No distro package for it; cargo install alass-cli, symlinked to the
name the wrapper actually invokes. Also builds the daemon with
translate-command and triggers on arr-subs changes.
2026-08-25 05:25:37 +01:00
Miguel Palhas 6ee79519ca test(arr-e2e): cover the subtitle path end to end
Cross-process against the real arr binary, real ffmpeg and real alass:
a provider fetch synced and named under DESIGN.md §15, never
re-searched once satisfied; an embedded English track extracted and
translated into a .mt sidecar; an implausible sync kept unsynced and
surfaced in the missing-subtitles queue. Podnapisi and the translation
backend are stubbed at the HTTP/process boundary, never a live tracker
or a live translation API.
2026-08-25 05:25:33 +01:00
Miguel Palhas e47d2c920b feat(arr-e2e): extend the harness for subtitle scenarios
Daemon::spawn_with_env for extra child environment, database_path()
and media_root() accessors so a test can seed a media file directly,
the daemon built with translate-command, and the plausible/farfetched
Podnapisi zip fixtures the subtitle tests download from.
2026-08-25 05:25:28 +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 da1a932459 fix(arr): correct alass reference/subtitle argument order
Syncer::run passed [subtitle, video] where alass expects
<reference-file> <incorrect-sub-file>; every real invocation failed
before comparing timings, silently degrading to SyncState::NotRun.
2026-08-25 05:25:19 +01:00
Miguel Palhas 6d665e7c2f feat(arr): render subtitle settings in /settings
Adds wanted languages, translation engine (restricted to
available_engines), remote-command timeout, and an ordered
enable/disable list per provider with daily budgets, wired to
the existing GET/PUT /api/settings/subtitles from #198.
2026-08-25 05:08:58 +01:00
Miguel Palhas 9f0a7de37a fix(arr): sync translated subtitles too 2026-08-25 04:56:35 +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 78aabe99e5 Merge #202: add the missing-subtitles queue
Closes #202
2026-08-25 04:54:12 +01:00
Miguel Palhas a62a8de8fc Merge #219: apply the remote-command timeout setting
Closes #219
2026-08-25 04:54:12 +01:00
Miguel Palhas b3cca4f692 fix(api): drop subtitle queue gaps for unwanted languages
An attempt row survives after a language leaves wanted_languages —
missing_for (#201) already bounds by the current wanted set, so the
queue reads the same way instead of showing a stale gap forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 03:16:54 +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 47c7ef6682 fix(arr): re-read the command timeout every batch 2026-08-25 03:15:33 +01:00
Miguel Palhas a780b49ab3 feat(web): missing-subtitles queue alongside no-pt-source
A third lane in the attention queues view: one row per movie or
series with a gap chip per language and why (#186's reasons, plus a
sync alass flagged). Reuses the existing deck-group markup and
missingChipLabel wording so it reads like its two siblings; each
row opens the title's own page, where the #203 fetch/translate
panel already carries the resolving actions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 03:11:34 +01:00
Miguel Palhas fb40b35156 feat(api): add the missing-subtitles queue endpoint
GET /api/queues/subtitles, grouped by title with why each language
is a gap (#186's attempt states, plus a sync alass rejected). Series
episodes collapse into one season row when the gap is uniform, the
same restraint §9.5 gives the TV attention queues.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 03:03:17 +01:00
Miguel Palhas 4003c3a5a0 fix(arr): gate the shared LLM prompt helpers
#193 moved them out of openai.rs so the command backend could share them,
but left them ungated: with neither LLM backend compiled in they are dead
code. just ci missed it because it lints the workspace with --all-features;
building arr-api alone does not enable arr-subs' features.
2026-08-25 02:50:55 +01:00
Miguel Palhas 9ca3c3ce43 Merge #203: manual subtitle search and translation
Closes #203
2026-08-25 02:49:45 +01:00
Miguel Palhas 100a869c89 Merge #218: delete subtitle sidecars with their file
Closes #218
2026-08-25 02:49:45 +01:00
Miguel Palhas d6fdd3850f Merge #197: budget provider and translator calls
Closes #197
2026-08-25 02:49:45 +01:00
Miguel Palhas 22faad66ca fix(arr): allow the candidate DTO its five bools 2026-08-25 02:49:02 +01:00
Miguel Palhas e850ddff81 feat(arr): manual subtitle fetch and translate
The manual surface §9.3 describes, applied to subtitles (§15). It opens
inline under the file's own row and borrows the release deck's layout
language rather than inventing a second one: fixed-width chips lead, the
release name is secondary, eligible shows and rejected collapses to a
count naming the rule.

The translate lane takes any subtitle already on the file as a source,
including an extracted embedded track — the thing Bazarr cannot do — and
lists only the engines this binary was compiled with.
2026-08-25 02:45:32 +01:00
Miguel Palhas 218c83589e chore(arr): refresh sqlx offline query cache
For #197's budget queries and the settings query's new columns.
2026-08-25 02:40:12 +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 4d84625332 feat(arr): token bucket table for subtitle budgets
One row per provider/translator per day; try_spend is a single
atomic upsert so concurrent reconcile closes can't both slip a spend
past the daily cap. No allowance configured reads as unlimited.
2026-08-25 02:40:06 +01:00
Miguel Palhas f49507b7d1 refactor(arr): compute translator billing per call
Backend::characters_billed() polled a cumulative counter that races
under concurrent closes and can't attribute cost to one call. Drop it
in favour of the caller counting source characters before it sends
anything, which #197's budget needs anyway.
2026-08-25 02:40:02 +01:00
Miguel Palhas 9a21649afa feat(arr): report release-name match per candidate
The manual subtitle view shows the facts that decided a row (§9.3), and
release-name match is §15's second ranking tier. The file's own release
name is not otherwise on the wire, so the UI cannot derive it.
2026-08-25 02:29:50 +01:00
Miguel Palhas 2fa74137f9 fix(arr): delete subtitle sidecars with their file
remove_library_files only resolved video paths from media_files, so a
season/episode-scoped delete dropped subtitle_files rows via cascade
but left the .srt sidecars on disk (#218).
2026-08-25 02:29:13 +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 987bfa2864 Merge #216: wire translation backends into the daemon
Closes #216
2026-08-25 02:20:07 +01:00
Miguel Palhas 5ee5c56b88 Merge #201: show subtitle state on title detail
Closes #201
2026-08-25 02:20:07 +01:00
Miguel Palhas e684813d0c feat(arr): show subtitle chips on title detail
Per media file: a chip per present language (origin, forced/SDH, sync
flag, dashed for machine translation) and a chip per still-missing
wanted language naming why (§15, #201). Movie and series pages both
wire it in through the new status endpoints.
2026-08-25 02:16:25 +01:00
Miguel Palhas e5092034d3 feat(arr): serve subtitle status for title detail
Exposes per-media-file subtitles and missing wanted languages, with the
attempt reason, so #201's UI has one call per title (movies, episodes)
and one bulk call per series instead of one per episode.
2026-08-25 02:16:20 +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 0d91beff14 Merge #195: write subtitle sidecars, refresh Jellyfin
Closes #195
2026-08-25 01:48:30 +01:00
Miguel Palhas ae604e53d5 Merge #193: remote-command translation backend
Closes #193
2026-08-25 01:48:30 +01:00
Miguel Palhas 07921c9e1e test(arr): drive the command backend via stub scripts 2026-08-25 01:44:52 +01:00
Miguel Palhas 8b92a68edd feat(arr): remote-command translation backend 2026-08-25 01:44:52 +01:00
Miguel Palhas 4af6e0a083 refactor(arr): share the LLM prompt helpers across backends 2026-08-25 01:44:52 +01:00
Miguel Palhas 8baef11c0e feat(arr): refresh jellyfin after a subtitle write
DESIGN.md §7.5's watcher gap applies to a sidecar dropped next to a
file Jellyfin already knows about, same as an imported file. A grab
or translation now makes the same refresh call import does; a
refresh failure logs and never fails the write that already landed.
2026-08-25 01:31:59 +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 635a651bee feat(arr): add Syncer::settle as the alass seam
Folds an implausible result and an unusable alass binary into one
SyncState both the grab handler (#199) and reconcile loop (#196)
can record without re-deriving the same match arms.
2026-08-25 01:24:02 +01:00
Miguel Palhas c6cfdff2c9 test(arr): read available_engines from the build
Second test that assumed the empty default feature set. It asserted
available_engines was literally [], which --all-features makes false.
2026-08-25 01:11:08 +01:00
Miguel Palhas f773489fd7 test(arr): make the engine gate test ask the build
Turning on --all-features compiled every translation backend, so the test
asserting that a known-but-uncompiled engine is refused had nothing left to
refuse and failed. It hardcoded "deepl" and a comment that no feature was
on, which stopped being true in the same commit that made CI see it.

It now picks whichever engine this build did not compile, and when all of
them are compiled asserts the complementary truth instead: a compiled
engine is accepted. Meaningful under either feature set.
2026-08-25 01:08:15 +01:00
Miguel Palhas db9ae271cb fix(arr): make CI compile the translation backends
Every translation backend sits behind a default-off cargo feature, and the
gate ran with the default set, so clippy and the test run never saw a line
of arr-subs' openai, deepl or google modules. #191 and #192 each reported
it after verifying their own work by hand. lint and test now pass
--all-features.

Also drops a duplicated wiremock suppression the #188 merge left behind and
puts the module list back in order.
2026-08-25 01:05:50 +01:00
Miguel Palhas 0f0d56ef0e Merge #192: DeepL and Google Translate backends
Closes #192
2026-08-25 01:04:51 +01:00
Miguel Palhas dd1e5d02a3 Merge #191: OpenAI-compatible translation backend
Closes #191
2026-08-25 01:04:47 +01:00
Miguel Palhas f84b04536a Merge #194: sync subtitles with alass
Closes #194
2026-08-25 01:04:43 +01:00
Miguel Palhas 3e5fdc6908 feat(arr): translate through Google Translate 2026-08-25 01:01:17 +01:00
Miguel Palhas 0c07fbaed5 feat(arr): translate through the DeepL API 2026-08-25 01:01:01 +01:00
Miguel Palhas ef0fac4933 feat(arr): let backends report billed characters 2026-08-25 01:00:41 +01:00
Miguel Palhas 82620e940c feat(arr): wrap alass for subtitle sync 2026-08-25 00:50:25 +01:00
Miguel Palhas 8efb9cbf23 feat(arr): OpenAI-compatible translation backend
Behind the existing translate-openai feature. One JSON-in/JSON-out
chat/completions request per batch; refusal, content-filter and
truncated replies fail loudly instead of validating as a short batch.
2026-08-25 00:50:06 +01:00
Miguel Palhas 26bd25e2f6 feat(arr): convert non-SRT grabs instead of refusing
#199 shipped the manual grab before #213 existed, so srt_text answered 422
for any provider not serving SRT. Fetched::to_srt already decodes and
converts, so this is that call. A format with no parser still fails rather
than reaching the disk.

Leaves the alass half of #214 open; that waits on #194.
2026-08-25 00:46:33 +01:00
Miguel Palhas 63472d99cf Merge #199: serve the subtitle API
Closes #199
2026-08-25 00:41:02 +01:00
Miguel Palhas 182285c356 Merge #213: convert non-SRT downloads to SRT
Closes #213
2026-08-25 00:41:02 +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 449750e426 feat(arr): serve the subtitle API
Issue #199, DESIGN.md §15 and §9.1. Lists what exists per media file and
per title, runs the enabled providers for one language and returns every
candidate with §9.3's verdict vocabulary — including the rejected ones
naming the rule that killed each — then grabs, translates and deletes.

Inline rather than 202-and-poll like the release deck: a subtitle search
is one or two HTTP calls and nothing persists its candidates, so there
is nothing to come back for. The cost is that a grab repeats the
`forced` and `sdh` facts the search reported, since the server does not
remember them.

Every write ends by marking the language satisfied, whether or not it is
in the wanted set. That is §15's "manual actions bypass the wanted-set
logic": the operator asking for Spanish gets Spanish, and the loop does
not then read it as a gap. A forced track is the exception §15 names — it
covers signs only — so it is recorded and satisfies nothing.

`alass` (#194) does not run yet: a fetched sidecar is recorded unsynced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:27:03 +01:00
Miguel Palhas 0c9b9fb796 feat(arr): reopen a language on subtitle delete
DESIGN.md §15 reads satisfaction off the files, so a `satisfied` attempt
row whose sidecar was just deleted by hand is a stale claim that hides
the gap from the reconcile loop's work list. `unsatisfy` withdraws only
that claim: the attempt count and timestamp stay, because the backoff is
a fact about what providers were already asked and a delete does not
un-ask them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:26:55 +01:00
Miguel Palhas 8450b06e76 feat(arr): convert VTT and ASS downloads to SRT 2026-08-24 23:13:25 +01:00
Miguel Palhas b9f4ee98d5 fix(arr): recover lost fixtures and align provider names
The Podnapisi zip fixtures never reached the branch: a global gitignore
excludes *.zip, so git add skipped them silently and the worker's CI passed
against untracked files. Recovered, with a fixtures .gitignore that keeps
the next binary fixture from vanishing the same way.

Two providers landing in parallel also disagreed on names and on which
crate dependencies each integration-test target uses. PodnapisiProvider is
now Podnapisi, matching OpenSubtitles, and both test targets declare the
dependencies they do not use so the per-target lint stays quiet.
2026-08-24 23:07:46 +01:00
Miguel Palhas 529d7a4ee4 Merge #188: Podnapisi provider
Closes #188

# Conflicts:
#	Cargo.lock
#	crates/arr-subs/Cargo.toml
#	crates/arr-subs/src/error.rs
#	crates/arr-subs/src/lib.rs
2026-08-24 23:06:34 +01:00
Miguel Palhas ee3e2ba4b4 Merge #187: OpenSubtitles.com provider
Closes #187

# Conflicts:
#	Cargo.lock
#	crates/arr-subs/Cargo.toml
2026-08-24 23:04:46 +01:00
Miguel Palhas f617403912 Merge #212: decode fetched subtitles to UTF-8
Closes #212
2026-08-24 23:04:13 +01:00
Miguel Palhas da0c30c606 feat(arr): add opensubtitles.com subtitle provider
Searches by moviehash computed from the media file and by TMDB id
with season/episode for TV, ranks candidates through
arr_core::subs::rank, and downloads under a lazily-fetched user
token. The daily download cap (429/406) surfaces as Error::RateLimited
so the loop can show a queue state. Credentials come from config or
environment only; tests run against wiremock fixtures.
2026-08-24 22:58:48 +01:00
Miguel Palhas 6ec4703edb test(arr): cover the Podnapisi provider 2026-08-24 22:54:13 +01:00
Miguel Palhas 8311307453 feat(arr): fetch subtitles from Podnapisi 2026-08-24 22:54:13 +01:00
Miguel Palhas d8acf30fb7 feat(arr): add zip dep and provider config error 2026-08-24 22:54:13 +01:00
Miguel Palhas 81dd606414 feat(arr): decode fetched subtitles to UTF-8 2026-08-24 22:41:17 +01:00
Miguel Palhas 5d7d881ab9 feat(arr): add Error::Decode for bad subtitle bytes 2026-08-24 22:41:17 +01:00
Miguel Palhas 94d9fc8b89 Merge #190: pluggable subtitle translation
Closes #190
2026-08-24 22:36:13 +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 340c113007 Merge #189: extract text subtitle streams to SRT
Closes #189
2026-08-24 22:36:12 +01:00
Miguel Palhas 0bdf0103bd feat(arr): extract text subtitle tracks to srt
ffmpeg, spawned and left to die like ffprobe, maps one subtitle stream
and converts it to SRT under §15's sidecar name. Text formats become
legal translation sources; bitmap tracks never extract.
2026-08-24 22:32:26 +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 802d2a1208 feat(arr): expose subtitle settings API 2026-08-24 22:28:31 +01:00
Miguel Palhas ed1a74e03f feat(arr): report compiled subtitle engines 2026-08-24 22:28:17 +01:00
Miguel Palhas 1900a82d55 feat(arr): add subtitle_settings table 2026-08-24 22:28:14 +01:00
Miguel Palhas 39ddf65afa style(arr): rustfmt arr-subs 2026-08-24 22:21:40 +01:00
Miguel Palhas e55ce05880 feat(arr): add pluggable subtitle translation layer
The Backend trait plus everything the backends (#191-#193) share, so no
backend can skip it: chunking into character-budgeted batches, rejection
of replies whose cue count or numbering drifted, and reassembly onto the
original timings. Timing data never leaves arr; pt-PT and pt-BR are
distinct targets a backend must refuse rather than conflate.
2026-08-24 22:21:09 +01:00
Miguel Palhas cd304d552c feat(arr): parse and render SRT cues in arr-subs
Translation reassembles translated text onto original timings (§15), so
cues need a structured form. Parsing is tolerant of real files (CRLF,
BOM, missing indices, dot milliseconds); rendering is strict and
renumbers from 1.
2026-08-24 22:21:09 +01:00
Miguel Palhas cdd6133bce fix(arr): close the subtitle ranking seam
#184 and #185 were built in parallel and their candidate types did not
meet. Ranking returned no identity for a candidate, so the winner of a
rank() could not be handed back to Provider::download -- rank() reorders,
so the caller could not recover it by position either.

RankedSubtitle now carries the index of the candidate in the slice it was
given, and Candidate::to_core is the one place the two shapes are mapped:
hash_match against a compared hash, sdh against hearing_impaired, group
against release_group, and the two optional tiebreakers defaulted to sort
last rather than block a candidate.
2026-08-24 22:14:01 +01:00
Miguel Palhas 44c7013e49 Merge #186: persist subtitle files and attempts
Closes #186
2026-08-24 22:06:01 +01:00
Miguel Palhas b0e3905486 Merge #185: rank subtitle candidates in arr-core
Closes #185
2026-08-24 22:06:01 +01:00
Miguel Palhas 3d2b816f9f Merge #184: add arr-subs with the provider trait
Closes #184
2026-08-24 22:06:01 +01:00
Miguel Palhas c3bd009442 feat(arr): add subtitle queries
Recording a subtitle is idempotent on both keys the schema carries: the
sidecar path, and the language an embedded track satisfies, so a second
probe or a re-import converges instead of duplicating. Attempts are
upserted per (media file, language); the work list is every language that
is not satisfied, newest import first, which is the order §15 wants the
daily allowance spent in.
2026-08-24 21:46:13 +01:00
Miguel Palhas 151135b545 feat(arr): add subtitle tables
DESIGN.md §15 needs two shapes: what subtitles exist for a media file,
and what arr has tried per wanted language. An embedded track carries no
path — it is recorded because it satisfies a language, not because there
is a file — and the CHECK constraints tie provider, engine and path to
the origin so an impossible row cannot be written.
2026-08-24 21:46:04 +01:00
Miguel Palhas 89154614e1 feat(arr): rank subtitle candidates in arr-core
Implements DESIGN.md §15 ranking: moviehash > exact release-name >
group/source > rating/downloads, forced always rejected, SDH always
below plain. Verdict mirrors the release path (eligible / rejected(rule)).
2026-08-24 21:43:46 +01:00
Miguel Palhas 8b83ac9511 feat(arr): add arr-subs with the provider trait
The skeleton the subtitles milestone hangs off: provider domain types, an
object-safe Provider trait, the crate's own error type, and the cargo
features the translation backends will sit behind. No provider, no
translation, no ranking — DESIGN.md §15 keeps ranking pure in arr-core.
2026-08-24 21:42:40 +01:00
Miguel Palhas 695cb69f2f Merge #183: DESIGN.md §15 subtitles contract
Closes #183
2026-08-24 21:38:01 +01:00
Miguel Palhas 1401e9e00f docs(arr): add DESIGN.md §15 subtitles contract
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>
2026-08-24 21:37:37 +01:00
199 changed files with 31832 additions and 1960 deletions
+66
View File
@@ -119,3 +119,69 @@ jobs:
- name: design tokens
if: steps.probe.outputs.present == 'true'
run: pnpm -C web run check-tokens
# Build the production image here rather than on the Dokploy host: the gate
# above has to pass first, the layer cache lives in the registry instead of
# being thrown away every deploy, and Dokploy's job shrinks to a pull.
#
# This job is also the deploy trigger. The Gitea push webhook fires the
# instant the commit lands, long before the image exists, so autoDeploy on
# the Dokploy application must stay off — the deploy is the last step here.
image:
needs: [rust, web]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
IMAGE: git.naps.pt/yolo/arr
DOKPLOY_APP_ID: uEaWG5JDpVIrJloG5rMAP
steps:
- uses: actions/checkout@v4
- name: Docker CLI and buildx
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl gnupg
. /etc/os-release
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/$ID/gpg" \
-o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc]" \
"https://download.docker.com/linux/$ID $VERSION_CODENAME stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin
docker version
- name: Registry login
run: |
printf '%s' "${{ secrets.REGISTRY_TOKEN }}" \
| docker login git.naps.pt -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Buildx builder
# The default docker driver cannot export a cache. docker-container can.
run: docker buildx create --name arr --driver docker-container --use
- name: Build and push
run: |
docker buildx build \
--tag "$IMAGE:sha-$GITHUB_SHA" \
--tag "$IMAGE:main" \
--cache-from "type=registry,ref=$IMAGE:buildcache" \
--cache-to "type=registry,ref=$IMAGE:buildcache,mode=max" \
--push .
- name: Deploy
# Pinned to the commit sha, not :main — swarm does not reliably re-pull
# an unchanged tag name, and an older sha is the rollback.
run: |
# The package is public (org yolo is public), so Dokploy pulls with
# no credentials and the registry fields stay null.
curl -fsS -X POST https://dokploy.n62.casa/api/application.update \
-H "x-api-key: ${{ secrets.DOKPLOY_API_KEY }}" \
-H 'content-type: application/json' \
-d "{\"applicationId\":\"$DOKPLOY_APP_ID\",\"dockerImage\":\"$IMAGE:sha-$GITHUB_SHA\"}"
curl -fsS -X POST https://dokploy.n62.casa/api/application.deploy \
-H "x-api-key: ${{ secrets.DOKPLOY_API_KEY }}" \
-H 'content-type: application/json' \
-d "{\"applicationId\":\"$DOKPLOY_APP_ID\",\"title\":\"ci ${GITHUB_SHA:0:8}\"}"
+40 -12
View File
@@ -13,6 +13,7 @@ on:
- 'crates/arr-indexer/**'
- 'crates/arr-meta/**'
- 'crates/arr-probe/**'
- 'crates/arr-subs/**'
- 'crates/arr-api/**'
- 'crates/arr-daemon/**'
- '.gitea/workflows/e2e.yml'
@@ -25,15 +26,6 @@ jobs:
e2e:
runs-on: ubuntu-latest
services:
transmission:
image: linuxserver/transmission:latest
env:
PUID: "1000"
PGID: "1000"
ports:
- 9091:9091
steps:
- uses: actions/checkout@v4
@@ -70,12 +62,48 @@ jobs:
curl -LsSf https://get.nexte.st/latest/linux \
| tar zxf - -C "$HOME/.cargo/bin"
- name: alass
# No distro package; the subtitle sync path (DESIGN.md §15) shells
# out to a binary literally named `alass`, which the crate is not.
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v alass >/dev/null || {
cargo install alass-cli --locked
ln -sf "$HOME/.cargo/bin/alass-cli" "$HOME/.cargo/bin/alass"
}
- name: qBittorrent
# Not a `services:` container: qBittorrent has no environment
# variable for the WebUI password and prints a random one per boot,
# so the only scriptable login is a config seeded before first start,
# which a service container cannot be given.
run: |
mkdir -p /tmp/qbt/config/qBittorrent
cat > /tmp/qbt/config/qBittorrent/qBittorrent.conf <<'CONF'
[Preferences]
WebUI\Port=8080
WebUI\AuthSubnetWhitelistEnabled=true
WebUI\AuthSubnetWhitelist=0.0.0.0/0
WebUI\CSRFProtection=false
WebUI\HostHeaderValidation=false
Downloads\SavePath=/downloads
CONF
docker run -d --name arr-e2e-qbittorrent --network host \
-e PUID=1000 -e PGID=1000 -e WEBUI_PORT=8080 \
-v /tmp/qbt/config:/config \
linuxserver/qbittorrent:latest
for _ in $(seq 1 60); do
curl -sf http://127.0.0.1:8080/api/v2/app/version && break
sleep 2
done
- name: build daemon
# The harness spawns this binary; building it here keeps the compile
# out of the first test's boot window.
run: cargo build -p arr-daemon --bin arr
# out of the first test's boot window. `translate-command` is the
# subtitle e2e scenarios' stub-script translation backend.
run: cargo build -p arr-daemon --bin arr --features translate-command
- name: e2e
env:
TRANSMISSION_RPC_URL: http://transmission:9091/transmission/rpc
QBITTORRENT_URL: http://127.0.0.1:8080
run: cargo nextest run -p arr-e2e
+45 -4
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-22T00:00:00.000Z",
"generatedAt": "2026-08-26T00:00:00.000Z",
"title": "Design System: arr — Master Control",
"extensions": {
"colorMeta": {
@@ -96,7 +96,7 @@
"oklch(0.8 0.015 250)"
]
},
"status-channel": {
"status-airing": {
"role": "tertiary",
"displayName": "Channel Violet",
"canonical": "oklch(0.78 0.14 305)",
@@ -106,6 +106,20 @@
"oklch(0.78 0.14 305)",
"oklch(0.86 0.09 305)"
]
},
"score-heat": {
"role": "tertiary",
"displayName": "Score Heat",
"canonical": "oklch(0.8 0.17 150)",
"tonalRamp": [
"oklch(0.68 0.19 25)",
"oklch(0.7 0.185 46)",
"oklch(0.72 0.175 67)",
"oklch(0.75 0.165 88)",
"oklch(0.77 0.16 108)",
"oklch(0.79 0.165 129)",
"oklch(0.8 0.17 150)"
]
}
},
"typographyMeta": {
@@ -202,11 +216,27 @@
{
"name": "Status chip",
"kind": "custom",
"refersTo": "status-channel",
"refersTo": "status-airing",
"description": "Library status (§4.2): airing / incomplete / waiting / complete / ended. Informational, not severity — colour marks activity in one channel-violet family (airing brightest, incomplete, waiting dimmest), satisfaction is neutral (complete bright ink, ended faint). The mono word is always present.",
"html": "<span class=\"chip readout\" data-status=\"airing\">airing</span>",
"css": ".chip[data-status=\"airing\"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); } .chip[data-status=\"incomplete\"] { color: var(--signal-warn); border-color: oklch(from var(--signal-warn) l c h / 55%); } .chip[data-status=\"complete\"], .chip[data-status=\"ended\"] { color: var(--signal-ok); border-color: oklch(from var(--signal-ok) l c h / 45%); } .chip[data-counts=\"complete\"] { color: var(--signal-ok); } .chip[data-counts=\"partial\"] { color: var(--signal-warn); }"
},
{
"name": "Media state chip",
"kind": "custom",
"refersTo": "signal-ok",
"description": "§4.2's per-title state chip. Every state keeps the word, with one drawn exemption: `available` renders as a check glyph instead of the word `available` — a shape, not only a hue, so it survives a greyscale read the rule otherwise protects. The word moves to the chip's aria-label (exposed via role=\"img\", since a bare span's aria-label is not reliably read).",
"html": "<span class=\"chip readout\" role=\"img\" aria-label=\"available\" data-movie-state=\"available\"><svg viewBox=\"0 0 16 16\" width=\"12\" height=\"12\"><path d=\"M3.2 8.4l3 3 6.6-6.8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg></span>",
"css": ".chip[data-movie-state=\"available\"] { color: var(--signal-ok); border-color: oklch(from var(--signal-ok) l c h / 45%); } .chip[data-movie-state=\"missing\"][data-wanted=\"true\"] { color: var(--signal-warn); border-color: oklch(from var(--signal-warn) l c h / 55%); } .chip[data-movie-state=\"downloading\"] { color: var(--status-airing); border-color: oklch(from var(--status-airing) l c h / 45%); }"
},
{
"name": "Score chip",
"kind": "custom",
"refersTo": "score-heat",
"description": "Issue 111: the release deck's score column (§9.3) reads on a list-relative heat scale — seven stops from the lowest-scoring candidate on screen (red) to the highest (green) — rather than a flat eligible-green fill. Every other column chip stays the plain neutral Chip.",
"html": "<span class=\"chip readout cw cw-score\" data-score=\"6\" aria-label=\"score 128\">128</span>",
"css": ".cw-score[data-score=\"0\"] { color: var(--score-0); border-color: oklch(from var(--score-0) l c h / 45%); } .cw-score[data-score=\"6\"] { color: var(--score-6); border-color: oklch(from var(--score-6) l c h / 45%); }"
},
{
"name": "Module (channel strip)",
"kind": "card",
@@ -290,6 +320,16 @@
"name": "The Dead-Air Rule",
"body": "When nothing is flowing, nothing animates. Pulse means probing; data-state=\"off\" is dark and still. Motion that misreports activity is a truth bug.",
"section": "motion"
},
{
"name": "The Check-Glyph Exemption",
"body": "The one state chip drawn instead of worded: `available` is a check glyph, because a drawn shape survives the greyscale read the Word-Beside-The-Lamp doctrine otherwise protects with text alone. The word is not deleted — it moves to the chip's aria-label. No other state gets this treatment; `incomplete` and `waiting` have no unambiguous glyph, so they keep the word on the chip.",
"section": "colors"
},
{
"name": "The List-Relative Heat Rule",
"body": "The release deck's score chip (issue 111) is not an absolute quality scale — its seven stops are relative to the lowest and highest score on screen right now. Colour there answers \"which of these\", not \"how good is this release in general\".",
"section": "colors"
}
],
"dos": [
@@ -297,7 +337,8 @@
"Do keep every control ≥44px, focus rings cyan and visible, and reduced-motion honored.",
"Do keep muted text at or above 4.5:1 on panel ground (--ink-muted floor).",
"Do self-host every asset; the SPA is embedded in the binary and must work with no network.",
"Do take every colour, spacing and font-size from the :root tokens; the token check (web/scripts/check-tokens.mjs) fails CI on literals."
"Do take every colour, spacing and font-size from the :root tokens; the token check (web/scripts/check-tokens.mjs) fails CI on literals.",
"Do suppress a redundant status chip where another control on the same row already implies the exact same state one-to-one — the missing chip disappears from episode rows because the want button only ever renders for that state."
],
"donts": [
"Colour state chips on the ramp — `complete` and `ended` green, `incomplete` and a partial count amber, `airing` and `downloading` violet, `waiting` and `untracked` neutral. The word stays in every chip, so state survives with colour removed.",
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
"query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
false
]
},
"hash": "3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa"
"hash": "028ba37575a1081f7678fd8649227f39c29afc1883e3a3607260455d1a088143"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT r.name AS \"name!: String\",\n r.parsed AS \"parsed!: serde_json::Value\"\n FROM episodes e\n JOIN grabs g ON g.target_kind = 'season' AND g.target_id = e.season_id\n JOIN releases r ON r.id = g.release_id\n WHERE e.id = ?\n ORDER BY g.imported_at DESC, g.id DESC LIMIT 1",
"describe": {
"columns": [
{
"name": "name!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "name"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "05b5757f0fb7e6ab1176387854a14fc8db793871193b6f16e3719d73040e8ce8"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
"describe": {
"columns": [
{
@@ -94,5 +94,5 @@
false
]
},
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
"hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_attempts\n (media_file_id, language, state, attempts, last_attempt_at, last_failure)\n VALUES (?, ?, ?, 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)\n ON CONFLICT (media_file_id, language) DO UPDATE\n SET state = excluded.state,\n attempts = subtitle_attempts.attempts + 1,\n last_attempt_at = excluded.last_attempt_at,\n last_failure = excluded.last_failure,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "08c356bfcd7f7cd7a4bac0bf0ce44ce3276577cb18f32b0c01839150e7827bca"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM episodes WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "0b40dbc80628244531a044e872238e356ab6bc66567c72008cff506e595932b1"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -58,5 +58,5 @@
false
]
},
"hash": "1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e"
"hash": "0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n -- A later attempt owns the headline while it is still in\n -- flight. Keep the failed grab recorded; it is simply no\n -- longer the current explanation for the gap.\n AND NOT EXISTS (\n SELECT 1 FROM grabs newer\n WHERE newer.target_kind = 'season'\n AND newer.target_id = g.target_id\n -- 'vanished' is not a live attempt either: the\n -- torrent left qBittorrent, so letting it take the\n -- headline would leave the season saying nothing at\n -- all about a gap that still exists.\n AND newer.state NOT IN ('failed', 'vanished')\n AND (newer.grabbed_at > coalesce(g.failed_at, g.grabbed_at)\n OR (newer.grabbed_at = coalesce(g.failed_at, g.grabbed_at)\n AND newer.id > g.id))\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id",
"describe": {
"columns": [
{
"name": "season_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "grabs",
"name": "target_id"
}
}
},
{
"name": "name!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "name"
}
}
},
{
"name": "infohash!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "grabs",
"name": "infohash"
}
}
},
{
"name": "failed_at",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "grabs",
"name": "failed_at"
}
}
},
{
"name": "grabbed_at!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "grabs",
"name": "grabbed_at"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
false
]
},
"hash": "0cfce0fe064cccb1dceb285a50fa77d990b116a95b987e604913cd8953da36ad"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT wanted_languages FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "wanted_languages",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "wanted_languages"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "0f94b0f839cfcbe4a70ee5be81f452c88dc610d05042d4af811963c0cbc6113e"
}
@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT required_audio AS \"required_audio!: String\",\n dub_blacklist AS \"dub_blacklist!: String\",\n hdr_rules AS \"hdr_rules!: String\",\n size_bands AS \"size_bands!: String\",\n resolution_pref AS \"resolution_pref!: String\",\n source_weights AS \"source_weights!: String\",\n score_weights AS \"score_weights!: String\"\n FROM policies WHERE id = ?",
"describe": {
"columns": [
{
"name": "required_audio!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "required_audio"
}
}
},
{
"name": "dub_blacklist!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "dub_blacklist"
}
}
},
{
"name": "hdr_rules!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "hdr_rules"
}
}
},
{
"name": "size_bands!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "size_bands"
}
}
},
{
"name": "resolution_pref!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "resolution_pref"
}
}
},
{
"name": "source_weights!: String",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "source_weights"
}
}
},
{
"name": "score_weights!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "score_weights"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM series WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "2084928704dbb8a2c81692b9aa3b6edff64745945ea4e88d70bcb139e272c857"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(\n SELECT 1 FROM subtitle_files\n WHERE media_file_id = ? AND language = ? AND forced = 0\n ) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "233bae03912e50e6bcde4b24a9d7fbe256dbf626e4e236a06ba08a00643566d3"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_files\n (media_file_id, language, origin, provider, candidate_id, engine,\n forced, sdh, synced, sync_rejected, skeleton_aligned, path)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (path) DO NOTHING\n ON CONFLICT (media_file_id, language, forced, sdh)\n WHERE origin = 'embedded' DO NOTHING\n RETURNING id AS \"id!: i64\"",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
}
],
"parameters": {
"Right": 12
},
"nullable": [
null
]
},
"hash": "266c8e63e7f81cb07921de180f32b47ac8c4e9d47a31af786742404f8e54f517"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT sr.tmdb_id AS \"tmdb_id!: i64\",\n s.number AS \"season!: i64\",\n e.number AS \"episode!: i64\"\n FROM episodes e\n JOIN seasons s ON s.id = e.season_id\n JOIN series sr ON sr.id = s.series_id\n WHERE e.id = ?",
"describe": {
"columns": [
{
"name": "tmdb_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "season!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode!: i64",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "268cb11282b16494330c1655ba3dfcb4f2506686822711f3c6489c8ddd42b0e7"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n WHERE mf.owner_kind = 'movie' AND mf.owner_id = ? AND sf.path IS NOT NULL",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "295476f5ee76f552a0d0727fca01f7602111b1dc5614bf2f8c8626744ecb22db"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)\n VALUES (?, ?, ?, ?, 'sent')\n ON CONFLICT (infohash) DO UPDATE SET\n release_id = excluded.release_id,\n target_kind = excluded.target_kind,\n target_id = excluded.target_id,\n state = 'sent',\n grabbed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n imported_at = NULL\n WHERE grabs.state = 'vanished'\n RETURNING id AS \"id!: i64\"",
"query": "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)\n VALUES (?, ?, ?, ?, 'sent')\n ON CONFLICT (infohash) DO UPDATE SET\n release_id = excluded.release_id,\n target_kind = excluded.target_kind,\n target_id = excluded.target_id,\n state = 'sent',\n grabbed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n imported_at = NULL\n WHERE grabs.state = 'vanished'\n OR (grabs.target_kind = 'movie'\n AND NOT EXISTS (SELECT 1 FROM movies WHERE id = grabs.target_id))\n OR (grabs.target_kind = 'season'\n AND NOT EXISTS (SELECT 1 FROM seasons WHERE id = grabs.target_id))\n OR (grabs.target_kind = 'episode'\n AND NOT EXISTS (SELECT 1 FROM episodes WHERE id = grabs.target_id))\n RETURNING id AS \"id!: i64\"",
"describe": {
"columns": [
{
@@ -22,5 +22,5 @@
null
]
},
"hash": "2d0b92ba9aa4bc257286a67f0d37e2182c4478153c9096c1522c5ab23ad472e9"
"hash": "2aeb5f9dab4a62d4cc463a05c0636c379b9c5a1cebee9dfa61df2162f2c64383"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT wanted_languages AS \"wanted_languages!: String\" FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "wanted_languages!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "wanted_languages"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "2afeefde38499d722f329d1d472bc89524ce6498ccd4098ba97979e0621b840b"
}
@@ -0,0 +1,122 @@
{
"db_name": "SQLite",
"query": "SELECT m.id AS \"movie_id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\", m.year, m.poster_path,\n mf.id AS \"media_file_id!: i64\", sa.language AS \"language!: String\",\n sa.state AS \"state!: arr_db::SubtitleState\", sa.last_failure\n FROM subtitle_attempts sa\n JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'movie'\n JOIN movies m ON m.id = mf.owner_id\n WHERE sa.state IN ('failed', 'capped', 'unavailable')\n ORDER BY m.title, sa.language",
"describe": {
"columns": [
{
"name": "movie_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "poster_path"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "language"
}
}
},
{
"name": "state!: arr_db::SubtitleState",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "state"
}
}
},
{
"name": "last_failure",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_failure"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
true
]
},
"hash": "2b67abe4db9e00517fecfa53fbad5bbbc633a9f537eaaaa9d46a445d8c25eb2c"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM movies WHERE root_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE subtitle_attempts\n SET state = 'wanted',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE media_file_id = ? AND language = ? AND state = 'satisfied'",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "31bed4c9097d6ea2ed189de48440bd0d882873d27450e5d177baed65c74fd7de"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\",\n path AS \"path!: String\",\n size AS \"size!: i64\",\n owner_kind AS \"owner_kind!: String\",\n owner_id AS \"owner_id!: i64\"\n FROM media_files WHERE id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
},
{
"name": "size!: i64",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "size"
}
}
},
{
"name": "owner_kind!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "owner_kind"
}
}
},
{
"name": "owner_id!: i64",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "owner_id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "32443dc6e5bdd414340443c5d527b2578bcdeebd053b667db35423d829d4bf10"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_attempts (media_file_id, language)\n SELECT id, ? FROM media_files WHERE probed IS NOT NULL\n ON CONFLICT (media_file_id, language) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "35ace0417f613340f82522549d70d0dffeb680afb57511cb17fa15b3a571b974"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
"describe": {
"columns": [
{
@@ -46,5 +46,5 @@
true
]
},
"hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be"
"hash": "35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
"hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
"hash": "3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
"describe": {
"columns": [
{
@@ -190,5 +190,5 @@
true
]
},
"hash": "b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614"
"hash": "3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT r.name AS \"name!: String\",\n r.parsed AS \"parsed!: serde_json::Value\"\n FROM grabs g JOIN releases r ON r.id = g.release_id\n WHERE g.target_kind = ? AND g.target_id = ?\n ORDER BY g.imported_at DESC, g.id DESC LIMIT 1",
"describe": {
"columns": [
{
"name": "name!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "name"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false
]
},
"hash": "432cf38775bc4d7dee1dd9d009e06638ea57f805127f7634f34b8189e831f461"
}
@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT a.media_file_id AS \"media_file_id!: i64\",\n a.language AS \"language!: String\",\n a.state AS \"state!: SubtitleState\",\n a.attempts AS \"attempts!: i64\",\n a.last_attempt_at,\n a.last_failure,\n f.path AS \"media_file_path!: String\"\n FROM subtitle_attempts a\n JOIN media_files f ON f.id = a.media_file_id\n WHERE a.state IN ('wanted', 'failed')\n ORDER BY f.created_at DESC, a.last_attempt_at, a.language\n LIMIT ?",
"describe": {
"columns": [
{
"name": "media_file_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "media_file_id"
}
}
},
{
"name": "language!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "language"
}
}
},
{
"name": "state!: SubtitleState",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "state"
}
}
},
{
"name": "attempts!: i64",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "attempts"
}
}
},
{
"name": "last_attempt_at",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_attempt_at"
}
}
},
{
"name": "last_failure",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_failure"
}
}
},
{
"name": "media_file_path!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
true,
false
]
},
"hash": "47ad9162ba373dfbdf061898f5f148e65de261b494453804db912a919a78c2c5"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT tmdb_id AS \"tmdb_id!: i64\" FROM movies WHERE id = ?",
"describe": {
"columns": [
{
"name": "tmdb_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "4f4f037c956ac6b246503354ff9b59db59e36f9a6d59adc0753b95662c9c630a"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -94,5 +94,5 @@
false
]
},
"hash": "a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c"
"hash": "4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ? AND sf.path IS NOT NULL",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "5153426ad8b36b6a2b8c17942dfedf02b1e1f91482c8784357d2ef61c9d160c1"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ? AND sf.path IS NOT NULL",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "5f3a663eef0318b38cd864a2e06befa20fba22fc2d281295aed81a851a35ea07"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM grabs WHERE target_kind = 'movie' AND target_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "607f337ce8db7eb277dfc569fb1a68e956a38f1db4462cab02fe26c8c9912a81"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_attempts (media_file_id, language)\n VALUES (?, ?)\n ON CONFLICT (media_file_id, language) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "6236e1be0e268c678271dd72be52454347da6637f0bf3ecb52dda82cddfe459a"
}
@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT m.id AS \"movie_id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\", m.year, m.poster_path,\n mf.id AS \"media_file_id!: i64\", sf.language AS \"language!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'movie'\n JOIN movies m ON m.id = mf.owner_id\n WHERE sf.sync_rejected = 1\n ORDER BY m.title, sf.language",
"describe": {
"columns": [
{
"name": "movie_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "poster_path"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "69f34468a61979b85b7513056d58e34c743af3ae62d5327d4dea49fe7586a85b"
}
@@ -0,0 +1,158 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.poster_path,\n e.id AS \"episode_id!: i64\", se.number AS \"season_number!: i64\",\n e.number AS \"episode_number!: i64\", mf.id AS \"media_file_id!: i64\",\n sa.language AS \"language!: String\",\n sa.state AS \"state!: arr_db::SubtitleState\", sa.last_failure\n FROM subtitle_attempts sa\n JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'episode'\n JOIN episodes e ON e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE sa.state IN ('failed', 'capped', 'unavailable')\n ORDER BY s.title, se.number, e.number, sa.language",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "poster_path"
}
}
},
{
"name": "episode_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode_number!: i64",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 8,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "language"
}
}
},
{
"name": "state!: arr_db::SubtitleState",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "state"
}
}
},
{
"name": "last_failure",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_failure"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "6cdb9a9344bbab557433da35471968b6e5cf96b5b220ffb41f10b6d851884803"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_budget_spend (kind, name, day, spent)\n SELECT ?1, ?2, strftime('%Y-%m-%d', 'now'), ?3\n WHERE ?3 <= ?4\n ON CONFLICT (kind, name, day) DO UPDATE\n SET spent = subtitle_budget_spend.spent + excluded.spent\n WHERE subtitle_budget_spend.spent + excluded.spent <= ?4\n RETURNING spent AS \"spent!: i64\"",
"describe": {
"columns": [
{
"name": "spent!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_budget_spend",
"name": "spent"
}
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false
]
},
"hash": "6e27f3a4c642ac324ef5ae2017a54b93f1d95aa54c46ff7e169278a295d1a2cc"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -58,5 +58,5 @@
false
]
},
"hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86"
"hash": "6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM subtitle_attempts WHERE language = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "74178ac3ec41aa8a5ce164103a8d8301cb555eea2fa5d655a7365fd02c557cce"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT wanted_languages AS \"wanted_languages!: String\",\n providers_enabled AS \"providers_enabled!: String\",\n translation_engine AS \"translation_engine: String\",\n provider_daily_budgets AS \"provider_daily_budgets!: String\",\n translator_daily_budgets AS \"translator_daily_budgets!: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "wanted_languages!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "wanted_languages"
}
}
},
{
"name": "providers_enabled!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "providers_enabled"
}
}
},
{
"name": "translation_engine: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translation_engine"
}
}
},
{
"name": "provider_daily_budgets!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "provider_daily_budgets"
}
}
},
{
"name": "translator_daily_budgets!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translator_daily_budgets"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false,
false
]
},
"hash": "77e55f2aa534468cf6e599d0c01679653553a68f2f1b9b5516f5d2fb42974b05"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"describe": {
"columns": [
{
@@ -134,6 +134,12 @@
"name": "rejected_rule"
}
}
},
{
"name": "blacklist_reason?: String",
"ordinal": 12,
"type_info": "Null",
"origin": "Expression"
}
],
"parameters": {
@@ -151,8 +157,9 @@
false,
true,
true,
true,
true
]
},
"hash": "aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f"
"hash": "7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT remote_command_timeout_seconds AS \"remote_command_timeout_seconds!: i64\",\n openai_base_url AS \"openai_base_url: String\",\n openai_model AS \"openai_model: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "remote_command_timeout_seconds!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "remote_command_timeout_seconds"
}
}
},
{
"name": "openai_base_url: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "openai_base_url"
}
}
},
{
"name": "openai_model: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "openai_model"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
true,
true
]
},
"hash": "7c501e25106ea8d4ad9f28ca904c446551c6afd68d85e2231742bebd215a1a4b"
}
@@ -0,0 +1,110 @@
{
"db_name": "SQLite",
"query": "SELECT wanted_languages AS \"wanted_languages!: String\",\n providers_enabled AS \"providers_enabled!: String\",\n translation_engine AS \"translation_engine: String\",\n provider_daily_budgets AS \"provider_daily_budgets!: String\",\n translator_daily_budgets AS \"translator_daily_budgets!: String\",\n remote_command_timeout_seconds AS \"remote_command_timeout_seconds!: i64\",\n openai_base_url AS \"openai_base_url: String\",\n openai_model AS \"openai_model: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "wanted_languages!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "wanted_languages"
}
}
},
{
"name": "providers_enabled!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "providers_enabled"
}
}
},
{
"name": "translation_engine: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translation_engine"
}
}
},
{
"name": "provider_daily_budgets!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "provider_daily_budgets"
}
}
},
{
"name": "translator_daily_budgets!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translator_daily_budgets"
}
}
},
{
"name": "remote_command_timeout_seconds!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "remote_command_timeout_seconds"
}
}
},
{
"name": "openai_base_url: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "openai_base_url"
}
}
},
{
"name": "openai_model: String",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "openai_model"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false,
false,
false,
true,
true
]
},
"hash": "82bb36910db14d5471117285ef2d3a9f8efefc5d400a8f94e1f4aed321284504"
}
@@ -0,0 +1,134 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.poster_path,\n e.id AS \"episode_id!: i64\", se.number AS \"season_number!: i64\",\n e.number AS \"episode_number!: i64\", mf.id AS \"media_file_id!: i64\",\n sf.language AS \"language!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'episode'\n JOIN episodes e ON e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE sf.sync_rejected = 1\n ORDER BY s.title, se.number, e.number, sf.language",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "poster_path"
}
}
},
{
"name": "episode_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode_number!: i64",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 8,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
false
]
},
"hash": "8543e4ad8c59e83911688ceace72f66d360240a45eee05aaf15dab233e22e7e6"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM media_files WHERE path = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "8651688f9f999629b4996c0451f87f5a84fabe748849b4b80063af1c18f73917"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE subtitle_files\n SET synced = ?, sync_rejected = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "874079e2ed732468a1f249e6aa026753c78042e164d11eb7cc014c9500211b64"
}
@@ -0,0 +1,170 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\",\n media_file_id AS \"media_file_id!: i64\",\n language AS \"language!: String\",\n origin AS \"origin!: SubtitleOrigin\",\n provider,\n candidate_id,\n engine,\n forced AS \"forced!: bool\",\n sdh AS \"sdh!: bool\",\n synced AS \"synced!: bool\",\n sync_rejected AS \"sync_rejected!: bool\",\n skeleton_aligned AS \"skeleton_aligned!: bool\",\n path\n FROM subtitle_files\n WHERE media_file_id = ?\n ORDER BY language, id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "media_file_id"
}
}
},
{
"name": "language!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
},
{
"name": "origin!: SubtitleOrigin",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "origin"
}
}
},
{
"name": "provider",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "provider"
}
}
},
{
"name": "candidate_id",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "candidate_id"
}
}
},
{
"name": "engine",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "engine"
}
}
},
{
"name": "forced!: bool",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "forced"
}
}
},
{
"name": "sdh!: bool",
"ordinal": 8,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "sdh"
}
}
},
{
"name": "synced!: bool",
"ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "synced"
}
}
},
{
"name": "sync_rejected!: bool",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "sync_rejected"
}
}
},
{
"name": "skeleton_aligned!: bool",
"ordinal": 11,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "skeleton_aligned"
}
}
},
{
"name": "path",
"ordinal": 12,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "8abb1e992e0972a19e76c7788c8c350951a1d14766088b1cf3fb886fb658d575"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"describe": {
"columns": [
{
@@ -134,6 +134,12 @@
"name": "rejected_rule"
}
}
},
{
"name": "blacklist_reason?: String",
"ordinal": 12,
"type_info": "Null",
"origin": "Expression"
}
],
"parameters": {
@@ -151,8 +157,9 @@
false,
true,
true,
true,
true
]
},
"hash": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e"
"hash": "8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_skeletons (media_file_id, stream_index, language, cues)\n VALUES (?, ?, ?, ?)\n ON CONFLICT (media_file_id, stream_index) DO UPDATE SET\n language = excluded.language,\n cues = excluded.cues",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "8e3c23ed488bc82ff3dd4f6da02d00435525d321b6002b9f31979a39281369ce"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM subtitle_files WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "90480a6b6e724d7e4d05239799ad2f586c57deea100320fe433da007396f1e48"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT media_file_id AS \"media_file_id!: i64\",\n language AS \"language!: String\",\n path\n FROM subtitle_files WHERE id = ?",
"describe": {
"columns": [
{
"name": "media_file_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "media_file_id"
}
}
},
{
"name": "language!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
},
{
"name": "path",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true
]
},
"hash": "9091de460ba1cb47c9faa24fe37fee03615e7b23b10ceef88ee12c8deb0dbaaf"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE s.root_id = ? AND sf.path IS NOT NULL\n ORDER BY sf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "92a3d21df1ee60492a1e0742f1425c73a16ee7dd38bfdf2d7166db95d663dbf4"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT normalised_name AS \"normalised_name!: String\", infohash FROM blacklist",
"query": "SELECT normalised_name AS \"normalised_name!: String\",\n infohash,\n reason AS \"reason!: String\"\n FROM blacklist\n ORDER BY id",
"describe": {
"columns": [
{
@@ -24,6 +24,17 @@
"name": "infohash"
}
}
},
{
"name": "reason!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "blacklist",
"name": "reason"
}
}
}
],
"parameters": {
@@ -31,8 +42,9 @@
},
"nullable": [
false,
true
true,
false
]
},
"hash": "071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97"
"hash": "939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE subtitle_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "9b129ff8a5951c9dfcb9778da0174ac66d4307936323b1eff9440adb4c34b893"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
"describe": {
"columns": [
{
@@ -134,6 +134,12 @@
"name": "rejected_rule"
}
}
},
{
"name": "blacklist_reason?: String",
"ordinal": 12,
"type_info": "Null",
"origin": "Expression"
}
],
"parameters": {
@@ -151,8 +157,9 @@
false,
true,
true,
true,
true
]
},
"hash": "f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba"
"hash": "9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT spent AS \"spent!: i64\" FROM subtitle_budget_spend\n WHERE kind = ? AND name = ? AND day = strftime('%Y-%m-%d', 'now')",
"describe": {
"columns": [
{
"name": "spent!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_budget_spend",
"name": "spent"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "9ee9ff74406e333df50076d1c456f0a9672173af252f36e5d75d2a9df99ab68d"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id\n WHERE m.root_id = ? AND sf.path IS NOT NULL\n ORDER BY sf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "a1256d6eec88a6ce5c308a7b96d95b8b43100b1f4d8ac4cabc7c3bef9fefd745"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
"describe": {
"columns": [
{
@@ -34,5 +34,5 @@
false
]
},
"hash": "89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc"
"hash": "a2023f024d53aa5f139da347f22af38d57c6f846bb8eff74958fd4d1299268f8"
}
@@ -1,10 +1,10 @@
{
"db_name": "SQLite",
"query": "SELECT id FROM roots WHERE path = ? AND id <> ?",
"query": "SELECT id AS \"id!: i64\" FROM roots WHERE policy_id = ?",
"describe": {
"columns": [
{
"name": "id",
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
@@ -16,11 +16,11 @@
}
],
"parameters": {
"Right": 2
"Right": 1
},
"nullable": [
false
]
},
"hash": "fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59"
"hash": "a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562"
}
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT media_file_id AS \"media_file_id!: i64\",\n language AS \"language!: String\",\n state AS \"state!: SubtitleState\",\n attempts AS \"attempts!: i64\",\n last_attempt_at,\n last_failure\n FROM subtitle_attempts\n WHERE media_file_id = ?\n ORDER BY language",
"describe": {
"columns": [
{
"name": "media_file_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "media_file_id"
}
}
},
{
"name": "language!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "language"
}
}
},
{
"name": "state!: SubtitleState",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "state"
}
}
},
{
"name": "attempts!: i64",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "attempts"
}
}
},
{
"name": "last_attempt_at",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_attempt_at"
}
}
},
{
"name": "last_failure",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_failure"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
true
]
},
"hash": "adf45101bc3ba20d35b59794053029e89d22ada5c7ffecd47187ec34261bc223"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT probed FROM media_files WHERE id = ?",
"describe": {
"columns": [
{
"name": "probed",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "probed"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "af10ccd655f39235f525a531e38850b2e60ff713a038ee1e7c04c616b4613b52"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n WHERE e.season_id = ? AND sf.path IS NOT NULL",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "b2d937e5b8381de44fc6c1915e3deb24236c2c8afa25ffec1c7bbe519aac8803"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT providers_enabled AS \"providers_enabled!: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "providers_enabled!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "providers_enabled"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "bc6dfe5a5cc639a64e3020c348a2dc74b4298101f8c29d9f8e6397cf79bb83d7"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM roots\n WHERE CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END = ?\n AND (? IS NULL OR id <> ?)",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false
]
},
"hash": "bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO subtitle_attempts (media_file_id, language, state)\n VALUES (?, ?, 'satisfied')\n ON CONFLICT (media_file_id, language) DO UPDATE\n SET state = 'satisfied',\n last_failure = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "c5a9e43cfeb315e70bbb603f22acc519d7df3cd0b4d606d0d9056414ac6c503b"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE grabs SET state = 'failed' WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"describe": {
"columns": [
{
@@ -24,5 +24,5 @@
true
]
},
"hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
"hash": "c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM series WHERE root_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM movies WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "d81ec62e6c4879d08ce3551c0c71df012bed05ac2a37933adc2deb6552eeeac1"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -82,5 +82,5 @@
false
]
},
"hash": "44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42"
"hash": "dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT path AS \"path!: String\" FROM subtitle_files\n WHERE media_file_id = ? AND language = ? AND path IS NOT NULL",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
true
]
},
"hash": "e17b71f67d8b79c54d2d6f32d5e4197d77faac1763b360e8e609b0b9ba5820f1"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM subtitle_files WHERE path = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "e3b82dc7b2a3b28753c84864239699cb2007a8101cccc22d223d1f587a5533f8"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM subtitle_files WHERE path = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "e5b075d0b7fe5410d3e55d5daf7837d0a9398b1954e713e80a1d82c0a7e172a6"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM grabs\n WHERE (target_kind = 'season'\n AND target_id IN (SELECT id FROM seasons WHERE series_id = ?))\n OR (target_kind = 'episode'\n AND target_id IN (SELECT e.id FROM episodes e\n JOIN seasons s ON s.id = e.season_id\n WHERE s.series_id = ?))",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e6fbef670552928dfeeb6f6d442021340acb438a1ed6a7b917be91c9d368cb57"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT translation_engine AS \"translation_engine: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "translation_engine: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translation_engine"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
true
]
},
"hash": "e7a0291c2fb8cfe59a02ffef11d9657ea6a40e4079573f569573075a205c50e7"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM media_files WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "e8066bed6fb53ff90d78f47bb1c9ce8160918d4b35764fd988e5e1dd6c101089"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE subtitle_settings SET\n wanted_languages = ?, providers_enabled = ?, translation_engine = ?,\n provider_daily_budgets = ?, translator_daily_budgets = ?,\n remote_command_timeout_seconds = ?,\n openai_base_url = ?, openai_model = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = 1",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "eab2c4e7c21cd5d22a583c36dd2d79590e9ce55801c4b87b8db1b90f7b317225"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT stream_index AS \"stream_index!: i64\",\n language AS \"language!: String\",\n cues AS \"cues!: String\"\n FROM subtitle_skeletons\n WHERE media_file_id = ?\n ORDER BY stream_index",
"describe": {
"columns": [
{
"name": "stream_index!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_skeletons",
"name": "stream_index"
}
}
},
{
"name": "language!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_skeletons",
"name": "language"
}
}
},
{
"name": "cues!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_skeletons",
"name": "cues"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "eb7df52055a60a00b721e3eef152e1b04a8512a85e8ad3618545c634c23abf31"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"media_file_id!: i64\", e.id AS \"episode_id!: i64\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?\n ORDER BY mf.path",
"describe": {
"columns": [
{
"name": "media_file_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "episode_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "f4c3eb506289667ace56da5d28c046163d5f12bd1dca53d3d6fc559f6e9ce393"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n WHERE mf.owner_kind = 'episode' AND mf.owner_id = ? AND sf.path IS NOT NULL",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "fa35de1c7e62c68dd1049d7e7e08c825d3ca31eb631744a57594aa2fc08fb945"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM media_files\n WHERE owner_kind = ? AND owner_id = ? ORDER BY path",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "fcba9e1f708e6bf2d8471d0c7d26be0b5ad8a30fa042434ffaaff8c69ccf31de"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM subtitle_files\n WHERE media_file_id = ? AND language = ? AND origin = 'embedded'\n AND forced = ? AND sdh = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "id"
}
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false
]
},
"hash": "fe15c4a3774e663b03455300d0baec1dadc6cf995c20df803bc4bf49d1cc7c77"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE grabs\n SET state = 'failed',\n failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1"
}
+5 -3
View File
@@ -16,8 +16,9 @@ crates/
├── arr-parse/ release name parsing (no IO)
├── arr-meta/ TMDB client
├── arr-indexer/ Torznab via Prowlarr
├── arr-dl/ Transmission RPC
├── arr-dl/ qBittorrent WebUI API
├── arr-probe/ ffprobe wrapper
├── arr-subs/ subtitle providers, translation, sync
├── arr-db/ sqlx + migrations
├── arr-api/ axum + OpenAPI
├── arr-compat/ Radarr/Sonarr v3 shim for Jellyseerr
@@ -243,8 +244,9 @@ mid-issue.
- **Prowlarr** — `prowlarr` container on the Dokploy host, port 9696. Owns
tracker auth, FlareSolverr and the Cardigann definitions. Not replaced.
- **Transmission** — native in LXC 130 at `10.6.10.45:9091`, RPC
unauthenticated. Download dir `/mnt/media/transmission/complete`.
- **qBittorrent** — `qbittorrent.n62.casa`, WebUI API v2, login required
(`ARR_QBITTORRENT_USERNAME` / `ARR_QBITTORRENT_PASSWORD`). Download dir
`/mnt/media/qbittorrent/complete`.
- **Jellyfin** — native in LXC at `10.6.10.18:8096`. Library roots under
`/mnt/media-v2`.
- **Media** — ZFS, single dataset, bind-mounted as `/mnt/media`. Downloads and
Generated
+61 -3
View File
@@ -32,9 +32,12 @@ version = "0.1.0"
dependencies = [
"arr-core",
"arr-db",
"arr-dl",
"arr-indexer",
"arr-meta",
"arr-parse",
"arr-probe",
"arr-subs",
"axum",
"chrono",
"reqwest",
@@ -88,8 +91,8 @@ dependencies = [
"arr-meta",
"arr-parse",
"arr-probe",
"arr-subs",
"axum",
"base64",
"chrono",
"include_dir",
"mime_guess",
@@ -119,16 +122,17 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "arr-dl"
version = "0.1.0"
dependencies = [
"base64",
"reqwest",
"serde",
"serde_json",
"sha1 0.10.7",
"thiserror",
"tokio",
"url",
@@ -139,12 +143,14 @@ dependencies = [
name = "arr-e2e"
version = "0.1.0"
dependencies = [
"arr-db",
"arr-dl",
"arr-indexer",
"arr-meta",
"chrono",
"reqwest",
"serde_json",
"sqlx",
"tempfile",
"tokio",
"uuid",
@@ -194,11 +200,30 @@ dependencies = [
"arr-core",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "arr-subs"
version = "0.1.0"
dependencies = [
"arr-core",
"arr-parse",
"chardetng",
"encoding_rs",
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tokio",
"tracing",
"wiremock",
]
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -366,6 +391,17 @@ dependencies = [
"rand_core",
]
[[package]]
name = "chardetng"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53"
dependencies = [
"cfg-if",
"encoding_rs",
"memchr",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -531,6 +567,15 @@ dependencies = [
"serde",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1498,6 +1543,7 @@ dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -1506,6 +1552,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -1688,6 +1735,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha1"
version = "0.11.0"
@@ -1887,7 +1945,7 @@ dependencies = [
"log",
"percent-encoding",
"serde",
"sha1",
"sha1 0.11.0",
"sha2 0.11.0",
"sqlx-core",
"thiserror",
+5 -2
View File
@@ -20,13 +20,13 @@ arr-indexer = { path = "crates/arr-indexer" }
arr-meta = { path = "crates/arr-meta" }
arr-parse = { path = "crates/arr-parse" }
arr-probe = { path = "crates/arr-probe" }
arr-subs = { path = "crates/arr-subs" }
# Runtime and HTTP
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
tower-http = { version = "0.6", features = ["trace"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
base64 = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
# Persistence
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono", "json"] }
@@ -47,8 +47,11 @@ quick-xml = { version = "0.37", features = ["serialize"] }
toml = "0.8"
# Odds and ends
chardetng = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5", features = ["derive", "env"] }
encoding_rs = "0.8"
sha1 = "0.10"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+293 -24
View File
@@ -27,7 +27,7 @@ allowed to be narrow.
Explicitly out of scope, permanently unless stated:
- **Authentication.** The perimeter is a VPN plus Authelia at the proxy. The
service binds without auth, same trust model as the existing Transmission RPC.
service binds without auth, same trust model as the rest of the stack.
- **Library migration or filesystem scan.** The service knows only what it put
on disk. Adopting the pre-existing library, if ever wanted, is a one-off
script against both APIs, not a feature.
@@ -46,7 +46,7 @@ Explicitly out of scope, permanently unless stated:
```
┌──────────────┐
TMDB ──────▶│ │
│ arr │────▶ Transmission RPC 10.6.10.45:9091
│ arr │────▶ qBittorrent WebUI qbittorrent.n62.casa
Prowlarr ──────▶│ │
(Torznab) │ (this) │────▶ ffprobe local subprocess
│ │
@@ -61,8 +61,9 @@ Everything already exists except `arr`. Prowlarr keeps owning tracker auth,
Cloudflare bypass via FlareSolverr, rate limiting and the Cardigann
definitions — replacing it buys nothing.
Transmission runs natively in its own LXC (VMID 130, `10.6.10.45`), RPC
unauthenticated, download dir `/mnt/media/transmission/complete`.
qBittorrent is reached at `qbittorrent.n62.casa`, WebUI API v2, download dir
`/mnt/media/qbittorrent/complete`. Its WebUI requires a login, so arr carries
credentials — the one upstream that does.
## 4. Domain model
@@ -174,6 +175,13 @@ not yet found, violet (`--status-airing`) for downloading, neutral for
untracked. The word stays present in every chip, so state survives with colour
removed.
One state is exempt: a file being on disk is drawn as a check glyph rather
than the word `available`, with the word on the chip's `aria-label`. A drawn
check is a shape, not only a hue, so it survives the greyscale read the rule
exists to protect. The exemption is that narrow — every other state, and every
derived-status chip above, keeps its word, because no unambiguous glyph stands
in for `incomplete` or `waiting`.
Season 0 is invisible to all of it: derived status ignores season 0 episodes
entirely, so a manually wanted special cannot pin a series at `incomplete` or
hold back `ended`. The accepted consequence is that specials are visible and
@@ -199,6 +207,26 @@ fake profile per root and ignoring whatever it sends.
Per-title `overrides` relax the root policy for one title. Same mechanism in
both directions — `only_4k` tightens, `allow_english_audio` loosens.
**Stored verdicts follow the effective policy.** A release's verdict is
stamped by the search that found it, and both §9.3's deck and the daemon's
manual-grab gate read that stored column. So the four operator actions that
change a title's effective policy — editing its overrides, moving it to a
root with a different policy, pointing a root at a different policy, and
editing the contents of a policy some root points at — re-derive the stored
verdicts of everything they touch, inline in the same request. The operator
is never left reading a verdict computed under a policy that no longer
applies.
The fourth is the widest: a repoint moves one library, a policy edit moves
every library sharing the policy. Inline still holds there. Re-evaluation is
pure and in-memory, a row whose verdict does not move is not rewritten, and
the ceiling is the whole database rather than something that grows with the
number of roots — roots partition titles, and a title has exactly one root.
Measured over 2000 titles and 10 000 stored releases split across two roots
sharing one policy: 0.36 s when the edit moves no verdict, 2.7 s in the
pathological case where it moves all 10 000. A rename changes no rule and
re-derives nothing.
### 5.2 Language
Requires a concept the release name does not carry: the **original language of
@@ -385,6 +413,13 @@ happened within the last 30 days. One bad torrent is not a decision — a
release that hard-failed is blacklisted (§6.3) and the next candidate is
grabbed, which is the system working.
The window runs from the failure, not the grab. The two are usually minutes
apart, but a torrent can sit stalling on a dead swarm for five weeks before
`ffprobe` finally condemns it — and that failure is fresh evidence the target
is broken now, not history. Measured from the grab it would be born outside
the window and a genuinely broken target could never surface. So `grabs`
records `failed_at` alongside `grabbed_at`, and the window reads it.
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
without it the queue only ever grows and the one season that wants attention
sits behind eight that were dealt with months ago. It is the queue's version of
@@ -395,6 +430,22 @@ guard retries at worst weekly (§6.2) — and stays queued for exactly as long a
it is genuinely broken. Nothing is dismissed by hand and no acknowledgement
state is stored, so there is no second thing to keep correct.
**Only a target still waiting for a file is queued.** The count and the
window already express one rule — bounded attention — and this is another
face of it: a failure history queues a target only while that target still
has a gap to fill. A movie or an episode is queued while it is `wanted` and
not `available`. A season holds no intent of its own (§4.1), so it is queued
while at least one of its episodes is still `wanted` and not `available`. A
season pack that hard-failed twice, fell back to per-episode grabbing
exactly as §6.2 says it should, and was then fully acquired leaves at once
rather than waiting out the 30 days — that is the system working, not a
decision. A target that is still broken keeps producing failures and stays.
The same rule applies on all three lanes and in both readers.
`GET /api/queues/attention` (§9.3) and the `ntfy` notification (§9.5) are two
views of one queue; filtering differently tells the operator two different
stories on two channels.
## 6. Sourcing
### 6.1 Prowlarr, per-indexer Torznab
@@ -437,6 +488,22 @@ its own, the escape hatch is the season release deck, not a lane exception.
Targeted search backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when the
title's metadata changes. It never gives up entirely, it goes quiet.
**The ladder runs from the failure, not the grab.** A failed season-pack grab
quiets the pack lane on that same curve, and the rung is measured from the
moment the grab entered `failed``grabs.failed_at`, the column §5.7's
attention window reads — not from when it was sent. The two are usually
minutes apart, but a torrent can stall on a dead swarm for five weeks before
`ffprobe` condemns it at import. Measured from the grab, the whole ladder has
already elapsed by the time the failure lands, so the lane retries the source
that just failed at once, which is the one thing the backoff exists to
prevent. The ladder's job is to stay off a source that has recently failed,
and "recently" can only mean recently failed.
One anchor covers both features. §5.7's window and this ladder ask the same
question of the same event and read the same column; a target still broken
keeps producing fresh failures, and each one both re-arms this backoff and
holds the target in the attention queue.
**Do not search before the release exists.** TMDB carries release dates; a
movie with no digital release date gets zero targeted searches. This is the
single largest source of wasted queries in Radarr and it is free to avoid.
@@ -451,11 +518,20 @@ leaving RSS matching on.
## 7. Download and import
### 7.1 Transmission
### 7.1 qBittorrent
RPC at `10.6.10.45:9091`, no credentials. Labels are `movies-main`, `tv-kids`
and so on — enough to find things in Transmission's own UI, and distinct from
Radarr's existing `radarr`/`sonarr` labels so both stacks can run side by side.
WebUI API v2 at `qbittorrent.n62.casa`. Labels are qBittorrent **tags**
`movies-main`, `tv-kids` and so on — enough to find things in qBittorrent's own
UI, and distinct from Radarr's existing `radarr`/`sonarr` labels so both stacks
can run side by side. Tags rather than a category because a category also
governs the save path, and arr owns that.
`torrents/add` answers `Ok.` and nothing else: no hash, no name, and no signal
that the torrent was already there. arr therefore derives the v1 infohash from
the magnet or the `.torrent` bytes before the call, and looks the torrent up by
it. That is also what makes an add idempotent across a restart (§8) — the same
release resolves to the same hash, and a second add is recognised as the
duplicate it is.
### 7.2 Hardlink
@@ -474,9 +550,16 @@ at 4K is 40-80 GB per title.
The torrent and the library entry are separate state machines.
Seeding obligation is **per tracker**, configured locally because Prowlarr does
not expose tracker rules — `ratio` and `min_seed_time`. Set `seedRatioLimit`
and `seedIdleLimit` on the torrent at add time and let Transmission enforce
them. A reaper deletes torrents Transmission reports as done seeding.
not expose tracker rules — `ratio` and `min_seed_time`. Set `ratioLimit` and
`inactiveSeedingTimeLimit` on the torrent at add time, with
`shareLimitAction` set to stop rather than delete, and let qBittorrent enforce
them. A reaper deletes torrents qBittorrent stopped on a limit arr set.
Stopped-on-a-limit, not merely stopped: a torrent the operator paused by hand
looks identical in the state field alone, and the reaper deletes data. So the
ratio and idle limits are checked against the torrent's own counters, and a
torrent stopped by a *global* limit reads as still seeding — the safe direction
to be wrong in.
Consequently a hard-failed release is blacklisted and never imported, but its
torrent keeps seeding until the obligation clears. Nothing is deleted early to
@@ -505,7 +588,12 @@ Media kind first, hard audience boundary second, people nowhere.
- **Release group is deliberately absent.** It is not a selection criterion and
it makes filenames long enough to break a terminal.
Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. Changing a root's path is the same move over every title under it, and it is all or nothing: one folder that cannot move puts back the ones that already did and leaves the root's path alone, so the stored path always describes the disk.
Changing a title's root relocates its title folder into the new root; roots
are assumed to share one filesystem, so the move is a rename, never a copy.
Changing a root's path is the same move over every title under it, and it
is all or nothing: one folder that cannot move puts back the ones that
already did and leaves the root's path alone, so the stored path always
describes the disk.
During transition, write into the existing roots so Jellyfin needs no
reconfiguration and new content appears immediately. Radarr will not touch a
@@ -528,11 +616,12 @@ state and act on the gap.
This is idempotent and crash-safe by construction. Kill the process mid-grab and
the next tick recomputes the same gap and continues. A job table would need
retry counts, dead-lettering and reconciliation against Transmission anyway,
because Transmission is an external system that changes underneath the app.
retry counts, dead-lettering and reconciliation against qBittorrent anyway,
because qBittorrent is an external system that changes underneath the app.
Transient state — a search in flight, download progress — is in memory and
rebuilt from Transmission on startup.
rebuilt from qBittorrent on startup. Where that state is ever seen is §9.8:
inline on the row that owns the item, never persisted.
Ticks are staggered: reconcile every 30 s, RSS every 10 min, metadata refresh
daily, reaper every 5 min.
@@ -615,8 +704,8 @@ notifying on everything and being muted within a week.
- **Imported** → to the title's owners. The only good-news notification.
- **Needs a decision** → to the operator alone. Entered the no-PT-source queue,
or hard-failed twice on different releases.
- **Broken** → to the operator alone. Prowlarr, Transmission or TMDB
or the needs-a-decision queue (§5.7).
- **Broken** → to the operator alone. Prowlarr, qBittorrent or TMDB
unreachable, disk full.
Not notified: grabs, searches, downloads starting or finishing, soft fails.
@@ -670,7 +759,7 @@ inside the app, review text.
opens on.
**The signal chain is a settings section.** The four upstreams — tmdb,
prowlarr, arr, transmission — and their lamps live inside `/settings`, and have
prowlarr, arr, qbittorrent — and their lamps live inside `/settings`, and have
no route of their own. Per-upstream health is something you check when
something is wrong, not a homepage.
@@ -679,6 +768,37 @@ is healthy survives the move and no navigation is needed to get it.
**The wordmark is a link home.** `arr` on the rail navigates to `/`.
**The rail composition is fixed.** From left to right it carries the master
lamp and wordmark, a warning mark only while queues need attention, the centred
search, the version readout and the settings cog.
### 9.8 Downloads on the row
§8 keeps download progress as transient state; nothing in §9 so far says where
it is seen. Without a rule here, a season pack downloading and one that never
started look identical.
**Progress is inline, on the row that owns the item** — a movie row, a season
row for its pack, an episode row. There is no downloads page and no count in
the rail: a download is an attribute of the thing being downloaded, not a place
to visit.
Alongside progress the same row carries the other states a torrent can be in:
**seeding** under §7.3's obligation, **stalled**, **errored**.
**A torrent arr did not grab is never shown.** §2 already rules the service
knows only what it put on disk; qBittorrent's own UI lists the rest.
Nothing is persisted — no progress column, no new table. The snapshot comes
from qBittorrent and dies with the process (§8), refreshed by the UI's normal
polling cadence at roughly 15 s rather than SSE.
At phone width only active-grab progress survives; seeding and stalled shed
with the row's other secondary chips.
Out of scope here: any change to what the reconcile loop does, and any new
state machine — §7.3's two lifecycles stay as they are.
## 10. Persistence
SQLite via `sqlx`, compile-time-checked queries, migrations in `arr-db`.
@@ -688,8 +808,10 @@ service.
Policy lives in the database, not a config file — size targets and DV rules get
tuned by hand during testing and a restart-to-reload loop gets old immediately.
Only bootstrap settings (bind address, Prowlarr URL, Transmission URL, TMDB key,
media root, TMDB response cache directory) come from config/env.
Only bootstrap settings (bind address, Prowlarr URL, qBittorrent URL and
login, TMDB key, media root, TMDB response cache directory) come from
config/env. The qBittorrent password is a secret, so it is env-only and has no
config-file field.
Backup is `sqlite3 .backup` on a timer.
@@ -703,8 +825,9 @@ arr-core domain types, policy engine, scoring no IO, no heavy deps
arr-parse release name parsing no IO
arr-meta TMDB client
arr-indexer Torznab via Prowlarr
arr-dl Transmission RPC
arr-dl qBittorrent WebUI API
arr-probe ffprobe wrapper
arr-subs subtitle providers, translation, sync
arr-db sqlx + migrations
arr-api axum + OpenAPI
arr-compat Radarr/Sonarr v3 shim for Jellyseerr
@@ -759,8 +882,10 @@ and `target/`, keyed on `Cargo.lock` plus `rust-toolchain.toml`. Never build
- Prowlarr and TMDB — `wiremock` with recorded real responses as fixtures.
Never live: trackers rate-limit, and it would leak credentials into CI.
- Transmission — a real container as a CI service. RPC semantics are the most
likely source of surprise and it starts in a second.
- qBittorrent — a real container, started with a seeded config because the
image prints a random WebUI password per boot. Its API semantics are the most
likely source of surprise: `torrents/add` reports nothing back, and required
parameters have changed between major versions.
- `ffprobe` — tiny committed clips, a few KB each. The Jellyfin LXC already has
the right fixtures at `/srv/jellyfin-test`, including a real **DV Profile 5**
clip. That is the test proving the policy engine rejects Profile 5 and accepts
@@ -787,7 +912,7 @@ previous one.
grabbing, derived status.
7. **Owners and notifications** — tags, per-person ntfy topics, filtered views.
8. **Jellyseerr compat**`arr-compat`.
9. **Subtitles** — replaces Bazarr. Out of scope for this document.
9. **Subtitles** — replaces Bazarr. See §15.
Movies before TV because TV adds season packs, air-date calendars and
per-episode state on top of an otherwise identical pipeline. Doing it second
@@ -807,3 +932,147 @@ means that pipeline is already proven.
RSS lane obeys the same guard — it skips packs for any season with episodes
on disk. Whether a re-grab is ever wanted remains unresolved and deliberately
deferred.
## 15. Subtitles
Replaces Bazarr. Phase 9 in §13; the `arr-subs` crate in §11.
**Wanted set.** Global, not per root. Two languages are separately wanted for
every media file: Portuguese — pt-PT preferred, pt-BR accepted — and English.
A file is satisfied for a language when a subtitle in it exists, embedded or as
a sidecar. Satisfying a language is a statement about *viewing* it, and not
about having text in it — the distinction matters where an image track is all
there is, and **Translation** below is where it bites. This is deliberately unlike §5.2's audio rules, which attach to a
root: subtitles carry no blacklist there and none here. pt-BR subtitles are
always fine.
**Embedded tracks.** An embedded subtitle track satisfies its language.
Text-format tracks (`subrip`, `ass`, `mov_text`) are additionally extracted to
a sidecar SRT, because an extracted track is a legal translation source.
Image-format tracks (PGS on BluRay, VobSub on DVD) carry bitmaps, not text:
they satisfy viewing but can never feed a translator, and arr does not OCR
them. `arr-probe` already reports subtitle tracks with resolved languages; the
format is the new fact it must carry.
**Cue skeletons.** What an image track *does* have is exact timings, because
its packet timestamps are the disc's own cue structure. At import — while the
file is being read and hardlinked anyway — arr derives a **cue skeleton** from
each non-forced image track: `ffprobe -show_packets` on the track, packets
paired show-to-clear, no pixel read. The skeleton has timings and no text, and
that is enough, because `alass` matches on interval structure rather than on
words. A sparse skeleton is still a strong reference; a downloaded subtitle and
a retail disc's track never carry the same cues anyway.
The pairing is the whole of it, so it is validated before it is trusted: an
even packet count, every implied duration plausible, and a cue density that
fits the runtime. PGS permits several composition segments per subtitle, and a
track built that way pairs into something that is quietly half a second out —
worse than no skeleton. A track that fails validation gets none, and alignment
falls back to the video.
**Providers.** OpenSubtitles.com, behind one trait.
**Ranking.** A `moviehash` match wins outright. Then an exact release-name
match, then same release group or same source, then uploader rating and
download count as tiebreakers. Every rejected candidate names the rule that
killed it, so the manual view described in §9.3 works unchanged for subtitles.
**Forced and SDH.** A forced track covers only foreign-language lines and
on-screen signs; it never satisfies a want and arr never goes looking for one.
An SDH track is complete and satisfies, ranked below a plain subtitle.
Neither gets its own sidecar name. A language is satisfied by exactly one
sidecar, so `<video>.<lang>.srt` needs no segment distinguishing forced from
plain from SDH — where a plain subtitle exists for a language, the forced one
is ignored rather than kept beside it. The database says the same thing: one
sidecar row per (media file, language).
**Translation.** When no provider has a wanted language, arr translates
immediately — there is no waiting window. The source is an existing subtitle:
a downloaded one, or one extracted from a text-format embedded track. Being
able to translate from an embedded track is a deliberate improvement on
Bazarr, which cannot.
**Fetching a source.** A release whose only subtitle is an image track has no
text source and never will: the track satisfies its language, so nothing is
ever fetched in it, and the track itself cannot be translated from. That is a
deadlock, and it is broken by a narrow carve-out — when a wanted language needs
translating and no text source exists, arr fetches a text subtitle in the image
track's language *even though that language reads as satisfied*. The fetch
obtains a source; it settles no want of its own, and the no-upgrade rule below
does not apply to it. Where that track has a cue skeleton, the fetched subtitle
is aligned against the skeleton rather than the video, which puts it on the
disc's own timings instead of on a heuristic read of the audio. OCR remains a
non-goal: this reaches the same place with real text.
**Translation backends.** Pluggable, each behind its own cargo feature: an
OpenAI-compatible HTTP endpoint, DeepL, Google Translate, and a generic remote
command driven by a configured template (`ssh box claude -p` is one instance
of that template, not a backend of its own). The engine in use is a database
setting, so switching does not need a rebuild when the feature is compiled in.
Subtitles are sent in batches of cues; a reply whose cue count or numbering
does not match the batch is rejected. Timing data never leaves arr.
**No upgrade loop.** Once a language is satisfied — by a machine translation
too — arr stops working on it. A real subtitle appearing later does not
replace anything. Replacement is a manual action from the UI. This is §5.4's
rule applied to subtitles.
The one exception is the source fetch above. It is not an upgrade: the language
it downloads is already satisfied and stays satisfied by the same track it was
before, and what the download settles is a *different* language's gap. Nothing
is replaced, so nothing about the rule changes.
**On disk.** Sidecars live next to the video inside the §7.4 title folder,
named `<video basename>.<lang>.srt`, e.g.
`… - [2160p][WEB-DL][HDR10].pt-PT.srt`. A machine translation carries an extra
`.mt` segment: `… [HDR10].pt-PT.mt.srt`. That keeps §7.4's audit-by-`ls`
property — with the app stopped, the filename says which subtitles are
machine-made. Folder-level delete stays atomic because sidecars are inside the
folder.
`.mt` is the only optional segment. One language, one sidecar: a second
subtitle for a language arr already has is refused, and replacing one is the
manual delete-then-fetch §15's no-upgrade rule already describes.
**Sync.** `alass` runs on every fetched and every translated subtitle. It is a
single small binary invoked like `ffprobe`, so it costs nothing at rest. It
reports no confidence value, so its output is accepted unless it is
implausible — a shift beyond 60 seconds, or cues lost — in which case the
unsynced original is kept and the file is flagged.
The reference is the video, except where a cue skeleton exists, and then it is
the skeleton. A subtitle a skeleton accepted is already on disc-exact timings,
and translation copies those timings over untouched, so the pass that would
otherwise run on the translation is skipped: a second alignment has nothing
left to find and can only move them off.
**Configuration.** Provider credentials and translator API keys are bootstrap
config or environment, per §10 — a secret never becomes a database row. Wanted
languages, chosen engine, per-provider enable and the daily budgets are
database rows edited from `/settings` without a restart.
So is everything needed to point the OpenAI-compatible backend somewhere else:
its **base URL and its model name are database rows too**, not bootstrap
config. That backend is not "OpenAI" — it is any endpoint speaking that shape,
`llama.cpp` and a local gateway included, and which one is in use is a thing to
try and change, not a property of the deployment fixed at start-up. Only the
API key stays in the environment, and an endpoint that needs no key is a valid
configuration.
**Budgets.** A token bucket per provider and per translator, with a configured
daily allowance. The reconcile loop spends it newest-import-first, so enabling
this on an existing library drains the backlog over days instead of hitting
every rate limit at once. Being at the cap is a visible queue state, not an
error.
**Notifications.** No new event classes. §9.5 stands: subtitle fetches never
notify, and a provider or translator being unreachable folds into the existing
"Broken" message to the operator alone.
**Non-goals**, stated here so they do not creep back: OCR of image-based
tracks, transcribing audio when no subtitle exists anywhere, adopting subtitle
files already on disk that arr did not write (§2 already says the service
knows only what it put there — which does mean arr may fetch a second copy
alongside one Bazarr left), and any background loop that upgrades a subtitle
in place.
+46 -2
View File
@@ -8,17 +8,61 @@ RUN pnpm -C web install --frozen-lockfile --config.dangerouslyAllowAllBuilds=tru
COPY web ./web
RUN pnpm -C web build
FROM rust:1-bookworm AS build
# Every translation backend is a default-off cargo feature (DESIGN.md §15), so
# a build without these ships an image that cannot translate at all. All four
# go in: which one is in use is a database setting, and the point of the
# feature switches is the build, not the deployment.
#
# The dependency build and the workspace build are split with cargo-chef so a
# source-only change reuses the dependency layer. The two `cargo` invocations
# must carry identical flags or the cook output is not reusable.
FROM lukemathwalker/cargo-chef:latest-rust-1-bookworm AS chef
WORKDIR /src
ENV ARR_FEATURES=translate-openai,translate-deepl,translate-google,translate-command
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS build
COPY --from=planner /src/recipe.json recipe.json
# cargo-chef's skeleton keeps build scripts, and arr-daemon's panics in release
# when it can find no SPA bundle. A stub satisfies it; the real bundle arrives
# below and the changed ARR_WEB_DIST reruns only that one build script.
RUN mkdir -p /stub/dist && printf '<!doctype html>stub\n' > /stub/dist/index.html
ENV ARR_WEB_DIST=/stub/dist
RUN cargo chef cook --release -p arr-daemon --features "$ARR_FEATURES" \
--recipe-path recipe.json
COPY . .
COPY --from=web /src/web/dist ./web/dist
ENV ARR_WEB_DIST=/src/web/dist
RUN cargo build --release -p arr-daemon
RUN cargo build --release -p arr-daemon --features "$ARR_FEATURES"
# §15 runs `alass` over every fetched and every translated subtitle, and it is
# not in Debian — a release binary is the only way in. Its own stage so the
# runtime image needs no download tool, and so a source change does not refetch
# it. The digest is pinned: an unverified binary from a release page is not
# something to run over the library. The asset is x86-64 only, which is what
# this image targets.
FROM debian:bookworm-slim AS alass
ARG ALASS_VERSION=2.0.0
ARG ALASS_SHA256=7bd0b9ae7e035d3ba940eacffb21243614df36231d47f21f0b4ce42001ab7fcd
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL -o /usr/local/bin/alass \
"https://github.com/kaegi/alass/releases/download/v${ALASS_VERSION}/alass-linux64" \
&& echo "${ALASS_SHA256} /usr/local/bin/alass" | sha256sum -c - \
&& chmod +x /usr/local/bin/alass \
&& alass --version
FROM debian:bookworm-slim
# ffmpeg covers both `ffprobe` for arr-probe and the audio extraction `alass`
# shells out to.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=alass /usr/local/bin/alass /usr/local/bin/alass
COPY --from=build /src/target/release/arr /usr/local/bin/arr
ENV ARR_BIND_ADDR=0.0.0.0:7878 \
ARR_DATABASE_PATH=/data/arr.db
+34 -12
View File
@@ -15,32 +15,54 @@ fmt-check:
fmt:
cargo fmt --all
# Clippy, warnings denied.
# Clippy, warnings denied. `--all-features` matters: every subtitle
# translation backend sits behind a default-off cargo feature, so without it
# the gate never compiles a line of them (#191, #192).
lint:
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
# Unused workspace dependencies. Stable toolchain, unlike cargo-udeps.
deps:
cargo machete
# Unit and integration tests, excluding the e2e crate.
# Unit and integration tests, excluding the e2e crate. `--all-features` for
# the same reason `lint` needs it: the translation backends are feature-gated
# and would otherwise never run.
test:
cargo nextest run --workspace --exclude arr-e2e
cargo nextest run --workspace --exclude arr-e2e --all-features
# End-to-end tests. Needs a Transmission container (`just e2e-up`); not part
# of the push gate. Never point this at the production Transmission LXC.
# End-to-end tests. Needs a qBittorrent container (`just e2e-up`); not part
# of the push gate. Never point this at the production qBittorrent.
e2e:
cargo nextest run -p arr-e2e
# Throwaway local Transmission for `just e2e`, mirroring the CI service.
# Throwaway local qBittorrent for `just e2e`, mirroring the CI container.
#
# The config is seeded before first start because qBittorrent prints a random
# WebUI password per boot and has no environment variable to set one; the
# subnet whitelist is what lets the tests talk to it without credentials.
e2e-up:
docker run --rm -d --name arr-e2e-transmission \
-e PUID=1000 -e PGID=1000 -p 9091:9091 \
linuxserver/transmission:latest
#!/usr/bin/env bash
set -euo pipefail
mkdir -p /tmp/arr-e2e-qbt/config/qBittorrent
cat > /tmp/arr-e2e-qbt/config/qBittorrent/qBittorrent.conf <<'CONF'
[Preferences]
WebUI\Port=8080
WebUI\AuthSubnetWhitelistEnabled=true
WebUI\AuthSubnetWhitelist=0.0.0.0/0
WebUI\CSRFProtection=false
WebUI\HostHeaderValidation=false
Downloads\SavePath=/downloads
CONF
docker run --rm -d --name arr-e2e-qbittorrent \
-e PUID=1000 -e PGID=1000 -e WEBUI_PORT=8080 -p 8080:8080 \
-v /tmp/arr-e2e-qbt/config:/config \
linuxserver/qbittorrent:latest
until curl -sf http://127.0.0.1:8080/api/v2/app/version >/dev/null; do sleep 1; done
# Stop and discard the local Transmission container.
# Stop and discard the local qBittorrent container.
e2e-down:
docker stop arr-e2e-transmission
docker stop arr-e2e-qbittorrent
# Frontend lint and typecheck. No-op until the SPA exists (issue #6). Probes
# for package.json, not the directory: `just gen-client` writes into web/ and
+1 -1
View File
@@ -43,7 +43,7 @@ manual search buries the decision under the release name column.
- Self-hosted behind VPN + Authelia; the app itself has no auth layer.
- API-first: the SPA is one client of the OpenAPI-described HTTP API, no
privileged path.
- Coexists with Prowlarr (indexers), Transmission (downloads), Jellyfin
- Coexists with Prowlarr (indexers), qBittorrent (downloads), Jellyfin
(playback), Jellyseerr (requests), ntfy (notifications).
## Capabilities and Constraints
+1 -1
View File
@@ -12,7 +12,7 @@ This is that loop, once, in Rust.
- **Prowlarr** stays — it owns tracker auth, Cloudflare bypass and the indexer
definitions, and replacing it buys nothing.
- **Transmission** stays.
- **qBittorrent** stays.
- **Jellyseerr** stays, talking to a thin Radarr-compatible shim.
## Status
+3
View File
@@ -8,10 +8,13 @@ publish = false
[dependencies]
arr-core = { workspace = true }
arr-dl = { workspace = true }
arr-db = { workspace = true }
arr-indexer = { workspace = true }
arr-meta = { workspace = true }
arr-parse = { workspace = true }
arr-probe = { workspace = true }
arr-subs = { workspace = true }
axum = { workspace = true }
chrono = { workspace = true }
reqwest = { workspace = true }
+231
View File
@@ -0,0 +1,231 @@
//! The live download snapshot, joined to arr-owned grabs only.
use std::time::{Duration, Instant};
use arr_dl::{Torrent, TorrentState};
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
use crate::movies::ApiError;
use crate::state::AppState;
const SNAPSHOT_TTL: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, Copy, Serialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DownloadPhase {
Downloading,
Stalled,
Errored,
Seeding,
Queued,
}
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct Download {
pub target_kind: String,
pub target_id: i64,
pub infohash: String,
pub name: String,
pub phase: DownloadPhase,
pub progress: f64,
pub download_rate: u64,
pub eta: Option<i64>,
pub error: Option<String>,
}
#[utoipa::path(
get,
path = "/api/downloads",
tag = "system",
responses(
(status = 200, description = "Live downloads started by arr", body = [Download]),
(status = 503, description = "qBittorrent or database unavailable", body = crate::movies::ErrorBody)
)
)]
pub(crate) async fn list(State(state): State<AppState>) -> Result<Json<Vec<Download>>, ApiError> {
let database = state.database().ok_or(ApiError::Unavailable)?;
let qbit = state.qbit().ok_or(ApiError::Upstream("qbit"))?;
let torrents = {
let mut cache = state.download_snapshot().write().await;
if let Some((at, torrents)) = cache.as_ref() {
if at.elapsed() < SNAPSHOT_TTL {
torrents.clone()
} else {
let torrents = qbit
.list_torrents()
.await
.map_err(|_| ApiError::Upstream("qbit"))?;
*cache = Some((Instant::now(), torrents.clone()));
torrents
}
} else {
let torrents = qbit
.list_torrents()
.await
.map_err(|_| ApiError::Upstream("qbit"))?;
*cache = Some((Instant::now(), torrents.clone()));
torrents
}
};
let grabs: Vec<Grab> =
sqlx::query_as("SELECT target_kind, target_id, infohash FROM grabs ORDER BY id")
.fetch_all(database.pool())
.await?;
let mut downloads = Vec::new();
for grab in grabs {
let Some(torrent) = torrents
.iter()
.find(|torrent| torrent.hash.eq_ignore_ascii_case(&grab.infohash))
else {
continue;
};
downloads.push(Download {
target_kind: grab.target_kind,
target_id: grab.target_id,
infohash: torrent.hash.clone(),
name: torrent.name.clone(),
phase: phase(torrent),
progress: torrent.progress,
download_rate: torrent.download_rate,
eta: torrent.eta,
error: torrent.error.clone(),
});
}
Ok(Json(downloads))
}
#[derive(Debug, sqlx::FromRow)]
struct Grab {
target_kind: String,
target_id: i64,
infohash: String,
}
fn phase(torrent: &Torrent) -> DownloadPhase {
if torrent.error.is_some() {
DownloadPhase::Errored
} else {
match torrent.state {
TorrentState::Seeding => DownloadPhase::Seeding,
// qBittorrent's own verdict, not a zero rate: a torrent between
// peers reads zero for a poll or two and finishes fine, and
// §9.8's stalled chip is for the case that never does.
TorrentState::Downloading if torrent.is_stalled => DownloadPhase::Stalled,
TorrentState::Downloading => DownloadPhase::Downloading,
_ => DownloadPhase::Queued,
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use arr_db::Db;
use axum::extract::State;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{list, phase, DownloadPhase};
use crate::state::{AppState, Upstreams};
#[test]
fn torrent_states_become_the_download_phases() {
let torrent =
|state: arr_dl::TorrentState, rate: u64, error: Option<&str>| arr_dl::Torrent {
name: "name".into(),
hash: "hash".into(),
state,
progress: 0.5,
download_rate: rate,
eta: None,
error: error.map(str::to_owned),
is_stalled: rate == 0,
download_dir: PathBuf::from("/downloads"),
labels: Vec::new(),
is_finished: false,
};
assert_eq!(
phase(&torrent(arr_dl::TorrentState::Downloading, 10, None)),
DownloadPhase::Downloading
);
assert_eq!(
phase(&torrent(arr_dl::TorrentState::Downloading, 0, None)),
DownloadPhase::Stalled
);
assert_eq!(
phase(&torrent(arr_dl::TorrentState::Seeding, 0, None)),
DownloadPhase::Seeding
);
assert_eq!(
phase(&torrent(
arr_dl::TorrentState::Downloading,
10,
Some("disk full")
)),
DownloadPhase::Errored
);
assert_eq!(
phase(&torrent(arr_dl::TorrentState::QueuedToDownload, 0, None)),
DownloadPhase::Queued
);
}
#[tokio::test]
async fn endpoint_joins_only_arr_grabs_and_reuses_the_snapshot() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v2/torrents/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([{
"hash": "ABC", "name": "grabbed", "state": "downloading",
"progress": 0.4, "dlspeed": 123, "eta": 60,
"save_path": "/downloads", "tags": ""
}])))
.expect(1)
.mount(&server)
.await;
let directory = tempfile::tempdir().expect("tempdir");
let database = Db::connect(directory.path().join("arr.db"))
.await
.expect("database");
database.migrate().await.expect("migrate");
sqlx::query(
"INSERT INTO releases (id, indexer_id, guid, name, size, download_url, parsed)
VALUES (1, 1, 'guid', 'release', 1, 'magnet:?x', '{}')",
)
.execute(database.pool())
.await
.expect("release");
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash)
VALUES (1, 'movie', 42, 'abc')",
)
.execute(database.pool())
.await
.expect("grab");
let state = AppState::new(Upstreams::new("unused".into(), server.uri()))
.expect("state")
.with_database(database)
.with_qbit(arr_dl::QbitClient::new(&server.uri()).expect("client"));
let first = list(State(state.clone())).await.expect("first").0;
let second = list(State(state)).await.expect("second").0;
assert_eq!(first.len(), 1);
assert_eq!(first[0].target_kind, "movie");
assert_eq!(first[0].target_id, 42);
assert_eq!(first[0].infohash, "ABC");
assert_eq!(first[0].phase, DownloadPhase::Downloading);
assert_eq!(first[0].download_rate, 123);
assert_eq!(second.len(), 1);
}
}
+384 -27
View File
@@ -1,16 +1,20 @@
//! `GET /api/health` — is each of the three upstreams answering.
//!
//! The three probed here are the ones DESIGN.md §9.5 calls "Broken": without
//! Prowlarr nothing is found, without Transmission nothing is fetched, and
//! Prowlarr nothing is found, without qBittorrent nothing is fetched, and
//! without TMDB nothing is identified. The endpoint always answers `200` —
//! the body carries the verdict, so a degraded service can still explain
//! itself to the UI instead of looking like a fourth outage.
use std::ffi::OsStr;
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use utoipa::ToSchema;
use arr_subs::{binary_present, translate as backend_error};
use crate::state::AppState;
/// Whether the service as a whole can do its job.
@@ -68,6 +72,44 @@ impl Check {
}
}
/// One enabled subtitle provider's verdict (#200).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
pub struct ProviderCheck {
/// The provider's id as settings name it.
pub id: String,
pub status: Status,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
/// The subtitle lane's verdicts (DESIGN.md §15, #200). Only what is actually
/// in use appears here: providers nobody enabled and engines nobody selected
/// cannot be broken.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
pub struct SubtitleHealth {
/// One entry per provider the settings enable, in that order.
pub providers: Vec<ProviderCheck>,
/// The selected translation engine, or `None` when none is chosen —
/// which is a configuration state, not an outage.
#[serde(skip_serializing_if = "Option::is_none")]
pub translation: Option<Check>,
/// The `alass` sync binary, present at its configured path.
pub alass: Check,
/// The `ffmpeg` extraction binary, present at its configured path.
pub ffmpeg: Check,
}
impl SubtitleHealth {
/// Every verdict the lane carries, for the overall status.
fn statuses(&self) -> impl Iterator<Item = Status> + '_ {
self.providers
.iter()
.map(|provider| provider.status)
.chain(self.translation.iter().map(|check| check.status))
.chain([self.alass.status, self.ffmpeg.status])
}
}
/// The body of `GET /api/health`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
pub struct HealthReport {
@@ -76,11 +118,13 @@ pub struct HealthReport {
#[schema(example = "0.1.0")]
pub version: String,
pub prowlarr: Check,
pub transmission: Check,
pub qbit: Check,
pub tmdb: Check,
pub subtitles: SubtitleHealth,
}
/// Report reachability of Prowlarr, Transmission and TMDB.
/// Report reachability of Prowlarr, qBittorrent and TMDB, plus the
/// subtitle upstreams (#200).
#[utoipa::path(
get,
path = "/api/health",
@@ -90,17 +134,19 @@ pub struct HealthReport {
),
)]
pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
// Three independent network probes; serialising them would make the
// endpoint as slow as the sum of the timeouts.
let (prowlarr, transmission, tmdb) = tokio::join!(
// Independent network probes; serialising them would make the endpoint
// as slow as the sum of the timeouts.
let (prowlarr, qbit, tmdb, subtitles) = tokio::join!(
probe_prowlarr(&state),
probe_transmission(&state),
probe_qbit(&state),
probe_tmdb(&state),
probe_subtitles(&state)
);
let status = if [prowlarr.status, transmission.status, tmdb.status]
.iter()
.all(|s| *s == Status::Ok)
let status = if [prowlarr.status, qbit.status, tmdb.status]
.into_iter()
.chain(subtitles.statuses())
.all(|check| check == Status::Ok)
{
Health::Ok
} else {
@@ -111,8 +157,9 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthReport> {
status,
version: env!("CARGO_PKG_VERSION").to_string(),
prowlarr,
transmission,
qbit,
tmdb,
subtitles,
})
}
@@ -134,22 +181,21 @@ async fn probe_prowlarr(state: &AppState) -> Check {
}
}
/// Transmission answers an RPC call without a session id with `409` plus the
/// id to retry with. That is a live daemon, so it counts as reachable.
async fn probe_transmission(state: &AppState) -> Check {
let request = state
.http()
.post(&state.upstreams().transmission_url)
.json(&serde_json::json!({ "method": "session-get" }));
match request.send().await {
Ok(response)
if response.status().is_success()
|| response.status() == reqwest::StatusCode::CONFLICT =>
{
Check::ok()
}
Ok(response) => Check::unreachable(format!("http {}", response.status().as_u16())),
Err(err) => Check::unreachable(describe(err)),
/// The probe is a real torrent list through the configured client, not a bare
/// reachability check.
///
/// A `WebUI` that wants credentials answers `403` to anything unauthenticated,
/// so "the port is open" is true of an instance arr cannot use at all. Reading
/// that as healthy once left the lamp green while every grab and every reaper
/// tick failed on a rejected password, which is the one thing this lamp exists
/// to catch.
async fn probe_qbit(state: &AppState) -> Check {
let Some(qbit) = state.qbit() else {
return Check::unconfigured("no qBittorrent client is configured");
};
match qbit.list_torrents().await {
Ok(_) => Check::ok(),
Err(error) => Check::unreachable(error.to_string()),
}
}
@@ -181,3 +227,314 @@ async fn probe_tmdb(state: &AppState) -> Check {
fn describe(err: reqwest::Error) -> String {
err.without_url().to_string()
}
// ---- the subtitle lane (DESIGN.md §15, #200) -----------------------------
/// Probe every subtitle lamp: enabled providers, the selected engine, and
/// the two binaries. The settings row decides what is *in use* — a provider
/// nobody enabled or an engine nobody selected is not probed at all, so it
/// cannot fail a lamp.
async fn probe_subtitles(state: &AppState) -> SubtitleHealth {
let settings = match state.database() {
Some(_) => crate::subtitle_settings::load(state).await.ok(),
None => None,
};
// No readable row means nothing is known to be in use; the lamps stay
// quiet rather than failing on data the operator has not entered yet.
let enabled = settings
.as_ref()
.map(|settings| settings.providers_enabled.as_slice())
.unwrap_or_default();
let engine = settings
.as_ref()
.and_then(|settings| settings.translation_engine.as_deref());
let mut probes = Vec::with_capacity(enabled.len());
for id in enabled {
probes.push(probe_provider(state, id).await);
}
let translation = match engine {
Some(engine) => Some(probe_engine(state, engine).await),
None => None,
};
SubtitleHealth {
providers: probes,
translation,
alass: binary_check("alass", state.syncer().binary_path()),
ffmpeg: binary_check("ffmpeg", state.ffmpeg_binary()),
}
}
/// Reachable and credentials accepted, per enabled provider (#200).
///
/// A provider that is enabled but was never attached is unconfigured, not
/// unreachable: its credentials are bootstrap config that this deployment
/// simply does not have.
async fn probe_provider(state: &AppState, id: &str) -> ProviderCheck {
let check = match state.subtitle_provider(id) {
Some(provider) => check_from(provider.probe().await),
None => Check::unconfigured(format!(
"{id} is enabled but has no credentials — they are bootstrap config, not a setting"
)),
};
ProviderCheck {
id: id.to_owned(),
status: check.status,
detail: check.detail,
}
}
/// Reachable, credentials accepted — for the remote-command backend,
/// "reachable" means the command ran and exited cleanly (#200).
async fn probe_engine(state: &AppState, engine: &str) -> Check {
match state.translation_backend(engine) {
Some(backend) => backend_check(backend.probe().await),
None => Check::unconfigured(format!(
"'{engine}' is selected but unavailable in this build"
)),
}
}
/// One probe verdict, whatever kind of upstream produced it. A refused key
/// gets its own wording because it never resolves by retrying. The two error
/// enums — providers' and backends' — carry the same shape for this purpose.
fn check_from(result: Result<(), arr_subs::Error>) -> Check {
match result {
Ok(()) => Check::ok(),
Err(arr_subs::Error::Unauthorized { .. }) => Check::unreachable("credentials refused"),
Err(error) => Check::unreachable(error.to_string()),
}
}
fn backend_check(result: Result<(), backend_error::Error>) -> Check {
match result {
Ok(()) => Check::ok(),
Err(backend_error::Error::Unauthorized { .. }) => Check::unreachable("credentials refused"),
Err(error) => Check::unreachable(error.to_string()),
}
}
/// A binary lamp (#200): present and executable at its configured path.
/// Nothing is spawned — the endpoint is polled, and starting `ffmpeg` per
/// poll would be neither cheap nor side-effect-free.
fn binary_check(name: &str, binary: &OsStr) -> Check {
if binary_present(binary) {
Check::ok()
} else {
Check::unreachable(format!("{name} not found at {}", binary.to_string_lossy()))
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arr_db::Db;
use arr_subs::translate as backend;
use arr_subs::{
Backend, CandidateId, DownloadFuture, Provider, ProviderId, SearchFuture, SearchRequest,
Syncer,
};
use crate::{router, AppState, Upstreams};
/// A provider whose lamp is what `lamp` says — the probe under test is
/// ours, so the stub never touches the network.
#[derive(Debug)]
struct StubProvider {
id: &'static str,
up: bool,
}
impl Provider for StubProvider {
fn id(&self) -> ProviderId {
ProviderId::new(self.id)
}
fn search<'a>(&'a self, _request: &'a SearchRequest) -> SearchFuture<'a> {
Box::pin(async move { Ok(Vec::new()) })
}
fn download<'a>(&'a self, _id: &'a CandidateId) -> DownloadFuture<'a> {
unreachable!("health probes never download");
}
fn probe(&self) -> arr_subs::ProbeFuture<'_> {
let result = if self.up {
Ok(())
} else {
Err(arr_subs::Error::Unauthorized {
provider: ProviderId::new(self.id),
})
};
Box::pin(async move { result })
}
}
/// Same idea for the selected engine; the closure rebuilds its error per
/// call because probing borrows.
#[derive(Debug)]
struct StubBackend {
#[allow(dead_code)]
detail: &'static str,
}
impl Backend for StubBackend {
fn id(&self) -> backend::BackendId {
backend::BackendId::new("openai")
}
fn supports(&self, _target: &arr_core::Language) -> bool {
true
}
fn translate<'a>(&'a self, _batch: &'a backend::Batch) -> backend::TranslateFuture<'a> {
unreachable!("health probes never translate");
}
fn probe(&self) -> backend::ProbeFuture<'_> {
Box::pin(async move {
Err(backend::Error::Transport {
backend: backend::BackendId::new("openai"),
source: "connection refused".into(),
})
})
}
}
/// Serve the app on an ephemeral port and return its base URL.
async fn serve(state: AppState) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
format!("http://{address}")
}
/// An app with a migrated database and both binaries somewhere findable.
/// The three classic upstreams point at a dead port; these tests read the
/// subtitle lamps, not theirs.
async fn application() -> (tempfile::TempDir, AppState) {
let dir = tempfile::tempdir().expect("tempdir");
let database = Db::connect(dir.path().join("arr.db"))
.await
.expect("connect");
database.migrate().await.expect("migrate");
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_database(database)
.with_syncer(Syncer::new().with_binary("sh"))
.with_ffmpeg_binary("sh");
(dir, state)
}
async fn report(state: AppState) -> serde_json::Value {
let base = serve(state).await;
reqwest::get(format!("{base}/api/health"))
.await
.expect("request health")
.json()
.await
.expect("health body")
}
/// Replace the seeded enabled set. Runtime-checked rather than going
/// through the settings API, whose validation refuses engines this test
/// binary has not compiled in.
async fn set_enabled(state: &AppState, providers: &str, engine: Option<&str>) {
sqlx::query("UPDATE subtitle_settings SET providers_enabled = ?, translation_engine = ?")
.bind(providers)
.bind(engine)
.execute(state.database().expect("database").pool())
.await
.expect("settings update");
}
#[tokio::test]
async fn without_a_settings_row_the_lane_is_quiet_and_binaries_still_checked() {
// No database attached: nothing is known to be in use, so nothing
// may fail a lamp — but a missing binary is a fact about the host.
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_syncer(Syncer::new().with_binary("sh"))
.with_ffmpeg_binary("/nowhere/ffmpeg");
let body = report(state).await;
assert_eq!(body["subtitles"]["providers"], serde_json::json!([]));
assert!(body["subtitles"]["translation"].is_null());
assert_eq!(body["subtitles"]["alass"]["status"], "ok");
assert_eq!(body["subtitles"]["ffmpeg"]["status"], "unreachable");
assert_eq!(body["status"], "degraded");
}
#[tokio::test]
async fn an_enabled_provider_without_credentials_is_unconfigured() {
let (_dir, state) = application().await;
// The seed row enables OpenSubtitles; none is attached.
let body = report(state).await;
assert_eq!(
body["subtitles"]["providers"],
serde_json::json!([
{ "id": "opensubtitles", "status": "unconfigured",
"detail": "opensubtitles is enabled but has no credentials — they are bootstrap config, not a setting" },
])
);
assert_eq!(body["status"], "degraded");
}
#[tokio::test]
async fn an_attached_provider_lamp_follows_its_probe() {
let (_dir, state) = application().await;
set_enabled(&state, r#"["ok","bad"]"#, None).await;
let state = state.with_subtitle_providers(vec![
Arc::new(StubProvider { id: "ok", up: true }),
Arc::new(StubProvider {
id: "bad",
up: false,
}),
]);
let body = report(state).await;
assert_eq!(body["subtitles"]["providers"][0]["id"], "ok");
assert_eq!(body["subtitles"]["providers"][0]["status"], "ok");
assert_eq!(body["subtitles"]["providers"][1]["id"], "bad");
assert_eq!(body["subtitles"]["providers"][1]["status"], "unreachable");
assert_eq!(
body["subtitles"]["providers"][1]["detail"],
"credentials refused"
);
}
#[tokio::test]
async fn a_selected_engine_is_probed_only_when_selected() {
let (_dir, state) = application().await;
let body = report(state.clone()).await;
assert!(body["subtitles"]["translation"].is_null(), "{body}");
set_enabled(&state, "[]", Some("openai")).await;
// Selected but never attached: unavailable in this deployment.
let body = report(state).await;
assert_eq!(body["subtitles"]["translation"]["status"], "unconfigured");
}
#[tokio::test]
async fn an_unreachable_engine_fails_the_whole_report() {
let (_dir, state) = application().await;
set_enabled(&state, "[]", Some("openai")).await;
let state = state.with_translation_backends(vec![Arc::new(StubBackend {
detail: "connection refused",
})]);
let body = report(state).await;
assert_eq!(body["subtitles"]["translation"]["status"], "unreachable");
assert_eq!(body["status"], "degraded");
}
}
+88 -47
View File
@@ -6,18 +6,23 @@
//! carrying a `#[utoipa::path]` annotation. A handler added without one fails
//! to compile, and the gate in DESIGN.md §12 fails with it.
mod downloads;
mod health;
pub mod jellyfin;
mod metadata;
mod movies;
mod owners;
mod policies;
#[cfg(test)]
mod privilege;
mod reclassify;
mod relocate;
mod roots;
mod search;
mod series;
mod state;
mod subtitle_settings;
mod subtitles;
mod trailer;
use axum::routing::get;
@@ -27,6 +32,7 @@ use utoipa_axum::router::OpenApiRouter;
use utoipa_axum::routes;
use utoipa_scalar::{Scalar, Servable};
pub use downloads::{Download, DownloadPhase};
pub use health::{Check, Health, HealthReport, Status};
pub use metadata::{MetadataTrailer, MovieMetadata, SeriesMetadata};
pub use movies::{
@@ -46,6 +52,13 @@ pub use state::{
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
DEFAULT_TMDB_URL,
};
pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput};
pub use subtitles::{
EmbeddedTrack, EpisodeSubtitleGaps, EpisodeSubtitleStatus, MissingSubtitle, MovieSubtitleGaps,
SeasonSubtitleGaps, SeriesSubtitleGaps, Subtitle, SubtitleCandidate, SubtitleExtractInput,
SubtitleGap, SubtitleGrabInput, SubtitleProviderError, SubtitleQueue, SubtitleSearchInput,
SubtitleSearchResults, SubtitleStatus, SubtitleTranslateInput,
};
pub use trailer::{Trailer, TrailerKind};
/// Where the generated document is served, and where `just gen-client` reads
@@ -71,7 +84,8 @@ pub const DOCS_PATH: &str = "/api/docs";
(name = "owners", description = "Owner tags and filtered views (DESIGN.md §4.3)"),
(name = "policies", description = "Quality policies (DESIGN.md §5)"),
(name = "search", description = "Unified title and release search"),
(name = "roots", description = "Root folders and their policies")
(name = "roots", description = "Root folders and their policies"),
(name = "subtitles", description = "Subtitles and their configuration (DESIGN.md §15)")
),
)]
struct ApiDoc;
@@ -80,6 +94,7 @@ struct ApiDoc;
fn api_router() -> OpenApiRouter<AppState> {
OpenApiRouter::with_openapi(ApiDoc::openapi())
.routes(routes!(health::health))
.routes(routes!(downloads::list))
.routes(routes!(movies::list, movies::create))
.routes(routes!(movies::get, movies::update, movies::delete))
.routes(routes!(movies::search))
@@ -118,6 +133,19 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(roots::get, roots::update, roots::delete))
.routes(routes!(policies::list, policies::create))
.routes(routes!(policies::get, policies::update, policies::delete))
.routes(routes!(subtitle_settings::get, subtitle_settings::update))
.routes(routes!(subtitles::list_for_media_file))
.routes(routes!(subtitles::list_for_movie))
.routes(routes!(subtitles::list_for_episode))
.routes(routes!(subtitles::status_for_movie))
.routes(routes!(subtitles::status_for_episode))
.routes(routes!(subtitles::status_for_series))
.routes(routes!(subtitles::search))
.routes(routes!(subtitles::grab))
.routes(routes!(subtitles::extract))
.routes(routes!(subtitles::translate))
.routes(routes!(subtitles::delete))
.routes(routes!(subtitles::queue))
}
/// The generated `OpenAPI` document.
@@ -149,8 +177,8 @@ mod tests {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A Prowlarr that answers `/ping`, and a Transmission that answers an
/// RPC call the way a real one does when it has no session id yet.
/// A Prowlarr that answers `/ping`, and a qBittorrent that answers the
/// version call the way a real one does for an unauthenticated caller.
async fn upstreams_up() -> (MockServer, MockServer) {
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
@@ -161,16 +189,14 @@ mod tests {
.mount(&prowlarr)
.await;
let transmission = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/transmission/rpc"))
.respond_with(
ResponseTemplate::new(409).insert_header("X-Transmission-Session-Id", "abc"),
)
.mount(&transmission)
let qbit = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v2/torrents/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&qbit)
.await;
(prowlarr, transmission)
(prowlarr, qbit)
}
/// Serve the app on an ephemeral port and return its base URL. The server
@@ -197,7 +223,7 @@ mod tests {
#[tokio::test]
async fn all_upstreams_up_is_ok() {
let (prowlarr, transmission) = upstreams_up().await;
let (prowlarr, qbit) = upstreams_up().await;
let tmdb = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/configuration"))
@@ -206,31 +232,31 @@ mod tests {
.await;
let state = AppState::new(
Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
)
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
Upstreams::new(prowlarr.uri(), qbit.uri())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state");
.expect("state")
.with_qbit(arr_dl::QbitClient::new(&qbit.uri()).expect("client"))
// The subtitle binaries default to `PATH`; pin them to something
// every machine has so this test stays about the classic upstreams.
.with_syncer(arr_subs::Syncer::new().with_binary("sh"))
.with_ffmpeg_binary("sh");
let body = report(state).await;
assert_eq!(body["status"], "ok");
assert_eq!(body["prowlarr"]["status"], "ok");
assert_eq!(body["transmission"]["status"], "ok");
assert_eq!(body["qbit"]["status"], "ok");
assert_eq!(body["tmdb"]["status"], "ok");
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
}
#[tokio::test]
async fn a_missing_tmdb_key_is_unconfigured_not_an_outage() {
let (prowlarr, transmission) = upstreams_up().await;
let state = AppState::new(Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let (prowlarr, qbit) = upstreams_up().await;
let state = AppState::new(Upstreams::new(prowlarr.uri(), qbit.uri()))
.expect("state")
.with_qbit(arr_dl::QbitClient::new(&qbit.uri()).expect("client"));
let body = report(state).await;
assert_eq!(body["tmdb"]["status"], "unconfigured");
@@ -239,20 +265,18 @@ mod tests {
#[tokio::test]
async fn an_unreachable_upstream_degrades_the_service() {
let (_prowlarr, transmission) = upstreams_up().await;
let (_prowlarr, qbit) = upstreams_up().await;
// Port 1 is privileged and nothing binds it, so the probe gets a
// refused connection immediately instead of waiting out the timeout.
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let state = AppState::new(Upstreams::new("http://127.0.0.1:1".into(), qbit.uri()))
.expect("state")
.with_qbit(arr_dl::QbitClient::new(&qbit.uri()).expect("client"));
let body = report(state).await;
assert_eq!(body["status"], "degraded");
assert_eq!(body["prowlarr"]["status"], "unreachable");
assert_eq!(body["transmission"]["status"], "ok");
assert_eq!(body["qbit"]["status"], "ok");
}
#[tokio::test]
@@ -263,13 +287,9 @@ mod tests {
.respond_with(ResponseTemplate::new(500))
.mount(&prowlarr)
.await;
let transmission = MockServer::start().await;
let qbit = MockServer::start().await;
let state = AppState::new(Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
))
.expect("state");
let state = AppState::new(Upstreams::new(prowlarr.uri(), qbit.uri())).expect("state");
let body = report(state).await;
assert_eq!(body["prowlarr"]["status"], "unreachable");
@@ -278,7 +298,7 @@ mod tests {
#[tokio::test]
async fn a_failed_tmdb_probe_never_echoes_the_api_key() {
let (prowlarr, transmission) = upstreams_up().await;
let (prowlarr, qbit) = upstreams_up().await;
let tmdb = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/configuration"))
@@ -287,14 +307,12 @@ mod tests {
.await;
let state = AppState::new(
Upstreams::new(
prowlarr.uri(),
format!("{}/transmission/rpc", transmission.uri()),
)
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("super-secret".into())),
Upstreams::new(prowlarr.uri(), qbit.uri())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("super-secret".into())),
)
.expect("state");
.expect("state")
.with_qbit(arr_dl::QbitClient::new(&qbit.uri()).expect("client"));
let body = report(state).await;
assert_eq!(body["tmdb"]["status"], "unreachable");
@@ -359,6 +377,23 @@ mod tests {
("/api/policies/{policy_id}", "put"),
("/api/policies/{policy_id}", "delete"),
("/api/roots", "post"),
("/api/settings/subtitles", "get"),
("/api/settings/subtitles", "put"),
("/api/media-files/{media_file_id}/subtitles", "get"),
("/api/movies/{movie_id}/subtitles", "get"),
("/api/episodes/{episode_id}/subtitles", "get"),
("/api/movies/{movie_id}/subtitles/status", "get"),
("/api/episodes/{episode_id}/subtitles/status", "get"),
("/api/series/{series_id}/subtitles/status", "get"),
("/api/media-files/{media_file_id}/subtitles/search", "post"),
("/api/media-files/{media_file_id}/subtitles/grab", "post"),
("/api/media-files/{media_file_id}/subtitles/extract", "post"),
(
"/api/media-files/{media_file_id}/subtitles/translate",
"post",
),
("/api/subtitles/{subtitle_id}", "delete"),
("/api/queues/subtitles", "get"),
] {
assert!(
json["paths"][path][method].is_object(),
@@ -373,6 +408,12 @@ mod tests {
"AttentionQueues",
"SeriesAttention",
"Series",
"Subtitle",
"SubtitleCandidate",
"SubtitleSearchResults",
"SubtitleStatus",
"MissingSubtitle",
"EpisodeSubtitleStatus",
] {
assert!(
json["components"]["schemas"][schema].is_object(),
+624 -82
View File
@@ -76,7 +76,42 @@ pub struct Release {
pub parsed: serde_json::Value,
pub score: Option<f64>,
pub verdict: Option<String>,
// The rule behind the verdict: the one that killed a `rejected` row, or
// the one a `waived` row relaxed (#211). Null on an `eligible` row, and
// on a `waived` row stored before migration 0032, which could not record
// it. A plain comment, not a doc comment: doc comments here become
// OpenAPI descriptions and would put the generated client in web/ out of
// date, which #227 and #232 own.
pub rejected_rule: Option<String>,
// #227: what the blacklist recorded this release as failing on, when
// `rejected_rule` is `blacklisted`. Null on every other row, and on a
// blacklisted row whose blacklist entry has since gone. A size rejection
// is a policy opinion the operator can relax; a corrupt or mismatched
// release is not, and a bare `blacklisted` reads the same for both.
// Plain comment for the same reason as the field above.
pub blacklist_reason: Option<String>,
}
/// Fill in [`Release::blacklist_reason`] for every deck row the blacklist
/// holds (#227, §6.3).
///
/// The blacklist is keyed on the *normalised* name, which SQL cannot compute,
/// so the match happens here over the whole table — a handful of rows, the
/// same reasoning as [`arr_db::blacklist::Blacklist`] itself.
pub(crate) async fn attach_blacklist_reasons(
pool: &sqlx::SqlitePool,
releases: &mut [Release],
) -> Result<(), ApiError> {
if releases.is_empty() {
return Ok(());
}
let blacklist = arr_db::blacklist::Blacklist::load(pool).await?;
for release in releases.iter_mut() {
release.blacklist_reason = blacklist
.reason_for_candidate(&release.name, &release.download_url)
.map(str::to_owned);
}
Ok(())
}
/// A library file and what it cost to accept it (`DESIGN.md` §5.7).
@@ -145,6 +180,11 @@ pub struct Accepted {
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ErrorBody {
pub error: String,
/// A machine-readable discriminant, set only where a client needs to
/// branch on the failure rather than display it (issue #221). `None`
/// everywhere else — the message is for the operator, not the client.
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
#[derive(Debug)]
@@ -156,6 +196,22 @@ pub enum ApiError {
OwnerNotFound,
PolicyNotFound,
RootNotFound,
/// A `media_files` row that is not there. Named apart from
/// [`Self::NotFound`] because the subtitle surface (§15) is keyed on
/// files, not on titles, and "movie not found" would misdirect.
MediaFileNotFound,
SubtitleNotFound,
/// A subtitle provider or translation backend could not do what a manual
/// action asked (§15). Carries the message because two providers and
/// four engines are configurable at once and an unnamed failure is
/// unactionable.
SubtitleUpstream(String),
/// A grab named a `candidate_id` the provider no longer recognises
/// (issue #221): a search's results outlive the search itself only in
/// the client's memory, and the provider can expire one at will. Kept
/// apart from [`Self::SubtitleUpstream`] so the panel can offer "search
/// again" from a `code`, not from matching the message text.
SubtitleCandidateExpired,
/// The §9.6 chip outcome: the title exists upstream but has no trailer.
/// Ordinary, so it must stay distinguishable from an upstream failure.
NoTrailer,
@@ -176,38 +232,60 @@ pub enum ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error) = match self {
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string()),
Self::SeriesNotFound => (StatusCode::NOT_FOUND, "series not found".to_string()),
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string()),
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string()),
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()),
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()),
Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string()),
Self::Conflict(error) => (StatusCode::CONFLICT, error),
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
let (status, error, code) = match self {
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string(), None),
Self::SeriesNotFound => (StatusCode::NOT_FOUND, "series not found".to_string(), None),
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string(), None),
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string(), None),
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string(), None),
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string(), None),
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string(), None),
Self::MediaFileNotFound => (
StatusCode::NOT_FOUND,
"media file not found".to_string(),
None,
),
Self::SubtitleNotFound => (
StatusCode::NOT_FOUND,
"subtitle not found".to_string(),
None,
),
Self::SubtitleUpstream(error) => (StatusCode::SERVICE_UNAVAILABLE, error, None),
Self::SubtitleCandidateExpired => (
StatusCode::NOT_FOUND,
"candidate no longer exists — search again".to_string(),
Some("candidate_expired".to_string()),
),
Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string(), None),
Self::Conflict(error) => (StatusCode::CONFLICT, error, None),
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error, None),
Self::Unavailable => (
StatusCode::SERVICE_UNAVAILABLE,
"database unavailable".into(),
None,
),
Self::Upstream(name) => (
StatusCode::SERVICE_UNAVAILABLE,
format!("{name} unavailable"),
None,
),
Self::Database(error) => {
tracing::error!(%error, "API database error");
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
}
Self::Filesystem(error) => {
tracing::error!(%error, "API filesystem error");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("files not removed: {error}"),
"database error".into(),
None,
)
}
// The message is the caller's: this variant is returned by the
// delete lane and by the relocate lane, and "files not removed"
// is a lie about a move that failed.
Self::Filesystem(error) => {
tracing::error!(%error, "API filesystem error");
(StatusCode::INTERNAL_SERVER_ERROR, error.clone(), None)
}
};
(status, Json(ErrorBody { error })).into_response()
(status, Json(ErrorBody { error, code })).into_response()
}
}
@@ -475,7 +553,10 @@ pub async fn update(
{
crate::relocate::refresh_jellyfin(&state).await;
}
if overrides_changed {
// A root change swaps the effective policy the same way an overrides
// edit does (§5.1), so both re-derive; `relocation` is `Some` exactly
// when the root changed.
if overrides_changed || relocation.is_some() {
crate::reclassify::movie(&state, id).await?;
}
Ok(Json(load_movie(&state, id).await?))
@@ -515,6 +596,14 @@ pub async fn delete(
)
.execute(pool(&state)?)
.await?;
// `grabs.infohash` is unique, so an orphan left here would block the row
// a later grab of the same release needs to write.
sqlx::query!(
"DELETE FROM grabs WHERE target_kind = 'movie' AND target_id = ?",
id
)
.execute(pool(&state)?)
.await?;
let result = sqlx::query!("DELETE FROM movies WHERE id = ?", id)
.execute(pool(&state)?)
.await?;
@@ -570,7 +659,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
Ok(metadata) => metadata,
// Already gone is the state we wanted.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
};
let removed = if metadata.is_dir() {
tokio::fs::remove_dir_all(&target).await
@@ -580,7 +669,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
match removed {
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
}
}
Ok(())
@@ -641,7 +730,7 @@ pub async fn releases(
Path(id): Path<i64>,
) -> Result<Json<Vec<Release>>, ApiError> {
load_movie(&state, id).await?;
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
.fetch_all(pool(&state)?)
.await?;
let policy = state
@@ -653,6 +742,7 @@ pub async fn releases(
.ok_or(ApiError::NotFound)?
.policy;
rescore(&mut releases, &policy, None, 0)?;
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
Ok(Json(releases))
}
@@ -770,6 +860,12 @@ pub async fn grab(
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
}
// The four attention lanes (§9.5). The hard-fail lanes carry §5.7's bar in
// full — two failures on *different* releases, both inside
// `ATTENTION_WINDOW`, against a target still waiting for a file — so this
// endpoint and the daemon's notifier report the same queue. Deliberately not
// a doc comment: utoipa would fold it into the OpenAPI description and drift
// the committed document.
#[utoipa::path(
get, path = "/api/queues/attention", tag = "movies",
responses(
@@ -782,7 +878,7 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
.fetch_all(pool(&state)?)
.await?;
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW)
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW)
.fetch_all(pool(&state)?)
.await?;
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
@@ -800,37 +896,73 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
/// share a lane; a series arriving through both is merged into one entry.
///
/// Both hard-fail branches hold to §5.7's bar: two failures on *different*
/// releases, both inside `ATTENTION_WINDOW`. One bad torrent is not a
/// decision, and a failure the operator already dealt with ages out instead
/// of sitting in the queue forever (#226).
/// releases, both inside `ATTENTION_WINDOW`, against a target still waiting
/// for a file. One bad torrent is not a decision (#226), a failure the
/// operator already dealt with ages out instead of sitting in the queue
/// forever (#226), and a target since acquired leaves at once (#238). Seasons
/// hold no intent of their own (§4.1), so the season branch reads liveness
/// off its episodes: it is queued while any of them is still wanted and still
/// without a file. The daemon's notifier filters identically.
async fn tv_attention(
state: &AppState,
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
let database = pool(state)?;
Ok((
tv_no_pt_source_lane(database).await?,
tv_hard_fail_lane(database).await?,
))
}
let no_pt_rows = sqlx::query!(
/// §5.2's no-PT-source lane: wanted, searched episodes on a `kids` root whose
/// every candidate release was rejected for language.
async fn tv_no_pt_source_lane(
database: &sqlx::SqlitePool,
) -> Result<Vec<SeriesAttention>, ApiError> {
let rows = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year,
e.id AS "episode_id!: i64",
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
JOIN roots root ON root.id = s.root_id
WHERE root.audience = 'kids'
AND s.blocked = 0
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
AND NOT EXISTS (
SELECT 1 FROM episode_releases er
JOIN releases r ON r.id = er.release_id
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
)
ORDER BY se.number, e.number
"#
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year,
e.id AS "episode_id!: i64",
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
JOIN roots root ON root.id = s.root_id
WHERE root.audience = 'kids'
AND s.blocked = 0
AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0
AND NOT EXISTS (
SELECT 1 FROM episode_releases er
JOIN releases r ON r.id = er.release_id
WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')
)
ORDER BY se.number, e.number
"#
)
.fetch_all(database)
.await?;
let mut entries: Vec<SeriesAttention> = Vec::new();
for row in rows {
merge_episode(
&mut entries,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.episode_id,
row.season_number,
row.episode_number,
);
}
Ok(entries)
}
/// §5.7's hard-fail lane: episodes and seasons two *different* releases failed
/// on inside `ATTENTION_WINDOW`, restricted to targets still waiting for a
/// file. The season half reads that last condition off its episodes, which is
/// where intent lives (§4.1). Both halves merge into one entry per series.
async fn tv_hard_fail_lane(database: &sqlx::SqlitePool) -> Result<Vec<SeriesAttention>, ApiError> {
let episode_hard_fails = sqlx::query!(
r#"
SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
@@ -842,7 +974,8 @@ async fn tv_attention(
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND e.wanted = 1 AND e.state != 'available'
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
HAVING count(DISTINCT g.release_id) >= 2
"#,
@@ -859,7 +992,12 @@ async fn tv_attention(
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND EXISTS (
SELECT 1 FROM episodes e
WHERE e.season_id = se.id
AND e.wanted = 1 AND e.state != 'available'
)
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
HAVING count(DISTINCT g.release_id) >= 2
"#,
@@ -868,24 +1006,10 @@ async fn tv_attention(
.fetch_all(database)
.await?;
let mut tv_no_pt_source: Vec<SeriesAttention> = Vec::new();
for row in no_pt_rows {
merge_episode(
&mut tv_no_pt_source,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.episode_id,
row.season_number,
row.episode_number,
);
}
let mut tv_needs_decision: Vec<SeriesAttention> = Vec::new();
let mut entries: Vec<SeriesAttention> = Vec::new();
for row in episode_hard_fails {
merge_episode(
&mut tv_needs_decision,
&mut entries,
row.series_id,
row.tmdb_id,
&row.title,
@@ -897,7 +1021,7 @@ async fn tv_attention(
}
for row in season_pack_fails {
merge_season(
&mut tv_needs_decision,
&mut entries,
row.series_id,
row.tmdb_id,
&row.title,
@@ -906,8 +1030,7 @@ async fn tv_attention(
row.season_number,
);
}
Ok((tv_no_pt_source, tv_needs_decision))
Ok(entries)
}
/// One more qualifying season for its series, creating the series' entry on
@@ -1267,6 +1390,73 @@ mod tests {
.expect("releases json");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["verdict"], "waived");
// #211: and it says which rule the waiver relaxed, rather than
// sitting in the deck as a bare `waived` beside rejections that each
// name their own.
assert_eq!(releases[0]["rejected_rule"], "size");
}
/// #241: moving a title to a root with a different policy re-derives its
/// stored verdicts (§5.1) — an English-audio release a `main` root found
/// eligible is only a waiver under the `kids` root it moved to.
#[tokio::test]
async fn moving_a_movie_to_another_root_rederives_its_verdicts() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let created: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
"original_language": "en", "root_id": 1, "overrides": {}
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
let movie_id = created["id"].as_i64().expect("id");
let name = "Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP";
let parsed = arr_parse::parse(name);
let size = 6_i64 * (1 << 30);
let release_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'eligible') RETURNING id",
)
.bind(name)
.bind(size)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.fetch_one(pool)
.await
.expect("release");
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
.bind(movie_id)
.bind(release_id)
.execute(pool)
.await
.expect("association");
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{movie_id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root");
assert_eq!(response.status(), StatusCode::OK);
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases.len(), 1);
assert_eq!(
releases[0]["verdict"], "waived",
"kids requires pt-PT, English is a soft fail: {releases:?}"
);
}
/// A blacklisted release (§6.3) is not a policy opinion, so no override
@@ -1314,6 +1504,26 @@ mod tests {
.expect("releases json");
assert_eq!(releases[0]["verdict"], "rejected");
assert_eq!(releases[0]["rejected_rule"], "blacklisted");
// #227: a row the blacklist has no entry for keeps rendering — the
// rule is all the record holds, and no reason is invented for it.
assert_eq!(releases[0]["blacklist_reason"], serde_json::Value::Null);
// With the entry, the row says what it was blacklisted for: a
// corrupt file is not the same decision as a size rejection.
arr_db::blacklist::add(pool, None, name, "no original-language audio")
.await
.expect("blacklist");
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(
releases[0]["blacklist_reason"],
"no original-language audio"
);
}
#[tokio::test]
@@ -1430,6 +1640,46 @@ mod tests {
assert_eq!(orphans, 0, "the file rows go with the files");
}
/// `grabs.infohash` is unique, so a row left behind by a deleted title
/// blocks the row a later grab of the same release needs to write — and
/// the grab records nothing while the new title still reads as
/// downloading.
#[tokio::test]
async fn deleting_a_movie_takes_its_grabs_with_it() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let pool = state.database().expect("database").pool();
sqlx::query(
"INSERT INTO releases (id, indexer_id, guid, name, size, download_url, parsed)
VALUES (1, 1, 'guid', 'release', 1, 'magnet:?x', '{}')",
)
.execute(pool)
.await
.expect("release");
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
VALUES (1, 'movie', ?, 'deadbeef', 'sent')",
)
.bind(id)
.execute(pool)
.await
.expect("grab");
let response = reqwest::Client::new()
.delete(format!("{base}/api/movies/{id}"))
.send()
.await
.expect("delete");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let orphans: i64 = sqlx::query_scalar("SELECT count(*) FROM grabs")
.fetch_one(pool)
.await
.expect("count grabs");
assert_eq!(orphans, 0, "no grab outlives the title it was for");
}
/// The guard that keeps a delete inside the library: a path that is not
/// under the title's root is left alone, whatever the row says.
#[tokio::test]
@@ -1545,6 +1795,122 @@ mod tests {
);
}
/// Issue #237: the folder rename #228 wired up already carries a sidecar
/// with it, but the `subtitle_files` row pointing at the old path did not
/// follow — it went stale silently, and `DELETE /api/subtitles/{id}`
/// (#218) would then report success while leaving the real file on disk.
#[tokio::test]
async fn changing_root_moves_the_subtitle_row_with_its_sidecar() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
point_root_at(&state, 2, destination.path()).await;
let pool = state.database().expect("database").pool();
let media_file_id: i64 = sqlx::query_scalar(
"SELECT id FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
)
.bind(id)
.fetch_one(pool)
.await
.expect("media file id");
let sidecar = folder.join("dune.pt.srt");
sqlx::query(
"INSERT INTO subtitle_files (media_file_id, language, origin, provider, path)
VALUES (?, 'pt-PT', 'provider', 'opensubtitles', ?)",
)
.bind(media_file_id)
.bind(sidecar.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("subtitle file");
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], 2);
let path: String =
sqlx::query_scalar("SELECT path FROM subtitle_files WHERE media_file_id = ?")
.bind(media_file_id)
.fetch_one(pool)
.await
.expect("subtitle file row");
assert!(
std::path::Path::new(&path).starts_with(destination.path()),
"the subtitle row follows the file: {path}"
);
assert!(
std::path::Path::new(&path).exists(),
"the rewritten subtitle path describes the disk"
);
}
/// Reported in production: moving a title into a root whose directory has
/// never been written to failed with
/// `could not move '...The Batman...': No such file or directory`, naming
/// a folder that was sitting exactly where the operator left it. A root is
/// a database row and nothing makes its directory exist; `rename` reports
/// a missing destination parent as the same `NotFound` as a missing
/// source. Every earlier test pointed the destination at a `tempdir`,
/// which is why the assumption was never exercised.
#[tokio::test]
async fn a_root_whose_directory_does_not_exist_yet_still_receives_the_title() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let parent = tempfile::tempdir().expect("media tree");
let folder = library_on_disk(&state, id, source.path()).await;
// Configured, but never written to: the row exists, the folder does not.
let destination = parent.path().join("movies").join("kids");
assert!(!destination.exists());
point_root_at(&state, 2, &destination).await;
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root");
assert_eq!(
response.status(),
StatusCode::OK,
"a root that has no folder yet is not a reason to refuse the move"
);
assert!(!folder.exists(), "the folder left the old root");
assert!(
destination
.join("Dune Part Two (2024) [tmdbid-693134]")
.join("Dune Part Two (2024) [tmdbid-693134] - [2160p].mkv")
.exists(),
"the feature arrived in a root that had to be created for it"
);
let path: String = sqlx::query_scalar(
"SELECT path FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
)
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("media file row");
assert!(
std::path::Path::new(&path).starts_with(&destination),
"the row follows the file: {path}"
);
}
/// Issue #228: a title with nothing on disk changes root with no
/// filesystem work at all — the seeded root paths do not even exist.
#[tokio::test]
@@ -1621,22 +1987,25 @@ mod tests {
/// Issue #228: if the rename fails, the row must not change — the
/// operator sees the title where its files actually are and can retry.
#[cfg(unix)]
#[tokio::test]
async fn a_failed_rename_leaves_the_row_alone() {
use std::os::unix::fs::PermissionsExt;
if !crate::privilege::mode_bits_bind().await {
crate::privilege::skipped_because_privileged("a_failed_rename_leaves_the_row_alone");
return;
}
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
// A destination whose parent does not exist makes the rename itself
// fail while the collision pre-check still passes.
point_root_at(
&state,
2,
&destination.path().join("missing").join("library"),
)
.await;
point_root_at(&state, 2, destination.path()).await;
tokio::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o555))
.await
.expect("freeze the title folder");
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
@@ -1645,7 +2014,14 @@ mod tests {
.await
.expect("move root");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body: serde_json::Value = response.json().await.expect("error body");
let error = body["error"].as_str().expect("error text");
assert!(error.contains(folder.to_str().expect("utf-8 source")));
assert!(error.contains(destination.path().to_str().expect("utf-8 destination")));
tokio::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755))
.await
.expect("thaw the title folder");
assert!(folder.exists(), "the folder never left the old root");
let (root_id, path): (i64, String) = sqlx::query_as(
"SELECT m.root_id, f.path FROM movies m
@@ -1880,7 +2256,7 @@ mod tests {
.expect("release")
.last_insert_rowid();
release_ids.push(release_id);
sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) VALUES (?, 'movie', ?, ?, 'failed')")
sqlx::query("INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, failed_at) VALUES (?, 'movie', ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))")
.bind(release_id)
.bind(movie_id)
.bind(format!("hash-{suffix}"))
@@ -2006,8 +2382,9 @@ mod tests {
);
}
/// A series with one empty season, for exercising the season lane on its
/// own.
/// A series with one season holding a single wanted, missing episode — the
/// least that satisfies §5.7's liveness condition — for exercising the
/// season lane on its own.
async fn seed_bare_season(pool: &sqlx::SqlitePool, tmdb_id: i64) -> (i64, i64) {
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
@@ -2031,6 +2408,14 @@ mod tests {
.fetch_one(pool)
.await
.expect("season");
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, 1, 'Episode 1', 1, 'missing')",
)
.bind(season_id)
.execute(pool)
.await
.expect("episode");
(series_id, season_id)
}
@@ -2046,8 +2431,9 @@ mod tests {
.get(0)
}
/// A hard-failed grab, stamped `age_days` in the past so the §5.7 window
/// can be exercised without waiting a month.
/// A hard-failed grab, its failure stamped `age_days` in the past so the
/// §5.7 window — which runs from the failure — can be exercised without
/// waiting a month.
async fn insert_failed_grab(
pool: &sqlx::SqlitePool,
release_id: i64,
@@ -2057,14 +2443,17 @@ mod tests {
age_days: i64,
) {
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at)
VALUES (?, ?, ?, ?, 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
)
.bind(release_id)
.bind(target_kind)
.bind(target_id)
.bind(infohash)
.bind(format!("-{age_days} days"))
.bind(format!("-{age_days} days"))
.execute(pool)
.await
.expect("failed grab");
@@ -2162,6 +2551,159 @@ mod tests {
);
}
/// §5.7: the queue only holds targets still waiting for a file. A season
/// whose pack failed twice, fell back to per-episode grabbing (§6.2) and
/// was then fully acquired is the system working, so it drops out at once
/// instead of sitting there for 30 days (#238).
#[tokio::test]
async fn a_fully_acquired_season_leaves_the_attention_queue() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let (series_id, season_id) = seed_bare_season(pool, 1).await;
sqlx::query(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, 2, 'Episode 2', 1, 'missing')",
)
.bind(season_id)
.execute(pool)
.await
.expect("second episode");
for (guid, hash) in [("pack-one", "hash-one"), ("pack-two", "hash-two")] {
let release_id = insert_release(pool, guid).await;
insert_failed_grab(pool, release_id, "season", season_id, hash, 0).await;
}
assert_eq!(
queued_seasons(&base, series_id).await,
vec![season_id],
"two packs failed and episodes are still missing"
);
// Per-episode grabbing got one of the two. Still a gap, still queued.
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
.bind(season_id)
.execute(pool)
.await
.expect("first episode imported");
assert_eq!(
queued_seasons(&base, series_id).await,
vec![season_id],
"one episode still wanted and missing: still broken, still queued"
);
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
.bind(season_id)
.execute(pool)
.await
.expect("season imported");
assert!(
queued_seasons(&base, series_id).await.is_empty(),
"every episode acquired: the fallback worked, no decision to make"
);
}
/// §5.7: the same liveness condition on the movie and episode lanes, so
/// `GET /api/queues/attention` reports exactly what the daemon notifies
/// on (#238).
#[tokio::test]
async fn an_acquired_movie_or_episode_leaves_the_attention_queue() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let root_id: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE audience = 'kids'")
.fetch_one(pool)
.await
.expect("kids root");
let movie = add_movie(&base, 82728, root_id).await;
let movie_id = movie["id"].as_i64().expect("id");
for (guid, hash) in [("movie-one", "hash-m1"), ("movie-two", "hash-m2")] {
let release_id = insert_release(pool, guid).await;
insert_failed_grab(pool, release_id, "movie", movie_id, hash, 0).await;
}
let (series_id, season_id) = seed_bare_season(pool, 99).await;
let episode_id: i64 =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
.bind(season_id)
.fetch_one(pool)
.await
.expect("episode id");
for (guid, hash) in [("ep-one", "hash-e1"), ("ep-two", "hash-e2")] {
let release_id = insert_release(pool, guid).await;
insert_failed_grab(pool, release_id, "episode", episode_id, hash, 0).await;
}
let queues = attention_queues(&base).await;
assert_eq!(queues["needs_decision"][0]["id"], movie_id);
assert_eq!(
queued_episodes(&queues, series_id),
vec![episode_id],
"still wanted and missing: queued"
);
sqlx::query("UPDATE movies SET state = 'available' WHERE id = ?")
.bind(movie_id)
.execute(pool)
.await
.expect("movie imported");
sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?")
.bind(episode_id)
.execute(pool)
.await
.expect("episode imported");
let queues = attention_queues(&base).await;
assert_eq!(
queues["needs_decision"].as_array().map(Vec::len),
Some(0),
"imported from a third release: no decision to make"
);
assert!(
queued_episodes(&queues, series_id).is_empty(),
"imported from a third release: no decision to make"
);
// Withdrawing intent empties the lane just the same.
sqlx::query("UPDATE movies SET state = 'missing', wanted = 0 WHERE id = ?")
.bind(movie_id)
.execute(pool)
.await
.expect("movie unwanted");
assert_eq!(
attention_queues(&base).await["needs_decision"]
.as_array()
.map(Vec::len),
Some(0),
"nothing is waiting for a file"
);
}
/// The whole attention payload.
async fn attention_queues(base: &str) -> serde_json::Value {
reqwest::get(format!("{base}/api/queues/attention"))
.await
.expect("queues")
.json()
.await
.expect("queues json")
}
/// The episodes a series contributes to the hard-fail TV lane.
fn queued_episodes(queues: &serde_json::Value, series_id: i64) -> Vec<i64> {
queues["tv_needs_decision"]
.as_array()
.expect("tv lane")
.iter()
.filter(|entry| entry["series_id"] == series_id)
.flat_map(|entry| {
entry["episodes"]
.as_array()
.expect("episodes")
.iter()
.map(|episode| episode["id"].as_i64().expect("episode id"))
})
.collect()
}
/// One series hitting all three §9.5 TV entry conditions: two wanted,
/// searched episodes whose every candidate was rejected for language; a
/// season two different packs hard-failed on; and an episode two different
@@ -2231,8 +2773,8 @@ mod tests {
.expect("release")
.get(0);
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
VALUES (?, ?, ?, ?, 'failed')",
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, failed_at)
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
)
.bind(release_id)
.bind(kind)
+332 -9
View File
@@ -173,6 +173,33 @@ struct PolicyColumns {
score_weights: String,
}
/// Everything in a policy row that a verdict depends on — the name is the
/// one column that does not.
#[derive(PartialEq, Eq)]
struct PolicyRules {
required_audio: String,
dub_blacklist: String,
hdr_rules: String,
size_bands: String,
resolution_pref: String,
source_weights: String,
score_weights: String,
}
impl PolicyColumns {
fn rules(&self) -> PolicyRules {
PolicyRules {
required_audio: self.required_audio.clone(),
dub_blacklist: self.dub_blacklist.clone(),
hdr_rules: self.hdr_rules.clone(),
size_bands: self.size_bands.clone(),
resolution_pref: self.resolution_pref.clone(),
source_weights: self.source_weights.clone(),
score_weights: self.score_weights.clone(),
}
}
}
fn column<T: serde::de::DeserializeOwned>(
column: &'static str,
value: &str,
@@ -376,6 +403,8 @@ pub async fn update(
input.validate().map_err(ApiError::Invalid)?;
let name = input.name.trim().to_owned();
let columns = input.into_columns(name)?;
let after = columns.rules();
let before = stored_rules(&state, id).await?;
let result = sqlx::query!(
r#"UPDATE policies SET
name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?,
@@ -405,9 +434,44 @@ pub async fn update(
if result.rows_affected() == 0 {
return Err(ApiError::PolicyNotFound);
}
// Every verdict stored under every root pointing here was reached under
// the rules this write just replaced; §9.3's deck and the grab gate both
// read them (`reclassify`). A rename leaves the rules alone, so it walks
// nothing.
if before.is_none_or(|before| before != after) {
crate::reclassify::policy(&state, id).await?;
}
Ok(Json(load_policy(&state, id).await?))
}
/// The rule columns of a policy as they stand, or `None` when there is no
/// such row. Compared against what the write is about to store, so an edit
/// that only moves the name does not re-derive a library.
async fn stored_rules(state: &AppState, id: i64) -> Result<Option<PolicyRules>, ApiError> {
let row = sqlx::query!(
r#"SELECT required_audio AS "required_audio!: String",
dub_blacklist AS "dub_blacklist!: String",
hdr_rules AS "hdr_rules!: String",
size_bands AS "size_bands!: String",
resolution_pref AS "resolution_pref!: String",
source_weights AS "source_weights!: String",
score_weights AS "score_weights!: String"
FROM policies WHERE id = ?"#,
id
)
.fetch_optional(pool(state)?)
.await?;
Ok(row.map(|row| PolicyRules {
required_audio: row.required_audio,
dub_blacklist: row.dub_blacklist,
hdr_rules: row.hdr_rules,
size_bands: row.size_bands,
resolution_pref: row.resolution_pref,
source_weights: row.source_weights,
score_weights: row.score_weights,
}))
}
#[utoipa::path(
delete, path = "/api/policies/{policy_id}", tag = "policies",
params(("policy_id" = i64, Path, description = "Policy row id")),
@@ -450,7 +514,7 @@ mod tests {
use crate::{router, Upstreams};
use axum::http::StatusCode;
async fn application() -> (tempfile::TempDir, String) {
async fn application() -> (tempfile::TempDir, AppState, String) {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
@@ -466,9 +530,9 @@ mod tests {
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let app = router(state);
let app = router(state.clone());
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, format!("http://{address}"))
(dir, state, format!("http://{address}"))
}
fn valid_input(name: &str) -> serde_json::Value {
@@ -501,7 +565,7 @@ mod tests {
#[tokio::test]
async fn crud_round_trips_a_policy() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let created: serde_json::Value = create(&base, valid_input("test policy"))
.await
@@ -557,7 +621,7 @@ mod tests {
#[tokio::test]
async fn a_referenced_policy_refuses_to_die() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
@@ -582,7 +646,7 @@ mod tests {
#[tokio::test]
async fn an_unknown_resolution_is_a_422_naming_the_field() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let mut payload = valid_input("bad bands");
payload["size_bands"]["1440p"] = serde_json::json!({ "floor_gib": 2, "target_gib": 6, "penalty_points_per_gib_over": 60 });
@@ -596,7 +660,7 @@ mod tests {
#[tokio::test]
async fn every_field_validates_by_name() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let with = |patch: &dyn Fn(&mut serde_json::Value)| {
let mut payload = valid_input("validation probe");
patch(&mut payload);
@@ -645,7 +709,7 @@ mod tests {
#[tokio::test]
async fn malformed_json_is_422_not_400_or_500() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let client = reqwest::Client::new();
let response = client
.post(format!("{base}/api/policies"))
@@ -667,8 +731,267 @@ mod tests {
#[tokio::test]
async fn a_duplicate_name_conflicts() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let response = create(&base, valid_input("Movies — main")).await;
assert_eq!(response.status(), StatusCode::CONFLICT);
}
async fn policy_id_named(base: &str, name: &str) -> i64 {
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
.await
.expect("policies")
.json()
.await
.expect("policies json");
policies
.iter()
.find(|policy| policy["name"] == name)
.and_then(|policy| policy["id"].as_i64())
.expect("policy id")
}
/// A movie under `root_id`, with one English 1080p release stamped
/// `verdict` as a search would have stamped it.
async fn movie_with_release(
state: &AppState,
base: &str,
root_id: i64,
tmdb_id: i64,
verdict: &str,
) -> i64 {
let movie: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": tmdb_id, "title": format!("Title {tmdb_id}"), "year": 2024,
"original_language": "en", "root_id": root_id, "overrides": {}
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
let movie_id = movie["id"].as_i64().expect("movie id");
let name = format!("Title {tmdb_id} 2024 1080p WEB-DL ENGLISH x264-GROUP");
let release_id = stamped_release(state, &name, 6_i64 * (1 << 30), verdict).await;
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
.bind(movie_id)
.bind(release_id)
.execute(state.database().expect("database").pool())
.await
.expect("movie association");
movie_id
}
/// One release row, verdict stamped by hand. `waived` carries no rule,
/// which the `releases` CHECK allows — only `rejected` needs one.
async fn stamped_release(state: &AppState, name: &str, size: i64, verdict: &str) -> i64 {
let parsed = arr_parse::parse(name);
sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
VALUES (7, ?, ?, ?, 40, 'url', ?, 0, ?) RETURNING id",
)
.bind(name)
.bind(name)
.bind(size)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.bind(verdict)
.fetch_one(state.database().expect("database").pool())
.await
.expect("release")
}
/// A series under `root_id` with a ten-episode season 9 pack stamped
/// `waived`. Under its own policy the pack is eligible, so the stamp
/// only survives if nothing re-derived it.
async fn series_with_waived_pack(state: &AppState, base: &str, root_id: i64) -> i64 {
let client = reqwest::Client::new();
let series: serde_json::Value = client
.post(format!("{base}/api/series"))
.json(&serde_json::json!({
"tmdb_id": 82_728, "title": "Bluey", "year": 2018,
"original_language": "en", "root_id": root_id,
"auto_track": false
}))
.send()
.await
.expect("create series")
.json()
.await
.expect("series json");
let series_id = series["id"].as_i64().expect("series id");
let episodes: Vec<serde_json::Value> = (1..=10)
.map(|number| {
serde_json::json!({
"number": number, "title": format!("Episode {number}"),
"air_date": "2025-01-01"
})
})
.collect();
let season: serde_json::Value = client
.post(format!("{base}/api/series/{series_id}/seasons"))
.json(&serde_json::json!({"number": 9, "episodes": episodes}))
.send()
.await
.expect("create season")
.json()
.await
.expect("season json");
let season_id = season["id"].as_i64().expect("season id");
// Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
let release_id = stamped_release(
state,
"Bluey S09 1080p WEB-DL ENGLISH x264-GROUP",
15_i64 * (1 << 30),
"waived",
)
.await;
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
.bind(season_id)
.bind(release_id)
.execute(state.database().expect("database").pool())
.await
.expect("season association");
series_id
}
async fn verdict_at(url: String) -> serde_json::Value {
let releases: Vec<serde_json::Value> = reqwest::get(url)
.await
.expect("releases")
.json()
.await
.expect("releases json");
releases[0]["verdict"].clone()
}
async fn movie_verdict(base: &str, movie_id: i64) -> serde_json::Value {
verdict_at(format!("{base}/api/movies/{movie_id}/releases")).await
}
async fn root_id_of(state: &AppState, kind: &str, audience: &str) -> i64 {
sqlx::query_scalar("SELECT id FROM roots WHERE kind = ? AND audience = ?")
.bind(kind)
.bind(audience)
.fetch_one(state.database().expect("database").pool())
.await
.expect("root id")
}
/// The same policy document with a different required-audio rule, so an
/// English release that was eligible becomes a waiver.
fn requires_portuguese(name: &str) -> serde_json::Value {
let mut payload = valid_input(name);
payload["required_audio"] = serde_json::json!({ "require": "any_of", "langs": ["pt-PT"] });
payload
}
/// #246: editing a policy re-derives the stored verdicts of every title
/// under every root pointing at it (§5.1) — both libraries when two
/// roots share it, and nothing under a root that does not.
#[tokio::test]
async fn editing_a_policy_rederives_every_root_that_points_at_it() {
let (_dir, state, base) = application().await;
let client = reqwest::Client::new();
let shared = policy_id_named(&base, "Movies — main").await;
let main_root = root_id_of(&state, "movie", "main").await;
let kids_root = root_id_of(&state, "movie", "kids").await;
// Two roots on one policy: the edit has two libraries to reach
// rather than one. The path stays, so nothing on disk moves.
let response = client
.put(format!("{base}/api/roots/{kids_root}"))
.json(&serde_json::json!({
"kind": "movie", "audience": "kids",
"path": "/mnt/media/movies/kids", "policy_id": shared,
}))
.send()
.await
.expect("repoint kids root");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
let here = movie_with_release(&state, &base, main_root, 693_134, "eligible").await;
let there = movie_with_release(&state, &base, kids_root, 27_205, "eligible").await;
// On the TV main root, a different policy, so the edit must not
// reach it. Stamped against its own policy's answer, so a walk that
// did reach it would show.
let tv_root = root_id_of(&state, "tv", "main").await;
let series_id = series_with_waived_pack(&state, &base, tv_root).await;
let response = client
.put(format!("{base}/api/policies/{shared}"))
.json(&requires_portuguese("Movies — main"))
.send()
.await
.expect("edit policy");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
assert_eq!(
movie_verdict(&base, here).await,
"waived",
"the first root's library re-derives"
);
assert_eq!(
movie_verdict(&base, there).await,
"waived",
"and so does the second root's, sharing the policy"
);
assert_eq!(
verdict_at(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
"waived",
"a root on another policy keeps the verdict it was stamped with"
);
}
/// Renaming a policy changes no rule, so it re-derives nothing — the
/// stamped verdict survives even though the rules would not produce it.
#[tokio::test]
async fn renaming_a_policy_leaves_verdicts_alone() {
let (_dir, state, base) = application().await;
let client = reqwest::Client::new();
let kids = policy_id_named(&base, "Movies — kids").await;
let kids_root = root_id_of(&state, "movie", "kids").await;
// Write the rules through the API once, so the rename that follows
// stores byte-identical rule columns and the only change is the name.
let response = client
.put(format!("{base}/api/policies/{kids}"))
.json(&valid_input("Movies — kids"))
.send()
.await
.expect("normalise policy");
assert_eq!(response.status(), StatusCode::OK);
// Deliberately the wrong answer: these rules make an English release
// a waiver, so a re-derivation would move this row.
let movie = movie_with_release(&state, &base, kids_root, 157_336, "eligible").await;
let response = client
.put(format!("{base}/api/policies/{kids}"))
.json(&valid_input("Movies — children"))
.send()
.await
.expect("rename policy");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
assert_eq!(
movie_verdict(&base, movie).await,
"eligible",
"a rename touches no rule, so it walks nothing"
);
}
}
+36
View File
@@ -0,0 +1,36 @@
//! Whether mode bits actually constrain this process.
//!
//! Four tests force a filesystem failure by freezing a directory to `0o555`
//! and asserting the handler reports it. Mode bits do not constrain a
//! privileged user, so under `root` — which is what the CI container runs
//! as — the operation succeeds and the assertion fails for a reason that has
//! nothing to do with the code under test.
//!
//! The probe asks the filesystem rather than asking for the uid: what the
//! tests depend on is the refusal, not the identity, and a container can
//! hold `CAP_DAC_OVERRIDE` without being uid 0.
/// True when a read-only directory refuses a write to this process.
pub(crate) async fn mode_bits_bind() -> bool {
use std::os::unix::fs::PermissionsExt;
let probe = tempfile::tempdir().expect("probe root");
let frozen = probe.path().join("frozen");
tokio::fs::create_dir(&frozen).await.expect("probe folder");
tokio::fs::set_permissions(&frozen, std::fs::Permissions::from_mode(0o555))
.await
.expect("freeze the probe folder");
let refused = tokio::fs::write(frozen.join("probe"), b"x").await.is_err();
// `TempDir::drop` needs the write back to remove the tree
tokio::fs::set_permissions(&frozen, std::fs::Permissions::from_mode(0o755))
.await
.expect("thaw the probe folder");
refused
}
/// Announces a test that cannot run here, on stderr, which `nextest` prints
/// when the run is given `--no-capture` and swallows otherwise — the same
/// deal every other skipped case in a Rust suite gets.
pub(crate) fn skipped_because_privileged(test: &str) {
eprintln!("{test}: skipped — this process overrides mode bits, so the failure it forces cannot happen");
}
+67 -6
View File
@@ -1,10 +1,17 @@
//! Stored verdicts, re-derived when a title's overrides change (§9.3).
//! Stored verdicts, re-derived when a title's effective policy changes:
//! an overrides edit (§9.3), a move to a root with a different policy, a
//! root pointed at a different policy, or an edit to the contents of a
//! policy some root points at (§5.1).
//!
//! A release's verdict is stamped once, by the search that found it. Both the
//! deck and the daemon's manual-grab gate read that stored column, so an
//! override written from the deck's one-click waive would change nothing
//! until the next sweep — the row the operator just acted on would keep
//! reading `rejected` and the grab would be refused.
//! reading `rejected` and the grab would be refused. A root move and a
//! policy repoint invalidate the column the same way, just wider: nothing
//! else re-reads it, so the write that changed the effective policy is the
//! only place the correction can happen. A policy edit invalidates it wider
//! still: every root pointing at that policy, not just one.
//!
//! So the rules run again here, over the releases already attached to the
//! title. This is the same correction the daemon makes when a grab turns out
@@ -141,6 +148,60 @@ pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiEr
.await
}
/// Re-evaluate every release of every title under one root, for a
/// `PUT /api/roots/{id}` that pointed the root at a different policy (§5.1).
///
/// Title by title through [`movie`] and [`series`], so the skip rules — no
/// stored original language, the blacklist (§6.3) — stay in one place. Rows
/// whose verdict the new policy does not move are left unwritten, so the
/// usual case (most of a library re-evaluates to the same answer) costs
/// reads, not writes.
pub(crate) async fn root(state: &AppState, root_id: i64) -> Result<(), ApiError> {
let movie_ids = sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM movies WHERE root_id = ?"#,
root_id
)
.fetch_all(pool(state)?)
.await?;
for movie_id in movie_ids {
movie(state, movie_id).await?;
}
let series_ids = sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM series WHERE root_id = ?"#,
root_id
)
.fetch_all(pool(state)?)
.await?;
for series_id in series_ids {
series(state, series_id).await?;
}
Ok(())
}
/// Re-evaluate every release of every title under every root that points at
/// one policy, for a `PUT /api/policies/{id}` that changed its rules (§5.1).
///
/// Root by root through [`root`], which is title by title through [`movie`]
/// and [`series`]: one walker, one set of skip rules, one place that decides
/// a row does not need rewriting.
///
/// This is the widest of the four re-derivations — a root repoint moves one
/// library, a policy edit moves every library sharing the policy — but the
/// ceiling is the whole database rather than something that grows with it,
/// since roots partition titles and a title has exactly one root.
pub(crate) async fn policy(state: &AppState, policy_id: i64) -> Result<(), ApiError> {
let root_ids = sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM roots WHERE policy_id = ?"#,
policy_id
)
.fetch_all(pool(state)?)
.await?;
for root_id in root_ids {
root(state, root_id).await?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn apply(
state: &AppState,
@@ -173,11 +234,11 @@ async fn apply(
episodes,
runtime_minutes,
);
// `releases` allows a rule name only on a rejected row
// (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`),
// which is also how the daemon writes a waiver.
// A waiver records the rule it relaxed, the same as a rejection
// (#211). Migration 0032 relaxed `releases` to
// `CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)` so it
// can, and the daemon writes waivers the same way.
let (verdict, rule) = verdict_columns(&evaluation.verdict);
let rule = if verdict == "rejected" { rule } else { None };
if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule {
continue;
}

Some files were not shown because too many files have changed in this diff Show More