Merge #201: show subtitle state on title detail

Closes #201
This commit is contained in:
Miguel Palhas
2026-08-25 02:20:07 +01:00
8 changed files with 731 additions and 9 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
+12 -2
View File
@@ -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<AppState> {
.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(),
+380
View File
@@ -129,6 +129,42 @@ pub struct SubtitleCandidate {
pub rejected_rule: Option<String>,
}
/// 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<String>,
}
/// 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<Subtitle>,
pub missing: Vec<MissingSubtitle>,
}
/// 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<Subtitle>,
pub missing: Vec<MissingSubtitle>,
}
/// 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<Vec<String>, 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<String>) {
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<Vec<MissingSubtitle>, 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<Vec<SubtitleStatus>, 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<AppState>,
UrlPath(movie_id): UrlPath<i64>,
) -> Result<Json<Vec<SubtitleStatus>>, 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<AppState>,
UrlPath(episode_id): UrlPath<i64>,
) -> Result<Json<Vec<SubtitleStatus>>, 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<AppState>,
UrlPath(series_id): UrlPath<i64>,
) -> Result<Json<Vec<EpisodeSubtitleStatus>>, 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<serde_json::Value> {
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");
}
}
+98 -7
View File
@@ -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<number, SubtitleStatus>();
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<number, EpisodeFile>();
let subtitlesByEpisode = new Map<number, EpisodeSubtitleStatus>();
// which seasons stand open survives the refetch every action triggers
let expanded = new Set<number>();
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();
+17
View File
@@ -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 */
+139
View File
@@ -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<SubtitleStatusOutcome> {
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<EpisodeSubtitleStatusOutcome> {
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<string, string> = {
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<string, string> = {
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}`;
}