From 632c7053a03c25b6a4af82aa12356f6a70485e97 Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 12 Apr 2026 14:45:09 -0400 Subject: [PATCH] feat: add FacetData types, ChatContext, and resolve_chat_context server function --- src/catalog.rs | 61 +++++++++++++++++++++ src/chat.rs | 140 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) diff --git a/src/catalog.rs b/src/catalog.rs index 4c3c8c2..a038b0e 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -39,6 +39,7 @@ pub struct VideoMetadata { 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>, @@ -910,6 +911,8 @@ pub(crate) async fn fetch_video_metadata(at_uri: &AtUri) -> Result Result, + talk_ends_at: Option, +) -> Result { + use crate::chat::{self, ChatContext}; + + let client = shared_client(); + let authority: AtIdentifier = livestream_uri.authority().into_static(); + let streamer_did: Did = match authority { + AtIdentifier::Did(did) => did, + AtIdentifier::Handle(_) => { + return Err(ServerFnError::new( + "livestream URI authority is not a DID".to_string(), + )) + } + }; + + // Fetch livestream for its timing data + let livestream_resp = client + .get_record::(&livestream_uri) + .await + .map_err(|e| ServerFnError::new(format!("failed to fetch livestream: {e}")))?; + let livestream = livestream_resp + .into_output() + .map_err(|e| ServerFnError::new(format!("failed to parse livestream: {e}")))? + .value; + + let anchor = chat::resolve_anchor_time_from_fields( + talk_starts_at.as_ref(), + Some(&livestream.created_at), + &video_created_at, + ); + let end_ms = chat::resolve_end_time_from_fields( + talk_ends_at.as_ref(), + livestream.ended_at.as_ref(), + &anchor, + video_duration_ns, + ); + + const FUZZ_MINUTES: u32 = 5; + let (window_start_ms, window_end_ms) = + chat::compute_time_window(&anchor, end_ms, FUZZ_MINUTES); + + Ok(ChatContext { + streamer_did, + anchor, + window_start_ms, + window_end_ms, + }) +} + #[cfg(feature = "server")] pub async fn thumbnail_handler( axum::extract::Query(params): axum::extract::Query>, diff --git a/src/chat.rs b/src/chat.rs index 1cb5a45..5032ac6 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1,7 +1,107 @@ +use jacquard::deps::smol_str::SmolStr; +use jacquard::types::did::Did; use jacquard::types::string::Datetime; use serde::{Deserialize, Serialize}; use vodplace_api::tv_ionosphere::talk::Talk; +/// Unified facet data for both chat messages and ionosphere comments. +/// Converts from both `place.stream.richtext.facet::Facet` and `app.bsky.richtext.facet::Facet`. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FacetData { + pub byte_start: usize, + pub byte_end: usize, + pub features: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum FacetFeatureData { + Mention { did: Did }, + Link { uri: SmolStr }, + Tag { tag: SmolStr }, +} + +/// Convert place.stream richtext facets to unified FacetData. +/// Validates byte ranges (rejects negative, clamps overflow). +pub fn convert_facets(facets: &[vodplace_api::place_stream::richtext::facet::Facet]) -> Vec { + facets + .iter() + .filter_map(|f| { + let byte_start = usize::try_from(f.index.byte_start).ok()?; + let byte_end = usize::try_from(f.index.byte_end).ok()?; + if byte_start > byte_end { + return None; + } + + let features = f + .features + .iter() + .filter_map(|feat| { + use vodplace_api::place_stream::richtext::facet::FacetFeaturesItem; + match feat { + FacetFeaturesItem::FacetMention(m) => { + Some(FacetFeatureData::Mention { did: m.did.clone() }) + } + FacetFeaturesItem::FacetLink(l) => Some(FacetFeatureData::Link { + uri: SmolStr::new(l.uri.as_ref()), + }), + _ => None, // open union unknown variants + } + }) + .collect(); + + Some(FacetData { byte_start, byte_end, features }) + }) + .collect() +} + +/// Convert app.bsky richtext facets to unified FacetData. +/// Includes the Tag variant in addition to Mention and Link. +pub fn convert_bsky_facets( + facets: &[vodplace_api::app_bsky::richtext::facet::Facet], +) -> Vec { + facets + .iter() + .filter_map(|f| { + let byte_start = usize::try_from(f.index.byte_start).ok()?; + let byte_end = usize::try_from(f.index.byte_end).ok()?; + if byte_start > byte_end { + return None; + } + + let features = f + .features + .iter() + .filter_map(|feat| { + use vodplace_api::app_bsky::richtext::facet::FacetFeaturesItem; + match feat { + FacetFeaturesItem::Mention(m) => { + Some(FacetFeatureData::Mention { did: m.did.clone() }) + } + FacetFeaturesItem::Link(l) => Some(FacetFeatureData::Link { + uri: SmolStr::new(l.uri.as_ref()), + }), + FacetFeaturesItem::Tag(t) => Some(FacetFeatureData::Tag { + tag: SmolStr::new(<_ as AsRef>::as_ref(&t.tag)), + }), + _ => None, // open union unknown variants + } + }) + .collect(); + + Some(FacetData { byte_start, byte_end, features }) + }) + .collect() +} + +/// The resolved chat context for a VOD, used to fetch and display chat replay. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ChatContext { + pub streamer_did: Did, + pub anchor: StreamAnchorTime, + pub window_start_ms: i64, + pub window_end_ms: i64, +} + /// Identifies which timestamp source was used to anchor a stream in wall-clock time. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum AnchorSource { @@ -109,6 +209,46 @@ pub(crate) fn datetime_to_ms(dt: &Datetime) -> i64 { dt.as_ref().timestamp_millis() } +/// Resolve anchor time from individual timing fields (avoids needing a full Talk record). +pub(crate) fn resolve_anchor_time_from_fields( + talk_starts_at: Option<&Datetime>, + livestream_created_at: Option<&Datetime>, + video_created_at: &Datetime, +) -> StreamAnchorTime { + if let Some(starts_at) = talk_starts_at { + return StreamAnchorTime { + timestamp_ms: datetime_to_ms(starts_at), + source: AnchorSource::Talk, + }; + } + if let Some(livestream_dt) = livestream_created_at { + return StreamAnchorTime { + timestamp_ms: datetime_to_ms(livestream_dt), + source: AnchorSource::Livestream, + }; + } + StreamAnchorTime { + timestamp_ms: datetime_to_ms(video_created_at), + source: AnchorSource::Video, + } +} + +/// Resolve end time from individual timing fields. +pub(crate) fn resolve_end_time_from_fields( + talk_ends_at: Option<&Datetime>, + livestream_ended_at: Option<&Datetime>, + anchor: &StreamAnchorTime, + duration_ns: u64, +) -> i64 { + if let Some(ends_at) = talk_ends_at { + return datetime_to_ms(ends_at); + } + if let Some(ended_at) = livestream_ended_at { + return datetime_to_ms(ended_at); + } + anchor.timestamp_ms + (duration_ns / 1_000_000) as i64 +} + #[cfg(test)] mod tests { use super::*; -- 2.51.2