2319 lines
83 KiB
Rust
2319 lines
83 KiB
Rust
//! 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};
|
|
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<String>,
|
|
pub candidate_id: Option<String>,
|
|
/// The translation backend, when arr made this one.
|
|
pub engine: Option<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
impl From<arr_db::SubtitleFile> 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.
|
|
// Five independent facts, not a state machine: every combination occurs.
|
|
#[allow(clippy::struct_excessive_bools)]
|
|
#[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,
|
|
/// Whether the candidate's release name is the one the file was
|
|
/// imported under — §15's second ranking tier. Reported rather than
|
|
/// left implicit in the order, because §9.3's manual view shows the
|
|
/// facts that decided a row, and the release name of the file on disk
|
|
/// is not otherwise on the wire.
|
|
pub release_match: bool,
|
|
pub release_name: Option<String>,
|
|
pub group: Option<String>,
|
|
pub source: Option<String>,
|
|
pub rating: Option<f32>,
|
|
pub download_count: Option<u64>,
|
|
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<String>,
|
|
}
|
|
|
|
/// One wanted language a media file still lacks, and why (§15, §9.6).
|
|
///
|
|
/// `reason` collapses `subtitle_attempts.state` to the words the title
|
|
/// detail page shows (issue #201): `searching`, `no_candidates`, `capped`
|
|
/// or `failed`. A wanted language with no attempt row yet — the reconcile
|
|
/// loop has not reached it — reads the same as `searching`: no verdict
|
|
/// exists either way.
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct MissingSubtitle {
|
|
pub language: String,
|
|
pub reason: String,
|
|
/// Why the last attempt failed. Set only when `reason` is `failed`.
|
|
pub detail: Option<String>,
|
|
}
|
|
|
|
/// One media file's subtitles and the wanted languages it still lacks.
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct SubtitleStatus {
|
|
pub media_file_id: i64,
|
|
/// Ordered by language (§9.6's one row of chips).
|
|
pub subtitles: Vec<Subtitle>,
|
|
pub missing: Vec<MissingSubtitle>,
|
|
}
|
|
|
|
/// One episode's media file, subtitles and gaps — the series-wide bulk
|
|
/// sibling of [`SubtitleStatus`], joined the same way `series::files`
|
|
/// already joins episode files, so the series detail page costs one call
|
|
/// rather than one per episode.
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct EpisodeSubtitleStatus {
|
|
pub episode_id: i64,
|
|
pub media_file_id: i64,
|
|
pub subtitles: Vec<Subtitle>,
|
|
pub missing: Vec<MissingSubtitle>,
|
|
}
|
|
|
|
/// 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<SubtitleCandidate>,
|
|
/// Providers that were asked and could not answer.
|
|
pub provider_errors: Vec<SubtitleProviderError>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
release_group: Option<String>,
|
|
source: Option<arr_core::Source>,
|
|
}
|
|
|
|
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<PathBuf, ApiError> {
|
|
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<Target, ApiError> {
|
|
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<MediaRef, ApiError> {
|
|
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<Option<(String, serde_json::Value)>, 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<String> {
|
|
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<Vec<Arc<dyn arr_subs::Provider>>, 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<String> =
|
|
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<Language, ApiError> {
|
|
if tag.trim().is_empty() {
|
|
return Err(ApiError::Invalid("language: must not be empty".into()));
|
|
}
|
|
Ok(arr_db::policy::language(tag))
|
|
}
|
|
|
|
/// Whether two release names are the same one, matched exactly as
|
|
/// `arr_core::subs`' ranking tier does — case-insensitively, and never when
|
|
/// either side is unknown.
|
|
fn same_release(candidate: Option<&str>, target: Option<&str>) -> bool {
|
|
match (candidate, target) {
|
|
(Some(candidate), Some(target)) => candidate.eq_ignore_ascii_case(target),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
async fn subtitles_of(state: &AppState, media_file_id: i64) -> Result<Vec<Subtitle>, 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<Vec<Subtitle>, 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)
|
|
}
|
|
|
|
/// The global wanted set (§15), as `/settings` last saved it.
|
|
async fn wanted_languages(state: &AppState) -> Result<Vec<String>, ApiError> {
|
|
let raw = sqlx::query_scalar!(
|
|
r#"SELECT wanted_languages AS "wanted_languages!: String" FROM subtitle_settings WHERE id = 1"#
|
|
)
|
|
.fetch_one(pool(state)?)
|
|
.await?;
|
|
serde_json::from_str(&raw).map_err(|error| ApiError::Database(error.to_string()))
|
|
}
|
|
|
|
/// `subtitle_attempts.state` in the title detail page's own words.
|
|
fn reason_of(attempt: &arr_db::SubtitleAttempt) -> (String, Option<String>) {
|
|
match attempt.state {
|
|
// Satisfied never reaches here: a satisfied language is dropped
|
|
// before this is called (satisfaction is read off the files, not
|
|
// the attempt row — DESIGN.md §15).
|
|
arr_db::SubtitleState::Wanted | arr_db::SubtitleState::Satisfied => {
|
|
("searching".to_owned(), None)
|
|
}
|
|
arr_db::SubtitleState::Unavailable => ("no_candidates".to_owned(), None),
|
|
arr_db::SubtitleState::Capped => ("capped".to_owned(), None),
|
|
arr_db::SubtitleState::Failed => ("failed".to_owned(), attempt.last_failure.clone()),
|
|
}
|
|
}
|
|
|
|
/// The wanted languages one media file still lacks, with why.
|
|
///
|
|
/// Satisfaction is decided here against the files actually on this file,
|
|
/// never against `subtitle_attempts.state` — that column is the loop's own
|
|
/// bookkeeping and DESIGN.md §15 is explicit that it must never become a
|
|
/// second source of truth for what counts as satisfied.
|
|
async fn missing_for(
|
|
state: &AppState,
|
|
media_file_id: i64,
|
|
subtitles: &[Subtitle],
|
|
wanted: &[String],
|
|
) -> Result<Vec<MissingSubtitle>, ApiError> {
|
|
let satisfied: BTreeSet<&str> = subtitles
|
|
.iter()
|
|
.filter(|subtitle| !subtitle.forced)
|
|
.map(|subtitle| subtitle.language.as_str())
|
|
.collect();
|
|
let gaps: Vec<&String> = wanted
|
|
.iter()
|
|
.filter(|language| !satisfied.contains(language.as_str()))
|
|
.collect();
|
|
if gaps.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let attempts = db::attempts_for(pool(state)?, media_file_id).await?;
|
|
Ok(gaps
|
|
.into_iter()
|
|
.map(|language| {
|
|
let (reason, detail) = attempts
|
|
.iter()
|
|
.find(|attempt| attempt.language == *language)
|
|
.map_or_else(|| ("searching".to_owned(), None), reason_of);
|
|
MissingSubtitle {
|
|
language: language.clone(),
|
|
reason,
|
|
detail,
|
|
}
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// Every media file one owner has, with its subtitles and its gaps (§9.6).
|
|
async fn status_for_owner(
|
|
state: &AppState,
|
|
owner_kind: &str,
|
|
owner_id: i64,
|
|
) -> Result<Vec<SubtitleStatus>, 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 wanted = wanted_languages(state).await?;
|
|
let mut out = Vec::with_capacity(ids.len());
|
|
for id in ids {
|
|
let subtitles = subtitles_of(state, id).await?;
|
|
let missing = missing_for(state, id, &subtitles, &wanted).await?;
|
|
out.push(SubtitleStatus {
|
|
media_file_id: id,
|
|
subtitles,
|
|
missing,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get, path = "/api/movies/{movie_id}/subtitles/status", tag = "subtitles",
|
|
params(("movie_id" = i64, Path, description = "Movie row id")),
|
|
responses(
|
|
(status = 200, body = [SubtitleStatus]),
|
|
(status = 404, body = ErrorBody),
|
|
(status = 500, body = ErrorBody),
|
|
(status = 503, body = ErrorBody)
|
|
)
|
|
)]
|
|
pub async fn status_for_movie(
|
|
State(state): State<AppState>,
|
|
UrlPath(movie_id): UrlPath<i64>,
|
|
) -> Result<Json<Vec<SubtitleStatus>>, 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(status_for_owner(&state, "movie", movie_id).await?))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get, path = "/api/episodes/{episode_id}/subtitles/status", tag = "subtitles",
|
|
params(("episode_id" = i64, Path, description = "Episode row id")),
|
|
responses(
|
|
(status = 200, body = [SubtitleStatus]),
|
|
(status = 404, body = ErrorBody),
|
|
(status = 500, body = ErrorBody),
|
|
(status = 503, body = ErrorBody)
|
|
)
|
|
)]
|
|
pub async fn status_for_episode(
|
|
State(state): State<AppState>,
|
|
UrlPath(episode_id): UrlPath<i64>,
|
|
) -> Result<Json<Vec<SubtitleStatus>>, 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(status_for_owner(&state, "episode", episode_id).await?))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get, path = "/api/series/{series_id}/subtitles/status", tag = "subtitles",
|
|
params(("series_id" = i64, Path, description = "Series row id")),
|
|
responses(
|
|
(status = 200, body = [EpisodeSubtitleStatus]),
|
|
(status = 404, body = ErrorBody),
|
|
(status = 500, body = ErrorBody),
|
|
(status = 503, body = ErrorBody)
|
|
)
|
|
)]
|
|
pub async fn status_for_series(
|
|
State(state): State<AppState>,
|
|
UrlPath(series_id): UrlPath<i64>,
|
|
) -> Result<Json<Vec<EpisodeSubtitleStatus>>, ApiError> {
|
|
let exists = sqlx::query_scalar!(
|
|
r#"SELECT EXISTS(SELECT 1 FROM series WHERE id = ?) AS "exists!: bool""#,
|
|
series_id
|
|
)
|
|
.fetch_one(pool(&state)?)
|
|
.await?;
|
|
if !exists {
|
|
return Err(ApiError::NotFound);
|
|
}
|
|
|
|
let rows = sqlx::query!(
|
|
r#"SELECT mf.id AS "media_file_id!: i64", e.id AS "episode_id!: i64"
|
|
FROM media_files mf
|
|
JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id
|
|
JOIN seasons se ON se.id = e.season_id
|
|
WHERE se.series_id = ?
|
|
ORDER BY mf.path"#,
|
|
series_id
|
|
)
|
|
.fetch_all(pool(&state)?)
|
|
.await?;
|
|
let wanted = wanted_languages(&state).await?;
|
|
let mut out = Vec::with_capacity(rows.len());
|
|
for row in rows {
|
|
let subtitles = subtitles_of(&state, row.media_file_id).await?;
|
|
let missing = missing_for(&state, row.media_file_id, &subtitles, &wanted).await?;
|
|
out.push(EpisodeSubtitleStatus {
|
|
episode_id: row.episode_id,
|
|
media_file_id: row.media_file_id,
|
|
subtitles,
|
|
missing,
|
|
});
|
|
}
|
|
Ok(Json(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<AppState>,
|
|
UrlPath(media_file_id): UrlPath<i64>,
|
|
) -> Result<Json<Vec<Subtitle>>, 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<AppState>,
|
|
UrlPath(movie_id): UrlPath<i64>,
|
|
) -> Result<Json<Vec<Subtitle>>, 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<AppState>,
|
|
UrlPath(episode_id): UrlPath<i64>,
|
|
) -> Result<Json<Vec<Subtitle>>, 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<AppState>,
|
|
UrlPath(media_file_id): UrlPath<i64>,
|
|
body: Result<Json<SubtitleSearchInput>, JsonRejection>,
|
|
) -> Result<Json<SubtitleSearchResults>, 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_match: same_release(
|
|
candidate.release_name.as_deref(),
|
|
target.release_name.as_deref(),
|
|
),
|
|
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<AppState>,
|
|
UrlPath(media_file_id): UrlPath<i64>,
|
|
body: Result<Json<SubtitleGrabInput>, JsonRejection>,
|
|
) -> Result<(StatusCode, Json<Subtitle>), 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 sync = state.syncer().settle(&target.path, &destination).await;
|
|
if let Some(synced) = &sync.content {
|
|
write_sidecar(&destination, synced).await?;
|
|
}
|
|
|
|
let mut record = arr_db::NewSubtitleFile::fetched(
|
|
target.media_file_id,
|
|
&language.to_string(),
|
|
&input.provider,
|
|
&input.candidate_id,
|
|
&destination.to_string_lossy(),
|
|
)
|
|
.sync(db_sync_state(sync.state));
|
|
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<AppState>,
|
|
UrlPath(media_file_id): UrlPath<i64>,
|
|
body: Result<Json<SubtitleTranslateInput>, JsonRejection>,
|
|
) -> Result<(StatusCode, Json<Subtitle>), 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<AppState>,
|
|
UrlPath(subtitle_id): UrlPath<i64>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
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<String, ApiError> {
|
|
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<String, ApiError> {
|
|
// Sidecars are SRT (§15), but a provider serving VTT or ASS is converted
|
|
// rather than refused (#213). Decoding happens inside `to_srt`, and a
|
|
// format with no parser still fails here rather than reaching the disk.
|
|
fetched
|
|
.to_srt()
|
|
.map_err(|error| ApiError::SubtitleUpstream(error.to_string()))
|
|
}
|
|
|
|
/// Map `arr_subs`'s three-state sync result onto the column pair `arr_db`
|
|
/// stores it as. `Syncer::settle` already folded an unusable `alass` and an
|
|
/// implausible result together into "nothing changed" — this is just the
|
|
/// vocabulary switch between the crate that ran `alass` and the one that
|
|
/// persists what it decided.
|
|
const fn db_sync_state(state: arr_subs::SyncState) -> arr_db::SubtitleSync {
|
|
match state {
|
|
arr_subs::SyncState::NotRun => arr_db::SubtitleSync::NotRun,
|
|
arr_subs::SyncState::Synced => arr_db::SubtitleSync::Synced,
|
|
arr_subs::SyncState::Rejected => arr_db::SubtitleSync::Rejected,
|
|
}
|
|
}
|
|
|
|
/// 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<Subtitle>), 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)?;
|
|
refresh_jellyfin(state).await;
|
|
Ok((StatusCode::CREATED, Json(Subtitle::from(subtitle))))
|
|
}
|
|
|
|
/// Ask Jellyfin to rescan, the same single call §7.5 already makes on
|
|
/// import. Its filesystem watcher misses a sidecar dropped in next to a file
|
|
/// it already knows about, and a failure here must not fail the write that
|
|
/// already landed on disk.
|
|
async fn refresh_jellyfin(state: &AppState) {
|
|
let Some(jellyfin) = state.jellyfin() else {
|
|
return;
|
|
};
|
|
if let Err(error) = jellyfin.refresh().await {
|
|
tracing::warn!(%error, "jellyfin refresh failed");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::too_many_lines)]
|
|
mod tests {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
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 tokio::io::AsyncWriteExt;
|
|
|
|
use crate::{router, AppState, Upstreams};
|
|
|
|
const SRT: &str = "1\n00:00:01,000 --> 00:00:02,000\nolá\n";
|
|
|
|
/// §15's second ranking tier, as the manual view reads it: the same
|
|
/// release name matches whatever its casing, and an unknown name on
|
|
/// either side is never a match.
|
|
#[test]
|
|
fn release_names_match_case_insensitively_and_never_when_unknown() {
|
|
assert!(super::same_release(
|
|
Some("Dune.2021.2160p.WEB-DL-GROUP"),
|
|
Some("dune.2021.2160p.web-dl-group")
|
|
));
|
|
assert!(!super::same_release(Some("Dune.2021"), Some("Dune.2024")));
|
|
assert!(!super::same_release(Some("Dune.2021"), None));
|
|
assert!(!super::same_release(None, Some("Dune.2021")));
|
|
assert!(!super::same_release(None, None));
|
|
}
|
|
|
|
/// 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,
|
|
body: String,
|
|
}
|
|
|
|
impl StubProvider {
|
|
fn new(name: &str) -> Self {
|
|
Self {
|
|
id: ProviderId::new(name),
|
|
format: SubtitleFormat::Srt,
|
|
body: SRT.to_owned(),
|
|
}
|
|
}
|
|
|
|
fn serving(name: &str, format: SubtitleFormat) -> Self {
|
|
Self {
|
|
id: ProviderId::new(name),
|
|
format,
|
|
body: SRT.to_owned(),
|
|
}
|
|
}
|
|
|
|
/// Serve a format together with a body actually in that format, so a
|
|
/// conversion test exercises the parser rather than the error path.
|
|
fn serving_body(name: &str, format: SubtitleFormat, body: &str) -> Self {
|
|
Self {
|
|
id: ProviderId::new(name),
|
|
format,
|
|
body: body.to_owned(),
|
|
}
|
|
}
|
|
|
|
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: self.body.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::SubtitleFile> {
|
|
arr_db::subtitles::files_for(&self.pool, self.media_file_id)
|
|
.await
|
|
.expect("files")
|
|
}
|
|
|
|
async fn attempt_state(&self, language: &str) -> Option<String> {
|
|
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<Arc<dyn Provider>>,
|
|
backends: Vec<Arc<dyn Backend>>,
|
|
) -> Fixture {
|
|
build_fixture(providers, backends, arr_subs::Syncer::default()).await
|
|
}
|
|
|
|
async fn application_with_syncer(
|
|
providers: Vec<Arc<dyn Provider>>,
|
|
backends: Vec<Arc<dyn Backend>>,
|
|
syncer: arr_subs::Syncer,
|
|
) -> Fixture {
|
|
build_fixture(providers, backends, syncer).await
|
|
}
|
|
|
|
async fn build_fixture(
|
|
providers: Vec<Arc<dyn Provider>>,
|
|
backends: Vec<Arc<dyn Backend>>,
|
|
syncer: arr_subs::Syncer,
|
|
) -> 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)
|
|
.with_syncer(syncer);
|
|
|
|
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
|
|
}
|
|
|
|
/// Same fixture as [`application`], with a Jellyfin client attached so a
|
|
/// write can be observed asking it to refresh (§7.5, §15).
|
|
async fn application_with_jellyfin(
|
|
providers: Vec<Arc<dyn Provider>>,
|
|
backends: Vec<Arc<dyn Backend>>,
|
|
jellyfin_url: &str,
|
|
) -> 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();
|
|
|
|
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");
|
|
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 jellyfin =
|
|
crate::jellyfin::JellyfinClient::new(jellyfin_url, None).expect("jellyfin client");
|
|
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)
|
|
.with_jellyfin(jellyfin);
|
|
|
|
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 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");
|
|
// No grab record backs this file, so nothing can claim its release
|
|
// name — the chip reads "no", never "unknown".
|
|
assert_eq!(candidates[0]["release_match"], false);
|
|
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");
|
|
}
|
|
|
|
/// §7.5 applied to subtitles: a grab calls Jellyfin's refresh, the same
|
|
/// single call import already makes, because its watcher misses a
|
|
/// sidecar dropped next to a file it already knows about.
|
|
#[tokio::test]
|
|
async fn a_grab_refreshes_jellyfin() {
|
|
let jellyfin = wiremock::MockServer::start().await;
|
|
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
|
.and(wiremock::matchers::path("/Library/Refresh"))
|
|
.respond_with(wiremock::ResponseTemplate::new(204))
|
|
.mount(&jellyfin)
|
|
.await;
|
|
|
|
let fixture = application_with_jellyfin(
|
|
vec![Arc::new(StubProvider::new("opensubtitles"))],
|
|
vec![],
|
|
&jellyfin.uri(),
|
|
)
|
|
.await;
|
|
|
|
let (status, body) = grab(&fixture, pt()).await;
|
|
assert_eq!(status, StatusCode::CREATED, "{body}");
|
|
assert_eq!(
|
|
jellyfin.received_requests().await.expect("requests").len(),
|
|
1
|
|
);
|
|
}
|
|
|
|
/// A refresh failure must not fail the grab that already landed the
|
|
/// sidecar on disk (§7.5).
|
|
#[tokio::test]
|
|
async fn an_unreachable_jellyfin_does_not_fail_the_grab() {
|
|
let fixture = application_with_jellyfin(
|
|
vec![Arc::new(StubProvider::new("opensubtitles"))],
|
|
vec![],
|
|
"http://127.0.0.1:1",
|
|
)
|
|
.await;
|
|
|
|
let (status, body) = grab(&fixture, pt()).await;
|
|
assert_eq!(status, StatusCode::CREATED, "{body}");
|
|
}
|
|
|
|
/// §15's disk rule: the sidecar sits next to the video, inside the §7.4
|
|
/// folder, named `<video basename>.<lang>.srt`.
|
|
#[tokio::test]
|
|
async fn a_grab_writes_the_sidecar_beside_the_video_and_records_it() {
|
|
let fixture = stub_application().await;
|
|
let (status, body) = grab(&fixture, pt()).await;
|
|
assert_eq!(status, StatusCode::CREATED, "{body}");
|
|
|
|
let expected = fixture
|
|
.folder
|
|
.join("Dune (2021) [tmdbid-438631] - [2160p][WEB-DL].pt-PT.srt");
|
|
assert_eq!(body["path"], expected.to_string_lossy().as_ref());
|
|
assert_eq!(body["origin"], "provider");
|
|
assert_eq!(body["provider"], "opensubtitles");
|
|
assert_eq!(body["candidate_id"], "hashed");
|
|
assert_eq!(
|
|
tokio::fs::read_to_string(&expected).await.expect("sidecar"),
|
|
SRT
|
|
);
|
|
assert!(
|
|
!fixture
|
|
.folder
|
|
.join("Dune (2021) [tmdbid-438631] - [2160p][WEB-DL].pt-PT.srt.partial")
|
|
.exists(),
|
|
"the temporary file is renamed away, not left behind"
|
|
);
|
|
assert_eq!(
|
|
fixture.attempt_state("pt-PT").await.as_deref(),
|
|
Some("satisfied")
|
|
);
|
|
}
|
|
|
|
/// §15: a manual action bypasses the wanted set. Spanish is not wanted,
|
|
/// and after this the loop must still not read it as a gap.
|
|
#[tokio::test]
|
|
async fn a_grab_outside_the_wanted_set_satisfies_its_own_language() {
|
|
let fixture = stub_application().await;
|
|
let mut body = pt();
|
|
body["language"] = serde_json::json!("es");
|
|
let (status, _) = grab(&fixture, body).await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
|
assert_eq!(
|
|
fixture.attempt_state("es").await.as_deref(),
|
|
Some("satisfied")
|
|
);
|
|
let pending = arr_db::subtitles::pending(&fixture.pool, 10)
|
|
.await
|
|
.expect("pending");
|
|
assert!(
|
|
pending.iter().all(|row| row.attempt.language != "es"),
|
|
"a manual grab must not leave a gap the loop would refill"
|
|
);
|
|
}
|
|
|
|
/// §15: a forced track covers signs only, so it is recorded and it still
|
|
/// satisfies nothing.
|
|
#[tokio::test]
|
|
async fn a_forced_grab_is_recorded_without_satisfying_the_language() {
|
|
let fixture = stub_application().await;
|
|
let mut body = pt();
|
|
body["candidate_id"] = serde_json::json!("forced");
|
|
body["forced"] = serde_json::json!(true);
|
|
let (status, subtitle) = grab(&fixture, body).await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
assert_eq!(subtitle["forced"], true);
|
|
assert_eq!(fixture.attempt_state("pt-PT").await, None);
|
|
}
|
|
|
|
/// §15 has no in-place replacement: delete first, then fetch.
|
|
#[tokio::test]
|
|
async fn a_second_grab_at_the_same_path_is_refused() {
|
|
let fixture = stub_application().await;
|
|
assert_eq!(grab(&fixture, pt()).await.0, StatusCode::CREATED);
|
|
let (status, body) = grab(&fixture, pt()).await;
|
|
assert_eq!(status, StatusCode::CONFLICT, "{body}");
|
|
assert_eq!(fixture.subtitle_rows().await.len(), 1);
|
|
}
|
|
|
|
/// Write an executable fake `alass`. Its body receives the subtitle,
|
|
/// video and output paths as `$1`, `$2`, `$3`.
|
|
async fn fake_alass(dir: &std::path::Path, body: &str) -> PathBuf {
|
|
let path = dir.join("alass");
|
|
let mut file = tokio::fs::File::create(&path).await.expect("fake alass");
|
|
file.write_all(b"#!/bin/sh\n").await.expect("fake alass");
|
|
file.write_all(body.as_bytes()).await.expect("fake alass");
|
|
file.sync_all().await.expect("fake alass");
|
|
drop(file);
|
|
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
|
.await
|
|
.expect("fake alass");
|
|
path
|
|
}
|
|
|
|
/// §15: `alass` runs on every fetched subtitle (#214), and an accepted
|
|
/// shift replaces the sidecar's content before the row is recorded.
|
|
#[tokio::test]
|
|
async fn a_grab_writes_the_synced_content_when_alass_accepts_it() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
// The fetched cue starts at 1s (`SRT`); a 5s shift is within §15's
|
|
// 60-second bound.
|
|
let binary = fake_alass(
|
|
dir.path(),
|
|
"printf '1\\n00:00:06,000 --> 00:00:07,000\\nola\\n' > \"$3\"\n",
|
|
)
|
|
.await;
|
|
let fixture = application_with_syncer(
|
|
vec![Arc::new(StubProvider::new("opensubtitles"))],
|
|
vec![],
|
|
arr_subs::Syncer::new().with_binary(&binary),
|
|
)
|
|
.await;
|
|
|
|
let (status, body) = grab(&fixture, pt()).await;
|
|
assert_eq!(status, StatusCode::CREATED, "{body}");
|
|
assert_eq!(body["sync"], "synced");
|
|
let sidecar = PathBuf::from(body["path"].as_str().expect("path"));
|
|
assert_eq!(
|
|
tokio::fs::read_to_string(&sidecar).await.expect("sidecar"),
|
|
"1\n00:00:06,000 --> 00:00:07,000\nola\n"
|
|
);
|
|
}
|
|
|
|
/// §15: an implausible shift keeps the unsynced original and flags the
|
|
/// file rather than failing the grab.
|
|
#[tokio::test]
|
|
async fn a_grab_keeps_the_original_when_alass_is_implausible() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let binary = fake_alass(
|
|
dir.path(),
|
|
"printf '1\\n00:05:00,000 --> 00:05:01,000\\nola\\n' > \"$3\"\n",
|
|
)
|
|
.await;
|
|
let fixture = application_with_syncer(
|
|
vec![Arc::new(StubProvider::new("opensubtitles"))],
|
|
vec![],
|
|
arr_subs::Syncer::new().with_binary(&binary),
|
|
)
|
|
.await;
|
|
|
|
let (status, body) = grab(&fixture, pt()).await;
|
|
assert_eq!(status, StatusCode::CREATED, "{body}");
|
|
assert_eq!(body["sync"], "rejected");
|
|
let sidecar = PathBuf::from(body["path"].as_str().expect("path"));
|
|
assert_eq!(
|
|
tokio::fs::read_to_string(&sidecar).await.expect("sidecar"),
|
|
SRT
|
|
);
|
|
}
|
|
|
|
/// Sidecars are SRT (§15), so a provider serving ASS or VTT is converted
|
|
/// on the way to disk (#213) rather than refused.
|
|
#[tokio::test]
|
|
async fn a_provider_serving_ass_is_converted_to_srt() {
|
|
const ASS: &str = "[Events]\n\
|
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
|
|
Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,{\\an8}olá\n";
|
|
let fixture = application(
|
|
vec![Arc::new(StubProvider::serving_body(
|
|
"opensubtitles",
|
|
SubtitleFormat::Ass,
|
|
ASS,
|
|
))],
|
|
vec![],
|
|
)
|
|
.await;
|
|
|
|
let (status, body) = grab(&fixture, pt()).await;
|
|
|
|
assert_eq!(status, StatusCode::CREATED, "{body}");
|
|
let sidecar = fixture
|
|
.folder
|
|
.join("Dune (2021) [tmdbid-438631] - [2160p][WEB-DL].pt-PT.srt");
|
|
let written = std::fs::read_to_string(&sidecar).expect("the sidecar is written");
|
|
assert!(
|
|
written.starts_with("1\n00:00:01,000 --> 00:00:02,000"),
|
|
"{written}"
|
|
);
|
|
// The override tag is styling, dropped by the conversion.
|
|
assert!(
|
|
written.contains("olá") && !written.contains("an8"),
|
|
"{written}"
|
|
);
|
|
}
|
|
|
|
/// A format with no parser is still refused — converting is not guessing.
|
|
#[tokio::test]
|
|
async fn a_provider_serving_a_format_with_no_parser_is_refused() {
|
|
let fixture = application(
|
|
vec![Arc::new(StubProvider::serving(
|
|
"opensubtitles",
|
|
SubtitleFormat::Other("sub".to_owned()),
|
|
))],
|
|
vec![],
|
|
)
|
|
.await;
|
|
|
|
let (status, body) = grab(&fixture, pt()).await;
|
|
|
|
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}");
|
|
assert!(fixture.subtitle_rows().await.is_empty());
|
|
assert!(!fixture
|
|
.folder
|
|
.join("Dune (2021) [tmdbid-438631] - [2160p][WEB-DL].pt-PT.srt")
|
|
.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_grab_naming_an_unconfigured_provider_is_a_422() {
|
|
let fixture = stub_application().await;
|
|
let mut body = pt();
|
|
body["provider"] = serde_json::json!("podnapisi");
|
|
let (status, _) = grab(&fixture, body).await;
|
|
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
|
}
|
|
|
|
/// §15: the sidecar of a machine translation carries `.mt`, so `ls`
|
|
/// says which subtitles arr made.
|
|
#[tokio::test]
|
|
async fn a_translation_writes_an_mt_sidecar_from_an_existing_subtitle() {
|
|
let fixture = application(
|
|
vec![Arc::new(StubProvider::new("opensubtitles"))],
|
|
vec![Arc::new(StubBackend)],
|
|
)
|
|
.await;
|
|
let (_, source) = grab(&fixture, pt()).await;
|
|
let source_id = source["id"].as_i64().expect("source id");
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{}/api/media-files/1/subtitles/translate",
|
|
fixture.base
|
|
))
|
|
.json(&serde_json::json!({
|
|
"source_subtitle_id": source_id,
|
|
"target_language": "en",
|
|
"engine": "openai"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("translate");
|
|
assert_eq!(response.status(), StatusCode::CREATED);
|
|
let body: serde_json::Value = response.json().await.expect("json");
|
|
assert_eq!(body["origin"], "translated");
|
|
assert_eq!(body["engine"], "openai");
|
|
|
|
let expected = fixture
|
|
.folder
|
|
.join("Dune (2021) [tmdbid-438631] - [2160p][WEB-DL].en.mt.srt");
|
|
assert_eq!(body["path"], expected.to_string_lossy().as_ref());
|
|
let written = tokio::fs::read_to_string(&expected).await.expect("sidecar");
|
|
assert!(written.contains("OLÁ"), "{written}");
|
|
assert!(
|
|
written.contains("00:00:01,000 --> 00:00:02,000"),
|
|
"timings never change: {written}"
|
|
);
|
|
assert_eq!(
|
|
fixture.attempt_state("en").await.as_deref(),
|
|
Some("satisfied")
|
|
);
|
|
}
|
|
|
|
/// §15 allows a machine translation as the source of another one.
|
|
#[tokio::test]
|
|
async fn a_machine_translation_is_itself_a_legal_source() {
|
|
let fixture = application(
|
|
vec![Arc::new(StubProvider::new("opensubtitles"))],
|
|
vec![Arc::new(StubBackend)],
|
|
)
|
|
.await;
|
|
let (_, source) = grab(&fixture, pt()).await;
|
|
let client = reqwest::Client::new();
|
|
let translate = |id: i64, target: &str| {
|
|
client
|
|
.post(format!(
|
|
"{}/api/media-files/1/subtitles/translate",
|
|
fixture.base
|
|
))
|
|
.json(&serde_json::json!({
|
|
"source_subtitle_id": id,
|
|
"target_language": target,
|
|
"engine": "openai"
|
|
}))
|
|
.send()
|
|
};
|
|
|
|
let first: serde_json::Value = translate(source["id"].as_i64().expect("id"), "en")
|
|
.await
|
|
.expect("translate")
|
|
.json()
|
|
.await
|
|
.expect("json");
|
|
let second = translate(first["id"].as_i64().expect("id"), "es")
|
|
.await
|
|
.expect("translate again");
|
|
assert_eq!(second.status(), StatusCode::CREATED);
|
|
}
|
|
|
|
/// An embedded track has no file, so it carries no text to translate.
|
|
#[tokio::test]
|
|
async fn translating_from_an_unextracted_embedded_track_is_refused() {
|
|
let fixture = application(vec![], vec![Arc::new(StubBackend)]).await;
|
|
let id = arr_db::subtitles::record_file(
|
|
&fixture.pool,
|
|
&arr_db::NewSubtitleFile::embedded(1, "pt-PT"),
|
|
)
|
|
.await
|
|
.expect("record embedded");
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{}/api/media-files/1/subtitles/translate",
|
|
fixture.base
|
|
))
|
|
.json(&serde_json::json!({
|
|
"source_subtitle_id": id,
|
|
"target_language": "en",
|
|
"engine": "openai"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("translate");
|
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
}
|
|
|
|
/// The engines §15 names are not all compiled in; one that is not must
|
|
/// say so rather than fail obscurely.
|
|
#[tokio::test]
|
|
async fn translating_with_an_engine_this_binary_lacks_is_a_503() {
|
|
let fixture = application(vec![Arc::new(StubProvider::new("opensubtitles"))], vec![]).await;
|
|
let (_, source) = grab(&fixture, pt()).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{}/api/media-files/1/subtitles/translate",
|
|
fixture.base
|
|
))
|
|
.json(&serde_json::json!({
|
|
"source_subtitle_id": source["id"],
|
|
"target_language": "en",
|
|
"engine": "deepl"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("translate");
|
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn translating_with_no_engine_named_or_configured_is_a_422() {
|
|
let fixture = application(vec![Arc::new(StubProvider::new("opensubtitles"))], vec![]).await;
|
|
let (_, source) = grab(&fixture, pt()).await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.post(format!(
|
|
"{}/api/media-files/1/subtitles/translate",
|
|
fixture.base
|
|
))
|
|
.json(&serde_json::json!({
|
|
"source_subtitle_id": source["id"],
|
|
"target_language": "en"
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("translate");
|
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
}
|
|
|
|
/// Deleting removes both halves, and the language becomes a gap again —
|
|
/// satisfaction is read off the files (§15), never off the attempt row.
|
|
#[tokio::test]
|
|
async fn deleting_a_subtitle_removes_the_sidecar_and_reopens_the_gap() {
|
|
let fixture = stub_application().await;
|
|
let (_, subtitle) = grab(&fixture, pt()).await;
|
|
let path = PathBuf::from(subtitle["path"].as_str().expect("path"));
|
|
let id = subtitle["id"].as_i64().expect("id");
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!("{}/api/subtitles/{id}", fixture.base))
|
|
.send()
|
|
.await
|
|
.expect("delete");
|
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
|
assert!(!path.exists(), "the sidecar is gone from disk");
|
|
assert!(fixture.subtitle_rows().await.is_empty());
|
|
assert_eq!(
|
|
fixture.attempt_state("pt-PT").await.as_deref(),
|
|
Some("wanted")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deleting_a_subtitle_that_is_not_there_is_a_404() {
|
|
let fixture = stub_application().await;
|
|
let response = reqwest::Client::new()
|
|
.delete(format!("{}/api/subtitles/404", fixture.base))
|
|
.send()
|
|
.await
|
|
.expect("delete");
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
/// The same subtitles, reachable by file and by title (§9.1).
|
|
#[tokio::test]
|
|
async fn subtitles_list_by_media_file_and_by_title() {
|
|
let fixture = stub_application().await;
|
|
arr_db::subtitles::record_file(
|
|
&fixture.pool,
|
|
&arr_db::NewSubtitleFile::embedded(1, "en").sdh(),
|
|
)
|
|
.await
|
|
.expect("embedded");
|
|
grab(&fixture, pt()).await;
|
|
|
|
for url in [
|
|
format!("{}/api/media-files/1/subtitles", fixture.base),
|
|
format!("{}/api/movies/1/subtitles", fixture.base),
|
|
] {
|
|
let listed: Vec<serde_json::Value> = reqwest::get(&url)
|
|
.await
|
|
.expect("list")
|
|
.json()
|
|
.await
|
|
.expect("json");
|
|
assert_eq!(listed.len(), 2, "{url}");
|
|
assert_eq!(listed[0]["language"], "en");
|
|
assert_eq!(listed[0]["origin"], "embedded");
|
|
assert_eq!(listed[0]["sdh"], true);
|
|
assert!(listed[0]["path"].is_null(), "an embedded track has no file");
|
|
assert_eq!(listed[1]["language"], "pt-PT");
|
|
assert_eq!(listed[1]["sync"], "not_run");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn listing_subtitles_for_a_title_that_is_not_there_is_a_404() {
|
|
let fixture = stub_application().await;
|
|
for url in [
|
|
format!("{}/api/movies/99/subtitles", fixture.base),
|
|
format!("{}/api/episodes/99/subtitles", fixture.base),
|
|
format!("{}/api/media-files/99/subtitles", fixture.base),
|
|
] {
|
|
assert_eq!(
|
|
reqwest::get(&url).await.expect("list").status(),
|
|
StatusCode::NOT_FOUND,
|
|
"{url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_empty_language_is_a_422() {
|
|
let fixture = stub_application().await;
|
|
let (status, _) = search(&fixture, " ").await;
|
|
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
|
}
|
|
|
|
/// The video is where the sidecar goes, so the fixture's path has to be
|
|
/// the one the handler derives from.
|
|
#[tokio::test]
|
|
async fn the_sidecar_lands_in_the_video_s_own_folder() {
|
|
let fixture = stub_application().await;
|
|
let (_, body) = grab(&fixture, pt()).await;
|
|
let written = PathBuf::from(body["path"].as_str().expect("path"));
|
|
assert_eq!(written.parent(), fixture.video.parent());
|
|
}
|
|
|
|
async fn status(url: &str) -> Vec<serde_json::Value> {
|
|
reqwest::get(url)
|
|
.await
|
|
.expect("status")
|
|
.json()
|
|
.await
|
|
.expect("json")
|
|
}
|
|
|
|
/// #201: a language nothing has been tried for yet reads as `searching`,
|
|
/// with no attempt row required — the reconcile loop just has not
|
|
/// reached it.
|
|
#[tokio::test]
|
|
async fn status_reports_untouched_wanted_languages_as_searching() {
|
|
let fixture = stub_application().await;
|
|
let statuses = status(&format!("{}/api/movies/1/subtitles/status", fixture.base)).await;
|
|
assert_eq!(statuses.len(), 1);
|
|
assert_eq!(statuses[0]["media_file_id"], 1);
|
|
assert!(statuses[0]["subtitles"]
|
|
.as_array()
|
|
.expect("subtitles")
|
|
.is_empty());
|
|
let missing = statuses[0]["missing"].as_array().expect("missing");
|
|
assert_eq!(missing.len(), 2, "{missing:?}");
|
|
assert_eq!(missing[0]["language"], "pt-PT");
|
|
assert_eq!(missing[0]["reason"], "searching");
|
|
assert_eq!(missing[1]["language"], "en");
|
|
assert_eq!(missing[1]["reason"], "searching");
|
|
}
|
|
|
|
/// Satisfaction is read off the files (§15): once pt-PT has a subtitle,
|
|
/// it drops out of `missing` regardless of what its attempt row says.
|
|
#[tokio::test]
|
|
async fn status_drops_a_satisfied_language_from_missing() {
|
|
let fixture = stub_application().await;
|
|
assert_eq!(grab(&fixture, pt()).await.0, StatusCode::CREATED);
|
|
|
|
let statuses = status(&format!("{}/api/movies/1/subtitles/status", fixture.base)).await;
|
|
let subtitles = statuses[0]["subtitles"].as_array().expect("subtitles");
|
|
assert_eq!(subtitles.len(), 1);
|
|
assert_eq!(subtitles[0]["language"], "pt-PT");
|
|
let missing = statuses[0]["missing"].as_array().expect("missing");
|
|
assert_eq!(missing.len(), 1);
|
|
assert_eq!(missing[0]["language"], "en");
|
|
}
|
|
|
|
/// #201: `no_candidates`, `capped` and `failed` carry the attempt row's
|
|
/// own state, and `failed` also carries `last_failure` as `detail`.
|
|
#[tokio::test]
|
|
async fn status_reports_the_attempt_s_reason_for_a_missing_language() {
|
|
let fixture = stub_application().await;
|
|
arr_db::subtitles::record_attempt(
|
|
&fixture.pool,
|
|
fixture.media_file_id,
|
|
"en",
|
|
arr_db::SubtitleState::Failed,
|
|
Some("429 from opensubtitles"),
|
|
)
|
|
.await
|
|
.expect("record failed attempt");
|
|
arr_db::subtitles::record_attempt(
|
|
&fixture.pool,
|
|
fixture.media_file_id,
|
|
"pt-PT",
|
|
arr_db::SubtitleState::Capped,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("record capped attempt");
|
|
|
|
let statuses = status(&format!("{}/api/movies/1/subtitles/status", fixture.base)).await;
|
|
let missing = statuses[0]["missing"].as_array().expect("missing");
|
|
let by_language = |language: &str| {
|
|
missing
|
|
.iter()
|
|
.find(|entry| entry["language"] == language)
|
|
.unwrap_or_else(|| panic!("{language} missing from {missing:?}"))
|
|
};
|
|
assert_eq!(by_language("en")["reason"], "failed");
|
|
assert_eq!(by_language("en")["detail"], "429 from opensubtitles");
|
|
assert_eq!(by_language("pt-PT")["reason"], "capped");
|
|
assert!(by_language("pt-PT")["detail"].is_null());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn status_for_a_title_that_is_not_there_is_a_404() {
|
|
let fixture = stub_application().await;
|
|
for url in [
|
|
format!("{}/api/movies/99/subtitles/status", fixture.base),
|
|
format!("{}/api/episodes/99/subtitles/status", fixture.base),
|
|
format!("{}/api/series/99/subtitles/status", fixture.base),
|
|
] {
|
|
assert_eq!(
|
|
reqwest::get(&url).await.expect("status").status(),
|
|
StatusCode::NOT_FOUND,
|
|
"{url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// #201: the series-wide bulk endpoint joins episode media files the
|
|
/// same way `series::files` does, so the detail page costs one call.
|
|
#[tokio::test]
|
|
async fn series_status_joins_every_episode_s_file() {
|
|
let fixture = stub_application().await;
|
|
sqlx::query(
|
|
"INSERT INTO series (id, tmdb_id, title, root_id)
|
|
SELECT 1, 9999, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1",
|
|
)
|
|
.execute(&fixture.pool)
|
|
.await
|
|
.expect("series");
|
|
sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)")
|
|
.execute(&fixture.pool)
|
|
.await
|
|
.expect("season");
|
|
sqlx::query(
|
|
"INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 1, 'Hospital')",
|
|
)
|
|
.execute(&fixture.pool)
|
|
.await
|
|
.expect("episode");
|
|
sqlx::query(
|
|
"INSERT INTO media_files (id, owner_kind, owner_id, path, size) VALUES (2, 'episode', 1, 'S01E01.mkv', 100)",
|
|
)
|
|
.execute(&fixture.pool)
|
|
.await
|
|
.expect("media file");
|
|
arr_db::subtitles::mark_satisfied(&fixture.pool, 2, "en")
|
|
.await
|
|
.expect("mark satisfied");
|
|
arr_db::subtitles::record_file(&fixture.pool, &arr_db::NewSubtitleFile::embedded(2, "en"))
|
|
.await
|
|
.expect("record file");
|
|
|
|
let statuses = status(&format!("{}/api/series/1/subtitles/status", fixture.base)).await;
|
|
assert_eq!(statuses.len(), 1, "{statuses:?}");
|
|
assert_eq!(statuses[0]["episode_id"], 1);
|
|
assert_eq!(statuses[0]["media_file_id"], 2);
|
|
let subtitles = statuses[0]["subtitles"].as_array().expect("subtitles");
|
|
assert_eq!(subtitles.len(), 1);
|
|
assert_eq!(subtitles[0]["language"], "en");
|
|
let missing = statuses[0]["missing"].as_array().expect("missing");
|
|
assert_eq!(missing.len(), 1);
|
|
assert_eq!(missing[0]["language"], "pt-PT");
|
|
}
|
|
}
|