Merge milestone 'Season packs'
e2e / e2e (push) Successful in 1m16s
ci / web (push) Successful in 1m41s
ci / rust (push) Successful in 1m48s

Size bands describe one episode, so a pack's size is divided by the
episodes it covers before the floor and target are applied — a 2160p
pack no longer loses to a 1080p one on a penalty that scaled with
season length.

A failed pack grab now quiets the pack lane on the shared §6.2 backoff
curve instead of disabling it forever, and a manual season search
waives the window. The season deck names which state it is actually in
rather than blaming backoff for a sweep that was never going to run.

Closes #179, #180, #181, #182
This commit is contained in:
Miguel Palhas
2026-08-24 21:31:38 +01:00
30 changed files with 1595 additions and 173 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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": {
@@ -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"
}
@@ -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": {
@@ -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"
}
@@ -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"
}
+24 -1
View File
@@ -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
+5
View File
@@ -100,6 +100,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.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",
+19 -4
View File
@@ -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<u32, u32>>,
) -> 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<usize> = (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)]
+18 -3
View File
@@ -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<Vec<ClassifiedRelease>, 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<u32, u32>>,
) -> Result<ClassifiedRelease, ApiError> {
let parsed = arr_parse::parse(&release.name);
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
// 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");
+273 -6
View File
@@ -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<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Vec<Release>>, 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<BTreeMap<u32, u32>, 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<i64, ApiError> {
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<PackLaneReason>,
/// 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<String>,
/// 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<String>,
}
#[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<AppState>,
Path((series_id, number)): Path<(i64, i64)>,
) -> Result<Json<SeasonPackState>, 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<Option<SystemTime>> = 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::<serde_json::Value>()
.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;
+141 -18
View File
@@ -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<PerEpisodeReason> {
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);
}
}
+4 -1
View File
@@ -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,
+23 -4
View File
@@ -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<u64>,
/// 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<u64>,
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!(
+263 -14
View File
@@ -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<bool> {
pub fn is_below_floor(
policy: &Policy,
resolution: Resolution,
size_bytes: u64,
episode_count: u32,
) -> Option<bool> {
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, u32>,
) -> 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);
}
}
+52 -2
View File
@@ -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<Eligible>), 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<BTreeMap<u32, u32>, 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<Eligible>), 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
+2
View File
@@ -307,6 +307,7 @@ impl ImportAction {
&original_language,
Candidate::PostDownload(&feature.media),
Some(feature.size),
1,
);
let waiver: Option<Rule> = 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) => {
+12 -8
View File
@@ -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<bool, GrabError>
)
.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<bool, GrabError>
.collect::<Vec<_>>(),
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)
}
+200 -38
View File
@@ -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<Option<SeasonWork>, 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<Vec<TvCandidate>, 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<bool, GrabError> {
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<bool, GrabError> {
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<String>>(
"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<String> =
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]
@@ -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;
+6 -1
View File
@@ -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)
);
}
+144 -28
View File
@@ -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) {
+61
View File
@@ -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<PackStateOutcome> {
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;
+14 -2
View File
@@ -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<ReleasesOutcome>;
grab: (releaseId: number) => Promise<ActionOutcome>;
search: () => Promise<ActionOutcome>;
/**
* Why the deck is empty (#182). Seasons only: an episode deck has one
* lane, so it has nothing to disambiguate.
*/
packState?: () => Promise<PackStateOutcome>;
}
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),
};
}
+11
View File
@@ -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;