Initial implementation: core vertical slice
Implements the core of SPEC.md — the manifest exchange, less audio-tier matching (§3) and federation (§9a), both of which the spec sequences as later work. - §2 Jmanifest format and series bundles - §3 cut matching: exact / runtime / loose tiers - §4 API, less POST /manifests/search - §5 rate limiting; §5a trust model, anonymous bearer tokens - §6 upload validation, all four stages - §7 relational storage, no JSON blob on the write path - §8 Rust + Axum + SQLite, single serialized writer, in-process job queue - §9a content addressing, computed on upload Reconciled against the system spec: - anneal_sec removed, withdrawn upstream by AR-012/AR-013. Presence follows track extent, so a track survives its own gaps and there is nothing to anneal. Its successor extinction_sec and the new gallery_scope are accepted and stored; scope enters the §7 ranking. A manifest still carrying anneal_sec is a hard 400, not silently ignored — it came from a pipeline whose window semantics differ from what this server assumes. - Audio signature: media under 120 s now emits no signature at all, matching scene-actor-extraction IR-007. The earlier §3 draft allowed a shortened window under 150 s, which was the weaker rule — a caller-varying length is the property SR-004 forbids. - UR IDs regularised to UR-nnn; docs/requirements.md registers 32 requirements, each tracing to an SR-nnn or PR-nnn. 189 tests: unit, end-to-end through the real router, and an injection suite covering SQL, JSON, header and Unicode payloads. Writing that suite found two real gaps, both fixed here: compatibility homoglyphs passed the §5a character class, and a one-frame audio signature was accepted on a feature-length item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
//! API error type mapping onto the status codes §4 specifies.
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
/// §6 stage 2 — malformed, unrecognised or forbidden field. The message
|
||||
/// names the offending field so a client that forgets to strip `movie` or
|
||||
/// `jellyfin_id` gets a hard, diagnosable `400` (§6).
|
||||
#[error("{0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
|
||||
/// §4 — identical `(identity, cut)` already exists from this contributor.
|
||||
#[error("{0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("{0}")]
|
||||
PayloadTooLarge(String),
|
||||
|
||||
#[error("missing or invalid API token")]
|
||||
Unauthorized,
|
||||
|
||||
/// §5 — carries the `Retry-After` value in seconds.
|
||||
#[error("rate limited")]
|
||||
RateLimited { retry_after: u64 },
|
||||
|
||||
#[error("internal error")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code) = match &self {
|
||||
ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
|
||||
ApiError::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||
ApiError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
ApiError::PayloadTooLarge(_) => (StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large"),
|
||||
ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
ApiError::RateLimited { .. } => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"),
|
||||
ApiError::Internal(e) => {
|
||||
// Internal detail is logged, never returned.
|
||||
tracing::error!(error = ?e, "internal error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal")
|
||||
}
|
||||
};
|
||||
|
||||
let body = Json(ErrorBody {
|
||||
error: code.to_string(),
|
||||
message: match &self {
|
||||
ApiError::Internal(_) => "internal error".to_string(),
|
||||
other => other.to_string(),
|
||||
},
|
||||
});
|
||||
|
||||
let mut resp = (status, body).into_response();
|
||||
if let ApiError::RateLimited { retry_after } = self {
|
||||
if let Ok(v) = retry_after.to_string().parse() {
|
||||
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, v);
|
||||
}
|
||||
}
|
||||
resp
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for ApiError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
ApiError::Internal(anyhow::Error::new(e))
|
||||
}
|
||||
}
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
Reference in New Issue
Block a user