fix(api): drop subtitle queue gaps for unwanted languages

An attempt row survives after a language leaves wanted_languages —
missing_for (#201) already bounds by the current wanted set, so the
queue reads the same way instead of showing a stale gap forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-25 03:16:54 +01:00
parent a780b49ab3
commit b3cca4f692
+43 -4
View File
@@ -1272,9 +1272,13 @@ async fn refresh_jellyfin(state: &AppState) {
) )
)] )]
pub async fn queue(State(state): State<AppState>) -> Result<Json<SubtitleQueue>, ApiError> { pub async fn queue(State(state): State<AppState>) -> Result<Json<SubtitleQueue>, ApiError> {
// A language dropped from the wanted set (§15) is not a gap any more —
// its attempt row just has not been cleaned up yet. `missing_for` (#201)
// already applies this bound; the queue reads the same way.
let wanted: BTreeSet<String> = wanted_languages(&state).await?.into_iter().collect();
Ok(Json(SubtitleQueue { Ok(Json(SubtitleQueue {
movies: movie_gaps(&state).await?, movies: movie_gaps(&state, &wanted).await?,
series: series_gaps(&state).await?, series: series_gaps(&state, &wanted).await?,
})) }))
} }
@@ -1295,7 +1299,10 @@ fn attempt_reason(
} }
} }
async fn movie_gaps(state: &AppState) -> Result<Vec<MovieSubtitleGaps>, ApiError> { async fn movie_gaps(
state: &AppState,
wanted: &BTreeSet<String>,
) -> Result<Vec<MovieSubtitleGaps>, ApiError> {
let database = pool(state)?; let database = pool(state)?;
let mut out: Vec<MovieSubtitleGaps> = Vec::new(); let mut out: Vec<MovieSubtitleGaps> = Vec::new();
@@ -1313,6 +1320,9 @@ async fn movie_gaps(state: &AppState) -> Result<Vec<MovieSubtitleGaps>, ApiError
.fetch_all(database) .fetch_all(database)
.await?; .await?;
for row in attempts { for row in attempts {
if !wanted.contains(&row.language) {
continue;
}
let (reason, detail) = attempt_reason(row.state, row.last_failure); let (reason, detail) = attempt_reason(row.state, row.last_failure);
push_movie_gap( push_movie_gap(
&mut out, &mut out,
@@ -1388,7 +1398,10 @@ fn push_movie_gap(
}); });
} }
async fn series_gaps(state: &AppState) -> Result<Vec<SeriesSubtitleGaps>, ApiError> { async fn series_gaps(
state: &AppState,
wanted: &BTreeSet<String>,
) -> Result<Vec<SeriesSubtitleGaps>, ApiError> {
let database = pool(state)?; let database = pool(state)?;
let mut out: Vec<SeriesSubtitleGaps> = Vec::new(); let mut out: Vec<SeriesSubtitleGaps> = Vec::new();
@@ -1410,6 +1423,9 @@ async fn series_gaps(state: &AppState) -> Result<Vec<SeriesSubtitleGaps>, ApiErr
.fetch_all(database) .fetch_all(database)
.await?; .await?;
for row in attempts { for row in attempts {
if !wanted.contains(&row.language) {
continue;
}
let (reason, detail) = attempt_reason(row.state, row.last_failure); let (reason, detail) = attempt_reason(row.state, row.last_failure);
push_episode_gap( push_episode_gap(
&mut out, &mut out,
@@ -2710,6 +2726,29 @@ mod tests {
assert!(body["series"].as_array().expect("series").is_empty()); assert!(body["series"].as_array().expect("series").is_empty());
} }
/// #202: an attempt row for a language no longer in the wanted set is
/// not a gap any more — `missing_for` (#201) already draws this line.
#[tokio::test]
async fn subtitle_queue_drops_a_language_no_longer_wanted() {
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");
sqlx::query("UPDATE subtitle_settings SET wanted_languages = '[\"pt-PT\"]' WHERE id = 1")
.execute(&fixture.pool)
.await
.expect("narrow wanted set");
let body = queue(&fixture.base).await;
assert!(body["movies"].as_array().expect("movies").is_empty());
}
/// #202: a movie's failed attempt surfaces with its language and detail. /// #202: a movie's failed attempt surfaces with its language and detail.
#[tokio::test] #[tokio::test]
async fn subtitle_queue_lists_a_movie_gap() { async fn subtitle_queue_lists_a_movie_gap() {