Merge #221: give an expired candidate a typed error

Closes #221
This commit is contained in:
Miguel Palhas
2026-08-25 06:30:34 +01:00
5 changed files with 100 additions and 23 deletions
+47 -16
View File
@@ -145,6 +145,11 @@ pub struct Accepted {
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ErrorBody {
pub error: String,
/// A machine-readable discriminant, set only where a client needs to
/// branch on the failure rather than display it (issue #221). `None`
/// everywhere else — the message is for the operator, not the client.
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
#[derive(Debug)]
@@ -166,6 +171,12 @@ pub enum ApiError {
/// four engines are configurable at once and an unnamed failure is
/// unactionable.
SubtitleUpstream(String),
/// A grab named a `candidate_id` the provider no longer recognises
/// (issue #221): a search's results outlive the search itself only in
/// the client's memory, and the provider can expire one at will. Kept
/// apart from [`Self::SubtitleUpstream`] so the panel can offer "search
/// again" from a `code`, not from matching the message text.
SubtitleCandidateExpired,
/// The §9.6 chip outcome: the title exists upstream but has no trailer.
/// Ordinary, so it must stay distinguishable from an upstream failure.
NoTrailer,
@@ -186,41 +197,61 @@ pub enum ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error) = match self {
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string()),
Self::SeriesNotFound => (StatusCode::NOT_FOUND, "series not found".to_string()),
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string()),
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string()),
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),
let (status, error, code) = match self {
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string(), None),
Self::SeriesNotFound => (StatusCode::NOT_FOUND, "series not found".to_string(), None),
Self::SeasonNotFound => (StatusCode::NOT_FOUND, "season not found".to_string(), None),
Self::EpisodeNotFound => (StatusCode::NOT_FOUND, "episode not found".to_string(), None),
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string(), None),
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string(), None),
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string(), None),
Self::MediaFileNotFound => (
StatusCode::NOT_FOUND,
"media file not found".to_string(),
None,
),
Self::SubtitleNotFound => (
StatusCode::NOT_FOUND,
"subtitle not found".to_string(),
None,
),
Self::SubtitleUpstream(error) => (StatusCode::SERVICE_UNAVAILABLE, error, None),
Self::SubtitleCandidateExpired => (
StatusCode::NOT_FOUND,
"candidate no longer exists — search again".to_string(),
Some("candidate_expired".to_string()),
),
Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string(), None),
Self::Conflict(error) => (StatusCode::CONFLICT, error, None),
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error, None),
Self::Unavailable => (
StatusCode::SERVICE_UNAVAILABLE,
"database unavailable".into(),
None,
),
Self::Upstream(name) => (
StatusCode::SERVICE_UNAVAILABLE,
format!("{name} unavailable"),
None,
),
Self::Database(error) => {
tracing::error!(%error, "API database error");
(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
(
StatusCode::INTERNAL_SERVER_ERROR,
"database error".into(),
None,
)
}
Self::Filesystem(error) => {
tracing::error!(%error, "API filesystem error");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("files not removed: {error}"),
None,
)
}
};
(status, Json(ErrorBody { error })).into_response()
(status, Json(ErrorBody { error, code })).into_response()
}
}
+26 -1
View File
@@ -968,7 +968,10 @@ pub async fn grab(
let fetched = provider
.download(&CandidateId::new(input.candidate_id.clone()))
.await
.map_err(|error| ApiError::SubtitleUpstream(error.to_string()))?;
.map_err(|error| match error {
arr_subs::Error::NotFound { .. } => ApiError::SubtitleCandidateExpired,
other => ApiError::SubtitleUpstream(other.to_string()),
})?;
let text = srt_text(&fetched)?;
write_sidecar(&destination, &text).await?;
@@ -2307,6 +2310,28 @@ mod tests {
.exists());
}
/// A candidate id does not outlive the search that produced it (issue
/// #221): the panel gets a typed `code` to switch on, not a string it has
/// to pattern-match against a provider-chosen message.
#[tokio::test]
async fn a_grab_of_an_expired_candidate_is_a_typed_404() {
let fixture = application(vec![Arc::new(DeadProvider)], vec![]).await;
let (status, body) = grab(
&fixture,
serde_json::json!({
"provider": "dead",
"candidate_id": "stale",
"language": "pt-PT"
}),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
assert_eq!(body["code"], "candidate_expired");
assert!(fixture.subtitle_rows().await.is_empty());
}
#[tokio::test]
async fn a_grab_naming_an_unconfigured_provider_is_a_422() {
let fixture = stub_application().await;