feat(api): move title files on root change

Changing a movie's or series' root previously rewrote root_id and left
the files behind, so the §7.4 layout stopped describing the disk and
the root's policy applied to a library the files were not in. Series
had no root control at all.

All roots share one ZFS dataset, so the move is a rename of the title
folder into the new root, never a copy — hardlinks and the seeding
torrent survive it (§7.3). Disk first, row second: a destination that
already holds the folder is a 409, a failed rename leaves the row
unchanged, and a title with nothing on disk moves with no filesystem
work. media_files rows are rewritten in the same transaction as the
root_id, and a successful move triggers the §7.5 Jellyfin refresh.

Issue #228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-25 09:55:08 +01:00
parent c64c572781
commit bce3d3823d
9 changed files with 770 additions and 6 deletions
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "20dd51ed5a7e54bb156d9a5eaf83621c69971e6dc0784f3d2c011c8e90a001d6"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_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": "3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT path AS \"path!: String\" FROM roots WHERE id = ?",
"describe": {
"columns": [
{
"name": "path!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "6ef45d7e6fc113b578fab91148f9ab5455e7a2d06fc86291e2c6c9696fd08953"
}
@@ -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 WHERE se.series_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": "89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc"
}
+2
View File
@@ -488,6 +488,8 @@ 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.
During transition, write into the existing roots so Jellyfin needs no
reconfiguration and new content appears immediately. Radarr will not touch a
folder it has no record of.
+1
View File
@@ -13,6 +13,7 @@ mod movies;
mod owners;
mod policies;
mod reclassify;
mod relocate;
mod roots;
mod search;
mod series;
+236 -3
View File
@@ -430,9 +430,51 @@ pub async fn update(
}
let wanted = input.wanted.unwrap_or(current.wanted);
let blocked = input.blocked.unwrap_or(current.blocked);
sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id)
.execute(pool(&state)?)
.await?;
// A root change moves the §7.4 title folder with the row (issue #228).
// Disk first, row second: a failed rename leaves the row alone, so the
// operator sees the title where its files actually are and can retry —
// the same ordering `remove_library_files` documents.
let relocation = if root_id == current.root_id {
None
} else {
Some(
crate::relocate::relocate_title(
&state,
crate::relocate::TitleKind::Movie,
id,
current.root_id,
root_id,
)
.await?,
)
};
let mut transaction = pool(&state)?.begin().await?;
let written: Result<(), sqlx::Error> = async {
sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, 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(ApiError::from),
Err(error) => Err(ApiError::from(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;
}
if overrides_changed {
crate::reclassify::movie(&state, id).await?;
}
@@ -1417,6 +1459,197 @@ mod tests {
);
}
/// Point one of the seeded roots at a real directory for the duration of
/// a test.
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");
}
/// Issue #228: changing a movie's root renames its §7.4 folder into the
/// new root — sidecars ride along inside it — and the `media_files` rows
/// follow in the same write.
#[tokio::test]
async fn changing_root_moves_the_title_folder_and_its_rows() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
point_root_at(&state, 2, destination.path()).await;
// The wrong kind of root is still rejected, before anything moves.
let rejected = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 3}))
.send()
.await
.expect("move to a tv root");
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], 2);
assert!(!folder.exists(), "the folder left the old root");
let moved = destination
.path()
.join("Dune Part Two (2024) [tmdbid-693134]");
assert!(
moved
.join("Dune Part Two (2024) [tmdbid-693134] - [2160p].mkv")
.exists(),
"the feature arrived in the new root"
);
assert!(
moved.join("dune.pt.srt").exists(),
"sidecars travel inside the folder"
);
let path: String = sqlx::query_scalar(
"SELECT path FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?",
)
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("media file row");
assert!(
std::path::Path::new(&path).starts_with(destination.path()),
"the row follows the file: {path}"
);
assert!(
std::path::Path::new(&path).exists(),
"the rewritten path describes the disk"
);
}
/// Issue #228: a title with nothing on disk changes root with no
/// filesystem work at all — the seeded root paths do not even exist.
#[tokio::test]
async fn a_movie_with_no_files_changes_root_cleanly() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], 2);
let root_id: i64 = sqlx::query_scalar("SELECT root_id FROM movies WHERE id = ?")
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(root_id, 2);
}
/// Issue #228: a destination already holding a folder of that name is a
/// conflict, not an overwrite — and the row stays where the files are.
#[tokio::test]
async fn a_destination_collision_is_refused_and_the_row_unchanged() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
point_root_at(&state, 2, destination.path()).await;
let squatter = destination
.path()
.join("Dune Part Two (2024) [tmdbid-693134]");
tokio::fs::create_dir_all(&squatter)
.await
.expect("pre-existing folder");
tokio::fs::write(squatter.join("theirs.mkv"), b"not ours")
.await
.expect("write squatter file");
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(folder.exists(), "the folder stayed in the old root");
assert!(
squatter.join("theirs.mkv").exists(),
"the occupant was not overwritten"
);
let (root_id, path): (i64, String) = sqlx::query_as(
"SELECT m.root_id, f.path FROM movies m
JOIN media_files f ON f.owner_kind = 'movie' AND f.owner_id = m.id
WHERE m.id = ?",
)
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(root_id, 1, "the row is unchanged");
assert!(std::path::Path::new(&path).starts_with(source.path()));
}
/// Issue #228: if the rename fails, the row must not change — the
/// operator sees the title where its files actually are and can retry.
#[tokio::test]
async fn a_failed_rename_leaves_the_row_alone() {
let (_dir, state, base) = application().await;
let movie = add_movie(&base, 693_134, 1).await;
let id = movie["id"].as_i64().expect("id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = library_on_disk(&state, id, source.path()).await;
// A destination whose parent does not exist makes the rename itself
// fail while the collision pre-check still passes.
point_root_at(
&state,
2,
&destination.path().join("missing").join("library"),
)
.await;
let response = reqwest::Client::new()
.patch(format!("{base}/api/movies/{id}"))
.json(&serde_json::json!({"root_id": 2}))
.send()
.await
.expect("move root");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(folder.exists(), "the folder never left the old root");
let (root_id, path): (i64, String) = sqlx::query_as(
"SELECT m.root_id, f.path FROM movies m
JOIN media_files f ON f.owner_kind = 'movie' AND f.owner_id = m.id
WHERE m.id = ?",
)
.bind(id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(root_id, 1, "the row is unchanged");
assert!(std::path::Path::new(&path).starts_with(source.path()));
}
#[test]
fn a_title_target_is_the_folder_directly_under_the_root() {
let root = "/mnt/media/movies/main";
+259
View File
@@ -0,0 +1,259 @@
//! 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.
//!
//! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this
//! is a directory rename, never a copy. Hardlinked files keep their inodes
//! and the torrent keeps seeding against them (§7.3).
//!
//! 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.
use std::path::PathBuf;
use crate::movies::{pool, title_target, ApiError};
use crate::state::AppState;
/// Which table owns the moving title's files.
#[derive(Debug, Clone, Copy)]
pub(crate) enum TitleKind {
Movie,
Series,
}
/// One rename from the old root into the new one: a §7.4 title folder, or a
/// loose file sitting straight in the root.
#[derive(Debug)]
struct PlannedRename {
source: PathBuf,
destination: PathBuf,
}
/// The renames already performed on disk and the row rewrites they imply.
/// The database half is the caller's transaction; [`Self::undo`] is for when
/// that transaction fails after the disk already changed.
#[derive(Debug)]
pub(crate) struct Relocation {
performed: Vec<PlannedRename>,
rewrites: Vec<(i64, String)>,
}
/// Rename the title's folders into the new root. Called before the row is
/// written, and only when the root actually changes.
///
/// A title with nothing on disk — no `media_files` rows, or rows whose
/// targets are already gone — changes root with no filesystem work at all.
///
/// # Errors
///
/// [`ApiError::Conflict`] when the destination already holds an entry of the
/// same name — a conflict, never an overwrite. [`ApiError::Filesystem`] when
/// a rename fails; whatever had already been renamed is moved back first, so
/// the row the caller then leaves unchanged still describes the disk.
pub(crate) async fn relocate_title(
state: &AppState,
kind: TitleKind,
title_id: i64,
old_root_id: i64,
new_root_id: i64,
) -> Result<Relocation, ApiError> {
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?;
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 {
// 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");
continue;
};
let Some(name) = source.file_name() else {
continue;
};
let destination = std::path::Path::new(&new_root).join(name);
if !renames.iter().any(|rename| rename.source == source) {
renames.push(PlannedRename {
source,
destination,
});
}
let relative = std::path::Path::new(path)
.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 Some(rewritten) = rewritten.to_str() else {
return Err(ApiError::Filesystem(format!(
"non-UTF-8 path under {new_root}"
)));
};
rewrites.push((*file_id, rewritten.to_owned()));
}
// Every destination is checked before anything is renamed, so a conflict
// never leaves a half-moved title behind.
for rename in &renames {
match tokio::fs::symlink_metadata(&rename.destination).await {
Ok(_) => {
return Err(ApiError::Conflict(format!(
"the destination root already has '{}'",
rename.destination.display()
)))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(ApiError::Filesystem(error.to_string())),
}
}
let mut performed: Vec<PlannedRename> = Vec::new();
for rename in renames {
// A recorded file with nothing on disk: the rows still follow the
// title, the same way a delete treats already-gone as done. Checked
// on the source, so a missing *destination* parent stays an error.
match tokio::fs::symlink_metadata(&rename.source).await {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(
source = %rename.source.display(),
"nothing on disk to move for this title"
);
continue;
}
Err(error) => {
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 {
Ok(()) => {
tracing::info!(
source = %rename.source.display(),
destination = %rename.destination.display(),
"moved title folder between roots"
);
performed.push(rename);
}
Err(error) => {
let failed = ApiError::Filesystem(error.to_string());
Relocation {
performed,
rewrites: Vec::new(),
}
.undo()
.await;
return Err(failed);
}
}
}
Ok(Relocation {
performed,
rewrites,
})
}
impl Relocation {
/// Whether anything on disk actually moved — the trigger for the same
/// single Jellyfin refresh import performs (§7.5).
pub(crate) fn moved_files(&self) -> bool {
!self.performed.is_empty()
}
/// Point the `media_files` rows at the new root, inside the caller's
/// transaction so they land together with the `root_id` change or not at
/// all.
pub(crate) async fn rewrite_rows(
&self,
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
) -> Result<(), sqlx::Error> {
for (file_id, path) in &self.rewrites {
sqlx::query!(
"UPDATE media_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
path,
file_id
)
.execute(&mut **transaction)
.await?;
}
Ok(())
}
/// Best-effort reversal of the renames, for when the disk moved but the
/// database write failed. A reversal that itself fails is logged: at that
/// point the operator's retry is the recovery path.
pub(crate) async fn undo(&self) {
for rename in self.performed.iter().rev() {
if let Err(error) = tokio::fs::rename(&rename.destination, &rename.source).await {
tracing::error!(
source = %rename.source.display(),
destination = %rename.destination.display(),
%error,
"could not move the title folder back after a failed root change"
);
}
}
}
}
/// §7.5 after a move: the same single best-effort refresh import performs.
/// Failure logs and never fails the write that already committed.
pub(crate) async fn refresh_jellyfin(state: &AppState) {
if let Some(jellyfin) = state.jellyfin() {
if let Err(error) = jellyfin.refresh().await {
tracing::warn!(%error, "jellyfin refresh after a root change failed");
}
}
}
async fn root_path(state: &AppState, root_id: i64) -> Result<String, ApiError> {
Ok(sqlx::query_scalar!(
r#"SELECT path AS "path!: String" FROM roots WHERE id = ?"#,
root_id
)
.fetch_one(pool(state)?)
.await?)
}
/// 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(
state: &AppState,
kind: TitleKind,
title_id: i64,
) -> Result<Vec<(i64, String)>, ApiError> {
Ok(match kind {
TitleKind::Movie => sqlx::query!(
r#"SELECT id AS "id!: i64", path AS "path!: String"
FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect(),
TitleKind::Series => 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
WHERE se.series_id = ?"#,
title_id
)
.fetch_all(pool(state)?)
.await?
.into_iter()
.map(|row| (row.id, row.path))
.collect(),
})
}
+158 -3
View File
@@ -582,9 +582,51 @@ pub async fn update(
let auto_track = input.auto_track.unwrap_or(current.auto_track);
let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended);
let blocked = input.blocked.unwrap_or(current.blocked);
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id)
.execute(pool(&state)?)
.await?;
// A root change moves the §7.4 title folder with the row (issue #228).
// Disk first, row second: a failed rename leaves the row alone, so the
// operator sees the title where its files actually are and can retry —
// the same ordering `remove_library_files` documents.
let relocation = if root_id == current.root_id {
None
} else {
Some(
crate::relocate::relocate_title(
&state,
crate::relocate::TitleKind::Series,
id,
current.root_id,
root_id,
)
.await?,
)
};
let mut transaction = pool(&state)?.begin().await?;
let written: Result<(), sqlx::Error> = async {
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, 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(ApiError::from),
Err(error) => Err(ApiError::from(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;
}
if overrides_changed {
crate::reclassify::series(&state, id).await?;
}
@@ -3654,6 +3696,119 @@ mod tests {
);
}
/// Issue #228: changing a series' root renames its §7.4 title folder —
/// season subfolders inside it — into the new root, and every episode's
/// `media_files` row follows.
#[tokio::test]
async fn changing_root_moves_the_series_folder_and_its_rows() {
let (_dir, state, base) = application().await;
let main_root = tv_root(&state, "main").await;
let kids_root = tv_root(&state, "kids").await;
let series = add_series(&base, main_root, false).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(
&base,
series_id,
1,
serde_json::json!([{ "number": 1, "title": "Magic Xylophone" }]),
)
.await;
let season_id = season["id"].as_i64().expect("season id");
let episode_id: i64 =
sqlx::query_scalar("SELECT id FROM episodes WHERE season_id = ? AND number = 1")
.bind(season_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("episode id");
let source = tempfile::tempdir().expect("source root");
let destination = tempfile::tempdir().expect("destination root");
let folder = source.path().join("Bluey (2018) [tmdbid-82728]");
let episode_file = folder.join("Season 01").join("Bluey (2018) - S01E01.mkv");
tokio::fs::create_dir_all(folder.join("Season 01"))
.await
.expect("create season folder");
tokio::fs::write(&episode_file, b"episode")
.await
.expect("write episode");
let pool = state.database().expect("database").pool();
for (root, path) in [(main_root, source.path()), (kids_root, destination.path())] {
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
.bind(path.to_str().expect("utf-8 root"))
.bind(root)
.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(episode_file.to_str().expect("utf-8 path"))
.execute(pool)
.await
.expect("media file");
// The wrong kind of root is still rejected, before anything moves.
let rejected = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"root_id": 1}))
.send()
.await
.expect("move to a movie root");
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"root_id": kids_root}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], kids_root);
assert!(!folder.exists(), "the folder left the old root");
let moved = destination
.path()
.join("Bluey (2018) [tmdbid-82728]")
.join("Season 01")
.join("Bluey (2018) - S01E01.mkv");
assert!(moved.exists(), "the episode arrived, season folder intact");
let path: String = sqlx::query_scalar(
"SELECT path FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?",
)
.bind(episode_id)
.fetch_one(pool)
.await
.expect("media file row");
assert_eq!(path, moved.to_str().expect("utf-8 path").to_owned());
}
/// Issue #228: a series with nothing on disk changes root with no
/// filesystem work at all.
#[tokio::test]
async fn a_series_with_no_files_changes_root_cleanly() {
let (_dir, state, base) = application().await;
let main_root = tv_root(&state, "main").await;
let kids_root = tv_root(&state, "kids").await;
let series = add_series(&base, main_root, false).await;
let series_id = series["id"].as_i64().expect("id");
let updated: serde_json::Value = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}"))
.json(&serde_json::json!({"root_id": kids_root}))
.send()
.await
.expect("move root")
.json()
.await
.expect("updated json");
assert_eq!(updated["root_id"], kids_root);
}
#[test]
fn air_dates_parse_as_dates_and_as_timestamps() {
assert_eq!(