feat(api): move title folders on root path change

Changing a root's path rewrote the row and moved nothing, so every title
under it was mislocated at once. It now reuses the #228 mover: plan every
rename, refuse a destination that already exists, rewrite the media_files
rows in the same transaction as the row change.

The move is all or nothing. A root row carries one path, so a half-moved
library would have to describe both places; instead one folder that
cannot move puts back the ones that already did and leaves the root's
path alone, and the same request is the retry.

just ci ran clean through the gate: 498 tests passed.
This commit is contained in:
Miguel Palhas
2026-08-25 10:20:37 +01:00
parent 442ee3b022
commit efb47d64e7
7 changed files with 688 additions and 58 deletions
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", "query": "UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@@ -8,5 +8,5 @@
}, },
"nullable": [] "nullable": []
}, },
"hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b" "hash": "2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41"
} }
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE s.root_id = ?\n ORDER BY mf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "73f978ab92e16183828ed15674f69f08914d6faf42d21c1689d7e2872c7fdbaa"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT mf.id AS \"id!: i64\", mf.path AS \"path!: String\"\n FROM media_files mf\n JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id\n WHERE m.root_id = ?\n ORDER BY mf.id",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "ba0db4be362417f625fcede07f26056f40ae1b47098dffd02047a7b001ad0302"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id FROM roots WHERE path = ? AND id <> ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "fa3d1e4a6cae94780daf8fe20062a107963ba6ab2bcef2c8cfb9a1efbd905b59"
}
+1 -1
View File
@@ -505,7 +505,7 @@ Media kind first, hard audience boundary second, people nowhere.
- **Release group is deliberately absent.** It is not a selection criterion and - **Release group is deliberately absent.** It is not a selection criterion and
it makes filenames long enough to break a terminal. it makes filenames long enough to break a terminal.
Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. Changing a title's root relocates its title folder into the new root; roots are assumed to share one filesystem, so the move is a rename, never a copy. Changing a root's path is the same move over every title under it, and it is all or nothing: one folder that cannot move puts back the ones that already did and leaves the root's path alone, so the stored path always describes the disk.
During transition, write into the existing roots so Jellyfin needs no During transition, write into the existing roots so Jellyfin needs no
reconfiguration and new content appears immediately. Radarr will not touch a reconfiguration and new content appears immediately. Radarr will not touch a
+133 -32
View File
@@ -1,7 +1,8 @@
//! Moving a title between roots (issue #228). Changing a title's `root_id` //! Moving library files when the layout under them changes: a title changing
//! renames its §7.4 folder into the new root and rewrites the `media_files` //! its `root_id` (issue #228), and a root changing its `path` (issue #236).
//! rows to match, so the layout keeps describing the disk and the new root's //! Both rename §7.4 folders and rewrite the `media_files` rows to match, so
//! policy (§5.1) applies to a library the files are actually in. //! the layout keeps describing the disk and the root's policy (§5.1) applies
//! to a library the files are actually in.
//! //!
//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this //! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this
//! is a directory rename, never a copy. Hardlinked files keep their inodes //! is a directory rename, never a copy. Hardlinked files keep their inodes
@@ -10,6 +11,16 @@
//! Ordering mirrors `remove_library_files`: the disk is touched before the //! Ordering mirrors `remove_library_files`: the disk is touched before the
//! row changes, so a failed rename leaves the title where its files actually //! row changes, so a failed rename leaves the title where its files actually
//! are and the operator can retry. //! are and the operator can retry.
//!
//! A root path change is the same move repeated over every title under the
//! root, and it is all or nothing. If the seventh of ten folders fails to
//! move, the six already renamed are moved back and the root row is left
//! alone: a root row carries one path, so a half-moved library would have to
//! describe both, and neither the operator nor the next import could tell
//! which titles were where. Refusing leaves one answer — everything is still
//! at the old path — and the retry is the same request again. A retry after
//! an undo that itself failed still converges, because a source that is no
//! longer on disk is skipped while its row is still rewritten.
use std::path::PathBuf; use std::path::PathBuf;
@@ -40,6 +51,15 @@ pub(crate) struct Relocation {
rewrites: Vec<(i64, String)>, rewrites: Vec<(i64, String)>,
} }
/// Whether the destination root is a directory that must already be there.
/// A title moves into another configured root, which exists; a root moving to
/// a new path is moving somewhere that need not exist yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Destination {
Existing,
Create,
}
/// Rename the title's folders into the new root. Called before the row is /// Rename the title's folders into the new root. Called before the row is
/// written, and only when the root actually changes. /// written, and only when the root actually changes.
/// ///
@@ -62,11 +82,45 @@ pub(crate) async fn relocate_title(
let old_root = root_path(state, old_root_id).await?; let old_root = root_path(state, old_root_id).await?;
let new_root = root_path(state, new_root_id).await?; let new_root = root_path(state, new_root_id).await?;
let files = title_files(state, kind, title_id).await?; let files = title_files(state, kind, title_id).await?;
relocate_files(&files, &old_root, &new_root, Destination::Existing).await
}
/// Rename every title folder under a root into the root's new path, for a
/// `PUT /api/roots/{id}` that changes `path` (issue #236).
///
/// A root with no titles under it — or whose titles have nothing on disk —
/// changes path with no filesystem work at all. The new path is created when
/// there is something to move into it, since a root is normally pointed at a
/// directory that does not exist yet.
///
/// # Errors
///
/// The same two as [`relocate_title`], with the whole root's move treated as
/// one unit: one folder that cannot move takes the entire change down and
/// moves back whatever had already moved.
pub(crate) async fn relocate_root(
state: &AppState,
root_id: i64,
old_path: &str,
new_path: &str,
) -> Result<Relocation, ApiError> {
let files = root_files(state, root_id).await?;
relocate_files(&files, old_path, new_path, Destination::Create).await
}
/// The one mover both callers share: plan every rename, refuse every
/// destination that already exists, then perform them, undoing what was
/// performed if one fails.
async fn relocate_files(
files: &[(i64, String)],
old_root: &str,
new_root: &str,
destination: Destination,
) -> Result<Relocation, ApiError> {
let mut renames: Vec<PlannedRename> = Vec::new(); let mut renames: Vec<PlannedRename> = Vec::new();
let mut rewrites: Vec<(i64, String)> = Vec::new(); let mut rewrites: Vec<(i64, String)> = Vec::new();
for (file_id, path) in &files { for (file_id, path) in files {
let Some(source) = title_target(&old_root, path) else { let Some(source) = title_target(old_root, path) else {
// Outside its own root: not ours to move, and the row keeps // Outside its own root: not ours to move, and the row keeps
// pointing at where the file really is. // pointing at where the file really is.
tracing::warn!(%path, %old_root, "media file is outside its root, not moved"); tracing::warn!(%path, %old_root, "media file is outside its root, not moved");
@@ -75,7 +129,7 @@ pub(crate) async fn relocate_title(
let Some(name) = source.file_name() else { let Some(name) = source.file_name() else {
continue; continue;
}; };
let destination = std::path::Path::new(&new_root).join(name); let destination = std::path::Path::new(new_root).join(name);
if !renames.iter().any(|rename| rename.source == source) { if !renames.iter().any(|rename| rename.source == source) {
renames.push(PlannedRename { renames.push(PlannedRename {
source, source,
@@ -83,9 +137,9 @@ pub(crate) async fn relocate_title(
}); });
} }
let relative = std::path::Path::new(path) let relative = std::path::Path::new(path)
.strip_prefix(std::path::Path::new(&old_root)) .strip_prefix(std::path::Path::new(old_root))
.map_err(|error| ApiError::Filesystem(error.to_string()))?; .map_err(|error| ApiError::Filesystem(error.to_string()))?;
let rewritten = std::path::Path::new(&new_root).join(relative); let rewritten = std::path::Path::new(new_root).join(relative);
let Some(rewritten) = rewritten.to_str() else { let Some(rewritten) = rewritten.to_str() else {
return Err(ApiError::Filesystem(format!( return Err(ApiError::Filesystem(format!(
"non-UTF-8 path under {new_root}" "non-UTF-8 path under {new_root}"
@@ -95,12 +149,12 @@ pub(crate) async fn relocate_title(
} }
// Every destination is checked before anything is renamed, so a conflict // Every destination is checked before anything is renamed, so a conflict
// never leaves a half-moved title behind. // never leaves a half-moved library behind.
for rename in &renames { for rename in &renames {
match tokio::fs::symlink_metadata(&rename.destination).await { match tokio::fs::symlink_metadata(&rename.destination).await {
Ok(_) => { Ok(_) => {
return Err(ApiError::Conflict(format!( return Err(ApiError::Conflict(format!(
"the destination root already has '{}'", "the destination already has '{}'",
rename.destination.display() rename.destination.display()
))) )))
} }
@@ -109,6 +163,14 @@ pub(crate) async fn relocate_title(
} }
} }
if destination == Destination::Create && !renames.is_empty() {
if let Err(error) = tokio::fs::create_dir_all(new_root).await {
return Err(ApiError::Filesystem(format!(
"could not create '{new_root}': {error}"
)));
}
}
let mut performed: Vec<PlannedRename> = Vec::new(); let mut performed: Vec<PlannedRename> = Vec::new();
for rename in renames { for rename in renames {
// A recorded file with nothing on disk: the rows still follow the // A recorded file with nothing on disk: the rows still follow the
@@ -123,36 +185,18 @@ pub(crate) async fn relocate_title(
); );
continue; continue;
} }
Err(error) => { Err(error) => return Err(failed(&rename, &error, performed).await),
let failed = ApiError::Filesystem(error.to_string());
Relocation {
performed,
rewrites: Vec::new(),
}
.undo()
.await;
return Err(failed);
}
} }
match tokio::fs::rename(&rename.source, &rename.destination).await { match tokio::fs::rename(&rename.source, &rename.destination).await {
Ok(()) => { Ok(()) => {
tracing::info!( tracing::info!(
source = %rename.source.display(), source = %rename.source.display(),
destination = %rename.destination.display(), destination = %rename.destination.display(),
"moved title folder between roots" "moved a title folder"
); );
performed.push(rename); performed.push(rename);
} }
Err(error) => { Err(error) => return Err(failed(&rename, &error, performed).await),
let failed = ApiError::Filesystem(error.to_string());
Relocation {
performed,
rewrites: Vec::new(),
}
.undo()
.await;
return Err(failed);
}
} }
} }
@@ -162,6 +206,26 @@ pub(crate) async fn relocate_title(
}) })
} }
/// One rename failed: move back everything that had already moved and name
/// the folder that stopped the move, so the operator knows which title to
/// look at before retrying.
async fn failed(
rename: &PlannedRename,
error: &std::io::Error,
performed: Vec<PlannedRename>,
) -> ApiError {
Relocation {
performed,
rewrites: Vec::new(),
}
.undo()
.await;
ApiError::Filesystem(format!(
"could not move '{}': {error}",
rename.source.display()
))
}
impl Relocation { impl Relocation {
/// Whether anything on disk actually moved — the trigger for the same /// Whether anything on disk actually moved — the trigger for the same
/// single Jellyfin refresh import performs (§7.5). /// single Jellyfin refresh import performs (§7.5).
@@ -224,6 +288,43 @@ async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
.await?) .await?)
} }
/// Every file the service recorded under a root: the rows of every movie in
/// it, and the rows of every episode of every series in it. Ordered so the
/// renames happen in a stable order, which is what makes a failure part-way
/// through reproducible.
async fn root_files(state: &AppState, root_id: i64) -> Result<Vec<(i64, String)>, ApiError> {
let mut files: Vec<(i64, String)> = sqlx::query!(
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
FROM media_files mf
JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id
WHERE m.root_id = ?
ORDER BY mf.id"#,
root_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect();
files.extend(
sqlx::query!(
r#"SELECT mf.id AS "id!: i64", mf.path AS "path!: String"
FROM media_files mf
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE s.root_id = ?
ORDER BY mf.id"#,
root_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path)),
);
Ok(files)
}
/// Every file the service recorded for the title: a movie's own rows, or the /// Every file the service recorded for the title: a movie's own rows, or the
/// rows of every episode below a series. /// rows of every episode below a series.
async fn title_files( async fn title_files(
+450 -23
View File
@@ -172,26 +172,74 @@ pub async fn update(
let input = parsed(body)?; let input = parsed(body)?;
input.validate().map_err(ApiError::Invalid)?; input.validate().map_err(ApiError::Invalid)?;
input.policy_exists(&state).await?; input.policy_exists(&state).await?;
let current = load_root(&state, id).await?;
let path = input.path.trim().to_owned(); let path = input.path.trim().to_owned();
let result = sqlx::query!( // A path change moves every §7.4 title folder under this root with the
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?, // row (issue #236), the same way changing a title's root moves one
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') // (#228). Disk first, row second: a failed rename leaves the root row
WHERE id = ?"#, // alone, so the operator sees the library where its files actually are
input.kind, // and can retry. A path already taken is refused before any of it, since
input.audience, // the write would fail afterwards anyway.
path, let relocation = if path == current.path {
input.policy_id, None
id, } else {
) path_is_free(&state, id, &path).await?;
.execute(pool(&state)?) Some(crate::relocate::relocate_root(&state, id, &current.path, &path).await?)
.await };
.map_err(root_conflict)?; let mut transaction = pool(&state)?.begin().await?;
if result.rows_affected() == 0 { let written: Result<(), sqlx::Error> = async {
return Err(ApiError::RootNotFound); sqlx::query!(
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?"#,
input.kind,
input.audience,
path,
input.policy_id,
id,
)
.execute(&mut *transaction)
.await?;
if let Some(relocation) = &relocation {
relocation.rewrite_rows(&mut transaction).await?;
}
Ok(())
}
.await;
let committed = match written {
Ok(()) => transaction.commit().await.map_err(root_conflict),
Err(error) => Err(root_conflict(error)),
};
if let Err(error) = committed {
if let Some(relocation) = &relocation {
relocation.undo().await;
}
return Err(error);
}
if relocation
.as_ref()
.is_some_and(crate::relocate::Relocation::moved_files)
{
crate::relocate::refresh_jellyfin(&state).await;
} }
Ok(Json(load_root(&state, id).await?)) Ok(Json(load_root(&state, id).await?))
} }
/// The unique index on `path` would catch this after the move; catching it
/// first keeps a doomed write from touching the disk at all.
async fn path_is_free(state: &AppState, id: i64, path: &str) -> Result<(), ApiError> {
let taken: Option<i64> =
sqlx::query_scalar!("SELECT id FROM roots WHERE path = ? AND id <> ?", path, id)
.fetch_optional(pool(state)?)
.await?;
if taken.is_some() {
return Err(ApiError::Conflict(
"a root with this path already exists".into(),
));
}
Ok(())
}
/// A duplicate path or a duplicate (kind, audience) pair is a settings /// A duplicate path or a duplicate (kind, audience) pair is a settings
/// mistake the operator can fix, not a server fault. /// mistake the operator can fix, not a server fault.
fn root_conflict(error: sqlx::Error) -> ApiError { fn root_conflict(error: sqlx::Error) -> ApiError {
@@ -258,7 +306,7 @@ mod tests {
use crate::{router, AppState, Upstreams}; use crate::{router, AppState, Upstreams};
use axum::http::StatusCode; use axum::http::StatusCode;
async fn application() -> (tempfile::TempDir, String) { async fn application() -> (tempfile::TempDir, AppState, String) {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db")) let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await .await
@@ -274,14 +322,14 @@ mod tests {
.await .await
.expect("bind"); .expect("bind");
let address = listener.local_addr().expect("address"); let address = listener.local_addr().expect("address");
let app = router(state); let app = router(state.clone());
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
(dir, format!("http://{address}")) (dir, state, format!("http://{address}"))
} }
#[tokio::test] #[tokio::test]
async fn roots_carry_their_policy_name() { async fn roots_carry_their_policy_name() {
let (_dir, base) = application().await; let (_dir, _state, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots")) let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await .await
.expect("roots") .expect("roots")
@@ -325,7 +373,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_root_round_trips_through_create_and_update() { async fn a_root_round_trips_through_create_and_update() {
let (_dir, base) = application().await; let (_dir, _state, base) = application().await;
let policy_ids = first_policy_ids(&base).await; let policy_ids = first_policy_ids(&base).await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots")) let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await .await
@@ -399,7 +447,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() { async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() {
let (_dir, base) = application().await; let (_dir, _state, base) = application().await;
let policy_ids = first_policy_ids(&base).await; let policy_ids = first_policy_ids(&base).await;
for (mut payload, field) in [ for (mut payload, field) in [
@@ -428,7 +476,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_duplicate_path_or_pair_conflicts() { async fn a_duplicate_path_or_pair_conflicts() {
let (_dir, base) = application().await; let (_dir, _state, base) = application().await;
let mut path = root_input(1); let mut path = root_input(1);
path["audience"] = serde_json::json!("kids"); path["audience"] = serde_json::json!("kids");
path["path"] = serde_json::json!("/mnt/media/movies/kids"); path["path"] = serde_json::json!("/mnt/media/movies/kids");
@@ -454,7 +502,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn a_root_with_titles_refuses_to_die() { async fn a_root_with_titles_refuses_to_die() {
let (_dir, base) = application().await; let (_dir, _state, base) = application().await;
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots")) let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
.await .await
.expect("roots") .expect("roots")
@@ -495,4 +543,383 @@ mod tests {
.expect("delete empty root"); .expect("delete empty root");
assert_eq!(response.status(), StatusCode::NO_CONTENT); assert_eq!(response.status(), StatusCode::NO_CONTENT);
} }
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 add_movie(base: &str, tmdb_id: i64, title: &str, root_id: i64) -> i64 {
let created: serde_json::Value = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": tmdb_id, "title": title, "year": 2024,
"original_language": "en", "root_id": root_id,
}))
.send()
.await
.expect("create movie")
.json()
.await
.expect("movie json");
created["id"].as_i64().expect("movie id")
}
/// One movie's §7.4 folder on disk with a feature in it, and the
/// `media_files` row that points at the feature.
async fn library_folder(
state: &AppState,
movie_id: i64,
root: &std::path::Path,
folder: &str,
) -> std::path::PathBuf {
let folder = root.join(folder);
tokio::fs::create_dir_all(&folder)
.await
.expect("create title folder");
let feature = folder.join("feature.mkv");
tokio::fs::write(&feature, b"feature").await.expect("write");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('movie', ?, ?, 7)",
)
.bind(movie_id)
.bind(feature.to_str().expect("utf-8 path"))
.execute(state.database().expect("database").pool())
.await
.expect("media file");
folder
}
async fn root_payload(base: &str, id: i64, path: &str) -> serde_json::Value {
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
.await
.expect("root")
.json()
.await
.expect("root json");
serde_json::json!({
"kind": root["kind"],
"audience": root["audience"],
"path": path,
"policy_id": root["policy_id"],
})
}
async fn file_paths(state: &AppState) -> Vec<String> {
sqlx::query_scalar("SELECT path FROM media_files ORDER BY id")
.fetch_all(state.database().expect("database").pool())
.await
.expect("media files")
}
async fn stored_path(base: &str, id: i64) -> String {
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
.await
.expect("root")
.json()
.await
.expect("root json");
root["path"].as_str().expect("path").to_owned()
}
/// Issue #236: changing a root's path renames every §7.4 title folder
/// under it into the new path and rewrites the `media_files` rows in the
/// same write. Titles under other roots are not this root's business.
#[tokio::test]
async fn changing_a_root_path_moves_every_title_under_it() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let other = tempfile::tempdir().expect("other root");
point_root_at(&state, 1, old.path()).await;
point_root_at(&state, 2, other.path()).await;
let mut folders = Vec::new();
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
let id = add_movie(&base, tmdb_id, title, 1).await;
folders.push(library_folder(&state, id, old.path(), title).await);
}
let elsewhere = add_movie(&base, 200, "Prisoners", 2).await;
let untouched = library_folder(&state, elsewhere, other.path(), "Prisoners").await;
// The new path need not exist yet — pointing a root somewhere fresh
// is the ordinary case.
let new = old.path().parent().expect("parent").join("relocated-main");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
for folder in &folders {
assert!(!folder.exists(), "{} left the old path", folder.display());
}
for title in ["Dune", "Arrival", "Sicario"] {
assert!(
new.join(title).join("feature.mkv").exists(),
"{title} arrived under the new path"
);
}
assert!(
untouched.join("feature.mkv").exists(),
"a title under another root is untouched"
);
let paths = file_paths(&state).await;
for path in paths.iter().take(3) {
assert!(
std::path::Path::new(path).starts_with(&new),
"the row follows the file: {path}"
);
assert!(
std::path::Path::new(path).exists(),
"the rewritten path describes the disk: {path}"
);
}
assert!(
std::path::Path::new(&paths[3]).starts_with(other.path()),
"the other root's row is untouched: {}",
paths[3]
);
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
tokio::fs::remove_dir_all(&new).await.expect("clean up");
}
/// Issue #236: a root with nothing under it changes path with no
/// filesystem work at all — the new path is not even created.
#[tokio::test]
async fn an_empty_root_changes_path_with_no_filesystem_work() {
let (_dir, state, base) = application().await;
let home = tempfile::tempdir().expect("home");
let new = home.path().join("nothing-here");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
assert!(!new.exists(), "nothing was created on disk");
assert!(file_paths(&state).await.is_empty());
}
/// Issue #236: a destination already holding a folder of that name is a
/// conflict, not an overwrite, and it is caught before anything moves.
#[tokio::test]
async fn a_squatted_destination_refuses_the_whole_move() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let new = tempfile::tempdir().expect("new root");
point_root_at(&state, 1, old.path()).await;
let first = add_movie(&base, 100, "Dune", 1).await;
let second = add_movie(&base, 101, "Arrival", 1).await;
let dune = library_folder(&state, first, old.path(), "Dune").await;
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
tokio::fs::create_dir_all(new.path().join("Arrival"))
.await
.expect("squatter");
let before = file_paths(&state).await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(dune.exists(), "not even the first folder moved");
assert!(arrival.exists());
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
assert_eq!(
stored_path(&base, 1).await,
old.path().to_str().expect("utf-8"),
"the root still points where the files are"
);
}
/// Issue #236: the move is all or nothing. A folder that cannot be
/// renamed part-way through takes the whole change down: what had already
/// moved is moved back, the root row keeps the old path, and the rows
/// still describe the disk, so the operator can fix the folder and send
/// the same request again.
#[cfg(unix)]
#[tokio::test]
async fn one_folder_that_cannot_move_puts_the_others_back() {
use std::os::unix::fs::PermissionsExt;
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let home = tempfile::tempdir().expect("home of the new path");
let new = home.path().join("relocated-main");
point_root_at(&state, 1, old.path()).await;
let mut folders = Vec::new();
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
let id = add_movie(&base, tmdb_id, title, 1).await;
folders.push(library_folder(&state, id, old.path(), title).await);
}
// Moving a directory to another parent rewrites its `..`, which needs
// write permission on the directory itself: the second title cannot
// move, the first already has.
let stuck = folders[1].clone();
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
.await
.expect("freeze the second title folder");
let before = file_paths(&state).await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body: serde_json::Value = response.json().await.expect("error body");
assert!(
body["error"]
.as_str()
.expect("text")
.contains(stuck.to_str().expect("utf-8")),
"the error names the folder that stopped the move: {body}"
);
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
.await
.expect("thaw the second title folder");
for folder in &folders {
assert!(
folder.join("feature.mkv").exists(),
"{} is back where the row says it is",
folder.display()
);
}
assert!(
new.exists(),
"the move reached the disk: the new path was created for it"
);
assert!(
!new.join("Dune").exists(),
"the folder that had already moved was moved back"
);
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
assert_eq!(
stored_path(&base, 1).await,
old.path().to_str().expect("utf-8"),
"the root still points where the files are"
);
// The same request again, with the folder fixed, is the retry path.
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("retry the move");
assert_eq!(response.status(), StatusCode::OK);
for title in ["Dune", "Arrival", "Sicario"] {
assert!(new.join(title).join("feature.mkv").exists());
}
}
/// Issue #236: a TV root carries series folders, whose files hang off
/// episodes rather than off the title row. They move with the root too.
#[tokio::test]
async fn a_tv_root_moves_its_series_folders() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let home = tempfile::tempdir().expect("home of the new path");
let new = home.path().join("relocated-tv");
point_root_at(&state, 3, old.path()).await;
let pool = state.database().expect("database").pool();
sqlx::query(
"INSERT INTO series (id, tmdb_id, title, year, original_language, root_id)
VALUES (1, 82728, 'Bluey', 2018, 'en', 3)",
)
.execute(pool)
.await
.expect("series");
sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)")
.execute(pool)
.await
.expect("season");
sqlx::query(
"INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 2, 'Hospital')",
)
.execute(pool)
.await
.expect("episode");
let season = old
.path()
.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01");
tokio::fs::create_dir_all(&season)
.await
.expect("create season folder");
let episode = season.join("Bluey - S01E02.mkv");
tokio::fs::write(&episode, b"episode").await.expect("write");
sqlx::query(
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', 1, ?, 9)",
)
.bind(episode.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("media file");
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/3"))
.json(&root_payload(&base, 3, new.to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::OK);
assert!(
new.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01")
.join("Bluey - S01E02.mkv")
.exists(),
"the whole series folder moved, season layout intact"
);
let paths = file_paths(&state).await;
assert!(
std::path::Path::new(&paths[0]).starts_with(&new)
&& std::path::Path::new(&paths[0]).exists(),
"the episode row follows the file: {}",
paths[0]
);
}
/// Issue #236: a path another root already holds is refused before the
/// disk is touched at all.
#[tokio::test]
async fn a_path_another_root_holds_is_refused_before_any_move() {
let (_dir, state, base) = application().await;
let old = tempfile::tempdir().expect("old root");
let taken = tempfile::tempdir().expect("taken root");
point_root_at(&state, 1, old.path()).await;
point_root_at(&state, 2, taken.path()).await;
let id = add_movie(&base, 100, "Dune", 1).await;
let folder = library_folder(&state, id, old.path(), "Dune").await;
let response = reqwest::Client::new()
.put(format!("{base}/api/roots/1"))
.json(&root_payload(&base, 1, taken.path().to_str().expect("utf-8")).await)
.send()
.await
.expect("move the root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(folder.exists(), "nothing moved");
assert!(!taken.path().join("Dune").exists());
}
} }