feat(api): extract an embedded track on demand (#261)
Closes #260.
This commit was merged in pull request #261.
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user