diff --git a/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json b/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.json new file mode 100644 index 0000000..1813f52 --- /dev/null +++ b/.sqlx/query-0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b.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": "0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b" +} diff --git a/.sqlx/query-3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7.json b/.sqlx/query-3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7.json new file mode 100644 index 0000000..d2161fd --- /dev/null +++ b/.sqlx/query-3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "3775aa6dbb8d3ae7de48c3fb5dc81eca64ac5fa5b410cb14d885af2a137693b7" +} diff --git a/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json b/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.json new file mode 100644 index 0000000..904a6d9 --- /dev/null +++ b/.sqlx/query-87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4.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": "87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4" +} diff --git a/.sqlx/query-f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d.json b/.sqlx/query-f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d.json new file mode 100644 index 0000000..caef66d --- /dev/null +++ b/.sqlx/query-f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT r.path AS \"path!: String\" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?", + "describe": { + "columns": [ + { + "name": "path!: String", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "f513c2cc848ab8e42366ad83ad6fdb4d18ad0d6b5083221531fb70962670ef1d" +} diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 28cad1b..599b030 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -478,7 +478,7 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> /// root, or the file itself when it sits in the root with no folder of its /// own. `None` when the file is not under the root at all, which is the /// guard that keeps a delete inside the library it belongs to. -fn title_target(root: &str, file: &str) -> Option { +pub(crate) fn title_target(root: &str, file: &str) -> Option { let root = std::path::Path::new(root); let relative = std::path::Path::new(file).strip_prefix(root).ok()?; let first = relative.components().next()?; diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 56e765b..fc9e74b 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -589,6 +589,28 @@ pub async fn delete( State(state): State, Path(id): Path, ) -> Result { + // 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?; + sqlx::query!( + "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?", + id + ) + .execute(pool(&state)?) + .await?; let result = sqlx::query!("DELETE FROM series WHERE id = ?", id) .execute(pool(&state)?) .await?; @@ -598,6 +620,73 @@ pub async fn delete( Ok(StatusCode::NO_CONTENT) } +/// Unlink everything this series put under its root. Mirrors the movie +/// handler in `movies.rs`. +/// +/// 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. +/// +/// 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?; + + let mut targets: Vec = Vec::new(); + for path in &paths { + let Some(target) = crate::movies::title_target(&root, path) else { + // Outside its own root: not ours to delete. The row still goes, + // so the operator sees the series leave and the file stay. + tracing::warn!(%path, %root, "media file is outside its root, not deleted"); + continue; + }; + if !targets.contains(&target) { + targets.push(target); + } + } + + for target in targets { + let metadata = match tokio::fs::symlink_metadata(&target).await { + Ok(metadata) => metadata, + // Already gone is the state we wanted. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(ApiError::Filesystem(error.to_string())), + }; + let removed = if metadata.is_dir() { + tokio::fs::remove_dir_all(&target).await + } else { + tokio::fs::remove_file(&target).await + }; + match removed { + Ok(()) => tracing::info!(target = %target.display(), "removed library files"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(ApiError::Filesystem(error.to_string())), + } + } + Ok(()) +} + async fn load_seasons(state: &AppState, series_id: i64) -> Result, ApiError> { let seasons = sqlx::query!( r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#, @@ -1838,6 +1927,143 @@ mod tests { assert_eq!(episodes, 0); } + /// A series on disk for one test: the §7.4 title folder with a season + /// subfolder holding one episode file and one sidecar subtitle. + async fn library_on_disk( + state: &AppState, + episode_id: i64, + root: &std::path::Path, + ) -> std::path::PathBuf { + let folder = root.join("Bluey (2018) [tmdbid-82728]"); + let season = folder.join("Season 01"); + tokio::fs::create_dir_all(&season) + .await + .expect("create title folder"); + let feature = season.join("Bluey (2018) - S01E01 - Pilot [1080p][WEB-DL].mkv"); + tokio::fs::write(&feature, b"episode").await.expect("write"); + tokio::fs::write(season.join("bluey.s01e01.pt.srt"), b"subs") + .await + .expect("write sidecar"); + + let pool = state.database().expect("database").pool(); + let root_path = root.to_str().expect("utf-8 root"); + sqlx::query("UPDATE roots SET path = ? WHERE kind = 'tv' AND audience = 'main'") + .bind(root_path) + .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(feature.to_str().expect("utf-8 path")) + .execute(pool) + .await + .expect("media file"); + folder + } + + /// The §7.4 title folder is the unit of deletion, so the season + /// subfolder and sidecars go with it — and the root is never touched. + #[tokio::test] + async fn deleting_a_series_removes_the_whole_title_folder() { + 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 season = add_season( + &base, + series_id, + 1, + serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), + ) + .await; + let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); + let root = tempfile::tempdir().expect("root"); + let folder = library_on_disk(&state, episode_id, root.path()).await; + + let response = reqwest::Client::new() + .delete(format!("{base}/api/series/{series_id}")) + .send() + .await + .expect("delete"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!( + !folder.exists(), + "the title folder, its seasons and its sidecars are gone" + ); + assert!(root.path().exists(), "the root survives its titles"); + + let pool = state.database().expect("database").pool(); + let orphans: i64 = + sqlx::query_scalar("SELECT count(*) FROM media_files WHERE owner_kind = 'episode'") + .fetch_one(pool) + .await + .expect("count files"); + assert_eq!(orphans, 0, "the file rows go with the files"); + } + + /// A missing series is 404 before anything touches the disk. + #[tokio::test] + async fn deleting_a_missing_series_is_a_404() { + let (_dir, _state, base) = application().await; + let response = reqwest::Client::new() + .delete(format!("{base}/api/series/999")) + .send() + .await + .expect("delete"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + /// The guard that keeps a delete inside the library: a path that is not + /// under the series' root is left alone, whatever the row says. + #[tokio::test] + async fn a_series_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 season = add_season( + &base, + series_id, + 1, + serde_json::json!([{"number": 1, "title": "Pilot"}]), + ) + .await; + let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); + + let root = tempfile::tempdir().expect("root"); + sqlx::query("UPDATE roots SET path = ? WHERE id = ?") + .bind(root.path().to_str().expect("utf-8 root")) + .bind(root_id) + .execute(state.database().expect("database").pool()) + .await + .expect("point the root at the tempdir"); + + 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/series/{series_id}")) + .send() + .await + .expect("delete"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!( + stray.exists(), + "a path outside the root is not ours to delete" + ); + } + #[test] fn air_dates_parse_as_dates_and_as_timestamps() { assert_eq!(