From fb40b35156f433a0095b2cffb08ec0d2e26574c5 Mon Sep 17 00:00:00 2001
From: Miguel Palhas
Date: Tue, 25 Aug 2026 03:03:17 +0100
Subject: [PATCH 1/3] 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:?}");
+ }
}
From a780b49ab34db46a4de407ae48cffd149b7e7097 Mon Sep 17 00:00:00 2001
From: Miguel Palhas
Date: Tue, 25 Aug 2026 03:11:34 +0100
Subject: [PATCH 2/3] feat(web): missing-subtitles queue alongside no-pt-source
A third lane in the attention queues view: one row per movie or
series with a gap chip per language and why (#186's reasons, plus a
sync alass flagged). Reuses the existing deck-group markup and
missingChipLabel wording so it reads like its two siblings; each
row opens the title's own page, where the #203 fetch/translate
panel already carries the resolving actions.
Co-Authored-By: Claude Sonnet 5
---
web/index.html | 13 ++++
web/src/main.ts | 140 ++++++++++++++++++++++++++++++++++++++-----
web/src/queues.ts | 78 ++++++++++++++++++++++++
web/src/subtitles.ts | 3 +
4 files changed, 220 insertions(+), 14 deletions(-)
diff --git a/web/index.html b/web/index.html
index dd45e56..dda56e6 100644
--- a/web/index.html
+++ b/web/index.html
@@ -199,6 +199,19 @@
+
+
+
+
+ a wanted language with no subtitle, and why — no candidate, a translation or provider
+ failure, the daily budget, or a sync `alass` flagged. open the title to search,
+ translate or retry by hand.
+
+
+