Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac0e80c044 |
+3
-3
@@ -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"
|
||||
}
|
||||
-12
@@ -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"
|
||||
}
|
||||
-38
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
-26
@@ -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"
|
||||
}
|
||||
-38
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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
@@ -42,7 +42,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
] {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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(), ¬ifier)?;
|
||||
// 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(),
|
||||
)?)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user