From c9369d1c4fd07be5d5a18b3024e753f10f41918f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 18:10:52 +0100 Subject: [PATCH] fix(arr): rewrite subtitle_files paths on relocate relocate.rs gathered only media_files rows for a title/root move, leaving subtitle_files rows pointing at the old folder. Sidecars already ride along in the folder rename; only their rows were stale. Tags each rewrite with its owning table (media_files or subtitle_files) and updates both in the same transaction. --- ...7f39c29afc1883e3a3607260455d1a088143.json} | 4 +- ...1f7602111b1dc5614bf2f8c8626744ecb22db.json | 38 ++++ ...edf02b1e1f91482c8784357d2ef61c9d160c1.json | 38 ++++ ...25c73a16ee7dd38bfdf2d7166db95d663dbf4.json | 38 ++++ ...74ac66d4307936323b1eff9440adb4c34b893.json | 12 ++ ...95b8b43100b1f4d8ac4cabc7c3bef9fefd745.json | 38 ++++ ...f38d57c6f846bb8eff74958fd4d1299268f8.json} | 4 +- crates/arr-api/src/movies.rs | 60 ++++++ crates/arr-api/src/relocate.rs | 200 +++++++++++++----- 9 files changed, 377 insertions(+), 55 deletions(-) rename .sqlx/{query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json => query-028ba37575a1081f7678fd8649227f39c29afc1883e3a3607260455d1a088143.json} (80%) create mode 100644 .sqlx/query-295476f5ee76f552a0d0727fca01f7602111b1dc5614bf2f8c8626744ecb22db.json create mode 100644 .sqlx/query-5153426ad8b36b6a2b8c17942dfedf02b1e1f91482c8784357d2ef61c9d160c1.json create mode 100644 .sqlx/query-92a3d21df1ee60492a1e0742f1425c73a16ee7dd38bfdf2d7166db95d663dbf4.json create mode 100644 .sqlx/query-9b129ff8a5951c9dfcb9778da0174ac66d4307936323b1eff9440adb4c34b893.json create mode 100644 .sqlx/query-a1256d6eec88a6ce5c308a7b96d95b8b43100b1f4d8ac4cabc7c3bef9fefd745.json rename .sqlx/{query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json => query-a2023f024d53aa5f139da347f22af38d57c6f846bb8eff74958fd4d1299268f8.json} (68%) diff --git a/.sqlx/query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json b/.sqlx/query-028ba37575a1081f7678fd8649227f39c29afc1883e3a3607260455d1a088143.json similarity index 80% rename from .sqlx/query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json rename to .sqlx/query-028ba37575a1081f7678fd8649227f39c29afc1883e3a3607260455d1a088143.json index d962efc..e5a7b56 100644 --- a/.sqlx/query-3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa.json +++ b/.sqlx/query-028ba37575a1081f7678fd8649227f39c29afc1883e3a3607260455d1a088143.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?", + "query": "SELECT id AS \"id!: i64\", path AS \"path!: String\"\n FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?", "describe": { "columns": [ { @@ -34,5 +34,5 @@ false ] }, - "hash": "3be9c350ef24a69490540379225d95d2949fe2a0f1f59d55d6272cca1bd431aa" + "hash": "028ba37575a1081f7678fd8649227f39c29afc1883e3a3607260455d1a088143" } diff --git a/.sqlx/query-295476f5ee76f552a0d0727fca01f7602111b1dc5614bf2f8c8626744ecb22db.json b/.sqlx/query-295476f5ee76f552a0d0727fca01f7602111b1dc5614bf2f8c8626744ecb22db.json new file mode 100644 index 0000000..da5a15a --- /dev/null +++ b/.sqlx/query-295476f5ee76f552a0d0727fca01f7602111b1dc5614bf2f8c8626744ecb22db.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n WHERE mf.owner_kind = 'movie' AND mf.owner_id = ? AND sf.path IS NOT NULL", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "id" + } + } + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "295476f5ee76f552a0d0727fca01f7602111b1dc5614bf2f8c8626744ecb22db" +} diff --git a/.sqlx/query-5153426ad8b36b6a2b8c17942dfedf02b1e1f91482c8784357d2ef61c9d160c1.json b/.sqlx/query-5153426ad8b36b6a2b8c17942dfedf02b1e1f91482c8784357d2ef61c9d160c1.json new file mode 100644 index 0000000..284077b --- /dev/null +++ b/.sqlx/query-5153426ad8b36b6a2b8c17942dfedf02b1e1f91482c8784357d2ef61c9d160c1.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\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 = ? AND sf.path IS NOT NULL", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "id" + } + } + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "5153426ad8b36b6a2b8c17942dfedf02b1e1f91482c8784357d2ef61c9d160c1" +} diff --git a/.sqlx/query-92a3d21df1ee60492a1e0742f1425c73a16ee7dd38bfdf2d7166db95d663dbf4.json b/.sqlx/query-92a3d21df1ee60492a1e0742f1425c73a16ee7dd38bfdf2d7166db95d663dbf4.json new file mode 100644 index 0000000..baa8cd2 --- /dev/null +++ b/.sqlx/query-92a3d21df1ee60492a1e0742f1425c73a16ee7dd38bfdf2d7166db95d663dbf4.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\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 = ? AND sf.path IS NOT NULL\n ORDER BY sf.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "id" + } + } + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "92a3d21df1ee60492a1e0742f1425c73a16ee7dd38bfdf2d7166db95d663dbf4" +} diff --git a/.sqlx/query-9b129ff8a5951c9dfcb9778da0174ac66d4307936323b1eff9440adb4c34b893.json b/.sqlx/query-9b129ff8a5951c9dfcb9778da0174ac66d4307936323b1eff9440adb4c34b893.json new file mode 100644 index 0000000..155eda7 --- /dev/null +++ b/.sqlx/query-9b129ff8a5951c9dfcb9778da0174ac66d4307936323b1eff9440adb4c34b893.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE subtitle_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "9b129ff8a5951c9dfcb9778da0174ac66d4307936323b1eff9440adb4c34b893" +} diff --git a/.sqlx/query-a1256d6eec88a6ce5c308a7b96d95b8b43100b1f4d8ac4cabc7c3bef9fefd745.json b/.sqlx/query-a1256d6eec88a6ce5c308a7b96d95b8b43100b1f4d8ac4cabc7c3bef9fefd745.json new file mode 100644 index 0000000..baa9be3 --- /dev/null +++ b/.sqlx/query-a1256d6eec88a6ce5c308a7b96d95b8b43100b1f4d8ac4cabc7c3bef9fefd745.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT sf.id AS \"id!: i64\", sf.path AS \"path!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id\n JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id\n WHERE m.root_id = ? AND sf.path IS NOT NULL\n ORDER BY sf.id", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "id" + } + } + }, + { + "name": "path!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "a1256d6eec88a6ce5c308a7b96d95b8b43100b1f4d8ac4cabc7c3bef9fefd745" +} diff --git a/.sqlx/query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json b/.sqlx/query-a2023f024d53aa5f139da347f22af38d57c6f846bb8eff74958fd4d1299268f8.json similarity index 68% rename from .sqlx/query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json rename to .sqlx/query-a2023f024d53aa5f139da347f22af38d57c6f846bb8eff74958fd4d1299268f8.json index ade8330..db86695 100644 --- a/.sqlx/query-89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc.json +++ b/.sqlx/query-a2023f024d53aa5f139da347f22af38d57c6f846bb8eff74958fd4d1299268f8.json @@ -1,6 +1,6 @@ { "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 = ?", + "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": [ { @@ -34,5 +34,5 @@ false ] }, - "hash": "89fb1aa140a11bcd9bb082b2dcba50df414a77bc191755d515e5d5905a651adc" + "hash": "a2023f024d53aa5f139da347f22af38d57c6f846bb8eff74958fd4d1299268f8" } diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 5b860e2..96577e9 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -1747,6 +1747,66 @@ mod tests { ); } + /// Issue #237: the folder rename #228 wired up already carries a sidecar + /// with it, but the `subtitle_files` row pointing at the old path did not + /// follow — it went stale silently, and `DELETE /api/subtitles/{id}` + /// (#218) would then report success while leaving the real file on disk. + #[tokio::test] + async fn changing_root_moves_the_subtitle_row_with_its_sidecar() { + 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 pool = state.database().expect("database").pool(); + let media_file_id: i64 = sqlx::query_scalar( + "SELECT id FROM media_files WHERE owner_kind = 'movie' AND owner_id = ?", + ) + .bind(id) + .fetch_one(pool) + .await + .expect("media file id"); + let sidecar = folder.join("dune.pt.srt"); + sqlx::query( + "INSERT INTO subtitle_files (media_file_id, language, origin, provider, path) + VALUES (?, 'pt-PT', 'provider', 'opensubtitles', ?)", + ) + .bind(media_file_id) + .bind(sidecar.to_str().expect("utf-8 path")) + .execute(pool) + .await + .expect("subtitle file"); + + 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 path: String = + sqlx::query_scalar("SELECT path FROM subtitle_files WHERE media_file_id = ?") + .bind(media_file_id) + .fetch_one(pool) + .await + .expect("subtitle file row"); + assert!( + std::path::Path::new(&path).starts_with(destination.path()), + "the subtitle row follows the file: {path}" + ); + assert!( + std::path::Path::new(&path).exists(), + "the rewritten subtitle path describes the disk" + ); + } + /// Reported in production: moving a title into a root whose directory has /// never been written to failed with /// `could not move '...The Batman...': No such file or directory`, naming diff --git a/crates/arr-api/src/relocate.rs b/crates/arr-api/src/relocate.rs index 6ae9af7..723c732 100644 --- a/crates/arr-api/src/relocate.rs +++ b/crates/arr-api/src/relocate.rs @@ -1,9 +1,12 @@ //! 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. This module only moves things: the -//! stored verdicts a root change invalidates (§5.1) are re-derived by the -//! calling handler through `reclassify`, after the row commits. +//! Both rename §7.4 folders and rewrite the `media_files` and `subtitle_files` +//! rows to match, so the layout keeps describing the disk. Sidecars (§15) live +//! inside the same folder as the video, so the rename already carries them — +//! only their rows need rewriting (issue #237). This module only moves +//! things: the stored verdicts a root change invalidates (§5.1) are +//! re-derived by the calling handler through `reclassify`, after the row +//! commits. //! //! Every root shares one filesystem — one ZFS dataset, bind-mounted — so this //! is a directory rename, never a copy. Hardlinked files keep their inodes @@ -35,6 +38,15 @@ pub(crate) enum TitleKind { Series, } +/// Which table a rewritten row belongs to — `rewrite_rows` needs this to +/// target the right `UPDATE`, since a `media_files` id and a `subtitle_files` +/// id share no namespace. +#[derive(Debug, Clone, Copy)] +enum FileTable { + Media, + Subtitle, +} + /// 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)] @@ -49,7 +61,7 @@ struct PlannedRename { #[derive(Debug)] pub(crate) struct Relocation { performed: Vec, - rewrites: Vec<(i64, String)>, + rewrites: Vec<(FileTable, i64, String)>, /// Every directory level this request materialised for the new root, /// deepest first. `create_dir_all` can make more than one — moving a /// root to `/mnt/media-v2/tv/kids` when `/mnt/media-v2` is all that @@ -112,13 +124,13 @@ pub(crate) async fn relocate_root( /// destination that already exists, then perform them, undoing what was /// performed if one fails. async fn relocate_files( - files: &[(i64, String)], + files: &[(FileTable, i64, String)], old_root: &str, new_root: &str, ) -> Result { let mut renames: Vec = Vec::new(); - let mut rewrites: Vec<(i64, String)> = Vec::new(); - for (file_id, path) in files { + let mut rewrites: Vec<(FileTable, i64, String)> = Vec::new(); + for (table, 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. @@ -144,7 +156,7 @@ async fn relocate_files( "non-UTF-8 path under {new_root}" ))); }; - rewrites.push((*file_id, rewritten.to_owned())); + rewrites.push((*table, *file_id, rewritten.to_owned())); } // Every destination is checked before anything is renamed, so a conflict @@ -275,21 +287,34 @@ impl Relocation { !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. + /// Point the `media_files` and `subtitle_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?; + for (table, file_id, path) in &self.rewrites { + match table { + FileTable::Media => { + 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?; + } + FileTable::Subtitle => { + sqlx::query!( + "UPDATE subtitle_files SET path = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", + path, + file_id + ) + .execute(&mut **transaction) + .await?; + } + } } Ok(()) } @@ -346,12 +371,17 @@ async fn root_path(state: &AppState, root_id: i64) -> Result { .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 +/// Every file the service recorded under a root: the `media_files` rows of +/// every movie and episode in it, and the `subtitle_files` sidecars (§15) on +/// those files — an embedded track has no `path` and nothing on disk to +/// carry, so it is excluded rather than rewritten to nothing. 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, ApiError> { - let mut files: Vec<(i64, String)> = sqlx::query!( +async fn root_files( + state: &AppState, + root_id: i64, +) -> Result, ApiError> { + let mut files: Vec<(FileTable, 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 @@ -362,7 +392,7 @@ async fn root_files(state: &AppState, root_id: i64) -> Result .fetch_all(pool(state)?) .await? .into_iter() - .map(|row| (row.id, row.path)) + .map(|row| (FileTable::Media, row.id, row.path)) .collect(); files.extend( sqlx::query!( @@ -378,41 +408,109 @@ async fn root_files(state: &AppState, root_id: i64) -> Result .fetch_all(pool(state)?) .await? .into_iter() - .map(|row| (row.id, row.path)), + .map(|row| (FileTable::Media, row.id, row.path)), + ); + files.extend( + sqlx::query!( + r#"SELECT sf.id AS "id!: i64", sf.path AS "path!: String" + FROM subtitle_files sf + JOIN media_files mf ON mf.id = sf.media_file_id + JOIN movies m ON mf.owner_kind = 'movie' AND m.id = mf.owner_id + WHERE m.root_id = ? AND sf.path IS NOT NULL + ORDER BY sf.id"#, + root_id + ) + .fetch_all(pool(state)?) + .await? + .into_iter() + .map(|row| (FileTable::Subtitle, row.id, row.path)), + ); + files.extend( + sqlx::query!( + r#"SELECT sf.id AS "id!: i64", sf.path AS "path!: String" + FROM subtitle_files sf + JOIN media_files mf ON mf.id = sf.media_file_id + 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 = ? AND sf.path IS NOT NULL + ORDER BY sf.id"#, + root_id + ) + .fetch_all(pool(state)?) + .await? + .into_iter() + .map(|row| (FileTable::Subtitle, 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. +/// rows of every episode below a series — plus the `subtitle_files` sidecars +/// (§15) on those files. An embedded track has no `path` and nothing on disk +/// to carry, so it is excluded rather than rewritten to nothing. async fn title_files( state: &AppState, kind: TitleKind, title_id: i64, -) -> Result, ApiError> { +) -> Result, 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(), + TitleKind::Movie => { + let mut files: Vec<(FileTable, i64, String)> = 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| (FileTable::Media, row.id, row.path)) + .collect(); + files.extend( + sqlx::query!( + r#"SELECT sf.id AS "id!: i64", sf.path AS "path!: String" + FROM subtitle_files sf + JOIN media_files mf ON mf.id = sf.media_file_id + WHERE mf.owner_kind = 'movie' AND mf.owner_id = ? AND sf.path IS NOT NULL"#, + title_id + ) + .fetch_all(pool(state)?) + .await? + .into_iter() + .map(|row| (FileTable::Subtitle, row.id, row.path)), + ); + files + } + TitleKind::Series => { + let mut files: Vec<(FileTable, i64, String)> = 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| (FileTable::Media, row.id, row.path)) + .collect(); + files.extend( + sqlx::query!( + r#"SELECT sf.id AS "id!: i64", sf.path AS "path!: String" + FROM subtitle_files sf + JOIN media_files mf ON mf.id = sf.media_file_id + 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 = ? AND sf.path IS NOT NULL"#, + title_id + ) + .fetch_all(pool(state)?) + .await? + .into_iter() + .map(|row| (FileTable::Subtitle, row.id, row.path)), + ); + files + } }) }