Compare commits

..

8 Commits

Author SHA1 Message Date
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 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
32 changed files with 1686 additions and 89 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,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"
}
@@ -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 WHERE 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,6 +145,17 @@
"name": "vote_average"
}
}
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
@@ -163,8 +174,9 @@
true,
true,
true,
true,
true
]
},
"hash": "e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10"
"hash": "4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377"
}
@@ -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,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"
}
@@ -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"
}
@@ -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\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"describe": {
"columns": [
{
@@ -145,6 +145,17 @@
"name": "vote_average"
}
}
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
@@ -163,8 +174,9 @@
true,
true,
true,
true,
true
]
},
"hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644"
"hash": "ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b"
}
@@ -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 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"
}
+1
View File
@@ -11,6 +11,7 @@ mod metadata;
mod movies;
mod owners;
mod policies;
mod reclassify;
mod roots;
mod search;
mod series;
+135 -5
View File
@@ -240,10 +240,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()) {
@@ -406,8 +406,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 title 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();
@@ -430,6 +433,9 @@ pub async fn update(
sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id)
.execute(pool(&state)?)
.await?;
if overrides_changed {
crate::reclassify::movie(&state, id).await?;
}
Ok(Json(load_movie(&state, id).await?))
}
@@ -604,7 +610,7 @@ pub async fn releases(
.map_err(|error| ApiError::Database(error.to_string()))?
.ok_or(ApiError::NotFound)?
.policy;
rescore(&mut releases, &policy, None)?;
rescore(&mut releases, &policy, None, 0)?;
Ok(Json(releases))
}
@@ -618,6 +624,10 @@ pub async fn releases(
/// many it covers. `None` is the movie decks: one release is one film,
/// whatever episode-shaped noise its name parses to.
///
/// `runtime_minutes` scales the bands by the series' minutes per episode
/// (§5.5). Movies are never scaled, so the movie decks pass zero — the same
/// zero a series with no known runtime gets.
///
/// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of
/// policy weights in the thousands), so the `i64` -> `f64` cast into the
/// column's storage type is exact.
@@ -626,6 +636,7 @@ pub(crate) fn rescore(
releases: &mut [Release],
policy: &Policy,
season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<(), ApiError> {
let mut totals = Vec::with_capacity(releases.len());
for release in releases.iter() {
@@ -636,7 +647,17 @@ pub(crate) fn rescore(
let episodes = season_lengths.map_or(1, |lengths| {
claimed_episode_count(parsed.episode.as_ref(), lengths)
});
totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders, episodes).total);
totals.push(
score(
policy,
Candidate::PreGrab(&parsed),
size,
seeders,
episodes,
runtime_minutes,
)
.total,
);
}
let mut indices: Vec<usize> = (0..releases.len()).collect();
indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i]));
@@ -1135,6 +1156,114 @@ mod tests {
assert_eq!(files[0]["probed"]["resolution"], "1080p");
}
/// #210 on the movie deck: relaxing §5.5's floor for one title moves its
/// size rejections into the waived bucket and no further — a waiver is
/// never picked automatically (§9.3), only offered.
#[tokio::test]
async fn allow_below_floor_moves_a_size_rejection_to_waived_not_eligible() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let created: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
"original_language": "en", "root_id": 1, "overrides": {}
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
let movie_id = created["id"].as_i64().expect("id");
// 2 GiB at 1080p, under the movie policy's 3 GiB floor.
let name = "Dune Part Two 2024 1080p WEB-DL x264-GROUP";
let parsed = arr_parse::parse(name);
let size = 2_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, 'g', ?, ?, 40, '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 movie_releases (movie_id, release_id) VALUES (?, ?)")
.bind(movie_id)
.bind(release_id)
.execute(pool)
.await
.expect("association");
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{movie_id}"))
.json(&serde_json::json!({"overrides": {"allow_below_floor": true}}))
.send()
.await
.expect("override");
assert_eq!(response.status(), StatusCode::OK);
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["verdict"], "waived");
}
/// A blacklisted release (§6.3) is not a policy opinion, so no override
/// re-opens it.
#[tokio::test]
async fn an_override_never_revives_a_blacklisted_release() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let movie = add_movie(&base, 693_134, 1).await;
let movie_id = movie["id"].as_i64().expect("id");
let name = "Dune Part Two 2024 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, 'g', ?, ?, 40, 'url', ?, 0, 'rejected', 'blacklisted') RETURNING id",
)
.bind(name)
.bind(size)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.fetch_one(pool)
.await
.expect("release");
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
.bind(movie_id)
.bind(release_id)
.execute(pool)
.await
.expect("association");
reqwest::Client::new()
.patch(format!("{base}/api/movies/{movie_id}"))
.json(&serde_json::json!({"overrides": {"allow_below_floor": true, "only_4k": false}}))
.send()
.await
.expect("override");
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
.await
.expect("releases")
.json()
.await
.expect("releases json");
assert_eq!(releases[0]["verdict"], "rejected");
assert_eq!(releases[0]["rejected_rule"], "blacklisted");
}
#[tokio::test]
async fn crud_preserves_intent_and_overrides() {
let (_dir, _state, base) = application().await;
@@ -1445,6 +1574,7 @@ mod tests {
size_bytes,
seeders,
1,
0,
)
.total;
#[allow(clippy::cast_precision_loss)]
+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)))
}
+11 -3
View File
@@ -490,6 +490,7 @@ async fn movie_releases(
&original_language,
&blacklist,
None,
0,
)?);
}
}
@@ -525,6 +526,7 @@ async fn episode_releases(
.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
@@ -568,6 +570,7 @@ async fn episode_releases(
&original_language,
&blacklist,
Some(&season_lengths),
runtime_minutes,
)?);
}
}
@@ -709,6 +712,7 @@ fn classify(
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
@@ -724,11 +728,12 @@ fn classify(
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,
@@ -736,6 +741,7 @@ fn classify(
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
@@ -778,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))),
@@ -1465,6 +1471,7 @@ mod tests {
&Language::Other("en".into()),
&Blacklist::default(),
None,
0,
)
.expect("classified release")
};
@@ -1558,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, 1);
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1, 0);
let classified = classify(
release,
@@ -1567,6 +1574,7 @@ mod tests {
&Language::Other("en".into()),
&Blacklist::default(),
None,
0,
)
.expect("classified release");
+155 -7
View File
@@ -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();
@@ -578,6 +585,9 @@ pub async fn update(
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?;
if overrides_changed {
crate::reclassify::series(&state, id).await?;
}
Ok(Json(load_series(&state, id).await?))
}
@@ -1373,7 +1383,8 @@ pub async fn episode_releases(
.ok_or(ApiError::EpisodeNotFound)?
.policy;
let lengths = season_lengths(&state, episode.series_id).await?;
rescore(&mut releases, &policy, Some(&lengths))?;
let runtime = series_runtime(&state, episode.series_id).await?;
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
Ok(Json(releases))
}
@@ -1412,6 +1423,19 @@ pub async fn grab_episode(
/// 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,
@@ -1514,7 +1538,8 @@ pub async fn season_releases(
.ok_or(ApiError::SeasonNotFound)?
.policy;
let lengths = season_lengths(&state, series_id).await?;
rescore(&mut releases, &policy, Some(&lengths))?;
let runtime = series_runtime(&state, series_id).await?;
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
Ok(Json(releases))
}
@@ -1869,6 +1894,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
+8
View File
@@ -237,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)]
+211 -5
View File
@@ -68,6 +68,11 @@ pub struct EvaluationContext<'a> {
/// 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.
@@ -112,6 +117,7 @@ pub fn evaluate(
candidate: Candidate<'_>,
size_bytes: Option<u64>,
episode_count: u32,
runtime_minutes: u32,
) -> Evaluation {
let context = EvaluationContext {
policy,
@@ -120,6 +126,7 @@ pub fn evaluate(
candidate,
size_bytes,
episode_count,
runtime_minutes,
};
let rules: [&dyn PolicyRule; 6] = [
&ResolutionRule,
@@ -224,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;
@@ -233,9 +246,17 @@ impl PolicyRule for SizeRule {
else {
return RuleEvaluation::Unknown(RuleKind::Size);
};
match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count)
{
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),
}
@@ -522,7 +543,7 @@ mod tests {
}
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
evaluate(policy, overrides, &en(), candidate, None, 1).verdict
evaluate(policy, overrides, &en(), candidate, None, 1, 0).verdict
}
fn verdict_for(
@@ -537,6 +558,7 @@ mod tests {
candidate,
None,
1,
0,
)
.verdict
}
@@ -552,6 +574,7 @@ mod tests {
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
@@ -655,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();
@@ -735,6 +931,7 @@ mod tests {
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
@@ -792,6 +989,7 @@ mod tests {
Candidate::PostDownload(&media),
None,
1,
0,
);
let expected = if rejected {
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
@@ -812,6 +1010,7 @@ mod tests {
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
@@ -845,6 +1044,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims),
size_bytes: None,
episode_count: 1,
runtime_minutes: 0,
};
let soft = FixedRule {
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
@@ -874,6 +1074,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims),
size_bytes: None,
episode_count: 1,
runtime_minutes: 0,
};
let first = FixedRule {
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
@@ -1010,7 +1211,8 @@ mod tests {
&en(),
Candidate::PreGrab(&claims),
None,
1
1,
0,
)
.verdict,
Verdict::Eligible
@@ -1024,7 +1226,8 @@ mod tests {
&en(),
Candidate::PostDownload(&media),
None,
1
1,
0,
)
.verdict,
Verdict::Waived(Rule::RequiredAudio)
@@ -1099,6 +1302,7 @@ mod tests {
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
@@ -1111,6 +1315,7 @@ mod tests {
Candidate::PostDownload(&media),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
}
@@ -1125,6 +1330,7 @@ mod tests {
Candidate::PreGrab(&claims),
None,
1,
0,
);
assert_eq!(report.verdict, Verdict::Eligible);
assert_eq!(
+193 -22
View File
@@ -27,6 +27,11 @@ 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)]
@@ -94,6 +99,13 @@ pub struct Score {
/// 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,
@@ -101,13 +113,16 @@ pub fn score(
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, per_episode));
.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));
@@ -130,13 +145,19 @@ pub fn score(
///
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
#[must_use]
pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> 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,
)
}
@@ -146,6 +167,9 @@ pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) ->
/// (`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]
@@ -154,11 +178,11 @@ pub fn is_below_floor(
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) < band.floor_bytes)
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
@@ -195,6 +219,26 @@ 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
/// grows with every gigabyte above it.
fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 {
@@ -329,6 +373,7 @@ mod tests {
size_bytes,
seeders,
1,
0,
)
}
@@ -350,6 +395,7 @@ mod tests {
size_bytes,
seeders,
1,
0,
)
}
@@ -358,6 +404,10 @@ mod tests {
}
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());
@@ -369,6 +419,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims),
size_bytes: Some(size_bytes),
episode_count,
runtime_minutes,
})
}
@@ -400,6 +451,7 @@ mod tests {
Candidate::PreGrab(&claims),
Some(gib(60)),
1,
0,
);
// A bad score, but a score: nothing filters it out, so a selection
@@ -466,11 +518,11 @@ 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), 1),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0),
Some(true)
);
assert_eq!(
is_below_floor(&policy(), Resolution::R720p, gib(3), 1),
is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0),
None
);
}
@@ -481,7 +533,7 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..NameClaims::default()
};
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1);
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);
@@ -499,8 +551,8 @@ mod tests {
},
);
let claims = claims(ClaimedSource::WebDl);
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1);
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1);
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);
@@ -548,7 +600,7 @@ mod tests {
resolution: Some(resolution),
..NameClaims::default()
};
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1, 0).resolution
};
assert_eq!(at(ClaimedResolution::P2160), 2 * step);
@@ -565,7 +617,7 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..NameClaims::default()
};
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1);
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1, 0);
assert_eq!(unranked.resolution, 0);
assert_eq!(unclaimed.resolution, 0);
@@ -617,7 +669,7 @@ mod tests {
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);
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10, 0);
assert_eq!(pack.size, episode.size);
assert_eq!(pack.total, episode.total);
@@ -628,7 +680,7 @@ mod tests {
// 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),
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10, 0),
Some(true)
);
assert_eq!(
@@ -638,22 +690,141 @@ mod tests {
// The same total over three episodes is 10 GiB each and passes.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3),
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);
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),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1)
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0)
);
}
@@ -733,18 +904,18 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..NameClaims::default()
};
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes)
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),
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes, 0),
Some(false)
);
assert_eq!(
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes),
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0),
Some(false)
);
assert!(uhd.total > hd.total);
+31
View File
@@ -1192,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,
@@ -1200,6 +1201,7 @@ pub(crate) async fn store_release(
original_language,
blacklist,
1,
0,
)
.await?;
sqlx::query!(
@@ -1241,6 +1243,11 @@ pub(crate) async fn store_episode_release(
_ => 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,
@@ -1249,6 +1256,7 @@ pub(crate) async fn store_episode_release(
original_language,
blacklist,
episode_count,
runtime_minutes,
)
.await?;
for episode_id in episode_ids {
@@ -1293,6 +1301,26 @@ async fn season_lengths_of(
.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,
@@ -1301,6 +1329,7 @@ async fn classify_and_store(
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(
@@ -1310,6 +1339,7 @@ async fn classify_and_store(
Candidate::PreGrab(&parsed),
release.size,
episode_count,
runtime_minutes,
);
let scored = score(
policy,
@@ -1317,6 +1347,7 @@ async fn classify_and_store(
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
+14
View File
@@ -308,6 +308,7 @@ impl ImportAction {
Candidate::PostDownload(&feature.media),
Some(feature.size),
1,
0,
);
let waiver: Option<Rule> = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -477,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 {
@@ -496,6 +501,7 @@ impl ImportAction {
Candidate::PostDownload(&assignment.file.media),
Some(assignment.file.size),
1,
runtime_minutes,
);
let waiver = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -938,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,
}
@@ -974,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
@@ -997,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,
}));
@@ -1011,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
@@ -1033,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,
}));
+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.
@@ -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);
+75 -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 {
@@ -623,7 +696,8 @@ mod tests {
&loaded.policy,
Resolution::R1080p,
one_and_a_half_gib,
1
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()
+2 -8
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">
@@ -470,10 +467,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">
+32 -20
View File
@@ -50,6 +50,7 @@ import {
movieFiles,
movieReleases,
movieSearchState,
overridable,
probedAttributeTags,
queueSearch,
removeMovie,
@@ -59,7 +60,6 @@ import {
totalSize,
type WaiveOutcome,
waiveAndGrab,
waiverOverride,
} from "./releases";
import { currentRoute, navigate, type Route } from "./router";
import {
@@ -393,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 },
});
@@ -1294,7 +1294,6 @@ function externalLink(label: string, href: string): HTMLAnchorElement {
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");
@@ -1352,7 +1351,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);
@@ -1906,7 +1905,8 @@ function movieMain(views: HideableView[]): MovieView {
filesSection.hidden = true;
diskRows.replaceChildren();
sweep.disabled = false;
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -1929,8 +1929,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(
@@ -2091,7 +2089,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",
@@ -2155,10 +2154,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) {
@@ -2295,8 +2300,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;
}),
);
@@ -2313,11 +2324,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;
@@ -2829,7 +2843,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
@@ -3109,7 +3123,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");
@@ -3777,7 +3790,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
view.hidden = false;
clearRemove();
clearRichDetail();
back.focus();
titleEl.setAttribute("tabindex", "-1");
titleEl.focus();
await load();
}
@@ -3798,8 +3812,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(
+12
View File
@@ -257,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) {
@@ -264,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 };
+6
View File
@@ -528,6 +528,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);