feat(api): extract an embedded track on demand (#261)
ci / web (push) Successful in 40s
ci / rust (push) Successful in 3m22s
e2e / e2e (push) Failing after 3m31s

Closes #260.
This commit was merged in pull request #261.
This commit is contained in:
2026-08-26 12:08:42 +01:00
parent dfa4656509
commit adb61dda3b
9 changed files with 666 additions and 28 deletions
+1
View File
@@ -12,6 +12,7 @@ arr-db = { workspace = true }
arr-indexer = { workspace = true }
arr-meta = { workspace = true }
arr-parse = { workspace = true }
arr-probe = { workspace = true }
arr-subs = { workspace = true }
axum = { workspace = true }
chrono = { workspace = true }
+5 -3
View File
@@ -52,9 +52,9 @@ pub use state::{
};
pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput};
pub use subtitles::{
EpisodeSubtitleGaps, EpisodeSubtitleStatus, MissingSubtitle, MovieSubtitleGaps,
SeasonSubtitleGaps, SeriesSubtitleGaps, Subtitle, SubtitleCandidate, SubtitleGap,
SubtitleGrabInput, SubtitleProviderError, SubtitleQueue, SubtitleSearchInput,
EmbeddedTrack, EpisodeSubtitleGaps, EpisodeSubtitleStatus, MissingSubtitle, MovieSubtitleGaps,
SeasonSubtitleGaps, SeriesSubtitleGaps, Subtitle, SubtitleCandidate, SubtitleExtractInput,
SubtitleGap, SubtitleGrabInput, SubtitleProviderError, SubtitleQueue, SubtitleSearchInput,
SubtitleSearchResults, SubtitleStatus, SubtitleTranslateInput,
};
pub use trailer::{Trailer, TrailerKind};
@@ -139,6 +139,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(subtitles::status_for_series))
.routes(routes!(subtitles::search))
.routes(routes!(subtitles::grab))
.routes(routes!(subtitles::extract))
.routes(routes!(subtitles::translate))
.routes(routes!(subtitles::delete))
.routes(routes!(subtitles::queue))
@@ -397,6 +398,7 @@ mod tests {
("/api/series/{series_id}/subtitles/status", "get"),
("/api/media-files/{media_file_id}/subtitles/search", "post"),
("/api/media-files/{media_file_id}/subtitles/grab", "post"),
("/api/media-files/{media_file_id}/subtitles/extract", "post"),
(
"/api/media-files/{media_file_id}/subtitles/translate",
"post",
+17 -3
View File
@@ -6,6 +6,7 @@ use std::sync::{atomic::AtomicU64, Arc};
use std::time::Duration;
use arr_db::Db;
use arr_probe::Extractor;
use arr_subs::{Backend, OpenAiEndpoint, Provider, Syncer};
use tokio::sync::mpsc;
@@ -97,6 +98,11 @@ pub struct AppState {
openai_endpoint: Option<OpenAiEndpoint>,
/// The configured `ffmpeg` binary, for the health lamps (#200).
ffmpeg_binary: OsString,
/// Runs that same `ffmpeg` to write an embedded text track out as a
/// sidecar (§15, #260). Derived from `ffmpeg_binary` rather than set on
/// its own, so the lamp and the extraction can never disagree about
/// which binary this deployment has.
extractor: Extractor,
jellyfin: Option<JellyfinClient>,
syncer: Syncer,
}
@@ -174,6 +180,7 @@ impl AppState {
command_timeout: None,
openai_endpoint: None,
ffmpeg_binary: DEFAULT_FFMPEG_BINARY.into(),
extractor: Extractor::default(),
jellyfin: None,
syncer: Syncer::default(),
})
@@ -272,11 +279,14 @@ impl AppState {
self
}
/// Attach the configured `ffmpeg` binary, for the health lamps (#200).
/// Defaults to resolving `ffmpeg` from `PATH`.
/// Attach the configured `ffmpeg` binary the health lamps (#200)
/// probe it and the extract lane (#260) runs it. Defaults to resolving
/// `ffmpeg` from `PATH`.
#[must_use]
pub fn with_ffmpeg_binary(mut self, binary: impl Into<OsString>) -> Self {
self.ffmpeg_binary = binary.into();
let binary = binary.into();
self.extractor = Extractor::new().with_binary(binary.clone());
self.ffmpeg_binary = binary;
self
}
@@ -289,6 +299,10 @@ impl AppState {
&self.syncer
}
pub(crate) fn extractor(&self) -> &Extractor {
&self.extractor
}
/// Wait for the next manual movie action in the daemon's reconcile loop.
///
/// # Errors
+409 -1
View File
@@ -28,7 +28,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use arr_core::subs::{rank, SubtitleTarget, SubtitleVerdict};
use arr_core::{layout, Language};
use arr_core::{layout, Language, SubtitleCodec};
use arr_db::subtitles as db;
use arr_db::SubtitleOrigin;
use arr_subs::{CandidateId, MediaFile, MediaRef, SearchRequest};
@@ -154,6 +154,34 @@ pub struct MissingSubtitle {
pub detail: Option<String>,
}
/// An embedded track this file carries that could be written out as a
/// sidecar on request (§15, issue #260).
///
/// `subtitle_files` records an embedded track by the language it satisfies
/// and nothing else, so the codec — the fact that decides whether a track is
/// text or bitmaps — lives only in `media_files.probed`. The status endpoint
/// reads it from there and joins it in, because the panel that offers the
/// extraction is already loading status and must not need a second call to
/// find out what it may offer.
///
/// Only the extractable ones are listed. An image-format track carries
/// bitmaps and arr does not OCR (§15), and a forced track is excluded too:
/// §15 gives a language exactly one sidecar and no filename segment marks a
/// forced one, so extracting it would spend the language's only slot on the
/// signs-only track.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct EmbeddedTrack {
/// Position among the file's subtitle streams — what an extract names,
/// not the container's absolute stream id.
pub index: usize,
/// The tag `arr_core::Language` spells, as the probe resolved it.
pub language: String,
/// `ffprobe`'s own codec name: `subrip`, `ass` or `mov_text`.
pub codec: String,
/// Complete, with sound descriptions. Satisfies, ranked below plain.
pub sdh: bool,
}
/// One media file's subtitles and the wanted languages it still lacks.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SubtitleStatus {
@@ -161,6 +189,10 @@ pub struct SubtitleStatus {
/// Ordered by language (§9.6's one row of chips).
pub subtitles: Vec<Subtitle>,
pub missing: Vec<MissingSubtitle>,
/// Text tracks inside the video that an extract could turn into a
/// sidecar (§15, #260). Empty for a file probed before #189, whose
/// `sub_tracks` carry a language and no codec.
pub embedded_tracks: Vec<EmbeddedTrack>,
}
/// One episode's media file, subtitles and gaps — the series-wide bulk
@@ -173,6 +205,7 @@ pub struct EpisodeSubtitleStatus {
pub media_file_id: i64,
pub subtitles: Vec<Subtitle>,
pub missing: Vec<MissingSubtitle>,
pub embedded_tracks: Vec<EmbeddedTrack>,
}
/// One wanted language a media file lacks, or a subtitle it has but `alass`
@@ -293,6 +326,15 @@ pub struct SubtitleGrabInput {
pub sdh: bool,
}
/// Which embedded track to write out as a sidecar (§15, #260).
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct SubtitleExtractInput {
/// The `index` of an [`EmbeddedTrack`] the status endpoint listed:
/// position among the file's subtitle streams, not the container's
/// absolute stream id.
pub track_index: usize,
}
/// The source subtitle, the language wanted, and which engine to use.
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct SubtitleTranslateInput {
@@ -576,6 +618,70 @@ async fn wanted_languages(state: &AppState) -> Result<Vec<String>, ApiError> {
serde_json::from_str(&raw).map_err(|error| ApiError::Database(error.to_string()))
}
/// The extractable text tracks one media file carries (§15, #260).
///
/// Parsed as leniently as the reconcile loop parses the same column: a row
/// written before #189 carries a language and no codec, and must read as
/// "nothing extractable here" rather than fail the whole status call. That
/// is the same set of files the issue puts out of scope — they are
/// re-downloaded, not migrated.
async fn embedded_tracks_of(
state: &AppState,
media_file_id: i64,
) -> Result<Vec<EmbeddedTrack>, ApiError> {
let probed = sqlx::query_scalar!(
r#"SELECT probed FROM media_files WHERE id = ?"#,
media_file_id
)
.fetch_optional(pool(state)?)
.await?
.flatten();
Ok(probed
.as_deref()
.map(extractable_tracks)
.unwrap_or_default())
}
/// The `sub_tracks` array of a `probed` blob, filtered to what §15 allows an
/// extraction to touch.
fn extractable_tracks(probed: &str) -> Vec<EmbeddedTrack> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(probed) else {
return Vec::new();
};
let Some(tracks) = value
.get("sub_tracks")
.and_then(serde_json::Value::as_array)
else {
return Vec::new();
};
tracks
.iter()
.enumerate()
.filter_map(|(index, track)| {
let language = track.get("language")?.as_str()?.to_owned();
let codec = track.get("codec")?.as_str()?.to_owned();
if !SubtitleCodec::from_probe_name(&codec).is_text() {
return None;
}
let flag = |name: &str| {
track
.get(name)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
};
if flag("forced") {
return None;
}
Some(EmbeddedTrack {
index,
language,
codec,
sdh: flag("sdh"),
})
})
.collect()
}
/// `subtitle_attempts.state` in the title detail page's own words.
fn reason_of(attempt: &arr_db::SubtitleAttempt) -> (String, Option<String>) {
match attempt.state {
@@ -656,6 +762,7 @@ async fn status_for_owner(
media_file_id: id,
subtitles,
missing,
embedded_tracks: embedded_tracks_of(state, id).await?,
});
}
Ok(out)
@@ -758,6 +865,7 @@ pub async fn status_for_series(
media_file_id: row.media_file_id,
subtitles,
missing,
embedded_tracks: embedded_tracks_of(&state, row.media_file_id).await?,
});
}
Ok(Json(out))
@@ -997,6 +1105,77 @@ pub async fn grab(
finish(&state, &record, target.media_file_id, &language).await
}
#[utoipa::path(
post, path = "/api/media-files/{media_file_id}/subtitles/extract", tag = "subtitles",
params(("media_file_id" = i64, Path, description = "Media file row id")),
request_body = SubtitleExtractInput,
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)
)
)]
/// Write one embedded text track out as a sidecar SRT (§15, issue #260).
///
/// The reconcile loop extracts only when it is already translating a gap, so
/// on a file where nothing is missing no sidecar is ever written and the
/// panel's translate lane has no source to offer. This is the operator's own
/// way in, and it obeys §15's rule that a manual action bypasses the wanted
/// set: the language is marked satisfied whether or not it was wanted.
///
/// `alass` is deliberately not run. §15 syncs every *fetched* and every
/// *translated* subtitle; a track lifted out of the container already
/// carries the video's own timings, and shifting it against itself could
/// only make it worse.
pub async fn extract(
State(state): State<AppState>,
UrlPath(media_file_id): UrlPath<i64>,
body: Result<Json<SubtitleExtractInput>, JsonRejection>,
) -> Result<(StatusCode, Json<Subtitle>), ApiError> {
let input = parsed(body)?;
let target = target(&state, media_file_id).await?;
let track = embedded_tracks_of(&state, media_file_id)
.await?
.into_iter()
.find(|track| track.index == input.track_index)
.ok_or_else(|| {
ApiError::Invalid(format!(
"track_index: this file has no extractable text track at {}",
input.track_index
))
})?;
let language = language_of(&track.language)?;
let destination = target.sidecar(&language, false)?;
claim_language(&state, target.media_file_id, &language).await?;
claim_path(&state, &destination).await?;
state
.extractor()
.extract_srt(&target.path, track.index, &destination)
.await
.map_err(|error| {
ApiError::Filesystem(format!(
"extracting stream {} of {}: {error}",
track.index,
target.path.display()
))
})?;
let mut record = arr_db::NewSubtitleFile::extracted(
target.media_file_id,
&track.language,
&destination.to_string_lossy(),
);
if track.sdh {
record = record.sdh();
}
finish(&state, &record, target.media_file_id, &language).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")),
@@ -1808,6 +1987,33 @@ mod tests {
.expect("files")
}
/// Give the file a `probed` column, the way import writes one.
async fn probe(&self, sub_tracks: serde_json::Value) {
let probed = serde_json::json!({
"resolution": "1080p",
"source": null,
"hdr": "SDR",
"audio_tracks": [],
"sub_tracks": sub_tracks,
})
.to_string();
sqlx::query("UPDATE media_files SET probed = ? WHERE id = ?")
.bind(probed)
.bind(self.media_file_id)
.execute(&self.pool)
.await
.expect("probe");
}
/// Put a real container behind the `media_files` row: three `subrip`
/// tracks, `eng` / `por` / `eng`, so a real `ffmpeg` has something to
/// extract and the stream index means something.
async fn real_video(&self) {
let clip = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../arr-probe/tests/fixtures/movie-1080p.mkv");
tokio::fs::copy(&clip, &self.video).await.expect("clip");
}
async fn attempt_state(&self, language: &str) -> Option<String> {
sqlx::query_scalar::<_, String>(
"SELECT state FROM subtitle_attempts WHERE media_file_id = ? AND language = ?",
@@ -2848,6 +3054,208 @@ mod tests {
assert_eq!(missing[0]["language"], "pt-PT");
}
/* ---- extract (§15, issue #260) --------------------------------- */
async fn extract(fixture: &Fixture, track_index: i64) -> (StatusCode, serde_json::Value) {
let response = reqwest::Client::new()
.post(format!(
"{}/api/media-files/1/subtitles/extract",
fixture.base
))
.json(&serde_json::json!({ "track_index": track_index }))
.send()
.await
.expect("extract");
let status = response.status();
(status, response.json().await.expect("extract json"))
}
/// §15: a text track extracts, a bitmap one never does — arr does not
/// OCR — and a forced track is not offered either, because it would
/// spend the language's only sidecar slot on the signs-only track.
#[tokio::test]
async fn status_lists_only_the_extractable_embedded_tracks() {
let fixture = stub_application().await;
fixture
.probe(serde_json::json!([
{ "language": "en", "codec": "subrip", "forced": false, "sdh": false },
{ "language": "pt-PT", "codec": "hdmv_pgs_subtitle", "forced": false, "sdh": false },
{ "language": "en", "codec": "subrip", "forced": true, "sdh": false },
{ "language": "es", "codec": "ass", "forced": false, "sdh": true },
]))
.await;
let statuses = status(&format!("{}/api/movies/1/subtitles/status", fixture.base)).await;
let tracks = statuses[0]["embedded_tracks"]
.as_array()
.expect("embedded_tracks");
assert_eq!(tracks.len(), 2, "{tracks:?}");
assert_eq!(tracks[0]["index"], 0);
assert_eq!(tracks[0]["language"], "en");
assert_eq!(tracks[0]["codec"], "subrip");
assert_eq!(tracks[0]["sdh"], false);
// The index is the position among *all* subtitle streams, including
// the ones not listed — it is what `ffmpeg` maps.
assert_eq!(tracks[1]["index"], 3);
assert_eq!(tracks[1]["language"], "es");
assert_eq!(tracks[1]["sdh"], true);
}
/// A file imported before #189 carries a language per track and nothing
/// else. The issue puts those out of scope: they read as having nothing
/// to offer rather than failing the call.
#[tokio::test]
async fn status_offers_nothing_from_a_probe_that_predates_the_codec() {
let fixture = stub_application().await;
fixture.probe(serde_json::json!(["en", "pt-PT"])).await;
let statuses = status(&format!("{}/api/movies/1/subtitles/status", fixture.base)).await;
assert!(statuses[0]["embedded_tracks"]
.as_array()
.expect("embedded_tracks")
.is_empty());
}
/// The issue's whole point: nothing is missing on this file, so the
/// reconcile loop never runs — and the operator can still get a sidecar
/// out of the container.
#[tokio::test]
async fn extracting_a_track_writes_the_sidecar_beside_the_video() {
let fixture = stub_application().await;
fixture.real_video().await;
fixture
.probe(serde_json::json!([
{ "language": "en", "codec": "subrip", "forced": false, "sdh": false },
{ "language": "pt-PT", "codec": "subrip", "forced": false, "sdh": false },
]))
.await;
let (status, body) = extract(&fixture, 1).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"], "extracted");
assert_eq!(body["language"], "pt-PT");
// No provider, no engine: it came out of the file itself.
assert!(body["provider"].is_null());
assert!(body["engine"].is_null());
// §15 syncs what is fetched and what is translated. A track lifted
// out of the container already carries the video's own timings.
assert_eq!(body["sync"], "not_run");
let written = tokio::fs::read_to_string(&expected).await.expect("sidecar");
assert!(!written.trim().is_empty(), "the track has cues");
assert_eq!(
fixture.attempt_state("pt-PT").await.as_deref(),
Some("satisfied"),
"§15: a manual action settles the language it answers"
);
}
/// The sidecar is a translation source the moment it exists — that is
/// the whole reason §15 extracts at all.
#[tokio::test]
async fn an_extracted_track_can_be_translated_from() {
let fixture = application(
vec![Arc::new(StubProvider::new("opensubtitles"))],
vec![Arc::new(StubBackend)],
)
.await;
fixture.real_video().await;
fixture
.probe(serde_json::json!([
{ "language": "en", "codec": "subrip", "forced": false, "sdh": false },
]))
.await;
let (status, source) = extract(&fixture, 0).await;
assert_eq!(status, StatusCode::CREATED, "{source}");
let response = reqwest::Client::new()
.post(format!(
"{}/api/media-files/1/subtitles/translate",
fixture.base
))
.json(&serde_json::json!({
"source_subtitle_id": source["id"].as_i64().expect("source id"),
"target_language": "pt-PT",
"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["language"], "pt-PT");
}
/// §15 gives a language exactly one sidecar. The English track here is
/// already answered by a fetched subtitle, so extracting it would be a
/// second file competing for one name.
#[tokio::test]
async fn extracting_a_language_that_already_has_a_sidecar_is_refused() {
let fixture = stub_application().await;
fixture.real_video().await;
fixture
.probe(serde_json::json!([
{ "language": "en", "codec": "subrip", "forced": false, "sdh": false },
{ "language": "pt-PT", "codec": "subrip", "forced": false, "sdh": false },
]))
.await;
assert_eq!(grab(&fixture, pt()).await.0, StatusCode::CREATED);
let (status, body) = extract(&fixture, 1).await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert_eq!(fixture.subtitle_rows().await.len(), 1);
}
/// A bitmap track is never listed, and naming its index anyway is
/// refused before `ffmpeg` is spawned — §15 does not OCR.
#[tokio::test]
async fn extracting_an_image_track_is_refused() {
let fixture = stub_application().await;
fixture.real_video().await;
fixture
.probe(serde_json::json!([
{ "language": "en", "codec": "hdmv_pgs_subtitle", "forced": false, "sdh": false },
]))
.await;
let (status, body) = extract(&fixture, 0).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
assert!(fixture.subtitle_rows().await.is_empty());
}
#[tokio::test]
async fn extracting_a_track_that_is_not_there_is_refused() {
let fixture = stub_application().await;
fixture
.probe(serde_json::json!([
{ "language": "en", "codec": "subrip", "forced": false, "sdh": false },
]))
.await;
let (status, body) = extract(&fixture, 9).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
}
#[tokio::test]
async fn extracting_from_an_unknown_media_file_is_a_404() {
let fixture = stub_application().await;
let response = reqwest::Client::new()
.post(format!(
"{}/api/media-files/99/subtitles/extract",
fixture.base
))
.json(&serde_json::json!({ "track_index": 0 }))
.send()
.await
.expect("extract");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
async fn queue(base: &str) -> serde_json::Value {
reqwest::get(format!("{base}/api/queues/subtitles"))
.await