diff --git a/.sqlx/query-2084928704dbb8a2c81692b9aa3b6edff64745945ea4e88d70bcb139e272c857.json b/.sqlx/query-2084928704dbb8a2c81692b9aa3b6edff64745945ea4e88d70bcb139e272c857.json new file mode 100644 index 0000000..5eaad0c --- /dev/null +++ b/.sqlx/query-2084928704dbb8a2c81692b9aa3b6edff64745945ea4e88d70bcb139e272c857.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(SELECT 1 FROM series WHERE id = ?) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "2084928704dbb8a2c81692b9aa3b6edff64745945ea4e88d70bcb139e272c857" +} diff --git a/.sqlx/query-2afeefde38499d722f329d1d472bc89524ce6498ccd4098ba97979e0621b840b.json b/.sqlx/query-2afeefde38499d722f329d1d472bc89524ce6498ccd4098ba97979e0621b840b.json new file mode 100644 index 0000000..d62efc1 --- /dev/null +++ b/.sqlx/query-2afeefde38499d722f329d1d472bc89524ce6498ccd4098ba97979e0621b840b.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT wanted_languages AS \"wanted_languages!: String\" FROM subtitle_settings WHERE id = 1", + "describe": { + "columns": [ + { + "name": "wanted_languages!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "subtitle_settings", + "name": "wanted_languages" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "2afeefde38499d722f329d1d472bc89524ce6498ccd4098ba97979e0621b840b" +} diff --git a/.sqlx/query-f4c3eb506289667ace56da5d28c046163d5f12bd1dca53d3d6fc559f6e9ce393.json b/.sqlx/query-f4c3eb506289667ace56da5d28c046163d5f12bd1dca53d3d6fc559f6e9ce393.json new file mode 100644 index 0000000..b5cd900 --- /dev/null +++ b/.sqlx/query-f4c3eb506289667ace56da5d28c046163d5f12bd1dca53d3d6fc559f6e9ce393.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT mf.id AS \"media_file_id!: i64\", e.id AS \"episode_id!: i64\"\n FROM media_files mf\n JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?\n ORDER BY mf.path", + "describe": { + "columns": [ + { + "name": "media_file_id!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "media_files", + "name": "id" + } + } + }, + { + "name": "episode_id!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "episodes", + "name": "id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "f4c3eb506289667ace56da5d28c046163d5f12bd1dca53d3d6fc559f6e9ce393" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 88defef..cdfce02 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -48,8 +48,9 @@ pub use state::{ }; pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput}; pub use subtitles::{ - Subtitle, SubtitleCandidate, SubtitleGrabInput, SubtitleProviderError, SubtitleSearchInput, - SubtitleSearchResults, SubtitleTranslateInput, + EpisodeSubtitleStatus, MissingSubtitle, Subtitle, SubtitleCandidate, SubtitleGrabInput, + SubtitleProviderError, SubtitleSearchInput, SubtitleSearchResults, SubtitleStatus, + SubtitleTranslateInput, }; pub use trailer::{Trailer, TrailerKind}; @@ -128,6 +129,9 @@ fn api_router() -> OpenApiRouter { .routes(routes!(subtitles::list_for_media_file)) .routes(routes!(subtitles::list_for_movie)) .routes(routes!(subtitles::list_for_episode)) + .routes(routes!(subtitles::status_for_movie)) + .routes(routes!(subtitles::status_for_episode)) + .routes(routes!(subtitles::status_for_series)) .routes(routes!(subtitles::search)) .routes(routes!(subtitles::grab)) .routes(routes!(subtitles::translate)) @@ -378,6 +382,9 @@ mod tests { ("/api/media-files/{media_file_id}/subtitles", "get"), ("/api/movies/{movie_id}/subtitles", "get"), ("/api/episodes/{episode_id}/subtitles", "get"), + ("/api/movies/{movie_id}/subtitles/status", "get"), + ("/api/episodes/{episode_id}/subtitles/status", "get"), + ("/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"), ( @@ -402,6 +409,9 @@ mod tests { "Subtitle", "SubtitleCandidate", "SubtitleSearchResults", + "SubtitleStatus", + "MissingSubtitle", + "EpisodeSubtitleStatus", ] { assert!( json["components"]["schemas"][schema].is_object(), diff --git a/crates/arr-api/src/subtitles.rs b/crates/arr-api/src/subtitles.rs index dc708e6..7c1386e 100644 --- a/crates/arr-api/src/subtitles.rs +++ b/crates/arr-api/src/subtitles.rs @@ -129,6 +129,42 @@ pub struct SubtitleCandidate { pub rejected_rule: Option, } +/// One wanted language a media file still lacks, and why (§15, §9.6). +/// +/// `reason` collapses `subtitle_attempts.state` to the words the title +/// detail page shows (issue #201): `searching`, `no_candidates`, `capped` +/// or `failed`. A wanted language with no attempt row yet — the reconcile +/// loop has not reached it — reads the same as `searching`: no verdict +/// exists either way. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct MissingSubtitle { + pub language: String, + pub reason: String, + /// Why the last attempt failed. Set only when `reason` is `failed`. + pub detail: Option, +} + +/// One media file's subtitles and the wanted languages it still lacks. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SubtitleStatus { + pub media_file_id: i64, + /// Ordered by language (§9.6's one row of chips). + pub subtitles: Vec, + pub missing: Vec, +} + +/// One episode's media file, subtitles and gaps — the series-wide bulk +/// sibling of [`SubtitleStatus`], joined the same way `series::files` +/// already joins episode files, so the series detail page costs one call +/// rather than one per episode. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct EpisodeSubtitleStatus { + pub episode_id: i64, + pub media_file_id: i64, + pub subtitles: Vec, + pub missing: Vec, +} + /// A provider that could not answer this search. /// /// One unreachable provider does not fail the search: §15 configures two at @@ -438,6 +474,203 @@ async fn subtitles_for_owner( Ok(out) } +/// The global wanted set (§15), as `/settings` last saved it. +async fn wanted_languages(state: &AppState) -> Result, ApiError> { + let raw = sqlx::query_scalar!( + r#"SELECT wanted_languages AS "wanted_languages!: String" FROM subtitle_settings WHERE id = 1"# + ) + .fetch_one(pool(state)?) + .await?; + serde_json::from_str(&raw).map_err(|error| ApiError::Database(error.to_string())) +} + +/// `subtitle_attempts.state` in the title detail page's own words. +fn reason_of(attempt: &arr_db::SubtitleAttempt) -> (String, Option) { + match attempt.state { + // Satisfied never reaches here: a satisfied language is dropped + // before this is called (satisfaction is read off the files, not + // the attempt row — DESIGN.md §15). + arr_db::SubtitleState::Wanted | arr_db::SubtitleState::Satisfied => { + ("searching".to_owned(), None) + } + arr_db::SubtitleState::Unavailable => ("no_candidates".to_owned(), None), + arr_db::SubtitleState::Capped => ("capped".to_owned(), None), + arr_db::SubtitleState::Failed => ("failed".to_owned(), attempt.last_failure.clone()), + } +} + +/// The wanted languages one media file still lacks, with why. +/// +/// Satisfaction is decided here against the files actually on this file, +/// never against `subtitle_attempts.state` — that column is the loop's own +/// bookkeeping and DESIGN.md §15 is explicit that it must never become a +/// second source of truth for what counts as satisfied. +async fn missing_for( + state: &AppState, + media_file_id: i64, + subtitles: &[Subtitle], + wanted: &[String], +) -> Result, ApiError> { + let satisfied: BTreeSet<&str> = subtitles + .iter() + .filter(|subtitle| !subtitle.forced) + .map(|subtitle| subtitle.language.as_str()) + .collect(); + let gaps: Vec<&String> = wanted + .iter() + .filter(|language| !satisfied.contains(language.as_str())) + .collect(); + if gaps.is_empty() { + return Ok(Vec::new()); + } + + let attempts = db::attempts_for(pool(state)?, media_file_id).await?; + Ok(gaps + .into_iter() + .map(|language| { + let (reason, detail) = attempts + .iter() + .find(|attempt| attempt.language == *language) + .map_or_else(|| ("searching".to_owned(), None), reason_of); + MissingSubtitle { + language: language.clone(), + reason, + detail, + } + }) + .collect()) +} + +/// Every media file one owner has, with its subtitles and its gaps (§9.6). +async fn status_for_owner( + state: &AppState, + owner_kind: &str, + owner_id: i64, +) -> Result, ApiError> { + let ids = sqlx::query_scalar!( + r#"SELECT id AS "id!: i64" FROM media_files + WHERE owner_kind = ? AND owner_id = ? ORDER BY path"#, + owner_kind, + owner_id + ) + .fetch_all(pool(state)?) + .await?; + let wanted = wanted_languages(state).await?; + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + let subtitles = subtitles_of(state, id).await?; + let missing = missing_for(state, id, &subtitles, &wanted).await?; + out.push(SubtitleStatus { + media_file_id: id, + subtitles, + missing, + }); + } + Ok(out) +} + +#[utoipa::path( + get, path = "/api/movies/{movie_id}/subtitles/status", tag = "subtitles", + params(("movie_id" = i64, Path, description = "Movie row id")), + responses( + (status = 200, body = [SubtitleStatus]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn status_for_movie( + State(state): State, + UrlPath(movie_id): UrlPath, +) -> Result>, ApiError> { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM movies WHERE id = ?) AS "exists!: bool""#, + movie_id + ) + .fetch_one(pool(&state)?) + .await?; + if !exists { + return Err(ApiError::NotFound); + } + Ok(Json(status_for_owner(&state, "movie", movie_id).await?)) +} + +#[utoipa::path( + get, path = "/api/episodes/{episode_id}/subtitles/status", tag = "subtitles", + params(("episode_id" = i64, Path, description = "Episode row id")), + responses( + (status = 200, body = [SubtitleStatus]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn status_for_episode( + State(state): State, + UrlPath(episode_id): UrlPath, +) -> Result>, ApiError> { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM episodes WHERE id = ?) AS "exists!: bool""#, + episode_id + ) + .fetch_one(pool(&state)?) + .await?; + if !exists { + return Err(ApiError::EpisodeNotFound); + } + Ok(Json(status_for_owner(&state, "episode", episode_id).await?)) +} + +#[utoipa::path( + get, path = "/api/series/{series_id}/subtitles/status", tag = "subtitles", + params(("series_id" = i64, Path, description = "Series row id")), + responses( + (status = 200, body = [EpisodeSubtitleStatus]), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn status_for_series( + State(state): State, + UrlPath(series_id): UrlPath, +) -> Result>, ApiError> { + let exists = sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM series WHERE id = ?) AS "exists!: bool""#, + series_id + ) + .fetch_one(pool(&state)?) + .await?; + if !exists { + return Err(ApiError::NotFound); + } + + let rows = sqlx::query!( + r#"SELECT mf.id AS "media_file_id!: i64", e.id AS "episode_id!: i64" + FROM media_files mf + JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id + JOIN seasons se ON se.id = e.season_id + WHERE se.series_id = ? + ORDER BY mf.path"#, + series_id + ) + .fetch_all(pool(&state)?) + .await?; + let wanted = wanted_languages(&state).await?; + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let subtitles = subtitles_of(&state, row.media_file_id).await?; + let missing = missing_for(&state, row.media_file_id, &subtitles, &wanted).await?; + out.push(EpisodeSubtitleStatus { + episode_id: row.episode_id, + media_file_id: row.media_file_id, + subtitles, + missing, + }); + } + Ok(Json(out)) +} + #[utoipa::path( get, path = "/api/media-files/{media_file_id}/subtitles", tag = "subtitles", params(("media_file_id" = i64, Path, description = "Media file row id")), @@ -1895,4 +2128,151 @@ mod tests { let written = PathBuf::from(body["path"].as_str().expect("path")); assert_eq!(written.parent(), fixture.video.parent()); } + + async fn status(url: &str) -> Vec { + reqwest::get(url) + .await + .expect("status") + .json() + .await + .expect("json") + } + + /// #201: a language nothing has been tried for yet reads as `searching`, + /// with no attempt row required — the reconcile loop just has not + /// reached it. + #[tokio::test] + async fn status_reports_untouched_wanted_languages_as_searching() { + let fixture = stub_application().await; + let statuses = status(&format!("{}/api/movies/1/subtitles/status", fixture.base)).await; + assert_eq!(statuses.len(), 1); + assert_eq!(statuses[0]["media_file_id"], 1); + assert!(statuses[0]["subtitles"] + .as_array() + .expect("subtitles") + .is_empty()); + let missing = statuses[0]["missing"].as_array().expect("missing"); + assert_eq!(missing.len(), 2, "{missing:?}"); + assert_eq!(missing[0]["language"], "pt-PT"); + assert_eq!(missing[0]["reason"], "searching"); + assert_eq!(missing[1]["language"], "en"); + assert_eq!(missing[1]["reason"], "searching"); + } + + /// Satisfaction is read off the files (§15): once pt-PT has a subtitle, + /// it drops out of `missing` regardless of what its attempt row says. + #[tokio::test] + async fn status_drops_a_satisfied_language_from_missing() { + let fixture = stub_application().await; + assert_eq!(grab(&fixture, pt()).await.0, StatusCode::CREATED); + + let statuses = status(&format!("{}/api/movies/1/subtitles/status", fixture.base)).await; + let subtitles = statuses[0]["subtitles"].as_array().expect("subtitles"); + assert_eq!(subtitles.len(), 1); + assert_eq!(subtitles[0]["language"], "pt-PT"); + let missing = statuses[0]["missing"].as_array().expect("missing"); + assert_eq!(missing.len(), 1); + assert_eq!(missing[0]["language"], "en"); + } + + /// #201: `no_candidates`, `capped` and `failed` carry the attempt row's + /// own state, and `failed` also carries `last_failure` as `detail`. + #[tokio::test] + async fn status_reports_the_attempt_s_reason_for_a_missing_language() { + let fixture = stub_application().await; + arr_db::subtitles::record_attempt( + &fixture.pool, + fixture.media_file_id, + "en", + arr_db::SubtitleState::Failed, + Some("429 from opensubtitles"), + ) + .await + .expect("record failed attempt"); + arr_db::subtitles::record_attempt( + &fixture.pool, + fixture.media_file_id, + "pt-PT", + arr_db::SubtitleState::Capped, + None, + ) + .await + .expect("record capped attempt"); + + let statuses = status(&format!("{}/api/movies/1/subtitles/status", fixture.base)).await; + let missing = statuses[0]["missing"].as_array().expect("missing"); + let by_language = |language: &str| { + missing + .iter() + .find(|entry| entry["language"] == language) + .unwrap_or_else(|| panic!("{language} missing from {missing:?}")) + }; + assert_eq!(by_language("en")["reason"], "failed"); + assert_eq!(by_language("en")["detail"], "429 from opensubtitles"); + assert_eq!(by_language("pt-PT")["reason"], "capped"); + assert!(by_language("pt-PT")["detail"].is_null()); + } + + #[tokio::test] + async fn status_for_a_title_that_is_not_there_is_a_404() { + let fixture = stub_application().await; + for url in [ + format!("{}/api/movies/99/subtitles/status", fixture.base), + format!("{}/api/episodes/99/subtitles/status", fixture.base), + format!("{}/api/series/99/subtitles/status", fixture.base), + ] { + assert_eq!( + reqwest::get(&url).await.expect("status").status(), + StatusCode::NOT_FOUND, + "{url}" + ); + } + } + + /// #201: the series-wide bulk endpoint joins episode media files the + /// same way `series::files` does, so the detail page costs one call. + #[tokio::test] + async fn series_status_joins_every_episode_s_file() { + let fixture = stub_application().await; + sqlx::query( + "INSERT INTO series (id, tmdb_id, title, root_id) + SELECT 1, 9999, 'Bluey', id FROM roots WHERE kind = 'tv' LIMIT 1", + ) + .execute(&fixture.pool) + .await + .expect("series"); + sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)") + .execute(&fixture.pool) + .await + .expect("season"); + sqlx::query( + "INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 1, 'Hospital')", + ) + .execute(&fixture.pool) + .await + .expect("episode"); + sqlx::query( + "INSERT INTO media_files (id, owner_kind, owner_id, path, size) VALUES (2, 'episode', 1, 'S01E01.mkv', 100)", + ) + .execute(&fixture.pool) + .await + .expect("media file"); + arr_db::subtitles::mark_satisfied(&fixture.pool, 2, "en") + .await + .expect("mark satisfied"); + arr_db::subtitles::record_file(&fixture.pool, &arr_db::NewSubtitleFile::embedded(2, "en")) + .await + .expect("record file"); + + let statuses = status(&format!("{}/api/series/1/subtitles/status", fixture.base)).await; + assert_eq!(statuses.len(), 1, "{statuses:?}"); + assert_eq!(statuses[0]["episode_id"], 1); + assert_eq!(statuses[0]["media_file_id"], 2); + let subtitles = statuses[0]["subtitles"].as_array().expect("subtitles"); + assert_eq!(subtitles.len(), 1); + assert_eq!(subtitles[0]["language"], "en"); + let missing = statuses[0]["missing"].as_array().expect("missing"); + assert_eq!(missing.len(), 1); + assert_eq!(missing[0]["language"], "pt-PT"); + } } diff --git a/web/src/main.ts b/web/src/main.ts index cb3714b..766daac 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -105,6 +105,16 @@ import { } from "./series"; import { armedDelete, settingsMain } from "./settings"; import "./style.css"; +import { + type EpisodeSubtitleStatus, + type MissingSubtitle, + missingChipLabel, + movieSubtitleStatus, + type Subtitle, + type SubtitleStatus, + seriesSubtitleStatus, + subtitleChipLabel, +} from "./subtitles"; const POLL_MS = 15_000; @@ -758,6 +768,63 @@ function countsChip( }); } +/* ---- subtitle status (§15, §9.6, issue #201) --------------------------- */ + +/** + * One present-subtitle chip. Green like an eligible release (the want is + * met); a forced track never satisfies a want (§15) so it stays neutral + * instead. `data-mt` marks a machine translation with the same dashed + * border a waived release already carries — a translation is a relaxed + * case of "has a subtitle", not the clean one, and §15 wants it readable at + * a glance rather than by hovering for the origin word. + */ +function subtitleChip(subtitle: Subtitle): HTMLSpanElement { + return chip(subtitleChipLabel(subtitle), (span) => { + if (!subtitle.forced) { + span.dataset.verdict = "eligible"; + } + if (subtitle.origin === "translated") { + span.dataset.mt = "true"; + } + }); +} + +/** One missing-language chip: the same amber "wanted, not yet on disk" ramp §4.2 already uses. */ +function missingSubtitleChip(missing: MissingSubtitle): HTMLSpanElement { + return chip(missingChipLabel(missing), (span) => { + span.dataset.movieState = "missing"; + span.dataset.wanted = "true"; + if (missing.reason === "failed" && missing.detail !== null) { + span.title = missing.detail; + } + }); +} + +/** + * The subtitle status row for one media file (§9.6): every present + * language, then every wanted language still missing, each carrying its own + * reason. `null` only when there is nothing to say — no subtitles and + * nothing wanted — which does not happen with the shipped default wanted + * set, but an operator could empty it from `/settings`. + */ +function subtitleStatusLine(status: SubtitleStatus | null): HTMLElement | null { + if (status === null) { + return null; + } + if (status.subtitles.length === 0 && status.missing.length === 0) { + return null; + } + const line = document.createElement("div"); + line.className = "rel-line subtitle-line"; + for (const subtitle of status.subtitles) { + line.append(subtitleChip(subtitle)); + } + for (const missing of status.missing) { + line.append(missingSubtitleChip(missing)); + } + return line; +} + function rowTitle(title: string, year: number | null): HTMLElement { const wrap = document.createElement("span"); wrap.className = "row-id"; @@ -1339,6 +1406,7 @@ function movieMain(views: HideableView[]): MovieView { let movieId: number | null = null; let current: LibraryMovie | null = null; let roots: Root[] = []; + let subtitlesByFile = new Map(); let removed: ((parent: Route) => void) | null = null; let origin: HTMLElement | null = null; let returnTo: HTMLElement | null = null; @@ -1663,6 +1731,10 @@ function movieMain(views: HideableView[]): MovieView { ); } item.append(line); + const subtitleLine = subtitleStatusLine(subtitlesByFile.get(file.id) ?? null); + if (subtitleLine !== null) { + item.append(subtitleLine); + } diskRows.append(item); } } @@ -1865,9 +1937,10 @@ function movieMain(views: HideableView[]): MovieView { sequence += 1; const ticket = sequence; window.clearTimeout(pollTimer); - const [movie, filesOutcome, fetchedRoots] = await Promise.all([ + const [movie, filesOutcome, subtitleOutcome, fetchedRoots] = await Promise.all([ fetchMovie(id), movieFiles(id), + movieSubtitleStatus(id), allRoots().catch(() => roots), ]); if (ticket !== sequence || movieId !== id) { @@ -1879,6 +1952,11 @@ function movieMain(views: HideableView[]): MovieView { return; } current = movie; + subtitlesByFile = new Map( + subtitleOutcome.kind === "status" + ? subtitleOutcome.statuses.map((status) => [status.media_file_id, status]) + : [], + ); clearRichDetail(); paintIdentity(); paintControls(); @@ -3129,6 +3207,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { let series: ApiSeries | null = null; let seasons: ApiSeason[] | null = null; let filesByEpisode = new Map(); + let subtitlesByEpisode = new Map(); // which seasons stand open survives the refetch every action triggers let expanded = new Set(); let origin: HTMLElement | null = null; @@ -3591,6 +3670,10 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { } item.append(tag, name, air, chips, actions); + const subtitleLine = subtitleStatusLine(subtitlesByEpisode.get(episode.id) ?? null); + if (subtitleLine !== null) { + item.append(subtitleLine); + } return item; } @@ -3722,12 +3805,15 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { const ticket = sequence; window.clearTimeout(refreshPollTimer); setStatus("reading series…"); - const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([ - fetchSeries(id), - fetchSeasons(id), - fetchSeriesFiles(id), - allRoots().catch(() => roots), - ]); + const [detail, seasonsOutcome, filesOutcome, subtitleOutcome, fetchedRoots] = await Promise.all( + [ + fetchSeries(id), + fetchSeasons(id), + fetchSeriesFiles(id), + seriesSubtitleStatus(id), + allRoots().catch(() => roots), + ], + ); if (ticket !== sequence || seriesId !== id) { return; } @@ -3747,6 +3833,11 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView { ? filesOutcome.files.map((file) => [file.episode_id, file]) : [], ); + subtitlesByEpisode = new Map( + subtitleOutcome.kind === "status" + ? subtitleOutcome.statuses.map((status) => [status.episode_id, status]) + : [], + ); paintHeader(); clearRichDetail(); renderSeasons(); diff --git a/web/src/style.css b/web/src/style.css index 8a0ca95..b4469af 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1464,6 +1464,23 @@ body { border-color: oklch(from var(--signal-warn) l c h / 55%); } +/* ---- subtitle status (§15, §9.6, issue #201) --------------------------- */ + +/* forces the subtitle row below a disk-row's or episode's own line, the + same 100%-basis trick .rel-name uses to keep the release name secondary */ +.subtitle-line { + flex-basis: 100%; + padding-top: var(--space-1); + margin-top: var(--space-1); + border-top: 1px solid oklch(from var(--line) l c h / 30%); +} + +/* a machine translation reads as machine-made at a glance (§15) — the same + dashed mark a waived release already carries for "not the clean case" */ +.chip[data-mt="true"] { + border-style: dashed; +} + /* ---- attention queues (§5.2 + §5.7) ------------------------------------ */ /* the badge is a signal, not a control: amber attention on the cyan word */ diff --git a/web/src/subtitles.ts b/web/src/subtitles.ts new file mode 100644 index 0000000..0daf53a --- /dev/null +++ b/web/src/subtitles.ts @@ -0,0 +1,139 @@ +// Hand-written mirror of arr-api's /api/{movies,episodes,series}/{id}/subtitles/status +// schemas — same reasoning as search.ts: the generated client (src/api/) is +// uncommitted, so CI's tsc cannot see it. + +import { errorDetail } from "./releases"; + +/** One subtitle arr knows about, as `/subtitles` and `/subtitles/status` render it. */ +export interface Subtitle { + id: number; + media_file_id: number; + /** As `arr_core::Language` spells it: `pt-PT`, `pt-BR`, `en`. */ + language: string; + /** `embedded`, `extracted`, `provider` or `translated` (§15). */ + origin: string; + provider: string | null; + candidate_id: string | null; + /** The translation backend, when arr made this one. */ + engine: string | null; + forced: boolean; + sdh: boolean; + /** `not_run`, `synced` or `rejected` — what `alass` did (§15). */ + sync: string; + path: string | null; +} + +/** A wanted language a media file still lacks, and why (§15, issue #201). */ +export interface MissingSubtitle { + language: string; + /** `searching`, `no_candidates`, `capped` or `failed`. */ + reason: string; + /** Set only when `reason` is `failed`. */ + detail: string | null; +} + +/** One media file's subtitles and the wanted languages it still lacks. */ +export interface SubtitleStatus { + media_file_id: number; + subtitles: Subtitle[]; + missing: MissingSubtitle[]; +} + +/** The series-wide bulk sibling of `SubtitleStatus` (one call, not one per episode). */ +export interface EpisodeSubtitleStatus extends SubtitleStatus { + episode_id: number; +} + +export type SubtitleStatusOutcome = + | { kind: "status"; statuses: SubtitleStatus[] } + | { kind: "error"; detail: string }; + +export type EpisodeSubtitleStatusOutcome = + | { kind: "status"; statuses: EpisodeSubtitleStatus[] } + | { kind: "error"; detail: string }; + +export async function movieSubtitleStatus(movieId: number): Promise { + try { + const response = await fetch(`/api/movies/${movieId}/subtitles/status`); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "status", statuses: (await response.json()) as SubtitleStatus[] }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +/** One call for the whole series (§9.6) — never one request per episode. */ +export async function seriesSubtitleStatus( + seriesId: number, +): Promise { + try { + const response = await fetch(`/api/series/${seriesId}/subtitles/status`); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "status", statuses: (await response.json()) as EpisodeSubtitleStatus[] }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +const ORIGIN_LABELS: Record = { + embedded: "embedded", + extracted: "extracted", +}; + +/** The origin half of a present-subtitle chip's label (§15's own vocabulary). */ +function originLabel(subtitle: Subtitle): string { + if (subtitle.origin === "provider") { + return subtitle.provider ?? "provider"; + } + if (subtitle.origin === "translated") { + return subtitle.engine !== null ? `${subtitle.engine} · MT` : "MT"; + } + return ORIGIN_LABELS[subtitle.origin] ?? subtitle.origin; +} + +/** + * A present subtitle's chip label: language, origin, then only the flags + * that are true. §15: a machine translation must read as machine-made at a + * glance, so `translated` always carries the `MT` marker rather than + * relying on colour alone. + */ +export function subtitleChipLabel(subtitle: Subtitle): string { + const parts = [subtitle.language, originLabel(subtitle)]; + if (subtitle.sdh) { + parts.push("SDH"); + } + if (subtitle.forced) { + parts.push("forced"); + } + if (subtitle.sync === "rejected") { + parts.push("sync flagged"); + } + return parts.join(" · "); +} + +const REASON_LABELS: Record = { + searching: "searching", + no_candidates: "no candidates", + capped: "capped", + failed: "failed", +}; + +/** How long a `failed` detail rides in the chip text before it is title-only. */ +const DETAIL_CHARS = 60; + +/** A missing language's chip label: language and why, in §201's own words. */ +export function missingChipLabel(missing: MissingSubtitle): string { + const reason = REASON_LABELS[missing.reason] ?? missing.reason; + if (missing.reason !== "failed" || missing.detail === null) { + return `${missing.language} · ${reason}`; + } + const detail = + missing.detail.length > DETAIL_CHARS + ? `${missing.detail.slice(0, DETAIL_CHARS)}…` + : missing.detail; + return `${missing.language} · ${reason} — ${detail}`; +}