Removes `cut.video_hash` and the `exact` match tier on legal grounds. The OpenSubtitles hash was the strongest technical signal available — it identifies a specific file, so it cannot produce a false positive — and that is exactly the problem. Every tier must be a claim about a *cut*, never about a copy. A TMDB id discloses "some copy of this film", which is what a library catalogue discloses. A file hash discloses "this exact release": it made a read endpoint into a release-level oracle, and made an instance's database a mapping from file fingerprints to the instances holding them. That is a far more specific disclosure than PR-005 permits, and a dataset no volunteer operator should be asked to hold. The audio signature is the replacement: derived from content, it identifies the cut rather than the copy, so two encodes of the same edit agree. The field is deleted rather than kept as a vestigial null, on the same reasoning §2 applied to `anneal_sec` — a key naming a signal the format no longer has is actively misleading — so an upload carrying one is now an unknown-field 400, with a test asserting it. **Every content_id changes**, including for manifests that never carried a hash, because the canonical `cut` object lost a key. The golden vector is regenerated and re-verified against an independent Python implementation; the plugin and extraction repos must adopt the new value or federation deduplication silently breaks. Free now, pre-release; not free later. Adds docs/legal-posture.md, the operator-facing half of what §5a asks for: what an instance holds exhaustively, what it structurally cannot do, and how that sits against the intermediary-liability regimes that plausibly apply. 208 tests. Coverage 25/32. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: UR-011 | SR-004, PR-005
185 lines
6.5 KiB
Rust
185 lines
6.5 KiB
Rust
//! `GET /manifests/exists` and its batch form — UR-1.
|
|
//!
|
|
//! Deliberately a *separate, cheaper* endpoint from the fetch: it answers
|
|
//! "should I bother?" for a whole library sweep without transferring payloads,
|
|
//! and it is the endpoint a scheduled task will hammer. It is also the most
|
|
//! abuse-prone surface, since it doubles as an oracle for "does the community
|
|
//! have this title" — so it is rate-limited harder than the fetches and returns
|
|
//! no manifest content (§0).
|
|
|
|
use axum::extract::{Query, State};
|
|
use axum::http::HeaderMap;
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Json;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use super::LookupParams;
|
|
use crate::db::repo;
|
|
use crate::error::{ApiError, ApiResult};
|
|
use crate::matching::{self, StoredCut};
|
|
use crate::model::{IdentityType, MatchTier};
|
|
use crate::ratelimit::Surface;
|
|
use crate::state::{with_quota_headers, AppState};
|
|
|
|
/// §4: no manifest content, just availability and tier.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ExistsResponse {
|
|
pub exists: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub r#match: Option<&'static str>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub manifest_id: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub actor_count: Option<i64>,
|
|
}
|
|
|
|
impl ExistsResponse {
|
|
fn absent() -> Self {
|
|
Self { exists: false, r#match: None, manifest_id: None, actor_count: None }
|
|
}
|
|
}
|
|
|
|
/// §4 batch form: up to 100 items.
|
|
///
|
|
/// Exists specifically so the §5 rate limit can be generous per *request* while
|
|
/// staying strict per *item*, and so a 2000-item library sweep is 20 requests
|
|
/// rather than 2000.
|
|
pub const MAX_BATCH_ITEMS: usize = 100;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct BatchRequest {
|
|
pub items: Vec<LookupParams>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct BatchResponse {
|
|
/// Positional, matching the request order (§4).
|
|
pub results: Vec<ExistsResponse>,
|
|
}
|
|
|
|
/// TRACES: UR-001 | SR-001
|
|
pub async fn exists(
|
|
State(state): State<AppState>,
|
|
peer: crate::state::PeerIp,
|
|
headers: HeaderMap,
|
|
Query(params): Query<LookupParams>,
|
|
) -> ApiResult<Response> {
|
|
let ip = state.client_ip(&headers, peer.0);
|
|
let quota = state.check_limit(&ip, Surface::ExistsSingle)?;
|
|
let body = lookup_one(&state, ¶ms).await?;
|
|
Ok(with_quota_headers(Json(body).into_response(), quota))
|
|
}
|
|
|
|
/// TRACES: UR-001, UR-007 | SR-001 | PR-005
|
|
pub async fn exists_batch(
|
|
State(state): State<AppState>,
|
|
peer: crate::state::PeerIp,
|
|
headers: HeaderMap,
|
|
super::json::Json(req): super::json::Json<BatchRequest>,
|
|
) -> ApiResult<Response> {
|
|
if req.items.len() > MAX_BATCH_ITEMS {
|
|
return Err(ApiError::BadRequest(format!(
|
|
"items: at most {MAX_BATCH_ITEMS} per request, got {}",
|
|
req.items.len()
|
|
)));
|
|
}
|
|
if req.items.is_empty() {
|
|
return Err(ApiError::BadRequest("items: must not be empty".into()));
|
|
}
|
|
|
|
let ip = state.client_ip(&headers, peer.0);
|
|
let quota = state.check_limit(&ip, Surface::ExistsBatch)?;
|
|
|
|
let mut results = Vec::with_capacity(req.items.len());
|
|
for item in &req.items {
|
|
// A malformed item yields "absent" rather than failing the whole batch —
|
|
// a sweep of 100 items should not be lost to one bad entry.
|
|
results.push(lookup_one(&state, item).await.unwrap_or_else(|_| ExistsResponse::absent()));
|
|
}
|
|
|
|
Ok(with_quota_headers(Json(BatchResponse { results }).into_response(), quota))
|
|
}
|
|
|
|
async fn lookup_one(state: &AppState, params: &LookupParams) -> ApiResult<ExistsResponse> {
|
|
let Some((kind, tmdb_id, imdb_id)) = resolve_kind(params) else {
|
|
return Err(ApiError::BadRequest(
|
|
"requires tmdb_id/imdb_id, or series_tmdb_id with season and episode".into(),
|
|
));
|
|
};
|
|
|
|
let (season, episode) = match kind {
|
|
IdentityType::Movie => (None, None),
|
|
IdentityType::Episode => (params.season, params.episode),
|
|
};
|
|
let client_cut = params.client_cut();
|
|
|
|
let found = state
|
|
.db
|
|
.read(move |conn| {
|
|
let Some(title) = repo::find_title(conn, kind, tmdb_id.as_deref(), imdb_id.as_deref())?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let candidates = repo::candidates_for_title(conn, &title.id, season, episode)?;
|
|
if candidates.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let cuts: Vec<(String, StoredCut)> = candidates
|
|
.iter()
|
|
.map(|m| (m.id.clone(), StoredCut { runtime_sec: m.runtime_sec }))
|
|
.collect();
|
|
|
|
let Some((id, m)) = matching::best_match(&client_cut, &cuts) else {
|
|
return Ok(None);
|
|
};
|
|
let actor_count = repo::manifest_actor_ids(conn, &id)?.len() as i64;
|
|
Ok(Some((id, m.tier, actor_count)))
|
|
})
|
|
.await
|
|
.map_err(ApiError::Internal)?;
|
|
|
|
// §4: `exists: false` is returned with `200`, not `404` — absence is a normal
|
|
// answer to this question, and `404` would conflate "no manifest" with "bad
|
|
// route" for the client.
|
|
Ok(match found {
|
|
Some((id, tier, actor_count)) => ExistsResponse {
|
|
exists: true,
|
|
// With no cut parameters the answer is "some manifest exists" with
|
|
// `"match": "unknown"`; the client must still fetch to find out
|
|
// whether a cut aligns. This is the mode a library sweep uses (§4).
|
|
r#match: Some(tier.as_str()),
|
|
manifest_id: Some(id),
|
|
actor_count: Some(actor_count),
|
|
},
|
|
None => ExistsResponse::absent(),
|
|
})
|
|
}
|
|
|
|
/// Determines whether these parameters address a movie or an episode.
|
|
pub fn resolve_kind(
|
|
params: &LookupParams,
|
|
) -> Option<(IdentityType, Option<String>, Option<String>)> {
|
|
if params.series_tmdb_id.is_some() || params.series_imdb_id.is_some() {
|
|
// Episode coordinates are required alongside series identity; without
|
|
// them the caller wants the series bundle endpoint instead.
|
|
params.season?;
|
|
params.episode?;
|
|
return Some((
|
|
IdentityType::Episode,
|
|
params.series_tmdb_id.clone(),
|
|
params.series_imdb_id.clone(),
|
|
));
|
|
}
|
|
if params.tmdb_id.is_some() || params.imdb_id.is_some() {
|
|
return Some((IdentityType::Movie, params.tmdb_id.clone(), params.imdb_id.clone()));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Exposed for tests asserting the documented tier string.
|
|
pub fn tier_str(t: MatchTier) -> &'static str {
|
|
t.as_str()
|
|
}
|