From fb40b35156f433a0095b2cffb08ec0d2e26574c5 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 03:03:17 +0100 Subject: [PATCH] feat(api): add the missing-subtitles queue endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...d5bbbc633a9f537eaaaa9d46a445d8c25eb2c.json | 122 ++++ ...8e34c743af3ae62d5327d4dea49fe7586a85b.json | 98 +++ ...968b6e5cf96b5b220ffb41f10b6d851884803.json | 158 +++++ ...2f66d360240a45eee05aaf15dab233e22e7e6.json | 134 ++++ crates/arr-api/src/lib.rs | 9 +- crates/arr-api/src/subtitles.rs | 579 ++++++++++++++++++ 6 files changed, 1097 insertions(+), 3 deletions(-) create mode 100644 .sqlx/query-2b67abe4db9e00517fecfa53fbad5bbbc633a9f537eaaaa9d46a445d8c25eb2c.json create mode 100644 .sqlx/query-69f34468a61979b85b7513056d58e34c743af3ae62d5327d4dea49fe7586a85b.json create mode 100644 .sqlx/query-6cdb9a9344bbab557433da35471968b6e5cf96b5b220ffb41f10b6d851884803.json create mode 100644 .sqlx/query-8543e4ad8c59e83911688ceace72f66d360240a45eee05aaf15dab233e22e7e6.json diff --git a/.sqlx/query-2b67abe4db9e00517fecfa53fbad5bbbc633a9f537eaaaa9d46a445d8c25eb2c.json b/.sqlx/query-2b67abe4db9e00517fecfa53fbad5bbbc633a9f537eaaaa9d46a445d8c25eb2c.json new file mode 100644 index 0000000..afdbb22 --- /dev/null +++ b/.sqlx/query-2b67abe4db9e00517fecfa53fbad5bbbc633a9f537eaaaa9d46a445d8c25eb2c.json @@ -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" +} diff --git a/.sqlx/query-69f34468a61979b85b7513056d58e34c743af3ae62d5327d4dea49fe7586a85b.json b/.sqlx/query-69f34468a61979b85b7513056d58e34c743af3ae62d5327d4dea49fe7586a85b.json new file mode 100644 index 0000000..1d494d9 --- /dev/null +++ b/.sqlx/query-69f34468a61979b85b7513056d58e34c743af3ae62d5327d4dea49fe7586a85b.json @@ -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" +} diff --git a/.sqlx/query-6cdb9a9344bbab557433da35471968b6e5cf96b5b220ffb41f10b6d851884803.json b/.sqlx/query-6cdb9a9344bbab557433da35471968b6e5cf96b5b220ffb41f10b6d851884803.json new file mode 100644 index 0000000..316cb2a --- /dev/null +++ b/.sqlx/query-6cdb9a9344bbab557433da35471968b6e5cf96b5b220ffb41f10b6d851884803.json @@ -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" +} diff --git a/.sqlx/query-8543e4ad8c59e83911688ceace72f66d360240a45eee05aaf15dab233e22e7e6.json b/.sqlx/query-8543e4ad8c59e83911688ceace72f66d360240a45eee05aaf15dab233e22e7e6.json new file mode 100644 index 0000000..3acf94b --- /dev/null +++ b/.sqlx/query-8543e4ad8c59e83911688ceace72f66d360240a45eee05aaf15dab233e22e7e6.json @@ -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" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index cdfce02..be82dd8 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -48,9 +48,10 @@ pub use state::{ }; pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput}; pub use subtitles::{ - EpisodeSubtitleStatus, MissingSubtitle, Subtitle, SubtitleCandidate, SubtitleGrabInput, - SubtitleProviderError, SubtitleSearchInput, SubtitleSearchResults, SubtitleStatus, - SubtitleTranslateInput, + EpisodeSubtitleGaps, EpisodeSubtitleStatus, MissingSubtitle, MovieSubtitleGaps, + SeasonSubtitleGaps, SeriesSubtitleGaps, Subtitle, SubtitleCandidate, SubtitleGap, + SubtitleGrabInput, SubtitleProviderError, SubtitleQueue, SubtitleSearchInput, + SubtitleSearchResults, SubtitleStatus, SubtitleTranslateInput, }; pub use trailer::{Trailer, TrailerKind}; @@ -136,6 +137,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(subtitles::grab)) .routes(routes!(subtitles::translate)) .routes(routes!(subtitles::delete)) + .routes(routes!(subtitles::queue)) } /// The generated `OpenAPI` document. @@ -392,6 +394,7 @@ mod tests { "post", ), ("/api/subtitles/{subtitle_id}", "delete"), + ("/api/queues/subtitles", "get"), ] { assert!( json["paths"][path][method].is_object(), diff --git a/crates/arr-api/src/subtitles.rs b/crates/arr-api/src/subtitles.rs index 93e6e29..7c24d3e 100644 --- a/crates/arr-api/src/subtitles.rs +++ b/crates/arr-api/src/subtitles.rs @@ -173,6 +173,78 @@ pub struct EpisodeSubtitleStatus { pub missing: Vec, } +/// 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, +} + +/// 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, + pub poster_path: Option, + pub media_file_id: i64, + pub gaps: Vec, +} + +/// 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, +} + +/// 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, + pub gaps: Vec, +} + +/// 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, + pub poster_path: Option, + pub episodes: Vec, + pub seasons: Vec, +} + +/// 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, + pub series: Vec, +} + /// A provider that could not answer this search. /// /// 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) -> Result, 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, Option) { + 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, ApiError> { + let database = pool(state)?; + let mut out: Vec = 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, + movie_id: i64, + tmdb_id: i64, + title: &str, + year: Option, + poster_path: Option, + 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, ApiError> { + let database = pool(state)?; + let mut out: Vec = 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, + series_id: i64, + tmdb_id: i64, + title: &str, + year: Option, + poster_path: Option, + 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> = + 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 = gaps + .iter() + .map(|gap| { + format!( + "{}|{}|{}", + gap.language, + gap.reason, + gap.detail.as_deref().unwrap_or("") + ) + }) + .collect(); + parts.sort(); + parts.join(",") +} + #[cfg(test)] #[allow(clippy::too_many_lines)] mod tests { @@ -2315,4 +2691,207 @@ mod tests { assert_eq!(missing.len(), 1); 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 = 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:?}"); + } }