From 64e28e5930cc78331cf8e092f53d1072cb7cee24 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 16:43:53 +0100 Subject: [PATCH] feat: remove files for one season or episode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...81c591fab8dcf3e2c223e8cbb82db00e2f02b.json | 12 - ...6ac1579490b29da670fe9062117451911576a.json | 20 + ...4c14ed005683c68f06d322409c5c2b39b84d1.json | 12 + ...0d45fcc4b2ac595d3b04efb224b3e426cc1cf.json | 12 + ...aa55931045af290894e396b3b410123bfd539.json | 20 + ...b731e8ccec88228f7a81b2e8ff5cee76f3fa4.json | 12 + ...fcc049e3754e5b1837ff54289e8908523d40c.json | 86 +++ ...a548afa40d46b938df101bf3d3b42b01791b4.json | 20 - ...5a41b345fec599ba1562ae1fdf617ef49ff54.json | 86 +++ ...4b49cbad1d65ea0f130d091995bfed87cb844.json | 86 +++ ...773b3af29299658a21681237cfa11cc806a39.json | 20 + ...d29ca685cdaa1fb2b93124e099b0bbd6a26af.json | 20 + ...790566082d7db2928220fc2f1509e0015423e.json | 12 + ...1218e15b57d565039f3de2d48b320d3cec92b.json | 20 + crates/arr-api/src/lib.rs | 7 + crates/arr-api/src/series.rs | 722 +++++++++++++++++- 16 files changed, 1096 insertions(+), 71 deletions(-) delete mode 100644 .sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json create mode 100644 .sqlx/query-32c562fdb70cabb3265e351583b6ac1579490b29da670fe9062117451911576a.json create mode 100644 .sqlx/query-3531a16bd038c149465397a70c24c14ed005683c68f06d322409c5c2b39b84d1.json create mode 100644 .sqlx/query-58fe68be59f015fd8214306483c0d45fcc4b2ac595d3b04efb224b3e426cc1cf.json create mode 100644 .sqlx/query-5b059a2f93a3fb46b6c55e0b665aa55931045af290894e396b3b410123bfd539.json create mode 100644 .sqlx/query-6feaa1abb79ce2a0298bef052d3b731e8ccec88228f7a81b2e8ff5cee76f3fa4.json create mode 100644 .sqlx/query-7c638b148b697645d250fe43cf7fcc049e3754e5b1837ff54289e8908523d40c.json delete mode 100644 .sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json create mode 100644 .sqlx/query-90813e43938fcf91b96e444354a5a41b345fec599ba1562ae1fdf617ef49ff54.json create mode 100644 .sqlx/query-97033814ba641d94878f16f23224b49cbad1d65ea0f130d091995bfed87cb844.json create mode 100644 .sqlx/query-98af62bdeb2be99ece2ebcb3567773b3af29299658a21681237cfa11cc806a39.json create mode 100644 .sqlx/query-9f2ce3364cd2c227d6dbccf9973d29ca685cdaa1fb2b93124e099b0bbd6a26af.json create mode 100644 .sqlx/query-dca537a0b312f758af06608c729790566082d7db2928220fc2f1509e0015423e.json create mode 100644 .sqlx/query-e5c40363c81cebf7026b49699981218e15b57d565039f3de2d48b320d3cec92b.json diff --git a/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json b/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json deleted file mode 100644 index 1813f52..0000000 --- a/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-32c562fdb70cabb3265e351583b6ac1579490b29da670fe9062117451911576a.json b/.sqlx/query-32c562fdb70cabb3265e351583b6ac1579490b29da670fe9062117451911576a.json new file mode 100644 index 0000000..52fd192 --- /dev/null +++ b/.sqlx/query-32c562fdb70cabb3265e351583b6ac1579490b29da670fe9062117451911576a.json @@ -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" +} diff --git a/.sqlx/query-3531a16bd038c149465397a70c24c14ed005683c68f06d322409c5c2b39b84d1.json b/.sqlx/query-3531a16bd038c149465397a70c24c14ed005683c68f06d322409c5c2b39b84d1.json new file mode 100644 index 0000000..6209aac --- /dev/null +++ b/.sqlx/query-3531a16bd038c149465397a70c24c14ed005683c68f06d322409c5c2b39b84d1.json @@ -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" +} diff --git a/.sqlx/query-58fe68be59f015fd8214306483c0d45fcc4b2ac595d3b04efb224b3e426cc1cf.json b/.sqlx/query-58fe68be59f015fd8214306483c0d45fcc4b2ac595d3b04efb224b3e426cc1cf.json new file mode 100644 index 0000000..e21c346 --- /dev/null +++ b/.sqlx/query-58fe68be59f015fd8214306483c0d45fcc4b2ac595d3b04efb224b3e426cc1cf.json @@ -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" +} diff --git a/.sqlx/query-5b059a2f93a3fb46b6c55e0b665aa55931045af290894e396b3b410123bfd539.json b/.sqlx/query-5b059a2f93a3fb46b6c55e0b665aa55931045af290894e396b3b410123bfd539.json new file mode 100644 index 0000000..be2bede --- /dev/null +++ b/.sqlx/query-5b059a2f93a3fb46b6c55e0b665aa55931045af290894e396b3b410123bfd539.json @@ -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" +} diff --git a/.sqlx/query-6feaa1abb79ce2a0298bef052d3b731e8ccec88228f7a81b2e8ff5cee76f3fa4.json b/.sqlx/query-6feaa1abb79ce2a0298bef052d3b731e8ccec88228f7a81b2e8ff5cee76f3fa4.json new file mode 100644 index 0000000..62a593a --- /dev/null +++ b/.sqlx/query-6feaa1abb79ce2a0298bef052d3b731e8ccec88228f7a81b2e8ff5cee76f3fa4.json @@ -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" +} diff --git a/.sqlx/query-7c638b148b697645d250fe43cf7fcc049e3754e5b1837ff54289e8908523d40c.json b/.sqlx/query-7c638b148b697645d250fe43cf7fcc049e3754e5b1837ff54289e8908523d40c.json new file mode 100644 index 0000000..064d958 --- /dev/null +++ b/.sqlx/query-7c638b148b697645d250fe43cf7fcc049e3754e5b1837ff54289e8908523d40c.json @@ -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" +} diff --git a/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json b/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json deleted file mode 100644 index 904a6d9..0000000 --- a/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-90813e43938fcf91b96e444354a5a41b345fec599ba1562ae1fdf617ef49ff54.json b/.sqlx/query-90813e43938fcf91b96e444354a5a41b345fec599ba1562ae1fdf617ef49ff54.json new file mode 100644 index 0000000..efa7d3a --- /dev/null +++ b/.sqlx/query-90813e43938fcf91b96e444354a5a41b345fec599ba1562ae1fdf617ef49ff54.json @@ -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" +} diff --git a/.sqlx/query-97033814ba641d94878f16f23224b49cbad1d65ea0f130d091995bfed87cb844.json b/.sqlx/query-97033814ba641d94878f16f23224b49cbad1d65ea0f130d091995bfed87cb844.json new file mode 100644 index 0000000..bb3138e --- /dev/null +++ b/.sqlx/query-97033814ba641d94878f16f23224b49cbad1d65ea0f130d091995bfed87cb844.json @@ -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" +} diff --git a/.sqlx/query-98af62bdeb2be99ece2ebcb3567773b3af29299658a21681237cfa11cc806a39.json b/.sqlx/query-98af62bdeb2be99ece2ebcb3567773b3af29299658a21681237cfa11cc806a39.json new file mode 100644 index 0000000..7c43374 --- /dev/null +++ b/.sqlx/query-98af62bdeb2be99ece2ebcb3567773b3af29299658a21681237cfa11cc806a39.json @@ -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" +} diff --git a/.sqlx/query-9f2ce3364cd2c227d6dbccf9973d29ca685cdaa1fb2b93124e099b0bbd6a26af.json b/.sqlx/query-9f2ce3364cd2c227d6dbccf9973d29ca685cdaa1fb2b93124e099b0bbd6a26af.json new file mode 100644 index 0000000..401c95f --- /dev/null +++ b/.sqlx/query-9f2ce3364cd2c227d6dbccf9973d29ca685cdaa1fb2b93124e099b0bbd6a26af.json @@ -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" +} diff --git a/.sqlx/query-dca537a0b312f758af06608c729790566082d7db2928220fc2f1509e0015423e.json b/.sqlx/query-dca537a0b312f758af06608c729790566082d7db2928220fc2f1509e0015423e.json new file mode 100644 index 0000000..4da27d2 --- /dev/null +++ b/.sqlx/query-dca537a0b312f758af06608c729790566082d7db2928220fc2f1509e0015423e.json @@ -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" +} diff --git a/.sqlx/query-e5c40363c81cebf7026b49699981218e15b57d565039f3de2d48b320d3cec92b.json b/.sqlx/query-e5c40363c81cebf7026b49699981218e15b57d565039f3de2d48b320d3cec92b.json new file mode 100644 index 0000000..54439ae --- /dev/null +++ b/.sqlx/query-e5c40363c81cebf7026b49699981218e15b57d565039f3de2d48b320d3cec92b.json @@ -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" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 31a2eb3..2f887c1 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -90,7 +90,9 @@ fn api_router() -> OpenApiRouter { .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"), diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 632799c..2994d7f 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -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 = 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 { + 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, 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 { + 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, + Path((series_id, number)): Path<(i64, i64)>, +) -> Result { + // 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, + Path(id): Path, +) -> Result { + // 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, 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 = + 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 = + 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!(