Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 815a072ef0 | |||
| 8c3e1c4a92 | |||
| bb2708f7ee | |||
| 5c5b1e234c | |||
| 917aa4fa76 | |||
| 024786f356 | |||
| f0d45996a0 | |||
| 9fee07f080 |
@@ -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.
|
||||
|
||||
+182
@@ -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"
|
||||
}
|
||||
+15
-3
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+14
-2
@@ -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"
|
||||
}
|
||||
+74
@@ -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"
|
||||
}
|
||||
+15
-3
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+74
@@ -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"
|
||||
}
|
||||
+14
-2
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod metadata;
|
||||
mod movies;
|
||||
mod owners;
|
||||
mod policies;
|
||||
mod reclassify;
|
||||
mod roots;
|
||||
mod search;
|
||||
mod series;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user