+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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE subtitle_attempts\n SET state = 'wanted',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE media_file_id = ? AND language = ? AND state = 'satisfied'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "31bed4c9097d6ea2ed189de48440bd0d882873d27450e5d177baed65c74fd7de"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
Generated
+1
@@ -94,6 +94,7 @@ dependencies = [
|
||||
"arr-meta",
|
||||
"arr-parse",
|
||||
"arr-probe",
|
||||
"arr-subs",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
|
||||
@@ -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<AppState> {
|
||||
.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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
|
||||
metadata_commands: mpsc::Sender<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.
|
||||
@@ -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<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.
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ arr-indexer = { workspace = true }
|
||||
arr-meta = { workspace = true }
|
||||
arr-parse = { workspace = true }
|
||||
arr-probe = { workspace = true }
|
||||
arr-subs = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
include_dir = { workspace = true }
|
||||
|
||||
@@ -144,7 +144,13 @@ async fn run() -> Result<(), Error> {
|
||||
if let Some(tmdb_url) = config.tmdb_url {
|
||||
upstreams = upstreams.with_tmdb_url(tmdb_url);
|
||||
}
|
||||
let state = AppState::new(upstreams)?.with_database(database.clone());
|
||||
let state = AppState::new(upstreams)?
|
||||
.with_database(database.clone())
|
||||
.with_subtitle_providers(subtitle_providers(
|
||||
config.opensubtitles_api_key,
|
||||
config.opensubtitles_username,
|
||||
config.opensubtitles_password,
|
||||
));
|
||||
|
||||
let app = arr_api::router(state.clone())
|
||||
.merge(arr_compat::router(compat))
|
||||
@@ -443,3 +449,42 @@ async fn shutdown() {
|
||||
|
||||
tracing::info!("shutting down");
|
||||
}
|
||||
|
||||
/// The subtitle providers this deployment can reach (DESIGN.md §15).
|
||||
///
|
||||
/// Credentials are bootstrap config and never reach the database (§10), so
|
||||
/// which providers *exist* is decided here, once, at startup; which of them a
|
||||
/// search *runs* is the `providers_enabled` setting the API reads per
|
||||
/// request. OpenSubtitles.com needs a registered API key to be called at all,
|
||||
/// so without one it is not offered; Podnapisi is anonymous and always is.
|
||||
///
|
||||
/// Translation backends are not wired: none is implemented yet (#191-#193),
|
||||
/// and `AppState` defaults to an empty registry, which makes a manual
|
||||
/// translation answer "no such engine" rather than fail obscurely.
|
||||
fn subtitle_providers(
|
||||
opensubtitles_api_key: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Vec<std::sync::Arc<dyn arr_subs::Provider>> {
|
||||
let mut providers: Vec<std::sync::Arc<dyn arr_subs::Provider>> = Vec::new();
|
||||
|
||||
match arr_subs::Podnapisi::new() {
|
||||
Ok(podnapisi) => providers.push(std::sync::Arc::new(podnapisi)),
|
||||
Err(error) => tracing::warn!(%error, "Podnapisi not available"),
|
||||
}
|
||||
|
||||
let Some(api_key) = opensubtitles_api_key else {
|
||||
tracing::info!("no OpenSubtitles.com API key configured; that provider is off");
|
||||
return providers;
|
||||
};
|
||||
match arr_subs::OpenSubtitles::new(arr_subs::OpenSubtitlesConfig {
|
||||
api_key,
|
||||
username,
|
||||
password,
|
||||
}) {
|
||||
Ok(opensubtitles) => providers.push(std::sync::Arc::new(opensubtitles)),
|
||||
Err(error) => tracing::warn!(%error, "OpenSubtitles.com not available"),
|
||||
}
|
||||
|
||||
providers
|
||||
}
|
||||
|
||||
@@ -473,6 +473,39 @@ pub async fn mark_satisfied(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Put a tracked language back to `wanted`, after the last subtitle in it
|
||||
/// was deleted.
|
||||
///
|
||||
/// Satisfaction is read off the files (§15), so a `satisfied` row whose file
|
||||
/// has just been removed by hand is a stale claim that would hide the gap
|
||||
/// from [`pending`]. The attempt count and timestamp are left alone: the
|
||||
/// backoff is a fact about what providers have already been asked, and a
|
||||
/// delete does not un-ask them.
|
||||
///
|
||||
/// A no-op when the language was never tracked — a manual grab in a language
|
||||
/// outside the wanted set has nothing to put back.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If the update fails.
|
||||
pub async fn unsatisfy(
|
||||
pool: &SqlitePool,
|
||||
media_file_id: i64,
|
||||
language: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"UPDATE subtitle_attempts
|
||||
SET state = 'wanted',
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE media_file_id = ? AND language = ? AND state = 'satisfied'",
|
||||
media_file_id,
|
||||
language
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every tracked language for one media file, ordered by language.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -804,3 +837,85 @@ mod tests {
|
||||
assert!(attempts_for(db.pool(), file).await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod unsatisfy_tests {
|
||||
use super::{attempts_for, mark_satisfied, pending, record_attempt, unsatisfy, SubtitleState};
|
||||
use crate::Db;
|
||||
|
||||
async fn file() -> (Db, tempfile::TempDir, i64) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let database = Db::connect(dir.path().join("test.db")).await.unwrap();
|
||||
database.migrate().await.unwrap();
|
||||
let id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO media_files (owner_kind, owner_id, path, size)
|
||||
VALUES ('movie', 1, '/m/a.mkv', 1) RETURNING id",
|
||||
)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
(database, dir, id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_deleted_subtitle_puts_its_language_back_in_the_work_list() {
|
||||
let (db, _dir, file) = file().await;
|
||||
mark_satisfied(db.pool(), file, "pt-PT").await.unwrap();
|
||||
assert!(pending(db.pool(), 10).await.unwrap().is_empty());
|
||||
|
||||
unsatisfy(db.pool(), file, "pt-PT").await.unwrap();
|
||||
|
||||
let pending = pending(db.pool(), 10).await.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].attempt.state, SubtitleState::Wanted);
|
||||
}
|
||||
|
||||
/// The backoff is a fact about what providers were already asked, and a
|
||||
/// delete does not un-ask them.
|
||||
#[tokio::test]
|
||||
async fn reopening_a_language_keeps_the_attempt_count() {
|
||||
let (db, _dir, file) = file().await;
|
||||
record_attempt(db.pool(), file, "pt-PT", SubtitleState::Failed, Some("429"))
|
||||
.await
|
||||
.unwrap();
|
||||
mark_satisfied(db.pool(), file, "pt-PT").await.unwrap();
|
||||
|
||||
unsatisfy(db.pool(), file, "pt-PT").await.unwrap();
|
||||
|
||||
let attempts = attempts_for(db.pool(), file).await.unwrap();
|
||||
assert_eq!(attempts[0].state, SubtitleState::Wanted);
|
||||
assert_eq!(attempts[0].attempts, 1);
|
||||
assert!(attempts[0].last_attempt_at.is_some());
|
||||
}
|
||||
|
||||
/// A language outside the wanted set was never tracked; there is nothing
|
||||
/// to put back and inventing a row would invent a gap.
|
||||
#[tokio::test]
|
||||
async fn an_untracked_language_is_left_alone() {
|
||||
let (db, _dir, file) = file().await;
|
||||
unsatisfy(db.pool(), file, "es").await.unwrap();
|
||||
assert!(attempts_for(db.pool(), file).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Only a satisfied claim is withdrawn: a language the loop has already
|
||||
/// given up on (`unavailable`) must not silently become work again.
|
||||
#[tokio::test]
|
||||
async fn a_language_in_another_state_is_not_reopened() {
|
||||
let (db, _dir, file) = file().await;
|
||||
record_attempt(
|
||||
db.pool(),
|
||||
file,
|
||||
"pt-PT",
|
||||
SubtitleState::Unavailable,
|
||||
Some("nothing anywhere"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
unsatisfy(db.pool(), file, "pt-PT").await.unwrap();
|
||||
|
||||
let attempts = attempts_for(db.pool(), file).await.unwrap();
|
||||
assert_eq!(attempts[0].state, SubtitleState::Unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user