+21
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+98
-7
@@ -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();
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user