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,241 @@
|
||||
//! Contribution endpoints (§4) — UR-2 and UR-6.
|
||||
//!
|
||||
//! Both require a token (§5). Both return `202`: the upload has passed size and
|
||||
//! schema validation and is held unlisted pending the asynchronous TMDB cast
|
||||
//! check (§6 stage 3).
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::repo;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::ingest::{self, IngestOutcome};
|
||||
use crate::model::{IdentityType, Jmanifest, SeriesBundle};
|
||||
use crate::ratelimit::Surface;
|
||||
use crate::state::{with_quota_headers, AppState};
|
||||
use crate::validate::{self, limits};
|
||||
use crate::worker::now_iso;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UploadAccepted {
|
||||
pub manifest_id: String,
|
||||
pub status: &'static str,
|
||||
}
|
||||
|
||||
/// `POST /manifests` — UR-2.
|
||||
pub async fn post_manifest(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
super::json::Json(manifest): super::json::Json<Jmanifest>,
|
||||
) -> ApiResult<Response> {
|
||||
let contributor = state.require_contributor(&headers).await?;
|
||||
// §5: limits are per token where one is present.
|
||||
let quota = state.check_limit(&contributor.id, Surface::ManifestUpload)?;
|
||||
|
||||
// §6 stage 2. A rejection names the offending field, so a client that forgets
|
||||
// to strip `movie`/`jellyfin_id` gets a diagnosable `400`.
|
||||
let valid =
|
||||
validate::validate_manifest(manifest).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
|
||||
let origin = state.config.server_id.clone();
|
||||
let contributor_id = contributor.id.clone();
|
||||
let now = now_iso();
|
||||
|
||||
let outcome = state
|
||||
.db
|
||||
.write(move |tx| ingest::persist(tx, &valid, Some(&contributor_id), &origin, None, &now))
|
||||
.await
|
||||
.map_err(ApiError::Internal)?;
|
||||
|
||||
let resp = match outcome {
|
||||
IngestOutcome::Pending { manifest_id } => {
|
||||
(StatusCode::ACCEPTED, Json(UploadAccepted { manifest_id, status: "pending" }))
|
||||
.into_response()
|
||||
}
|
||||
// §4 `409` — an identical `(identity, cut)` manifest already exists from
|
||||
// this contributor.
|
||||
IngestOutcome::DuplicateFromContributor { manifest_id } => {
|
||||
return Err(ApiError::Conflict(format!(
|
||||
"an identical manifest already exists from this contributor: {manifest_id}"
|
||||
)))
|
||||
}
|
||||
// §9a: identical content already held, from any source. Not an error —
|
||||
// the contributor's work is simply already represented.
|
||||
IngestOutcome::DuplicateContent { manifest_id } => {
|
||||
(StatusCode::OK, Json(UploadAccepted { manifest_id, status: "already_present" }))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(with_quota_headers(resp, quota))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BundleResult {
|
||||
pub season: Option<i64>,
|
||||
pub episode: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manifest_id: Option<String>,
|
||||
pub status: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BundleAccepted {
|
||||
pub results: Vec<BundleResult>,
|
||||
}
|
||||
|
||||
/// `POST /manifests/bundle` — UR-6.
|
||||
///
|
||||
/// **Per-episode validation, not atomic**: valid episodes are accepted and
|
||||
/// invalid ones rejected, with a per-episode result list. All-or-nothing would let
|
||||
/// one bad episode discard an entire season's compute (§2).
|
||||
///
|
||||
/// **One rate-limit unit**, so contributing a season is not punished relative to
|
||||
/// contributing a film (§2, §5).
|
||||
pub async fn post_bundle(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
super::json::Json(bundle): super::json::Json<SeriesBundle>,
|
||||
) -> ApiResult<Response> {
|
||||
let contributor = state.require_contributor(&headers).await?;
|
||||
let quota = state.check_limit(&contributor.id, Surface::BundleUpload)?;
|
||||
|
||||
// §4: `413` for exceeding the episode cap, distinct from a malformed envelope.
|
||||
if bundle.episodes.len() > limits::MAX_BUNDLE_EPISODES {
|
||||
return Err(ApiError::PayloadTooLarge(format!(
|
||||
"bundle carries {} episodes, limit is {}",
|
||||
bundle.episodes.len(),
|
||||
limits::MAX_BUNDLE_EPISODES
|
||||
)));
|
||||
}
|
||||
// §4: `400` only for the envelope itself; individual bad episodes are
|
||||
// reported in the results list, not as a whole-request error.
|
||||
validate::validate_bundle_envelope(&bundle).map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
|
||||
let series_tmdb = bundle.series.series_tmdb_id.clone();
|
||||
let mut results = Vec::with_capacity(bundle.episodes.len());
|
||||
|
||||
for episode in bundle.episodes {
|
||||
let coords = (episode.identity.season, episode.identity.episode);
|
||||
|
||||
// An episode whose identity contradicts the envelope is rejected on its
|
||||
// own rather than being silently reattributed to the bundle's series.
|
||||
if episode.identity.kind != IdentityType::Episode {
|
||||
results.push(BundleResult {
|
||||
season: coords.0,
|
||||
episode: coords.1,
|
||||
manifest_id: None,
|
||||
status: "rejected",
|
||||
reason: Some("identity.type must be 'episode' within a bundle".into()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if let (Some(envelope), Some(ep)) = (&series_tmdb, &episode.identity.series_tmdb_id) {
|
||||
if envelope != ep {
|
||||
results.push(BundleResult {
|
||||
season: coords.0,
|
||||
episode: coords.1,
|
||||
manifest_id: None,
|
||||
status: "rejected",
|
||||
reason: Some("series_tmdb_id does not match the bundle envelope".into()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let valid = match validate::validate_manifest(episode) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
results.push(BundleResult {
|
||||
season: coords.0,
|
||||
episode: coords.1,
|
||||
manifest_id: None,
|
||||
status: "rejected",
|
||||
reason: Some(e.to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let origin = state.config.server_id.clone();
|
||||
let contributor_id = contributor.id.clone();
|
||||
let now = now_iso();
|
||||
// One transaction per episode, so a bundle never holds the write lock for
|
||||
// the whole request (§8 chunked ingest reasoning).
|
||||
let outcome = state
|
||||
.db
|
||||
.write(move |tx| {
|
||||
ingest::persist(tx, &valid, Some(&contributor_id), &origin, None, &now)
|
||||
})
|
||||
.await;
|
||||
|
||||
results.push(match outcome {
|
||||
Ok(IngestOutcome::Pending { manifest_id }) => BundleResult {
|
||||
season: coords.0,
|
||||
episode: coords.1,
|
||||
manifest_id: Some(manifest_id),
|
||||
status: "pending",
|
||||
reason: None,
|
||||
},
|
||||
Ok(IngestOutcome::DuplicateFromContributor { manifest_id })
|
||||
| Ok(IngestOutcome::DuplicateContent { manifest_id }) => BundleResult {
|
||||
season: coords.0,
|
||||
episode: coords.1,
|
||||
manifest_id: Some(manifest_id),
|
||||
status: "already_present",
|
||||
reason: None,
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = ?e, "bundle episode failed to persist");
|
||||
BundleResult {
|
||||
season: coords.0,
|
||||
episode: coords.1,
|
||||
manifest_id: None,
|
||||
status: "rejected",
|
||||
reason: Some("internal error".into()),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let resp = (StatusCode::ACCEPTED, Json(BundleAccepted { results })).into_response();
|
||||
Ok(with_quota_headers(resp, quota))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TokenIssued {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Issues an anonymous bearer capability (§5a).
|
||||
///
|
||||
/// Self-issued on request: no email, no verification, no personal data. Stored
|
||||
/// only as a hash, so the server cannot enumerate who holds tokens. Discarding a
|
||||
/// token and requesting another is trivially easy — and that is fine, because the
|
||||
/// token is not the defence; the content checks are.
|
||||
pub async fn post_token(
|
||||
State(state): State<AppState>,
|
||||
peer: crate::state::PeerIp,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<TokenIssued>> {
|
||||
let ip = state.client_ip(&headers, peer.0);
|
||||
// Reuse the report budget: issuing tokens is cheap but should not be a free
|
||||
// unbounded write.
|
||||
state.check_limit(&ip, Surface::Report)?;
|
||||
|
||||
let token = crate::auth::generate_token();
|
||||
let hash = crate::auth::hash_token(&token);
|
||||
let now = now_iso();
|
||||
state
|
||||
.db
|
||||
.write(move |tx| repo::insert_contributor(tx, &hash, &now))
|
||||
.await
|
||||
.map_err(ApiError::Internal)?;
|
||||
|
||||
Ok(Json(TokenIssued { token }))
|
||||
}
|
||||
Reference in New Issue
Block a user