Merge main into blitz/subtitles
Feedback pass 2 and the size-band work landed on main while this branch was finishing. Brings them in ahead of the merge back. # Conflicts: # crates/arr-api/src/movies.rs # crates/arr-api/src/state.rs # crates/arr-daemon/src/main.rs # web/src/main.ts
This commit is contained in:
@@ -12,6 +12,8 @@ mod metadata;
|
||||
mod movies;
|
||||
mod owners;
|
||||
mod policies;
|
||||
mod reclassify;
|
||||
mod relocate;
|
||||
mod roots;
|
||||
mod search;
|
||||
mod series;
|
||||
|
||||
+978
-62
File diff suppressed because it is too large
Load Diff
@@ -173,6 +173,33 @@ struct PolicyColumns {
|
||||
score_weights: String,
|
||||
}
|
||||
|
||||
/// Everything in a policy row that a verdict depends on — the name is the
|
||||
/// one column that does not.
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct PolicyRules {
|
||||
required_audio: String,
|
||||
dub_blacklist: String,
|
||||
hdr_rules: String,
|
||||
size_bands: String,
|
||||
resolution_pref: String,
|
||||
source_weights: String,
|
||||
score_weights: String,
|
||||
}
|
||||
|
||||
impl PolicyColumns {
|
||||
fn rules(&self) -> PolicyRules {
|
||||
PolicyRules {
|
||||
required_audio: self.required_audio.clone(),
|
||||
dub_blacklist: self.dub_blacklist.clone(),
|
||||
hdr_rules: self.hdr_rules.clone(),
|
||||
size_bands: self.size_bands.clone(),
|
||||
resolution_pref: self.resolution_pref.clone(),
|
||||
source_weights: self.source_weights.clone(),
|
||||
score_weights: self.score_weights.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn column<T: serde::de::DeserializeOwned>(
|
||||
column: &'static str,
|
||||
value: &str,
|
||||
@@ -376,6 +403,8 @@ pub async fn update(
|
||||
input.validate().map_err(ApiError::Invalid)?;
|
||||
let name = input.name.trim().to_owned();
|
||||
let columns = input.into_columns(name)?;
|
||||
let after = columns.rules();
|
||||
let before = stored_rules(&state, id).await?;
|
||||
let result = sqlx::query!(
|
||||
r#"UPDATE policies SET
|
||||
name = ?, required_audio = ?, dub_blacklist = ?, hdr_rules = ?,
|
||||
@@ -405,9 +434,44 @@ pub async fn update(
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::PolicyNotFound);
|
||||
}
|
||||
// Every verdict stored under every root pointing here was reached under
|
||||
// the rules this write just replaced; §9.3's deck and the grab gate both
|
||||
// read them (`reclassify`). A rename leaves the rules alone, so it walks
|
||||
// nothing.
|
||||
if before.is_none_or(|before| before != after) {
|
||||
crate::reclassify::policy(&state, id).await?;
|
||||
}
|
||||
Ok(Json(load_policy(&state, id).await?))
|
||||
}
|
||||
|
||||
/// The rule columns of a policy as they stand, or `None` when there is no
|
||||
/// such row. Compared against what the write is about to store, so an edit
|
||||
/// that only moves the name does not re-derive a library.
|
||||
async fn stored_rules(state: &AppState, id: i64) -> Result<Option<PolicyRules>, ApiError> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT required_audio AS "required_audio!: String",
|
||||
dub_blacklist AS "dub_blacklist!: String",
|
||||
hdr_rules AS "hdr_rules!: String",
|
||||
size_bands AS "size_bands!: String",
|
||||
resolution_pref AS "resolution_pref!: String",
|
||||
source_weights AS "source_weights!: String",
|
||||
score_weights AS "score_weights!: String"
|
||||
FROM policies WHERE id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?;
|
||||
Ok(row.map(|row| PolicyRules {
|
||||
required_audio: row.required_audio,
|
||||
dub_blacklist: row.dub_blacklist,
|
||||
hdr_rules: row.hdr_rules,
|
||||
size_bands: row.size_bands,
|
||||
resolution_pref: row.resolution_pref,
|
||||
source_weights: row.source_weights,
|
||||
score_weights: row.score_weights,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/policies/{policy_id}", tag = "policies",
|
||||
params(("policy_id" = i64, Path, description = "Policy row id")),
|
||||
@@ -450,7 +514,7 @@ mod tests {
|
||||
use crate::{router, Upstreams};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
async fn application() -> (tempfile::TempDir, String) {
|
||||
async fn application() -> (tempfile::TempDir, AppState, String) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
@@ -466,9 +530,9 @@ mod tests {
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
let app = router(state);
|
||||
let app = router(state.clone());
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
||||
(dir, format!("http://{address}"))
|
||||
(dir, state, format!("http://{address}"))
|
||||
}
|
||||
|
||||
fn valid_input(name: &str) -> serde_json::Value {
|
||||
@@ -501,7 +565,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn crud_round_trips_a_policy() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
|
||||
let created: serde_json::Value = create(&base, valid_input("test policy"))
|
||||
.await
|
||||
@@ -557,7 +621,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_referenced_policy_refuses_to_die() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
.expect("roots")
|
||||
@@ -582,7 +646,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_resolution_is_a_422_naming_the_field() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let mut payload = valid_input("bad bands");
|
||||
payload["size_bands"]["1440p"] = serde_json::json!({ "floor_gib": 2, "target_gib": 6, "penalty_points_per_gib_over": 60 });
|
||||
|
||||
@@ -596,7 +660,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_field_validates_by_name() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let with = |patch: &dyn Fn(&mut serde_json::Value)| {
|
||||
let mut payload = valid_input("validation probe");
|
||||
patch(&mut payload);
|
||||
@@ -645,7 +709,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_json_is_422_not_400_or_500() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(format!("{base}/api/policies"))
|
||||
@@ -667,8 +731,267 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_duplicate_name_conflicts() {
|
||||
let (_dir, base) = application().await;
|
||||
let (_dir, _state, base) = application().await;
|
||||
let response = create(&base, valid_input("Movies — main")).await;
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
async fn policy_id_named(base: &str, name: &str) -> i64 {
|
||||
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
|
||||
.await
|
||||
.expect("policies")
|
||||
.json()
|
||||
.await
|
||||
.expect("policies json");
|
||||
policies
|
||||
.iter()
|
||||
.find(|policy| policy["name"] == name)
|
||||
.and_then(|policy| policy["id"].as_i64())
|
||||
.expect("policy id")
|
||||
}
|
||||
|
||||
/// A movie under `root_id`, with one English 1080p release stamped
|
||||
/// `verdict` as a search would have stamped it.
|
||||
async fn movie_with_release(
|
||||
state: &AppState,
|
||||
base: &str,
|
||||
root_id: i64,
|
||||
tmdb_id: i64,
|
||||
verdict: &str,
|
||||
) -> i64 {
|
||||
let movie: serde_json::Value = reqwest::Client::new()
|
||||
.post(format!("{base}/api/movies"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": tmdb_id, "title": format!("Title {tmdb_id}"), "year": 2024,
|
||||
"original_language": "en", "root_id": root_id, "overrides": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create movie")
|
||||
.json()
|
||||
.await
|
||||
.expect("movie json");
|
||||
let movie_id = movie["id"].as_i64().expect("movie id");
|
||||
let name = format!("Title {tmdb_id} 2024 1080p WEB-DL ENGLISH x264-GROUP");
|
||||
let release_id = stamped_release(state, &name, 6_i64 * (1 << 30), verdict).await;
|
||||
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||
.bind(movie_id)
|
||||
.bind(release_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("movie association");
|
||||
movie_id
|
||||
}
|
||||
|
||||
/// One release row, verdict stamped by hand. `waived` carries no rule,
|
||||
/// which the `releases` CHECK allows — only `rejected` needs one.
|
||||
async fn stamped_release(state: &AppState, name: &str, size: i64, verdict: &str) -> i64 {
|
||||
let parsed = arr_parse::parse(name);
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||
VALUES (7, ?, ?, ?, 40, 'url', ?, 0, ?) RETURNING id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(name)
|
||||
.bind(size)
|
||||
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||
.bind(verdict)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("release")
|
||||
}
|
||||
|
||||
/// A series under `root_id` with a ten-episode season 9 pack stamped
|
||||
/// `waived`. Under its own policy the pack is eligible, so the stamp
|
||||
/// only survives if nothing re-derived it.
|
||||
async fn series_with_waived_pack(state: &AppState, base: &str, root_id: i64) -> i64 {
|
||||
let client = reqwest::Client::new();
|
||||
let series: serde_json::Value = client
|
||||
.post(format!("{base}/api/series"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": 82_728, "title": "Bluey", "year": 2018,
|
||||
"original_language": "en", "root_id": root_id,
|
||||
"auto_track": false
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create series")
|
||||
.json()
|
||||
.await
|
||||
.expect("series json");
|
||||
let series_id = series["id"].as_i64().expect("series 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: serde_json::Value = client
|
||||
.post(format!("{base}/api/series/{series_id}/seasons"))
|
||||
.json(&serde_json::json!({"number": 9, "episodes": episodes}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create season")
|
||||
.json()
|
||||
.await
|
||||
.expect("season json");
|
||||
let season_id = season["id"].as_i64().expect("season id");
|
||||
// Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
|
||||
let release_id = stamped_release(
|
||||
state,
|
||||
"Bluey S09 1080p WEB-DL ENGLISH x264-GROUP",
|
||||
15_i64 * (1 << 30),
|
||||
"waived",
|
||||
)
|
||||
.await;
|
||||
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||
.bind(season_id)
|
||||
.bind(release_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("season association");
|
||||
series_id
|
||||
}
|
||||
|
||||
async fn verdict_at(url: String) -> serde_json::Value {
|
||||
let releases: Vec<serde_json::Value> = reqwest::get(url)
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("releases json");
|
||||
releases[0]["verdict"].clone()
|
||||
}
|
||||
|
||||
async fn movie_verdict(base: &str, movie_id: i64) -> serde_json::Value {
|
||||
verdict_at(format!("{base}/api/movies/{movie_id}/releases")).await
|
||||
}
|
||||
|
||||
async fn root_id_of(state: &AppState, kind: &str, audience: &str) -> i64 {
|
||||
sqlx::query_scalar("SELECT id FROM roots WHERE kind = ? AND audience = ?")
|
||||
.bind(kind)
|
||||
.bind(audience)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("root id")
|
||||
}
|
||||
|
||||
/// The same policy document with a different required-audio rule, so an
|
||||
/// English release that was eligible becomes a waiver.
|
||||
fn requires_portuguese(name: &str) -> serde_json::Value {
|
||||
let mut payload = valid_input(name);
|
||||
payload["required_audio"] = serde_json::json!({ "require": "any_of", "langs": ["pt-PT"] });
|
||||
payload
|
||||
}
|
||||
|
||||
/// #246: editing a policy re-derives the stored verdicts of every title
|
||||
/// under every root pointing at it (§5.1) — both libraries when two
|
||||
/// roots share it, and nothing under a root that does not.
|
||||
#[tokio::test]
|
||||
async fn editing_a_policy_rederives_every_root_that_points_at_it() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let client = reqwest::Client::new();
|
||||
let shared = policy_id_named(&base, "Movies — main").await;
|
||||
let main_root = root_id_of(&state, "movie", "main").await;
|
||||
let kids_root = root_id_of(&state, "movie", "kids").await;
|
||||
|
||||
// Two roots on one policy: the edit has two libraries to reach
|
||||
// rather than one. The path stays, so nothing on disk moves.
|
||||
let response = client
|
||||
.put(format!("{base}/api/roots/{kids_root}"))
|
||||
.json(&serde_json::json!({
|
||||
"kind": "movie", "audience": "kids",
|
||||
"path": "/mnt/media/movies/kids", "policy_id": shared,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("repoint kids root");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{:?}",
|
||||
response.text().await
|
||||
);
|
||||
|
||||
let here = movie_with_release(&state, &base, main_root, 693_134, "eligible").await;
|
||||
let there = movie_with_release(&state, &base, kids_root, 27_205, "eligible").await;
|
||||
// On the TV main root, a different policy, so the edit must not
|
||||
// reach it. Stamped against its own policy's answer, so a walk that
|
||||
// did reach it would show.
|
||||
let tv_root = root_id_of(&state, "tv", "main").await;
|
||||
let series_id = series_with_waived_pack(&state, &base, tv_root).await;
|
||||
|
||||
let response = client
|
||||
.put(format!("{base}/api/policies/{shared}"))
|
||||
.json(&requires_portuguese("Movies — main"))
|
||||
.send()
|
||||
.await
|
||||
.expect("edit policy");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{:?}",
|
||||
response.text().await
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
movie_verdict(&base, here).await,
|
||||
"waived",
|
||||
"the first root's library re-derives"
|
||||
);
|
||||
assert_eq!(
|
||||
movie_verdict(&base, there).await,
|
||||
"waived",
|
||||
"and so does the second root's, sharing the policy"
|
||||
);
|
||||
assert_eq!(
|
||||
verdict_at(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
|
||||
"waived",
|
||||
"a root on another policy keeps the verdict it was stamped with"
|
||||
);
|
||||
}
|
||||
|
||||
/// Renaming a policy changes no rule, so it re-derives nothing — the
|
||||
/// stamped verdict survives even though the rules would not produce it.
|
||||
#[tokio::test]
|
||||
async fn renaming_a_policy_leaves_verdicts_alone() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let client = reqwest::Client::new();
|
||||
let kids = policy_id_named(&base, "Movies — kids").await;
|
||||
let kids_root = root_id_of(&state, "movie", "kids").await;
|
||||
|
||||
// Write the rules through the API once, so the rename that follows
|
||||
// stores byte-identical rule columns and the only change is the name.
|
||||
let response = client
|
||||
.put(format!("{base}/api/policies/{kids}"))
|
||||
.json(&valid_input("Movies — kids"))
|
||||
.send()
|
||||
.await
|
||||
.expect("normalise policy");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
// Deliberately the wrong answer: these rules make an English release
|
||||
// a waiver, so a re-derivation would move this row.
|
||||
let movie = movie_with_release(&state, &base, kids_root, 157_336, "eligible").await;
|
||||
|
||||
let response = client
|
||||
.put(format!("{base}/api/policies/{kids}"))
|
||||
.json(&valid_input("Movies — children"))
|
||||
.send()
|
||||
.await
|
||||
.expect("rename policy");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{:?}",
|
||||
response.text().await
|
||||
);
|
||||
assert_eq!(
|
||||
movie_verdict(&base, movie).await,
|
||||
"eligible",
|
||||
"a rename touches no rule, so it walks nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Stored verdicts, re-derived when a title's effective policy changes:
|
||||
//! an overrides edit (§9.3), a move to a root with a different policy, a
|
||||
//! root pointed at a different policy, or an edit to the contents of a
|
||||
//! policy some root points at (§5.1).
|
||||
//!
|
||||
//! A release's verdict is stamped once, by the search that found it. Both the
|
||||
//! deck and the daemon's manual-grab gate read that stored column, so an
|
||||
//! override written from the deck's one-click waive would change nothing
|
||||
//! until the next sweep — the row the operator just acted on would keep
|
||||
//! reading `rejected` and the grab would be refused. A root move and a
|
||||
//! policy repoint invalidate the column the same way, just wider: nothing
|
||||
//! else re-reads it, so the write that changed the effective policy is the
|
||||
//! only place the correction can happen. A policy edit invalidates it wider
|
||||
//! still: every root pointing at that policy, not just one.
|
||||
//!
|
||||
//! So the rules run again here, over the releases already attached to the
|
||||
//! title. This is the same correction the daemon makes when a grab turns out
|
||||
//! to be blacklisted: fix the row now rather than leave §9.3 offering, or
|
||||
//! refusing, something that is no longer true.
|
||||
//!
|
||||
//! The blacklist (§6.3) is not a policy opinion, so a row rejected by it is
|
||||
//! left exactly as it is.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::score::claimed_episode_count;
|
||||
use arr_core::{Language, ParsedRelease, Policy, TitleOverrides};
|
||||
use arr_db::blacklist;
|
||||
|
||||
use crate::movies::{pool, ApiError};
|
||||
use crate::search::verdict_columns;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// One stored release, as much of it as re-evaluation needs.
|
||||
struct Stored {
|
||||
id: i64,
|
||||
size: i64,
|
||||
parsed: serde_json::Value,
|
||||
verdict: Option<String>,
|
||||
rejected_rule: Option<String>,
|
||||
}
|
||||
|
||||
/// Re-evaluate every release attached to one movie.
|
||||
///
|
||||
/// A movie with no stored original language is left alone, the same rule the
|
||||
/// daemon's search applies: without it the required-audio rule has nothing to
|
||||
/// compare, and guessing would move verdicts on worse evidence than the ones
|
||||
/// already there.
|
||||
pub(crate) async fn movie(state: &AppState, movie_id: i64) -> Result<(), ApiError> {
|
||||
let Some(language) = movie_language(state, movie_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(loaded) = state
|
||||
.database()
|
||||
.ok_or(ApiError::Unavailable)?
|
||||
.movie_policy(movie_id)
|
||||
.await
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let releases = sqlx::query_as!(
|
||||
Stored,
|
||||
r#"
|
||||
SELECT r.id AS "id!: i64",
|
||||
r.size AS "size!: i64",
|
||||
r.parsed AS "parsed!: serde_json::Value",
|
||||
r.verdict,
|
||||
r.rejected_rule
|
||||
FROM releases r
|
||||
JOIN movie_releases mr ON mr.release_id = r.id
|
||||
WHERE mr.movie_id = ?
|
||||
"#,
|
||||
movie_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
|
||||
apply(
|
||||
state,
|
||||
&releases,
|
||||
&loaded.policy,
|
||||
&loaded.overrides,
|
||||
&language,
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Re-evaluate every release attached to one series, episode decks and season
|
||||
/// decks alike — overrides sit on the series (§5.1), so one change moves both.
|
||||
pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiError> {
|
||||
let Some(language) = series_language(state, series_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(loaded) = state
|
||||
.database()
|
||||
.ok_or(ApiError::Unavailable)?
|
||||
.series_policy(series_id)
|
||||
.await
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let releases = sqlx::query_as!(
|
||||
Stored,
|
||||
r#"
|
||||
SELECT r.id AS "id!: i64",
|
||||
r.size AS "size!: i64",
|
||||
r.parsed AS "parsed!: serde_json::Value",
|
||||
r.verdict,
|
||||
r.rejected_rule
|
||||
FROM releases r
|
||||
WHERE r.id IN (
|
||||
SELECT er.release_id
|
||||
FROM episode_releases er
|
||||
JOIN episodes e ON e.id = er.episode_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?
|
||||
UNION
|
||||
SELECT sr.release_id
|
||||
FROM season_releases sr
|
||||
JOIN seasons se ON se.id = sr.season_id
|
||||
WHERE se.series_id = ?
|
||||
)
|
||||
"#,
|
||||
series_id,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
|
||||
// A size band describes one episode (§5.5), so a pack's verdict needs the
|
||||
// same divisor the deck scores it with.
|
||||
let lengths = crate::series::season_lengths(state, series_id).await?;
|
||||
let runtime = crate::series::series_runtime(state, series_id).await?;
|
||||
apply(
|
||||
state,
|
||||
&releases,
|
||||
&loaded.policy,
|
||||
&loaded.overrides,
|
||||
&language,
|
||||
Some(&lengths),
|
||||
runtime,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Re-evaluate every release of every title under one root, for a
|
||||
/// `PUT /api/roots/{id}` that pointed the root at a different policy (§5.1).
|
||||
///
|
||||
/// Title by title through [`movie`] and [`series`], so the skip rules — no
|
||||
/// stored original language, the blacklist (§6.3) — stay in one place. Rows
|
||||
/// whose verdict the new policy does not move are left unwritten, so the
|
||||
/// usual case (most of a library re-evaluates to the same answer) costs
|
||||
/// reads, not writes.
|
||||
pub(crate) async fn root(state: &AppState, root_id: i64) -> Result<(), ApiError> {
|
||||
let movie_ids = sqlx::query_scalar!(
|
||||
r#"SELECT id AS "id!: i64" FROM movies WHERE root_id = ?"#,
|
||||
root_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
for movie_id in movie_ids {
|
||||
movie(state, movie_id).await?;
|
||||
}
|
||||
let series_ids = sqlx::query_scalar!(
|
||||
r#"SELECT id AS "id!: i64" FROM series WHERE root_id = ?"#,
|
||||
root_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
for series_id in series_ids {
|
||||
series(state, series_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-evaluate every release of every title under every root that points at
|
||||
/// one policy, for a `PUT /api/policies/{id}` that changed its rules (§5.1).
|
||||
///
|
||||
/// Root by root through [`root`], which is title by title through [`movie`]
|
||||
/// and [`series`]: one walker, one set of skip rules, one place that decides
|
||||
/// a row does not need rewriting.
|
||||
///
|
||||
/// This is the widest of the four re-derivations — a root repoint moves one
|
||||
/// library, a policy edit moves every library sharing the policy — but the
|
||||
/// ceiling is the whole database rather than something that grows with it,
|
||||
/// since roots partition titles and a title has exactly one root.
|
||||
pub(crate) async fn policy(state: &AppState, policy_id: i64) -> Result<(), ApiError> {
|
||||
let root_ids = sqlx::query_scalar!(
|
||||
r#"SELECT id AS "id!: i64" FROM roots WHERE policy_id = ?"#,
|
||||
policy_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
for root_id in root_ids {
|
||||
root(state, root_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn apply(
|
||||
state: &AppState,
|
||||
releases: &[Stored],
|
||||
policy: &Policy,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
season_lengths: Option<&BTreeMap<u32, u32>>,
|
||||
runtime_minutes: u32,
|
||||
) -> Result<(), ApiError> {
|
||||
for release in releases {
|
||||
if release.rejected_rule.as_deref() == Some(blacklist::RULE) {
|
||||
continue;
|
||||
}
|
||||
let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone())
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?;
|
||||
let episodes = season_lengths.map_or(1, |lengths| {
|
||||
claimed_episode_count(parsed.episode.as_ref(), lengths)
|
||||
});
|
||||
// A stored zero is a release the indexer gave no size for, and a
|
||||
// release with no size has nothing to say about its band — the same
|
||||
// reading the deck's rescore takes.
|
||||
let size = u64::try_from(release.size).ok().filter(|size| *size > 0);
|
||||
let evaluation = evaluate(
|
||||
policy,
|
||||
overrides,
|
||||
original_language,
|
||||
Candidate::PreGrab(&parsed),
|
||||
size,
|
||||
episodes,
|
||||
runtime_minutes,
|
||||
);
|
||||
// A waiver records the rule it relaxed, the same as a rejection
|
||||
// (#211). Migration 0032 relaxed `releases` to
|
||||
// `CHECK (verdict != 'rejected' OR rejected_rule IS NOT NULL)` so it
|
||||
// can, and the daemon writes waivers the same way.
|
||||
let (verdict, rule) = verdict_columns(&evaluation.verdict);
|
||||
if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule {
|
||||
continue;
|
||||
}
|
||||
sqlx::query!(
|
||||
"UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
|
||||
verdict,
|
||||
rule,
|
||||
release.id
|
||||
)
|
||||
.execute(pool(state)?)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn movie_language(state: &AppState, movie_id: i64) -> Result<Option<Language>, ApiError> {
|
||||
let stored = sqlx::query_scalar!(
|
||||
"SELECT original_language FROM movies WHERE id = ?",
|
||||
movie_id
|
||||
)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(stored.map(|value| arr_db::policy::language(&value)))
|
||||
}
|
||||
|
||||
async fn series_language(state: &AppState, series_id: i64) -> Result<Option<Language>, ApiError> {
|
||||
let stored = sqlx::query_scalar!(
|
||||
"SELECT original_language FROM series WHERE id = ?",
|
||||
series_id
|
||||
)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(stored.map(|value| arr_db::policy::language(&value)))
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
//! Moving library files when the layout under them changes: a title changing
|
||||
//! its `root_id` (issue #228), and a root changing its `path` (issue #236).
|
||||
//! Both rename §7.4 folders and rewrite the `media_files` rows to match, so
|
||||
//! the layout keeps describing the disk. This module only moves things: the
|
||||
//! stored verdicts a root change invalidates (§5.1) are re-derived by the
|
||||
//! calling handler through `reclassify`, after the row commits.
|
||||
//!
|
||||
//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this
|
||||
//! is a directory rename, never a copy. Hardlinked files keep their inodes
|
||||
//! and the torrent keeps seeding against them (§7.3).
|
||||
//!
|
||||
//! Ordering mirrors `remove_library_files`: the disk is touched before the
|
||||
//! row changes, so a failed rename leaves the title where its files actually
|
||||
//! are and the operator can retry.
|
||||
//!
|
||||
//! A root path change is the same move repeated over every title under the
|
||||
//! root, and it is all or nothing. If the seventh of ten folders fails to
|
||||
//! move, the six already renamed are moved back and the root row is left
|
||||
//! alone: a root row carries one path, so a half-moved library would have to
|
||||
//! describe both, and neither the operator nor the next import could tell
|
||||
//! which titles were where. Refusing leaves one answer — everything is still
|
||||
//! at the old path — and the retry is the same request again. A retry after
|
||||
//! an undo that itself failed still converges, because a source that is no
|
||||
//! longer on disk is skipped while its row is still rewritten.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::movies::{pool, title_target, ApiError};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Which table owns the moving title's files.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum TitleKind {
|
||||
Movie,
|
||||
Series,
|
||||
}
|
||||
|
||||
/// One rename from the old root into the new one: a §7.4 title folder, or a
|
||||
/// loose file sitting straight in the root.
|
||||
#[derive(Debug)]
|
||||
struct PlannedRename {
|
||||
source: PathBuf,
|
||||
destination: PathBuf,
|
||||
}
|
||||
|
||||
/// The renames already performed on disk and the row rewrites they imply.
|
||||
/// The database half is the caller's transaction; [`Self::undo`] is for when
|
||||
/// that transaction fails after the disk already changed.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Relocation {
|
||||
performed: Vec<PlannedRename>,
|
||||
rewrites: Vec<(i64, String)>,
|
||||
/// Every directory level this request materialised for the new root,
|
||||
/// deepest first. `create_dir_all` can make more than one — moving a
|
||||
/// root to `/mnt/media-v2/tv/kids` when `/mnt/media-v2` is all that
|
||||
/// exists creates both `tv` and `kids` — and [`Self::undo`] removes all
|
||||
/// of them, so a failed move leaves nothing behind (#244). Levels that
|
||||
/// were already on disk are never in this list and are never touched. A
|
||||
/// move that commits never calls `undo` and the directories stay.
|
||||
created_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Rename the title's folders into the new root. Called before the row is
|
||||
/// written, and only when the root actually changes.
|
||||
///
|
||||
/// A title with nothing on disk — no `media_files` rows, or rows whose
|
||||
/// targets are already gone — changes root with no filesystem work at all.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`ApiError::Conflict`] when the destination already holds an entry of the
|
||||
/// same name — a conflict, never an overwrite. [`ApiError::Filesystem`] when
|
||||
/// a rename fails; whatever had already been renamed is moved back first, so
|
||||
/// the row the caller then leaves unchanged still describes the disk.
|
||||
pub(crate) async fn relocate_title(
|
||||
state: &AppState,
|
||||
kind: TitleKind,
|
||||
title_id: i64,
|
||||
old_root_id: i64,
|
||||
new_root_id: i64,
|
||||
) -> Result<Relocation, ApiError> {
|
||||
let old_root = root_path(state, old_root_id).await?;
|
||||
let new_root = root_path(state, new_root_id).await?;
|
||||
let files = title_files(state, kind, title_id).await?;
|
||||
relocate_files(&files, &old_root, &new_root).await
|
||||
}
|
||||
|
||||
/// Rename every title folder under a root into the root's new path, for a
|
||||
/// `PUT /api/roots/{id}` that changes `path` (issue #236).
|
||||
///
|
||||
/// A root with no titles under it — or whose titles have nothing on disk —
|
||||
/// changes path with no filesystem work at all. The new path is created when
|
||||
/// there is something to move into it, since a root is normally pointed at a
|
||||
/// directory that does not exist yet.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The same two as [`relocate_title`], with the whole root's move treated as
|
||||
/// one unit: one folder that cannot move takes the entire change down and
|
||||
/// moves back whatever had already moved.
|
||||
pub(crate) async fn relocate_root(
|
||||
state: &AppState,
|
||||
root_id: i64,
|
||||
old_path: &str,
|
||||
new_path: &str,
|
||||
) -> Result<Relocation, ApiError> {
|
||||
let files = root_files(state, root_id).await?;
|
||||
relocate_files(&files, old_path, new_path).await
|
||||
}
|
||||
|
||||
/// The one mover both callers share: plan every rename, refuse every
|
||||
/// destination that already exists, then perform them, undoing what was
|
||||
/// performed if one fails.
|
||||
async fn relocate_files(
|
||||
files: &[(i64, String)],
|
||||
old_root: &str,
|
||||
new_root: &str,
|
||||
) -> Result<Relocation, ApiError> {
|
||||
let mut renames: Vec<PlannedRename> = Vec::new();
|
||||
let mut rewrites: Vec<(i64, String)> = Vec::new();
|
||||
for (file_id, path) in files {
|
||||
let Some(source) = title_target(old_root, path) else {
|
||||
// Outside its own root: not ours to move, and the row keeps
|
||||
// pointing at where the file really is.
|
||||
tracing::warn!(%path, %old_root, "media file is outside its root, not moved");
|
||||
continue;
|
||||
};
|
||||
let Some(name) = source.file_name() else {
|
||||
continue;
|
||||
};
|
||||
let destination = std::path::Path::new(new_root).join(name);
|
||||
if !renames.iter().any(|rename| rename.source == source) {
|
||||
renames.push(PlannedRename {
|
||||
source,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
let relative = std::path::Path::new(path)
|
||||
.strip_prefix(std::path::Path::new(old_root))
|
||||
.map_err(|error| ApiError::Filesystem(error.to_string()))?;
|
||||
let rewritten = std::path::Path::new(new_root).join(relative);
|
||||
let Some(rewritten) = rewritten.to_str() else {
|
||||
return Err(ApiError::Filesystem(format!(
|
||||
"non-UTF-8 path under {new_root}"
|
||||
)));
|
||||
};
|
||||
rewrites.push((*file_id, rewritten.to_owned()));
|
||||
}
|
||||
|
||||
// Every destination is checked before anything is renamed, so a conflict
|
||||
// never leaves a half-moved library behind. A rename whose source is
|
||||
// already gone is skipped here rather than refused: the perform loop below
|
||||
// skips it too, and refusing it would make the documented retry
|
||||
// impossible. After an undo that itself failed, the folder sits at the
|
||||
// destination while the row still names the source, and every later
|
||||
// attempt would 409 on a conflict with the operator's own half-moved
|
||||
// library.
|
||||
for rename in &renames {
|
||||
match tokio::fs::symlink_metadata(&rename.source).await {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
_ => {}
|
||||
}
|
||||
match tokio::fs::symlink_metadata(&rename.destination).await {
|
||||
Ok(_) => {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"the destination already has '{}'",
|
||||
rename.destination.display()
|
||||
)))
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// Tracked only for the levels this call is the one to create, so a
|
||||
// failed move can remove them again without ever touching a directory
|
||||
// that already existed on disk. Recorded before `create_dir_all`, since
|
||||
// afterwards there is no way to tell which levels it made.
|
||||
let mut created_dirs: Vec<PathBuf> = Vec::new();
|
||||
// A configured root need not exist on disk. Without creating it here,
|
||||
// `rename` reports its missing parent as the same `NotFound` as a missing
|
||||
// source and sends the operator looking at the wrong end of the move.
|
||||
if !renames.is_empty() {
|
||||
created_dirs = missing_levels(std::path::Path::new(new_root)).await;
|
||||
if let Err(error) = tokio::fs::create_dir_all(new_root).await {
|
||||
return Err(ApiError::Filesystem(format!(
|
||||
"could not create '{new_root}': {error}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut performed: Vec<PlannedRename> = Vec::new();
|
||||
for rename in renames {
|
||||
// A recorded file with nothing on disk: the rows still follow the
|
||||
// title, the same way a delete treats already-gone as done. Checked
|
||||
// on the source, so a missing *destination* parent stays an error.
|
||||
match tokio::fs::symlink_metadata(&rename.source).await {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::warn!(
|
||||
source = %rename.source.display(),
|
||||
"nothing on disk to move for this title"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(failed(&rename, &error, performed, created_dirs).await),
|
||||
}
|
||||
match tokio::fs::rename(&rename.source, &rename.destination).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
source = %rename.source.display(),
|
||||
destination = %rename.destination.display(),
|
||||
"moved a title folder"
|
||||
);
|
||||
performed.push(rename);
|
||||
}
|
||||
Err(error) => return Err(failed(&rename, &error, performed, created_dirs).await),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Relocation {
|
||||
performed,
|
||||
rewrites,
|
||||
created_dirs,
|
||||
})
|
||||
}
|
||||
|
||||
/// The levels of `path` that are not on disk, deepest first — exactly what a
|
||||
/// following `create_dir_all` will materialise. The walk stops at the first
|
||||
/// ancestor that exists, so nothing already there is ever listed.
|
||||
async fn missing_levels(path: &std::path::Path) -> Vec<PathBuf> {
|
||||
let mut missing = Vec::new();
|
||||
for ancestor in path.ancestors() {
|
||||
// `ancestors` ends in an empty path for a relative input; there is
|
||||
// no level above that to create.
|
||||
if ancestor.as_os_str().is_empty() || tokio::fs::symlink_metadata(ancestor).await.is_ok() {
|
||||
break;
|
||||
}
|
||||
missing.push(ancestor.to_path_buf());
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// One rename failed: move back everything that had already moved, remove
|
||||
/// every directory level this request created, and name the folder that
|
||||
/// stopped the move so the operator knows which title to look at before
|
||||
/// retrying.
|
||||
async fn failed(
|
||||
rename: &PlannedRename,
|
||||
error: &std::io::Error,
|
||||
performed: Vec<PlannedRename>,
|
||||
created_dirs: Vec<PathBuf>,
|
||||
) -> ApiError {
|
||||
Relocation {
|
||||
performed,
|
||||
rewrites: Vec::new(),
|
||||
created_dirs,
|
||||
}
|
||||
.undo()
|
||||
.await;
|
||||
// Name both ends. `rename` returns NotFound for a missing destination
|
||||
// parent as readily as for a missing source, and naming only the source
|
||||
// sent an operator looking for a file that was sitting where they left it.
|
||||
ApiError::Filesystem(format!(
|
||||
"could not move '{}' to '{}': {error}",
|
||||
rename.source.display(),
|
||||
rename.destination.display()
|
||||
))
|
||||
}
|
||||
|
||||
impl Relocation {
|
||||
/// Whether anything on disk actually moved — the trigger for the same
|
||||
/// single Jellyfin refresh import performs (§7.5).
|
||||
pub(crate) fn moved_files(&self) -> bool {
|
||||
!self.performed.is_empty()
|
||||
}
|
||||
|
||||
/// Point the `media_files` rows at the new root, inside the caller's
|
||||
/// transaction so they land together with the `root_id` change or not at
|
||||
/// all.
|
||||
pub(crate) async fn rewrite_rows(
|
||||
&self,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
for (file_id, path) in &self.rewrites {
|
||||
sqlx::query!(
|
||||
"UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||
path,
|
||||
file_id
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort reversal of the renames, for when the disk moved but the
|
||||
/// database write failed. A reversal that itself fails is logged: at that
|
||||
/// point the operator's retry is the recovery path.
|
||||
pub(crate) async fn undo(&self) {
|
||||
for rename in self.performed.iter().rev() {
|
||||
if let Err(error) = tokio::fs::rename(&rename.destination, &rename.source).await {
|
||||
tracing::error!(
|
||||
source = %rename.source.display(),
|
||||
destination = %rename.destination.display(),
|
||||
%error,
|
||||
"could not move the title folder back after a failed root change"
|
||||
);
|
||||
}
|
||||
}
|
||||
// `remove_dir` rather than `remove_dir_all`: it only succeeds on an
|
||||
// empty directory, so anything unexpected left inside it — this
|
||||
// request's own undo failing, say — is a reason to leave it alone.
|
||||
// Deepest first, since a parent cannot go while its child is there;
|
||||
// the first level that will not go stops the walk, because every
|
||||
// level above it now has content and refusing is the right answer.
|
||||
for directory in &self.created_dirs {
|
||||
if let Err(error) = tokio::fs::remove_dir(directory).await {
|
||||
tracing::warn!(
|
||||
path = %directory.display(),
|
||||
%error,
|
||||
"could not remove a directory created for a move that did not complete"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// §7.5 after a move: the same single best-effort refresh import performs.
|
||||
/// Failure logs and never fails the write that already committed.
|
||||
pub(crate) async fn refresh_jellyfin(state: &AppState) {
|
||||
if let Some(jellyfin) = state.jellyfin() {
|
||||
if let Err(error) = jellyfin.refresh().await {
|
||||
tracing::warn!(%error, "jellyfin refresh after a root change failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
|
||||
Ok(sqlx::query_scalar!(
|
||||
r#"SELECT path AS "path!: String" FROM roots WHERE id = ?"#,
|
||||
root_id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Every file the service recorded under a root: the rows of every movie in
|
||||
/// it, and the rows of every episode of every series in it. Ordered so the
|
||||
/// renames happen in a stable order, which is what makes a failure part-way
|
||||
/// through reproducible.
|
||||
async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>, ApiError> {
|
||||
let mut files: Vec<(i64, String)> = sqlx::query!(
|
||||
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||
FROM media_files mf
|
||||
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
|
||||
WHERE m.root_id = ?
|
||||
ORDER BY mf.id"#,
|
||||
root_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| (row.id, row.path))
|
||||
.collect();
|
||||
files.extend(
|
||||
sqlx::query!(
|
||||
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||
FROM media_files mf
|
||||
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series s ON s.id = se.series_id
|
||||
WHERE s.root_id = ?
|
||||
ORDER BY mf.id"#,
|
||||
root_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| (row.id, row.path)),
|
||||
);
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Every file the service recorded for the title: a movie's own rows, or the
|
||||
/// rows of every episode below a series.
|
||||
async fn title_files(
|
||||
state: &AppState,
|
||||
kind: TitleKind,
|
||||
title_id: i64,
|
||||
) -> Result<Vec<(i64, String)>, ApiError> {
|
||||
Ok(match kind {
|
||||
TitleKind::Movie => sqlx::query!(
|
||||
r#"SELECT id AS "id!: i64", path AS "path!: String"
|
||||
FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#,
|
||||
title_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| (row.id, row.path))
|
||||
.collect(),
|
||||
TitleKind::Series => sqlx::query!(
|
||||
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
|
||||
FROM media_files mf
|
||||
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?"#,
|
||||
title_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| (row.id, row.path))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
+1028
-25
File diff suppressed because it is too large
Load Diff
@@ -156,6 +156,10 @@ pub struct ClassifiedRelease {
|
||||
pub score_terms: ScoreTerms,
|
||||
pub verdict: String,
|
||||
pub rule: Option<String>,
|
||||
/// What the blacklist recorded this release as failing on (#227, §6.3),
|
||||
/// when `rule` is `blacklisted`. `None` on every other row. A policy
|
||||
/// rejection and a bad release both read as `blacklisted` without it.
|
||||
pub blacklist_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -490,6 +494,7 @@ async fn movie_releases(
|
||||
&original_language,
|
||||
&blacklist,
|
||||
None,
|
||||
0,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -525,6 +530,7 @@ async fn episode_releases(
|
||||
.await?
|
||||
.ok_or(ApiError::EpisodeNotFound)?;
|
||||
let season_lengths = crate::series::season_lengths(state, episode.series_id).await?;
|
||||
let runtime_minutes = crate::series::series_runtime(state, episode.series_id).await?;
|
||||
let loaded = database
|
||||
.episode_policy(episode_id)
|
||||
.await
|
||||
@@ -568,6 +574,7 @@ async fn episode_releases(
|
||||
&original_language,
|
||||
&blacklist,
|
||||
Some(&season_lengths),
|
||||
runtime_minutes,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -709,6 +716,7 @@ fn classify(
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
season_lengths: Option<&BTreeMap<u32, u32>>,
|
||||
runtime_minutes: u32,
|
||||
) -> Result<ClassifiedRelease, ApiError> {
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
|
||||
@@ -724,11 +732,17 @@ fn classify(
|
||||
Candidate::PreGrab(&parsed),
|
||||
release.size,
|
||||
episodes,
|
||||
runtime_minutes,
|
||||
);
|
||||
// #227: the reason the blacklist holds is what tells a policy rejection
|
||||
// the operator can relax from a release that should never be retried.
|
||||
let blacklist_reason = blacklist
|
||||
.reason_for_candidate(&release.name, &release.download_url)
|
||||
.map(str::to_owned);
|
||||
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
|
||||
("rejected", Some(blacklist::RULE.to_owned()))
|
||||
} else {
|
||||
verdict(&evaluation.verdict)
|
||||
verdict_columns(&evaluation.verdict)
|
||||
};
|
||||
let score = score(
|
||||
policy,
|
||||
@@ -736,6 +750,7 @@ fn classify(
|
||||
release.size.unwrap_or_default(),
|
||||
release.seeders.unwrap_or_default(),
|
||||
episodes,
|
||||
runtime_minutes,
|
||||
);
|
||||
// A release with no size has nothing to say about its size band, so that
|
||||
// term is dropped rather than scored as if it were at the floor. Every
|
||||
@@ -775,10 +790,11 @@ fn classify(
|
||||
score_terms: terms,
|
||||
verdict: verdict.to_owned(),
|
||||
rule,
|
||||
blacklist_reason,
|
||||
})
|
||||
}
|
||||
|
||||
fn verdict(verdict: &Verdict) -> (&'static str, Option<String>) {
|
||||
pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
|
||||
match verdict {
|
||||
Verdict::Eligible => ("eligible", None),
|
||||
Verdict::Waived(rule) => ("waived", Some(rule_name(rule))),
|
||||
@@ -1465,6 +1481,7 @@ mod tests {
|
||||
&Language::Other("en".into()),
|
||||
&Blacklist::default(),
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.expect("classified release")
|
||||
};
|
||||
@@ -1558,7 +1575,7 @@ mod tests {
|
||||
imdb_id: None,
|
||||
};
|
||||
let parsed = arr_parse::parse(&release.name);
|
||||
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1);
|
||||
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1, 0);
|
||||
|
||||
let classified = classify(
|
||||
release,
|
||||
@@ -1567,6 +1584,7 @@ mod tests {
|
||||
&Language::Other("en".into()),
|
||||
&Blacklist::default(),
|
||||
None,
|
||||
0,
|
||||
)
|
||||
.expect("classified release");
|
||||
|
||||
|
||||
+644
-19
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -184,10 +190,10 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
|
||||
};
|
||||
if object
|
||||
.keys()
|
||||
.any(|key| key != "only_4k" && key != "allow_english_audio")
|
||||
.any(|key| key != "only_4k" && key != "allow_english_audio" && key != "allow_below_floor")
|
||||
{
|
||||
return Err(ApiError::Invalid(
|
||||
"overrides supports only only_4k and allow_english_audio".into(),
|
||||
"overrides supports only only_4k, allow_english_audio and allow_below_floor".into(),
|
||||
));
|
||||
}
|
||||
if object.values().any(|value| !value.is_boolean()) {
|
||||
@@ -468,11 +474,15 @@ pub async fn create(
|
||||
let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone());
|
||||
let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone());
|
||||
let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average);
|
||||
let runtime_minutes = tmdb_series
|
||||
.as_ref()
|
||||
.and_then(|s| s.episode_runtime)
|
||||
.map(i64::from);
|
||||
let result = sqlx::query!(
|
||||
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
|
||||
input.auto_track, input.upstream_ended, input.blocked, overrides,
|
||||
poster_path, backdrop_path, vote_average,
|
||||
poster_path, backdrop_path, vote_average, runtime_minutes,
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
@@ -561,8 +571,11 @@ pub async fn update(
|
||||
if title.trim().is_empty() {
|
||||
return Err(ApiError::Invalid("title cannot be empty".into()));
|
||||
}
|
||||
let overrides = input.overrides.unwrap_or(current.overrides);
|
||||
let overrides = input.overrides.unwrap_or(current.overrides.clone());
|
||||
validate_overrides(&overrides)?;
|
||||
// Verdicts already stored for this series were reached under the old
|
||||
// overrides; §9.3's deck and the grab gate both read them (`reclassify`).
|
||||
let overrides_changed = overrides != current.overrides;
|
||||
let overrides =
|
||||
serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?;
|
||||
let title = title.trim();
|
||||
@@ -575,9 +588,57 @@ pub async fn update(
|
||||
let auto_track = input.auto_track.unwrap_or(current.auto_track);
|
||||
let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended);
|
||||
let blocked = input.blocked.unwrap_or(current.blocked);
|
||||
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
// A root change moves the §7.4 title folder with the row (issue #228).
|
||||
// Disk first, row second: a failed rename leaves the row alone, so the
|
||||
// operator sees the title where its files actually are and can retry —
|
||||
// the same ordering `remove_library_files` documents.
|
||||
let relocation = if root_id == current.root_id {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
crate::relocate::relocate_title(
|
||||
&state,
|
||||
crate::relocate::TitleKind::Series,
|
||||
id,
|
||||
current.root_id,
|
||||
root_id,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
};
|
||||
let mut transaction = pool(&state)?.begin().await?;
|
||||
let written: Result<(), sqlx::Error> = async {
|
||||
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
if let Some(relocation) = &relocation {
|
||||
relocation.rewrite_rows(&mut transaction).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let committed = match written {
|
||||
Ok(()) => transaction.commit().await.map_err(ApiError::from),
|
||||
Err(error) => Err(ApiError::from(error)),
|
||||
};
|
||||
if let Err(error) = committed {
|
||||
if let Some(relocation) = &relocation {
|
||||
relocation.undo().await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if relocation
|
||||
.as_ref()
|
||||
.is_some_and(crate::relocate::Relocation::moved_files)
|
||||
{
|
||||
crate::relocate::refresh_jellyfin(&state).await;
|
||||
}
|
||||
// A root change swaps the effective policy the same way an overrides
|
||||
// edit does (§5.1), so both re-derive; `relocation` is `Some` exactly
|
||||
// when the root changed.
|
||||
if overrides_changed || relocation.is_some() {
|
||||
crate::reclassify::series(&state, id).await?;
|
||||
}
|
||||
Ok(Json(load_series(&state, id).await?))
|
||||
}
|
||||
|
||||
@@ -684,7 +745,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
|
||||
Ok(metadata) => metadata,
|
||||
// Already gone is the state we wanted.
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||
};
|
||||
let removed = if metadata.is_dir() {
|
||||
tokio::fs::remove_dir_all(&target).await
|
||||
@@ -694,7 +755,7 @@ async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(),
|
||||
match removed {
|
||||
Ok(()) => tracing::info!(target = %target.display(), "removed library files"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
|
||||
Err(error) => return Err(ApiError::Filesystem(format!("files not removed: {error}"))),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1030,6 +1091,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 {
|
||||
@@ -1038,6 +1101,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)
|
||||
@@ -1407,7 +1471,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
|
||||
@@ -1419,7 +1483,9 @@ pub async fn episode_releases(
|
||||
.ok_or(ApiError::EpisodeNotFound)?
|
||||
.policy;
|
||||
let lengths = season_lengths(&state, episode.series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths))?;
|
||||
let runtime = series_runtime(&state, episode.series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
||||
attach_blacklist_reasons(pool(&state)?, &mut releases).await?;
|
||||
Ok(Json(releases))
|
||||
}
|
||||
|
||||
@@ -1458,6 +1524,19 @@ pub async fn grab_episode(
|
||||
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
|
||||
/// revealed episodes counts zero, which `claimed_episode_count` treats as
|
||||
/// unknown.
|
||||
/// The series' minutes per episode (`DESIGN.md` §5.5): the scale factor
|
||||
/// behind runtime-scaled size bands. Zero when unknown, which applies the
|
||||
/// bands unscaled.
|
||||
pub(crate) async fn series_runtime(state: &AppState, series_id: i64) -> Result<u32, ApiError> {
|
||||
let minutes = sqlx::query_scalar!("SELECT runtime_minutes FROM series WHERE id = ?", series_id)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(minutes
|
||||
.and_then(|minutes| u32::try_from(minutes).ok())
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub(crate) async fn season_lengths(
|
||||
state: &AppState,
|
||||
series_id: i64,
|
||||
@@ -1548,7 +1627,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
|
||||
@@ -1560,10 +1639,94 @@ pub async fn season_releases(
|
||||
.ok_or(ApiError::SeasonNotFound)?
|
||||
.policy;
|
||||
let lengths = season_lengths(&state, series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths))?;
|
||||
let runtime = series_runtime(&state, series_id).await?;
|
||||
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
|
||||
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")]
|
||||
@@ -1605,6 +1768,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(
|
||||
@@ -1648,9 +1816,12 @@ pub async fn season_pack_state(
|
||||
.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.
|
||||
// `last_failed_at` really is the failure time (#245): `reopens_at` and
|
||||
// `pack_retry_at` below hand it to the deck, so an alias holding a grab
|
||||
// time would put a grab under a name §5.7 gave to something else.
|
||||
let failed = sqlx::query!(
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(grabbed_at) AS "last_failed_at?: String"
|
||||
r#"SELECT count(*) AS "failures!: i64",
|
||||
max(coalesce(failed_at, grabbed_at)) AS "last_failed_at?: String"
|
||||
FROM grabs
|
||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||
season_id
|
||||
@@ -1696,6 +1867,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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1915,6 +2089,321 @@ 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
|
||||
/// rejections into waivers — offered, never automatic.
|
||||
#[tokio::test]
|
||||
async fn allow_below_floor_turns_a_size_rejected_pack_into_a_waiver() {
|
||||
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, 9, serde_json::json!(episodes)).await;
|
||||
let season_id = season["id"].as_i64().expect("season id");
|
||||
|
||||
// Ten episodes in 8 GiB: 0.8 GiB each, under the 1 GiB 1080p floor.
|
||||
let pool = state.database().expect("database").pool();
|
||||
let name = "Bluey S09 1080p WEB-DL x264-GROUP";
|
||||
let parsed = arr_parse::parse(name);
|
||||
let size = 8_i64 * (1 << 30);
|
||||
let release_id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule)
|
||||
VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'rejected', 'size') RETURNING id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(size)
|
||||
.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");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"overrides": {"allow_below_floor": true}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("override");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{:?}",
|
||||
response.text().await
|
||||
);
|
||||
let updated: serde_json::Value = reqwest::get(format!("{base}/api/series/{series_id}"))
|
||||
.await
|
||||
.expect("series")
|
||||
.json()
|
||||
.await
|
||||
.expect("series json");
|
||||
assert_eq!(updated["overrides"]["allow_below_floor"], true);
|
||||
|
||||
let releases: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("releases json");
|
||||
assert_eq!(releases.len(), 1);
|
||||
assert_eq!(
|
||||
releases[0]["verdict"], "waived",
|
||||
"a waived grab stays a waiver; nothing here makes it eligible"
|
||||
);
|
||||
// #211: the waiver keeps the rule it relaxed, so the deck names it
|
||||
// the way it names a rejection (§9.3) instead of showing a bare
|
||||
// `waived`.
|
||||
assert_eq!(releases[0]["rejected_rule"], "size");
|
||||
|
||||
// The grab the deck's one click sends is now accepted.
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{base}/api/series/{series_id}/seasons/9/releases/{release_id}/grab"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("grab");
|
||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||
|
||||
// And clearing the override puts the floor back.
|
||||
reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"overrides": {}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("clear override");
|
||||
let releases: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("releases json");
|
||||
assert_eq!(releases[0]["verdict"], "rejected");
|
||||
assert_eq!(releases[0]["rejected_rule"], "size");
|
||||
}
|
||||
|
||||
/// #241: moving a series to a root with a different policy re-derives
|
||||
/// the stored verdicts of its whole deck (§5.1), season packs included.
|
||||
#[tokio::test]
|
||||
async fn moving_a_series_to_another_root_rederives_its_verdicts() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let main_root = tv_root(&state, "main").await;
|
||||
let kids_root = tv_root(&state, "kids").await;
|
||||
let series = add_series(&base, main_root, 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, 9, serde_json::json!(episodes)).await;
|
||||
let season_id = season["id"].as_i64().expect("season id");
|
||||
|
||||
// Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
|
||||
let pool = state.database().expect("database").pool();
|
||||
let name = "Bluey S09 1080p WEB-DL ENGLISH x264-GROUP";
|
||||
let parsed = arr_parse::parse(name);
|
||||
let size = 15_i64 * (1 << 30);
|
||||
let release_id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
||||
VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'eligible') RETURNING id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(size)
|
||||
.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");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"root_id": kids_root}))
|
||||
.send()
|
||||
.await
|
||||
.expect("move root");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let releases: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("releases json");
|
||||
assert_eq!(releases.len(), 1);
|
||||
assert_eq!(
|
||||
releases[0]["verdict"], "waived",
|
||||
"kids requires pt-PT, English is a soft fail: {releases:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overrides_reject_a_key_the_policy_engine_has_no_rule_for() {
|
||||
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 response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"overrides": {"allow_anything": true}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("override");
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
/// §9.6: the three stored artwork fields come off the series detail
|
||||
/// response at add time — the same call that used to fetch only the
|
||||
/// TVDB id — so a series added today has a poster before tomorrow's
|
||||
@@ -2640,8 +3129,11 @@ mod tests {
|
||||
.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'))",
|
||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state,
|
||||
grabbed_at, failed_at)
|
||||
VALUES (?, 'season', ?, 'hash', 'failed',
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(season_id)
|
||||
@@ -2657,6 +3149,26 @@ mod tests {
|
||||
"the deck offers a date, not just a closed door"
|
||||
);
|
||||
|
||||
// §6.2/#245: push the grab five weeks back and leave the failure
|
||||
// where it is. The deck reads the failure, so the window it names is
|
||||
// unmoved — anchored on the grab it would have expired long ago and
|
||||
// the deck would claim the lane was open.
|
||||
sqlx::query(
|
||||
"UPDATE grabs SET grabbed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-35 days')
|
||||
WHERE target_kind = 'season' AND target_id = ?",
|
||||
)
|
||||
.bind(season_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("age the grab");
|
||||
let stalled = state_of(url.clone()).await;
|
||||
assert_eq!(stalled["lane"], "per_episode");
|
||||
assert_eq!(stalled["reason"], "pack_backoff");
|
||||
assert_eq!(
|
||||
stalled["pack_retry_at"], quiet["pack_retry_at"],
|
||||
"the window is anchored on the failure, so aging the grab moves nothing"
|
||||
);
|
||||
|
||||
// §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)")
|
||||
@@ -3704,6 +4216,119 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #228: changing a series' root renames its §7.4 title folder —
|
||||
/// season subfolders inside it — into the new root, and every episode's
|
||||
/// `media_files` row follows.
|
||||
#[tokio::test]
|
||||
async fn changing_root_moves_the_series_folder_and_its_rows() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let main_root = tv_root(&state, "main").await;
|
||||
let kids_root = tv_root(&state, "kids").await;
|
||||
let series = add_series(&base, main_root, 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": "Magic Xylophone" }]),
|
||||
)
|
||||
.await;
|
||||
let season_id = season["id"].as_i64().expect("season id");
|
||||
let episode_id: i64 =
|
||||
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
|
||||
.bind(season_id)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("episode id");
|
||||
|
||||
let source = tempfile::tempdir().expect("source root");
|
||||
let destination = tempfile::tempdir().expect("destination root");
|
||||
let folder = source.path().join("Bluey (2018) [tmdbid-82728]");
|
||||
let episode_file = folder.join("Season 01").join("Bluey (2018) - S01E01.mkv");
|
||||
tokio::fs::create_dir_all(folder.join("Season 01"))
|
||||
.await
|
||||
.expect("create season folder");
|
||||
tokio::fs::write(&episode_file, b"episode")
|
||||
.await
|
||||
.expect("write episode");
|
||||
let pool = state.database().expect("database").pool();
|
||||
for (root, path) in [(main_root, source.path()), (kids_root, destination.path())] {
|
||||
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
||||
.bind(path.to_str().expect("utf-8 root"))
|
||||
.bind(root)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("point the root at the tempdir");
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)",
|
||||
)
|
||||
.bind(episode_id)
|
||||
.bind(episode_file.to_str().expect("utf-8 path"))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("media file");
|
||||
|
||||
// The wrong kind of root is still rejected, before anything moves.
|
||||
let rejected = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"root_id": 1}))
|
||||
.send()
|
||||
.await
|
||||
.expect("move to a movie root");
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let updated: serde_json::Value = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"root_id": kids_root}))
|
||||
.send()
|
||||
.await
|
||||
.expect("move root")
|
||||
.json()
|
||||
.await
|
||||
.expect("updated json");
|
||||
assert_eq!(updated["root_id"], kids_root);
|
||||
|
||||
assert!(!folder.exists(), "the folder left the old root");
|
||||
let moved = destination
|
||||
.path()
|
||||
.join("Bluey (2018) [tmdbid-82728]")
|
||||
.join("Season 01")
|
||||
.join("Bluey (2018) - S01E01.mkv");
|
||||
assert!(moved.exists(), "the episode arrived, season folder intact");
|
||||
|
||||
let path: String = sqlx::query_scalar(
|
||||
"SELECT path FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
|
||||
)
|
||||
.bind(episode_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("media file row");
|
||||
assert_eq!(path, moved.to_str().expect("utf-8 path").to_owned());
|
||||
}
|
||||
|
||||
/// Issue #228: a series with nothing on disk changes root with no
|
||||
/// filesystem work at all.
|
||||
#[tokio::test]
|
||||
async fn a_series_with_no_files_changes_root_cleanly() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let main_root = tv_root(&state, "main").await;
|
||||
let kids_root = tv_root(&state, "kids").await;
|
||||
let series = add_series(&base, main_root, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
|
||||
let updated: serde_json::Value = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"root_id": kids_root}))
|
||||
.send()
|
||||
.await
|
||||
.expect("move root")
|
||||
.json()
|
||||
.await
|
||||
.expect("updated json");
|
||||
assert_eq!(updated["root_id"], kids_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_dates_parse_as_dates_and_as_timestamps() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user