+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -8,5 +8,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0a27365a669aa217ae7dae675ebf54c4e4a343c4b56c4a44eb56d1a26c2aea4b"
|
||||
"hash": "2231fa4a963a5f1a15dcdb835fb7c2d970ad55a8fd62b12aadd68ee6a261ed41"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
reconfiguration and new content appears immediately. Radarr will not touch a
|
||||
|
||||
+133
-32
@@ -1,7 +1,8 @@
|
||||
//! Moving a title between roots (issue #228). Changing a title's `root_id`
|
||||
//! renames its §7.4 folder into the new root and rewrites the `media_files`
|
||||
//! rows to match, so the layout keeps describing the disk and the new root's
|
||||
//! policy (§5.1) applies to a library the files are actually in.
|
||||
//! Moving library files when the layout under them changes: a title changing
|
||||
//! its `root_id` (issue #228), and a root changing its `path` (issue #236).
|
||||
//! Both rename §7.4 folders and rewrite the `media_files` rows to match, so
|
||||
//! 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
|
||||
//! 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
|
||||
//! row changes, so a failed rename leaves the title where its files actually
|
||||
//! 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;
|
||||
|
||||
@@ -40,6 +51,15 @@ pub(crate) struct Relocation {
|
||||
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
|
||||
/// 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 new_root = root_path(state, new_root_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 rewrites: Vec<(i64, String)> = Vec::new();
|
||||
for (file_id, path) in &files {
|
||||
let Some(source) = title_target(&old_root, path) else {
|
||||
for (file_id, path) in files {
|
||||
let Some(source) = title_target(old_root, path) else {
|
||||
// Outside its own root: not ours to move, and the row keeps
|
||||
// pointing at where the file really is.
|
||||
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 {
|
||||
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) {
|
||||
renames.push(PlannedRename {
|
||||
source,
|
||||
@@ -83,9 +137,9 @@ pub(crate) async fn relocate_title(
|
||||
});
|
||||
}
|
||||
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()))?;
|
||||
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 {
|
||||
return Err(ApiError::Filesystem(format!(
|
||||
"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
|
||||
// never leaves a half-moved title behind.
|
||||
// never leaves a half-moved library behind.
|
||||
for rename in &renames {
|
||||
match tokio::fs::symlink_metadata(&rename.destination).await {
|
||||
Ok(_) => {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"the destination root already has '{}'",
|
||||
"the destination already has '{}'",
|
||||
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();
|
||||
for rename in renames {
|
||||
// A recorded file with nothing on disk: the rows still follow the
|
||||
@@ -123,36 +185,18 @@ pub(crate) async fn relocate_title(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
let failed = ApiError::Filesystem(error.to_string());
|
||||
Relocation {
|
||||
performed,
|
||||
rewrites: Vec::new(),
|
||||
}
|
||||
.undo()
|
||||
.await;
|
||||
return Err(failed);
|
||||
}
|
||||
Err(error) => return Err(failed(&rename, &error, performed).await),
|
||||
}
|
||||
match tokio::fs::rename(&rename.source, &rename.destination).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
source = %rename.source.display(),
|
||||
destination = %rename.destination.display(),
|
||||
"moved title folder between roots"
|
||||
"moved a title folder"
|
||||
);
|
||||
performed.push(rename);
|
||||
}
|
||||
Err(error) => {
|
||||
let failed = ApiError::Filesystem(error.to_string());
|
||||
Relocation {
|
||||
performed,
|
||||
rewrites: Vec::new(),
|
||||
}
|
||||
.undo()
|
||||
.await;
|
||||
return Err(failed);
|
||||
}
|
||||
Err(error) => return Err(failed(&rename, &error, performed).await),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// Whether anything on disk actually moved — the trigger for the same
|
||||
/// single Jellyfin refresh import performs (§7.5).
|
||||
@@ -224,6 +288,43 @@ async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
|
||||
.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
|
||||
/// rows of every episode below a series.
|
||||
async fn title_files(
|
||||
|
||||
+450
-23
@@ -172,26 +172,74 @@ pub async fn update(
|
||||
let input = parsed(body)?;
|
||||
input.validate().map_err(ApiError::Invalid)?;
|
||||
input.policy_exists(&state).await?;
|
||||
let current = load_root(&state, id).await?;
|
||||
let path = input.path.trim().to_owned();
|
||||
let result = 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(pool(&state)?)
|
||||
.await
|
||||
.map_err(root_conflict)?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::RootNotFound);
|
||||
// A path change moves every §7.4 title folder under this root with the
|
||||
// row (issue #236), the same way changing a title's root moves one
|
||||
// (#228). Disk first, row second: a failed rename leaves the root row
|
||||
// alone, so the operator sees the library where its files actually are
|
||||
// and can retry. A path already taken is refused before any of it, since
|
||||
// the write would fail afterwards anyway.
|
||||
let relocation = if path == current.path {
|
||||
None
|
||||
} else {
|
||||
path_is_free(&state, id, &path).await?;
|
||||
Some(crate::relocate::relocate_root(&state, id, ¤t.path, &path).await?)
|
||||
};
|
||||
let mut transaction = pool(&state)?.begin().await?;
|
||||
let written: Result<(), sqlx::Error> = async {
|
||||
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?))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// mistake the operator can fix, not a server fault.
|
||||
fn root_conflict(error: sqlx::Error) -> ApiError {
|
||||
@@ -258,7 +306,7 @@ mod tests {
|
||||
use crate::{router, AppState, Upstreams};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
async fn application() -> (tempfile::TempDir, String) {
|
||||
async fn application() -> (tempfile::TempDir, AppState, String) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
||||
.await
|
||||
@@ -274,14 +322,14 @@ mod tests {
|
||||
.await
|
||||
.expect("bind");
|
||||
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") });
|
||||
(dir, format!("http://{address}"))
|
||||
(dir, state, format!("http://{address}"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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"))
|
||||
.await
|
||||
.expect("roots")
|
||||
@@ -325,7 +373,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
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 roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
||||
.await
|
||||
@@ -399,7 +447,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
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;
|
||||
|
||||
for (mut payload, field) in [
|
||||
@@ -428,7 +476,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
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);
|
||||
path["audience"] = serde_json::json!("kids");
|
||||
path["path"] = serde_json::json!("/mnt/media/movies/kids");
|
||||
@@ -454,7 +502,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
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"))
|
||||
.await
|
||||
.expect("roots")
|
||||
@@ -495,4 +543,383 @@ mod tests {
|
||||
.expect("delete empty root");
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user