fix(api): reclassify on root and policy changes

Moving a title to a root with a different policy, and pointing a root
at a different policy via PUT /api/roots/{id}, both changed the
effective policy without re-deriving stored verdicts — which §9.3's
deck and the daemon's manual-grab gate read. Both now run the same
reclassify the overrides path uses, inline in the request; §5.1 states
the contract, and relocate.rs no longer claims the move alone makes
the policy apply.

PUT /api/policies/{id} has the same gap one level up; noted on #241
for its own issue.

Closes #241

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-25 11:17:33 +01:00
parent 2a58a103d3
commit 43e65514ed
8 changed files with 429 additions and 6 deletions
+67 -1
View File
@@ -475,7 +475,10 @@ pub async fn update(
{
crate::relocate::refresh_jellyfin(&state).await;
}
if overrides_changed {
// 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::movie(&state, id).await?;
}
Ok(Json(load_movie(&state, id).await?))
@@ -1302,6 +1305,69 @@ mod tests {
assert_eq!(releases[0]["verdict"], "waived");
}
/// #241: moving a title to a root with a different policy re-derives its
/// stored verdicts (§5.1) — an English-audio release a `main` root found
/// eligible is only a waiver under the `kids` root it moved to.
#[tokio::test]
async fn moving_a_movie_to_another_root_rederives_its_verdicts() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let created: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
"original_language": "en", "root_id": 1, "overrides": {}
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
let movie_id = created["id"].as_i64().expect("id");
let name = "Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP";
let parsed = arr_parse::parse(name);
let size = 6_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, 'g', ?, ?, 40, '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 movie_releases (movie_id, release_id) VALUES (?, ?)")
.bind(movie_id)
.bind(release_id)
.execute(pool)
.await
.expect("association");
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{movie_id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root");
assert_eq!(response.status(), StatusCode::OK);
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/movies/{movie_id}/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:?}"
);
}
/// A blacklisted release (§6.3) is not a policy opinion, so no override
/// re-opens it.
#[tokio::test]
+37 -2
View File
@@ -1,10 +1,15 @@
//! Stored verdicts, re-derived when a title's overrides change (§9.3).
//! 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, or a
//! root pointed at a different policy (§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.
//! 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.
//!
//! 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
@@ -141,6 +146,36 @@ pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiEr
.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(())
}
#[allow(clippy::too_many_arguments)]
async fn apply(
state: &AppState,
+3 -2
View File
@@ -1,8 +1,9 @@
//! 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 and the root's policy (§5.1) applies
//! to a library the files are actually in.
//! 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
+191
View File
@@ -236,6 +236,11 @@ pub async fn update(
{
crate::relocate::refresh_jellyfin(&state).await;
}
// Verdicts already stored under this root were reached under the old
// policy; §9.3's deck and the grab gate both read them (`reclassify`).
if input.policy_id != current.policy_id {
crate::reclassify::root(&state, id).await?;
}
Ok(Json(load_root(&state, id).await?))
}
@@ -385,6 +390,192 @@ mod tests {
.collect()
}
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")
}
async fn stamped_eligible_release(state: &AppState, name: &str, size: i64) -> 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, 'eligible') RETURNING id",
)
.bind(name)
.bind(name)
.bind(size)
.bind(serde_json::to_string(&parsed).expect("parsed json"))
.fetch_one(state.database().expect("database").pool())
.await
.expect("release")
}
/// A movie under the movie main root, its release stamped eligible.
async fn movie_with_eligible_release(state: &AppState, base: &str) -> i64 {
let movie: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
"original_language": "en", "root_id": 1, "overrides": {}
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
let movie_id = movie["id"].as_i64().expect("movie id");
let release_id = stamped_eligible_release(
state,
"Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP",
6_i64 * (1 << 30),
)
.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
}
/// A series under the given TV root, with a season 9 pack stamped
/// eligible. Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
async fn series_with_eligible_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");
let release_id = stamped_eligible_release(
state,
"Bluey S09 1080p WEB-DL ENGLISH x264-GROUP",
15_i64 * (1 << 30),
)
.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_of(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()
}
/// #241: `PUT /api/roots/{id}` writing a different `policy_id`
/// re-derives the stored verdicts of every title under the root (§5.1)
/// — and of nothing outside it.
#[tokio::test]
async fn repointing_a_root_at_another_policy_rederives_its_library() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let client = reqwest::Client::new();
let movie_id = movie_with_eligible_release(&state, &base).await;
let tv_main: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
.fetch_one(pool)
.await
.expect("tv root");
let series_id = series_with_eligible_pack(&state, &base, tv_main).await;
// Point the movie main root at the kids policy; the path stays, so
// nothing on disk is touched.
let kids_policy = policy_id_named(&base, "Movies — kids").await;
let response = client
.put(format!("{base}/api/roots/1"))
.json(&serde_json::json!({
"kind": "movie", "audience": "main",
"path": "/mnt/media/movies/main", "policy_id": kids_policy
}))
.send()
.await
.expect("repoint movie root");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
verdict_of(format!("{base}/api/movies/{movie_id}/releases")).await,
"waived",
"kids requires pt-PT, English is a soft fail"
);
// The TV root was not repointed, so its library is untouched.
assert_eq!(
verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
"eligible"
);
// Now the TV root; the series' pack re-derives too.
let tv_kids_policy = policy_id_named(&base, "TV — kids").await;
let response = client
.put(format!("{base}/api/roots/{tv_main}"))
.json(&serde_json::json!({
"kind": "tv", "audience": "main",
"path": "/mnt/media/tv/main", "policy_id": tv_kids_policy
}))
.send()
.await
.expect("repoint tv root");
assert_eq!(
response.status(),
StatusCode::OK,
"{:?}",
response.text().await
);
assert_eq!(
verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
"waived",
"kids requires pt-PT, English is a soft fail"
);
}
#[tokio::test]
async fn a_root_round_trips_through_create_and_update() {
let (_dir, _state, base) = application().await;
+68 -1
View File
@@ -627,7 +627,10 @@ pub async fn update(
{
crate::relocate::refresh_jellyfin(&state).await;
}
if overrides_changed {
// 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?))
@@ -2043,6 +2046,70 @@ mod tests {
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;