Files
JRay-public-server/src/tmdb.rs
T
dtourolleandClaude Opus 5 a848750a65
CI / fmt, clippy, test (push) Failing after 2m46s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 4m22s
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>
2026-07-30 18:14:02 +02:00

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");
}
}