use jacquard::IntoStatic; use jacquard::deps::smol_str::SmolStr; use jacquard::types::{ aturi::AtUri, cid::Cid, did::Did, handle::Handle, nsid::Nsid, string::Datetime, string::Rkey, }; use jacquard::{BosStr, Data, DefaultStr}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, IntoStatic)] #[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct EnrichedVideo { pub at_uri: AtUri, pub cid: Option>, pub title: S, pub duration_ns: u64, pub created_at: Datetime, pub creator: ProfileInfo, pub thumbnail_url: Option, pub livestream_uri: Option>, pub speakers: Vec>, pub talk: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, IntoStatic)] #[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct ProfileInfo { pub did: Did, pub handle: Handle, pub display_name: Option, pub avatar_url: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, IntoStatic)] #[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct VideoMetadata { pub at_uri: AtUri, pub title: S, pub duration_ns: u64, pub created_at: Datetime, pub creator: ProfileInfo, pub thumbnail_url: Option, pub livestream_uri: Option>, pub speakers: Vec>, pub talk: Option>, pub talk_uri: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, IntoStatic)] #[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct SpeakerInfo { pub name: S, pub did: Option>, pub handle: Option>, pub avatar_url: Option, } /// A single ionosphere comment, ready for client-side consumption. /// /// Returned by `get_comments` as a flat list. The client builds the reply /// tree and maps byte ranges to word indices using `build_comment_tree`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct CommentData { pub uri: AtUri, pub text: SmolStr, pub author_did: Did, pub author_handle: Handle, pub created_at: Datetime, /// Byte range anchoring this comment to a substring of the transcript text. /// `None` means the comment is not anchored to any specific transcript position. pub anchor: Option, /// The AT URI this comment is directed at (talk, transcript, or parent comment). pub subject_uri: AtUri, pub facets: Option>, } /// A validated byte range from a `tv.ionosphere.comment` anchor field. /// /// Negative or inverted i64 values from the source lexicon are rejected during /// conversion and the containing comment is treated as non-anchored. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct ByteRangeData { pub byte_start: usize, pub byte_end: usize, } const _: () = { fn _assert_server_fn_compatible() where Vec: serde::Serialize + serde::de::DeserializeOwned, VideoMetadata: serde::Serialize + serde::de::DeserializeOwned, Vec: serde::Serialize + serde::de::DeserializeOwned, { } }; #[allow(dead_code)] pub const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; #[allow(dead_code)] #[derive(Serialize, Deserialize, Debug, Clone, jacquard_derive::XrpcRequest)] #[xrpc( nsid = "blue.microcosm.links.getBacklinks", method = Query, output = GetBacklinksResponse, )] #[serde( rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr") )] pub struct GetBacklinksQuery { pub subject: S, pub source: S, #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub did: Vec>, /// Allow returning links in reverse order (default: false) #[serde(default)] pub reverse: bool, } #[allow(dead_code)] #[derive(Serialize, Deserialize, Debug, Clone, IntoStatic)] #[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct GetBacklinksResponse { pub total: u32, pub records: Vec>, pub cursor: Option, } #[allow(dead_code)] #[derive(Serialize, Deserialize, Debug, Clone, IntoStatic)] #[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct BacklinkRecord { pub did: Did, pub collection: Nsid, pub rkey: Rkey, } // --- Slingshot hydration proxy types --- #[allow(dead_code)] pub const SLINGSHOT_URL: &str = "https://slingshot.microcosm.blue"; /// User-Agent header value for outbound constellation/slingshot requests. #[cfg(feature = "server")] const USER_AGENT: &str = "vodplace/0.1.0"; /// User-Agent header components for xrpc requests. #[cfg(feature = "server")] const fn user_agent_header() -> (axum::http::HeaderName, axum::http::HeaderValue) { ( axum::http::HeaderName::from_static("user-agent"), axum::http::HeaderValue::from_static(USER_AGENT), ) } /// Request body for slingshot's hydrateQueryResponse endpoint. #[derive(Serialize, Deserialize, Debug, Clone, jacquard_derive::XrpcRequest)] #[xrpc( nsid = "com.bad-example.proxy.hydrateQuery", method = Procedure, output = HydrateQueryResponse, )] #[serde( rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr") )] pub struct HydrateQueryRequest { pub xrpc: S, pub atproto_proxy: S, #[serde(skip_serializing_if = "Option::is_none")] pub authorization: Option, #[serde(skip_serializing_if = "Option::is_none")] pub atproto_accept_labelers: Option, pub params: Data, pub hydration_sources: Vec>, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct HydrationSource { pub path: S, pub shape: Shape, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "kebab-case")] pub enum Shape { StrongRef, AtUri, AtUriParts, Did, Handle, AtIdentifier, } /// Response from slingshot's hydrateQueryResponse endpoint. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct HydrateQueryResponse { pub output: Data, #[serde(default)] pub records: BTreeMap>, #[serde(default)] pub identifiers: BTreeMap>, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "status", bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub enum HydratedRecord { #[serde(rename = "found")] Found { uri: S, #[serde(skip_serializing_if = "Option::is_none")] cid: Option>, value: Data, }, #[serde(rename = "pending")] Pending { reason: S, #[serde(rename = "followUp")] follow_up: S, }, #[serde(rename = "error")] Error { reason: S, #[serde(rename = "shouldRetry")] should_retry: bool, #[serde(rename = "followUp")] follow_up: S, }, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "status", bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub enum HydratedIdentifier { #[serde(rename = "found")] Found { did: Did, handle: Handle, pds: S, signing_key: S, }, #[serde(rename = "pending")] Pending { reason: S, #[serde(rename = "followUp")] follow_up: S, }, #[serde(rename = "error")] Error { reason: S, #[serde(rename = "shouldRetry")] should_retry: bool, #[serde(rename = "followUp")] follow_up: S, }, } #[cfg(any(test, feature = "server"))] pub fn thumbnail_cdn_url(creator_did: &str, blob_cid: &str) -> jacquard::deps::smol_str::SmolStr { jacquard::deps::smol_str::format_smolstr!( "https://cdn.bsky.app/img/feed_thumbnail/plain/{}/{}@jpeg", creator_did, blob_cid ) } // --- Server-only types and implementation --- #[cfg(feature = "server")] use { crate::lexicons::Video, crate::player::uri::playlist_url_for_at_uri, futures::future::join_all, jacquard::client::{AgentSessionExt, BasicClient}, jacquard::deps::smol_str::{ToSmolStr, format_smolstr}, jacquard::prelude::XrpcClient, jacquard::types::collection::Collection, jacquard::types::value::from_data, jacquard::xrpc::XrpcExt, jacquard_common::deps::fluent_uri::Uri, jacquard_common::types::ident::AtIdentifier, mini_moka_wasm::sync::Cache as MokaCache, std::sync::Arc, std::sync::OnceLock, std::time::Duration, tracing::warn, vodplace_api::app_bsky::actor::get_profile::GetProfile, vodplace_api::com_atproto::repo::list_records::ListRecords, vodplace_api::com_atproto::sync::get_repo::GetRepo, vodplace_api::com_atproto::sync::list_repos_by_collection::ListReposByCollection, vodplace_api::place_stream::livestream::Livestream, }; use std::collections::BTreeMap; use std::collections::HashMap; #[cfg(feature = "server")] #[derive(Debug, thiserror::Error)] enum ThumbnailError { #[error("ffmpeg: {0}")] Ffmpeg(#[from] ffmpeg_next::Error), #[error("no video stream found")] NoVideoStream, #[error("no frames decoded")] NoFrames, #[error("image encoding failed: {0}")] Image(#[from] image::ImageError), #[error("pixel data invalid")] InvalidPixelData, } #[cfg(feature = "server")] static CATALOG_CACHE: OnceLock>> = OnceLock::new(); #[cfg(feature = "server")] static THUMBNAIL_CACHE: OnceLock> = OnceLock::new(); #[cfg(feature = "server")] static IONOSPHERE_CACHE: OnceLock> = OnceLock::new(); #[cfg(feature = "server")] static CHAT_COLOUR_CACHE: OnceLock>> = OnceLock::new(); #[cfg(feature = "server")] static CLIENT: OnceLock> = OnceLock::new(); #[cfg(feature = "server")] static FFMPEG_INIT: std::sync::Once = std::sync::Once::new(); #[cfg(feature = "server")] const CACHE_TTL: Duration = Duration::from_secs(3600); #[cfg(feature = "server")] const THUMBNAIL_CACHE_MAX: u64 = 200; #[cfg(feature = "server")] const IONOSPHERE_CACHE_MAX: u64 = 500; #[cfg(feature = "server")] const CHAT_COLOUR_CACHE_MAX: u64 = 1000; #[cfg(feature = "server")] fn shared_client() -> Arc { CLIENT .get_or_init(|| Arc::new(BasicClient::unauthenticated())) .clone() } #[cfg(feature = "server")] fn init_ffmpeg() { FFMPEG_INIT.call_once(|| { ffmpeg_next::init().expect("ffmpeg initialization failed"); }); } #[cfg(feature = "server")] const RELAY_URL: &str = "https://lightrail.microcosm.blue"; /// Intermediate discovery result before enrichment. #[cfg(feature = "server")] struct DiscoveredVideo { at_uri: AtUri, cid: Option, video: Video, creator_did: Did, } #[cfg(feature = "server")] fn is_501(e: &jacquard_common::error::ClientError) -> bool { matches!( e.kind(), jacquard_common::error::ClientErrorKind::Http { status } if *status == axum::http::StatusCode::NOT_IMPLEMENTED ) } /// Resolve the PDS endpoint URL for a DID. #[cfg(feature = "server")] async fn resolve_pds(client: &Arc, did: &Did) -> Option> { use jacquard::prelude::IdentityResolver; match client.pds_for_did(did).await { Ok(uri) => Some(uri), Err(e) => { warn!(did = %did.as_str(), "failed to resolve PDS: {e}"); None } } } /// Discover video records from a DID via `sync.getRepo` CAR download. /// /// Fallback for PDSes that don't implement `listRecords` (HTTP 501). /// Downloads the full repo as a CAR file, parses the MST, and extracts /// records under the `place.stream.video` collection. #[cfg(feature = "server")] async fn discover_from_repo( client: &Arc, did: &Did, pds_uri: &Uri, ) -> Vec<(AtUri, DiscoveredVideo)> { use jacquard_repo::{BlockStore, MemoryBlockStore, Repository, car::parse_car_bytes}; let pds_ref = Uri::parse(pds_uri.as_str()).expect("already validated PDS URI"); let request = GetRepo::new().did(did.clone()).build(); let resp = match client.xrpc(pds_ref).send(&request).await { Ok(r) => r, Err(e) => { warn!(did = %did.as_str(), "sync.getRepo failed: {e}"); return vec![]; } }; let output = match resp.into_output() { Ok(o) => o, Err(e) => { warn!(did = %did.as_str(), "failed to parse getRepo response: {e}"); return vec![]; } }; let parsed = match parse_car_bytes(&output.body).await { Ok(p) => p, Err(e) => { warn!(did = %did.as_str(), "failed to parse CAR: {e}"); return vec![]; } }; let storage = Arc::new(MemoryBlockStore::new()); if let Err(e) = storage.put_many(parsed.blocks).await { warn!(did = %did.as_str(), "failed to store CAR blocks: {e}"); return vec![]; } let repo = match Repository::::from_commit(storage, &parsed.root).await { Ok(r) => r, Err(e) => { warn!(did = %did.as_str(), "failed to load repo from CAR: {e}"); return vec![]; } }; let nsid: Nsid =