Something went wrong. Try again.
Streamplace AtmosphereConf 2026 VOD JAM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462use jacquard::deps::smol_str::{SmolStr, ToSmolStr};use jacquard::types::did::Did;use jacquard::types::handle::Handle;use jacquard::types::string::Datetime;use serde::{Deserialize, Serialize};#[cfg(test)]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, PartialEq)]pub struct FacetData { pub byte_start: usize, pub byte_end: usize, pub features: Vec<FacetFeatureData>,}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]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<FacetData> { 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<FacetData> { 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: t.tag.to_smolstr(), }), _ => 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,}
/// A batch of chat messages for a VOD time window, returned by `get_chat_messages`.#[derive(Serialize, Deserialize, Debug, Clone)]pub struct ChatMessageBatch { pub messages: Vec<ChatMessageView>, pub has_more: bool,}
/// A single chat message ready for display, with resolved author handle and VOD-relative time.#[derive(Serialize, Deserialize, Debug, Clone)]pub struct ChatMessageView { pub tid: SmolStr, pub text: SmolStr, pub author_did: Did, pub author_handle: Handle, pub vod_relative_ms: i64, pub reply_snippet: Option<ReplySnippet>, pub facets: Vec<FacetData>,}
/// A truncated preview of the parent message for reply display.#[derive(Serialize, Deserialize, Debug, Clone)]pub struct ReplySnippet { pub author_did: Did, pub author_handle: Handle, pub text_preview: SmolStr, pub parent_tid: SmolStr,}
/// Truncate text for reply preview. Uses char-level truncation (not grapheme clusters,/// which would require the unicode-segmentation crate). Appends an ellipsis if truncated.pub(crate) fn truncate_preview(text: &str, max_chars: usize) -> SmolStr { use jacquard::deps::smol_str::format_smolstr; match text.char_indices().nth(max_chars) { None => SmolStr::new(text), Some((byte_offset, _)) => format_smolstr!("{}\u{2026}", &text[..byte_offset]), }}
/// 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 { /// Anchor derived from a scheduled talk's `starts_at` field. Talk, /// Anchor derived from the livestream record's `created_at` field. Livestream, /// Anchor derived from the video record's `created_at` field (fallback). Video,}
/// The resolved wall-clock anchor for a stream, used to convert chat message/// timestamps to VOD-relative offsets.#[derive(Serialize, Deserialize, Debug, Clone)]pub struct StreamAnchorTime { /// Unix epoch milliseconds of the anchor point. pub timestamp_ms: i64, /// Which source was used to determine the anchor. pub source: AnchorSource,}
/// Resolve the best available anchor time for a stream.////// Priority order:/// 1. `talk.starts_at` — scheduled start time from ionosphere talk record/// 2. `livestream_created_at` — when the livestream started/// 3. `video_created_at` — fallback: when the VOD record was created#[cfg(test)]pub(crate) fn resolve_anchor_time( talk: Option<&Talk>, livestream_created_at: Option<&Datetime>, video_created_at: &Datetime,) -> StreamAnchorTime { resolve_anchor_time_from_fields( talk.and_then(|t| t.starts_at.as_ref()), livestream_created_at, video_created_at, )}
/// Resolve the end time of the stream in epoch milliseconds.////// Priority order:/// 1. `talk.ends_at` — scheduled end time from ionosphere talk record/// 2. `livestream_ended_at` — when the livestream ended/// 3. `anchor.timestamp_ms + duration_ns / 1_000_000` — computed from video duration#[cfg(test)]pub(crate) fn resolve_end_time( talk: Option<&Talk>, livestream_ended_at: Option<&Datetime>, anchor: &StreamAnchorTime, duration_ns: u64,) -> i64 { resolve_end_time_from_fields( talk.and_then(|t| t.ends_at.as_ref()), livestream_ended_at, anchor, duration_ns, )}
/// Compute the wall-clock window of chat messages to fetch.////// Expands the \[anchor, end\] window by `fuzz_minutes` on each side to account/// for clock skew and messages posted slightly before/after the stream.pub(crate) fn compute_time_window( anchor: &StreamAnchorTime, end_ms: i64, fuzz_minutes: u32,) -> (i64, i64) { let fuzz_ms = fuzz_minutes as i64 * 60_000; let start = anchor.timestamp_ms - fuzz_ms; let end = end_ms + fuzz_ms; (start, end)}
/// Convert a chat message's wall-clock timestamp to a VOD-relative offset in milliseconds.////// Returns a positive value if the message was posted after the anchor (during playback),/// or negative if it was posted before.pub(crate) fn to_vod_relative_ms(message_created_at_ms: i64, anchor_ms: i64) -> i64 { message_created_at_ms - anchor_ms}
/// Convert a `Datetime` to Unix epoch milliseconds.////// Assumes valid AT Protocol datetime range (post-1970, within i64 bounds).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::*; use vodplace_api::tv_ionosphere::talk::Talk;
fn test_talk(starts_at: Option<&str>, ends_at: Option<&str>) -> Talk { Talk { title: "Test Talk".into(), event_uri: "at://did:plc:test/tv.ionosphere.event/1".parse().unwrap(), starts_at: starts_at.map(|s| s.parse().unwrap()), ends_at: ends_at.map(|s| s.parse().unwrap()), category: None, description: None, document: None, duration: None, room: None, schedule_uri: None, speaker_uris: None, talk_type: None, video_uri: None, extra_data: None, } }
fn test_datetime(iso: &str) -> Datetime { iso.parse().unwrap() }
// AC3.1: talk with starts_at → AnchorSource::Talk #[test] fn resolve_anchor_prefers_talk_starts_at() { let talk = test_talk(Some("2026-04-01T10:00:00Z"), None); let video_dt = test_datetime("2026-04-01T09:00:00Z");
let anchor = resolve_anchor_time(Some(&talk), None, &video_dt);
assert_eq!(anchor.source, AnchorSource::Talk); // 2026-04-01T10:00:00Z = 1775037600000 ms assert_eq!(anchor.timestamp_ms, 1775037600000); }
// AC3.1 edge: talk present but starts_at = None → falls through to livestream #[test] fn resolve_anchor_talk_without_starts_at_falls_through_to_livestream() { let talk = test_talk(None, None); let livestream_dt = test_datetime("2026-04-01T10:30:00Z"); let video_dt = test_datetime("2026-04-01T09:00:00Z");
let anchor = resolve_anchor_time(Some(&talk), Some(&livestream_dt), &video_dt);
assert_eq!(anchor.source, AnchorSource::Livestream); assert_eq!(anchor.timestamp_ms, datetime_to_ms(&livestream_dt)); }
// AC3.2: no talk, livestream_created_at present → AnchorSource::Livestream #[test] fn resolve_anchor_uses_livestream_when_no_talk() { let livestream_dt = test_datetime("2026-04-01T10:30:00Z"); let video_dt = test_datetime("2026-04-01T09:00:00Z");
let anchor = resolve_anchor_time(None, Some(&livestream_dt), &video_dt);
assert_eq!(anchor.source, AnchorSource::Livestream); assert_eq!(anchor.timestamp_ms, datetime_to_ms(&livestream_dt)); }
// AC3.3: talk = None, livestream_created_at = None → AnchorSource::Video #[test] fn resolve_anchor_falls_back_to_video_created_at() { let video_dt = test_datetime("2026-04-01T09:00:00Z");
let anchor = resolve_anchor_time(None, None, &video_dt);
assert_eq!(anchor.source, AnchorSource::Video); assert_eq!(anchor.timestamp_ms, datetime_to_ms(&video_dt)); }
// AC3.4: compute_time_window with fuzz_minutes=5 #[test] fn compute_time_window_applies_fuzz_on_both_sides() { let anchor = StreamAnchorTime { timestamp_ms: 1_000_000, source: AnchorSource::Video, }; let end_ms = 2_000_000i64; let fuzz_minutes = 5u32;
let (start, end) = compute_time_window(&anchor, end_ms, fuzz_minutes);
let fuzz_ms = 5 * 60_000i64; assert_eq!(start, 1_000_000 - fuzz_ms); assert_eq!(end, 2_000_000 + fuzz_ms); }
// AC3.5: resolve_end_time with all None → computed from anchor + duration #[test] fn resolve_end_time_falls_back_to_duration() { let anchor = StreamAnchorTime { timestamp_ms: 1_000_000, source: AnchorSource::Video, }; let duration_ns = 3_600_000_000_000u64; // 1 hour in nanoseconds
let end_ms = resolve_end_time(None, None, &anchor, duration_ns);
assert_eq!(end_ms, 1_000_000 + 3_600_000); }
// AC3.5: talk.ends_at = Some → uses it #[test] fn resolve_end_time_prefers_talk_ends_at() { let talk = test_talk(None, Some("2026-04-01T12:00:00Z")); let anchor = StreamAnchorTime { timestamp_ms: 0, source: AnchorSource::Video, };
let end_ms = resolve_end_time(Some(&talk), None, &anchor, 0);
assert_eq!(end_ms, datetime_to_ms(&test_datetime("2026-04-01T12:00:00Z"))); }
// AC3.5: livestream_ended_at = Some → uses it (talk has no ends_at) #[test] fn resolve_end_time_uses_livestream_ended_at_when_talk_has_no_ends_at() { let talk = test_talk(None, None); let livestream_ended_at = test_datetime("2026-04-01T11:00:00Z"); let anchor = StreamAnchorTime { timestamp_ms: 0, source: AnchorSource::Video, };
let end_ms = resolve_end_time(Some(&talk), Some(&livestream_ended_at), &anchor, 0);
assert_eq!(end_ms, datetime_to_ms(&livestream_ended_at)); }
// AC3.6: to_vod_relative_ms for message 30s before anchor (negative) #[test] fn to_vod_relative_ms_negative_for_message_before_anchor() { let anchor_ms = 1_000_000i64; let msg_ms = 1_000_000 - 30_000; // 30 seconds before anchor
let offset = to_vod_relative_ms(msg_ms, anchor_ms);
assert_eq!(offset, -30_000); }
// AC3.6: to_vod_relative_ms for message 30s after anchor (positive) #[test] fn to_vod_relative_ms_positive_for_message_after_anchor() { let anchor_ms = 1_000_000i64; let msg_ms = 1_000_000 + 30_000; // 30 seconds after anchor
let offset = to_vod_relative_ms(msg_ms, anchor_ms);
assert_eq!(offset, 30_000); }
// truncate_preview tests #[test] fn truncate_preview_short_text_unchanged() { let text = "Hello, world!"; assert_eq!(truncate_preview(text, 80).as_str(), "Hello, world!"); }
#[test] fn truncate_preview_exact_length_unchanged() { let text: String = "a".repeat(80); assert_eq!(truncate_preview(&text, 80).as_str(), text.as_str()); }
#[test] fn truncate_preview_long_text_truncated_with_ellipsis() { let text: String = "a".repeat(100); let result = truncate_preview(&text, 80); // Should be 80 'a's + ellipsis assert_eq!(result.len(), 80 + "\u{2026}".len()); assert!(result.ends_with('\u{2026}')); }
#[test] fn truncate_preview_empty_text() { assert_eq!(truncate_preview("", 80).as_str(), ""); }}