feat(api): extract an embedded track on demand #261

Merged
naps62-yolo merged 1 commits from issue/260-extract-on-demand into main 2026-08-26 12:08:42 +01:00
9 changed files with 666 additions and 28 deletions
Generated
+1
View File
@@ -41,6 +41,7 @@ dependencies = [
"arr-indexer",
"arr-meta",
"arr-parse",
"arr-probe",
"arr-subs",
"axum",
"chrono",
+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
+38 -1
View File
@@ -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 {
+5 -1
View File
@@ -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));
}
+126 -19
View File
@@ -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();
},
+64
View File
@@ -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<SubtitleWriteOutcome> {
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) -------------------------- */
/**