Compare commits

..

8 Commits

Author SHA1 Message Date
Miguel Palhas ac0e80c044 feat: require two recent failures to queue a season
The season branch of the attention queue listed a season on one failed
grab of any age, so `GET /api/queues/attention` returned Rick and Morty
with every season it has and buried the one that needed attention.

Two changes, both stated in DESIGN.md §5.7:

- The season branch now enforces the same bar the episode branch does:
  two grabs that hard-failed on *different* releases.
- A failed grab counts toward the queue for 30 days
  (`arr_db::ATTENTION_WINDOW`). Nothing clears a `grabs` row, so without
  a window the queue only grows and can never be emptied. #181 gave the
  pack guard a backoff curve for the same reason; this is the queue's
  version of §6.2's "it never gives up entirely, it goes quiet". A
  season the operator dealt with stops failing and drops out; one still
  breaking keeps failing (the pack guard retries at worst weekly) and
  stays.

The window applies to all three hard-fail lanes — movie, episode and
season — because DESIGN.md states one rule for the queue, and to the
daemon's needs-a-decision notifier as well as the API, since both read
the same queue and a season-per-failure notification is the same noise
on a different channel. No schema change: `grabs.grabbed_at` already
carries the timestamp.

Gate: `just ci` green (486 tests).
2026-08-25 10:09:07 +01:00
Miguel Palhas 8c3e1c4a92 Merge milestone 'Size bands and waivers'
ci / web (push) Successful in 45s
e2e / e2e (push) Successful in 1m30s
ci / rust (push) Successful in 1m38s
A size band is now a rate: floor and target scale by the series'
minutes per episode against a 45-minute reference, so a short-form
show is no longer judged against an hour of video. Shipped band
values are unchanged — the reference runtime is chosen so they keep
their meaning. A missing runtime scales by one, and movies are never
scaled.

A size rejection can also be waived. The override relaxes the floor
for one title into a waiver rather than lifting it, so the release
stays out of automatic grabbing and imports on the record (§5.7).

The runtime migration is numbered 0026 to leave 0024 and 0025 to the
concurrent subtitles milestone; two files claiming one version do not
conflict in git and would have reached main unnoticed.

Closes #208, #209, #210
2026-08-24 22:51:55 +01:00
Miguel Palhas bb2708f7ee chore(db): renumber the runtime migration to 0026
The concurrent subtitles milestone carries 0024_subtitles.sql and
0025_subtitle_settings.sql. Two migrations claiming version 24 do not
conflict in git — the filenames differ — so both would land on main and
sqlx would see a duplicate version. Renumbering here is the half that
does not depend on the other milestone acting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:47:18 +01:00
Miguel Palhas 5c5b1e234c Merge #209: scale size bands by episode runtime
Closes #209
2026-08-24 22:47:11 +01:00
Miguel Palhas 917aa4fa76 feat: scale size bands by episode runtime
Implements #209 per §5.5 as amended by #208: a band's floor and target
are rates against a 45-minute reference runtime, scaled by the series'
minutes per episode. A missing or zero runtime applies the bands
unscaled, and movies are never scaled. The runtime is stored on the
series row (new migration), filled on add and by the metadata refresh,
which never blanks a known value against TMDB's frequently-empty
episode_run_time. Composes with #210: allow_below_floor waives against
the scaled floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 22:45:55 +01:00
Miguel Palhas 024786f356 Merge #210: let a size rejection be waived
Closes #210
2026-08-24 22:16:01 +01:00
Miguel Palhas f0d45996a0 Merge #208: size bands scale with episode runtime
Closes #208
2026-08-24 22:16:01 +01:00
Miguel Palhas bd52941a6d docs: size bands scale with episode runtime
A band's floor and target now read as a rate against a 45-minute
reference runtime, scaled by the series' per-episode runtime from
TMDB. Missing or zero runtime falls back to the reference, keeping
today's behaviour. Movies are explicitly unscaled. Closes the axis
question in #208; #209 implements it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:53:32 +01:00
29 changed files with 928 additions and 85 deletions
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ",
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n s.runtime_minutes,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ",
"describe": {
"columns": [
{
@@ -103,8 +103,19 @@
}
},
{
"name": "release_name!: String",
"name": "runtime_minutes",
"ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
},
{
"name": "release_name!: String",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
@@ -127,8 +138,9 @@
false,
true,
true,
true,
false
]
},
"hash": "7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc"
"hash": "1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67"
}
@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 13
"Right": 14
},
"nullable": []
},
"hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331"
"hash": "3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT runtime_minutes FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "runtime_minutes",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
"describe": {
"columns": [
{
@@ -38,7 +38,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -46,5 +46,5 @@
true
]
},
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
"hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"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"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average, runtime_minutes\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"describe": {
"columns": [
{
@@ -145,6 +145,17 @@
"name": "vote_average"
}
}
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
@@ -163,8 +174,9 @@
true,
true,
true,
true,
true
]
},
"hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644"
"hash": "ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT s.runtime_minutes FROM series s\n WHERE s.id = (SELECT s2.series_id FROM episodes e\n JOIN seasons s2 ON s2.id = e.season_id\n WHERE e.id = ?)",
"describe": {
"columns": [
{
"name": "runtime_minutes",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166"
}
+37
View File
@@ -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
+190 -7
View File
@@ -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"),
] {
+6
View File
@@ -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))`),
+9 -1
View File
@@ -490,6 +490,7 @@ async fn movie_releases(
&original_language,
&blacklist,
None,
0,
)?);
}
}
@@ -525,6 +526,7 @@ async fn episode_releases(
.await?
.ok_or(ApiError::EpisodeNotFound)?;
let season_lengths = crate::series::season_lengths(state, episode.series_id).await?;
let runtime_minutes = crate::series::series_runtime(state, episode.series_id).await?;
let loaded = database
.episode_policy(episode_id)
.await
@@ -568,6 +570,7 @@ async fn episode_releases(
&original_language,
&blacklist,
Some(&season_lengths),
runtime_minutes,
)?);
}
}
@@ -709,6 +712,7 @@ fn classify(
original_language: &Language,
blacklist: &Blacklist,
season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<ClassifiedRelease, ApiError> {
let parsed = arr_parse::parse(&release.name);
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
@@ -724,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");
+23 -4
View File
@@ -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))
}
+70 -5
View File
@@ -68,6 +68,11 @@ pub struct EvaluationContext<'a> {
/// describes one episode, so the size rule divides by this. One for a
/// movie or an unknown count; zero is treated as one.
pub episode_count: u32,
/// The series' minutes per episode (`DESIGN.md` §5.5) — a size band is a
/// rate against 45 minutes, so the size rule scales its floor by
/// `runtime / 45`. Zero is a missing runtime and applies the band
/// unscaled; movies are never scaled and pass zero.
pub runtime_minutes: u32,
}
/// A rule's identity when no violation exists to carry concrete evidence.
@@ -112,6 +117,7 @@ pub fn evaluate(
candidate: Candidate<'_>,
size_bytes: Option<u64>,
episode_count: u32,
runtime_minutes: u32,
) -> Evaluation {
let context = EvaluationContext {
policy,
@@ -120,6 +126,7 @@ pub fn evaluate(
candidate,
size_bytes,
episode_count,
runtime_minutes,
};
let rules: [&dyn PolicyRule; 6] = [
&ResolutionRule,
@@ -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
View File
@@ -27,6 +27,11 @@ use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source};
const BYTES_PER_GIB: i64 = 1 << 30;
/// The reference runtime (`DESIGN.md` §5.5): a band's shipped values are a
/// rate against a 45-minute episode, and both floor and target scale by
/// `runtime / 45` before a per-episode size is compared to them.
pub const REFERENCE_RUNTIME_MINUTES: u32 = 45;
/// How much each scoring term is worth. Policy data, not constants in the
/// code, for the same reason the size bands are.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -94,6 +99,13 @@ pub struct Score {
/// band is compared against. The caller supplies it — `arr-core` has no IO,
/// and a `Season` claim names a season, not a length. A movie, and any release
/// whose count is unknown, is one episode; zero is treated as one.
///
/// A band also describes a rate against [`REFERENCE_RUNTIME_MINUTES`], so
/// `runtime_minutes` — the series' minutes per episode, caller-supplied the
/// same way — scales its floor and target before the comparison. Zero is a
/// missing runtime and applies the band unscaled, exactly the pre-scaling
/// behaviour. Movies are never scaled: their bands are already tuned against
/// feature length, so a movie caller passes zero.
#[must_use]
pub fn score(
policy: &Policy,
@@ -101,13 +113,16 @@ pub fn score(
size_bytes: u64,
seeders: u32,
episode_count: u32,
runtime_minutes: u32,
) -> Score {
let weights = &policy.score_weights;
let claimed = candidate.resolution();
let per_episode = per_episode_size(size_bytes, episode_count);
let size = claimed
.and_then(|resolution| policy.size_bands.get(&resolution))
.map_or(0, |band| size_points(band, weights, per_episode));
.map_or(0, |band| {
size_points(&scaled_band(band, runtime_minutes), weights, per_episode)
});
let source = candidate
.source()
.map_or(0, |source| source_points(policy, source));
@@ -130,13 +145,19 @@ pub fn score(
///
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
#[must_use]
pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score {
pub fn score_release(
policy: &Policy,
release: &Release,
episode_count: u32,
runtime_minutes: u32,
) -> Score {
score(
policy,
Candidate::PreGrab(&release.parsed),
release.size,
release.seeders,
episode_count,
runtime_minutes,
)
}
@@ -146,6 +167,9 @@ pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) ->
/// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor
/// would let every pack through untested. Zero `episode_count` is one episode.
///
/// The floor is also scaled by `runtime_minutes / 45` the way [`score`]
/// scales it: zero runtime means unscaled, and a movie caller passes zero.
///
/// `None` when the policy carries no band for that resolution: no band is no
/// opinion, not a rejection.
#[must_use]
@@ -154,11 +178,11 @@ pub fn is_below_floor(
resolution: Resolution,
size_bytes: u64,
episode_count: u32,
runtime_minutes: u32,
) -> Option<bool> {
policy
.size_bands
.get(&resolution)
.map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes)
policy.size_bands.get(&resolution).map(|band| {
per_episode_size(size_bytes, episode_count) < scaled_band(band, runtime_minutes).floor_bytes
})
}
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
@@ -195,6 +219,26 @@ fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 {
size_bytes / u64::from(episode_count.max(1))
}
/// A band read at a runtime (`DESIGN.md` §5.5): floor and target scale by
/// `runtime / 45`, the penalty rate stays per gibibyte over. Zero runtime is
/// the reference runtime — the band applies unscaled.
fn scaled_band(band: &SizeBand, runtime_minutes: u32) -> SizeBand {
if runtime_minutes == 0 || runtime_minutes == REFERENCE_RUNTIME_MINUTES {
return *band;
}
SizeBand {
floor_bytes: scale_by_runtime(band.floor_bytes, runtime_minutes),
target_bytes: scale_by_runtime(band.target_bytes, runtime_minutes),
penalty_points_per_gib_over: band.penalty_points_per_gib_over,
}
}
fn scale_by_runtime(bytes: u64, runtime_minutes: u32) -> u64 {
let scaled =
u128::from(bytes) * u128::from(runtime_minutes) / u128::from(REFERENCE_RUNTIME_MINUTES);
u64::try_from(scaled).unwrap_or(u64::MAX)
}
/// The size term: a ramp from the floor up to the target, then a penalty that
/// grows with every gigabyte above it.
fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 {
@@ -329,6 +373,7 @@ mod tests {
size_bytes,
seeders,
1,
0,
)
}
@@ -350,6 +395,7 @@ mod tests {
size_bytes,
seeders,
1,
0,
)
}
@@ -358,6 +404,10 @@ mod tests {
}
fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation {
size_rule_at(size_bytes, episode_count, 0)
}
fn size_rule_at(size_bytes: u64, episode_count: u32, runtime_minutes: u32) -> RuleEvaluation {
let policy = policy();
let overrides = TitleOverrides::default();
let language = Language::Other("en".to_owned());
@@ -369,6 +419,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims),
size_bytes: Some(size_bytes),
episode_count,
runtime_minutes,
})
}
@@ -400,6 +451,7 @@ mod tests {
Candidate::PreGrab(&claims),
Some(gib(60)),
1,
0,
);
// A bad score, but a score: nothing filters it out, so a selection
@@ -466,11 +518,11 @@ mod tests {
assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size));
assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_)));
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0),
Some(true)
);
assert_eq!(
is_below_floor(&policy(), Resolution::R720p, gib(3), 1),
is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0),
None
);
}
@@ -481,7 +533,7 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..NameClaims::default()
};
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1);
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1, 0);
assert_eq!(score.size, 0);
assert_eq!(score.total, score.source + score.seeders);
@@ -499,8 +551,8 @@ mod tests {
},
);
let claims = claims(ClaimedSource::WebDl);
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1);
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1);
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1, 0);
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1, 0);
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
assert_eq!(under.size, 0);
@@ -548,7 +600,7 @@ mod tests {
resolution: Some(resolution),
..NameClaims::default()
};
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1, 0).resolution
};
assert_eq!(at(ClaimedResolution::P2160), 2 * step);
@@ -565,7 +617,7 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..NameClaims::default()
};
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1);
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1, 0);
assert_eq!(unranked.resolution, 0);
assert_eq!(unclaimed.resolution, 0);
@@ -617,7 +669,7 @@ mod tests {
fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() {
let episode = scored(ClaimedSource::WebDl, gib(22), 10);
let claims = claims(ClaimedSource::WebDl);
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10);
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10, 0);
assert_eq!(pack.size, episode.size);
assert_eq!(pack.total, episode.total);
@@ -628,7 +680,7 @@ mod tests {
// 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor
// — a pack of mud-quality encodes fails as plainly as one of them.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10),
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10, 0),
Some(true)
);
assert_eq!(
@@ -638,22 +690,141 @@ mod tests {
// The same total over three episodes is 10 GiB each and passes.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3),
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3, 0),
Some(false)
);
assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_)));
}
/// The corrected acceptance criterion from issue #209: a series whose
/// runtime is known and short is judged against a proportionally scaled
/// floor and target, at both 22 and 45 minutes.
#[test]
fn a_known_short_runtime_scales_the_floor_at_22_and_45_minutes() {
// 4K floor is 8 GiB per 45 minutes; at 22 minutes it is ~3.91 GiB.
// 5 GiB fails the unscaled floor and clears the 22-minute one.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
Some(true)
);
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 22),
Some(false)
);
// Genuinely thin stays rejected even scaled: 3 GiB < 3.91 GiB.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 22),
Some(true)
);
// 45 minutes is the reference runtime: identical to no scaling.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 0)
);
// The size rule takes the same scaled floor.
assert_eq!(
size_rule_at(gib(5), 1, 0),
RuleEvaluation::HardFail(Rule::Size)
);
assert_eq!(
size_rule_at(gib(5), 1, 45),
RuleEvaluation::HardFail(Rule::Size)
);
assert!(matches!(
size_rule_at(gib(5), 1, 22),
RuleEvaluation::Pass(_)
));
assert_eq!(
size_rule_at(gib(3), 1, 22),
RuleEvaluation::HardFail(Rule::Size)
);
}
#[test]
fn the_target_scales_with_runtime_so_equal_bitrates_score_equally() {
let claims = claims(ClaimedSource::WebDl);
let scored_at_runtime = |size, runtime| {
score(&policy(), Candidate::PreGrab(&claims), size, 10, 1, runtime).size
};
// At-target bitrate: 22 GiB per 45 minutes is 22 GiB × 22/45 at 22
// minutes, and both sit at the top of the size term.
let at_target = i64::from(ScoreWeights::default().size_at_target);
assert_eq!(scored_at_runtime(gib(22), 45), at_target);
assert_eq!(scored_at_runtime(gib(22) * 22 / 45, 22), at_target);
// A below-target bitrate lands on the same point of the ramp at any
// runtime, give or take integer rounding.
let half_way_45 = scored_at_runtime(gib(15), 45);
let half_way_22 = scored_at_runtime(gib(15) * 22 / 45, 22);
assert!((half_way_45 - half_way_22).abs() <= 1);
}
/// The correction on issue #209: the Rick and Morty S09 packs are 0.19,
/// 0.24 and 0.32 GiB per 22-minute episode against a 1 GiB 1080p floor.
/// The scaled floor is ~0.489 GiB, they are genuinely low-bitrate, and
/// scaling must not let them through.
#[test]
fn the_rick_and_morty_s09_packs_stay_below_the_scaled_floor() {
let mut policy = policy();
policy.size_bands.insert(
Resolution::R1080p,
SizeBand {
floor_bytes: gib(1),
target_bytes: gib(2),
penalty_points_per_gib_over: 60,
},
);
let episodes = 10;
for per_episode_gib in [19, 24, 32] {
let pack = per_episode_gib * GIB / 100 * u64::from(episodes);
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, pack, episodes, 22),
Some(true)
);
}
// Half a GiB per episode clears the scaled floor: the floor still
// discriminates rather than rejecting every 22-minute release.
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, gib(5), episodes, 22),
Some(false)
);
}
/// A missing runtime is the reference runtime (`DESIGN.md` §5.5): zero
/// reproduces the pre-scaling score exactly, pinned to literals the same
/// way #180 pinned movie scoring.
#[test]
fn a_missing_runtime_reproduces_the_unscaled_score() {
let claims = claims(ClaimedSource::WebDl);
let missing = score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 0);
assert_eq!(
missing,
Score {
total: 918,
size: 520,
source: 50,
seeders: 48,
resolution: 300,
}
);
assert_eq!(
missing,
score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 45)
);
}
#[test]
fn an_unknown_episode_count_falls_back_to_one_episode() {
let single = scored(ClaimedSource::WebDl, gib(22), 10);
let claims = claims(ClaimedSource::WebDl);
let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0);
let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0, 0);
assert_eq!(zero, single);
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1)
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0)
);
}
@@ -733,18 +904,18 @@ mod tests {
source: Some(ClaimedSource::WebDl),
..NameClaims::default()
};
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes)
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes, 0)
};
let hd = scored(ClaimedResolution::P1080, hd_pack);
let uhd = scored(ClaimedResolution::P2160, uhd_pack);
// Neither pack trips the floor per episode, so the ranking decides.
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes),
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes, 0),
Some(false)
);
assert_eq!(
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes),
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0),
Some(false)
);
assert!(uhd.total > hd.total);
+119 -12
View File
@@ -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"
);
}
}
+31
View File
@@ -1192,6 +1192,7 @@ pub(crate) async fn store_release(
original_language: &Language,
blacklist: &Blacklist,
) -> Result<Option<Eligible>, GrabError> {
// A movie is one episode's worth and is never runtime-scaled (§5.5).
let (release_id, eligible) = classify_and_store(
database,
release,
@@ -1200,6 +1201,7 @@ pub(crate) async fn store_release(
original_language,
blacklist,
1,
0,
)
.await?;
sqlx::query!(
@@ -1241,6 +1243,11 @@ pub(crate) async fn store_episode_release(
_ => BTreeMap::new(),
};
let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths);
// §5.5: the size bands scale by the series' minutes per episode.
let runtime_minutes = match episode_ids.first() {
Some(&episode_id) => series_runtime_of(database, episode_id).await?,
None => 0,
};
let (release_id, eligible) = classify_and_store(
database,
release,
@@ -1249,6 +1256,7 @@ pub(crate) async fn store_episode_release(
original_language,
blacklist,
episode_count,
runtime_minutes,
)
.await?;
for episode_id in episode_ids {
@@ -1293,6 +1301,26 @@ async fn season_lengths_of(
.collect())
}
/// The minutes-per-episode of the series one covered episode belongs to
/// (`DESIGN.md` §5.5): the scale factor for its size bands. Zero when the
/// series has no known runtime, which applies the bands unscaled.
async fn series_runtime_of(database: &Db, episode_id: i64) -> Result<u32, GrabError> {
let minutes = sqlx::query_scalar!(
r#"SELECT s.runtime_minutes FROM series s
WHERE s.id = (SELECT s2.series_id FROM episodes e
JOIN seasons s2 ON s2.id = e.season_id
WHERE e.id = ?)"#,
episode_id
)
.fetch_optional(database.pool())
.await?
.flatten();
Ok(minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0))
}
#[allow(clippy::too_many_arguments)]
async fn classify_and_store(
database: &Db,
release: &SearchRelease,
@@ -1301,6 +1329,7 @@ async fn classify_and_store(
original_language: &Language,
blacklist: &Blacklist,
episode_count: u32,
runtime_minutes: u32,
) -> Result<(i64, Option<Eligible>), GrabError> {
let parsed = arr_parse::parse(&release.name);
let evaluation = evaluate(
@@ -1310,6 +1339,7 @@ async fn classify_and_store(
Candidate::PreGrab(&parsed),
release.size,
episode_count,
runtime_minutes,
);
let scored = score(
policy,
@@ -1317,6 +1347,7 @@ async fn classify_and_store(
release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(),
episode_count,
runtime_minutes,
);
// A release that did not say its size is not a tiny one: scoring it
// against the band's floor would bury it. Same treatment as the manual
+14
View File
@@ -308,6 +308,7 @@ impl ImportAction {
Candidate::PostDownload(&feature.media),
Some(feature.size),
1,
0,
);
let waiver: Option<Rule> = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -477,6 +478,10 @@ impl ImportAction {
// §5.6 second phase of truth, over every file that would be
// imported, before anything is placed: one hard failure condemns
// the whole release (§5.7), not the episodes.
let runtime_minutes = pending
.runtime_minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0);
let mut imports = Vec::new();
for assignment in assignments {
if assignment.episode.has_file {
@@ -496,6 +501,7 @@ impl ImportAction {
Candidate::PostDownload(&assignment.file.media),
Some(assignment.file.size),
1,
runtime_minutes,
);
let waiver = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -938,6 +944,10 @@ struct PendingTvImport {
series_title: String,
series_year: Option<i64>,
original_language: Option<String>,
/// §5.5: the series' minutes per episode, scaling the size bands the
/// same way the pre-grab verdict scaled them. `None` applies them
/// unscaled.
runtime_minutes: Option<i64>,
release_name: String,
}
@@ -974,6 +984,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String"
FROM grabs g
JOIN episodes e ON e.id = g.target_id
@@ -997,6 +1008,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title,
series_year: row.series_year,
original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name,
}));
@@ -1011,6 +1023,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String"
FROM grabs g
JOIN seasons se ON se.id = g.target_id
@@ -1033,6 +1046,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title,
series_year: row.series_year,
original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name,
}));
+66 -2
View File
@@ -75,7 +75,7 @@ impl SeriesRefreshAction {
title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average
poster_path, backdrop_path, vote_average, runtime_minutes
FROM series
ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"#
)
@@ -118,7 +118,7 @@ impl SeriesRefreshAction {
title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average
poster_path, backdrop_path, vote_average, runtime_minutes
FROM series WHERE id = ?"#,
series_id
)
@@ -250,6 +250,20 @@ impl SeriesRefreshAction {
changed = true;
}
}
// §5.5: the size bands scale by this. TMDB's `episode_run_time` is
// frequently empty; a known value is never overwritten by a missing
// one, so a series keeps its runtime across TMDB's blank spells.
let runtime = metadata.episode_runtime.map(i64::from);
if runtime.is_some() && runtime != stale.runtime_minutes {
sqlx::query!(
"UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
runtime,
stale.id
)
.execute(&mut *executor)
.await?;
changed = true;
}
let ended = is_upstream_ended(&metadata.status);
if ended != stale.upstream_ended {
sqlx::query!(
@@ -547,6 +561,7 @@ struct DueSeries {
poster_path: Option<String>,
backdrop_path: Option<String>,
vote_average: Option<f64>,
runtime_minutes: Option<i64>,
}
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
/// STRICT). A number past `u16::MAX` cannot match anything real and would
@@ -817,6 +832,55 @@ mod tests {
assert_eq!(vote, Some(8.417));
}
/// §5.5: the refresh stores the minutes-per-episode the size bands scale
/// by, and a later refresh with TMDB's frequently-empty
/// `episode_run_time` never blanks a known value.
#[tokio::test]
async fn refresh_stores_the_episode_runtime_and_keeps_it_over_blanks() {
let (_dir, database) = seeded_series(false).await;
let server = MockServer::start().await;
let body = |episode_run_time: serde_json::Value| {
json!({
"id": 82_728,
"name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"status": "Returning Series",
"episode_run_time": episode_run_time,
"seasons": []
})
};
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([7]))))
.mount(&server)
.await;
action(&server).tick(&database).await.unwrap();
let runtime: Option<i64> =
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(runtime, Some(7));
server.reset().await;
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([]))))
.mount(&server)
.await;
expire_refresh(&database).await;
action(&server).tick(&database).await.unwrap();
let runtime: Option<i64> =
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(runtime, Some(7));
}
/// #160. A series' first refresh reveals its back catalogue, but §4.1
/// never tracks what was already there at add time: nothing is tracked,
/// nothing arrives wanted.
@@ -0,0 +1,6 @@
-- §5.5 as amended by #187/#208: size bands are rates against a 45-minute
-- reference runtime, scaled by the series' minutes per episode. NULL is a
-- missing runtime — TMDB's episode_run_time is frequently empty — and means
-- the bands apply unscaled.
ALTER TABLE series ADD COLUMN runtime_minutes INTEGER
CHECK (runtime_minutes IS NULL OR runtime_minutes > 0);
+9
View File
@@ -17,6 +17,15 @@ use sqlx::{migrate::MigrateError, SqlitePool};
/// The migrations embedded in the binary, so a deploy is one file.
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision
/// queue, as a SQLite time modifier.
///
/// Nothing ever clears a `grabs` row, so without a bound the queue only grows
/// and the one season that wants attention sits behind the ones that do not.
/// Callers pair it with the `grabbed_at` format:
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
pub const ATTENTION_WINDOW: &str = "-30 days";
/// How long a writer waits for the write lock before giving up.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
+2 -1
View File
@@ -696,7 +696,8 @@ mod tests {
&loaded.policy,
Resolution::R1080p,
one_and_a_half_gib,
1
1,
0
),
Some(false)
);
+10
View File
@@ -156,6 +156,10 @@ pub struct Series {
pub vote_average: Option<f64>,
/// How many votes the rating rests on.
pub vote_count: u32,
/// Minutes per episode, the first non-zero entry of TMDB's
/// `episode_run_time`. Frequently empty for returning series — §5.5
/// treats a missing runtime as the reference runtime.
pub episode_runtime: Option<u32>,
pub seasons: Vec<SeasonSummary>,
}
@@ -368,6 +372,8 @@ pub(crate) struct RawSeries {
#[serde(default)]
vote_count: u32,
#[serde(default)]
episode_run_time: Vec<u32>,
#[serde(default)]
seasons: Vec<RawSeasonSummary>,
#[serde(default)]
external_ids: Option<RawExternalIds>,
@@ -406,6 +412,10 @@ impl From<RawSeries> for Series {
backdrop_path: non_empty(raw.backdrop_path),
vote_average: rating(raw.vote_average),
vote_count: raw.vote_count,
episode_runtime: raw
.episode_run_time
.into_iter()
.find(|&minutes| minutes > 0),
seasons: raw
.seasons
.into_iter()