Merge main into blitz/subtitles
Feedback pass 2 and the size-band work landed on main while this branch was finishing. Brings them in ahead of the merge back. # Conflicts: # crates/arr-api/src/movies.rs # crates/arr-api/src/state.rs # crates/arr-daemon/src/main.rs # web/src/main.ts
This commit is contained in:
@@ -29,9 +29,15 @@ reads the library as derived status, not flags.
|
|||||||
counts behind cyan show/hide toggles. Release name is a secondary
|
counts behind cyan show/hide toggles. Release name is a secondary
|
||||||
truncated line, full string plus indexer/date/bytes on row expand. Every
|
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
|
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
|
own control per row; where the rule that failed has an override it reads
|
||||||
per-title override (rule→override mapping in releases.ts — bookkeeping,
|
"waive + grab" and writes the per-title override (rule→override mapping in
|
||||||
never policy; verdicts always come from the API). Esc steps back one
|
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.
|
layer: deck → search results → board.
|
||||||
- Removal (§7.4, issue 104): REMOVE is a quiet control in the deck head,
|
- 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.
|
never a row affordance — a dense list is the wrong place for a delete.
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
|
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n e.id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN roots root ON root.id = s.root_id\n WHERE root.audience = 'kids'\n AND s.blocked = 0\n AND e.wanted = 1 AND e.state = 'missing' AND e.search_attempts > 0\n AND NOT EXISTS (\n SELECT 1 FROM episode_releases er\n JOIN releases r ON r.id = er.release_id\n WHERE er.episode_id = e.id AND r.verdict IN ('eligible', 'waived')\n )\n ORDER BY se.number, e.number\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -94,5 +94,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "29d6fdda533e0552f5da24a8e5180e5935b3550ee2c024e09aef9b76fd47a708"
|
"hash": "06eca0d86be94dc4615cfaa1f75f630b89ab665e0c4323d8c1aa6521c200b86d"
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ",
|
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 0
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
@@ -58,5 +58,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
|
"hash": "0c97b43e22b1c83c5a7131ce9a07fa96cc595454d8803b3c4307074ab273eb1d"
|
||||||
}
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT required_audio AS \"required_audio!: String\",\n dub_blacklist AS \"dub_blacklist!: String\",\n hdr_rules AS \"hdr_rules!: String\",\n size_bands AS \"size_bands!: String\",\n resolution_pref AS \"resolution_pref!: String\",\n source_weights AS \"source_weights!: String\",\n score_weights AS \"score_weights!: String\"\n FROM policies WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "required_audio!: String",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "required_audio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dub_blacklist!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "dub_blacklist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hdr_rules!: String",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "hdr_rules"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "size_bands!: String",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "size_bands"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resolution_pref!: String",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "resolution_pref"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source_weights!: String",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "source_weights"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "score_weights!: String",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "policies",
|
||||||
|
"name": "score_weights"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "100e5e9297a9a917eb107c673ac492efe0df0123d9def212d84b88e57aaa0484"
|
||||||
|
}
|
||||||
+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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -103,8 +103,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "release_name!: String",
|
"name": "runtime_minutes",
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "runtime_minutes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_name!: String",
|
||||||
|
"ordinal": 10,
|
||||||
"type_info": "Text",
|
"type_info": "Text",
|
||||||
"origin": {
|
"origin": {
|
||||||
"Table": {
|
"Table": {
|
||||||
@@ -127,8 +138,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc"
|
"hash": "1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67"
|
||||||
}
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6"
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
"query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -8,5 +8,5 @@
|
|||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b"
|
"hash": "2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41"
|
||||||
}
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id AS \"id!: i64\" FROM movies WHERE root_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "movies",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2bb8e5739bd8d16bc7c5a08f5530839c26fbbea836aca6a55a43d75210ae4368"
|
||||||
|
}
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
|
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 0
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
@@ -46,5 +46,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
|
"hash": "35d381e3acde2f3e49f8559da15d03e9414398ce9d0b716e5c8e367003625a46"
|
||||||
}
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "media_files",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "media_files",
|
||||||
|
"name": "path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa"
|
||||||
|
}
|
||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 13
|
"Right": 14
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331"
|
"hash": "3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
"query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
|
"hash": "3e8fdbb8d28441b429d2ef011f206c4fad280f56905b6a85035a142dd592dbec"
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
|
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE movies.wanted = 1 AND movies.state != 'available' AND (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 0
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
@@ -190,5 +190,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
|
"hash": "3ebb77103e3b3ec01b3de47c35ef613790d8c88ed4cabd46b33f67eddc47f9c0"
|
||||||
}
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT g.target_id AS \"season_id!: i64\",\n r.name AS \"name!: String\",\n g.infohash AS \"infohash!: String\",\n g.failed_at,\n g.grabbed_at AS \"grabbed_at!: String\"\n FROM grabs g\n JOIN releases r ON r.id = g.release_id\n JOIN seasons s ON s.id = g.target_id\n WHERE g.target_kind = 'season'\n AND g.state = 'failed'\n AND s.series_id = ?\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = s.id AND e.wanted\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n )\n ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "season_id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "grabs",
|
||||||
|
"name": "target_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "releases",
|
||||||
|
"name": "name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "infohash!: String",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "grabs",
|
||||||
|
"name": "infohash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "failed_at",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "grabs",
|
||||||
|
"name": "failed_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "grabbed_at!: String",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "grabs",
|
||||||
|
"name": "grabbed_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "3f252a992c1bc5b18bb4467d1c4fd4137966a469b134fbeb51fb91f67951cbbe"
|
||||||
|
}
|
||||||
+14
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -145,6 +145,17 @@
|
|||||||
"name": "vote_average"
|
"name": "vote_average"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "runtime_minutes",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "runtime_minutes"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -163,8 +174,9 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10"
|
"hash": "4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377"
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 0
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
@@ -94,5 +94,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
|
"hash": "4f9e8728ca5f0bd70c40155b05a857365f0ced5fbf77c49f25049492658998db"
|
||||||
}
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT path AS \"path!: String\" FROM roots WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "path!: String",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "roots",
|
||||||
|
"name": "path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953"
|
||||||
|
}
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 0
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
@@ -58,5 +58,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
|
"hash": "6faa1fc2c2015becebb83843b031b071e9720132a42e837a0e9bb8b2d955a9e8"
|
||||||
}
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE s.root_id = ?\n ORDER BY mf.id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "media_files",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "media_files",
|
||||||
|
"name": "path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa"
|
||||||
|
}
|
||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN movie_releases mr ON mr.release_id = r.id WHERE mr.movie_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -134,6 +134,12 @@
|
|||||||
"name": "rejected_rule"
|
"name": "rejected_rule"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "blacklist_reason?: String",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Null",
|
||||||
|
"origin": "Expression"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -151,8 +157,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "aebbabd41e2086ac37dbd9d2151b6d54cbad525b1d1d63c2a1495c24af44db7f"
|
"hash": "7aa154a1bd54ec84412a1be6d7db51bb65ce9702e3bb1d26b51b93c5fdefa79f"
|
||||||
}
|
}
|
||||||
+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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -114,8 +114,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "release_name!: String",
|
"name": "runtime_minutes",
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "runtime_minutes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "release_name!: String",
|
||||||
|
"ordinal": 11,
|
||||||
"type_info": "Text",
|
"type_info": "Text",
|
||||||
"origin": {
|
"origin": {
|
||||||
"Table": {
|
"Table": {
|
||||||
@@ -139,8 +150,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
false
|
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"
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "media_files",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "media_files",
|
||||||
|
"name": "path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc"
|
||||||
|
}
|
||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -134,6 +134,12 @@
|
|||||||
"name": "rejected_rule"
|
"name": "rejected_rule"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "blacklist_reason?: String",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Null",
|
||||||
|
"origin": "Expression"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -151,8 +157,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "aaf6f4f7243bffa925fa17c9af3afb91c076ae3976b11cf18eb7583b8ce69a7e"
|
"hash": "8b2aa810e679ddde423a5dc5a1a89e0967206e8186b2157bb4b4d11e9a136759"
|
||||||
}
|
}
|
||||||
+15
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT normalised_name AS \"normalised_name!: String\", infohash FROM blacklist",
|
"query": "SELECT normalised_name AS \"normalised_name!: String\",\n infohash,\n reason AS \"reason!: String\"\n FROM blacklist\n ORDER BY id",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,17 @@
|
|||||||
"name": "infohash"
|
"name": "infohash"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reason!: String",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "blacklist",
|
||||||
|
"name": "reason"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -31,8 +42,9 @@
|
|||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
true
|
true,
|
||||||
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "071c14544e225001d31c5da60c90d3144fc5a071893c74b2c1eea51bfe00ac97"
|
"hash": "939252a81cdd0103b2aaa3cf19d5cae6e6e327709dc9e5a2073092b75137e13c"
|
||||||
}
|
}
|
||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
"query": "SELECT r.id AS \"id!: i64\", r.indexer_id AS \"indexer_id!: i64\", r.guid AS \"guid!: String\", r.name AS \"name!: String\", r.size AS \"size!: i64\", r.seeders, r.publish_date, r.download_url AS \"download_url!: String\", r.parsed AS \"parsed!: serde_json::Value\", r.score, r.verdict, r.rejected_rule, NULL AS \"blacklist_reason?: String\" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -134,6 +134,12 @@
|
|||||||
"name": "rejected_rule"
|
"name": "rejected_rule"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "blacklist_reason?: String",
|
||||||
|
"ordinal": 12,
|
||||||
|
"type_info": "Null",
|
||||||
|
"origin": "Expression"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -151,8 +157,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "f203b69afb44bfb5f906ff2e1ec13915645c35af5349032be9898a697d0d05ba"
|
"hash": "9cb6a575aa3e0ff377551a324f839c02d0a070277a9ddf4d5749545315746389"
|
||||||
}
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id AS \"id!: i64\" FROM roots WHERE policy_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "roots",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a28caf97dfb6a7e6b69543df0693e15c746702c36a0d4068fa70d28b950e7562"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id\n WHERE m.root_id = ?\n ORDER BY mf.id",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "media_files",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "path!: String",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "media_files",
|
||||||
|
"name": "path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302"
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id AS \"id!: i64\" FROM roots\n WHERE CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END = ?\n AND (? IS NULL OR id <> ?)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "roots",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 3
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "bda8991590c009ca7084fed36fae088f1b6ae12ad95f972724a417711ad37fa1"
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "UPDATE grabs SET state = 'failed' WHERE id = ?",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 1
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "c66f29b6edc4bca3a751e85e133fa379d232b045498310debab5ddd97f039294"
|
|
||||||
}
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
"query": "SELECT count(*) AS \"failures!: i64\",\n max(coalesce(failed_at, grabbed_at)) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019"
|
"hash": "c73422a1c9d28742f200d1744ba0862bba0dd2d708e948c32fd74f013cc4cda5"
|
||||||
}
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id AS \"id!: i64\" FROM series WHERE root_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id!: i64",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "cc5686ba4766a79d333e98ddcce232fc8a45e31c1a74ae487198b2d286b81e96"
|
||||||
|
}
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
|
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1 AND e.state != 'available'\n )\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 0
|
"Right": 1
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
@@ -82,5 +82,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
|
"hash": "dba46c4901a633fc1bda1b2adf75eed66904f2847ee45a699c96fd53d5d9e777"
|
||||||
}
|
}
|
||||||
+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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -145,6 +145,17 @@
|
|||||||
"name": "vote_average"
|
"name": "vote_average"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "runtime_minutes",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "runtime_minutes"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -163,8 +174,9 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE grabs\n SET state = 'failed',\n failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "fefda8c73b3b4a4dac4ae6551ed9cc4ed55dd24352a3d57a78a49e07604003c1"
|
||||||
|
}
|
||||||
+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"
|
||||||
|
}
|
||||||
@@ -199,6 +199,26 @@ fake profile per root and ignoring whatever it sends.
|
|||||||
Per-title `overrides` relax the root policy for one title. Same mechanism in
|
Per-title `overrides` relax the root policy for one title. Same mechanism in
|
||||||
both directions — `only_4k` tightens, `allow_english_audio` loosens.
|
both directions — `only_4k` tightens, `allow_english_audio` loosens.
|
||||||
|
|
||||||
|
**Stored verdicts follow the effective policy.** A release's verdict is
|
||||||
|
stamped by the search that found it, and both §9.3's deck and the daemon's
|
||||||
|
manual-grab gate read that stored column. So the four operator actions that
|
||||||
|
change a title's effective policy — editing its overrides, moving it to a
|
||||||
|
root with a different policy, pointing a root at a different policy, and
|
||||||
|
editing the contents of a policy some root points at — re-derive the stored
|
||||||
|
verdicts of everything they touch, inline in the same request. The operator
|
||||||
|
is never left reading a verdict computed under a policy that no longer
|
||||||
|
applies.
|
||||||
|
|
||||||
|
The fourth is the widest: a repoint moves one library, a policy edit moves
|
||||||
|
every library sharing the policy. Inline still holds there. Re-evaluation is
|
||||||
|
pure and in-memory, a row whose verdict does not move is not rewritten, and
|
||||||
|
the ceiling is the whole database rather than something that grows with the
|
||||||
|
number of roots — roots partition titles, and a title has exactly one root.
|
||||||
|
Measured over 2000 titles and 10 000 stored releases split across two roots
|
||||||
|
sharing one policy: 0.36 s when the edit moves no verdict, 2.7 s in the
|
||||||
|
pathological case where it moves all 10 000. A rename changes no rule and
|
||||||
|
re-derives nothing.
|
||||||
|
|
||||||
### 5.2 Language
|
### 5.2 Language
|
||||||
|
|
||||||
Requires a concept the release name does not carry: the **original language of
|
Requires a concept the release name does not carry: the **original language of
|
||||||
@@ -307,6 +327,26 @@ measured by its full size. That is the behaviour today, and it makes a pack look
|
|||||||
oversized rather than undersized — it fails toward rejecting a good pack rather
|
oversized rather than undersized — it fails toward rejecting a good pack rather
|
||||||
than grabbing a bad one, and the next search after a refresh has the real count.
|
than grabbing a bad one, and the next search after a refresh has the real count.
|
||||||
|
|
||||||
|
**A band describes a rate, not a fixed size per episode.** The shipped values
|
||||||
|
are read against a **reference runtime of 45 minutes**: a 1080p target of 2 GiB
|
||||||
|
means 2 GiB per 45 minutes of episode. Before the per-episode figure is compared
|
||||||
|
against them, a band's floor and target are both scaled by
|
||||||
|
`runtime / 45`, where `runtime` is the series' minutes-per-episode from TMDB
|
||||||
|
metadata. For a typical drama around 45 minutes the shipped numbers keep exactly
|
||||||
|
their current meaning; a 22-minute show is judged against roughly half the floor
|
||||||
|
and half the target instead of being rejected for weighing half of what an hour
|
||||||
|
of video weighs. The shipped band values themselves do not change — the
|
||||||
|
reference runtime is chosen so they do not have to.
|
||||||
|
|
||||||
|
**A missing or zero runtime is the reference runtime.** When metadata carries no
|
||||||
|
per-episode runtime, or carries zero, the scale factor is 1 and the band applies
|
||||||
|
unscaled — exactly today's behaviour. The rule does not guess a duration, for
|
||||||
|
the same reason the unknown episode count does not guess a number.
|
||||||
|
|
||||||
|
**Movies are not scaled.** This applies to episodes only. A movie's bands are
|
||||||
|
already tuned against feature length, so its floor and target keep their current
|
||||||
|
meaning regardless of the movie's own runtime.
|
||||||
|
|
||||||
Source tier (`Remux > BluRay > WEB-DL > WEBRip > HDTV`) survives as a small
|
Source tier (`Remux > BluRay > WEB-DL > WEBRip > HDTV`) survives as a small
|
||||||
tiebreaker. Seeders are log-scaled and small: enough to complete, past that it
|
tiebreaker. Seeders are log-scaled and small: enough to complete, past that it
|
||||||
does not matter. Telesync, CAM and screener are **hard filters**, not low
|
does not matter. Telesync, CAM and screener are **hard filters**, not low
|
||||||
@@ -358,6 +398,46 @@ A policy violation found by `ffprobe` is not one thing.
|
|||||||
|
|
||||||
Neither deletes the torrent. See §7.3.
|
Neither deletes the torrent. See §7.3.
|
||||||
|
|
||||||
|
**Two hard failures make a decision, and only for 30 days.** A movie, an
|
||||||
|
episode or a season enters the needs-a-decision queue (§9.5) when two grabs
|
||||||
|
against *different* releases hard-failed on it, and both of those failures
|
||||||
|
happened within the last 30 days. One bad torrent is not a decision — a
|
||||||
|
release that hard-failed is blacklisted (§6.3) and the next candidate is
|
||||||
|
grabbed, which is the system working.
|
||||||
|
|
||||||
|
The window runs from the failure, not the grab. The two are usually minutes
|
||||||
|
apart, but a torrent can sit stalling on a dead swarm for five weeks before
|
||||||
|
`ffprobe` finally condemns it — and that failure is fresh evidence the target
|
||||||
|
is broken now, not history. Measured from the grab it would be born outside
|
||||||
|
the window and a genuinely broken target could never surface. So `grabs`
|
||||||
|
records `failed_at` alongside `grabbed_at`, and the window reads it.
|
||||||
|
|
||||||
|
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
|
||||||
|
without it the queue only ever grows and the one season that wants attention
|
||||||
|
sits behind eight that were dealt with months ago. It is the queue's version of
|
||||||
|
§6.2's "it never gives up entirely, it goes quiet": a target the operator has
|
||||||
|
dealt with stops producing failures and drops out once the last one ages past
|
||||||
|
30 days, while a target that is still broken keeps producing them — the pack
|
||||||
|
guard retries at worst weekly (§6.2) — and stays queued for exactly as long as
|
||||||
|
it is genuinely broken. Nothing is dismissed by hand and no acknowledgement
|
||||||
|
state is stored, so there is no second thing to keep correct.
|
||||||
|
|
||||||
|
**Only a target still waiting for a file is queued.** The count and the
|
||||||
|
window already express one rule — bounded attention — and this is another
|
||||||
|
face of it: a failure history queues a target only while that target still
|
||||||
|
has a gap to fill. A movie or an episode is queued while it is `wanted` and
|
||||||
|
not `available`. A season holds no intent of its own (§4.1), so it is queued
|
||||||
|
while at least one of its episodes is still `wanted` and not `available`. A
|
||||||
|
season pack that hard-failed twice, fell back to per-episode grabbing
|
||||||
|
exactly as §6.2 says it should, and was then fully acquired leaves at once
|
||||||
|
rather than waiting out the 30 days — that is the system working, not a
|
||||||
|
decision. A target that is still broken keeps producing failures and stays.
|
||||||
|
|
||||||
|
The same rule applies on all three lanes and in both readers.
|
||||||
|
`GET /api/queues/attention` (§9.3) and the `ntfy` notification (§9.5) are two
|
||||||
|
views of one queue; filtering differently tells the operator two different
|
||||||
|
stories on two channels.
|
||||||
|
|
||||||
## 6. Sourcing
|
## 6. Sourcing
|
||||||
|
|
||||||
### 6.1 Prowlarr, per-indexer Torznab
|
### 6.1 Prowlarr, per-indexer Torznab
|
||||||
@@ -400,6 +480,22 @@ its own, the escape hatch is the season release deck, not a lane exception.
|
|||||||
Targeted search backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when the
|
Targeted search backs off `1h → 6h → 1d → 3d`, capped at 7d, reset when the
|
||||||
title's metadata changes. It never gives up entirely, it goes quiet.
|
title's metadata changes. It never gives up entirely, it goes quiet.
|
||||||
|
|
||||||
|
**The ladder runs from the failure, not the grab.** A failed season-pack grab
|
||||||
|
quiets the pack lane on that same curve, and the rung is measured from the
|
||||||
|
moment the grab entered `failed` — `grabs.failed_at`, the column §5.7's
|
||||||
|
attention window reads — not from when it was sent. The two are usually
|
||||||
|
minutes apart, but a torrent can stall on a dead swarm for five weeks before
|
||||||
|
`ffprobe` condemns it at import. Measured from the grab, the whole ladder has
|
||||||
|
already elapsed by the time the failure lands, so the lane retries the source
|
||||||
|
that just failed at once, which is the one thing the backoff exists to
|
||||||
|
prevent. The ladder's job is to stay off a source that has recently failed,
|
||||||
|
and "recently" can only mean recently failed.
|
||||||
|
|
||||||
|
One anchor covers both features. §5.7's window and this ladder ask the same
|
||||||
|
question of the same event and read the same column; a target still broken
|
||||||
|
keeps producing fresh failures, and each one both re-arms this backoff and
|
||||||
|
holds the target in the attention queue.
|
||||||
|
|
||||||
**Do not search before the release exists.** TMDB carries release dates; a
|
**Do not search before the release exists.** TMDB carries release dates; a
|
||||||
movie with no digital release date gets zero targeted searches. This is the
|
movie with no digital release date gets zero targeted searches. This is the
|
||||||
single largest source of wasted queries in Radarr and it is free to avoid.
|
single largest source of wasted queries in Radarr and it is free to avoid.
|
||||||
@@ -468,6 +564,13 @@ Media kind first, hard audience boundary second, people nowhere.
|
|||||||
- **Release group is deliberately absent.** It is not a selection criterion and
|
- **Release group is deliberately absent.** It is not a selection criterion and
|
||||||
it makes filenames long enough to break a terminal.
|
it makes filenames long enough to break a terminal.
|
||||||
|
|
||||||
|
Changing a title's root relocates its title folder into the new root; roots
|
||||||
|
are assumed to share one filesystem, so the move is a rename, never a copy.
|
||||||
|
Changing a root's path is the same move over every title under it, and it
|
||||||
|
is all or nothing: one folder that cannot move puts back the ones that
|
||||||
|
already did and leaves the root's path alone, so the stored path always
|
||||||
|
describes the disk.
|
||||||
|
|
||||||
During transition, write into the existing roots so Jellyfin needs no
|
During transition, write into the existing roots so Jellyfin needs no
|
||||||
reconfiguration and new content appears immediately. Radarr will not touch a
|
reconfiguration and new content appears immediately. Radarr will not touch a
|
||||||
folder it has no record of.
|
folder it has no record of.
|
||||||
@@ -576,7 +679,7 @@ notifying on everything and being muted within a week.
|
|||||||
|
|
||||||
- **Imported** → to the title's owners. The only good-news notification.
|
- **Imported** → to the title's owners. The only good-news notification.
|
||||||
- **Needs a decision** → to the operator alone. Entered the no-PT-source queue,
|
- **Needs a decision** → to the operator alone. Entered the no-PT-source queue,
|
||||||
or hard-failed twice on different releases.
|
or the needs-a-decision queue (§5.7).
|
||||||
- **Broken** → to the operator alone. Prowlarr, Transmission or TMDB
|
- **Broken** → to the operator alone. Prowlarr, Transmission or TMDB
|
||||||
unreachable, disk full.
|
unreachable, disk full.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ mod metadata;
|
|||||||
mod movies;
|
mod movies;
|
||||||
mod owners;
|
mod owners;
|
||||||
mod policies;
|
mod policies;
|
||||||
|
mod reclassify;
|
||||||
|
mod relocate;
|
||||||
mod roots;
|
mod roots;
|
||||||
mod search;
|
mod search;
|
||||||
mod series;
|
mod series;
|
||||||
|
|||||||
+978
-62
File diff suppressed because it is too large
Load Diff
@@ -173,6 +173,33 @@ struct PolicyColumns {
|
|||||||
score_weights: String,
|
score_weights: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything in a policy row that a verdict depends on — the name is the
|
||||||
|
/// one column that does not.
|
||||||
|
#[derive(PartialEq, Eq)]
|
||||||
|
struct PolicyRules {
|
||||||
|
required_audio: String,
|
||||||
|
dub_blacklist: String,
|
||||||
|
hdr_rules: String,
|
||||||
|
size_bands: String,
|
||||||
|
resolution_pref: String,
|
||||||
|
source_weights: String,
|
||||||
|
score_weights: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PolicyColumns {
|
||||||
|
fn rules(&self) -> PolicyRules {
|
||||||
|
PolicyRules {
|
||||||
|
required_audio: self.required_audio.clone(),
|
||||||
|
dub_blacklist: self.dub_blacklist.clone(),
|
||||||
|
hdr_rules: self.hdr_rules.clone(),
|
||||||
|
size_bands: self.size_bands.clone(),
|
||||||
|
resolution_pref: self.resolution_pref.clone(),
|
||||||
|
source_weights: self.source_weights.clone(),
|
||||||
|
score_weights: self.score_weights.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn column<T: serde::de::DeserializeOwned>(
|
fn column<T: serde::de::DeserializeOwned>(
|
||||||
column: &'static str,
|
column: &'static str,
|
||||||
value: &str,
|
value: &str,
|
||||||
@@ -376,6 +403,8 @@ pub async fn update(
|
|||||||
input.validate().map_err(ApiError::Invalid)?;
|
input.validate().map_err(ApiError::Invalid)?;
|
||||||
let name = input.name.trim().to_owned();
|
let name = input.name.trim().to_owned();
|
||||||
let columns = input.into_columns(name)?;
|
let columns = input.into_columns(name)?;
|
||||||
|
let after = columns.rules();
|
||||||
|
let before = stored_rules(&state, id).await?;
|
||||||
let result = sqlx::query!(
|
let result = sqlx::query!(
|
||||||
r#"UPDATE policies SET
|
r#"UPDATE policies SET
|
||||||
name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?,
|
name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?,
|
||||||
@@ -405,9 +434,44 @@ pub async fn update(
|
|||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(ApiError::PolicyNotFound);
|
return Err(ApiError::PolicyNotFound);
|
||||||
}
|
}
|
||||||
|
// Every verdict stored under every root pointing here was reached under
|
||||||
|
// the rules this write just replaced; §9.3's deck and the grab gate both
|
||||||
|
// read them (`reclassify`). A rename leaves the rules alone, so it walks
|
||||||
|
// nothing.
|
||||||
|
if before.is_none_or(|before| before != after) {
|
||||||
|
crate::reclassify::policy(&state, id).await?;
|
||||||
|
}
|
||||||
Ok(Json(load_policy(&state, id).await?))
|
Ok(Json(load_policy(&state, id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The rule columns of a policy as they stand, or `None` when there is no
|
||||||
|
/// such row. Compared against what the write is about to store, so an edit
|
||||||
|
/// that only moves the name does not re-derive a library.
|
||||||
|
async fn stored_rules(state: &AppState, id: i64) -> Result<Option<PolicyRules>, ApiError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"SELECT required_audio AS "required_audio!: String",
|
||||||
|
dub_blacklist AS "dub_blacklist!: String",
|
||||||
|
hdr_rules AS "hdr_rules!: String",
|
||||||
|
size_bands AS "size_bands!: String",
|
||||||
|
resolution_pref AS "resolution_pref!: String",
|
||||||
|
source_weights AS "source_weights!: String",
|
||||||
|
score_weights AS "score_weights!: String"
|
||||||
|
FROM policies WHERE id = ?"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool(state)?)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|row| PolicyRules {
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
delete, path = "/api/policies/{policy_id}", tag = "policies",
|
delete, path = "/api/policies/{policy_id}", tag = "policies",
|
||||||
params(("policy_id" = i64, Path, description = "Policy row id")),
|
params(("policy_id" = i64, Path, description = "Policy row id")),
|
||||||
@@ -450,7 +514,7 @@ mod tests {
|
|||||||
use crate::{router, Upstreams};
|
use crate::{router, Upstreams};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
async fn application() -> (tempfile::TempDir, String) {
|
async fn application() -> (tempfile::TempDir, AppState, String) {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||||
.await
|
.await
|
||||||
@@ -466,9 +530,9 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("bind");
|
.expect("bind");
|
||||||
let address = listener.local_addr().expect("address");
|
let address = listener.local_addr().expect("address");
|
||||||
let app = router(state);
|
let app = router(state.clone());
|
||||||
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
||||||
(dir, format!("http://{address}"))
|
(dir, state, format!("http://{address}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn valid_input(name: &str) -> serde_json::Value {
|
fn valid_input(name: &str) -> serde_json::Value {
|
||||||
@@ -501,7 +565,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn crud_round_trips_a_policy() {
|
async fn crud_round_trips_a_policy() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
|
|
||||||
let created: serde_json::Value = create(&base, valid_input("test policy"))
|
let created: serde_json::Value = create(&base, valid_input("test policy"))
|
||||||
.await
|
.await
|
||||||
@@ -557,7 +621,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_referenced_policy_refuses_to_die() {
|
async fn a_referenced_policy_refuses_to_die() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||||
.await
|
.await
|
||||||
.expect("roots")
|
.expect("roots")
|
||||||
@@ -582,7 +646,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn an_unknown_resolution_is_a_422_naming_the_field() {
|
async fn an_unknown_resolution_is_a_422_naming_the_field() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
let mut payload = valid_input("bad bands");
|
let mut payload = valid_input("bad bands");
|
||||||
payload["size_bands"]["1440p"] = serde_json::json!({ "floor_gib": 2, "target_gib": 6, "penalty_points_per_gib_over": 60 });
|
payload["size_bands"]["1440p"] = serde_json::json!({ "floor_gib": 2, "target_gib": 6, "penalty_points_per_gib_over": 60 });
|
||||||
|
|
||||||
@@ -596,7 +660,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn every_field_validates_by_name() {
|
async fn every_field_validates_by_name() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
let with = |patch: &dyn Fn(&mut serde_json::Value)| {
|
let with = |patch: &dyn Fn(&mut serde_json::Value)| {
|
||||||
let mut payload = valid_input("validation probe");
|
let mut payload = valid_input("validation probe");
|
||||||
patch(&mut payload);
|
patch(&mut payload);
|
||||||
@@ -645,7 +709,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn malformed_json_is_422_not_400_or_500() {
|
async fn malformed_json_is_422_not_400_or_500() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
.post(format!("{base}/api/policies"))
|
.post(format!("{base}/api/policies"))
|
||||||
@@ -667,8 +731,267 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_duplicate_name_conflicts() {
|
async fn a_duplicate_name_conflicts() {
|
||||||
let (_dir, base) = application().await;
|
let (_dir, _state, base) = application().await;
|
||||||
let response = create(&base, valid_input("Movies — main")).await;
|
let response = create(&base, valid_input("Movies — main")).await;
|
||||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn policy_id_named(base: &str, name: &str) -> i64 {
|
||||||
|
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
|
||||||
|
.await
|
||||||
|
.expect("policies")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("policies json");
|
||||||
|
policies
|
||||||
|
.iter()
|
||||||
|
.find(|policy| policy["name"] == name)
|
||||||
|
.and_then(|policy| policy["id"].as_i64())
|
||||||
|
.expect("policy id")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A movie under `root_id`, with one English 1080p release stamped
|
||||||
|
/// `verdict` as a search would have stamped it.
|
||||||
|
async fn movie_with_release(
|
||||||
|
state: &AppState,
|
||||||
|
base: &str,
|
||||||
|
root_id: i64,
|
||||||
|
tmdb_id: i64,
|
||||||
|
verdict: &str,
|
||||||
|
) -> i64 {
|
||||||
|
let movie: serde_json::Value = reqwest::Client::new()
|
||||||
|
.post(format!("{base}/api/movies"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"tmdb_id": tmdb_id, "title": format!("Title {tmdb_id}"), "year": 2024,
|
||||||
|
"original_language": "en", "root_id": root_id, "overrides": {}
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create movie")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("movie json");
|
||||||
|
let movie_id = movie["id"].as_i64().expect("movie id");
|
||||||
|
let name = format!("Title {tmdb_id} 2024 1080p WEB-DL ENGLISH x264-GROUP");
|
||||||
|
let release_id = stamped_release(state, &name, 6_i64 * (1 << 30), verdict).await;
|
||||||
|
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(movie_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("movie association");
|
||||||
|
movie_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One release row, verdict stamped by hand. `waived` carries no rule,
|
||||||
|
/// which the `releases` CHECK allows — only `rejected` needs one.
|
||||||
|
async fn stamped_release(state: &AppState, name: &str, size: i64, verdict: &str) -> i64 {
|
||||||
|
let parsed = arr_parse::parse(name);
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||||
|
VALUES (7, ?, ?, ?, 40, 'url', ?, 0, ?) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(name)
|
||||||
|
.bind(size)
|
||||||
|
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||||
|
.bind(verdict)
|
||||||
|
.fetch_one(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("release")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A series under `root_id` with a ten-episode season 9 pack stamped
|
||||||
|
/// `waived`. Under its own policy the pack is eligible, so the stamp
|
||||||
|
/// only survives if nothing re-derived it.
|
||||||
|
async fn series_with_waived_pack(state: &AppState, base: &str, root_id: i64) -> i64 {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let series: serde_json::Value = client
|
||||||
|
.post(format!("{base}/api/series"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"tmdb_id": 82_728, "title": "Bluey", "year": 2018,
|
||||||
|
"original_language": "en", "root_id": root_id,
|
||||||
|
"auto_track": false
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create series")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("series json");
|
||||||
|
let series_id = series["id"].as_i64().expect("series 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: serde_json::Value = client
|
||||||
|
.post(format!("{base}/api/series/{series_id}/seasons"))
|
||||||
|
.json(&serde_json::json!({"number": 9, "episodes": episodes}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("create season")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("season json");
|
||||||
|
let season_id = season["id"].as_i64().expect("season id");
|
||||||
|
// Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
|
||||||
|
let release_id = stamped_release(
|
||||||
|
state,
|
||||||
|
"Bluey S09 1080p WEB-DL ENGLISH x264-GROUP",
|
||||||
|
15_i64 * (1 << 30),
|
||||||
|
"waived",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(season_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("season association");
|
||||||
|
series_id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verdict_at(url: String) -> serde_json::Value {
|
||||||
|
let releases: Vec<serde_json::Value> = reqwest::get(url)
|
||||||
|
.await
|
||||||
|
.expect("releases")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("releases json");
|
||||||
|
releases[0]["verdict"].clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn movie_verdict(base: &str, movie_id: i64) -> serde_json::Value {
|
||||||
|
verdict_at(format!("{base}/api/movies/{movie_id}/releases")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root_id_of(state: &AppState, kind: &str, audience: &str) -> i64 {
|
||||||
|
sqlx::query_scalar("SELECT id FROM roots WHERE kind = ? AND audience = ?")
|
||||||
|
.bind(kind)
|
||||||
|
.bind(audience)
|
||||||
|
.fetch_one(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("root id")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same policy document with a different required-audio rule, so an
|
||||||
|
/// English release that was eligible becomes a waiver.
|
||||||
|
fn requires_portuguese(name: &str) -> serde_json::Value {
|
||||||
|
let mut payload = valid_input(name);
|
||||||
|
payload["required_audio"] = serde_json::json!({ "require": "any_of", "langs": ["pt-PT"] });
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #246: editing a policy re-derives the stored verdicts of every title
|
||||||
|
/// under every root pointing at it (§5.1) — both libraries when two
|
||||||
|
/// roots share it, and nothing under a root that does not.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn editing_a_policy_rederives_every_root_that_points_at_it() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let shared = policy_id_named(&base, "Movies — main").await;
|
||||||
|
let main_root = root_id_of(&state, "movie", "main").await;
|
||||||
|
let kids_root = root_id_of(&state, "movie", "kids").await;
|
||||||
|
|
||||||
|
// Two roots on one policy: the edit has two libraries to reach
|
||||||
|
// rather than one. The path stays, so nothing on disk moves.
|
||||||
|
let response = client
|
||||||
|
.put(format!("{base}/api/roots/{kids_root}"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"kind": "movie", "audience": "kids",
|
||||||
|
"path": "/mnt/media/movies/kids", "policy_id": shared,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("repoint kids root");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{:?}",
|
||||||
|
response.text().await
|
||||||
|
);
|
||||||
|
|
||||||
|
let here = movie_with_release(&state, &base, main_root, 693_134, "eligible").await;
|
||||||
|
let there = movie_with_release(&state, &base, kids_root, 27_205, "eligible").await;
|
||||||
|
// On the TV main root, a different policy, so the edit must not
|
||||||
|
// reach it. Stamped against its own policy's answer, so a walk that
|
||||||
|
// did reach it would show.
|
||||||
|
let tv_root = root_id_of(&state, "tv", "main").await;
|
||||||
|
let series_id = series_with_waived_pack(&state, &base, tv_root).await;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.put(format!("{base}/api/policies/{shared}"))
|
||||||
|
.json(&requires_portuguese("Movies — main"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("edit policy");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{:?}",
|
||||||
|
response.text().await
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
movie_verdict(&base, here).await,
|
||||||
|
"waived",
|
||||||
|
"the first root's library re-derives"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
movie_verdict(&base, there).await,
|
||||||
|
"waived",
|
||||||
|
"and so does the second root's, sharing the policy"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
verdict_at(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
|
||||||
|
"waived",
|
||||||
|
"a root on another policy keeps the verdict it was stamped with"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renaming a policy changes no rule, so it re-derives nothing — the
|
||||||
|
/// stamped verdict survives even though the rules would not produce it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn renaming_a_policy_leaves_verdicts_alone() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let kids = policy_id_named(&base, "Movies — kids").await;
|
||||||
|
let kids_root = root_id_of(&state, "movie", "kids").await;
|
||||||
|
|
||||||
|
// Write the rules through the API once, so the rename that follows
|
||||||
|
// stores byte-identical rule columns and the only change is the name.
|
||||||
|
let response = client
|
||||||
|
.put(format!("{base}/api/policies/{kids}"))
|
||||||
|
.json(&valid_input("Movies — kids"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("normalise policy");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// Deliberately the wrong answer: these rules make an English release
|
||||||
|
// a waiver, so a re-derivation would move this row.
|
||||||
|
let movie = movie_with_release(&state, &base, kids_root, 157_336, "eligible").await;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.put(format!("{base}/api/policies/{kids}"))
|
||||||
|
.json(&valid_input("Movies — children"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("rename policy");
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"{:?}",
|
||||||
|
response.text().await
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
movie_verdict(&base, movie).await,
|
||||||
|
"eligible",
|
||||||
|
"a rename touches no rule, so it walks nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
//! Stored verdicts, re-derived when a title's effective policy changes:
|
||||||
|
//! an overrides edit (§9.3), a move to a root with a different policy, a
|
||||||
|
//! root pointed at a different policy, or an edit to the contents of a
|
||||||
|
//! policy some root points at (§5.1).
|
||||||
|
//!
|
||||||
|
//! 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. A root move and a
|
||||||
|
//! policy repoint invalidate the column the same way, just wider: nothing
|
||||||
|
//! else re-reads it, so the write that changed the effective policy is the
|
||||||
|
//! only place the correction can happen. A policy edit invalidates it wider
|
||||||
|
//! still: every root pointing at that policy, not just one.
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-evaluate every release of every title under one root, for a
|
||||||
|
/// `PUT /api/roots/{id}` that pointed the root at a different policy (§5.1).
|
||||||
|
///
|
||||||
|
/// Title by title through [`movie`] and [`series`], so the skip rules — no
|
||||||
|
/// stored original language, the blacklist (§6.3) — stay in one place. Rows
|
||||||
|
/// whose verdict the new policy does not move are left unwritten, so the
|
||||||
|
/// usual case (most of a library re-evaluates to the same answer) costs
|
||||||
|
/// reads, not writes.
|
||||||
|
pub(crate) async fn root(state: &AppState, root_id: i64) -> Result<(), ApiError> {
|
||||||
|
let movie_ids = sqlx::query_scalar!(
|
||||||
|
r#"SELECT id AS "id!: i64" FROM movies WHERE root_id = ?"#,
|
||||||
|
root_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool(state)?)
|
||||||
|
.await?;
|
||||||
|
for movie_id in movie_ids {
|
||||||
|
movie(state, movie_id).await?;
|
||||||
|
}
|
||||||
|
let series_ids = sqlx::query_scalar!(
|
||||||
|
r#"SELECT id AS "id!: i64" FROM series WHERE root_id = ?"#,
|
||||||
|
root_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool(state)?)
|
||||||
|
.await?;
|
||||||
|
for series_id in series_ids {
|
||||||
|
series(state, series_id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-evaluate every release of every title under every root that points at
|
||||||
|
/// one policy, for a `PUT /api/policies/{id}` that changed its rules (§5.1).
|
||||||
|
///
|
||||||
|
/// Root by root through [`root`], which is title by title through [`movie`]
|
||||||
|
/// and [`series`]: one walker, one set of skip rules, one place that decides
|
||||||
|
/// a row does not need rewriting.
|
||||||
|
///
|
||||||
|
/// This is the widest of the four re-derivations — a root repoint moves one
|
||||||
|
/// library, a policy edit moves every library sharing the policy — but the
|
||||||
|
/// ceiling is the whole database rather than something that grows with it,
|
||||||
|
/// since roots partition titles and a title has exactly one root.
|
||||||
|
pub(crate) async fn policy(state: &AppState, policy_id: i64) -> Result<(), ApiError> {
|
||||||
|
let root_ids = sqlx::query_scalar!(
|
||||||
|
r#"SELECT id AS "id!: i64" FROM roots WHERE policy_id = ?"#,
|
||||||
|
policy_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool(state)?)
|
||||||
|
.await?;
|
||||||
|
for root_id in root_ids {
|
||||||
|
root(state, root_id).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
);
|
||||||
|
// A waiver records the rule it relaxed, the same as a rejection
|
||||||
|
// (#211). Migration 0032 relaxed `releases` to
|
||||||
|
// `CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)` so it
|
||||||
|
// can, and the daemon writes waivers the same way.
|
||||||
|
let (verdict, rule) = verdict_columns(&evaluation.verdict);
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
//! Moving library files when the layout under them changes: a title changing
|
||||||
|
//! its `root_id` (issue #228), and a root changing its `path` (issue #236).
|
||||||
|
//! Both rename §7.4 folders and rewrite the `media_files` rows to match, so
|
||||||
|
//! the layout keeps describing the disk. This module only moves things: the
|
||||||
|
//! stored verdicts a root change invalidates (§5.1) are re-derived by the
|
||||||
|
//! calling handler through `reclassify`, after the row commits.
|
||||||
|
//!
|
||||||
|
//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this
|
||||||
|
//! is a directory rename, never a copy. Hardlinked files keep their inodes
|
||||||
|
//! and the torrent keeps seeding against them (§7.3).
|
||||||
|
//!
|
||||||
|
//! Ordering mirrors `remove_library_files`: the disk is touched before the
|
||||||
|
//! row changes, so a failed rename leaves the title where its files actually
|
||||||
|
//! are and the operator can retry.
|
||||||
|
//!
|
||||||
|
//! A root path change is the same move repeated over every title under the
|
||||||
|
//! root, and it is all or nothing. If the seventh of ten folders fails to
|
||||||
|
//! move, the six already renamed are moved back and the root row is left
|
||||||
|
//! alone: a root row carries one path, so a half-moved library would have to
|
||||||
|
//! describe both, and neither the operator nor the next import could tell
|
||||||
|
//! which titles were where. Refusing leaves one answer — everything is still
|
||||||
|
//! at the old path — and the retry is the same request again. A retry after
|
||||||
|
//! an undo that itself failed still converges, because a source that is no
|
||||||
|
//! longer on disk is skipped while its row is still rewritten.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::movies::{pool, title_target, ApiError};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Which table owns the moving title's files.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub(crate) enum TitleKind {
|
||||||
|
Movie,
|
||||||
|
Series,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One rename from the old root into the new one: a §7.4 title folder, or a
|
||||||
|
/// loose file sitting straight in the root.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PlannedRename {
|
||||||
|
source: PathBuf,
|
||||||
|
destination: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The renames already performed on disk and the row rewrites they imply.
|
||||||
|
/// The database half is the caller's transaction; [`Self::undo`] is for when
|
||||||
|
/// that transaction fails after the disk already changed.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Relocation {
|
||||||
|
performed: Vec<PlannedRename>,
|
||||||
|
rewrites: Vec<(i64, String)>,
|
||||||
|
/// Every directory level this request materialised for the new root,
|
||||||
|
/// deepest first. `create_dir_all` can make more than one — moving a
|
||||||
|
/// root to `/mnt/media-v2/tv/kids` when `/mnt/media-v2` is all that
|
||||||
|
/// exists creates both `tv` and `kids` — and [`Self::undo`] removes all
|
||||||
|
/// of them, so a failed move leaves nothing behind (#244). Levels that
|
||||||
|
/// were already on disk are never in this list and are never touched. A
|
||||||
|
/// move that commits never calls `undo` and the directories stay.
|
||||||
|
created_dirs: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename the title's folders into the new root. Called before the row is
|
||||||
|
/// written, and only when the root actually changes.
|
||||||
|
///
|
||||||
|
/// A title with nothing on disk — no `media_files` rows, or rows whose
|
||||||
|
/// targets are already gone — changes root with no filesystem work at all.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// [`ApiError::Conflict`] when the destination already holds an entry of the
|
||||||
|
/// same name — a conflict, never an overwrite. [`ApiError::Filesystem`] when
|
||||||
|
/// a rename fails; whatever had already been renamed is moved back first, so
|
||||||
|
/// the row the caller then leaves unchanged still describes the disk.
|
||||||
|
pub(crate) async fn relocate_title(
|
||||||
|
state: &AppState,
|
||||||
|
kind: TitleKind,
|
||||||
|
title_id: i64,
|
||||||
|
old_root_id: i64,
|
||||||
|
new_root_id: i64,
|
||||||
|
) -> Result<Relocation, ApiError> {
|
||||||
|
let old_root = root_path(state, old_root_id).await?;
|
||||||
|
let new_root = root_path(state, new_root_id).await?;
|
||||||
|
let files = title_files(state, kind, title_id).await?;
|
||||||
|
relocate_files(&files, &old_root, &new_root).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename every title folder under a root into the root's new path, for a
|
||||||
|
/// `PUT /api/roots/{id}` that changes `path` (issue #236).
|
||||||
|
///
|
||||||
|
/// A root with no titles under it — or whose titles have nothing on disk —
|
||||||
|
/// changes path with no filesystem work at all. The new path is created when
|
||||||
|
/// there is something to move into it, since a root is normally pointed at a
|
||||||
|
/// directory that does not exist yet.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// The same two as [`relocate_title`], with the whole root's move treated as
|
||||||
|
/// one unit: one folder that cannot move takes the entire change down and
|
||||||
|
/// moves back whatever had already moved.
|
||||||
|
pub(crate) async fn relocate_root(
|
||||||
|
state: &AppState,
|
||||||
|
root_id: i64,
|
||||||
|
old_path: &str,
|
||||||
|
new_path: &str,
|
||||||
|
) -> Result<Relocation, ApiError> {
|
||||||
|
let files = root_files(state, root_id).await?;
|
||||||
|
relocate_files(&files, old_path, new_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one mover both callers share: plan every rename, refuse every
|
||||||
|
/// destination that already exists, then perform them, undoing what was
|
||||||
|
/// performed if one fails.
|
||||||
|
async fn relocate_files(
|
||||||
|
files: &[(i64, String)],
|
||||||
|
old_root: &str,
|
||||||
|
new_root: &str,
|
||||||
|
) -> Result<Relocation, ApiError> {
|
||||||
|
let mut renames: Vec<PlannedRename> = Vec::new();
|
||||||
|
let mut rewrites: Vec<(i64, String)> = Vec::new();
|
||||||
|
for (file_id, path) in files {
|
||||||
|
let Some(source) = title_target(old_root, path) else {
|
||||||
|
// Outside its own root: not ours to move, and the row keeps
|
||||||
|
// pointing at where the file really is.
|
||||||
|
tracing::warn!(%path, %old_root, "media file is outside its root, not moved");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(name) = source.file_name() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let destination = std::path::Path::new(new_root).join(name);
|
||||||
|
if !renames.iter().any(|rename| rename.source == source) {
|
||||||
|
renames.push(PlannedRename {
|
||||||
|
source,
|
||||||
|
destination,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let relative = std::path::Path::new(path)
|
||||||
|
.strip_prefix(std::path::Path::new(old_root))
|
||||||
|
.map_err(|error| ApiError::Filesystem(error.to_string()))?;
|
||||||
|
let rewritten = std::path::Path::new(new_root).join(relative);
|
||||||
|
let Some(rewritten) = rewritten.to_str() else {
|
||||||
|
return Err(ApiError::Filesystem(format!(
|
||||||
|
"non-UTF-8 path under {new_root}"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
rewrites.push((*file_id, rewritten.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every destination is checked before anything is renamed, so a conflict
|
||||||
|
// never leaves a half-moved library behind. A rename whose source is
|
||||||
|
// already gone is skipped here rather than refused: the perform loop below
|
||||||
|
// skips it too, and refusing it would make the documented retry
|
||||||
|
// impossible. After an undo that itself failed, the folder sits at the
|
||||||
|
// destination while the row still names the source, and every later
|
||||||
|
// attempt would 409 on a conflict with the operator's own half-moved
|
||||||
|
// library.
|
||||||
|
for rename in &renames {
|
||||||
|
match tokio::fs::symlink_metadata(&rename.source).await {
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
match tokio::fs::symlink_metadata(&rename.destination).await {
|
||||||
|
Ok(_) => {
|
||||||
|
return Err(ApiError::Conflict(format!(
|
||||||
|
"the destination already has '{}'",
|
||||||
|
rename.destination.display()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracked only for the levels this call is the one to create, so a
|
||||||
|
// failed move can remove them again without ever touching a directory
|
||||||
|
// that already existed on disk. Recorded before `create_dir_all`, since
|
||||||
|
// afterwards there is no way to tell which levels it made.
|
||||||
|
let mut created_dirs: Vec<PathBuf> = Vec::new();
|
||||||
|
// A configured root need not exist on disk. Without creating it here,
|
||||||
|
// `rename` reports its missing parent as the same `NotFound` as a missing
|
||||||
|
// source and sends the operator looking at the wrong end of the move.
|
||||||
|
if !renames.is_empty() {
|
||||||
|
created_dirs = missing_levels(std::path::Path::new(new_root)).await;
|
||||||
|
if let Err(error) = tokio::fs::create_dir_all(new_root).await {
|
||||||
|
return Err(ApiError::Filesystem(format!(
|
||||||
|
"could not create '{new_root}': {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut performed: Vec<PlannedRename> = Vec::new();
|
||||||
|
for rename in renames {
|
||||||
|
// A recorded file with nothing on disk: the rows still follow the
|
||||||
|
// title, the same way a delete treats already-gone as done. Checked
|
||||||
|
// on the source, so a missing *destination* parent stays an error.
|
||||||
|
match tokio::fs::symlink_metadata(&rename.source).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
tracing::warn!(
|
||||||
|
source = %rename.source.display(),
|
||||||
|
"nothing on disk to move for this title"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(failed(&rename, &error, performed, created_dirs).await),
|
||||||
|
}
|
||||||
|
match tokio::fs::rename(&rename.source, &rename.destination).await {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
source = %rename.source.display(),
|
||||||
|
destination = %rename.destination.display(),
|
||||||
|
"moved a title folder"
|
||||||
|
);
|
||||||
|
performed.push(rename);
|
||||||
|
}
|
||||||
|
Err(error) => return Err(failed(&rename, &error, performed, created_dirs).await),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Relocation {
|
||||||
|
performed,
|
||||||
|
rewrites,
|
||||||
|
created_dirs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The levels of `path` that are not on disk, deepest first — exactly what a
|
||||||
|
/// following `create_dir_all` will materialise. The walk stops at the first
|
||||||
|
/// ancestor that exists, so nothing already there is ever listed.
|
||||||
|
async fn missing_levels(path: &std::path::Path) -> Vec<PathBuf> {
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
for ancestor in path.ancestors() {
|
||||||
|
// `ancestors` ends in an empty path for a relative input; there is
|
||||||
|
// no level above that to create.
|
||||||
|
if ancestor.as_os_str().is_empty() || tokio::fs::symlink_metadata(ancestor).await.is_ok() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
missing.push(ancestor.to_path_buf());
|
||||||
|
}
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One rename failed: move back everything that had already moved, remove
|
||||||
|
/// every directory level this request created, and name the folder that
|
||||||
|
/// stopped the move so the operator knows which title to look at before
|
||||||
|
/// retrying.
|
||||||
|
async fn failed(
|
||||||
|
rename: &PlannedRename,
|
||||||
|
error: &std::io::Error,
|
||||||
|
performed: Vec<PlannedRename>,
|
||||||
|
created_dirs: Vec<PathBuf>,
|
||||||
|
) -> ApiError {
|
||||||
|
Relocation {
|
||||||
|
performed,
|
||||||
|
rewrites: Vec::new(),
|
||||||
|
created_dirs,
|
||||||
|
}
|
||||||
|
.undo()
|
||||||
|
.await;
|
||||||
|
// Name both ends. `rename` returns NotFound for a missing destination
|
||||||
|
// parent as readily as for a missing source, and naming only the source
|
||||||
|
// sent an operator looking for a file that was sitting where they left it.
|
||||||
|
ApiError::Filesystem(format!(
|
||||||
|
"could not move '{}' to '{}': {error}",
|
||||||
|
rename.source.display(),
|
||||||
|
rename.destination.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Relocation {
|
||||||
|
/// Whether anything on disk actually moved — the trigger for the same
|
||||||
|
/// single Jellyfin refresh import performs (§7.5).
|
||||||
|
pub(crate) fn moved_files(&self) -> bool {
|
||||||
|
!self.performed.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point the `media_files` rows at the new root, inside the caller's
|
||||||
|
/// transaction so they land together with the `root_id` change or not at
|
||||||
|
/// all.
|
||||||
|
pub(crate) async fn rewrite_rows(
|
||||||
|
&self,
|
||||||
|
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
for (file_id, path) in &self.rewrites {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||||
|
path,
|
||||||
|
file_id
|
||||||
|
)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort reversal of the renames, for when the disk moved but the
|
||||||
|
/// database write failed. A reversal that itself fails is logged: at that
|
||||||
|
/// point the operator's retry is the recovery path.
|
||||||
|
pub(crate) async fn undo(&self) {
|
||||||
|
for rename in self.performed.iter().rev() {
|
||||||
|
if let Err(error) = tokio::fs::rename(&rename.destination, &rename.source).await {
|
||||||
|
tracing::error!(
|
||||||
|
source = %rename.source.display(),
|
||||||
|
destination = %rename.destination.display(),
|
||||||
|
%error,
|
||||||
|
"could not move the title folder back after a failed root change"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `remove_dir` rather than `remove_dir_all`: it only succeeds on an
|
||||||
|
// empty directory, so anything unexpected left inside it — this
|
||||||
|
// request's own undo failing, say — is a reason to leave it alone.
|
||||||
|
// Deepest first, since a parent cannot go while its child is there;
|
||||||
|
// the first level that will not go stops the walk, because every
|
||||||
|
// level above it now has content and refusing is the right answer.
|
||||||
|
for directory in &self.created_dirs {
|
||||||
|
if let Err(error) = tokio::fs::remove_dir(directory).await {
|
||||||
|
tracing::warn!(
|
||||||
|
path = %directory.display(),
|
||||||
|
%error,
|
||||||
|
"could not remove a directory created for a move that did not complete"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §7.5 after a move: the same single best-effort refresh import performs.
|
||||||
|
/// Failure logs and never fails the write that already committed.
|
||||||
|
pub(crate) async fn refresh_jellyfin(state: &AppState) {
|
||||||
|
if let Some(jellyfin) = state.jellyfin() {
|
||||||
|
if let Err(error) = jellyfin.refresh().await {
|
||||||
|
tracing::warn!(%error, "jellyfin refresh after a root change failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
|
||||||
|
Ok(sqlx::query_scalar!(
|
||||||
|
r#"SELECT path AS "path!: String" FROM roots WHERE id = ?"#,
|
||||||
|
root_id
|
||||||
|
)
|
||||||
|
.fetch_one(pool(state)?)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every file the service recorded under a root: the rows of every movie in
|
||||||
|
/// it, and the rows of every episode of every series in it. Ordered so the
|
||||||
|
/// renames happen in a stable order, which is what makes a failure part-way
|
||||||
|
/// through reproducible.
|
||||||
|
async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>, ApiError> {
|
||||||
|
let mut files: Vec<(i64, String)> = sqlx::query!(
|
||||||
|
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||||
|
FROM media_files mf
|
||||||
|
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
|
||||||
|
WHERE m.root_id = ?
|
||||||
|
ORDER BY mf.id"#,
|
||||||
|
root_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool(state)?)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| (row.id, row.path))
|
||||||
|
.collect();
|
||||||
|
files.extend(
|
||||||
|
sqlx::query!(
|
||||||
|
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||||
|
FROM media_files mf
|
||||||
|
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
||||||
|
JOIN seasons se ON se.id = e.season_id
|
||||||
|
JOIN series s ON s.id = se.series_id
|
||||||
|
WHERE s.root_id = ?
|
||||||
|
ORDER BY mf.id"#,
|
||||||
|
root_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool(state)?)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| (row.id, row.path)),
|
||||||
|
);
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every file the service recorded for the title: a movie's own rows, or the
|
||||||
|
/// rows of every episode below a series.
|
||||||
|
async fn title_files(
|
||||||
|
state: &AppState,
|
||||||
|
kind: TitleKind,
|
||||||
|
title_id: i64,
|
||||||
|
) -> Result<Vec<(i64, String)>, ApiError> {
|
||||||
|
Ok(match kind {
|
||||||
|
TitleKind::Movie => sqlx::query!(
|
||||||
|
r#"SELECT id AS "id!: i64", path AS "path!: String"
|
||||||
|
FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#,
|
||||||
|
title_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool(state)?)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| (row.id, row.path))
|
||||||
|
.collect(),
|
||||||
|
TitleKind::Series => sqlx::query!(
|
||||||
|
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||||
|
FROM media_files mf
|
||||||
|
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
||||||
|
JOIN seasons se ON se.id = e.season_id
|
||||||
|
WHERE se.series_id = ?"#,
|
||||||
|
title_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool(state)?)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| (row.id, row.path))
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
+1028
-25
File diff suppressed because it is too large
Load Diff
@@ -156,6 +156,10 @@ pub struct ClassifiedRelease {
|
|||||||
pub score_terms: ScoreTerms,
|
pub score_terms: ScoreTerms,
|
||||||
pub verdict: String,
|
pub verdict: String,
|
||||||
pub rule: Option<String>,
|
pub rule: Option<String>,
|
||||||
|
/// What the blacklist recorded this release as failing on (#227, §6.3),
|
||||||
|
/// when `rule` is `blacklisted`. `None` on every other row. A policy
|
||||||
|
/// rejection and a bad release both read as `blacklisted` without it.
|
||||||
|
pub blacklist_reason: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -490,6 +494,7 @@ async fn movie_releases(
|
|||||||
&original_language,
|
&original_language,
|
||||||
&blacklist,
|
&blacklist,
|
||||||
None,
|
None,
|
||||||
|
0,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,6 +530,7 @@ async fn episode_releases(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(ApiError::EpisodeNotFound)?;
|
.ok_or(ApiError::EpisodeNotFound)?;
|
||||||
let season_lengths = crate::series::season_lengths(state, episode.series_id).await?;
|
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
|
let loaded = database
|
||||||
.episode_policy(episode_id)
|
.episode_policy(episode_id)
|
||||||
.await
|
.await
|
||||||
@@ -568,6 +574,7 @@ async fn episode_releases(
|
|||||||
&original_language,
|
&original_language,
|
||||||
&blacklist,
|
&blacklist,
|
||||||
Some(&season_lengths),
|
Some(&season_lengths),
|
||||||
|
runtime_minutes,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -709,6 +716,7 @@ fn classify(
|
|||||||
original_language: &Language,
|
original_language: &Language,
|
||||||
blacklist: &Blacklist,
|
blacklist: &Blacklist,
|
||||||
season_lengths: Option<&BTreeMap<u32, u32>>,
|
season_lengths: Option<&BTreeMap<u32, u32>>,
|
||||||
|
runtime_minutes: u32,
|
||||||
) -> Result<ClassifiedRelease, ApiError> {
|
) -> Result<ClassifiedRelease, ApiError> {
|
||||||
let parsed = arr_parse::parse(&release.name);
|
let parsed = arr_parse::parse(&release.name);
|
||||||
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
|
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
|
||||||
@@ -724,11 +732,17 @@ fn classify(
|
|||||||
Candidate::PreGrab(&parsed),
|
Candidate::PreGrab(&parsed),
|
||||||
release.size,
|
release.size,
|
||||||
episodes,
|
episodes,
|
||||||
|
runtime_minutes,
|
||||||
);
|
);
|
||||||
|
// #227: the reason the blacklist holds is what tells a policy rejection
|
||||||
|
// the operator can relax from a release that should never be retried.
|
||||||
|
let blacklist_reason = blacklist
|
||||||
|
.reason_for_candidate(&release.name, &release.download_url)
|
||||||
|
.map(str::to_owned);
|
||||||
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
|
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
|
||||||
("rejected", Some(blacklist::RULE.to_owned()))
|
("rejected", Some(blacklist::RULE.to_owned()))
|
||||||
} else {
|
} else {
|
||||||
verdict(&evaluation.verdict)
|
verdict_columns(&evaluation.verdict)
|
||||||
};
|
};
|
||||||
let score = score(
|
let score = score(
|
||||||
policy,
|
policy,
|
||||||
@@ -736,6 +750,7 @@ fn classify(
|
|||||||
release.size.unwrap_or_default(),
|
release.size.unwrap_or_default(),
|
||||||
release.seeders.unwrap_or_default(),
|
release.seeders.unwrap_or_default(),
|
||||||
episodes,
|
episodes,
|
||||||
|
runtime_minutes,
|
||||||
);
|
);
|
||||||
// A release with no size has nothing to say about its size band, so that
|
// 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
|
// term is dropped rather than scored as if it were at the floor. Every
|
||||||
@@ -775,10 +790,11 @@ fn classify(
|
|||||||
score_terms: terms,
|
score_terms: terms,
|
||||||
verdict: verdict.to_owned(),
|
verdict: verdict.to_owned(),
|
||||||
rule,
|
rule,
|
||||||
|
blacklist_reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verdict(verdict: &Verdict) -> (&'static str, Option<String>) {
|
pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
|
||||||
match verdict {
|
match verdict {
|
||||||
Verdict::Eligible => ("eligible", None),
|
Verdict::Eligible => ("eligible", None),
|
||||||
Verdict::Waived(rule) => ("waived", Some(rule_name(rule))),
|
Verdict::Waived(rule) => ("waived", Some(rule_name(rule))),
|
||||||
@@ -1465,6 +1481,7 @@ mod tests {
|
|||||||
&Language::Other("en".into()),
|
&Language::Other("en".into()),
|
||||||
&Blacklist::default(),
|
&Blacklist::default(),
|
||||||
None,
|
None,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.expect("classified release")
|
.expect("classified release")
|
||||||
};
|
};
|
||||||
@@ -1558,7 +1575,7 @@ mod tests {
|
|||||||
imdb_id: None,
|
imdb_id: None,
|
||||||
};
|
};
|
||||||
let parsed = arr_parse::parse(&release.name);
|
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(
|
let classified = classify(
|
||||||
release,
|
release,
|
||||||
@@ -1567,6 +1584,7 @@ mod tests {
|
|||||||
&Language::Other("en".into()),
|
&Language::Other("en".into()),
|
||||||
&Blacklist::default(),
|
&Blacklist::default(),
|
||||||
None,
|
None,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.expect("classified release");
|
.expect("classified release");
|
||||||
|
|
||||||
|
|||||||
+644
-19
@@ -29,7 +29,9 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
|
|
||||||
use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release};
|
use crate::movies::{
|
||||||
|
attach_blacklist_reasons, pool, rescore, Accepted, ApiError, ErrorBody, Release,
|
||||||
|
};
|
||||||
use crate::owners::Owner;
|
use crate::owners::Owner;
|
||||||
use crate::search::tmdb_client;
|
use crate::search::tmdb_client;
|
||||||
use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand};
|
use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand};
|
||||||
@@ -110,6 +112,10 @@ pub struct Season {
|
|||||||
/// which is why the row still exists. A conflict for the operator to
|
/// which is why the row still exists. A conflict for the operator to
|
||||||
/// resolve; nothing was deleted from disk.
|
/// resolve; nothing was deleted from disk.
|
||||||
pub vanished: bool,
|
pub vanished: bool,
|
||||||
|
/// #227. The last season pack that downloaded in full and was condemned
|
||||||
|
/// at import, while the season is still waiting for a file. `None` when
|
||||||
|
/// no pack was abandoned, or when the gap has since been filled.
|
||||||
|
pub import_failure: Option<ImportFailure>,
|
||||||
pub episodes: Vec<Episode>,
|
pub episodes: Vec<Episode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,10 +190,10 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
|
|||||||
};
|
};
|
||||||
if object
|
if object
|
||||||
.keys()
|
.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(
|
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()) {
|
if object.values().any(|value| !value.is_boolean()) {
|
||||||
@@ -468,11 +474,15 @@ pub async fn create(
|
|||||||
let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone());
|
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 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 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!(
|
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.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
|
||||||
input.auto_track, input.upstream_ended, input.blocked, overrides,
|
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)?)
|
.execute(pool(&state)?)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -561,8 +571,11 @@ pub async fn update(
|
|||||||
if title.trim().is_empty() {
|
if title.trim().is_empty() {
|
||||||
return Err(ApiError::Invalid("title cannot be empty".into()));
|
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)?;
|
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 =
|
let overrides =
|
||||||
serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?;
|
serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?;
|
||||||
let title = title.trim();
|
let title = title.trim();
|
||||||
@@ -575,9 +588,57 @@ pub async fn update(
|
|||||||
let auto_track = input.auto_track.unwrap_or(current.auto_track);
|
let auto_track = input.auto_track.unwrap_or(current.auto_track);
|
||||||
let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended);
|
let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended);
|
||||||
let blocked = input.blocked.unwrap_or(current.blocked);
|
let blocked = input.blocked.unwrap_or(current.blocked);
|
||||||
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id)
|
// A root change moves the §7.4 title folder with the row (issue #228).
|
||||||
.execute(pool(&state)?)
|
// Disk first, row second: a failed rename leaves the row alone, so the
|
||||||
.await?;
|
// operator sees the title where its files actually are and can retry —
|
||||||
|
// the same ordering `remove_library_files` documents.
|
||||||
|
let relocation = if root_id == current.root_id {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
crate::relocate::relocate_title(
|
||||||
|
&state,
|
||||||
|
crate::relocate::TitleKind::Series,
|
||||||
|
id,
|
||||||
|
current.root_id,
|
||||||
|
root_id,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut transaction = pool(&state)?.begin().await?;
|
||||||
|
let written: Result<(), sqlx::Error> = async {
|
||||||
|
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
if let Some(relocation) = &relocation {
|
||||||
|
relocation.rewrite_rows(&mut transaction).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
let committed = match written {
|
||||||
|
Ok(()) => transaction.commit().await.map_err(ApiError::from),
|
||||||
|
Err(error) => Err(ApiError::from(error)),
|
||||||
|
};
|
||||||
|
if let Err(error) = committed {
|
||||||
|
if let Some(relocation) = &relocation {
|
||||||
|
relocation.undo().await;
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
if relocation
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(crate::relocate::Relocation::moved_files)
|
||||||
|
{
|
||||||
|
crate::relocate::refresh_jellyfin(&state).await;
|
||||||
|
}
|
||||||
|
// A root change swaps the effective policy the same way an overrides
|
||||||
|
// edit does (§5.1), so both re-derive; `relocation` is `Some` exactly
|
||||||
|
// when the root changed.
|
||||||
|
if overrides_changed || relocation.is_some() {
|
||||||
|
crate::reclassify::series(&state, id).await?;
|
||||||
|
}
|
||||||
Ok(Json(load_series(&state, id).await?))
|
Ok(Json(load_series(&state, id).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,7 +745,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
|
|||||||
Ok(metadata) => metadata,
|
Ok(metadata) => metadata,
|
||||||
// Already gone is the state we wanted.
|
// Already gone is the state we wanted.
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||||
};
|
};
|
||||||
let removed = if metadata.is_dir() {
|
let removed = if metadata.is_dir() {
|
||||||
tokio::fs::remove_dir_all(&target).await
|
tokio::fs::remove_dir_all(&target).await
|
||||||
@@ -694,7 +755,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
|
|||||||
match removed {
|
match removed {
|
||||||
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
|
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1030,6 +1091,8 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
|||||||
.fetch_all(pool(state)?)
|
.fetch_all(pool(state)?)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let mut failures = season_import_failures(state, series_id).await?;
|
||||||
|
|
||||||
Ok(seasons
|
Ok(seasons
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|season| Season {
|
.map(|season| Season {
|
||||||
@@ -1038,6 +1101,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
|||||||
number: season.number,
|
number: season.number,
|
||||||
tracked: season.tracked,
|
tracked: season.tracked,
|
||||||
vanished: season.vanished,
|
vanished: season.vanished,
|
||||||
|
import_failure: failures.remove(&season.id),
|
||||||
episodes: episodes
|
episodes: episodes
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|episode| episode.season_id == season.id)
|
.filter(|episode| episode.season_id == season.id)
|
||||||
@@ -1407,7 +1471,7 @@ pub async fn episode_releases(
|
|||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<Json<Vec<Release>>, ApiError> {
|
) -> Result<Json<Vec<Release>>, ApiError> {
|
||||||
let episode = load_episode(&state, id).await?;
|
let episode = load_episode(&state, id).await?;
|
||||||
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
|
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
|
||||||
.fetch_all(pool(&state)?)
|
.fetch_all(pool(&state)?)
|
||||||
.await?;
|
.await?;
|
||||||
let policy = state
|
let policy = state
|
||||||
@@ -1419,7 +1483,9 @@ pub async fn episode_releases(
|
|||||||
.ok_or(ApiError::EpisodeNotFound)?
|
.ok_or(ApiError::EpisodeNotFound)?
|
||||||
.policy;
|
.policy;
|
||||||
let lengths = season_lengths(&state, episode.series_id).await?;
|
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)?;
|
||||||
|
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
|
||||||
Ok(Json(releases))
|
Ok(Json(releases))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1458,6 +1524,19 @@ pub async fn grab_episode(
|
|||||||
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
|
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
|
||||||
/// revealed episodes counts zero, which `claimed_episode_count` treats as
|
/// revealed episodes counts zero, which `claimed_episode_count` treats as
|
||||||
/// unknown.
|
/// 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(
|
pub(crate) async fn season_lengths(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
series_id: i64,
|
series_id: i64,
|
||||||
@@ -1548,7 +1627,7 @@ pub async fn season_releases(
|
|||||||
) -> Result<Json<Vec<Release>>, ApiError> {
|
) -> Result<Json<Vec<Release>>, ApiError> {
|
||||||
load_series_row(&state, series_id).await?;
|
load_series_row(&state, series_id).await?;
|
||||||
let season_id = load_season_id(&state, series_id, number).await?;
|
let season_id = load_season_id(&state, series_id, number).await?;
|
||||||
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id)
|
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule, NULL AS "blacklist_reason?: String" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id)
|
||||||
.fetch_all(pool(&state)?)
|
.fetch_all(pool(&state)?)
|
||||||
.await?;
|
.await?;
|
||||||
let policy = state
|
let policy = state
|
||||||
@@ -1560,10 +1639,94 @@ pub async fn season_releases(
|
|||||||
.ok_or(ApiError::SeasonNotFound)?
|
.ok_or(ApiError::SeasonNotFound)?
|
||||||
.policy;
|
.policy;
|
||||||
let lengths = season_lengths(&state, series_id).await?;
|
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)?;
|
||||||
|
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
|
||||||
Ok(Json(releases))
|
Ok(Json(releases))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A grab for this season that downloaded in full and was then condemned at
|
||||||
|
/// import (#227, §5.7).
|
||||||
|
///
|
||||||
|
/// The torrent stays at 100% in Transmission — §7.3 leaves that lifecycle to
|
||||||
|
/// the reaper — the release is blacklisted and every episode it was covering
|
||||||
|
/// reopens as a gap. Nothing on screen connected the two, so the season read
|
||||||
|
/// `0/10` as though no grab had ever been tried. Every fact is already
|
||||||
|
/// recorded; this is the row that carries them out.
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct ImportFailure {
|
||||||
|
/// The blacklisted release name, as the indexer spelled it.
|
||||||
|
pub release: String,
|
||||||
|
/// What the blacklist recorded it as failing on — a policy rule name the
|
||||||
|
/// operator can relax, or a sentence about the release itself. `None`
|
||||||
|
/// when no blacklist row answers to either key, which is possible after a
|
||||||
|
/// blacklist row is cleared by hand; the failure is still true.
|
||||||
|
pub reason: Option<String>,
|
||||||
|
/// When the grab entered `failed`, RFC3339. `None` on a row that failed
|
||||||
|
/// before migration 0030 gave the column a value.
|
||||||
|
pub failed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent abandoned pack per season of one series (#227).
|
||||||
|
///
|
||||||
|
/// Only seasons still waiting for a file are answered: once the gap is
|
||||||
|
/// filled, the failure is history and the season has nothing to explain. The
|
||||||
|
/// blacklist reason is matched in memory because its name key is normalised,
|
||||||
|
/// which SQL cannot compute.
|
||||||
|
async fn season_import_failures(
|
||||||
|
state: &AppState,
|
||||||
|
series_id: i64,
|
||||||
|
) -> Result<HashMap<i64, ImportFailure>, ApiError> {
|
||||||
|
let pool = pool(state)?;
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"SELECT g.target_id AS "season_id!: i64",
|
||||||
|
r.name AS "name!: String",
|
||||||
|
g.infohash AS "infohash!: String",
|
||||||
|
g.failed_at,
|
||||||
|
g.grabbed_at AS "grabbed_at!: String"
|
||||||
|
FROM grabs g
|
||||||
|
JOIN releases r ON r.id = g.release_id
|
||||||
|
JOIN seasons s ON s.id = g.target_id
|
||||||
|
WHERE g.target_kind = 'season'
|
||||||
|
AND g.state = 'failed'
|
||||||
|
AND s.series_id = ?
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM episodes e
|
||||||
|
WHERE e.season_id = s.id AND e.wanted
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM media_files f
|
||||||
|
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id"#,
|
||||||
|
series_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let blacklist = arr_db::blacklist::Blacklist::load(pool).await?;
|
||||||
|
// Ascending order, so the last row written for a season wins.
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let reason = blacklist
|
||||||
|
.reason_for_infohash(&row.infohash)
|
||||||
|
.or_else(|| blacklist.reason_for_name(&row.name))
|
||||||
|
.map(str::to_owned);
|
||||||
|
(
|
||||||
|
row.season_id,
|
||||||
|
ImportFailure {
|
||||||
|
release: row.name,
|
||||||
|
reason,
|
||||||
|
failed_at: row.failed_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Which lane a season's missing episodes take (#182, §6.2).
|
/// Which lane a season's missing episodes take (#182, §6.2).
|
||||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -1605,6 +1768,11 @@ pub struct SeasonPackState {
|
|||||||
/// When a pack sweep for this season last completed. `None` means no
|
/// When a pack sweep for this season last completed. `None` means no
|
||||||
/// pack search has ever run, so an empty deck is pending, not settled.
|
/// pack search has ever run, so an empty deck is pending, not settled.
|
||||||
pub last_pack_search_at: Option<String>,
|
pub last_pack_search_at: Option<String>,
|
||||||
|
/// #227. The last pack that downloaded in full and was then condemned at
|
||||||
|
/// import, while the season is still waiting for a file. A deck that
|
||||||
|
/// cannot say this leaves the season reading as though nothing was ever
|
||||||
|
/// tried, with the torrent still sitting at 100% in Transmission.
|
||||||
|
pub import_failure: Option<ImportFailure>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -1648,9 +1816,12 @@ pub async fn season_pack_state(
|
|||||||
.await?;
|
.await?;
|
||||||
// The same failed-pack tally the grab lane backs off on (#181), read
|
// The same failed-pack tally the grab lane backs off on (#181), read
|
||||||
// here so the deck can name the window instead of guessing at one.
|
// here so the deck can name the window instead of guessing at one.
|
||||||
|
// `last_failed_at` really is the failure time (#245): `reopens_at` and
|
||||||
|
// `pack_retry_at` below hand it to the deck, so an alias holding a grab
|
||||||
|
// time would put a grab under a name §5.7 gave to something else.
|
||||||
let failed = sqlx::query!(
|
let failed = sqlx::query!(
|
||||||
r#"SELECT count(*) AS "failures!: i64",
|
r#"SELECT count(*) AS "failures!: i64",
|
||||||
max(grabbed_at) AS "last_failed_at?: String"
|
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||||
FROM grabs
|
FROM grabs
|
||||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||||
season_id
|
season_id
|
||||||
@@ -1696,6 +1867,9 @@ pub async fn season_pack_state(
|
|||||||
pack_failures: failed.failures,
|
pack_failures: failed.failures,
|
||||||
pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()),
|
pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()),
|
||||||
last_pack_search_at,
|
last_pack_search_at,
|
||||||
|
import_failure: season_import_failures(&state, series_id)
|
||||||
|
.await?
|
||||||
|
.remove(&season_id),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1915,6 +2089,321 @@ mod tests {
|
|||||||
response.json().await.expect("season json")
|
response.json().await.expect("season json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn seasons_json(base: &str, series_id: i64) -> Vec<serde_json::Value> {
|
||||||
|
reqwest::get(format!("{base}/api/series/{series_id}/seasons"))
|
||||||
|
.await
|
||||||
|
.expect("seasons")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("seasons json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #227, the operator's own report: a pack downloaded in full, §5.7
|
||||||
|
/// condemned it at import, every episode went back to `missing`, and the
|
||||||
|
/// season read `0/10` with nothing anywhere saying a grab had been tried.
|
||||||
|
/// The season row, its deck and the deck's blacklisted release now each
|
||||||
|
/// carry the failure and the reason it failed on.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_abandoned_pack_is_visible_on_the_season_and_its_deck() {
|
||||||
|
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, 8, serde_json::json!(episodes)).await;
|
||||||
|
let season_id = season["id"].as_i64().expect("season id");
|
||||||
|
assert!(
|
||||||
|
season["import_failure"].is_null(),
|
||||||
|
"nothing has been grabbed yet"
|
||||||
|
);
|
||||||
|
// §4.1: tracking the season is what makes its episodes wanted, and a
|
||||||
|
// season with no intent has no gap to explain.
|
||||||
|
let tracked = reqwest::Client::new()
|
||||||
|
.patch(format!("{base}/api/series/{series_id}/seasons/8"))
|
||||||
|
.json(&serde_json::json!({"tracked": true}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("track the season");
|
||||||
|
assert_eq!(tracked.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let pool = state.database().expect("database").pool();
|
||||||
|
let name = "Rick.And.Morty.S08.1080p.WEB-DL.x264-GROUP";
|
||||||
|
let parsed = arr_parse::parse(name);
|
||||||
|
let release_id = sqlx::query_scalar::<_, i64>(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||||
|
VALUES (7, 'pack', ?, 1000, 50, 'url', ?, 0, 'eligible') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.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");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at)
|
||||||
|
VALUES (?, 'season', ?, 'abc123', 'failed', '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z')",
|
||||||
|
)
|
||||||
|
.bind(release_id)
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("failed pack grab");
|
||||||
|
|
||||||
|
// The failure with no blacklist row yet: still a failure, and the
|
||||||
|
// reason is simply not known. Rows written before the blacklist
|
||||||
|
// carried one read this way and must keep rendering.
|
||||||
|
let seasons = seasons_json(&base, series_id).await;
|
||||||
|
let row = &seasons[0]["import_failure"];
|
||||||
|
assert_eq!(row["release"], name);
|
||||||
|
assert_eq!(row["reason"], serde_json::Value::Null);
|
||||||
|
assert_eq!(row["failed_at"], "2026-01-02T00:00:00.000Z");
|
||||||
|
|
||||||
|
arr_db::blacklist::add(pool, Some("ABC123"), name, "size")
|
||||||
|
.await
|
||||||
|
.expect("blacklist");
|
||||||
|
|
||||||
|
let seasons = seasons_json(&base, series_id).await;
|
||||||
|
assert_eq!(seasons[0]["import_failure"]["reason"], "size");
|
||||||
|
|
||||||
|
// The deck the season row leads to says the same thing.
|
||||||
|
let pack_state: serde_json::Value = reqwest::get(format!(
|
||||||
|
"{base}/api/series/{series_id}/seasons/8/pack-state"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("pack state")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("pack state json");
|
||||||
|
assert_eq!(pack_state["import_failure"]["release"], name);
|
||||||
|
assert_eq!(pack_state["import_failure"]["reason"], "size");
|
||||||
|
|
||||||
|
// §6.3: the release itself is rejected in the deck, and now names
|
||||||
|
// what it was blacklisted for — a size rejection the operator can
|
||||||
|
// relax, not a corrupt file they should leave alone.
|
||||||
|
let releases: Vec<serde_json::Value> =
|
||||||
|
reqwest::get(format!("{base}/api/series/{series_id}/seasons/8/releases"))
|
||||||
|
.await
|
||||||
|
.expect("releases")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("releases json");
|
||||||
|
assert_eq!(releases.len(), 1);
|
||||||
|
assert_eq!(releases[0]["blacklist_reason"], "size");
|
||||||
|
|
||||||
|
// Once the gap is filled the failure is history: the season has
|
||||||
|
// nothing left to explain and stops saying it.
|
||||||
|
for episode in seasons[0]["episodes"].as_array().expect("episodes") {
|
||||||
|
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 1)")
|
||||||
|
.bind(episode["id"].as_i64().expect("episode id"))
|
||||||
|
.bind(format!("/library/e{}.mkv", episode["number"]))
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("file on disk");
|
||||||
|
}
|
||||||
|
let seasons = seasons_json(&base, series_id).await;
|
||||||
|
assert!(seasons[0]["import_failure"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
);
|
||||||
|
// #211: the waiver keeps the rule it relaxed, so the deck names it
|
||||||
|
// the way it names a rejection (§9.3) instead of showing a bare
|
||||||
|
// `waived`.
|
||||||
|
assert_eq!(releases[0]["rejected_rule"], "size");
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #241: moving a series to a root with a different policy re-derives
|
||||||
|
/// the stored verdicts of its whole deck (§5.1), season packs included.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn moving_a_series_to_another_root_rederives_its_verdicts() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let main_root = tv_root(&state, "main").await;
|
||||||
|
let kids_root = tv_root(&state, "kids").await;
|
||||||
|
let series = add_series(&base, main_root, false).await;
|
||||||
|
let series_id = series["id"].as_i64().expect("id");
|
||||||
|
let 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 15 GiB: 1.5 GiB each, inside the 1080p band.
|
||||||
|
let pool = state.database().expect("database").pool();
|
||||||
|
let name = "Bluey S09 1080p WEB-DL ENGLISH x264-GROUP";
|
||||||
|
let parsed = arr_parse::parse(name);
|
||||||
|
let size = 15_i64 * (1 << 30);
|
||||||
|
let release_id = sqlx::query_scalar::<_, i64>(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||||
|
VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'eligible') 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!({"root_id": kids_root}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("move root");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
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",
|
||||||
|
"kids requires pt-PT, English is a soft fail: {releases:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
/// §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
|
/// 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
|
/// TVDB id — so a series added today has a poster before tomorrow's
|
||||||
@@ -2640,8 +3129,11 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("release");
|
.expect("release");
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
|
||||||
VALUES (?, 'season', ?, 'hash', 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
grabbed_at, failed_at)
|
||||||
|
VALUES (?, 'season', ?, 'hash', 'failed',
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
||||||
)
|
)
|
||||||
.bind(release_id)
|
.bind(release_id)
|
||||||
.bind(season_id)
|
.bind(season_id)
|
||||||
@@ -2657,6 +3149,26 @@ mod tests {
|
|||||||
"the deck offers a date, not just a closed door"
|
"the deck offers a date, not just a closed door"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// §6.2/#245: push the grab five weeks back and leave the failure
|
||||||
|
// where it is. The deck reads the failure, so the window it names is
|
||||||
|
// unmoved — anchored on the grab it would have expired long ago and
|
||||||
|
// the deck would claim the lane was open.
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE grabs SET grabbed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days')
|
||||||
|
WHERE target_kind = 'season' AND target_id = ?",
|
||||||
|
)
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("age the grab");
|
||||||
|
let stalled = state_of(url.clone()).await;
|
||||||
|
assert_eq!(stalled["lane"], "per_episode");
|
||||||
|
assert_eq!(stalled["reason"], "pack_backoff");
|
||||||
|
assert_eq!(
|
||||||
|
stalled["pack_retry_at"], quiet["pack_retry_at"],
|
||||||
|
"the window is anchored on the failure, so aging the grab moves nothing"
|
||||||
|
);
|
||||||
|
|
||||||
// §14 outranks it: a pack would re-import what is on disk, so
|
// §14 outranks it: a pack would re-import what is on disk, so
|
||||||
// clearing the failure would not earn a pack anyway.
|
// clearing the failure would not earn a pack anyway.
|
||||||
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)")
|
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)")
|
||||||
@@ -3704,6 +4216,119 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Issue #228: changing a series' root renames its §7.4 title folder —
|
||||||
|
/// season subfolders inside it — into the new root, and every episode's
|
||||||
|
/// `media_files` row follows.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn changing_root_moves_the_series_folder_and_its_rows() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let main_root = tv_root(&state, "main").await;
|
||||||
|
let kids_root = tv_root(&state, "kids").await;
|
||||||
|
let series = add_series(&base, main_root, false).await;
|
||||||
|
let series_id = series["id"].as_i64().expect("id");
|
||||||
|
let season = add_season(
|
||||||
|
&base,
|
||||||
|
series_id,
|
||||||
|
1,
|
||||||
|
serde_json::json!([{ "number": 1, "title": "Magic Xylophone" }]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let season_id = season["id"].as_i64().expect("season id");
|
||||||
|
let episode_id: i64 =
|
||||||
|
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
|
||||||
|
.bind(season_id)
|
||||||
|
.fetch_one(state.database().expect("database").pool())
|
||||||
|
.await
|
||||||
|
.expect("episode id");
|
||||||
|
|
||||||
|
let source = tempfile::tempdir().expect("source root");
|
||||||
|
let destination = tempfile::tempdir().expect("destination root");
|
||||||
|
let folder = source.path().join("Bluey (2018) [tmdbid-82728]");
|
||||||
|
let episode_file = folder.join("Season 01").join("Bluey (2018) - S01E01.mkv");
|
||||||
|
tokio::fs::create_dir_all(folder.join("Season 01"))
|
||||||
|
.await
|
||||||
|
.expect("create season folder");
|
||||||
|
tokio::fs::write(&episode_file, b"episode")
|
||||||
|
.await
|
||||||
|
.expect("write episode");
|
||||||
|
let pool = state.database().expect("database").pool();
|
||||||
|
for (root, path) in [(main_root, source.path()), (kids_root, destination.path())] {
|
||||||
|
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||||
|
.bind(path.to_str().expect("utf-8 root"))
|
||||||
|
.bind(root)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("point the root at the tempdir");
|
||||||
|
}
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)",
|
||||||
|
)
|
||||||
|
.bind(episode_id)
|
||||||
|
.bind(episode_file.to_str().expect("utf-8 path"))
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.expect("media file");
|
||||||
|
|
||||||
|
// The wrong kind of root is still rejected, before anything moves.
|
||||||
|
let rejected = reqwest::Client::new()
|
||||||
|
.patch(format!("{base}/api/series/{series_id}"))
|
||||||
|
.json(&serde_json::json!({"root_id": 1}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("move to a movie root");
|
||||||
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
let updated: serde_json::Value = reqwest::Client::new()
|
||||||
|
.patch(format!("{base}/api/series/{series_id}"))
|
||||||
|
.json(&serde_json::json!({"root_id": kids_root}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("move root")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("updated json");
|
||||||
|
assert_eq!(updated["root_id"], kids_root);
|
||||||
|
|
||||||
|
assert!(!folder.exists(), "the folder left the old root");
|
||||||
|
let moved = destination
|
||||||
|
.path()
|
||||||
|
.join("Bluey (2018) [tmdbid-82728]")
|
||||||
|
.join("Season 01")
|
||||||
|
.join("Bluey (2018) - S01E01.mkv");
|
||||||
|
assert!(moved.exists(), "the episode arrived, season folder intact");
|
||||||
|
|
||||||
|
let path: String = sqlx::query_scalar(
|
||||||
|
"SELECT path FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
|
||||||
|
)
|
||||||
|
.bind(episode_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.expect("media file row");
|
||||||
|
assert_eq!(path, moved.to_str().expect("utf-8 path").to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #228: a series with nothing on disk changes root with no
|
||||||
|
/// filesystem work at all.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_series_with_no_files_changes_root_cleanly() {
|
||||||
|
let (_dir, state, base) = application().await;
|
||||||
|
let main_root = tv_root(&state, "main").await;
|
||||||
|
let kids_root = tv_root(&state, "kids").await;
|
||||||
|
let series = add_series(&base, main_root, false).await;
|
||||||
|
let series_id = series["id"].as_i64().expect("id");
|
||||||
|
|
||||||
|
let updated: serde_json::Value = reqwest::Client::new()
|
||||||
|
.patch(format!("{base}/api/series/{series_id}"))
|
||||||
|
.json(&serde_json::json!({"root_id": kids_root}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("move root")
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.expect("updated json");
|
||||||
|
assert_eq!(updated["root_id"], kids_root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn air_dates_parse_as_dates_and_as_timestamps() {
|
fn air_dates_parse_as_dates_and_as_timestamps() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -242,6 +242,14 @@ pub struct Policy {
|
|||||||
pub struct TitleOverrides {
|
pub struct TitleOverrides {
|
||||||
pub only_4k: bool,
|
pub only_4k: bool,
|
||||||
pub allow_english_audio: 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)]
|
#[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
|
/// describes one episode, so the size rule divides by this. One for a
|
||||||
/// movie or an unknown count; zero is treated as one.
|
/// movie or an unknown count; zero is treated as one.
|
||||||
pub episode_count: u32,
|
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.
|
/// A rule's identity when no violation exists to carry concrete evidence.
|
||||||
@@ -112,6 +117,7 @@ pub fn evaluate(
|
|||||||
candidate: Candidate<'_>,
|
candidate: Candidate<'_>,
|
||||||
size_bytes: Option<u64>,
|
size_bytes: Option<u64>,
|
||||||
episode_count: u32,
|
episode_count: u32,
|
||||||
|
runtime_minutes: u32,
|
||||||
) -> Evaluation {
|
) -> Evaluation {
|
||||||
let context = EvaluationContext {
|
let context = EvaluationContext {
|
||||||
policy,
|
policy,
|
||||||
@@ -120,6 +126,7 @@ pub fn evaluate(
|
|||||||
candidate,
|
candidate,
|
||||||
size_bytes,
|
size_bytes,
|
||||||
episode_count,
|
episode_count,
|
||||||
|
runtime_minutes,
|
||||||
};
|
};
|
||||||
let rules: [&dyn PolicyRule; 6] = [
|
let rules: [&dyn PolicyRule; 6] = [
|
||||||
&ResolutionRule,
|
&ResolutionRule,
|
||||||
@@ -224,6 +231,12 @@ impl PolicyRule for SourceRule {
|
|||||||
/// "smaller is better" selects a 3 GB 4K encode that looks like mud. How far
|
/// "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,
|
/// a candidate sits from the band's *target* is [`crate::score`]'s question,
|
||||||
/// not this rule's.
|
/// 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)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
pub struct SizeRule;
|
pub struct SizeRule;
|
||||||
|
|
||||||
@@ -233,9 +246,17 @@ impl PolicyRule for SizeRule {
|
|||||||
else {
|
else {
|
||||||
return RuleEvaluation::Unknown(RuleKind::Size);
|
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),
|
None => RuleEvaluation::Unknown(RuleKind::Size),
|
||||||
|
Some(true) if context.overrides.allow_below_floor => {
|
||||||
|
RuleEvaluation::SoftFail(Rule::Size)
|
||||||
|
}
|
||||||
Some(true) => RuleEvaluation::HardFail(Rule::Size),
|
Some(true) => RuleEvaluation::HardFail(Rule::Size),
|
||||||
Some(false) => RuleEvaluation::Pass(RuleKind::Size),
|
Some(false) => RuleEvaluation::Pass(RuleKind::Size),
|
||||||
}
|
}
|
||||||
@@ -522,7 +543,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
|
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(
|
fn verdict_for(
|
||||||
@@ -537,6 +558,7 @@ mod tests {
|
|||||||
candidate,
|
candidate,
|
||||||
None,
|
None,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.verdict
|
.verdict
|
||||||
}
|
}
|
||||||
@@ -552,6 +574,7 @@ mod tests {
|
|||||||
Candidate::PreGrab(&claims),
|
Candidate::PreGrab(&claims),
|
||||||
None,
|
None,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(report.verdict, Verdict::Eligible);
|
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]
|
#[test]
|
||||||
fn every_unsafe_source_hard_fails_in_both_phases() {
|
fn every_unsafe_source_hard_fails_in_both_phases() {
|
||||||
let policy = policy();
|
let policy = policy();
|
||||||
@@ -735,6 +931,7 @@ mod tests {
|
|||||||
Candidate::PostDownload(&media),
|
Candidate::PostDownload(&media),
|
||||||
None,
|
None,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(report.verdict, Verdict::Eligible);
|
assert_eq!(report.verdict, Verdict::Eligible);
|
||||||
@@ -792,6 +989,7 @@ mod tests {
|
|||||||
Candidate::PostDownload(&media),
|
Candidate::PostDownload(&media),
|
||||||
None,
|
None,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
let expected = if rejected {
|
let expected = if rejected {
|
||||||
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
|
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
|
||||||
@@ -812,6 +1010,7 @@ mod tests {
|
|||||||
Candidate::PreGrab(&claims),
|
Candidate::PreGrab(&claims),
|
||||||
None,
|
None,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(report.verdict, Verdict::Eligible);
|
assert_eq!(report.verdict, Verdict::Eligible);
|
||||||
@@ -845,6 +1044,7 @@ mod tests {
|
|||||||
candidate: Candidate::PreGrab(&claims),
|
candidate: Candidate::PreGrab(&claims),
|
||||||
size_bytes: None,
|
size_bytes: None,
|
||||||
episode_count: 1,
|
episode_count: 1,
|
||||||
|
runtime_minutes: 0,
|
||||||
};
|
};
|
||||||
let soft = FixedRule {
|
let soft = FixedRule {
|
||||||
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
|
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
|
||||||
@@ -874,6 +1074,7 @@ mod tests {
|
|||||||
candidate: Candidate::PreGrab(&claims),
|
candidate: Candidate::PreGrab(&claims),
|
||||||
size_bytes: None,
|
size_bytes: None,
|
||||||
episode_count: 1,
|
episode_count: 1,
|
||||||
|
runtime_minutes: 0,
|
||||||
};
|
};
|
||||||
let first = FixedRule {
|
let first = FixedRule {
|
||||||
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
|
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
|
||||||
@@ -1010,7 +1211,8 @@ mod tests {
|
|||||||
&en(),
|
&en(),
|
||||||
Candidate::PreGrab(&claims),
|
Candidate::PreGrab(&claims),
|
||||||
None,
|
None,
|
||||||
1
|
1,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.verdict,
|
.verdict,
|
||||||
Verdict::Eligible
|
Verdict::Eligible
|
||||||
@@ -1024,7 +1226,8 @@ mod tests {
|
|||||||
&en(),
|
&en(),
|
||||||
Candidate::PostDownload(&media),
|
Candidate::PostDownload(&media),
|
||||||
None,
|
None,
|
||||||
1
|
1,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.verdict,
|
.verdict,
|
||||||
Verdict::Waived(Rule::RequiredAudio)
|
Verdict::Waived(Rule::RequiredAudio)
|
||||||
@@ -1102,6 +1305,7 @@ mod tests {
|
|||||||
Candidate::PostDownload(&media),
|
Candidate::PostDownload(&media),
|
||||||
None,
|
None,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||||
|
|
||||||
@@ -1114,6 +1318,7 @@ mod tests {
|
|||||||
Candidate::PostDownload(&media),
|
Candidate::PostDownload(&media),
|
||||||
None,
|
None,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||||
}
|
}
|
||||||
@@ -1128,6 +1333,7 @@ mod tests {
|
|||||||
Candidate::PreGrab(&claims),
|
Candidate::PreGrab(&claims),
|
||||||
None,
|
None,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
assert_eq!(report.verdict, Verdict::Eligible);
|
assert_eq!(report.verdict, Verdict::Eligible);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+193
-22
@@ -27,6 +27,11 @@ use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source};
|
|||||||
|
|
||||||
const BYTES_PER_GIB: i64 = 1 << 30;
|
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
|
/// How much each scoring term is worth. Policy data, not constants in the
|
||||||
/// code, for the same reason the size bands are.
|
/// code, for the same reason the size bands are.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[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,
|
/// 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
|
/// 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.
|
/// 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]
|
#[must_use]
|
||||||
pub fn score(
|
pub fn score(
|
||||||
policy: &Policy,
|
policy: &Policy,
|
||||||
@@ -101,13 +113,16 @@ pub fn score(
|
|||||||
size_bytes: u64,
|
size_bytes: u64,
|
||||||
seeders: u32,
|
seeders: u32,
|
||||||
episode_count: u32,
|
episode_count: u32,
|
||||||
|
runtime_minutes: u32,
|
||||||
) -> Score {
|
) -> Score {
|
||||||
let weights = &policy.score_weights;
|
let weights = &policy.score_weights;
|
||||||
let claimed = candidate.resolution();
|
let claimed = candidate.resolution();
|
||||||
let per_episode = per_episode_size(size_bytes, episode_count);
|
let per_episode = per_episode_size(size_bytes, episode_count);
|
||||||
let size = claimed
|
let size = claimed
|
||||||
.and_then(|resolution| policy.size_bands.get(&resolution))
|
.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
|
let source = candidate
|
||||||
.source()
|
.source()
|
||||||
.map_or(0, |source| source_points(policy, 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).
|
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
|
||||||
#[must_use]
|
#[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(
|
score(
|
||||||
policy,
|
policy,
|
||||||
Candidate::PreGrab(&release.parsed),
|
Candidate::PreGrab(&release.parsed),
|
||||||
release.size,
|
release.size,
|
||||||
release.seeders,
|
release.seeders,
|
||||||
episode_count,
|
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
|
/// (`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.
|
/// 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
|
/// `None` when the policy carries no band for that resolution: no band is no
|
||||||
/// opinion, not a rejection.
|
/// opinion, not a rejection.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -154,11 +178,11 @@ pub fn is_below_floor(
|
|||||||
resolution: Resolution,
|
resolution: Resolution,
|
||||||
size_bytes: u64,
|
size_bytes: u64,
|
||||||
episode_count: u32,
|
episode_count: u32,
|
||||||
|
runtime_minutes: u32,
|
||||||
) -> Option<bool> {
|
) -> Option<bool> {
|
||||||
policy
|
policy.size_bands.get(&resolution).map(|band| {
|
||||||
.size_bands
|
per_episode_size(size_bytes, episode_count) < scaled_band(band, runtime_minutes).floor_bytes
|
||||||
.get(&resolution)
|
})
|
||||||
.map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
|
/// 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))
|
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
|
/// The size term: a ramp from the floor up to the target, then a penalty that
|
||||||
/// grows with every gigabyte above it.
|
/// grows with every gigabyte above it.
|
||||||
fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 {
|
fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 {
|
||||||
@@ -329,6 +373,7 @@ mod tests {
|
|||||||
size_bytes,
|
size_bytes,
|
||||||
seeders,
|
seeders,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +395,7 @@ mod tests {
|
|||||||
size_bytes,
|
size_bytes,
|
||||||
seeders,
|
seeders,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +404,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation {
|
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 policy = policy();
|
||||||
let overrides = TitleOverrides::default();
|
let overrides = TitleOverrides::default();
|
||||||
let language = Language::Other("en".to_owned());
|
let language = Language::Other("en".to_owned());
|
||||||
@@ -369,6 +419,7 @@ mod tests {
|
|||||||
candidate: Candidate::PreGrab(&claims),
|
candidate: Candidate::PreGrab(&claims),
|
||||||
size_bytes: Some(size_bytes),
|
size_bytes: Some(size_bytes),
|
||||||
episode_count,
|
episode_count,
|
||||||
|
runtime_minutes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,6 +451,7 @@ mod tests {
|
|||||||
Candidate::PreGrab(&claims),
|
Candidate::PreGrab(&claims),
|
||||||
Some(gib(60)),
|
Some(gib(60)),
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// A bad score, but a score: nothing filters it out, so a selection
|
// 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_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size));
|
||||||
assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_)));
|
assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1),
|
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0),
|
||||||
Some(true)
|
Some(true)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
is_below_floor(&policy(), Resolution::R720p, gib(3), 1),
|
is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -481,7 +533,7 @@ mod tests {
|
|||||||
source: Some(ClaimedSource::WebDl),
|
source: Some(ClaimedSource::WebDl),
|
||||||
..NameClaims::default()
|
..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.size, 0);
|
||||||
assert_eq!(score.total, score.source + score.seeders);
|
assert_eq!(score.total, score.source + score.seeders);
|
||||||
@@ -499,8 +551,8 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
let claims = claims(ClaimedSource::WebDl);
|
let claims = claims(ClaimedSource::WebDl);
|
||||||
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 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);
|
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!(at.size, i64::from(ScoreWeights::default().size_at_target));
|
||||||
assert_eq!(under.size, 0);
|
assert_eq!(under.size, 0);
|
||||||
@@ -548,7 +600,7 @@ mod tests {
|
|||||||
resolution: Some(resolution),
|
resolution: Some(resolution),
|
||||||
..NameClaims::default()
|
..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);
|
assert_eq!(at(ClaimedResolution::P2160), 2 * step);
|
||||||
@@ -565,7 +617,7 @@ mod tests {
|
|||||||
source: Some(ClaimedSource::WebDl),
|
source: Some(ClaimedSource::WebDl),
|
||||||
..NameClaims::default()
|
..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!(unranked.resolution, 0);
|
||||||
assert_eq!(unclaimed.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() {
|
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 episode = scored(ClaimedSource::WebDl, gib(22), 10);
|
||||||
let claims = claims(ClaimedSource::WebDl);
|
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.size, episode.size);
|
||||||
assert_eq!(pack.total, episode.total);
|
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
|
// 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.
|
// — a pack of mud-quality encodes fails as plainly as one of them.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10),
|
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10, 0),
|
||||||
Some(true)
|
Some(true)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -638,22 +690,141 @@ mod tests {
|
|||||||
|
|
||||||
// The same total over three episodes is 10 GiB each and passes.
|
// The same total over three episodes is 10 GiB each and passes.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3),
|
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3, 0),
|
||||||
Some(false)
|
Some(false)
|
||||||
);
|
);
|
||||||
assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_)));
|
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]
|
#[test]
|
||||||
fn an_unknown_episode_count_falls_back_to_one_episode() {
|
fn an_unknown_episode_count_falls_back_to_one_episode() {
|
||||||
let single = scored(ClaimedSource::WebDl, gib(22), 10);
|
let single = scored(ClaimedSource::WebDl, gib(22), 10);
|
||||||
let claims = claims(ClaimedSource::WebDl);
|
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!(zero, single);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0),
|
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0),
|
||||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1)
|
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,18 +904,18 @@ mod tests {
|
|||||||
source: Some(ClaimedSource::WebDl),
|
source: Some(ClaimedSource::WebDl),
|
||||||
..NameClaims::default()
|
..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 hd = scored(ClaimedResolution::P1080, hd_pack);
|
||||||
let uhd = scored(ClaimedResolution::P2160, uhd_pack);
|
let uhd = scored(ClaimedResolution::P2160, uhd_pack);
|
||||||
|
|
||||||
// Neither pack trips the floor per episode, so the ranking decides.
|
// Neither pack trips the floor per episode, so the ranking decides.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes),
|
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes, 0),
|
||||||
Some(false)
|
Some(false)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes),
|
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0),
|
||||||
Some(false)
|
Some(false)
|
||||||
);
|
);
|
||||||
assert!(uhd.total > hd.total);
|
assert!(uhd.total > hd.total);
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
//! the no-PT-source queue, or hard-failed twice on different releases (the
|
//! the no-PT-source queue, or hard-failed twice on different releases (the
|
||||||
//! same queues `GET /api/queues/attention` reports, §9.3).
|
//! same queues `GET /api/queues/attention` reports, §9.3).
|
||||||
//!
|
//!
|
||||||
|
//! §5.7 sets the bar for the hard-fail side: two failures on *different*
|
||||||
|
//! releases, both inside `arr_db::ATTENTION_WINDOW`, against a target still
|
||||||
|
//! waiting for a file. One bad torrent is not a decision, a failure already
|
||||||
|
//! dealt with ages out (#226), and a target that has since been acquired
|
||||||
|
//! leaves at once (#238). The season lane reads that last condition off its
|
||||||
|
//! episodes, which is where intent lives (§4.1). `GET /api/queues/attention`
|
||||||
|
//! filters identically, or the two channels tell the operator different
|
||||||
|
//! stories.
|
||||||
|
//!
|
||||||
//! Edge-triggered per title: it notifies once when the title enters either
|
//! Edge-triggered per title: it notifies once when the title enters either
|
||||||
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
|
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
|
||||||
//! again. A series notifies as its series, never per episode — a broken
|
//! again. A series notifies as its series, never per episode — a broken
|
||||||
@@ -34,8 +43,8 @@ struct TvEntry {
|
|||||||
no_pt_source: Vec<i64>,
|
no_pt_source: Vec<i64>,
|
||||||
/// Episodes two different releases hard-failed post-probe (§5.7).
|
/// Episodes two different releases hard-failed post-probe (§5.7).
|
||||||
hard_failed_episodes: Vec<i64>,
|
hard_failed_episodes: Vec<i64>,
|
||||||
/// Seasons whose pack grab hard-failed, sending the season back to
|
/// Seasons two different pack releases hard-failed on (§5.7), sending the
|
||||||
/// per-episode grabbing.
|
/// season back to per-episode grabbing.
|
||||||
failed_season_packs: Vec<i64>,
|
failed_season_packs: Vec<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +68,8 @@ impl TvEntry {
|
|||||||
if !self.failed_season_packs.is_empty() {
|
if !self.failed_season_packs.is_empty() {
|
||||||
parts.push(plural(
|
parts.push(plural(
|
||||||
self.failed_season_packs.len(),
|
self.failed_season_packs.len(),
|
||||||
"season pack hard-failed",
|
"season hard-failed twice on different packs",
|
||||||
"season packs hard-failed",
|
"seasons hard-failed twice on different packs",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
parts.join("; ")
|
parts.join("; ")
|
||||||
@@ -134,8 +143,10 @@ impl AttentionAction {
|
|||||||
AND (SELECT count(DISTINCT g.release_id)
|
AND (SELECT count(DISTINCT g.release_id)
|
||||||
FROM grabs g
|
FROM grabs g
|
||||||
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
|
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
|
||||||
AND g.state = 'failed') >= 2
|
AND g.state = 'failed'
|
||||||
"#
|
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2
|
||||||
|
"#,
|
||||||
|
arr_db::ATTENTION_WINDOW
|
||||||
)
|
)
|
||||||
.fetch_all(database.pool())
|
.fetch_all(database.pool())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -209,9 +220,14 @@ fn tv_entry(
|
|||||||
|
|
||||||
/// TV roll-up (§9.5): every queued series with what put it there — wanted
|
/// TV roll-up (§9.5): every queued series with what put it there — wanted
|
||||||
/// episodes whose every candidate was rejected for language, episodes two
|
/// episodes whose every candidate was rejected for language, episodes two
|
||||||
/// different releases hard-failed post-probe, and seasons whose pack grab
|
/// different releases hard-failed post-probe, and seasons two different packs
|
||||||
/// hard-failed. One entry per series, so the notification can be one per
|
/// hard-failed on. One entry per series, so the notification can be one per
|
||||||
/// series however long the broken season is.
|
/// series however long the broken season is.
|
||||||
|
///
|
||||||
|
/// Both hard-fail lanes carry §5.7's liveness condition: an episode is queued
|
||||||
|
/// only while `wanted` and not `available`, and a season only while at least
|
||||||
|
/// one of its episodes is. A season pack that failed twice and then fell back
|
||||||
|
/// to per-episode grabbing (§6.2) drops out as those episodes land.
|
||||||
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
|
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
|
||||||
let mut tv = HashMap::new();
|
let mut tv = HashMap::new();
|
||||||
|
|
||||||
@@ -250,10 +266,12 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
|||||||
JOIN seasons se ON se.id = e.season_id
|
JOIN seasons se ON se.id = e.season_id
|
||||||
JOIN series s ON s.id = se.series_id
|
JOIN series s ON s.id = se.series_id
|
||||||
WHERE g.state = 'failed'
|
WHERE g.state = 'failed'
|
||||||
|
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||||
AND e.wanted = 1 AND e.state != 'available'
|
AND e.wanted = 1 AND e.state != 'available'
|
||||||
GROUP BY s.id, s.title, s.year, e.id
|
GROUP BY s.id, s.title, s.year, e.id
|
||||||
HAVING count(DISTINCT g.release_id) >= 2
|
HAVING count(DISTINCT g.release_id) >= 2
|
||||||
"#
|
"#,
|
||||||
|
arr_db::ATTENTION_WINDOW
|
||||||
)
|
)
|
||||||
.fetch_all(database.pool())
|
.fetch_all(database.pool())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -271,8 +289,16 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
|||||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||||
JOIN series s ON s.id = se.series_id
|
JOIN series s ON s.id = se.series_id
|
||||||
WHERE g.state = 'failed'
|
WHERE g.state = 'failed'
|
||||||
|
AND g.failed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM episodes e
|
||||||
|
WHERE e.season_id = se.id
|
||||||
|
AND e.wanted = 1 AND e.state != 'available'
|
||||||
|
)
|
||||||
GROUP BY s.id, s.title, s.year, se.id
|
GROUP BY s.id, s.title, s.year, se.id
|
||||||
"#
|
HAVING count(DISTINCT g.release_id) >= 2
|
||||||
|
"#,
|
||||||
|
arr_db::ATTENTION_WINDOW
|
||||||
)
|
)
|
||||||
.fetch_all(database.pool())
|
.fetch_all(database.pool())
|
||||||
.await?;
|
.await?;
|
||||||
@@ -382,13 +408,32 @@ mod tests {
|
|||||||
series_id
|
series_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A wanted, missing episode: the least that keeps its season live for
|
||||||
|
/// §5.7's liveness condition.
|
||||||
|
async fn insert_wanted_episode(database: &Db, season_id: i64, number: i64) -> i64 {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"INSERT INTO episodes (season_id, number, title, wanted, state)
|
||||||
|
VALUES (?, ?, ?, 1, 'missing') RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(season_id)
|
||||||
|
.bind(number)
|
||||||
|
.bind(format!("Episode {number}"))
|
||||||
|
.fetch_one(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||||
/// standing in for what the import tick leaves behind post-probe.
|
/// grabbed `grab_age_days` ago and failed `fail_age_days` ago, so §5.7's
|
||||||
async fn insert_failed_grab(
|
/// window — which runs from the failure — can be exercised without
|
||||||
|
/// waiting a month.
|
||||||
|
async fn insert_dated_failed_grab(
|
||||||
database: &Db,
|
database: &Db,
|
||||||
target_kind: &str,
|
target_kind: &str,
|
||||||
target_id: i64,
|
target_id: i64,
|
||||||
release_guid: &str,
|
release_guid: &str,
|
||||||
|
grab_age_days: i64,
|
||||||
|
fail_age_days: i64,
|
||||||
) {
|
) {
|
||||||
let release_id: i64 = sqlx::query_scalar(
|
let release_id: i64 = sqlx::query_scalar(
|
||||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||||
@@ -400,18 +445,53 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at)
|
||||||
VALUES (?, ?, ?, ?, 'failed')",
|
VALUES (?, ?, ?, ?, 'failed',
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||||
)
|
)
|
||||||
.bind(release_id)
|
.bind(release_id)
|
||||||
.bind(target_kind)
|
.bind(target_kind)
|
||||||
.bind(target_id)
|
.bind(target_id)
|
||||||
.bind(format!("hash-{release_guid}"))
|
.bind(format!("hash-{release_guid}"))
|
||||||
|
.bind(format!("-{grab_age_days} days"))
|
||||||
|
.bind(format!("-{fail_age_days} days"))
|
||||||
.execute(database.pool())
|
.execute(database.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||||
|
/// failed `age_days` in the past.
|
||||||
|
async fn insert_aged_failed_grab(
|
||||||
|
database: &Db,
|
||||||
|
target_kind: &str,
|
||||||
|
target_id: i64,
|
||||||
|
release_guid: &str,
|
||||||
|
age_days: i64,
|
||||||
|
) {
|
||||||
|
insert_dated_failed_grab(
|
||||||
|
database,
|
||||||
|
target_kind,
|
||||||
|
target_id,
|
||||||
|
release_guid,
|
||||||
|
age_days,
|
||||||
|
age_days,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||||
|
/// standing in for what the import tick leaves behind post-probe.
|
||||||
|
async fn insert_failed_grab(
|
||||||
|
database: &Db,
|
||||||
|
target_kind: &str,
|
||||||
|
target_id: i64,
|
||||||
|
release_guid: &str,
|
||||||
|
) {
|
||||||
|
insert_dated_failed_grab(database, target_kind, target_id, release_guid, 0, 0).await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn action(server: &MockServer) -> AttentionAction {
|
async fn action(server: &MockServer) -> AttentionAction {
|
||||||
Mock::given(method("POST"))
|
Mock::given(method("POST"))
|
||||||
.respond_with(ResponseTemplate::new(200))
|
.respond_with(ResponseTemplate::new(200))
|
||||||
@@ -548,9 +628,10 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
insert_failed_grab(&database, "episode", episode_id, "first").await;
|
insert_failed_grab(&database, "episode", episode_id, "first").await;
|
||||||
insert_failed_grab(&database, "episode", episode_id, "second").await;
|
insert_failed_grab(&database, "episode", episode_id, "second").await;
|
||||||
// The pack's failure sent this season back to per-episode grabbing;
|
// The packs' failures sent this season back to per-episode grabbing;
|
||||||
// it queues the same series, so it must not double the message.
|
// they queue the same series, so it must not double the message.
|
||||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||||
|
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||||
|
|
||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
let action = action(&server).await;
|
let action = action(&server).await;
|
||||||
@@ -631,4 +712,158 @@ mod tests {
|
|||||||
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
||||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §5.7: the season lane holds to the same two-distinct-releases bar the
|
||||||
|
/// episode lane does, so one bad pack does not notify (#226).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_failed_season_pack_does_not_notify() {
|
||||||
|
let (_dir, database) = seeded_database().await;
|
||||||
|
insert_no_pt_source_series(&database, 1, 0).await;
|
||||||
|
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||||
|
.fetch_one(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_wanted_episode(&database, season_id, 1).await;
|
||||||
|
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||||
|
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
let action = action(&server).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
action.tick(&database).await.unwrap().len(),
|
||||||
|
0,
|
||||||
|
"one failed pack is the blacklist working, not a decision"
|
||||||
|
);
|
||||||
|
|
||||||
|
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
action.tick(&database).await.unwrap().len(),
|
||||||
|
1,
|
||||||
|
"two distinct packs hard-failed: the operator decides"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5.7: the queue only holds targets still waiting for a file. A season
|
||||||
|
/// whose packs both hard-failed falls back to per-episode grabbing (§6.2);
|
||||||
|
/// once every episode has landed the system worked, so the season leaves
|
||||||
|
/// the queue at once rather than notifying for 30 days (#238).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_fully_acquired_season_leaves_the_queue() {
|
||||||
|
let (_dir, database) = seeded_database().await;
|
||||||
|
insert_no_pt_source_series(&database, 1, 0).await;
|
||||||
|
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||||
|
.fetch_one(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_wanted_episode(&database, season_id, 1).await;
|
||||||
|
insert_wanted_episode(&database, season_id, 2).await;
|
||||||
|
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||||
|
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||||
|
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
let action = action(&server).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
action.tick(&database).await.unwrap().len(),
|
||||||
|
1,
|
||||||
|
"two packs failed and the season still has episodes missing"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-episode grabbing got the first one. Still a gap, still queued.
|
||||||
|
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ? AND number = 1")
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
action.tick(&database).await.unwrap().len(),
|
||||||
|
0,
|
||||||
|
"already notified, and still queued"
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query("UPDATE episodes SET state = 'available' WHERE season_id = ?")
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
action.tick(&database).await.unwrap().len(),
|
||||||
|
0,
|
||||||
|
"every episode acquired: nothing left to decide"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Proof it actually left rather than merely staying quiet: a season
|
||||||
|
// still queued would not notify again on re-entry.
|
||||||
|
sqlx::query("UPDATE episodes SET state = 'missing' WHERE season_id = ? AND number = 2")
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
action.tick(&database).await.unwrap().len(),
|
||||||
|
1,
|
||||||
|
"broken again: re-enters the queue and notifies"
|
||||||
|
);
|
||||||
|
assert_eq!(server.received_requests().await.unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5.7: a failure counts for 30 days, so a season dealt with leaves the
|
||||||
|
/// queue instead of sitting in it forever (#226).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn season_failures_older_than_the_window_do_not_notify() {
|
||||||
|
let (_dir, database) = seeded_database().await;
|
||||||
|
insert_no_pt_source_series(&database, 1, 0).await;
|
||||||
|
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||||
|
.fetch_one(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_wanted_episode(&database, season_id, 1).await;
|
||||||
|
insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await;
|
||||||
|
insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await;
|
||||||
|
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
let action = action(&server).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
action.tick(&database).await.unwrap().len(),
|
||||||
|
0,
|
||||||
|
"failures older than the window are history, not attention"
|
||||||
|
);
|
||||||
|
|
||||||
|
insert_aged_failed_grab(&database, "season", season_id, "new-one", 0).await;
|
||||||
|
insert_aged_failed_grab(&database, "season", season_id, "new-two", 0).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
action.tick(&database).await.unwrap().len(),
|
||||||
|
1,
|
||||||
|
"still breaking: back in the queue"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §5.7/#239: the window runs from the failure, not the grab. A torrent
|
||||||
|
/// that stalls on a slow swarm for five weeks and then hard-fails at
|
||||||
|
/// import is fresh evidence the target is broken, however old the grab.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_grab_stalled_past_the_window_before_failing_still_counts() {
|
||||||
|
let (_dir, database) = seeded_database().await;
|
||||||
|
insert_no_pt_source_series(&database, 1, 0).await;
|
||||||
|
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||||
|
.fetch_one(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
insert_wanted_episode(&database, season_id, 1).await;
|
||||||
|
// Both grabbed 40 days ago — outside the window — but failed today.
|
||||||
|
insert_dated_failed_grab(&database, "season", season_id, "stalled-one", 40, 0).await;
|
||||||
|
insert_dated_failed_grab(&database, "season", season_id, "stalled-two", 40, 0).await;
|
||||||
|
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
let action = action(&server).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
action.tick(&database).await.unwrap().len(),
|
||||||
|
1,
|
||||||
|
"grab age is irrelevant: two fresh failures queue the target"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1192,6 +1192,7 @@ pub(crate) async fn store_release(
|
|||||||
original_language: &Language,
|
original_language: &Language,
|
||||||
blacklist: &Blacklist,
|
blacklist: &Blacklist,
|
||||||
) -> Result<Option<Eligible>, GrabError> {
|
) -> 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(
|
let (release_id, eligible) = classify_and_store(
|
||||||
database,
|
database,
|
||||||
release,
|
release,
|
||||||
@@ -1200,6 +1201,7 @@ pub(crate) async fn store_release(
|
|||||||
original_language,
|
original_language,
|
||||||
blacklist,
|
blacklist,
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
@@ -1241,6 +1243,11 @@ pub(crate) async fn store_episode_release(
|
|||||||
_ => BTreeMap::new(),
|
_ => BTreeMap::new(),
|
||||||
};
|
};
|
||||||
let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths);
|
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(
|
let (release_id, eligible) = classify_and_store(
|
||||||
database,
|
database,
|
||||||
release,
|
release,
|
||||||
@@ -1249,6 +1256,7 @@ pub(crate) async fn store_episode_release(
|
|||||||
original_language,
|
original_language,
|
||||||
blacklist,
|
blacklist,
|
||||||
episode_count,
|
episode_count,
|
||||||
|
runtime_minutes,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
for episode_id in episode_ids {
|
for episode_id in episode_ids {
|
||||||
@@ -1293,6 +1301,26 @@ async fn season_lengths_of(
|
|||||||
.collect())
|
.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(
|
async fn classify_and_store(
|
||||||
database: &Db,
|
database: &Db,
|
||||||
release: &SearchRelease,
|
release: &SearchRelease,
|
||||||
@@ -1301,6 +1329,7 @@ async fn classify_and_store(
|
|||||||
original_language: &Language,
|
original_language: &Language,
|
||||||
blacklist: &Blacklist,
|
blacklist: &Blacklist,
|
||||||
episode_count: u32,
|
episode_count: u32,
|
||||||
|
runtime_minutes: u32,
|
||||||
) -> Result<(i64, Option<Eligible>), GrabError> {
|
) -> Result<(i64, Option<Eligible>), GrabError> {
|
||||||
let parsed = arr_parse::parse(&release.name);
|
let parsed = arr_parse::parse(&release.name);
|
||||||
let evaluation = evaluate(
|
let evaluation = evaluate(
|
||||||
@@ -1310,6 +1339,7 @@ async fn classify_and_store(
|
|||||||
Candidate::PreGrab(&parsed),
|
Candidate::PreGrab(&parsed),
|
||||||
release.size,
|
release.size,
|
||||||
episode_count,
|
episode_count,
|
||||||
|
runtime_minutes,
|
||||||
);
|
);
|
||||||
let scored = score(
|
let scored = score(
|
||||||
policy,
|
policy,
|
||||||
@@ -1317,6 +1347,7 @@ async fn classify_and_store(
|
|||||||
release.size.unwrap_or_default(),
|
release.size.unwrap_or_default(),
|
||||||
release.seeders.unwrap_or_default(),
|
release.seeders.unwrap_or_default(),
|
||||||
episode_count,
|
episode_count,
|
||||||
|
runtime_minutes,
|
||||||
);
|
);
|
||||||
// A release that did not say its size is not a tiny one: scoring it
|
// 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
|
// against the band's floor would bury it. Same treatment as the manual
|
||||||
@@ -1503,10 +1534,17 @@ fn search_query(movie: &PendingMovie) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `verdict` and `rejected_rule` columns for a verdict.
|
||||||
|
///
|
||||||
|
/// A waiver names the rule it relaxed (#211). §5.7 calls a soft fail
|
||||||
|
/// "watchable but not what was asked", and which rule was relaxed is the
|
||||||
|
/// whole content of that sentence, so §9.3's deck can name it the way it
|
||||||
|
/// names a rejection. Rows written before 0032 hold `NULL` there and stay
|
||||||
|
/// readable.
|
||||||
fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
|
fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
|
||||||
match verdict {
|
match verdict {
|
||||||
Verdict::Eligible => ("eligible", None),
|
Verdict::Eligible => ("eligible", None),
|
||||||
Verdict::Waived(_) => ("waived", None),
|
Verdict::Waived(rule) => ("waived", Some(rule.name())),
|
||||||
Verdict::Rejected(rule) => ("rejected", Some(rule.name())),
|
Verdict::Rejected(rule) => ("rejected", Some(rule.name())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1998,6 +2036,22 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #211: a waiver names the rule it relaxed, the same as a rejection, so
|
||||||
|
/// §9.3's deck shows what was given up instead of a bare `waived`.
|
||||||
|
/// Migration 0032 relaxed the constraint that forbade it.
|
||||||
|
#[test]
|
||||||
|
fn a_waiver_records_the_rule_it_relaxed() {
|
||||||
|
assert_eq!(
|
||||||
|
verdict_columns(&Verdict::Waived(arr_core::Rule::Size)),
|
||||||
|
("waived", Some("size".to_owned()))
|
||||||
|
);
|
||||||
|
assert_eq!(verdict_columns(&Verdict::Eligible), ("eligible", None));
|
||||||
|
assert_eq!(
|
||||||
|
verdict_columns(&Verdict::Rejected(arr_core::Rule::RequiredAudio)),
|
||||||
|
("rejected", Some("required_audio".to_owned()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Every candidate is cached with its verdict, which is what the manual
|
/// Every candidate is cached with its verdict, which is what the manual
|
||||||
/// search view and the attention queues read (§9.3).
|
/// search view and the attention queues read (§9.3).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ impl ImportAction {
|
|||||||
Candidate::PostDownload(&feature.media),
|
Candidate::PostDownload(&feature.media),
|
||||||
Some(feature.size),
|
Some(feature.size),
|
||||||
1,
|
1,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
let waiver: Option<Rule> = match evaluation.verdict {
|
let waiver: Option<Rule> = match evaluation.verdict {
|
||||||
Verdict::Rejected(rule) => {
|
Verdict::Rejected(rule) => {
|
||||||
@@ -477,6 +478,10 @@ impl ImportAction {
|
|||||||
// §5.6 second phase of truth, over every file that would be
|
// §5.6 second phase of truth, over every file that would be
|
||||||
// imported, before anything is placed: one hard failure condemns
|
// imported, before anything is placed: one hard failure condemns
|
||||||
// the whole release (§5.7), not the episodes.
|
// 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();
|
let mut imports = Vec::new();
|
||||||
for assignment in assignments {
|
for assignment in assignments {
|
||||||
if assignment.episode.has_file {
|
if assignment.episode.has_file {
|
||||||
@@ -496,6 +501,7 @@ impl ImportAction {
|
|||||||
Candidate::PostDownload(&assignment.file.media),
|
Candidate::PostDownload(&assignment.file.media),
|
||||||
Some(assignment.file.size),
|
Some(assignment.file.size),
|
||||||
1,
|
1,
|
||||||
|
runtime_minutes,
|
||||||
);
|
);
|
||||||
let waiver = match evaluation.verdict {
|
let waiver = match evaluation.verdict {
|
||||||
Verdict::Rejected(rule) => {
|
Verdict::Rejected(rule) => {
|
||||||
@@ -615,7 +621,10 @@ impl ImportAction {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE grabs SET state = 'failed' WHERE id = ?",
|
"UPDATE grabs
|
||||||
|
SET state = 'failed',
|
||||||
|
failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE id = ?",
|
||||||
pending.grab_id
|
pending.grab_id
|
||||||
)
|
)
|
||||||
.execute(database.pool())
|
.execute(database.pool())
|
||||||
@@ -753,7 +762,10 @@ impl ImportAction {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"UPDATE grabs SET state = 'failed' WHERE id = ?",
|
"UPDATE grabs
|
||||||
|
SET state = 'failed',
|
||||||
|
failed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE id = ?",
|
||||||
pending.grab_id
|
pending.grab_id
|
||||||
)
|
)
|
||||||
.execute(database.pool())
|
.execute(database.pool())
|
||||||
@@ -938,6 +950,10 @@ struct PendingTvImport {
|
|||||||
series_title: String,
|
series_title: String,
|
||||||
series_year: Option<i64>,
|
series_year: Option<i64>,
|
||||||
original_language: Option<String>,
|
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,
|
release_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,6 +990,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
|||||||
s.title AS "series_title!: String",
|
s.title AS "series_title!: String",
|
||||||
s.year AS "series_year",
|
s.year AS "series_year",
|
||||||
s.original_language,
|
s.original_language,
|
||||||
|
s.runtime_minutes,
|
||||||
r.name AS "release_name!: String"
|
r.name AS "release_name!: String"
|
||||||
FROM grabs g
|
FROM grabs g
|
||||||
JOIN episodes e ON e.id = g.target_id
|
JOIN episodes e ON e.id = g.target_id
|
||||||
@@ -997,6 +1014,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
|||||||
series_title: row.series_title,
|
series_title: row.series_title,
|
||||||
series_year: row.series_year,
|
series_year: row.series_year,
|
||||||
original_language: row.original_language,
|
original_language: row.original_language,
|
||||||
|
runtime_minutes: row.runtime_minutes,
|
||||||
release_name: row.release_name,
|
release_name: row.release_name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1011,6 +1029,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
|||||||
s.title AS "series_title!: String",
|
s.title AS "series_title!: String",
|
||||||
s.year AS "series_year",
|
s.year AS "series_year",
|
||||||
s.original_language,
|
s.original_language,
|
||||||
|
s.runtime_minutes,
|
||||||
r.name AS "release_name!: String"
|
r.name AS "release_name!: String"
|
||||||
FROM grabs g
|
FROM grabs g
|
||||||
JOIN seasons se ON se.id = g.target_id
|
JOIN seasons se ON se.id = g.target_id
|
||||||
@@ -1033,6 +1052,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
|||||||
series_title: row.series_title,
|
series_title: row.series_title,
|
||||||
series_year: row.series_year,
|
series_year: row.series_year,
|
||||||
original_language: row.original_language,
|
original_language: row.original_language,
|
||||||
|
runtime_minutes: row.runtime_minutes,
|
||||||
release_name: row.release_name,
|
release_name: row.release_name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1557,11 +1577,16 @@ mod tests {
|
|||||||
assert_eq!(normalised, arr_parse::normalise(RELEASE_NAME));
|
assert_eq!(normalised, arr_parse::normalise(RELEASE_NAME));
|
||||||
assert_eq!(reason, "dolby_vision_profile");
|
assert_eq!(reason, "dolby_vision_profile");
|
||||||
|
|
||||||
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
|
let (grab_state, failed_at): (String, Option<String>) =
|
||||||
.fetch_one(h.database.pool())
|
sqlx::query_as("SELECT state, failed_at FROM grabs")
|
||||||
.await
|
.fetch_one(h.database.pool())
|
||||||
.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(grab_state, "failed");
|
assert_eq!(grab_state, "failed");
|
||||||
|
assert!(
|
||||||
|
failed_at.is_some(),
|
||||||
|
"§5.7's window runs from the failure, so the failure is stamped"
|
||||||
|
);
|
||||||
let movie_state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
|
let movie_state: String = sqlx::query_scalar("SELECT state FROM movies WHERE id = 1")
|
||||||
.fetch_one(h.database.pool())
|
.fetch_one(h.database.pool())
|
||||||
.await
|
.await
|
||||||
@@ -2195,11 +2220,16 @@ mod tests {
|
|||||||
)],
|
)],
|
||||||
"only the release is blacklisted, never the season"
|
"only the release is blacklisted, never the season"
|
||||||
);
|
);
|
||||||
let grab_state: String = sqlx::query_scalar("SELECT state FROM grabs")
|
let (grab_state, failed_at): (String, Option<String>) =
|
||||||
.fetch_one(h.database.pool())
|
sqlx::query_as("SELECT state, failed_at FROM grabs")
|
||||||
.await
|
.fetch_one(h.database.pool())
|
||||||
.unwrap();
|
.await
|
||||||
|
.unwrap();
|
||||||
assert_eq!(grab_state, "failed");
|
assert_eq!(grab_state, "failed");
|
||||||
|
assert!(
|
||||||
|
failed_at.is_some(),
|
||||||
|
"§5.7's window runs from the failure, so the failure is stamped"
|
||||||
|
);
|
||||||
let states: Vec<(String, bool)> =
|
let states: Vec<(String, bool)> =
|
||||||
sqlx::query_as("SELECT state, wanted FROM episodes ORDER BY number")
|
sqlx::query_as("SELECT state, wanted FROM episodes ORDER BY number")
|
||||||
.fetch_all(h.database.pool())
|
.fetch_all(h.database.pool())
|
||||||
|
|||||||
@@ -625,9 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
|||||||
)
|
)
|
||||||
.fetch_all(database.pool())
|
.fetch_all(database.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
// §6.2's ladder runs from the failure, not the grab (#245), with
|
||||||
|
// `grabbed_at` as the fallback for rows older than #239's column.
|
||||||
let failed_packs = sqlx::query!(
|
let failed_packs = sqlx::query!(
|
||||||
r#"SELECT count(*) AS "failures!: i64",
|
r#"SELECT count(*) AS "failures!: i64",
|
||||||
max(grabbed_at) AS "last_failed_at?: String"
|
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||||
FROM grabs
|
FROM grabs
|
||||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||||
season_id
|
season_id
|
||||||
@@ -1164,6 +1166,60 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §6.2, issue #245: the RSS lane reads the same ladder, anchored on the
|
||||||
|
/// failure. A pack sent five weeks ago and condemned at import ten
|
||||||
|
/// minutes ago holds the lane shut, where anchoring on the grab would
|
||||||
|
/// have handed it the very release class that just failed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_pack_that_stalled_for_weeks_stays_backed_off_on_rss() {
|
||||||
|
let (_dir, database) = wanted(&[]).await;
|
||||||
|
let (season_id, episodes) =
|
||||||
|
wanted_series(&database, &["2024-04-11", "2024-04-18", "2024-04-25"]).await;
|
||||||
|
let release_id: i64 = sqlx::query_scalar(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||||
|
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
|
||||||
|
'https://tracker/oldpack.torrent', '{}', 'eligible')
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.fetch_one(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
|
||||||
|
grabbed_at, failed_at)
|
||||||
|
VALUES (?, 'season', ?, 'dead', 'failed',
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days'),
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-10 minutes'))",
|
||||||
|
)
|
||||||
|
.bind(release_id)
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(database.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let indexer = prowlarr(TV_FEED).await;
|
||||||
|
let (downloader, _fake) = transmission().await;
|
||||||
|
|
||||||
|
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||||
|
|
||||||
|
let sent: Vec<(String, i64, String)> = tv_grabs(&database)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, _, state)| state == "sent")
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
!sent.iter().any(|(kind, _, _)| kind == "season"),
|
||||||
|
"the failure is ten minutes old, so the pack lane is shut: {sent:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sent,
|
||||||
|
vec![
|
||||||
|
("episode".to_owned(), episodes[0], "sent".to_owned()),
|
||||||
|
("episode".to_owned(), episodes[1], "sent".to_owned()),
|
||||||
|
("episode".to_owned(), episodes[2], "sent".to_owned()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// §6.2 with #117's guard: an episode already on disk keeps the season
|
/// §6.2 with #117's guard: an episode already on disk keeps the season
|
||||||
/// per-episode here too — the pack is skipped and the open gaps take
|
/// per-episode here too — the pack is skipped and the open gaps take
|
||||||
/// their singles.
|
/// their singles.
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ impl SeriesRefreshAction {
|
|||||||
title AS "title!: String", year, original_language,
|
title AS "title!: String", year, original_language,
|
||||||
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
|
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
|
||||||
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
|
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
|
FROM series
|
||||||
ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"#
|
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,
|
title AS "title!: String", year, original_language,
|
||||||
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
|
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
|
||||||
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
|
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 = ?"#,
|
FROM series WHERE id = ?"#,
|
||||||
series_id
|
series_id
|
||||||
)
|
)
|
||||||
@@ -250,6 +250,20 @@ impl SeriesRefreshAction {
|
|||||||
changed = true;
|
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);
|
let ended = is_upstream_ended(&metadata.status);
|
||||||
if ended != stale.upstream_ended {
|
if ended != stale.upstream_ended {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
@@ -547,6 +561,7 @@ struct DueSeries {
|
|||||||
poster_path: Option<String>,
|
poster_path: Option<String>,
|
||||||
backdrop_path: Option<String>,
|
backdrop_path: Option<String>,
|
||||||
vote_average: Option<f64>,
|
vote_average: Option<f64>,
|
||||||
|
runtime_minutes: Option<i64>,
|
||||||
}
|
}
|
||||||
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
|
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
|
||||||
/// STRICT). A number past `u16::MAX` cannot match anything real and would
|
/// STRICT). A number past `u16::MAX` cannot match anything real and would
|
||||||
@@ -817,6 +832,55 @@ mod tests {
|
|||||||
assert_eq!(vote, Some(8.417));
|
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
|
/// #160. A series' first refresh reveals its back catalogue, but §4.1
|
||||||
/// never tracks what was already there at add time: nothing is tracked,
|
/// never tracks what was already there at add time: nothing is tracked,
|
||||||
/// nothing arrives wanted.
|
/// nothing arrives wanted.
|
||||||
|
|||||||
@@ -1139,11 +1139,15 @@ async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabErr
|
|||||||
/// Whether failed season-pack grabs still hold this season off the pack
|
/// Whether failed season-pack grabs still hold this season off the pack
|
||||||
/// lane. §6.2: a failure quiets the pack search on the shared backoff curve
|
/// lane. §6.2: a failure quiets the pack search on the shared backoff curve
|
||||||
/// (each failed grab is one attempt), it never disables it. Anchored on the
|
/// (each failed grab is one attempt), it never disables it. Anchored on the
|
||||||
/// latest failed grab's `grabbed_at` — failure time itself is not recorded.
|
/// latest `failed_at` (#245), the same anchor §5.7's window uses: a torrent
|
||||||
|
/// can stall for weeks before `ffprobe` condemns it, and measured from the
|
||||||
|
/// grab the whole ladder would already have elapsed when the failure lands.
|
||||||
|
/// `grabbed_at` is the fallback for rows written before #239 added the
|
||||||
|
/// column.
|
||||||
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
r#"SELECT count(*) AS "failures!: i64",
|
r#"SELECT count(*) AS "failures!: i64",
|
||||||
max(grabbed_at) AS "last_failed_at?: String"
|
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||||
FROM grabs
|
FROM grabs
|
||||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||||
season_id
|
season_id
|
||||||
@@ -1496,8 +1500,17 @@ mod tests {
|
|||||||
/// release on the blacklist and the season falls back to per-episode —
|
/// release on the blacklist and the season falls back to per-episode —
|
||||||
/// the pack is not tried again and the episodes are not written off.
|
/// the pack is not tried again and the episodes are not written off.
|
||||||
/// Seed what the import tick leaves behind after a pack fails: one
|
/// Seed what the import tick leaves behind after a pack fails: one
|
||||||
/// `failed` season grab per (infohash, age) pair.
|
/// `failed` season grab per (infohash, age) pair, grabbed and failed at
|
||||||
|
/// the same age, which is the usual case — the two are minutes apart.
|
||||||
async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) {
|
async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) {
|
||||||
|
let pairs: Vec<(&str, &str)> = ages.iter().map(|age| (*age, *age)).collect();
|
||||||
|
stalled_failed_packs(database, season_id, &pairs).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same seed, but with the grab and the failure at different ages —
|
||||||
|
/// the #245 case, where a torrent stalls for weeks before `ffprobe`
|
||||||
|
/// condemns it at import.
|
||||||
|
async fn stalled_failed_packs(database: &Db, season_id: i64, ages: &[(&str, &str)]) {
|
||||||
let release_id: i64 = sqlx::query_scalar(
|
let release_id: i64 = sqlx::query_scalar(
|
||||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||||
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
|
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
|
||||||
@@ -1507,16 +1520,19 @@ mod tests {
|
|||||||
.fetch_one(database.pool())
|
.fetch_one(database.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for (index, age) in ages.iter().enumerate() {
|
for (index, (grabbed_age, failed_age)) in ages.iter().enumerate() {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
|
||||||
|
grabbed_at, failed_at)
|
||||||
VALUES (?, 'season', ?, ?, 'failed',
|
VALUES (?, 'season', ?, ?, 'failed',
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?),
|
||||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||||
)
|
)
|
||||||
.bind(release_id)
|
.bind(release_id)
|
||||||
.bind(season_id)
|
.bind(season_id)
|
||||||
.bind(format!("dead{index}"))
|
.bind(format!("dead{index}"))
|
||||||
.bind(age)
|
.bind(grabbed_age)
|
||||||
|
.bind(failed_age)
|
||||||
.execute(database.pool())
|
.execute(database.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1575,6 +1591,34 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// §6.2, issue #245: the ladder runs from the failure, not the grab. A
|
||||||
|
/// pack sent five weeks ago and condemned by `ffprobe` ten minutes ago
|
||||||
|
/// is one minute into a 1h window, not five weeks past it — the lane
|
||||||
|
/// stays quiet and the episodes carry the season instead.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_pack_that_stalled_for_weeks_backs_off_from_the_failure() {
|
||||||
|
let (_dir, database, season_id) =
|
||||||
|
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||||
|
stalled_failed_packs(&database, season_id, &[("-35 days", "-10 minutes")]).await;
|
||||||
|
let indexer = prowlarr().await;
|
||||||
|
let (downloader, fake) = transmission().await;
|
||||||
|
|
||||||
|
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||||
|
|
||||||
|
let sources: Vec<String> = fake
|
||||||
|
.torrents()
|
||||||
|
.into_iter()
|
||||||
|
.map(|torrent| torrent.source)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
sources
|
||||||
|
.iter()
|
||||||
|
.all(|source| !source.ends_with("pack.torrent")),
|
||||||
|
"the grab is five weeks old but the failure is ten minutes old: {sources:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(sources.len(), 3, "{sources:?}");
|
||||||
|
}
|
||||||
|
|
||||||
/// Repeated failures ride the capped curve: five failed packs mean a 7d
|
/// Repeated failures ride the capped curve: five failed packs mean a 7d
|
||||||
/// window — still closed at 6d, open at 8d. Quiet, never off.
|
/// window — still closed at 6d, open at 8d. Quiet, never off.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- #239: §5.7's 30-day attention window runs from the failure, but only
|
||||||
|
-- grabbed_at existed, so a torrent that stalled past the window and then
|
||||||
|
-- hard-failed at import was born outside it. failed_at records the moment
|
||||||
|
-- the grab entered 'failed'. Existing failed rows get grabbed_at as the
|
||||||
|
-- best available approximation.
|
||||||
|
ALTER TABLE grabs ADD COLUMN failed_at TEXT;
|
||||||
|
|
||||||
|
UPDATE grabs SET failed_at = grabbed_at WHERE state = 'failed';
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Issue #244. A root's path could be stored with a trailing separator until
|
||||||
|
-- #243 normalised the incoming value. `roots::update` normalises the payload
|
||||||
|
-- and compares it against the stored value, so such a row never compares
|
||||||
|
-- equal: every edit, a policy change included, takes the relocation branch,
|
||||||
|
-- and there every planned destination is its own source. Normalising the
|
||||||
|
-- payload alone fixed the half that cannot bite; this is the other half.
|
||||||
|
--
|
||||||
|
-- `rtrim` strips every trailing separator at once, so '/mnt/x//' normalises
|
||||||
|
-- in one pass. A bare '/' rtrims to the empty string and is put back, which
|
||||||
|
-- is what `normalize_path` in arr-api does.
|
||||||
|
--
|
||||||
|
-- Guarded, because `roots.path` is UNIQUE and a migration that cannot apply
|
||||||
|
-- stops the daemon booting -- worse than the bug it fixes. A row is
|
||||||
|
-- normalised only when no other row shares its normalised path: neither a
|
||||||
|
-- row already holding the stripped value, nor another trailing-separator row
|
||||||
|
-- that would strip to the same thing. Every row in such a group is left
|
||||||
|
-- exactly as it is. That leaves two roots naming one directory, which is a
|
||||||
|
-- settings mistake for the operator to resolve by hand, not a reason to
|
||||||
|
-- refuse to boot.
|
||||||
|
--
|
||||||
|
-- This cannot introduce a collision either. An updated row's new value is a
|
||||||
|
-- normalised path no other row normalises to, and a row left alone whose raw
|
||||||
|
-- path equalled that value would have had the same normalised path, which is
|
||||||
|
-- the case the guard excludes.
|
||||||
|
UPDATE roots
|
||||||
|
SET path = CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END
|
||||||
|
WHERE path <> CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM roots AS other
|
||||||
|
WHERE other.id <> roots.id
|
||||||
|
AND CASE WHEN rtrim(other.path, '/') = '' THEN '/' ELSE rtrim(other.path, '/') END
|
||||||
|
= CASE WHEN rtrim(roots.path, '/') = '' THEN '/' ELSE rtrim(roots.path, '/') END
|
||||||
|
);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- no-transaction
|
||||||
|
-- #211: a waived release could not record which rule it relaxed. The old
|
||||||
|
-- constraint made the rule name an exact synonym for rejection:
|
||||||
|
--
|
||||||
|
-- CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))
|
||||||
|
--
|
||||||
|
-- so §9.3's deck showed a bare `waived` beside rejected rows that each named
|
||||||
|
-- their own rule, and §5.7's "watchable but not what was asked" lost the half
|
||||||
|
-- that says what was not asked for. The relaxed form still demands a rule on
|
||||||
|
-- a rejection and stops demanding its absence elsewhere.
|
||||||
|
--
|
||||||
|
-- SQLite cannot alter a CHECK, so the table is rebuilt (see 0014). Unlike the
|
||||||
|
-- rebuilds there, `releases` is a parent: `grabs`, `movie_releases`,
|
||||||
|
-- `episode_releases` and `season_releases` all point at it, three of them
|
||||||
|
-- ON DELETE CASCADE. Dropping the old table with foreign keys enforced would
|
||||||
|
-- delete those children (or, for `grabs`, refuse outright), so this follows
|
||||||
|
-- SQLite's own procedure — foreign keys off, the rebuild in one transaction,
|
||||||
|
-- foreign keys back on. `PRAGMA foreign_keys` is a no-op inside a
|
||||||
|
-- transaction, which is why the file opens `-- no-transaction` and manages
|
||||||
|
-- its own; the migration is still all-or-nothing.
|
||||||
|
--
|
||||||
|
-- Rows are copied verbatim. Every existing `waived` row keeps its NULL and
|
||||||
|
-- goes on reading as it does today; only rows written after this migration
|
||||||
|
-- carry a waived rule.
|
||||||
|
PRAGMA foreign_keys = OFF;
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE releases_new (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
-- Prowlarr's indexer id. Not a foreign key: indexers live in Prowlarr.
|
||||||
|
indexer_id INTEGER NOT NULL,
|
||||||
|
guid TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
seeders INTEGER,
|
||||||
|
publish_date TEXT,
|
||||||
|
download_url TEXT NOT NULL,
|
||||||
|
-- §5.6. What the release name claims, before anything is downloaded.
|
||||||
|
parsed TEXT NOT NULL CHECK (json_valid(parsed)),
|
||||||
|
score REAL,
|
||||||
|
-- §9.3. Three buckets. `rejected_rule` names the rule that killed it so
|
||||||
|
-- an over-strict filter is visible without reading release names, and
|
||||||
|
-- §5.7's waiver names the rule it relaxed for the same reason.
|
||||||
|
verdict TEXT CHECK (verdict IN ('eligible', 'waived', 'rejected')),
|
||||||
|
rejected_rule TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
UNIQUE (indexer_id, guid),
|
||||||
|
CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
INSERT INTO releases_new (
|
||||||
|
id, indexer_id, guid, name, size, seeders, publish_date, download_url,
|
||||||
|
parsed, score, verdict, rejected_rule, created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, indexer_id, guid, name, size, seeders, publish_date, download_url,
|
||||||
|
parsed, score, verdict, rejected_rule, created_at
|
||||||
|
FROM releases;
|
||||||
|
|
||||||
|
DROP TABLE releases;
|
||||||
|
ALTER TABLE releases_new RENAME TO releases;
|
||||||
|
|
||||||
|
CREATE INDEX releases_verdict ON releases (verdict, score);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
//! infohash, and
|
//! infohash, and
|
||||||
//! - **infohash**, because the same torrent is re-listed under a new name.
|
//! - **infohash**, because the same torrent is re-listed under a new name.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
@@ -21,15 +21,21 @@ use sqlx::SqlitePool;
|
|||||||
/// rejected row names the rule that killed it" reads the same everywhere.
|
/// rejected row names the rule that killed it" reads the same everywhere.
|
||||||
pub const RULE: &str = "blacklisted";
|
pub const RULE: &str = "blacklisted";
|
||||||
|
|
||||||
/// Every blacklist key, loaded once per tick or request.
|
/// Every blacklist key and the reason it was written under, loaded once per
|
||||||
|
/// tick or request.
|
||||||
///
|
///
|
||||||
/// Household scale: a handful of rows. Loading it whole and matching in
|
/// Household scale: a handful of rows. Loading it whole and matching in
|
||||||
/// memory keeps the check identical for a release that has a database row and
|
/// memory keeps the check identical for a release that has a database row and
|
||||||
/// one that has only just arrived from an indexer.
|
/// one that has only just arrived from an indexer.
|
||||||
|
///
|
||||||
|
/// The reason travels with the key because §9.3's deck has to say what a row
|
||||||
|
/// was blacklisted for (#227): a release the policy rejected on size is one
|
||||||
|
/// the operator can relax and try again, and a corrupt or mismatched one is
|
||||||
|
/// not. A bare `blacklisted` makes those two read the same.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Blacklist {
|
pub struct Blacklist {
|
||||||
names: HashSet<String>,
|
names: HashMap<String, String>,
|
||||||
infohashes: HashSet<String>,
|
infohashes: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Blacklist {
|
impl Blacklist {
|
||||||
@@ -39,17 +45,30 @@ impl Blacklist {
|
|||||||
///
|
///
|
||||||
/// If the query fails.
|
/// If the query fails.
|
||||||
pub async fn load(pool: &SqlitePool) -> Result<Self, sqlx::Error> {
|
pub async fn load(pool: &SqlitePool) -> Result<Self, sqlx::Error> {
|
||||||
|
// Oldest first, so a key that hard-failed twice under different
|
||||||
|
// reasons keeps the first one — the same rule [`add`] applies when it
|
||||||
|
// refuses to write the second row.
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query!(
|
||||||
r#"SELECT normalised_name AS "normalised_name!: String", infohash FROM blacklist"#
|
r#"SELECT normalised_name AS "normalised_name!: String",
|
||||||
|
infohash,
|
||||||
|
reason AS "reason!: String"
|
||||||
|
FROM blacklist
|
||||||
|
ORDER BY id"#
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut blacklist = Self::default();
|
let mut blacklist = Self::default();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
blacklist.names.insert(row.normalised_name);
|
blacklist
|
||||||
|
.names
|
||||||
|
.entry(row.normalised_name)
|
||||||
|
.or_insert_with(|| row.reason.clone());
|
||||||
if let Some(infohash) = row.infohash {
|
if let Some(infohash) = row.infohash {
|
||||||
blacklist.infohashes.insert(infohash.to_ascii_lowercase());
|
blacklist
|
||||||
|
.infohashes
|
||||||
|
.entry(infohash.to_ascii_lowercase())
|
||||||
|
.or_insert(row.reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(blacklist)
|
Ok(blacklist)
|
||||||
@@ -58,14 +77,44 @@ impl Blacklist {
|
|||||||
/// Whether this release name has been blacklisted, under any spelling.
|
/// Whether this release name has been blacklisted, under any spelling.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn blocks_name(&self, release_name: &str) -> bool {
|
pub fn blocks_name(&self, release_name: &str) -> bool {
|
||||||
self.names.contains(&arr_parse::normalise(release_name))
|
self.names.contains_key(&arr_parse::normalise(release_name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this infohash has been blacklisted. Case-insensitive:
|
/// Whether this infohash has been blacklisted. Case-insensitive:
|
||||||
/// Transmission and Torznab disagree on the hex casing.
|
/// Transmission and Torznab disagree on the hex casing.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn blocks_infohash(&self, infohash: &str) -> bool {
|
pub fn blocks_infohash(&self, infohash: &str) -> bool {
|
||||||
self.infohashes.contains(&infohash.to_ascii_lowercase())
|
self.infohashes.contains_key(&infohash.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this release name was blacklisted for, or `None` if it was not.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reason_for_name(&self, release_name: &str) -> Option<&str> {
|
||||||
|
self.names
|
||||||
|
.get(&arr_parse::normalise(release_name))
|
||||||
|
.map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this infohash was blacklisted for, or `None` if it was not.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reason_for_infohash(&self, infohash: &str) -> Option<&str> {
|
||||||
|
self.infohashes
|
||||||
|
.get(&infohash.to_ascii_lowercase())
|
||||||
|
.map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a candidate was blacklisted for, under either key (#227).
|
||||||
|
///
|
||||||
|
/// The name is asked first: it is the key every candidate has, and a
|
||||||
|
/// `.torrent` URL hides its infohash until the download client fetches
|
||||||
|
/// it, exactly as [`Blacklist::blocks_candidate`] describes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn reason_for_candidate(&self, release_name: &str, download_url: &str) -> Option<&str> {
|
||||||
|
self.reason_for_name(release_name).or_else(|| {
|
||||||
|
magnet_infohash(download_url)
|
||||||
|
.and_then(|hash| self.infohashes.get(&hash))
|
||||||
|
.map(String::as_str)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a candidate is blacklisted before anything is sent to the
|
/// Whether a candidate is blacklisted before anything is sent to the
|
||||||
@@ -175,6 +224,54 @@ mod tests {
|
|||||||
assert!(!blacklist.blocks_name("Dune Part Two 2024 1080p WEB-DL"));
|
assert!(!blacklist.blocks_name("Dune Part Two 2024 1080p WEB-DL"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_key_carries_the_reason_it_was_blacklisted_for() {
|
||||||
|
let (database, _dir) = database().await;
|
||||||
|
add(
|
||||||
|
database.pool(),
|
||||||
|
Some(HASH),
|
||||||
|
"Rick.And.Morty.S08.1080p",
|
||||||
|
"size",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
add(
|
||||||
|
database.pool(),
|
||||||
|
None,
|
||||||
|
"Some.Other.Pack.S01",
|
||||||
|
"no file matches a wanted episode",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let blacklist = Blacklist::load(database.pool()).await.unwrap();
|
||||||
|
// #227: the deck has to tell a policy rejection from a bad release,
|
||||||
|
// and the reason is the only thing that says which.
|
||||||
|
assert_eq!(
|
||||||
|
blacklist.reason_for_name("Rick And Morty S08 1080p"),
|
||||||
|
Some("size")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
blacklist.reason_for_infohash(&HASH.to_ascii_uppercase()),
|
||||||
|
Some("size")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
blacklist.reason_for_name("Some.Other.Pack.S01"),
|
||||||
|
Some("no file matches a wanted episode")
|
||||||
|
);
|
||||||
|
assert_eq!(blacklist.reason_for_name("Never.Failed.S01"), None);
|
||||||
|
|
||||||
|
let magnet = format!("magnet:?xt=urn:btih:{HASH}&dn=Renamed.Pack");
|
||||||
|
assert_eq!(
|
||||||
|
blacklist.reason_for_candidate("Renamed.Pack", &magnet),
|
||||||
|
Some("size")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
blacklist.reason_for_candidate("Renamed.Pack", "https://tracker/x.torrent"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_second_hard_fail_of_the_same_torrent_adds_no_row() {
|
async fn a_second_hard_fail_of_the_same_torrent_adds_no_row() {
|
||||||
let (database, _dir) = database().await;
|
let (database, _dir) = database().await;
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ use sqlx::{migrate::MigrateError, SqlitePool};
|
|||||||
/// The migrations embedded in the binary, so a deploy is one file.
|
/// The migrations embedded in the binary, so a deploy is one file.
|
||||||
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||||
|
|
||||||
|
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision
|
||||||
|
/// queue, as a SQLite time modifier. Measured from `failed_at` — the moment
|
||||||
|
/// the grab hard-failed, not the moment it was grabbed — so a torrent that
|
||||||
|
/// stalls past the window and then fails still counts (#239).
|
||||||
|
///
|
||||||
|
/// Nothing ever clears a `grabs` row, so without a bound the queue only grows
|
||||||
|
/// and the one season that wants attention sits behind the ones that do not.
|
||||||
|
/// Callers pair it with the `failed_at` format:
|
||||||
|
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
|
||||||
|
pub const ATTENTION_WINDOW: &str = "-30 days";
|
||||||
|
|
||||||
/// How long a writer waits for the write lock before giving up.
|
/// How long a writer waits for the write lock before giving up.
|
||||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
|
||||||
@@ -343,6 +354,93 @@ mod tests {
|
|||||||
assert_eq!(renamed_from_imported, "available");
|
assert_eq!(renamed_from_imported, "available");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The four seeded roots, up to but not including migration 0031, with
|
||||||
|
/// the given legacy paths written straight into the rows.
|
||||||
|
async fn roots_before_normalisation(paths: &[(i64, &str)]) -> (tempfile::TempDir, Db) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let db = Db::connect(dir.path().join("arr.db"))
|
||||||
|
.await
|
||||||
|
.expect("connect");
|
||||||
|
MIGRATOR
|
||||||
|
.run_to(30, db.pool())
|
||||||
|
.await
|
||||||
|
.expect("migrations before #244");
|
||||||
|
for (id, path) in paths {
|
||||||
|
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||||
|
.bind(path)
|
||||||
|
.bind(id)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("legacy path, valid under the unique index");
|
||||||
|
}
|
||||||
|
(dir, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root_path(db: &Db, id: i64) -> String {
|
||||||
|
sqlx::query_scalar("SELECT path FROM roots WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("root path")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #244: a root's path could be stored with a trailing separator until
|
||||||
|
/// #243 normalised the incoming value, and `roots::update` compares a
|
||||||
|
/// normalised payload against the stored value — so such a row could
|
||||||
|
/// never be edited again. Migration 0031 strips the separator, and skips
|
||||||
|
/// a row whose stripped form another row already holds rather than
|
||||||
|
/// tripping the unique index and refusing to apply.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_paths_are_normalised_but_never_onto_a_path_in_use() {
|
||||||
|
let (_dir, db) = roots_before_normalisation(&[
|
||||||
|
(1, "/mnt/media/movies/main/"),
|
||||||
|
(2, "/mnt/collide"),
|
||||||
|
(3, "/mnt/collide/"),
|
||||||
|
(4, "/mnt/media/tv/kids///"),
|
||||||
|
])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
db.migrate()
|
||||||
|
.await
|
||||||
|
.expect("0031 applies with a collision present");
|
||||||
|
|
||||||
|
assert_eq!(root_path(&db, 1).await, "/mnt/media/movies/main");
|
||||||
|
assert_eq!(
|
||||||
|
root_path(&db, 4).await,
|
||||||
|
"/mnt/media/tv/kids",
|
||||||
|
"every trailing separator goes in one pass"
|
||||||
|
);
|
||||||
|
assert_eq!(root_path(&db, 2).await, "/mnt/collide");
|
||||||
|
assert_eq!(
|
||||||
|
root_path(&db, 3).await,
|
||||||
|
"/mnt/collide/",
|
||||||
|
"left exactly as it is: normalising it would collide with root 2"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #244: the harder half of the same guard. Two rows that strip to the
|
||||||
|
/// same path, *neither* of which already holds the stripped value, still
|
||||||
|
/// have to be left alone — normalising them would collide with each
|
||||||
|
/// other, and a migration that cannot apply stops the daemon booting.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn two_rows_that_would_collide_with_each_other_stop_nothing() {
|
||||||
|
let (_dir, db) =
|
||||||
|
roots_before_normalisation(&[(1, "/mnt/one/"), (2, "/mnt/dup/"), (3, "/mnt/dup//")])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
db.migrate()
|
||||||
|
.await
|
||||||
|
.expect("0031 applies with a mutually colliding pair present");
|
||||||
|
|
||||||
|
assert_eq!(root_path(&db, 2).await, "/mnt/dup/");
|
||||||
|
assert_eq!(root_path(&db, 3).await, "/mnt/dup//");
|
||||||
|
assert_eq!(
|
||||||
|
root_path(&db, 1).await,
|
||||||
|
"/mnt/one",
|
||||||
|
"the rest of the table is still normalised"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// #155: the table-rebuild migrations (0007, 0014, 0021) run outside a
|
/// #155: the table-rebuild migrations (0007, 0014, 0021) run outside a
|
||||||
/// transaction so `PRAGMA foreign_keys = OFF` holds and dropping the old
|
/// transaction so `PRAGMA foreign_keys = OFF` holds and dropping the old
|
||||||
/// tables does not cascade-delete `movie_releases`/`episode_releases`.
|
/// tables does not cascade-delete `movie_releases`/`episode_releases`.
|
||||||
@@ -426,6 +524,177 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #211: 0032 rebuilds `releases`, which is a parent — `grabs`,
|
||||||
|
/// `movie_releases`, `episode_releases` and `season_releases` all point
|
||||||
|
/// at it, three of them ON DELETE CASCADE. The rebuild runs with foreign
|
||||||
|
/// keys off so dropping the old table neither cascades those children
|
||||||
|
/// away nor is refused by `grabs`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_releases_rebuild_preserves_children_and_rows() {
|
||||||
|
let (_dir, db) = a_deck_with_every_child_row().await;
|
||||||
|
|
||||||
|
db.migrate().await.expect("remaining migrations");
|
||||||
|
|
||||||
|
for (label, query, expected) in [
|
||||||
|
("releases", "SELECT count(*) FROM releases", 3),
|
||||||
|
("movie_releases", "SELECT count(*) FROM movie_releases", 1),
|
||||||
|
(
|
||||||
|
"episode_releases",
|
||||||
|
"SELECT count(*) FROM episode_releases",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
("season_releases", "SELECT count(*) FROM season_releases", 1),
|
||||||
|
("grabs", "SELECT count(*) FROM grabs", 1),
|
||||||
|
] {
|
||||||
|
let rows: i64 = sqlx::query_scalar(query)
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("count");
|
||||||
|
assert_eq!(rows, expected, "{label} survives the releases rebuild");
|
||||||
|
}
|
||||||
|
|
||||||
|
// An existing waived row keeps its NULL and reads as it did before.
|
||||||
|
let waived: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT rejected_rule FROM releases WHERE guid = 'waived-guid'")
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("waived row");
|
||||||
|
assert_eq!(waived, None);
|
||||||
|
let rejected: Option<String> =
|
||||||
|
sqlx::query_scalar("SELECT rejected_rule FROM releases WHERE guid = 'rejected-guid'")
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("rejected row");
|
||||||
|
assert_eq!(rejected.as_deref(), Some("size"));
|
||||||
|
|
||||||
|
// Foreign keys are back on for the connection the migration used.
|
||||||
|
let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("foreign_keys");
|
||||||
|
assert_eq!(foreign_keys, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A database migrated to just before 0032, holding one release per
|
||||||
|
/// verdict and one row in every table that points at `releases`.
|
||||||
|
async fn a_deck_with_every_child_row() -> (tempfile::TempDir, Db) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let db = Db::connect(dir.path().join("arr.db"))
|
||||||
|
.await
|
||||||
|
.expect("connect");
|
||||||
|
|
||||||
|
MIGRATOR
|
||||||
|
.run_to(31, db.pool())
|
||||||
|
.await
|
||||||
|
.expect("migrations before the releases rebuild");
|
||||||
|
|
||||||
|
let movie_id = sqlx::query(
|
||||||
|
"INSERT INTO movies (tmdb_id, title, root_id)
|
||||||
|
SELECT 693134, 'Dune Part Two', id FROM roots WHERE kind = 'movie' LIMIT 1",
|
||||||
|
)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("movie")
|
||||||
|
.last_insert_rowid();
|
||||||
|
let series_id = sqlx::query(
|
||||||
|
"INSERT INTO series (tmdb_id, title, root_id)
|
||||||
|
SELECT 82728, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1",
|
||||||
|
)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("series")
|
||||||
|
.last_insert_rowid();
|
||||||
|
let season_id = sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1)")
|
||||||
|
.bind(series_id)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("season")
|
||||||
|
.last_insert_rowid();
|
||||||
|
let episode_id =
|
||||||
|
sqlx::query("INSERT INTO episodes (season_id, number, title) VALUES (?, 1, 'x')")
|
||||||
|
.bind(season_id)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("episode")
|
||||||
|
.last_insert_rowid();
|
||||||
|
// One row per verdict, including the waived row this issue is about,
|
||||||
|
// which under the old constraint could only hold NULL.
|
||||||
|
for (guid, verdict, rule) in [
|
||||||
|
("eligible-guid", "eligible", None),
|
||||||
|
("waived-guid", "waived", None),
|
||||||
|
("rejected-guid", "rejected", Some("size")),
|
||||||
|
] {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict, rejected_rule)
|
||||||
|
VALUES (1, ?, ?, 1024, 'http://x', '{}', ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(guid)
|
||||||
|
.bind(guid)
|
||||||
|
.bind(verdict)
|
||||||
|
.bind(rule)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("release");
|
||||||
|
}
|
||||||
|
let release_id: i64 =
|
||||||
|
sqlx::query_scalar("SELECT id FROM releases WHERE guid = 'eligible-guid'")
|
||||||
|
.fetch_one(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("release id");
|
||||||
|
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(movie_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("movie link");
|
||||||
|
sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(episode_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("episode link");
|
||||||
|
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||||
|
.bind(season_id)
|
||||||
|
.bind(release_id)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("season link");
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash)
|
||||||
|
VALUES (?, 'movie', ?, 'infohash-1')",
|
||||||
|
)
|
||||||
|
.bind(release_id)
|
||||||
|
.bind(movie_id)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("grab");
|
||||||
|
|
||||||
|
(dir, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The relaxed constraint (#211) admits a rule on a waiver and still
|
||||||
|
/// refuses a rejection without one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_waiver_may_name_the_rule_it_relaxed() {
|
||||||
|
let (_dir, db) = fresh().await;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict, rejected_rule)
|
||||||
|
VALUES (1, 'waived', 'Some.Release', 1024, 'http://x', '{}', 'waived', 'size')",
|
||||||
|
)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect("a waiver names its rule");
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||||
|
VALUES (1, 'rejected', 'Some.Release', 1024, 'http://x', '{}', 'rejected')",
|
||||||
|
)
|
||||||
|
.execute(db.pool())
|
||||||
|
.await
|
||||||
|
.expect_err("a rejection still has to name its rule");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn seeds_two_tv_roots_with_distinct_policies() {
|
async fn seeds_two_tv_roots_with_distinct_policies() {
|
||||||
let (_dir, db) = fresh().await;
|
let (_dir, db) = fresh().await;
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ impl Db {
|
|||||||
overrides: TitleOverrides {
|
overrides: TitleOverrides {
|
||||||
only_4k: overrides.only_4k,
|
only_4k: overrides.only_4k,
|
||||||
allow_english_audio: overrides.allow_english_audio,
|
allow_english_audio: overrides.allow_english_audio,
|
||||||
|
allow_below_floor: overrides.allow_below_floor,
|
||||||
},
|
},
|
||||||
root_id: row.root_id,
|
root_id: row.root_id,
|
||||||
root_kind: row.root_kind,
|
root_kind: row.root_kind,
|
||||||
@@ -268,6 +269,75 @@ impl Db {
|
|||||||
overrides: TitleOverrides {
|
overrides: TitleOverrides {
|
||||||
only_4k: overrides.only_4k,
|
only_4k: overrides.only_4k,
|
||||||
allow_english_audio: overrides.allow_english_audio,
|
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_id: row.root_id,
|
||||||
root_kind: row.root_kind,
|
root_kind: row.root_kind,
|
||||||
@@ -337,6 +407,7 @@ impl Db {
|
|||||||
overrides: TitleOverrides {
|
overrides: TitleOverrides {
|
||||||
only_4k: overrides.only_4k,
|
only_4k: overrides.only_4k,
|
||||||
allow_english_audio: overrides.allow_english_audio,
|
allow_english_audio: overrides.allow_english_audio,
|
||||||
|
allow_below_floor: overrides.allow_below_floor,
|
||||||
},
|
},
|
||||||
root_id: row.root_id,
|
root_id: row.root_id,
|
||||||
root_kind: row.root_kind,
|
root_kind: row.root_kind,
|
||||||
@@ -428,6 +499,8 @@ struct OverridesJson {
|
|||||||
only_4k: bool,
|
only_4k: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
allow_english_audio: bool,
|
allow_english_audio: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
allow_below_floor: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn gib(value: u64) -> u64 {
|
fn gib(value: u64) -> u64 {
|
||||||
@@ -623,7 +696,8 @@ mod tests {
|
|||||||
&loaded.policy,
|
&loaded.policy,
|
||||||
Resolution::R1080p,
|
Resolution::R1080p,
|
||||||
one_and_a_half_gib,
|
one_and_a_half_gib,
|
||||||
1
|
1,
|
||||||
|
0
|
||||||
),
|
),
|
||||||
Some(false)
|
Some(false)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ pub struct Series {
|
|||||||
pub vote_average: Option<f64>,
|
pub vote_average: Option<f64>,
|
||||||
/// How many votes the rating rests on.
|
/// How many votes the rating rests on.
|
||||||
pub vote_count: u32,
|
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>,
|
pub seasons: Vec<SeasonSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +372,8 @@ pub(crate) struct RawSeries {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
vote_count: u32,
|
vote_count: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
episode_run_time: Vec<u32>,
|
||||||
|
#[serde(default)]
|
||||||
seasons: Vec<RawSeasonSummary>,
|
seasons: Vec<RawSeasonSummary>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
external_ids: Option<RawExternalIds>,
|
external_ids: Option<RawExternalIds>,
|
||||||
@@ -406,6 +412,10 @@ impl From<RawSeries> for Series {
|
|||||||
backdrop_path: non_empty(raw.backdrop_path),
|
backdrop_path: non_empty(raw.backdrop_path),
|
||||||
vote_average: rating(raw.vote_average),
|
vote_average: rating(raw.vote_average),
|
||||||
vote_count: raw.vote_count,
|
vote_count: raw.vote_count,
|
||||||
|
episode_runtime: raw
|
||||||
|
.episode_run_time
|
||||||
|
.into_iter()
|
||||||
|
.find(|&minutes| minutes > 0),
|
||||||
seasons: raw
|
seasons: raw
|
||||||
.seasons
|
.seasons
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
+48
-18
@@ -231,10 +231,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<main class="deck" id="movie" hidden aria-label="movie detail">
|
<main class="deck" id="movie" hidden aria-label="movie detail">
|
||||||
<header class="releases-head">
|
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
|
||||||
<button type="button" class="control" id="movie-back">back</button>
|
|
||||||
<p class="deck-status readout" id="movie-status" role="status" hidden></p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
|
<section class="module movie-hero" id="movie-hero" aria-label="title metadata">
|
||||||
<div class="movie-body">
|
<div class="movie-body">
|
||||||
@@ -268,7 +265,7 @@
|
|||||||
id="movie-blocked"
|
id="movie-blocked"
|
||||||
aria-pressed="false"
|
aria-pressed="false"
|
||||||
>
|
>
|
||||||
blocked
|
no targeted search
|
||||||
</button>
|
</button>
|
||||||
<label class="movie-root-field">
|
<label class="movie-root-field">
|
||||||
<span class="field-label readout dim" id="movie-root-label">root</span>
|
<span class="field-label readout dim" id="movie-root-label">root</span>
|
||||||
@@ -279,14 +276,24 @@
|
|||||||
<button type="button" class="control" id="movie-sweep">search indexers</button>
|
<button type="button" class="control" id="movie-sweep">search indexers</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="control control-quiet"
|
class="control control-quiet control-icon"
|
||||||
id="movie-remove"
|
id="movie-remove"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-controls="remove-panel"
|
aria-controls="remove-panel"
|
||||||
|
aria-label="remove from the library"
|
||||||
>
|
>
|
||||||
remove
|
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M2.8 4.3h10.4" />
|
||||||
|
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
|
||||||
|
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
|
||||||
|
<path d="M6.6 7v4.2" />
|
||||||
|
<path d="M9.4 7v4.2" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="control-note readout dim" id="movie-blocked-note" hidden>
|
||||||
|
targeted search is off for this title. RSS still matches it, so a release can still arrive.
|
||||||
|
</p>
|
||||||
<div class="remove-panel" id="remove-panel" hidden></div>
|
<div class="remove-panel" id="remove-panel" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -323,7 +330,7 @@
|
|||||||
|
|
||||||
<section class="deck-group" id="bucket-waived" hidden aria-labelledby="label-waived">
|
<section class="deck-group" id="bucket-waived" hidden aria-labelledby="label-waived">
|
||||||
<header class="deck-head bucket-head">
|
<header class="deck-head bucket-head">
|
||||||
<h3 class="deck-label" id="label-waived">waived</h3>
|
<h3 class="deck-label" id="label-waived">below policy</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="bucket-toggle readout"
|
class="bucket-toggle readout"
|
||||||
@@ -466,7 +473,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<p class="queue-note readout dim">
|
<p class="queue-note readout dim">
|
||||||
a policy edit rescores search decks immediately — open any title's releases to check it.
|
a policy edit rescores every title's releases immediately — open any title to check it.
|
||||||
</p>
|
</p>
|
||||||
<ul class="deck-rows" id="rows-policies"></ul>
|
<ul class="deck-rows" id="rows-policies"></ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -496,10 +503,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<main class="deck releases" id="series" hidden aria-label="series detail">
|
<main class="deck releases" id="series" hidden aria-label="series detail">
|
||||||
<header class="releases-head">
|
<p class="deck-status readout" id="series-status" role="status" hidden></p>
|
||||||
<button type="button" class="control" id="series-back">back</button>
|
|
||||||
<p class="deck-status readout" id="series-status" role="status" hidden></p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
|
<section class="module movie-hero" id="series-hero" aria-label="title metadata">
|
||||||
<div class="movie-body">
|
<div class="movie-body">
|
||||||
@@ -521,23 +525,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- removal is the one library control a series carries (issue 175):
|
|
||||||
the same arming panel a movie uses — evidence first, then the
|
|
||||||
decision — wired to DELETE /api/series/{id} -->
|
|
||||||
<section class="deck-group" id="series-library" aria-label="library controls">
|
<section class="deck-group" id="series-library" aria-label="library controls">
|
||||||
<header class="deck-head">
|
<header class="deck-head">
|
||||||
<h3 class="deck-label">library</h3>
|
<h3 class="deck-label">library</h3>
|
||||||
</header>
|
</header>
|
||||||
<div class="movie-controls">
|
<div class="movie-controls">
|
||||||
|
<label class="movie-root-field">
|
||||||
|
<span class="field-label readout dim" id="series-root-label">root</span>
|
||||||
|
<select
|
||||||
|
class="form-input movie-root"
|
||||||
|
id="series-root"
|
||||||
|
data-focus-id="series-root"
|
||||||
|
aria-labelledby="series-root-label"
|
||||||
|
></select>
|
||||||
|
</label>
|
||||||
<span class="movie-controls-space"></span>
|
<span class="movie-controls-space"></span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="control control-quiet"
|
class="control control-quiet control-icon"
|
||||||
id="series-remove"
|
id="series-remove"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-controls="series-remove-panel"
|
aria-controls="series-remove-panel"
|
||||||
|
aria-label="remove from the library"
|
||||||
>
|
>
|
||||||
remove
|
<svg class="icon" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M2.8 4.3h10.4" />
|
||||||
|
<path d="M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2" />
|
||||||
|
<path d="M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5" />
|
||||||
|
<path d="M6.6 7v4.2" />
|
||||||
|
<path d="M9.4 7v4.2" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="remove-panel" id="series-remove-panel" hidden></div>
|
<div class="remove-panel" id="series-remove-panel" hidden></div>
|
||||||
@@ -556,6 +573,19 @@
|
|||||||
<button type="button" class="control" id="tv-releases-sweep">re-search</button>
|
<button type="button" class="control" id="tv-releases-sweep">re-search</button>
|
||||||
</header>
|
</header>
|
||||||
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
|
<p class="deck-status readout" id="tv-releases-status" role="status" hidden></p>
|
||||||
|
<!-- issue 227: a pack that downloaded in full and was condemned at
|
||||||
|
import (§5.7). It outlives the status line because it is a fact
|
||||||
|
about the season, not about the request in flight. -->
|
||||||
|
<section class="deck-notice" id="tv-releases-failure" hidden aria-labelledby="tv-failure-label">
|
||||||
|
<h3 class="deck-label" id="tv-failure-label">pack abandoned at import</h3>
|
||||||
|
<p class="notice-release readout" id="tv-failure-release"></p>
|
||||||
|
<p class="notice-line" id="tv-failure-what"></p>
|
||||||
|
<p class="notice-line" id="tv-failure-next"></p>
|
||||||
|
<p class="notice-line dim">
|
||||||
|
nothing was imported, and the torrent was left where it is: nothing is
|
||||||
|
deleted early to satisfy the library (§7.3).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
<div id="tv-buckets"></div>
|
<div id="tv-buckets"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// The shared icon-only control block (#230), consumed by the title pages,
|
||||||
|
// decks and readouts in main.ts and the settings rows (#231). One glyph set,
|
||||||
|
// one arm-then-confirm idiom — never a second implementation per view.
|
||||||
|
|
||||||
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
const ICON_PATHS = {
|
||||||
|
trash: [
|
||||||
|
"M2.8 4.3h10.4",
|
||||||
|
"M5.7 4.3V3.1c0-.5.4-.9.9-.9h2.8c.5 0 .9.4.9.9v1.2",
|
||||||
|
"M4.2 4.3l.5 8.5c0 .8.7 1.4 1.5 1.4h3.6c.8 0 1.5-.6 1.5-1.4l.5-8.5",
|
||||||
|
"M6.6 7v4.2",
|
||||||
|
"M9.4 7v4.2",
|
||||||
|
],
|
||||||
|
search: ["M7.2 2.6a4.4 4.4 0 1 1 0 8.8 4.4 4.4 0 0 1 0-8.8Z", "M10.4 10.4l3.2 3.2"],
|
||||||
|
disk: [
|
||||||
|
"M2.5 9.8l1.6-5c.2-.7.8-1.2 1.5-1.2h4.8c.7 0 1.3.5 1.5 1.2l1.6 5",
|
||||||
|
"M2.5 9.8h11v2.5c0 .7-.5 1.2-1.2 1.2H3.7c-.7 0-1.2-.5-1.2-1.2z",
|
||||||
|
"M10.9 11.6h.01",
|
||||||
|
],
|
||||||
|
pencil: [
|
||||||
|
"M11.2 2.4a1.5 1.5 0 0 1 2.1 0l.3.3a1.5 1.5 0 0 1 0 2.1L5.9 12.5l-3.2.8.8-3.2z",
|
||||||
|
"M10.1 3.5l2.4 2.4",
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Stroke glyph on the shared 16-grid; the control's aria-label carries the meaning. */
|
||||||
|
export function icon(name: keyof typeof ICON_PATHS): SVGSVGElement {
|
||||||
|
const svg = document.createElementNS(SVG_NS, "svg");
|
||||||
|
svg.setAttribute("viewBox", "0 0 16 16");
|
||||||
|
svg.setAttribute("class", "icon");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
for (const d of ICON_PATHS[name]) {
|
||||||
|
const path = document.createElementNS(SVG_NS, "path");
|
||||||
|
path.setAttribute("d", d);
|
||||||
|
svg.append(path);
|
||||||
|
}
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings arm-then-confirm, icon-only: a trash glyph resting, the
|
||||||
|
* visible word "confirm" while armed, so an accidental hit never destroys.
|
||||||
|
*/
|
||||||
|
export function armedDeleteIcon(label: string, execute: () => void): HTMLButtonElement {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "control control-quiet control-icon";
|
||||||
|
button.setAttribute("aria-label", label);
|
||||||
|
button.title = label;
|
||||||
|
button.replaceChildren(icon("trash"));
|
||||||
|
let armed = false;
|
||||||
|
let resetTimer: number | undefined;
|
||||||
|
const disarm = () => {
|
||||||
|
armed = false;
|
||||||
|
window.clearTimeout(resetTimer);
|
||||||
|
delete button.dataset.armed;
|
||||||
|
button.setAttribute("aria-label", label);
|
||||||
|
button.replaceChildren(icon("trash"));
|
||||||
|
};
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (armed) {
|
||||||
|
disarm();
|
||||||
|
button.disabled = true;
|
||||||
|
execute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
armed = true;
|
||||||
|
button.dataset.armed = "true";
|
||||||
|
button.textContent = "confirm";
|
||||||
|
button.setAttribute("aria-label", `confirm — ${label}`);
|
||||||
|
resetTimer = window.setTimeout(disarm, 4000);
|
||||||
|
});
|
||||||
|
button.addEventListener("blur", () => {
|
||||||
|
if (armed) {
|
||||||
|
disarm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
+6
-1
@@ -70,6 +70,9 @@ export function movieNeedsAttention(movie: LibraryMovie): boolean {
|
|||||||
* `allow_english_audio` reads as "english, no dub", not as a clean match.
|
* `allow_english_audio` reads as "english, no dub", not as a clean match.
|
||||||
* The import pipeline records the relaxed rule; both the bare rule name and
|
* The import pipeline records the relaxed rule; both the bare rule name and
|
||||||
* an object carrying one are accepted.
|
* an object carrying one are accepted.
|
||||||
|
*
|
||||||
|
* Every arm names what was relaxed, never what the record calls it — the
|
||||||
|
* operator can act on "english, no dub" and cannot act on "waived".
|
||||||
*/
|
*/
|
||||||
export function waiverLabel(waiver: unknown): string | null {
|
export function waiverLabel(waiver: unknown): string | null {
|
||||||
const rule = waiverRule(waiver);
|
const rule = waiverRule(waiver);
|
||||||
@@ -81,8 +84,10 @@ export function waiverLabel(waiver: unknown): string | null {
|
|||||||
return "english, no dub";
|
return "english, no dub";
|
||||||
case "resolution":
|
case "resolution":
|
||||||
return "below wanted resolution";
|
return "below wanted resolution";
|
||||||
|
case "size":
|
||||||
|
return "below the size floor";
|
||||||
default:
|
default:
|
||||||
return `waived · ${rule.replaceAll("_", " ")}`;
|
return `${rule.replaceAll("_", " ")} relaxed`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+364
-83
@@ -5,6 +5,7 @@ import {
|
|||||||
probeHealth,
|
probeHealth,
|
||||||
type SubtitleHealth,
|
type SubtitleHealth,
|
||||||
} from "./health";
|
} from "./health";
|
||||||
|
import { armedDeleteIcon, icon, SVG_NS } from "./icons";
|
||||||
import {
|
import {
|
||||||
fetchLibrary,
|
fetchLibrary,
|
||||||
type LibrarySeries,
|
type LibrarySeries,
|
||||||
@@ -44,6 +45,9 @@ import {
|
|||||||
} from "./queues";
|
} from "./queues";
|
||||||
import {
|
import {
|
||||||
type ActionOutcome,
|
type ActionOutcome,
|
||||||
|
blacklistAdvice,
|
||||||
|
blacklistClass,
|
||||||
|
blacklistReasonLabel,
|
||||||
bucketOf,
|
bucketOf,
|
||||||
type FilesOutcome,
|
type FilesOutcome,
|
||||||
formatAudio,
|
formatAudio,
|
||||||
@@ -56,11 +60,14 @@ import {
|
|||||||
formatSource,
|
formatSource,
|
||||||
formatSweepAge,
|
formatSweepAge,
|
||||||
grabRelease,
|
grabRelease,
|
||||||
|
type ImportFailure,
|
||||||
libraryFolder,
|
libraryFolder,
|
||||||
type MovieRelease,
|
type MovieRelease,
|
||||||
movieFiles,
|
movieFiles,
|
||||||
movieReleases,
|
movieReleases,
|
||||||
movieSearchState,
|
movieSearchState,
|
||||||
|
overridable,
|
||||||
|
type PackStateOutcome,
|
||||||
probedAttributeTags,
|
probedAttributeTags,
|
||||||
queueSearch,
|
queueSearch,
|
||||||
removeMovie,
|
removeMovie,
|
||||||
@@ -70,7 +77,6 @@ import {
|
|||||||
totalSize,
|
totalSize,
|
||||||
type WaiveOutcome,
|
type WaiveOutcome,
|
||||||
waiveAndGrab,
|
waiveAndGrab,
|
||||||
waiverOverride,
|
|
||||||
} from "./releases";
|
} from "./releases";
|
||||||
import { currentRoute, navigate, type Route } from "./router";
|
import { currentRoute, navigate, type Route } from "./router";
|
||||||
import {
|
import {
|
||||||
@@ -112,9 +118,10 @@ import {
|
|||||||
setEpisodeWanted,
|
setEpisodeWanted,
|
||||||
setSeasonTracked,
|
setSeasonTracked,
|
||||||
type TvTarget,
|
type TvTarget,
|
||||||
|
updateSeries,
|
||||||
waiveAndGrabTv,
|
waiveAndGrabTv,
|
||||||
} from "./series";
|
} from "./series";
|
||||||
import { armedDelete, settingsMain } from "./settings";
|
import { settingsMain } from "./settings";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import {
|
import {
|
||||||
deleteSubtitle,
|
deleteSubtitle,
|
||||||
@@ -482,14 +489,14 @@ function main() {
|
|||||||
must<HTMLElement>("#library"),
|
must<HTMLElement>("#library"),
|
||||||
{ kind: "library" },
|
{ kind: "library" },
|
||||||
);
|
);
|
||||||
// no origin click to restore focus to on a deep link — the series
|
// no origin click to restore focus to on a deep link — the library
|
||||||
// view's back control is the closest stand-in
|
// rail button is the closest stand-in
|
||||||
tvDeck.open({
|
tvDeck.open({
|
||||||
title,
|
title,
|
||||||
sub,
|
sub,
|
||||||
seriesId,
|
seriesId,
|
||||||
target,
|
target,
|
||||||
origin: must<HTMLButtonElement>("#series-back"),
|
origin: must<HTMLElement>("#nav-library"),
|
||||||
returnTo: must<HTMLElement>("#series"),
|
returnTo: must<HTMLElement>("#series"),
|
||||||
parentRoute: { kind: "series", seriesId },
|
parentRoute: { kind: "series", seriesId },
|
||||||
});
|
});
|
||||||
@@ -808,6 +815,18 @@ function searchMain(
|
|||||||
return { showIdle, restore };
|
return { showIdle, restore };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the operator reads for the stored `blocked` flag. §6.3 gives it one
|
||||||
|
* effect — it stops the targeted-search lane and leaves RSS matching on —
|
||||||
|
* and "blocked" reads as "block this title outright", which is the wrong
|
||||||
|
* promise. The flag, the column and the API keep their name; only the words
|
||||||
|
* on screen say what it does. [`BLOCKED_EFFECT`] carries the RSS half.
|
||||||
|
*/
|
||||||
|
const NO_TARGETED_SEARCH = "no targeted search";
|
||||||
|
|
||||||
|
/** The half a three-word label cannot hold, for titles and accessible names. */
|
||||||
|
const BLOCKED_EFFECT = "arr stops searching for this title; RSS still matches it";
|
||||||
|
|
||||||
function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
|
function chip(text: string, extra?: (chip: HTMLSpanElement) => void): HTMLSpanElement {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "chip readout";
|
span.className = "chip readout";
|
||||||
@@ -1750,7 +1769,7 @@ function libraryRow(
|
|||||||
chips.append(chip("not wanted"));
|
chips.append(chip("not wanted"));
|
||||||
}
|
}
|
||||||
if (movie.blocked) {
|
if (movie.blocked) {
|
||||||
chips.append(chip("blocked"));
|
chips.append(chip(NO_TARGETED_SEARCH));
|
||||||
}
|
}
|
||||||
const rating = ratingChip(movie.vote_average);
|
const rating = ratingChip(movie.vote_average);
|
||||||
if (rating !== null) {
|
if (rating !== null) {
|
||||||
@@ -1783,7 +1802,7 @@ function librarySeriesRow(
|
|||||||
const root = roots.find((candidate) => candidate.id === series.root_id);
|
const root = roots.find((candidate) => candidate.id === series.root_id);
|
||||||
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
|
chips.append(chip(root ? root.audience : `root ${series.root_id}`));
|
||||||
if (series.blocked) {
|
if (series.blocked) {
|
||||||
chips.append(chip("blocked"));
|
chips.append(chip(NO_TARGETED_SEARCH));
|
||||||
}
|
}
|
||||||
const rating = ratingChip(series.vote_average);
|
const rating = ratingChip(series.vote_average);
|
||||||
if (rating !== null) {
|
if (rating !== null) {
|
||||||
@@ -2083,6 +2102,48 @@ function wireCollapsedToggle(bucket: CollapsedBucketDom) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- icon-only controls (#230) ----------------------------------------- */
|
||||||
|
|
||||||
|
// icon() and armedDeleteIcon() live in icons.ts, shared with the settings
|
||||||
|
// rows (#231); the §9.6 painters below stay here as their only consumer.
|
||||||
|
|
||||||
|
/** §9.6 provider marks, drawn and shipped inline — never a remote image. */
|
||||||
|
function providerMark(name: "tmdb" | "tvdb" | "imdb" | "rt"): SVGSVGElement {
|
||||||
|
const svg = document.createElementNS(SVG_NS, "svg");
|
||||||
|
svg.setAttribute("class", "icon icon-mark");
|
||||||
|
svg.setAttribute("aria-hidden", "true");
|
||||||
|
if (name === "rt") {
|
||||||
|
svg.setAttribute("viewBox", "0 0 20 20");
|
||||||
|
const paths = [
|
||||||
|
"M10 6.6c4.1 0 6.8 2.1 6.8 5.2 0 3.3-3 5.7-6.8 5.7s-6.8-2.4-6.8-5.7c0-3.1 2.7-5.2 6.8-5.2Z",
|
||||||
|
"M10 6.6C8.6 7.1 7 6.8 5.9 5.8",
|
||||||
|
"M10 6.6c1.4.5 3 .2 4.1-.8",
|
||||||
|
"M10 6.6c-.3-1.2 0-2.3.9-3.2",
|
||||||
|
];
|
||||||
|
for (const d of paths) {
|
||||||
|
const path = document.createElementNS(SVG_NS, "path");
|
||||||
|
path.setAttribute("d", d);
|
||||||
|
svg.append(path);
|
||||||
|
}
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
svg.setAttribute("viewBox", "0 0 44 20");
|
||||||
|
const box = document.createElementNS(SVG_NS, "rect");
|
||||||
|
box.setAttribute("x", "1");
|
||||||
|
box.setAttribute("y", "1.5");
|
||||||
|
box.setAttribute("width", "42");
|
||||||
|
box.setAttribute("height", "17");
|
||||||
|
box.setAttribute("rx", "4");
|
||||||
|
const letters = document.createElementNS(SVG_NS, "text");
|
||||||
|
letters.setAttribute("x", "22");
|
||||||
|
letters.setAttribute("y", "13.2");
|
||||||
|
letters.setAttribute("text-anchor", "middle");
|
||||||
|
letters.setAttribute("font-size", "9");
|
||||||
|
letters.textContent = { tmdb: "TMDB", tvdb: "TVDB", imdb: "IMDb" }[name];
|
||||||
|
svg.append(box, letters);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- §9.6 painters shared by both detail pages ------------------------- */
|
/* ---- §9.6 painters shared by both detail pages ------------------------- */
|
||||||
|
|
||||||
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
|
/** The drawn star before a TMDB rating — no glyph standing in for an icon. */
|
||||||
@@ -2116,20 +2177,21 @@ function closeIcon(): SVGSVGElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** External links read as quiet controls; they leave the app entirely. */
|
/** External links read as quiet controls; they leave the app entirely. */
|
||||||
function externalLink(label: string, href: string): HTMLAnchorElement {
|
function externalLink(mark: SVGSVGElement, label: string, href: string): HTMLAnchorElement {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.className = "control control-quiet";
|
link.className = "control control-quiet control-icon";
|
||||||
link.href = href;
|
link.href = href;
|
||||||
link.target = "_blank";
|
link.target = "_blank";
|
||||||
link.rel = "noreferrer";
|
link.rel = "noreferrer";
|
||||||
link.textContent = label;
|
link.setAttribute("aria-label", label);
|
||||||
|
link.title = label;
|
||||||
|
link.append(mark);
|
||||||
return link;
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
function movieMain(views: HideableView[]): MovieView {
|
function movieMain(views: HideableView[]): MovieView {
|
||||||
const view = must<HTMLElement>("#movie");
|
const view = must<HTMLElement>("#movie");
|
||||||
const deckEl = must<HTMLElement>("#deck");
|
const deckEl = must<HTMLElement>("#deck");
|
||||||
const back = must<HTMLButtonElement>("#movie-back");
|
|
||||||
const statusEl = must<HTMLElement>("#movie-status");
|
const statusEl = must<HTMLElement>("#movie-status");
|
||||||
const hero = must<HTMLElement>("#movie-hero");
|
const hero = must<HTMLElement>("#movie-hero");
|
||||||
const poster = must<HTMLImageElement>("#movie-poster");
|
const poster = must<HTMLImageElement>("#movie-poster");
|
||||||
@@ -2143,6 +2205,10 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
const actionsEl = must<HTMLElement>("#movie-actions");
|
const actionsEl = must<HTMLElement>("#movie-actions");
|
||||||
const wanted = must<HTMLButtonElement>("#movie-wanted");
|
const wanted = must<HTMLButtonElement>("#movie-wanted");
|
||||||
const blocked = must<HTMLButtonElement>("#movie-blocked");
|
const blocked = must<HTMLButtonElement>("#movie-blocked");
|
||||||
|
const blockedNote = must<HTMLElement>("#movie-blocked-note");
|
||||||
|
// the accessible name carries what three words cannot, at rest and on
|
||||||
|
blocked.setAttribute("aria-label", `${NO_TARGETED_SEARCH} — ${BLOCKED_EFFECT}`);
|
||||||
|
blocked.title = BLOCKED_EFFECT;
|
||||||
const rootSelect = must<HTMLSelectElement>("#movie-root");
|
const rootSelect = must<HTMLSelectElement>("#movie-root");
|
||||||
const sweep = must<HTMLButtonElement>("#movie-sweep");
|
const sweep = must<HTMLButtonElement>("#movie-sweep");
|
||||||
const remove = must<HTMLButtonElement>("#movie-remove");
|
const remove = must<HTMLButtonElement>("#movie-remove");
|
||||||
@@ -2189,7 +2255,7 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
if (!movie) {
|
if (!movie) {
|
||||||
return { kind: "error", detail: "page closed", overrideWritten: false };
|
return { kind: "error", detail: "page closed", overrideWritten: false };
|
||||||
}
|
}
|
||||||
if (bucket === "waived") {
|
if (bucket !== "eligible") {
|
||||||
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
|
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
|
||||||
}
|
}
|
||||||
const outcome = await grabRelease(movie.id, release.id);
|
const outcome = await grabRelease(movie.id, release.id);
|
||||||
@@ -2251,6 +2317,8 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
}
|
}
|
||||||
titleEl.textContent = movie.title;
|
titleEl.textContent = movie.title;
|
||||||
yearEl.textContent = movie.year === null ? "" : String(movie.year);
|
yearEl.textContent = movie.year === null ? "" : String(movie.year);
|
||||||
|
remove.setAttribute("aria-label", `remove ${movie.title} from the library`);
|
||||||
|
remove.title = `remove ${movie.title} from the library`;
|
||||||
chipsEl.replaceChildren();
|
chipsEl.replaceChildren();
|
||||||
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
const root = roots.find((candidate) => candidate.id === movie.root_id);
|
||||||
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
|
chipsEl.append(chip(root ? root.audience : `root ${movie.root_id}`));
|
||||||
@@ -2265,7 +2333,7 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (movie.blocked) {
|
if (movie.blocked) {
|
||||||
chipsEl.append(chip("blocked"));
|
chipsEl.append(chip(NO_TARGETED_SEARCH));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2286,6 +2354,10 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
}
|
}
|
||||||
wanted.setAttribute("aria-pressed", String(movie.wanted));
|
wanted.setAttribute("aria-pressed", String(movie.wanted));
|
||||||
blocked.setAttribute("aria-pressed", String(movie.blocked));
|
blocked.setAttribute("aria-pressed", String(movie.blocked));
|
||||||
|
// §6.3 is the whole point of the flag and a bare toggle hides it: the
|
||||||
|
// note appears when the flag is on, which is when the RSS half is the
|
||||||
|
// thing that surprises. At rest the label and the title carry it.
|
||||||
|
blockedNote.hidden = !movie.blocked;
|
||||||
paintRootOptions();
|
paintRootOptions();
|
||||||
// a root list that does not carry the title's own root still shows it —
|
// a root list that does not carry the title's own root still shows it —
|
||||||
// a select lying by omission would make the next change move it blindly
|
// a select lying by omission would make the next change move it blindly
|
||||||
@@ -2340,7 +2412,7 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => {
|
void updateMovie(movie.id, { blocked: !movie.blocked }).then((outcome) => {
|
||||||
if (outcome.kind === "error") {
|
if (outcome.kind === "error") {
|
||||||
blocked.disabled = false;
|
blocked.disabled = false;
|
||||||
setStatus(`blocked failed — ${outcome.detail}`, "fault");
|
setStatus(`targeted search change failed — ${outcome.detail}`, "fault");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshMovie().then(() => {
|
void refreshMovie().then(() => {
|
||||||
@@ -2443,13 +2515,26 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
trailer.textContent = "trailer";
|
trailer.textContent = "trailer";
|
||||||
actionsEl.append(trailer);
|
actionsEl.append(trailer);
|
||||||
}
|
}
|
||||||
actionsEl.append(externalLink("tmdb", tmdbMovieLink(detail.tmdb_id)));
|
const linkTitle = current?.title ?? "this movie";
|
||||||
|
actionsEl.append(
|
||||||
|
externalLink(
|
||||||
|
providerMark("tmdb"),
|
||||||
|
`open ${linkTitle} on TMDB`,
|
||||||
|
tmdbMovieLink(detail.tmdb_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
if (detail.imdb_id !== null) {
|
if (detail.imdb_id !== null) {
|
||||||
actionsEl.append(externalLink("imdb", imdbLink(detail.imdb_id)));
|
actionsEl.append(
|
||||||
|
externalLink(providerMark("imdb"), `open ${linkTitle} on IMDb`, imdbLink(detail.imdb_id)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (current !== null) {
|
if (current !== null) {
|
||||||
actionsEl.append(
|
actionsEl.append(
|
||||||
externalLink("rotten tomatoes", rottenTomatoesSearch(current.title, current.year)),
|
externalLink(
|
||||||
|
providerMark("rt"),
|
||||||
|
`search Rotten Tomatoes for ${current.title}`,
|
||||||
|
rottenTomatoesSearch(current.title, current.year),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2777,7 +2862,8 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
filesSection.hidden = true;
|
filesSection.hidden = true;
|
||||||
diskRows.replaceChildren();
|
diskRows.replaceChildren();
|
||||||
sweep.disabled = false;
|
sweep.disabled = false;
|
||||||
back.focus();
|
titleEl.setAttribute("tabindex", "-1");
|
||||||
|
titleEl.focus();
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2800,8 +2886,6 @@ function movieMain(views: HideableView[]): MovieView {
|
|||||||
target?.focus();
|
target?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
back.addEventListener("click", close);
|
|
||||||
|
|
||||||
// capture + stopImmediatePropagation: one Escape steps back one layer —
|
// capture + stopImmediatePropagation: one Escape steps back one layer —
|
||||||
// the library and search decks also listen for Escape on this window
|
// the library and search decks also listen for Escape on this window
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
@@ -2962,7 +3046,8 @@ function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElemen
|
|||||||
interface ReleaseActions {
|
interface ReleaseActions {
|
||||||
reload: () => Promise<void>;
|
reload: () => Promise<void>;
|
||||||
notify: (text: string, tone?: "fault") => 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: (
|
grab: (
|
||||||
release: MovieRelease,
|
release: MovieRelease,
|
||||||
bucket: "eligible" | "waived" | "rejected",
|
bucket: "eligible" | "waived" | "rejected",
|
||||||
@@ -3026,10 +3111,23 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas
|
|||||||
dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions));
|
dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions));
|
||||||
}
|
}
|
||||||
} else {
|
} 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.
|
||||||
|
//
|
||||||
|
// Only a rejected row needs forcing. A row below policy is offered
|
||||||
|
// already, one click, whatever its rule — and since #211 gave it a rule
|
||||||
|
// at all, counting every overridable row here claimed the whole
|
||||||
|
// collapsed deck had to be forced.
|
||||||
|
const forceable = releases.filter(
|
||||||
|
(release) => bucketOf(release) === "rejected" && overridable(release),
|
||||||
|
).length;
|
||||||
const none = document.createElement("li");
|
const none = document.createElement("li");
|
||||||
none.className = "rel rel-none readout dim";
|
none.className = "rel rel-none readout dim";
|
||||||
none.textContent = "none — every candidate was waived or rejected by policy";
|
none.textContent =
|
||||||
|
forceable > 0
|
||||||
|
? `none — every candidate is below policy or rejected; ${forceable} can be force grabbed`
|
||||||
|
: "none — every candidate is below policy or rejected";
|
||||||
dom.eligible.rows.append(none);
|
dom.eligible.rows.append(none);
|
||||||
}
|
}
|
||||||
for (const name of ["waived", "rejected"] as const) {
|
for (const name of ["waived", "rejected"] as const) {
|
||||||
@@ -3116,7 +3214,7 @@ function buildBucketDom(root: HTMLElement): BucketsDom {
|
|||||||
|
|
||||||
const dom: BucketsDom = {
|
const dom: BucketsDom = {
|
||||||
eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows },
|
eligible: { section: eligibleSection, count: eligibleCount, rows: eligibleRows },
|
||||||
waived: collapsed("waived"),
|
waived: collapsed("below policy"),
|
||||||
rejected: collapsed("rejected"),
|
rejected: collapsed("rejected"),
|
||||||
};
|
};
|
||||||
root.append(dom.eligible.section, dom.waived.section, dom.rejected.section);
|
root.append(dom.eligible.section, dom.waived.section, dom.rejected.section);
|
||||||
@@ -3165,10 +3263,36 @@ function releaseRow(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// #227: a blacklisted release was grabbed, downloaded and condemned at
|
||||||
|
// import. Which of the two things happened decides what the operator does
|
||||||
|
// next, and `blacklisted` alone reads the same for both.
|
||||||
|
const blacklisted =
|
||||||
|
bucket === "rejected" && release.rejected_rule === "blacklisted"
|
||||||
|
? blacklistClass(release.blacklist_reason)
|
||||||
|
: null;
|
||||||
if (bucket !== "eligible") {
|
if (bucket !== "eligible") {
|
||||||
|
// §9.3: every row that is not eligible names the rule behind it, waived
|
||||||
|
// and rejected alike — three waivers for three different reasons read
|
||||||
|
// identically otherwise, and reading release names to tell them apart is
|
||||||
|
// the Radarr defect this view exists to fix. #211 gave a waived row the
|
||||||
|
// rule it relaxed; a row written before it still has none, and says the
|
||||||
|
// plainer thing alone.
|
||||||
|
const verdict =
|
||||||
|
bucket === "waived"
|
||||||
|
? release.rejected_rule
|
||||||
|
? `below policy · ${ruleLabel(release.rejected_rule)}`
|
||||||
|
: "below policy"
|
||||||
|
: blacklisted
|
||||||
|
? `blacklisted · ${blacklistReasonLabel(release.blacklist_reason)}`
|
||||||
|
: release.rejected_rule
|
||||||
|
? `rejected · ${ruleLabel(release.rejected_rule)}`
|
||||||
|
: "rejected";
|
||||||
line.append(
|
line.append(
|
||||||
chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => {
|
chip(verdict, (span) => {
|
||||||
span.dataset.verdict = bucket;
|
span.dataset.verdict = bucket;
|
||||||
|
if (blacklisted) {
|
||||||
|
span.dataset.blacklist = blacklisted;
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3177,6 +3301,13 @@ function releaseRow(
|
|||||||
name.className = "rel-name readout";
|
name.className = "rel-name readout";
|
||||||
name.textContent = release.name;
|
name.textContent = release.name;
|
||||||
line.append(name);
|
line.append(name);
|
||||||
|
if (blacklisted) {
|
||||||
|
const why = document.createElement("span");
|
||||||
|
why.className = "rel-why readout";
|
||||||
|
why.dataset.blacklist = blacklisted;
|
||||||
|
why.textContent = blacklistAdvice(release.blacklist_reason);
|
||||||
|
line.append(why);
|
||||||
|
}
|
||||||
item.append(line);
|
item.append(line);
|
||||||
|
|
||||||
const note = document.createElement("span");
|
const note = document.createElement("span");
|
||||||
@@ -3184,12 +3315,22 @@ function releaseRow(
|
|||||||
note.setAttribute("role", "status");
|
note.setAttribute("role", "status");
|
||||||
note.hidden = true;
|
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");
|
const grab = document.createElement("button");
|
||||||
grab.type = "button";
|
grab.type = "button";
|
||||||
grab.className = "control rel-grab";
|
grab.className = "control rel-grab";
|
||||||
const writesOverride = bucket === "waived" && waiverOverride(release.rejected_rule) !== null;
|
// "force grab" says what the click does; the accessible name says what
|
||||||
grab.textContent = writesOverride ? "waive + grab" : "grab";
|
// it forces past, since the rule is the reason the row is not eligible.
|
||||||
|
grab.textContent = writesOverride ? "force grab" : "grab";
|
||||||
|
if (writesOverride) {
|
||||||
|
const forced = `force grab — relaxes ${ruleLabel(release.rejected_rule)} for this title and grabs anyway`;
|
||||||
|
grab.setAttribute("aria-label", forced);
|
||||||
|
grab.title = forced;
|
||||||
|
}
|
||||||
grab.addEventListener("click", () => {
|
grab.addEventListener("click", () => {
|
||||||
grab.disabled = true;
|
grab.disabled = true;
|
||||||
note.hidden = false;
|
note.hidden = false;
|
||||||
@@ -3462,7 +3603,7 @@ function seriesRow(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (series.blocked) {
|
if (series.blocked) {
|
||||||
chips.append(chip("blocked"));
|
chips.append(chip(NO_TARGETED_SEARCH));
|
||||||
}
|
}
|
||||||
chips.append(
|
chips.append(
|
||||||
chip(series.status, (span) => {
|
chip(series.status, (span) => {
|
||||||
@@ -3574,7 +3715,7 @@ function seriesCard(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (series.blocked) {
|
if (series.blocked) {
|
||||||
chips.push(chip("blocked"));
|
chips.push(chip(NO_TARGETED_SEARCH));
|
||||||
}
|
}
|
||||||
chips.push(
|
chips.push(
|
||||||
chip(series.status, (span) => {
|
chip(series.status, (span) => {
|
||||||
@@ -3611,7 +3752,7 @@ function movieCard(
|
|||||||
chips.push(chip("not wanted"));
|
chips.push(chip("not wanted"));
|
||||||
}
|
}
|
||||||
if (movie.blocked) {
|
if (movie.blocked) {
|
||||||
chips.push(chip("blocked"));
|
chips.push(chip(NO_TARGETED_SEARCH));
|
||||||
}
|
}
|
||||||
const rating = ratingChip(movie.vote_average);
|
const rating = ratingChip(movie.vote_average);
|
||||||
if (rating !== null) {
|
if (rating !== null) {
|
||||||
@@ -3650,6 +3791,10 @@ function tvReleasesMain(): TvReleasesView {
|
|||||||
const sub = must<HTMLElement>("#tv-releases-sub");
|
const sub = must<HTMLElement>("#tv-releases-sub");
|
||||||
const sweep = must<HTMLButtonElement>("#tv-releases-sweep");
|
const sweep = must<HTMLButtonElement>("#tv-releases-sweep");
|
||||||
const statusEl = must<HTMLElement>("#tv-releases-status");
|
const statusEl = must<HTMLElement>("#tv-releases-status");
|
||||||
|
const failureEl = must<HTMLElement>("#tv-releases-failure");
|
||||||
|
const failureRelease = must<HTMLElement>("#tv-failure-release");
|
||||||
|
const failureWhat = must<HTMLElement>("#tv-failure-what");
|
||||||
|
const failureNext = must<HTMLElement>("#tv-failure-next");
|
||||||
const dom = buildBucketDom(must<HTMLElement>("#tv-buckets"));
|
const dom = buildBucketDom(must<HTMLElement>("#tv-buckets"));
|
||||||
|
|
||||||
let request: TvDeckRequest | null = null;
|
let request: TvDeckRequest | null = null;
|
||||||
@@ -3692,15 +3837,49 @@ function tvReleasesMain(): TvReleasesView {
|
|||||||
statusEl.dataset.action = "";
|
statusEl.dataset.action = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #227: the season's own history, above the candidates. A pack that
|
||||||
|
* downloaded in full and was condemned at import (§5.7) blacklists the
|
||||||
|
* release and puts every episode back to `missing`, which leaves the season
|
||||||
|
* reading `0/10` as though nothing had ever been tried. It sits outside the
|
||||||
|
* status line because it is a fact about the season, not about the request
|
||||||
|
* in flight, and it has to survive a sweep that repaints the status.
|
||||||
|
*/
|
||||||
|
function paintFailure(failure: ImportFailure | null) {
|
||||||
|
if (failure === null) {
|
||||||
|
failureEl.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failureEl.dataset.blacklist = blacklistClass(failure.reason);
|
||||||
|
failureEl.hidden = false;
|
||||||
|
failureRelease.textContent =
|
||||||
|
failure.failed_at === null
|
||||||
|
? failure.release
|
||||||
|
: `${failure.release} · ${formatSweepAge(failure.failed_at)}`;
|
||||||
|
// A rule name reads as a preposition — "condemned on size"; a reason
|
||||||
|
// written as a sentence has to be quoted, not conjugated.
|
||||||
|
const on =
|
||||||
|
blacklistClass(failure.reason) === "policy"
|
||||||
|
? ` on ${blacklistReasonLabel(failure.reason)}`
|
||||||
|
: "";
|
||||||
|
const said = blacklistClass(failure.reason) === "release" ? `${failure.reason}. ` : "";
|
||||||
|
failureWhat.textContent = `downloaded in full, then condemned at import${on} — ${said}every episode went back to missing and the release is blacklisted.`;
|
||||||
|
failureNext.textContent = `${blacklistAdvice(failure.reason)}.`;
|
||||||
|
}
|
||||||
|
|
||||||
const actions: ReleaseActions = {
|
const actions: ReleaseActions = {
|
||||||
reload: () => load(),
|
reload: () => load(),
|
||||||
notify: (text, tone) => setStatus(text, tone),
|
notify: (text, tone) => setStatus(text, tone),
|
||||||
grab: (release, bucket) => {
|
grab: (release, bucket) => {
|
||||||
const current = request;
|
const current = request;
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false });
|
return Promise.resolve({
|
||||||
|
kind: "error",
|
||||||
|
detail: "releases closed",
|
||||||
|
overrideWritten: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (bucket === "waived") {
|
if (bucket !== "eligible") {
|
||||||
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
|
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
|
||||||
}
|
}
|
||||||
return current.target
|
return current.target
|
||||||
@@ -3731,9 +3910,17 @@ function tvReleasesMain(): TvReleasesView {
|
|||||||
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
|
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!paintBuckets(dom, outcome.releases, actions)) {
|
const painted = paintBuckets(dom, outcome.releases, actions);
|
||||||
|
// One read of the season's state, shared by the notice and the empty
|
||||||
|
// verdict below — they answer two questions from the same row.
|
||||||
|
const pack = await current.target.packState?.();
|
||||||
|
if (ticket !== sequence || request !== current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
paintFailure(pack?.kind === "state" ? pack.state.import_failure : null);
|
||||||
|
if (!painted) {
|
||||||
clearBuckets(dom);
|
clearBuckets(dom);
|
||||||
await emptyVerdict(current, ticket, sweepIfEmpty);
|
await emptyVerdict(current, ticket, sweepIfEmpty, pack);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
@@ -3747,8 +3934,13 @@ function tvReleasesMain(): TvReleasesView {
|
|||||||
* answers the third; `last_pack_search_at` separates the first two, the
|
* answers the third; `last_pack_search_at` separates the first two, the
|
||||||
* same way a movie's `last_searched_at` does (#177).
|
* same way a movie's `last_searched_at` does (#177).
|
||||||
*/
|
*/
|
||||||
async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) {
|
async function emptyVerdict(
|
||||||
const outcome = await current.target.packState?.();
|
current: TvDeckRequest,
|
||||||
|
ticket: number,
|
||||||
|
sweepIfEmpty: boolean,
|
||||||
|
known?: PackStateOutcome,
|
||||||
|
) {
|
||||||
|
const outcome = known ?? (await current.target.packState?.());
|
||||||
if (ticket !== sequence || request !== current) {
|
if (ticket !== sequence || request !== current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3840,6 +4032,9 @@ function tvReleasesMain(): TvReleasesView {
|
|||||||
if (ticket !== sequence || request !== current) {
|
if (ticket !== sequence || request !== current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state?.kind === "state") {
|
||||||
|
paintFailure(state.state.import_failure);
|
||||||
|
}
|
||||||
if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) {
|
if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) {
|
||||||
sweep.disabled = false;
|
sweep.disabled = false;
|
||||||
await emptyVerdict(current, ticket, false);
|
await emptyVerdict(current, ticket, false);
|
||||||
@@ -3912,6 +4107,7 @@ function tvReleasesMain(): TvReleasesView {
|
|||||||
next.returnTo.hidden = true;
|
next.returnTo.hidden = true;
|
||||||
view.hidden = false;
|
view.hidden = false;
|
||||||
clearBuckets(dom);
|
clearBuckets(dom);
|
||||||
|
paintFailure(null);
|
||||||
sweep.disabled = false;
|
sweep.disabled = false;
|
||||||
back.focus();
|
back.focus();
|
||||||
void load(true);
|
void load(true);
|
||||||
@@ -3980,7 +4176,6 @@ const SERIES_REFRESH_WAIT_MS = 30_000;
|
|||||||
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||||
const view = must<HTMLElement>("#series");
|
const view = must<HTMLElement>("#series");
|
||||||
const deckEl = must<HTMLElement>("#deck");
|
const deckEl = must<HTMLElement>("#deck");
|
||||||
const back = must<HTMLButtonElement>("#series-back");
|
|
||||||
const hero = must<HTMLElement>("#series-hero");
|
const hero = must<HTMLElement>("#series-hero");
|
||||||
const poster = must<HTMLImageElement>("#series-poster");
|
const poster = must<HTMLImageElement>("#series-poster");
|
||||||
const titleEl = must<HTMLElement>("#series-title");
|
const titleEl = must<HTMLElement>("#series-title");
|
||||||
@@ -3993,6 +4188,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
const actionsEl = must<HTMLElement>("#series-actions");
|
const actionsEl = must<HTMLElement>("#series-actions");
|
||||||
const statusEl = must<HTMLElement>("#series-status");
|
const statusEl = must<HTMLElement>("#series-status");
|
||||||
const seasonsList = must<HTMLUListElement>("#rows-seasons");
|
const seasonsList = must<HTMLUListElement>("#rows-seasons");
|
||||||
|
const rootSelect = must<HTMLSelectElement>("#series-root");
|
||||||
const remove = must<HTMLButtonElement>("#series-remove");
|
const remove = must<HTMLButtonElement>("#series-remove");
|
||||||
const removeWrap = must<HTMLElement>("#series-remove-panel");
|
const removeWrap = must<HTMLElement>("#series-remove-panel");
|
||||||
|
|
||||||
@@ -4087,6 +4283,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
}
|
}
|
||||||
titleEl.textContent = current.title;
|
titleEl.textContent = current.title;
|
||||||
yearEl.textContent = current.year === null ? "" : String(current.year);
|
yearEl.textContent = current.year === null ? "" : String(current.year);
|
||||||
|
remove.setAttribute("aria-label", `remove ${current.title} from the library`);
|
||||||
|
remove.title = `remove ${current.title} from the library`;
|
||||||
chipsEl.replaceChildren();
|
chipsEl.replaceChildren();
|
||||||
const root = roots.find((candidate) => candidate.id === current.root_id);
|
const root = roots.find((candidate) => candidate.id === current.root_id);
|
||||||
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
|
chipsEl.append(chip(root ? root.audience : `root ${current.root_id}`));
|
||||||
@@ -4104,10 +4302,53 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (current.blocked) {
|
if (current.blocked) {
|
||||||
chipsEl.append(chip("blocked"));
|
chipsEl.append(chip(NO_TARGETED_SEARCH));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paintRootControl() {
|
||||||
|
const current = series;
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rootSelect.replaceChildren();
|
||||||
|
for (const root of roots.filter((candidate) => candidate.kind === "tv")) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = String(root.id);
|
||||||
|
option.textContent = `${root.audience} · ${root.policy_name}`;
|
||||||
|
rootSelect.append(option);
|
||||||
|
}
|
||||||
|
if (rootSelect.value !== String(current.root_id)) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = String(current.root_id);
|
||||||
|
option.textContent = `root ${current.root_id}`;
|
||||||
|
rootSelect.append(option);
|
||||||
|
}
|
||||||
|
rootSelect.value = String(current.root_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
rootSelect.addEventListener("change", () => {
|
||||||
|
const current = series;
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = Number(rootSelect.value);
|
||||||
|
if (!Number.isInteger(next) || next === current.root_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rootSelect.disabled = true;
|
||||||
|
void updateSeries(current.id, { root_id: next }).then((outcome) => {
|
||||||
|
rootSelect.disabled = false;
|
||||||
|
if (outcome.kind === "error") {
|
||||||
|
rootSelect.value = String(current.root_id);
|
||||||
|
setStatus(`root change failed — ${outcome.detail}`, "fault");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
focusKey = "series-root";
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
|
/* ---- §9.6 rich detail: one request, images hotlinked ---- */
|
||||||
|
|
||||||
function clearRichDetail() {
|
function clearRichDetail() {
|
||||||
@@ -4189,26 +4430,41 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
trailer.textContent = "trailer";
|
trailer.textContent = "trailer";
|
||||||
actionsEl.append(trailer);
|
actionsEl.append(trailer);
|
||||||
}
|
}
|
||||||
actionsEl.append(externalLink("tmdb", tmdbSeriesLink(detail.tmdb_id)));
|
const linkTitle = series?.title ?? "this series";
|
||||||
|
actionsEl.append(
|
||||||
|
externalLink(
|
||||||
|
providerMark("tmdb"),
|
||||||
|
`open ${linkTitle} on TMDB`,
|
||||||
|
tmdbSeriesLink(detail.tmdb_id),
|
||||||
|
),
|
||||||
|
);
|
||||||
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
|
// a series has no imdb_id in this app; TVDB is its second id (§9.6)
|
||||||
if (detail.tvdb_id !== null) {
|
if (detail.tvdb_id !== null) {
|
||||||
actionsEl.append(externalLink("tvdb", tvdbLink(detail.tvdb_id)));
|
actionsEl.append(
|
||||||
|
externalLink(providerMark("tvdb"), `open ${linkTitle} on TVDB`, tvdbLink(detail.tvdb_id)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (series !== null) {
|
if (series !== null) {
|
||||||
actionsEl.append(
|
actionsEl.append(
|
||||||
externalLink("rotten tomatoes", rottenTomatoesSearch(series.title, series.year)),
|
externalLink(
|
||||||
|
providerMark("rt"),
|
||||||
|
`search Rotten Tomatoes for ${series.title}`,
|
||||||
|
rottenTomatoesSearch(series.title, series.year),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
|
function seasonCountsChip(season: ApiSeason): HTMLSpanElement {
|
||||||
const counts = seasonCounts(season);
|
const counts = seasonCounts(season);
|
||||||
return countsChip(
|
const span = countsChip(
|
||||||
`${counts.available}/${counts.wanted} on disk`,
|
`${counts.available}/${counts.wanted}`,
|
||||||
counts.available,
|
counts.available,
|
||||||
counts.wanted,
|
counts.wanted,
|
||||||
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
|
`${counts.available} of ${counts.wanted} wanted episodes on disk`,
|
||||||
);
|
);
|
||||||
|
span.prepend(icon("disk"));
|
||||||
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
function seasonRow(season: ApiSeason): HTMLLIElement {
|
function seasonRow(season: ApiSeason): HTMLLIElement {
|
||||||
@@ -4275,8 +4531,14 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
|
|
||||||
const deckBtn = document.createElement("button");
|
const deckBtn = document.createElement("button");
|
||||||
deckBtn.type = "button";
|
deckBtn.type = "button";
|
||||||
deckBtn.className = "control";
|
deckBtn.className = "control control-icon";
|
||||||
deckBtn.textContent = "deck";
|
const deckLabel =
|
||||||
|
season.number === 0
|
||||||
|
? "search releases for specials"
|
||||||
|
: `search releases for season ${PAD_TWO(season.number)}`;
|
||||||
|
deckBtn.setAttribute("aria-label", deckLabel);
|
||||||
|
deckBtn.title = deckLabel;
|
||||||
|
deckBtn.append(icon("search"));
|
||||||
deckBtn.addEventListener("click", () => {
|
deckBtn.addEventListener("click", () => {
|
||||||
const currentId = seriesId;
|
const currentId = seriesId;
|
||||||
const currentTitle = series?.title ?? "";
|
const currentTitle = series?.title ?? "";
|
||||||
@@ -4296,6 +4558,24 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
line.append(disclose, name, track, seasonCountsChip(season));
|
line.append(disclose, name, track, seasonCountsChip(season));
|
||||||
|
// #227: the season the operator's report is about read `0/10` with
|
||||||
|
// nothing saying a pack had been grabbed, downloaded in full and thrown
|
||||||
|
// out at import. The counts chip beside this one is exactly the number
|
||||||
|
// that looked like nothing was ever tried.
|
||||||
|
const failure = season.import_failure;
|
||||||
|
if (failure) {
|
||||||
|
const kind = blacklistClass(failure.reason);
|
||||||
|
const when = failure.failed_at === null ? "" : ` ${formatSweepAge(failure.failed_at)}`;
|
||||||
|
const told = `a pack for this season downloaded in full and failed at import${when} — ${failure.release} · ${blacklistAdvice(failure.reason)}`;
|
||||||
|
line.append(
|
||||||
|
chip(`import failed · ${blacklistReasonLabel(failure.reason)}`, (span) => {
|
||||||
|
span.dataset.flag = "import-failed";
|
||||||
|
span.dataset.blacklist = kind;
|
||||||
|
span.title = told;
|
||||||
|
span.setAttribute("aria-label", told);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
// The season-level twin of the episode flag above: gone upstream while
|
// The season-level twin of the episode flag above: gone upstream while
|
||||||
// files under it remained.
|
// files under it remained.
|
||||||
if (season.vanished) {
|
if (season.vanished) {
|
||||||
@@ -4309,26 +4589,25 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
// #174: files go, episodes stop being wanted, the season stays listed.
|
// #174: files go, episodes stop being wanted, the season stays listed.
|
||||||
// Only offered with files on disk — intent alone is the tracked toggle.
|
// Only offered with files on disk — intent alone is the tracked toggle.
|
||||||
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
|
if (season.episodes.some((episode) => filesByEpisode.has(episode.id))) {
|
||||||
const clear = armedDelete("remove files", () => {
|
const clear = armedDeleteIcon(
|
||||||
const currentId = seriesId;
|
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
|
||||||
if (currentId === null) {
|
() => {
|
||||||
return;
|
const currentId = seriesId;
|
||||||
}
|
if (currentId === null) {
|
||||||
void removeSeasonFiles(currentId, season.number).then((outcome) => {
|
|
||||||
if (outcome.kind === "error") {
|
|
||||||
clear.disabled = false;
|
|
||||||
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// the control itself disappears with the files; the tracked
|
void removeSeasonFiles(currentId, season.number).then((outcome) => {
|
||||||
// toggle is the season's control that survives the repaint
|
if (outcome.kind === "error") {
|
||||||
focusKey = `track-${season.number}`;
|
clear.disabled = false;
|
||||||
void load();
|
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
||||||
});
|
return;
|
||||||
});
|
}
|
||||||
clear.setAttribute(
|
// the control itself disappears with the files; the tracked
|
||||||
"aria-label",
|
// toggle is the season's control that survives the repaint
|
||||||
`remove ${season.number === 0 ? "specials" : `season ${PAD_TWO(season.number)}`} files from disk and stop wanting its episodes — the season stays listed`,
|
focusKey = `track-${season.number}`;
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
line.append(clear);
|
line.append(clear);
|
||||||
}
|
}
|
||||||
@@ -4396,21 +4675,20 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
if (aired && onDisk) {
|
if (aired && onDisk) {
|
||||||
// #174: the file goes and the episode stops being wanted; the row
|
// #174: the file goes and the episode stops being wanted; the row
|
||||||
// stays listed. Same arm-then-confirm as a settings row.
|
// stays listed. Same arm-then-confirm as a settings row.
|
||||||
const clear = armedDelete("remove file", () => {
|
const clear = armedDeleteIcon(
|
||||||
void removeEpisodeFiles(episode.id).then((outcome) => {
|
|
||||||
if (outcome.kind === "error") {
|
|
||||||
clear.disabled = false;
|
|
||||||
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// once missing, the row's want control is what remains to focus
|
|
||||||
focusKey = `want-${episode.id}`;
|
|
||||||
void load();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
clear.setAttribute(
|
|
||||||
"aria-label",
|
|
||||||
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
|
`remove the ${episodeTag(seasonNumber, episode.number)} file from disk and stop wanting the episode — it stays listed`,
|
||||||
|
() => {
|
||||||
|
void removeEpisodeFiles(episode.id).then((outcome) => {
|
||||||
|
if (outcome.kind === "error") {
|
||||||
|
clear.disabled = false;
|
||||||
|
setStatus(`remove failed — ${outcome.detail}`, "fault");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// once missing, the row's want control is what remains to focus
|
||||||
|
focusKey = `want-${episode.id}`;
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
actions.append(clear);
|
actions.append(clear);
|
||||||
} else if (!aired) {
|
} else if (!aired) {
|
||||||
@@ -4441,8 +4719,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
|
|
||||||
const deckBtn = document.createElement("button");
|
const deckBtn = document.createElement("button");
|
||||||
deckBtn.type = "button";
|
deckBtn.type = "button";
|
||||||
deckBtn.className = "control";
|
deckBtn.className = "control control-icon";
|
||||||
deckBtn.textContent = "deck";
|
const deckLabel = `search releases for ${episodeTag(seasonNumber, episode.number)}`;
|
||||||
|
deckBtn.setAttribute("aria-label", deckLabel);
|
||||||
|
deckBtn.title = deckLabel;
|
||||||
|
deckBtn.append(icon("search"));
|
||||||
deckBtn.addEventListener("click", () => {
|
deckBtn.addEventListener("click", () => {
|
||||||
const currentId = seriesId;
|
const currentId = seriesId;
|
||||||
const currentTitle = series?.title ?? "";
|
const currentTitle = series?.title ?? "";
|
||||||
@@ -4657,6 +4938,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
paintHeader();
|
paintHeader();
|
||||||
|
paintRootControl();
|
||||||
clearRichDetail();
|
clearRichDetail();
|
||||||
renderSeasons();
|
renderSeasons();
|
||||||
syncRefreshWatch(ticket);
|
syncRefreshWatch(ticket);
|
||||||
@@ -4686,7 +4968,8 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
view.hidden = false;
|
view.hidden = false;
|
||||||
clearRemove();
|
clearRemove();
|
||||||
clearRichDetail();
|
clearRichDetail();
|
||||||
back.focus();
|
titleEl.setAttribute("tabindex", "-1");
|
||||||
|
titleEl.focus();
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4707,8 +4990,6 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
|||||||
origin?.focus();
|
origin?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
back.addEventListener("click", close);
|
|
||||||
|
|
||||||
// capture, like every other layer: the tv deck's listener is registered
|
// capture, like every other layer: the tv deck's listener is registered
|
||||||
// first, so one Esc steps back one layer
|
// first, so one Esc steps back one layer
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export interface MovieRelease {
|
|||||||
score: number | null;
|
score: number | null;
|
||||||
verdict: string | null;
|
verdict: string | null;
|
||||||
rejected_rule: string | null;
|
rejected_rule: string | null;
|
||||||
|
/**
|
||||||
|
* What the blacklist recorded this release as failing on (#227, §6.3).
|
||||||
|
* Null on every row the blacklist does not hold, and on a blacklisted row
|
||||||
|
* whose entry has since gone — `blacklisted` is then all the record has.
|
||||||
|
*/
|
||||||
|
blacklist_reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReleasesOutcome =
|
export type ReleasesOutcome =
|
||||||
@@ -113,6 +119,23 @@ export interface SeasonPackState {
|
|||||||
pack_failures: number;
|
pack_failures: number;
|
||||||
pack_retry_at: string | null;
|
pack_retry_at: string | null;
|
||||||
last_pack_search_at: string | null;
|
last_pack_search_at: string | null;
|
||||||
|
import_failure: ImportFailure | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pack that downloaded in full and was condemned at import (#227, §5.7).
|
||||||
|
*
|
||||||
|
* The torrent stays where it is — §7.3 hands that lifecycle to the reaper —
|
||||||
|
* the release is blacklisted and every episode it covered reopens as a gap.
|
||||||
|
* Nothing on screen joined those facts, so the season read `0/10` as though
|
||||||
|
* no grab had ever been tried and the operator found out by opening
|
||||||
|
* Transmission.
|
||||||
|
*/
|
||||||
|
export interface ImportFailure {
|
||||||
|
release: string;
|
||||||
|
/** A policy rule name, or a sentence about the release. See `blacklistClass`. */
|
||||||
|
reason: string | null;
|
||||||
|
failed_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PackStateOutcome =
|
export type PackStateOutcome =
|
||||||
@@ -257,6 +280,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).
|
* 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
|
* Mapping a rule name to its override is bookkeeping, not policy — the
|
||||||
* verdict itself always comes from the API.
|
* 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 {
|
export function waiverOverride(rule: string | null): Record<string, unknown> | null {
|
||||||
switch (rule) {
|
switch (rule) {
|
||||||
@@ -264,11 +292,18 @@ export function waiverOverride(rule: string | null): Record<string, unknown> | n
|
|||||||
return { allow_english_audio: true };
|
return { allow_english_audio: true };
|
||||||
case "resolution":
|
case "resolution":
|
||||||
return { only_4k: false };
|
return { only_4k: false };
|
||||||
|
case "size":
|
||||||
|
return { allow_below_floor: true };
|
||||||
default:
|
default:
|
||||||
return null;
|
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 =
|
export type WaiveOutcome =
|
||||||
| { kind: "done"; overrideWritten: boolean }
|
| { kind: "done"; overrideWritten: boolean }
|
||||||
| { kind: "error"; detail: string; overrideWritten: boolean };
|
| { kind: "error"; detail: string; overrideWritten: boolean };
|
||||||
@@ -455,6 +490,48 @@ export function ruleLabel(rule: string | null): string {
|
|||||||
return RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
|
return RULE_LABEL[rule] ?? rule.replaceAll("_", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a blacklisting was: the policy turning a file down, or the release
|
||||||
|
* itself failing (#227).
|
||||||
|
*
|
||||||
|
* The blacklist reason is either a policy rule name — the same vocabulary
|
||||||
|
* `rejected_rule` uses — or a sentence about the release, written where the
|
||||||
|
* import gave up before any rule was consulted. The two demand opposite
|
||||||
|
* decisions: a size rejection is the operator's own floor and they can relax
|
||||||
|
* it, a corrupt or mismatched pack is not theirs to argue with. `unknown` is
|
||||||
|
* a row the blacklist no longer answers for; nothing is claimed about it.
|
||||||
|
*/
|
||||||
|
export type BlacklistClass = "policy" | "release" | "unknown";
|
||||||
|
|
||||||
|
export function blacklistClass(reason: string | null): BlacklistClass {
|
||||||
|
if (reason === null) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
return reason in RULE_LABEL ? "policy" : "release";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reason as a chip word: a rule's short label, or the sentence itself. */
|
||||||
|
export function blacklistReasonLabel(reason: string | null): string {
|
||||||
|
return reason === null ? "reason not recorded" : (RULE_LABEL[reason] ?? reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the operator does about it, which is the whole difference between the
|
||||||
|
* two classes — and the sentence #227 exists to put on screen.
|
||||||
|
*/
|
||||||
|
export function blacklistAdvice(reason: string | null): string {
|
||||||
|
switch (blacklistClass(reason)) {
|
||||||
|
case "policy":
|
||||||
|
return waiverOverride(reason) === null
|
||||||
|
? "policy rejected the file — that rule has no per-title relaxation"
|
||||||
|
: `policy rejected the file — relax ${ruleLabel(reason)} for this title and the next candidate can pass`;
|
||||||
|
case "release":
|
||||||
|
return "the release itself failed at import — a retry downloads the same files";
|
||||||
|
default:
|
||||||
|
return "blacklisted before the reason was recorded";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function errorDetail(response: Response): Promise<string> {
|
export async function errorDetail(response: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const body = (await response.json()) as { error?: string };
|
const body = (await response.json()) as { error?: string };
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { MetadataTrailer } from "./movie";
|
import type { MetadataTrailer } from "./movie";
|
||||||
import type {
|
import type {
|
||||||
ActionOutcome,
|
ActionOutcome,
|
||||||
|
ImportFailure,
|
||||||
MovieRelease,
|
MovieRelease,
|
||||||
PackStateOutcome,
|
PackStateOutcome,
|
||||||
ReleasesOutcome,
|
ReleasesOutcome,
|
||||||
@@ -49,6 +50,12 @@ export interface ApiSeason {
|
|||||||
tracked: boolean;
|
tracked: boolean;
|
||||||
/** Gone upstream while a file under it remained — a conflict, not a state. */
|
/** Gone upstream while a file under it remained — a conflict, not a state. */
|
||||||
vanished: boolean;
|
vanished: boolean;
|
||||||
|
/**
|
||||||
|
* #227: the last pack that downloaded in full and was condemned at import,
|
||||||
|
* while the season is still waiting for a file. Null once the gap is
|
||||||
|
* filled — a season with nothing missing has nothing to explain.
|
||||||
|
*/
|
||||||
|
import_failure: ImportFailure | null;
|
||||||
episodes: ApiEpisode[];
|
episodes: ApiEpisode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +72,26 @@ export async function fetchSeries(seriesId: number): Promise<ApiSeries | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move a series to another TV root through its partial update endpoint. */
|
||||||
|
export async function updateSeries(
|
||||||
|
seriesId: number,
|
||||||
|
patch: { root_id: number },
|
||||||
|
): Promise<ActionOutcome> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/series/${seriesId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
return { kind: "error", detail: await errorDetail(response) };
|
||||||
|
}
|
||||||
|
return { kind: "done" };
|
||||||
|
} catch {
|
||||||
|
return { kind: "error", detail: "daemon unreachable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */
|
/** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */
|
||||||
export async function fetchEpisode(episodeId: number): Promise<ApiEpisode | null> {
|
export async function fetchEpisode(episodeId: number): Promise<ApiEpisode | null> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+21
-55
@@ -2,6 +2,7 @@
|
|||||||
// plus the settings view over them — same reasoning as search.ts: the
|
// plus the settings view over them — same reasoning as search.ts: the
|
||||||
// generated client (src/api/) is uncommitted, so CI's tsc cannot see it.
|
// generated client (src/api/) is uncommitted, so CI's tsc cannot see it.
|
||||||
|
|
||||||
|
import { armedDeleteIcon, icon } from "./icons";
|
||||||
import { navigate } from "./router";
|
import { navigate } from "./router";
|
||||||
import type { Root } from "./search";
|
import type { Root } from "./search";
|
||||||
|
|
||||||
@@ -222,40 +223,14 @@ function listValue(input: HTMLInputElement): string[] {
|
|||||||
.filter((part) => part !== "");
|
.filter((part) => part !== "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Icon-only edit toggle for one row; the label names the row it opens. */
|
||||||
* First click arms the destructive action, second confirms. The armed state
|
function editIcon(label: string): HTMLButtonElement {
|
||||||
* clears on blur or after a few seconds, so an accidental double click
|
const button = el("button", "control control-quiet control-icon");
|
||||||
* never deletes. Shared with the series detail rows — one confirmation
|
|
||||||
* idiom for row-level destruction, not one per page.
|
|
||||||
*/
|
|
||||||
export function armedDelete(label: string, execute: () => void): HTMLButtonElement {
|
|
||||||
const button = el("button", "control control-quiet readout", label);
|
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
let armed = false;
|
button.setAttribute("aria-label", label);
|
||||||
let resetTimer: number | undefined;
|
button.title = label;
|
||||||
const disarm = () => {
|
button.setAttribute("aria-expanded", "false");
|
||||||
armed = false;
|
button.append(icon("pencil"));
|
||||||
window.clearTimeout(resetTimer);
|
|
||||||
delete button.dataset.armed;
|
|
||||||
button.textContent = label;
|
|
||||||
};
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
if (armed) {
|
|
||||||
disarm();
|
|
||||||
button.disabled = true;
|
|
||||||
execute();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
armed = true;
|
|
||||||
button.dataset.armed = "true";
|
|
||||||
button.textContent = `confirm ${label}`;
|
|
||||||
resetTimer = window.setTimeout(disarm, 4000);
|
|
||||||
});
|
|
||||||
button.addEventListener("blur", () => {
|
|
||||||
if (armed) {
|
|
||||||
disarm();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,31 +325,29 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
|||||||
|
|
||||||
/* -- roots -------------------------------------------------------------- */
|
/* -- roots -------------------------------------------------------------- */
|
||||||
|
|
||||||
/** A delete/edit action pair sharing the row's feedback line. */
|
/** The row's line: identity, chips right, the icon action pair, feedback. */
|
||||||
function rowActions(
|
function rowLine(
|
||||||
item: HTMLLIElement,
|
item: HTMLLIElement,
|
||||||
|
title: string,
|
||||||
|
chips: HTMLSpanElement,
|
||||||
del: HTMLButtonElement,
|
del: HTMLButtonElement,
|
||||||
edit: HTMLButtonElement,
|
edit: HTMLButtonElement,
|
||||||
): HTMLParagraphElement {
|
): HTMLParagraphElement {
|
||||||
const actions = el("div", "settings-row-actions");
|
const actions = el("div", "settings-row-actions");
|
||||||
actions.append(edit, del);
|
actions.append(edit, del);
|
||||||
item.append(actions);
|
|
||||||
const note = el("p", "add-note readout");
|
const note = el("p", "add-note readout");
|
||||||
note.setAttribute("role", "status");
|
note.setAttribute("role", "status");
|
||||||
note.hidden = true;
|
note.hidden = true;
|
||||||
item.append(note);
|
item.append(rowTitle(title), chips, actions, note);
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rootRow(root: Root): HTMLLIElement {
|
function rootRow(root: Root): HTMLLIElement {
|
||||||
const item = el("li", "queue-item");
|
const item = el("li", "queue-item settings-row");
|
||||||
const row = el("div", "row");
|
|
||||||
const chips = el("span", "row-chips");
|
const chips = el("span", "row-chips");
|
||||||
chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name));
|
chips.append(chip(root.kind), chip(root.audience), chip(root.policy_name));
|
||||||
row.append(rowTitle(root.path), chips);
|
|
||||||
item.append(row);
|
|
||||||
|
|
||||||
const del = armedDelete("delete", () => {
|
const del = armedDeleteIcon(`delete root ${root.path}`, () => {
|
||||||
void deleteRow("roots", root.id).then((detail) => {
|
void deleteRow("roots", root.id).then((detail) => {
|
||||||
if (detail !== null) {
|
if (detail !== null) {
|
||||||
note.hidden = false;
|
note.hidden = false;
|
||||||
@@ -386,9 +359,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const edit = el("button", "control control-quiet readout", "edit");
|
const edit = editIcon(`edit root ${root.path}`);
|
||||||
edit.type = "button";
|
|
||||||
edit.setAttribute("aria-expanded", "false");
|
|
||||||
edit.addEventListener("click", () => {
|
edit.addEventListener("click", () => {
|
||||||
const existing = item.querySelector(".edit-panel");
|
const existing = item.querySelector(".edit-panel");
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -400,7 +371,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
|||||||
edit.setAttribute("aria-expanded", "true");
|
edit.setAttribute("aria-expanded", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
const note = rowActions(item, del, edit);
|
const note = rowLine(item, root.path, chips, del, edit);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,17 +453,14 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function policyRow(policy: PolicyDoc): HTMLLIElement {
|
function policyRow(policy: PolicyDoc): HTMLLIElement {
|
||||||
const item = el("li", "queue-item");
|
const item = el("li", "queue-item settings-row");
|
||||||
const row = el("div", "row");
|
|
||||||
const chips = el("span", "row-chips");
|
const chips = el("span", "row-chips");
|
||||||
chips.append(chip(policy.resolution_pref.join(" › ") || "no resolutions"));
|
chips.append(chip(policy.resolution_pref.join(" › ") || "no resolutions"));
|
||||||
chips.append(chip(requiredAudioLabel(policy)));
|
chips.append(chip(requiredAudioLabel(policy)));
|
||||||
const bandCount = Object.keys(policy.size_bands).length;
|
const bandCount = Object.keys(policy.size_bands).length;
|
||||||
chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`));
|
chips.append(chip(`${bandCount} band${bandCount === 1 ? "" : "s"}`));
|
||||||
row.append(rowTitle(policy.name), chips);
|
|
||||||
item.append(row);
|
|
||||||
|
|
||||||
const del = armedDelete("delete", () => {
|
const del = armedDeleteIcon(`delete policy ${policy.name}`, () => {
|
||||||
void deleteRow("policies", policy.id).then((detail) => {
|
void deleteRow("policies", policy.id).then((detail) => {
|
||||||
if (detail !== null) {
|
if (detail !== null) {
|
||||||
note.hidden = false;
|
note.hidden = false;
|
||||||
@@ -504,9 +472,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const edit = el("button", "control control-quiet readout", "edit");
|
const edit = editIcon(`edit policy ${policy.name}`);
|
||||||
edit.type = "button";
|
|
||||||
edit.setAttribute("aria-expanded", "false");
|
|
||||||
edit.addEventListener("click", () => {
|
edit.addEventListener("click", () => {
|
||||||
const existing = item.querySelector(".edit-panel");
|
const existing = item.querySelector(".edit-panel");
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -518,7 +484,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
|||||||
edit.setAttribute("aria-expanded", "true");
|
edit.setAttribute("aria-expanded", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
const note = rowActions(item, del, edit);
|
const note = rowLine(item, policy.name, chips, del, edit);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+200
-2
@@ -481,6 +481,62 @@ body {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- icon-only controls (#230) ---------------------------------------- */
|
||||||
|
|
||||||
|
/* One block, shared: title pages, decks and readouts here, the settings
|
||||||
|
rows via #231. The glyph carries the action, the aria-label carries the
|
||||||
|
meaning, and the geometry stays on the --control-h rail with the chips. */
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: block;
|
||||||
|
flex: none;
|
||||||
|
height: 0.875rem;
|
||||||
|
width: auto;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-icon {
|
||||||
|
min-width: var(--control-h);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* provider wordmark badges (§9.6): the mark is drawn and shipped inline,
|
||||||
|
monochrome on currentColor — never a remote image */
|
||||||
|
.icon-mark {
|
||||||
|
height: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-mark rect {
|
||||||
|
stroke-width: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the mark's letters live in viewBox units, so the size rides the SVG
|
||||||
|
scale as an attribute; only voice and weight come from the tokens */
|
||||||
|
.icon-mark text {
|
||||||
|
fill: currentColor;
|
||||||
|
stroke: none;
|
||||||
|
font-family: var(--font-readout);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* a readout chip's glyph sits beside its figure, sized to the readout */
|
||||||
|
.chip .icon {
|
||||||
|
height: 0.75rem;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* rows widen every .control's padding; an icon control stays near-square */
|
||||||
|
.season-line .control-icon,
|
||||||
|
.ep-actions .control-icon,
|
||||||
|
.movie-controls .control-icon {
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- unified search (§9.2) ------------------------------------------- */
|
/* ---- unified search (§9.2) ------------------------------------------- */
|
||||||
|
|
||||||
.rail-search {
|
.rail-search {
|
||||||
@@ -539,6 +595,12 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* movie and series: the hero banner meets the rail — no top padding (#229) */
|
||||||
|
.deck#movie,
|
||||||
|
.deck#series {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.deck-status {
|
.deck-status {
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
@@ -566,6 +628,48 @@ body {
|
|||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #227: a pack abandoned at import, above the candidates. A quiet panel, not
|
||||||
|
an alert: the failure is history the season owes an explanation for, and
|
||||||
|
the operator opened this deck to grab something, not to be shouted at. The
|
||||||
|
tone follows the same reading as the row chips — amber for their own policy
|
||||||
|
floor, red for a release that failed on its own. */
|
||||||
|
.deck-notice {
|
||||||
|
margin: 0 0 var(--space-6);
|
||||||
|
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-notice[data-blacklist="policy"] {
|
||||||
|
background: oklch(from var(--signal-warn) l c h / 7%);
|
||||||
|
border-color: oklch(from var(--signal-warn) l c h / 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-notice[data-blacklist="release"] {
|
||||||
|
background: oklch(from var(--signal-fault) l c h / 7%);
|
||||||
|
border-color: oklch(from var(--signal-fault) l c h / 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the release name is evidence, in the readout face like every other one */
|
||||||
|
.notice-release {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-line {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 68ch;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-line + .notice-line {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.deck-group {
|
.deck-group {
|
||||||
margin: 0 0 var(--space-8);
|
margin: 0 0 var(--space-8);
|
||||||
}
|
}
|
||||||
@@ -593,6 +697,18 @@ body {
|
|||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deck-notice .deck-label {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-notice[data-blacklist="policy"] .deck-label {
|
||||||
|
color: var(--signal-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-notice[data-blacklist="release"] .deck-label {
|
||||||
|
color: var(--signal-fault);
|
||||||
|
}
|
||||||
|
|
||||||
.deck-rows {
|
.deck-rows {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -668,6 +784,35 @@ body {
|
|||||||
color: var(--verdict-rejected);
|
color: var(--verdict-rejected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #227: a blacklisted release was grabbed, downloaded in full and thrown out
|
||||||
|
at import, and the two ways that happens want opposite decisions. The
|
||||||
|
policy turning a file down is the operator's own floor — amber, the hue
|
||||||
|
this app already gives a gap they can act on. The release itself failing is
|
||||||
|
the one case in the deck that is genuinely broken, so it takes fault red;
|
||||||
|
the "never fault red" rule above is about a rejection, and this is not one.
|
||||||
|
A row whose blacklist entry is gone claims nothing and stays slate. */
|
||||||
|
.chip[data-blacklist="policy"] {
|
||||||
|
color: var(--signal-warn);
|
||||||
|
border-color: oklch(from var(--signal-warn) l c h / 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip[data-blacklist="release"] {
|
||||||
|
color: var(--signal-fault);
|
||||||
|
border-color: oklch(from var(--signal-fault) l c h / 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* a reason written as a sentence is longer than any rule name, and the chips
|
||||||
|
around it are fixed-width columns that must not be pushed off the line
|
||||||
|
(§9.3: no horizontal scroll at any viewport). This one chip wraps instead. */
|
||||||
|
.chip[data-blacklist],
|
||||||
|
.chip[data-flag="import-failed"] {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
/* media state ramp (§4.2): green on disk, violet downloading, amber wanted
|
/* media state ramp (§4.2): green on disk, violet downloading, amber wanted
|
||||||
and still missing. Unwanted-missing and parked are nothing-happening and
|
and still missing. Unwanted-missing and parked are nothing-happening and
|
||||||
stay neutral; `parked` exists so a vanished grab never reads as a gap. */
|
stay neutral; `parked` exists so a vanished grab never reads as a gap. */
|
||||||
@@ -1148,6 +1293,20 @@ body {
|
|||||||
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
border-bottom: 1px solid oklch(from var(--line) l c h / 45%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #227: what the blacklisting means for the next move, on its own line under
|
||||||
|
the chips. Only a blacklisted row carries it, so the dense list stays dense
|
||||||
|
everywhere else. */
|
||||||
|
.rel-why {
|
||||||
|
flex-basis: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rel-why[data-blacklist="release"] {
|
||||||
|
color: oklch(from var(--signal-fault) 0.78 0.1 h);
|
||||||
|
}
|
||||||
|
|
||||||
.rel-note {
|
.rel-note {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
@@ -1468,6 +1627,13 @@ body {
|
|||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #227: the season the operator's own report was about read `0/10` beside
|
||||||
|
this chip's absence. It sits next to the counts because that number is what
|
||||||
|
looked like nothing had ever been tried. */
|
||||||
|
.chip[data-flag="import-failed"] {
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
/* issue 122: gone upstream while its file remained — a conflict, amber dashed */
|
/* issue 122: gone upstream while its file remained — a conflict, amber dashed */
|
||||||
.chip[data-flag="vanished"] {
|
.chip[data-flag="vanished"] {
|
||||||
color: var(--signal-warn);
|
color: var(--signal-warn);
|
||||||
@@ -1866,6 +2032,17 @@ body {
|
|||||||
padding: 0 var(--space-3);
|
padding: 0 var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The one control whose effect is narrower than its name suggests (§6.3)
|
||||||
|
says so under the row while it is on: three words on the button, the
|
||||||
|
half they cannot hold on the line below. Aligned with the row's own
|
||||||
|
padding so it reads as a note on the controls, not a new section. */
|
||||||
|
.control-note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 var(--space-1) var(--space-2);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
/* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */
|
/* wanted and blocked read as rules, pressed = on — the tracked-toggle idiom */
|
||||||
.movie-controls .control[aria-pressed="true"] {
|
.movie-controls .control[aria-pressed="true"] {
|
||||||
color: var(--accent-bright);
|
color: var(--accent-bright);
|
||||||
@@ -2097,11 +2274,32 @@ body {
|
|||||||
|
|
||||||
/* ---- settings (issue #116) -------------------------------------------- */
|
/* ---- settings (issue #116) -------------------------------------------- */
|
||||||
|
|
||||||
/* rows follow the queues pattern: line, then an action pair */
|
/* one machined line per row (#231): identity, chips at the right edge,
|
||||||
|
then the icon action pair — the episode-row idiom, not a two-line group */
|
||||||
.settings-row-actions {
|
.settings-row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin: var(--space-1) 0 var(--space-2);
|
margin-left: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row .row-title {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* feedback and the edit panel drop below the line, full width */
|
||||||
|
.settings-row .add-note,
|
||||||
|
.settings-row .edit-panel {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* narrow, the row wraps like an episode row: identity first, chips
|
||||||
|
full-width (the shared .row-chips rule), the icon pair keeps the right
|
||||||
|
edge. Lives here, after the base rule, so the margin actually flips. */
|
||||||
|
@media (max-width: 46rem) {
|
||||||
|
.settings-row-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
.form-grid {
|
||||||
|
|||||||
Reference in New Issue
Block a user