feat: remove files for one season or episode
DELETE /api/series/{id}/seasons/{n}/files and
DELETE /api/episodes/{id}/files unlink what the scope covers, drop the
matching media_files rows and clear wanted, in one action. 204 on
success, 404 for an unknown season or episode, and a scope with nothing
on disk still clears intent.
Season and episode rows stay: TMDB owns that metadata and the next
refresh would recreate them.
The three scopes share one unlink path. A whole series still resolves to
its title folder (§7.4, atomic); a season or episode resolves to the
recorded file and nothing else, so a narrow call cannot reach a sibling.
The intent clear goes through arr_core::tracking::apply_tracked(false),
the same §4.1 rule #171 landed.
An episode whose file just went is set back to 'missing' when it was
'available', matching what a failed import already does. Closes #174.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "32c562fdb70cabb3265e351583b6ac1579490b29da670fe9062117451911576a"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3531a16bd038c149465397a70c24c14ed005683c68f06d322409c5c2b39b84d1"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE episodes\n SET wanted = ?,\n state = CASE WHEN state = 'available' THEN 'missing' ELSE state END,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "58fe68be59f015fd8214306483c0d45fcc4b2ac595d3b04efb224b3e426cc1cf"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT r.path AS \"path!: String\"\n FROM roots r\n JOIN series s ON s.root_id = r.id\n JOIN seasons se ON se.series_id = s.id\n JOIN episodes e ON e.season_id = se.id\n WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5b059a2f93a3fb46b6c55e0b665aa55931045af290894e396b3b410123bfd539"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (\n SELECT e.id FROM episodes e WHERE e.season_id = ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6feaa1abb79ce2a0298bef052d3b731e8ccec88228f7a81b2e8ff5cee76f3fa4"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7c638b148b697645d250fe43cf7fcc049e3754e5b1837ff54289e8908523d40c"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT 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": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "90813e43938fcf91b96e444354a5a41b345fec599ba1562ae1fdf617ef49ff54"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "id!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_id!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "title!: String",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "air_date",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "wanted!: bool",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "state!: String",
|
||||
"ordinal": 8,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "vanished!: bool",
|
||||
"ordinal": 9,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "search_attempts!: i64",
|
||||
"ordinal": 10,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "last_searched_at",
|
||||
"ordinal": 11,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "97033814ba641d94878f16f23224b49cbad1d65ea0f130d091995bfed87cb844"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT r.path AS \"path!: String\"\n FROM roots r\n JOIN series s ON s.root_id = r.id\n JOIN seasons se ON se.series_id = s.id\n WHERE se.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "98af62bdeb2be99ece2ebcb3567773b3af29299658a21681237cfa11cc806a39"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT 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": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9f2ce3364cd2c227d6dbccf9973d29ca685cdaa1fb2b93124e099b0bbd6a26af"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dca537a0b312f758af06608c729790566082d7db2928220fc2f1509e0015423e"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT 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 WHERE e.season_id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e5c40363c81cebf7026b49699981218e15b57d565039f3de2d48b320d3cec92b"
|
||||
}
|
||||
@@ -90,7 +90,9 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(series::get, series::update, series::delete))
|
||||
.routes(routes!(series::seasons, series::create_season))
|
||||
.routes(routes!(series::update_season))
|
||||
.routes(routes!(series::delete_season_files))
|
||||
.routes(routes!(series::get_episode, series::update_episode))
|
||||
.routes(routes!(series::delete_episode_files))
|
||||
.routes(routes!(series::search_episode))
|
||||
.routes(routes!(series::episode_releases))
|
||||
.routes(routes!(series::grab_episode))
|
||||
@@ -334,6 +336,11 @@ mod tests {
|
||||
"/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab",
|
||||
"post",
|
||||
),
|
||||
(
|
||||
"/api/series/{series_id}/seasons/{season_number}/files",
|
||||
"delete",
|
||||
),
|
||||
("/api/episodes/{episode_id}/files", "delete"),
|
||||
("/api/queues/attention", "get"),
|
||||
("/api/trailer", "get"),
|
||||
("/api/series", "get"),
|
||||
|
||||
+683
-39
@@ -560,19 +560,12 @@ pub async fn delete(
|
||||
// The row is loaded first so a missing series is 404 before anything
|
||||
// touches the disk.
|
||||
load_series_row(&state, id).await?;
|
||||
remove_library_files(&state, id).await?;
|
||||
// `media_files.path` is UNIQUE and the owner is polymorphic, so nothing
|
||||
// cascades from the seasons and episodes rows (which the series row's
|
||||
// delete does): leaving the rows behind would block re-importing the
|
||||
// same paths after a re-add. Owner tags go with the title they tagged.
|
||||
sqlx::query!(
|
||||
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (
|
||||
SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?)",
|
||||
id
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
// The whole-title scope of the same removal the season and episode
|
||||
// endpoints use (#174). The intent clear it performs is redundant here —
|
||||
// the episode rows go with the series row below — but sharing one path
|
||||
// is what keeps the three scopes from drifting apart.
|
||||
remove_scope_files(&state, FileScope::Series(id)).await?;
|
||||
// Owner tags go with the title they tagged.
|
||||
sqlx::query!(
|
||||
"DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?",
|
||||
id
|
||||
@@ -588,44 +581,55 @@ pub async fn delete(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Unlink everything this series put under its root. Mirrors the movie
|
||||
/// handler in `movies.rs`.
|
||||
/// What one removal call covers. The series variant is the whole title; the
|
||||
/// other two are the sub-series scopes #174 adds, and they must never widen
|
||||
/// past themselves — removing one episode leaves its siblings and the rest
|
||||
/// of the season on disk.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum FileScope {
|
||||
/// Series row id.
|
||||
Series(i64),
|
||||
/// Season row id, not its number.
|
||||
Season(i64),
|
||||
/// Episode row id.
|
||||
Episode(i64),
|
||||
}
|
||||
|
||||
/// Unlink what this scope put under its series' root. Mirrors the movie
|
||||
/// handler in `movies.rs`, and is the only unlink path below a series (#174).
|
||||
///
|
||||
/// The service knows only what it wrote (§2), so the targets come from
|
||||
/// `media_files`, never from a scan and never from re-deriving the §7.4 name
|
||||
/// — a series renamed after import would derive a folder that does not exist
|
||||
/// while the real one stayed. Each episode file resolves to its title folder,
|
||||
/// which makes the delete atomic (§7.4): season subfolders, sidecar subtitles
|
||||
/// and artwork go with it.
|
||||
/// while the real one stayed.
|
||||
///
|
||||
/// What a target *is* depends on the scope. A whole series resolves each file
|
||||
/// to its title folder, which makes that delete atomic (§7.4): season
|
||||
/// subfolders, sidecar subtitles and artwork go with it. A season or a single
|
||||
/// episode resolves to the recorded file and nothing else — the title folder
|
||||
/// holds the siblings this call must not touch, and a season subfolder would
|
||||
/// have to be re-derived to be named, which §2 forbids. Sidecars beside a
|
||||
/// removed episode therefore stay; they are not rows this service wrote.
|
||||
///
|
||||
/// The torrent is untouched (§7.3). It keeps seeding under its own rule and
|
||||
/// the reaper deletes it; a hardlinked file loses only its library name.
|
||||
///
|
||||
/// Failure leaves the database alone, so the operator sees the series still
|
||||
/// there and can retry rather than losing the record of what is on disk.
|
||||
async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> {
|
||||
let root = sqlx::query_scalar!(
|
||||
r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?;
|
||||
let paths = sqlx::query_scalar!(
|
||||
r#"SELECT 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 = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
/// Failure leaves the database alone, so the operator sees the files still
|
||||
/// recorded and can retry rather than losing the record of what is on disk.
|
||||
async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(), ApiError> {
|
||||
let root = scope_root(state, scope).await?;
|
||||
let paths = scope_paths(state, scope).await?;
|
||||
|
||||
let mut targets: Vec<std::path::PathBuf> = Vec::new();
|
||||
for path in &paths {
|
||||
let Some(target) = crate::movies::title_target(&root, path) else {
|
||||
let resolved = match scope {
|
||||
FileScope::Series(_) => crate::movies::title_target(&root, path),
|
||||
FileScope::Season(_) | FileScope::Episode(_) => contained_file(&root, path),
|
||||
};
|
||||
let Some(target) = resolved else {
|
||||
// Outside its own root: not ours to delete. The row still goes,
|
||||
// so the operator sees the series leave and the file stay.
|
||||
// so the operator sees the file leave the library and stay on
|
||||
// disk.
|
||||
tracing::warn!(%path, %root, "media file is outside its root, not deleted");
|
||||
continue;
|
||||
};
|
||||
@@ -655,6 +659,274 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The library root the scope's series sits on. Every unlink is measured
|
||||
/// against it, so it is looked up rather than assumed.
|
||||
async fn scope_root(state: &AppState, scope: FileScope) -> Result<String, ApiError> {
|
||||
Ok(match scope {
|
||||
FileScope::Series(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
FileScope::Season(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT r.path AS "path!: String"
|
||||
FROM roots r
|
||||
JOIN series s ON s.root_id = r.id
|
||||
JOIN seasons se ON se.series_id = s.id
|
||||
WHERE se.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
FileScope::Episode(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT r.path AS "path!: String"
|
||||
FROM roots r
|
||||
JOIN series s ON s.root_id = r.id
|
||||
JOIN seasons se ON se.series_id = s.id
|
||||
JOIN episodes e ON e.season_id = se.id
|
||||
WHERE e.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Every file this service recorded for the scope, and nothing else. The
|
||||
/// `WHERE` clause is the whole guard against a narrow call widening.
|
||||
async fn scope_paths(state: &AppState, scope: FileScope) -> Result<Vec<String>, ApiError> {
|
||||
Ok(match scope {
|
||||
FileScope::Series(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT 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 = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
FileScope::Season(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT mf.path AS "path!: String"
|
||||
FROM media_files mf
|
||||
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
||||
WHERE e.season_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
FileScope::Episode(id) => {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT path AS "path!: String"
|
||||
FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The one recorded file, when it really sits inside the root. `None` when it
|
||||
/// does not, which is the guard that keeps a sub-series delete inside the
|
||||
/// library it belongs to.
|
||||
///
|
||||
/// Unlike [`crate::movies::title_target`] this keeps the whole relative path
|
||||
/// rather than its first component, so it can only ever name the file the row
|
||||
/// records. Every component must be a plain name: one `..` anywhere would
|
||||
/// climb back out of the root it just proved it was under.
|
||||
fn contained_file(root: &str, file: &str) -> Option<std::path::PathBuf> {
|
||||
let root = std::path::Path::new(root);
|
||||
let relative = std::path::Path::new(file).strip_prefix(root).ok()?;
|
||||
if relative.as_os_str().is_empty() {
|
||||
// The root itself is never a file of ours.
|
||||
return None;
|
||||
}
|
||||
if !relative
|
||||
.components()
|
||||
.all(|component| matches!(component, std::path::Component::Normal(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(root.join(relative))
|
||||
}
|
||||
|
||||
/// Removes the files a scope below a series covers, drops their `media_files`
|
||||
/// rows and clears the intent behind them (#174).
|
||||
///
|
||||
/// One action, both halves: the operator asked for removal and un-wanting
|
||||
/// together, not two controls to remember to use in order.
|
||||
///
|
||||
/// The season and episode rows themselves stay. TMDB owns that metadata and
|
||||
/// the next refresh would recreate them, which is what separates this from
|
||||
/// `DELETE /api/series/{id}`.
|
||||
///
|
||||
/// Disk first, database second, so a filesystem failure leaves the rows
|
||||
/// describing what is still there.
|
||||
async fn remove_scope_files(state: &AppState, scope: FileScope) -> Result<(), ApiError> {
|
||||
remove_library_files(state, scope).await?;
|
||||
|
||||
// One transaction: the file rows and the intent they carried land
|
||||
// together or not at all.
|
||||
let mut transaction = pool(state)?.begin().await?;
|
||||
// `media_files.path` is UNIQUE and the owner is polymorphic, so nothing
|
||||
// cascades and nothing here deletes an owning row: leaving these behind
|
||||
// would block re-importing the same path after a re-grab.
|
||||
match scope {
|
||||
FileScope::Series(id) => {
|
||||
sqlx::query!(
|
||||
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (
|
||||
SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?)",
|
||||
id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
FileScope::Season(id) => {
|
||||
sqlx::query!(
|
||||
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN (
|
||||
SELECT e.id FROM episodes e WHERE e.season_id = ?)",
|
||||
id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
FileScope::Episode(id) => {
|
||||
sqlx::query!(
|
||||
"DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
|
||||
id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// The intent goes through `arr_core::tracking::apply_tracked` with
|
||||
// `false`, the same §4.1 rule an untracked season runs (#171), so both
|
||||
// paths agree on what clearing intent means. An episode-scoped call
|
||||
// hands it a slice of one: the rule cannot reach a sibling it was not
|
||||
// given.
|
||||
let rows = match scope {
|
||||
FileScope::Series(id) => {
|
||||
sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?
|
||||
}
|
||||
FileScope::Season(id) => {
|
||||
sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?
|
||||
}
|
||||
FileScope::Episode(id) => {
|
||||
sqlx::query_as!(
|
||||
EpisodeRow,
|
||||
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
|
||||
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let mut episodes: Vec<_> = rows.iter().map(core_episode).collect();
|
||||
apply_tracked(false, &mut episodes);
|
||||
for episode in &episodes {
|
||||
// `available` was true of an episode with a file. It no longer has
|
||||
// one, so it goes back to `missing` the way a failed import already
|
||||
// puts it — `wanted` is now 0, so this opens no gap. `downloading`
|
||||
// is left alone: that grab is still in flight.
|
||||
sqlx::query!(
|
||||
"UPDATE episodes
|
||||
SET wanted = ?,
|
||||
state = CASE WHEN state = 'available' THEN 'missing' ELSE state END,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
episode.wanted,
|
||||
episode.id.0
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/series/{series_id}/seasons/{season_number}/files", tag = "series",
|
||||
params(
|
||||
("series_id" = i64, Path, description = "Series row id"),
|
||||
("season_number" = i64, Path, description = "Season number, not its row id")
|
||||
),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_season_files(
|
||||
State(state): State<AppState>,
|
||||
Path((series_id, number)): Path<(i64, i64)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// Resolved first so an unknown series or season is 404 before anything
|
||||
// touches the disk. A season with nothing on disk is not an error — the
|
||||
// call still clears intent.
|
||||
load_series_row(&state, series_id).await?;
|
||||
let season_id = sqlx::query_scalar!(
|
||||
r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#,
|
||||
series_id,
|
||||
number
|
||||
)
|
||||
.fetch_optional(pool(&state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::SeasonNotFound)?;
|
||||
|
||||
remove_scope_files(&state, FileScope::Season(season_id)).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/episodes/{episode_id}/files", tag = "series",
|
||||
params(("episode_id" = i64, Path, description = "Episode row id")),
|
||||
responses(
|
||||
(status = 204),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn delete_episode_files(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// 404 before the disk, and an episode with no file still clears intent.
|
||||
load_episode(&state, id).await?;
|
||||
remove_scope_files(&state, FileScope::Episode(id)).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, ApiError> {
|
||||
let seasons = sqlx::query!(
|
||||
// §9.6: newest-first, so season 0 lands last under plain numeric
|
||||
@@ -2444,6 +2716,378 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Puts one episode file plus a sidecar into a §7.4 season folder and
|
||||
/// records the file. Returns the file's path.
|
||||
async fn episode_file_on_disk(
|
||||
state: &AppState,
|
||||
episode_id: i64,
|
||||
root: &std::path::Path,
|
||||
season: i64,
|
||||
name: &str,
|
||||
) -> std::path::PathBuf {
|
||||
let folder = root
|
||||
.join("Bluey (2018) [tmdbid-82728]")
|
||||
.join(format!("Season {season:02}"));
|
||||
tokio::fs::create_dir_all(&folder)
|
||||
.await
|
||||
.expect("create season folder");
|
||||
let file = folder.join(name);
|
||||
tokio::fs::write(&file, b"episode").await.expect("write");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)",
|
||||
)
|
||||
.bind(episode_id)
|
||||
.bind(file.to_str().expect("utf-8 path"))
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("media file");
|
||||
file
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async fn wanted_of(state: &AppState, episode_id: i64) -> bool {
|
||||
sqlx::query_scalar::<_, bool>("SELECT wanted FROM episodes WHERE id = ?")
|
||||
.bind(episode_id)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("wanted")
|
||||
}
|
||||
|
||||
/// #174: one call unlinks the season's files, drops their `media_files`
|
||||
/// rows and clears the intent behind them. The season and episode rows
|
||||
/// stay — TMDB owns that metadata — and the next season is untouched.
|
||||
#[tokio::test]
|
||||
async fn removing_a_season_takes_its_files_and_its_wanted() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
point_root_at(&state, root_id, root.path()).await;
|
||||
|
||||
let first = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "The Magic Xylophone"},
|
||||
{"number": 2, "title": "Hospital"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
2,
|
||||
serde_json::json!([{"number": 1, "title": "Dance Mode"}]),
|
||||
)
|
||||
.await;
|
||||
let s01e01 = first["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.find(|episode| episode["number"] == 1)
|
||||
.expect("s01e01")["id"]
|
||||
.as_i64()
|
||||
.expect("id");
|
||||
let s01e02 = first["episodes"]
|
||||
.as_array()
|
||||
.expect("episodes")
|
||||
.iter()
|
||||
.find(|episode| episode["number"] == 2)
|
||||
.expect("s01e02")["id"]
|
||||
.as_i64()
|
||||
.expect("id");
|
||||
let s02e01 = second["episodes"][0]["id"].as_i64().expect("id");
|
||||
|
||||
let one = episode_file_on_disk(&state, s01e01, root.path(), 1, "Bluey - S01E01.mkv").await;
|
||||
let two = episode_file_on_disk(&state, s01e02, root.path(), 1, "Bluey - S01E02.mkv").await;
|
||||
let other =
|
||||
episode_file_on_disk(&state, s02e01, root.path(), 2, "Bluey - S02E01.mkv").await;
|
||||
|
||||
let pool = state.database().expect("database").pool();
|
||||
for episode in [s01e01, s01e02, s02e01] {
|
||||
sqlx::query("UPDATE episodes SET wanted = 1, state = 'available' WHERE id = ?")
|
||||
.bind(episode)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("seed wanted");
|
||||
}
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/series/{series_id}/seasons/1/files"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete season files");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
assert!(!one.exists(), "the season's files are gone from disk");
|
||||
assert!(!two.exists(), "the season's files are gone from disk");
|
||||
assert!(other.exists(), "another season's file is not in scope");
|
||||
|
||||
let rows: Vec<String> =
|
||||
sqlx::query_scalar("SELECT path FROM media_files WHERE owner_kind = 'episode'")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("files");
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![other.to_str().expect("utf-8").to_string()],
|
||||
"only the season's rows go"
|
||||
);
|
||||
|
||||
assert!(!wanted_of(&state, s01e01).await, "intent cleared");
|
||||
assert!(!wanted_of(&state, s01e02).await, "across the whole season");
|
||||
assert!(wanted_of(&state, s02e01).await, "and nowhere else");
|
||||
|
||||
let state_of: String = sqlx::query_scalar("SELECT state FROM episodes WHERE id = ?")
|
||||
.bind(s01e01)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("state");
|
||||
assert_eq!(
|
||||
state_of, "missing",
|
||||
"an episode with no file is not available"
|
||||
);
|
||||
|
||||
let seasons: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons WHERE series_id = ?")
|
||||
.bind(series_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("seasons");
|
||||
let episodes: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM episodes e JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?",
|
||||
)
|
||||
.bind(series_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("episodes");
|
||||
assert_eq!(seasons, 2, "TMDB owns the season rows, so they stay");
|
||||
assert_eq!(episodes, 3, "and the episode rows with them");
|
||||
}
|
||||
|
||||
/// #174: the narrow scope really is narrow. Removing one episode leaves
|
||||
/// its sibling's file, row and intent exactly as they were, and leaves
|
||||
/// the season folder standing.
|
||||
#[tokio::test]
|
||||
async fn removing_one_episode_leaves_its_siblings_alone() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
point_root_at(&state, root_id, root.path()).await;
|
||||
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([
|
||||
{"number": 1, "title": "The Magic Xylophone"},
|
||||
{"number": 2, "title": "Hospital"}
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let episodes = season["episodes"].as_array().expect("episodes");
|
||||
let first = episodes
|
||||
.iter()
|
||||
.find(|episode| episode["number"] == 1)
|
||||
.expect("s01e01")["id"]
|
||||
.as_i64()
|
||||
.expect("id");
|
||||
let second = episodes
|
||||
.iter()
|
||||
.find(|episode| episode["number"] == 2)
|
||||
.expect("s01e02")["id"]
|
||||
.as_i64()
|
||||
.expect("id");
|
||||
|
||||
let one = episode_file_on_disk(&state, first, root.path(), 1, "Bluey - S01E01.mkv").await;
|
||||
let two = episode_file_on_disk(&state, second, root.path(), 1, "Bluey - S01E02.mkv").await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
for episode in [first, second] {
|
||||
sqlx::query("UPDATE episodes SET wanted = 1 WHERE id = ?")
|
||||
.bind(episode)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("seed wanted");
|
||||
}
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/episodes/{first}/files"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete episode files");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
assert!(!one.exists(), "the episode's file is gone");
|
||||
assert!(two.exists(), "its sibling's file is not in scope");
|
||||
assert!(
|
||||
two.parent().expect("season folder").exists(),
|
||||
"and neither is the season folder around them"
|
||||
);
|
||||
assert!(!wanted_of(&state, first).await, "intent cleared for it");
|
||||
assert!(wanted_of(&state, second).await, "and not for its sibling");
|
||||
|
||||
let remaining: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT owner_id FROM media_files WHERE owner_kind = 'episode'")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("files");
|
||||
assert_eq!(remaining, vec![second], "only the episode's row goes");
|
||||
}
|
||||
|
||||
/// #174: removal is not conditional on there being anything to remove.
|
||||
/// A scope with no files still clears intent, and still answers 204.
|
||||
#[tokio::test]
|
||||
async fn removing_a_scope_with_no_files_still_clears_intent() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
point_root_at(&state, root_id, root.path()).await;
|
||||
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{"number": 1, "title": "The Magic Xylophone"}]),
|
||||
)
|
||||
.await;
|
||||
let episode_id = season["episodes"][0]["id"].as_i64().expect("id");
|
||||
sqlx::query("UPDATE episodes SET wanted = 1 WHERE id = ?")
|
||||
.bind(episode_id)
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("seed wanted");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/series/{series_id}/seasons/1/files"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete season files");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
assert!(!wanted_of(&state, episode_id).await, "intent still cleared");
|
||||
|
||||
// And again on the episode, which now has neither file nor intent.
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/episodes/{episode_id}/files"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete episode files");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
/// #174: an unknown scope is 404, and an unknown series is 404 even for
|
||||
/// a season number that exists under some other series.
|
||||
#[tokio::test]
|
||||
async fn removing_files_from_an_unknown_scope_is_a_404() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{"number": 1, "title": "The Magic Xylophone"}]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
for path in [
|
||||
format!("api/series/{series_id}/seasons/9/files"),
|
||||
"api/series/999/seasons/1/files".to_string(),
|
||||
"api/episodes/999/files".to_string(),
|
||||
] {
|
||||
let response = client
|
||||
.delete(format!("{base}/{path}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete");
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #174 reuses the §7.4 containment guard: a recorded path that is not
|
||||
/// under the series' root is left on disk, whatever the row says. Its
|
||||
/// row still goes, so the library stops claiming the file.
|
||||
#[tokio::test]
|
||||
async fn an_episode_file_outside_its_root_is_never_unlinked() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, true).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let root = tempfile::tempdir().expect("root");
|
||||
point_root_at(&state, root_id, root.path()).await;
|
||||
|
||||
let season = add_season(
|
||||
&base,
|
||||
series_id,
|
||||
1,
|
||||
serde_json::json!([{"number": 1, "title": "The Magic Xylophone"}]),
|
||||
)
|
||||
.await;
|
||||
let episode_id = season["episodes"][0]["id"].as_i64().expect("id");
|
||||
|
||||
let elsewhere = tempfile::tempdir().expect("elsewhere");
|
||||
let stray = elsewhere.path().join("not-ours.mkv");
|
||||
tokio::fs::write(&stray, b"stray").await.expect("write");
|
||||
sqlx::query(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 5)",
|
||||
)
|
||||
.bind(episode_id)
|
||||
.bind(stray.to_str().expect("utf-8 path"))
|
||||
.execute(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("media file");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{base}/api/episodes/{episode_id}/files"))
|
||||
.send()
|
||||
.await
|
||||
.expect("delete episode files");
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
assert!(
|
||||
stray.exists(),
|
||||
"a path outside the root is not ours to delete"
|
||||
);
|
||||
}
|
||||
|
||||
/// The sub-series guard keeps the whole relative path, so it can only
|
||||
/// name the recorded file — and one `..` anywhere is enough to refuse.
|
||||
#[test]
|
||||
fn a_contained_file_is_the_recorded_path_under_the_root() {
|
||||
let root = "/mnt/media/tv/main";
|
||||
assert_eq!(
|
||||
contained_file(
|
||||
root,
|
||||
"/mnt/media/tv/main/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv"
|
||||
),
|
||||
Some(std::path::PathBuf::from(
|
||||
"/mnt/media/tv/main/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv"
|
||||
))
|
||||
);
|
||||
assert_eq!(contained_file(root, "/mnt/media/tv/kids/other.mkv"), None);
|
||||
assert_eq!(contained_file(root, root), None);
|
||||
assert_eq!(
|
||||
contained_file(root, "/mnt/media/tv/main/../kids/other.mkv"),
|
||||
None,
|
||||
"one `..` climbs back out of the root it was under"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_dates_parse_as_dates_and_as_timestamps() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user