diff --git a/.sqlx/query-7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc.json b/.sqlx/query-1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67.json similarity index 80% rename from .sqlx/query-7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc.json rename to .sqlx/query-1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67.json index 7559915..d34c95a 100644 --- a/.sqlx/query-7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc.json +++ b/.sqlx/query-1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67.json @@ -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" } diff --git a/.sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json b/.sqlx/query-3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c.json similarity index 52% rename from .sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json rename to .sqlx/query-3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c.json index b3fa0d0..413eb80 100644 --- a/.sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json +++ b/.sqlx/query-3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c.json @@ -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" } diff --git a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json b/.sqlx/query-4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377.json similarity index 90% rename from .sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json rename to .sqlx/query-4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377.json index a440463..54d5718 100644 --- a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json +++ b/.sqlx/query-4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377.json @@ -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" } diff --git a/.sqlx/query-7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4.json b/.sqlx/query-81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b.json similarity index 80% rename from .sqlx/query-7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4.json rename to .sqlx/query-81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b.json index 70f41db..bb05272 100644 --- a/.sqlx/query-7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4.json +++ b/.sqlx/query-81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b.json @@ -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" } diff --git a/.sqlx/query-b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9.json b/.sqlx/query-b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9.json new file mode 100644 index 0000000..2f69b71 --- /dev/null +++ b/.sqlx/query-b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9.json @@ -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" +} diff --git a/.sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json b/.sqlx/query-ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b.json similarity index 88% rename from .sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json rename to .sqlx/query-ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b.json index 509e530..910129e 100644 --- a/.sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json +++ b/.sqlx/query-ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b.json @@ -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" } diff --git a/.sqlx/query-f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16.json b/.sqlx/query-f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16.json new file mode 100644 index 0000000..d65ea71 --- /dev/null +++ b/.sqlx/query-f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16.json @@ -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" +} diff --git a/.sqlx/query-fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166.json b/.sqlx/query-fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166.json new file mode 100644 index 0000000..55302ed --- /dev/null +++ b/.sqlx/query-fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166.json @@ -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" +} diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 04e8184..d7d13ac 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -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>, + 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 = (0..releases.len()).collect(); indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i])); @@ -1559,6 +1574,7 @@ mod tests { size_bytes, seeders, 1, + 0, ) .total; #[allow(clippy::cast_precision_loss)] diff --git a/crates/arr-api/src/reclassify.rs b/crates/arr-api/src/reclassify.rs index 1a8fb28..70fd9a8 100644 --- a/crates/arr-api/src/reclassify.rs +++ b/crates/arr-api/src/reclassify.rs @@ -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>, + 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))`), diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 2e73d73..2fc2c6d 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -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>, + runtime_minutes: u32, ) -> Result { 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"); diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 6c85afa..a8c48f3 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -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 { + 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)) } diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index 033b396..ea828f8 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -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, 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!( diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index 3cfa5b4..d569358 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -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 { - 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); diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index d462a77..b38d37d 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -1192,6 +1192,7 @@ pub(crate) async fn store_release( original_language: &Language, blacklist: &Blacklist, ) -> Result, 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 { + 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), 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 diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index c26dcd0..80db79a 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -308,6 +308,7 @@ impl ImportAction { Candidate::PostDownload(&feature.media), Some(feature.size), 1, + 0, ); let waiver: Option = 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, original_language: Option, + /// §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, release_name: String, } @@ -974,6 +984,7 @@ async fn pending_tv_imports(database: &Db) -> Result, 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, 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, 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, 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, })); diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 46d7471..391c47e 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -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, backdrop_path: Option, vote_average: Option, + runtime_minutes: Option, } /// 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 = + 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 = + 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. diff --git a/crates/arr-db/migrations/0024_series_runtime.sql b/crates/arr-db/migrations/0024_series_runtime.sql new file mode 100644 index 0000000..a121900 --- /dev/null +++ b/crates/arr-db/migrations/0024_series_runtime.sql @@ -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); diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index d33701d..5701a71 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -696,7 +696,8 @@ mod tests { &loaded.policy, Resolution::R1080p, one_and_a_half_gib, - 1 + 1, + 0 ), Some(false) ); diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index 05d8125..e3a6f71 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -156,6 +156,10 @@ pub struct Series { pub vote_average: Option, /// 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, pub seasons: Vec, } @@ -368,6 +372,8 @@ pub(crate) struct RawSeries { #[serde(default)] vote_count: u32, #[serde(default)] + episode_run_time: Vec, + #[serde(default)] seasons: Vec, #[serde(default)] external_ids: Option, @@ -406,6 +412,10 @@ impl From 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()