feat(arr): serve the subtitle API
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 <noreply@anthropic.com>
This commit is contained in:
+38
@@ -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"
|
||||||
|
}
|
||||||
+21
@@ -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"
|
||||||
|
}
|
||||||
+21
@@ -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"
|
||||||
|
}
|
||||||
+50
@@ -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"
|
||||||
|
}
|
||||||
+74
@@ -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"
|
||||||
|
}
|
||||||
+38
@@ -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"
|
||||||
|
}
|
||||||
+26
@@ -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"
|
||||||
|
}
|
||||||
+50
@@ -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"
|
||||||
|
}
|
||||||
+26
@@ -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"
|
||||||
|
}
|
||||||
+21
@@ -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"
|
||||||
|
}
|
||||||
+21
@@ -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"
|
||||||
|
}
|
||||||
+26
@@ -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"
|
||||||
|
}
|
||||||
+21
@@ -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"
|
||||||
|
}
|
||||||
+26
@@ -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"
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ mod search;
|
|||||||
mod series;
|
mod series;
|
||||||
mod state;
|
mod state;
|
||||||
mod subtitle_settings;
|
mod subtitle_settings;
|
||||||
|
mod subtitles;
|
||||||
mod trailer;
|
mod trailer;
|
||||||
|
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
@@ -45,6 +46,10 @@ pub use state::{
|
|||||||
DEFAULT_TMDB_URL,
|
DEFAULT_TMDB_URL,
|
||||||
};
|
};
|
||||||
pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput};
|
pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput};
|
||||||
|
pub use subtitles::{
|
||||||
|
Subtitle, SubtitleCandidate, SubtitleGrabInput, SubtitleProviderError, SubtitleSearchInput,
|
||||||
|
SubtitleSearchResults, SubtitleTranslateInput,
|
||||||
|
};
|
||||||
pub use trailer::{Trailer, TrailerKind};
|
pub use trailer::{Trailer, TrailerKind};
|
||||||
|
|
||||||
/// Where the generated document is served, and where `just gen-client` reads
|
/// 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 = "policies", description = "Quality policies (DESIGN.md §5)"),
|
||||||
(name = "search", description = "Unified title and release search"),
|
(name = "search", description = "Unified title and release search"),
|
||||||
(name = "roots", description = "Root folders and their policies"),
|
(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;
|
struct ApiDoc;
|
||||||
@@ -119,6 +124,13 @@ fn api_router() -> OpenApiRouter<AppState> {
|
|||||||
.routes(routes!(policies::list, policies::create))
|
.routes(routes!(policies::list, policies::create))
|
||||||
.routes(routes!(policies::get, policies::update, policies::delete))
|
.routes(routes!(policies::get, policies::update, policies::delete))
|
||||||
.routes(routes!(subtitle_settings::get, subtitle_settings::update))
|
.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.
|
/// The generated `OpenAPI` document.
|
||||||
@@ -362,6 +374,16 @@ mod tests {
|
|||||||
("/api/roots", "post"),
|
("/api/roots", "post"),
|
||||||
("/api/settings/subtitles", "get"),
|
("/api/settings/subtitles", "get"),
|
||||||
("/api/settings/subtitles", "put"),
|
("/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!(
|
assert!(
|
||||||
json["paths"][path][method].is_object(),
|
json["paths"][path][method].is_object(),
|
||||||
@@ -376,6 +398,9 @@ mod tests {
|
|||||||
"AttentionQueues",
|
"AttentionQueues",
|
||||||
"SeriesAttention",
|
"SeriesAttention",
|
||||||
"Series",
|
"Series",
|
||||||
|
"Subtitle",
|
||||||
|
"SubtitleCandidate",
|
||||||
|
"SubtitleSearchResults",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
json["components"]["schemas"][schema].is_object(),
|
json["components"]["schemas"][schema].is_object(),
|
||||||
|
|||||||
@@ -156,6 +156,16 @@ pub enum ApiError {
|
|||||||
OwnerNotFound,
|
OwnerNotFound,
|
||||||
PolicyNotFound,
|
PolicyNotFound,
|
||||||
RootNotFound,
|
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.
|
/// The §9.6 chip outcome: the title exists upstream but has no trailer.
|
||||||
/// Ordinary, so it must stay distinguishable from an upstream failure.
|
/// Ordinary, so it must stay distinguishable from an upstream failure.
|
||||||
NoTrailer,
|
NoTrailer,
|
||||||
@@ -184,6 +194,9 @@ impl IntoResponse for ApiError {
|
|||||||
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
|
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
|
||||||
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy 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::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::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string()),
|
||||||
Self::Conflict(error) => (StatusCode::CONFLICT, error),
|
Self::Conflict(error) => (StatusCode::CONFLICT, error),
|
||||||
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use arr_db::Db;
|
use arr_db::Db;
|
||||||
|
use arr_subs::{Backend, Provider};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key
|
/// 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<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
|
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
|
||||||
metadata_commands: mpsc::Sender<MetadataCommand>,
|
metadata_commands: mpsc::Sender<MetadataCommand>,
|
||||||
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
|
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
|
||||||
|
subtitle_providers: Arc<Vec<Arc<dyn Provider>>>,
|
||||||
|
translation_backends: Arc<Vec<Arc<dyn Backend>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Work explicitly requested through the movie API.
|
/// 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)),
|
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
|
||||||
metadata_commands,
|
metadata_commands,
|
||||||
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_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
|
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<Arc<dyn Provider>>) -> 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<Arc<dyn Backend>>) -> Self {
|
||||||
|
self.translation_backends = Arc::new(backends);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn subtitle_provider(&self, id: &str) -> Option<&Arc<dyn Provider>> {
|
||||||
|
self.subtitle_providers
|
||||||
|
.iter()
|
||||||
|
.find(|provider| provider.id().as_str() == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn subtitle_providers(&self) -> &[Arc<dyn Provider>] {
|
||||||
|
&self.subtitle_providers
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn translation_backend(&self, id: &str) -> Option<&Arc<dyn Backend>> {
|
||||||
|
self.translation_backends
|
||||||
|
.iter()
|
||||||
|
.find(|backend| backend.id().as_str() == id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Wait for the next manual movie action in the daemon's reconcile loop.
|
/// Wait for the next manual movie action in the daemon's reconcile loop.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user