Files
arr/crates/arr-api/src/subtitles.rs
T
Miguel Palhas 26bd25e2f6 feat(arr): convert non-SRT grabs instead of refusing
#199 shipped the manual grab before #213 existed, so srt_text answered 422
for any provider not serving SRT. Fetched::to_srt already decodes and
converts, so this is that call. A format with no parser still fails rather
than reaching the disk.

Leaves the alass half of #214 open; that waits on #194.
2026-08-25 00:46:33 +01:00

1670 lines
59 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.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SubtitleCandidate {
/// The provider offering it, and its own handle for it. Both travel back
/// unchanged in a grab.
pub provider: String,
pub candidate_id: String,
pub language: String,
/// Whether the provider matched the exact file by `moviehash` — §15's
/// outright winner.
pub hash_match: bool,
pub release_name: Option<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>,
}
/// 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))
}
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)
}
#[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_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 mut record = arr_db::NewSubtitleFile::fetched(
target.media_file_id,
&language.to_string(),
&input.provider,
&input.candidate_id,
&destination.to_string_lossy(),
);
if input.forced {
record = record.forced();
}
if input.sdh {
record = record.sdh();
}
finish(
&state,
&record,
target.media_file_id,
&language,
input.forced,
)
.await
}
#[utoipa::path(
post, path = "/api/media-files/{media_file_id}/subtitles/translate", tag = "subtitles",
params(("media_file_id" = i64, Path, description = "Media file row id")),
request_body = SubtitleTranslateInput,
responses(
(status = 201, body = Subtitle),
(status = 404, body = ErrorBody),
(status = 409, body = ErrorBody),
(status = 422, body = ErrorBody),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn translate(
State(state): State<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()))
}
/// 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)?;
Ok((StatusCode::CREATED, Json(Subtitle::from(subtitle))))
}
#[cfg(test)]
#[allow(clippy::too_many_lines)]
mod tests {
use std::path::PathBuf;
use std::sync::Arc;
use arr_core::Language;
use arr_subs::{
Backend, BackendId, Batch, Candidate, CandidateId, DownloadFuture, Fetched, Provider,
ProviderId, SearchFuture, SearchRequest, SubtitleFormat, TranslateFuture, TranslatedCue,
};
use axum::http::StatusCode;
use crate::{router, AppState, Upstreams};
const SRT: &str = "1\n00:00:01,000 --> 00:00:02,000\nolá\n";
/// Offers three candidates for whatever it is asked: one plain, one that
/// matched by hash, one forced.
#[derive(Debug)]
struct StubProvider {
id: ProviderId,
format: SubtitleFormat,
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 {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let pool = database.pool().clone();
// §7.4: one folder per title, the sidecar lands inside it.
let folder = dir.path().join("Dune (2021) [tmdbid-438631]");
tokio::fs::create_dir_all(&folder).await.expect("folder");
let video = folder.join("Dune (2021) [tmdbid-438631] - [2160p][WEB-DL].mkv");
// Big enough for a `moviehash`: OpenSubtitles hashes the first and
// last 64 KiB, and a shorter file has no hash at all — which would
// silently drop §15's outright-winning ranking tier from the tests.
tokio::fs::write(&video, vec![7u8; 200_000])
.await
.expect("video");
sqlx::query(
"INSERT INTO movies (id, tmdb_id, title, year, root_id) VALUES (1, 438631, 'Dune', 2021, 1)",
)
.execute(&pool)
.await
.expect("movie");
let path = video.to_string_lossy().into_owned();
sqlx::query(
"INSERT INTO media_files (id, owner_kind, owner_id, path, size) VALUES (1, 'movie', 1, ?, 200000)",
)
.bind(&path)
.execute(&pool)
.await
.expect("media file");
let state = AppState::new(Upstreams::new(
"http://127.0.0.1:1".into(),
"http://127.0.0.1:1".into(),
))
.expect("state")
.with_database(database)
.with_subtitle_providers(providers)
.with_translation_backends(backends);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
Fixture {
_dir: dir,
base: format!("http://{address}"),
pool,
media_file_id: 1,
folder,
video,
}
}
async fn stub_application() -> Fixture {
application(vec![Arc::new(StubProvider::new("opensubtitles"))], vec![]).await
}
async fn search(fixture: &Fixture, language: &str) -> (StatusCode, serde_json::Value) {
let response = reqwest::Client::new()
.post(format!(
"{}/api/media-files/1/subtitles/search",
fixture.base
))
.json(&serde_json::json!({ "language": language }))
.send()
.await
.expect("search");
let status = response.status();
(status, response.json().await.expect("search json"))
}
async fn grab(fixture: &Fixture, body: serde_json::Value) -> (StatusCode, serde_json::Value) {
let response = reqwest::Client::new()
.post(format!("{}/api/media-files/1/subtitles/grab", fixture.base))
.json(&body)
.send()
.await
.expect("grab");
let status = response.status();
(status, response.json().await.expect("grab json"))
}
fn pt() -> serde_json::Value {
serde_json::json!({
"provider": "opensubtitles",
"candidate_id": "hashed",
"language": "pt-PT"
})
}
/// §9.3 applied to subtitles: eligible first, every rejected row naming
/// the rule that killed it.
#[tokio::test]
async fn a_search_ranks_candidates_and_names_the_rule_that_rejected_each() {
let fixture = stub_application().await;
let (status, body) = search(&fixture, "pt-PT").await;
assert_eq!(status, StatusCode::OK);
let candidates = body["candidates"].as_array().expect("candidates");
// The French candidate the provider volunteered is not in the answer.
assert_eq!(candidates.len(), 3, "{body}");
assert_eq!(candidates[0]["candidate_id"], "hashed");
assert_eq!(candidates[0]["verdict"], "eligible");
assert!(candidates[0]["rejected_rule"].is_null());
assert_eq!(candidates[1]["candidate_id"], "plain");
assert_eq!(candidates[2]["candidate_id"], "forced");
assert_eq!(candidates[2]["verdict"], "rejected");
assert_eq!(candidates[2]["rejected_rule"], "forced");
assert!(body["provider_errors"]
.as_array()
.expect("errors")
.is_empty());
}
/// One provider failing is not the search failing: §15 configures two.
#[tokio::test]
async fn a_provider_that_cannot_answer_is_reported_beside_the_candidates() {
let fixture = application(
vec![
Arc::new(StubProvider::new("opensubtitles")),
Arc::new(DeadProvider),
],
vec![],
)
.await;
sqlx::query("UPDATE subtitle_settings SET providers_enabled = '[\"opensubtitles\",\"dead\"]' WHERE id = 1")
.execute(&fixture.pool)
.await
.expect("enable both");
let (status, body) = search(&fixture, "pt-PT").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["candidates"].as_array().expect("candidates").len(), 3);
let errors = body["provider_errors"].as_array().expect("errors");
assert_eq!(errors.len(), 1, "{body}");
assert_eq!(errors[0]["provider"], "dead");
}
/// `providers_enabled` is the operator's statement about which sources
/// to use, so a search never reaches a provider left out of it.
#[tokio::test]
async fn a_disabled_provider_is_never_asked() {
let fixture = stub_application().await;
sqlx::query("UPDATE subtitle_settings SET providers_enabled = '[]' WHERE id = 1")
.execute(&fixture.pool)
.await
.expect("disable everything");
let (status, _) = search(&fixture, "pt-PT").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn a_search_for_an_unknown_media_file_is_a_404() {
let fixture = stub_application().await;
let response = reqwest::Client::new()
.post(format!(
"{}/api/media-files/99/subtitles/search",
fixture.base
))
.json(&serde_json::json!({ "language": "pt-PT" }))
.send()
.await
.expect("search");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body: serde_json::Value = response.json().await.expect("json");
assert_eq!(body["error"], "media file not found");
}
/// §15's disk rule: the sidecar sits next to the video, inside the §7.4
/// folder, named `<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);
}
/// 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());
}
}