Compare commits

...

234 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 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 efb47d64e7 feat(api): move title folders on root path change
Changing a root's path rewrote the row and moved nothing, so every title
under it was mislocated at once. It now reuses the #228 mover: plan every
rename, refuse a destination that already exists, rewrite the media_files
rows in the same transaction as the row change.

The move is all or nothing. A root row carries one path, so a half-moved
library would have to describe both places; instead one folder that
cannot move puts back the ones that already did and leaves the root's
path alone, and the same request is the retry.

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

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

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

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

Gate: `just ci` green (486 tests).
2026-08-25 10:09:07 +01:00
Miguel Palhas 9fc7272765 Merge #228: move title files on root change
Closes #228
2026-08-25 10:02:00 +01:00
Miguel Palhas bce3d3823d feat(api): move title files on root change
Changing a movie's or series' root previously rewrote root_id and left
the files behind, so the §7.4 layout stopped describing the disk and
the root's policy applied to a library the files were not in. Series
had no root control at all.

All roots share one ZFS dataset, so the move is a rename of the title
folder into the new root, never a copy — hardlinks and the seeding
torrent survive it (§7.3). Disk first, row second: a destination that
already holds the folder is a 409, a failed rename leaves the row
unchanged, and a title with nothing on disk moves with no filesystem
work. media_files rows are rewritten in the same transaction as the
root_id, and a successful move triggers the §7.5 Jellyfin refresh.

Issue #228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:55:18 +01:00
Miguel Palhas c64c572781 refactor(arr): give arr-api its own jellyfin client
arr-daemon depends on arr-api, so a handler in arr-api can never
reach the daemon's private JellyfinClient. Move it into arr-api and
attach an instance to AppState, so a manual subtitle write can ask
for the same refresh import already does (#195).
2026-08-25 09:47:53 +01:00
Miguel Palhas 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
205 changed files with 33466 additions and 1860 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.",
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_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"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"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 GROUP BY s.id, s.title, s.year, se.id\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": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -58,5 +58,5 @@
false
]
},
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
"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,12 @@
{
"db_name": "SQLite",
"query": "UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b"
"hash": "2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41"
}
@@ -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') >= 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": [
{
@@ -38,7 +38,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -46,5 +46,5 @@
true
]
},
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
"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": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
"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') >= 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": [
{
@@ -170,7 +170,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -190,5 +190,5 @@
true
]
},
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
"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 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": [
{
@@ -82,7 +82,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -94,5 +94,5 @@
false
]
},
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
"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"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT path AS \"path!: String\" FROM roots WHERE id = ?",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953"
}
@@ -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 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": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -58,5 +58,5 @@
false
]
},
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
"hash": "6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8"
}
@@ -0,0 +1,38 @@
{
"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 JOIN series s ON s.id = se.series_id\n WHERE s.root_id = ?\n ORDER BY mf.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"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa"
}
@@ -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 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 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": "f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba"
"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 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 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": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e"
"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"
}
@@ -0,0 +1,38 @@
{
"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 = ?",
"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"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "a2023f024d53aa5f139da347f22af38d57c6f846bb8eff74958fd4d1299268f8"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM roots WHERE policy_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"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,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id\n WHERE m.root_id = ?\n ORDER BY mf.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"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302"
}
@@ -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": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
"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 GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.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 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": [
{
@@ -71,7 +71,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -82,5 +82,5 @@
false
]
},
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
"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
+62 -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",
@@ -42,6 +45,7 @@ dependencies = [
"serde_json",
"sqlx",
"tempfile",
"thiserror",
"tokio",
"tracing",
"utoipa",
@@ -87,8 +91,8 @@ dependencies = [
"arr-meta",
"arr-parse",
"arr-probe",
"arr-subs",
"axum",
"base64",
"chrono",
"include_dir",
"mime_guess",
@@ -118,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",
@@ -138,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",
@@ -193,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"
@@ -365,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"
@@ -530,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"
@@ -1497,6 +1543,7 @@ dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
@@ -1505,6 +1552,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -1687,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"
@@ -1886,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"] }
+311 -23
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
@@ -378,6 +406,46 @@ A policy violation found by `ffprobe` is not one thing.
Neither deletes the torrent. See §7.3.
**Two hard failures make a decision, and only for 30 days.** A movie, an
episode or a season enters the needs-a-decision queue (§9.5) when two grabs
against *different* releases hard-failed on it, and both of those failures
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
§6.2's "it never gives up entirely, it goes quiet": a target the operator has
dealt with stops producing failures and drops out once the last one ages past
30 days, while a target that is still broken keeps producing them — the pack
guard retries at worst weekly (§6.2) — and stays queued for exactly as long as
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
@@ -420,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.
@@ -434,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
@@ -457,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
@@ -488,6 +588,13 @@ 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.
During transition, write into the existing roots so Jellyfin needs no
reconfiguration and new content appears immediately. Radarr will not touch a
folder it has no record of.
@@ -509,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.
@@ -596,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.
@@ -651,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.
@@ -660,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`.
@@ -669,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.
@@ -684,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
@@ -740,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
@@ -768,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
@@ -788,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
+4
View File
@@ -8,16 +8,20 @@ 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 }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
utoipa = { 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");
}
}

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