Compare commits

...

34 Commits

Author SHA1 Message Date
Miguel Palhas 070fd3d7ba Merge blitz/feedback-2: fmt fix and attention queue 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 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 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 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 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 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
76 changed files with 5871 additions and 594 deletions
+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,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"
}
@@ -1,11 +1,22 @@
{
"db_name": "SQLite",
"query": "SELECT 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 = ?",
"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_title!: String",
"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": {
@@ -16,7 +27,7 @@
},
{
"name": "series_tvdb_id",
"ordinal": 1,
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
@@ -27,7 +38,7 @@
},
{
"name": "original_language",
"ordinal": 2,
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
@@ -38,7 +49,7 @@
},
{
"name": "season_number!: i64",
"ordinal": 3,
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
@@ -49,7 +60,7 @@
},
{
"name": "episode_number!: i64",
"ordinal": 4,
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
@@ -63,6 +74,7 @@
"Right": 1
},
"nullable": [
false,
false,
true,
true,
@@ -70,5 +82,5 @@
false
]
},
"hash": "c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d"
"hash": "0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
"describe": {
"columns": [
{
@@ -94,5 +94,5 @@
false
]
},
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
"hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d"
}
@@ -0,0 +1,28 @@
{
"db_name": "SQLite",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"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": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"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 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 ",
"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": [
{
@@ -103,8 +103,19 @@
}
},
{
"name": "release_name!: String",
"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": {
@@ -127,8 +138,9 @@
false,
true,
true,
true,
false
]
},
"hash": "7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc"
"hash": "1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67"
}
@@ -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"
}
@@ -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 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": "3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"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\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"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": [
{
@@ -145,10 +145,21 @@
"name": "vote_average"
}
}
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -163,8 +174,9 @@
true,
true,
true,
true,
true
]
},
"hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644"
"hash": "4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
"describe": {
"columns": [
{
@@ -170,7 +170,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -190,5 +190,5 @@
true
]
},
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
"hash": "55b41a8ac2ce6eb3224e669653b2e98a87296da17205e0175414ccc5aeab4bb5"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT path AS \"path!: String\" FROM roots WHERE id = ?",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE s.root_id = ?\n ORDER BY mf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n SELECT r.id AS \"id!: i64\",\n r.size AS \"size!: i64\",\n r.parsed AS \"parsed!: serde_json::Value\",\n r.verdict,\n r.rejected_rule\n FROM releases r\n WHERE r.id IN (\n SELECT er.release_id\n FROM episode_releases er\n JOIN episodes e ON e.id = er.episode_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?\n UNION\n SELECT sr.release_id\n FROM season_releases sr\n JOIN seasons se ON se.id = sr.season_id\n WHERE se.series_id = ?\n )\n ",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "id"
}
}
},
{
"name": "size!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "size"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
},
{
"name": "verdict",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "verdict"
}
}
},
{
"name": "rejected_rule",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "rejected_rule"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n e.id AS \"episode_id!: i64\",\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 r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN episodes e ON 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 JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'episode'\n ORDER BY g.id\n ",
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n e.id AS \"episode_id!: i64\",\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 episodes e ON 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 JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'episode'\n ORDER BY g.id\n ",
"describe": {
"columns": [
{
@@ -114,8 +114,19 @@
}
},
{
"name": "release_name!: String",
"name": "runtime_minutes",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
},
{
"name": "release_name!: String",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
@@ -139,8 +150,9 @@
false,
true,
true,
true,
false
]
},
"hash": "7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4"
"hash": "81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT original_language FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "original_language",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "original_language"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc"
}
@@ -1,21 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS (\n SELECT 1 FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'\n ) AS \"failed!: bool\"",
"describe": {
"columns": [
{
"name": "failed!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1"
}
@@ -6,12 +6,24 @@
{
"name": "tmdb_id!: i64",
"ordinal": 0,
"type_info": "Integer"
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "metadata_refreshed_at",
"ordinal": 1,
"type_info": "Text"
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "metadata_refreshed_at"
}
}
}
],
"parameters": {
@@ -0,0 +1,33 @@
{
"db_name": "SQLite",
"query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se\n LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = (SELECT s2.series_id FROM episodes e2\n JOIN seasons s2 ON s2.id = e2.season_id\n WHERE e2.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": "a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT runtime_minutes FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "runtime_minutes",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -71,7 +71,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -82,5 +82,5 @@
false
]
},
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
"hash": "b5b8f0ff18d9554a63b24d9693e4e07dc4d0bdb89dd8eced880e5a36eb596d37"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -82,7 +82,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -94,5 +94,5 @@
false
]
},
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
"hash": "b8d707635737b155b2c0318aca5ee4a511f744f46bb21b072be1a03a88866c9b"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id\n WHERE m.root_id = ?\n ORDER BY mf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
"describe": {
"columns": [
{
@@ -38,7 +38,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -46,5 +46,5 @@
true
]
},
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
"hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -58,5 +58,5 @@
false
]
},
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
"hash": "d903535e39c0957aae78c40eacb76a65ad77d0ab7f1bccda7e28e7972243b5e1"
}
@@ -1,92 +0,0 @@
{
"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\n FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "tvdb_id",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "root_id!: i64",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "auto_track!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "upstream_ended!: bool",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "metadata_refreshed_at",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "backdrop_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
true,
true,
false,
false,
false,
true,
true,
true,
true
]
},
"hash": "e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n SELECT r.id AS \"id!: i64\",\n r.size AS \"size!: i64\",\n r.parsed AS \"parsed!: serde_json::Value\",\n r.verdict,\n r.rejected_rule\n FROM releases r\n JOIN movie_releases mr ON mr.release_id = r.id\n WHERE mr.movie_id = ?\n ",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "id"
}
}
},
{
"name": "size!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "size"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
},
{
"name": "verdict",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "verdict"
}
}
},
{
"name": "rejected_rule",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "rejected_rule"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5"
}
@@ -0,0 +1,28 @@
{
"db_name": "SQLite",
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
"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": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -58,5 +58,5 @@
false
]
},
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
"hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86"
}
@@ -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\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, 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": 0
},
"nullable": [
false,
false,
true,
false,
true,
true,
false,
false,
false,
true,
true,
true,
true,
true
]
},
"hash": "ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE seasons\n SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id FROM roots WHERE path = ? AND id <> ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT s.runtime_minutes FROM series s\n WHERE s.id = (SELECT s2.series_id FROM episodes e\n JOIN seasons s2 ON s2.id = e.season_id\n WHERE e.id = ?)",
"describe": {
"columns": [
{
"name": "runtime_minutes",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166"
}
Generated
+1
View File
@@ -42,6 +42,7 @@ dependencies = [
"serde_json",
"sqlx",
"tempfile",
"thiserror",
"tokio",
"tracing",
"utoipa",
+79 -1
View File
@@ -77,7 +77,8 @@ Policy
required_audio rule set, see §5.2
dub_blacklist [pt-BR]
hdr_rules see §5.3
size_bands per resolution: floor, target, penalty curve
size_bands per resolution: floor, target, penalty curve,
all per episode (§5.5)
resolution_pref [2160p, 1080p]
source_weights small tiebreaker
@@ -284,6 +285,48 @@ remux scores badly but stays eligible, so it is picked when it is the only
option. The floor matters — unbounded "smaller is better" selects a 3 GB 4K
encode that looks like mud.
**A band describes one episode.** For a movie the question never arises — one
release is one film. For TV a release may carry a single episode, several, or a
whole season, and the shipped TV numbers are episode numbers: a 1080p target of
2 GiB is what one episode should weigh, not a ten-episode pack. So a release is
measured by its size divided by the number of episodes it covers, and that
per-episode figure is what both the target penalty and the floor are compared
against. A 20 GiB pack of ten episodes is scored as 2 GiB and sits at target,
not ten times above it.
The floor takes the same figure. It is a hard reject rather than a penalty, so
comparing a pack's total against an episode-sized floor lets every pack through
untested — wrong in the opposite direction from the target penalty. The floor
exists to keep a 3 GB 4K encode out, and a pack of ten such encodes has to fail
it just as plainly.
**An unknown episode count is one episode.** A release matched before its
season's episodes have been revealed by a metadata refresh has nothing to divide
by, and the rule does not guess a number: the divisor is 1 and the release is
measured by its full size. That is the behaviour today, and it makes a pack look
oversized rather than undersized — it fails toward rejecting a good pack rather
than grabbing a bad one, and the next search after a refresh has the real count.
**A band describes a rate, not a fixed size per episode.** The shipped values
are read against a **reference runtime of 45 minutes**: a 1080p target of 2 GiB
means 2 GiB per 45 minutes of episode. Before the per-episode figure is compared
against them, a band's floor and target are both scaled by
`runtime / 45`, where `runtime` is the series' minutes-per-episode from TMDB
metadata. For a typical drama around 45 minutes the shipped numbers keep exactly
their current meaning; a 22-minute show is judged against roughly half the floor
and half the target instead of being rejected for weighing half of what an hour
of video weighs. The shipped band values themselves do not change — the
reference runtime is chosen so they do not have to.
**A missing or zero runtime is the reference runtime.** When metadata carries no
per-episode runtime, or carries zero, the scale factor is 1 and the band applies
unscaled — exactly today's behaviour. The rule does not guess a duration, for
the same reason the unknown episode count does not guess a number.
**Movies are not scaled.** This applies to episodes only. A movie's bands are
already tuned against feature length, so its floor and target keep their current
meaning regardless of the movie's own runtime.
Source tier (`Remux > BluRay > WEB-DL > WEBRip > HDTV`) survives as a small
tiebreaker. Seeders are log-scaled and small: enough to complete, past that it
does not matter. Telesync, CAM and screener are **hard filters**, not low
@@ -335,6 +378,39 @@ A policy violation found by `ffprobe` is not one thing.
Neither deletes the torrent. See §7.3.
**Two hard failures make a decision, and only for 30 days.** A movie, an
episode or a season enters the needs-a-decision queue (§9.5) when two grabs
against *different* releases hard-failed on it, and both of those failures
happened within the last 30 days. One bad torrent is not a decision — a
release that hard-failed is blacklisted (§6.3) and the next candidate is
grabbed, which is the system working.
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
without it the queue only ever grows and the one season that wants attention
sits behind eight that were dealt with months ago. It is the queue's version of
§6.2's "it never gives up entirely, it goes quiet": a target the operator has
dealt with stops producing failures and drops out once the last one ages past
30 days, while a target that is still broken keeps producing them — the pack
guard retries at worst weekly (§6.2) — and stays queued for exactly as long as
it is genuinely broken. Nothing is dismissed by hand and no acknowledgement
state is stored, so there is no second thing to keep correct.
**Only a target still waiting for a file is queued.** The count and the window
are one rule seen twice — the queue is meant to be visited and emptied — and
this is its third face: a failure history queues a target only while that
target still has a gap to fill. A movie or an episode is queued while it is
`wanted` and not `available`. A season holds no intent of its own (§4.1), so it
is queued while at least one of its episodes is still wanted and still without
a file. A season pack that hard-failed twice, fell back to per-episode grabbing
exactly as §6.2 says it should, and was then fully acquired leaves at once
rather than waiting out the 30 days — that is the system working, not a
decision. A target that is still broken keeps producing failures and stays.
The same rule applies on all three lanes and in both readers.
`GET /api/queues/attention` (§9.3) and the `ntfy` notification (§9.5) are two
views of one queue; filtering differently tells the operator two different
stories on two channels.
## 6. Sourcing
### 6.1 Prowlarr, per-indexer Torznab
@@ -445,6 +521,8 @@ Media kind first, hard audience boundary second, people nowhere.
- **Release group is deliberately absent.** It is not a selection criterion and
it makes filenames long enough to break a terminal.
Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. Changing a root's path is the same move over every title under it, and it is all or nothing: one folder that cannot move puts back the ones that already did and leaves the root's path alone, so the stored path always describes the disk.
During transition, write into the existing roots so Jellyfin needs no
reconfiguration and new content appears immediately. Radarr will not touch a
folder it has no record of.
+1
View File
@@ -18,6 +18,7 @@ reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
utoipa = { workspace = true }
@@ -1,8 +1,9 @@
//! Jellyfin library refresh after import. See DESIGN.md §7.5.
//! Jellyfin library refresh after import or a subtitle write. See DESIGN.md
//! §7.5 and §15.
//!
//! Jellyfin's own filesystem watcher misses the hardlinked file, so import
//! asks it to rescan directly. A refresh failure must not fail the import —
//! the caller logs and continues (§7.5).
//! Jellyfin's own filesystem watcher misses a hardlinked or sidecar file, so
//! callers ask it to rescan directly. A refresh failure must not fail the
//! caller — it logs and continues (§7.5).
use std::time::Duration;
@@ -27,6 +28,9 @@ pub struct JellyfinClient {
}
impl JellyfinClient {
/// # Errors
///
/// If the underlying HTTP client cannot be built.
pub fn new(base_url: impl Into<String>, api_key: Option<String>) -> Result<Self, Error> {
let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?;
Ok(Self {
@@ -38,6 +42,10 @@ impl JellyfinClient {
/// Trigger a full library scan. Jellyfin exposes no per-library refresh
/// without knowing that library's ID, which this app never learns.
///
/// # Errors
///
/// If the request fails, or Jellyfin answers with a non-success status.
pub async fn refresh(&self) -> Result<(), Error> {
let url = format!("{}/Library/Refresh", self.base_url.trim_end_matches('/'));
let mut request = self.client.post(url);
+8
View File
@@ -7,10 +7,13 @@
//! to compile, and the gate in DESIGN.md §12 fails with it.
mod health;
pub mod jellyfin;
mod metadata;
mod movies;
mod owners;
mod policies;
mod reclassify;
mod relocate;
mod roots;
mod search;
mod series;
@@ -100,6 +103,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(series::grab_episode))
.routes(routes!(series::search_season))
.routes(routes!(series::season_releases))
.routes(routes!(series::season_pack_state))
.routes(routes!(series::grab_season_release))
.routes(routes!(series::files))
.routes(routes!(metadata::series_metadata))
@@ -334,6 +338,10 @@ mod tests {
"/api/series/{series_id}/seasons/{season_number}/releases",
"get",
),
(
"/api/series/{series_id}/seasons/{season_number}/pack-state",
"get",
),
(
"/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab",
"post",
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
//! Stored verdicts, re-derived when a title's overrides change (§9.3).
//!
//! A release's verdict is stamped once, by the search that found it. Both the
//! deck and the daemon's manual-grab gate read that stored column, so an
//! override written from the deck's one-click waive would change nothing
//! until the next sweep — the row the operator just acted on would keep
//! reading `rejected` and the grab would be refused.
//!
//! So the rules run again here, over the releases already attached to the
//! title. This is the same correction the daemon makes when a grab turns out
//! to be blacklisted: fix the row now rather than leave §9.3 offering, or
//! refusing, something that is no longer true.
//!
//! The blacklist (§6.3) is not a policy opinion, so a row rejected by it is
//! left exactly as it is.
use std::collections::BTreeMap;
use arr_core::policy::{evaluate, Candidate};
use arr_core::score::claimed_episode_count;
use arr_core::{Language, ParsedRelease, Policy, TitleOverrides};
use arr_db::blacklist;
use crate::movies::{pool, ApiError};
use crate::search::verdict_columns;
use crate::state::AppState;
/// One stored release, as much of it as re-evaluation needs.
struct Stored {
id: i64,
size: i64,
parsed: serde_json::Value,
verdict: Option<String>,
rejected_rule: Option<String>,
}
/// Re-evaluate every release attached to one movie.
///
/// A movie with no stored original language is left alone, the same rule the
/// daemon's search applies: without it the required-audio rule has nothing to
/// compare, and guessing would move verdicts on worse evidence than the ones
/// already there.
pub(crate) async fn movie(state: &AppState, movie_id: i64) -> Result<(), ApiError> {
let Some(language) = movie_language(state, movie_id).await? else {
return Ok(());
};
let Some(loaded) = state
.database()
.ok_or(ApiError::Unavailable)?
.movie_policy(movie_id)
.await
.map_err(|error| ApiError::Database(error.to_string()))?
else {
return Ok(());
};
let releases = sqlx::query_as!(
Stored,
r#"
SELECT r.id AS "id!: i64",
r.size AS "size!: i64",
r.parsed AS "parsed!: serde_json::Value",
r.verdict,
r.rejected_rule
FROM releases r
JOIN movie_releases mr ON mr.release_id = r.id
WHERE mr.movie_id = ?
"#,
movie_id
)
.fetch_all(pool(state)?)
.await?;
apply(
state,
&releases,
&loaded.policy,
&loaded.overrides,
&language,
None,
0,
)
.await
}
/// Re-evaluate every release attached to one series, episode decks and season
/// decks alike — overrides sit on the series (§5.1), so one change moves both.
pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiError> {
let Some(language) = series_language(state, series_id).await? else {
return Ok(());
};
let Some(loaded) = state
.database()
.ok_or(ApiError::Unavailable)?
.series_policy(series_id)
.await
.map_err(|error| ApiError::Database(error.to_string()))?
else {
return Ok(());
};
let releases = sqlx::query_as!(
Stored,
r#"
SELECT r.id AS "id!: i64",
r.size AS "size!: i64",
r.parsed AS "parsed!: serde_json::Value",
r.verdict,
r.rejected_rule
FROM releases r
WHERE r.id IN (
SELECT er.release_id
FROM episode_releases er
JOIN episodes e ON e.id = er.episode_id
JOIN seasons se ON se.id = e.season_id
WHERE se.series_id = ?
UNION
SELECT sr.release_id
FROM season_releases sr
JOIN seasons se ON se.id = sr.season_id
WHERE se.series_id = ?
)
"#,
series_id,
series_id
)
.fetch_all(pool(state)?)
.await?;
// A size band describes one episode (§5.5), so a pack's verdict needs the
// same divisor the deck scores it with.
let lengths = crate::series::season_lengths(state, series_id).await?;
let runtime = crate::series::series_runtime(state, series_id).await?;
apply(
state,
&releases,
&loaded.policy,
&loaded.overrides,
&language,
Some(&lengths),
runtime,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn apply(
state: &AppState,
releases: &[Stored],
policy: &Policy,
overrides: &TitleOverrides,
original_language: &Language,
season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<(), ApiError> {
for release in releases {
if release.rejected_rule.as_deref() == Some(blacklist::RULE) {
continue;
}
let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone())
.map_err(|error| ApiError::Database(error.to_string()))?;
let episodes = season_lengths.map_or(1, |lengths| {
claimed_episode_count(parsed.episode.as_ref(), lengths)
});
// A stored zero is a release the indexer gave no size for, and a
// release with no size has nothing to say about its band — the same
// reading the deck's rescore takes.
let size = u64::try_from(release.size).ok().filter(|size| *size > 0);
let evaluation = evaluate(
policy,
overrides,
original_language,
Candidate::PreGrab(&parsed),
size,
episodes,
runtime_minutes,
);
// `releases` allows a rule name only on a rejected row
// (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`),
// which is also how the daemon writes a waiver.
let (verdict, rule) = verdict_columns(&evaluation.verdict);
let rule = if verdict == "rejected" { rule } else { None };
if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule {
continue;
}
sqlx::query!(
"UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
verdict,
rule,
release.id
)
.execute(pool(state)?)
.await?;
}
Ok(())
}
async fn movie_language(state: &AppState, movie_id: i64) -> Result<Option<Language>, ApiError> {
let stored = sqlx::query_scalar!(
"SELECT original_language FROM movies WHERE id = ?",
movie_id
)
.fetch_optional(pool(state)?)
.await?
.flatten();
Ok(stored.map(|value| arr_db::policy::language(&value)))
}
async fn series_language(state: &AppState, series_id: i64) -> Result<Option<Language>, ApiError> {
let stored = sqlx::query_scalar!(
"SELECT original_language FROM series WHERE id = ?",
series_id
)
.fetch_optional(pool(state)?)
.await?
.flatten();
Ok(stored.map(|value| arr_db::policy::language(&value)))
}
+370
View File
@@ -0,0 +1,370 @@
//! Moving library files when the layout under them changes: a title changing
//! its `root_id` (issue #228), and a root changing its `path` (issue #236).
//! Both rename §7.4 folders and rewrite the `media_files` rows to match, so
//! the layout keeps describing the disk and the root's policy (§5.1) applies
//! to a library the files are actually in.
//!
//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this
//! is a directory rename, never a copy. Hardlinked files keep their inodes
//! and the torrent keeps seeding against them (§7.3).
//!
//! Ordering mirrors `remove_library_files`: the disk is touched before the
//! row changes, so a failed rename leaves the title where its files actually
//! are and the operator can retry.
//!
//! A root path change is the same move repeated over every title under the
//! root, and it is all or nothing. If the seventh of ten folders fails to
//! move, the six already renamed are moved back and the root row is left
//! alone: a root row carries one path, so a half-moved library would have to
//! describe both, and neither the operator nor the next import could tell
//! which titles were where. Refusing leaves one answer — everything is still
//! at the old path — and the retry is the same request again. A retry after
//! an undo that itself failed still converges, because a source that is no
//! longer on disk is skipped while its row is still rewritten.
use std::path::PathBuf;
use crate::movies::{pool, title_target, ApiError};
use crate::state::AppState;
/// Which table owns the moving title's files.
#[derive(Debug, Clone, Copy)]
pub(crate) enum TitleKind {
Movie,
Series,
}
/// One rename from the old root into the new one: a §7.4 title folder, or a
/// loose file sitting straight in the root.
#[derive(Debug)]
struct PlannedRename {
source: PathBuf,
destination: PathBuf,
}
/// The renames already performed on disk and the row rewrites they imply.
/// The database half is the caller's transaction; [`Self::undo`] is for when
/// that transaction fails after the disk already changed.
#[derive(Debug)]
pub(crate) struct Relocation {
performed: Vec<PlannedRename>,
rewrites: Vec<(i64, String)>,
}
/// Whether the destination root is a directory that must already be there.
/// A title moves into another configured root, which exists; a root moving to
/// a new path is moving somewhere that need not exist yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Destination {
Existing,
Create,
}
/// Rename the title's folders into the new root. Called before the row is
/// written, and only when the root actually changes.
///
/// A title with nothing on disk — no `media_files` rows, or rows whose
/// targets are already gone — changes root with no filesystem work at all.
///
/// # Errors
///
/// [`ApiError::Conflict`] when the destination already holds an entry of the
/// same name — a conflict, never an overwrite. [`ApiError::Filesystem`] when
/// a rename fails; whatever had already been renamed is moved back first, so
/// the row the caller then leaves unchanged still describes the disk.
pub(crate) async fn relocate_title(
state: &AppState,
kind: TitleKind,
title_id: i64,
old_root_id: i64,
new_root_id: i64,
) -> Result<Relocation, ApiError> {
let old_root = root_path(state, old_root_id).await?;
let new_root = root_path(state, new_root_id).await?;
let files = title_files(state, kind, title_id).await?;
relocate_files(&files, &old_root, &new_root, Destination::Existing).await
}
/// Rename every title folder under a root into the root's new path, for a
/// `PUT /api/roots/{id}` that changes `path` (issue #236).
///
/// A root with no titles under it — or whose titles have nothing on disk —
/// changes path with no filesystem work at all. The new path is created when
/// there is something to move into it, since a root is normally pointed at a
/// directory that does not exist yet.
///
/// # Errors
///
/// The same two as [`relocate_title`], with the whole root's move treated as
/// one unit: one folder that cannot move takes the entire change down and
/// moves back whatever had already moved.
pub(crate) async fn relocate_root(
state: &AppState,
root_id: i64,
old_path: &str,
new_path: &str,
) -> Result<Relocation, ApiError> {
let files = root_files(state, root_id).await?;
relocate_files(&files, old_path, new_path, Destination::Create).await
}
/// The one mover both callers share: plan every rename, refuse every
/// destination that already exists, then perform them, undoing what was
/// performed if one fails.
async fn relocate_files(
files: &[(i64, String)],
old_root: &str,
new_root: &str,
destination: Destination,
) -> Result<Relocation, ApiError> {
let mut renames: Vec<PlannedRename> = Vec::new();
let mut rewrites: Vec<(i64, String)> = Vec::new();
for (file_id, path) in files {
let Some(source) = title_target(old_root, path) else {
// Outside its own root: not ours to move, and the row keeps
// pointing at where the file really is.
tracing::warn!(%path, %old_root, "media file is outside its root, not moved");
continue;
};
let Some(name) = source.file_name() else {
continue;
};
let destination = std::path::Path::new(new_root).join(name);
if !renames.iter().any(|rename| rename.source == source) {
renames.push(PlannedRename {
source,
destination,
});
}
let relative = std::path::Path::new(path)
.strip_prefix(std::path::Path::new(old_root))
.map_err(|error| ApiError::Filesystem(error.to_string()))?;
let rewritten = std::path::Path::new(new_root).join(relative);
let Some(rewritten) = rewritten.to_str() else {
return Err(ApiError::Filesystem(format!(
"non-UTF-8 path under {new_root}"
)));
};
rewrites.push((*file_id, rewritten.to_owned()));
}
// Every destination is checked before anything is renamed, so a conflict
// never leaves a half-moved library behind. A rename whose source is
// already gone is skipped here rather than refused: the perform loop below
// skips it too, and refusing it would make the documented retry
// impossible. After an undo that itself failed, the folder sits at the
// destination while the row still names the source, and every later
// attempt would 409 on a conflict with the operator's own half-moved
// library.
for rename in &renames {
match tokio::fs::symlink_metadata(&rename.source).await {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
_ => {}
}
match tokio::fs::symlink_metadata(&rename.destination).await {
Ok(_) => {
return Err(ApiError::Conflict(format!(
"the destination already has '{}'",
rename.destination.display()
)))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
}
}
if destination == Destination::Create && !renames.is_empty() {
if let Err(error) = tokio::fs::create_dir_all(new_root).await {
return Err(ApiError::Filesystem(format!(
"could not create '{new_root}': {error}"
)));
}
}
let mut performed: Vec<PlannedRename> = Vec::new();
for rename in renames {
// A recorded file with nothing on disk: the rows still follow the
// title, the same way a delete treats already-gone as done. Checked
// on the source, so a missing *destination* parent stays an error.
match tokio::fs::symlink_metadata(&rename.source).await {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(
source = %rename.source.display(),
"nothing on disk to move for this title"
);
continue;
}
Err(error) => return Err(failed(&rename, &error, performed).await),
}
match tokio::fs::rename(&rename.source, &rename.destination).await {
Ok(()) => {
tracing::info!(
source = %rename.source.display(),
destination = %rename.destination.display(),
"moved a title folder"
);
performed.push(rename);
}
Err(error) => return Err(failed(&rename, &error, performed).await),
}
}
Ok(Relocation {
performed,
rewrites,
})
}
/// One rename failed: move back everything that had already moved and name
/// the folder that stopped the move, so the operator knows which title to
/// look at before retrying.
async fn failed(
rename: &PlannedRename,
error: &std::io::Error,
performed: Vec<PlannedRename>,
) -> ApiError {
Relocation {
performed,
rewrites: Vec::new(),
}
.undo()
.await;
ApiError::Filesystem(format!(
"could not move '{}': {error}",
rename.source.display()
))
}
impl Relocation {
/// Whether anything on disk actually moved — the trigger for the same
/// single Jellyfin refresh import performs (§7.5).
pub(crate) fn moved_files(&self) -> bool {
!self.performed.is_empty()
}
/// Point the `media_files` rows at the new root, inside the caller's
/// transaction so they land together with the `root_id` change or not at
/// all.
pub(crate) async fn rewrite_rows(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
) -> Result<(), sqlx::Error> {
for (file_id, path) in &self.rewrites {
sqlx::query!(
"UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
path,
file_id
)
.execute(&mut **transaction)
.await?;
}
Ok(())
}
/// Best-effort reversal of the renames, for when the disk moved but the
/// database write failed. A reversal that itself fails is logged: at that
/// point the operator's retry is the recovery path.
pub(crate) async fn undo(&self) {
for rename in self.performed.iter().rev() {
if let Err(error) = tokio::fs::rename(&rename.destination, &rename.source).await {
tracing::error!(
source = %rename.source.display(),
destination = %rename.destination.display(),
%error,
"could not move the title folder back after a failed root change"
);
}
}
}
}
/// §7.5 after a move: the same single best-effort refresh import performs.
/// Failure logs and never fails the write that already committed.
pub(crate) async fn refresh_jellyfin(state: &AppState) {
if let Some(jellyfin) = state.jellyfin() {
if let Err(error) = jellyfin.refresh().await {
tracing::warn!(%error, "jellyfin refresh after a root change failed");
}
}
}
async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
Ok(sqlx::query_scalar!(
r#"SELECT path AS "path!: String" FROM roots WHERE id = ?"#,
root_id
)
.fetch_one(pool(state)?)
.await?)
}
/// Every file the service recorded under a root: the rows of every movie in
/// it, and the rows of every episode of every series in it. Ordered so the
/// renames happen in a stable order, which is what makes a failure part-way
/// through reproducible.
async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>, ApiError> {
let mut files: Vec<(i64, String)> = sqlx::query!(
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
FROM media_files mf
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
WHERE m.root_id = ?
ORDER BY mf.id"#,
root_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect();
files.extend(
sqlx::query!(
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
FROM media_files mf
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE s.root_id = ?
ORDER BY mf.id"#,
root_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path)),
);
Ok(files)
}
/// Every file the service recorded for the title: a movie's own rows, or the
/// rows of every episode below a series.
async fn title_files(
state: &AppState,
kind: TitleKind,
title_id: i64,
) -> Result<Vec<(i64, String)>, ApiError> {
Ok(match kind {
TitleKind::Movie => sqlx::query!(
r#"SELECT id AS "id!: i64", path AS "path!: String"
FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect(),
TitleKind::Series => sqlx::query!(
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
FROM media_files mf
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
WHERE se.series_id = ?"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect(),
})
}
+503 -23
View File
@@ -172,26 +172,74 @@ pub async fn update(
let input = parsed(body)?;
input.validate().map_err(ApiError::Invalid)?;
input.policy_exists(&state).await?;
let current = load_root(&state, id).await?;
let path = input.path.trim().to_owned();
let result = sqlx::query!(
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?"#,
input.kind,
input.audience,
path,
input.policy_id,
id,
)
.execute(pool(&state)?)
.await
.map_err(root_conflict)?;
if result.rows_affected() == 0 {
return Err(ApiError::RootNotFound);
// A path change moves every §7.4 title folder under this root with the
// row (issue #236), the same way changing a title's root moves one
// (#228). Disk first, row second: a failed rename leaves the root row
// alone, so the operator sees the library where its files actually are
// and can retry. A path already taken is refused before any of it, since
// the write would fail afterwards anyway.
let relocation = if path == current.path {
None
} else {
path_is_free(&state, id, &path).await?;
Some(crate::relocate::relocate_root(&state, id, &current.path, &path).await?)
};
let mut transaction = pool(&state)?.begin().await?;
let written: Result<(), sqlx::Error> = async {
sqlx::query!(
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?"#,
input.kind,
input.audience,
path,
input.policy_id,
id,
)
.execute(&mut *transaction)
.await?;
if let Some(relocation) = &relocation {
relocation.rewrite_rows(&mut transaction).await?;
}
Ok(())
}
.await;
let committed = match written {
Ok(()) => transaction.commit().await.map_err(root_conflict),
Err(error) => Err(root_conflict(error)),
};
if let Err(error) = committed {
if let Some(relocation) = &relocation {
relocation.undo().await;
}
return Err(error);
}
if relocation
.as_ref()
.is_some_and(crate::relocate::Relocation::moved_files)
{
crate::relocate::refresh_jellyfin(&state).await;
}
Ok(Json(load_root(&state, id).await?))
}
/// The unique index on `path` would catch this after the move; catching it
/// first keeps a doomed write from touching the disk at all.
async fn path_is_free(state: &AppState, id: i64, path: &str) -> Result<(), ApiError> {
let taken: Option<i64> =
sqlx::query_scalar!("SELECT id FROM roots WHERE path = ? AND id <> ?", path, id)
.fetch_optional(pool(state)?)
.await?;
if taken.is_some() {
return Err(ApiError::Conflict(
"a root with this path already exists".into(),
));
}
Ok(())
}
/// A duplicate path or a duplicate (kind, audience) pair is a settings
/// mistake the operator can fix, not a server fault.
fn root_conflict(error: sqlx::Error) -> ApiError {
@@ -258,7 +306,7 @@ mod tests {
use crate::{router, AppState, Upstreams};
use axum::http::StatusCode;
async fn application() -> (tempfile::TempDir, String) {
async fn application() -> (tempfile::TempDir, AppState, String) {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
@@ -274,14 +322,14 @@ mod tests {
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let app = router(state);
let app = router(state.clone());
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, format!("http://{address}"))
(dir, state, format!("http://{address}"))
}
#[tokio::test]
async fn roots_carry_their_policy_name() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
@@ -325,7 +373,7 @@ mod tests {
#[tokio::test]
async fn a_root_round_trips_through_create_and_update() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let policy_ids = first_policy_ids(&base).await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
@@ -399,7 +447,7 @@ mod tests {
#[tokio::test]
async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let policy_ids = first_policy_ids(&base).await;
for (mut payload, field) in [
@@ -428,7 +476,7 @@ mod tests {
#[tokio::test]
async fn a_duplicate_path_or_pair_conflicts() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let mut path = root_input(1);
path["audience"] = serde_json::json!("kids");
path["path"] = serde_json::json!("/mnt/media/movies/kids");
@@ -454,7 +502,7 @@ mod tests {
#[tokio::test]
async fn a_root_with_titles_refuses_to_die() {
let (_dir, base) = application().await;
let (_dir, _state, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await
.expect("roots")
@@ -495,4 +543,436 @@ mod tests {
.expect("delete empty root");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}
async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) {
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(path.to_str().expect("utf-8 root"))
.bind(root_id)
.execute(state.database().expect("database").pool())
.await
.expect("point the root at the tempdir");
}
async fn add_movie(base: &str, tmdb_id: i64, title: &str, root_id: i64) -> i64 {
let created: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": tmdb_id, "title": title, "year": 2024,
"original_language": "en", "root_id": root_id,
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
created["id"].as_i64().expect("movie id")
}
/// One movie's §7.4 folder on disk with a feature in it, and the
/// `media_files` row that points at the feature.
async fn library_folder(
state: &AppState,
movie_id: i64,
root: &std::path::Path,
folder: &str,
) -> std::path::PathBuf {
let folder = root.join(folder);
tokio::fs::create_dir_all(&folder)
.await
.expect("create title folder");
let feature = folder.join("feature.mkv");
tokio::fs::write(&feature, b"feature").await.expect("write");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('movie', ?, ?, 7)",
)
.bind(movie_id)
.bind(feature.to_str().expect("utf-8 path"))
.execute(state.database().expect("database").pool())
.await
.expect("media file");
folder
}
async fn root_payload(base: &str, id: i64, path: &str) -> serde_json::Value {
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
.await
.expect("root")
.json()
.await
.expect("root json");
serde_json::json!({
"kind": root["kind"],
"audience": root["audience"],
"path": path,
"policy_id": root["policy_id"],
})
}
async fn file_paths(state: &AppState) -> Vec<String> {
sqlx::query_scalar("SELECT path FROM media_files ORDER BY id")
.fetch_all(state.database().expect("database").pool())
.await
.expect("media files")
}
async fn stored_path(base: &str, id: i64) -> String {
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
.await
.expect("root")
.json()
.await
.expect("root json");
root["path"].as_str().expect("path").to_owned()
}
/// Issue #236: changing a root's path renames every §7.4 title folder
/// under it into the new path and rewrites the `media_files` rows in the
/// same write. Titles under other roots are not this root's business.
#[tokio::test]
async fn changing_a_root_path_moves_every_title_under_it() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let other = tempfile::tempdir().expect("other root");
point_root_at(&state, 1, old.path()).await;
point_root_at(&state, 2, other.path()).await;
let mut folders = Vec::new();
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
let id = add_movie(&base, tmdb_id, title, 1).await;
folders.push(library_folder(&state, id, old.path(), title).await);
}
let elsewhere = add_movie(&base, 200, "Prisoners", 2).await;
let untouched = library_folder(&state, elsewhere, other.path(), "Prisoners").await;
// The new path need not exist yet — pointing a root somewhere fresh
// is the ordinary case.
let new = old.path().parent().expect("parent").join("relocated-main");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
for folder in &folders {
assert!(!folder.exists(), "{} left the old path", folder.display());
}
for title in ["Dune", "Arrival", "Sicario"] {
assert!(
new.join(title).join("feature.mkv").exists(),
"{title} arrived under the new path"
);
}
assert!(
untouched.join("feature.mkv").exists(),
"a title under another root is untouched"
);
let paths = file_paths(&state).await;
for path in paths.iter().take(3) {
assert!(
std::path::Path::new(path).starts_with(&new),
"the row follows the file: {path}"
);
assert!(
std::path::Path::new(path).exists(),
"the rewritten path describes the disk: {path}"
);
}
assert!(
std::path::Path::new(&paths[3]).starts_with(other.path()),
"the other root's row is untouched: {}",
paths[3]
);
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
tokio::fs::remove_dir_all(&new).await.expect("clean up");
}
/// Issue #236: a root with nothing under it changes path with no
/// filesystem work at all — the new path is not even created.
#[tokio::test]
async fn an_empty_root_changes_path_with_no_filesystem_work() {
let (_dir, state, base) = application().await;
let home = tempfile::tempdir().expect("home");
let new = home.path().join("nothing-here");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
assert!(!new.exists(), "nothing was created on disk");
assert!(file_paths(&state).await.is_empty());
}
/// Issue #236: a destination already holding a folder of that name is a
/// conflict, not an overwrite, and it is caught before anything moves.
#[tokio::test]
async fn a_squatted_destination_refuses_the_whole_move() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let new = tempfile::tempdir().expect("new root");
point_root_at(&state, 1, old.path()).await;
let first = add_movie(&base, 100, "Dune", 1).await;
let second = add_movie(&base, 101, "Arrival", 1).await;
let dune = library_folder(&state, first, old.path(), "Dune").await;
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
tokio::fs::create_dir_all(new.path().join("Arrival"))
.await
.expect("squatter");
let before = file_paths(&state).await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(dune.exists(), "not even the first folder moved");
assert!(arrival.exists());
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
assert_eq!(
stored_path(&base, 1).await,
old.path().to_str().expect("utf-8"),
"the root still points where the files are"
);
}
/// Integration review of the Feedback pass 2 blitz: the retry the module
/// documents has to actually converge. An undo is best-effort, so a move
/// can fail and leave one folder at the destination with its row still
/// naming the source. Every later attempt used to 409 against the
/// operator's own half-moved library, and the only way out was moving the
/// folder back by hand.
#[tokio::test]
async fn a_folder_left_at_the_destination_by_a_failed_undo_does_not_block_the_retry() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let new = tempfile::tempdir().expect("new root");
point_root_at(&state, 1, old.path()).await;
let first = add_movie(&base, 100, "Dune", 1).await;
let second = add_movie(&base, 101, "Arrival", 1).await;
let dune = library_folder(&state, first, old.path(), "Dune").await;
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
// What a failed undo leaves behind: Arrival is already at the new
// path, its row still points at the old one, and the root row was
// never changed.
tokio::fs::create_dir_all(new.path())
.await
.expect("the new root");
tokio::fs::rename(&arrival, new.path().join("Arrival"))
.await
.expect("strand the folder");
assert!(!arrival.exists());
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("retry the move");
assert_eq!(
response.status(),
StatusCode::OK,
"the stranded folder is the state we wanted, not a conflict"
);
assert!(!dune.exists(), "the folder still on disk moved");
assert!(new.path().join("Dune").exists());
assert!(new.path().join("Arrival").exists(), "left where it was");
let paths = file_paths(&state).await;
let root = new.path().to_str().expect("utf-8");
assert!(
paths.iter().all(|path| path.starts_with(root)),
"every row follows the root, the stranded one included: {paths:?}"
);
assert_eq!(stored_path(&base, 1).await, root);
}
/// Issue #236: the move is all or nothing. A folder that cannot be
/// renamed part-way through takes the whole change down: what had already
/// moved is moved back, the root row keeps the old path, and the rows
/// still describe the disk, so the operator can fix the folder and send
/// the same request again.
#[cfg(unix)]
#[tokio::test]
async fn one_folder_that_cannot_move_puts_the_others_back() {
use std::os::unix::fs::PermissionsExt;
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let home = tempfile::tempdir().expect("home of the new path");
let new = home.path().join("relocated-main");
point_root_at(&state, 1, old.path()).await;
let mut folders = Vec::new();
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
let id = add_movie(&base, tmdb_id, title, 1).await;
folders.push(library_folder(&state, id, old.path(), title).await);
}
// Moving a directory to another parent rewrites its `..`, which needs
// write permission on the directory itself: the second title cannot
// move, the first already has.
let stuck = folders[1].clone();
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
.await
.expect("freeze the second title folder");
let before = file_paths(&state).await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body: serde_json::Value = response.json().await.expect("error body");
assert!(
body["error"]
.as_str()
.expect("text")
.contains(stuck.to_str().expect("utf-8")),
"the error names the folder that stopped the move: {body}"
);
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
.await
.expect("thaw the second title folder");
for folder in &folders {
assert!(
folder.join("feature.mkv").exists(),
"{} is back where the row says it is",
folder.display()
);
}
assert!(
new.exists(),
"the move reached the disk: the new path was created for it"
);
assert!(
!new.join("Dune").exists(),
"the folder that had already moved was moved back"
);
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
assert_eq!(
stored_path(&base, 1).await,
old.path().to_str().expect("utf-8"),
"the root still points where the files are"
);
// The same request again, with the folder fixed, is the retry path.
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("retry the move");
assert_eq!(response.status(), StatusCode::OK);
for title in ["Dune", "Arrival", "Sicario"] {
assert!(new.join(title).join("feature.mkv").exists());
}
}
/// Issue #236: a TV root carries series folders, whose files hang off
/// episodes rather than off the title row. They move with the root too.
#[tokio::test]
async fn a_tv_root_moves_its_series_folders() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let home = tempfile::tempdir().expect("home of the new path");
let new = home.path().join("relocated-tv");
point_root_at(&state, 3, old.path()).await;
let pool = state.database().expect("database").pool();
sqlx::query(
"INSERT INTO series (id, tmdb_id, title, year, original_language, root_id)
VALUES (1, 82728, 'Bluey', 2018, 'en', 3)",
)
.execute(pool)
.await
.expect("series");
sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)")
.execute(pool)
.await
.expect("season");
sqlx::query(
"INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 2, 'Hospital')",
)
.execute(pool)
.await
.expect("episode");
let season = old
.path()
.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01");
tokio::fs::create_dir_all(&season)
.await
.expect("create season folder");
let episode = season.join("Bluey - S01E02.mkv");
tokio::fs::write(&episode, b"episode").await.expect("write");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', 1, ?, 9)",
)
.bind(episode.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("media file");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/3"))
.json(&root_payload(&base, 3, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
assert!(
new.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01")
.join("Bluey - S01E02.mkv")
.exists(),
"the whole series folder moved, season layout intact"
);
let paths = file_paths(&state).await;
assert!(
std::path::Path::new(&paths[0]).starts_with(&new)
&& std::path::Path::new(&paths[0]).exists(),
"the episode row follows the file: {}",
paths[0]
);
}
/// Issue #236: a path another root already holds is refused before the
/// disk is touched at all.
#[tokio::test]
async fn a_path_another_root_holds_is_refused_before_any_move() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let taken = tempfile::tempdir().expect("taken root");
point_root_at(&state, 1, old.path()).await;
point_root_at(&state, 2, taken.path()).await;
let id = add_movie(&base, 100, "Dune", 1).await;
let folder = library_folder(&state, id, old.path(), "Dune").await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, taken.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(folder.exists(), "nothing moved");
assert!(!taken.path().join("Dune").exists());
}
}
+28 -5
View File
@@ -1,7 +1,8 @@
use std::collections::BTreeMap;
use std::time::UNIX_EPOCH;
use arr_core::policy::{evaluate, Candidate};
use arr_core::score::score;
use arr_core::score::{claimed_episode_count, score};
use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict};
use arr_db::policy::language;
use arr_db::{blacklist, Blacklist};
@@ -488,6 +489,8 @@ async fn movie_releases(
&overrides,
&original_language,
&blacklist,
None,
0,
)?);
}
}
@@ -511,7 +514,7 @@ async fn episode_releases(
) -> Result<Vec<ClassifiedRelease>, ApiError> {
let database = state.database().ok_or(ApiError::Unavailable)?;
let episode = sqlx::query!(
r#"SELECT s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language,
r#"SELECT s.id AS "series_id!: i64", s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language,
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
@@ -522,6 +525,8 @@ async fn episode_releases(
.fetch_optional(database.pool())
.await?
.ok_or(ApiError::EpisodeNotFound)?;
let season_lengths = crate::series::season_lengths(state, episode.series_id).await?;
let runtime_minutes = crate::series::series_runtime(state, episode.series_id).await?;
let loaded = database
.episode_policy(episode_id)
.await
@@ -564,6 +569,8 @@ async fn episode_releases(
&loaded.overrides,
&original_language,
&blacklist,
Some(&season_lengths),
runtime_minutes,
)?);
}
}
@@ -704,25 +711,37 @@ fn classify(
overrides: &TitleOverrides,
original_language: &Language,
blacklist: &Blacklist,
season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<ClassifiedRelease, ApiError> {
let parsed = arr_parse::parse(&release.name);
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
// divided by how many episodes it covers. `None` is the movie search —
// one release is one film.
let episodes = season_lengths.map_or(1, |lengths| {
claimed_episode_count(parsed.episode.as_ref(), lengths)
});
let evaluation = evaluate(
policy,
overrides,
original_language,
Candidate::PreGrab(&parsed),
release.size,
episodes,
runtime_minutes,
);
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
("rejected", Some(blacklist::RULE.to_owned()))
} else {
verdict(&evaluation.verdict)
verdict_columns(&evaluation.verdict)
};
let score = score(
policy,
Candidate::PreGrab(&parsed),
release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(),
episodes,
runtime_minutes,
);
// A release with no size has nothing to say about its size band, so that
// term is dropped rather than scored as if it were at the floor. Every
@@ -765,7 +784,7 @@ fn classify(
})
}
fn verdict(verdict: &Verdict) -> (&'static str, Option<String>) {
pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
match verdict {
Verdict::Eligible => ("eligible", None),
Verdict::Waived(rule) => ("waived", Some(rule_name(rule))),
@@ -1451,6 +1470,8 @@ mod tests {
&TitleOverrides::default(),
&Language::Other("en".into()),
&Blacklist::default(),
None,
0,
)
.expect("classified release")
};
@@ -1544,7 +1565,7 @@ mod tests {
imdb_id: None,
};
let parsed = arr_parse::parse(&release.name);
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8);
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1, 0);
let classified = classify(
release,
@@ -1552,6 +1573,8 @@ mod tests {
&TitleOverrides::default(),
&Language::Other("en".into()),
&Blacklist::default(),
None,
0,
)
.expect("classified release");
+586 -16
View File
@@ -13,13 +13,13 @@
//! - **Status is derived, never stored** (§4.2). Every series the API returns
//! carries a status computed from its episodes at request time.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason};
use arr_core::{
derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId,
SeriesStatus, TitleOverrides,
derive_series_status, search_backoff, season_grab_reason, EpisodeId, Language, MediaState,
PerEpisodeReason, RootId, SeasonGrabFacts, SeasonId, SeriesId, SeriesStatus, TitleOverrides,
};
use arr_db::policy::language;
use axum::extract::{Path, Query, State};
@@ -184,10 +184,10 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
};
if object
.keys()
.any(|key| key != "only_4k" && key != "allow_english_audio")
.any(|key| key != "only_4k" && key != "allow_english_audio" && key != "allow_below_floor")
{
return Err(ApiError::Invalid(
"overrides supports only only_4k and allow_english_audio".into(),
"overrides supports only only_4k, allow_english_audio and allow_below_floor".into(),
));
}
if object.values().any(|value| !value.is_boolean()) {
@@ -468,11 +468,15 @@ pub async fn create(
let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone());
let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone());
let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average);
let runtime_minutes = tmdb_series
.as_ref()
.and_then(|s| s.episode_runtime)
.map(i64::from);
let result = sqlx::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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
input.auto_track, input.upstream_ended, input.blocked, overrides,
poster_path, backdrop_path, vote_average,
poster_path, backdrop_path, vote_average, runtime_minutes,
)
.execute(pool(&state)?)
.await?;
@@ -561,8 +565,11 @@ pub async fn update(
if title.trim().is_empty() {
return Err(ApiError::Invalid("title cannot be empty".into()));
}
let overrides = input.overrides.unwrap_or(current.overrides);
let overrides = input.overrides.unwrap_or(current.overrides.clone());
validate_overrides(&overrides)?;
// Verdicts already stored for this series were reached under the old
// overrides; §9.3's deck and the grab gate both read them (`reclassify`).
let overrides_changed = overrides != current.overrides;
let overrides =
serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?;
let title = title.trim();
@@ -575,9 +582,54 @@ pub async fn update(
let auto_track = input.auto_track.unwrap_or(current.auto_track);
let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended);
let blocked = input.blocked.unwrap_or(current.blocked);
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id)
.execute(pool(&state)?)
.await?;
// A root change moves the §7.4 title folder with the row (issue #228).
// Disk first, row second: a failed rename leaves the row alone, so the
// operator sees the title where its files actually are and can retry —
// the same ordering `remove_library_files` documents.
let relocation = if root_id == current.root_id {
None
} else {
Some(
crate::relocate::relocate_title(
&state,
crate::relocate::TitleKind::Series,
id,
current.root_id,
root_id,
)
.await?,
)
};
let mut transaction = pool(&state)?.begin().await?;
let written: Result<(), sqlx::Error> = async {
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id)
.execute(&mut *transaction)
.await?;
if let Some(relocation) = &relocation {
relocation.rewrite_rows(&mut transaction).await?;
}
Ok(())
}
.await;
let committed = match written {
Ok(()) => transaction.commit().await.map_err(ApiError::from),
Err(error) => Err(ApiError::from(error)),
};
if let Err(error) = committed {
if let Some(relocation) = &relocation {
relocation.undo().await;
}
return Err(error);
}
if relocation
.as_ref()
.is_some_and(crate::relocate::Relocation::moved_files)
{
crate::relocate::refresh_jellyfin(&state).await;
}
if overrides_changed {
crate::reclassify::series(&state, id).await?;
}
Ok(Json(load_series(&state, id).await?))
}
@@ -682,7 +734,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
Ok(metadata) => metadata,
// Already gone is the state we wanted.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
};
let removed = if metadata.is_dir() {
tokio::fs::remove_dir_all(&target).await
@@ -692,7 +744,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
match removed {
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
}
}
Ok(())
@@ -1360,7 +1412,7 @@ pub async fn episode_releases(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Vec<Release>>, ApiError> {
load_episode(&state, id).await?;
let episode = load_episode(&state, id).await?;
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
.fetch_all(pool(&state)?)
.await?;
@@ -1372,7 +1424,9 @@ pub async fn episode_releases(
.map_err(|error| ApiError::Database(error.to_string()))?
.ok_or(ApiError::EpisodeNotFound)?
.policy;
rescore(&mut releases, &policy)?;
let lengths = season_lengths(&state, episode.series_id).await?;
let runtime = series_runtime(&state, episode.series_id).await?;
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
Ok(Json(releases))
}
@@ -1407,6 +1461,47 @@ pub async fn grab_episode(
/// Resolves a season by its number within one series, so the deck is
/// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`).
/// Per-season episode counts for one series: the divisor data behind
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
/// revealed episodes counts zero, which `claimed_episode_count` treats as
/// unknown.
/// The series' minutes per episode (`DESIGN.md` §5.5): the scale factor
/// behind runtime-scaled size bands. Zero when unknown, which applies the
/// bands unscaled.
pub(crate) async fn series_runtime(state: &AppState, series_id: i64) -> Result<u32, ApiError> {
let minutes = sqlx::query_scalar!("SELECT runtime_minutes FROM series WHERE id = ?", series_id)
.fetch_optional(pool(state)?)
.await?
.flatten();
Ok(minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0))
}
pub(crate) async fn season_lengths(
state: &AppState,
series_id: i64,
) -> Result<BTreeMap<u32, u32>, ApiError> {
let rows = sqlx::query!(
r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64"
FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id
WHERE se.series_id = ?
GROUP BY se.number"#,
series_id
)
.fetch_all(pool(state)?)
.await?;
Ok(rows
.into_iter()
.map(|row| {
(
u32::try_from(row.number).unwrap_or_default(),
u32::try_from(row.episodes).unwrap_or_default(),
)
})
.collect())
}
async fn load_season_id(state: &AppState, series_id: i64, number: i64) -> Result<i64, ApiError> {
sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#,
@@ -1484,10 +1579,147 @@ pub async fn season_releases(
.map_err(|error| ApiError::Database(error.to_string()))?
.ok_or(ApiError::SeasonNotFound)?
.policy;
rescore(&mut releases, &policy)?;
let lengths = season_lengths(&state, series_id).await?;
let runtime = series_runtime(&state, series_id).await?;
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
Ok(Json(releases))
}
/// Which lane a season's missing episodes take (#182, §6.2).
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SeasonLane {
SeasonPack,
PerEpisode,
}
/// What holds a season off the pack lane, in the order that answers the
/// operator's question best — the most fundamental reason first (#182).
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum PackLaneReason {
NoEpisodes,
StillAiring,
EpisodesOnDisk,
PackBackoff,
}
/// Why the season release deck holds what it holds (#182).
///
/// An empty deck is three different truths, and until this endpoint existed
/// the deck could not tell them apart, so it blamed backoff for all three:
/// a pack sweep that has not landed yet, a pack sweep that landed and found
/// nothing, and a season on the per-episode lane, for which no pack sweep is
/// coming at all. The lane says which; `last_pack_search_at` separates the
/// first two, exactly as a movie's `last_searched_at` does (#177).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SeasonPackState {
pub lane: SeasonLane,
/// `None` when the season is on the pack lane.
pub reason: Option<PackLaneReason>,
/// Failed pack grabs so far. §6.2's backoff step is indexed by this, so
/// it is also how long the lane stays quiet (#181).
pub pack_failures: i64,
/// When the pack lane reopens on its own, RFC3339. `None` unless a
/// failure's window is currently open.
pub pack_retry_at: Option<String>,
/// When a pack sweep for this season last completed. `None` means no
/// pack search has ever run, so an empty deck is pending, not settled.
pub last_pack_search_at: Option<String>,
}
#[utoipa::path(
get, path = "/api/series/{series_id}/seasons/{season_number}/pack-state", tag = "series",
params(
("series_id" = i64, Path, description = "Series row id"),
("season_number" = i64, Path, description = "Season number, not its row id")
),
responses(
(status = 200, body = SeasonPackState),
(status = 404, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn season_pack_state(
State(state): State<AppState>,
Path((series_id, number)): Path<(i64, i64)>,
) -> Result<Json<SeasonPackState>, ApiError> {
load_series_row(&state, series_id).await?;
let season_id = load_season_id(&state, series_id, number).await?;
let pool = pool(&state)?;
let episodes = sqlx::query!(
r#"SELECT e.air_date,
EXISTS (
SELECT 1 FROM media_files f
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
) AS "has_file!: bool"
FROM episodes e
WHERE e.season_id = ?"#,
season_id
)
.fetch_all(pool)
.await?;
let last_pack_search_at = sqlx::query_scalar!(
"SELECT last_pack_search_at FROM seasons WHERE id = ?",
season_id
)
.fetch_one(pool)
.await?;
// The same failed-pack tally the grab lane backs off on (#181), read
// here so the deck can name the window instead of guessing at one.
let failed = sqlx::query!(
r#"SELECT count(*) AS "failures!: i64",
max(grabbed_at) AS "last_failed_at?: String"
FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
season_id
)
.fetch_one(pool)
.await?;
let window = chrono::TimeDelta::from_std(search_backoff(failed.failures)).unwrap_or_default();
let reopens_at = if failed.failures > 0 {
failed
.last_failed_at
.as_deref()
.and_then(|at| DateTime::parse_from_rfc3339(at).ok())
.map(|at| at.with_timezone(&Utc) + window)
} else {
None
};
let pack_retry_at = reopens_at.filter(|at| *at > Utc::now());
let air_dates: Vec<Option<SystemTime>> = episodes
.iter()
.map(|episode| air_date(episode.air_date.as_deref()))
.collect();
let reason = season_grab_reason(&SeasonGrabFacts {
air_dates: &air_dates,
now: SystemTime::now(),
any_episode_on_disk: episodes.iter().any(|episode| episode.has_file),
pack_backoff_active: pack_retry_at.is_some(),
});
Ok(Json(SeasonPackState {
lane: if reason.is_some() {
SeasonLane::PerEpisode
} else {
SeasonLane::SeasonPack
},
reason: reason.map(|reason| match reason {
PerEpisodeReason::NoEpisodes => PackLaneReason::NoEpisodes,
PerEpisodeReason::StillAiring => PackLaneReason::StillAiring,
PerEpisodeReason::EpisodesOnDisk => PackLaneReason::EpisodesOnDisk,
PerEpisodeReason::PackBackoff => PackLaneReason::PackBackoff,
}),
pack_failures: failed.failures,
pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()),
last_pack_search_at,
}))
}
#[utoipa::path(
post, path = "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", tag = "series",
params(("series_id" = i64, Path), ("season_number" = i64, Path), ("release_id" = i64, Path)),
@@ -1704,6 +1936,129 @@ mod tests {
response.json().await.expect("season json")
}
/// The production case behind #210: every pack of a season is under
/// §5.5's per-episode floor, so the deck holds three candidates and
/// nothing is grabbable. Writing `allow_below_floor` turns the
/// rejections into waivers — offered, never automatic.
#[tokio::test]
async fn allow_below_floor_turns_a_size_rejected_pack_into_a_waiver() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let episodes: Vec<serde_json::Value> = (1..=10)
.map(|number| {
serde_json::json!({
"number": number, "title": format!("Episode {number}"),
"air_date": "2025-01-01"
})
})
.collect();
let season = add_season(&base, series_id, 9, serde_json::json!(episodes)).await;
let season_id = season["id"].as_i64().expect("season id");
// Ten episodes in 8 GiB: 0.8 GiB each, under the 1 GiB 1080p floor.
let pool = state.database().expect("database").pool();
let name = "Bluey S09 1080p WEB-DL x264-GROUP";
let parsed = arr_parse::parse(name);
let size = 8_i64 * (1 << 30);
let release_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule)
VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'rejected', 'size') RETURNING id",
)
.bind(name)
.bind(size)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.fetch_one(pool)
.await
.expect("release");
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
.bind(season_id)
.bind(release_id)
.execute(pool)
.await
.expect("association");
let response = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"overrides": {"allow_below_floor": true}}))
.send()
.await
.expect("override");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
let updated: serde_json::Value = reqwest::get(format!("{base}/api/series/{series_id}"))
.await
.expect("series")
.json()
.await
.expect("series json");
assert_eq!(updated["overrides"]["allow_below_floor"], true);
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases.len(), 1);
assert_eq!(
releases[0]["verdict"], "waived",
"a waived grab stays a waiver; nothing here makes it eligible"
);
// The row's rule name goes with the rejection; what survives is the
// dashed `waived` verdict the deck reads (§9.3).
assert!(releases[0]["rejected_rule"].is_null());
// The grab the deck's one click sends is now accepted.
let response = reqwest::Client::new()
.post(format!(
"{base}/api/series/{series_id}/seasons/9/releases/{release_id}/grab"
))
.send()
.await
.expect("grab");
assert_eq!(response.status(), StatusCode::ACCEPTED);
// And clearing the override puts the floor back.
reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"overrides": {}}))
.send()
.await
.expect("clear override");
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases[0]["verdict"], "rejected");
assert_eq!(releases[0]["rejected_rule"], "size");
}
#[tokio::test]
async fn overrides_reject_a_key_the_policy_engine_has_no_rule_for() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let response = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"overrides": {"allow_anything": true}}))
.send()
.await
.expect("override");
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
/// §9.6: the three stored artwork fields come off the series detail
/// response at add time — the same call that used to fetch only the
/// TVDB id — so a series added today has a poster before tomorrow's
@@ -2366,6 +2721,108 @@ mod tests {
assert_eq!(after["status"], "complete");
}
/// #182: an empty season deck has to say which empty it is. The lane
/// answers "no pack is coming"; `last_pack_search_at` answers "one ran
/// and found nothing" — and until both existed the deck blamed backoff
/// for every case, including seasons that were never on the pack lane.
#[tokio::test]
async fn the_pack_state_names_why_a_season_deck_is_empty() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([
{"number": 1, "title": "Pilot", "air_date": "2001-01-01"},
{"number": 2, "title": "Second", "air_date": "2001-01-08"}
]),
)
.await;
let season_id = season["id"].as_i64().expect("season id");
let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id");
let pool = state.database().expect("database").pool();
let state_of = |url: String| async move {
let response = reqwest::get(url).await.expect("pack state");
assert_eq!(response.status(), StatusCode::OK);
response
.json::<serde_json::Value>()
.await
.expect("pack state json")
};
let url = format!("{base}/api/series/{series_id}/seasons/1/pack-state");
let fresh = state_of(url.clone()).await;
assert_eq!(fresh["lane"], "season_pack");
assert_eq!(fresh["reason"], serde_json::Value::Null);
assert_eq!(fresh["last_pack_search_at"], serde_json::Value::Null);
assert_eq!(fresh["pack_failures"], 0);
// A pack sweep that ran turns an empty deck from pending to settled.
sqlx::query(
"UPDATE seasons SET last_pack_search_at = '2026-01-01T00:00:00.000Z' WHERE id = ?",
)
.bind(season_id)
.execute(pool)
.await
.expect("stamp the sweep");
let swept = state_of(url.clone()).await;
assert_eq!(swept["lane"], "season_pack");
assert_eq!(swept["last_pack_search_at"], "2026-01-01T00:00:00.000Z");
// §6.2/#181: a failed pack quiets the lane, and the deck says until
// when rather than only that it is quiet.
let release_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, 'pack', 'Bluey S01 1080p WEB-DL', 1000, 'url', '{}', 'eligible')
RETURNING id",
)
.fetch_one(pool)
.await
.expect("release");
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
VALUES (?, 'season', ?, 'hash', 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
)
.bind(release_id)
.bind(season_id)
.execute(pool)
.await
.expect("failed pack grab");
let quiet = state_of(url.clone()).await;
assert_eq!(quiet["lane"], "per_episode");
assert_eq!(quiet["reason"], "pack_backoff");
assert_eq!(quiet["pack_failures"], 1);
assert!(
quiet["pack_retry_at"].is_string(),
"the deck offers a date, not just a closed door"
);
// §14 outranks it: a pack would re-import what is on disk, so
// clearing the failure would not earn a pack anyway.
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)")
.bind(episode_id)
.execute(pool)
.await
.expect("file on disk");
let on_disk = state_of(url.clone()).await;
assert_eq!(on_disk["reason"], "episodes_on_disk");
// An unaired episode outranks both.
sqlx::query(
"UPDATE episodes SET air_date = '2999-01-01' WHERE season_id = ? AND number = 2",
)
.bind(season_id)
.execute(pool)
.await
.expect("unair the finale");
let airing = state_of(url).await;
assert_eq!(airing["reason"], "still_airing");
}
#[tokio::test]
async fn manual_episode_actions_are_scoped_and_respect_blocked() {
let (_dir, state, base) = application().await;
@@ -3239,6 +3696,119 @@ mod tests {
);
}
/// Issue #228: changing a series' root renames its §7.4 title folder —
/// season subfolders inside it — into the new root, and every episode's
/// `media_files` row follows.
#[tokio::test]
async fn changing_root_moves_the_series_folder_and_its_rows() {
let (_dir, state, base) = application().await;
let main_root = tv_root(&state, "main").await;
let kids_root = tv_root(&state, "kids").await;
let series = add_series(&base, main_root, false).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([{ "number": 1, "title": "Magic Xylophone" }]),
)
.await;
let season_id = season["id"].as_i64().expect("season id");
let episode_id: i64 =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
.bind(season_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("episode id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = source.path().join("Bluey (2018) [tmdbid-82728]");
let episode_file = folder.join("Season 01").join("Bluey (2018) - S01E01.mkv");
tokio::fs::create_dir_all(folder.join("Season 01"))
.await
.expect("create season folder");
tokio::fs::write(&episode_file, b"episode")
.await
.expect("write episode");
let pool = state.database().expect("database").pool();
for (root, path) in [(main_root, source.path()), (kids_root, destination.path())] {
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(path.to_str().expect("utf-8 root"))
.bind(root)
.execute(pool)
.await
.expect("point the root at the tempdir");
}
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)",
)
.bind(episode_id)
.bind(episode_file.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("media file");
// The wrong kind of root is still rejected, before anything moves.
let rejected = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"root_id": 1}))
.send()
.await
.expect("move to a movie root");
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"root_id": kids_root}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], kids_root);
assert!(!folder.exists(), "the folder left the old root");
let moved = destination
.path()
.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01")
.join("Bluey (2018) - S01E01.mkv");
assert!(moved.exists(), "the episode arrived, season folder intact");
let path: String = sqlx::query_scalar(
"SELECT path FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
)
.bind(episode_id)
.fetch_one(pool)
.await
.expect("media file row");
assert_eq!(path, moved.to_str().expect("utf-8 path").to_owned());
}
/// Issue #228: a series with nothing on disk changes root with no
/// filesystem work at all.
#[tokio::test]
async fn a_series_with_no_files_changes_root_cleanly() {
let (_dir, state, base) = application().await;
let main_root = tv_root(&state, "main").await;
let kids_root = tv_root(&state, "kids").await;
let series = add_series(&base, main_root, false).await;
let series_id = series["id"].as_i64().expect("id");
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"root_id": kids_root}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], kids_root);
}
#[test]
fn air_dates_parse_as_dates_and_as_timestamps() {
assert_eq!(
+16
View File
@@ -7,6 +7,8 @@ use std::time::Duration;
use arr_db::Db;
use tokio::sync::mpsc;
use crate::jellyfin::JellyfinClient;
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key
/// is configurable, so this is a constant that tests point elsewhere.
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
@@ -76,6 +78,7 @@ pub struct AppState {
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
metadata_commands: mpsc::Sender<MetadataCommand>,
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
jellyfin: Option<JellyfinClient>,
}
/// Work explicitly requested through the movie API.
@@ -146,6 +149,7 @@ impl AppState {
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
metadata_commands,
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)),
jellyfin: None,
})
}
@@ -156,6 +160,18 @@ impl AppState {
self
}
/// Attach the Jellyfin client, so a manual subtitle grab or translation
/// can trigger the same library refresh import does (§7.5, §15).
#[must_use]
pub fn with_jellyfin(mut self, jellyfin: JellyfinClient) -> Self {
self.jellyfin = Some(jellyfin);
self
}
pub(crate) fn jellyfin(&self) -> Option<&JellyfinClient> {
self.jellyfin.as_ref()
}
/// Wait for the next manual movie action in the daemon's reconcile loop.
///
/// # Errors
+141 -18
View File
@@ -4,12 +4,13 @@
//! while a season is airing, grab per episode. A completed season with no
//! episodes on disk prefers the pack — one torrent, better seeded, consistent
//! encode. A pack that hard-failed must not cost the whole season, so the
//! season falls back to per-episode instead of being blacklisted outright.
//! season falls back to per-episode while the failure's §6.2 backoff window
//! is open, and retries the pack once it elapses — quiet, never off.
//!
//! Re-grabbing a pack once an airing season completes is deliberately not
//! done (§14): a season with any episode already on disk grabs per episode.
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
/// How a season's missing wanted episodes should be grabbed next.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -31,28 +32,80 @@ pub struct SeasonGrabFacts<'a> {
/// re-grabs a pack over episodes on disk, and a pack must not re-import
/// what exists).
pub any_episode_on_disk: bool,
/// Whether a season-pack grab for this season already hard-failed.
pub pack_hard_failed: bool,
/// Whether a failed season-pack grab still holds the season off the
/// pack lane — true only while the §6.2 backoff window is open.
pub pack_backoff_active: bool,
}
/// Picks the grab mode for one season.
/// Why a season is not on the season-pack lane.
///
/// The season release deck (§9.3) is empty for a season on the per-episode
/// lane and stays empty however long it waits, so it has to name which of
/// these it is rather than blaming a sweep that is not coming (#182).
/// Ordered by how fundamental the answer is: an unaired episode outranks a
/// failed pack, because clearing the failure would still not earn a pack.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PerEpisodeReason {
/// No episodes are known for the season, so there is nothing to pack.
NoEpisodes,
/// An episode has not aired, or carries no air date at all.
StillAiring,
/// §14: a pack would re-import episodes that are already on disk.
EpisodesOnDisk,
/// §6.2: a failed pack grab's backoff window is still open.
PackBackoff,
}
/// Why one season takes the per-episode lane, or `None` when it takes the
/// pack lane.
///
/// A season is fully released only when every known episode has an air date
/// in the past. An episode with no date could still be unaired, and grabbing
/// a "complete" pack of a season that is not complete costs a whole torrent
/// of the wrong thing — so an undated episode keeps the season per-episode.
#[must_use]
pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
let fully_released = !facts.air_dates.is_empty()
&& facts
.air_dates
.iter()
.all(|date| date.is_some_and(|date| date <= facts.now));
pub fn season_grab_reason(facts: &SeasonGrabFacts<'_>) -> Option<PerEpisodeReason> {
if facts.air_dates.is_empty() {
return Some(PerEpisodeReason::NoEpisodes);
}
if !facts
.air_dates
.iter()
.all(|date| date.is_some_and(|date| date <= facts.now))
{
return Some(PerEpisodeReason::StillAiring);
}
if facts.any_episode_on_disk {
return Some(PerEpisodeReason::EpisodesOnDisk);
}
if facts.pack_backoff_active {
return Some(PerEpisodeReason::PackBackoff);
}
None
}
if fully_released && !facts.any_episode_on_disk && !facts.pack_hard_failed {
SeasonGrabMode::SeasonPack
} else {
SeasonGrabMode::PerEpisode
/// Picks the grab mode for one season.
#[must_use]
pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
match season_grab_reason(facts) {
None => SeasonGrabMode::SeasonPack,
Some(_) => SeasonGrabMode::PerEpisode,
}
}
/// §6.2's targeted-search curve: `1h → 6h → 1d → 3d`, capped at 7d, indexed
/// by how many attempts have already been spent.
///
/// The pack lane counts a season's failed pack grabs as its attempts, so the
/// deck can say when the lane reopens rather than only that it is shut.
#[must_use]
pub fn search_backoff(attempts: i64) -> Duration {
match attempts {
..=1 => Duration::from_hours(1),
2 => Duration::from_hours(6),
3 => Duration::from_hours(24),
4 => Duration::from_hours(72),
_ => Duration::from_hours(24 * 7),
}
}
@@ -69,7 +122,7 @@ mod tests {
air_dates,
now: SystemTime::UNIX_EPOCH + 100 * DAY,
any_episode_on_disk: false,
pack_hard_failed: false,
pack_backoff_active: false,
}
}
@@ -105,10 +158,10 @@ mod tests {
}
#[test]
fn a_hard_failed_pack_falls_back_to_per_episode() {
fn a_failed_pack_inside_its_backoff_window_falls_back_to_per_episode() {
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
let mut facts = facts(&aired);
facts.pack_hard_failed = true;
facts.pack_backoff_active = true;
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
}
@@ -120,4 +173,74 @@ mod tests {
facts.any_episode_on_disk = true;
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
}
/// #182: the deck says which of the four it is, not just "not a pack".
#[test]
fn the_reason_names_the_condition_that_holds_the_pack_lane_shut() {
assert_eq!(
season_grab_reason(&facts(&[])),
Some(PerEpisodeReason::NoEpisodes)
);
let airing = [
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
];
assert_eq!(
season_grab_reason(&facts(&airing)),
Some(PerEpisodeReason::StillAiring)
);
let undated = [Some(SystemTime::UNIX_EPOCH + 10 * DAY), None];
assert_eq!(
season_grab_reason(&facts(&undated)),
Some(PerEpisodeReason::StillAiring)
);
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
let mut on_disk = facts(&aired);
on_disk.any_episode_on_disk = true;
assert_eq!(
season_grab_reason(&on_disk),
Some(PerEpisodeReason::EpisodesOnDisk)
);
let mut backoff = facts(&aired);
backoff.pack_backoff_active = true;
assert_eq!(
season_grab_reason(&backoff),
Some(PerEpisodeReason::PackBackoff)
);
assert_eq!(season_grab_reason(&facts(&aired)), None);
}
/// A failed pack is not the headline when the season could not have had
/// a pack anyway — clearing it would change nothing.
#[test]
fn an_unaired_episode_outranks_a_failed_pack() {
let airing = [
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
];
let mut facts = facts(&airing);
facts.pack_backoff_active = true;
facts.any_episode_on_disk = true;
assert_eq!(
season_grab_reason(&facts),
Some(PerEpisodeReason::StillAiring)
);
}
/// §6.2's curve, shared by the pack lane so the deck can say when it
/// reopens rather than only that it is shut.
#[test]
fn the_backoff_curve_climbs_and_caps_at_a_week() {
assert_eq!(search_backoff(0), Duration::from_hours(1));
assert_eq!(search_backoff(1), Duration::from_hours(1));
assert_eq!(search_backoff(2), Duration::from_hours(6));
assert_eq!(search_backoff(3), DAY);
assert_eq!(search_backoff(4), 3 * DAY);
assert_eq!(search_backoff(5), 7 * DAY);
assert_eq!(search_backoff(50), 7 * DAY);
}
}
+12 -1
View File
@@ -14,7 +14,10 @@ pub mod status;
pub mod tracking;
pub use arr_parse::NameClaims as ParsedRelease;
pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
pub use grabbing::{
search_backoff, season_grab_mode, season_grab_reason, PerEpisodeReason, SeasonGrabFacts,
SeasonGrabMode,
};
pub use matching::{
match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds,
WantedEpisode, WantedMovie,
@@ -234,6 +237,14 @@ pub struct Policy {
pub struct TitleOverrides {
pub only_4k: bool,
pub allow_english_audio: bool,
/// Take a release below its size band's floor on this title (§5.5).
///
/// No band is right for every title, and the floor is a hard reject, so
/// a title the band is wrong about has nothing grabbable at all. This
/// relaxes the floor to a soft fail rather than removing it: the release
/// is waived, never eligible, so it stays a deliberate manual grab and
/// imports on the record as a §5.7 waiver.
pub allow_below_floor: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+229 -4
View File
@@ -64,6 +64,15 @@ pub struct EvaluationContext<'a> {
/// when the caller has no size to offer, which abstains rather than
/// rejects.
pub size_bytes: Option<u64>,
/// How many episodes `size_bytes` covers (`DESIGN.md` §5.5) — a size band
/// describes one episode, so the size rule divides by this. One for a
/// movie or an unknown count; zero is treated as one.
pub episode_count: u32,
/// The series' minutes per episode (`DESIGN.md` §5.5) — a size band is a
/// rate against 45 minutes, so the size rule scales its floor by
/// `runtime / 45`. Zero is a missing runtime and applies the band
/// unscaled; movies are never scaled and pass zero.
pub runtime_minutes: u32,
}
/// A rule's identity when no violation exists to carry concrete evidence.
@@ -107,6 +116,8 @@ pub fn evaluate(
original_language: &Language,
candidate: Candidate<'_>,
size_bytes: Option<u64>,
episode_count: u32,
runtime_minutes: u32,
) -> Evaluation {
let context = EvaluationContext {
policy,
@@ -114,6 +125,8 @@ pub fn evaluate(
original_language,
candidate,
size_bytes,
episode_count,
runtime_minutes,
};
let rules: [&dyn PolicyRule; 6] = [
&ResolutionRule,
@@ -218,6 +231,12 @@ impl PolicyRule for SourceRule {
/// "smaller is better" selects a 3 GB 4K encode that looks like mud. How far
/// a candidate sits from the band's *target* is [`crate::score`]'s question,
/// not this rule's.
///
/// `allow_below_floor` softens the floor for one title rather than lifting
/// it: a below-floor release becomes a waiver, so it is never picked
/// automatically and its import is recorded as a §5.7 waiver. This mirrors
/// [`ResolutionRule`], where an override moves a failure between hard and
/// soft and never makes the rule stop applying.
#[derive(Clone, Copy, Debug, Default)]
pub struct SizeRule;
@@ -227,8 +246,17 @@ impl PolicyRule for SizeRule {
else {
return RuleEvaluation::Unknown(RuleKind::Size);
};
match crate::score::is_below_floor(context.policy, resolution, size) {
match crate::score::is_below_floor(
context.policy,
resolution,
size,
context.episode_count,
context.runtime_minutes,
) {
None => RuleEvaluation::Unknown(RuleKind::Size),
Some(true) if context.overrides.allow_below_floor => {
RuleEvaluation::SoftFail(Rule::Size)
}
Some(true) => RuleEvaluation::HardFail(Rule::Size),
Some(false) => RuleEvaluation::Pass(RuleKind::Size),
}
@@ -515,7 +543,7 @@ mod tests {
}
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
evaluate(policy, overrides, &en(), candidate, None).verdict
evaluate(policy, overrides, &en(), candidate, None, 1, 0).verdict
}
fn verdict_for(
@@ -529,6 +557,8 @@ mod tests {
original_language,
candidate,
None,
1,
0,
)
.verdict
}
@@ -543,6 +573,8 @@ mod tests {
&en(),
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
@@ -646,6 +678,179 @@ mod tests {
);
}
/// A policy with one 1080p band, so the floor has something to say.
fn banded_policy() -> Policy {
Policy {
size_bands: BTreeMap::from([(
Resolution::R1080p,
crate::SizeBand {
floor_bytes: 2 << 30,
target_bytes: 4 << 30,
penalty_points_per_gib_over: 600,
},
)]),
resolution_preference: vec![Resolution::R1080p],
..policy()
}
}
#[test]
fn a_below_floor_release_is_rejected_without_the_override() {
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
let evaluation = evaluate(
&banded_policy(),
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
Some(1 << 30),
1,
0,
);
assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size));
}
#[test]
fn allow_below_floor_waives_the_floor_rather_than_lifting_it() {
let policy = banded_policy();
let overrides = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// Pre-grab the deck offers it, and only as a waiver: `waived` is
// never picked automatically (§9.3), so the operator still decides.
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PreGrab(&claims),
Some(1 << 30),
1,
0,
)
.verdict,
Verdict::Waived(Rule::Size)
);
// And the import records the waiver rather than hard-failing (§5.7).
let media = probed(Resolution::R1080p, Some(Source::WebDl));
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PostDownload(&media),
Some(1 << 30),
1,
0,
)
.verdict,
Verdict::Waived(Rule::Size)
);
}
#[test]
fn allow_below_floor_says_nothing_about_a_release_that_clears_the_floor() {
let overrides = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
assert_eq!(
evaluate(
&banded_policy(),
&overrides,
&en(),
Candidate::PreGrab(&claims),
Some(4 << 30),
1,
0,
)
.verdict,
Verdict::Eligible
);
}
/// §5.5: the floor takes the per-episode figure, so the override that
/// relaxes it has to travel the same divisor.
#[test]
fn allow_below_floor_waives_a_pack_measured_per_episode() {
let policy = banded_policy();
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// Ten episodes at 1 GiB each: over the floor in total, under it per
// episode, which is the figure the floor compares.
let size = Some(10 << 30);
assert_eq!(
evaluate(
&policy,
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
size,
10,
0,
)
.verdict,
Verdict::Rejected(Rule::Size)
);
assert_eq!(
evaluate(
&policy,
&TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
},
&en(),
Candidate::PreGrab(&claims),
size,
10,
0,
)
.verdict,
Verdict::Waived(Rule::Size)
);
}
/// §5.5 scaling composes with the #210 waiver: the runtime moves the
/// floor, and `allow_below_floor` still only softens what remains below
/// it — it never bypasses the scaled comparison.
#[test]
fn allow_below_floor_waives_against_the_scaled_floor() {
let policy = banded_policy();
let waive = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// The 2 GiB floor at 22 minutes is ~0.98 GiB. 1.5 GiB clears it, so
// the override has nothing to waive; 0.5 GiB is below even the
// scaled floor and stays a waiver rather than eligible.
let at = |size_bytes, overrides| {
evaluate(
&policy,
overrides,
&en(),
Candidate::PreGrab(&claims),
Some(size_bytes),
1,
22,
)
.verdict
};
assert_eq!(at(3 << 29, &waive), Verdict::Eligible);
assert_eq!(at(1 << 29, &waive), Verdict::Waived(Rule::Size));
assert_eq!(
at(1 << 29, &TitleOverrides::default()),
Verdict::Rejected(Rule::Size)
);
}
#[test]
fn every_unsafe_source_hard_fails_in_both_phases() {
let policy = policy();
@@ -725,6 +930,8 @@ mod tests {
&en(),
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
@@ -781,6 +988,8 @@ mod tests {
&en(),
Candidate::PostDownload(&media),
None,
1,
0,
);
let expected = if rejected {
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
@@ -800,6 +1009,8 @@ mod tests {
&en(),
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
@@ -832,6 +1043,8 @@ mod tests {
original_language: &original,
candidate: Candidate::PreGrab(&claims),
size_bytes: None,
episode_count: 1,
runtime_minutes: 0,
};
let soft = FixedRule {
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
@@ -860,6 +1073,8 @@ mod tests {
original_language: &original,
candidate: Candidate::PreGrab(&claims),
size_bytes: None,
episode_count: 1,
runtime_minutes: 0,
};
let first = FixedRule {
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
@@ -995,7 +1210,9 @@ mod tests {
&overrides,
&en(),
Candidate::PreGrab(&claims),
None
None,
1,
0,
)
.verdict,
Verdict::Eligible
@@ -1008,7 +1225,9 @@ mod tests {
&overrides,
&en(),
Candidate::PostDownload(&media),
None
None,
1,
0,
)
.verdict,
Verdict::Waived(Rule::RequiredAudio)
@@ -1082,6 +1301,8 @@ mod tests {
&en(),
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
@@ -1093,6 +1314,8 @@ mod tests {
&Language::PortugueseBrazil,
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
}
@@ -1106,6 +1329,8 @@ mod tests {
&en(),
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
assert_eq!(
+437 -17
View File
@@ -19,10 +19,19 @@
//! remux playback test in `DESIGN.md` §14; the model is the decision, not the
//! constants.
use std::collections::BTreeMap;
use arr_parse::EpisodeClaim;
use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source};
const BYTES_PER_GIB: i64 = 1 << 30;
/// The reference runtime (`DESIGN.md` §5.5): a band's shipped values are a
/// rate against a 45-minute episode, and both floor and target scale by
/// `runtime / 45` before a per-episode size is compared to them.
pub const REFERENCE_RUNTIME_MINUTES: u32 = 45;
/// How much each scoring term is worth. Policy data, not constants in the
/// code, for the same reason the size bands are.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -84,13 +93,36 @@ pub struct Score {
/// resolution the policy carries no band for — scores zero on the dominant
/// term rather than being penalised for what the release name failed to say.
/// The resolution-rank term treats an unclaimed resolution the same way.
///
/// A band describes one episode (`DESIGN.md` §5.5), so `episode_count` is the
/// divisor that turns a season pack's total into the per-episode figure the
/// band is compared against. The caller supplies it — `arr-core` has no IO,
/// and a `Season` claim names a season, not a length. A movie, and any release
/// whose count is unknown, is one episode; zero is treated as one.
///
/// A band also describes a rate against [`REFERENCE_RUNTIME_MINUTES`], so
/// `runtime_minutes` — the series' minutes per episode, caller-supplied the
/// same way — scales its floor and target before the comparison. Zero is a
/// missing runtime and applies the band unscaled, exactly the pre-scaling
/// behaviour. Movies are never scaled: their bands are already tuned against
/// feature length, so a movie caller passes zero.
#[must_use]
pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score {
pub fn score(
policy: &Policy,
candidate: Candidate<'_>,
size_bytes: u64,
seeders: u32,
episode_count: u32,
runtime_minutes: u32,
) -> Score {
let weights = &policy.score_weights;
let claimed = candidate.resolution();
let per_episode = per_episode_size(size_bytes, episode_count);
let size = claimed
.and_then(|resolution| policy.size_bands.get(&resolution))
.map_or(0, |band| size_points(band, weights, size_bytes));
.map_or(0, |band| {
size_points(&scaled_band(band, runtime_minutes), weights, per_episode)
});
let source = candidate
.source()
.map_or(0, |source| source_points(policy, source));
@@ -113,25 +145,98 @@ pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders
///
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
#[must_use]
pub fn score_release(policy: &Policy, release: &Release) -> Score {
pub fn score_release(
policy: &Policy,
release: &Release,
episode_count: u32,
runtime_minutes: u32,
) -> Score {
score(
policy,
Candidate::PreGrab(&release.parsed),
release.size,
release.seeders,
episode_count,
runtime_minutes,
)
}
/// Whether a size falls below its band's floor, which is a hard filter.
///
/// The floor takes the same per-episode figure as the target penalty
/// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor
/// would let every pack through untested. Zero `episode_count` is one episode.
///
/// The floor is also scaled by `runtime_minutes / 45` the way [`score`]
/// scales it: zero runtime means unscaled, and a movie caller passes zero.
///
/// `None` when the policy carries no band for that resolution: no band is no
/// opinion, not a rejection.
#[must_use]
pub fn is_below_floor(policy: &Policy, resolution: Resolution, size_bytes: u64) -> Option<bool> {
policy
.size_bands
.get(&resolution)
.map(|band| size_bytes < band.floor_bytes)
pub fn is_below_floor(
policy: &Policy,
resolution: Resolution,
size_bytes: u64,
episode_count: u32,
runtime_minutes: u32,
) -> Option<bool> {
policy.size_bands.get(&resolution).map(|band| {
per_episode_size(size_bytes, episode_count) < scaled_band(band, runtime_minutes).floor_bytes
})
}
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
/// behind [`score`] and [`is_below_floor`].
///
/// `season_lengths` maps season number to that season's episode count as the
/// caller knows it — the caller supplies the fact the way `apply_auto_track`
/// takes `ever_refreshed`, because `arr-core` cannot look it up. An `Episodes`
/// claim knows its own length; a `Season` or `Seasons` claim is summed from
/// the map. Anything unknown — no claim, a daily episode, a season the map
/// does not carry — falls back to one episode, which measures the release by
/// its full size and fails toward rejecting a good pack rather than grabbing
/// a bad one.
#[must_use]
pub fn claimed_episode_count(
claim: Option<&EpisodeClaim>,
season_lengths: &BTreeMap<u32, u32>,
) -> u32 {
let count = match claim {
Some(EpisodeClaim::Episodes { episodes, .. }) => {
u32::try_from(episodes.len()).unwrap_or(u32::MAX)
}
Some(EpisodeClaim::Season { season }) => season_lengths.get(season).copied().unwrap_or(0),
Some(EpisodeClaim::Seasons { first, last }) => (*first..=*last)
.map(|season| season_lengths.get(&season).copied().unwrap_or(0))
.fold(0u32, u32::saturating_add),
Some(EpisodeClaim::Daily { .. }) | None => 1,
};
count.max(1)
}
/// The per-episode figure every size band is compared against.
fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 {
size_bytes / u64::from(episode_count.max(1))
}
/// A band read at a runtime (`DESIGN.md` §5.5): floor and target scale by
/// `runtime / 45`, the penalty rate stays per gibibyte over. Zero runtime is
/// the reference runtime — the band applies unscaled.
fn scaled_band(band: &SizeBand, runtime_minutes: u32) -> SizeBand {
if runtime_minutes == 0 || runtime_minutes == REFERENCE_RUNTIME_MINUTES {
return *band;
}
SizeBand {
floor_bytes: scale_by_runtime(band.floor_bytes, runtime_minutes),
target_bytes: scale_by_runtime(band.target_bytes, runtime_minutes),
penalty_points_per_gib_over: band.penalty_points_per_gib_over,
}
}
fn scale_by_runtime(bytes: u64, runtime_minutes: u32) -> u64 {
let scaled =
u128::from(bytes) * u128::from(runtime_minutes) / u128::from(REFERENCE_RUNTIME_MINUTES);
u64::try_from(scaled).unwrap_or(u64::MAX)
}
/// The size term: a ramp from the floor up to the target, then a penalty that
@@ -262,7 +367,14 @@ mod tests {
fn scored(source: ClaimedSource, size_bytes: u64, seeders: u32) -> Score {
let claims = claims(source);
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
score(
&policy(),
Candidate::PreGrab(&claims),
size_bytes,
seeders,
1,
0,
)
}
/// The same, at a resolution the caller picks.
@@ -277,10 +389,25 @@ mod tests {
source: Some(source),
..NameClaims::default()
};
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
score(
&policy(),
Candidate::PreGrab(&claims),
size_bytes,
seeders,
1,
0,
)
}
fn size_rule(size_bytes: u64) -> RuleEvaluation {
size_rule_for(size_bytes, 1)
}
fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation {
size_rule_at(size_bytes, episode_count, 0)
}
fn size_rule_at(size_bytes: u64, episode_count: u32, runtime_minutes: u32) -> RuleEvaluation {
let policy = policy();
let overrides = TitleOverrides::default();
let language = Language::Other("en".to_owned());
@@ -291,6 +418,8 @@ mod tests {
original_language: &language,
candidate: Candidate::PreGrab(&claims),
size_bytes: Some(size_bytes),
episode_count,
runtime_minutes,
})
}
@@ -321,6 +450,8 @@ mod tests {
&Language::Other("en".to_owned()),
Candidate::PreGrab(&claims),
Some(gib(60)),
1,
0,
);
// A bad score, but a score: nothing filters it out, so a selection
@@ -387,10 +518,13 @@ mod tests {
assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size));
assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_)));
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3)),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0),
Some(true)
);
assert_eq!(is_below_floor(&policy(), Resolution::R720p, gib(3)), None);
assert_eq!(
is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0),
None
);
}
#[test]
@@ -399,7 +533,7 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..NameClaims::default()
};
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10);
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1, 0);
assert_eq!(score.size, 0);
assert_eq!(score.total, score.source + score.seeders);
@@ -417,8 +551,8 @@ mod tests {
},
);
let claims = claims(ClaimedSource::WebDl);
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10);
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10);
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1, 0);
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1, 0);
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
assert_eq!(under.size, 0);
@@ -466,7 +600,7 @@ mod tests {
resolution: Some(resolution),
..NameClaims::default()
};
score(&policy, Candidate::PreGrab(&claims), gib(8), 0).resolution
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1, 0).resolution
};
assert_eq!(at(ClaimedResolution::P2160), 2 * step);
@@ -483,7 +617,7 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..NameClaims::default()
};
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10);
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1, 0);
assert_eq!(unranked.resolution, 0);
assert_eq!(unclaimed.resolution, 0);
@@ -500,4 +634,290 @@ mod tests {
assert!(hd.seeders > uhd.seeders);
assert!(uhd.total > hd.total);
}
/// Issue #180: a movie passes one episode's worth and must score exactly
/// as it did before packs learned to divide. Every term pinned to a
/// literal so the normalisation cannot quietly move movie scoring.
#[test]
fn a_movie_scores_exactly_as_before_pack_normalisation() {
let at_target = scored(ClaimedSource::WebDl, gib(22), 40);
assert_eq!(
at_target,
Score {
total: 1398,
size: 1000,
source: 50,
seeders: 48,
resolution: 300,
}
);
let over_target = scored(ClaimedSource::WebDl, gib(30), 40);
assert_eq!(
over_target,
Score {
total: 918,
size: 520,
source: 50,
seeders: 48,
resolution: 300,
}
);
}
#[test]
fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() {
let episode = scored(ClaimedSource::WebDl, gib(22), 10);
let claims = claims(ClaimedSource::WebDl);
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10, 0);
assert_eq!(pack.size, episode.size);
assert_eq!(pack.total, episode.total);
}
#[test]
fn a_pack_below_the_floor_per_episode_is_rejected() {
// 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor
// — a pack of mud-quality encodes fails as plainly as one of them.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10, 0),
Some(true)
);
assert_eq!(
size_rule_for(gib(30), 10),
RuleEvaluation::HardFail(Rule::Size)
);
// The same total over three episodes is 10 GiB each and passes.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3, 0),
Some(false)
);
assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_)));
}
/// The corrected acceptance criterion from issue #209: a series whose
/// runtime is known and short is judged against a proportionally scaled
/// floor and target, at both 22 and 45 minutes.
#[test]
fn a_known_short_runtime_scales_the_floor_at_22_and_45_minutes() {
// 4K floor is 8 GiB per 45 minutes; at 22 minutes it is ~3.91 GiB.
// 5 GiB fails the unscaled floor and clears the 22-minute one.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
Some(true)
);
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 22),
Some(false)
);
// Genuinely thin stays rejected even scaled: 3 GiB < 3.91 GiB.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 22),
Some(true)
);
// 45 minutes is the reference runtime: identical to no scaling.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 0)
);
// The size rule takes the same scaled floor.
assert_eq!(
size_rule_at(gib(5), 1, 0),
RuleEvaluation::HardFail(Rule::Size)
);
assert_eq!(
size_rule_at(gib(5), 1, 45),
RuleEvaluation::HardFail(Rule::Size)
);
assert!(matches!(
size_rule_at(gib(5), 1, 22),
RuleEvaluation::Pass(_)
));
assert_eq!(
size_rule_at(gib(3), 1, 22),
RuleEvaluation::HardFail(Rule::Size)
);
}
#[test]
fn the_target_scales_with_runtime_so_equal_bitrates_score_equally() {
let claims = claims(ClaimedSource::WebDl);
let scored_at_runtime = |size, runtime| {
score(&policy(), Candidate::PreGrab(&claims), size, 10, 1, runtime).size
};
// At-target bitrate: 22 GiB per 45 minutes is 22 GiB × 22/45 at 22
// minutes, and both sit at the top of the size term.
let at_target = i64::from(ScoreWeights::default().size_at_target);
assert_eq!(scored_at_runtime(gib(22), 45), at_target);
assert_eq!(scored_at_runtime(gib(22) * 22 / 45, 22), at_target);
// A below-target bitrate lands on the same point of the ramp at any
// runtime, give or take integer rounding.
let half_way_45 = scored_at_runtime(gib(15), 45);
let half_way_22 = scored_at_runtime(gib(15) * 22 / 45, 22);
assert!((half_way_45 - half_way_22).abs() <= 1);
}
/// The correction on issue #209: the Rick and Morty S09 packs are 0.19,
/// 0.24 and 0.32 GiB per 22-minute episode against a 1 GiB 1080p floor.
/// The scaled floor is ~0.489 GiB, they are genuinely low-bitrate, and
/// scaling must not let them through.
#[test]
fn the_rick_and_morty_s09_packs_stay_below_the_scaled_floor() {
let mut policy = policy();
policy.size_bands.insert(
Resolution::R1080p,
SizeBand {
floor_bytes: gib(1),
target_bytes: gib(2),
penalty_points_per_gib_over: 60,
},
);
let episodes = 10;
for per_episode_gib in [19, 24, 32] {
let pack = per_episode_gib * GIB / 100 * u64::from(episodes);
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, pack, episodes, 22),
Some(true)
);
}
// Half a GiB per episode clears the scaled floor: the floor still
// discriminates rather than rejecting every 22-minute release.
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, gib(5), episodes, 22),
Some(false)
);
}
/// A missing runtime is the reference runtime (`DESIGN.md` §5.5): zero
/// reproduces the pre-scaling score exactly, pinned to literals the same
/// way #180 pinned movie scoring.
#[test]
fn a_missing_runtime_reproduces_the_unscaled_score() {
let claims = claims(ClaimedSource::WebDl);
let missing = score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 0);
assert_eq!(
missing,
Score {
total: 918,
size: 520,
source: 50,
seeders: 48,
resolution: 300,
}
);
assert_eq!(
missing,
score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 45)
);
}
#[test]
fn an_unknown_episode_count_falls_back_to_one_episode() {
let single = scored(ClaimedSource::WebDl, gib(22), 10);
let claims = claims(ClaimedSource::WebDl);
let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0, 0);
assert_eq!(zero, single);
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0)
);
}
#[test]
fn claimed_episode_count_comes_from_the_claim_and_the_season_lengths() {
let lengths = BTreeMap::from([(1, 10), (2, 8), (3, 12)]);
let episodes = EpisodeClaim::Episodes {
season: 1,
episodes: vec![2, 3, 4],
};
assert_eq!(claimed_episode_count(Some(&episodes), &lengths), 3);
assert_eq!(
claimed_episode_count(Some(&EpisodeClaim::Season { season: 2 }), &lengths),
8
);
assert_eq!(
claimed_episode_count(Some(&EpisodeClaim::Seasons { first: 1, last: 3 }), &lengths),
30
);
// Unknowns are one episode: no claim, a daily episode, a season the
// map has not revealed yet.
assert_eq!(claimed_episode_count(None, &lengths), 1);
assert_eq!(
claimed_episode_count(
Some(&EpisodeClaim::Daily {
year: 2024,
month: 5,
day: 13
}),
&lengths
),
1
);
assert_eq!(
claimed_episode_count(Some(&EpisodeClaim::Season { season: 9 }), &lengths),
1
);
}
/// The observed case from issue #180: Rick and Morty S08 against a
/// TV policy (1080p target 2 GiB, 2160p target 6 GiB, 60 points per GiB
/// over, resolution step 300). Measured by pack total, the 1080p pack's
/// smaller size penalty beat the 300-point resolution step; per episode,
/// 2160p ranks first as `resolution_pref` says it should.
#[test]
fn the_rick_and_morty_season_pack_ranks_2160p_above_1080p() {
let policy = Policy {
size_bands: BTreeMap::from([
(
Resolution::R1080p,
SizeBand {
floor_bytes: GIB / 4,
target_bytes: gib(2),
penalty_points_per_gib_over: 60,
},
),
(
Resolution::R2160p,
SizeBand {
floor_bytes: gib(1),
target_bytes: gib(6),
penalty_points_per_gib_over: 60,
},
),
]),
..self::policy()
};
let episodes = 10;
let hd_pack = (43 * GIB) / 10; // Rick.and.Morty.S08.1080p.x265-ELiTE, 4.3 GiB
let uhd_pack = (138 * GIB) / 10; // S08 Complete 2160p HDR, 13.8 GiB
let scored = |resolution, size| {
let claims = NameClaims {
resolution: Some(resolution),
source: Some(ClaimedSource::WebDl),
..NameClaims::default()
};
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes, 0)
};
let hd = scored(ClaimedResolution::P1080, hd_pack);
let uhd = scored(ClaimedResolution::P2160, uhd_pack);
// Neither pack trips the floor per episode, so the ranking decides.
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes, 0),
Some(false)
);
assert_eq!(
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0),
Some(false)
);
assert!(uhd.total > hd.total);
}
}
+215 -12
View File
@@ -2,6 +2,15 @@
//! the no-PT-source queue, or hard-failed twice on different releases (the
//! same queues `GET /api/queues/attention` reports, §9.3).
//!
//! §5.7 sets the bar for the hard-fail side: two failures on *different*
//! releases, both inside `arr_db::ATTENTION_WINDOW`, against a target still
//! waiting for a file. One bad torrent is not a decision, a failure already
//! dealt with ages out (#226), and a target that has since been acquired
//! leaves at once (#238). The season lane reads that last condition off its
//! episodes, which is where intent lives (§4.1). `GET /api/queues/attention`
//! filters identically, or the two channels tell the operator different
//! stories.
//!
//! Edge-triggered per title: it notifies once when the title enters either
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
//! again. A series notifies as its series, never per episode — a broken
@@ -34,8 +43,8 @@ struct TvEntry {
no_pt_source: Vec<i64>,
/// Episodes two different releases hard-failed post-probe (§5.7).
hard_failed_episodes: Vec<i64>,
/// Seasons whose pack grab hard-failed, sending the season back to
/// per-episode grabbing.
/// Seasons two different pack releases hard-failed on (§5.7), sending the
/// season back to per-episode grabbing.
failed_season_packs: Vec<i64>,
}
@@ -59,8 +68,8 @@ impl TvEntry {
if !self.failed_season_packs.is_empty() {
parts.push(plural(
self.failed_season_packs.len(),
"season pack hard-failed",
"season packs hard-failed",
"season hard-failed twice on different packs",
"seasons hard-failed twice on different packs",
));
}
parts.join("; ")
@@ -134,8 +143,10 @@ impl AttentionAction {
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') >= 2
"#
AND g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -209,9 +220,14 @@ fn tv_entry(
/// TV roll-up (§9.5): every queued series with what put it there — wanted
/// episodes whose every candidate was rejected for language, episodes two
/// different releases hard-failed post-probe, and seasons whose pack grab
/// hard-failed. One entry per series, so the notification can be one per
/// different releases hard-failed post-probe, and seasons two different packs
/// hard-failed on. One entry per series, so the notification can be one per
/// series however long the broken season is.
///
/// Both hard-fail lanes carry §5.7's liveness condition: an episode is queued
/// only while `wanted` and not `available`, and a season only while at least
/// one of its episodes is. A season pack that failed twice and then fell back
/// to per-episode grabbing (§6.2) drops out as those episodes land.
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
let mut tv = HashMap::new();
@@ -250,10 +266,12 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND e.wanted = 1 AND e.state != 'available'
GROUP BY s.id, s.title, s.year, e.id
HAVING count(DISTINCT g.release_id) >= 2
"#
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -271,8 +289,16 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND EXISTS (
SELECT 1 FROM episodes e
WHERE e.season_id = se.id
AND e.wanted = 1 AND e.state != 'available'
)
GROUP BY s.id, s.title, s.year, se.id
"#
HAVING count(DISTINCT g.release_id) >= 2
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -382,6 +408,54 @@ mod tests {
series_id
}
/// A wanted, missing episode: the least that keeps its season live for
/// §5.7's liveness condition.
async fn insert_wanted_episode(database: &Db, season_id: i64, number: i64) -> i64 {
sqlx::query_scalar(
"INSERT INTO episodes (season_id, number, title, wanted, state)
VALUES (?, ?, ?, 1, 'missing') RETURNING id",
)
.bind(season_id)
.bind(number)
.bind(format!("Episode {number}"))
.fetch_one(database.pool())
.await
.unwrap()
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// stamped `age_days` in the past, so §5.7's window can be exercised
/// without waiting a month.
async fn insert_aged_failed_grab(
database: &Db,
target_kind: &str,
target_id: i64,
release_guid: &str,
age_days: i64,
) {
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
RETURNING id",
)
.bind(release_guid)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
)
.bind(release_id)
.bind(target_kind)
.bind(target_id)
.bind(format!("hash-{release_guid}"))
.bind(format!("-{age_days} days"))
.execute(database.pool())
.await
.unwrap();
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// standing in for what the import tick leaves behind post-probe.
async fn insert_failed_grab(
@@ -548,9 +622,10 @@ mod tests {
.unwrap();
insert_failed_grab(&database, "episode", episode_id, "first").await;
insert_failed_grab(&database, "episode", episode_id, "second").await;
// The pack's failure sent this season back to per-episode grabbing;
// it queues the same series, so it must not double the message.
// The packs' failures sent this season back to per-episode grabbing;
// they queue the same series, so it must not double the message.
insert_failed_grab(&database, "season", season_id, "pack").await;
insert_failed_grab(&database, "season", season_id, "pack-two").await;
let server = MockServer::start().await;
let action = action(&server).await;
@@ -631,4 +706,132 @@ mod tests {
assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
/// §5.7: the season lane holds to the same two-distinct-releases bar the
/// episode lane does, so one bad pack does not notify (#226).
#[tokio::test]
async fn one_failed_season_pack_does_not_notify() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_wanted_episode(&database, season_id, 1).await;
insert_failed_grab(&database, "season", season_id, "pack").await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"one failed pack is the blacklist working, not a decision"
);
insert_failed_grab(&database, "season", season_id, "pack-two").await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"two distinct packs hard-failed: the operator decides"
);
}
/// §5.7: the queue only holds targets still waiting for a file. A season
/// whose packs both hard-failed falls back to per-episode grabbing (§6.2);
/// once every episode has landed the system worked, so the season leaves
/// the queue at once rather than notifying for 30 days (#238).
#[tokio::test]
async fn a_fully_acquired_season_leaves_the_queue() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_wanted_episode(&database, season_id, 1).await;
insert_wanted_episode(&database, season_id, 2).await;
insert_failed_grab(&database, "season", season_id, "pack").await;
insert_failed_grab(&database, "season", season_id, "pack-two").await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"two packs failed and the season still has episodes missing"
);
// Per-episode grabbing got the first one. Still a gap, still queued.
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"already notified, and still queued"
);
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"every episode acquired: nothing left to decide"
);
// Proof it actually left rather than merely staying quiet: a season
// still queued would not notify again on re-entry.
sqlx::query("UPDATE episodes SET state = 'missing' WHERE season_id = ? AND number = 2")
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"broken again: re-enters the queue and notifies"
);
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
/// §5.7: a failure counts for 30 days, so a season dealt with leaves the
/// queue instead of sitting in it forever (#226).
#[tokio::test]
async fn season_failures_older_than_the_window_do_not_notify() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_wanted_episode(&database, season_id, 1).await;
insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await;
insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"failures older than the window are history, not attention"
);
insert_aged_failed_grab(&database, "season", season_id, "new-one", 0).await;
insert_aged_failed_grab(&database, "season", season_id, "new-two", 0).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"still breaking: back in the queue"
);
}
}
+83 -2
View File
@@ -13,13 +13,14 @@
//! - the `grabs` row is written from that response, so a crash between the add
//! and the insert heals on the next tick instead of leaving an orphan.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use arr_core::policy::{evaluate, Candidate};
use arr_core::{score::score, Language, Policy, TitleOverrides, Verdict};
use arr_core::score::{claimed_episode_count, score};
use arr_core::{Language, Policy, TitleOverrides, Verdict};
use arr_db::{blacklist, Blacklist, Db, MoviePolicy};
use arr_dl::{AddTorrent, TorrentSource, TransmissionClient};
use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest};
@@ -1191,6 +1192,7 @@ pub(crate) async fn store_release(
original_language: &Language,
blacklist: &Blacklist,
) -> Result<Option<Eligible>, GrabError> {
// A movie is one episode's worth and is never runtime-scaled (§5.5).
let (release_id, eligible) = classify_and_store(
database,
release,
@@ -1198,6 +1200,8 @@ pub(crate) async fn store_release(
overrides,
original_language,
blacklist,
1,
0,
)
.await?;
sqlx::query!(
@@ -1224,6 +1228,26 @@ pub(crate) async fn store_episode_release(
original_language: &Language,
blacklist: &Blacklist,
) -> Result<(i64, Option<Eligible>), GrabError> {
// A size band describes one episode (`DESIGN.md` §5.5): the release is
// measured per episode, and a season pack's length comes from the series
// the covered episodes belong to. An unrevealed season divides by one.
let claim = arr_parse::parse(&release.name).episode;
let season_lengths = match episode_ids.first() {
Some(&episode_id)
if claim
.as_ref()
.is_some_and(arr_parse::EpisodeClaim::is_season_pack) =>
{
season_lengths_of(database, episode_id).await?
}
_ => BTreeMap::new(),
};
let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths);
// §5.5: the size bands scale by the series' minutes per episode.
let runtime_minutes = match episode_ids.first() {
Some(&episode_id) => series_runtime_of(database, episode_id).await?,
None => 0,
};
let (release_id, eligible) = classify_and_store(
database,
release,
@@ -1231,6 +1255,8 @@ pub(crate) async fn store_episode_release(
overrides,
original_language,
blacklist,
episode_count,
runtime_minutes,
)
.await?;
for episode_id in episode_ids {
@@ -1246,6 +1272,55 @@ pub(crate) async fn store_episode_release(
Ok((release_id, eligible))
}
/// Per-season episode counts for the series one covered episode belongs to
/// (`DESIGN.md` §5.5): the divisor data for season-pack normalisation.
async fn season_lengths_of(
database: &Db,
episode_id: i64,
) -> Result<BTreeMap<u32, u32>, GrabError> {
let rows = sqlx::query!(
r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64"
FROM seasons se
LEFT JOIN episodes e ON e.season_id = se.id
WHERE se.series_id = (SELECT s2.series_id FROM episodes e2
JOIN seasons s2 ON s2.id = e2.season_id
WHERE e2.id = ?)
GROUP BY se.number"#,
episode_id
)
.fetch_all(database.pool())
.await?;
Ok(rows
.into_iter()
.map(|row| {
(
u32::try_from(row.number).unwrap_or_default(),
u32::try_from(row.episodes).unwrap_or_default(),
)
})
.collect())
}
/// The minutes-per-episode of the series one covered episode belongs to
/// (`DESIGN.md` §5.5): the scale factor for its size bands. Zero when the
/// series has no known runtime, which applies the bands unscaled.
async fn series_runtime_of(database: &Db, episode_id: i64) -> Result<u32, GrabError> {
let minutes = sqlx::query_scalar!(
r#"SELECT s.runtime_minutes FROM series s
WHERE s.id = (SELECT s2.series_id FROM episodes e
JOIN seasons s2 ON s2.id = e.season_id
WHERE e.id = ?)"#,
episode_id
)
.fetch_optional(database.pool())
.await?
.flatten();
Ok(minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0))
}
#[allow(clippy::too_many_arguments)]
async fn classify_and_store(
database: &Db,
release: &SearchRelease,
@@ -1253,6 +1328,8 @@ async fn classify_and_store(
overrides: &TitleOverrides,
original_language: &Language,
blacklist: &Blacklist,
episode_count: u32,
runtime_minutes: u32,
) -> Result<(i64, Option<Eligible>), GrabError> {
let parsed = arr_parse::parse(&release.name);
let evaluation = evaluate(
@@ -1261,12 +1338,16 @@ async fn classify_and_store(
original_language,
Candidate::PreGrab(&parsed),
release.size,
episode_count,
runtime_minutes,
);
let scored = score(
policy,
Candidate::PreGrab(&parsed),
release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(),
episode_count,
runtime_minutes,
);
// A release that did not say its size is not a tiny one: scoring it
// against the band's floor would bury it. Same treatment as the manual
+17 -1
View File
@@ -25,9 +25,9 @@ use arr_db::Db;
use arr_dl::TransmissionClient;
use arr_probe::Prober;
use crate::jellyfin::JellyfinClient;
use crate::notify::Notifier;
use crate::reconcile::{Action, ActionFuture, Outcome};
use arr_api::jellyfin::JellyfinClient;
/// A failure during one import tick.
#[derive(Debug, thiserror::Error)]
@@ -307,6 +307,8 @@ impl ImportAction {
&original_language,
Candidate::PostDownload(&feature.media),
Some(feature.size),
1,
0,
);
let waiver: Option<Rule> = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -476,6 +478,10 @@ impl ImportAction {
// §5.6 second phase of truth, over every file that would be
// imported, before anything is placed: one hard failure condemns
// the whole release (§5.7), not the episodes.
let runtime_minutes = pending
.runtime_minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0);
let mut imports = Vec::new();
for assignment in assignments {
if assignment.episode.has_file {
@@ -494,6 +500,8 @@ impl ImportAction {
&original_language,
Candidate::PostDownload(&assignment.file.media),
Some(assignment.file.size),
1,
runtime_minutes,
);
let waiver = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -936,6 +944,10 @@ struct PendingTvImport {
series_title: String,
series_year: Option<i64>,
original_language: Option<String>,
/// §5.5: the series' minutes per episode, scaling the size bands the
/// same way the pre-grab verdict scaled them. `None` applies them
/// unscaled.
runtime_minutes: Option<i64>,
release_name: String,
}
@@ -972,6 +984,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String"
FROM grabs g
JOIN episodes e ON e.id = g.target_id
@@ -995,6 +1008,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title,
series_year: row.series_year,
original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name,
}));
@@ -1009,6 +1023,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String"
FROM grabs g
JOIN seasons se ON se.id = g.target_id
@@ -1031,6 +1046,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title,
series_year: row.series_year,
original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name,
}));
+16 -7
View File
@@ -6,7 +6,6 @@ mod config;
mod grab;
mod import;
mod indexers;
mod jellyfin;
mod manual;
mod metadata;
mod notify;
@@ -94,7 +93,7 @@ enum Error {
#[error("transmission client: {0}")]
Transmission(#[from] arr_dl::Error),
#[error("jellyfin client: {0}")]
Jellyfin(#[from] jellyfin::Error),
Jellyfin(#[from] arr_api::jellyfin::Error),
#[error("ntfy client: {0}")]
Notify(#[from] notify::NotifyError),
#[error("bind {addr}: {source}")]
@@ -124,6 +123,7 @@ async fn run() -> Result<(), Error> {
None
};
let notifier = Notifier::new(config.ntfy_url.clone())?;
let api_jellyfin = jellyfin_client(&config)?;
let (reconcile, manual_grab, manual_tv) =
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?;
// Issue #176: the on-demand half of the metadata lane needs its own
@@ -144,7 +144,9 @@ async fn run() -> Result<(), Error> {
if let Some(tmdb_url) = config.tmdb_url {
upstreams = upstreams.with_tmdb_url(tmdb_url);
}
let state = AppState::new(upstreams)?.with_database(database.clone());
let state = AppState::new(upstreams)?
.with_database(database.clone())
.with_jellyfin(api_jellyfin);
let app = arr_api::router(state.clone())
.merge(arr_compat::router(compat))
@@ -294,10 +296,7 @@ fn reconcile_loop(
} else {
tracing::warn!("TMDB is not configured: series metadata refresh is disabled");
}
let jellyfin = jellyfin::JellyfinClient::new(
config.jellyfin_url.clone(),
config.jellyfin_api_key.clone(),
)?;
let jellyfin = jellyfin_client(config)?;
// Grab before import, so a download that completes on this tick is
// imported on this tick.
reconcile = reconcile.register(
@@ -443,3 +442,13 @@ async fn shutdown() {
tracing::info!("shutting down");
}
/// The Jellyfin client, built fresh for each of its two independent callers:
/// import's own reconcile action, and the subtitle API's manual grab and
/// translate handlers (§7.5, §15).
fn jellyfin_client(config: &Config) -> Result<arr_api::jellyfin::JellyfinClient, Error> {
Ok(arr_api::jellyfin::JellyfinClient::new(
config.jellyfin_url.clone(),
config.jellyfin_api_key.clone(),
)?)
}
+12 -8
View File
@@ -32,8 +32,8 @@ use arr_dl::TransmissionClient;
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
use crate::grab::{
store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber,
SeedingRules,
backoff_elapsed, store_episode_release, store_release, Eligible, GrabError, GrabScope,
GrabTarget, Grabber, SeedingRules,
};
use crate::indexers::IndexerDirectory;
use crate::reconcile::{Action, ActionFuture, Outcome};
@@ -625,11 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
)
.fetch_all(database.pool())
.await?;
let pack_hard_failed = sqlx::query_scalar!(
r#"SELECT EXISTS (
SELECT 1 FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
) AS "failed!: bool""#,
let failed_packs = sqlx::query!(
r#"SELECT count(*) AS "failures!: i64",
max(grabbed_at) AS "last_failed_at?: String"
FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
season_id
)
.fetch_one(database.pool())
@@ -642,7 +642,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
.collect::<Vec<_>>(),
now: std::time::SystemTime::now(),
any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk),
pack_hard_failed,
pack_backoff_active: failed_packs.failures > 0
&& !backoff_elapsed(
failed_packs.failures,
failed_packs.last_failed_at.as_deref(),
),
}) == SeasonGrabMode::SeasonPack)
}
+66 -2
View File
@@ -75,7 +75,7 @@ impl SeriesRefreshAction {
title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average
poster_path, backdrop_path, vote_average, runtime_minutes
FROM series
ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"#
)
@@ -118,7 +118,7 @@ impl SeriesRefreshAction {
title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average
poster_path, backdrop_path, vote_average, runtime_minutes
FROM series WHERE id = ?"#,
series_id
)
@@ -250,6 +250,20 @@ impl SeriesRefreshAction {
changed = true;
}
}
// §5.5: the size bands scale by this. TMDB's `episode_run_time` is
// frequently empty; a known value is never overwritten by a missing
// one, so a series keeps its runtime across TMDB's blank spells.
let runtime = metadata.episode_runtime.map(i64::from);
if runtime.is_some() && runtime != stale.runtime_minutes {
sqlx::query!(
"UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
runtime,
stale.id
)
.execute(&mut *executor)
.await?;
changed = true;
}
let ended = is_upstream_ended(&metadata.status);
if ended != stale.upstream_ended {
sqlx::query!(
@@ -547,6 +561,7 @@ struct DueSeries {
poster_path: Option<String>,
backdrop_path: Option<String>,
vote_average: Option<f64>,
runtime_minutes: Option<i64>,
}
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
/// STRICT). A number past `u16::MAX` cannot match anything real and would
@@ -817,6 +832,55 @@ mod tests {
assert_eq!(vote, Some(8.417));
}
/// §5.5: the refresh stores the minutes-per-episode the size bands scale
/// by, and a later refresh with TMDB's frequently-empty
/// `episode_run_time` never blanks a known value.
#[tokio::test]
async fn refresh_stores_the_episode_runtime_and_keeps_it_over_blanks() {
let (_dir, database) = seeded_series(false).await;
let server = MockServer::start().await;
let body = |episode_run_time: serde_json::Value| {
json!({
"id": 82_728,
"name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"status": "Returning Series",
"episode_run_time": episode_run_time,
"seasons": []
})
};
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([7]))))
.mount(&server)
.await;
action(&server).tick(&database).await.unwrap();
let runtime: Option<i64> =
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(runtime, Some(7));
server.reset().await;
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([]))))
.mount(&server)
.await;
expire_refresh(&database).await;
action(&server).tick(&database).await.unwrap();
let runtime: Option<i64> =
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(runtime, Some(7));
}
/// #160. A series' first refresh reveals its back catalogue, but §4.1
/// never tracks what was already there at add time: nothing is tracked,
/// nothing arrives wanted.
+200 -38
View File
@@ -79,6 +79,7 @@ impl TvGrabAction {
&season,
&blacklist,
EPISODE_SEARCHES_PER_TICK - episode_searches,
false,
)
.await
{
@@ -102,12 +103,15 @@ impl TvGrabAction {
/// Search and grab for one season. `None` means the season was skipped
/// without spending an indexer call — nothing due, or no language yet.
/// `ignore_pack_backoff` is the manual trigger's "try again now": it
/// waives the failed-pack backoff window for this one invocation.
async fn grab_season(
&self,
database: &Db,
season: &PendingSeason,
blacklist: &Blacklist,
episode_budget: usize,
ignore_pack_backoff: bool,
) -> Result<Option<SeasonWork>, GrabError> {
// §5.2: without the title's original language there is nothing to
// evaluate a release against, and guessing is worse than waiting.
@@ -141,7 +145,8 @@ impl TvGrabAction {
air_dates: &air_dates,
now,
any_episode_on_disk: episodes.iter().any(|episode| episode.has_file),
pack_hard_failed: pack_hard_failed(database, season.season_id).await?,
pack_backoff_active: !ignore_pack_backoff
&& pack_backoff_active(database, season.season_id).await?,
});
let aired_due_gaps: Vec<&SeasonEpisode> = gaps
@@ -369,6 +374,10 @@ impl TvGrabAction {
original_language: &Language,
blacklist: &Blacklist,
) -> Result<Vec<TvCandidate>, GrabError> {
// #182: a season-scoped sweep is the pack lane's own search, and the
// release deck needs to know it completed to tell "nothing found"
// from "nothing has run yet".
let pack_sweep = matches!(selector, TvSelector::Season { .. });
let target = TvTarget {
tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()),
title: season.series_title.clone(),
@@ -446,6 +455,9 @@ impl TvGrabAction {
eligible: stored,
});
}
if pack_sweep && any_searchable {
record_pack_search(database, season.season_id).await?;
}
Ok(candidates)
}
@@ -566,8 +578,10 @@ impl TvGrabAction {
}
/// The manual season trigger (issue #125's deck, drained per #132):
/// reset every open episode's backoff and run the same pack-or-fall-back
/// lane as the tick, scoped to this one season with no tick budgets.
/// reset every open episode's backoff, waive the failed-pack backoff
/// (§6.2's escape hatch is this deck), and run the same
/// pack-or-fall-back lane as the tick, scoped to this one season with
/// no tick budgets.
///
/// A satisfied season — nothing open, or a grab already in flight —
/// gets its deck refreshed and no grab; a blocked series refuses.
@@ -625,7 +639,7 @@ impl TvGrabAction {
);
return Ok(());
}
self.grab_season(database, &season, &blacklist, usize::MAX)
self.grab_season(database, &season, &blacklist, usize::MAX, true)
.await?;
Ok(())
}
@@ -1106,19 +1120,37 @@ async fn load_season_release(
}))
}
/// Whether a season-pack grab for this season already hard-failed — the
/// fall-back-to-per-episode signal.
async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
let failed = sqlx::query_scalar!(
r#"SELECT EXISTS (
SELECT 1 FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
) AS "failed!: bool""#,
/// When the season's own pack search last completed (#182). Written only by
/// a season-scoped sweep, so it answers the deck's question — did a pack
/// search run — and nothing else.
async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabError> {
sqlx::query!(
"UPDATE seasons
SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
season_id
)
.execute(database.pool())
.await?;
Ok(())
}
/// Whether failed season-pack grabs still hold this season off the pack
/// lane. §6.2: a failure quiets the pack search on the shared backoff curve
/// (each failed grab is one attempt), it never disables it. Anchored on the
/// latest failed grab's `grabbed_at` — failure time itself is not recorded.
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
let row = sqlx::query!(
r#"SELECT count(*) AS "failures!: i64",
max(grabbed_at) AS "last_failed_at?: String"
FROM grabs
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
season_id
)
.fetch_one(database.pool())
.await?;
Ok(failed)
Ok(row.failures > 0 && !backoff_elapsed(row.failures, row.last_failed_at.as_deref()))
}
/// A pack for exactly this season. Multi-season packs are never selected
@@ -1463,38 +1495,41 @@ mod tests {
/// The third acceptance case, selection side: a hard-failed pack put the
/// release on the blacklist and the season falls back to per-episode —
/// the pack is not tried again and the episodes are not written off.
#[tokio::test]
async fn a_hard_failed_pack_falls_back_to_per_episode() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
// What the import tick left behind: a failed season grab and the
// pack release on the blacklist (§6.3).
/// Seed what the import tick leaves behind after a pack fails: one
/// `failed` season grab per (infohash, age) pair.
async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) {
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, 'pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
'https://tracker/pack.torrent', '{}', 'eligible')
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
'https://tracker/oldpack.torrent', '{}', 'eligible')
RETURNING id",
)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
VALUES (?, 'season', ?, 'dead', 'failed')",
)
.bind(release_id)
.bind(season_id)
.execute(database.pool())
.await
.unwrap();
arr_db::blacklist::add(
database.pool(),
Some("dead"),
"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos",
"dolby_vision_profile",
)
.await
.unwrap();
for (index, age) in ages.iter().enumerate() {
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
VALUES (?, 'season', ?, ?, 'failed',
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
)
.bind(release_id)
.bind(season_id)
.bind(format!("dead{index}"))
.bind(age)
.execute(database.pool())
.await
.unwrap();
}
}
/// A failed pack inside its §6.2 backoff window stays per-episode: the
/// window quiets the pack lane, per-episode still makes progress.
#[tokio::test]
async fn a_failed_pack_inside_its_window_falls_back_to_per_episode() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
failed_packs(&database, season_id, &["-10 minutes"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
@@ -1520,6 +1555,90 @@ mod tests {
);
}
/// §6.2: one failed pack backs the pack lane off for 1h, then the tick
/// retries the pack instead of never trying again.
#[tokio::test]
async fn a_failed_pack_retries_after_its_backoff_window() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
failed_packs(&database, season_id, &["-2 hours"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader).tick(&database).await.unwrap();
assert_eq!(fake.torrents().len(), 1);
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
assert_eq!(
grabs(&database).await.last().unwrap(),
&("season".to_owned(), season_id, "sent".to_owned())
);
}
/// Repeated failures ride the capped curve: five failed packs mean a 7d
/// window — still closed at 6d, open at 8d. Quiet, never off.
#[tokio::test]
async fn a_repeatedly_failed_pack_retries_on_the_capped_curve() {
let inside = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
failed_packs(
&inside.1,
inside.2,
&["-30 days", "-25 days", "-20 days", "-12 days", "-6 days"],
)
.await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader).tick(&inside.1).await.unwrap();
assert!(
fake.torrents()
.iter()
.all(|torrent| !torrent.source.ends_with("pack.torrent")),
"6 days into a 7d window the pack lane stays quiet"
);
let elapsed = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
failed_packs(
&elapsed.1,
elapsed.2,
&["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"],
)
.await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader)
.tick(&elapsed.1)
.await
.unwrap();
assert_eq!(fake.torrents().len(), 1);
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
}
/// The escape hatch (§6.2, issue #181): a manual season search waives
/// the failed-pack backoff and retries the pack right now.
#[tokio::test]
async fn a_manual_season_search_waives_the_pack_backoff() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
failed_packs(&database, season_id, &["-10 minutes"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
action(&indexer, &downloader)
.search_season_now(&database, season_id)
.await
.unwrap();
assert_eq!(fake.torrents().len(), 1);
assert!(fake.torrents()[0].source.ends_with("pack.torrent"));
assert_eq!(
grabs(&database).await.last().unwrap(),
&("season".to_owned(), season_id, "sent".to_owned())
);
assert_eq!(
episode_states(&database).await,
vec!["downloading", "downloading", "downloading"]
);
}
/// §14: a season with an episode already on disk never grabs the pack,
/// even when fully released. The remaining episodes come individually.
#[tokio::test]
@@ -1552,6 +1671,49 @@ mod tests {
assert!(sources[1].ends_with("e03.torrent"));
}
/// #182: the season deck cannot tell a queued pack sweep from one that
/// ran and found nothing unless the pack search leaves a mark. Episode
/// searches must not leave it — a season on the per-episode lane never
/// asked for a pack, and pretending otherwise is the lie the deck told.
#[tokio::test]
async fn only_a_pack_sweep_stamps_the_season() {
let (_dir, database, season_id) =
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
let indexer = prowlarr().await;
let (downloader, fake) = transmission().await;
let stamp = |database: Db| async move {
sqlx::query_scalar::<_, Option<String>>(
"SELECT last_pack_search_at FROM seasons WHERE id = ?",
)
.bind(season_id)
.fetch_one(database.pool())
.await
.unwrap()
};
assert!(stamp(database.clone()).await.is_none());
action(&indexer, &downloader).tick(&database).await.unwrap();
let after_pack = stamp(database.clone()).await;
assert!(
after_pack.is_some(),
"a fully released season takes the pack lane and its sweep is the deck's evidence"
);
assert_eq!(fake.torrents().len(), 1);
// An airing season is on the per-episode lane: its episode searches
// say nothing about whether a pack was ever looked for.
let (_other_dir, airing, airing_id) = wanted_season(&["2024-04-11", "2999-01-01"]).await;
action(&indexer, &downloader).tick(&airing).await.unwrap();
let never: Option<String> =
sqlx::query_scalar("SELECT last_pack_search_at FROM seasons WHERE id = ?")
.bind(airing_id)
.fetch_one(airing.pool())
.await
.unwrap();
assert!(never.is_none());
}
/// An empty result backs the whole season off together (§6.2) instead of
/// hammering the tracker every 30 s.
#[tokio::test]
@@ -1,13 +1,13 @@
-- no-transaction
-- #73. movies.state used missing|grabbed|imported, predating arr-core's
-- canonical MediaState vocabulary (missing|downloading|available) that
-- series/episodes (0005) already follow.
--
-- SQLite can't ALTER a CHECK constraint, so this rebuilds the table. It runs
-- outside a transaction (#155) so `PRAGMA foreign_keys = OFF` takes effect
-- and dropping the old `movies` does not cascade into `movie_releases`.
-- SQLite can't ALTER a CHECK constraint, so this rebuilds the table -- which
-- means dropping the old copy. `movie_releases` references it with
-- ON DELETE CASCADE, so the drop would take its rows down too; stash them
-- first and restore them once the new `movies` exists with the same ids.
PRAGMA foreign_keys = OFF;
CREATE TABLE movie_releases_backup AS SELECT * FROM movie_releases;
CREATE TABLE movies_new (
id INTEGER PRIMARY KEY,
@@ -68,4 +68,6 @@ BEGIN
SELECT RAISE(ABORT, 'movies require a movie root');
END;
PRAGMA foreign_keys = ON;
INSERT INTO movie_releases SELECT * FROM movie_releases_backup;
DROP TABLE movie_releases_backup;
@@ -1,15 +1,11 @@
-- no-transaction
-- #108: a vanished torrent must not re-grab. Overrides #86, which reopened
-- the gap (state = 'missing', wanted untouched) so the next tick re-grabbed
-- the same release. Adds 'parked' so the daemon can clear `wanted` and mark
-- the title honestly instead — distinct from 'missing' (an open gap) and
-- 'available' (satisfied). SQLite cannot alter a CHECK, so both tables are
-- rebuilt (see 0007).
--
-- #155: runs outside a transaction so `PRAGMA foreign_keys = OFF` takes
-- effect and dropping the old tables does not cascade into their children.
PRAGMA foreign_keys = OFF;
CREATE TABLE movie_releases_backup AS SELECT * FROM movie_releases;
CREATE TABLE movies_new (
id INTEGER PRIMARY KEY,
@@ -67,6 +63,11 @@ BEGIN
SELECT RAISE(ABORT, 'movies require a movie root');
END;
INSERT INTO movie_releases SELECT * FROM movie_releases_backup;
DROP TABLE movie_releases_backup;
CREATE TABLE episode_releases_backup AS SELECT * FROM episode_releases;
CREATE TABLE episodes_new (
id INTEGER PRIMARY KEY,
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
@@ -101,4 +102,5 @@ CREATE INDEX episodes_pending_search
CREATE INDEX episodes_state ON episodes (state);
PRAGMA foreign_keys = ON;
INSERT INTO episode_releases SELECT * FROM episode_releases_backup;
DROP TABLE episode_releases_backup;
@@ -1,51 +1,11 @@
-- no-transaction
-- #153. `episodes.title` accepted the empty string, which TMDB sends for an
-- unaired episode it has not named yet. Empty titles leaked into §9.2's
-- search haystack, §7.4 filenames and the compat shim as if they were real
-- text. Rows already carrying `''` take the same "TBA" placeholder the TMDB
-- boundary now substitutes — #121's guarded update replaces it once TMDB
-- fills the title in — and the rebuilt table enforces non-empty with a
-- CHECK. The rebuild runs outside a transaction (#155) so
-- `PRAGMA foreign_keys = OFF` takes effect and dropping the old `episodes`
-- does not cascade into `episode_releases`.
-- fills the title in.
--
-- A hard CHECK (title <> '') would need a table rebuild with foreign_keys
-- off, which sqlx 0.8's migrator cannot run (it always wraps a migration in
-- a transaction, where that pragma is a no-op); enforcement lives in code.
UPDATE episodes SET title = 'TBA', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE title = '';
PRAGMA foreign_keys = OFF;
CREATE TABLE episodes_new (
id INTEGER PRIMARY KEY,
season_id INTEGER NOT NULL REFERENCES seasons (id) ON DELETE CASCADE,
number INTEGER NOT NULL CHECK (number >= 0),
title TEXT NOT NULL CHECK (title <> ''),
air_date TEXT,
wanted INTEGER NOT NULL DEFAULT 0 CHECK (wanted IN (0, 1)),
state TEXT NOT NULL DEFAULT 'missing'
CHECK (state IN ('missing', 'downloading', 'available', 'parked')),
search_attempts INTEGER NOT NULL DEFAULT 0,
last_searched_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
vanished INTEGER NOT NULL DEFAULT 0 CHECK (vanished IN (0, 1)),
UNIQUE (season_id, number)
) STRICT;
INSERT INTO episodes_new (
id, season_id, number, title, air_date, wanted, state, search_attempts,
last_searched_at, created_at, updated_at, vanished
)
SELECT
id, season_id, number, title, air_date, wanted, state, search_attempts,
last_searched_at, created_at, updated_at, vanished
FROM episodes;
DROP TABLE episodes;
ALTER TABLE episodes_new RENAME TO episodes;
CREATE INDEX episodes_pending_search
ON episodes (last_searched_at)
WHERE wanted = 1 AND state = 'missing';
CREATE INDEX episodes_state ON episodes (state);
PRAGMA foreign_keys = ON;
@@ -0,0 +1,6 @@
-- #182. The season release deck cannot tell a queued pack sweep from one
-- that ran and found nothing, so it blames backoff for both. Only the season
-- knows when its own pack search last completed: episodes' `last_searched_at`
-- moves for reasons that have nothing to do with the pack lane, and a season
-- on the per-episode lane never touches the pack search at all.
ALTER TABLE seasons ADD COLUMN last_pack_search_at TEXT;
@@ -0,0 +1,6 @@
-- §5.5 as amended by #187/#208: size bands are rates against a 45-minute
-- reference runtime, scaled by the series' minutes per episode. NULL is a
-- missing runtime — TMDB's episode_run_time is frequently empty — and means
-- the bands apply unscaled.
ALTER TABLE series ADD COLUMN runtime_minutes INTEGER
CHECK (runtime_minutes IS NULL OR runtime_minutes > 0);
+9 -49
View File
@@ -17,6 +17,15 @@ use sqlx::{migrate::MigrateError, SqlitePool};
/// The migrations embedded in the binary, so a deploy is one file.
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision
/// queue, as a SQLite time modifier.
///
/// Nothing ever clears a `grabs` row, so without a bound the queue only grows
/// and the one season that wants attention sits behind the ones that do not.
/// Callers pair it with the `grabbed_at` format:
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
pub const ATTENTION_WINDOW: &str = "-30 days";
/// How long a writer waits for the write lock before giving up.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
@@ -419,55 +428,6 @@ mod tests {
);
}
/// #153: an empty episode title takes the TBA placeholder during 0021's
/// backfill, and the rebuilt table's CHECK rejects new empty titles.
#[tokio::test]
async fn empty_episode_titles_are_backfilled_and_then_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Db::connect(dir.path().join("arr.db"))
.await
.expect("connect");
MIGRATOR
.run_to(20, db.pool())
.await
.expect("migrations before the #153 backfill");
let series_id = sqlx::query(
"INSERT INTO series (tmdb_id, title, root_id)
SELECT 82728, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1",
)
.execute(db.pool())
.await
.expect("series")
.last_insert_rowid();
let season_id = sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
.bind(series_id)
.execute(db.pool())
.await
.expect("season")
.last_insert_rowid();
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 1, '')")
.bind(season_id)
.execute(db.pool())
.await
.expect("empty title, valid before 0021");
db.migrate().await.expect("remaining migrations");
let title: String = sqlx::query_scalar("SELECT title FROM episodes")
.fetch_one(db.pool())
.await
.expect("episode row survives the rebuild");
assert_eq!(title, "TBA");
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 2, '')")
.bind(season_id)
.execute(db.pool())
.await
.expect_err("the rebuilt column rejects the empty string");
}
#[tokio::test]
async fn seeds_two_tv_roots_with_distinct_policies() {
let (_dir, db) = fresh().await;
+80 -1
View File
@@ -193,6 +193,7 @@ impl Db {
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
},
root_id: row.root_id,
root_kind: row.root_kind,
@@ -268,6 +269,75 @@ impl Db {
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
},
root_id: row.root_id,
root_kind: row.root_kind,
root_audience: row.root_audience,
root_path: row.root_path,
}))
}
/// The policy attached to one series' root, with the series' own
/// overrides (§5.1).
///
/// `None` when the series does not exist.
///
/// # Errors
///
/// If the query fails, or a policy column does not hold the JSON its
/// migration promises.
pub async fn series_policy(&self, series_id: i64) -> Result<Option<TitlePolicy>, PolicyError> {
let row = sqlx::query!(
r#"
SELECT s.overrides AS "overrides!: String",
r.id AS "root_id!: i64",
r.kind AS "root_kind!: String",
r.audience AS "root_audience!: String",
r.path AS "root_path!: String",
p.id AS "policy_id!: i64",
p.name AS "policy_name!: String",
p.required_audio AS "required_audio!: String",
p.dub_blacklist AS "dub_blacklist!: String",
p.hdr_rules AS "hdr_rules!: String",
p.size_bands AS "size_bands!: String",
p.resolution_pref AS "resolution_pref!: String",
p.source_weights AS "source_weights!: String",
p.score_weights AS "score_weights!: String"
FROM series s
JOIN roots r ON r.id = s.root_id
JOIN policies p ON p.id = r.policy_id
WHERE s.id = ?
"#,
series_id
)
.fetch_optional(self.pool())
.await?;
let Some(row) = row else {
return Ok(None);
};
let overrides: OverridesJson = json("overrides", &row.overrides)?;
let policy = PolicyColumns {
id: row.policy_id,
name: row.policy_name,
required_audio: row.required_audio,
dub_blacklist: row.dub_blacklist,
hdr_rules: row.hdr_rules,
size_bands: row.size_bands,
resolution_pref: row.resolution_pref,
source_weights: row.source_weights,
score_weights: row.score_weights,
}
.to_policy()?;
Ok(Some(TitlePolicy {
policy,
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
},
root_id: row.root_id,
root_kind: row.root_kind,
@@ -337,6 +407,7 @@ impl Db {
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
},
root_id: row.root_id,
root_kind: row.root_kind,
@@ -428,6 +499,8 @@ struct OverridesJson {
only_4k: bool,
#[serde(default)]
allow_english_audio: bool,
#[serde(default)]
allow_below_floor: bool,
}
fn gib(value: u64) -> u64 {
@@ -619,7 +692,13 @@ mod tests {
let one_and_a_half_gib = 1536 << 20;
assert_eq!(
arr_core::score::is_below_floor(&loaded.policy, Resolution::R1080p, one_and_a_half_gib),
arr_core::score::is_below_floor(
&loaded.policy,
Resolution::R1080p,
one_and_a_half_gib,
1,
0
),
Some(false)
);
}
+10
View File
@@ -156,6 +156,10 @@ pub struct Series {
pub vote_average: Option<f64>,
/// How many votes the rating rests on.
pub vote_count: u32,
/// Minutes per episode, the first non-zero entry of TMDB's
/// `episode_run_time`. Frequently empty for returning series — §5.5
/// treats a missing runtime as the reference runtime.
pub episode_runtime: Option<u32>,
pub seasons: Vec<SeasonSummary>,
}
@@ -368,6 +372,8 @@ pub(crate) struct RawSeries {
#[serde(default)]
vote_count: u32,
#[serde(default)]
episode_run_time: Vec<u32>,
#[serde(default)]
seasons: Vec<RawSeasonSummary>,
#[serde(default)]
external_ids: Option<RawExternalIds>,
@@ -406,6 +412,10 @@ impl From<RawSeries> for Series {
backdrop_path: non_empty(raw.backdrop_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
episode_runtime: raw
.episode_run_time
.into_iter()
.find(|&minutes| minutes > 0),
seasons: raw
.seasons
.into_iter()
+20 -12
View File
@@ -218,10 +218,7 @@
-->
<main class="deck" id="movie" hidden aria-label="movie detail">
<header class="releases-head">
<button type="button" class="control" id="movie-back">back</button>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
</header>
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
<div class="movie-body">
@@ -266,12 +263,19 @@
<button type="button" class="control" id="movie-sweep">search indexers</button>
<button
type="button"
class="control control-quiet"
class="control control-quiet control-icon"
id="movie-remove"
aria-expanded="false"
aria-controls="remove-panel"
aria-label="remove from the library"
>
remove
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<div class="remove-panel" id="remove-panel" hidden></div>
@@ -470,10 +474,7 @@
-->
<main class="deck releases" id="series" hidden aria-label="series detail">
<header class="releases-head">
<button type="button" class="control" id="series-back">back</button>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
</header>
<p class="deck-status readout" id="series-status" role="status" hidden></p>
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
<div class="movie-body">
@@ -506,12 +507,19 @@
<span class="movie-controls-space"></span>
<button
type="button"
class="control control-quiet"
class="control control-quiet control-icon"
id="series-remove"
aria-expanded="false"
aria-controls="series-remove-panel"
aria-label="remove from the library"
>
remove
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2.8 4.3h10.4" />
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
<path d="M6.6 7v4.2" />
<path d="M9.4 7v4.2" />
</svg>
</button>
</div>
<div class="remove-panel" id="series-remove-panel" hidden></div>
+377 -96
View File
@@ -38,6 +38,7 @@ import {
formatAudio,
formatHdr,
formatResolution,
formatRetryWait,
formatScore,
formatSeeders,
formatSize,
@@ -49,15 +50,16 @@ import {
movieFiles,
movieReleases,
movieSearchState,
overridable,
probedAttributeTags,
queueSearch,
removeMovie,
ruleLabel,
type SeasonPackState,
sweepExpected,
totalSize,
type WaiveOutcome,
waiveAndGrab,
waiverOverride,
} from "./releases";
import { currentRoute, navigate, type Route } from "./router";
import {
@@ -101,7 +103,7 @@ import {
type TvTarget,
waiveAndGrabTv,
} from "./series";
import { armedDelete, settingsMain } from "./settings";
import { settingsMain } from "./settings";
import "./style.css";
const POLL_MS = 15_000;
@@ -391,14 +393,14 @@ function main() {
must<HTMLElement>("#library"),
{ kind: "library" },
);
// no origin click to restore focus to on a deep link — the series
// view's back control is the closest stand-in
// no origin click to restore focus to on a deep link — the library
// rail button is the closest stand-in
tvDeck.open({
title,
sub,
seriesId,
target,
origin: must<HTMLButtonElement>("#series-back"),
origin: must<HTMLElement>("#nav-library"),
returnTo: must<HTMLElement>("#series"),
parentRoute: { kind: "series", seriesId },
});
@@ -1261,6 +1263,118 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
});
}
/* ---- icon-only controls (#230) ----------------------------------------- */
const SVG_NS = "http://www.w3.org/2000/svg";
const ICON_PATHS = {
trash: [
"M2.8 4.3h10.4",
"M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2",
"M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5",
"M6.6 7v4.2",
"M9.4 7v4.2",
],
search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"],
disk: [
"M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5",
"M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z",
"M10.9 11.6h.01",
],
} as const;
/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */
function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 16 16");
svg.setAttribute("class", "icon");
svg.setAttribute("aria-hidden", "true");
for (const d of ICON_PATHS[name]) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
/** §9.6 provider marks, drawn and shipped inline — never a remote image. */
function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("class", "icon icon-mark");
svg.setAttribute("aria-hidden", "true");
if (name === "rt") {
svg.setAttribute("viewBox", "0 0 20 20");
const paths = [
"M10 6.6c4.1 0 6.8 2.1 6.8 5.2 0 3.3-3 5.7-6.8 5.7s-6.8-2.4-6.8-5.7c0-3.1 2.7-5.2 6.8-5.2Z",
"M10 6.6C8.6 7.1 7 6.8 5.9 5.8",
"M10 6.6c1.4.5 3 .2 4.1-.8",
"M10 6.6c-.3-1.2 0-2.3.9-3.2",
];
for (const d of paths) {
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", d);
svg.append(path);
}
return svg;
}
svg.setAttribute("viewBox", "0 0 44 20");
const box = document.createElementNS(SVG_NS, "rect");
box.setAttribute("x", "1");
box.setAttribute("y", "1.5");
box.setAttribute("width", "42");
box.setAttribute("height", "17");
box.setAttribute("rx", "4");
const letters = document.createElementNS(SVG_NS, "text");
letters.setAttribute("x", "22");
letters.setAttribute("y", "13.2");
letters.setAttribute("text-anchor", "middle");
letters.setAttribute("font-size", "9");
letters.textContent = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb" }[name];
svg.append(box, letters);
return svg;
}
/**
* The settings arm-then-confirm, icon-only: a trash glyph resting, the
* visible word "confirm" while armed, so an accidental hit never destroys.
*/
function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "control control-quiet control-icon";
button.setAttribute("aria-label", label);
button.title = label;
button.replaceChildren(icon("trash"));
let armed = false;
let resetTimer: number | undefined;
const disarm = () => {
armed = false;
window.clearTimeout(resetTimer);
delete button.dataset.armed;
button.setAttribute("aria-label", label);
button.replaceChildren(icon("trash"));
};
button.addEventListener("click", () => {
if (armed) {
disarm();
button.disabled = true;
execute();
return;
}
armed = true;
button.dataset.armed = "true";
button.textContent = "confirm";
button.setAttribute("aria-label", `confirm — ${label}`);
resetTimer = window.setTimeout(disarm, 4000);
});
button.addEventListener("blur", () => {
if (armed) {
disarm();
}
});
return button;
}
/* ---- §9.6 painters shared by both detail pages ------------------------- */
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
@@ -1279,20 +1393,21 @@ function starIcon(): SVGSVGElement {
}
/** External links read as quiet controls; they leave the app entirely. */
function externalLink(label: string, href: string): HTMLAnchorElement {
function externalLink(mark: SVGSVGElement, label: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
link.className = "control control-quiet";
link.className = "control control-quiet control-icon";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = label;
link.setAttribute("aria-label", label);
link.title = label;
link.append(mark);
return link;
}
function movieMain(views: HideableView[]): MovieView {
const view = must<HTMLElement>("#movie");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#movie-back");
const statusEl = must<HTMLElement>("#movie-status");
const hero = must<HTMLElement>("#movie-hero");
const poster = must<HTMLImageElement>("#movie-poster");
@@ -1350,7 +1465,7 @@ function movieMain(views: HideableView[]): MovieView {
if (!movie) {
return { kind: "error", detail: "page closed", overrideWritten: false };
}
if (bucket === "waived") {
if (bucket !== "eligible") {
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
}
const outcome = await grabRelease(movie.id, release.id);
@@ -1412,6 +1527,8 @@ function movieMain(views: HideableView[]): MovieView {
}
titleEl.textContent = movie.title;
yearEl.textContent = movie.year === null ? "" : String(movie.year);
remove.setAttribute("aria-label", `remove ${movie.title} from the library`);
remove.title = `remove ${movie.title} from the library`;
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === movie.root_id);
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
@@ -1604,13 +1721,26 @@ function movieMain(views: HideableView[]): MovieView {
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
const linkTitle = current?.title ?? "this movie";
actionsEl.append(
externalLink(
providerMark("tmdb"),
`open ${linkTitle} on TMDB`,
tmdbMovieLink(detail.tmdb_id),
),
);
if (detail.imdb_id !== null) {
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
actionsEl.append(
externalLink(providerMark("imdb"), `open ${linkTitle} on IMDb`, imdbLink(detail.imdb_id)),
);
}
if (current !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
externalLink(
providerMark("rt"),
`search Rotten Tomatoes for ${current.title}`,
rottenTomatoesSearch(current.title, current.year),
),
);
}
}
@@ -1904,7 +2034,8 @@ function movieMain(views: HideableView[]): MovieView {
filesSection.hidden = true;
diskRows.replaceChildren();
sweep.disabled = false;
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -1927,8 +2058,6 @@ function movieMain(views: HideableView[]): MovieView {
target?.focus();
}
back.addEventListener("click", close);
// capture + stopImmediatePropagation: one Escape steps back one layer —
// the library and search decks also listen for Escape on this window
window.addEventListener(
@@ -2089,7 +2218,8 @@ function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElemen
interface ReleaseActions {
reload: () => Promise<void>;
notify: (text: string, tone?: "fault") => void;
/** One click on grab — a plain grab, or waive-then-grab on a waived row. */
/** One click on grab a plain grab, or waive-then-grab where the rule
* that failed has an override (§9.3). */
grab: (
release: MovieRelease,
bucket: "eligible" | "waived" | "rejected",
@@ -2153,10 +2283,16 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas
dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions));
}
} else {
// §9.3: over-strict filters must be visible, not silently absent
// §9.3: over-strict filters must be visible, not silently absent — and
// where a rule can be waived, the count says so rather than leaving the
// way out folded inside a collapsed bucket.
const waivable = releases.filter(overridable).length;
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent = "none — every candidate was waived or rejected by policy";
none.textContent =
waivable > 0
? `none — every candidate was waived or rejected by policy; ${waivable} can be waived`
: "none — every candidate was waived or rejected by policy";
dom.eligible.rows.append(none);
}
for (const name of ["waived", "rejected"] as const) {
@@ -2293,8 +2429,14 @@ function releaseRow(
);
}
if (bucket !== "eligible") {
// A rejected row always names its rule; a waived one cannot — the
// `releases` CHECK allows `rejected_rule` only on a rejection. Say
// `waived` plainly rather than calling a classified row unclassified.
const verdict = release.rejected_rule
? `${bucket} · ${ruleLabel(release.rejected_rule)}`
: bucket;
line.append(
chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => {
chip(verdict, (span) => {
span.dataset.verdict = bucket;
}),
);
@@ -2311,11 +2453,14 @@ function releaseRow(
note.setAttribute("role", "status");
note.hidden = true;
if (bucket !== "rejected") {
// A rejected row is normally inert, but §9.3's one click is exactly for
// the rule the operator disagrees with: where an override exists, the row
// offers it and the grab that follows stays a waiver.
const writesOverride = bucket !== "eligible" && overridable(release);
if (bucket !== "rejected" || writesOverride) {
const grab = document.createElement("button");
grab.type = "button";
grab.className = "control rel-grab";
const writesOverride = bucket === "waived" && waiverOverride(release.rejected_rule) !== null;
grab.textContent = writesOverride ? "waive + grab" : "grab";
grab.addEventListener("click", () => {
grab.disabled = true;
@@ -2799,6 +2944,24 @@ function tvReleasesMain(): TvReleasesView {
} else {
delete statusEl.dataset.tone;
}
delete statusEl.dataset.action;
}
/**
* A status that carries its own way out. A season held off the pack lane
* cannot be helped by the head's re-search alone (#181, #182), so the
* sentence that explains the wait also offers the retry that ends it.
*/
function setStatusAction(text: string, label: string, run: () => void) {
const action = document.createElement("button");
action.type = "button";
action.className = "control";
action.textContent = label;
action.addEventListener("click", run);
statusEl.hidden = false;
statusEl.replaceChildren(document.createTextNode(text), action);
delete statusEl.dataset.tone;
statusEl.dataset.action = "";
}
const actions: ReleaseActions = {
@@ -2809,7 +2972,7 @@ function tvReleasesMain(): TvReleasesView {
if (!current) {
return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false });
}
if (bucket === "waived") {
if (bucket !== "eligible") {
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
}
return current.target
@@ -2841,23 +3004,93 @@ function tvReleasesMain(): TvReleasesView {
return;
}
if (!paintBuckets(dom, outcome.releases, actions)) {
// issue 167: an empty deck sweeps on open instead of describing a sweep
if (sweepIfEmpty) {
startSweep(current);
} else {
setStatus("no releases indexed yet — re-search queues a targeted sweep");
}
clearBuckets(dom);
await emptyVerdict(current, ticket, sweepIfEmpty);
return;
}
setStatus(null);
}
/**
* A queued sweep is done when its releases appear in the table. Nothing
* found is indistinguishable from still running, so the wait says so
* honestly instead of promising either way.
* An empty season deck is three different truths (#182), and until the
* season could say which, it blamed backoff for all three: a pack sweep
* still running, a pack sweep that ran and found nothing, and a season on
* the per-episode lane, where no pack sweep is coming at all. The lane
* answers the third; `last_pack_search_at` separates the first two, the
* same way a movie's `last_searched_at` does (#177).
*/
function watchSweep(current: TvDeckRequest, ticket: number) {
async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) {
const outcome = await current.target.packState?.();
if (ticket !== sequence || request !== current) {
return;
}
if (outcome?.kind === "state" && outcome.state.lane === "per_episode") {
sweep.disabled = false;
describePerEpisode(current, outcome.state);
return;
}
if (outcome?.kind === "state" && outcome.state.last_pack_search_at !== null) {
sweep.disabled = false;
setStatus(
`no season pack found — indexers last swept ${formatSweepAge(outcome.state.last_pack_search_at)}; re-search runs a new one`,
);
return;
}
// issue 167: an empty deck sweeps on open instead of describing a sweep
if (sweepIfEmpty) {
void startSweep(current, outcome?.kind === "state" ? outcome.state : null);
return;
}
setStatus("no releases indexed yet — re-search queues a targeted sweep");
}
/**
* Name the lane instead of the backoff. A season grabbing episode by
* episode has no pack to show and never will while the reason holds, so
* the deck says which reason it is and where the releases actually are.
*/
function describePerEpisode(current: TvDeckRequest, state: SeasonPackState) {
const elsewhere = "open an episode for its releases";
if (state.reason === "no_episodes") {
setStatus(
"no episodes known for this season yet — a metadata refresh has to find them before anything can be searched",
);
return;
}
if (state.reason === "still_airing") {
setStatus(
`season still airing — a pack is only searched once every episode has aired, so this one is grabbed episode by episode; ${elsewhere}`,
);
return;
}
if (state.reason === "episodes_on_disk") {
setStatus(
`episodes already on disk — a pack would re-import them, so the rest is grabbed episode by episode; ${elsewhere}`,
);
return;
}
const failures =
state.pack_failures === 1 ? "1 failed pack grab" : `${state.pack_failures} failed pack grabs`;
const quiet =
state.pack_retry_at === null
? "pack search is quiet until its backoff elapses"
: `pack search is quiet for another ${formatRetryWait(state.pack_retry_at)}, then retries on its own`;
setStatusAction(
`${failures}${quiet}. episodes are grabbed one at a time meanwhile.`,
"retry the pack now",
() => {
void startSweep(current, state);
},
);
}
/**
* A sweep is done when its releases appear, or when the season stamps the
* pack search it just finished. `baseline` is that stamp as it read before
* the sweep was queued: once it moves, an empty deck is a settled answer
* rather than a pending one, and `load` says so.
*/
function watchSweep(current: TvDeckRequest, baseline: string | null, ticket: number) {
sweep.disabled = true;
setStatus("sweeping indexers…", undefined, true);
const deadline = Date.now() + TV_SWEEP_WAIT_MS;
@@ -2875,10 +3108,19 @@ function tvReleasesMain(): TvReleasesView {
setStatus(null);
return;
}
const state = await current.target.packState?.();
if (ticket !== sequence || request !== current) {
return;
}
if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) {
sweep.disabled = false;
await emptyVerdict(current, ticket, false);
return;
}
if (Date.now() >= deadline) {
sweep.disabled = false;
setStatus(
"sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs",
"sweep has not landed yet — nothing has come back from the indexers; results appear here when it does",
);
return;
}
@@ -2891,23 +3133,40 @@ function tvReleasesMain(): TvReleasesView {
}, TV_SWEEP_POLL_MS);
}
/** Queue a targeted sweep, then watch for its releases to land (§6.2). */
function startSweep(current: TvDeckRequest) {
/**
* Queue a targeted sweep, then watch for it to land (§6.2). `known` is the
* lane as it read a moment ago, so the watch is only entered when a pack
* sweep is actually expected: on the per-episode lane the sweep searches
* episodes, and waiting for a pack that is not coming is the lie #182 is
* about. A failed pack is the exception a manual search waives its
* window and does try a pack (#181).
*/
async function startSweep(current: TvDeckRequest, known?: SeasonPackState | null) {
sweep.disabled = true;
void (async () => {
const outcome = await current.target.search();
if (request !== current) {
return;
}
if (outcome.kind === "error") {
sweep.disabled = false;
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
sequence += 1;
window.clearTimeout(pollTimer);
watchSweep(current, sequence);
})();
const before =
known === undefined
? await current.target.packState?.().then((it) => (it.kind === "state" ? it.state : null))
: known;
if (request !== current) {
return;
}
const outcome = await current.target.search();
if (request !== current) {
return;
}
sequence += 1;
window.clearTimeout(pollTimer);
if (outcome.kind === "error") {
sweep.disabled = false;
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
if (before && before.lane === "per_episode" && before.reason !== "pack_backoff") {
sweep.disabled = false;
setStatus("searching the season's episodes — open an episode for its releases");
return;
}
watchSweep(current, before?.last_pack_search_at ?? null, sequence);
}
sweep.addEventListener("click", () => {
@@ -2915,7 +3174,7 @@ function tvReleasesMain(): TvReleasesView {
if (!current) {
return;
}
startSweep(current);
void startSweep(current);
});
function open(next: TvDeckRequest) {
@@ -2993,7 +3252,6 @@ const SERIES_REFRESH_WAIT_MS = 30_000;
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck");
const back = must<HTMLButtonElement>("#series-back");
const hero = must<HTMLElement>("#series-hero");
const poster = must<HTMLImageElement>("#series-poster");
const titleEl = must<HTMLElement>("#series-title");
@@ -3098,6 +3356,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}
titleEl.textContent = current.title;
yearEl.textContent = current.year === null ? "" : String(current.year);
remove.setAttribute("aria-label", `remove ${current.title} from the library`);
remove.title = `remove ${current.title} from the library`;
chipsEl.replaceChildren();
const root = roots.find((candidate) => candidate.id === current.root_id);
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
@@ -3200,26 +3460,41 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
trailer.textContent = "trailer";
actionsEl.append(trailer);
}
actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
const linkTitle = series?.title ?? "this series";
actionsEl.append(
externalLink(
providerMark("tmdb"),
`open ${linkTitle} on TMDB`,
tmdbSeriesLink(detail.tmdb_id),
),
);
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
if (detail.tvdb_id !== null) {
actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
actionsEl.append(
externalLink(providerMark("tvdb"), `open ${linkTitle} on TVDB`, tvdbLink(detail.tvdb_id)),
);
}
if (series !== null) {
actionsEl.append(
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
externalLink(
providerMark("rt"),
`search Rotten Tomatoes for ${series.title}`,
rottenTomatoesSearch(series.title, series.year),
),
);
}
}
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
const counts = seasonCounts(season);
return countsChip(
`${counts.available}/${counts.wanted} on disk`,
const span = countsChip(
`${counts.available}/${counts.wanted}`,
counts.available,
counts.wanted,
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
);
span.prepend(icon("disk"));
return span;
}
function seasonRow(season: ApiSeason): HTMLLIElement {
@@ -3286,8 +3561,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.className = "control control-icon";
const deckLabel =
season.number === 0
? "open the specials release deck"
: `open the season ${PAD_TWO(season.number)} release deck`;
deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel;
deckBtn.append(icon("search"));
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
@@ -3320,26 +3601,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
// #174: files go, episodes stop being wanted, the season stays listed.
// Only offered with files on disk — intent alone is the tracked toggle.
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
const clear = armedDelete("remove files", () => {
const currentId = seriesId;
if (currentId === null) {
return;
}
void removeSeasonFiles(currentId, season.number).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
const clear = armedDeleteIcon(
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
() => {
const currentId = seriesId;
if (currentId === null) {
return;
}
// the control itself disappears with the files; the tracked
// toggle is the season's control that survives the repaint
focusKey = `track-${season.number}`;
void load();
});
});
clear.setAttribute(
"aria-label",
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
void removeSeasonFiles(currentId, season.number).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// the control itself disappears with the files; the tracked
// toggle is the season's control that survives the repaint
focusKey = `track-${season.number}`;
void load();
});
},
);
line.append(clear);
}
@@ -3407,21 +3687,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
if (aired && onDisk) {
// #174: the file goes and the episode stops being wanted; the row
// stays listed. Same arm-then-confirm as a settings row.
const clear = armedDelete("remove file", () => {
void removeEpisodeFiles(episode.id).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// once missing, the row's want control is what remains to focus
focusKey = `want-${episode.id}`;
void load();
});
});
clear.setAttribute(
"aria-label",
const clear = armedDeleteIcon(
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
() => {
void removeEpisodeFiles(episode.id).then((outcome) => {
if (outcome.kind === "error") {
clear.disabled = false;
setStatus(`remove failed — ${outcome.detail}`, "fault");
return;
}
// once missing, the row's want control is what remains to focus
focusKey = `want-${episode.id}`;
void load();
});
},
);
actions.append(clear);
} else if (!aired) {
@@ -3452,8 +3731,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const deckBtn = document.createElement("button");
deckBtn.type = "button";
deckBtn.className = "control";
deckBtn.textContent = "deck";
deckBtn.className = "control control-icon";
const deckLabel = `open the ${episodeTag(seasonNumber, episode.number)} release deck`;
deckBtn.setAttribute("aria-label", deckLabel);
deckBtn.title = deckLabel;
deckBtn.append(icon("search"));
deckBtn.addEventListener("click", () => {
const currentId = seriesId;
const currentTitle = series?.title ?? "";
@@ -3661,7 +3943,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
view.hidden = false;
clearRemove();
clearRichDetail();
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -3682,8 +3965,6 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
origin?.focus();
}
back.addEventListener("click", close);
// capture, like every other layer: the tv deck's listener is registered
// first, so one Esc steps back one layer
window.addEventListener(
+73
View File
@@ -95,6 +95,67 @@ export function formatSweepAge(lastSearchedAt: string, now = Date.now()): string
return `${Math.round(minutes / (24 * 60))} d ago`;
}
/** Which lane a season's missing episodes take (#182, §6.2). */
export type SeasonLane = "season_pack" | "per_episode";
/** What holds a season off the pack lane (#182). */
export type PackLaneReason = "no_episodes" | "still_airing" | "episodes_on_disk" | "pack_backoff";
/**
* Why the season deck holds what it holds, from
* `/api/series/{id}/seasons/{n}/pack-state`. An empty deck is three states,
* not one: a pack sweep still running, a pack sweep that found nothing, and
* a season on the per-episode lane, where no pack sweep is coming.
*/
export interface SeasonPackState {
lane: SeasonLane;
reason: PackLaneReason | null;
pack_failures: number;
pack_retry_at: string | null;
last_pack_search_at: string | null;
}
export type PackStateOutcome =
| { kind: "state"; state: SeasonPackState }
| { kind: "error"; detail: string };
export async function seasonPackState(
seriesId: number,
seasonNumber: number,
): Promise<PackStateOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}/pack-state`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "state", state: (await response.json()) as SeasonPackState };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* `pack_retry_at` as a coarse wait. The deck says how long the lane stays
* quiet, so "it retries on its own" is a promise with a date on it.
*/
export function formatRetryWait(retryAt: string, now = Date.now()): string {
const reopens = Date.parse(retryAt);
if (Number.isNaN(reopens)) {
return "later";
}
const minutes = Math.round((reopens - now) / 60_000);
if (minutes < 2) {
return "any moment";
}
if (minutes < 60) {
return `${minutes} min`;
}
if (minutes < 48 * 60) {
return `${Math.round(minutes / 60)} h`;
}
return `${Math.round(minutes / (24 * 60))} d`;
}
/** One library file as `/api/movies/{id}/files` reports it (§5.6, §5.7). */
export interface MovieFile {
id: number;
@@ -196,6 +257,11 @@ export async function grabRelease(movieId: number, releaseId: number): Promise<A
* The per-title override one click on a waived row writes (§5.2, §9.3).
* Mapping a rule name to its override is bookkeeping, not policy the
* verdict itself always comes from the API.
*
* `size` relaxes §5.5's floor for this title only, and only into a waiver:
* the release stays out of automatic grabbing and imports on the record as
* a §5.7 waiver. No band is right for every title, which is why the
* override exists at all.
*/
export function waiverOverride(rule: string | null): Record<string, unknown> | null {
switch (rule) {
@@ -203,11 +269,18 @@ export function waiverOverride(rule: string | null): Record<string, unknown> | n
return { allow_english_audio: true };
case "resolution":
return { only_4k: false };
case "size":
return { allow_below_floor: true };
default:
return null;
}
}
/** Whether one click on this row has an override to write (§9.3). */
export function overridable(release: { rejected_rule: string | null }): boolean {
return waiverOverride(release.rejected_rule) !== null;
}
export type WaiveOutcome =
| { kind: "done"; overrideWritten: boolean }
| { kind: "error"; detail: string; overrideWritten: boolean };
+14 -2
View File
@@ -3,8 +3,14 @@
// (src/api/) is uncommitted, so CI's tsc cannot see it.
import type { MetadataTrailer } from "./movie";
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases";
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases";
import type {
ActionOutcome,
MovieRelease,
PackStateOutcome,
ReleasesOutcome,
WaiveOutcome,
} from "./releases";
import { errorDetail, probedAttributeTags, seasonPackState, waiverOverride } from "./releases";
/** §4.2 derived status — displayed, never editable. */
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
@@ -233,6 +239,11 @@ export interface TvTarget {
releases: () => Promise<ReleasesOutcome>;
grab: (releaseId: number) => Promise<ActionOutcome>;
search: () => Promise<ActionOutcome>;
/**
* Why the deck is empty (#182). Seasons only: an episode deck has one
* lane, so it has nothing to disambiguate.
*/
packState?: () => Promise<PackStateOutcome>;
}
export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
@@ -241,6 +252,7 @@ export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
releases: () => fetchJson(`${base}/releases`),
grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`),
search: () => post(`${base}/search`),
packState: () => seasonPackState(seriesId, seasonNumber),
};
}
+73
View File
@@ -470,6 +470,62 @@ body {
outline-offset: 2px;
}
/* ---- icon-only controls (#230) ---------------------------------------- */
/* One block, shared: title pages, decks and readouts here, the settings
rows via #231. The glyph carries the action, the aria-label carries the
meaning, and the geometry stays on the --control-h rail with the chips. */
.icon {
display: block;
flex: none;
height: 0.875rem;
width: auto;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.control-icon {
min-width: var(--control-h);
padding: 0 var(--space-2);
}
/* provider wordmark badges (§9.6): the mark is drawn and shipped inline,
monochrome on currentColor never a remote image */
.icon-mark {
height: 1.125rem;
}
.icon-mark rect {
stroke-width: 1.6;
}
/* the mark's letters live in viewBox units, so the size rides the SVG
scale as an attribute; only voice and weight come from the tokens */
.icon-mark text {
fill: currentColor;
stroke: none;
font-family: var(--font-readout);
font-weight: 700;
letter-spacing: 0.06em;
}
/* a readout chip's glyph sits beside its figure, sized to the readout */
.chip .icon {
height: 0.75rem;
stroke-width: 1.7;
}
/* rows widen every .control's padding; an icon control stays near-square */
.season-line .control-icon,
.ep-actions .control-icon,
.movie-controls .control-icon {
padding: 0 var(--space-2);
}
/* ---- unified search (§9.2) ------------------------------------------- */
.rail-search {
@@ -528,6 +584,12 @@ body {
flex: 1;
}
/* movie and series: the hero banner meets the rail — no top padding (#229) */
.deck#movie,
.deck#series {
padding-top: 0;
}
.deck-status {
margin: 0 0 var(--space-4);
color: var(--ink-muted);
@@ -537,6 +599,17 @@ body {
color: var(--signal-fault);
}
/* a status that carries its own way out keeps the control on the sentence's
own left edge, so the explanation reads first and the action follows it
(#182). Long enough copy wraps the control to its own line, which is the
reading order anyway. */
.deck-status[data-action] {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
/* a sweep in flight borrows the rail's probing-lamp idiom, inline */
.deck-status .lamp {
display: inline-block;