diff --git a/.sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json b/.sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json new file mode 100644 index 0000000..1ae4e8d --- /dev/null +++ b/.sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json @@ -0,0 +1,33 @@ +{ + "db_name": "SQLite", + "query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = ?\n GROUP BY se.number", + "describe": { + "columns": [ + { + "name": "number!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "seasons", + "name": "number" + } + } + }, + { + "name": "episodes!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d" +} diff --git a/.sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json b/.sqlx/query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json similarity index 66% rename from .sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json rename to .sqlx/query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json index ea47275..86b77a4 100644 --- a/.sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json +++ b/.sqlx/query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json @@ -1,11 +1,22 @@ { "db_name": "SQLite", - "query": "SELECT s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?", + "query": "SELECT s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?", "describe": { "columns": [ { - "name": "series_title!: String", + "name": "series_id!: i64", "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "id" + } + } + }, + { + "name": "series_title!: String", + "ordinal": 1, "type_info": "Text", "origin": { "Table": { @@ -16,7 +27,7 @@ }, { "name": "series_tvdb_id", - "ordinal": 1, + "ordinal": 2, "type_info": "Integer", "origin": { "Table": { @@ -27,7 +38,7 @@ }, { "name": "original_language", - "ordinal": 2, + "ordinal": 3, "type_info": "Text", "origin": { "Table": { @@ -38,7 +49,7 @@ }, { "name": "season_number!: i64", - "ordinal": 3, + "ordinal": 4, "type_info": "Integer", "origin": { "Table": { @@ -49,7 +60,7 @@ }, { "name": "episode_number!: i64", - "ordinal": 4, + "ordinal": 5, "type_info": "Integer", "origin": { "Table": { @@ -63,6 +74,7 @@ "Right": 1 }, "nullable": [ + false, false, true, true, @@ -70,5 +82,5 @@ false ] }, - "hash": "c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d" + "hash": "0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33" } diff --git a/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json b/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json new file mode 100644 index 0000000..d9045ff --- /dev/null +++ b/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json @@ -0,0 +1,28 @@ +{ + "db_name": "SQLite", + "query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'", + "describe": { + "columns": [ + { + "name": "failures!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "last_failed_at?: String", + "ordinal": 1, + "type_info": "Text", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162" +} diff --git a/.sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json b/.sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json new file mode 100644 index 0000000..00f73bd --- /dev/null +++ b/.sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json @@ -0,0 +1,33 @@ +{ + "db_name": "SQLite", + "query": "SELECT e.air_date,\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"has_file!: bool\"\n FROM episodes e\n WHERE e.season_id = ?", + "describe": { + "columns": [ + { + "name": "air_date", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "episodes", + "name": "air_date" + } + } + }, + { + "name": "has_file!: bool", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false + ] + }, + "hash": "20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2" +} diff --git a/.sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json b/.sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json new file mode 100644 index 0000000..aeb8ac3 --- /dev/null +++ b/.sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT last_pack_search_at FROM seasons WHERE id = ?", + "describe": { + "columns": [ + { + "name": "last_pack_search_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "seasons", + "name": "last_pack_search_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26" +} diff --git a/.sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json b/.sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json deleted file mode 100644 index 92c0152..0000000 --- a/.sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT EXISTS (\n SELECT 1 FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'\n ) AS \"failed!: bool\"", - "describe": { - "columns": [ - { - "name": "failed!: bool", - "ordinal": 0, - "type_info": "Integer", - "origin": "Expression" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1" -} diff --git a/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json b/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json index 4720e1d..29cd7e6 100644 --- a/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json +++ b/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json @@ -6,12 +6,24 @@ { "name": "tmdb_id!: i64", "ordinal": 0, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "movies", + "name": "tmdb_id" + } + } }, { "name": "metadata_refreshed_at", "ordinal": 1, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "movies", + "name": "metadata_refreshed_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json b/.sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json new file mode 100644 index 0000000..8373dad --- /dev/null +++ b/.sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json @@ -0,0 +1,33 @@ +{ + "db_name": "SQLite", + "query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se\n LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = (SELECT s2.series_id FROM episodes e2\n JOIN seasons s2 ON s2.id = e2.season_id\n WHERE e2.id = ?)\n GROUP BY se.number", + "describe": { + "columns": [ + { + "name": "number!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "seasons", + "name": "number" + } + } + }, + { + "name": "episodes!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51" +} diff --git a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json b/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json index 42f6e34..a440463 100644 --- a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json +++ b/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json @@ -6,67 +6,145 @@ { "name": "id!: i64", "ordinal": 0, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "id" + } + } }, { "name": "tmdb_id!: i64", "ordinal": 1, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "tmdb_id" + } + } }, { "name": "tvdb_id", "ordinal": 2, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "tvdb_id" + } + } }, { "name": "title!: String", "ordinal": 3, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "title" + } + } }, { "name": "year", "ordinal": 4, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "year" + } + } }, { "name": "original_language", "ordinal": 5, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "original_language" + } + } }, { "name": "root_id!: i64", "ordinal": 6, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "root_id" + } + } }, { "name": "auto_track!: bool", "ordinal": 7, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "auto_track" + } + } }, { "name": "upstream_ended!: bool", "ordinal": 8, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "upstream_ended" + } + } }, { "name": "metadata_refreshed_at", "ordinal": 9, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "metadata_refreshed_at" + } + } }, { "name": "poster_path", "ordinal": 10, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "poster_path" + } + } }, { "name": "backdrop_path", "ordinal": 11, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "backdrop_path" + } + } }, { "name": "vote_average", "ordinal": 12, - "type_info": "Float" + "type_info": "Float", + "origin": { + "Table": { + "table": "series", + "name": "vote_average" + } + } } ], "parameters": { diff --git a/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json b/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json new file mode 100644 index 0000000..207bd32 --- /dev/null +++ b/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json @@ -0,0 +1,28 @@ +{ + "db_name": "SQLite", + "query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'", + "describe": { + "columns": [ + { + "name": "failures!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "last_failed_at?: String", + "ordinal": 1, + "type_info": "Text", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019" +} diff --git a/.sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json b/.sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json new file mode 100644 index 0000000..ac65f55 --- /dev/null +++ b/.sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE seasons\n SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1" +} diff --git a/DESIGN.md b/DESIGN.md index 457278a..09e4f0e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -77,7 +77,8 @@ Policy required_audio rule set, see §5.2 dub_blacklist [pt-BR] hdr_rules see §5.3 - size_bands per resolution: floor, target, penalty curve + size_bands per resolution: floor, target, penalty curve, + all per episode (§5.5) resolution_pref [2160p, 1080p] source_weights small tiebreaker @@ -284,6 +285,28 @@ remux scores badly but stays eligible, so it is picked when it is the only option. The floor matters — unbounded "smaller is better" selects a 3 GB 4K encode that looks like mud. +**A band describes one episode.** For a movie the question never arises — one +release is one film. For TV a release may carry a single episode, several, or a +whole season, and the shipped TV numbers are episode numbers: a 1080p target of +2 GiB is what one episode should weigh, not a ten-episode pack. So a release is +measured by its size divided by the number of episodes it covers, and that +per-episode figure is what both the target penalty and the floor are compared +against. A 20 GiB pack of ten episodes is scored as 2 GiB and sits at target, +not ten times above it. + +The floor takes the same figure. It is a hard reject rather than a penalty, so +comparing a pack's total against an episode-sized floor lets every pack through +untested — wrong in the opposite direction from the target penalty. The floor +exists to keep a 3 GB 4K encode out, and a pack of ten such encodes has to fail +it just as plainly. + +**An unknown episode count is one episode.** A release matched before its +season's episodes have been revealed by a metadata refresh has nothing to divide +by, and the rule does not guess a number: the divisor is 1 and the release is +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. + 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 diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index ccd645d..1fd1f75 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -100,6 +100,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(series::grab_episode)) .routes(routes!(series::search_season)) .routes(routes!(series::season_releases)) + .routes(routes!(series::season_pack_state)) .routes(routes!(series::grab_season_release)) .routes(routes!(series::files)) .routes(routes!(metadata::series_metadata)) @@ -334,6 +335,10 @@ mod tests { "/api/series/{series_id}/seasons/{season_number}/releases", "get", ), + ( + "/api/series/{series_id}/seasons/{season_number}/pack-state", + "get", + ), ( "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", "post", diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 9cbf8b8..351b1e5 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -1,5 +1,7 @@ +use std::collections::BTreeMap; + use arr_core::policy::Candidate; -use arr_core::score::score; +use arr_core::score::{claimed_episode_count, score}; use arr_core::{ParsedRelease, Policy}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; @@ -602,7 +604,7 @@ pub async fn releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::NotFound)? .policy; - rescore(&mut releases, &policy)?; + rescore(&mut releases, &policy, None)?; Ok(Json(releases)) } @@ -611,18 +613,30 @@ pub async fn releases( /// daemon's winner pick; the deck the user sees must reflect policy tuning /// made since the release was last searched, not whatever was true then. /// +/// `season_lengths` maps season number to episode count for the deck's series +/// — a size band describes one episode, so a pack's size is divided by how +/// many it covers. `None` is the movie decks: one release is one film, +/// whatever episode-shaped noise its name parses to. +/// /// 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. #[allow(clippy::cast_precision_loss)] -pub(crate) fn rescore(releases: &mut [Release], policy: &Policy) -> Result<(), ApiError> { +pub(crate) fn rescore( + releases: &mut [Release], + policy: &Policy, + season_lengths: Option<&BTreeMap>, +) -> Result<(), ApiError> { let mut totals = Vec::with_capacity(releases.len()); for release in releases.iter() { let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone()) .map_err(|error| ApiError::Database(error.to_string()))?; let seeders = u32::try_from(release.seeders.unwrap_or(0)).unwrap_or(u32::MAX); let size = u64::try_from(release.size).unwrap_or(0); - totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders).total); + 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); } let mut indices: Vec = (0..releases.len()).collect(); indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i])); @@ -1430,6 +1444,7 @@ mod tests { arr_core::policy::Candidate::PreGrab(&parsed), size_bytes, seeders, + 1, ) .total; #[allow(clippy::cast_precision_loss)] diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 73dc4ce..0c178b4 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -1,7 +1,8 @@ +use std::collections::BTreeMap; use std::time::UNIX_EPOCH; use arr_core::policy::{evaluate, Candidate}; -use arr_core::score::score; +use arr_core::score::{claimed_episode_count, score}; use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict}; use arr_db::policy::language; use arr_db::{blacklist, Blacklist}; @@ -488,6 +489,7 @@ async fn movie_releases( &overrides, &original_language, &blacklist, + None, )?); } } @@ -511,7 +513,7 @@ async fn episode_releases( ) -> Result, ApiError> { let database = state.database().ok_or(ApiError::Unavailable)?; let episode = sqlx::query!( - r#"SELECT s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language, + r#"SELECT s.id AS "series_id!: i64", s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language, se.number AS "season_number!: i64", e.number AS "episode_number!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id @@ -522,6 +524,7 @@ async fn episode_releases( .fetch_optional(database.pool()) .await? .ok_or(ApiError::EpisodeNotFound)?; + let season_lengths = crate::series::season_lengths(state, episode.series_id).await?; let loaded = database .episode_policy(episode_id) .await @@ -564,6 +567,7 @@ async fn episode_releases( &loaded.overrides, &original_language, &blacklist, + Some(&season_lengths), )?); } } @@ -704,14 +708,22 @@ fn classify( overrides: &TitleOverrides, original_language: &Language, blacklist: &Blacklist, + season_lengths: Option<&BTreeMap>, ) -> Result { let parsed = arr_parse::parse(&release.name); + // A size band describes one episode (`DESIGN.md` §5.5): a pack's size is + // divided by how many episodes it covers. `None` is the movie search — + // one release is one film. + let episodes = season_lengths.map_or(1, |lengths| { + claimed_episode_count(parsed.episode.as_ref(), lengths) + }); let evaluation = evaluate( policy, overrides, original_language, Candidate::PreGrab(&parsed), release.size, + episodes, ); let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) { ("rejected", Some(blacklist::RULE.to_owned())) @@ -723,6 +735,7 @@ fn classify( Candidate::PreGrab(&parsed), release.size.unwrap_or_default(), release.seeders.unwrap_or_default(), + episodes, ); // 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 @@ -1451,6 +1464,7 @@ mod tests { &TitleOverrides::default(), &Language::Other("en".into()), &Blacklist::default(), + None, ) .expect("classified release") }; @@ -1544,7 +1558,7 @@ mod tests { imdb_id: None, }; let parsed = arr_parse::parse(&release.name); - let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8); + let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1); let classified = classify( release, @@ -1552,6 +1566,7 @@ mod tests { &TitleOverrides::default(), &Language::Other("en".into()), &Blacklist::default(), + None, ) .expect("classified release"); diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index f7dd443..cf78ba1 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -13,13 +13,13 @@ //! - **Status is derived, never stored** (§4.2). Every series the API returns //! carries a status computed from its episodes at request time. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason}; use arr_core::{ - derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId, - SeriesStatus, TitleOverrides, + derive_series_status, search_backoff, season_grab_reason, EpisodeId, Language, MediaState, + PerEpisodeReason, RootId, SeasonGrabFacts, SeasonId, SeriesId, SeriesStatus, TitleOverrides, }; use arr_db::policy::language; use axum::extract::{Path, Query, State}; @@ -1360,7 +1360,7 @@ pub async fn episode_releases( State(state): State, Path(id): Path, ) -> Result>, ApiError> { - load_episode(&state, id).await?; + let episode = load_episode(&state, id).await?; let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) .fetch_all(pool(&state)?) .await?; @@ -1372,7 +1372,8 @@ pub async fn episode_releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::EpisodeNotFound)? .policy; - rescore(&mut releases, &policy)?; + let lengths = season_lengths(&state, episode.series_id).await?; + rescore(&mut releases, &policy, Some(&lengths))?; Ok(Json(releases)) } @@ -1407,6 +1408,34 @@ pub async fn grab_episode( /// Resolves a season by its number within one series, so the deck is /// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`). +/// Per-season episode counts for one series: the divisor data behind +/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no +/// revealed episodes counts zero, which `claimed_episode_count` treats as +/// unknown. +pub(crate) async fn season_lengths( + state: &AppState, + series_id: i64, +) -> Result, ApiError> { + let rows = sqlx::query!( + r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64" + FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id + WHERE se.series_id = ? + GROUP BY se.number"#, + series_id + ) + .fetch_all(pool(state)?) + .await?; + Ok(rows + .into_iter() + .map(|row| { + ( + u32::try_from(row.number).unwrap_or_default(), + u32::try_from(row.episodes).unwrap_or_default(), + ) + }) + .collect()) +} + async fn load_season_id(state: &AppState, series_id: i64, number: i64) -> Result { sqlx::query_scalar!( r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#, @@ -1484,10 +1513,146 @@ pub async fn season_releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::SeasonNotFound)? .policy; - rescore(&mut releases, &policy)?; + let lengths = season_lengths(&state, series_id).await?; + rescore(&mut releases, &policy, Some(&lengths))?; Ok(Json(releases)) } +/// Which lane a season's missing episodes take (#182, §6.2). +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum SeasonLane { + SeasonPack, + PerEpisode, +} + +/// What holds a season off the pack lane, in the order that answers the +/// operator's question best — the most fundamental reason first (#182). +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PackLaneReason { + NoEpisodes, + StillAiring, + EpisodesOnDisk, + PackBackoff, +} + +/// Why the season release deck holds what it holds (#182). +/// +/// An empty deck is three different truths, and until this endpoint existed +/// the deck could not tell them apart, so it blamed backoff for all three: +/// a pack sweep that has not landed yet, a pack sweep that landed and found +/// nothing, and a season on the per-episode lane, for which no pack sweep is +/// coming at all. The lane says which; `last_pack_search_at` separates the +/// first two, exactly as a movie's `last_searched_at` does (#177). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SeasonPackState { + pub lane: SeasonLane, + /// `None` when the season is on the pack lane. + pub reason: Option, + /// Failed pack grabs so far. §6.2's backoff step is indexed by this, so + /// it is also how long the lane stays quiet (#181). + pub pack_failures: i64, + /// When the pack lane reopens on its own, RFC3339. `None` unless a + /// failure's window is currently open. + pub pack_retry_at: Option, + /// When a pack sweep for this season last completed. `None` means no + /// pack search has ever run, so an empty deck is pending, not settled. + pub last_pack_search_at: Option, +} + +#[utoipa::path( + get, path = "/api/series/{series_id}/seasons/{season_number}/pack-state", tag = "series", + params( + ("series_id" = i64, Path, description = "Series row id"), + ("season_number" = i64, Path, description = "Season number, not its row id") + ), + responses( + (status = 200, body = SeasonPackState), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn season_pack_state( + State(state): State, + Path((series_id, number)): Path<(i64, i64)>, +) -> Result, ApiError> { + load_series_row(&state, series_id).await?; + let season_id = load_season_id(&state, series_id, number).await?; + let pool = pool(&state)?; + + let episodes = sqlx::query!( + r#"SELECT e.air_date, + EXISTS ( + SELECT 1 FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = e.id + ) AS "has_file!: bool" + FROM episodes e + WHERE e.season_id = ?"#, + season_id + ) + .fetch_all(pool) + .await?; + let last_pack_search_at = sqlx::query_scalar!( + "SELECT last_pack_search_at FROM seasons WHERE id = ?", + season_id + ) + .fetch_one(pool) + .await?; + // The same failed-pack tally the grab lane backs off on (#181), read + // here so the deck can name the window instead of guessing at one. + let failed = sqlx::query!( + r#"SELECT count(*) AS "failures!: i64", + max(grabbed_at) AS "last_failed_at?: String" + FROM grabs + WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, + season_id + ) + .fetch_one(pool) + .await?; + + let window = chrono::TimeDelta::from_std(search_backoff(failed.failures)).unwrap_or_default(); + let reopens_at = if failed.failures > 0 { + failed + .last_failed_at + .as_deref() + .and_then(|at| DateTime::parse_from_rfc3339(at).ok()) + .map(|at| at.with_timezone(&Utc) + window) + } else { + None + }; + let pack_retry_at = reopens_at.filter(|at| *at > Utc::now()); + + let air_dates: Vec> = episodes + .iter() + .map(|episode| air_date(episode.air_date.as_deref())) + .collect(); + let reason = season_grab_reason(&SeasonGrabFacts { + air_dates: &air_dates, + now: SystemTime::now(), + any_episode_on_disk: episodes.iter().any(|episode| episode.has_file), + pack_backoff_active: pack_retry_at.is_some(), + }); + + Ok(Json(SeasonPackState { + lane: if reason.is_some() { + SeasonLane::PerEpisode + } else { + SeasonLane::SeasonPack + }, + reason: reason.map(|reason| match reason { + PerEpisodeReason::NoEpisodes => PackLaneReason::NoEpisodes, + PerEpisodeReason::StillAiring => PackLaneReason::StillAiring, + PerEpisodeReason::EpisodesOnDisk => PackLaneReason::EpisodesOnDisk, + PerEpisodeReason::PackBackoff => PackLaneReason::PackBackoff, + }), + pack_failures: failed.failures, + pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()), + last_pack_search_at, + })) +} + #[utoipa::path( post, path = "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", tag = "series", params(("series_id" = i64, Path), ("season_number" = i64, Path), ("release_id" = i64, Path)), @@ -2366,6 +2531,108 @@ mod tests { assert_eq!(after["status"], "complete"); } + /// #182: an empty season deck has to say which empty it is. The lane + /// answers "no pack is coming"; `last_pack_search_at` answers "one ran + /// and found nothing" — and until both existed the deck blamed backoff + /// for every case, including seasons that were never on the pack lane. + #[tokio::test] + async fn the_pack_state_names_why_a_season_deck_is_empty() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([ + {"number": 1, "title": "Pilot", "air_date": "2001-01-01"}, + {"number": 2, "title": "Second", "air_date": "2001-01-08"} + ]), + ) + .await; + let season_id = season["id"].as_i64().expect("season id"); + let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); + let pool = state.database().expect("database").pool(); + + let state_of = |url: String| async move { + let response = reqwest::get(url).await.expect("pack state"); + assert_eq!(response.status(), StatusCode::OK); + response + .json::() + .await + .expect("pack state json") + }; + let url = format!("{base}/api/series/{series_id}/seasons/1/pack-state"); + + let fresh = state_of(url.clone()).await; + assert_eq!(fresh["lane"], "season_pack"); + assert_eq!(fresh["reason"], serde_json::Value::Null); + assert_eq!(fresh["last_pack_search_at"], serde_json::Value::Null); + assert_eq!(fresh["pack_failures"], 0); + + // A pack sweep that ran turns an empty deck from pending to settled. + sqlx::query( + "UPDATE seasons SET last_pack_search_at = '2026-01-01T00:00:00.000Z' WHERE id = ?", + ) + .bind(season_id) + .execute(pool) + .await + .expect("stamp the sweep"); + let swept = state_of(url.clone()).await; + assert_eq!(swept["lane"], "season_pack"); + assert_eq!(swept["last_pack_search_at"], "2026-01-01T00:00:00.000Z"); + + // §6.2/#181: a failed pack quiets the lane, and the deck says until + // when rather than only that it is quiet. + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (7, 'pack', 'Bluey S01 1080p WEB-DL', 1000, 'url', '{}', 'eligible') + RETURNING id", + ) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + VALUES (?, 'season', ?, 'hash', 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + ) + .bind(release_id) + .bind(season_id) + .execute(pool) + .await + .expect("failed pack grab"); + let quiet = state_of(url.clone()).await; + assert_eq!(quiet["lane"], "per_episode"); + assert_eq!(quiet["reason"], "pack_backoff"); + assert_eq!(quiet["pack_failures"], 1); + assert!( + quiet["pack_retry_at"].is_string(), + "the deck offers a date, not just a closed door" + ); + + // §14 outranks it: a pack would re-import what is on disk, so + // clearing the failure would not earn a pack anyway. + sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)") + .bind(episode_id) + .execute(pool) + .await + .expect("file on disk"); + let on_disk = state_of(url.clone()).await; + assert_eq!(on_disk["reason"], "episodes_on_disk"); + + // An unaired episode outranks both. + sqlx::query( + "UPDATE episodes SET air_date = '2999-01-01' WHERE season_id = ? AND number = 2", + ) + .bind(season_id) + .execute(pool) + .await + .expect("unair the finale"); + let airing = state_of(url).await; + assert_eq!(airing["reason"], "still_airing"); + } + #[tokio::test] async fn manual_episode_actions_are_scoped_and_respect_blocked() { let (_dir, state, base) = application().await; diff --git a/crates/arr-core/src/grabbing.rs b/crates/arr-core/src/grabbing.rs index 7f8ec4e..e6d5491 100644 --- a/crates/arr-core/src/grabbing.rs +++ b/crates/arr-core/src/grabbing.rs @@ -4,12 +4,13 @@ //! while a season is airing, grab per episode. A completed season with no //! episodes on disk prefers the pack — one torrent, better seeded, consistent //! encode. A pack that hard-failed must not cost the whole season, so the -//! season falls back to per-episode instead of being blacklisted outright. +//! season falls back to per-episode while the failure's §6.2 backoff window +//! is open, and retries the pack once it elapses — quiet, never off. //! //! Re-grabbing a pack once an airing season completes is deliberately not //! done (§14): a season with any episode already on disk grabs per episode. -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; /// How a season's missing wanted episodes should be grabbed next. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -31,28 +32,80 @@ pub struct SeasonGrabFacts<'a> { /// re-grabs a pack over episodes on disk, and a pack must not re-import /// what exists). pub any_episode_on_disk: bool, - /// Whether a season-pack grab for this season already hard-failed. - pub pack_hard_failed: bool, + /// Whether a failed season-pack grab still holds the season off the + /// pack lane — true only while the §6.2 backoff window is open. + pub pack_backoff_active: bool, } -/// Picks the grab mode for one season. +/// Why a season is not on the season-pack lane. +/// +/// The season release deck (§9.3) is empty for a season on the per-episode +/// lane and stays empty however long it waits, so it has to name which of +/// these it is rather than blaming a sweep that is not coming (#182). +/// Ordered by how fundamental the answer is: an unaired episode outranks a +/// failed pack, because clearing the failure would still not earn a pack. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PerEpisodeReason { + /// No episodes are known for the season, so there is nothing to pack. + NoEpisodes, + /// An episode has not aired, or carries no air date at all. + StillAiring, + /// §14: a pack would re-import episodes that are already on disk. + EpisodesOnDisk, + /// §6.2: a failed pack grab's backoff window is still open. + PackBackoff, +} + +/// Why one season takes the per-episode lane, or `None` when it takes the +/// pack lane. /// /// A season is fully released only when every known episode has an air date /// in the past. An episode with no date could still be unaired, and grabbing /// a "complete" pack of a season that is not complete costs a whole torrent /// of the wrong thing — so an undated episode keeps the season per-episode. #[must_use] -pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode { - let fully_released = !facts.air_dates.is_empty() - && facts - .air_dates - .iter() - .all(|date| date.is_some_and(|date| date <= facts.now)); +pub fn season_grab_reason(facts: &SeasonGrabFacts<'_>) -> Option { + if facts.air_dates.is_empty() { + return Some(PerEpisodeReason::NoEpisodes); + } + if !facts + .air_dates + .iter() + .all(|date| date.is_some_and(|date| date <= facts.now)) + { + return Some(PerEpisodeReason::StillAiring); + } + if facts.any_episode_on_disk { + return Some(PerEpisodeReason::EpisodesOnDisk); + } + if facts.pack_backoff_active { + return Some(PerEpisodeReason::PackBackoff); + } + None +} - if fully_released && !facts.any_episode_on_disk && !facts.pack_hard_failed { - SeasonGrabMode::SeasonPack - } else { - SeasonGrabMode::PerEpisode +/// Picks the grab mode for one season. +#[must_use] +pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode { + match season_grab_reason(facts) { + None => SeasonGrabMode::SeasonPack, + Some(_) => SeasonGrabMode::PerEpisode, + } +} + +/// §6.2's targeted-search curve: `1h → 6h → 1d → 3d`, capped at 7d, indexed +/// by how many attempts have already been spent. +/// +/// The pack lane counts a season's failed pack grabs as its attempts, so the +/// deck can say when the lane reopens rather than only that it is shut. +#[must_use] +pub fn search_backoff(attempts: i64) -> Duration { + match attempts { + ..=1 => Duration::from_hours(1), + 2 => Duration::from_hours(6), + 3 => Duration::from_hours(24), + 4 => Duration::from_hours(72), + _ => Duration::from_hours(24 * 7), } } @@ -69,7 +122,7 @@ mod tests { air_dates, now: SystemTime::UNIX_EPOCH + 100 * DAY, any_episode_on_disk: false, - pack_hard_failed: false, + pack_backoff_active: false, } } @@ -105,10 +158,10 @@ mod tests { } #[test] - fn a_hard_failed_pack_falls_back_to_per_episode() { + fn a_failed_pack_inside_its_backoff_window_falls_back_to_per_episode() { let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)]; let mut facts = facts(&aired); - facts.pack_hard_failed = true; + facts.pack_backoff_active = true; assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode); } @@ -120,4 +173,74 @@ mod tests { facts.any_episode_on_disk = true; assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode); } + + /// #182: the deck says which of the four it is, not just "not a pack". + #[test] + fn the_reason_names_the_condition_that_holds_the_pack_lane_shut() { + assert_eq!( + season_grab_reason(&facts(&[])), + Some(PerEpisodeReason::NoEpisodes) + ); + + let airing = [ + Some(SystemTime::UNIX_EPOCH + 10 * DAY), + Some(SystemTime::UNIX_EPOCH + 110 * DAY), + ]; + assert_eq!( + season_grab_reason(&facts(&airing)), + Some(PerEpisodeReason::StillAiring) + ); + let undated = [Some(SystemTime::UNIX_EPOCH + 10 * DAY), None]; + assert_eq!( + season_grab_reason(&facts(&undated)), + Some(PerEpisodeReason::StillAiring) + ); + + let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)]; + let mut on_disk = facts(&aired); + on_disk.any_episode_on_disk = true; + assert_eq!( + season_grab_reason(&on_disk), + Some(PerEpisodeReason::EpisodesOnDisk) + ); + + let mut backoff = facts(&aired); + backoff.pack_backoff_active = true; + assert_eq!( + season_grab_reason(&backoff), + Some(PerEpisodeReason::PackBackoff) + ); + + assert_eq!(season_grab_reason(&facts(&aired)), None); + } + + /// A failed pack is not the headline when the season could not have had + /// a pack anyway — clearing it would change nothing. + #[test] + fn an_unaired_episode_outranks_a_failed_pack() { + let airing = [ + Some(SystemTime::UNIX_EPOCH + 10 * DAY), + Some(SystemTime::UNIX_EPOCH + 110 * DAY), + ]; + let mut facts = facts(&airing); + facts.pack_backoff_active = true; + facts.any_episode_on_disk = true; + assert_eq!( + season_grab_reason(&facts), + Some(PerEpisodeReason::StillAiring) + ); + } + + /// §6.2's curve, shared by the pack lane so the deck can say when it + /// reopens rather than only that it is shut. + #[test] + fn the_backoff_curve_climbs_and_caps_at_a_week() { + assert_eq!(search_backoff(0), Duration::from_hours(1)); + assert_eq!(search_backoff(1), Duration::from_hours(1)); + assert_eq!(search_backoff(2), Duration::from_hours(6)); + assert_eq!(search_backoff(3), DAY); + assert_eq!(search_backoff(4), 3 * DAY); + assert_eq!(search_backoff(5), 7 * DAY); + assert_eq!(search_backoff(50), 7 * DAY); + } } diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 4c5abde..4421f0f 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -14,7 +14,10 @@ pub mod status; pub mod tracking; pub use arr_parse::NameClaims as ParsedRelease; -pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode}; +pub use grabbing::{ + search_backoff, season_grab_mode, season_grab_reason, PerEpisodeReason, SeasonGrabFacts, + SeasonGrabMode, +}; pub use matching::{ match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds, WantedEpisode, WantedMovie, diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index cd3e2e0..f9ab8bb 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -64,6 +64,10 @@ pub struct EvaluationContext<'a> { /// when the caller has no size to offer, which abstains rather than /// rejects. pub size_bytes: Option, + /// How many episodes `size_bytes` covers (`DESIGN.md` §5.5) — a size band + /// 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, } /// A rule's identity when no violation exists to carry concrete evidence. @@ -107,6 +111,7 @@ pub fn evaluate( original_language: &Language, candidate: Candidate<'_>, size_bytes: Option, + episode_count: u32, ) -> Evaluation { let context = EvaluationContext { policy, @@ -114,6 +119,7 @@ pub fn evaluate( original_language, candidate, size_bytes, + episode_count, }; let rules: [&dyn PolicyRule; 6] = [ &ResolutionRule, @@ -227,7 +233,8 @@ impl PolicyRule for SizeRule { else { return RuleEvaluation::Unknown(RuleKind::Size); }; - match crate::score::is_below_floor(context.policy, resolution, size) { + match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count) + { None => RuleEvaluation::Unknown(RuleKind::Size), Some(true) => RuleEvaluation::HardFail(Rule::Size), Some(false) => RuleEvaluation::Pass(RuleKind::Size), @@ -515,7 +522,7 @@ mod tests { } fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict { - evaluate(policy, overrides, &en(), candidate, None).verdict + evaluate(policy, overrides, &en(), candidate, None, 1).verdict } fn verdict_for( @@ -529,6 +536,7 @@ mod tests { original_language, candidate, None, + 1, ) .verdict } @@ -543,6 +551,7 @@ mod tests { &en(), Candidate::PreGrab(&claims), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -725,6 +734,7 @@ mod tests { &en(), Candidate::PostDownload(&media), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -781,6 +791,7 @@ mod tests { &en(), Candidate::PostDownload(&media), None, + 1, ); let expected = if rejected { Verdict::Rejected(Rule::DolbyVisionProfile(profile)) @@ -800,6 +811,7 @@ mod tests { &en(), Candidate::PreGrab(&claims), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -832,6 +844,7 @@ mod tests { original_language: &original, candidate: Candidate::PreGrab(&claims), size_bytes: None, + episode_count: 1, }; let soft = FixedRule { evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())), @@ -860,6 +873,7 @@ mod tests { original_language: &original, candidate: Candidate::PreGrab(&claims), size_bytes: None, + episode_count: 1, }; let first = FixedRule { evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())), @@ -995,7 +1009,8 @@ mod tests { &overrides, &en(), Candidate::PreGrab(&claims), - None + None, + 1 ) .verdict, Verdict::Eligible @@ -1008,7 +1023,8 @@ mod tests { &overrides, &en(), Candidate::PostDownload(&media), - None + None, + 1 ) .verdict, Verdict::Waived(Rule::RequiredAudio) @@ -1082,6 +1098,7 @@ mod tests { &en(), Candidate::PostDownload(&media), None, + 1, ); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); @@ -1093,6 +1110,7 @@ mod tests { &Language::PortugueseBrazil, Candidate::PostDownload(&media), None, + 1, ); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); } @@ -1106,6 +1124,7 @@ mod tests { &en(), Candidate::PreGrab(&claims), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); assert_eq!( diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index bc3bd92..3cfa5b4 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -19,6 +19,10 @@ //! remux playback test in `DESIGN.md` §14; the model is the decision, not the //! constants. +use std::collections::BTreeMap; + +use arr_parse::EpisodeClaim; + use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source}; const BYTES_PER_GIB: i64 = 1 << 30; @@ -84,13 +88,26 @@ pub struct Score { /// resolution the policy carries no band for — scores zero on the dominant /// term rather than being penalised for what the release name failed to say. /// The resolution-rank term treats an unclaimed resolution the same way. +/// +/// A band describes one episode (`DESIGN.md` §5.5), so `episode_count` is the +/// divisor that turns a season pack's total into the per-episode figure the +/// 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. #[must_use] -pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score { +pub fn score( + policy: &Policy, + candidate: Candidate<'_>, + size_bytes: u64, + seeders: u32, + episode_count: 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, size_bytes)); + .map_or(0, |band| size_points(band, weights, per_episode)); let source = candidate .source() .map_or(0, |source| source_points(policy, source)); @@ -113,25 +130,69 @@ pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders /// /// Pre-grab, the name is all there is (`DESIGN.md` §5.6). #[must_use] -pub fn score_release(policy: &Policy, release: &Release) -> Score { +pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score { score( policy, Candidate::PreGrab(&release.parsed), release.size, release.seeders, + episode_count, ) } /// Whether a size falls below its band's floor, which is a hard filter. /// +/// The floor takes the same per-episode figure as the target penalty +/// (`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. +/// /// `None` when the policy carries no band for that resolution: no band is no /// opinion, not a rejection. #[must_use] -pub fn is_below_floor(policy: &Policy, resolution: Resolution, size_bytes: u64) -> Option { +pub fn is_below_floor( + policy: &Policy, + resolution: Resolution, + size_bytes: u64, + episode_count: u32, +) -> Option { policy .size_bands .get(&resolution) - .map(|band| size_bytes < band.floor_bytes) + .map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes) +} + +/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor +/// behind [`score`] and [`is_below_floor`]. +/// +/// `season_lengths` maps season number to that season's episode count as the +/// caller knows it — the caller supplies the fact the way `apply_auto_track` +/// takes `ever_refreshed`, because `arr-core` cannot look it up. An `Episodes` +/// claim knows its own length; a `Season` or `Seasons` claim is summed from +/// the map. Anything unknown — no claim, a daily episode, a season the map +/// does not carry — falls back to one episode, which measures the release by +/// its full size and fails toward rejecting a good pack rather than grabbing +/// a bad one. +#[must_use] +pub fn claimed_episode_count( + claim: Option<&EpisodeClaim>, + season_lengths: &BTreeMap, +) -> u32 { + let count = match claim { + Some(EpisodeClaim::Episodes { episodes, .. }) => { + u32::try_from(episodes.len()).unwrap_or(u32::MAX) + } + Some(EpisodeClaim::Season { season }) => season_lengths.get(season).copied().unwrap_or(0), + Some(EpisodeClaim::Seasons { first, last }) => (*first..=*last) + .map(|season| season_lengths.get(&season).copied().unwrap_or(0)) + .fold(0u32, u32::saturating_add), + Some(EpisodeClaim::Daily { .. }) | None => 1, + }; + count.max(1) +} + +/// The per-episode figure every size band is compared against. +fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 { + size_bytes / u64::from(episode_count.max(1)) } /// The size term: a ramp from the floor up to the target, then a penalty that @@ -262,7 +323,13 @@ mod tests { fn scored(source: ClaimedSource, size_bytes: u64, seeders: u32) -> Score { let claims = claims(source); - score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders) + score( + &policy(), + Candidate::PreGrab(&claims), + size_bytes, + seeders, + 1, + ) } /// The same, at a resolution the caller picks. @@ -277,10 +344,20 @@ mod tests { source: Some(source), ..NameClaims::default() }; - score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders) + score( + &policy(), + Candidate::PreGrab(&claims), + size_bytes, + seeders, + 1, + ) } fn size_rule(size_bytes: u64) -> RuleEvaluation { + size_rule_for(size_bytes, 1) + } + + fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation { let policy = policy(); let overrides = TitleOverrides::default(); let language = Language::Other("en".to_owned()); @@ -291,6 +368,7 @@ mod tests { original_language: &language, candidate: Candidate::PreGrab(&claims), size_bytes: Some(size_bytes), + episode_count, }) } @@ -321,6 +399,7 @@ mod tests { &Language::Other("en".to_owned()), Candidate::PreGrab(&claims), Some(gib(60)), + 1, ); // A bad score, but a score: nothing filters it out, so a selection @@ -387,10 +466,13 @@ 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)), + is_below_floor(&policy(), Resolution::R2160p, gib(3), 1), Some(true) ); - assert_eq!(is_below_floor(&policy(), Resolution::R720p, gib(3)), None); + assert_eq!( + is_below_floor(&policy(), Resolution::R720p, gib(3), 1), + None + ); } #[test] @@ -399,7 +481,7 @@ mod tests { source: Some(ClaimedSource::WebDl), ..NameClaims::default() }; - let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10); + let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1); assert_eq!(score.size, 0); assert_eq!(score.total, score.source + score.seeders); @@ -417,8 +499,8 @@ mod tests { }, ); let claims = claims(ClaimedSource::WebDl); - let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10); - let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10); + let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1); + let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1); assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target)); assert_eq!(under.size, 0); @@ -466,7 +548,7 @@ mod tests { resolution: Some(resolution), ..NameClaims::default() }; - score(&policy, Candidate::PreGrab(&claims), gib(8), 0).resolution + score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution }; assert_eq!(at(ClaimedResolution::P2160), 2 * step); @@ -483,7 +565,7 @@ mod tests { source: Some(ClaimedSource::WebDl), ..NameClaims::default() }; - let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10); + let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1); assert_eq!(unranked.resolution, 0); assert_eq!(unclaimed.resolution, 0); @@ -500,4 +582,171 @@ mod tests { assert!(hd.seeders > uhd.seeders); assert!(uhd.total > hd.total); } + + /// Issue #180: a movie passes one episode's worth and must score exactly + /// as it did before packs learned to divide. Every term pinned to a + /// literal so the normalisation cannot quietly move movie scoring. + #[test] + fn a_movie_scores_exactly_as_before_pack_normalisation() { + let at_target = scored(ClaimedSource::WebDl, gib(22), 40); + assert_eq!( + at_target, + Score { + total: 1398, + size: 1000, + source: 50, + seeders: 48, + resolution: 300, + } + ); + + let over_target = scored(ClaimedSource::WebDl, gib(30), 40); + assert_eq!( + over_target, + Score { + total: 918, + size: 520, + source: 50, + seeders: 48, + resolution: 300, + } + ); + } + + #[test] + 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); + + assert_eq!(pack.size, episode.size); + assert_eq!(pack.total, episode.total); + } + + #[test] + fn a_pack_below_the_floor_per_episode_is_rejected() { + // 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), + Some(true) + ); + assert_eq!( + size_rule_for(gib(30), 10), + RuleEvaluation::HardFail(Rule::Size) + ); + + // The same total over three episodes is 10 GiB each and passes. + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(30), 3), + Some(false) + ); + assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_))); + } + + #[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); + + assert_eq!(zero, single); + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(3), 0), + is_below_floor(&policy(), Resolution::R2160p, gib(3), 1) + ); + } + + #[test] + fn claimed_episode_count_comes_from_the_claim_and_the_season_lengths() { + let lengths = BTreeMap::from([(1, 10), (2, 8), (3, 12)]); + + let episodes = EpisodeClaim::Episodes { + season: 1, + episodes: vec![2, 3, 4], + }; + assert_eq!(claimed_episode_count(Some(&episodes), &lengths), 3); + assert_eq!( + claimed_episode_count(Some(&EpisodeClaim::Season { season: 2 }), &lengths), + 8 + ); + assert_eq!( + claimed_episode_count(Some(&EpisodeClaim::Seasons { first: 1, last: 3 }), &lengths), + 30 + ); + + // Unknowns are one episode: no claim, a daily episode, a season the + // map has not revealed yet. + assert_eq!(claimed_episode_count(None, &lengths), 1); + assert_eq!( + claimed_episode_count( + Some(&EpisodeClaim::Daily { + year: 2024, + month: 5, + day: 13 + }), + &lengths + ), + 1 + ); + assert_eq!( + claimed_episode_count(Some(&EpisodeClaim::Season { season: 9 }), &lengths), + 1 + ); + } + + /// The observed case from issue #180: Rick and Morty S08 against a + /// TV policy (1080p target 2 GiB, 2160p target 6 GiB, 60 points per GiB + /// over, resolution step 300). Measured by pack total, the 1080p pack's + /// smaller size penalty beat the 300-point resolution step; per episode, + /// 2160p ranks first as `resolution_pref` says it should. + #[test] + fn the_rick_and_morty_season_pack_ranks_2160p_above_1080p() { + let policy = Policy { + size_bands: BTreeMap::from([ + ( + Resolution::R1080p, + SizeBand { + floor_bytes: GIB / 4, + target_bytes: gib(2), + penalty_points_per_gib_over: 60, + }, + ), + ( + Resolution::R2160p, + SizeBand { + floor_bytes: gib(1), + target_bytes: gib(6), + penalty_points_per_gib_over: 60, + }, + ), + ]), + ..self::policy() + }; + let episodes = 10; + let hd_pack = (43 * GIB) / 10; // Rick.and.Morty.S08.1080p.x265-ELiTE, 4.3 GiB + let uhd_pack = (138 * GIB) / 10; // S08 Complete 2160p HDR, 13.8 GiB + + let scored = |resolution, size| { + let claims = NameClaims { + resolution: Some(resolution), + source: Some(ClaimedSource::WebDl), + ..NameClaims::default() + }; + score(&policy, Candidate::PreGrab(&claims), size, 20, episodes) + }; + 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), + Some(false) + ); + assert_eq!( + is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes), + Some(false) + ); + assert!(uhd.total > hd.total); + } } diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index 66b0eb9..d462a77 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -13,13 +13,14 @@ //! - the `grabs` row is written from that response, so a crash between the add //! and the insert heals on the next tick instead of leaving an orphan. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use arr_core::policy::{evaluate, Candidate}; -use arr_core::{score::score, Language, Policy, TitleOverrides, Verdict}; +use arr_core::score::{claimed_episode_count, score}; +use arr_core::{Language, Policy, TitleOverrides, Verdict}; use arr_db::{blacklist, Blacklist, Db, MoviePolicy}; use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest}; @@ -1198,6 +1199,7 @@ pub(crate) async fn store_release( overrides, original_language, blacklist, + 1, ) .await?; sqlx::query!( @@ -1224,6 +1226,21 @@ pub(crate) async fn store_episode_release( original_language: &Language, blacklist: &Blacklist, ) -> Result<(i64, Option), GrabError> { + // A size band describes one episode (`DESIGN.md` §5.5): the release is + // measured per episode, and a season pack's length comes from the series + // the covered episodes belong to. An unrevealed season divides by one. + let claim = arr_parse::parse(&release.name).episode; + let season_lengths = match episode_ids.first() { + Some(&episode_id) + if claim + .as_ref() + .is_some_and(arr_parse::EpisodeClaim::is_season_pack) => + { + season_lengths_of(database, episode_id).await? + } + _ => BTreeMap::new(), + }; + let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths); let (release_id, eligible) = classify_and_store( database, release, @@ -1231,6 +1248,7 @@ pub(crate) async fn store_episode_release( overrides, original_language, blacklist, + episode_count, ) .await?; for episode_id in episode_ids { @@ -1246,6 +1264,35 @@ pub(crate) async fn store_episode_release( Ok((release_id, eligible)) } +/// Per-season episode counts for the series one covered episode belongs to +/// (`DESIGN.md` §5.5): the divisor data for season-pack normalisation. +async fn season_lengths_of( + database: &Db, + episode_id: i64, +) -> Result, GrabError> { + let rows = sqlx::query!( + r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64" + FROM seasons se + LEFT JOIN episodes e ON e.season_id = se.id + WHERE se.series_id = (SELECT s2.series_id FROM episodes e2 + JOIN seasons s2 ON s2.id = e2.season_id + WHERE e2.id = ?) + GROUP BY se.number"#, + episode_id + ) + .fetch_all(database.pool()) + .await?; + Ok(rows + .into_iter() + .map(|row| { + ( + u32::try_from(row.number).unwrap_or_default(), + u32::try_from(row.episodes).unwrap_or_default(), + ) + }) + .collect()) +} + async fn classify_and_store( database: &Db, release: &SearchRelease, @@ -1253,6 +1300,7 @@ async fn classify_and_store( overrides: &TitleOverrides, original_language: &Language, blacklist: &Blacklist, + episode_count: u32, ) -> Result<(i64, Option), GrabError> { let parsed = arr_parse::parse(&release.name); let evaluation = evaluate( @@ -1261,12 +1309,14 @@ async fn classify_and_store( original_language, Candidate::PreGrab(&parsed), release.size, + episode_count, ); let scored = score( policy, Candidate::PreGrab(&parsed), release.size.unwrap_or_default(), release.seeders.unwrap_or_default(), + episode_count, ); // 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 diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index c55ef8d..c26dcd0 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -307,6 +307,7 @@ impl ImportAction { &original_language, Candidate::PostDownload(&feature.media), Some(feature.size), + 1, ); let waiver: Option = match evaluation.verdict { Verdict::Rejected(rule) => { @@ -494,6 +495,7 @@ impl ImportAction { &original_language, Candidate::PostDownload(&assignment.file.media), Some(assignment.file.size), + 1, ); let waiver = match evaluation.verdict { Verdict::Rejected(rule) => { diff --git a/crates/arr-daemon/src/rss.rs b/crates/arr-daemon/src/rss.rs index 03a1cb3..1116b96 100644 --- a/crates/arr-daemon/src/rss.rs +++ b/crates/arr-daemon/src/rss.rs @@ -32,8 +32,8 @@ use arr_dl::TransmissionClient; use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest}; use crate::grab::{ - store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber, - SeedingRules, + backoff_elapsed, store_episode_release, store_release, Eligible, GrabError, GrabScope, + GrabTarget, Grabber, SeedingRules, }; use crate::indexers::IndexerDirectory; use crate::reconcile::{Action, ActionFuture, Outcome}; @@ -625,11 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result ) .fetch_all(database.pool()) .await?; - let pack_hard_failed = sqlx::query_scalar!( - r#"SELECT EXISTS ( - SELECT 1 FROM grabs - WHERE target_kind = 'season' AND target_id = ? AND state = 'failed' - ) AS "failed!: bool""#, + let failed_packs = sqlx::query!( + r#"SELECT count(*) AS "failures!: i64", + max(grabbed_at) AS "last_failed_at?: String" + FROM grabs + WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, season_id ) .fetch_one(database.pool()) @@ -642,7 +642,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result .collect::>(), now: std::time::SystemTime::now(), any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk), - pack_hard_failed, + pack_backoff_active: failed_packs.failures > 0 + && !backoff_elapsed( + failed_packs.failures, + failed_packs.last_failed_at.as_deref(), + ), }) == SeasonGrabMode::SeasonPack) } diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index 92ae166..e3fc68b 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -79,6 +79,7 @@ impl TvGrabAction { &season, &blacklist, EPISODE_SEARCHES_PER_TICK - episode_searches, + false, ) .await { @@ -102,12 +103,15 @@ impl TvGrabAction { /// Search and grab for one season. `None` means the season was skipped /// without spending an indexer call — nothing due, or no language yet. + /// `ignore_pack_backoff` is the manual trigger's "try again now": it + /// waives the failed-pack backoff window for this one invocation. async fn grab_season( &self, database: &Db, season: &PendingSeason, blacklist: &Blacklist, episode_budget: usize, + ignore_pack_backoff: bool, ) -> Result, GrabError> { // §5.2: without the title's original language there is nothing to // evaluate a release against, and guessing is worse than waiting. @@ -141,7 +145,8 @@ impl TvGrabAction { air_dates: &air_dates, now, any_episode_on_disk: episodes.iter().any(|episode| episode.has_file), - pack_hard_failed: pack_hard_failed(database, season.season_id).await?, + pack_backoff_active: !ignore_pack_backoff + && pack_backoff_active(database, season.season_id).await?, }); let aired_due_gaps: Vec<&SeasonEpisode> = gaps @@ -369,6 +374,10 @@ impl TvGrabAction { original_language: &Language, blacklist: &Blacklist, ) -> Result, GrabError> { + // #182: a season-scoped sweep is the pack lane's own search, and the + // release deck needs to know it completed to tell "nothing found" + // from "nothing has run yet". + let pack_sweep = matches!(selector, TvSelector::Season { .. }); let target = TvTarget { tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()), title: season.series_title.clone(), @@ -446,6 +455,9 @@ impl TvGrabAction { eligible: stored, }); } + if pack_sweep && any_searchable { + record_pack_search(database, season.season_id).await?; + } Ok(candidates) } @@ -566,8 +578,10 @@ impl TvGrabAction { } /// The manual season trigger (issue #125's deck, drained per #132): - /// reset every open episode's backoff and run the same pack-or-fall-back - /// lane as the tick, scoped to this one season with no tick budgets. + /// reset every open episode's backoff, waive the failed-pack backoff + /// (§6.2's escape hatch is this deck), and run the same + /// pack-or-fall-back lane as the tick, scoped to this one season with + /// no tick budgets. /// /// A satisfied season — nothing open, or a grab already in flight — /// gets its deck refreshed and no grab; a blocked series refuses. @@ -625,7 +639,7 @@ impl TvGrabAction { ); return Ok(()); } - self.grab_season(database, &season, &blacklist, usize::MAX) + self.grab_season(database, &season, &blacklist, usize::MAX, true) .await?; Ok(()) } @@ -1106,19 +1120,37 @@ async fn load_season_release( })) } -/// Whether a season-pack grab for this season already hard-failed — the -/// fall-back-to-per-episode signal. -async fn pack_hard_failed(database: &Db, season_id: i64) -> Result { - let failed = sqlx::query_scalar!( - r#"SELECT EXISTS ( - SELECT 1 FROM grabs - WHERE target_kind = 'season' AND target_id = ? AND state = 'failed' - ) AS "failed!: bool""#, +/// When the season's own pack search last completed (#182). Written only by +/// a season-scoped sweep, so it answers the deck's question — did a pack +/// search run — and nothing else. +async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabError> { + sqlx::query!( + "UPDATE seasons + SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?", + season_id + ) + .execute(database.pool()) + .await?; + Ok(()) +} + +/// Whether failed season-pack grabs still hold this season off the pack +/// lane. §6.2: a failure quiets the pack search on the shared backoff curve +/// (each failed grab is one attempt), it never disables it. Anchored on the +/// latest failed grab's `grabbed_at` — failure time itself is not recorded. +async fn pack_backoff_active(database: &Db, season_id: i64) -> Result { + let row = sqlx::query!( + r#"SELECT count(*) AS "failures!: i64", + max(grabbed_at) AS "last_failed_at?: String" + FROM grabs + WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, season_id ) .fetch_one(database.pool()) .await?; - Ok(failed) + Ok(row.failures > 0 && !backoff_elapsed(row.failures, row.last_failed_at.as_deref())) } /// A pack for exactly this season. Multi-season packs are never selected @@ -1463,38 +1495,41 @@ mod tests { /// The third acceptance case, selection side: a hard-failed pack put the /// release on the blacklist and the season falls back to per-episode — /// the pack is not tried again and the episodes are not written off. - #[tokio::test] - async fn a_hard_failed_pack_falls_back_to_per_episode() { - let (_dir, database, season_id) = - wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; - // What the import tick left behind: a failed season grab and the - // pack release on the blacklist (§6.3). + /// Seed what the import tick leaves behind after a pack fails: one + /// `failed` season grab per (infohash, age) pair. + async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) { let release_id: i64 = sqlx::query_scalar( "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) - VALUES (7, 'pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920, - 'https://tracker/pack.torrent', '{}', 'eligible') + VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920, + 'https://tracker/oldpack.torrent', '{}', 'eligible') RETURNING id", ) .fetch_one(database.pool()) .await .unwrap(); - sqlx::query( - "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) - VALUES (?, 'season', ?, 'dead', 'failed')", - ) - .bind(release_id) - .bind(season_id) - .execute(database.pool()) - .await - .unwrap(); - arr_db::blacklist::add( - database.pool(), - Some("dead"), - "Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos", - "dolby_vision_profile", - ) - .await - .unwrap(); + for (index, age) in ages.iter().enumerate() { + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + VALUES (?, 'season', ?, ?, 'failed', + strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", + ) + .bind(release_id) + .bind(season_id) + .bind(format!("dead{index}")) + .bind(age) + .execute(database.pool()) + .await + .unwrap(); + } + } + + /// A failed pack inside its §6.2 backoff window stays per-episode: the + /// window quiets the pack lane, per-episode still makes progress. + #[tokio::test] + async fn a_failed_pack_inside_its_window_falls_back_to_per_episode() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs(&database, season_id, &["-10 minutes"]).await; let indexer = prowlarr().await; let (downloader, fake) = transmission().await; @@ -1520,6 +1555,90 @@ mod tests { ); } + /// §6.2: one failed pack backs the pack lane off for 1h, then the tick + /// retries the pack instead of never trying again. + #[tokio::test] + async fn a_failed_pack_retries_after_its_backoff_window() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs(&database, season_id, &["-2 hours"]).await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader).tick(&database).await.unwrap(); + + assert_eq!(fake.torrents().len(), 1); + assert!(fake.torrents()[0].source.ends_with("pack.torrent")); + assert_eq!( + grabs(&database).await.last().unwrap(), + &("season".to_owned(), season_id, "sent".to_owned()) + ); + } + + /// Repeated failures ride the capped curve: five failed packs mean a 7d + /// window — still closed at 6d, open at 8d. Quiet, never off. + #[tokio::test] + async fn a_repeatedly_failed_pack_retries_on_the_capped_curve() { + let inside = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs( + &inside.1, + inside.2, + &["-30 days", "-25 days", "-20 days", "-12 days", "-6 days"], + ) + .await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + action(&indexer, &downloader).tick(&inside.1).await.unwrap(); + assert!( + fake.torrents() + .iter() + .all(|torrent| !torrent.source.ends_with("pack.torrent")), + "6 days into a 7d window the pack lane stays quiet" + ); + + let elapsed = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs( + &elapsed.1, + elapsed.2, + &["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"], + ) + .await; + let (downloader, fake) = transmission().await; + action(&indexer, &downloader) + .tick(&elapsed.1) + .await + .unwrap(); + assert_eq!(fake.torrents().len(), 1); + assert!(fake.torrents()[0].source.ends_with("pack.torrent")); + } + + /// The escape hatch (§6.2, issue #181): a manual season search waives + /// the failed-pack backoff and retries the pack right now. + #[tokio::test] + async fn a_manual_season_search_waives_the_pack_backoff() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs(&database, season_id, &["-10 minutes"]).await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader) + .search_season_now(&database, season_id) + .await + .unwrap(); + + assert_eq!(fake.torrents().len(), 1); + assert!(fake.torrents()[0].source.ends_with("pack.torrent")); + assert_eq!( + grabs(&database).await.last().unwrap(), + &("season".to_owned(), season_id, "sent".to_owned()) + ); + assert_eq!( + episode_states(&database).await, + vec!["downloading", "downloading", "downloading"] + ); + } + /// §14: a season with an episode already on disk never grabs the pack, /// even when fully released. The remaining episodes come individually. #[tokio::test] @@ -1552,6 +1671,49 @@ mod tests { assert!(sources[1].ends_with("e03.torrent")); } + /// #182: the season deck cannot tell a queued pack sweep from one that + /// ran and found nothing unless the pack search leaves a mark. Episode + /// searches must not leave it — a season on the per-episode lane never + /// asked for a pack, and pretending otherwise is the lie the deck told. + #[tokio::test] + async fn only_a_pack_sweep_stamps_the_season() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + let stamp = |database: Db| async move { + sqlx::query_scalar::<_, Option>( + "SELECT last_pack_search_at FROM seasons WHERE id = ?", + ) + .bind(season_id) + .fetch_one(database.pool()) + .await + .unwrap() + }; + assert!(stamp(database.clone()).await.is_none()); + + action(&indexer, &downloader).tick(&database).await.unwrap(); + let after_pack = stamp(database.clone()).await; + assert!( + after_pack.is_some(), + "a fully released season takes the pack lane and its sweep is the deck's evidence" + ); + assert_eq!(fake.torrents().len(), 1); + + // An airing season is on the per-episode lane: its episode searches + // say nothing about whether a pack was ever looked for. + let (_other_dir, airing, airing_id) = wanted_season(&["2024-04-11", "2999-01-01"]).await; + action(&indexer, &downloader).tick(&airing).await.unwrap(); + let never: Option = + sqlx::query_scalar("SELECT last_pack_search_at FROM seasons WHERE id = ?") + .bind(airing_id) + .fetch_one(airing.pool()) + .await + .unwrap(); + assert!(never.is_none()); + } + /// An empty result backs the whole season off together (§6.2) instead of /// hammering the tracker every 30 s. #[tokio::test] diff --git a/crates/arr-db/migrations/0023_season_pack_search.sql b/crates/arr-db/migrations/0023_season_pack_search.sql new file mode 100644 index 0000000..a6d14f1 --- /dev/null +++ b/crates/arr-db/migrations/0023_season_pack_search.sql @@ -0,0 +1,6 @@ +-- #182. The season release deck cannot tell a queued pack sweep from one +-- that ran and found nothing, so it blames backoff for both. Only the season +-- knows when its own pack search last completed: episodes' `last_searched_at` +-- moves for reasons that have nothing to do with the pack lane, and a season +-- on the per-episode lane never touches the pack search at all. +ALTER TABLE seasons ADD COLUMN last_pack_search_at TEXT; diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index 5130f33..354d183 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -619,7 +619,12 @@ mod tests { let one_and_a_half_gib = 1536 << 20; assert_eq!( - arr_core::score::is_below_floor(&loaded.policy, Resolution::R1080p, one_and_a_half_gib), + arr_core::score::is_below_floor( + &loaded.policy, + Resolution::R1080p, + one_and_a_half_gib, + 1 + ), Some(false) ); } diff --git a/web/src/main.ts b/web/src/main.ts index cd15fbd..cb3714b 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -38,6 +38,7 @@ import { formatAudio, formatHdr, formatResolution, + formatRetryWait, formatScore, formatSeeders, formatSize, @@ -53,6 +54,7 @@ import { queueSearch, removeMovie, ruleLabel, + type SeasonPackState, sweepExpected, totalSize, type WaiveOutcome, @@ -2799,6 +2801,24 @@ function tvReleasesMain(): TvReleasesView { } else { delete statusEl.dataset.tone; } + delete statusEl.dataset.action; + } + + /** + * A status that carries its own way out. A season held off the pack lane + * cannot be helped by the head's re-search alone (#181, #182), so the + * sentence that explains the wait also offers the retry that ends it. + */ + function setStatusAction(text: string, label: string, run: () => void) { + const action = document.createElement("button"); + action.type = "button"; + action.className = "control"; + action.textContent = label; + action.addEventListener("click", run); + statusEl.hidden = false; + statusEl.replaceChildren(document.createTextNode(text), action); + delete statusEl.dataset.tone; + statusEl.dataset.action = ""; } const actions: ReleaseActions = { @@ -2841,23 +2861,93 @@ function tvReleasesMain(): TvReleasesView { return; } if (!paintBuckets(dom, outcome.releases, actions)) { - // issue 167: an empty deck sweeps on open instead of describing a sweep - if (sweepIfEmpty) { - startSweep(current); - } else { - setStatus("no releases indexed yet — re-search queues a targeted sweep"); - } + clearBuckets(dom); + await emptyVerdict(current, ticket, sweepIfEmpty); return; } setStatus(null); } /** - * A queued sweep is done when its releases appear in the table. Nothing - * found is indistinguishable from still running, so the wait says so - * honestly instead of promising either way. + * An empty season deck is three different truths (#182), and until the + * season could say which, it blamed backoff for all three: a pack sweep + * still running, a pack sweep that ran and found nothing, and a season on + * the per-episode lane, where no pack sweep is coming at all. The lane + * answers the third; `last_pack_search_at` separates the first two, the + * same way a movie's `last_searched_at` does (#177). */ - function watchSweep(current: TvDeckRequest, ticket: number) { + async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) { + const outcome = await current.target.packState?.(); + if (ticket !== sequence || request !== current) { + return; + } + if (outcome?.kind === "state" && outcome.state.lane === "per_episode") { + sweep.disabled = false; + describePerEpisode(current, outcome.state); + return; + } + if (outcome?.kind === "state" && outcome.state.last_pack_search_at !== null) { + sweep.disabled = false; + setStatus( + `no season pack found — indexers last swept ${formatSweepAge(outcome.state.last_pack_search_at)}; re-search runs a new one`, + ); + return; + } + // issue 167: an empty deck sweeps on open instead of describing a sweep + if (sweepIfEmpty) { + void startSweep(current, outcome?.kind === "state" ? outcome.state : null); + return; + } + setStatus("no releases indexed yet — re-search queues a targeted sweep"); + } + + /** + * Name the lane instead of the backoff. A season grabbing episode by + * episode has no pack to show and never will while the reason holds, so + * the deck says which reason it is and where the releases actually are. + */ + function describePerEpisode(current: TvDeckRequest, state: SeasonPackState) { + const elsewhere = "open an episode for its releases"; + if (state.reason === "no_episodes") { + setStatus( + "no episodes known for this season yet — a metadata refresh has to find them before anything can be searched", + ); + return; + } + if (state.reason === "still_airing") { + setStatus( + `season still airing — a pack is only searched once every episode has aired, so this one is grabbed episode by episode; ${elsewhere}`, + ); + return; + } + if (state.reason === "episodes_on_disk") { + setStatus( + `episodes already on disk — a pack would re-import them, so the rest is grabbed episode by episode; ${elsewhere}`, + ); + return; + } + const failures = + state.pack_failures === 1 ? "1 failed pack grab" : `${state.pack_failures} failed pack grabs`; + const quiet = + state.pack_retry_at === null + ? "pack search is quiet until its backoff elapses" + : `pack search is quiet for another ${formatRetryWait(state.pack_retry_at)}, then retries on its own`; + setStatusAction( + `${failures} — ${quiet}. episodes are grabbed one at a time meanwhile.`, + "retry the pack now", + () => { + void startSweep(current, state); + }, + ); + } + + /** + * A sweep is done when its releases appear, or when the season stamps the + * pack search it just finished. `baseline` is that stamp as it read before + * the sweep was queued: once it moves, an empty deck is a settled answer + * rather than a pending one, and `load` says so. + */ + function watchSweep(current: TvDeckRequest, baseline: string | null, ticket: number) { sweep.disabled = true; setStatus("sweeping indexers…", undefined, true); const deadline = Date.now() + TV_SWEEP_WAIT_MS; @@ -2875,10 +2965,19 @@ function tvReleasesMain(): TvReleasesView { setStatus(null); return; } + const state = await current.target.packState?.(); + if (ticket !== sequence || request !== current) { + return; + } + if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) { + sweep.disabled = false; + await emptyVerdict(current, ticket, false); + return; + } if (Date.now() >= deadline) { sweep.disabled = false; setStatus( - "sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs", + "sweep has not landed yet — nothing has come back from the indexers; results appear here when it does", ); return; } @@ -2891,23 +2990,40 @@ function tvReleasesMain(): TvReleasesView { }, TV_SWEEP_POLL_MS); } - /** Queue a targeted sweep, then watch for its releases to land (§6.2). */ - function startSweep(current: TvDeckRequest) { + /** + * Queue a targeted sweep, then watch for it to land (§6.2). `known` is the + * lane as it read a moment ago, so the watch is only entered when a pack + * sweep is actually expected: on the per-episode lane the sweep searches + * episodes, and waiting for a pack that is not coming is the lie #182 is + * about. A failed pack is the exception — a manual search waives its + * window and does try a pack (#181). + */ + async function startSweep(current: TvDeckRequest, known?: SeasonPackState | null) { sweep.disabled = true; - void (async () => { - const outcome = await current.target.search(); - if (request !== current) { - return; - } - if (outcome.kind === "error") { - sweep.disabled = false; - setStatus(`search failed — ${outcome.detail}`, "fault"); - return; - } - sequence += 1; - window.clearTimeout(pollTimer); - watchSweep(current, sequence); - })(); + const before = + known === undefined + ? await current.target.packState?.().then((it) => (it.kind === "state" ? it.state : null)) + : known; + if (request !== current) { + return; + } + const outcome = await current.target.search(); + if (request !== current) { + return; + } + sequence += 1; + window.clearTimeout(pollTimer); + if (outcome.kind === "error") { + sweep.disabled = false; + setStatus(`search failed — ${outcome.detail}`, "fault"); + return; + } + if (before && before.lane === "per_episode" && before.reason !== "pack_backoff") { + sweep.disabled = false; + setStatus("searching the season's episodes — open an episode for its releases"); + return; + } + watchSweep(current, before?.last_pack_search_at ?? null, sequence); } sweep.addEventListener("click", () => { @@ -2915,7 +3031,7 @@ function tvReleasesMain(): TvReleasesView { if (!current) { return; } - startSweep(current); + void startSweep(current); }); function open(next: TvDeckRequest) { diff --git a/web/src/releases.ts b/web/src/releases.ts index 2e74b21..527bc48 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -95,6 +95,67 @@ export function formatSweepAge(lastSearchedAt: string, now = Date.now()): string return `${Math.round(minutes / (24 * 60))} d ago`; } +/** Which lane a season's missing episodes take (#182, §6.2). */ +export type SeasonLane = "season_pack" | "per_episode"; + +/** What holds a season off the pack lane (#182). */ +export type PackLaneReason = "no_episodes" | "still_airing" | "episodes_on_disk" | "pack_backoff"; + +/** + * Why the season deck holds what it holds, from + * `/api/series/{id}/seasons/{n}/pack-state`. An empty deck is three states, + * not one: a pack sweep still running, a pack sweep that found nothing, and + * a season on the per-episode lane, where no pack sweep is coming. + */ +export interface SeasonPackState { + lane: SeasonLane; + reason: PackLaneReason | null; + pack_failures: number; + pack_retry_at: string | null; + last_pack_search_at: string | null; +} + +export type PackStateOutcome = + | { kind: "state"; state: SeasonPackState } + | { kind: "error"; detail: string }; + +export async function seasonPackState( + seriesId: number, + seasonNumber: number, +): Promise { + try { + const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}/pack-state`); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "state", state: (await response.json()) as SeasonPackState }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +/** + * `pack_retry_at` as a coarse wait. The deck says how long the lane stays + * quiet, so "it retries on its own" is a promise with a date on it. + */ +export function formatRetryWait(retryAt: string, now = Date.now()): string { + const reopens = Date.parse(retryAt); + if (Number.isNaN(reopens)) { + return "later"; + } + const minutes = Math.round((reopens - now) / 60_000); + if (minutes < 2) { + return "any moment"; + } + if (minutes < 60) { + return `${minutes} min`; + } + if (minutes < 48 * 60) { + return `${Math.round(minutes / 60)} h`; + } + return `${Math.round(minutes / (24 * 60))} d`; +} + /** One library file as `/api/movies/{id}/files` reports it (§5.6, §5.7). */ export interface MovieFile { id: number; diff --git a/web/src/series.ts b/web/src/series.ts index 6d1deeb..0c5d63c 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -3,8 +3,14 @@ // (src/api/) is uncommitted, so CI's tsc cannot see it. import type { MetadataTrailer } from "./movie"; -import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases"; -import { errorDetail, probedAttributeTags, waiverOverride } from "./releases"; +import type { + ActionOutcome, + MovieRelease, + PackStateOutcome, + ReleasesOutcome, + WaiveOutcome, +} from "./releases"; +import { errorDetail, probedAttributeTags, seasonPackState, waiverOverride } from "./releases"; /** §4.2 derived status — displayed, never editable. */ export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended"; @@ -233,6 +239,11 @@ export interface TvTarget { releases: () => Promise; grab: (releaseId: number) => Promise; search: () => Promise; + /** + * Why the deck is empty (#182). Seasons only: an episode deck has one + * lane, so it has nothing to disambiguate. + */ + packState?: () => Promise; } export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget { @@ -241,6 +252,7 @@ export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget { releases: () => fetchJson(`${base}/releases`), grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`), search: () => post(`${base}/search`), + packState: () => seasonPackState(seriesId, seasonNumber), }; } diff --git a/web/src/style.css b/web/src/style.css index f44f7ca..8a0ca95 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -537,6 +537,17 @@ body { color: var(--signal-fault); } +/* a status that carries its own way out keeps the control on the sentence's + own left edge, so the explanation reads first and the action follows it + (#182). Long enough copy wraps the control to its own line, which is the + reading order anyway. */ +.deck-status[data-action] { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); +} + /* a sweep in flight borrows the rail's probing-lamp idiom, inline */ .deck-status .lamp { display: inline-block;