feat(arr): reopen a language on subtitle delete

DESIGN.md §15 reads satisfaction off the files, so a `satisfied` attempt
row whose sidecar was just deleted by hand is a stale claim that hides
the gap from the reconcile loop's work list. `unsatisfy` withdraws only
that claim: the attempt count and timestamp stay, because the backoff is
a fact about what providers were already asked and a delete does not
un-ask them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-24 23:26:55 +01:00
parent b9f4ee98d5
commit 0c9b9fb796
2 changed files with 127 additions and 0 deletions
+115
View File
@@ -473,6 +473,39 @@ pub async fn mark_satisfied(
Ok(())
}
/// Put a tracked language back to `wanted`, after the last subtitle in it
/// was deleted.
///
/// Satisfaction is read off the files (§15), so a `satisfied` row whose file
/// has just been removed by hand is a stale claim that would hide the gap
/// from [`pending`]. The attempt count and timestamp are left alone: the
/// backoff is a fact about what providers have already been asked, and a
/// delete does not un-ask them.
///
/// A no-op when the language was never tracked — a manual grab in a language
/// outside the wanted set has nothing to put back.
///
/// # Errors
///
/// If the update fails.
pub async fn unsatisfy(
pool: &SqlitePool,
media_file_id: i64,
language: &str,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"UPDATE subtitle_attempts
SET state = 'wanted',
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE media_file_id = ? AND language = ? AND state = 'satisfied'",
media_file_id,
language
)
.execute(pool)
.await?;
Ok(())
}
/// Every tracked language for one media file, ordered by language.
///
/// # Errors
@@ -804,3 +837,85 @@ mod tests {
assert!(attempts_for(db.pool(), file).await.unwrap().is_empty());
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod unsatisfy_tests {
use super::{attempts_for, mark_satisfied, pending, record_attempt, unsatisfy, SubtitleState};
use crate::Db;
async fn file() -> (Db, tempfile::TempDir, i64) {
let dir = tempfile::tempdir().unwrap();
let database = Db::connect(dir.path().join("test.db")).await.unwrap();
database.migrate().await.unwrap();
let id: i64 = sqlx::query_scalar(
"INSERT INTO media_files (owner_kind, owner_id, path, size)
VALUES ('movie', 1, '/m/a.mkv', 1) RETURNING id",
)
.fetch_one(database.pool())
.await
.unwrap();
(database, dir, id)
}
#[tokio::test]
async fn a_deleted_subtitle_puts_its_language_back_in_the_work_list() {
let (db, _dir, file) = file().await;
mark_satisfied(db.pool(), file, "pt-PT").await.unwrap();
assert!(pending(db.pool(), 10).await.unwrap().is_empty());
unsatisfy(db.pool(), file, "pt-PT").await.unwrap();
let pending = pending(db.pool(), 10).await.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].attempt.state, SubtitleState::Wanted);
}
/// The backoff is a fact about what providers were already asked, and a
/// delete does not un-ask them.
#[tokio::test]
async fn reopening_a_language_keeps_the_attempt_count() {
let (db, _dir, file) = file().await;
record_attempt(db.pool(), file, "pt-PT", SubtitleState::Failed, Some("429"))
.await
.unwrap();
mark_satisfied(db.pool(), file, "pt-PT").await.unwrap();
unsatisfy(db.pool(), file, "pt-PT").await.unwrap();
let attempts = attempts_for(db.pool(), file).await.unwrap();
assert_eq!(attempts[0].state, SubtitleState::Wanted);
assert_eq!(attempts[0].attempts, 1);
assert!(attempts[0].last_attempt_at.is_some());
}
/// A language outside the wanted set was never tracked; there is nothing
/// to put back and inventing a row would invent a gap.
#[tokio::test]
async fn an_untracked_language_is_left_alone() {
let (db, _dir, file) = file().await;
unsatisfy(db.pool(), file, "es").await.unwrap();
assert!(attempts_for(db.pool(), file).await.unwrap().is_empty());
}
/// Only a satisfied claim is withdrawn: a language the loop has already
/// given up on (`unavailable`) must not silently become work again.
#[tokio::test]
async fn a_language_in_another_state_is_not_reopened() {
let (db, _dir, file) = file().await;
record_attempt(
db.pool(),
file,
"pt-PT",
SubtitleState::Unavailable,
Some("nothing anywhere"),
)
.await
.unwrap();
unsatisfy(db.pool(), file, "pt-PT").await.unwrap();
let attempts = attempts_for(db.pool(), file).await.unwrap();
assert_eq!(attempts[0].state, SubtitleState::Unavailable);
}
}