fix(api): delete series files and media_files rows
This commit is contained in:
+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": "0ff61f3af9e7185dcd1d506027381c591fab8dcf3e2c223e8cbb82db00e2f02b"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+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": "87b2e62f51149472db07f32fbc2a548afa40d46b938df101bf3d3b42b01791b4"
|
||||||
|
}
|
||||||
+20
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
/// 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
|
/// 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.
|
/// guard that keeps a delete inside the library it belongs to.
|
||||||
fn title_target(root: &str, file: &str) -> Option<std::path::PathBuf> {
|
pub(crate) fn title_target(root: &str, file: &str) -> Option<std::path::PathBuf> {
|
||||||
let root = std::path::Path::new(root);
|
let root = std::path::Path::new(root);
|
||||||
let relative = std::path::Path::new(file).strip_prefix(root).ok()?;
|
let relative = std::path::Path::new(file).strip_prefix(root).ok()?;
|
||||||
let first = relative.components().next()?;
|
let first = relative.components().next()?;
|
||||||
|
|||||||
@@ -589,6 +589,28 @@ pub async fn delete(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// 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)
|
let result = sqlx::query!("DELETE FROM series WHERE id = ?", id)
|
||||||
.execute(pool(&state)?)
|
.execute(pool(&state)?)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -598,6 +620,73 @@ pub async fn delete(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
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<std::path::PathBuf> = 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<Vec<Season>, ApiError> {
|
async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, ApiError> {
|
||||||
let seasons = sqlx::query!(
|
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"#,
|
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);
|
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]
|
#[test]
|
||||||
fn air_dates_parse_as_dates_and_as_timestamps() {
|
fn air_dates_parse_as_dates_and_as_timestamps() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user