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>
227 lines
7.4 KiB
Rust
227 lines
7.4 KiB
Rust
//! TMDB client for the §6 stage 3 cast cross-check.
|
|
//!
|
|
//! §5a's Threat 2 defence rests entirely on the attacker not controlling TMDB:
|
|
//! to make a prank manifest pass, they would need those performers to be
|
|
//! credited cast on that title in TMDB, which means vandalising a separate,
|
|
//! moderated system.
|
|
//!
|
|
//! Responses are cached for 24h (§6) so a burst of episode uploads for one
|
|
//! series costs a single upstream call, and so the server stays within TMDB's
|
|
//! own rate limits.
|
|
|
|
use std::time::Duration;
|
|
|
|
use serde::Deserialize;
|
|
|
|
/// A credited cast member, reduced to what the check needs.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct CastMember {
|
|
pub id: u64,
|
|
#[serde(default)]
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub adult: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
pub struct Credits {
|
|
#[serde(default)]
|
|
pub cast: Vec<CastMember>,
|
|
/// Present on episode credits.
|
|
#[serde(default)]
|
|
pub guest_stars: Vec<CastMember>,
|
|
}
|
|
|
|
impl Credits {
|
|
/// Cast plus guest stars — the union §6 specifies for episodes.
|
|
pub fn all(&self) -> impl Iterator<Item = &CastMember> {
|
|
self.cast.iter().chain(self.guest_stars.iter())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
pub struct TitleDetails {
|
|
#[serde(default)]
|
|
pub adult: bool,
|
|
#[serde(default)]
|
|
pub title: Option<String>,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
/// A failure that should be retried rather than treated as a verdict.
|
|
///
|
|
/// §6: "TMDB unreachable / rate-limited → retry with backoff; stays unlisted,
|
|
/// not rejected." Distinguishing this from "TMDB has no credits" is essential —
|
|
/// conflating them would reject honest manifests during an outage.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum TmdbError {
|
|
#[error("tmdb transport error: {0}")]
|
|
Transport(String),
|
|
#[error("tmdb rate limited")]
|
|
RateLimited,
|
|
#[error("tmdb server error: {0}")]
|
|
ServerError(u16),
|
|
/// The id genuinely does not exist upstream.
|
|
#[error("tmdb resource not found")]
|
|
NotFound,
|
|
#[error("tmdb response was not understood: {0}")]
|
|
Malformed(String),
|
|
#[error("no tmdb api key configured")]
|
|
NotConfigured,
|
|
}
|
|
|
|
impl TmdbError {
|
|
/// True when the job should be rescheduled rather than resolved.
|
|
pub fn is_retryable(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
TmdbError::Transport(_)
|
|
| TmdbError::RateLimited
|
|
| TmdbError::ServerError(_)
|
|
| TmdbError::NotConfigured
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct TmdbClient {
|
|
http: reqwest::Client,
|
|
base_url: String,
|
|
api_key: Option<String>,
|
|
}
|
|
|
|
impl TmdbClient {
|
|
pub fn new(base_url: String, api_key: Option<String>) -> Self {
|
|
let http = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(15))
|
|
.user_agent(concat!("jray-server/", env!("CARGO_PKG_VERSION")))
|
|
.build()
|
|
.expect("building reqwest client");
|
|
Self { http, base_url, api_key }
|
|
}
|
|
|
|
pub fn is_configured(&self) -> bool {
|
|
self.api_key.is_some()
|
|
}
|
|
|
|
async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, TmdbError> {
|
|
let key = self.api_key.as_deref().ok_or(TmdbError::NotConfigured)?;
|
|
let url =
|
|
format!("{}/{}", self.base_url.trim_end_matches('/'), path.trim_start_matches('/'));
|
|
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.query(&[("api_key", key)])
|
|
.send()
|
|
.await
|
|
.map_err(|e| TmdbError::Transport(e.to_string()))?;
|
|
|
|
let status = resp.status();
|
|
if status == reqwest::StatusCode::NOT_FOUND {
|
|
return Err(TmdbError::NotFound);
|
|
}
|
|
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
|
return Err(TmdbError::RateLimited);
|
|
}
|
|
if status.is_server_error() {
|
|
return Err(TmdbError::ServerError(status.as_u16()));
|
|
}
|
|
if !status.is_success() {
|
|
return Err(TmdbError::Malformed(format!("unexpected status {status}")));
|
|
}
|
|
|
|
let body = resp.text().await.map_err(|e| TmdbError::Transport(e.to_string()))?;
|
|
serde_json::from_str(&body).map_err(|e| TmdbError::Malformed(e.to_string()))
|
|
}
|
|
|
|
pub async fn movie_credits(&self, tmdb_id: &str) -> Result<Credits, TmdbError> {
|
|
self.get(&format!("movie/{tmdb_id}/credits")).await
|
|
}
|
|
|
|
pub async fn movie_details(&self, tmdb_id: &str) -> Result<TitleDetails, TmdbError> {
|
|
self.get(&format!("movie/{tmdb_id}")).await
|
|
}
|
|
|
|
pub async fn series_credits(&self, series_tmdb_id: &str) -> Result<Credits, TmdbError> {
|
|
// Aggregate credits carry recurring cast TMDB lists only at series level.
|
|
self.get(&format!("tv/{series_tmdb_id}/aggregate_credits")).await
|
|
}
|
|
|
|
pub async fn episode_credits(
|
|
&self,
|
|
series_tmdb_id: &str,
|
|
season: i64,
|
|
episode: i64,
|
|
) -> Result<Credits, TmdbError> {
|
|
self.get(&format!("tv/{series_tmdb_id}/season/{season}/episode/{episode}/credits")).await
|
|
}
|
|
|
|
pub async fn series_details(&self, series_tmdb_id: &str) -> Result<TitleDetails, TmdbError> {
|
|
self.get(&format!("tv/{series_tmdb_id}")).await
|
|
}
|
|
}
|
|
|
|
/// Fetches a person's details, used by the §5a category guard.
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
pub struct PersonDetails {
|
|
#[serde(default)]
|
|
pub adult: bool,
|
|
#[serde(default)]
|
|
pub name: String,
|
|
}
|
|
|
|
impl TmdbClient {
|
|
pub async fn person(&self, tmdb_person_id: u64) -> Result<PersonDetails, TmdbError> {
|
|
self.get(&format!("person/{tmdb_person_id}")).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn credits_union_covers_cast_and_guest_stars() {
|
|
// §6: for episodes the check runs against the union of per-episode
|
|
// credits (cast + guest stars) and series aggregate credits.
|
|
let c: Credits = serde_json::from_str(
|
|
r#"{"cast":[{"id":1,"name":"A"}],"guest_stars":[{"id":2,"name":"B"}]}"#,
|
|
)
|
|
.unwrap();
|
|
let ids: Vec<u64> = c.all().map(|m| m.id).collect();
|
|
assert_eq!(ids, vec![1, 2]);
|
|
}
|
|
|
|
#[test]
|
|
fn credits_tolerate_missing_and_extra_fields() {
|
|
// TMDB adds fields freely; our own strictness applies to *uploads*, not
|
|
// to a trusted upstream we merely read.
|
|
let c: Credits =
|
|
serde_json::from_str(r#"{"cast":[{"id":1,"unexpected":true}],"id":99}"#).unwrap();
|
|
assert_eq!(c.cast.len(), 1);
|
|
assert_eq!(c.cast[0].name, "");
|
|
assert!(c.guest_stars.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn transport_and_rate_limit_are_retryable_but_not_found_is_not() {
|
|
// The distinction that keeps an outage from rejecting honest uploads.
|
|
assert!(TmdbError::Transport("x".into()).is_retryable());
|
|
assert!(TmdbError::RateLimited.is_retryable());
|
|
assert!(TmdbError::ServerError(503).is_retryable());
|
|
assert!(TmdbError::NotConfigured.is_retryable());
|
|
assert!(!TmdbError::NotFound.is_retryable());
|
|
assert!(!TmdbError::Malformed("x".into()).is_retryable());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unconfigured_client_reports_retryable_failure() {
|
|
let c = TmdbClient::new("http://127.0.0.1:1".into(), None);
|
|
assert!(!c.is_configured());
|
|
let err = c.movie_credits("1").await.unwrap_err();
|
|
assert!(err.is_retryable(), "missing key must hold uploads pending, not reject them");
|
|
}
|
|
}
|