Compare commits

...

301 Commits

Author SHA1 Message Date
Miguel Palhas d97cdfd557 fix(subs): ask OpenSubtitles for SRT at download
ci / web (push) Successful in 35s
ci / rust (push) Successful in 1m44s
e2e / e2e (push) Failing after 1m51s
ci / image (push) Successful in 2m23s
A search entry often carries no `format`, so `format_of(None)` produced
`Other("")`, that was stashed at search time and handed back as the
download's format, and conversion had no parser to pick:

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

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

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

Closes #272

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

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

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

Closes #271

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

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

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

Closes #270

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

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

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

Closes #268

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Post-download the floor now waives instead, whatever the overrides say.
The file imports and carries a waiver naming the floor it missed.
2026-08-26 18:34:57 +01:00
Miguel Palhas d3613c6081 Merge the Download visibility milestone
ci / web (push) Successful in 1m16s
ci / rust (push) Successful in 2m13s
e2e / e2e (push) Failing after 3m14s
Live download state, inline on the row that owns the item: DESIGN.md §9.8,
GET /api/downloads over arr's own grabs, chips on the movie, season and
episode rows, and a stale season import failure that now yields to a newer
attempt. Closes #262 #263 #264 #265 #266 #267.
2026-08-26 13:12:10 +01:00
Miguel Palhas 61d84ff51b Merge remote-tracking branch 'origin/issue/266-drop-status-chip' into blitz/download-visibility 2026-08-26 13:07:25 +01:00
Miguel Palhas 640bda3128 fix(web): show download chip on movie detail (#267) 2026-08-26 13:05:30 +01:00
Miguel Palhas 120416aabe fix(web): drop redundant status chip beside counts (#266) 2026-08-26 13:05:07 +01:00
Miguel Palhas 46554cfc75 fix(api): a vanished grab takes no headline
Superseding an import failure with any non-failed grab let a 'vanished'
one take the headline: the torrent has left Transmission, so the season
would show neither the failure nor any progress while the gap is still
there.
2026-08-26 12:55:52 +01:00
Miguel Palhas 42c1cd4dce Merge remote-tracking branch 'origin/issue/265-supersede-failure' into blitz/download-visibility 2026-08-26 12:55:20 +01:00
Miguel Palhas 77bf409c5d fix(arr): supersede stale import failure 2026-08-26 12:54:47 +01:00
Miguel Palhas eb21897c3d Merge remote-tracking branch 'origin/issue/264-download-chips' into blitz/download-visibility 2026-08-26 12:48:13 +01:00
Miguel Palhas a5b2131d4c feat(web): show live download state on rows (#264) 2026-08-26 12:47:19 +01:00
Miguel Palhas 5ba49edfd4 fix(dl): take stalled from Transmission
A zero download rate is not a stalled torrent: one between peers reads
zero for a poll or two and finishes fine, and at §9.8's 15s cadence that
flicker would raise the one chip reserved for a download that never
finishes. Transmission already decides this with its own stalled window;
carry isStalled and use it.
2026-08-26 12:27:42 +01:00
Miguel Palhas ad36a0aa83 Merge remote-tracking branch 'origin/issue/263-downloads-api' into blitz/download-visibility 2026-08-26 12:25:54 +01:00
Miguel Palhas 49d23e071a feat(arr): expose live downloads 2026-08-26 12:25:15 +01:00
Miguel Palhas 54438bb6ad docs(design): add the download surface as §9.8 2026-08-26 12:12:37 +01:00
naps62-yolo adb61dda3b feat(api): extract an embedded track on demand (#261)
ci / web (push) Successful in 40s
ci / rust (push) Successful in 3m22s
e2e / e2e (push) Failing after 3m31s
Closes #260.
2026-08-26 12:08:42 +01:00
naps62-yolo dfa4656509 fix(infra): ship alass and the translation backends (#259)
ci / web (push) Successful in 1m29s
ci / rust (push) Successful in 2m34s
e2e / e2e (push) Failing after 3m57s
2026-08-26 09:51:09 +01:00
naps62-yolo bd5131f424 fix(web): collapse subtitles into one row chip (#258)
ci / web (push) Successful in 32s
ci / rust (push) Successful in 2m26s
e2e / e2e (push) Failing after 3m58s
2026-08-26 08:58:14 +01:00
Miguel Palhas 656ca6c259 fix(api): skip mode-bit tests when they cannot bind
ci / web (push) Successful in 1m5s
ci / rust (push) Successful in 1m56s
e2e / e2e (push) Failing after 7m5s
Four tests force a filesystem failure by freezing a directory to 0o555.
The CI container runs as root, mode bits do not constrain root, and the
rename those tests expect to fail succeeds — main has been red on
a_failed_rename_leaves_the_row_alone since the move-on-root-change work
landed, with nextest's fail-fast hiding the other three.

The guard probes the filesystem rather than the uid: what the tests
depend on is the refusal, and a container can hold CAP_DAC_OVERRIDE
without being uid 0.
2026-08-26 08:35:26 +01:00
Miguel Palhas 425d154d54 Merge the Subtitles milestone
ci / web (push) Successful in 42s
ci / rust (push) Failing after 2m18s
e2e / e2e (push) Failing after 3m36s
DESIGN.md §15 end to end: provider search and download, embedded-track
extraction, translation behind four feature-gated backends, `alass` sync,
sidecar naming and the `.mt` machine-made segment, per-provider and
per-translator daily budgets, and the subtitle lane in /settings.

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

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

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

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

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

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

Closes #255
2026-08-25 22:33:40 +01:00
Miguel Palhas f3266a712b fix(web): keep episode rows on one line
ci / web (push) Successful in 45s
e2e / e2e (push) Successful in 1m44s
ci / rust (push) Failing after 2m1s
2026-08-25 22:04:34 +01:00
Miguel Palhas eeada0ef65 fix(web): keep season controls on one line
ci / web (push) Successful in 55s
e2e / e2e (push) Successful in 1m30s
ci / rust (push) Failing after 1m38s
2026-08-25 21:14:29 +01:00
Miguel Palhas 06f8a6f0ea fix(web): align the reserved delete slot
ci / web (push) Successful in 31s
e2e / e2e (push) Successful in 1m18s
ci / rust (push) Failing after 1m40s
2026-08-25 19:17:07 +01:00
Miguel Palhas dee7340a01 fix(web): steady action slot, red delete, clickable season
Closes #254
2026-08-25 19:06:25 +01:00
Miguel Palhas c9e44b9c03 fix(web): make the check chip's word reachable 2026-08-25 18:32:02 +01:00
Miguel Palhas e446da5858 Merge #253: row affordances, duplicate rows, check chip
Closes #253
2026-08-25 18:31:47 +01:00
Miguel Palhas 4f2e443045 merge: catch up with blitz/feedback-3
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:28:30 +01:00
Miguel Palhas a97795a997 fix(web): show poster in library series rows
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:28:17 +01:00
Miguel Palhas 17ad82f541 fix(web): drop duplicate tmdb hits from search
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:28:12 +01:00
Miguel Palhas 73d56fe821 fix(web): style row-add like a control
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:27:37 +01:00
Miguel Palhas 1bd482db2a fix(web): drop border on the title hero
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:27:21 +01:00
Miguel Palhas e157917797 fix(web): show available state as a check glyph
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 18:27:17 +01:00
Miguel Palhas eeaad52257 fix(web): draw a gear, not a sun 2026-08-25 18:19:19 +01:00
Miguel Palhas 4ba921b45a Merge #252: strip the rail to wordmark, warning, search, cog
Closes #252
2026-08-25 18:17:18 +01:00
Miguel Palhas a2b76bd634 feat(web): distill shell rail 2026-08-25 18:15:46 +01:00
Miguel Palhas 20a3b2847b Merge #251: adopt the shared icon and control vocabulary
Closes #251
2026-08-25 18:14:27 +01:00
Miguel Palhas 86af84be6d Merge #237: rewrite subtitle paths on relocate
Closes #237
2026-08-25 18:14:27 +01:00
Miguel Palhas a3186b2305 refactor(arr): chevron icons for provider order 2026-08-25 18:11:11 +01:00
Miguel Palhas 1afd8c1998 refactor(arr): subtitle chip takes shared delete
Drops the hand-rolled arm, disarm, timeout and label swap, and the local
12-grid close glyph that came with it.
2026-08-25 18:11:11 +01:00
Miguel Palhas 1e778afd18 feat(arr): compact armed delete and new glyphs
The subtitle chip needs the settings arm-then-confirm at chip scale: a
chip fits neither the control plate nor the word "confirm". The shared
control grows a compact variant that swaps the glyph instead, rather than
each view keeping a private copy of the idiom.
2026-08-25 18:11:06 +01:00
Miguel Palhas c9369d1c4f fix(arr): rewrite subtitle_files paths on relocate
relocate.rs gathered only media_files rows for a title/root move,
leaving subtitle_files rows pointing at the old folder. Sidecars
already ride along in the folder rename; only their rows were stale.

Tags each rewrite with its owning table (media_files or
subtitle_files) and updates both in the same transaction.
2026-08-25 18:10:52 +01:00
Miguel Palhas 34e5663920 Merge main into blitz/subtitles
Feedback pass 2 and the size-band work landed on main while this branch
was finishing. Brings them in ahead of the merge back.

# Conflicts:
#	crates/arr-api/src/movies.rs
#	crates/arr-api/src/state.rs
#	crates/arr-daemon/src/main.rs
#	web/src/main.ts
2026-08-25 17:53:18 +01:00
Miguel Palhas 8947febbff fix(web): add series library selector
ci / web (push) Successful in 1m20s
e2e / e2e (push) Successful in 2m8s
ci / rust (push) Failing after 14m52s
2026-08-25 14:24:30 +01:00
Miguel Palhas bd5b00b14a fix(api): create title move destination
ci / web (push) Successful in 34s
ci / rust (push) Failing after 1m48s
e2e / e2e (push) Successful in 1m49s
2026-08-25 13:01:16 +01:00
Miguel Palhas 90efeaa442 Merge milestone 'Feedback pass 2'
ci / web (push) Successful in 30s
ci / rust (push) Failing after 1m40s
e2e / e2e (push) Successful in 2m13s
17 issues: the operator's UI feedback (back button, icon-only controls,
one-line settings rows, plainer words), the root-move and root-path
relocation work, the attention queue's threshold, window, anchor and
liveness rule, reclassify on every action that changes an effective
policy, and the waived rule reaching the deck.

Gate green at 524 tests. Migrations 0030, 0031 and 0032 verified against
a database built through the production upgrade path; 0032's rebuild
preserves rows and children with foreign_key_check clean.
2026-08-25 12:17:01 +01:00
Miguel Palhas 5d80177622 Merge #227: say what a pack was abandoned for
Closes #227
2026-08-25 12:12:34 +01:00
Miguel Palhas 591cf27dc5 feat(web): say what a pack was abandoned for
A pack that hard-failed at import blacklisted its release, put every
episode back to missing and left the season reading 0/10, with nothing
on screen joining the two. Every fact was already recorded.

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

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

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

Refs #227, #211
2026-08-25 12:11:46 +01:00
Miguel Palhas 9e445d0398 Merge #240: reconcile design coherence in 5.7 and 9.5
Closes #240
2026-08-25 11:50:56 +01:00
Miguel Palhas 1e30c49a72 Merge #245: pack backoff runs from the failure
Closes #245
2026-08-25 11:50:39 +01:00
Miguel Palhas 55373d228c fix: pack backoff runs from the failure
#239 moved §5.7's attention window to `failed_at` and left §6.2's pack
ladder on `grabbed_at`. A torrent that stalls for weeks before ffprobe
condemns it at import has elapsed the whole ladder the moment it fails,
so the pack lane retried a source that had just failed — the one thing
the backoff exists to prevent.

The ladder now measures from the failure, the same anchor and the same
column §5.7 reads, with `grabbed_at` as the fallback for rows written
before the column existed. All three sites read
`max(coalesce(failed_at, grabbed_at))`, so the `last_failed_at` alias
holds what its name says — including the one the season deck feeds into
`reopens_at` and `pack_retry_at`, which was showing a grab time under a
name §5.7 had redefined.

DESIGN.md §6.2 states the anchor the way §5.7 states its own.

Tests cover a pack grabbed 35 days ago and failed 10 minutes ago on the
targeted lane, the RSS lane and the season deck.

`just ci` through the gate: 519/519 tests pass, web checks clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:49:44 +01:00
Miguel Palhas c78c73ec6c docs: reconcile design coherence issues in §5.7 and §9.5
§9.5 restated the pre-#226 decision-bar rule without the 30-day window,
giving the document two versions of the same rule. Make it defer to
§5.7 instead.

§5.7 used two phrasings for the season-queuing predicate in one
paragraph (existence-of-file vs. state check) though both readers
implement the state check; picked the state phrasing throughout. Also
fixed the arithmetically confusing "seen twice ... third face"
sentence, and rewrapped the single unwrapped ~450-character line in
§7.4 to the document's ~78-column width.

Ref #240. Ran full `just ci` through the gate (517 tests, exit 0).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 11:49:43 +01:00
Miguel Palhas 58a45fc98e Merge #211: let a waiver name the rule it relaxed
Closes #211
2026-08-25 11:47:25 +01:00
Miguel Palhas a0dc07f085 feat(db): let a waiver name the rule it relaxed
`releases` forbade a rule name on anything but a rejection, so §9.3's
deck showed a bare `waived` beside rejections that each named their own,
and §5.7's "watchable but not what was asked" lost the half that says
what was not asked for. Since #210 that is the ordinary outcome of
waiving a size rejection, not a rare one.

0032 rebuilds the table with `CHECK (verdict != 'rejected' OR
rejected_rule IS NOT NULL)`, and the daemon and arr-api's
reclassification both store the waived rule. Existing rows keep NULL and
read as they do today.

`releases` is a parent — `grabs`, `movie_releases`, `episode_releases`
and `season_releases` point at it, three ON DELETE CASCADE — so the
rebuild runs `-- no-transaction` with foreign keys off around one
explicit transaction, per SQLite's own procedure. Verified against a
real database: the pre-0032 binary created and populated it, this build
migrated a copy, and every release row, child row and created_at came
through byte-identical with `PRAGMA foreign_key_check` clean.

Refs #211
2026-08-25 11:44:05 +01:00
Miguel Palhas de6c35cce3 Merge #232: plainer words for grab, blocked, deck
Closes #232
2026-08-25 11:43:55 +01:00
Miguel Palhas a6720f4c06 feat(web): plainer words for grab, blocked, deck
Three words the operator reads change; nothing underneath does. Waivers
are still written, recorded and served under their existing names, the
`blocked` column and flag keep theirs, and the release deck keeps its
name in DESIGN.md §9.3 and in the code.

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

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

Refs #232
2026-08-25 11:43:29 +01:00
Miguel Palhas 9a413b10ed Merge #246: re-derive verdicts on a policy edit
Closes #246
2026-08-25 11:42:10 +01:00
Miguel Palhas bbb6d2f4a4 feat(api): re-derive verdicts on a policy edit
PUT /api/policies/{id} changed the rule every title under every root
pointing at the policy is judged by, and re-derived nothing, so §9.3's
deck and the daemon's grab gate kept reading verdicts computed under
rules that no longer existed.

Drives #241's walker from a policy id: root by root through
reclassify::root, so the skip rules and the leave-unchanged-rows-alone
rule stay in one place. A rename touches no rule and walks nothing.

Inline still holds at this width. Measured on a release build over 2000
titles and 10 000 stored releases across two roots sharing one policy:
0.36 s when no verdict moves, 2.7 s when all 10 000 do. DESIGN.md §5.1
now names four actions and carries those numbers.

just ci passed through the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:40:32 +01:00
Miguel Palhas c454b3ec11 Merge #244: normalise stored root paths
Closes #244
2026-08-25 11:29:13 +01:00
Miguel Palhas c4d4ade4da fix(api): normalise stored root paths
#243 normalised the incoming path but compared it against the value read
raw from the database, so a root stored with a trailing separator never
compared equal. Every edit of it -- a policy change included -- took the
relocation branch, where each planned destination is its own source and
the pre-check refuses. That root could not be edited at all.

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

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

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

Refs #244.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:28:35 +01:00
Miguel Palhas cfa0eaa77d Merge #231: settings rows on one line with icon actions
Closes #231
2026-08-25 11:21:35 +01:00
Miguel Palhas 955a0f309c feat(web): settings rows on one line with icon actions
Each root and policy row is one line: identity left, chips at the right
edge, then pencil/trash icon controls — the episode-row idiom instead of
a two-line group. Every icon action's aria-label names the action and
the row (edit root /mnt/media/tv/main). Delete keeps the arm-then-confirm
behaviour via the shared armedDeleteIcon; the local text-button
armedDelete is gone with it. Under 46rem the row wraps like an episode
row: identity, chips full-width, actions keeping the right edge.

Closes #231

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:19:05 +01:00
Miguel Palhas 7df83133b3 Merge #241: reclassify on root and policy changes
Closes #241
2026-08-25 11:18:51 +01:00
Miguel Palhas 5974d43785 Merge remote-tracking branch 'origin/blitz/feedback-2' into issue/241-reclassify-on-root-change 2026-08-25 11:17:47 +01:00
Miguel Palhas 43e65514ed fix(api): reclassify on root and policy changes
Moving a title to a root with a different policy, and pointing a root
at a different policy via PUT /api/roots/{id}, both changed the
effective policy without re-deriving stored verdicts — which §9.3's
deck and the daemon's manual-grab gate read. Both now run the same
reclassify the overrides path uses, inline in the request; §5.1 states
the contract, and relocate.rs no longer claims the move alone makes
the policy apply.

PUT /api/policies/{id} has the same gap one level up; noted on #241
for its own issue.

Closes #241

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:17:33 +01:00
Miguel Palhas a42f3970d2 Merge #230: icon-only controls and readouts
Closes #230
2026-08-25 11:10:04 +01:00
Miguel Palhas 2a58a103d3 Merge #239: measure attention window from failure
Closes #239
2026-08-25 11:06:42 +01:00
Miguel Palhas 528aadf59c fix(daemon): measure attention window from failure
Closes the gap #239 describes: §5.7's 30-day window was filtered on
grabbed_at, so a torrent stalling past the window before hard-failing
at import never surfaced in the needs-a-decision queue. grabs gains
failed_at (migration 0030, backfilled from grabbed_at for existing
failed rows), the import tick stamps it on hard fail, and every window
query in the daemon notifier and the attention endpoint reads it.
§5.7 now states the anchor explicitly. §6.2's pack backoff stays on
grabbed_at deliberately; noted on the issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:05:59 +01:00
Miguel Palhas 070fd3d7ba Merge blitz/feedback-2: fmt fix and attention queue 2026-08-25 10:58:07 +01:00
Miguel Palhas 0a0c6359a4 Merge #243: trailing-slash self-conflict and stray relocation dir
Closes #243
2026-08-25 10:58:07 +01:00
Miguel Palhas 0b4ded3fe9 feat(web): icon-only controls and readouts (#230)
Trash replaces the remove labels on title pages and season and
episode rows, a magnifier replaces the deck control, the season
on-disk readout carries a drive glyph before its 0/10, and the
TMDB, TVDB, IMDb and Rotten Tomatoes links carry drawn marks
shipped inline (wordmark badges plus a tomato), never remote
images. Every icon-only control keeps an aria-label naming the
action and the row or title it acts on, and the arm-then-confirm
delete speaks a visible amber "confirm" while armed, so the first
hit never destroys.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:58:07 +01:00
Miguel Palhas 6fa88b8f1f fix(api): trailing-slash self-conflict and stray relocation dir
A root path differing only by a trailing separator now normalises to
the same value on create and update, so PUT no longer treats a no-op
edit as a relocation whose destinations conflict with their own
sources.

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

Refs #243
2026-08-25 10:56:13 +01:00
Miguel Palhas afc17ca34a Merge #238: queue only targets still waiting for a file
Closes #238
2026-08-25 10:50:56 +01:00
Miguel Palhas 6847d25cf5 feat: queue only targets still waiting for a file
The needs-a-decision queue had no liveness condition on the season lane
and none at all in the API reader, so a season pack that hard-failed
twice, fell back to per-episode grabbing exactly as §6.2 intends, and was
then fully acquired kept notifying for 30 days, and
`GET /api/queues/attention` listed titles the daemon never notified on.

DESIGN.md §5.7 now states the third face of the same rule alongside the
count and the window: a movie or an episode is queued while `wanted` and
not `available`; a season, holding no intent of its own (§4.1), while at
least one of its episodes is. Both readers apply it on all three lanes.

`just ci` passed through the gate.
2026-08-25 10:49:02 +01:00
Miguel Palhas 50056a2bd9 style(api): reformat two error arms
Pre-existing rustfmt drift on the branch base; `just ci` fails on it
before reaching anything else.
2026-08-25 10:48:56 +01:00
Miguel Palhas c962998a2b Merge #229: drop the back button, banner meets the rail
Closes #229
2026-08-25 10:42:05 +01:00
Miguel Palhas 815a072ef0 fix(web): drop back button from movie and series pages (#229)
The rail, browser back, and Esc already cover navigation — the back
button was a third way to do what two other things already do, and it
cost the page its first line.

- Remove #movie-back and #series-back buttons and their event listeners
- Focus lands on the title element (tabindex=-1) instead of the removed
  button on open
- TV releases back button is untouched
- Hero banner now meets the rail with zero top padding on movie/series
- Esc and parent-route behaviour unchanged
- Deep link fallback for TV releases uses #nav-library instead of the
  removed #series-back
2026-08-25 10:40:37 +01:00
Miguel Palhas 690eaeda5c fix(api): let a stranded folder be retried, not refused
Two findings from the integration review of this milestone, both caused
by two sessions editing the same code without seeing each other.

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

ApiError::Filesystem rendered as "files not removed: {error}". That was
written for the delete lane; #228 and #236 then returned the same
variant for move failures, so a root path change with one unwritable
folder reported "files not removed" after an operation that removed
nothing. The variant now renders the caller's message and the two
delete lanes carry their own context.
2026-08-25 10:29:38 +01:00
Miguel Palhas d23ae0ebcf Merge #236: move title folders on root path change
Closes #236
2026-08-25 10:21:01 +01:00
Miguel Palhas 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 8c3e1c4a92 Merge milestone 'Size bands and waivers'
ci / web (push) Successful in 45s
e2e / e2e (push) Successful in 1m30s
ci / rust (push) Successful in 1m38s
A size band is now a rate: floor and target scale by the series'
minutes per episode against a 45-minute reference, so a short-form
show is no longer judged against an hour of video. Shipped band
values are unchanged — the reference runtime is chosen so they keep
their meaning. A missing runtime scales by one, and movies are never
scaled.

A size rejection can also be waived. The override relaxes the floor
for one title into a waiver rather than lifting it, so the release
stays out of automatic grabbing and imports on the record (§5.7).

The runtime migration is numbered 0026 to leave 0024 and 0025 to the
concurrent subtitles milestone; two files claiming one version do not
conflict in git and would have reached main unnoticed.

Closes #208, #209, #210
2026-08-24 22:51:55 +01:00
Miguel Palhas bb2708f7ee chore(db): renumber the runtime migration to 0026
The concurrent subtitles milestone carries 0024_subtitles.sql and
0025_subtitle_settings.sql. Two migrations claiming version 24 do not
conflict in git — the filenames differ — so both would land on main and
sqlx would see a duplicate version. Renumbering here is the half that
does not depend on the other milestone acting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:47:18 +01:00
Miguel Palhas 5c5b1e234c Merge #209: scale size bands by episode runtime
Closes #209
2026-08-24 22:47:11 +01:00
Miguel Palhas 917aa4fa76 feat: scale size bands by episode runtime
Implements #209 per §5.5 as amended by #208: a band's floor and target
are rates against a 45-minute reference runtime, scaled by the series'
minutes per episode. A missing or zero runtime applies the bands
unscaled, and movies are never scaled. The runtime is stored on the
series row (new migration), filled on add and by the metadata refresh,
which never blanks a known value against TMDB's frequently-empty
episode_run_time. Composes with #210: allow_below_floor waives against
the scaled floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:45:55 +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 024786f356 Merge #210: let a size rejection be waived
Closes #210
2026-08-24 22:16:01 +01:00
Miguel Palhas f0d45996a0 Merge #208: size bands scale with episode runtime
Closes #208
2026-08-24 22:16:01 +01:00
Miguel Palhas 9fee07f080 feat: let a size rejection be waived
A release below §5.5's floor was rejected with no way through, so a
policy wrong about one title left three Rick and Morty S09 packs
visible and none grabbable.

`allow_below_floor` relaxes the floor for one title into a soft fail,
never a pass: the release is waived, so automatic grabbing still skips
it and the import records a §5.7 waiver. The deck offers the one click
on a rejected row where the rule has an override, which is exactly what
§9.3's override is for.

Stored verdicts are re-derived when a title's overrides change — the
deck and the daemon's grab gate both read that column, so without it
the row the operator just acted on would keep reading `rejected`.

Closes #210

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 22:15:14 +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 bd52941a6d docs: size bands scale with episode runtime
A band's floor and target now read as a rate against a 45-minute
reference runtime, scaled by the series' per-episode runtime from
TMDB. Missing or zero runtime falls back to the reference, keeping
today's behaviour. Movies are explicitly unscaled. Closes the axis
question in #208; #209 implements it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:53:32 +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
Miguel Palhas 405358bf24 Merge milestone 'Season packs'
e2e / e2e (push) Successful in 1m16s
ci / web (push) Successful in 1m41s
ci / rust (push) Successful in 1m48s
Size bands describe one episode, so a pack's size is divided by the
episodes it covers before the floor and target are applied — a 2160p
pack no longer loses to a 1080p one on a penalty that scaled with
season length.

A failed pack grab now quiets the pack lane on the shared §6.2 backoff
curve instead of disabling it forever, and a manual season search
waives the window. The season deck names which state it is actually in
rather than blaming backoff for a sweep that was never going to run.

Closes #179, #180, #181, #182
2026-08-24 21:31:38 +01:00
Miguel Palhas 93c064701c Merge #182: name the season deck's real state
Closes #182
2026-08-24 21:27:42 +01:00
Miguel Palhas d474909ab5 Merge #180: score season packs per episode
Closes #180
2026-08-24 21:27:42 +01:00
Miguel Palhas 1e03873209 feat(web): name the season deck's real state
An empty season deck was three truths wearing one message, and the one
it chose to blame was wrong: a season on the per-episode lane sat on
"sweeping indexers…" for the full wait and then blamed a backoff for a
pack search that was never going to run.

`GET /api/series/{id}/seasons/{n}/pack-state` says which lane the
season takes and why, from `season_grab_reason` in arr-core, plus the
failed-pack tally and when #181's window reopens. Seasons gain
`last_pack_search_at`, written only by a season-scoped sweep, so a
pack search that ran and found nothing is a settled answer rather than
a pending one.

The deck then says the true thing in each case, and a season held off
the pack lane by a failure offers the retry that waives its window.

Refs #182
2026-08-24 20:14:48 +01:00
Miguel Palhas 2286cba862 feat(core): score season packs per episode
A size band describes one episode (DESIGN.md §5.5), so both the target
penalty and the floor now compare a release's size divided by the number
of episodes it covers. The caller supplies the count — arr-core has no
IO. Movies and unknown counts divide by one, so movie scoring is
unchanged and pinned by test.

Closes #180

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 20:10:23 +01:00
Miguel Palhas 577eef3f31 Merge #181: retry season packs after backoff
Closes #181
2026-08-24 19:52:40 +01:00
Miguel Palhas a5fd543561 Merge #179: size bands are per episode
Closes #179
2026-08-24 19:52:40 +01:00
Miguel Palhas dc6c25f582 fix(daemon): retry season packs after backoff
A failed season-pack grab held the season off the pack lane forever:
pack_hard_failed was a bare EXISTS over failed grabs, so one bad
torrent disabled pack search for good, against §6.2's "it never gives
up entirely, it goes quiet".

The guard now rides the shared backoff curve (backoff_elapsed, 1h → 6h
→ 1d → 3d, capped 7d), counting failed pack grabs as attempts and
anchoring on the latest one's grabbed_at. Both the targeted and RSS
lanes agree. A manual season search waives the window outright — the
season deck is §6.2's escape hatch.

Closes #181.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 19:51:28 +01:00
Miguel Palhas 0c2ba6ff15 docs(design): size bands are per episode
A release covering several episodes is measured by size divided by the
episode count, for the target penalty and the floor alike. An unknown
count divides by one, which fails toward rejecting rather than grabbing.

Refs #179
2026-08-24 19:42:04 +01:00
Miguel Palhas a3897df0ab fix(db): keep migration checksums stable
ci / web (push) Successful in 38s
ci / rust (push) Successful in 1m4s
e2e / e2e (push) Successful in 1m14s
Issue #155 removed the sqlx 0.8 rebuild workarounds by editing migrations
0007, 0014 and 0021 in place. Editing an applied migration changes its
checksum, and `migrate()` refuses to run when one no longer matches what
`_sqlx_migrations` recorded, so the daemon exited on startup against any
database that had already applied them — production included.

The sqlx 0.9 bump is the fix and survives: a new migration can carry
`-- no-transaction` so `PRAGMA foreign_keys = OFF` holds and a table
rebuild stops cascade-deleting its children. Only the retroactive cleanup
of migrations that already ran is reverted, along with #153's
`CHECK (title <> '')`, which rode on the 0021 edit and needs a migration
of its own rather than a rewrite of history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:14:42 +01:00
Miguel Palhas 83a0b247c9 Merge milestone 'Reliability and first run'
ci / web (push) Successful in 38s
e2e / e2e (push) Successful in 1m30s
ci / rust (push) Successful in 1m45s
Closes #155, #158, #168, #176, #177
2026-08-24 19:05:27 +01:00
Miguel Palhas 74cf82489b Merge #177: distinguish pending refresh from empty
Closes #177
2026-08-24 19:00:29 +01:00
Miguel Palhas b2147fe7b9 feat(web): tell pending seasons from settled empty 2026-08-24 18:59:15 +01:00
Miguel Palhas dc145ae2c1 Merge #158: bound the TMDB response cache
Closes #158
2026-08-24 18:59:01 +01:00
Miguel Palhas 3261741415 fix(meta): bound the TMDB response cache on disk
Closes #158
2026-08-24 18:56:59 +01:00
Miguel Palhas 998717b578 Merge #168: search survives a down TMDB
Closes #168
2026-08-24 18:53:21 +01:00
Miguel Palhas ddf1c5fd93 fix(api): search survives a down TMDB and errors name the upstream 2026-08-24 18:52:15 +01:00
Miguel Palhas 88a353dc68 Merge #176: refresh metadata on add
Closes #176
2026-08-24 18:41:48 +01:00
Miguel Palhas f954db9830 Merge #155: bump sqlx to 0.9, honour no-transaction
Closes #155
2026-08-24 18:41:48 +01:00
Miguel Palhas 93a3485c50 fix(db): drop 0.8 rebuild workarounds, enforce title
With sqlx 0.9 honouring `-- no-transaction`, 0007 loses its
movie_releases_backup stash like 0014 did, and 0021 gains
the CHECK (title <> '') that #153 abandoned because the 0.8
migrator could not run a rebuild with foreign keys off. The
rebuild test now enters at migration 6 so the child links
ride through all three rebuilds, and a new test proves the
'' -> 'TBA' backfill and the rejection of new empty titles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:39:22 +01:00
Miguel Palhas d8797e1996 feat(daemon): refresh metadata on add
The metadata lane runs daily, so a series added a moment ago showed no
seasons for up to 24 hours and a movie had no digital release date —
the field §6.2 gates targeted search on.

AppState now carries a MetadataCommand channel alongside the movie,
episode and season ones. Both create handlers send on it after the row
is committed, and a new daemon lane drains it. Its own task rather than
an arm of manual::run: a refresh against TMDB can take a while and must
not sit in front of an operator's manual search.

The add never waits on TMDB and never fails because of it. A refresh
that fails leaves metadata_refreshed_at NULL, which is what the daily
sweep already treats as due, so the title is retried rather than lost.
A command naming a title deleted in between finds no row and does
nothing. METADATA_INTERVAL is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:12:10 +01:00
Miguel Palhas ef4722e1dd fix(db): bump sqlx to 0.9, honour no-transaction
sqlx-sqlite 0.8 ignored Migration.no_tx and wrapped every
migration in a transaction, where PRAGMA foreign_keys = OFF
is a no-op — so any table rebuild cascade-deleted children.
0.9 honours `-- no-transaction`, so 0014 drops its
movie_releases_backup / episode_releases_backup workaround
and runs the plain rebuild recipe with foreign keys off.
A migration test rebuilds both parents from a pre-0014
database and asserts the child link rows survive.

Closes #155

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:10:53 +01:00
Miguel Palhas e9806d7a84 Merge origin/main
ci / web (push) Successful in 1m9s
e2e / e2e (push) Successful in 2m3s
ci / rust (push) Successful in 2m12s
2026-08-24 17:21:54 +01:00
Miguel Palhas 70e9e17136 Merge milestone 'Removal and navigation'
Closes #169, #170, #171, #172, #173, #174, #175
2026-08-24 17:21:45 +01:00
Miguel Palhas a6af920e39 Merge #175: removal controls for series, seasons, episodes
Closes #175
2026-08-24 17:14:00 +01:00
Miguel Palhas 7c30928907 feat(web): series, season and episode removal
Series detail exposes DELETE /api/series/{id} through the movie
removePanel, generalised over endpoints and file rollup instead of
copied. Season headers and on-disk episode rows get the settings
armed-delete control wired to #174's file endpoints, worded for what
they do: files go, wanted clears, the row stays listed.

Closes #175

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:09:46 +01:00
Miguel Palhas d5fc790839 docs: drop area/web Fable routing rule
ci / web (push) Successful in 48s
e2e / e2e (push) Successful in 1m36s
ci / rust (push) Successful in 1m42s
The rule justified pinning area/web issues to Fable 5 by saying they go
through the impeccable skill. That skill is symlinked into codex as well,
so it says nothing about which model to use. area/web now routes by
difficulty label like every other area; line 47 already requires
impeccable for anything that decides how something looks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:55:23 +01:00
Miguel Palhas 3dda666273 Merge #174: delete files and untrack a season or episode
Closes #174
2026-08-24 16:45:21 +01:00
Miguel Palhas 64e28e5930 feat: remove files for one season or episode
DELETE /api/series/{id}/seasons/{n}/files and
DELETE /api/episodes/{id}/files unlink what the scope covers, drop the
matching media_files rows and clear wanted, in one action. 204 on
success, 404 for an unknown season or episode, and a scope with nothing
on disk still clears intent.

Season and episode rows stay: TMDB owns that metadata and the next
refresh would recreate them.

The three scopes share one unlink path. A whole series still resolves to
its title folder (§7.4, atomic); a season or episode resolves to the
recorded file and nothing else, so a narrow call cannot reach a sibling.
The intent clear goes through arr_core::tracking::apply_tracked(false),
the same §4.1 rule #171 landed.

An episode whose file just went is set back to 'missing' when it was
'available', matching what a failed import already does. Closes #174.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 16:43:53 +01:00
Miguel Palhas f3336d066e Merge #172: search returns titles only
Closes #172
2026-08-24 16:40:44 +01:00
Miguel Palhas 27525302b2 Merge #173: library is the homepage
Closes #173
2026-08-24 16:40:38 +01:00
Miguel Palhas 4b89245f00 fix(api): drop episode rows from unified search
Search returns titles only (§9.2, amended): the episode branch of the
library query, its json_each token machinery, and the SPA's episode
row rendering are removed. Deep links to episode releases stay.

Fixes #172
2026-08-24 16:39:48 +01:00
Miguel Palhas 7ef24088a5 feat(web): library homepage, chain in settings
Implements the §9.7 shell rulings (issue #173): "/" renders the library
and the board route is gone — every former board target (Esc, cleared
search, detail parent routes, the not-found fallback) lands on the
library. The signal chain moves into /settings as its first section,
lamps still driven by the health poll; the master lamp stays on the
rail. The wordmark is now a real anchor that navigates home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:39:07 +01:00
Miguel Palhas 4a611a6794 Merge #171: untracking a season clears wanted
Closes #171
2026-08-24 16:32:02 +01:00
Miguel Palhas 9e003823c6 feat: untracking a season clears its wanted 2026-08-24 16:31:09 +01:00
Miguel Palhas 205855fa7f Merge #170: centre deck column chips in their box
Closes #170
2026-08-24 16:29:25 +01:00
Miguel Palhas fada2ff89a fix(web): centre deck chips in their boxes 2026-08-24 16:28:42 +01:00
Miguel Palhas 60642d8709 Merge #169: amend DESIGN.md for four rulings
Closes #169
2026-08-24 16:21:03 +01:00
Miguel Palhas c37031207e docs: amend DESIGN.md for four rulings
Untracking a season now clears its episodes' wanted flag, unified search
returns titles only, the library is the homepage and the signal chain moves
into settings, and the wordmark links home.

Refs #169
2026-08-24 16:20:13 +01:00
Miguel Palhas 869e3af9c8 Merge milestone 'Feedback pass'
ci / web (push) Successful in 36s
e2e / e2e (push) Successful in 1m10s
ci / rust (push) Successful in 1m19s
Nine issues from a hands-on pass over the deployed build.

Behaviour: adding a series no longer tracks its whole back catalogue
(auto_track applies from the second refresh onward); a series-title
query no longer returns every episode of that series; seasons and
episodes come back newest-first.

UI: cast comes off title detail; buttons and chips share one 2rem
geometry; state chips carry the ramp (green satisfied, amber gap,
violet active, neutral idle); the header drops its verdict line and
sits on the content width; season and episode rows lose their search
button and the deck sweeps itself when opened empty.

DESIGN.md 4.1, 4.2, 9.2 and 9.6 amended first; every other issue
implements against the amended text.

Closes #159, #160, #161, #162, #163, #164, #165, #166, #167
2026-08-24 15:26:12 +01:00
Miguel Palhas e509c37649 Merge #167: tv deck sweeps on open when empty 2026-08-24 15:14:53 +01:00
Miguel Palhas 8a672c1dcf feat(web): tv deck sweeps on open when empty
Drop the standalone search button from season and episode rows; the
deck is the one control. An empty deck fires a targeted sweep on
open and reports progress in place; a deck with results never sweeps
on open. re-search inside the deck forces a fresh sweep.
2026-08-24 14:42:29 +01:00
Miguel Palhas fb03fc3808 Merge #166: lamp-only header on the content width 2026-08-24 14:38:03 +01:00
Miguel Palhas 1add26b7f7 Merge #165: state ramp on status and counts chips 2026-08-24 14:38:03 +01:00
Miguel Palhas fa5d876c09 chore(web): sync design.json to the state ramp
The token system still mirrored the pre-amendment palette and carried
the rule 'Don't colour complete green or incomplete amber', which now
contradicts DESIGN.md 4.2 and would have had the next session revert
the ramp. Drops the four --status-* tokens no rule consumes any more;
satisfaction reuses --signal-ok and a gap --signal-warn.

Committed by the blitz orchestrator: the authoring session wedged.
2026-08-24 14:37:52 +01:00
Miguel Palhas 0cb8111678 fix(web): pin the version readout to the rail edge 2026-08-24 14:36:18 +01:00
Miguel Palhas d1d6b4f9a0 feat(web): lamp-only header on the content width
Drops #master-verdict entirely - element, .rail-verdict rules and every
line that wrote into it. The lamp is the only header signal; the
unreachable detail moves onto the board's arr module so nothing is lost.
Rail contents move into .rail-inner capped at the deck's 68rem and
centred, leaving the panel full-bleed.

Committed by the blitz orchestrator: the authoring session wedged with
the work complete on disk and stopped accepting input.
2026-08-24 14:36:18 +01:00
Miguel Palhas 2dbb0f48f7 feat(web): state ramp on status and counts chips
Per amended DESIGN.md §4.2: green --signal-ok for on disk/complete,
amber --signal-warn for wanted-but-missing, violet --status-airing for
downloading, neutral for untracked and waiting. Stars go amber. Words
stay in every chip; verdict chips untouched.
2026-08-24 13:57:17 +01:00
Miguel Palhas 8221ea633c Merge #163: drop cast from title detail 2026-08-24 12:34:34 +01:00
Miguel Palhas 7d4ddb7331 refactor(web): drop cast from title detail
DESIGN.md §9.6 no longer carries cast (#163). Removes the cast
sections, the MetadataCastMember schema and field from both metadata
responses, and every .cast-* rule. arr-meta keeps CastMember and the
credits append; the TMDB call is unchanged.
2026-08-24 12:34:00 +01:00
Miguel Palhas ed39b1ca49 Merge #164: one control geometry for buttons and chips 2026-08-24 12:12:08 +01:00
Miguel Palhas 1a4394cf0d feat(web): one control geometry for buttons and chips
Buttons, chips, toggles and the search input converge on --control-h
2rem with --text-xs shared, and .control becomes an inline-flex so
anchor text centres in the title-detail action bar. Chip colour is
untouched (#165 owns it). Verified in agent-browser at 1440px, 46rem
and 40rem; the release deck keeps its column widths and does not
scroll horizontally.
2026-08-24 12:11:08 +01:00
Miguel Palhas 4d5cc120d1 fix(api): newest-first season and episode order
DESIGN.md §9.6 now puts the current season at the top: seasons
descend by number within a series (season 0 lands last under plain
numeric descending) and episodes descend within each season. Changed
the two queries in load_seasons; the SPA renders this order as given.

arr-compat checked and untouched: it reads seasons with its own
ORDER BY number query straight from the database, never through
arr-api, and SeasonResource carries season_number, which Jellyseerr
matches on rather than position. Sonarr's real API also returns
seasons ascending, so the shim keeps the contract it emulates.

The existing vanished-flag test indexed seasons positionally; it now
looks them up by number so it tests the flag, not the order. New test
asserts [2, 1, 0] for seasons and descending episodes within each.
.sqlx regenerated via just db-prepare. just ci green locally.
2026-08-24 12:09:52 +01:00
Miguel Palhas 2c293e7f60 fix(api): episode rows need a token beyond series title
Per amended DESIGN.md 9.2, an episode row now also requires at
least one query token to match the episode title on its own, so
a series-title-only query lists the series and no episodes.
2026-08-24 11:59:18 +01:00
Miguel Palhas 8f7373d73d fix(core): auto-track skips the seeding refresh
A series' first metadata refresh reveals its whole back catalogue, and
apply_auto_track flagged every season as new — adding Rick and Morty
tracked S01-S09 and wanted 91 episodes (#160).

Per DESIGN.md 4.1 the rule applies from the second refresh onward.
The caller passes whether metadata_refreshed_at is set; arr-core stays
IO-free and decides. Wired through the daemon's daily refresh and the
API's create-season endpoint.
2026-08-24 11:40:44 +01:00
Miguel Palhas 2495895717 docs: record five rulings from the deployed build 2026-08-24 11:28:05 +01:00
Miguel Palhas 40620d93b4 fix(web): series page loads; cast art falls back
ci / web (push) Successful in 34s
e2e / e2e (push) Successful in 57s
ci / rust (push) Successful in 1m9s
2026-08-24 03:21:42 +01:00
Miguel Palhas 1c351eb867 Merge #151: library poster grid with list toggle
Closes #151
2026-08-24 03:05:59 +01:00
371 changed files with 46245 additions and 5178 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
+53 -12
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": {
@@ -123,7 +137,7 @@
},
"scale": {
"displayName": "Type scale",
"purpose": "Seven fixed steps as tokens: --text-2xs 0.6875rem (micro-labels), --text-xs 0.75rem (details, chips), --text-sm 0.8125rem (readouts, controls), --text-base 1rem (body), --text-md 1.0625rem (module names), --text-lg 1.3125rem (master module), --text-xl 1.625rem (wordmark). Components never carry a literal font-size."
"purpose": "Seven fixed steps as tokens: --text-2xs 0.6875rem (micro-labels), --text-xs 0.75rem (details, chips, controls), --text-sm 0.8125rem (readouts), --text-base 1rem (body), --text-md 1.0625rem (module names), --text-lg 1.3125rem (master module), --text-xl 1.625rem (wordmark). Components never carry a literal font-size. One control geometry: buttons and chips share --control-h 2rem and --text-xs, so a season line reads as one system."
}
},
"shadows": [
@@ -189,7 +203,7 @@
"refersTo": "ground",
"description": "The §9.3 column unit: a small machined panel holding one mono value (score, resolution, source, HDR, audio, size, seeders). Fixed rhythm so columns align; never a soft pill.",
"html": "<span class=\"chip readout\">2160p</span>",
"css": ".chip { display: inline-flex; align-items: center; gap: var(--space-1); min-height: 1.375rem; padding: 0 var(--space-2); background: var(--panel-raised); border: 1px solid var(--line); border-radius: var(--radius); font-family: var(--font-readout); font-size: var(--text-xs); color: var(--ink-muted); }"
"css": ".chip { display: inline-flex; align-items: center; gap: var(--space-1); min-height: var(--control-h); padding: 0 var(--space-2); background: var(--panel-raised); border: 1px solid var(--line); border-radius: var(--radius); font-family: var(--font-readout); font-size: var(--text-xs); color: var(--ink-muted); }"
},
{
"name": "Verdict chip",
@@ -202,10 +216,26 @@
{
"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(--status-incomplete); } .chip[data-status=\"waiting\"] { color: var(--status-waiting); } .chip[data-status=\"complete\"] { color: var(--status-complete); } .chip[data-status=\"ended\"] { color: var(--status-ended); }"
"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)",
@@ -229,15 +259,15 @@
"refersTo": "accent",
"description": "The interactive element: mono uppercase, phosphor-cyan text, panel ground, ≥44px hit target. Cyan belongs to interaction only — never to status.",
"html": "<button type=\"button\" class=\"control\">probe</button>",
"css": ".control { min-height: 2.75rem; padding: 0 var(--space-4); font-family: var(--font-readout); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: var(--radius); }"
"css": ".control { display: inline-flex; align-items: center; justify-content: center; min-height: var(--control-h); padding: 0 var(--space-4); font-family: var(--font-readout); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); background: var(--panel-raised); border: 1px solid var(--line-strong); border-radius: var(--radius); }"
},
{
"name": "Rail",
"kind": "nav",
"refersTo": "ground",
"description": "Full-width panel strip framing the board top (identity + verdict + controls) and bottom (readouts). Hairline-ruled, panel ground, inset highlight.",
"html": "<header class=\"rail\"><div class=\"rail-id\"><span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span><h1 class=\"wordmark\">arr</h1></div><p class=\"rail-verdict readout\" data-tone=\"ok\">all signals nominal</p></header>",
"css": ".rail { display: flex; align-items: center; gap: var(--space-6); padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--line); background: var(--panel); box-shadow: inset 0 1px var(--highlight); }"
"description": "Full-width panel strip framing the board top (identity + controls) and bottom (readouts). Hairline-ruled, panel ground, inset highlight. Contents sit in a .rail-inner column capped at 68rem, centred — the panel stays full-bleed. The lamp beside the wordmark is the only header status signal.",
"html": "<header class=\"rail\"><div class=\"rail-inner\"><div class=\"rail-id\"><span class=\"lamp\" data-state=\"ok\" aria-hidden=\"true\"></span><h1 class=\"wordmark\">arr</h1></div></div></header>",
"css": ".rail { padding: var(--space-3) var(--space-6); border-bottom: 1px solid var(--line); background: var(--panel); box-shadow: inset 0 1px var(--highlight); } .rail-inner { display: flex; align-items: center; gap: var(--space-6); width: 100%; max-width: 68rem; margin-inline: auto; }"
}
],
"narrative": {
@@ -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,10 +337,11 @@
"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": [
"Don't colour `complete` green or `incomplete` amber — library status is informational, not a severity ramp.",
"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.",
"Don't use fault red for a rejected release; red means the daemon itself is broken.",
"Don't introduce a second interactive hue or use cyan for a status.",
"Don't box content in soft-shadowed rounded cards or add glass/blur.",
+9 -3
View File
@@ -29,9 +29,15 @@ reads the library as derived status, not flags.
counts behind cyan show/hide toggles. Release name is a secondary
truncated line, full string plus indexer/date/bytes on row expand. Every
waived/rejected row carries a verdict chip naming the rule. Grab is its
own control per row; on a waived row it is "waive + grab" and writes the
per-title override (rule→override mapping in releases.ts — bookkeeping,
never policy; verdicts always come from the API). Esc steps back one
own control per row; where the rule that failed has an override it reads
"waive + grab" and writes the per-title override (rule→override mapping in
releases.ts — bookkeeping, never policy; verdicts always come from the
API). A rejected row offers it too — §9.3's one click exists precisely for
the rule the operator disagrees with, and a size rejection (#210) is only
ever rejected. What the click produces stays a waiver: the row re-reads as
a dashed WAIVED, never eligible. When ELIGIBLE is empty and something is
waivable, its "none" line carries the count, so the way out is not folded
inside a collapsed bucket. Esc steps back one
layer: deck → search results → board.
- Removal (§7.4, issue 104): REMOVE is a quiet control in the deck head,
never a row affordance — a dense list is the wrong place for a delete.
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"query": "UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "de2153eefd6ec03ceeaf640d599271834cb06c94eda7a9956c5bd7399593c0ae"
"hash": "0516e88e07d3708ba4ccc52d7ba635d45e9d1b2c639b71102e241f50982502eb"
}
@@ -0,0 +1,33 @@
{
"db_name": "SQLite",
"query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = ?\n GROUP BY se.number",
"describe": {
"columns": [
{
"name": "number!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episodes!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d"
}
@@ -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"
}
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\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 WHERE e.id = ?",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
},
{
"name": "series_title!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "series_tvdb_id",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tvdb_id"
}
}
},
{
"name": "original_language",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "original_language"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode_number!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
false,
false
]
},
"hash": "0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33"
}
@@ -6,34 +6,59 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
},
{
"name": "size!: i64",
"ordinal": 2,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "size"
}
}
},
{
"name": "probed?: serde_json::Value",
"ordinal": 3,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "probed"
}
}
},
{
"name": "waiver?: String",
"ordinal": 4,
"type_info": "Null"
"type_info": "Null",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
true,
@@ -0,0 +1,98 @@
{
"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 ",
"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": "episode_id!: i64",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode_number!: i64",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
false,
false,
false
]
},
"hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d"
}
@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT normalised_name AS \"normalised_name!: String\", infohash FROM blacklist",
"describe": {
"columns": [
{
"name": "normalised_name!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "infohash",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
true
]
},
"hash": "071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97"
}
@@ -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"
}
@@ -6,14 +6,20 @@
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "id"
}
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
true
null
]
},
"hash": "0aea0f15e8a03a37b5d9d32d3c7caffa70f9ec44f5a5da1dc0560f29f445ceab"
@@ -6,7 +6,8 @@
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
@@ -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"
}
@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.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": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
},
{
"name": "title!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "season_id!: i64",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "grabs",
"name": "target_id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false
]
},
"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"
}
@@ -6,54 +6,108 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "original_language"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 5,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 6,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "last_searched_at"
}
}
},
{
"name": "digital_release",
"ordinal": 7,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "digital_release"
}
}
},
{
"name": "metadata_refreshed_at",
"ordinal": 8,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "metadata_refreshed_at"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
true,
@@ -6,24 +6,42 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "owners",
"name": "id"
}
}
},
{
"name": "name!: String",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "owners",
"name": "name"
}
}
},
{
"name": "ntfy_topic!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "owners",
"name": "ntfy_topic"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
false
]
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n OR poster_path IS NOT ? OR backdrop_path IS NOT ?\n OR vote_average IS NOT ?\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 17
},
"nullable": []
},
"hash": "0f0966881a69a9f4009df7b27ebeadce81719e044c8fb65ba404b45b70bf8d8c"
}
@@ -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"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b"
}
@@ -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"
}
@@ -6,29 +6,53 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "grabs",
"name": "id"
}
}
},
{
"name": "infohash!: String",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "grabs",
"name": "infohash"
}
}
},
{
"name": "target_kind!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "grabs",
"name": "target_kind"
}
}
},
{
"name": "target_id!: i64",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "grabs",
"name": "target_id"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
false,
false
@@ -6,32 +6,68 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
},
{
"name": "kind!: String",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "kind"
}
}
},
{
"name": "audience!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "audience"
}
}
},
{
"name": "path!: String",
"ordinal": 3,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
},
{
"name": "policy_id!: i64",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "policies",
"name": "id"
}
}
},
{
"name": "policy_name!: String",
"ordinal": 5,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "name"
}
}
}
],
"parameters": {
@@ -6,7 +6,13 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "id"
}
}
}
],
"parameters": {
@@ -6,7 +6,8 @@
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
@@ -6,7 +6,13 @@
{
"name": "tmdb_id",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
}
],
"parameters": {
@@ -6,84 +6,174 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "original_language"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "root_id"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 6,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "wanted"
}
}
},
{
"name": "overrides!: serde_json::Value",
"ordinal": 7,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "overrides"
}
}
},
{
"name": "state!: String",
"ordinal": 8,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "state"
}
}
},
{
"name": "blocked!: bool",
"ordinal": 9,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "blocked"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "last_searched_at"
}
}
},
{
"name": "poster_path",
"ordinal": 12,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "poster_path"
}
}
},
{
"name": "vote_average",
"ordinal": 13,
"type_info": "Float"
"type_info": "Float",
"origin": {
"Table": {
"table": "movies",
"name": "vote_average"
}
}
},
{
"name": "waiver?: serde_json::Value",
"ordinal": 14,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "waiver"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
true,
@@ -6,27 +6,52 @@
{
"name": "episode_id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
},
{
"name": "size!: i64",
"ordinal": 2,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "size"
}
}
},
{
"name": "probed?: serde_json::Value",
"ordinal": 3,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "probed"
}
}
},
{
"name": "waiver?: String",
"ordinal": 4,
"type_info": "Null"
"type_info": "Null",
"origin": "Expression"
}
],
"parameters": {
@@ -1,38 +0,0 @@
{
"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 ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "episode_id!: i64",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false
]
},
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
}
@@ -6,7 +6,8 @@
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
@@ -0,0 +1,182 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.overrides AS \"overrides!: String\",\n r.id AS \"root_id!: i64\",\n r.kind AS \"root_kind!: String\",\n r.audience AS \"root_audience!: String\",\n r.path AS \"root_path!: String\",\n p.id AS \"policy_id!: i64\",\n p.name AS \"policy_name!: String\",\n p.required_audio AS \"required_audio!: String\",\n p.dub_blacklist AS \"dub_blacklist!: String\",\n p.hdr_rules AS \"hdr_rules!: String\",\n p.size_bands AS \"size_bands!: String\",\n p.resolution_pref AS \"resolution_pref!: String\",\n p.source_weights AS \"source_weights!: String\",\n p.score_weights AS \"score_weights!: String\"\n FROM series s\n JOIN roots r ON r.id = s.root_id\n JOIN policies p ON p.id = r.policy_id\n WHERE s.id = ?\n ",
"describe": {
"columns": [
{
"name": "overrides!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "overrides"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
},
{
"name": "root_kind!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "kind"
}
}
},
{
"name": "root_audience!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "audience"
}
}
},
{
"name": "root_path!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
},
{
"name": "policy_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "policies",
"name": "id"
}
}
},
{
"name": "policy_name!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "name"
}
}
},
{
"name": "required_audio!: String",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "required_audio"
}
}
},
{
"name": "dub_blacklist!: String",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "dub_blacklist"
}
}
},
{
"name": "hdr_rules!: String",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "hdr_rules"
}
}
},
{
"name": "size_bands!: String",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "size_bands"
}
}
},
{
"name": "resolution_pref!: String",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "resolution_pref"
}
}
},
{
"name": "source_weights!: String",
"ordinal": 12,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "source_weights"
}
}
},
{
"name": "score_weights!: String",
"ordinal": 13,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "score_weights"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4"
}
@@ -0,0 +1,146 @@
{
"db_name": "SQLite",
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n s.runtime_minutes,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ",
"describe": {
"columns": [
{
"name": "grab_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "grabs",
"name": "id"
}
}
},
{
"name": "infohash!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "grabs",
"name": "infohash"
}
}
},
{
"name": "season_id!: i64",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "series_id!: i64",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
},
{
"name": "series_tmdb_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "series_title!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "series_year",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "original_language",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "original_language"
}
}
},
{
"name": "runtime_minutes",
"ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
},
{
"name": "release_name!: String",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "name"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67"
}
@@ -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,33 @@
{
"db_name": "SQLite",
"query": "SELECT e.air_date,\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"has_file!: bool\"\n FROM episodes e\n WHERE e.season_id = ?",
"describe": {
"columns": [
{
"name": "air_date",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "air_date"
}
}
},
{
"name": "has_file!: bool",
"ordinal": 1,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false
]
},
"hash": "20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2"
}
@@ -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"
}
@@ -6,72 +6,156 @@
{
"name": "overrides!: String",
"ordinal": 0,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "overrides"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
},
{
"name": "root_kind!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "kind"
}
}
},
{
"name": "root_audience!: String",
"ordinal": 3,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "audience"
}
}
},
{
"name": "root_path!: String",
"ordinal": 4,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
},
{
"name": "policy_id!: i64",
"ordinal": 5,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "policies",
"name": "id"
}
}
},
{
"name": "policy_name!: String",
"ordinal": 6,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "name"
}
}
},
{
"name": "required_audio!: String",
"ordinal": 7,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "required_audio"
}
}
},
{
"name": "dub_blacklist!: String",
"ordinal": 8,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "dub_blacklist"
}
}
},
{
"name": "hdr_rules!: String",
"ordinal": 9,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "hdr_rules"
}
}
},
{
"name": "size_bands!: String",
"ordinal": 10,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "size_bands"
}
}
},
{
"name": "resolution_pref!: String",
"ordinal": 11,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "resolution_pref"
}
}
},
{
"name": "source_weights!: String",
"ordinal": 12,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "source_weights"
}
}
},
{
"name": "score_weights!: String",
"ordinal": 13,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "score_weights"
}
}
}
],
"parameters": {
@@ -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"
}
@@ -6,39 +6,75 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
},
{
"name": "kind!: String",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "kind"
}
}
},
{
"name": "audience!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "audience"
}
}
},
{
"name": "path!: String",
"ordinal": 3,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
},
{
"name": "policy_id",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "policy_id"
}
}
},
{
"name": "policy_name!: String",
"ordinal": 5,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "name"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
false,
false,
@@ -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"
}
@@ -6,47 +6,101 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "original_language"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "root_id"
}
}
},
{
"name": "blocked!: bool",
"ordinal": 6,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "blocked"
}
}
},
{
"name": "poster_path",
"ordinal": 7,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "poster_path"
}
}
},
{
"name": "vote_average",
"ordinal": 8,
"type_info": "Float"
"type_info": "Float",
"origin": {
"Table": {
"table": "series",
"name": "vote_average"
}
}
}
],
"parameters": {
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT last_pack_search_at FROM seasons WHERE id = ?",
"describe": {
"columns": [
{
"name": "last_pack_search_at",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "seasons",
"name": "last_pack_search_at"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26"
}
@@ -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,56 +0,0 @@
{
"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 ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "episode_id!: i64",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "season_number!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "episode_number!: i64",
"ordinal": 6,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
}
@@ -0,0 +1,26 @@
{
"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 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": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "grabs",
"name": "id"
}
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
null
]
},
"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"
}
@@ -1,62 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT e.id AS \"episode_id!: i64\", s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", printf('S%02dE%02d', se.number, e.number) AS \"tag!: String\", e.title AS \"title!: String\", s.poster_path, s.vote_average, s.tmdb_id AS \"series_tmdb_id!: i64\" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\\') ORDER BY s.title, se.number, e.number, e.id",
"describe": {
"columns": [
{
"name": "episode_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "series_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "series_title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "tag!: String",
"ordinal": 3,
"type_info": "Null"
},
{
"name": "title!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 6,
"type_info": "Float"
},
{
"name": "series_tmdb_id!: i64",
"ordinal": 7,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
null,
false,
true,
true,
false
]
},
"hash": "2bc048c0fca4822eb38befbe3091f44f961fafb79432d4771aa48e36d32bf2a2"
}
@@ -1,20 +0,0 @@
{
"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\"",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 4
},
"nullable": [
true
]
},
"hash": "2d0b92ba9aa4bc257286a67f0d37e2182c4478153c9096c1522c5ab23ad472e9"
}
@@ -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"
}
@@ -6,7 +6,13 @@
{
"name": "title!: String",
"ordinal": 0,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
}
],
"parameters": {
@@ -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,26 @@
{
"db_name": "SQLite",
"query": "SELECT path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "32c562fdb70cabb3265e351583b6ac1579490b29da670fe9062117451911576a"
}
@@ -6,56 +6,94 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "number!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "air_date",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "air_date"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "wanted"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 5,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "last_searched_at"
}
}
},
{
"name": "has_file!: bool",
"ordinal": 6,
"type_info": "Null"
"type_info": "Integer",
"origin": "Expression"
},
{
"name": "in_flight!: bool",
"ordinal": 7,
"type_info": "Null"
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true,
false,
false,
true,
null,
null
false,
false
]
},
"hash": "33de7e17f5defa761de0d6e174dca40e2d883b9990bec497355422f5b5e6dfe5"
@@ -6,47 +6,101 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "policies",
"name": "id"
}
}
},
{
"name": "name!: String",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "name"
}
}
},
{
"name": "required_audio!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "required_audio"
}
}
},
{
"name": "dub_blacklist!: String",
"ordinal": 3,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "dub_blacklist"
}
}
},
{
"name": "hdr_rules!: String",
"ordinal": 4,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "hdr_rules"
}
}
},
{
"name": "size_bands!: String",
"ordinal": 5,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "size_bands"
}
}
},
{
"name": "resolution_pref!: String",
"ordinal": 6,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "resolution_pref"
}
}
},
{
"name": "source_weights!: String",
"ordinal": 7,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "source_weights"
}
}
},
{
"name": "score_weights!: String",
"ordinal": 8,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "score_weights"
}
}
}
],
"parameters": {
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "3531a16bd038c149465397a70c24c14ed005683c68f06d322409c5c2b39b84d1"
}
@@ -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,26 +1,44 @@
{
"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": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "title!: String",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 2,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -28,5 +46,5 @@
true
]
},
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
"hash": "35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46"
}
@@ -0,0 +1,158 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "series_id"
}
}
},
{
"name": "id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_id!: i64",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "season_id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "number!: i64",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "title!: String",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "title"
}
}
},
{
"name": "air_date",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "air_date"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "wanted"
}
}
},
{
"name": "state!: String",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "state"
}
}
},
{
"name": "vanished!: bool",
"ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "vanished"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "last_searched_at"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
true
]
},
"hash": "3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed"
}
@@ -6,52 +6,102 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "wanted"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "root_id"
}
}
},
{
"name": "root_path!: String",
"ordinal": 6,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
},
{
"name": "created_at!: String",
"ordinal": 7,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "created_at"
}
}
},
{
"name": "file_count!: i64",
"ordinal": 8,
"type_info": "Integer"
"type_info": "Integer",
"origin": "Expression"
},
{
"name": "size_on_disk!: i64",
"ordinal": 9,
"type_info": "Integer"
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
@@ -0,0 +1,182 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id, title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\", overrides AS \"overrides!: serde_json::Value\", upstream_ended AS \"upstream_ended!: bool\", blocked AS \"blocked!: bool\", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id",
"describe": {
"columns": [
{
"name": "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": "tvdb_id",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tvdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "original_language",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "original_language"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "root_id"
}
}
},
{
"name": "auto_track!: bool",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "auto_track"
}
}
},
{
"name": "overrides!: serde_json::Value",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "overrides"
}
}
},
{
"name": "upstream_ended!: bool",
"ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "upstream_ended"
}
}
},
{
"name": "blocked!: bool",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "blocked"
}
}
},
{
"name": "poster_path",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "poster_path"
}
}
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float",
"origin": {
"Table": {
"table": "series",
"name": "vote_average"
}
}
},
{
"name": "metadata_refreshed_at",
"ordinal": 13,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "metadata_refreshed_at"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false,
true,
true,
false,
false,
false,
false,
false,
true,
true,
true
]
},
"hash": "3b518839d694b872f83005ca059d45d048a8c44efd78b75e08e74299fed072ea"
}
@@ -6,19 +6,31 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "id"
}
}
},
{
"name": "tracked!: bool",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "tracked"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false
]
},
@@ -6,62 +6,134 @@
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "series_id"
}
}
},
{
"name": "id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_id!: i64",
"ordinal": 2,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "season_id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "number!: i64",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "title"
}
}
},
{
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "air_date"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "wanted"
}
}
},
{
"name": "state!: String",
"ordinal": 8,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "state"
}
}
},
{
"name": "vanished!: bool",
"ordinal": 9,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "vanished"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "last_searched_at"
}
}
}
],
"parameters": {
@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 13
"Right": 14
},
"nullable": []
},
"hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331"
"hash": "3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c"
}
@@ -0,0 +1,28 @@
{
"db_name": "SQLite",
"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": [
{
"name": "failures!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
},
{
"name": "last_failed_at?: String",
"ordinal": 1,
"type_info": "Text",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec"
}
@@ -0,0 +1,194 @@
{
"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 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": [
{
"name": "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": "original_language",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "original_language"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "root_id"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "wanted"
}
}
},
{
"name": "overrides!: serde_json::Value",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "overrides"
}
}
},
{
"name": "state!: String",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "state"
}
}
},
{
"name": "blocked!: bool",
"ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "blocked"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "last_searched_at"
}
}
},
{
"name": "poster_path",
"ordinal": 12,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "poster_path"
}
}
},
{
"name": "vote_average",
"ordinal": 13,
"type_info": "Float",
"origin": {
"Table": {
"table": "movies",
"name": "vote_average"
}
}
},
{
"name": "waiver?: serde_json::Value",
"ordinal": 14,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "waiver"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0"
}
@@ -6,62 +6,134 @@
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "series_id"
}
}
},
{
"name": "id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_id!: i64",
"ordinal": 2,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "season_id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "number!: i64",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "title"
}
}
},
{
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "air_date"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "wanted"
}
}
},
{
"name": "state!: String",
"ordinal": 8,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "state"
}
}
},
{
"name": "vanished!: bool",
"ordinal": 9,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "vanished"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "last_searched_at"
}
}
}
],
"parameters": {
@@ -69,7 +141,7 @@
},
"nullable": [
false,
true,
false,
false,
false,
false,
@@ -6,12 +6,19 @@
{
"name": "air_date",
"ordinal": 0,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "air_date"
}
}
},
{
"name": "on_disk!: bool",
"ordinal": 1,
"type_info": "Null"
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
@@ -19,7 +26,7 @@
},
"nullable": [
true,
null
false
]
},
"hash": "3fc7d7e39520dc60fa81da21a4cae0c4a718e95f9718629f0aba9f3d00d179e1"
@@ -6,7 +6,8 @@
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
@@ -6,7 +6,13 @@
{
"name": "title!: String",
"ordinal": 0,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
}
],
"parameters": {
@@ -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"
}
@@ -6,7 +6,8 @@
{
"name": "count!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n OR poster_path IS NOT ? OR backdrop_path IS NOT ?\n OR vote_average IS NOT ?\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 17
},
"nullable": []
},
"hash": "462039959030ee881bcce2daf9d7c74f232ec6543787f64efeb53bffa8170113"
}
@@ -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,182 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average, runtime_minutes\n FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "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": "tvdb_id",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tvdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "original_language",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "original_language"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "root_id"
}
}
},
{
"name": "auto_track!: bool",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "auto_track"
}
}
},
{
"name": "upstream_ended!: bool",
"ordinal": 8,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "upstream_ended"
}
}
},
{
"name": "metadata_refreshed_at",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "metadata_refreshed_at"
}
}
},
{
"name": "poster_path",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "poster_path"
}
}
},
{
"name": "backdrop_path",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "backdrop_path"
}
}
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float",
"origin": {
"Table": {
"table": "series",
"name": "vote_average"
}
}
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
true,
true,
false,
false,
false,
true,
true,
true,
true,
true
]
},
"hash": "4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377"
}
@@ -1,38 +0,0 @@
{
"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 ",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "season_id!: i64",
"ordinal": 3,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false
]
},
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
}
@@ -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"
}
@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.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": [
{
"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": "episode_id!: i64",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "grabs",
"name": "target_id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode_number!: i64",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
false,
false,
false
]
},
"hash": "4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db"
}
@@ -6,12 +6,24 @@
{
"name": "title_id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "title_owners",
"name": "title_id"
}
}
},
{
"name": "owner_id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "title_owners",
"name": "owner_id"
}
}
}
],
"parameters": {
@@ -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"
}
@@ -6,32 +6,63 @@
{
"name": "season_id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "series_title!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "series_tvdb_id",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tvdb_id"
}
}
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "original_language"
}
}
},
{
"name": "is_gap!: bool",
"ordinal": 5,
"type_info": "Integer"
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
@@ -6,77 +6,167 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "original_language"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "root_id"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 6,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "wanted"
}
}
},
{
"name": "overrides!: serde_json::Value",
"ordinal": 7,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "overrides"
}
}
},
{
"name": "state!: String",
"ordinal": 8,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "state"
}
}
},
{
"name": "blocked!: bool",
"ordinal": 9,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "blocked"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "last_searched_at"
}
}
},
{
"name": "poster_path",
"ordinal": 12,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "poster_path"
}
}
},
{
"name": "vote_average",
"ordinal": 13,
"type_info": "Float"
"type_info": "Float",
"origin": {
"Table": {
"table": "movies",
"name": "vote_average"
}
}
},
{
"name": "waiver?: serde_json::Value",
"ordinal": 14,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "waiver"
}
}
}
],
"parameters": {
@@ -6,7 +6,13 @@
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
@@ -0,0 +1,158 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number DESC, e.number DESC",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "series_id"
}
}
},
{
"name": "id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_id!: i64",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "season_id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "number!: i64",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "title!: String",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "title"
}
}
},
{
"name": "air_date",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "air_date"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "wanted"
}
}
},
{
"name": "state!: String",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "state"
}
}
},
{
"name": "vanished!: bool",
"ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "vanished"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "last_searched_at"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
true
]
},
"hash": "580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a"
}
@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\", vanished AS \"vanished!: bool\" FROM seasons WHERE series_id = ? ORDER BY number",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "series_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "number!: i64",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "tracked!: bool",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "vanished!: bool",
"ordinal": 4,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false
]
},
"hash": "583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE episodes\n SET wanted = ?,\n state = CASE WHEN state = 'available' THEN 'missing' ELSE state END,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "58fe68be59f015fd8214306483c0d45fcc4b2ac595d3b04efb224b3e426cc1cf"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT r.path AS \"path!: String\"\n FROM roots r\n JOIN series s ON s.root_id = r.id\n JOIN seasons se ON se.series_id = s.id\n JOIN episodes e ON e.season_id = se.id\n WHERE e.id = ?",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "5b059a2f93a3fb46b6c55e0b665aa55931045af290894e396b3b410123bfd539"
}
@@ -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"
}
@@ -6,77 +6,167 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "original_language"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "root_id"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 6,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "wanted"
}
}
},
{
"name": "overrides!: serde_json::Value",
"ordinal": 7,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "overrides"
}
}
},
{
"name": "state!: String",
"ordinal": 8,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "state"
}
}
},
{
"name": "blocked!: bool",
"ordinal": 9,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "blocked"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "last_searched_at"
}
}
},
{
"name": "poster_path",
"ordinal": 12,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "poster_path"
}
}
},
{
"name": "vote_average",
"ordinal": 13,
"type_info": "Float"
"type_info": "Float",
"origin": {
"Table": {
"table": "movies",
"name": "vote_average"
}
}
},
{
"name": "waiver?: serde_json::Value",
"ordinal": 14,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "waiver"
}
}
}
],
"parameters": {
@@ -6,24 +6,42 @@
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "owners",
"name": "id"
}
}
},
{
"name": "name!: String",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "owners",
"name": "name"
}
}
},
{
"name": "ntfy_topic!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "owners",
"name": "ntfy_topic"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false
]
@@ -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"
}
@@ -6,42 +6,90 @@
{
"name": "name!: String",
"ordinal": 0,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "name"
}
}
},
{
"name": "required_audio!: String",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "required_audio"
}
}
},
{
"name": "dub_blacklist!: String",
"ordinal": 2,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "dub_blacklist"
}
}
},
{
"name": "hdr_rules!: String",
"ordinal": 3,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "hdr_rules"
}
}
},
{
"name": "size_bands!: String",
"ordinal": 4,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "size_bands"
}
}
},
{
"name": "resolution_pref!: String",
"ordinal": 5,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "resolution_pref"
}
}
},
{
"name": "source_weights!: String",
"ordinal": 6,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "source_weights"
}
}
},
{
"name": "score_weights!: String",
"ordinal": 7,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "score_weights"
}
}
}
],
"parameters": {
@@ -6,62 +6,134 @@
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "series_id"
}
}
},
{
"name": "id!: i64",
"ordinal": 1,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_id!: i64",
"ordinal": 2,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "season_id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "number!: i64",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "title"
}
}
},
{
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "air_date"
}
}
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "wanted"
}
}
},
{
"name": "state!: String",
"ordinal": 8,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "state"
}
}
},
{
"name": "vanished!: bool",
"ordinal": 9,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "vanished"
}
}
},
{
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "search_attempts"
}
}
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "last_searched_at"
}
}
}
],
"parameters": {

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