Merge #233: stop offering a forced subtitle grab

Closes #233
This commit is contained in:
Miguel Palhas
2026-08-25 08:47:00 +01:00
2 changed files with 25 additions and 44 deletions
+21 -41
View File
@@ -14,8 +14,10 @@
//! handlers do the work inline and answer with the result, rather than //! handlers do the work inline and answer with the result, rather than
//! returning 202 and leaving the operator to poll. The consequence the client //! returning 202 and leaving the operator to poll. The consequence the client
//! has to know about: candidate ids are meaningful only to the provider that //! has to know about: candidate ids are meaningful only to the provider that
//! issued them, and a grab therefore repeats the facts (`forced`, `sdh`) the //! issued them, and a grab therefore repeats the fact (`sdh`) the search
//! search reported, because nothing on the server remembers them. //! reported, because nothing on the server remembers it. A forced candidate
//! is never eligible (§15, #185), so a grab has no `forced` flag to accept —
//! there is nowhere to put one (issue #233).
//! //!
//! Verdict vocabulary is §9.3's, unchanged: `eligible` or `rejected` plus the //! Verdict vocabulary is §9.3's, unchanged: `eligible` or `rejected` plus the
//! name of the rule that killed it, exactly as `Release` spells it, so the //! name of the rule that killed it, exactly as `Release` spells it, so the
@@ -282,11 +284,11 @@ pub struct SubtitleGrabInput {
pub provider: String, pub provider: String,
pub candidate_id: String, pub candidate_id: String,
pub language: String, pub language: String,
/// The candidate's own flags, as the search reported them. Nothing on /// The candidate's own SDH flag, as the search reported it. Nothing on
/// the server remembers a search, so they travel with the grab; they are /// the server remembers a search, so it travels with the grab; it is a
/// facts about the subtitle and are stored with it. /// fact about the subtitle and is stored with it. No `forced` flag
#[serde(default)] /// exists here: ranking rejects every forced candidate outright (§15),
pub forced: bool, /// so a grab never has one to record (issue #233).
#[serde(default)] #[serde(default)]
pub sdh: bool, pub sdh: bool,
} }
@@ -989,20 +991,10 @@ pub async fn grab(
&destination.to_string_lossy(), &destination.to_string_lossy(),
) )
.sync(db_sync_state(sync.state)); .sync(db_sync_state(sync.state));
if input.forced {
record = record.forced();
}
if input.sdh { if input.sdh {
record = record.sdh(); record = record.sdh();
} }
finish( finish(&state, &record, target.media_file_id, &language).await
&state,
&record,
target.media_file_id,
&language,
input.forced,
)
.await
} }
#[utoipa::path( #[utoipa::path(
@@ -1082,14 +1074,7 @@ pub async fn translate(
&destination.to_string_lossy(), &destination.to_string_lossy(),
) )
.sync(db_sync_state(sync.state)); .sync(db_sync_state(sync.state));
finish( finish(&state, &record, target.media_file_id, &target_language).await
&state,
&record,
target.media_file_id,
&target_language,
false,
)
.await
} }
#[utoipa::path( #[utoipa::path(
@@ -1259,20 +1244,17 @@ async fn write_sidecar(destination: &Path, text: &str) -> Result<(), ApiError> {
/// The `mark_satisfied` is §15's "manual actions bypass the wanted-set /// The `mark_satisfied` is §15's "manual actions bypass the wanted-set
/// logic": the loop stops working on that language whether or not it was in /// logic": the loop stops working on that language whether or not it was in
/// the wanted set, so a manually requested Spanish subtitle is never treated /// the wanted set, so a manually requested Spanish subtitle is never treated
/// as a gap and never replaced. A forced track is the exception the same /// as a gap and never replaced. Every grab reaching this point is a plain or
/// section names — it covers signs only and satisfies nothing — so it is /// SDH subtitle — a forced candidate is never eligible, so there is no
/// recorded and left out of the satisfaction claim. /// exception left to carve out of the satisfaction claim (issue #233).
async fn finish( async fn finish(
state: &AppState, state: &AppState,
record: &arr_db::NewSubtitleFile, record: &arr_db::NewSubtitleFile,
media_file_id: i64, media_file_id: i64,
language: &Language, language: &Language,
forced: bool,
) -> Result<(StatusCode, Json<Subtitle>), ApiError> { ) -> Result<(StatusCode, Json<Subtitle>), ApiError> {
let id = db::record_file(pool(state)?, record).await?; let id = db::record_file(pool(state)?, record).await?;
if !forced { db::mark_satisfied(pool(state)?, media_file_id, &language.to_string()).await?;
db::mark_satisfied(pool(state)?, media_file_id, &language.to_string()).await?;
}
let subtitle = db::files_for(pool(state)?, media_file_id) let subtitle = db::files_for(pool(state)?, media_file_id)
.await? .await?
.into_iter() .into_iter()
@@ -2197,18 +2179,16 @@ mod tests {
); );
} }
/// §15: a forced track covers signs only, so it is recorded and it still /// §233: the grab endpoint has no `forced` flag to accept — a forced
/// satisfies nothing. /// candidate is never eligible (§15), so nothing sent it one.
#[tokio::test] #[tokio::test]
async fn a_forced_grab_is_recorded_without_satisfying_the_language() { async fn a_grab_has_no_forced_flag_to_send() {
let fixture = stub_application().await; let fixture = stub_application().await;
let mut body = pt(); let body = pt();
body["candidate_id"] = serde_json::json!("forced"); assert!(body.get("forced").is_none());
body["forced"] = serde_json::json!(true);
let (status, subtitle) = grab(&fixture, body).await; let (status, subtitle) = grab(&fixture, body).await;
assert_eq!(status, StatusCode::CREATED); assert_eq!(status, StatusCode::CREATED);
assert_eq!(subtitle["forced"], true); assert_eq!(subtitle["forced"], false);
assert_eq!(fixture.attempt_state("pt-PT").await, None);
} }
/// §15 has no in-place replacement: delete first, then fetch. /// §15 has no in-place replacement: delete first, then fetch.
+4 -3
View File
@@ -205,8 +205,10 @@ export type SubtitleWriteOutcome =
/** /**
* One click: the daemon fetches the candidate, runs `alass` over it and * One click: the daemon fetches the candidate, runs `alass` over it and
* writes the sidecar (§15). The candidate's own flags travel with it — * writes the sidecar (§15). The candidate's own SDH flag travels with it —
* nothing on the server remembers a search. * nothing on the server remembers a search. No `forced` flag to send: a
* forced candidate is never eligible, so the panel never offers a grab
* button for one (§15, issue #233).
* *
* A grab can fail because the candidate itself expired between the search * A grab can fail because the candidate itself expired between the search
* and the click (issue #221) — the server reports that as `code: * and the click (issue #221) — the server reports that as `code:
@@ -224,7 +226,6 @@ export async function grabSubtitle(
provider: candidate.provider, provider: candidate.provider,
candidate_id: candidate.candidate_id, candidate_id: candidate.candidate_id,
language: candidate.language, language: candidate.language,
forced: candidate.forced,
sdh: candidate.sdh, sdh: candidate.sdh,
}), }),
}); });