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:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE subtitle_attempts\n SET state = 'wanted',\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE media_file_id = ? AND language = ? AND state = 'satisfied'",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "31bed4c9097d6ea2ed189de48440bd0d882873d27450e5d177baed65c74fd7de"
|
||||||
|
}
|
||||||
@@ -473,6 +473,39 @@ pub async fn mark_satisfied(
|
|||||||
Ok(())
|
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.
|
/// Every tracked language for one media file, ordered by language.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -804,3 +837,85 @@ mod tests {
|
|||||||
assert!(attempts_for(db.pool(), file).await.unwrap().is_empty());
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user