diff --git a/crates/arr-api/src/subtitles.rs b/crates/arr-api/src/subtitles.rs index 9e97ccf..1dcd41e 100644 --- a/crates/arr-api/src/subtitles.rs +++ b/crates/arr-api/src/subtitles.rs @@ -14,8 +14,10 @@ //! handlers do the work inline and answer with the result, rather than //! 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 -//! issued them, and a grab therefore repeats the facts (`forced`, `sdh`) the -//! search reported, because nothing on the server remembers them. +//! issued them, and a grab therefore repeats the fact (`sdh`) the search +//! 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 //! 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 candidate_id: String, pub language: String, - /// The candidate's own flags, as the search reported them. Nothing on - /// the server remembers a search, so they travel with the grab; they are - /// facts about the subtitle and are stored with it. - #[serde(default)] - pub forced: bool, + /// The candidate's own SDH flag, as the search reported it. Nothing on + /// the server remembers a search, so it travels with the grab; it is a + /// fact about the subtitle and is stored with it. No `forced` flag + /// exists here: ranking rejects every forced candidate outright (§15), + /// so a grab never has one to record (issue #233). #[serde(default)] pub sdh: bool, } @@ -989,20 +991,10 @@ pub async fn grab( &destination.to_string_lossy(), ) .sync(db_sync_state(sync.state)); - if input.forced { - record = record.forced(); - } if input.sdh { record = record.sdh(); } - finish( - &state, - &record, - target.media_file_id, - &language, - input.forced, - ) - .await + finish(&state, &record, target.media_file_id, &language).await } #[utoipa::path( @@ -1082,14 +1074,7 @@ pub async fn translate( &destination.to_string_lossy(), ) .sync(db_sync_state(sync.state)); - finish( - &state, - &record, - target.media_file_id, - &target_language, - false, - ) - .await + finish(&state, &record, target.media_file_id, &target_language).await } #[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 /// 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 -/// as a gap and never replaced. A forced track is the exception the same -/// section names — it covers signs only and satisfies nothing — so it is -/// recorded and left out of the satisfaction claim. +/// as a gap and never replaced. Every grab reaching this point is a plain or +/// SDH subtitle — a forced candidate is never eligible, so there is no +/// exception left to carve out of the satisfaction claim (issue #233). async fn finish( state: &AppState, record: &arr_db::NewSubtitleFile, media_file_id: i64, language: &Language, - forced: bool, ) -> Result<(StatusCode, Json), ApiError> { 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) .await? .into_iter() @@ -2197,18 +2179,16 @@ mod tests { ); } - /// §15: a forced track covers signs only, so it is recorded and it still - /// satisfies nothing. + /// §233: the grab endpoint has no `forced` flag to accept — a forced + /// candidate is never eligible (§15), so nothing sent it one. #[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 mut body = pt(); - body["candidate_id"] = serde_json::json!("forced"); - body["forced"] = serde_json::json!(true); + let body = pt(); + assert!(body.get("forced").is_none()); let (status, subtitle) = grab(&fixture, body).await; assert_eq!(status, StatusCode::CREATED); - assert_eq!(subtitle["forced"], true); - assert_eq!(fixture.attempt_state("pt-PT").await, None); + assert_eq!(subtitle["forced"], false); } /// §15 has no in-place replacement: delete first, then fetch. diff --git a/web/src/subtitles.ts b/web/src/subtitles.ts index 3673fb6..eb357ef 100644 --- a/web/src/subtitles.ts +++ b/web/src/subtitles.ts @@ -205,8 +205,10 @@ export type SubtitleWriteOutcome = /** * One click: the daemon fetches the candidate, runs `alass` over it and - * writes the sidecar (§15). The candidate's own flags travel with it — - * nothing on the server remembers a search. + * writes the sidecar (§15). The candidate's own SDH flag travels with it — + * 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 * and the click (issue #221) — the server reports that as `code: @@ -224,7 +226,6 @@ export async function grabSubtitle( provider: candidate.provider, candidate_id: candidate.candidate_id, language: candidate.language, - forced: candidate.forced, sdh: candidate.sdh, }), });