Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac0e80c044 | |||
| 8c3e1c4a92 | |||
| bb2708f7ee | |||
| 5c5b1e234c | |||
| 917aa4fa76 | |||
| 024786f356 | |||
| f0d45996a0 | |||
| bd52941a6d |
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -58,5 +58,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
|
||||
"hash": "1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ",
|
||||
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n s.runtime_minutes,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -103,8 +103,19 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "release_name!: String",
|
||||
"name": "runtime_minutes",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "runtime_minutes"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "release_name!: String",
|
||||
"ordinal": 10,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -127,8 +138,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc"
|
||||
"hash": "1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67"
|
||||
}
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 13
|
||||
"Right": 14
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331"
|
||||
"hash": "3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -71,7 +71,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -82,5 +82,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
|
||||
"hash": "44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42"
|
||||
}
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series WHERE id = ?",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average, runtime_minutes\n FROM series WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -145,6 +145,17 @@
|
||||
"name": "vote_average"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "runtime_minutes",
|
||||
"ordinal": 13,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "runtime_minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -163,8 +174,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10"
|
||||
"hash": "4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377"
|
||||
}
|
||||
+15
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n e.id AS \"episode_id!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN episodes e ON e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'episode'\n ORDER BY g.id\n ",
|
||||
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n e.id AS \"episode_id!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n s.runtime_minutes,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN episodes e ON e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'episode'\n ORDER BY g.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -114,8 +114,19 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "release_name!: String",
|
||||
"name": "runtime_minutes",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "runtime_minutes"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "release_name!: String",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -139,8 +150,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4"
|
||||
"hash": "81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -82,7 +82,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -94,5 +94,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
|
||||
"hash": "a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -170,7 +170,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -190,5 +190,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
|
||||
"hash": "b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
|
||||
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -46,5 +46,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
|
||||
"hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
@@ -58,5 +58,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
|
||||
"hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86"
|
||||
}
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
|
||||
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average, runtime_minutes\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -145,6 +145,17 @@
|
||||
"name": "vote_average"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "runtime_minutes",
|
||||
"ordinal": 13,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "runtime_minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -163,8 +174,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644"
|
||||
"hash": "ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.runtime_minutes FROM series s\n WHERE s.id = (SELECT s2.series_id FROM episodes e\n JOIN seasons s2 ON s2.id = e.season_id\n WHERE e.id = ?)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "runtime_minutes",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "runtime_minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166"
|
||||
}
|
||||
@@ -307,6 +307,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
|
||||
than grabbing a bad one, and the next search after a refresh has the real count.
|
||||
|
||||
**A band describes a rate, not a fixed size per episode.** The shipped values
|
||||
are read against a **reference runtime of 45 minutes**: a 1080p target of 2 GiB
|
||||
means 2 GiB per 45 minutes of episode. Before the per-episode figure is compared
|
||||
against them, a band's floor and target are both scaled by
|
||||
`runtime / 45`, where `runtime` is the series' minutes-per-episode from TMDB
|
||||
metadata. For a typical drama around 45 minutes the shipped numbers keep exactly
|
||||
their current meaning; a 22-minute show is judged against roughly half the floor
|
||||
and half the target instead of being rejected for weighing half of what an hour
|
||||
of video weighs. The shipped band values themselves do not change — the
|
||||
reference runtime is chosen so they do not have to.
|
||||
|
||||
**A missing or zero runtime is the reference runtime.** When metadata carries no
|
||||
per-episode runtime, or carries zero, the scale factor is 1 and the band applies
|
||||
unscaled — exactly today's behaviour. The rule does not guess a duration, for
|
||||
the same reason the unknown episode count does not guess a number.
|
||||
|
||||
**Movies are not scaled.** This applies to episodes only. A movie's bands are
|
||||
already tuned against feature length, so its floor and target keep their current
|
||||
meaning regardless of the movie's own runtime.
|
||||
|
||||
Source tier (`Remux > BluRay > WEB-DL > WEBRip > HDTV`) survives as a small
|
||||
tiebreaker. Seeders are log-scaled and small: enough to complete, past that it
|
||||
does not matter. Telesync, CAM and screener are **hard filters**, not low
|
||||
@@ -358,6 +378,23 @@ A policy violation found by `ffprobe` is not one thing.
|
||||
|
||||
Neither deletes the torrent. See §7.3.
|
||||
|
||||
**Two hard failures make a decision, and only for 30 days.** A movie, an
|
||||
episode or a season enters the needs-a-decision queue (§9.5) when two grabs
|
||||
against *different* releases hard-failed on it, and both of those failures
|
||||
happened within the last 30 days. One bad torrent is not a decision — a
|
||||
release that hard-failed is blacklisted (§6.3) and the next candidate is
|
||||
grabbed, which is the system working.
|
||||
|
||||
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
|
||||
without it the queue only ever grows and the one season that wants attention
|
||||
sits behind eight that were dealt with months ago. It is the queue's version of
|
||||
§6.2's "it never gives up entirely, it goes quiet": a target the operator has
|
||||
dealt with stops producing failures and drops out once the last one ages past
|
||||
30 days, while a target that is still broken keeps producing them — the pack
|
||||
guard retries at worst weekly (§6.2) — and stays queued for exactly as long as
|
||||
it is genuinely broken. Nothing is dismissed by hand and no acknowledgement
|
||||
state is stored, so there is no second thing to keep correct.
|
||||
|
||||
## 6. Sourcing
|
||||
|
||||
### 6.1 Prowlarr, per-indexer Torznab
|
||||
|
||||
@@ -610,7 +610,7 @@ pub async fn releases(
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
.ok_or(ApiError::NotFound)?
|
||||
.policy;
|
||||
rescore(&mut releases, &policy, None)?;
|
||||
rescore(&mut releases, &policy, None, 0)?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
@@ -624,6 +624,10 @@ pub async fn releases(
|
||||
/// many it covers. `None` is the movie decks: one release is one film,
|
||||
/// whatever episode-shaped noise its name parses to.
|
||||
///
|
||||
/// `runtime_minutes` scales the bands by the series' minutes per episode
|
||||
/// (§5.5). Movies are never scaled, so the movie decks pass zero — the same
|
||||
/// zero a series with no known runtime gets.
|
||||
///
|
||||
/// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of
|
||||
/// policy weights in the thousands), so the `i64` -> `f64` cast into the
|
||||
/// column's storage type is exact.
|
||||
@@ -632,6 +636,7 @@ pub(crate) fn rescore(
|
||||
releases: &mut [Release],
|
||||
policy: &Policy,
|
||||
season_lengths: Option<&BTreeMap<u32, u32>>,
|
||||
runtime_minutes: u32,
|
||||
) -> Result<(), ApiError> {
|
||||
let mut totals = Vec::with_capacity(releases.len());
|
||||
for release in releases.iter() {
|
||||
@@ -642,7 +647,17 @@ pub(crate) fn rescore(
|
||||
let episodes = season_lengths.map_or(1, |lengths| {
|
||||
claimed_episode_count(parsed.episode.as_ref(), lengths)
|
||||
});
|
||||
totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders, episodes).total);
|
||||
totals.push(
|
||||
score(
|
||||
policy,
|
||||
Candidate::PreGrab(&parsed),
|
||||
size,
|
||||
seeders,
|
||||
episodes,
|
||||
runtime_minutes,
|
||||
)
|
||||
.total,
|
||||
);
|
||||
}
|
||||
let mut indices: Vec<usize> = (0..releases.len()).collect();
|
||||
indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i]));
|
||||
@@ -725,7 +740,7 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
|
||||
let no_pt_source = sqlx::query_as!(Movie, r#"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 id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
let needs_decision = sqlx::query_as!(Movie, r#"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"#)
|
||||
let needs_decision = sqlx::query_as!(Movie, r#"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' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW)
|
||||
.fetch_all(pool(&state)?)
|
||||
.await?;
|
||||
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
|
||||
@@ -741,6 +756,11 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
|
||||
/// The TV lanes of the attention queues (§9.5): one entry per series with
|
||||
/// the episodes and seasons that put it there. The two hard-fail conditions
|
||||
/// share a lane; a series arriving through both is merged into one entry.
|
||||
///
|
||||
/// Both hard-fail branches hold to §5.7's bar: two failures on *different*
|
||||
/// releases, both inside `ATTENTION_WINDOW`. One bad torrent is not a
|
||||
/// decision, and a failure the operator already dealt with ages out instead
|
||||
/// of sitting in the queue forever (#226).
|
||||
async fn tv_attention(
|
||||
state: &AppState,
|
||||
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
|
||||
@@ -780,9 +800,11 @@ async fn tv_attention(
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
@@ -795,8 +817,11 @@ async fn tv_attention(
|
||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
|
||||
"#
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database)
|
||||
.await?;
|
||||
@@ -1559,6 +1584,7 @@ mod tests {
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.total;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
@@ -1747,10 +1773,166 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A series with one empty season, for exercising the season lane on its
|
||||
/// own.
|
||||
async fn seed_bare_season(pool: &sqlx::SqlitePool, tmdb_id: i64) -> (i64, i64) {
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("kids tv root");
|
||||
let series_id: i64 = sqlx::query(
|
||||
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Rick and Morty', 2013, ?)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(tmdb_id)
|
||||
.bind(root_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("series")
|
||||
.get(0);
|
||||
let season_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO seasons (series_id, number) VALUES (?, 8) RETURNING id",
|
||||
)
|
||||
.bind(series_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("season");
|
||||
(series_id, season_id)
|
||||
}
|
||||
|
||||
async fn insert_release(pool: &sqlx::SqlitePool, guid: &str) -> i64 {
|
||||
sqlx::query(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id",
|
||||
)
|
||||
.bind(guid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("release")
|
||||
.get(0)
|
||||
}
|
||||
|
||||
/// A hard-failed grab, stamped `age_days` in the past so the §5.7 window
|
||||
/// can be exercised without waiting a month.
|
||||
async fn insert_failed_grab(
|
||||
pool: &sqlx::SqlitePool,
|
||||
release_id: i64,
|
||||
target_kind: &str,
|
||||
target_id: i64,
|
||||
infohash: &str,
|
||||
age_days: i64,
|
||||
) {
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(target_kind)
|
||||
.bind(target_id)
|
||||
.bind(infohash)
|
||||
.bind(format!("-{age_days} days"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("failed grab");
|
||||
}
|
||||
|
||||
/// The seasons `GET /api/queues/attention` currently reports for a series.
|
||||
async fn queued_seasons(base: &str, series_id: i64) -> Vec<i64> {
|
||||
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
|
||||
.await
|
||||
.expect("queues")
|
||||
.json()
|
||||
.await
|
||||
.expect("queues json");
|
||||
queues["tv_needs_decision"]
|
||||
.as_array()
|
||||
.expect("tv lane")
|
||||
.iter()
|
||||
.filter(|entry| entry["series_id"] == series_id)
|
||||
.flat_map(|entry| {
|
||||
entry["seasons"]
|
||||
.as_array()
|
||||
.expect("seasons")
|
||||
.iter()
|
||||
.map(|season| season["id"].as_i64().expect("season id"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// §5.7: one failed pack is the blacklist working, not a decision. The
|
||||
/// season lane holds to the same two-distinct-releases bar the episode
|
||||
/// lane does (#226).
|
||||
#[tokio::test]
|
||||
async fn a_season_queues_only_on_two_distinct_release_failures() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let (series_id, season_id) = seed_bare_season(pool, 1).await;
|
||||
|
||||
let first = insert_release(pool, "pack-one").await;
|
||||
insert_failed_grab(pool, first, "season", season_id, "hash-one", 0).await;
|
||||
assert!(
|
||||
queued_seasons(&base, series_id).await.is_empty(),
|
||||
"one failed pack is not a decision"
|
||||
);
|
||||
|
||||
// A second failure on the *same* release is still one release.
|
||||
insert_failed_grab(pool, first, "season", season_id, "hash-one-again", 0).await;
|
||||
assert!(
|
||||
queued_seasons(&base, series_id).await.is_empty(),
|
||||
"two grabs of one release are not two releases"
|
||||
);
|
||||
|
||||
let second = insert_release(pool, "pack-two").await;
|
||||
insert_failed_grab(pool, second, "season", season_id, "hash-two", 0).await;
|
||||
assert_eq!(
|
||||
queued_seasons(&base, series_id).await,
|
||||
vec![season_id],
|
||||
"two distinct releases hard-failed: the operator decides"
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.7: a failure counts for 30 days. A season the operator has dealt
|
||||
/// with stops failing and leaves the queue; one still breaking keeps
|
||||
/// producing failures and stays (#226).
|
||||
#[tokio::test]
|
||||
async fn a_season_failure_ages_out_of_the_attention_queue() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let (series_id, season_id) = seed_bare_season(pool, 1).await;
|
||||
|
||||
for (guid, hash, age) in [
|
||||
("old-one", "hash-old-one", 40),
|
||||
("old-two", "hash-old-two", 35),
|
||||
] {
|
||||
let release_id = insert_release(pool, guid).await;
|
||||
insert_failed_grab(pool, release_id, "season", season_id, hash, age).await;
|
||||
}
|
||||
assert!(
|
||||
queued_seasons(&base, series_id).await.is_empty(),
|
||||
"failures older than the window are history, not attention"
|
||||
);
|
||||
|
||||
let fresh = insert_release(pool, "new-one").await;
|
||||
insert_failed_grab(pool, fresh, "season", season_id, "hash-new-one", 0).await;
|
||||
assert!(
|
||||
queued_seasons(&base, series_id).await.is_empty(),
|
||||
"one recent failure does not revive two stale ones"
|
||||
);
|
||||
|
||||
let fresher = insert_release(pool, "new-two").await;
|
||||
insert_failed_grab(pool, fresher, "season", season_id, "hash-new-two", 0).await;
|
||||
assert_eq!(
|
||||
queued_seasons(&base, series_id).await,
|
||||
vec![season_id],
|
||||
"still breaking: back in the queue"
|
||||
);
|
||||
}
|
||||
|
||||
/// One series hitting all three §9.5 TV entry conditions: two wanted,
|
||||
/// searched episodes whose every candidate was rejected for language; a
|
||||
/// season pack that hard-failed; and an episode two different releases
|
||||
/// hard-failed on.
|
||||
/// season two different packs hard-failed on; and an episode two different
|
||||
/// releases hard-failed on.
|
||||
async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) {
|
||||
let root_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
|
||||
@@ -1802,6 +1984,7 @@ mod tests {
|
||||
.expect("episode id");
|
||||
for (kind, guid, suffix) in [
|
||||
("season", "pack", "pack"),
|
||||
("season", "pack-two", "pack2"),
|
||||
("episode", "first", "a"),
|
||||
("episode", "second", "b"),
|
||||
] {
|
||||
|
||||
@@ -77,6 +77,7 @@ pub(crate) async fn movie(state: &AppState, movie_id: i64) -> Result<(), ApiErro
|
||||
&loaded.overrides,
|
||||
&language,
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -127,6 +128,7 @@ pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiEr
|
||||
// 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,
|
||||
@@ -134,10 +136,12 @@ pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiEr
|
||||
&loaded.overrides,
|
||||
&language,
|
||||
Some(&lengths),
|
||||
runtime,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn apply(
|
||||
state: &AppState,
|
||||
releases: &[Stored],
|
||||
@@ -145,6 +149,7 @@ async fn apply(
|
||||
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) {
|
||||
@@ -166,6 +171,7 @@ async fn apply(
|
||||
Candidate::PreGrab(&parsed),
|
||||
size,
|
||||
episodes,
|
||||
runtime_minutes,
|
||||
);
|
||||
// `releases` allows a rule name only on a rejected row
|
||||
// (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`),
|
||||
|
||||
@@ -490,6 +490,7 @@ async fn movie_releases(
|
||||
&original_language,
|
||||
&blacklist,
|
||||
None,
|
||||
0,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -525,6 +526,7 @@ async fn episode_releases(
|
||||
.await?
|
||||
.ok_or(ApiError::EpisodeNotFound)?;
|
||||
let season_lengths = crate::series::season_lengths(state, episode.series_id).await?;
|
||||
let runtime_minutes = crate::series::series_runtime(state, episode.series_id).await?;
|
||||
let loaded = database
|
||||
.episode_policy(episode_id)
|
||||
.await
|
||||
@@ -568,6 +570,7 @@ async fn episode_releases(
|
||||
&original_language,
|
||||
&blacklist,
|
||||
Some(&season_lengths),
|
||||
runtime_minutes,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -709,6 +712,7 @@ fn classify(
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
season_lengths: Option<&BTreeMap<u32, u32>>,
|
||||
runtime_minutes: u32,
|
||||
) -> Result<ClassifiedRelease, ApiError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
|
||||
@@ -724,6 +728,7 @@ fn classify(
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size,
|
||||
episodes,
|
||||
runtime_minutes,
|
||||
);
|
||||
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
|
||||
("rejected", Some(blacklist::RULE.to_owned()))
|
||||
@@ -736,6 +741,7 @@ fn classify(
|
||||
release.size.unwrap_or_default(),
|
||||
release.seeders.unwrap_or_default(),
|
||||
episodes,
|
||||
runtime_minutes,
|
||||
);
|
||||
// A release with no size has nothing to say about its size band, so that
|
||||
// term is dropped rather than scored as if it were at the floor. Every
|
||||
@@ -1465,6 +1471,7 @@ mod tests {
|
||||
&Language::Other("en".into()),
|
||||
&Blacklist::default(),
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.expect("classified release")
|
||||
};
|
||||
@@ -1558,7 +1565,7 @@ mod tests {
|
||||
imdb_id: None,
|
||||
};
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1);
|
||||
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1, 0);
|
||||
|
||||
let classified = classify(
|
||||
release,
|
||||
@@ -1567,6 +1574,7 @@ mod tests {
|
||||
&Language::Other("en".into()),
|
||||
&Blacklist::default(),
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.expect("classified release");
|
||||
|
||||
|
||||
@@ -468,11 +468,15 @@ pub async fn create(
|
||||
let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone());
|
||||
let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone());
|
||||
let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average);
|
||||
let runtime_minutes = tmdb_series
|
||||
.as_ref()
|
||||
.and_then(|s| s.episode_runtime)
|
||||
.map(i64::from);
|
||||
let result = sqlx::query!(
|
||||
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
|
||||
input.auto_track, input.upstream_ended, input.blocked, overrides,
|
||||
poster_path, backdrop_path, vote_average,
|
||||
poster_path, backdrop_path, vote_average, runtime_minutes,
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
@@ -1379,7 +1383,8 @@ pub async fn episode_releases(
|
||||
.ok_or(ApiError::EpisodeNotFound)?
|
||||
.policy;
|
||||
let lengths = season_lengths(&state, episode.series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths))?;
|
||||
let runtime = series_runtime(&state, episode.series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
@@ -1418,6 +1423,19 @@ pub async fn grab_episode(
|
||||
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
|
||||
/// revealed episodes counts zero, which `claimed_episode_count` treats as
|
||||
/// unknown.
|
||||
/// The series' minutes per episode (`DESIGN.md` §5.5): the scale factor
|
||||
/// behind runtime-scaled size bands. Zero when unknown, which applies the
|
||||
/// bands unscaled.
|
||||
pub(crate) async fn series_runtime(state: &AppState, series_id: i64) -> Result<u32, ApiError> {
|
||||
let minutes = sqlx::query_scalar!("SELECT runtime_minutes FROM series WHERE id = ?", series_id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(minutes
|
||||
.and_then(|minutes| u32::try_from(minutes).ok())
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(crate) async fn season_lengths(
|
||||
state: &AppState,
|
||||
series_id: i64,
|
||||
@@ -1520,7 +1538,8 @@ pub async fn season_releases(
|
||||
.ok_or(ApiError::SeasonNotFound)?
|
||||
.policy;
|
||||
let lengths = season_lengths(&state, series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths))?;
|
||||
let runtime = series_runtime(&state, series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,11 @@ pub struct EvaluationContext<'a> {
|
||||
/// describes one episode, so the size rule divides by this. One for a
|
||||
/// movie or an unknown count; zero is treated as one.
|
||||
pub episode_count: u32,
|
||||
/// The series' minutes per episode (`DESIGN.md` §5.5) — a size band is a
|
||||
/// rate against 45 minutes, so the size rule scales its floor by
|
||||
/// `runtime / 45`. Zero is a missing runtime and applies the band
|
||||
/// unscaled; movies are never scaled and pass zero.
|
||||
pub runtime_minutes: u32,
|
||||
}
|
||||
|
||||
/// A rule's identity when no violation exists to carry concrete evidence.
|
||||
@@ -112,6 +117,7 @@ pub fn evaluate(
|
||||
candidate: Candidate<'_>,
|
||||
size_bytes: Option<u64>,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Evaluation {
|
||||
let context = EvaluationContext {
|
||||
policy,
|
||||
@@ -120,6 +126,7 @@ pub fn evaluate(
|
||||
candidate,
|
||||
size_bytes,
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
};
|
||||
let rules: [&dyn PolicyRule; 6] = [
|
||||
&ResolutionRule,
|
||||
@@ -239,8 +246,13 @@ impl PolicyRule for SizeRule {
|
||||
else {
|
||||
return RuleEvaluation::Unknown(RuleKind::Size);
|
||||
};
|
||||
match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count)
|
||||
{
|
||||
match crate::score::is_below_floor(
|
||||
context.policy,
|
||||
resolution,
|
||||
size,
|
||||
context.episode_count,
|
||||
context.runtime_minutes,
|
||||
) {
|
||||
None => RuleEvaluation::Unknown(RuleKind::Size),
|
||||
Some(true) if context.overrides.allow_below_floor => {
|
||||
RuleEvaluation::SoftFail(Rule::Size)
|
||||
@@ -531,7 +543,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
|
||||
evaluate(policy, overrides, &en(), candidate, None, 1).verdict
|
||||
evaluate(policy, overrides, &en(), candidate, None, 1, 0).verdict
|
||||
}
|
||||
|
||||
fn verdict_for(
|
||||
@@ -546,6 +558,7 @@ mod tests {
|
||||
candidate,
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict
|
||||
}
|
||||
@@ -561,6 +574,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -690,6 +704,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(1 << 30),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size));
|
||||
@@ -714,6 +729,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(1 << 30),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::Size)
|
||||
@@ -729,6 +745,7 @@ mod tests {
|
||||
Candidate::PostDownload(&media),
|
||||
Some(1 << 30),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::Size)
|
||||
@@ -751,6 +768,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(4 << 30),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Eligible
|
||||
@@ -775,6 +793,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
size,
|
||||
10,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Rejected(Rule::Size)
|
||||
@@ -790,12 +809,48 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
size,
|
||||
10,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::Size)
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.5 scaling composes with the #210 waiver: the runtime moves the
|
||||
/// floor, and `allow_below_floor` still only softens what remains below
|
||||
/// it — it never bypasses the scaled comparison.
|
||||
#[test]
|
||||
fn allow_below_floor_waives_against_the_scaled_floor() {
|
||||
let policy = banded_policy();
|
||||
let waive = TitleOverrides {
|
||||
allow_below_floor: true,
|
||||
..TitleOverrides::default()
|
||||
};
|
||||
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
|
||||
// The 2 GiB floor at 22 minutes is ~0.98 GiB. 1.5 GiB clears it, so
|
||||
// the override has nothing to waive; 0.5 GiB is below even the
|
||||
// scaled floor and stays a waiver rather than eligible.
|
||||
let at = |size_bytes, overrides| {
|
||||
evaluate(
|
||||
&policy,
|
||||
overrides,
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(size_bytes),
|
||||
1,
|
||||
22,
|
||||
)
|
||||
.verdict
|
||||
};
|
||||
|
||||
assert_eq!(at(3 << 29, &waive), Verdict::Eligible);
|
||||
assert_eq!(at(1 << 29, &waive), Verdict::Waived(Rule::Size));
|
||||
assert_eq!(
|
||||
at(1 << 29, &TitleOverrides::default()),
|
||||
Verdict::Rejected(Rule::Size)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_unsafe_source_hard_fails_in_both_phases() {
|
||||
let policy = policy();
|
||||
@@ -876,6 +931,7 @@ mod tests {
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -933,6 +989,7 @@ mod tests {
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
let expected = if rejected {
|
||||
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
|
||||
@@ -953,6 +1010,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -986,6 +1044,7 @@ mod tests {
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: None,
|
||||
episode_count: 1,
|
||||
runtime_minutes: 0,
|
||||
};
|
||||
let soft = FixedRule {
|
||||
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
|
||||
@@ -1015,6 +1074,7 @@ mod tests {
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: None,
|
||||
episode_count: 1,
|
||||
runtime_minutes: 0,
|
||||
};
|
||||
let first = FixedRule {
|
||||
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
|
||||
@@ -1151,7 +1211,8 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Eligible
|
||||
@@ -1165,7 +1226,8 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::RequiredAudio)
|
||||
@@ -1240,6 +1302,7 @@ mod tests {
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||
|
||||
@@ -1252,6 +1315,7 @@ mod tests {
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||
}
|
||||
@@ -1266,6 +1330,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
assert_eq!(
|
||||
|
||||
+193
-22
@@ -27,6 +27,11 @@ use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source};
|
||||
|
||||
const BYTES_PER_GIB: i64 = 1 << 30;
|
||||
|
||||
/// The reference runtime (`DESIGN.md` §5.5): a band's shipped values are a
|
||||
/// rate against a 45-minute episode, and both floor and target scale by
|
||||
/// `runtime / 45` before a per-episode size is compared to them.
|
||||
pub const REFERENCE_RUNTIME_MINUTES: u32 = 45;
|
||||
|
||||
/// How much each scoring term is worth. Policy data, not constants in the
|
||||
/// code, for the same reason the size bands are.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -94,6 +99,13 @@ pub struct Score {
|
||||
/// band is compared against. The caller supplies it — `arr-core` has no IO,
|
||||
/// and a `Season` claim names a season, not a length. A movie, and any release
|
||||
/// whose count is unknown, is one episode; zero is treated as one.
|
||||
///
|
||||
/// A band also describes a rate against [`REFERENCE_RUNTIME_MINUTES`], so
|
||||
/// `runtime_minutes` — the series' minutes per episode, caller-supplied the
|
||||
/// same way — scales its floor and target before the comparison. Zero is a
|
||||
/// missing runtime and applies the band unscaled, exactly the pre-scaling
|
||||
/// behaviour. Movies are never scaled: their bands are already tuned against
|
||||
/// feature length, so a movie caller passes zero.
|
||||
#[must_use]
|
||||
pub fn score(
|
||||
policy: &Policy,
|
||||
@@ -101,13 +113,16 @@ pub fn score(
|
||||
size_bytes: u64,
|
||||
seeders: u32,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Score {
|
||||
let weights = &policy.score_weights;
|
||||
let claimed = candidate.resolution();
|
||||
let per_episode = per_episode_size(size_bytes, episode_count);
|
||||
let size = claimed
|
||||
.and_then(|resolution| policy.size_bands.get(&resolution))
|
||||
.map_or(0, |band| size_points(band, weights, per_episode));
|
||||
.map_or(0, |band| {
|
||||
size_points(&scaled_band(band, runtime_minutes), weights, per_episode)
|
||||
});
|
||||
let source = candidate
|
||||
.source()
|
||||
.map_or(0, |source| source_points(policy, source));
|
||||
@@ -130,13 +145,19 @@ pub fn score(
|
||||
///
|
||||
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
|
||||
#[must_use]
|
||||
pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score {
|
||||
pub fn score_release(
|
||||
policy: &Policy,
|
||||
release: &Release,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Score {
|
||||
score(
|
||||
policy,
|
||||
Candidate::PreGrab(&release.parsed),
|
||||
release.size,
|
||||
release.seeders,
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,6 +167,9 @@ pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) ->
|
||||
/// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor
|
||||
/// would let every pack through untested. Zero `episode_count` is one episode.
|
||||
///
|
||||
/// The floor is also scaled by `runtime_minutes / 45` the way [`score`]
|
||||
/// scales it: zero runtime means unscaled, and a movie caller passes zero.
|
||||
///
|
||||
/// `None` when the policy carries no band for that resolution: no band is no
|
||||
/// opinion, not a rejection.
|
||||
#[must_use]
|
||||
@@ -154,11 +178,11 @@ pub fn is_below_floor(
|
||||
resolution: Resolution,
|
||||
size_bytes: u64,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Option<bool> {
|
||||
policy
|
||||
.size_bands
|
||||
.get(&resolution)
|
||||
.map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes)
|
||||
policy.size_bands.get(&resolution).map(|band| {
|
||||
per_episode_size(size_bytes, episode_count) < scaled_band(band, runtime_minutes).floor_bytes
|
||||
})
|
||||
}
|
||||
|
||||
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
|
||||
@@ -195,6 +219,26 @@ fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 {
|
||||
size_bytes / u64::from(episode_count.max(1))
|
||||
}
|
||||
|
||||
/// A band read at a runtime (`DESIGN.md` §5.5): floor and target scale by
|
||||
/// `runtime / 45`, the penalty rate stays per gibibyte over. Zero runtime is
|
||||
/// the reference runtime — the band applies unscaled.
|
||||
fn scaled_band(band: &SizeBand, runtime_minutes: u32) -> SizeBand {
|
||||
if runtime_minutes == 0 || runtime_minutes == REFERENCE_RUNTIME_MINUTES {
|
||||
return *band;
|
||||
}
|
||||
SizeBand {
|
||||
floor_bytes: scale_by_runtime(band.floor_bytes, runtime_minutes),
|
||||
target_bytes: scale_by_runtime(band.target_bytes, runtime_minutes),
|
||||
penalty_points_per_gib_over: band.penalty_points_per_gib_over,
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_by_runtime(bytes: u64, runtime_minutes: u32) -> u64 {
|
||||
let scaled =
|
||||
u128::from(bytes) * u128::from(runtime_minutes) / u128::from(REFERENCE_RUNTIME_MINUTES);
|
||||
u64::try_from(scaled).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// The size term: a ramp from the floor up to the target, then a penalty that
|
||||
/// grows with every gigabyte above it.
|
||||
fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 {
|
||||
@@ -329,6 +373,7 @@ mod tests {
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -350,6 +395,7 @@ mod tests {
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,6 +404,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation {
|
||||
size_rule_at(size_bytes, episode_count, 0)
|
||||
}
|
||||
|
||||
fn size_rule_at(size_bytes: u64, episode_count: u32, runtime_minutes: u32) -> RuleEvaluation {
|
||||
let policy = policy();
|
||||
let overrides = TitleOverrides::default();
|
||||
let language = Language::Other("en".to_owned());
|
||||
@@ -369,6 +419,7 @@ mod tests {
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: Some(size_bytes),
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,6 +451,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(gib(60)),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
// A bad score, but a score: nothing filters it out, so a selection
|
||||
@@ -466,11 +518,11 @@ mod tests {
|
||||
assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size));
|
||||
assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_)));
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R720p, gib(3), 1),
|
||||
is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -481,7 +533,7 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1);
|
||||
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1, 0);
|
||||
|
||||
assert_eq!(score.size, 0);
|
||||
assert_eq!(score.total, score.source + score.seeders);
|
||||
@@ -499,8 +551,8 @@ mod tests {
|
||||
},
|
||||
);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1);
|
||||
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1);
|
||||
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1, 0);
|
||||
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1, 0);
|
||||
|
||||
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
|
||||
assert_eq!(under.size, 0);
|
||||
@@ -548,7 +600,7 @@ mod tests {
|
||||
resolution: Some(resolution),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1, 0).resolution
|
||||
};
|
||||
|
||||
assert_eq!(at(ClaimedResolution::P2160), 2 * step);
|
||||
@@ -565,7 +617,7 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1);
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1, 0);
|
||||
|
||||
assert_eq!(unranked.resolution, 0);
|
||||
assert_eq!(unclaimed.resolution, 0);
|
||||
@@ -617,7 +669,7 @@ mod tests {
|
||||
fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() {
|
||||
let episode = scored(ClaimedSource::WebDl, gib(22), 10);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10);
|
||||
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10, 0);
|
||||
|
||||
assert_eq!(pack.size, episode.size);
|
||||
assert_eq!(pack.total, episode.total);
|
||||
@@ -628,7 +680,7 @@ mod tests {
|
||||
// 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor
|
||||
// — a pack of mud-quality encodes fails as plainly as one of them.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10, 0),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -638,22 +690,141 @@ mod tests {
|
||||
|
||||
// The same total over three episodes is 10 GiB each and passes.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3, 0),
|
||||
Some(false)
|
||||
);
|
||||
assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_)));
|
||||
}
|
||||
|
||||
/// The corrected acceptance criterion from issue #209: a series whose
|
||||
/// runtime is known and short is judged against a proportionally scaled
|
||||
/// floor and target, at both 22 and 45 minutes.
|
||||
#[test]
|
||||
fn a_known_short_runtime_scales_the_floor_at_22_and_45_minutes() {
|
||||
// 4K floor is 8 GiB per 45 minutes; at 22 minutes it is ~3.91 GiB.
|
||||
// 5 GiB fails the unscaled floor and clears the 22-minute one.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 22),
|
||||
Some(false)
|
||||
);
|
||||
// Genuinely thin stays rejected even scaled: 3 GiB < 3.91 GiB.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 22),
|
||||
Some(true)
|
||||
);
|
||||
// 45 minutes is the reference runtime: identical to no scaling.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 0)
|
||||
);
|
||||
|
||||
// The size rule takes the same scaled floor.
|
||||
assert_eq!(
|
||||
size_rule_at(gib(5), 1, 0),
|
||||
RuleEvaluation::HardFail(Rule::Size)
|
||||
);
|
||||
assert_eq!(
|
||||
size_rule_at(gib(5), 1, 45),
|
||||
RuleEvaluation::HardFail(Rule::Size)
|
||||
);
|
||||
assert!(matches!(
|
||||
size_rule_at(gib(5), 1, 22),
|
||||
RuleEvaluation::Pass(_)
|
||||
));
|
||||
assert_eq!(
|
||||
size_rule_at(gib(3), 1, 22),
|
||||
RuleEvaluation::HardFail(Rule::Size)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_target_scales_with_runtime_so_equal_bitrates_score_equally() {
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let scored_at_runtime = |size, runtime| {
|
||||
score(&policy(), Candidate::PreGrab(&claims), size, 10, 1, runtime).size
|
||||
};
|
||||
|
||||
// At-target bitrate: 22 GiB per 45 minutes is 22 GiB × 22/45 at 22
|
||||
// minutes, and both sit at the top of the size term.
|
||||
let at_target = i64::from(ScoreWeights::default().size_at_target);
|
||||
assert_eq!(scored_at_runtime(gib(22), 45), at_target);
|
||||
assert_eq!(scored_at_runtime(gib(22) * 22 / 45, 22), at_target);
|
||||
|
||||
// A below-target bitrate lands on the same point of the ramp at any
|
||||
// runtime, give or take integer rounding.
|
||||
let half_way_45 = scored_at_runtime(gib(15), 45);
|
||||
let half_way_22 = scored_at_runtime(gib(15) * 22 / 45, 22);
|
||||
assert!((half_way_45 - half_way_22).abs() <= 1);
|
||||
}
|
||||
|
||||
/// The correction on issue #209: the Rick and Morty S09 packs are 0.19,
|
||||
/// 0.24 and 0.32 GiB per 22-minute episode against a 1 GiB 1080p floor.
|
||||
/// The scaled floor is ~0.489 GiB, they are genuinely low-bitrate, and
|
||||
/// scaling must not let them through.
|
||||
#[test]
|
||||
fn the_rick_and_morty_s09_packs_stay_below_the_scaled_floor() {
|
||||
let mut policy = policy();
|
||||
policy.size_bands.insert(
|
||||
Resolution::R1080p,
|
||||
SizeBand {
|
||||
floor_bytes: gib(1),
|
||||
target_bytes: gib(2),
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
);
|
||||
let episodes = 10;
|
||||
for per_episode_gib in [19, 24, 32] {
|
||||
let pack = per_episode_gib * GIB / 100 * u64::from(episodes);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R1080p, pack, episodes, 22),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
// Half a GiB per episode clears the scaled floor: the floor still
|
||||
// discriminates rather than rejecting every 22-minute release.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R1080p, gib(5), episodes, 22),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
/// A missing runtime is the reference runtime (`DESIGN.md` §5.5): zero
|
||||
/// reproduces the pre-scaling score exactly, pinned to literals the same
|
||||
/// way #180 pinned movie scoring.
|
||||
#[test]
|
||||
fn a_missing_runtime_reproduces_the_unscaled_score() {
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let missing = score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 0);
|
||||
assert_eq!(
|
||||
missing,
|
||||
Score {
|
||||
total: 918,
|
||||
size: 520,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
missing,
|
||||
score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 45)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_episode_count_falls_back_to_one_episode() {
|
||||
let single = scored(ClaimedSource::WebDl, gib(22), 10);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0);
|
||||
let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0, 0);
|
||||
|
||||
assert_eq!(zero, single);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1)
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -733,18 +904,18 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes)
|
||||
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes, 0)
|
||||
};
|
||||
let hd = scored(ClaimedResolution::P1080, hd_pack);
|
||||
let uhd = scored(ClaimedResolution::P2160, uhd_pack);
|
||||
|
||||
// Neither pack trips the floor per episode, so the ranking decides.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes),
|
||||
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes, 0),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes),
|
||||
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0),
|
||||
Some(false)
|
||||
);
|
||||
assert!(uhd.total > hd.total);
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
//! the no-PT-source queue, or hard-failed twice on different releases (the
|
||||
//! same queues `GET /api/queues/attention` reports, §9.3).
|
||||
//!
|
||||
//! §5.7 sets the bar for the hard-fail side: two failures on *different*
|
||||
//! releases, both inside `arr_db::ATTENTION_WINDOW`. One bad torrent is not a
|
||||
//! decision, and a failure already dealt with ages out (#226).
|
||||
//!
|
||||
//! Edge-triggered per title: it notifies once when the title enters either
|
||||
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
|
||||
//! again. A series notifies as its series, never per episode — a broken
|
||||
@@ -34,8 +38,8 @@ struct TvEntry {
|
||||
no_pt_source: Vec<i64>,
|
||||
/// Episodes two different releases hard-failed post-probe (§5.7).
|
||||
hard_failed_episodes: Vec<i64>,
|
||||
/// Seasons whose pack grab hard-failed, sending the season back to
|
||||
/// per-episode grabbing.
|
||||
/// Seasons two different pack releases hard-failed on (§5.7), sending the
|
||||
/// season back to per-episode grabbing.
|
||||
failed_season_packs: Vec<i64>,
|
||||
}
|
||||
|
||||
@@ -59,8 +63,8 @@ impl TvEntry {
|
||||
if !self.failed_season_packs.is_empty() {
|
||||
parts.push(plural(
|
||||
self.failed_season_packs.len(),
|
||||
"season pack hard-failed",
|
||||
"season packs hard-failed",
|
||||
"season hard-failed twice on different packs",
|
||||
"seasons hard-failed twice on different packs",
|
||||
));
|
||||
}
|
||||
parts.join("; ")
|
||||
@@ -134,8 +138,10 @@ impl AttentionAction {
|
||||
AND (SELECT count(DISTINCT g.release_id)
|
||||
FROM grabs g
|
||||
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
|
||||
AND g.state = 'failed') >= 2
|
||||
"#
|
||||
AND g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
@@ -209,8 +215,8 @@ fn tv_entry(
|
||||
|
||||
/// TV roll-up (§9.5): every queued series with what put it there — wanted
|
||||
/// episodes whose every candidate was rejected for language, episodes two
|
||||
/// different releases hard-failed post-probe, and seasons whose pack grab
|
||||
/// hard-failed. One entry per series, so the notification can be one per
|
||||
/// different releases hard-failed post-probe, and seasons two different packs
|
||||
/// hard-failed on. One entry per series, so the notification can be one per
|
||||
/// series however long the broken season is.
|
||||
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
|
||||
let mut tv = HashMap::new();
|
||||
@@ -250,10 +256,12 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
AND e.wanted = 1 AND e.state != 'available'
|
||||
GROUP BY s.id, s.title, s.year, e.id
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
@@ -271,8 +279,11 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
|
||||
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE g.state = 'failed'
|
||||
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
|
||||
GROUP BY s.id, s.title, s.year, se.id
|
||||
"#
|
||||
HAVING count(DISTINCT g.release_id) >= 2
|
||||
"#,
|
||||
arr_db::ATTENTION_WINDOW
|
||||
)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
@@ -382,6 +393,39 @@ mod tests {
|
||||
series_id
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// stamped `age_days` in the past, so §5.7's window can be exercised
|
||||
/// without waiting a month.
|
||||
async fn insert_aged_failed_grab(
|
||||
database: &Db,
|
||||
target_kind: &str,
|
||||
target_id: i64,
|
||||
release_guid: &str,
|
||||
age_days: i64,
|
||||
) {
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(release_guid)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(target_kind)
|
||||
.bind(target_id)
|
||||
.bind(format!("hash-{release_guid}"))
|
||||
.bind(format!("-{age_days} days"))
|
||||
.execute(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
|
||||
/// standing in for what the import tick leaves behind post-probe.
|
||||
async fn insert_failed_grab(
|
||||
@@ -548,9 +592,10 @@ mod tests {
|
||||
.unwrap();
|
||||
insert_failed_grab(&database, "episode", episode_id, "first").await;
|
||||
insert_failed_grab(&database, "episode", episode_id, "second").await;
|
||||
// The pack's failure sent this season back to per-episode grabbing;
|
||||
// it queues the same series, so it must not double the message.
|
||||
// The packs' failures sent this season back to per-episode grabbing;
|
||||
// they queue the same series, so it must not double the message.
|
||||
insert_failed_grab(&database, "season", season_id, "pack").await;
|
||||
insert_failed_grab(&database, "season", season_id, "pack-two").await;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let action = action(&server).await;
|
||||
@@ -631,4 +676,66 @@ mod tests {
|
||||
assert_eq!(second.len(), 0, "leaves the queue once imported");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// §5.7: the season lane holds to the same two-distinct-releases bar the
|
||||
/// episode lane does, so one bad pack does not notify (#226).
|
||||
#[tokio::test]
|
||||
async fn one_failed_season_pack_does_not_notify() {
|
||||
let (_dir, database) = seeded_database().await;
|
||||
insert_no_pt_source_series(&database, 1, 0).await;
|
||||
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
insert_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: 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_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1192,6 +1192,7 @@ pub(crate) async fn store_release(
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Option<Eligible>, GrabError> {
|
||||
// A movie is one episode's worth and is never runtime-scaled (§5.5).
|
||||
let (release_id, eligible) = classify_and_store(
|
||||
database,
|
||||
release,
|
||||
@@ -1200,6 +1201,7 @@ pub(crate) async fn store_release(
|
||||
original_language,
|
||||
blacklist,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
@@ -1241,6 +1243,11 @@ pub(crate) async fn store_episode_release(
|
||||
_ => BTreeMap::new(),
|
||||
};
|
||||
let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths);
|
||||
// §5.5: the size bands scale by the series' minutes per episode.
|
||||
let runtime_minutes = match episode_ids.first() {
|
||||
Some(&episode_id) => series_runtime_of(database, episode_id).await?,
|
||||
None => 0,
|
||||
};
|
||||
let (release_id, eligible) = classify_and_store(
|
||||
database,
|
||||
release,
|
||||
@@ -1249,6 +1256,7 @@ pub(crate) async fn store_episode_release(
|
||||
original_language,
|
||||
blacklist,
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
)
|
||||
.await?;
|
||||
for episode_id in episode_ids {
|
||||
@@ -1293,6 +1301,26 @@ async fn season_lengths_of(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The minutes-per-episode of the series one covered episode belongs to
|
||||
/// (`DESIGN.md` §5.5): the scale factor for its size bands. Zero when the
|
||||
/// series has no known runtime, which applies the bands unscaled.
|
||||
async fn series_runtime_of(database: &Db, episode_id: i64) -> Result<u32, GrabError> {
|
||||
let minutes = sqlx::query_scalar!(
|
||||
r#"SELECT s.runtime_minutes FROM series s
|
||||
WHERE s.id = (SELECT s2.series_id FROM episodes e
|
||||
JOIN seasons s2 ON s2.id = e.season_id
|
||||
WHERE e.id = ?)"#,
|
||||
episode_id
|
||||
)
|
||||
.fetch_optional(database.pool())
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(minutes
|
||||
.and_then(|minutes| u32::try_from(minutes).ok())
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn classify_and_store(
|
||||
database: &Db,
|
||||
release: &SearchRelease,
|
||||
@@ -1301,6 +1329,7 @@ async fn classify_and_store(
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Result<(i64, Option<Eligible>), GrabError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
let evaluation = evaluate(
|
||||
@@ -1310,6 +1339,7 @@ async fn classify_and_store(
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size,
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
);
|
||||
let scored = score(
|
||||
policy,
|
||||
@@ -1317,6 +1347,7 @@ async fn classify_and_store(
|
||||
release.size.unwrap_or_default(),
|
||||
release.seeders.unwrap_or_default(),
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
);
|
||||
// A release that did not say its size is not a tiny one: scoring it
|
||||
// against the band's floor would bury it. Same treatment as the manual
|
||||
|
||||
@@ -308,6 +308,7 @@ impl ImportAction {
|
||||
Candidate::PostDownload(&feature.media),
|
||||
Some(feature.size),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
let waiver: Option<Rule> = match evaluation.verdict {
|
||||
Verdict::Rejected(rule) => {
|
||||
@@ -477,6 +478,10 @@ impl ImportAction {
|
||||
// §5.6 second phase of truth, over every file that would be
|
||||
// imported, before anything is placed: one hard failure condemns
|
||||
// the whole release (§5.7), not the episodes.
|
||||
let runtime_minutes = pending
|
||||
.runtime_minutes
|
||||
.and_then(|minutes| u32::try_from(minutes).ok())
|
||||
.unwrap_or(0);
|
||||
let mut imports = Vec::new();
|
||||
for assignment in assignments {
|
||||
if assignment.episode.has_file {
|
||||
@@ -496,6 +501,7 @@ impl ImportAction {
|
||||
Candidate::PostDownload(&assignment.file.media),
|
||||
Some(assignment.file.size),
|
||||
1,
|
||||
runtime_minutes,
|
||||
);
|
||||
let waiver = match evaluation.verdict {
|
||||
Verdict::Rejected(rule) => {
|
||||
@@ -938,6 +944,10 @@ struct PendingTvImport {
|
||||
series_title: String,
|
||||
series_year: Option<i64>,
|
||||
original_language: Option<String>,
|
||||
/// §5.5: the series' minutes per episode, scaling the size bands the
|
||||
/// same way the pre-grab verdict scaled them. `None` applies them
|
||||
/// unscaled.
|
||||
runtime_minutes: Option<i64>,
|
||||
release_name: String,
|
||||
}
|
||||
|
||||
@@ -974,6 +984,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
s.title AS "series_title!: String",
|
||||
s.year AS "series_year",
|
||||
s.original_language,
|
||||
s.runtime_minutes,
|
||||
r.name AS "release_name!: String"
|
||||
FROM grabs g
|
||||
JOIN episodes e ON e.id = g.target_id
|
||||
@@ -997,6 +1008,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
series_title: row.series_title,
|
||||
series_year: row.series_year,
|
||||
original_language: row.original_language,
|
||||
runtime_minutes: row.runtime_minutes,
|
||||
release_name: row.release_name,
|
||||
}));
|
||||
|
||||
@@ -1011,6 +1023,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
s.title AS "series_title!: String",
|
||||
s.year AS "series_year",
|
||||
s.original_language,
|
||||
s.runtime_minutes,
|
||||
r.name AS "release_name!: String"
|
||||
FROM grabs g
|
||||
JOIN seasons se ON se.id = g.target_id
|
||||
@@ -1033,6 +1046,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
|
||||
series_title: row.series_title,
|
||||
series_year: row.series_year,
|
||||
original_language: row.original_language,
|
||||
runtime_minutes: row.runtime_minutes,
|
||||
release_name: row.release_name,
|
||||
}));
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ impl SeriesRefreshAction {
|
||||
title AS "title!: String", year, original_language,
|
||||
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
|
||||
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
|
||||
poster_path, backdrop_path, vote_average
|
||||
poster_path, backdrop_path, vote_average, runtime_minutes
|
||||
FROM series
|
||||
ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"#
|
||||
)
|
||||
@@ -118,7 +118,7 @@ impl SeriesRefreshAction {
|
||||
title AS "title!: String", year, original_language,
|
||||
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
|
||||
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
|
||||
poster_path, backdrop_path, vote_average
|
||||
poster_path, backdrop_path, vote_average, runtime_minutes
|
||||
FROM series WHERE id = ?"#,
|
||||
series_id
|
||||
)
|
||||
@@ -250,6 +250,20 @@ impl SeriesRefreshAction {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// §5.5: the size bands scale by this. TMDB's `episode_run_time` is
|
||||
// frequently empty; a known value is never overwritten by a missing
|
||||
// one, so a series keeps its runtime across TMDB's blank spells.
|
||||
let runtime = metadata.episode_runtime.map(i64::from);
|
||||
if runtime.is_some() && runtime != stale.runtime_minutes {
|
||||
sqlx::query!(
|
||||
"UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
runtime,
|
||||
stale.id
|
||||
)
|
||||
.execute(&mut *executor)
|
||||
.await?;
|
||||
changed = true;
|
||||
}
|
||||
let ended = is_upstream_ended(&metadata.status);
|
||||
if ended != stale.upstream_ended {
|
||||
sqlx::query!(
|
||||
@@ -547,6 +561,7 @@ struct DueSeries {
|
||||
poster_path: Option<String>,
|
||||
backdrop_path: Option<String>,
|
||||
vote_average: Option<f64>,
|
||||
runtime_minutes: Option<i64>,
|
||||
}
|
||||
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
|
||||
/// STRICT). A number past `u16::MAX` cannot match anything real and would
|
||||
@@ -817,6 +832,55 @@ mod tests {
|
||||
assert_eq!(vote, Some(8.417));
|
||||
}
|
||||
|
||||
/// §5.5: the refresh stores the minutes-per-episode the size bands scale
|
||||
/// by, and a later refresh with TMDB's frequently-empty
|
||||
/// `episode_run_time` never blanks a known value.
|
||||
#[tokio::test]
|
||||
async fn refresh_stores_the_episode_runtime_and_keeps_it_over_blanks() {
|
||||
let (_dir, database) = seeded_series(false).await;
|
||||
let server = MockServer::start().await;
|
||||
let body = |episode_run_time: serde_json::Value| {
|
||||
json!({
|
||||
"id": 82_728,
|
||||
"name": "Bluey",
|
||||
"original_language": "en",
|
||||
"first_air_date": "2018-10-01",
|
||||
"status": "Returning Series",
|
||||
"episode_run_time": episode_run_time,
|
||||
"seasons": []
|
||||
})
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([7]))))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
let runtime: Option<i64> =
|
||||
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(runtime, Some(7));
|
||||
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([]))))
|
||||
.mount(&server)
|
||||
.await;
|
||||
expire_refresh(&database).await;
|
||||
|
||||
action(&server).tick(&database).await.unwrap();
|
||||
let runtime: Option<i64> =
|
||||
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(runtime, Some(7));
|
||||
}
|
||||
|
||||
/// #160. A series' first refresh reveals its back catalogue, but §4.1
|
||||
/// never tracks what was already there at add time: nothing is tracked,
|
||||
/// nothing arrives wanted.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- §5.5 as amended by #187/#208: size bands are rates against a 45-minute
|
||||
-- reference runtime, scaled by the series' minutes per episode. NULL is a
|
||||
-- missing runtime — TMDB's episode_run_time is frequently empty — and means
|
||||
-- the bands apply unscaled.
|
||||
ALTER TABLE series ADD COLUMN runtime_minutes INTEGER
|
||||
CHECK (runtime_minutes IS NULL OR runtime_minutes > 0);
|
||||
@@ -17,6 +17,15 @@ use sqlx::{migrate::MigrateError, SqlitePool};
|
||||
/// The migrations embedded in the binary, so a deploy is one file.
|
||||
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision
|
||||
/// queue, as a SQLite time modifier.
|
||||
///
|
||||
/// Nothing ever clears a `grabs` row, so without a bound the queue only grows
|
||||
/// and the one season that wants attention sits behind the ones that do not.
|
||||
/// Callers pair it with the `grabbed_at` format:
|
||||
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
|
||||
pub const ATTENTION_WINDOW: &str = "-30 days";
|
||||
|
||||
/// How long a writer waits for the write lock before giving up.
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
|
||||
@@ -696,7 +696,8 @@ mod tests {
|
||||
&loaded.policy,
|
||||
Resolution::R1080p,
|
||||
one_and_a_half_gib,
|
||||
1
|
||||
1,
|
||||
0
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
|
||||
@@ -156,6 +156,10 @@ pub struct Series {
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
/// Minutes per episode, the first non-zero entry of TMDB's
|
||||
/// `episode_run_time`. Frequently empty for returning series — §5.5
|
||||
/// treats a missing runtime as the reference runtime.
|
||||
pub episode_runtime: Option<u32>,
|
||||
pub seasons: Vec<SeasonSummary>,
|
||||
}
|
||||
|
||||
@@ -368,6 +372,8 @@ pub(crate) struct RawSeries {
|
||||
#[serde(default)]
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
episode_run_time: Vec<u32>,
|
||||
#[serde(default)]
|
||||
seasons: Vec<RawSeasonSummary>,
|
||||
#[serde(default)]
|
||||
external_ids: Option<RawExternalIds>,
|
||||
@@ -406,6 +412,10 @@ impl From<RawSeries> for Series {
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
episode_runtime: raw
|
||||
.episode_run_time
|
||||
.into_iter()
|
||||
.find(|&minutes| minutes > 0),
|
||||
seasons: raw
|
||||
.seasons
|
||||
.into_iter()
|
||||
|
||||
Reference in New Issue
Block a user