|
|
|
@@ -29,7 +29,9 @@ use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use utoipa::{IntoParams, ToSchema};
|
|
|
|
|
|
|
|
|
|
use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release};
|
|
|
|
|
use crate::movies::{
|
|
|
|
|
attach_blacklist_reasons, pool, rescore, Accepted, ApiError, ErrorBody, Release,
|
|
|
|
|
};
|
|
|
|
|
use crate::owners::Owner;
|
|
|
|
|
use crate::search::tmdb_client;
|
|
|
|
|
use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand};
|
|
|
|
@@ -110,6 +112,10 @@ pub struct Season {
|
|
|
|
|
/// which is why the row still exists. A conflict for the operator to
|
|
|
|
|
/// resolve; nothing was deleted from disk.
|
|
|
|
|
pub vanished: bool,
|
|
|
|
|
/// #227. The last season pack that downloaded in full and was condemned
|
|
|
|
|
/// at import, while the season is still waiting for a file. `None` when
|
|
|
|
|
/// no pack was abandoned, or when the gap has since been filled.
|
|
|
|
|
pub import_failure: Option<ImportFailure>,
|
|
|
|
|
pub episodes: Vec<Episode>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1039,6 +1045,8 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
|
|
|
|
.fetch_all(pool(state)?)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let mut failures = season_import_failures(state, series_id).await?;
|
|
|
|
|
|
|
|
|
|
Ok(seasons
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|season| Season {
|
|
|
|
@@ -1047,6 +1055,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
|
|
|
|
number: season.number,
|
|
|
|
|
tracked: season.tracked,
|
|
|
|
|
vanished: season.vanished,
|
|
|
|
|
import_failure: failures.remove(&season.id),
|
|
|
|
|
episodes: episodes
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|episode| episode.season_id == season.id)
|
|
|
|
@@ -1416,7 +1425,7 @@ pub async fn episode_releases(
|
|
|
|
|
Path(id): Path<i64>,
|
|
|
|
|
) -> Result<Json<Vec<Release>>, ApiError> {
|
|
|
|
|
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)
|
|
|
|
|
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, NULL AS "blacklist_reason?: String" 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?;
|
|
|
|
|
let policy = state
|
|
|
|
@@ -1430,6 +1439,7 @@ pub async fn episode_releases(
|
|
|
|
|
let lengths = season_lengths(&state, episode.series_id).await?;
|
|
|
|
|
let runtime = series_runtime(&state, episode.series_id).await?;
|
|
|
|
|
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
|
|
|
|
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
|
|
|
|
|
Ok(Json(releases))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1571,7 +1581,7 @@ pub async fn season_releases(
|
|
|
|
|
) -> Result<Json<Vec<Release>>, ApiError> {
|
|
|
|
|
load_series_row(&state, series_id).await?;
|
|
|
|
|
let season_id = load_season_id(&state, series_id, number).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 season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id)
|
|
|
|
|
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, NULL AS "blacklist_reason?: String" FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id)
|
|
|
|
|
.fetch_all(pool(&state)?)
|
|
|
|
|
.await?;
|
|
|
|
|
let policy = state
|
|
|
|
@@ -1585,9 +1595,92 @@ pub async fn season_releases(
|
|
|
|
|
let lengths = season_lengths(&state, series_id).await?;
|
|
|
|
|
let runtime = series_runtime(&state, series_id).await?;
|
|
|
|
|
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
|
|
|
|
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
|
|
|
|
|
Ok(Json(releases))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A grab for this season that downloaded in full and was then condemned at
|
|
|
|
|
/// import (#227, §5.7).
|
|
|
|
|
///
|
|
|
|
|
/// The torrent stays at 100% in Transmission — §7.3 leaves that lifecycle to
|
|
|
|
|
/// the reaper — the release is blacklisted and every episode it was covering
|
|
|
|
|
/// reopens as a gap. Nothing on screen connected the two, so the season read
|
|
|
|
|
/// `0/10` as though no grab had ever been tried. Every fact is already
|
|
|
|
|
/// recorded; this is the row that carries them out.
|
|
|
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
|
|
|
pub struct ImportFailure {
|
|
|
|
|
/// The blacklisted release name, as the indexer spelled it.
|
|
|
|
|
pub release: String,
|
|
|
|
|
/// What the blacklist recorded it as failing on — a policy rule name the
|
|
|
|
|
/// operator can relax, or a sentence about the release itself. `None`
|
|
|
|
|
/// when no blacklist row answers to either key, which is possible after a
|
|
|
|
|
/// blacklist row is cleared by hand; the failure is still true.
|
|
|
|
|
pub reason: Option<String>,
|
|
|
|
|
/// When the grab entered `failed`, RFC3339. `None` on a row that failed
|
|
|
|
|
/// before migration 0030 gave the column a value.
|
|
|
|
|
pub failed_at: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The most recent abandoned pack per season of one series (#227).
|
|
|
|
|
///
|
|
|
|
|
/// Only seasons still waiting for a file are answered: once the gap is
|
|
|
|
|
/// filled, the failure is history and the season has nothing to explain. The
|
|
|
|
|
/// blacklist reason is matched in memory because its name key is normalised,
|
|
|
|
|
/// which SQL cannot compute.
|
|
|
|
|
async fn season_import_failures(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
series_id: i64,
|
|
|
|
|
) -> Result<HashMap<i64, ImportFailure>, ApiError> {
|
|
|
|
|
let pool = pool(state)?;
|
|
|
|
|
let rows = sqlx::query!(
|
|
|
|
|
r#"SELECT g.target_id AS "season_id!: i64",
|
|
|
|
|
r.name AS "name!: String",
|
|
|
|
|
g.infohash AS "infohash!: String",
|
|
|
|
|
g.failed_at,
|
|
|
|
|
g.grabbed_at AS "grabbed_at!: String"
|
|
|
|
|
FROM grabs g
|
|
|
|
|
JOIN releases r ON r.id = g.release_id
|
|
|
|
|
JOIN seasons s ON s.id = g.target_id
|
|
|
|
|
WHERE g.target_kind = 'season'
|
|
|
|
|
AND g.state = 'failed'
|
|
|
|
|
AND s.series_id = ?
|
|
|
|
|
AND EXISTS (
|
|
|
|
|
SELECT 1 FROM episodes e
|
|
|
|
|
WHERE e.season_id = s.id AND e.wanted
|
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM media_files f
|
|
|
|
|
WHERE f.owner_kind = 'episode' AND f.owner_id = e.id
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
ORDER BY coalesce(g.failed_at, g.grabbed_at), g.id"#,
|
|
|
|
|
series_id
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
if rows.is_empty() {
|
|
|
|
|
return Ok(HashMap::new());
|
|
|
|
|
}
|
|
|
|
|
let blacklist = arr_db::blacklist::Blacklist::load(pool).await?;
|
|
|
|
|
// Ascending order, so the last row written for a season wins.
|
|
|
|
|
Ok(rows
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|row| {
|
|
|
|
|
let reason = blacklist
|
|
|
|
|
.reason_for_infohash(&row.infohash)
|
|
|
|
|
.or_else(|| blacklist.reason_for_name(&row.name))
|
|
|
|
|
.map(str::to_owned);
|
|
|
|
|
(
|
|
|
|
|
row.season_id,
|
|
|
|
|
ImportFailure {
|
|
|
|
|
release: row.name,
|
|
|
|
|
reason,
|
|
|
|
|
failed_at: row.failed_at,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.collect())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Which lane a season's missing episodes take (#182, §6.2).
|
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
@@ -1629,6 +1722,11 @@ pub struct SeasonPackState {
|
|
|
|
|
/// 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>,
|
|
|
|
|
/// #227. The last pack that downloaded in full and was then condemned at
|
|
|
|
|
/// import, while the season is still waiting for a file. A deck that
|
|
|
|
|
/// cannot say this leaves the season reading as though nothing was ever
|
|
|
|
|
/// tried, with the torrent still sitting at 100% in Transmission.
|
|
|
|
|
pub import_failure: Option<ImportFailure>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[utoipa::path(
|
|
|
|
@@ -1723,6 +1821,9 @@ pub async fn season_pack_state(
|
|
|
|
|
pack_failures: failed.failures,
|
|
|
|
|
pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()),
|
|
|
|
|
last_pack_search_at,
|
|
|
|
|
import_failure: season_import_failures(&state, series_id)
|
|
|
|
|
.await?
|
|
|
|
|
.remove(&season_id),
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1942,6 +2043,133 @@ mod tests {
|
|
|
|
|
response.json().await.expect("season json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn seasons_json(base: &str, series_id: i64) -> Vec<serde_json::Value> {
|
|
|
|
|
reqwest::get(format!("{base}/api/series/{series_id}/seasons"))
|
|
|
|
|
.await
|
|
|
|
|
.expect("seasons")
|
|
|
|
|
.json()
|
|
|
|
|
.await
|
|
|
|
|
.expect("seasons json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// #227, the operator's own report: a pack downloaded in full, §5.7
|
|
|
|
|
/// condemned it at import, every episode went back to `missing`, and the
|
|
|
|
|
/// season read `0/10` with nothing anywhere saying a grab had been tried.
|
|
|
|
|
/// The season row, its deck and the deck's blacklisted release now each
|
|
|
|
|
/// carry the failure and the reason it failed on.
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn an_abandoned_pack_is_visible_on_the_season_and_its_deck() {
|
|
|
|
|
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 episodes: Vec<serde_json::Value> = (1..=10)
|
|
|
|
|
.map(|number| {
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"number": number, "title": format!("Episode {number}"),
|
|
|
|
|
"air_date": "2025-01-01"
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
let season = add_season(&base, series_id, 8, serde_json::json!(episodes)).await;
|
|
|
|
|
let season_id = season["id"].as_i64().expect("season id");
|
|
|
|
|
assert!(
|
|
|
|
|
season["import_failure"].is_null(),
|
|
|
|
|
"nothing has been grabbed yet"
|
|
|
|
|
);
|
|
|
|
|
// §4.1: tracking the season is what makes its episodes wanted, and a
|
|
|
|
|
// season with no intent has no gap to explain.
|
|
|
|
|
let tracked = reqwest::Client::new()
|
|
|
|
|
.patch(format!("{base}/api/series/{series_id}/seasons/8"))
|
|
|
|
|
.json(&serde_json::json!({"tracked": true}))
|
|
|
|
|
.send()
|
|
|
|
|
.await
|
|
|
|
|
.expect("track the season");
|
|
|
|
|
assert_eq!(tracked.status(), StatusCode::OK);
|
|
|
|
|
|
|
|
|
|
let pool = state.database().expect("database").pool();
|
|
|
|
|
let name = "Rick.And.Morty.S08.1080p.WEB-DL.x264-GROUP";
|
|
|
|
|
let parsed = arr_parse::parse(name);
|
|
|
|
|
let release_id = sqlx::query_scalar::<_, i64>(
|
|
|
|
|
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
|
|
|
|
VALUES (7, 'pack', ?, 1000, 50, 'url', ?, 0, 'eligible') RETURNING id",
|
|
|
|
|
)
|
|
|
|
|
.bind(name)
|
|
|
|
|
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await
|
|
|
|
|
.expect("release");
|
|
|
|
|
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
|
|
|
|
.bind(season_id)
|
|
|
|
|
.bind(release_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.expect("association");
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at, failed_at)
|
|
|
|
|
VALUES (?, 'season', ?, 'abc123', 'failed', '2026-01-01T00:00:00.000Z', '2026-01-02T00:00:00.000Z')",
|
|
|
|
|
)
|
|
|
|
|
.bind(release_id)
|
|
|
|
|
.bind(season_id)
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.expect("failed pack grab");
|
|
|
|
|
|
|
|
|
|
// The failure with no blacklist row yet: still a failure, and the
|
|
|
|
|
// reason is simply not known. Rows written before the blacklist
|
|
|
|
|
// carried one read this way and must keep rendering.
|
|
|
|
|
let seasons = seasons_json(&base, series_id).await;
|
|
|
|
|
let row = &seasons[0]["import_failure"];
|
|
|
|
|
assert_eq!(row["release"], name);
|
|
|
|
|
assert_eq!(row["reason"], serde_json::Value::Null);
|
|
|
|
|
assert_eq!(row["failed_at"], "2026-01-02T00:00:00.000Z");
|
|
|
|
|
|
|
|
|
|
arr_db::blacklist::add(pool, Some("ABC123"), name, "size")
|
|
|
|
|
.await
|
|
|
|
|
.expect("blacklist");
|
|
|
|
|
|
|
|
|
|
let seasons = seasons_json(&base, series_id).await;
|
|
|
|
|
assert_eq!(seasons[0]["import_failure"]["reason"], "size");
|
|
|
|
|
|
|
|
|
|
// The deck the season row leads to says the same thing.
|
|
|
|
|
let pack_state: serde_json::Value = reqwest::get(format!(
|
|
|
|
|
"{base}/api/series/{series_id}/seasons/8/pack-state"
|
|
|
|
|
))
|
|
|
|
|
.await
|
|
|
|
|
.expect("pack state")
|
|
|
|
|
.json()
|
|
|
|
|
.await
|
|
|
|
|
.expect("pack state json");
|
|
|
|
|
assert_eq!(pack_state["import_failure"]["release"], name);
|
|
|
|
|
assert_eq!(pack_state["import_failure"]["reason"], "size");
|
|
|
|
|
|
|
|
|
|
// §6.3: the release itself is rejected in the deck, and now names
|
|
|
|
|
// what it was blacklisted for — a size rejection the operator can
|
|
|
|
|
// relax, not a corrupt file they should leave alone.
|
|
|
|
|
let releases: Vec<serde_json::Value> =
|
|
|
|
|
reqwest::get(format!("{base}/api/series/{series_id}/seasons/8/releases"))
|
|
|
|
|
.await
|
|
|
|
|
.expect("releases")
|
|
|
|
|
.json()
|
|
|
|
|
.await
|
|
|
|
|
.expect("releases json");
|
|
|
|
|
assert_eq!(releases.len(), 1);
|
|
|
|
|
assert_eq!(releases[0]["blacklist_reason"], "size");
|
|
|
|
|
|
|
|
|
|
// Once the gap is filled the failure is history: the season has
|
|
|
|
|
// nothing left to explain and stops saying it.
|
|
|
|
|
for episode in seasons[0]["episodes"].as_array().expect("episodes") {
|
|
|
|
|
sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 1)")
|
|
|
|
|
.bind(episode["id"].as_i64().expect("episode id"))
|
|
|
|
|
.bind(format!("/library/e{}.mkv", episode["number"]))
|
|
|
|
|
.execute(pool)
|
|
|
|
|
.await
|
|
|
|
|
.expect("file on disk");
|
|
|
|
|
}
|
|
|
|
|
let seasons = seasons_json(&base, series_id).await;
|
|
|
|
|
assert!(seasons[0]["import_failure"].is_null());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The production case behind #210: every pack of a season is under
|
|
|
|
|
/// §5.5's per-episode floor, so the deck holds three candidates and
|
|
|
|
|
/// nothing is grabbable. Writing `allow_below_floor` turns the
|
|
|
|
|