diff --git a/.sqlx/query-05b5757f0fb7e6ab1176387854a14fc8db793871193b6f16e3719d73040e8ce8.json b/.sqlx/query-05b5757f0fb7e6ab1176387854a14fc8db793871193b6f16e3719d73040e8ce8.json new file mode 100644 index 0000000..78909c2 --- /dev/null +++ b/.sqlx/query-05b5757f0fb7e6ab1176387854a14fc8db793871193b6f16e3719d73040e8ce8.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT r.name AS \"name!: String\",\n r.parsed AS \"parsed!: serde_json::Value\"\n FROM episodes e\n JOIN grabs g ON g.target_kind = 'season' AND g.target_id = e.season_id\n JOIN releases r ON r.id = g.release_id\n WHERE e.id = ?\n ORDER BY g.imported_at DESC, g.id DESC LIMIT 1", + "describe": { + "columns": [ + { + "name": "name!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "name" + } + } + }, + { + "name": "parsed!: serde_json::Value", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "parsed" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "05b5757f0fb7e6ab1176387854a14fc8db793871193b6f16e3719d73040e8ce8" +} diff --git a/.sqlx/query-0b40dbc80628244531a044e872238e356ab6bc66567c72008cff506e595932b1.json b/.sqlx/query-0b40dbc80628244531a044e872238e356ab6bc66567c72008cff506e595932b1.json new file mode 100644 index 0000000..fb01335 --- /dev/null +++ b/.sqlx/query-0b40dbc80628244531a044e872238e356ab6bc66567c72008cff506e595932b1.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM episodes WHERE id = ?) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "0b40dbc80628244531a044e872238e356ab6bc66567c72008cff506e595932b1" +} diff --git a/.sqlx/query-233bae03912e50e6bcde4b24a9d7fbe256dbf626e4e236a06ba08a00643566d3.json b/.sqlx/query-233bae03912e50e6bcde4b24a9d7fbe256dbf626e4e236a06ba08a00643566d3.json new file mode 100644 index 0000000..c333a8b --- /dev/null +++ b/.sqlx/query-233bae03912e50e6bcde4b24a9d7fbe256dbf626e4e236a06ba08a00643566d3.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(\n SELECT 1 FROM subtitle_files\n WHERE media_file_id = ? AND language = ? AND forced = 0\n ) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "233bae03912e50e6bcde4b24a9d7fbe256dbf626e4e236a06ba08a00643566d3" +} diff --git a/.sqlx/query-268cb11282b16494330c1655ba3dfcb4f2506686822711f3c6489c8ddd42b0e7.json b/.sqlx/query-268cb11282b16494330c1655ba3dfcb4f2506686822711f3c6489c8ddd42b0e7.json new file mode 100644 index 0000000..2eafbdc --- /dev/null +++ b/.sqlx/query-268cb11282b16494330c1655ba3dfcb4f2506686822711f3c6489c8ddd42b0e7.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT sr.tmdb_id AS \"tmdb_id!: i64\",\n s.number AS \"season!: i64\",\n e.number AS \"episode!: i64\"\n FROM episodes e\n JOIN seasons s ON s.id = e.season_id\n JOIN series sr ON sr.id = s.series_id\n WHERE e.id = ?", + "describe": { + "columns": [ + { + "name": "tmdb_id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "tmdb_id" + } + } + }, + { + "name": "season!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "seasons", + "name": "number" + } + } + }, + { + "name": "episode!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "episodes", + "name": "number" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "268cb11282b16494330c1655ba3dfcb4f2506686822711f3c6489c8ddd42b0e7" +} diff --git a/.sqlx/query-32443dc6e5bdd414340443c5d527b2578bcdeebd053b667db35423d829d4bf10.json b/.sqlx/query-32443dc6e5bdd414340443c5d527b2578bcdeebd053b667db35423d829d4bf10.json new file mode 100644 index 0000000..d4468bd --- /dev/null +++ b/.sqlx/query-32443dc6e5bdd414340443c5d527b2578bcdeebd053b667db35423d829d4bf10.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\",\n path AS \"path!: String\",\n size AS \"size!: i64\",\n owner_kind AS \"owner_kind!: String\",\n owner_id AS \"owner_id!: i64\"\n FROM media_files WHERE 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" + } + } + }, + { + "name": "size!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "size" + } + } + }, + { + "name": "owner_kind!: String", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "media_files", + "name": "owner_kind" + } + } + }, + { + "name": "owner_id!: i64", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "owner_id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "32443dc6e5bdd414340443c5d527b2578bcdeebd053b667db35423d829d4bf10" +} diff --git a/.sqlx/query-432cf38775bc4d7dee1dd9d009e06638ea57f805127f7634f34b8189e831f461.json b/.sqlx/query-432cf38775bc4d7dee1dd9d009e06638ea57f805127f7634f34b8189e831f461.json new file mode 100644 index 0000000..40ce09c --- /dev/null +++ b/.sqlx/query-432cf38775bc4d7dee1dd9d009e06638ea57f805127f7634f34b8189e831f461.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT r.name AS \"name!: String\",\n r.parsed AS \"parsed!: serde_json::Value\"\n FROM grabs g JOIN releases r ON r.id = g.release_id\n WHERE g.target_kind = ? AND g.target_id = ?\n ORDER BY g.imported_at DESC, g.id DESC LIMIT 1", + "describe": { + "columns": [ + { + "name": "name!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "name" + } + } + }, + { + "name": "parsed!: serde_json::Value", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "releases", + "name": "parsed" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false + ] + }, + "hash": "432cf38775bc4d7dee1dd9d009e06638ea57f805127f7634f34b8189e831f461" +} diff --git a/.sqlx/query-4f4f037c956ac6b246503354ff9b59db59e36f9a6d59adc0753b95662c9c630a.json b/.sqlx/query-4f4f037c956ac6b246503354ff9b59db59e36f9a6d59adc0753b95662c9c630a.json new file mode 100644 index 0000000..3351356 --- /dev/null +++ b/.sqlx/query-4f4f037c956ac6b246503354ff9b59db59e36f9a6d59adc0753b95662c9c630a.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT tmdb_id AS \"tmdb_id!: i64\" FROM movies WHERE id = ?", + "describe": { + "columns": [ + { + "name": "tmdb_id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "movies", + "name": "tmdb_id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "4f4f037c956ac6b246503354ff9b59db59e36f9a6d59adc0753b95662c9c630a" +} diff --git a/.sqlx/query-9091de460ba1cb47c9faa24fe37fee03615e7b23b10ceef88ee12c8deb0dbaaf.json b/.sqlx/query-9091de460ba1cb47c9faa24fe37fee03615e7b23b10ceef88ee12c8deb0dbaaf.json new file mode 100644 index 0000000..e6aab24 --- /dev/null +++ b/.sqlx/query-9091de460ba1cb47c9faa24fe37fee03615e7b23b10ceef88ee12c8deb0dbaaf.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT media_file_id AS \"media_file_id!: i64\",\n language AS \"language!: String\",\n path\n FROM subtitle_files WHERE id = ?", + "describe": { + "columns": [ + { + "name": "media_file_id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "media_file_id" + } + } + }, + { + "name": "language!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "language" + } + } + }, + { + "name": "path", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "subtitle_files", + "name": "path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "9091de460ba1cb47c9faa24fe37fee03615e7b23b10ceef88ee12c8deb0dbaaf" +} diff --git a/.sqlx/query-bc6dfe5a5cc639a64e3020c348a2dc74b4298101f8c29d9f8e6397cf79bb83d7.json b/.sqlx/query-bc6dfe5a5cc639a64e3020c348a2dc74b4298101f8c29d9f8e6397cf79bb83d7.json new file mode 100644 index 0000000..7c0680a --- /dev/null +++ b/.sqlx/query-bc6dfe5a5cc639a64e3020c348a2dc74b4298101f8c29d9f8e6397cf79bb83d7.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT providers_enabled AS \"providers_enabled!: String\"\n FROM subtitle_settings WHERE id = 1", + "describe": { + "columns": [ + { + "name": "providers_enabled!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "subtitle_settings", + "name": "providers_enabled" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "bc6dfe5a5cc639a64e3020c348a2dc74b4298101f8c29d9f8e6397cf79bb83d7" +} diff --git a/.sqlx/query-d81ec62e6c4879d08ce3551c0c71df012bed05ac2a37933adc2deb6552eeeac1.json b/.sqlx/query-d81ec62e6c4879d08ce3551c0c71df012bed05ac2a37933adc2deb6552eeeac1.json new file mode 100644 index 0000000..6057bf2 --- /dev/null +++ b/.sqlx/query-d81ec62e6c4879d08ce3551c0c71df012bed05ac2a37933adc2deb6552eeeac1.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM movies WHERE id = ?) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "d81ec62e6c4879d08ce3551c0c71df012bed05ac2a37933adc2deb6552eeeac1" +} diff --git a/.sqlx/query-e3b82dc7b2a3b28753c84864239699cb2007a8101cccc22d223d1f587a5533f8.json b/.sqlx/query-e3b82dc7b2a3b28753c84864239699cb2007a8101cccc22d223d1f587a5533f8.json new file mode 100644 index 0000000..f22d82c --- /dev/null +++ b/.sqlx/query-e3b82dc7b2a3b28753c84864239699cb2007a8101cccc22d223d1f587a5533f8.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM subtitle_files WHERE path = ?) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "e3b82dc7b2a3b28753c84864239699cb2007a8101cccc22d223d1f587a5533f8" +} diff --git a/.sqlx/query-e7a0291c2fb8cfe59a02ffef11d9657ea6a40e4079573f569573075a205c50e7.json b/.sqlx/query-e7a0291c2fb8cfe59a02ffef11d9657ea6a40e4079573f569573075a205c50e7.json new file mode 100644 index 0000000..c3abdb0 --- /dev/null +++ b/.sqlx/query-e7a0291c2fb8cfe59a02ffef11d9657ea6a40e4079573f569573075a205c50e7.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT translation_engine AS \"translation_engine: String\"\n FROM subtitle_settings WHERE id = 1", + "describe": { + "columns": [ + { + "name": "translation_engine: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "subtitle_settings", + "name": "translation_engine" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true + ] + }, + "hash": "e7a0291c2fb8cfe59a02ffef11d9657ea6a40e4079573f569573075a205c50e7" +} diff --git a/.sqlx/query-e8066bed6fb53ff90d78f47bb1c9ce8160918d4b35764fd988e5e1dd6c101089.json b/.sqlx/query-e8066bed6fb53ff90d78f47bb1c9ce8160918d4b35764fd988e5e1dd6c101089.json new file mode 100644 index 0000000..ad138bf --- /dev/null +++ b/.sqlx/query-e8066bed6fb53ff90d78f47bb1c9ce8160918d4b35764fd988e5e1dd6c101089.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM media_files WHERE id = ?) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "e8066bed6fb53ff90d78f47bb1c9ce8160918d4b35764fd988e5e1dd6c101089" +} diff --git a/.sqlx/query-fcba9e1f708e6bf2d8471d0c7d26be0b5ad8a30fa042434ffaaff8c69ccf31de.json b/.sqlx/query-fcba9e1f708e6bf2d8471d0c7d26be0b5ad8a30fa042434ffaaff8c69ccf31de.json new file mode 100644 index 0000000..be8bf64 --- /dev/null +++ b/.sqlx/query-fcba9e1f708e6bf2d8471d0c7d26be0b5ad8a30fa042434ffaaff8c69ccf31de.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: i64\" FROM media_files\n WHERE owner_kind = ? AND owner_id = ? ORDER BY path", + "describe": { + "columns": [ + { + "name": "id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "id" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "fcba9e1f708e6bf2d8471d0c7d26be0b5ad8a30fa042434ffaaff8c69ccf31de" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index df6c6a6..02534a5 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -16,6 +16,7 @@ mod search; mod series; mod state; mod subtitle_settings; +mod subtitles; mod trailer; use axum::routing::get; @@ -45,6 +46,10 @@ pub use state::{ DEFAULT_TMDB_URL, }; pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput}; +pub use subtitles::{ + Subtitle, SubtitleCandidate, SubtitleGrabInput, SubtitleProviderError, SubtitleSearchInput, + SubtitleSearchResults, SubtitleTranslateInput, +}; pub use trailer::{Trailer, TrailerKind}; /// Where the generated document is served, and where `just gen-client` reads @@ -71,7 +76,7 @@ pub const DOCS_PATH: &str = "/api/docs"; (name = "policies", description = "Quality policies (DESIGN.md §5)"), (name = "search", description = "Unified title and release search"), (name = "roots", description = "Root folders and their policies"), - (name = "subtitles", description = "Subtitle configuration (DESIGN.md §15)") + (name = "subtitles", description = "Subtitles and their configuration (DESIGN.md §15)") ), )] struct ApiDoc; @@ -119,6 +124,13 @@ fn api_router() -> OpenApiRouter { .routes(routes!(policies::list, policies::create)) .routes(routes!(policies::get, policies::update, policies::delete)) .routes(routes!(subtitle_settings::get, subtitle_settings::update)) + .routes(routes!(subtitles::list_for_media_file)) + .routes(routes!(subtitles::list_for_movie)) + .routes(routes!(subtitles::list_for_episode)) + .routes(routes!(subtitles::search)) + .routes(routes!(subtitles::grab)) + .routes(routes!(subtitles::translate)) + .routes(routes!(subtitles::delete)) } /// The generated `OpenAPI` document. @@ -362,6 +374,16 @@ mod tests { ("/api/roots", "post"), ("/api/settings/subtitles", "get"), ("/api/settings/subtitles", "put"), + ("/api/media-files/{media_file_id}/subtitles", "get"), + ("/api/movies/{movie_id}/subtitles", "get"), + ("/api/episodes/{episode_id}/subtitles", "get"), + ("/api/media-files/{media_file_id}/subtitles/search", "post"), + ("/api/media-files/{media_file_id}/subtitles/grab", "post"), + ( + "/api/media-files/{media_file_id}/subtitles/translate", + "post", + ), + ("/api/subtitles/{subtitle_id}", "delete"), ] { assert!( json["paths"][path][method].is_object(), @@ -376,6 +398,9 @@ mod tests { "AttentionQueues", "SeriesAttention", "Series", + "Subtitle", + "SubtitleCandidate", + "SubtitleSearchResults", ] { assert!( json["components"]["schemas"][schema].is_object(), diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 351b1e5..0209707 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -156,6 +156,16 @@ pub enum ApiError { OwnerNotFound, PolicyNotFound, RootNotFound, + /// A `media_files` row that is not there. Named apart from + /// [`Self::NotFound`] because the subtitle surface (§15) is keyed on + /// files, not on titles, and "movie not found" would misdirect. + MediaFileNotFound, + SubtitleNotFound, + /// A subtitle provider or translation backend could not do what a manual + /// action asked (§15). Carries the message because two providers and + /// four engines are configurable at once and an unnamed failure is + /// unactionable. + SubtitleUpstream(String), /// The §9.6 chip outcome: the title exists upstream but has no trailer. /// Ordinary, so it must stay distinguishable from an upstream failure. NoTrailer, @@ -184,6 +194,9 @@ impl IntoResponse for ApiError { Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()), Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()), Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()), + Self::MediaFileNotFound => (StatusCode::NOT_FOUND, "media file not found".to_string()), + Self::SubtitleNotFound => (StatusCode::NOT_FOUND, "subtitle not found".to_string()), + Self::SubtitleUpstream(error) => (StatusCode::SERVICE_UNAVAILABLE, error), Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string()), Self::Conflict(error) => (StatusCode::CONFLICT, error), Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error), diff --git a/crates/arr-api/src/state.rs b/crates/arr-api/src/state.rs index de8c4c0..d79c72e 100644 --- a/crates/arr-api/src/state.rs +++ b/crates/arr-api/src/state.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use std::time::Duration; use arr_db::Db; +use arr_subs::{Backend, Provider}; use tokio::sync::mpsc; /// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key @@ -76,6 +77,8 @@ pub struct AppState { pending_season_commands: Arc>>, metadata_commands: mpsc::Sender, pending_metadata_commands: Arc>>, + subtitle_providers: Arc>>, + translation_backends: Arc>>, } /// Work explicitly requested through the movie API. @@ -146,6 +149,8 @@ impl AppState { pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)), metadata_commands, pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)), + subtitle_providers: Arc::new(Vec::new()), + translation_backends: Arc::new(Vec::new()), }) } @@ -156,6 +161,46 @@ impl AppState { self } + /// Attach the subtitle providers this deployment has credentials for + /// (`DESIGN.md` §15). + /// + /// Which of them a search actually runs is the `providers_enabled` + /// setting, read per request; this is the narrower fact of which ones + /// exist at all, because credentials are bootstrap config and never + /// reach the database (§10). + #[must_use] + pub fn with_subtitle_providers(mut self, providers: Vec>) -> Self { + self.subtitle_providers = Arc::new(providers); + self + } + + /// Attach the translation backends this binary compiled in (§15). + /// + /// Empty when no `translate-*` cargo feature is on, which is the default + /// — a manual translation then fails with a message saying so rather + /// than silently doing nothing. + #[must_use] + pub fn with_translation_backends(mut self, backends: Vec>) -> Self { + self.translation_backends = Arc::new(backends); + self + } + + pub(crate) fn subtitle_provider(&self, id: &str) -> Option<&Arc> { + self.subtitle_providers + .iter() + .find(|provider| provider.id().as_str() == id) + } + + pub(crate) fn subtitle_providers(&self) -> &[Arc] { + &self.subtitle_providers + } + + pub(crate) fn translation_backend(&self, id: &str) -> Option<&Arc> { + self.translation_backends + .iter() + .find(|backend| backend.id().as_str() == id) + } + /// Wait for the next manual movie action in the daemon's reconcile loop. /// /// # Errors diff --git a/crates/arr-api/src/subtitles.rs b/crates/arr-api/src/subtitles.rs new file mode 100644 index 0000000..2c9a970 --- /dev/null +++ b/crates/arr-api/src/subtitles.rs @@ -0,0 +1,1622 @@ +//! The subtitle surface (`DESIGN.md` §15, §9.1, §9.3, issue #199). +//! +//! Everything here is an **operator** action, and that is the whole reason +//! this module is separate from the reconcile loop's own subtitle work +//! (#196). §15 says manual actions bypass the wanted set: the operator asking +//! for a Spanish subtitle gets a Spanish subtitle, whether or not Spanish is +//! wanted, and the loop must not then read it as a gap or take it away again. +//! That is one line of code — every write here ends by marking the language +//! satisfied — but it is the point of the module. +//! +//! Unlike the release deck, which is asynchronous because Prowlarr fan-out is +//! slow and its results are persisted (`releases`), a subtitle search is one +//! or two HTTP calls and its candidates are not stored anywhere. So these +//! handlers do the work inline and answer with the result, rather than +//! returning 202 and leaving the operator to poll. The consequence the client +//! has to know about: candidate ids are meaningful only to the provider that +//! issued them, and a grab therefore repeats the facts (`forced`, `sdh`) the +//! search reported, because nothing on the server remembers them. +//! +//! Verdict vocabulary is §9.3's, unchanged: `eligible` or `rejected` plus the +//! name of the rule that killed it, exactly as `Release` spells it, so the +//! manual-search view needs no second concept for subtitles. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use arr_core::subs::{rank, SubtitleTarget, SubtitleVerdict}; +use arr_core::{layout, Language}; +use arr_db::subtitles as db; +use arr_db::SubtitleOrigin; +use arr_subs::{CandidateId, MediaFile, MediaRef, SearchRequest, SubtitleFormat}; +use axum::extract::rejection::JsonRejection; +use axum::extract::{Path as UrlPath, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::movies::{pool, ApiError, ErrorBody}; +use crate::policies::parsed; +use crate::state::AppState; + +/// One subtitle arr knows about, as the API renders it. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct Subtitle { + pub id: i64, + pub media_file_id: i64, + /// The tag `arr_core::Language` spells: `pt-PT`, `pt-BR`, `en`. + pub language: String, + /// `embedded`, `extracted`, `provider` or `translated` (§15). + pub origin: String, + /// The provider a fetch came from, and its own handle for the candidate. + pub provider: Option, + pub candidate_id: Option, + /// The translation backend, when arr made this one. + pub engine: Option, + /// Foreign lines and signs only. Never satisfies a want (§15). + pub forced: bool, + pub sdh: bool, + /// `not_run`, `synced` or `rejected` — what `alass` did (§15). A rejected + /// sync means the unsynced original was kept and the file is flagged. + pub sync: String, + /// The sidecar next to the video. `null` only for an embedded track, + /// which is inside the container and has no file of its own. + pub path: Option, +} + +impl From for Subtitle { + fn from(file: arr_db::SubtitleFile) -> Self { + Self { + id: file.id, + media_file_id: file.media_file_id, + language: file.language, + origin: origin_name(file.origin).to_owned(), + provider: file.provider, + candidate_id: file.candidate_id, + engine: file.engine, + forced: file.forced, + sdh: file.sdh, + sync: sync_name(file.sync).to_owned(), + path: file.path, + } + } +} + +const fn origin_name(origin: SubtitleOrigin) -> &'static str { + match origin { + SubtitleOrigin::Embedded => "embedded", + SubtitleOrigin::Extracted => "extracted", + SubtitleOrigin::Provider => "provider", + SubtitleOrigin::Translated => "translated", + } +} + +const fn sync_name(sync: arr_db::SubtitleSync) -> &'static str { + match sync { + arr_db::SubtitleSync::NotRun => "not_run", + arr_db::SubtitleSync::Synced => "synced", + arr_db::SubtitleSync::Rejected => "rejected", + } +} + +/// One candidate a provider offered, with the verdict that placed it. +/// +/// Rejected candidates are in the same list rather than hidden: §9.3's rule +/// is that every rejected row names the rule that killed it, so an +/// over-strict filter is visible without reading names. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SubtitleCandidate { + /// The provider offering it, and its own handle for it. Both travel back + /// unchanged in a grab. + pub provider: String, + pub candidate_id: String, + pub language: String, + /// Whether the provider matched the exact file by `moviehash` — §15's + /// outright winner. + pub hash_match: bool, + pub release_name: Option, + pub group: Option, + pub source: Option, + pub rating: Option, + pub download_count: Option, + pub forced: bool, + pub sdh: bool, + /// `eligible` or `rejected`, the same words the release deck uses. + pub verdict: String, + /// The rule that rejected it, `null` when eligible. + pub rejected_rule: Option, +} + +/// A provider that could not answer this search. +/// +/// One unreachable provider does not fail the search: §15 configures two at +/// once, and the operator can still grab from whichever answered. The failure +/// is reported rather than swallowed so "no candidates" and "nobody could be +/// asked" stay distinguishable. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SubtitleProviderError { + pub provider: String, + pub error: String, +} + +/// What one manual search found. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SubtitleSearchResults { + /// Ranked best first, rejected candidates last (§15's ranking, #185). + pub candidates: Vec, + /// Providers that were asked and could not answer. + pub provider_errors: Vec, +} + +/// Which language to search for. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct SubtitleSearchInput { + /// Exactly as `arr_core::Language` spells it. Matched exactly: pt-PT and + /// pt-BR are separate searches here, because the operator asked for one + /// of them by name and §15's "pt-BR is accepted" is a rule about the + /// wanted set, not about a manual request. + pub language: String, +} + +/// The candidate to fetch, repeated from the search results. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct SubtitleGrabInput { + pub provider: String, + pub candidate_id: String, + pub language: String, + /// The candidate's own flags, as the search reported them. Nothing on + /// the server remembers a search, so they travel with the grab; they are + /// facts about the subtitle and are stored with it. + #[serde(default)] + pub forced: bool, + #[serde(default)] + pub sdh: bool, +} + +/// The source subtitle, the language wanted, and which engine to use. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct SubtitleTranslateInput { + /// Any subtitle already on this file is a legal source (§15), including + /// one extracted from an embedded track and including another machine + /// translation. An unextracted embedded track is not: it has no text. + pub source_subtitle_id: i64, + pub target_language: String, + /// Defaults to the configured `translation_engine` when omitted. + #[serde(default)] + pub engine: Option, +} + +/// Everything one manual action needs to know about the file it targets. +struct Target { + media_file_id: i64, + path: PathBuf, + size: u64, + media: MediaRef, + /// The release the file was imported under, when a grab record still + /// says. Ranking scores an exact match against it (§15). + release_name: Option, + release_group: Option, + source: Option, +} + +impl Target { + fn search_request(&self, language: Language) -> SearchRequest { + SearchRequest { + file: MediaFile { + path: self.path.clone(), + size: self.size, + release_name: self.release_name.clone(), + media: self.media, + }, + languages: vec![language], + } + } + + /// Where a sidecar for `language` belongs: next to the video, inside the + /// §7.4 title folder, named by §15's rule. + fn sidecar(&self, language: &Language, machine_translated: bool) -> Result { + let name = self + .path + .file_name() + .and_then(std::ffi::OsStr::to_str) + .ok_or_else(|| { + ApiError::Database(format!( + "media file path {} has no name", + self.path.display() + )) + })?; + let parent = self.path.parent().ok_or_else(|| { + ApiError::Database(format!( + "media file path {} has no folder", + self.path.display() + )) + })?; + Ok(parent.join(layout::subtitle_name(name, language, machine_translated))) + } +} + +/// Load the file and everything ranking needs to score candidates for it. +async fn target(state: &AppState, media_file_id: i64) -> Result { + let file = sqlx::query!( + r#"SELECT id AS "id!: i64", + path AS "path!: String", + size AS "size!: i64", + owner_kind AS "owner_kind!: String", + owner_id AS "owner_id!: i64" + FROM media_files WHERE id = ?"#, + media_file_id + ) + .fetch_optional(pool(state)?) + .await? + .ok_or(ApiError::MediaFileNotFound)?; + + let media = media_ref(state, &file.owner_kind, file.owner_id).await?; + let release = imported_release(state, &file.owner_kind, file.owner_id).await?; + let (release_name, claims) = match release { + Some((name, parsed)) => { + let claims: arr_core::ParsedRelease = serde_json::from_value(parsed) + .map_err(|error| ApiError::Database(error.to_string()))?; + (Some(name), Some(claims)) + } + None => (None, None), + }; + + Ok(Target { + media_file_id: file.id, + path: PathBuf::from(file.path), + size: u64::try_from(file.size).unwrap_or(0), + media, + release_name, + release_group: claims.as_ref().and_then(|claims| claims.group.clone()), + source: claims + .as_ref() + .and_then(|claims| claims.source) + .map(Into::into), + }) +} + +/// The TMDB coordinates providers search by (§15, `arr_subs::MediaRef`). +async fn media_ref( + state: &AppState, + owner_kind: &str, + owner_id: i64, +) -> Result { + if owner_kind == "movie" { + let tmdb_id = sqlx::query_scalar!( + r#"SELECT tmdb_id AS "tmdb_id!: i64" FROM movies WHERE id = ?"#, + owner_id + ) + .fetch_optional(pool(state)?) + .await? + .ok_or(ApiError::NotFound)?; + return Ok(MediaRef::Movie { + tmdb_id: u64::try_from(tmdb_id).unwrap_or(0), + }); + } + + let row = sqlx::query!( + r#"SELECT sr.tmdb_id AS "tmdb_id!: i64", + s.number AS "season!: i64", + e.number AS "episode!: i64" + FROM episodes e + JOIN seasons s ON s.id = e.season_id + JOIN series sr ON sr.id = s.series_id + WHERE e.id = ?"#, + owner_id + ) + .fetch_optional(pool(state)?) + .await? + .ok_or(ApiError::EpisodeNotFound)?; + + Ok(MediaRef::Episode { + tmdb_id: u64::try_from(row.tmdb_id).unwrap_or(0), + season: u16::try_from(row.season).unwrap_or(0), + episode: u16::try_from(row.episode).unwrap_or(0), + }) +} + +/// The release a file was imported under, best effort. +/// +/// An episode that arrived inside a season pack has no grab of its own, so +/// the season's grab is the fallback. Nothing here is load-bearing: a missing +/// release name costs the exact-name tier in ranking and nothing else. +async fn imported_release( + state: &AppState, + owner_kind: &str, + owner_id: i64, +) -> Result, ApiError> { + let own = sqlx::query!( + r#"SELECT r.name AS "name!: String", + r.parsed AS "parsed!: serde_json::Value" + FROM grabs g JOIN releases r ON r.id = g.release_id + WHERE g.target_kind = ? AND g.target_id = ? + ORDER BY g.imported_at DESC, g.id DESC LIMIT 1"#, + owner_kind, + owner_id + ) + .fetch_optional(pool(state)?) + .await?; + if let Some(row) = own { + return Ok(Some((row.name, row.parsed))); + } + if owner_kind != "episode" { + return Ok(None); + } + + let pack = sqlx::query!( + r#"SELECT r.name AS "name!: String", + r.parsed AS "parsed!: serde_json::Value" + FROM episodes e + JOIN grabs g ON g.target_kind = 'season' AND g.target_id = e.season_id + JOIN releases r ON r.id = g.release_id + WHERE e.id = ? + ORDER BY g.imported_at DESC, g.id DESC LIMIT 1"#, + owner_id + ) + .fetch_optional(pool(state)?) + .await?; + Ok(pack.map(|row| (row.name, row.parsed))) +} + +/// The file's own `moviehash`, when it can be computed. +/// +/// Best effort: the video may be on a mount that is temporarily gone, and a +/// subtitle search that cannot hash still ranks — it just loses §15's +/// outright winner. Reading the head and tail of a large file is blocking IO, +/// so it does not run on the async worker. +async fn moviehash(path: &Path, size: u64) -> Option { + let owned = path.to_path_buf(); + let computed = tokio::task::spawn_blocking(move || arr_subs::moviehash(&owned, size)) + .await + .ok()?; + match computed { + Ok(hash) => hash, + Err(error) => { + tracing::warn!(path = %path.display(), %error, "moviehash not computed"); + None + } + } +} + +/// Which providers a manual search runs: those this deployment has +/// credentials for, intersected with `providers_enabled` (§15). +/// +/// A provider the operator switched off in `/settings` is not asked, because +/// "enabled" is the operator's own statement about which sources to use. A +/// grab does not go through here — naming a candidate is a stronger statement +/// than the setting, and the candidate came from somewhere. +async fn enabled_providers(state: &AppState) -> Result>, ApiError> { + let raw = sqlx::query_scalar!( + r#"SELECT providers_enabled AS "providers_enabled!: String" + FROM subtitle_settings WHERE id = 1"# + ) + .fetch_one(pool(state)?) + .await?; + let enabled: BTreeSet = + serde_json::from_str(&raw).map_err(|error| ApiError::Database(error.to_string()))?; + Ok(state + .subtitle_providers() + .iter() + .filter(|provider| enabled.contains(provider.id().as_str())) + .map(Arc::clone) + .collect()) +} + +/// A language tag as the domain spells it. +fn language_of(tag: &str) -> Result { + if tag.trim().is_empty() { + return Err(ApiError::Invalid("language: must not be empty".into())); + } + Ok(arr_db::policy::language(tag)) +} + +async fn subtitles_of(state: &AppState, media_file_id: i64) -> Result, ApiError> { + let files = db::files_for(pool(state)?, media_file_id).await?; + Ok(files.into_iter().map(Subtitle::from).collect()) +} + +/// Every subtitle on every file one owner has, ordered by file then language. +async fn subtitles_for_owner( + state: &AppState, + owner_kind: &str, + owner_id: i64, +) -> Result, ApiError> { + let ids = sqlx::query_scalar!( + r#"SELECT id AS "id!: i64" FROM media_files + WHERE owner_kind = ? AND owner_id = ? ORDER BY path"#, + owner_kind, + owner_id + ) + .fetch_all(pool(state)?) + .await?; + let mut out = Vec::new(); + for id in ids { + out.extend(subtitles_of(state, id).await?); + } + Ok(out) +} + +#[utoipa::path( + get, path = "/api/media-files/{media_file_id}/subtitles", tag = "subtitles", + params(("media_file_id" = i64, Path, description = "Media file row id")), + responses( + (status = 200, body = [Subtitle]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn list_for_media_file( + State(state): State, + UrlPath(media_file_id): UrlPath, +) -> Result>, ApiError> { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM media_files WHERE id = ?) AS "exists!: bool""#, + media_file_id + ) + .fetch_one(pool(&state)?) + .await?; + if !exists { + return Err(ApiError::MediaFileNotFound); + } + Ok(Json(subtitles_of(&state, media_file_id).await?)) +} + +#[utoipa::path( + get, path = "/api/movies/{movie_id}/subtitles", tag = "subtitles", + params(("movie_id" = i64, Path, description = "Movie row id")), + responses( + (status = 200, body = [Subtitle]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn list_for_movie( + State(state): State, + UrlPath(movie_id): UrlPath, +) -> Result>, ApiError> { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM movies WHERE id = ?) AS "exists!: bool""#, + movie_id + ) + .fetch_one(pool(&state)?) + .await?; + if !exists { + return Err(ApiError::NotFound); + } + Ok(Json(subtitles_for_owner(&state, "movie", movie_id).await?)) +} + +#[utoipa::path( + get, path = "/api/episodes/{episode_id}/subtitles", tag = "subtitles", + params(("episode_id" = i64, Path, description = "Episode row id")), + responses( + (status = 200, body = [Subtitle]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn list_for_episode( + State(state): State, + UrlPath(episode_id): UrlPath, +) -> Result>, ApiError> { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM episodes WHERE id = ?) AS "exists!: bool""#, + episode_id + ) + .fetch_one(pool(&state)?) + .await?; + if !exists { + return Err(ApiError::EpisodeNotFound); + } + Ok(Json( + subtitles_for_owner(&state, "episode", episode_id).await?, + )) +} + +#[utoipa::path( + post, path = "/api/media-files/{media_file_id}/subtitles/search", tag = "subtitles", + params(("media_file_id" = i64, Path, description = "Media file row id")), + request_body = SubtitleSearchInput, + responses( + (status = 200, body = SubtitleSearchResults), + (status = 404, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn search( + State(state): State, + UrlPath(media_file_id): UrlPath, + body: Result, JsonRejection>, +) -> Result, ApiError> { + let input = parsed(body)?; + let language = language_of(&input.language)?; + let target = target(&state, media_file_id).await?; + let providers = enabled_providers(&state).await?; + if providers.is_empty() { + return Err(ApiError::SubtitleUpstream( + "no subtitle provider is both configured and enabled".into(), + )); + } + + let request = target.search_request(language.clone()); + let mut offered = Vec::new(); + let mut provider_errors = Vec::new(); + for provider in &providers { + match provider.search(&request).await { + Ok(candidates) => { + offered.extend(candidates.into_iter().filter(|c| c.language == language)); + } + Err(error) => provider_errors.push(SubtitleProviderError { + provider: provider.id().to_string(), + error: error.to_string(), + }), + } + } + + let hash = moviehash(&target.path, target.size).await; + let ranking_target = SubtitleTarget { + moviehash: hash.as_deref(), + release_name: target.release_name.as_deref(), + release_group: target.release_group.as_deref(), + source: target.source, + }; + let cores: Vec<_> = offered + .iter() + .map(|candidate| candidate.to_core(hash.as_deref())) + .collect(); + + let candidates = rank(&ranking_target, &cores) + .into_iter() + .map(|ranked| { + let candidate = &offered[ranked.index]; + let (verdict, rejected_rule) = match ranked.verdict { + SubtitleVerdict::Eligible => ("eligible", None), + SubtitleVerdict::Rejected(rule) => ("rejected", Some(rule.name().to_owned())), + }; + SubtitleCandidate { + provider: candidate.provider.to_string(), + candidate_id: candidate.id.to_string(), + language: candidate.language.to_string(), + hash_match: candidate.hash_match, + release_name: candidate.release_name.clone(), + group: candidate.group.clone(), + source: candidate.source.map(|source| source.to_string()), + rating: candidate.rating, + download_count: candidate.download_count, + forced: candidate.forced, + sdh: candidate.sdh, + verdict: verdict.to_owned(), + rejected_rule, + } + }) + .collect(); + + Ok(Json(SubtitleSearchResults { + candidates, + provider_errors, + })) +} + +#[utoipa::path( + post, path = "/api/media-files/{media_file_id}/subtitles/grab", tag = "subtitles", + params(("media_file_id" = i64, Path, description = "Media file row id")), + request_body = SubtitleGrabInput, + responses( + (status = 201, body = Subtitle), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn grab( + State(state): State, + UrlPath(media_file_id): UrlPath, + body: Result, JsonRejection>, +) -> Result<(StatusCode, Json), ApiError> { + let input = parsed(body)?; + let language = language_of(&input.language)?; + let target = target(&state, media_file_id).await?; + let provider = state + .subtitle_provider(&input.provider) + .ok_or_else(|| { + ApiError::Invalid(format!( + "provider: '{}' is not configured on this deployment", + input.provider + )) + })? + .clone(); + + let destination = target.sidecar(&language, false)?; + claim_path(&state, &destination).await?; + + let fetched = provider + .download(&CandidateId::new(input.candidate_id.clone())) + .await + .map_err(|error| ApiError::SubtitleUpstream(error.to_string()))?; + let text = srt_text(&fetched)?; + write_sidecar(&destination, &text).await?; + + let mut record = arr_db::NewSubtitleFile::fetched( + target.media_file_id, + &language.to_string(), + &input.provider, + &input.candidate_id, + &destination.to_string_lossy(), + ); + if input.forced { + record = record.forced(); + } + if input.sdh { + record = record.sdh(); + } + finish( + &state, + &record, + target.media_file_id, + &language, + input.forced, + ) + .await +} + +#[utoipa::path( + post, path = "/api/media-files/{media_file_id}/subtitles/translate", tag = "subtitles", + params(("media_file_id" = i64, Path, description = "Media file row id")), + request_body = SubtitleTranslateInput, + responses( + (status = 201, body = Subtitle), + (status = 404, body = ErrorBody), + (status = 409, body = ErrorBody), + (status = 422, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn translate( + State(state): State, + UrlPath(media_file_id): UrlPath, + body: Result, JsonRejection>, +) -> Result<(StatusCode, Json), ApiError> { + let input = parsed(body)?; + let target_language = language_of(&input.target_language)?; + let target = target(&state, media_file_id).await?; + + let source = db::files_for(pool(&state)?, media_file_id) + .await? + .into_iter() + .find(|file| file.id == input.source_subtitle_id) + .ok_or(ApiError::SubtitleNotFound)?; + let source_path = source.path.clone().ok_or_else(|| { + ApiError::Invalid( + "source_subtitle_id: an embedded track carries no text; extract it first".into(), + ) + })?; + let source_language = language_of(&source.language)?; + if source_language == target_language { + return Err(ApiError::Invalid( + "target_language: same as the source subtitle's language".into(), + )); + } + + let engine = engine_name(&state, input.engine.as_deref()).await?; + let backend = state + .translation_backend(&engine) + .ok_or_else(|| { + ApiError::SubtitleUpstream(format!( + "translation engine '{engine}' is not compiled into this binary" + )) + })? + .clone(); + + let destination = target.sidecar(&target_language, true)?; + claim_path(&state, &destination).await?; + + let raw = tokio::fs::read_to_string(&source_path) + .await + .map_err(|error| ApiError::Filesystem(format!("{source_path}: {error}")))?; + let cues = arr_subs::srt::parse(&raw) + .map_err(|error| ApiError::Invalid(format!("source_subtitle_id: not SRT: {error}")))?; + let translated = + arr_subs::translate::translate(backend.as_ref(), &cues, &source_language, &target_language) + .await + .map_err(|error| ApiError::SubtitleUpstream(error.to_string()))?; + + write_sidecar(&destination, &arr_subs::srt::render(&translated)).await?; + + let record = arr_db::NewSubtitleFile::translated( + target.media_file_id, + &target_language.to_string(), + &engine, + &destination.to_string_lossy(), + ); + finish( + &state, + &record, + target.media_file_id, + &target_language, + false, + ) + .await +} + +#[utoipa::path( + delete, path = "/api/subtitles/{subtitle_id}", tag = "subtitles", + params(("subtitle_id" = i64, Path, description = "Subtitle row id")), + responses( + (status = 204), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn delete( + State(state): State, + UrlPath(subtitle_id): UrlPath, +) -> Result { + let row = sqlx::query!( + r#"SELECT media_file_id AS "media_file_id!: i64", + language AS "language!: String", + path + FROM subtitle_files WHERE id = ?"#, + subtitle_id + ) + .fetch_optional(pool(&state)?) + .await? + .ok_or(ApiError::SubtitleNotFound)?; + + if let Some(path) = &row.path { + match tokio::fs::remove_file(path).await { + Ok(()) => {} + // Already gone is the outcome asked for. Anything else is a real + // failure and the row stays, so a retry still has something to + // delete rather than leaving an orphan sidecar behind. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(ApiError::Filesystem(format!("{path}: {error}"))), + } + } + + db::delete_file(pool(&state)?, subtitle_id).await?; + + // §15 reads satisfaction off the files, so a language with nothing left + // is a gap again and the loop must be able to see it. + let remaining = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM subtitle_files + WHERE media_file_id = ? AND language = ? AND forced = 0 + ) AS "exists!: bool""#, + row.media_file_id, + row.language + ) + .fetch_one(pool(&state)?) + .await?; + if !remaining { + db::unsatisfy(pool(&state)?, row.media_file_id, &row.language).await?; + } + + Ok(StatusCode::NO_CONTENT) +} + +/// The engine to translate with: the one asked for, else the configured one. +async fn engine_name(state: &AppState, requested: Option<&str>) -> Result { + if let Some(engine) = requested { + if !arr_subs::ENGINES.contains(&engine) { + return Err(ApiError::Invalid(format!( + "engine: '{engine}' is not a known engine" + ))); + } + return Ok(engine.to_owned()); + } + sqlx::query_scalar!( + r#"SELECT translation_engine AS "translation_engine: String" + FROM subtitle_settings WHERE id = 1"# + ) + .fetch_one(pool(state)?) + .await? + .ok_or_else(|| ApiError::Invalid("engine: no translation engine is configured".into())) +} + +/// Refuse to write over a sidecar arr already knows about. +/// +/// §15 has no upgrade loop and no in-place replacement: replacing a subtitle +/// is delete-then-fetch. Without this a second grab in the same language +/// would overwrite the file while the unique index on `path` kept the first +/// row, leaving the database describing a file that is no longer there. +async fn claim_path(state: &AppState, destination: &Path) -> Result<(), ApiError> { + let path = destination.to_string_lossy().into_owned(); + let taken = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM subtitle_files WHERE path = ?) AS "exists!: bool""#, + path + ) + .fetch_one(pool(state)?) + .await?; + if taken { + return Err(ApiError::Conflict(format!( + "a subtitle already exists at {path}; delete it first" + ))); + } + Ok(()) +} + +/// A fetched subtitle as SRT text. +/// +/// Sidecars are SRT (§15) and converting other container formats is its own +/// issue (#213), so anything else is refused rather than written under a +/// `.srt` name it does not honour. +fn srt_text(fetched: &arr_subs::Fetched) -> Result { + if fetched.format != SubtitleFormat::Srt { + return Err(ApiError::Invalid(format!( + "candidate_id: the provider served {}, and sidecars are SRT", + fetched.format + ))); + } + fetched + .decode() + .map_err(|error| ApiError::SubtitleUpstream(error.to_string())) +} + +/// Write a sidecar whole or not at all, so Jellyfin never reads a half file. +async fn write_sidecar(destination: &Path, text: &str) -> Result<(), ApiError> { + let failure = |path: &Path, error: std::io::Error| { + ApiError::Filesystem(format!("{}: {error}", path.display())) + }; + let temp = destination.with_extension("srt.partial"); + tokio::fs::write(&temp, text) + .await + .map_err(|error| failure(&temp, error))?; + if let Err(error) = tokio::fs::rename(&temp, destination).await { + let _ = tokio::fs::remove_file(&temp).await; + return Err(failure(destination, error)); + } + Ok(()) +} + +/// Record a written sidecar and settle the language it answers. +/// +/// The `mark_satisfied` is §15's "manual actions bypass the wanted-set +/// logic": the loop stops working on that language whether or not it was in +/// the wanted set, so a manually requested Spanish subtitle is never treated +/// as a gap and never replaced. A forced track is the exception the same +/// section names — it covers signs only and satisfies nothing — so it is +/// recorded and left out of the satisfaction claim. +async fn finish( + state: &AppState, + record: &arr_db::NewSubtitleFile, + media_file_id: i64, + language: &Language, + forced: bool, +) -> Result<(StatusCode, Json), ApiError> { + let id = db::record_file(pool(state)?, record).await?; + if !forced { + db::mark_satisfied(pool(state)?, media_file_id, &language.to_string()).await?; + } + let subtitle = db::files_for(pool(state)?, media_file_id) + .await? + .into_iter() + .find(|file| file.id == id) + .ok_or(ApiError::SubtitleNotFound)?; + Ok((StatusCode::CREATED, Json(Subtitle::from(subtitle)))) +} + +#[cfg(test)] +#[allow(clippy::too_many_lines)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use arr_core::Language; + use arr_subs::{ + Backend, BackendId, Batch, Candidate, CandidateId, DownloadFuture, Fetched, Provider, + ProviderId, SearchFuture, SearchRequest, SubtitleFormat, TranslateFuture, TranslatedCue, + }; + use axum::http::StatusCode; + + use crate::{router, AppState, Upstreams}; + + const SRT: &str = "1\n00:00:01,000 --> 00:00:02,000\nolá\n"; + + /// Offers three candidates for whatever it is asked: one plain, one that + /// matched by hash, one forced. + #[derive(Debug)] + struct StubProvider { + id: ProviderId, + format: SubtitleFormat, + } + + impl StubProvider { + fn new(name: &str) -> Self { + Self { + id: ProviderId::new(name), + format: SubtitleFormat::Srt, + } + } + + fn serving(name: &str, format: SubtitleFormat) -> Self { + Self { + id: ProviderId::new(name), + format, + } + } + + fn candidate(&self, id: &str, language: &Language) -> Candidate { + Candidate { + provider: self.id.clone(), + id: CandidateId::new(id), + language: language.clone(), + hash_match: false, + release_name: None, + group: None, + source: None, + rating: Some(5.0), + download_count: Some(10), + forced: false, + sdh: false, + } + } + } + + impl Provider for StubProvider { + fn id(&self) -> ProviderId { + self.id.clone() + } + + fn search<'a>(&'a self, request: &'a SearchRequest) -> SearchFuture<'a> { + Box::pin(async move { + let language = request.languages[0].clone(); + Ok(vec![ + self.candidate("plain", &language), + Candidate { + hash_match: true, + ..self.candidate("hashed", &language) + }, + Candidate { + forced: true, + ..self.candidate("forced", &language) + }, + // A language nobody asked for: providers may answer with + // more than they were asked, and ranking discards it. + self.candidate("other-language", &Language::Other("fr".into())), + ]) + }) + } + + fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> { + Box::pin(async move { + Ok(Fetched { + id: id.clone(), + language: Language::PortuguesePortugal, + format: self.format.clone(), + content: SRT.as_bytes().to_vec(), + }) + }) + } + } + + /// A provider that is configured but never answers. + #[derive(Debug)] + struct DeadProvider; + + impl Provider for DeadProvider { + fn id(&self) -> ProviderId { + ProviderId::new("dead") + } + + fn search<'a>(&'a self, _request: &'a SearchRequest) -> SearchFuture<'a> { + Box::pin(async move { + Err(arr_subs::Error::Unauthorized { + provider: ProviderId::new("dead"), + }) + }) + } + + fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> { + Box::pin(async move { + Err(arr_subs::Error::NotFound { + provider: ProviderId::new("dead"), + candidate: id.clone(), + }) + }) + } + } + + /// Uppercases every cue. Enough to prove the pipeline, and it keeps cue + /// numbering intact so `translate`'s validation passes. + #[derive(Debug)] + struct StubBackend; + + impl Backend for StubBackend { + fn id(&self) -> BackendId { + BackendId::new("openai") + } + + fn supports(&self, _target: &Language) -> bool { + true + } + + fn translate<'a>(&'a self, batch: &'a Batch) -> TranslateFuture<'a> { + Box::pin(async move { + Ok(batch + .cues + .iter() + .map(|cue| TranslatedCue { + number: cue.number, + text: cue.text.to_uppercase(), + }) + .collect()) + }) + } + } + + struct Fixture { + _dir: tempfile::TempDir, + base: String, + pool: sqlx::SqlitePool, + media_file_id: i64, + folder: PathBuf, + video: PathBuf, + } + + impl Fixture { + async fn subtitle_rows(&self) -> Vec { + arr_db::subtitles::files_for(&self.pool, self.media_file_id) + .await + .expect("files") + } + + async fn attempt_state(&self, language: &str) -> Option { + sqlx::query_scalar::<_, String>( + "SELECT state FROM subtitle_attempts WHERE media_file_id = ? AND language = ?", + ) + .bind(self.media_file_id) + .bind(language) + .fetch_optional(&self.pool) + .await + .expect("attempt") + } + } + + async fn application( + providers: Vec>, + backends: Vec>, + ) -> Fixture { + let dir = tempfile::tempdir().expect("tempdir"); + let database = arr_db::Db::connect(dir.path().join("arr.db")) + .await + .expect("connect database"); + database.migrate().await.expect("migrate database"); + let pool = database.pool().clone(); + + // §7.4: one folder per title, the sidecar lands inside it. + let folder = dir.path().join("Dune (2021) [tmdbid-438631]"); + tokio::fs::create_dir_all(&folder).await.expect("folder"); + let video = folder.join("Dune (2021) [tmdbid-438631] - [2160p][WEB-DL].mkv"); + // Big enough for a `moviehash`: OpenSubtitles hashes the first and + // last 64 KiB, and a shorter file has no hash at all — which would + // silently drop §15's outright-winning ranking tier from the tests. + tokio::fs::write(&video, vec![7u8; 200_000]) + .await + .expect("video"); + + sqlx::query( + "INSERT INTO movies (id, tmdb_id, title, year, root_id) VALUES (1, 438631, 'Dune', 2021, 1)", + ) + .execute(&pool) + .await + .expect("movie"); + let path = video.to_string_lossy().into_owned(); + sqlx::query( + "INSERT INTO media_files (id, owner_kind, owner_id, path, size) VALUES (1, 'movie', 1, ?, 200000)", + ) + .bind(&path) + .execute(&pool) + .await + .expect("media file"); + + let state = AppState::new(Upstreams::new( + "http://127.0.0.1:1".into(), + "http://127.0.0.1:1".into(), + )) + .expect("state") + .with_database(database) + .with_subtitle_providers(providers) + .with_translation_backends(backends); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") }); + + Fixture { + _dir: dir, + base: format!("http://{address}"), + pool, + media_file_id: 1, + folder, + video, + } + } + + async fn stub_application() -> Fixture { + application(vec![Arc::new(StubProvider::new("opensubtitles"))], vec![]).await + } + + async fn search(fixture: &Fixture, language: &str) -> (StatusCode, serde_json::Value) { + let response = reqwest::Client::new() + .post(format!( + "{}/api/media-files/1/subtitles/search", + fixture.base + )) + .json(&serde_json::json!({ "language": language })) + .send() + .await + .expect("search"); + let status = response.status(); + (status, response.json().await.expect("search json")) + } + + async fn grab(fixture: &Fixture, body: serde_json::Value) -> (StatusCode, serde_json::Value) { + let response = reqwest::Client::new() + .post(format!("{}/api/media-files/1/subtitles/grab", fixture.base)) + .json(&body) + .send() + .await + .expect("grab"); + let status = response.status(); + (status, response.json().await.expect("grab json")) + } + + fn pt() -> serde_json::Value { + serde_json::json!({ + "provider": "opensubtitles", + "candidate_id": "hashed", + "language": "pt-PT" + }) + } + + /// §9.3 applied to subtitles: eligible first, every rejected row naming + /// the rule that killed it. + #[tokio::test] + async fn a_search_ranks_candidates_and_names_the_rule_that_rejected_each() { + let fixture = stub_application().await; + let (status, body) = search(&fixture, "pt-PT").await; + assert_eq!(status, StatusCode::OK); + + let candidates = body["candidates"].as_array().expect("candidates"); + // The French candidate the provider volunteered is not in the answer. + assert_eq!(candidates.len(), 3, "{body}"); + assert_eq!(candidates[0]["candidate_id"], "hashed"); + assert_eq!(candidates[0]["verdict"], "eligible"); + assert!(candidates[0]["rejected_rule"].is_null()); + assert_eq!(candidates[1]["candidate_id"], "plain"); + assert_eq!(candidates[2]["candidate_id"], "forced"); + assert_eq!(candidates[2]["verdict"], "rejected"); + assert_eq!(candidates[2]["rejected_rule"], "forced"); + assert!(body["provider_errors"] + .as_array() + .expect("errors") + .is_empty()); + } + + /// One provider failing is not the search failing: §15 configures two. + #[tokio::test] + async fn a_provider_that_cannot_answer_is_reported_beside_the_candidates() { + let fixture = application( + vec![ + Arc::new(StubProvider::new("opensubtitles")), + Arc::new(DeadProvider), + ], + vec![], + ) + .await; + sqlx::query("UPDATE subtitle_settings SET providers_enabled = '[\"opensubtitles\",\"dead\"]' WHERE id = 1") + .execute(&fixture.pool) + .await + .expect("enable both"); + + let (status, body) = search(&fixture, "pt-PT").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["candidates"].as_array().expect("candidates").len(), 3); + let errors = body["provider_errors"].as_array().expect("errors"); + assert_eq!(errors.len(), 1, "{body}"); + assert_eq!(errors[0]["provider"], "dead"); + } + + /// `providers_enabled` is the operator's statement about which sources + /// to use, so a search never reaches a provider left out of it. + #[tokio::test] + async fn a_disabled_provider_is_never_asked() { + let fixture = stub_application().await; + sqlx::query("UPDATE subtitle_settings SET providers_enabled = '[]' WHERE id = 1") + .execute(&fixture.pool) + .await + .expect("disable everything"); + + let (status, _) = search(&fixture, "pt-PT").await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + } + + #[tokio::test] + async fn a_search_for_an_unknown_media_file_is_a_404() { + let fixture = stub_application().await; + let response = reqwest::Client::new() + .post(format!( + "{}/api/media-files/99/subtitles/search", + fixture.base + )) + .json(&serde_json::json!({ "language": "pt-PT" })) + .send() + .await + .expect("search"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json().await.expect("json"); + assert_eq!(body["error"], "media file not found"); + } + + /// §15's disk rule: the sidecar sits next to the video, inside the §7.4 + /// folder, named `