Merge #199: serve the subtitle API

Closes #199
This commit is contained in:
Miguel Palhas
2026-08-25 00:41:02 +01:00
23 changed files with 2340 additions and 2 deletions
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT r.name AS \"name!: String\",\n r.parsed AS \"parsed!: serde_json::Value\"\n FROM episodes e\n JOIN grabs g ON g.target_kind = 'season' AND g.target_id = e.season_id\n JOIN releases r ON r.id = g.release_id\n WHERE e.id = ?\n ORDER BY g.imported_at DESC, g.id DESC LIMIT 1",
"describe": {
"columns": [
{
"name": "name!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "name"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "05b5757f0fb7e6ab1176387854a14fc8db793871193b6f16e3719d73040e8ce8"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM episodes WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "0b40dbc80628244531a044e872238e356ab6bc66567c72008cff506e595932b1"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(\n SELECT 1 FROM subtitle_files\n WHERE media_file_id = ? AND language = ? AND forced = 0\n ) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "233bae03912e50e6bcde4b24a9d7fbe256dbf626e4e236a06ba08a00643566d3"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT sr.tmdb_id AS \"tmdb_id!: i64\",\n s.number AS \"season!: i64\",\n e.number AS \"episode!: i64\"\n FROM episodes e\n JOIN seasons s ON s.id = e.season_id\n JOIN series sr ON sr.id = s.series_id\n WHERE e.id = ?",
"describe": {
"columns": [
{
"name": "tmdb_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "tmdb_id"
}
}
},
{
"name": "season!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "seasons",
"name": "number"
}
}
},
{
"name": "episode!: i64",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "episodes",
"name": "number"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "268cb11282b16494330c1655ba3dfcb4f2506686822711f3c6489c8ddd42b0e7"
}
@@ -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"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\",\n path AS \"path!: String\",\n size AS \"size!: i64\",\n owner_kind AS \"owner_kind!: String\",\n owner_id AS \"owner_id!: i64\"\n FROM media_files WHERE id = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "path"
}
}
},
{
"name": "size!: i64",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "size"
}
}
},
{
"name": "owner_kind!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "owner_kind"
}
}
},
{
"name": "owner_id!: i64",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "owner_id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "32443dc6e5bdd414340443c5d527b2578bcdeebd053b667db35423d829d4bf10"
}
@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT r.name AS \"name!: String\",\n r.parsed AS \"parsed!: serde_json::Value\"\n FROM grabs g JOIN releases r ON r.id = g.release_id\n WHERE g.target_kind = ? AND g.target_id = ?\n ORDER BY g.imported_at DESC, g.id DESC LIMIT 1",
"describe": {
"columns": [
{
"name": "name!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "name"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false
]
},
"hash": "432cf38775bc4d7dee1dd9d009e06638ea57f805127f7634f34b8189e831f461"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT tmdb_id AS \"tmdb_id!: i64\" FROM movies WHERE id = ?",
"describe": {
"columns": [
{
"name": "tmdb_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "movies",
"name": "tmdb_id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "4f4f037c956ac6b246503354ff9b59db59e36f9a6d59adc0753b95662c9c630a"
}
@@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT media_file_id AS \"media_file_id!: i64\",\n language AS \"language!: String\",\n path\n FROM subtitle_files WHERE id = ?",
"describe": {
"columns": [
{
"name": "media_file_id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "media_file_id"
}
}
},
{
"name": "language!: String",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "language"
}
}
},
{
"name": "path",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_files",
"name": "path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true
]
},
"hash": "9091de460ba1cb47c9faa24fe37fee03615e7b23b10ceef88ee12c8deb0dbaaf"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT providers_enabled AS \"providers_enabled!: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "providers_enabled!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "providers_enabled"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "bc6dfe5a5cc639a64e3020c348a2dc74b4298101f8c29d9f8e6397cf79bb83d7"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM movies WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "d81ec62e6c4879d08ce3551c0c71df012bed05ac2a37933adc2deb6552eeeac1"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM subtitle_files WHERE path = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "e3b82dc7b2a3b28753c84864239699cb2007a8101cccc22d223d1f587a5533f8"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT translation_engine AS \"translation_engine: String\"\n FROM subtitle_settings WHERE id = 1",
"describe": {
"columns": [
{
"name": "translation_engine: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "subtitle_settings",
"name": "translation_engine"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
true
]
},
"hash": "e7a0291c2fb8cfe59a02ffef11d9657ea6a40e4079573f569573075a205c50e7"
}
@@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM media_files WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "e8066bed6fb53ff90d78f47bb1c9ce8160918d4b35764fd988e5e1dd6c101089"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM media_files\n WHERE owner_kind = ? AND owner_id = ? ORDER BY path",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "media_files",
"name": "id"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "fcba9e1f708e6bf2d8471d0c7d26be0b5ad8a30fa042434ffaaff8c69ccf31de"
}
Generated
+1
View File
@@ -94,6 +94,7 @@ dependencies = [
"arr-meta",
"arr-parse",
"arr-probe",
"arr-subs",
"axum",
"base64",
"chrono",
+26 -1
View File
@@ -16,6 +16,7 @@ mod search;
mod series;
mod state;
mod subtitle_settings;
mod subtitles;
mod trailer;
use axum::routing::get;
@@ -45,6 +46,10 @@ pub use state::{
DEFAULT_TMDB_URL,
};
pub use subtitle_settings::{SubtitleSettings, SubtitleSettingsInput};
pub use subtitles::{
Subtitle, SubtitleCandidate, SubtitleGrabInput, SubtitleProviderError, SubtitleSearchInput,
SubtitleSearchResults, SubtitleTranslateInput,
};
pub use trailer::{Trailer, TrailerKind};
/// Where the generated document is served, and where `just gen-client` reads
@@ -71,7 +76,7 @@ pub const DOCS_PATH: &str = "/api/docs";
(name = "policies", description = "Quality policies (DESIGN.md §5)"),
(name = "search", description = "Unified title and release search"),
(name = "roots", description = "Root folders and their policies"),
(name = "subtitles", description = "Subtitle configuration (DESIGN.md §15)")
(name = "subtitles", description = "Subtitles and their configuration (DESIGN.md §15)")
),
)]
struct ApiDoc;
@@ -119,6 +124,13 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(policies::list, policies::create))
.routes(routes!(policies::get, policies::update, policies::delete))
.routes(routes!(subtitle_settings::get, subtitle_settings::update))
.routes(routes!(subtitles::list_for_media_file))
.routes(routes!(subtitles::list_for_movie))
.routes(routes!(subtitles::list_for_episode))
.routes(routes!(subtitles::search))
.routes(routes!(subtitles::grab))
.routes(routes!(subtitles::translate))
.routes(routes!(subtitles::delete))
}
/// The generated `OpenAPI` document.
@@ -362,6 +374,16 @@ mod tests {
("/api/roots", "post"),
("/api/settings/subtitles", "get"),
("/api/settings/subtitles", "put"),
("/api/media-files/{media_file_id}/subtitles", "get"),
("/api/movies/{movie_id}/subtitles", "get"),
("/api/episodes/{episode_id}/subtitles", "get"),
("/api/media-files/{media_file_id}/subtitles/search", "post"),
("/api/media-files/{media_file_id}/subtitles/grab", "post"),
(
"/api/media-files/{media_file_id}/subtitles/translate",
"post",
),
("/api/subtitles/{subtitle_id}", "delete"),
] {
assert!(
json["paths"][path][method].is_object(),
@@ -376,6 +398,9 @@ mod tests {
"AttentionQueues",
"SeriesAttention",
"Series",
"Subtitle",
"SubtitleCandidate",
"SubtitleSearchResults",
] {
assert!(
json["components"]["schemas"][schema].is_object(),
+13
View File
@@ -156,6 +156,16 @@ pub enum ApiError {
OwnerNotFound,
PolicyNotFound,
RootNotFound,
/// A `media_files` row that is not there. Named apart from
/// [`Self::NotFound`] because the subtitle surface (§15) is keyed on
/// files, not on titles, and "movie not found" would misdirect.
MediaFileNotFound,
SubtitleNotFound,
/// A subtitle provider or translation backend could not do what a manual
/// action asked (§15). Carries the message because two providers and
/// four engines are configurable at once and an unnamed failure is
/// unactionable.
SubtitleUpstream(String),
/// The §9.6 chip outcome: the title exists upstream but has no trailer.
/// Ordinary, so it must stay distinguishable from an upstream failure.
NoTrailer,
@@ -184,6 +194,9 @@ impl IntoResponse for ApiError {
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()),
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()),
Self::MediaFileNotFound => (StatusCode::NOT_FOUND, "media file not found".to_string()),
Self::SubtitleNotFound => (StatusCode::NOT_FOUND, "subtitle not found".to_string()),
Self::SubtitleUpstream(error) => (StatusCode::SERVICE_UNAVAILABLE, error),
Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string()),
Self::Conflict(error) => (StatusCode::CONFLICT, error),
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
+45
View File
@@ -5,6 +5,7 @@ use std::sync::Arc;
use std::time::Duration;
use arr_db::Db;
use arr_subs::{Backend, Provider};
use tokio::sync::mpsc;
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key
@@ -76,6 +77,8 @@ pub struct AppState {
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
metadata_commands: mpsc::Sender<MetadataCommand>,
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
subtitle_providers: Arc<Vec<Arc<dyn Provider>>>,
translation_backends: Arc<Vec<Arc<dyn Backend>>>,
}
/// Work explicitly requested through the movie API.
@@ -146,6 +149,8 @@ impl AppState {
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
metadata_commands,
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)),
subtitle_providers: Arc::new(Vec::new()),
translation_backends: Arc::new(Vec::new()),
})
}
@@ -156,6 +161,46 @@ impl AppState {
self
}
/// Attach the subtitle providers this deployment has credentials for
/// (`DESIGN.md` §15).
///
/// Which of them a search actually runs is the `providers_enabled`
/// setting, read per request; this is the narrower fact of which ones
/// exist at all, because credentials are bootstrap config and never
/// reach the database (§10).
#[must_use]
pub fn with_subtitle_providers(mut self, providers: Vec<Arc<dyn Provider>>) -> Self {
self.subtitle_providers = Arc::new(providers);
self
}
/// Attach the translation backends this binary compiled in (§15).
///
/// Empty when no `translate-*` cargo feature is on, which is the default
/// — a manual translation then fails with a message saying so rather
/// than silently doing nothing.
#[must_use]
pub fn with_translation_backends(mut self, backends: Vec<Arc<dyn Backend>>) -> Self {
self.translation_backends = Arc::new(backends);
self
}
pub(crate) fn subtitle_provider(&self, id: &str) -> Option<&Arc<dyn Provider>> {
self.subtitle_providers
.iter()
.find(|provider| provider.id().as_str() == id)
}
pub(crate) fn subtitle_providers(&self) -> &[Arc<dyn Provider>] {
&self.subtitle_providers
}
pub(crate) fn translation_backend(&self, id: &str) -> Option<&Arc<dyn Backend>> {
self.translation_backends
.iter()
.find(|backend| backend.id().as_str() == id)
}
/// Wait for the next manual movie action in the daemon's reconcile loop.
///
/// # Errors
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@ arr-indexer = { workspace = true }
arr-meta = { workspace = true }
arr-parse = { workspace = true }
arr-probe = { workspace = true }
arr-subs = { workspace = true }
axum = { workspace = true }
chrono = { workspace = true }
include_dir = { workspace = true }
+46 -1
View File
@@ -144,7 +144,13 @@ async fn run() -> Result<(), Error> {
if let Some(tmdb_url) = config.tmdb_url {
upstreams = upstreams.with_tmdb_url(tmdb_url);
}
let state = AppState::new(upstreams)?.with_database(database.clone());
let state = AppState::new(upstreams)?
.with_database(database.clone())
.with_subtitle_providers(subtitle_providers(
config.opensubtitles_api_key,
config.opensubtitles_username,
config.opensubtitles_password,
));
let app = arr_api::router(state.clone())
.merge(arr_compat::router(compat))
@@ -443,3 +449,42 @@ async fn shutdown() {
tracing::info!("shutting down");
}
/// The subtitle providers this deployment can reach (DESIGN.md §15).
///
/// Credentials are bootstrap config and never reach the database (§10), so
/// which providers *exist* is decided here, once, at startup; which of them a
/// search *runs* is the `providers_enabled` setting the API reads per
/// request. OpenSubtitles.com needs a registered API key to be called at all,
/// so without one it is not offered; Podnapisi is anonymous and always is.
///
/// Translation backends are not wired: none is implemented yet (#191-#193),
/// and `AppState` defaults to an empty registry, which makes a manual
/// translation answer "no such engine" rather than fail obscurely.
fn subtitle_providers(
opensubtitles_api_key: Option<String>,
username: Option<String>,
password: Option<String>,
) -> Vec<std::sync::Arc<dyn arr_subs::Provider>> {
let mut providers: Vec<std::sync::Arc<dyn arr_subs::Provider>> = Vec::new();
match arr_subs::Podnapisi::new() {
Ok(podnapisi) => providers.push(std::sync::Arc::new(podnapisi)),
Err(error) => tracing::warn!(%error, "Podnapisi not available"),
}
let Some(api_key) = opensubtitles_api_key else {
tracing::info!("no OpenSubtitles.com API key configured; that provider is off");
return providers;
};
match arr_subs::OpenSubtitles::new(arr_subs::OpenSubtitlesConfig {
api_key,
username,
password,
}) {
Ok(opensubtitles) => providers.push(std::sync::Arc::new(opensubtitles)),
Err(error) => tracing::warn!(%error, "OpenSubtitles.com not available"),
}
providers
}
+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);
}
}