Files
arr/crates/arr-api/src/subtitles.rs
T
2026-08-25 06:30:34 +01:00

3026 lines
108 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>,
}
/// One wanted language a media file lacks, or a subtitle it has but `alass`
/// flagged (§15, issue #202).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SubtitleGap {
pub language: String,
/// [`reason_of`]'s words — `no_candidates`, `capped` or `failed` — plus
/// `sync_rejected` for a subtitle that exists but whose sync `alass`
/// rejected (§15): not a missing language, but still something the
/// operator did not see happen.
pub reason: String,
/// Set only when `reason` is `failed`.
pub detail: Option<String>,
}
/// A movie with at least one subtitle gap, for the missing-subtitles queue.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MovieSubtitleGaps {
pub movie_id: i64,
pub tmdb_id: i64,
pub title: String,
pub year: Option<i64>,
pub poster_path: Option<String>,
pub media_file_id: i64,
pub gaps: Vec<SubtitleGap>,
}
/// One episode's gaps, named for the operator the way [`QueuedEpisode`] is
/// (`SxxEyy` comes from season and episode numbers).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct EpisodeSubtitleGaps {
pub episode_id: i64,
pub media_file_id: i64,
pub season_number: i64,
pub episode_number: i64,
pub gaps: Vec<SubtitleGap>,
}
/// A season collapsed into one row because every one of its episodes carries
/// the identical gap — a season-wide provider or budget failure otherwise
/// floods the queue one row per episode, the restraint §9.5 already applies
/// to the TV attention queues.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SeasonSubtitleGaps {
pub season_number: i64,
pub media_file_ids: Vec<i64>,
pub gaps: Vec<SubtitleGap>,
}
/// A series with at least one subtitle gap. One row per series (§9.5's
/// restraint): episodes that did not collapse into a season stay listed
/// individually, the rest roll up into `seasons`.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SeriesSubtitleGaps {
pub series_id: i64,
pub tmdb_id: i64,
pub title: String,
pub year: Option<i64>,
pub poster_path: Option<String>,
pub episodes: Vec<EpisodeSubtitleGaps>,
pub seasons: Vec<SeasonSubtitleGaps>,
}
/// The missing-subtitles queue (issue #202): every title with an unsatisfied
/// wanted language and why, plus subtitles a sync rejected. Each entry's
/// gaps are resolved with the manual actions this module already offers —
/// search, translate, or a retried search.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SubtitleQueue {
pub movies: Vec<MovieSubtitleGaps>,
pub series: Vec<SeriesSubtitleGaps>,
}
/// 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| match error {
arr_subs::Error::NotFound { .. } => ApiError::SubtitleCandidateExpired,
other => ApiError::SubtitleUpstream(other.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 sync = state.syncer().settle(&target.path, &destination).await;
if let Some(synced) = &sync.content {
write_sidecar(&destination, synced).await?;
}
let record = arr_db::NewSubtitleFile::translated(
target.media_file_id,
&target_language.to_string(),
&engine,
&destination.to_string_lossy(),
)
.sync(db_sync_state(sync.state));
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");
}
}
#[utoipa::path(
get, path = "/api/queues/subtitles", tag = "subtitles",
responses(
(status = 200, body = SubtitleQueue),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn queue(State(state): State<AppState>) -> Result<Json<SubtitleQueue>, ApiError> {
// A language dropped from the wanted set (§15) is not a gap any more —
// its attempt row just has not been cleaned up yet. `missing_for` (#201)
// already applies this bound; the queue reads the same way.
let wanted: BTreeSet<String> = wanted_languages(&state).await?.into_iter().collect();
Ok(Json(SubtitleQueue {
movies: movie_gaps(&state, &wanted).await?,
series: series_gaps(&state, &wanted).await?,
}))
}
/// `subtitle_attempts.state` in [`SubtitleGap`]'s words — the same vocabulary
/// [`reason_of`] uses for the title detail page (#201), so a gap reads the
/// same wherever it is shown.
fn attempt_reason(
state: arr_db::SubtitleState,
last_failure: Option<String>,
) -> (String, Option<String>) {
match state {
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(), last_failure),
}
}
async fn movie_gaps(
state: &AppState,
wanted: &BTreeSet<String>,
) -> Result<Vec<MovieSubtitleGaps>, ApiError> {
let database = pool(state)?;
let mut out: Vec<MovieSubtitleGaps> = Vec::new();
let attempts = sqlx::query!(
r#"SELECT m.id AS "movie_id!: i64", m.tmdb_id AS "tmdb_id!: i64",
m.title AS "title!: String", m.year, m.poster_path,
mf.id AS "media_file_id!: i64", sa.language AS "language!: String",
sa.state AS "state!: arr_db::SubtitleState", sa.last_failure
FROM subtitle_attempts sa
JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'movie'
JOIN movies m ON m.id = mf.owner_id
WHERE sa.state IN ('failed', 'capped', 'unavailable')
ORDER BY m.title, sa.language"#
)
.fetch_all(database)
.await?;
for row in attempts {
if !wanted.contains(&row.language) {
continue;
}
let (reason, detail) = attempt_reason(row.state, row.last_failure);
push_movie_gap(
&mut out,
row.movie_id,
row.tmdb_id,
&row.title,
row.year,
row.poster_path,
row.media_file_id,
SubtitleGap {
language: row.language,
reason,
detail,
},
);
}
let rejected = sqlx::query!(
r#"SELECT m.id AS "movie_id!: i64", m.tmdb_id AS "tmdb_id!: i64",
m.title AS "title!: String", m.year, m.poster_path,
mf.id AS "media_file_id!: i64", sf.language AS "language!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'movie'
JOIN movies m ON m.id = mf.owner_id
WHERE sf.sync_rejected = 1
ORDER BY m.title, sf.language"#
)
.fetch_all(database)
.await?;
for row in rejected {
push_movie_gap(
&mut out,
row.movie_id,
row.tmdb_id,
&row.title,
row.year,
row.poster_path,
row.media_file_id,
SubtitleGap {
language: row.language,
reason: "sync_rejected".to_owned(),
detail: None,
},
);
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
fn push_movie_gap(
entries: &mut Vec<MovieSubtitleGaps>,
movie_id: i64,
tmdb_id: i64,
title: &str,
year: Option<i64>,
poster_path: Option<String>,
media_file_id: i64,
gap: SubtitleGap,
) {
if let Some(entry) = entries.iter_mut().find(|entry| entry.movie_id == movie_id) {
entry.gaps.push(gap);
return;
}
entries.push(MovieSubtitleGaps {
movie_id,
tmdb_id,
title: title.to_owned(),
year,
poster_path,
media_file_id,
gaps: vec![gap],
});
}
async fn series_gaps(
state: &AppState,
wanted: &BTreeSet<String>,
) -> Result<Vec<SeriesSubtitleGaps>, ApiError> {
let database = pool(state)?;
let mut out: Vec<SeriesSubtitleGaps> = Vec::new();
let attempts = sqlx::query!(
r#"SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, s.poster_path,
e.id AS "episode_id!: i64", se.number AS "season_number!: i64",
e.number AS "episode_number!: i64", mf.id AS "media_file_id!: i64",
sa.language AS "language!: String",
sa.state AS "state!: arr_db::SubtitleState", sa.last_failure
FROM subtitle_attempts sa
JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'episode'
JOIN episodes e ON e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE sa.state IN ('failed', 'capped', 'unavailable')
ORDER BY s.title, se.number, e.number, sa.language"#
)
.fetch_all(database)
.await?;
for row in attempts {
if !wanted.contains(&row.language) {
continue;
}
let (reason, detail) = attempt_reason(row.state, row.last_failure);
push_episode_gap(
&mut out,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.poster_path,
row.episode_id,
row.season_number,
row.episode_number,
row.media_file_id,
SubtitleGap {
language: row.language,
reason,
detail,
},
);
}
let rejected = sqlx::query!(
r#"SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, s.poster_path,
e.id AS "episode_id!: i64", se.number AS "season_number!: i64",
e.number AS "episode_number!: i64", mf.id AS "media_file_id!: i64",
sf.language AS "language!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'episode'
JOIN episodes e ON e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE sf.sync_rejected = 1
ORDER BY s.title, se.number, e.number, sf.language"#
)
.fetch_all(database)
.await?;
for row in rejected {
push_episode_gap(
&mut out,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.poster_path,
row.episode_id,
row.season_number,
row.episode_number,
row.media_file_id,
SubtitleGap {
language: row.language,
reason: "sync_rejected".to_owned(),
detail: None,
},
);
}
for series in &mut out {
collapse_seasons(series);
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
fn push_episode_gap(
entries: &mut Vec<SeriesSubtitleGaps>,
series_id: i64,
tmdb_id: i64,
title: &str,
year: Option<i64>,
poster_path: Option<String>,
episode_id: i64,
season_number: i64,
episode_number: i64,
media_file_id: i64,
gap: SubtitleGap,
) {
if !entries.iter().any(|entry| entry.series_id == series_id) {
entries.push(SeriesSubtitleGaps {
series_id,
tmdb_id,
title: title.to_owned(),
year,
poster_path,
episodes: Vec::new(),
seasons: Vec::new(),
});
}
let entry = entries
.iter_mut()
.find(|entry| entry.series_id == series_id)
.unwrap_or_else(|| unreachable!());
match entry
.episodes
.iter_mut()
.find(|episode| episode.episode_id == episode_id)
{
Some(episode) => episode.gaps.push(gap),
None => entry.episodes.push(EpisodeSubtitleGaps {
episode_id,
media_file_id,
season_number,
episode_number,
gaps: vec![gap],
}),
}
}
/// Roll a season's episodes into one [`SeasonSubtitleGaps`] row when two or
/// more of them carry the identical set of gaps — the season-wide failure
/// §9.5's restraint is meant to catch. A season with only one gapped episode,
/// or episodes whose gaps differ, stays as individual episode rows.
fn collapse_seasons(series: &mut SeriesSubtitleGaps) {
let mut by_group: std::collections::BTreeMap<(i64, String), Vec<EpisodeSubtitleGaps>> =
std::collections::BTreeMap::new();
for episode in std::mem::take(&mut series.episodes) {
let key = (episode.season_number, gap_signature(&episode.gaps));
by_group.entry(key).or_default().push(episode);
}
for ((season_number, _signature), mut episodes) in by_group {
if let [first, ..] = episodes.as_slice() {
if episodes.len() >= 2 {
series.seasons.push(SeasonSubtitleGaps {
season_number,
media_file_ids: episodes
.iter()
.map(|episode| episode.media_file_id)
.collect(),
gaps: first.gaps.clone(),
});
continue;
}
}
series.episodes.append(&mut episodes);
}
series
.episodes
.sort_by_key(|episode| (episode.season_number, episode.episode_number));
series.seasons.sort_by_key(|season| season.season_number);
}
fn gap_signature(gaps: &[SubtitleGap]) -> String {
let mut parts: Vec<String> = gaps
.iter()
.map(|gap| {
format!(
"{}|{}|{}",
gap.language,
gap.reason,
gap.detail.as_deref().unwrap_or("")
)
})
.collect();
parts.sort();
parts.join(",")
}
#[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(),
})
})
}
fn probe(&self) -> arr_subs::ProbeFuture<'_> {
Box::pin(async move { Ok(()) })
}
}
/// 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(),
})
})
}
fn probe(&self) -> arr_subs::ProbeFuture<'_> {
Box::pin(async move {
Err(arr_subs::Error::Unauthorized {
provider: ProviderId::new("dead"),
})
})
}
}
/// 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())
})
}
fn probe(&self) -> arr_subs::translate::ProbeFuture<'_> {
Box::pin(async move { Ok(()) })
}
}
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());
}
/// A candidate id does not outlive the search that produced it (issue
/// #221): the panel gets a typed `code` to switch on, not a string it has
/// to pattern-match against a provider-chosen message.
#[tokio::test]
async fn a_grab_of_an_expired_candidate_is_a_typed_404() {
let fixture = application(vec![Arc::new(DeadProvider)], vec![]).await;
let (status, body) = grab(
&fixture,
serde_json::json!({
"provider": "dead",
"candidate_id": "stale",
"language": "pt-PT"
}),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert_eq!(body["code"], "candidate_expired");
assert!(fixture.subtitle_rows().await.is_empty());
}
#[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: `alass` runs on every translated subtitle too (#217), not just
/// fetched ones.
#[tokio::test]
async fn a_translation_writes_the_synced_content_when_alass_accepts_it() {
let dir = tempfile::tempdir().expect("tempdir");
let binary = fake_alass(
dir.path(),
"printf '1\\n00:00:06,000 --> 00:00:07,000\\nOLA\\n' > \"$3\"\n",
)
.await;
let fixture = build_fixture(
vec![Arc::new(StubProvider::new("opensubtitles"))],
vec![Arc::new(StubBackend)],
arr_subs::Syncer::new().with_binary(&binary),
)
.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["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 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");
}
async fn queue(base: &str) -> serde_json::Value {
reqwest::get(format!("{base}/api/queues/subtitles"))
.await
.expect("queue")
.json()
.await
.expect("json")
}
/// #202: a title with no gap at all does not appear in either lane.
#[tokio::test]
async fn subtitle_queue_omits_titles_with_no_gaps() {
let fixture = stub_application().await;
let body = queue(&fixture.base).await;
assert!(body["movies"].as_array().expect("movies").is_empty());
assert!(body["series"].as_array().expect("series").is_empty());
}
/// #202: an attempt row for a language no longer in the wanted set is
/// not a gap any more — `missing_for` (#201) already draws this line.
#[tokio::test]
async fn subtitle_queue_drops_a_language_no_longer_wanted() {
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");
sqlx::query("UPDATE subtitle_settings SET wanted_languages = '[\"pt-PT\"]' WHERE id = 1")
.execute(&fixture.pool)
.await
.expect("narrow wanted set");
let body = queue(&fixture.base).await;
assert!(body["movies"].as_array().expect("movies").is_empty());
}
/// #202: a movie's failed attempt surfaces with its language and detail.
#[tokio::test]
async fn subtitle_queue_lists_a_movie_gap() {
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");
let body = queue(&fixture.base).await;
let movies = body["movies"].as_array().expect("movies");
assert_eq!(movies.len(), 1, "{movies:?}");
assert_eq!(movies[0]["movie_id"], 1);
assert_eq!(movies[0]["media_file_id"], fixture.media_file_id);
let gaps = movies[0]["gaps"].as_array().expect("gaps");
assert_eq!(gaps.len(), 1, "{gaps:?}");
assert_eq!(gaps[0]["language"], "en");
assert_eq!(gaps[0]["reason"], "failed");
assert_eq!(gaps[0]["detail"], "429 from opensubtitles");
}
/// #202, §15: a subtitle whose sync `alass` rejected is flagged even
/// though the language it answers is satisfied.
#[tokio::test]
async fn subtitle_queue_surfaces_a_sync_rejected_file() {
let fixture = stub_application().await;
arr_db::subtitles::mark_satisfied(&fixture.pool, fixture.media_file_id, "pt-PT")
.await
.expect("mark satisfied");
arr_db::subtitles::record_file(
&fixture.pool,
&arr_db::NewSubtitleFile::fetched(
fixture.media_file_id,
"pt-PT",
"opensubtitles",
"1",
&fixture.video.with_extension("pt-PT.srt").to_string_lossy(),
)
.sync(arr_db::SubtitleSync::Rejected),
)
.await
.expect("record rejected sync");
let body = queue(&fixture.base).await;
let movies = body["movies"].as_array().expect("movies");
assert_eq!(movies.len(), 1, "{movies:?}");
let gaps = movies[0]["gaps"].as_array().expect("gaps");
assert_eq!(gaps.len(), 1, "{gaps:?}");
assert_eq!(gaps[0]["language"], "pt-PT");
assert_eq!(gaps[0]["reason"], "sync_rejected");
assert!(gaps[0]["detail"].is_null());
}
async fn seed_episode(
pool: &sqlx::SqlitePool,
episode_id: i64,
season_number: i64,
episode_number: i64,
) -> i64 {
sqlx::query(
"INSERT INTO seasons (id, series_id, number) VALUES (?, 1, ?)
ON CONFLICT (series_id, number) DO NOTHING",
)
.bind(season_number)
.bind(season_number)
.execute(pool)
.await
.expect("season");
sqlx::query("INSERT INTO episodes (id, season_id, number, title) VALUES (?, ?, ?, 'Ep')")
.bind(episode_id)
.bind(season_number)
.bind(episode_number)
.execute(pool)
.await
.expect("episode");
let media_file_id = 100 + episode_id;
sqlx::query(
"INSERT INTO media_files (id, owner_kind, owner_id, path, size) VALUES (?, 'episode', ?, ?, 100)",
)
.bind(media_file_id)
.bind(episode_id)
.bind(format!("S{season_number:02}E{episode_number:02}.mkv"))
.execute(pool)
.await
.expect("media file");
media_file_id
}
/// #202, same restraint as §9.5's TV attention lanes: when every episode
/// in a season carries the identical gap, the queue collapses them into
/// one season row instead of flooding it one row per episode.
#[tokio::test]
async fn subtitle_queue_collapses_a_season_when_every_episode_shares_the_gap() {
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");
let first = seed_episode(&fixture.pool, 1, 1, 1).await;
let second = seed_episode(&fixture.pool, 2, 1, 2).await;
for media_file_id in [first, second] {
arr_db::subtitles::record_attempt(
&fixture.pool,
media_file_id,
"pt-PT",
arr_db::SubtitleState::Capped,
None,
)
.await
.expect("record capped attempt");
}
let body = queue(&fixture.base).await;
let series = body["series"].as_array().expect("series");
assert_eq!(series.len(), 1, "{series:?}");
assert!(series[0]["episodes"]
.as_array()
.expect("episodes")
.is_empty());
let seasons = series[0]["seasons"].as_array().expect("seasons");
assert_eq!(seasons.len(), 1, "{seasons:?}");
assert_eq!(seasons[0]["season_number"], 1);
let mut media_file_ids: Vec<i64> = seasons[0]["media_file_ids"]
.as_array()
.expect("media_file_ids")
.iter()
.map(|id| id.as_i64().expect("id"))
.collect();
media_file_ids.sort_unstable();
assert_eq!(media_file_ids, vec![first, second]);
let gaps = seasons[0]["gaps"].as_array().expect("gaps");
assert_eq!(gaps.len(), 1, "{gaps:?}");
assert_eq!(gaps[0]["language"], "pt-PT");
assert_eq!(gaps[0]["reason"], "capped");
}
/// #202: episodes whose gaps differ stay listed individually — the
/// season only collapses when the failure is uniform across it.
#[tokio::test]
async fn subtitle_queue_keeps_episodes_separate_when_gaps_differ() {
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");
let first = seed_episode(&fixture.pool, 1, 1, 1).await;
let second = seed_episode(&fixture.pool, 2, 1, 2).await;
arr_db::subtitles::record_attempt(
&fixture.pool,
first,
"pt-PT",
arr_db::SubtitleState::Capped,
None,
)
.await
.expect("record capped attempt");
arr_db::subtitles::record_attempt(
&fixture.pool,
second,
"pt-PT",
arr_db::SubtitleState::Unavailable,
None,
)
.await
.expect("record unavailable attempt");
let body = queue(&fixture.base).await;
let series = body["series"].as_array().expect("series");
assert_eq!(series.len(), 1, "{series:?}");
assert!(series[0]["seasons"].as_array().expect("seasons").is_empty());
let episodes = series[0]["episodes"].as_array().expect("episodes");
assert_eq!(episodes.len(), 2, "{episodes:?}");
}
}