feat(api): add the missing-subtitles queue endpoint

GET /api/queues/subtitles, grouped by title with why each language
is a gap (#186's attempt states, plus a sync alass rejected). Series
episodes collapse into one season row when the gap is uniform, the
same restraint §9.5 gives the TV attention queues.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-25 03:03:17 +01:00
parent 4003c3a5a0
commit fb40b35156
6 changed files with 1097 additions and 3 deletions
@@ -0,0 +1,122 @@
{
"db_name": "SQLite",
"query": "SELECT m.id AS \"movie_id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\", m.year, m.poster_path,\n mf.id AS \"media_file_id!: i64\", sa.language AS \"language!: String\",\n sa.state AS \"state!: arr_db::SubtitleState\", sa.last_failure\n FROM subtitle_attempts sa\n JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'movie'\n JOIN movies m ON m.id = mf.owner_id\n WHERE sa.state IN ('failed', 'capped', 'unavailable')\n ORDER BY m.title, sa.language",
"describe": {
"columns": [
{
"name": "movie_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "poster_path"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "language"
}
}
},
{
"name": "state!: arr_db::SubtitleState",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "state"
}
}
},
{
"name": "last_failure",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_failure"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
true
]
},
"hash": "2b67abe4db9e00517fecfa53fbad5bbbc633a9f537eaaaa9d46a445d8c25eb2c"
}
@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT m.id AS \"movie_id!: i64\", m.tmdb_id AS \"tmdb_id!: i64\",\n m.title AS \"title!: String\", m.year, m.poster_path,\n mf.id AS \"media_file_id!: i64\", sf.language AS \"language!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'movie'\n JOIN movies m ON m.id = mf.owner_id\n WHERE sf.sync_rejected = 1\n ORDER BY m.title, sf.language",
"describe": {
"columns": [
{
"name": "movie_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "movies",
"name": "poster_path"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "69f34468a61979b85b7513056d58e34c743af3ae62d5327d4dea49fe7586a85b"
}
@@ -0,0 +1,158 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.poster_path,\n e.id AS \"episode_id!: i64\", se.number AS \"season_number!: i64\",\n e.number AS \"episode_number!: i64\", mf.id AS \"media_file_id!: i64\",\n sa.language AS \"language!: String\",\n sa.state AS \"state!: arr_db::SubtitleState\", sa.last_failure\n FROM subtitle_attempts sa\n JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'episode'\n JOIN episodes e ON e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE sa.state IN ('failed', 'capped', 'unavailable')\n ORDER BY s.title, se.number, e.number, sa.language",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "poster_path"
}
}
},
{
"name": "episode_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode_number!: i64",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 8,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "language"
}
}
},
{
"name": "state!: arr_db::SubtitleState",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "state"
}
}
},
{
"name": "last_failure",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_attempts",
"name": "last_failure"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "6cdb9a9344bbab557433da35471968b6e5cf96b5b220ffb41f10b6d851884803"
}
@@ -0,0 +1,134 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"series_id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.poster_path,\n e.id AS \"episode_id!: i64\", se.number AS \"season_number!: i64\",\n e.number AS \"episode_number!: i64\", mf.id AS \"media_file_id!: i64\",\n sf.language AS \"language!: String\"\n FROM subtitle_files sf\n JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'episode'\n JOIN episodes e ON e.id = mf.owner_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE sf.sync_rejected = 1\n ORDER BY s.title, se.number, e.number, sf.language",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "id"
}
}
},
{
"name": "tmdb_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "title!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "title"
}
}
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "year"
}
}
},
{
"name": "poster_path",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "poster_path"
}
}
},
{
"name": "episode_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "id"
}
}
},
{
"name": "season_number!: i64",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode_number!: i64",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
},
{
"name": "media_file_id!: i64",
"ordinal": 8,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "language!: String",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
false
]
},
"hash": "8543e4ad8c59e83911688ceace72f66d360240a45eee05aaf15dab233e22e7e6"
}
+6 -3
View File
@@ -48,9 +48,10 @@ pub use state::{
}; };
pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput}; pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput};
pub use subtitles::{ pub use subtitles::{
EpisodeSubtitleStatus, MissingSubtitle, Subtitle, SubtitleCandidate, SubtitleGrabInput, EpisodeSubtitleGaps, EpisodeSubtitleStatus, MissingSubtitle, MovieSubtitleGaps,
SubtitleProviderError, SubtitleSearchInput, SubtitleSearchResults, SubtitleStatus, SeasonSubtitleGaps, SeriesSubtitleGaps, Subtitle, SubtitleCandidate, SubtitleGap,
SubtitleTranslateInput, SubtitleGrabInput, SubtitleProviderError, SubtitleQueue, SubtitleSearchInput,
SubtitleSearchResults, SubtitleStatus, SubtitleTranslateInput,
}; };
pub use trailer::{Trailer, TrailerKind}; pub use trailer::{Trailer, TrailerKind};
@@ -136,6 +137,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(subtitles::grab)) .routes(routes!(subtitles::grab))
.routes(routes!(subtitles::translate)) .routes(routes!(subtitles::translate))
.routes(routes!(subtitles::delete)) .routes(routes!(subtitles::delete))
.routes(routes!(subtitles::queue))
} }
/// The generated `OpenAPI` document. /// The generated `OpenAPI` document.
@@ -392,6 +394,7 @@ mod tests {
"post", "post",
), ),
("/api/subtitles/{subtitle_id}", "delete"), ("/api/subtitles/{subtitle_id}", "delete"),
("/api/queues/subtitles", "get"),
] { ] {
assert!( assert!(
json["paths"][path][method].is_object(), json["paths"][path][method].is_object(),
+579
View File
@@ -173,6 +173,78 @@ pub struct EpisodeSubtitleStatus {
pub missing: Vec<MissingSubtitle>, pub missing: Vec<MissingSubtitle>,
} }
/// One wanted language a media file lacks, or a subtitle it has but `alass`
/// flagged (§15, issue #202).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SubtitleGap {
pub language: String,
/// [`reason_of`]'s words — `no_candidates`, `capped` or `failed` — plus
/// `sync_rejected` for a subtitle that exists but whose sync `alass`
/// rejected (§15): not a missing language, but still something the
/// operator did not see happen.
pub reason: String,
/// Set only when `reason` is `failed`.
pub detail: Option<String>,
}
/// A movie with at least one subtitle gap, for the missing-subtitles queue.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct MovieSubtitleGaps {
pub movie_id: i64,
pub tmdb_id: i64,
pub title: String,
pub year: Option<i64>,
pub poster_path: Option<String>,
pub media_file_id: i64,
pub gaps: Vec<SubtitleGap>,
}
/// One episode's gaps, named for the operator the way [`QueuedEpisode`] is
/// (`SxxEyy` comes from season and episode numbers).
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct EpisodeSubtitleGaps {
pub episode_id: i64,
pub media_file_id: i64,
pub season_number: i64,
pub episode_number: i64,
pub gaps: Vec<SubtitleGap>,
}
/// A season collapsed into one row because every one of its episodes carries
/// the identical gap — a season-wide provider or budget failure otherwise
/// floods the queue one row per episode, the restraint §9.5 already applies
/// to the TV attention queues.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SeasonSubtitleGaps {
pub season_number: i64,
pub media_file_ids: Vec<i64>,
pub gaps: Vec<SubtitleGap>,
}
/// A series with at least one subtitle gap. One row per series (§9.5's
/// restraint): episodes that did not collapse into a season stay listed
/// individually, the rest roll up into `seasons`.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SeriesSubtitleGaps {
pub series_id: i64,
pub tmdb_id: i64,
pub title: String,
pub year: Option<i64>,
pub poster_path: Option<String>,
pub episodes: Vec<EpisodeSubtitleGaps>,
pub seasons: Vec<SeasonSubtitleGaps>,
}
/// The missing-subtitles queue (issue #202): every title with an unsatisfied
/// wanted language and why, plus subtitles a sync rejected. Each entry's
/// gaps are resolved with the manual actions this module already offers —
/// search, translate, or a retried search.
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SubtitleQueue {
pub movies: Vec<MovieSubtitleGaps>,
pub series: Vec<SeriesSubtitleGaps>,
}
/// A provider that could not answer this search. /// A provider that could not answer this search.
/// ///
/// One unreachable provider does not fail the search: §15 configures two at /// One unreachable provider does not fail the search: §15 configures two at
@@ -1191,6 +1263,310 @@ async fn refresh_jellyfin(state: &AppState) {
} }
} }
#[utoipa::path(
get, path = "/api/queues/subtitles", tag = "subtitles",
responses(
(status = 200, body = SubtitleQueue),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn queue(State(state): State<AppState>) -> Result<Json<SubtitleQueue>, ApiError> {
Ok(Json(SubtitleQueue {
movies: movie_gaps(&state).await?,
series: series_gaps(&state).await?,
}))
}
/// `subtitle_attempts.state` in [`SubtitleGap`]'s words — the same vocabulary
/// [`reason_of`] uses for the title detail page (#201), so a gap reads the
/// same wherever it is shown.
fn attempt_reason(
state: arr_db::SubtitleState,
last_failure: Option<String>,
) -> (String, Option<String>) {
match state {
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(), last_failure),
}
}
async fn movie_gaps(state: &AppState) -> Result<Vec<MovieSubtitleGaps>, ApiError> {
let database = pool(state)?;
let mut out: Vec<MovieSubtitleGaps> = Vec::new();
let attempts = sqlx::query!(
r#"SELECT m.id AS "movie_id!: i64", m.tmdb_id AS "tmdb_id!: i64",
m.title AS "title!: String", m.year, m.poster_path,
mf.id AS "media_file_id!: i64", sa.language AS "language!: String",
sa.state AS "state!: arr_db::SubtitleState", sa.last_failure
FROM subtitle_attempts sa
JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'movie'
JOIN movies m ON m.id = mf.owner_id
WHERE sa.state IN ('failed', 'capped', 'unavailable')
ORDER BY m.title, sa.language"#
)
.fetch_all(database)
.await?;
for row in attempts {
let (reason, detail) = attempt_reason(row.state, row.last_failure);
push_movie_gap(
&mut out,
row.movie_id,
row.tmdb_id,
&row.title,
row.year,
row.poster_path,
row.media_file_id,
SubtitleGap {
language: row.language,
reason,
detail,
},
);
}
let rejected = sqlx::query!(
r#"SELECT m.id AS "movie_id!: i64", m.tmdb_id AS "tmdb_id!: i64",
m.title AS "title!: String", m.year, m.poster_path,
mf.id AS "media_file_id!: i64", sf.language AS "language!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'movie'
JOIN movies m ON m.id = mf.owner_id
WHERE sf.sync_rejected = 1
ORDER BY m.title, sf.language"#
)
.fetch_all(database)
.await?;
for row in rejected {
push_movie_gap(
&mut out,
row.movie_id,
row.tmdb_id,
&row.title,
row.year,
row.poster_path,
row.media_file_id,
SubtitleGap {
language: row.language,
reason: "sync_rejected".to_owned(),
detail: None,
},
);
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
fn push_movie_gap(
entries: &mut Vec<MovieSubtitleGaps>,
movie_id: i64,
tmdb_id: i64,
title: &str,
year: Option<i64>,
poster_path: Option<String>,
media_file_id: i64,
gap: SubtitleGap,
) {
if let Some(entry) = entries.iter_mut().find(|entry| entry.movie_id == movie_id) {
entry.gaps.push(gap);
return;
}
entries.push(MovieSubtitleGaps {
movie_id,
tmdb_id,
title: title.to_owned(),
year,
poster_path,
media_file_id,
gaps: vec![gap],
});
}
async fn series_gaps(state: &AppState) -> Result<Vec<SeriesSubtitleGaps>, ApiError> {
let database = pool(state)?;
let mut out: Vec<SeriesSubtitleGaps> = Vec::new();
let attempts = sqlx::query!(
r#"SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, s.poster_path,
e.id AS "episode_id!: i64", se.number AS "season_number!: i64",
e.number AS "episode_number!: i64", mf.id AS "media_file_id!: i64",
sa.language AS "language!: String",
sa.state AS "state!: arr_db::SubtitleState", sa.last_failure
FROM subtitle_attempts sa
JOIN media_files mf ON mf.id = sa.media_file_id AND mf.owner_kind = 'episode'
JOIN episodes e ON e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE sa.state IN ('failed', 'capped', 'unavailable')
ORDER BY s.title, se.number, e.number, sa.language"#
)
.fetch_all(database)
.await?;
for row in attempts {
let (reason, detail) = attempt_reason(row.state, row.last_failure);
push_episode_gap(
&mut out,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.poster_path,
row.episode_id,
row.season_number,
row.episode_number,
row.media_file_id,
SubtitleGap {
language: row.language,
reason,
detail,
},
);
}
let rejected = sqlx::query!(
r#"SELECT s.id AS "series_id!: i64", s.tmdb_id AS "tmdb_id!: i64",
s.title AS "title!: String", s.year, s.poster_path,
e.id AS "episode_id!: i64", se.number AS "season_number!: i64",
e.number AS "episode_number!: i64", mf.id AS "media_file_id!: i64",
sf.language AS "language!: String"
FROM subtitle_files sf
JOIN media_files mf ON mf.id = sf.media_file_id AND mf.owner_kind = 'episode'
JOIN episodes e ON e.id = mf.owner_id
JOIN seasons se ON se.id = e.season_id
JOIN series s ON s.id = se.series_id
WHERE sf.sync_rejected = 1
ORDER BY s.title, se.number, e.number, sf.language"#
)
.fetch_all(database)
.await?;
for row in rejected {
push_episode_gap(
&mut out,
row.series_id,
row.tmdb_id,
&row.title,
row.year,
row.poster_path,
row.episode_id,
row.season_number,
row.episode_number,
row.media_file_id,
SubtitleGap {
language: row.language,
reason: "sync_rejected".to_owned(),
detail: None,
},
);
}
for series in &mut out {
collapse_seasons(series);
}
Ok(out)
}
#[allow(clippy::too_many_arguments)]
fn push_episode_gap(
entries: &mut Vec<SeriesSubtitleGaps>,
series_id: i64,
tmdb_id: i64,
title: &str,
year: Option<i64>,
poster_path: Option<String>,
episode_id: i64,
season_number: i64,
episode_number: i64,
media_file_id: i64,
gap: SubtitleGap,
) {
if !entries.iter().any(|entry| entry.series_id == series_id) {
entries.push(SeriesSubtitleGaps {
series_id,
tmdb_id,
title: title.to_owned(),
year,
poster_path,
episodes: Vec::new(),
seasons: Vec::new(),
});
}
let entry = entries
.iter_mut()
.find(|entry| entry.series_id == series_id)
.unwrap_or_else(|| unreachable!());
match entry
.episodes
.iter_mut()
.find(|episode| episode.episode_id == episode_id)
{
Some(episode) => episode.gaps.push(gap),
None => entry.episodes.push(EpisodeSubtitleGaps {
episode_id,
media_file_id,
season_number,
episode_number,
gaps: vec![gap],
}),
}
}
/// Roll a season's episodes into one [`SeasonSubtitleGaps`] row when two or
/// more of them carry the identical set of gaps — the season-wide failure
/// §9.5's restraint is meant to catch. A season with only one gapped episode,
/// or episodes whose gaps differ, stays as individual episode rows.
fn collapse_seasons(series: &mut SeriesSubtitleGaps) {
let mut by_group: std::collections::BTreeMap<(i64, String), Vec<EpisodeSubtitleGaps>> =
std::collections::BTreeMap::new();
for episode in std::mem::take(&mut series.episodes) {
let key = (episode.season_number, gap_signature(&episode.gaps));
by_group.entry(key).or_default().push(episode);
}
for ((season_number, _signature), mut episodes) in by_group {
if let [first, ..] = episodes.as_slice() {
if episodes.len() >= 2 {
series.seasons.push(SeasonSubtitleGaps {
season_number,
media_file_ids: episodes
.iter()
.map(|episode| episode.media_file_id)
.collect(),
gaps: first.gaps.clone(),
});
continue;
}
}
series.episodes.append(&mut episodes);
}
series
.episodes
.sort_by_key(|episode| (episode.season_number, episode.episode_number));
series.seasons.sort_by_key(|season| season.season_number);
}
fn gap_signature(gaps: &[SubtitleGap]) -> String {
let mut parts: Vec<String> = gaps
.iter()
.map(|gap| {
format!(
"{}|{}|{}",
gap.language,
gap.reason,
gap.detail.as_deref().unwrap_or("")
)
})
.collect();
parts.sort();
parts.join(",")
}
#[cfg(test)] #[cfg(test)]
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
mod tests { mod tests {
@@ -2315,4 +2691,207 @@ mod tests {
assert_eq!(missing.len(), 1); assert_eq!(missing.len(), 1);
assert_eq!(missing[0]["language"], "pt-PT"); assert_eq!(missing[0]["language"], "pt-PT");
} }
async fn queue(base: &str) -> serde_json::Value {
reqwest::get(format!("{base}/api/queues/subtitles"))
.await
.expect("queue")
.json()
.await
.expect("json")
}
/// #202: a title with no gap at all does not appear in either lane.
#[tokio::test]
async fn subtitle_queue_omits_titles_with_no_gaps() {
let fixture = stub_application().await;
let body = queue(&fixture.base).await;
assert!(body["movies"].as_array().expect("movies").is_empty());
assert!(body["series"].as_array().expect("series").is_empty());
}
/// #202: a movie's failed attempt surfaces with its language and detail.
#[tokio::test]
async fn subtitle_queue_lists_a_movie_gap() {
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");
let body = queue(&fixture.base).await;
let movies = body["movies"].as_array().expect("movies");
assert_eq!(movies.len(), 1, "{movies:?}");
assert_eq!(movies[0]["movie_id"], 1);
assert_eq!(movies[0]["media_file_id"], fixture.media_file_id);
let gaps = movies[0]["gaps"].as_array().expect("gaps");
assert_eq!(gaps.len(), 1, "{gaps:?}");
assert_eq!(gaps[0]["language"], "en");
assert_eq!(gaps[0]["reason"], "failed");
assert_eq!(gaps[0]["detail"], "429 from opensubtitles");
}
/// #202, §15: a subtitle whose sync `alass` rejected is flagged even
/// though the language it answers is satisfied.
#[tokio::test]
async fn subtitle_queue_surfaces_a_sync_rejected_file() {
let fixture = stub_application().await;
arr_db::subtitles::mark_satisfied(&fixture.pool, fixture.media_file_id, "pt-PT")
.await
.expect("mark satisfied");
arr_db::subtitles::record_file(
&fixture.pool,
&arr_db::NewSubtitleFile::fetched(
fixture.media_file_id,
"pt-PT",
"opensubtitles",
"1",
&fixture.video.with_extension("pt-PT.srt").to_string_lossy(),
)
.sync(arr_db::SubtitleSync::Rejected),
)
.await
.expect("record rejected sync");
let body = queue(&fixture.base).await;
let movies = body["movies"].as_array().expect("movies");
assert_eq!(movies.len(), 1, "{movies:?}");
let gaps = movies[0]["gaps"].as_array().expect("gaps");
assert_eq!(gaps.len(), 1, "{gaps:?}");
assert_eq!(gaps[0]["language"], "pt-PT");
assert_eq!(gaps[0]["reason"], "sync_rejected");
assert!(gaps[0]["detail"].is_null());
}
async fn seed_episode(
pool: &sqlx::SqlitePool,
episode_id: i64,
season_number: i64,
episode_number: i64,
) -> i64 {
sqlx::query(
"INSERT INTO seasons (id, series_id, number) VALUES (?, 1, ?)
ON CONFLICT (series_id, number) DO NOTHING",
)
.bind(season_number)
.bind(season_number)
.execute(pool)
.await
.expect("season");
sqlx::query("INSERT INTO episodes (id, season_id, number, title) VALUES (?, ?, ?, 'Ep')")
.bind(episode_id)
.bind(season_number)
.bind(episode_number)
.execute(pool)
.await
.expect("episode");
let media_file_id = 100 + episode_id;
sqlx::query(
"INSERT INTO media_files (id, owner_kind, owner_id, path, size) VALUES (?, 'episode', ?, ?, 100)",
)
.bind(media_file_id)
.bind(episode_id)
.bind(format!("S{season_number:02}E{episode_number:02}.mkv"))
.execute(pool)
.await
.expect("media file");
media_file_id
}
/// #202, same restraint as §9.5's TV attention lanes: when every episode
/// in a season carries the identical gap, the queue collapses them into
/// one season row instead of flooding it one row per episode.
#[tokio::test]
async fn subtitle_queue_collapses_a_season_when_every_episode_shares_the_gap() {
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");
let first = seed_episode(&fixture.pool, 1, 1, 1).await;
let second = seed_episode(&fixture.pool, 2, 1, 2).await;
for media_file_id in [first, second] {
arr_db::subtitles::record_attempt(
&fixture.pool,
media_file_id,
"pt-PT",
arr_db::SubtitleState::Capped,
None,
)
.await
.expect("record capped attempt");
}
let body = queue(&fixture.base).await;
let series = body["series"].as_array().expect("series");
assert_eq!(series.len(), 1, "{series:?}");
assert!(series[0]["episodes"]
.as_array()
.expect("episodes")
.is_empty());
let seasons = series[0]["seasons"].as_array().expect("seasons");
assert_eq!(seasons.len(), 1, "{seasons:?}");
assert_eq!(seasons[0]["season_number"], 1);
let mut media_file_ids: Vec<i64> = seasons[0]["media_file_ids"]
.as_array()
.expect("media_file_ids")
.iter()
.map(|id| id.as_i64().expect("id"))
.collect();
media_file_ids.sort_unstable();
assert_eq!(media_file_ids, vec![first, second]);
let gaps = seasons[0]["gaps"].as_array().expect("gaps");
assert_eq!(gaps.len(), 1, "{gaps:?}");
assert_eq!(gaps[0]["language"], "pt-PT");
assert_eq!(gaps[0]["reason"], "capped");
}
/// #202: episodes whose gaps differ stay listed individually — the
/// season only collapses when the failure is uniform across it.
#[tokio::test]
async fn subtitle_queue_keeps_episodes_separate_when_gaps_differ() {
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");
let first = seed_episode(&fixture.pool, 1, 1, 1).await;
let second = seed_episode(&fixture.pool, 2, 1, 2).await;
arr_db::subtitles::record_attempt(
&fixture.pool,
first,
"pt-PT",
arr_db::SubtitleState::Capped,
None,
)
.await
.expect("record capped attempt");
arr_db::subtitles::record_attempt(
&fixture.pool,
second,
"pt-PT",
arr_db::SubtitleState::Unavailable,
None,
)
.await
.expect("record unavailable attempt");
let body = queue(&fixture.base).await;
let series = body["series"].as_array().expect("series");
assert_eq!(series.len(), 1, "{series:?}");
assert!(series[0]["seasons"].as_array().expect("seasons").is_empty());
let episodes = series[0]["episodes"].as_array().expect("episodes");
assert_eq!(episodes.len(), 2, "{episodes:?}");
}
} }