From 449750e426827b949c7676c411f257aecef19d0c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 23:27:03 +0100 Subject: [PATCH] feat(arr): serve the subtitle API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #199, DESIGN.md §15 and §9.1. Lists what exists per media file and per title, runs the enabled providers for one language and returns every candidate with §9.3's verdict vocabulary — including the rejected ones naming the rule that killed each — then grabs, translates and deletes. Inline rather than 202-and-poll like the release deck: a subtitle search is one or two HTTP calls and nothing persists its candidates, so there is nothing to come back for. The cost is that a grab repeats the `forced` and `sdh` facts the search reported, since the server does not remember them. Every write ends by marking the language satisfied, whether or not it is in the wanted set. That is §15's "manual actions bypass the wanted-set logic": the operator asking for Spanish gets Spanish, and the loop does not then read it as a gap. A forced track is the exception §15 names — it covers signs only — so it is recorded and satisfies nothing. `alass` (#194) does not run yet: a fetched sidecar is recorded unsynced. Co-Authored-By: Claude Opus 5 --- ...14fc8db793871193b6f16e3719d73040e8ce8.json | 38 + ...38e356ab6bc66567c72008cff506e595932b1.json | 21 + ...7fbe256dbf626e4e236a06ba08a00643566d3.json | 21 + ...dfcb4f2506686822711f3c6489c8ddd42b0e7.json | 50 + ...7b2578bcdeebd053b667db35423d829d4bf10.json | 74 + ...06638ea57f805127f7634f34b8189e831f461.json | 38 + ...b59db59e36f9a6d59adc0753b95662c9c630a.json | 26 + ...fee03615e7b23b10ceef88ee12c8deb0dbaaf.json | 50 + ...2dc74b4298101f8c29d9f8e6397cf79bb83d7.json | 26 + ...1df012bed05ac2a37933adc2deb6552eeeac1.json | 21 + ...699cb2007a8101cccc22d223d1f587a5533f8.json | 21 + ...9657ea6a40e4079573f569573075a205c50e7.json | 26 + ...9ce8160918d4b35764fd988e5e1dd6c101089.json | 21 + ...6be0b5ad8a30fa042434ffaaff8c69ccf31de.json | 26 + crates/arr-api/src/lib.rs | 27 +- crates/arr-api/src/movies.rs | 13 + crates/arr-api/src/state.rs | 45 + crates/arr-api/src/subtitles.rs | 1622 +++++++++++++++++ 18 files changed, 2165 insertions(+), 1 deletion(-) create mode 100644 .sqlx/query-05b5757f0fb7e6ab1176387854a14fc8db793871193b6f16e3719d73040e8ce8.json create mode 100644 .sqlx/query-0b40dbc80628244531a044e872238e356ab6bc66567c72008cff506e595932b1.json create mode 100644 .sqlx/query-233bae03912e50e6bcde4b24a9d7fbe256dbf626e4e236a06ba08a00643566d3.json create mode 100644 .sqlx/query-268cb11282b16494330c1655ba3dfcb4f2506686822711f3c6489c8ddd42b0e7.json create mode 100644 .sqlx/query-32443dc6e5bdd414340443c5d527b2578bcdeebd053b667db35423d829d4bf10.json create mode 100644 .sqlx/query-432cf38775bc4d7dee1dd9d009e06638ea57f805127f7634f34b8189e831f461.json create mode 100644 .sqlx/query-4f4f037c956ac6b246503354ff9b59db59e36f9a6d59adc0753b95662c9c630a.json create mode 100644 .sqlx/query-9091de460ba1cb47c9faa24fe37fee03615e7b23b10ceef88ee12c8deb0dbaaf.json create mode 100644 .sqlx/query-bc6dfe5a5cc639a64e3020c348a2dc74b4298101f8c29d9f8e6397cf79bb83d7.json create mode 100644 .sqlx/query-d81ec62e6c4879d08ce3551c0c71df012bed05ac2a37933adc2deb6552eeeac1.json create mode 100644 .sqlx/query-e3b82dc7b2a3b28753c84864239699cb2007a8101cccc22d223d1f587a5533f8.json create mode 100644 .sqlx/query-e7a0291c2fb8cfe59a02ffef11d9657ea6a40e4079573f569573075a205c50e7.json create mode 100644 .sqlx/query-e8066bed6fb53ff90d78f47bb1c9ce8160918d4b35764fd988e5e1dd6c101089.json create mode 100644 .sqlx/query-fcba9e1f708e6bf2d8471d0c7d26be0b5ad8a30fa042434ffaaff8c69ccf31de.json create mode 100644 crates/arr-api/src/subtitles.rs 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 `