diff --git a/Cargo.lock b/Cargo.lock index 212d713..9ac95d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,7 @@ dependencies = [ "arr-indexer", "arr-meta", "arr-parse", + "arr-probe", "arr-subs", "axum", "chrono", diff --git a/crates/arr-api/Cargo.toml b/crates/arr-api/Cargo.toml index 6801349..9608ff3 100644 --- a/crates/arr-api/Cargo.toml +++ b/crates/arr-api/Cargo.toml @@ -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 } diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index a147625..9924a15 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -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 { .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", diff --git a/crates/arr-api/src/state.rs b/crates/arr-api/src/state.rs index d64eb4a..5b2fb53 100644 --- a/crates/arr-api/src/state.rs +++ b/crates/arr-api/src/state.rs @@ -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, /// 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, 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) -> 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 diff --git a/crates/arr-api/src/subtitles.rs b/crates/arr-api/src/subtitles.rs index 1dcd41e..22b44d7 100644 --- a/crates/arr-api/src/subtitles.rs +++ b/crates/arr-api/src/subtitles.rs @@ -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, } +/// 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, pub missing: Vec, + /// 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, } /// 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, pub missing: Vec, + pub embedded_tracks: Vec, } /// 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, 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, 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 { + let Ok(value) = serde_json::from_str::(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) { 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, + UrlPath(media_file_id): UrlPath, + body: Result, JsonRejection>, +) -> Result<(StatusCode, Json), 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 { 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 diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 6d237e3..fb8c5f3 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -364,6 +364,23 @@ impl SubtitleCodec { pub const fn is_text(self) -> bool { matches!(self, Self::SubRip | Self::Ass | Self::MovText) } + + /// The inverse of [`Display`](fmt::Display): read back a codec that was + /// written out under its `ffprobe` name. `ffprobe`'s own aliases are + /// accepted alongside, so a column written by an older probe still + /// resolves. Anything unrecognised is [`Self::Other`], which extracts + /// nothing — the same answer as a bitmap track. + #[must_use] + pub fn from_probe_name(name: &str) -> Self { + match name { + "subrip" | "srt" => Self::SubRip, + "ass" | "ssa" => Self::Ass, + "mov_text" => Self::MovText, + "hdmv_pgs_subtitle" => Self::Pgs, + "dvd_subtitle" => Self::VobSub, + _ => Self::Other, + } + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -490,7 +507,7 @@ pub struct MovieOwner { #[cfg(test)] mod tests { - use super::{DolbyVisionProfile, HdrFormat, Language, Rule, Verdict}; + use super::{DolbyVisionProfile, HdrFormat, Language, Rule, SubtitleCodec, Verdict}; #[test] fn portuguese_variants_are_distinct() { @@ -512,6 +529,26 @@ mod tests { ); } + #[test] + fn probe_names_round_trip_through_the_codec() { + for codec in [ + SubtitleCodec::SubRip, + SubtitleCodec::Ass, + SubtitleCodec::MovText, + SubtitleCodec::Pgs, + SubtitleCodec::VobSub, + ] { + assert_eq!(SubtitleCodec::from_probe_name(&codec.to_string()), codec); + } + assert_eq!(SubtitleCodec::from_probe_name("srt"), SubtitleCodec::SubRip); + assert_eq!(SubtitleCodec::from_probe_name("ssa"), SubtitleCodec::Ass); + assert_eq!( + SubtitleCodec::from_probe_name("dvb_subtitle"), + SubtitleCodec::Other + ); + assert!(!SubtitleCodec::from_probe_name("other").is_text()); + } + #[test] fn non_eligible_verdicts_name_the_rule() { let rule = Rule::DolbyVisionProfile(DolbyVisionProfile { diff --git a/crates/arr-daemon/src/main.rs b/crates/arr-daemon/src/main.rs index 882dfde..7219482 100644 --- a/crates/arr-daemon/src/main.rs +++ b/crates/arr-daemon/src/main.rs @@ -137,7 +137,11 @@ fn api_state( )) .with_translation_backends(translators.backends.clone()) .with_jellyfin(jellyfin) - .with_syncer(arr_subs::Syncer::new().with_binary(config.alass_path.clone())); + .with_syncer(arr_subs::Syncer::new().with_binary(config.alass_path.clone())) + // The health lamp and the extract lane (#260) run the same binary, + // so the API is told which one this deployment has rather than + // resolving `ffmpeg` from `PATH` behind the operator's back. + .with_ffmpeg_binary(config.ffmpeg_path.clone()); if let Some(timeout) = &translators.command_timeout { state = state.with_command_timeout(Arc::clone(timeout)); } diff --git a/web/src/main.ts b/web/src/main.ts index 91fbcda..7eac208 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -126,7 +126,11 @@ import "./style.css"; import { cachedSubtitleOptions, deleteSubtitle, + type EmbeddedTrack, type EpisodeSubtitleStatus, + embeddedTrackLabel, + extractableTracks, + extractTrack, formatCandidateFlags, formatDownloads, formatHashMatch, @@ -1316,6 +1320,10 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS const mediaFileId = status.media_file_id; let subtitles = status.subtitles; let missing = status.missing; + let tracks = status.embedded_tracks; + /** Set by an extraction so the sidecar it just wrote stays picked across + * the repaint that follows. */ + let pickAfterRepaint: number | null = null; /** Null until the settings request lands; see `wantedSubtitles`. */ let wanted: string[] | null = null; @@ -1351,7 +1359,10 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS * with the delete affordance issue #223 put on each. */ function paintChips() { - const summary = subtitleSummary({ media_file_id: mediaFileId, subtitles, missing }, wanted); + const summary = subtitleSummary( + { media_file_id: mediaFileId, subtitles, missing, embedded_tracks: tracks }, + wanted, + ); // the codes read as one string — the chip's own flex gap would otherwise // open a space in front of every comma const langs = document.createElement("span"); @@ -1476,41 +1487,133 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS panel.append(presentLane, fetchLane, translateLane); - /** Keeps the target picker off the source's own language — a same-language - * translation is refused by the API and is never what was meant. */ + /** + * The source picker carries two kinds: the subtitles already written next + * to the video, and the text tracks still inside it (§15, issue #260). + * Picking one of the second kind extracts it — the reconcile loop only + * ever extracts while closing a gap, so on a file where nothing is + * missing this is the only way a sidecar is ever written. + */ function paintSources() { const sources = translationSources(subtitles); - source.select.replaceChildren( - ...sources.map((subtitle) => { + const offered = extractableTracks(tracks, subtitles); + source.select.replaceChildren(); + if (sources.length === 0 && offered.length > 0) { + // nothing on disk to fall back on, and a browser selects the first + // option on sight — which would leave a track reading as picked + // without having been written out. The operator picks it themselves. + const option = document.createElement("option"); + option.value = ""; + option.textContent = "pick a track to write out"; + source.select.append(option); + } + if (sources.length > 0) { + const group = document.createElement("optgroup"); + group.label = "on this file"; + for (const subtitle of sources) { const option = document.createElement("option"); - option.value = String(subtitle.id); + option.value = `subtitle:${subtitle.id}`; option.textContent = subtitleChipLabel(subtitle); - return option; - }), - ); - const empty = sources.length === 0; + group.append(option); + } + source.select.append(group); + } + if (offered.length > 0) { + const group = document.createElement("optgroup"); + group.label = "inside the video — written out when picked"; + for (const track of offered) { + const option = document.createElement("option"); + option.value = `track:${track.index}`; + option.textContent = embeddedTrackLabel(track); + group.append(option); + } + source.select.append(group); + } + + const empty = sources.length === 0 && offered.length === 0; source.select.disabled = empty; - translateButton.disabled = empty || engine.select.disabled; if (empty) { const option = document.createElement("option"); option.textContent = "nothing to translate from"; source.select.append(option); say( translateHint, - "no subtitle with text on this file yet — fetch one above, or wait for an embedded text track to be extracted. An image-based track carries bitmaps, not text, and is never a source.", + "no subtitle with text on this file, and no text track inside it either — fetch one above. An image-based track carries bitmaps, not text, and is never a source.", ); } else if (translateHint.dataset.tone === undefined) { say( translateHint, - "any subtitle already on the file is a legal source — a fetched one, an extracted embedded track, even another machine translation.", + offered.length > 0 + ? "any subtitle already on the file is a legal source, and so is a text track inside the video — picking one writes it out beside the video first." + : "any subtitle already on the file is a legal source — a fetched one, an extracted embedded track, even another machine translation.", ); } - targetLanguages.bar( - sources.find((s) => String(s.id) === source.select.value)?.language ?? null, - ); + if (pickAfterRepaint !== null) { + if (sources.some((subtitle) => subtitle.id === pickAfterRepaint)) { + source.select.value = `subtitle:${pickAfterRepaint}`; + } + pickAfterRepaint = null; + } + // the target picker never offers the source's own language, whichever + // kind of source it is — the API refuses it and it is never what was + // meant + const picked = pickedSource(); + targetLanguages.bar(picked?.language ?? null); + // a track is a source only once it has been written out, so until then + // the button has nothing to translate from + translateButton.disabled = engine.select.disabled || picked === null || "index" in picked; } - source.select.addEventListener("change", () => paintSources()); + /** + * What the picker currently names, and which of the two kinds it is: a + * subtitle already on disk, or a track still inside the video. Only the + * second carries an `index`. + */ + function pickedSource(): Subtitle | EmbeddedTrack | null { + const [kind, rest] = source.select.value.split(":"); + if (kind === "subtitle") { + return translationSources(subtitles).find((subtitle) => String(subtitle.id) === rest) ?? null; + } + if (kind === "track") { + return ( + extractableTracks(tracks, subtitles).find((track) => String(track.index) === rest) ?? null + ); + } + return null; + } + + /** + * §15's extraction, on demand: the sidecar lands beside the video and + * becomes an ordinary source. The row is repainted from the answer before + * the refresh returns, so the lane is usable immediately. + */ + async function extractPicked(track: EmbeddedTrack) { + source.select.disabled = true; + translateButton.disabled = true; + translateNote.hidden = false; + say(translateNote, `writing the ${track.language} ${track.codec} track out…`); + const outcome = await extractTrack(mediaFileId, track.index); + if (outcome.kind === "error") { + say(translateNote, `extraction failed — ${outcome.detail}`, "fault"); + paintSources(); + return; + } + say(translateNote, `written beside the video — ${outcome.subtitle.language} is now a source`); + subtitles = [...subtitles, outcome.subtitle]; + pickAfterRepaint = outcome.subtitle.id; + paintChips(); + paintSources(); + refresh(); + } + + source.select.addEventListener("change", () => { + const picked = pickedSource(); + if (picked !== null && "index" in picked) { + void extractPicked(picked); + return; + } + paintSources(); + }); let optionsLoaded = false; @@ -1651,10 +1754,13 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS translateButton.addEventListener("click", () => { const target = targetLanguages.selected(); - const sourceId = Number(source.select.value); - if (target === null || !Number.isFinite(sourceId) || source.select.disabled) { + const picked = pickedSource(); + // a track that has not been written out carries no text; the change + // handler extracts on pick, so this only guards the moment in between + if (target === null || picked === null || "index" in picked || source.select.disabled) { return; } + const sourceId = picked.id; translateButton.disabled = true; translateNote.hidden = false; say(translateNote, `translating into ${target}…`); @@ -1696,6 +1802,7 @@ function subtitleSection(status: SubtitleStatus, refresh: () => void): SubtitleS update: (next: SubtitleStatus) => { subtitles = next.subtitles; missing = next.missing; + tracks = next.embedded_tracks; paintChips(); paintSources(); }, diff --git a/web/src/subtitles.ts b/web/src/subtitles.ts index fef1440..c03f421 100644 --- a/web/src/subtitles.ts +++ b/web/src/subtitles.ts @@ -32,11 +32,27 @@ export interface MissingSubtitle { detail: string | null; } +/** + * A text track inside the video that an extract could turn into a sidecar + * (§15, issue #260). Image-format and forced tracks are never listed: arr + * does not OCR, and a language has exactly one sidecar to spend. + */ +export interface EmbeddedTrack { + /** Position among the file's subtitle streams — what an extract names. */ + index: number; + language: string; + /** `ffprobe`'s own codec name: `subrip`, `ass` or `mov_text`. */ + codec: string; + sdh: boolean; +} + /** One media file's subtitles and the wanted languages it still lacks. */ export interface SubtitleStatus { media_file_id: number; subtitles: Subtitle[]; missing: MissingSubtitle[]; + /** Empty for a file probed before #189, which recorded no codec. */ + embedded_tracks: EmbeddedTrack[]; } /** The series-wide bulk sibling of `SubtitleStatus` (one call, not one per episode). */ @@ -259,6 +275,33 @@ export async function translateSubtitle( } } +/** + * Write one embedded text track out as a sidecar (§15, issue #260). + * + * The reconcile loop extracts only while it is already translating a gap, + * so on a file where nothing is missing no sidecar is ever written. This is + * the operator's own way in, and the sidecar it leaves is a legal + * translation source the moment it lands. + */ +export async function extractTrack( + mediaFileId: number, + trackIndex: number, +): Promise { + try { + const response = await fetch(`/api/media-files/${mediaFileId}/subtitles/extract`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ track_index: trackIndex }), + }); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "done", subtitle: (await response.json()) as Subtitle }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + /* ---- delete (§15, issue #223) ------------------------------------------ */ export type SubtitleDeleteOutcome = { kind: "done" } | { kind: "error"; detail: string }; @@ -337,6 +380,27 @@ export function translationSources(subtitles: Subtitle[]): Subtitle[] { return subtitles.filter((subtitle) => subtitle.path !== null); } +/** + * The embedded tracks still worth offering: §15 gives a language exactly one + * sidecar, so a track whose language already has one on disk has nowhere to + * be written and is dropped rather than offered and then refused. + */ +export function extractableTracks(tracks: EmbeddedTrack[], subtitles: Subtitle[]): EmbeddedTrack[] { + const taken = new Set( + subtitles.filter((subtitle) => subtitle.path !== null).map((subtitle) => subtitle.language), + ); + return tracks.filter((track) => !taken.has(track.language)); +} + +/** An embedded track's own label: language, then the format it is in. */ +export function embeddedTrackLabel(track: EmbeddedTrack): string { + const parts = [track.language, track.codec]; + if (track.sdh) { + parts.push("SDH"); + } + return parts.join(" · "); +} + /* ---- candidate chip formatting (§9.3 columns) -------------------------- */ /**