feat(arr): serve subtitle status for title detail

Exposes per-media-file subtitles and missing wanted languages, with the
attempt reason, so #201's UI has one call per title (movies, episodes)
and one bulk call per series instead of one per episode.
This commit is contained in:
Miguel Palhas
2026-08-25 02:16:20 +01:00
parent f1a58c0810
commit e5092034d3
5 changed files with 477 additions and 2 deletions
+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");
}
}