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:
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user