Compare commits

..

1 Commits

Author SHA1 Message Date
Miguel Palhas ac0e80c044 feat: require two recent failures to queue a season
The season branch of the attention queue listed a season on one failed
grab of any age, so `GET /api/queues/attention` returned Rick and Morty
with every season it has and buried the one that needed attention.

Two changes, both stated in DESIGN.md §5.7:

- The season branch now enforces the same bar the episode branch does:
  two grabs that hard-failed on *different* releases.
- A failed grab counts toward the queue for 30 days
  (`arr_db::ATTENTION_WINDOW`). Nothing clears a `grabs` row, so without
  a window the queue only grows and can never be emptied. #181 gave the
  pack guard a backoff curve for the same reason; this is the queue's
  version of §6.2's "it never gives up entirely, it goes quiet". A
  season the operator dealt with stops failing and drops out; one still
  breaking keeps failing (the pack guard retries at worst weekly) and
  stays.

The window applies to all three hard-fail lanes — movie, episode and
season — because DESIGN.md states one rule for the queue, and to the
daemon's needs-a-decision notifier as well as the API, since both read
the same queue and a season-per-failure notification is the same noise
on a different channel. No schema change: `grabs.grabbed_at` already
carries the timestamp.

Gate: `just ci` green (486 tests).
2026-08-25 10:09:07 +01:00
23 changed files with 353 additions and 853 deletions
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.title, s.year, se.id\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.title, s.year, se.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -58,5 +58,5 @@
false
]
},
"hash": "4ddb143ab51ca61ac782f22cff84f01f7d58d8a424310ae0743fb0c91577665e"
"hash": "1a2660bb8b6ac22352a2c8262423151629f0b0870cad7a4462f21013ac43606e"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6"
}
@@ -1,38 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\"\n FROM grabs g\n JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -71,7 +71,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -82,5 +82,5 @@
false
]
},
"hash": "7930e2d10b25627dcbf81f60a5ac077c27b647a6f0b411e13105398a2963cd51"
"hash": "44d8376cc9cdf66afb89de1374a332bfed6d33927db2f0992e2e1b793ee99b42"
}
@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT path AS \"path!: String\" FROM roots WHERE id = ?",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953"
}
@@ -1,38 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\",\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -82,7 +82,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -94,5 +94,5 @@
false
]
},
"hash": "aaafc2e7577fad8be202f0d27e16e5f88ffa4644999dee42af3e387dd2cf8702"
"hash": "a8beee4a6c6f00a299cb6c6ec1bb2a4ef2613b8b57366fc7f2fbc56be29ce72c"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", title AS \"title!: String\", year, original_language, root_id AS \"root_id!: i64\", wanted AS \"wanted!: bool\", overrides AS \"overrides!: serde_json::Value\", state AS \"state!: String\", blocked AS \"blocked!: bool\", search_attempts AS \"search_attempts!: i64\", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS \"waiver?: serde_json::Value\" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title",
"describe": {
"columns": [
{
@@ -170,7 +170,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -190,5 +190,5 @@
true
]
},
"hash": "9e5df0da99c02d3bd2f9235bb53f1caac0b1b104ed78f34799f494d85d1eccc2"
"hash": "b35fe903d45f1c3ec7a963aac599331f602ea73b2e860a623b79ae435beca614"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed') >= 2\n ",
"query": "\n SELECT id AS \"id!: i64\", title AS \"title!: String\", year\n FROM movies\n WHERE movies.wanted = 1 AND movies.state != 'available'\n AND (SELECT count(DISTINCT g.release_id)\n FROM grabs g\n WHERE g.target_kind = 'movie' AND g.target_id = movies.id\n AND g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2\n ",
"describe": {
"columns": [
{
@@ -38,7 +38,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -46,5 +46,5 @@
true
]
},
"hash": "91d1ee1e8e206569b57d2a699228139d45cb658b94103f677dfa49dcd9f0e07d"
"hash": "ce36aacf193f285f8636f94e30295c1434a65467e2ab70efddb0423cde1829be"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"query": "\n SELECT s.id AS \"series_id!: i64\", s.title AS \"title!: String\", s.year,\n g.target_id AS \"episode_id!: i64\"\n FROM grabs g\n JOIN episodes e ON g.target_kind = 'episode' AND e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE g.state = 'failed'\n AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)\n AND e.wanted = 1 AND e.state != 'available'\n GROUP BY s.id, s.title, s.year, e.id\n HAVING count(DISTINCT g.release_id) >= 2\n ",
"describe": {
"columns": [
{
@@ -49,7 +49,7 @@
}
],
"parameters": {
"Right": 0
"Right": 1
},
"nullable": [
false,
@@ -58,5 +58,5 @@
false
]
},
"hash": "1878841679d1664139dfedffae9d97ed1764321d76022ff55684969be0171cb6"
"hash": "edebdc35904d3622fb6f28f9282d0d14dab165130719a46bd371cc3b9b135d86"
}
Generated
-1
View File
@@ -42,7 +42,6 @@ dependencies = [
"serde_json",
"sqlx",
"tempfile",
"thiserror",
"tokio",
"tracing",
"utoipa",
+17 -2
View File
@@ -378,6 +378,23 @@ A policy violation found by `ffprobe` is not one thing.
Neither deletes the torrent. See §7.3.
**Two hard failures make a decision, and only for 30 days.** A movie, an
episode or a season enters the needs-a-decision queue (§9.5) when two grabs
against *different* releases hard-failed on it, and both of those failures
happened within the last 30 days. One bad torrent is not a decision — a
release that hard-failed is blacklisted (§6.3) and the next candidate is
grabbed, which is the system working.
The window is what lets the queue be emptied. Nothing clears a `grabs` row, so
without it the queue only ever grows and the one season that wants attention
sits behind eight that were dealt with months ago. It is the queue's version of
§6.2's "it never gives up entirely, it goes quiet": a target the operator has
dealt with stops producing failures and drops out once the last one ages past
30 days, while a target that is still broken keeps producing them — the pack
guard retries at worst weekly (§6.2) — and stays queued for exactly as long as
it is genuinely broken. Nothing is dismissed by hand and no acknowledgement
state is stored, so there is no second thing to keep correct.
## 6. Sourcing
### 6.1 Prowlarr, per-indexer Torznab
@@ -488,8 +505,6 @@ Media kind first, hard audience boundary second, people nowhere.
- **Release group is deliberately absent.** It is not a selection criterion and
it makes filenames long enough to break a terminal.
Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy.
During transition, write into the existing roots so Jellyfin needs no
reconfiguration and new content appears immediately. Radarr will not touch a
folder it has no record of.
-1
View File
@@ -18,7 +18,6 @@ reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
utoipa = { workspace = true }
-2
View File
@@ -7,13 +7,11 @@
//! to compile, and the gate in DESIGN.md §12 fails with it.
mod health;
pub mod jellyfin;
mod metadata;
mod movies;
mod owners;
mod policies;
mod reclassify;
mod relocate;
mod roots;
mod search;
mod series;
+175 -241
View File
@@ -430,51 +430,9 @@ pub async fn update(
}
let wanted = input.wanted.unwrap_or(current.wanted);
let blocked = input.blocked.unwrap_or(current.blocked);
// 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::Movie,
id,
current.root_id,
root_id,
)
.await?,
)
};
let mut transaction = pool(&state)?.begin().await?;
let written: Result<(), sqlx::Error> = async {
sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, 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;
}
sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id)
.execute(pool(&state)?)
.await?;
if overrides_changed {
crate::reclassify::movie(&state, id).await?;
}
@@ -782,7 +740,7 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
let no_pt_source = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE id IN (SELECT m.id FROM movies m JOIN roots root ON root.id = m.root_id WHERE root.audience = 'kids' AND m.wanted = 1 AND m.blocked = 0 AND m.state = 'missing' AND m.search_attempts > 0 AND NOT EXISTS (SELECT 1 FROM movie_releases mr JOIN releases r ON r.id = mr.release_id WHERE mr.movie_id = m.id AND r.verdict IN ('eligible', 'waived'))) ORDER BY title"#)
.fetch_all(pool(&state)?)
.await?;
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed') >= 2 ORDER BY title"#)
let needs_decision = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE (SELECT count(DISTINCT g.release_id) FROM grabs g WHERE g.target_kind = 'movie' AND g.target_id = movies.id AND g.state = 'failed' AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2 ORDER BY title"#, arr_db::ATTENTION_WINDOW)
.fetch_all(pool(&state)?)
.await?;
let (tv_no_pt_source, tv_needs_decision) = tv_attention(&state).await?;
@@ -798,6 +756,11 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
/// The TV lanes of the attention queues (§9.5): one entry per series with
/// the episodes and seasons that put it there. The two hard-fail conditions
/// share a lane; a series arriving through both is merged into one entry.
///
/// Both hard-fail branches hold to §5.7's bar: two failures on *different*
/// releases, both inside `ATTENTION_WINDOW`. One bad torrent is not a
/// decision, and a failure the operator already dealt with ages out instead
/// of sitting in the queue forever (#226).
async fn tv_attention(
state: &AppState,
) -> Result<(Vec<SeriesAttention>, Vec<SeriesAttention>), ApiError> {
@@ -837,9 +800,11 @@ async fn tv_attention(
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
GROUP BY s.id, s.tmdb_id, s.title, s.year, e.id, se.number, e.number
HAVING count(DISTINCT g.release_id) >= 2
"#
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database)
.await?;
@@ -852,8 +817,11 @@ async fn tv_attention(
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
GROUP BY s.id, s.tmdb_id, s.title, s.year, se.id, se.number
"#
HAVING count(DISTINCT g.release_id) >= 2
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database)
.await?;
@@ -1459,197 +1427,6 @@ mod tests {
);
}
/// Point one of the seeded roots at a real directory for the duration of
/// a test.
async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) {
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(path.to_str().expect("utf-8 root"))
.bind(root_id)
.execute(state.database().expect("database").pool())
.await
.expect("point the root at the tempdir");
}
/// Issue #228: changing a movie's root renames its §7.4 folder into the
/// new root — sidecars ride along inside it — and the `media_files` rows
/// follow in the same write.
#[tokio::test]
async fn changing_root_moves_the_title_folder_and_its_rows() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
point_root_at(&state, 2, destination.path()).await;
// The wrong kind of root is still rejected, before anything moves.
let rejected = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 3}))
.send()
.await
.expect("move to a tv root");
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], 2);
assert!(!folder.exists(), "the folder left the old root");
let moved = destination
.path()
.join("Dune Part Two (2024) [tmdbid-693134]");
assert!(
moved
.join("Dune Part Two (2024) [tmdbid-693134] - [2160p].mkv")
.exists(),
"the feature arrived in the new root"
);
assert!(
moved.join("dune.pt.srt").exists(),
"sidecars travel inside the folder"
);
let path: String = sqlx::query_scalar(
"SELECT path FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
)
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("media file row");
assert!(
std::path::Path::new(&path).starts_with(destination.path()),
"the row follows the file: {path}"
);
assert!(
std::path::Path::new(&path).exists(),
"the rewritten path describes the disk"
);
}
/// Issue #228: a title with nothing on disk changes root with no
/// filesystem work at all — the seeded root paths do not even exist.
#[tokio::test]
async fn a_movie_with_no_files_changes_root_cleanly() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], 2);
let root_id: i64 = sqlx::query_scalar("SELECT root_id FROM movies WHERE id = ?")
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(root_id, 2);
}
/// Issue #228: a destination already holding a folder of that name is a
/// conflict, not an overwrite — and the row stays where the files are.
#[tokio::test]
async fn a_destination_collision_is_refused_and_the_row_unchanged() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
point_root_at(&state, 2, destination.path()).await;
let squatter = destination
.path()
.join("Dune Part Two (2024) [tmdbid-693134]");
tokio::fs::create_dir_all(&squatter)
.await
.expect("pre-existing folder");
tokio::fs::write(squatter.join("theirs.mkv"), b"not ours")
.await
.expect("write squatter file");
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(folder.exists(), "the folder stayed in the old root");
assert!(
squatter.join("theirs.mkv").exists(),
"the occupant was not overwritten"
);
let (root_id, path): (i64, String) = sqlx::query_as(
"SELECT m.root_id, f.path FROM movies m
JOIN media_files f ON f.owner_kind = 'movie' AND f.owner_id = m.id
WHERE m.id = ?",
)
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(root_id, 1, "the row is unchanged");
assert!(std::path::Path::new(&path).starts_with(source.path()));
}
/// Issue #228: if the rename fails, the row must not change — the
/// operator sees the title where its files actually are and can retry.
#[tokio::test]
async fn a_failed_rename_leaves_the_row_alone() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
// A destination whose parent does not exist makes the rename itself
// fail while the collision pre-check still passes.
point_root_at(
&state,
2,
&destination.path().join("missing").join("library"),
)
.await;
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(folder.exists(), "the folder never left the old root");
let (root_id, path): (i64, String) = sqlx::query_as(
"SELECT m.root_id, f.path FROM movies m
JOIN media_files f ON f.owner_kind = 'movie' AND f.owner_id = m.id
WHERE m.id = ?",
)
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(root_id, 1, "the row is unchanged");
assert!(std::path::Path::new(&path).starts_with(source.path()));
}
#[test]
fn a_title_target_is_the_folder_directly_under_the_root() {
let root = "/mnt/media/movies/main";
@@ -1996,10 +1773,166 @@ mod tests {
);
}
/// A series with one empty season, for exercising the season lane on its
/// own.
async fn seed_bare_season(pool: &sqlx::SqlitePool, tmdb_id: i64) -> (i64, i64) {
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
.fetch_one(pool)
.await
.expect("kids tv root");
let series_id: i64 = sqlx::query(
"INSERT INTO series (tmdb_id, title, year, root_id) VALUES (?, 'Rick and Morty', 2013, ?)
RETURNING id",
)
.bind(tmdb_id)
.bind(root_id)
.fetch_one(pool)
.await
.expect("series")
.get(0);
let season_id: i64 = sqlx::query_scalar(
"INSERT INTO seasons (series_id, number) VALUES (?, 8) RETURNING id",
)
.bind(series_id)
.fetch_one(pool)
.await
.expect("season");
(series_id, season_id)
}
async fn insert_release(pool: &sqlx::SqlitePool, guid: &str) -> i64 {
sqlx::query(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (1, ?, 'release', 1, 'url', '{}', 'eligible') RETURNING id",
)
.bind(guid)
.fetch_one(pool)
.await
.expect("release")
.get(0)
}
/// A hard-failed grab, stamped `age_days` in the past so the §5.7 window
/// can be exercised without waiting a month.
async fn insert_failed_grab(
pool: &sqlx::SqlitePool,
release_id: i64,
target_kind: &str,
target_id: i64,
infohash: &str,
age_days: i64,
) {
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
)
.bind(release_id)
.bind(target_kind)
.bind(target_id)
.bind(infohash)
.bind(format!("-{age_days} days"))
.execute(pool)
.await
.expect("failed grab");
}
/// The seasons `GET /api/queues/attention` currently reports for a series.
async fn queued_seasons(base: &str, series_id: i64) -> Vec<i64> {
let queues: serde_json::Value = reqwest::get(format!("{base}/api/queues/attention"))
.await
.expect("queues")
.json()
.await
.expect("queues json");
queues["tv_needs_decision"]
.as_array()
.expect("tv lane")
.iter()
.filter(|entry| entry["series_id"] == series_id)
.flat_map(|entry| {
entry["seasons"]
.as_array()
.expect("seasons")
.iter()
.map(|season| season["id"].as_i64().expect("season id"))
})
.collect()
}
/// §5.7: one failed pack is the blacklist working, not a decision. The
/// season lane holds to the same two-distinct-releases bar the episode
/// lane does (#226).
#[tokio::test]
async fn a_season_queues_only_on_two_distinct_release_failures() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let (series_id, season_id) = seed_bare_season(pool, 1).await;
let first = insert_release(pool, "pack-one").await;
insert_failed_grab(pool, first, "season", season_id, "hash-one", 0).await;
assert!(
queued_seasons(&base, series_id).await.is_empty(),
"one failed pack is not a decision"
);
// A second failure on the *same* release is still one release.
insert_failed_grab(pool, first, "season", season_id, "hash-one-again", 0).await;
assert!(
queued_seasons(&base, series_id).await.is_empty(),
"two grabs of one release are not two releases"
);
let second = insert_release(pool, "pack-two").await;
insert_failed_grab(pool, second, "season", season_id, "hash-two", 0).await;
assert_eq!(
queued_seasons(&base, series_id).await,
vec![season_id],
"two distinct releases hard-failed: the operator decides"
);
}
/// §5.7: a failure counts for 30 days. A season the operator has dealt
/// with stops failing and leaves the queue; one still breaking keeps
/// producing failures and stays (#226).
#[tokio::test]
async fn a_season_failure_ages_out_of_the_attention_queue() {
let (_dir, state, base) = application().await;
let pool = state.database().expect("database").pool();
let (series_id, season_id) = seed_bare_season(pool, 1).await;
for (guid, hash, age) in [
("old-one", "hash-old-one", 40),
("old-two", "hash-old-two", 35),
] {
let release_id = insert_release(pool, guid).await;
insert_failed_grab(pool, release_id, "season", season_id, hash, age).await;
}
assert!(
queued_seasons(&base, series_id).await.is_empty(),
"failures older than the window are history, not attention"
);
let fresh = insert_release(pool, "new-one").await;
insert_failed_grab(pool, fresh, "season", season_id, "hash-new-one", 0).await;
assert!(
queued_seasons(&base, series_id).await.is_empty(),
"one recent failure does not revive two stale ones"
);
let fresher = insert_release(pool, "new-two").await;
insert_failed_grab(pool, fresher, "season", season_id, "hash-new-two", 0).await;
assert_eq!(
queued_seasons(&base, series_id).await,
vec![season_id],
"still breaking: back in the queue"
);
}
/// One series hitting all three §9.5 TV entry conditions: two wanted,
/// searched episodes whose every candidate was rejected for language; a
/// season pack that hard-failed; and an episode two different releases
/// hard-failed on.
/// season two different packs hard-failed on; and an episode two different
/// releases hard-failed on.
async fn seed_queued_series(pool: &sqlx::SqlitePool) -> (i64, i64, i64) {
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'kids'")
@@ -2051,6 +1984,7 @@ mod tests {
.expect("episode id");
for (kind, guid, suffix) in [
("season", "pack", "pack"),
("season", "pack-two", "pack2"),
("episode", "first", "a"),
("episode", "second", "b"),
] {
-259
View File
@@ -1,259 +0,0 @@
//! Moving a title between roots (issue #228). Changing a title's `root_id`
//! renames its §7.4 folder into the new root and rewrites the `media_files`
//! rows to match, so the layout keeps describing the disk and the new root's
//! policy (§5.1) applies to a library the files are actually in.
//!
//! 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.
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)>,
}
/// 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?;
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 title behind.
for rename in &renames {
match tokio::fs::symlink_metadata(&rename.destination).await {
Ok(_) => {
return Err(ApiError::Conflict(format!(
"the destination root already has '{}'",
rename.destination.display()
)))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
}
}
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) => {
let failed = ApiError::Filesystem(error.to_string());
Relocation {
performed,
rewrites: Vec::new(),
}
.undo()
.await;
return Err(failed);
}
}
match tokio::fs::rename(&rename.source, &rename.destination).await {
Ok(()) => {
tracing::info!(
source = %rename.source.display(),
destination = %rename.destination.display(),
"moved title folder between roots"
);
performed.push(rename);
}
Err(error) => {
let failed = ApiError::Filesystem(error.to_string());
Relocation {
performed,
rewrites: Vec::new(),
}
.undo()
.await;
return Err(failed);
}
}
}
Ok(Relocation {
performed,
rewrites,
})
}
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"
);
}
}
}
}
/// §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 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(),
})
}
+3 -158
View File
@@ -582,51 +582,9 @@ 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);
// 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;
}
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?;
if overrides_changed {
crate::reclassify::series(&state, id).await?;
}
@@ -3696,119 +3654,6 @@ 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!(
-16
View File
@@ -7,8 +7,6 @@ use std::time::Duration;
use arr_db::Db;
use tokio::sync::mpsc;
use crate::jellyfin::JellyfinClient;
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key
/// is configurable, so this is a constant that tests point elsewhere.
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
@@ -78,7 +76,6 @@ pub struct AppState {
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
metadata_commands: mpsc::Sender<MetadataCommand>,
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
jellyfin: Option<JellyfinClient>,
}
/// Work explicitly requested through the movie API.
@@ -149,7 +146,6 @@ impl AppState {
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
metadata_commands,
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)),
jellyfin: None,
})
}
@@ -160,18 +156,6 @@ impl AppState {
self
}
/// Attach the Jellyfin client, so a manual subtitle grab or translation
/// can trigger the same library refresh import does (§7.5, §15).
#[must_use]
pub fn with_jellyfin(mut self, jellyfin: JellyfinClient) -> Self {
self.jellyfin = Some(jellyfin);
self
}
pub(crate) fn jellyfin(&self) -> Option<&JellyfinClient> {
self.jellyfin.as_ref()
}
/// Wait for the next manual movie action in the daemon's reconcile loop.
///
/// # Errors
+119 -12
View File
@@ -2,6 +2,10 @@
//! the no-PT-source queue, or hard-failed twice on different releases (the
//! same queues `GET /api/queues/attention` reports, §9.3).
//!
//! §5.7 sets the bar for the hard-fail side: two failures on *different*
//! releases, both inside `arr_db::ATTENTION_WINDOW`. One bad torrent is not a
//! decision, and a failure already dealt with ages out (#226).
//!
//! Edge-triggered per title: it notifies once when the title enters either
//! queue, and is forgotten once it leaves both, so a future re-entry notifies
//! again. A series notifies as its series, never per episode — a broken
@@ -34,8 +38,8 @@ struct TvEntry {
no_pt_source: Vec<i64>,
/// Episodes two different releases hard-failed post-probe (§5.7).
hard_failed_episodes: Vec<i64>,
/// Seasons whose pack grab hard-failed, sending the season back to
/// per-episode grabbing.
/// Seasons two different pack releases hard-failed on (§5.7), sending the
/// season back to per-episode grabbing.
failed_season_packs: Vec<i64>,
}
@@ -59,8 +63,8 @@ impl TvEntry {
if !self.failed_season_packs.is_empty() {
parts.push(plural(
self.failed_season_packs.len(),
"season pack hard-failed",
"season packs hard-failed",
"season hard-failed twice on different packs",
"seasons hard-failed twice on different packs",
));
}
parts.join("; ")
@@ -134,8 +138,10 @@ impl AttentionAction {
AND (SELECT count(DISTINCT g.release_id)
FROM grabs g
WHERE g.target_kind = 'movie' AND g.target_id = movies.id
AND g.state = 'failed') >= 2
"#
AND g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)) >= 2
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -209,8 +215,8 @@ fn tv_entry(
/// TV roll-up (§9.5): every queued series with what put it there — wanted
/// episodes whose every candidate was rejected for language, episodes two
/// different releases hard-failed post-probe, and seasons whose pack grab
/// hard-failed. One entry per series, so the notification can be one per
/// different releases hard-failed post-probe, and seasons two different packs
/// hard-failed on. One entry per series, so the notification can be one per
/// series however long the broken season is.
async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntry)>, sqlx::Error> {
let mut tv = HashMap::new();
@@ -250,10 +256,12 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
AND e.wanted = 1 AND e.state != 'available'
GROUP BY s.id, s.title, s.year, e.id
HAVING count(DISTINCT g.release_id) >= 2
"#
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -271,8 +279,11 @@ async fn queue_tv(database: &Db) -> Result<Vec<(i64, String, Option<i64>, TvEntr
JOIN seasons se ON g.target_kind = 'season' AND se.id = g.target_id
JOIN series s ON s.id = se.series_id
WHERE g.state = 'failed'
AND g.grabbed_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)
GROUP BY s.id, s.title, s.year, se.id
"#
HAVING count(DISTINCT g.release_id) >= 2
"#,
arr_db::ATTENTION_WINDOW
)
.fetch_all(database.pool())
.await?;
@@ -382,6 +393,39 @@ mod tests {
series_id
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// stamped `age_days` in the past, so §5.7's window can be exercised
/// without waiting a month.
async fn insert_aged_failed_grab(
database: &Db,
target_kind: &str,
target_id: i64,
release_guid: &str,
age_days: i64,
) {
let release_id: i64 = sqlx::query_scalar(
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
VALUES (7, ?, 'release', 10737418240, 'https://tracker/x.torrent', '{}', 'eligible')
RETURNING id",
)
.bind(release_guid)
.fetch_one(database.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
VALUES (?, ?, ?, ?, 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
)
.bind(release_id)
.bind(target_kind)
.bind(target_id)
.bind(format!("hash-{release_guid}"))
.bind(format!("-{age_days} days"))
.execute(database.pool())
.await
.unwrap();
}
/// A failed grab by `release_guid` against `target_kind`/`target_id`,
/// standing in for what the import tick leaves behind post-probe.
async fn insert_failed_grab(
@@ -548,9 +592,10 @@ mod tests {
.unwrap();
insert_failed_grab(&database, "episode", episode_id, "first").await;
insert_failed_grab(&database, "episode", episode_id, "second").await;
// The pack's failure sent this season back to per-episode grabbing;
// it queues the same series, so it must not double the message.
// The packs' failures sent this season back to per-episode grabbing;
// they queue the same series, so it must not double the message.
insert_failed_grab(&database, "season", season_id, "pack").await;
insert_failed_grab(&database, "season", season_id, "pack-two").await;
let server = MockServer::start().await;
let action = action(&server).await;
@@ -631,4 +676,66 @@ mod tests {
assert_eq!(second.len(), 0, "leaves the queue once imported");
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
/// §5.7: the season lane holds to the same two-distinct-releases bar the
/// episode lane does, so one bad pack does not notify (#226).
#[tokio::test]
async fn one_failed_season_pack_does_not_notify() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_failed_grab(&database, "season", season_id, "pack").await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"one failed pack is the blacklist working, not a decision"
);
insert_failed_grab(&database, "season", season_id, "pack-two").await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"two distinct packs hard-failed: the operator decides"
);
}
/// §5.7: a failure counts for 30 days, so a season dealt with leaves the
/// queue instead of sitting in it forever (#226).
#[tokio::test]
async fn season_failures_older_than_the_window_do_not_notify() {
let (_dir, database) = seeded_database().await;
insert_no_pt_source_series(&database, 1, 0).await;
let season_id: i64 = sqlx::query_scalar("SELECT id FROM seasons WHERE number = 1")
.fetch_one(database.pool())
.await
.unwrap();
insert_aged_failed_grab(&database, "season", season_id, "old-one", 40).await;
insert_aged_failed_grab(&database, "season", season_id, "old-two", 35).await;
let server = MockServer::start().await;
let action = action(&server).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
0,
"failures older than the window are history, not attention"
);
insert_aged_failed_grab(&database, "season", season_id, "new-one", 0).await;
insert_aged_failed_grab(&database, "season", season_id, "new-two", 0).await;
assert_eq!(
action.tick(&database).await.unwrap().len(),
1,
"still breaking: back in the queue"
);
}
}
+1 -1
View File
@@ -25,9 +25,9 @@ use arr_db::Db;
use arr_dl::TransmissionClient;
use arr_probe::Prober;
use crate::jellyfin::JellyfinClient;
use crate::notify::Notifier;
use crate::reconcile::{Action, ActionFuture, Outcome};
use arr_api::jellyfin::JellyfinClient;
/// A failure during one import tick.
#[derive(Debug, thiserror::Error)]
@@ -1,9 +1,8 @@
//! Jellyfin library refresh after import or a subtitle write. See DESIGN.md
//! §7.5 and §15.
//! Jellyfin library refresh after import. See DESIGN.md §7.5.
//!
//! Jellyfin's own filesystem watcher misses a hardlinked or sidecar file, so
//! callers ask it to rescan directly. A refresh failure must not fail the
//! caller — it logs and continues (§7.5).
//! Jellyfin's own filesystem watcher misses the hardlinked file, so import
//! asks it to rescan directly. A refresh failure must not fail the import —
//! the caller logs and continues (§7.5).
use std::time::Duration;
@@ -28,9 +27,6 @@ pub struct JellyfinClient {
}
impl JellyfinClient {
/// # Errors
///
/// If the underlying HTTP client cannot be built.
pub fn new(base_url: impl Into<String>, api_key: Option<String>) -> Result<Self, Error> {
let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?;
Ok(Self {
@@ -42,10 +38,6 @@ impl JellyfinClient {
/// Trigger a full library scan. Jellyfin exposes no per-library refresh
/// without knowing that library's ID, which this app never learns.
///
/// # Errors
///
/// If the request fails, or Jellyfin answers with a non-success status.
pub async fn refresh(&self) -> Result<(), Error> {
let url = format!("{}/Library/Refresh", self.base_url.trim_end_matches('/'));
let mut request = self.client.post(url);
+7 -16
View File
@@ -6,6 +6,7 @@ mod config;
mod grab;
mod import;
mod indexers;
mod jellyfin;
mod manual;
mod metadata;
mod notify;
@@ -93,7 +94,7 @@ enum Error {
#[error("transmission client: {0}")]
Transmission(#[from] arr_dl::Error),
#[error("jellyfin client: {0}")]
Jellyfin(#[from] arr_api::jellyfin::Error),
Jellyfin(#[from] jellyfin::Error),
#[error("ntfy client: {0}")]
Notify(#[from] notify::NotifyError),
#[error("bind {addr}: {source}")]
@@ -123,7 +124,6 @@ async fn run() -> Result<(), Error> {
None
};
let notifier = Notifier::new(config.ntfy_url.clone())?;
let api_jellyfin = jellyfin_client(&config)?;
let (reconcile, manual_grab, manual_tv) =
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?;
// Issue #176: the on-demand half of the metadata lane needs its own
@@ -144,9 +144,7 @@ async fn run() -> Result<(), Error> {
if let Some(tmdb_url) = config.tmdb_url {
upstreams = upstreams.with_tmdb_url(tmdb_url);
}
let state = AppState::new(upstreams)?
.with_database(database.clone())
.with_jellyfin(api_jellyfin);
let state = AppState::new(upstreams)?.with_database(database.clone());
let app = arr_api::router(state.clone())
.merge(arr_compat::router(compat))
@@ -296,7 +294,10 @@ fn reconcile_loop(
} else {
tracing::warn!("TMDB is not configured: series metadata refresh is disabled");
}
let jellyfin = jellyfin_client(config)?;
let jellyfin = jellyfin::JellyfinClient::new(
config.jellyfin_url.clone(),
config.jellyfin_api_key.clone(),
)?;
// Grab before import, so a download that completes on this tick is
// imported on this tick.
reconcile = reconcile.register(
@@ -442,13 +443,3 @@ async fn shutdown() {
tracing::info!("shutting down");
}
/// The Jellyfin client, built fresh for each of its two independent callers:
/// import's own reconcile action, and the subtitle API's manual grab and
/// translate handlers (§7.5, §15).
fn jellyfin_client(config: &Config) -> Result<arr_api::jellyfin::JellyfinClient, Error> {
Ok(arr_api::jellyfin::JellyfinClient::new(
config.jellyfin_url.clone(),
config.jellyfin_api_key.clone(),
)?)
}
+9
View File
@@ -17,6 +17,15 @@ use sqlx::{migrate::MigrateError, SqlitePool};
/// The migrations embedded in the binary, so a deploy is one file.
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
/// §5.7: how long a failed grab keeps counting toward the needs-a-decision
/// queue, as a SQLite time modifier.
///
/// Nothing ever clears a `grabs` row, so without a bound the queue only grows
/// and the one season that wants attention sits behind the ones that do not.
/// Callers pair it with the `grabbed_at` format:
/// `strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ATTENTION_WINDOW)`.
pub const ATTENTION_WINDOW: &str = "-30 days";
/// How long a writer waits for the write lock before giving up.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);