//! Room operations: discovery, create, get, join, leave, delete, settings, chat, public settings use crate::impls::ApiError; use futures::{stream, StreamExt as _, TryStreamExt as _}; use std::collections::{HashMap, HashSet}; use synctv_core::models::{ ChatMentionInput, ChatMessageEvent, ChatMessageType, ChatMessageWithAttachments, ChatPinEvent, ChatPlaybackMessagesQuery, CreateChatAttachmentUploadSession, MarkChatRead, PageParams, PlaybackSourceIdentity, SendChatMessage, SetChatReaction, StoreFileUploadResult, UserId, }; use synctv_core::provider::ExecutionControl; use synctv_core::service::ClientResourceAvailability; use super::convert::{ apply_room_settings_patch_from_proto, chat_message_selection_from_proto_values, chat_metadata_from_proto, file_metadata_from_proto, provider_target_from_proto, room_role_to_proto, room_settings_from_proto, room_settings_to_proto, try_members_to_proto, try_playback_state_to_proto, }; use super::media::{ complete_upload_response_fields, complete_upload_session_request, prepare_delete_entries_outbox_fanout, proto_file_range_request, proto_file_upload_range, proto_upload_manifest_parts, required_file_upload_reference, room_cover_object_to_proto, room_cover_upload_create_result_to_proto, uploaded_parts_response_fields, PrepareDeleteEntriesOutboxFanout, }; use super::{ClientApiImpl, RoomActor}; mod support; use support::*; pub use support::{ build_search_chat_messages_query, parse_optional_room_category_id, parse_proto_chat_attachments, parse_room_label_ids, }; #[cfg(test)] pub use support::{chat_reaction_count, chat_reaction_summary_to_proto}; const CLIENT_ROOM_LOAD_CONCURRENCY: usize = 16; struct DiscoveryRoomProjection { room_id: synctv_core::models::RoomId, room: synctv_proto::client::Room, settings: synctv_core::models::RoomSettings, member_count: i32, availability: ClientResourceAvailability, password_enabled: bool, } struct DiscoveryRoomWindow { featured_rooms: Vec, rooms: Vec, total: i32, } #[derive(Clone, Copy, PartialEq, Eq)] enum DiscoveryReadConsistency { Primary, EventuallyConsistent, } fn discovery_room_list_total(total: i64, featured_count: i64) -> i64 { total.saturating_sub(featured_count).max(0) } fn required_room_availability( availability_map: &HashMap, room_id: &synctv_core::models::RoomId, ) -> Result { availability_map.get(room_id).copied().ok_or_else(|| { ApiError::Internal(format!( "Missing client availability for room {room_id} in batch response" )) }) } fn room_discovery_access( viewer_state: synctv_core::repository::RoomDiscoveryViewerState, settings: &synctv_core::models::RoomSettings, member_count: i32, availability: ClientResourceAvailability, password_enabled: bool, ) -> (bool, i32) { use synctv_proto::client::RoomDiscoveryAccess; if !availability.is_available() { return (false, RoomDiscoveryAccess::Unavailable as i32); } if viewer_state.joined { return (false, RoomDiscoveryAccess::Enter as i32); } if viewer_state.pending_join_request { return (false, RoomDiscoveryAccess::PendingApproval as i32); } if viewer_state.in_kick_cooldown { return (false, RoomDiscoveryAccess::Cooldown as i32); } let max_members = settings.max_members.0; if max_members > 0 && u64::try_from(member_count).unwrap_or(u64::MAX) >= max_members { return (false, RoomDiscoveryAccess::Full as i32); } if !settings.allow_auto_join.0 { return (false, RoomDiscoveryAccess::Invitation as i32); } if password_enabled { return (true, RoomDiscoveryAccess::Password as i32); } if settings.require_approval.0 { return (true, RoomDiscoveryAccess::RequestApproval as i32); } (true, RoomDiscoveryAccess::Join as i32) } fn public_room_discovery_access( guest_enabled: bool, settings: &synctv_core::models::RoomSettings, availability: ClientResourceAvailability, password_enabled: bool, ) -> bool { guest_enabled && availability.is_available() && settings.allow_guest_join.0 && !password_enabled } fn build_server_time_response( req: synctv_proto::client::GetServerTimeRequest, clock: &dyn synctv_core::Clock, ) -> synctv_proto::client::GetServerTimeResponse { let server_received_at_nanos = clock.now_nanos(); let server_sent_at_nanos = clock.now_nanos().max(server_received_at_nanos); synctv_proto::client::GetServerTimeResponse { client_sent_at_nanos: req.client_sent_at_nanos, server_received_at_nanos, server_sent_at_nanos, } } impl ClientApiImpl { async fn load_room_creator_public_views( &self, rooms: &[synctv_core::models::Room], consistency: DiscoveryReadConsistency, ) -> Result, ApiError> { let creator_ids: Vec = rooms .iter() .map(|room| room.created_by) .collect::>() .into_iter() .collect(); let mut creators = match consistency { DiscoveryReadConsistency::Primary => self .user_service .get_users_by_ids(&creator_ids) .await .map_err(ApiError::from)?, DiscoveryReadConsistency::EventuallyConsistent => self .user_service .get_users_by_ids_eventually_consistent(&creator_ids) .await .map_err(ApiError::from)?, }; if consistency == DiscoveryReadConsistency::EventuallyConsistent { let loaded_ids = creators .iter() .map(|creator| creator.id) .collect::>(); let missing_ids = creator_ids .iter() .copied() .filter(|creator_id| !loaded_ids.contains(creator_id)) .collect::>(); if !missing_ids.is_empty() { creators.extend( self.user_service .get_users_by_ids(&missing_ids) .await .map_err(ApiError::from)?, ); } } // Batch load all avatars in parallel let creator_views = self .batch_user_public_views_with_loaded_avatars(&creators) .await?; // Build map from user ID to public view let mut map = HashMap::with_capacity(creator_views.len()); for (i, creator) in creators.iter().enumerate() { map.insert(creator.id, creator_views[i].clone()); } Ok(map) } async fn load_room_member_count( &self, room_id: &synctv_core::models::RoomId, ) -> Result, ApiError> { self.room_service .get_member_count(room_id) .await .map(Some) .map_err(ApiError::from) } async fn favorite_room_ids_for_viewer_eventually_consistent( &self, viewer_id: Option, room_ids: &[synctv_core::models::RoomId], ) -> Result, ApiError> { match viewer_id { Some(user_id) => self .room_service .favorite_room_ids_for_user_eventually_consistent(&user_id, room_ids) .await .map_err(ApiError::from), None => Ok(std::collections::HashSet::new()), } } async fn room_to_proto_for_favorite_view( &self, room: &synctv_core::models::Room, creator_blocked: bool, ) -> Result { let (settings, presence, availability, member_count) = tokio::join!( self.room_service.get_room_settings(&room.id), self.presence_service.room_stats(room.id), self.room_service.room_availability(room), self.load_room_member_count(&room.id), ); let settings = settings.map_err(ApiError::from)?; let presence = presence.map_err(ApiError::from)?; let availability = availability.map_err(ApiError::from)?; let member_count = member_count?; let mut proto_room = self .room_to_proto_with_availability_presence_and_loaded_cover( room, Some(&settings), member_count, availability, Some(&presence), None, ) .await?; proto_room.creator_blocked = creator_blocked; Ok(proto_room) } async fn load_room_playback_state_proto( &self, room_id: &synctv_core::models::RoomId, ) -> Result { let state = self .room_service .get_playback_state(room_id) .await .map_err(ApiError::from)?; try_playback_state_to_proto(&state, &self.public_id_codec) } async fn user_username_for_event(&self, user_id: &UserId) -> Result { self.user_service .get_user(user_id) .await .map(|user| user.username) .map_err(ApiError::from) } /// Get the currently playing media for a room. /// /// Requires the caller to be a member of the room. pub async fn get_playing_media( &self, user_id: &UserId, room_id: &str, ) -> Result, ApiError> { let uid = *user_id; let rid = self.parse_room_id(room_id)?; // Check membership before returning playing media self.room_service .check_membership(&rid, &uid) .await .map_err(Self::map_room_access_error)?; let media = self .room_service .get_playing_media(&rid) .await .map_err(ApiError::from)?; match media { Some(media) => { let availability = self .room_service .media_availability(&media) .await .map_err(ApiError::from)?; Ok(Some( self.media_to_proto_for_viewer_with_loaded_cover( &media, availability.is_available(), Some(uid), ) .await?, )) } None => Ok(None), } } async fn rooms_to_discovery_projections( &self, rooms: Vec, consistency: DiscoveryReadConsistency, ) -> Result, ApiError> { let room_ids = rooms.iter().map(|room| room.id).collect::>(); let room_id_refs = room_ids.iter().collect::>(); let ( availability_map, member_counts, room_settings_map, password_room_ids, presence_stats, creator_views, ) = tokio::try_join!( async { match consistency { DiscoveryReadConsistency::Primary => self .room_service .room_availability_batch(&rooms) .await .map_err(ApiError::from), DiscoveryReadConsistency::EventuallyConsistent => self .room_service .room_availability_batch_eventually_consistent(&rooms) .await .map_err(ApiError::from), } }, async { match consistency { DiscoveryReadConsistency::Primary => self .room_service .get_member_count_batch(&room_id_refs) .await .map_err(ApiError::from), DiscoveryReadConsistency::EventuallyConsistent => self .room_service .get_member_count_batch_eventually_consistent(&room_id_refs) .await .map_err(ApiError::from), } }, async { match consistency { DiscoveryReadConsistency::Primary => self .room_service .get_room_settings_batch(&room_ids) .await .map_err(ApiError::from), DiscoveryReadConsistency::EventuallyConsistent => self .room_service .get_room_settings_batch_eventually_consistent(&room_ids) .await .map_err(ApiError::from), } }, async { match consistency { DiscoveryReadConsistency::Primary => self .room_service .password_enabled_room_ids(&room_ids) .await .map_err(ApiError::from), DiscoveryReadConsistency::EventuallyConsistent => self .room_service .password_enabled_room_ids_eventually_consistent(&room_ids) .await .map_err(ApiError::from), } }, async { self.presence_service .room_stats_batch(&room_ids) .await .map_err(ApiError::from) }, self.load_room_creator_public_views(&rooms, consistency), )?; let presence_by_room = presence_stats .iter() .map(|stats| (stats.room_id, stats)) .collect::>(); let rooms_with_creators = rooms .into_iter() .filter_map(|room| { creator_views .get(&room.created_by) .cloned() .map(|creator| (room, creator)) }) .collect::>(); stream::iter(rooms_with_creators) .map(|(room, creator)| { let availability_map = &availability_map; let member_counts = &member_counts; let room_settings_map = &room_settings_map; let password_room_ids = &password_room_ids; let presence_by_room = &presence_by_room; async move { let member_count = crate::impls::room_member_count_or_zero(member_counts, &room.id); let availability = required_room_availability(availability_map, &room.id)?; let settings = required_room_settings(room_settings_map, &room.id)?; let proto_room = self .room_to_proto_with_availability_presence_and_loaded_cover( &room, Some(settings), Some(member_count), availability, presence_by_room.get(&room.id).copied(), Some(creator), ) .await?; Ok::<_, ApiError>(DiscoveryRoomProjection { room_id: room.id, room: proto_room, settings: settings.clone(), member_count, availability, password_enabled: password_room_ids.contains(&room.id), }) } }) .buffered(CLIENT_ROOM_LOAD_CONCURRENCY) .try_collect() .await } async fn rooms_to_discovery_items( &self, rooms: Vec, viewer_id: &UserId, consistency: DiscoveryReadConsistency, ) -> Result, ApiError> { let room_ids = rooms.iter().map(|room| room.id).collect::>(); let (projections, mut viewer_states) = tokio::try_join!( self.rooms_to_discovery_projections(rooms, consistency), async { match consistency { DiscoveryReadConsistency::Primary => self .room_service .discovery_viewer_states(viewer_id, &room_ids) .await .map_err(ApiError::from), DiscoveryReadConsistency::EventuallyConsistent => self .room_service .discovery_viewer_states_eventually_consistent(viewer_id, &room_ids) .await .map_err(ApiError::from), } }, )?; if consistency == DiscoveryReadConsistency::EventuallyConsistent { let missing_room_ids = room_ids .iter() .copied() .filter(|room_id| !viewer_states.contains_key(room_id)) .collect::>(); if !missing_room_ids.is_empty() { viewer_states.extend( self.room_service .discovery_viewer_states(viewer_id, &missing_room_ids) .await .map_err(ApiError::from)?, ); } } Ok(projections .into_iter() .filter_map(|projection| { let viewer_state = viewer_states.get(&projection.room_id).copied()?; let (can_join, access) = room_discovery_access( viewer_state, &projection.settings, projection.member_count, projection.availability, projection.password_enabled, ); Some(synctv_proto::client::RoomDiscoveryItem { room: Some(projection.room), joined: viewer_state.joined, favorited: viewer_state.favorited, can_join, access, }) }) .collect()) } async fn rooms_to_public_discovery_items( &self, rooms: Vec, guest_enabled: bool, consistency: DiscoveryReadConsistency, ) -> Result, ApiError> { let rooms = rooms.into_iter().filter(|room| room.is_public).collect(); Ok(self .rooms_to_discovery_projections(rooms, consistency) .await? .into_iter() .map(|projection| { let can_join = public_room_discovery_access( guest_enabled, &projection.settings, projection.availability, projection.password_enabled, ); let access = if !projection.availability.is_available() { synctv_proto::client::RoomDiscoveryAccess::Unavailable } else if can_join { synctv_proto::client::RoomDiscoveryAccess::Guest } else { synctv_proto::client::RoomDiscoveryAccess::SignIn }; synctv_proto::client::RoomDiscoveryItem { room: Some(projection.room), joined: false, favorited: false, can_join, access: access as i32, } }) .collect()) } fn public_guest_access_enabled(&self) -> Result { self.runtime_settings_store .as_ref() .map_or(Ok(false), |settings| { settings.user.enable_guest.get().map_err(ApiError::from) }) } pub async fn get_room_discovery( &self, viewer_id: &UserId, req: synctv_proto::client::GetRoomDiscoveryRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let room_id = self.parse_room_id(&req.room_id)?; let room = self .room_service .get_room(&room_id) .await .map_err(ApiError::from)?; if room.is_banned { let is_member = self .room_service .member_service() .is_member(&room_id, viewer_id) .await .map_err(ApiError::from)?; let is_admin = self .user_service .get_user(viewer_id) .await .map_err(ApiError::from)? .role .is_admin_or_above(); if !is_member && !is_admin { return Err(ApiError::NotFound("Room not found".to_string())); } } else if room.status.is_closed() { return Err(ApiError::NotFound("Room not found".to_string())); } let creator_blocked = self .user_service .is_blocking(viewer_id, &room.created_by) .await .map_err(ApiError::from)?; let mut item = self .rooms_to_discovery_items(vec![room], viewer_id, DiscoveryReadConsistency::Primary) .await? .into_iter() .next() .ok_or_else(|| ApiError::NotFound("Room not found".to_string()))?; if let Some(room) = item.room.as_mut() { room.creator_blocked = creator_blocked; } Ok(item) } pub async fn get_public_room_discovery( &self, req: synctv_proto::client::GetRoomDiscoveryRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let room_id = self.parse_room_id(&req.room_id)?; let room = self .room_service .list_active_unbanned_rooms_by_ids(&[room_id]) .await .map_err(ApiError::from)? .into_iter() .next() .ok_or_else(|| ApiError::NotFound("Room not found".to_string()))?; if !room.is_public { return Err(ApiError::NotFound("Room not found".to_string())); } self.rooms_to_public_discovery_items( vec![room], self.public_guest_access_enabled()?, DiscoveryReadConsistency::Primary, ) .await? .into_iter() .next() .ok_or_else(|| ApiError::NotFound("Room not found".to_string())) } async fn discover_room_window( &self, viewer_id: Option<&UserId>, req: synctv_proto::client::DiscoverRoomsRequest, ) -> Result { let mut query = build_room_discovery_query(req, &self.public_id_codec)?; if let Some(viewer_id) = viewer_id { query.excluded_creator_ids = self .user_service .blocked_user_ids_eventually_consistent(viewer_id) .await .map_err(ApiError::from)?; } let hot_stats = self .presence_service .hot_room_stats() .await .map_err(ApiError::from)?; let hot_room_ids = hot_stats .iter() .map(|stats| stats.room_id) .collect::>(); let has_filters = query.search.is_some() || query.category_id.is_some() || !query.label_ids.is_empty(); let featured_count = if has_filters { 0 } else { u64::try_from(DISCOVERY_FEATURED_ROOM_COUNT).map_err(|_| { ApiError::Internal("featured room count exceeds u64::MAX".to_string()) })? }; let page_offset = query.pagination.offset(); let page_size = query.pagination.limit(); let include_featured = featured_count > 0 && query.pagination.page == 1; let offset = if include_featured { 0 } else { page_offset.saturating_add(featured_count) }; let window_size = if include_featured { page_size.saturating_add(featured_count) } else { page_size }; let (mut rooms, hot_total) = self .room_service .list_ranked_rooms_window_eventually_consistent( &query, &hot_room_ids, offset, window_size, ) .await .map_err(ApiError::from)?; let hot_count = u64::try_from(hot_total) .map_err(|_| ApiError::Internal("hot room count exceeds u64::MAX".to_string()))?; let loaded_hot_count = u64::try_from(rooms.len()) .map_err(|_| ApiError::Internal("hot room window exceeds u64::MAX".to_string()))?; let offline_limit = window_size.saturating_sub(loaded_hot_count); let offline_offset = offset.saturating_sub(hot_count); let (offline_rooms, offline_total) = self .room_service .list_rooms_window_excluding_eventually_consistent( &query, &hot_room_ids, offline_offset, offline_limit, ) .await .map_err(ApiError::from)?; rooms.extend(offline_rooms); let total = hot_total .checked_add(offline_total) .ok_or_else(|| ApiError::Internal("room discovery total overflow".to_string()))?; let mut room_list = rooms; let featured_rooms = if include_featured { let split_at = DISCOVERY_FEATURED_ROOM_COUNT.min(room_list.len()); room_list.drain(..split_at).collect() } else { Vec::new() }; let featured_count = i64::try_from(featured_count) .map_err(|_| ApiError::Internal("featured room count exceeds i64::MAX".to_string()))?; let rooms_total = discovery_room_list_total(total, featured_count); Ok(DiscoveryRoomWindow { featured_rooms, rooms: room_list, total: i64_to_i32_api(rooms_total, "room discovery total")?, }) } pub async fn discover_rooms( &self, viewer_id: &UserId, req: synctv_proto::client::DiscoverRoomsRequest, ) -> Result { let window = self.discover_room_window(Some(viewer_id), req).await?; let (featured_rooms, rooms) = tokio::try_join!( self.rooms_to_discovery_items( window.featured_rooms, viewer_id, DiscoveryReadConsistency::EventuallyConsistent, ), self.rooms_to_discovery_items( window.rooms, viewer_id, DiscoveryReadConsistency::EventuallyConsistent, ), )?; Ok(synctv_proto::client::DiscoverRoomsResponse { featured_rooms, rooms, total: window.total, }) } pub async fn discover_public_rooms( &self, req: synctv_proto::client::DiscoverRoomsRequest, ) -> Result { let window = self.discover_room_window(None, req).await?; let guest_enabled = self.public_guest_access_enabled()?; let (featured_rooms, rooms) = tokio::try_join!( self.rooms_to_public_discovery_items( window.featured_rooms, guest_enabled, DiscoveryReadConsistency::EventuallyConsistent, ), self.rooms_to_public_discovery_items( window.rooms, guest_enabled, DiscoveryReadConsistency::EventuallyConsistent, ), )?; Ok(synctv_proto::client::DiscoverRoomsResponse { featured_rooms, rooms, total: window.total, }) } pub async fn list_my_rooms( &self, user_id: &UserId, req: synctv_proto::client::ListMyRoomsRequest, ) -> Result { let uid = *user_id; let query = build_my_room_list_query(req)?; let (rooms, total) = self .room_service .list_accessible_joined_rooms_with_query_eventually_consistent(&uid, &query) .await .map_err(ApiError::from)?; // Batch-fetch room settings for full permission calculation. let room_ids: Vec = rooms.iter().map(|(room, _, _)| room.id).collect(); let room_models: Vec = rooms.iter().map(|(room, _, _)| room.clone()).collect(); let ( room_settings_map, presence_stats, availability_map, creator_views, favorite_room_ids, blocked_creator_ids, ) = tokio::try_join!( async { self.room_service .get_room_settings_batch_eventually_consistent(&room_ids) .await .map_err(ApiError::from) }, async { self.presence_service .room_stats_batch(&room_ids) .await .map_err(ApiError::from) }, async { self.room_service .room_availability_batch_eventually_consistent(&room_models) .await .map_err(ApiError::from) }, self.load_room_creator_public_views( &room_models, DiscoveryReadConsistency::EventuallyConsistent, ), self.favorite_room_ids_for_viewer_eventually_consistent(Some(uid), &room_ids), async { self.user_service .blocked_user_ids_eventually_consistent(&uid) .await .map(|ids| ids.into_iter().collect::>()) .map_err(ApiError::from) }, )?; let presence_by_room: HashMap = presence_stats .iter() .map(|stats| (stats.room_id, stats)) .collect(); let rooms_with_creators = rooms .into_iter() .filter_map(|room_details| { creator_views .get(&room_details.0.created_by) .cloned() .map(|creator| (room_details, creator)) }) .collect::>(); let room_list = stream::iter(rooms_with_creators) .map(|((room, role, member_count), creator)| { let room_settings_map = &room_settings_map; let presence_by_room = &presence_by_room; let availability_map = &availability_map; let favorite_room_ids = &favorite_room_ids; let blocked_creator_ids = &blocked_creator_ids; async move { let settings = required_room_settings(room_settings_map, &room.id)?; let availability = required_room_availability(availability_map, &room.id)?; let permissions = self .room_service .permission_service() .calculate_role_default_permissions(&role, settings) .0; let relation = if room.created_by == uid { synctv_proto::client::MyRoomRelation::Created as i32 } else { synctv_proto::client::MyRoomRelation::Participating as i32 }; let mut proto_room = self .room_to_proto_with_availability_presence_and_loaded_cover( &room, Some(settings), Some(member_count), availability, presence_by_room.get(&room.id).copied(), Some(creator), ) .await?; proto_room.creator_blocked = blocked_creator_ids.contains(&room.created_by); Ok::<_, ApiError>(synctv_proto::client::MyRoom { room: Some(proto_room), permissions, role: room_role_to_proto(role), relation, favorited: favorite_room_ids.contains(&room.id), }) } }) .buffered(CLIENT_ROOM_LOAD_CONCURRENCY) .try_collect() .await?; Ok(synctv_proto::client::ListMyRoomsResponse { rooms: room_list, total: i64_to_i32_api(total, "my room total")?, }) } pub async fn favorite_room( &self, user_id: &UserId, req: synctv_proto::client::FavoriteRoomRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let uid = *user_id; let room_id = self.parse_room_id(&req.room_id)?; let room = self .room_service .favorite_room(&uid, &room_id) .await .map_err(ApiError::from)?; let creator_blocked = self .user_service .is_blocking(user_id, &room.created_by) .await .map_err(ApiError::from)?; let proto_room = self .room_to_proto_for_favorite_view(&room, creator_blocked) .await?; Ok(synctv_proto::client::FavoriteRoomResponse { room: Some(proto_room), }) } pub async fn unfavorite_room( &self, user_id: &UserId, req: synctv_proto::client::UnfavoriteRoomRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let uid = *user_id; let room_id = self.parse_room_id(&req.room_id)?; let room = self .room_service .unfavorite_room(&uid, &room_id) .await .map_err(ApiError::from)?; let creator_blocked = self .user_service .is_blocking(user_id, &room.created_by) .await .map_err(ApiError::from)?; let proto_room = self .room_to_proto_for_favorite_view(&room, creator_blocked) .await?; Ok(synctv_proto::client::UnfavoriteRoomResponse { room: Some(proto_room), }) } pub async fn list_favorite_rooms( &self, user_id: &UserId, req: synctv_proto::client::ListFavoriteRoomsRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let uid = *user_id; let page = positive_i32_to_u32(req.page, DEFAULT_ROOM_PAGE); let page_size = if req.page_size > 0 { req.page_size.cast_unsigned().min(MAX_ROOM_PAGE_SIZE) } else { DEFAULT_ROOM_PAGE_SIZE }; let pagination = PageParams::new(Some(page), Some(page_size)); let search = (!req.search.is_empty()).then_some(req.search); let (rooms, total) = self .room_service .list_favorite_rooms(&uid, pagination, search.as_deref()) .await .map_err(ApiError::from)?; let blocked_creator_ids = self .user_service .blocked_user_ids(user_id) .await .map_err(ApiError::from)? .into_iter() .collect::>(); let rooms_ref = &rooms; let blocked_creator_ids = &blocked_creator_ids; let response_rooms = stream::iter(0..rooms.len()) .map(|index| async move { let room = &rooms_ref[index]; self.room_to_proto_for_favorite_view( room, blocked_creator_ids.contains(&room.created_by), ) .await }) .buffered(16) .try_collect() .await?; Ok(synctv_proto::client::ListFavoriteRoomsResponse { rooms: response_rooms, total: i64_to_i32_api(total, "favorite room total")?, }) } pub async fn create_room( &self, user_id: &UserId, mut req: synctv_proto::client::CreateRoomRequest, ) -> Result { // Validate and sanitize room name req.name = crate::impls::validate_room_name_input(&req.name)?; // Validate and sanitize room description against ROOM_DESCRIPTION_MAX if !req.description.is_empty() { req.description = crate::impls::validate_room_description_input(&req.description)?; } crate::impls::validate_proto_request(&req)?; let uid = *user_id; let settings = req .settings .map(|settings| room_settings_from_proto(Some(settings))) .transpose()?; let password = if req.password.is_empty() { None } else { validate_room_password_for_set(&req.password)?; Some(req.password) }; let category_id = parse_optional_room_category_id(&req.category_id, &self.public_id_codec)?; let label_ids = parse_room_label_ids(&req.label_ids, &self.public_id_codec)?; let response_settings = settings.clone().unwrap_or_default(); let prepared_outbox_fanout = self .room_lifecycle_fanout .prepare_room_created_outbox_fanout(uid); let room = self .room_service .create_room_with_taxonomy_outbox( synctv_core::service::CreateRoomWithTaxonomyRequest { name: req.name, description: req.description, created_by: uid, password, settings, category_id, label_ids, is_public: req.is_public.unwrap_or(true), }, Some(prepared_outbox_fanout.outbox_factory()), ) .await .map_err(ApiError::from)?; prepared_outbox_fanout.publish_after_outbox_commit(); self.room_to_proto_basic_with_loaded_cover( &room, Some(&response_settings), self.load_room_member_count(&room.id).await?, ) .await } pub async fn list_room_categories( &self, req: synctv_proto::client::ListRoomCategoriesRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let categories = self .room_service .list_room_categories_eventually_consistent(!req.include_disabled) .await .map_err(ApiError::from)?; Ok(synctv_proto::client::ListRoomCategoriesResponse { categories: categories .iter() .map(|category| { crate::impls::client::convert::room_category_to_proto( category, &self.public_id_codec, ) }) .collect::, _>>()?, }) } pub async fn list_room_labels( &self, req: synctv_proto::client::ListRoomLabelsRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let category_id = parse_optional_room_category_id(&req.category_id, &self.public_id_codec)?; let labels = self .room_service .list_room_labels_eventually_consistent(!req.include_disabled, category_id) .await .map_err(ApiError::from)?; Ok(synctv_proto::client::ListRoomLabelsResponse { labels: labels .iter() .map(|label| { crate::impls::client::convert::room_label_to_proto(label, &self.public_id_codec) }) .collect::, _>>()?, }) } pub async fn get_room( &self, user_id: &UserId, room_id: &str, ) -> Result { let actor = self.room_actor_for_user(user_id, room_id).await?; self.get_room_for_actor(&actor).await } pub async fn get_room_for_actor( &self, actor: &RoomActor, ) -> Result { let rid = actor.room_id(); let room = self .room_service .get_room(&rid) .await .map_err(ApiError::from)?; let favorite = async { match actor.user_id() { Some(user_id) => self .room_service .is_room_favorited_by_user(&user_id, &rid) .await .map_err(ApiError::from), None => Ok(false), } }; let creator_blocked = async { match actor.user_id() { Some(user_id) => self .user_service .is_blocking(&user_id, &room.created_by) .await .map_err(ApiError::from), None => Ok(false), } }; let (playback_state, settings, presence, member_count, favorited, creator_blocked) = tokio::try_join!( self.load_room_playback_state_proto(&rid), async { self.room_service .get_room_settings(&rid) .await .map_err(ApiError::from) }, async { self.presence_service .room_stats(rid) .await .map_err(ApiError::from) }, self.load_room_member_count(&rid), favorite, creator_blocked, )?; let availability = self .room_service .room_availability(&room) .await .map_err(ApiError::from)?; let mut proto_room = self .room_to_proto_with_availability_presence_and_loaded_cover( &room, Some(&settings), member_count, availability, Some(&presence), None, ) .await?; proto_room.creator_blocked = creator_blocked; Ok(synctv_proto::client::GetRoomResponse { room: Some(proto_room), playback_state: Some(playback_state), favorited, }) } async fn room_response_after_room_update( &self, room: &synctv_core::models::Room, viewer_id: &UserId, ) -> Result { let rid = room.id; let (settings, member_count, playback_state, favorited) = tokio::try_join!( async { self.room_service .get_room_settings(&rid) .await .map_err(ApiError::from) }, self.load_room_member_count(&rid), self.load_room_playback_state_proto(&rid), async { self.room_service .is_room_favorited_by_user(viewer_id, &rid) .await .map_err(ApiError::from) }, )?; Ok(synctv_proto::client::GetRoomResponse { room: Some( self.room_to_proto_basic_with_loaded_cover(room, Some(&settings), member_count) .await?, ), playback_state: Some(playback_state), favorited, }) } pub async fn create_room_cover_upload_session( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::CreateRoomCoverUploadSessionRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let rid = self.parse_room_id(room_id)?; let session = self .room_service .create_room_cover_upload_session( rid, *user_id, synctv_core::service::CreateRoomCoverUploadSession { client_cover_id: optional_trimmed_string(&req.client_cover_id), mime_type: req.mime_type, size_bytes: req.size_bytes, width: (req.width > 0).then_some(req.width), height: (req.height > 0).then_some(req.height), duration_seconds: (req.duration_seconds > 0).then_some(req.duration_seconds), bitrate_bps: (req.bitrate_bps > 0).then_some(req.bitrate_bps), parts: proto_upload_manifest_parts(req.parts), metadata: file_metadata_from_proto(req.metadata.as_ref())?, }, ) .await .map_err(ApiError::from)?; room_cover_upload_create_result_to_proto(session) } pub async fn upload_room_cover_object( &self, req: synctv_proto::client::UploadRoomCoverObjectRequest, ) -> Result { let blob = self .room_service .store_room_cover_upload_object( &req.encoded_object_key, &req.token, req.content_type.as_deref(), proto_file_upload_range(req.content_range), req.data, ) .await .map_err(ApiError::from)?; let (complete, uploaded_size_bytes, uploaded_parts) = uploaded_parts_response_fields(&blob); Ok(synctv_proto::client::UploadRoomCoverObjectResponse { object: match blob { StoreFileUploadResult::Complete(blob) => Some(room_cover_object_to_proto(blob)), StoreFileUploadResult::PartAccepted { .. } => None, }, complete, uploaded_size_bytes, uploaded_parts, }) } pub async fn complete_room_cover_upload_session( &self, req: synctv_proto::client::CompleteRoomCoverUploadSessionRequest, ) -> Result { let result = self .room_service .complete_room_cover_upload_session(complete_upload_session_request( &req.file_id, req.encoded_object_key, req.token, req.upload_id, &req.ownership_proof, req.parts, )) .await .map_err(ApiError::from)?; let (complete, uploaded_size_bytes, uploaded_parts) = complete_upload_response_fields(&result); Ok( synctv_proto::client::CompleteRoomCoverUploadSessionResponse { object: result.object.map(room_cover_object_to_proto), complete, uploaded_size_bytes, uploaded_parts, }, ) } pub async fn get_room_cover_object( &self, req: synctv_proto::client::GetRoomCoverObjectRequest, ) -> Result { self.room_service .get_room_cover_object_stream( &req.encoded_object_key, &req.token, proto_file_range_request(req.range), ) .await .map_err(ApiError::from) } pub async fn update_room_cover( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::UpdateRoomCoverRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let rid = self.parse_room_id(room_id)?; let cover = required_file_upload_reference(req.cover_reference, "cover_reference")?; let room = self .room_service .update_room_cover(rid, *user_id, cover) .await .map_err(ApiError::from)?; self.room_cache_fanout.publish_invalidation(&rid); self.room_response_after_room_update(&room, user_id).await } pub async fn clear_room_cover( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::ClearRoomCoverRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let rid = self.parse_room_id(room_id)?; let room = self .room_service .clear_room_cover(rid, *user_id) .await .map_err(ApiError::from)?; self.room_cache_fanout.publish_invalidation(&rid); self.room_response_after_room_update(&room, user_id).await } pub async fn join_room_with_control( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::JoinRoomRequest, client_ip: Option<&str>, request_control: Option<&ExecutionControl>, ) -> Result { crate::impls::validate_proto_request(&req)?; let uid = *user_id; let rid = self.parse_room_id(room_id)?; let remark_name = crate::impls::normalize_member_remark_name(&req.remark_name); let display_tag = crate::impls::normalize_member_display_tag(&req.display_tag); let password = if req.password.is_empty() { None } else { validate_room_password_for_verify(&req.password)?; Some(req.password) }; let password_enabled = self .room_service .is_room_password_enabled(&rid) .await .map_err(ApiError::from)?; if password_enabled { let password = password.as_ref().ok_or_else(|| { ApiError::Authorization("Forbidden: Password required".to_string()) })?; let parsed_client_ip = parse_optional_client_ip(client_ip)?; if !self .room_service .check_room_password_with_rate_limit_with_control( &rid, password, parsed_client_ip, request_control, ) .await .map_err(ApiError::from)? { return Err(ApiError::Authorization( "Forbidden: Invalid password".to_string(), )); } } let room_settings = self .room_service .get_room_settings(&rid) .await .map_err(ApiError::from)?; let target_presence = self .presence_service .user_room_stats_fresh(uid, rid) .await .map_err(ApiError::from)?; let prepared_membership_fanout = self .membership_event_fanout .prepare_permission_changed_outbox_fanout( target_presence.is_online, target_presence.connection_count, ); let (_room, members) = self .room_service .join_room_with_outbox( rid, uid, password, remark_name, display_tag, Some(prepared_membership_fanout.outbox_factory()), ) .await .map_err(ApiError::from)?; prepared_membership_fanout.publish_after_outbox_commit(); // Get updated room and playback state let room = self .room_service .get_room(&rid) .await .map_err(ApiError::from)?; let playback_state = self.load_room_playback_state_proto(&rid).await?; let proto_members = try_members_to_proto( &members, &room_settings, self.room_service.permission_service(), &self.public_id_codec, )?; let requires_approval = proto_members.is_empty(); Ok(synctv_proto::client::JoinRoomResponse { room: Some( self.room_to_proto_basic_with_loaded_cover( &room, Some(&room_settings), self.load_room_member_count(&rid).await?, ) .await?, ), members: proto_members, playback_state: Some(playback_state), requires_approval, }) } pub async fn start_room_password_login_with_control( &self, user_id: &UserId, req: synctv_proto::client::StartRoomPasswordLoginRequest, client_ip: Option<&str>, request_control: Option<&ExecutionControl>, ) -> Result { crate::impls::validate_proto_request(&req)?; let uid = *user_id; let rid = self.parse_room_id(&req.room_id)?; let parsed_client_ip = parse_optional_client_ip(client_ip)?; let challenge = self .room_service .start_room_opaque_password_login_with_control( &rid, &uid, req.credential_request, parsed_client_ip, request_control, ) .await .map_err(ApiError::from)?; Ok(synctv_proto::client::StartRoomPasswordLoginResponse { session_id: challenge.session_id, credential_response: challenge.credential_response, }) } pub async fn finish_room_password_login_with_control( &self, user_id: &UserId, expected_room_id: Option<&str>, req: synctv_proto::client::FinishRoomPasswordLoginRequest, client_ip: Option<&str>, ) -> Result { crate::impls::validate_proto_request(&req)?; let uid = *user_id; let expected_room_id = expected_room_id .map(|room_id| self.parse_room_id(room_id)) .transpose()?; let parsed_client_ip = parse_optional_client_ip(client_ip)?; let target_presence = if let Some(room_id) = expected_room_id { Some( self.presence_service .user_room_stats_fresh(uid, room_id) .await .map_err(ApiError::from)?, ) } else { None }; let prepared_membership_fanout = self .membership_event_fanout .prepare_permission_changed_outbox_fanout( target_presence .as_ref() .is_some_and(|presence| presence.is_online), target_presence .as_ref() .map_or(0, |presence| presence.connection_count), ); let (room, members) = Box::pin( self.room_service .finish_room_opaque_password_login_with_outbox( expected_room_id.as_ref(), &req.session_id, &uid, req.credential_finalization, parsed_client_ip, Some(prepared_membership_fanout.outbox_factory()), ), ) .await .map_err(ApiError::from)?; prepared_membership_fanout.publish_after_outbox_commit(); let rid = room.id; let room_settings = self .room_service .get_room_settings(&rid) .await .map_err(ApiError::from)?; let playback_state = self.load_room_playback_state_proto(&rid).await?; let proto_members = try_members_to_proto( &members, &room_settings, self.room_service.permission_service(), &self.public_id_codec, )?; let requires_approval = proto_members.is_empty(); Ok(synctv_proto::client::JoinRoomResponse { room: Some( self.room_to_proto_basic_with_loaded_cover( &room, Some(&room_settings), self.load_room_member_count(&rid).await?, ) .await?, ), members: proto_members, playback_state: Some(playback_state), requires_approval, }) } pub async fn create_websocket_ticket_with_control( &self, user_id: &UserId, password_version: i32, req: synctv_proto::client::CreateWebSocketTicketRequest, request_control: Option<&ExecutionControl>, ) -> Result { let room_id = build_create_websocket_ticket_request(&req, &self.public_id_codec)?; let requested_room_id = req.room_id; let ws_ticket_service = &self.ws_ticket_service; let room = self .room_service .get_room(&room_id) .await .map_err(|err| match err { synctv_core::Error::NotFound(_) => { ApiError::NotFound(format!("Room {requested_room_id} not found")) } other => ApiError::from(other), })?; if room.is_banned { return Err(ApiError::Authorization("Room is banned".to_string())); } let is_member = self .room_service .member_service() .is_member(&room_id, user_id) .await .map_err(ApiError::from)?; if !is_member { return Err(ApiError::Authorization( "Not a member of this room. Join the room first.".to_string(), )); } let ticket = ws_ticket_service .create_ticket_with_control(user_id, &room_id, password_version, request_control) .await .map_err(ApiError::from)?; self.room_service .record_room_visit(&room_id, user_id) .await .map_err(ApiError::from)?; let public_room_id = self .public_id_codec .encode_room_id(room_id) .map_err(ApiError::from)?; Ok(synctv_proto::client::CreateWebSocketTicketResponse { ticket, room_id: public_room_id.clone(), expires_in_secs: ws_ticket_service.ticket_ttl_secs(), usage: format!("Use in WebSocket URL: ws://host/ws/rooms/{public_room_id}?ticket=xxx"), }) } pub async fn create_websocket_ticket_for_actor_with_control( &self, actor: RoomActor, req: synctv_proto::client::CreateWebSocketTicketRequest, request_control: Option<&ExecutionControl>, ) -> Result { let room_id = build_create_websocket_ticket_request(&req, &self.public_id_codec)?; let requested_room_id = req.room_id; if actor.room_id() != room_id { return Err(ApiError::Authorization( "Cannot create a WebSocket ticket for a different room".to_string(), )); } let ws_ticket_service = &self.ws_ticket_service; let room = self .room_service .get_room(&room_id) .await .map_err(|err| match err { synctv_core::Error::NotFound(_) => { ApiError::NotFound(format!("Room {requested_room_id} not found")) } other => ApiError::from(other), })?; if room.is_banned { return Err(ApiError::Authorization("Room is banned".to_string())); } let ticket = match actor { RoomActor::User { user_id, .. } => { let password_version = self .user_service .get_password_credential_state(&user_id) .await .map_err(ApiError::from)? .version; let ticket = ws_ticket_service .create_ticket_with_control( &user_id, &room_id, password_version, request_control, ) .await .map_err(ApiError::from)?; self.room_service .record_room_visit(&room_id, &user_id) .await .map_err(ApiError::from)?; ticket } RoomActor::Guest(access) => ws_ticket_service .create_guest_ticket_with_control( synctv_core::service::CreateGuestTicketRequest { room_id, guest_id: access.guest_id, display_name: access.display_name, session_id: access.session_id, token_jti: access.token_jti, room_guest_version: access.room_guest_version, permissions: access.permissions, }, request_control, ) .await .map_err(ApiError::from)?, }; let public_room_id = self .public_id_codec .encode_room_id(room_id) .map_err(ApiError::Internal)?; Ok(synctv_proto::client::CreateWebSocketTicketResponse { ticket, room_id: public_room_id.clone(), expires_in_secs: ws_ticket_service.ticket_ttl_secs(), usage: format!("Use in WebSocket URL: ws://host/ws/rooms/{public_room_id}?ticket=xxx"), }) } pub async fn leave_room( &self, user_id: &UserId, room_id: &str, ) -> Result { let uid = *user_id; let rid = self.parse_room_id(room_id)?; let prepared_membership_fanout = self .membership_event_fanout .prepare_user_left_outbox_fanout(); let username = self.user_username_for_event(&uid).await?; let prepared_cleanup_fanout = prepare_delete_entries_outbox_fanout(PrepareDeleteEntriesOutboxFanout { clock: self.clock.clone(), media_fanout: self.media_fanout.clone(), playlist_fanout: self.playlist_fanout.clone(), playback_fanout: self.playback_fanout.clone(), realtime_fanout: self.realtime_fanout.clone(), room_id: rid, user_id: uid, username, }); self.room_service .leave_room_with_outbox( rid, uid, Some(prepared_membership_fanout.outbox_factory()), Some(prepared_cleanup_fanout.member_cleanup_outbox_factory()), ) .await .map_err(ApiError::from)?; // Force disconnect the user's room-scoped connections and any local // publishers tied to the room they just left. self.realtime_lifecycle .disconnect_user_from_room(&rid, &uid) .await; prepared_membership_fanout.publish_after_outbox_commit(); prepared_cleanup_fanout.publish_after_outbox_commit(); Ok(synctv_proto::client::LeaveRoomResponse { success: true }) } pub async fn delete_room( &self, user_id: &UserId, room_id: &str, ) -> Result { let uid = *user_id; let rid = self.parse_room_id(room_id)?; let prepared_outbox_fanout = self .room_lifecycle_fanout .prepare_room_deleted_outbox_fanout(&rid, &uid)?; // 1. Delete the DB record first. If this fails, no realtime event is // published and no connections are dropped -- the room remains intact. self.room_service .delete_room_with_outbox(rid, uid, Some(prepared_outbox_fanout.cloned_outbox_event())) .await .map_err(ApiError::from)?; prepared_outbox_fanout.publish_after_outbox_commit(); // Force disconnect room members and any active publishers tied to this room. self.realtime_lifecycle .disconnect_room(&rid, synctv_realtime::sync::RoomDisconnectReason::Deleted) .await; Ok(synctv_proto::client::DeleteRoomResponse { success: true }) } pub async fn update_room_settings( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::UpdateRoomSettingsRequest, ) -> Result { let uid = *user_id; let rid = self.parse_room_id(room_id)?; let current_settings = self .room_service .get_room_settings(&rid) .await .map_err(ApiError::from)?; let settings = validate_update_room_settings_request(&req, current_settings)?; let username = self.user_username_for_event(&uid).await?; let prepared_settings_fanout = self .room_settings_fanout .prepare_settings_changed(&rid, &uid, &username)?; let snapshot = self .room_service .set_settings_with_outbox( rid, uid, settings, Some(prepared_settings_fanout.settings_outbox_factory()), ) .await .map_err(ApiError::from)?; let prepared_settings_fanout = prepared_settings_fanout .with_settings_and_version(&snapshot.settings, snapshot.version) .map_err(ApiError::from)?; self.room_settings_fanout .publish_prepared_after_outbox_commit(prepared_settings_fanout); self.room_cache_fanout.publish_invalidation(&rid); // Get updated room let room = self .room_service .get_room(&rid) .await .map_err(ApiError::from)?; self.room_to_proto_basic_with_loaded_cover( &room, Some(&snapshot.settings), self.load_room_member_count(&rid).await?, ) .await } pub async fn update_room_visibility( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::UpdateRoomVisibilityRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let rid = self.parse_room_id(room_id)?; let room = self .room_service .update_room_visibility(&rid, user_id, req.is_public) .await .map_err(ApiError::from)?; self.room_cache_fanout.publish_invalidation(&rid); let settings = self .room_service .get_room_settings(&rid) .await .map_err(ApiError::from)?; self.room_to_proto_basic_with_loaded_cover( &room, Some(&settings), self.load_room_member_count(&rid).await?, ) .await } pub async fn start_room_password_registration( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::StartRoomPasswordRegistrationRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let uid = *user_id; let rid = self.parse_room_id(room_id)?; let challenge = self .room_service .start_room_opaque_password_registration(&rid, &uid, req.registration_request) .await .map_err(ApiError::from)?; Ok( synctv_proto::client::StartRoomPasswordRegistrationResponse { session_id: challenge.session_id, registration_response: challenge.registration_response, }, ) } pub async fn finish_room_password_registration( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::FinishRoomPasswordRegistrationRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let uid = *user_id; let rid = self.parse_room_id(room_id)?; let username = self.user_username_for_event(&uid).await?; let state = self .room_service .finish_room_opaque_password_registration( &rid, &req.session_id, &uid, req.registration_upload, ) .await .map_err(ApiError::from)?; self.room_cache_fanout.publish_invalidation(&state.room_id); tracing::debug!( room_id = %state.room_id, user_id = %uid, username = %username, password_enabled = state.enabled, password_version = state.version, "Room password updated" ); Ok(synctv_proto::client::SetRoomPasswordResponse { success: true }) } pub async fn clear_room_password( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::ClearRoomPasswordRequest, ) -> Result { crate::impls::validate_proto_request(&req)?; let uid = *user_id; let rid = self.parse_room_id(room_id)?; self.room_service .check_permission( &rid, &uid, synctv_core::models::RoomPermission::MANAGE_ROOM_SETTINGS, ) .await .map_err(ApiError::from)?; let state = self .room_service .update_room_password_as(&rid, Some(&uid), None) .await .map_err(ApiError::from)?; self.room_cache_fanout.publish_invalidation(&rid); tracing::debug!( room_id = %rid, user_id = %uid, password_enabled = state.enabled, password_version = state.version, "Room password cleared" ); Ok(synctv_proto::client::SetRoomPasswordResponse { success: true }) } /// Get room settings /// /// Requires the caller to be a member of the room. pub async fn get_room_settings( &self, user_id: &UserId, room_id: &str, ) -> Result { let actor = self.room_actor_for_user(user_id, room_id).await?; self.get_room_settings_for_actor(&actor).await } pub async fn get_room_settings_for_actor( &self, actor: &RoomActor, ) -> Result { let rid = actor.room_id(); let (settings, version) = self .room_service .get_room_settings_with_version(&rid) .await .map_err(ApiError::from)?; Ok(synctv_proto::client::GetRoomSettingsResponse { settings: Some(room_settings_to_proto(&settings)), version, }) } /// Reset room settings to defaults pub async fn reset_room_settings( &self, user_id: &UserId, room_id: &str, ) -> Result { let uid = *user_id; let rid = self.parse_room_id(room_id)?; let username = self.user_username_for_event(&uid).await?; let prepared_settings_fanout = self .room_settings_fanout .prepare_settings_changed(&rid, &uid, &username)?; let snapshot = self .room_service .reset_room_settings_with_outbox( &rid, &uid, Some(prepared_settings_fanout.settings_outbox_factory()), ) .await .map_err(ApiError::from)?; self.room_settings_fanout .publish_prepared_after_outbox_commit( prepared_settings_fanout .with_settings_and_version(&snapshot.settings, snapshot.version)?, ); self.room_cache_fanout.publish_invalidation(&rid); Ok(room_settings_to_proto(&snapshot.settings)) } pub async fn transfer_room_ownership( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::TransferRoomOwnershipRequest, ) -> Result { let current_owner_id = *user_id; let rid = self.parse_room_id(room_id)?; let new_owner_id = build_transfer_room_ownership_request(req, &self.public_id_codec)?; let target_presence = self .presence_service .user_room_stats_fresh(current_owner_id, rid) .await .map_err(ApiError::from)?; let prepared_membership_fanout = self .membership_event_fanout .prepare_permission_changed_outbox_fanout( target_presence.is_online, target_presence.connection_count, ); let room = self .room_service .transfer_room_ownership_with_outbox( rid, current_owner_id, new_owner_id, Some(prepared_membership_fanout.outbox_factory()), ) .await .map_err(Self::map_room_access_error)?; prepared_membership_fanout.publish_after_outbox_commit(); self.room_cache_fanout.publish_invalidation(&rid); let settings = self .room_service .get_room_settings(&rid) .await .map_err(ApiError::from)?; self.room_to_proto_basic_with_loaded_cover( &room, Some(&settings), self.load_room_member_count(&rid).await?, ) .await } /// Get public settings pub fn get_public_settings( &self, ) -> Result { let reg = self .runtime_settings_store .as_ref() .ok_or_else(runtime_settings_store_unavailable_error)?; let s = reg.to_public_settings().map_err(ApiError::from)?; Ok(synctv_proto::client::GetPublicSettingsResponse { server_name: s.server_name, room_creation_enabled: s.room_creation_enabled, max_rooms_per_user: s.max_rooms_per_user, default_max_members: s.default_max_members, max_pinned_chat_messages_per_room: s.max_pinned_chat_messages_per_room, room_creation_approval_required: s.approval_required, room_password_policy: match s.room_password_policy { synctv_core::service::RoomPasswordPolicy::Optional => { synctv_proto::common::RoomPasswordPolicy::Optional as i32 } synctv_core::service::RoomPasswordPolicy::Required => { synctv_proto::common::RoomPasswordPolicy::Required as i32 } synctv_core::service::RoomPasswordPolicy::Forbidden => { synctv_proto::common::RoomPasswordPolicy::Forbidden as i32 } }, enable_password_signup: s.enable_password_signup, password_signup_need_review: s.password_signup_need_review, enable_email_signup: s.enable_email_signup, email_signup_need_review: s.email_signup_need_review, enable_email: s.enable_email && self.email_api.is_some(), enable_webauthn: self.passkey_service.is_some(), webauthn_rp_id: self .passkey_service .as_ref() .map_or_else(String::new, |service| service.rp_id().to_string()), enable_webauthn_signup: s.enable_webauthn_signup, webauthn_signup_need_review: s.webauthn_signup_need_review, enable_guest: s.enable_guest, ts_disguised_as_png: s.ts_disguised_as_png, advertise_address: s.advertise_address, email_whitelist_enabled: s.email_whitelist_enabled, email_whitelist_domains: s.email_whitelist_domains, }) } pub async fn get_server_info( &self, ) -> Result { let reg = self .runtime_settings_store .as_ref() .ok_or_else(runtime_settings_store_unavailable_error)?; let server_id = reg .get_or_initialize_server_id() .await .map_err(ApiError::from)?; Ok(synctv_proto::client::GetServerInfoResponse { server_id, server_name: reg.server.name.get().map_err(ApiError::from)?, }) } pub fn get_server_time( &self, req: synctv_proto::client::GetServerTimeRequest, ) -> synctv_proto::client::GetServerTimeResponse { build_server_time_response(req, self.clock.as_ref()) } pub async fn get_chat_history( &self, user_id: &UserId, room_id: &str, req: synctv_proto::client::GetChatHistoryRequest, ) -> Result { let actor = self.room_actor_for_user(user_id, room_id).await?; self.get_chat_history_for_actor(&actor, req).await } async fn chat_messages_to_proto( &self, messages: Vec, ) -> Result, ApiError> { let user_ids: Vec = messages .iter() .filter_map(|message| message.message.user_id) .collect::>() .into_iter() .collect(); let username_map: std::collections::HashMap = self .user_service .get_usernames(&user_ids) .await .map_err(ApiError::from)?; messages .into_iter() .map(|message| { let (user_id_str, username) = match &message.message.user_id { Some(uid) => { let uid_str = self.public_id_codec.encode_user_id(*uid).map_err(|error| { ApiError::Internal(format!( "Failed to encode chat message user id: {error}" )) })?; let name = username_map.get(uid).cloned(); (uid_str, name) } None => (String::new(), None), }; let mut proto = chat_message_to_proto(self, &message, username)?; proto.user_id = user_id_str; Ok(proto) }) .collect::, ApiError>>() } async fn get_chat_history_for_room_id( &self, rid: &synctv_core::models::RoomId, viewer_user_id: Option, req: synctv_proto::client::GetChatHistoryRequest, ) -> Result { let (limit, cursor, selection) = build_get_chat_history_request(&req)?; let cursor = cursor .map(|(created_at, id)| synctv_core::models::ChatHistoryCursor { created_at, id }); let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let page = chat_service .get_history_page_with_attachments_for_viewer( rid, cursor, limit, true, viewer_user_id.as_ref(), &selection, ) .await .map_err(ApiError::from)?; let next_cursor_str = page.next_cursor.map(|cursor| { format!( "{}|{}", synctv_common::time::format_datetime_rfc3339(cursor.created_at), cursor.id ) }); let proto_messages = self.chat_messages_to_proto(page.messages).await?; Ok(synctv_proto::client::GetChatHistoryResponse { messages: proto_messages, next_cursor: next_cursor_str.unwrap_or_default(), event_cursor: Some(synctv_proto::client::EventCursor { event_id: page.event_cursor.event_id, sequence: page.event_cursor.sequence, }), }) } async fn search_chat_messages_for_room_id( &self, rid: &synctv_core::models::RoomId, viewer_user_id: Option, req: synctv_proto::client::SearchChatMessagesRequest, ) -> Result { let query = build_search_chat_messages_query(*rid, &req, &self.public_id_codec)?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let page = chat_service .search_messages_with_attachments_for_viewer(query, viewer_user_id.as_ref()) .await .map_err(ApiError::from)?; let next_cursor = page .next_cursor .map(|cursor| { format!( "{}|{}", synctv_common::time::format_datetime_rfc3339(cursor.created_at), cursor.id ) }) .unwrap_or_default(); let messages = self.chat_messages_to_proto(page.messages).await?; Ok(synctv_proto::client::SearchChatMessagesResponse { messages, next_cursor, event_cursor: Some(synctv_proto::client::EventCursor { event_id: page.event_cursor.event_id, sequence: page.event_cursor.sequence, }), }) } pub async fn send_chat_message_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::SendChatMessageRequest, ) -> Result { let user_id = actor.require_user_id()?; let room_id = actor.room_id(); let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let attachments = parse_proto_chat_attachments(&req.attachments)?; let playback_state = self .room_service .playback_service() .get_state(&room_id) .await .map_err(ApiError::from)?; let playback_metadata = self .room_service .playback_service() .chat_playback_metadata_for_state(&playback_state, playback_state.computed_position()) .await .map_err(ApiError::from)?; let metadata = crate::impls::messaging::chat_metadata_for_send( chat_metadata_from_proto(req.metadata.as_ref())?, &req.display_position, &req.display_color, playback_metadata, ) .map_err(ApiError::InvalidInput)?; let outcome = chat_service .send_message_event_outcome(SendChatMessage { room_id, user_id, client_message_id: optional_trimmed_string(&req.client_message_id), content: req.content, message_type: ChatMessageType::User, reply_to_message_id: if req.reply_to_message_id.trim().is_empty() { None } else { Some(parse_chat_message_id(&req.reply_to_message_id)?) }, metadata, attachments, mentions: self.parse_proto_chat_mentions(req.mentions)?, }) .await .map_err(ApiError::from)?; if outcome.inserted { self.broadcast_chat_event(&outcome.event); if let Some(pin_event) = &outcome.pin_event { self.broadcast_chat_pin_event(pin_event); } } Ok(synctv_proto::client::ChatMessageEventResponse { event: Some(chat_event_to_proto(self, outcome.event).await?), }) } pub async fn create_chat_attachment_upload_session_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::CreateChatAttachmentUploadSessionRequest, ) -> Result { let user_id = actor.require_user_id()?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let session = chat_service .create_attachment_upload_session(CreateChatAttachmentUploadSession { room_id: actor.room_id(), user_id, client_attachment_id: optional_trimmed_string(&req.client_attachment_id), filename: optional_trimmed_string(&req.filename), mime_type: req.mime_type, size_bytes: req.size_bytes, width: (req.width > 0).then_some(req.width), height: (req.height > 0).then_some(req.height), duration_seconds: (req.duration_seconds > 0).then_some(req.duration_seconds), bitrate_bps: (req.bitrate_bps > 0).then_some(req.bitrate_bps), parts: proto_upload_manifest_parts(req.parts), metadata: file_metadata_from_proto(req.metadata.as_ref())?, }) .await .map_err(ApiError::from)?; chat_attachment_upload_create_result_to_proto(session) } pub async fn upload_chat_attachment_object( &self, req: synctv_proto::client::UploadChatAttachmentObjectRequest, ) -> Result { if !req.room_id.trim().is_empty() { let _room_id = self.parse_room_id(&req.room_id)?; } let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let blob = chat_service .store_attachment_upload_object( &req.encoded_object_key, &req.token, req.content_type.as_deref(), proto_file_upload_range(req.content_range), req.data, ) .await .map_err(ApiError::from)?; let (complete, uploaded_size_bytes, uploaded_parts) = uploaded_parts_response_fields(&blob); Ok(synctv_proto::client::UploadChatAttachmentObjectResponse { object: match blob { StoreFileUploadResult::Complete(blob) => { Some(chat_attachment_object_to_proto(&req.room_id, blob)) } StoreFileUploadResult::PartAccepted { .. } => None, }, complete, uploaded_size_bytes, uploaded_parts, }) } pub async fn complete_chat_attachment_upload_session( &self, req: synctv_proto::client::CompleteChatAttachmentUploadSessionRequest, ) -> Result { let room_id = req.room_id.clone(); if !room_id.trim().is_empty() { let _room_id = self.parse_room_id(&room_id)?; } let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let result = chat_service .complete_attachment_upload_session(complete_upload_session_request( &req.file_id, req.encoded_object_key, req.token, req.upload_id, &req.ownership_proof, req.parts, )) .await .map_err(ApiError::from)?; let (complete, uploaded_size_bytes, uploaded_parts) = complete_upload_response_fields(&result); Ok( synctv_proto::client::CompleteChatAttachmentUploadSessionResponse { object: result .object .map(|blob| chat_attachment_object_to_proto(&room_id, blob)), complete, uploaded_size_bytes, uploaded_parts, }, ) } pub async fn get_chat_attachment_object( &self, req: synctv_proto::client::GetChatAttachmentObjectRequest, ) -> Result { if !req.room_id.trim().is_empty() { let _room_id = self.parse_room_id(&req.room_id)?; } let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; chat_service .get_attachment_object_stream( &req.encoded_object_key, &req.token, proto_file_range_request(req.range), ) .await .map_err(ApiError::from) } pub async fn edit_chat_message_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::EditChatMessageRequest, ) -> Result { let user_id = actor.require_user_id()?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let outcome = chat_service .edit_message_outcome(edit_chat_message_request_to_core( actor.room_id(), user_id, req, )?) .await .map_err(ApiError::from)?; if outcome.inserted { self.broadcast_chat_event(&outcome.event); if let Some(pin_event) = &outcome.pin_event { self.broadcast_chat_pin_event(pin_event); } } Ok(synctv_proto::client::ChatMessageEventResponse { event: Some(chat_event_to_proto(self, outcome.event).await?), }) } pub async fn delete_chat_message_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::DeleteChatMessageRequest, ) -> Result { let user_id = actor.require_user_id()?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let outcome = chat_service .delete_message_event_outcome(delete_chat_message_request_to_core( actor.room_id(), user_id, &req, )?) .await .map_err(ApiError::from)?; if outcome.inserted { self.broadcast_chat_event(&outcome.event); if let Some(pin_event) = &outcome.pin_event { self.broadcast_chat_pin_event(pin_event); } } Ok(synctv_proto::client::ChatMessageEventResponse { event: Some(chat_event_to_proto(self, outcome.event).await?), }) } pub async fn list_pinned_chat_messages_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::ListPinnedChatMessagesRequest, ) -> Result { self.require_room_permission( actor, synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY, ) .await?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let limit = if req.limit <= 0 { 20 } else { req.limit.min(100) }; let pinned = chat_service .list_pinned_messages_for_authorized_viewer( &actor.room_id(), actor.user_id().as_ref(), limit, ) .await .map_err(ApiError::from)?; let mut messages = Vec::with_capacity(pinned.len()); for message in pinned { messages.push(chat_pinned_message_to_proto(self, message).await?); } Ok(synctv_proto::client::ListPinnedChatMessagesResponse { messages }) } pub async fn pin_chat_message_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::PinChatMessageRequest, ) -> Result { let user_id = actor.require_user_id()?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let outcome = chat_service .pin_message_event_outcome(pin_chat_message_request_to_core( actor.room_id(), user_id, &req, )?) .await .map_err(ApiError::from)?; if outcome.inserted { self.broadcast_chat_pin_event(&outcome.event); } Ok(synctv_proto::client::ChatPinEventResponse { event: Some(chat_pin_event_to_proto(self, outcome.event).await?), }) } pub async fn unpin_chat_message_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::UnpinChatMessageRequest, ) -> Result { let user_id = actor.require_user_id()?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let outcome = chat_service .unpin_message_event_outcome(unpin_chat_message_request_to_core( actor.room_id(), user_id, &req, )?) .await .map_err(ApiError::from)?; if outcome.inserted { self.broadcast_chat_pin_event(&outcome.event); } Ok(synctv_proto::client::ChatPinEventResponse { event: Some(chat_pin_event_to_proto(self, outcome.event).await?), }) } pub async fn set_chat_reaction_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::SetChatReactionRequest, ) -> Result { let user_id = actor.require_user_id()?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let outcome = chat_service .set_reaction_event_outcome(SetChatReaction { room_id: actor.room_id(), message_id: parse_chat_message_id(&req.message_id)?, user_id, reaction_key: req.reaction_key, enabled: req.enabled, }) .await .map_err(ApiError::from)?; if outcome.inserted { self.broadcast_chat_event(&outcome.event); if let Some(pin_event) = &outcome.pin_event { self.broadcast_chat_pin_event(pin_event); } } chat_event_to_proto(self, outcome.event).await } pub async fn list_chat_reaction_users_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::ListChatReactionUsersRequest, ) -> Result { let user_id = actor.require_user_id()?; self.require_room_permission( actor, synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY, ) .await?; let (limit, cursor) = build_list_chat_reaction_users_request(&req, &self.public_id_codec)?; let cursor = cursor.map( |(reacted_at, user_id)| synctv_core::models::ChatReactionUsersCursor { reacted_at, user_id, }, ); let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let page = chat_service .list_reaction_users( &actor.room_id(), parse_chat_message_id(&req.message_id)?, &user_id, &req.reaction_key, cursor, limit, ) .await .map_err(ApiError::from)?; let user_ids = page .users .iter() .map(|reaction_user| reaction_user.user_id) .collect::>(); let username_map = self .user_service .get_usernames(&user_ids) .await .map_err(ApiError::from)?; let users = page .users .into_iter() .map(|reaction_user| { let user_id = self .public_id_codec .encode_user_id(reaction_user.user_id) .map_err(|error| { ApiError::Internal(format!( "Failed to encode chat reaction user id: {error}" )) })?; let username = username_map .get(&reaction_user.user_id) .cloned() .ok_or_else(|| { ApiError::NotFound("Chat reaction user not found".to_string()) })?; Ok(synctv_proto::client::ChatReactionUser { user_id, username, reacted_at: reaction_user.reacted_at.timestamp(), }) }) .collect::, ApiError>>()?; let next_cursor = page .next_cursor .map(|cursor| { let user_id = self .public_id_codec .encode_user_id(cursor.user_id) .map_err(|error| { ApiError::Internal(format!( "Failed to encode chat reaction cursor user id: {error}" )) })?; Ok::(format!( "{}|{}", synctv_common::time::format_datetime_rfc3339(cursor.reacted_at), user_id )) }) .transpose()?; Ok(synctv_proto::client::ListChatReactionUsersResponse { users, next_cursor: next_cursor.unwrap_or_default(), total: page.total, }) } pub async fn mark_chat_read_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::MarkChatReadRequest, ) -> Result { let user_id = actor.require_user_id()?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let state = chat_service .mark_read(MarkChatRead { room_id: actor.room_id(), user_id, message_id: parse_chat_message_id(&req.message_id)?, }) .await .map_err(ApiError::from)?; chat_read_state_to_proto(self, state) } pub async fn get_chat_read_state_for_actor( &self, actor: &RoomActor, _req: synctv_proto::client::GetChatReadStateRequest, ) -> Result { let user_id = actor.require_user_id()?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let state = chat_service .get_read_state(&actor.room_id(), &user_id) .await .map_err(ApiError::from)?; chat_read_state_to_proto(self, state) } pub async fn get_chat_message_read_receipts_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::GetChatMessageReadReceiptsRequest, ) -> Result { let user_id = actor.require_user_id()?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let page = chat_service .get_message_read_receipts( &actor.room_id(), &user_id, parse_chat_message_id(&req.message_id)?, req.page, req.page_size, ) .await .map_err(ApiError::from)?; chat_message_read_receipts_to_proto(self, page).await } fn broadcast_chat_event(&self, event: &ChatMessageEvent) { self.chat_event_dispatcher.dispatch(event); } fn broadcast_chat_pin_event(&self, event: &ChatPinEvent) { self.chat_event_dispatcher.dispatch_pin(event); } pub async fn get_chat_history_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::GetChatHistoryRequest, ) -> Result { self.require_room_permission( actor, synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY, ) .await?; self.get_chat_history_for_room_id(&actor.room_id(), actor.user_id(), req) .await } pub async fn search_chat_messages_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::SearchChatMessagesRequest, ) -> Result { self.require_room_permission( actor, synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY, ) .await?; self.search_chat_messages_for_room_id(&actor.room_id(), actor.user_id(), req) .await } pub async fn get_chat_message_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::GetChatMessageRequest, ) -> Result { self.require_room_permission( actor, synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY, ) .await?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let message = chat_service .get_message_with_attachments_for_viewer( &actor.room_id(), parse_chat_message_id(&req.message_id)?, req.include_deleted, actor.user_id().as_ref(), ) .await .map_err(ApiError::from)?; let username = username_for_chat_message(self, &message.message).await?; chat_message_to_proto(self, &message, username) } pub async fn get_chat_message_context_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::GetChatMessageContextRequest, ) -> Result { self.require_room_permission( actor, synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY, ) .await?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let context = chat_service .get_message_context_for_viewer( &actor.room_id(), parse_chat_message_id(&req.message_id)?, positive_i32(req.before_limit, 20).min(50), positive_i32(req.after_limit, 20).min(50), req.include_deleted, actor.user_id().as_ref(), ) .await .map_err(ApiError::from)?; let before_messages = context.before; let anchor = context.anchor; let after_messages = context.after; let (before, username, after) = tokio::try_join!( self.chat_messages_to_proto(before_messages), username_for_chat_message(self, &anchor.message), self.chat_messages_to_proto(after_messages), )?; let message = chat_message_to_proto(self, &anchor, username)?; Ok(synctv_proto::client::GetChatMessageContextResponse { before, message: Some(message), after, }) } pub async fn get_chat_playback_messages_for_actor( &self, actor: &RoomActor, req: synctv_proto::client::GetChatPlaybackMessagesRequest, ) -> Result { self.require_room_permission( actor, synctv_core::models::RoomPermission::VIEW_CHAT_HISTORY, ) .await?; let chat_service = self .chat_service .as_ref() .ok_or_else(chat_service_unavailable_error)?; let media_id = optional_trimmed_string(&req.playback_media_id) .map(|id| crate::impls::proto_validated_media_id(id, &self.public_id_codec)) .transpose()?; let playlist_id = optional_trimmed_string(&req.playback_playlist_id) .map(|id| crate::impls::proto_validated_playlist_id(id, &self.public_id_codec)) .transpose()?; let target = provider_target_from_proto(req.playback_target)?; let position_seconds = required_playback_position_seconds(req.position_seconds)?; let before_seconds = optional_positive_window_seconds(req.before_seconds, 0.0, "before_seconds")?; let after_seconds = optional_positive_window_seconds(req.after_seconds, 30.0, "after_seconds")?; let limit = optional_positive_limit(req.limit, 200, 500, "limit")?; let selection = chat_message_selection_from_proto_values(&req.include_message_types)?; let target_hash = synctv_core::models::try_hash_playback_target(target.as_ref()) .map_err(ApiError::from)?; let source_metadata = self .room_service .playback_service() .get_playback_source_metadata(&PlaybackSourceIdentity { room_id: actor.room_id(), media_id, playlist_id, target_hash, }) .await .map_err(ApiError::from)?; if source_metadata.is_some_and(|metadata| { matches!( metadata.playback_kind, synctv_core::models::PlaybackKind::Live ) }) { return Ok(synctv_proto::client::GetChatPlaybackMessagesResponse { messages: Vec::new(), }); } let messages = chat_service .get_playback_messages_with_attachments_for_viewer( ChatPlaybackMessagesQuery { room_id: actor.room_id(), media_id, playlist_id, target, selection, position_seconds, before_seconds, after_seconds, limit, include_deleted: req.include_deleted, }, actor.user_id().as_ref(), ) .await .map_err(ApiError::from)?; Ok(synctv_proto::client::GetChatPlaybackMessagesResponse { messages: self.chat_messages_to_proto(messages).await?, }) } pub fn parse_proto_chat_mentions( &self, mentions: Vec, ) -> Result, ApiError> { mentions .into_iter() .map(|mention| { let user_id = crate::impls::parse_user_id_param( &mention.user_id, "mention.user_id", &self.public_id_codec, )?; Ok(ChatMentionInput { user_id, start: mention.start, length: mention.length, }) }) .collect() } } pub fn validate_update_room_settings_request( req: &synctv_proto::client::UpdateRoomSettingsRequest, current: synctv_core::models::RoomSettings, ) -> Result { apply_room_settings_patch_from_proto(current, req.clone()) } #[cfg(test)] mod tests { use super::{ build_create_websocket_ticket_request, build_get_chat_history_request, build_my_room_list_query, build_room_discovery_query, build_transfer_room_ownership_request, chat_pin_event_to_proto, delete_chat_message_request_to_core, discovery_room_list_total, edit_chat_message_request_to_core, optional_positive_limit, optional_positive_window_seconds, optional_trimmed_string, parse_proto_chat_attachments, public_room_discovery_access, required_playback_position_seconds, required_room_availability, room_discovery_access, runtime_settings_store_unavailable_error, }; use crate::impls::ErrorKind; use std::collections::HashMap; use synctv_core::service::ClientResourceAvailability; use synctv_core::{ models::{ ChatMessage, ChatMessagePin, ChatMessageWithAttachments, ChatPinEvent, ChatPinEventKind, RoomId, RoomRole, RoomSettings, SignupMethod, User, }, repository::{RoomDiscoveryViewerState, UserRepository}, service::RoomService, }; type TestResult = anyhow::Result; #[test] fn discovery_room_list_total_excludes_only_available_featured_rooms() { assert_eq!(discovery_room_list_total(0, 5), 0); assert_eq!(discovery_room_list_total(3, 5), 0); assert_eq!(discovery_room_list_total(8, 5), 3); } fn test_error(message: impl Into) -> anyhow::Error { anyhow::anyhow!(message.into()) } fn api_ok(result: Result) -> TestResult { result.map_err(|error| test_error(format!("{error:?}"))) } fn api_err(result: Result) -> TestResult { match result { Ok(_) => Err(test_error("expected API error result")), Err(error) => Ok(error), } } fn assert_invalid_argument_contains( error: &crate::impls::ApiError, expected: &str, ) -> TestResult { if !error.is_invalid_argument() { return Err(test_error(format!("expected invalid input, got {error:?}"))); } let message = error.message(); assert!(message.contains(expected), "{message}"); Ok(()) } fn codec_ok(result: Result) -> TestResult { result.map_err(test_error) } fn test_client_api( pool: sqlx::PgPool, user_service: std::sync::Arc, ) -> super::ClientApiImpl { let room_service = std::sync::Arc::new( RoomService::new_for_tests(pool, (*user_service).clone()) .expect("room service should build"), ); super::ClientApiImpl::new_with_runtime( crate::impls::ClientApiOptions { read_pool: None, user_service, room_service, chat_service: None, connection_service: std::sync::Arc::new( synctv_realtime::sync::ConnectionManager::default(), ), runtime_settings: std::sync::Arc::new(crate::ApiRuntimeSettings::default()), publish_key_service: None, jwt_service: synctv_core_testing::create_test_jwt_service(), live_streaming_infrastructure: None, runtime_settings_store: None, provider_stores: std::sync::Arc::new( synctv_core::provider::ProviderStoreRegistry::local_only("test:chat-pin-api:"), ), public_id_codec: std::sync::Arc::new(synctv_adapter::PublicIdCodec::plain()), email_api: None, passkey_service: None, }, crate::test_support::client_api_runtime(), ) } async fn create_user( pool: &sqlx::PgPool, username: &str, ) -> TestResult { UserRepository::new(pool.clone()) .create(&User::new(username.to_string(), SignupMethod::Password)) .await .map_err(|error| test_error(error.to_string())) } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn favorite_room_requires_current_membership() -> TestResult { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_service = std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone())); let owner = create_user(&pool, "favorite_requires_owner").await?; let outsider = create_user(&pool, "favorite_requires_outsider").await?; let api = test_client_api(pool, user_service); let room = api_ok( api.room_service .create_room( "Favorite membership room".to_string(), String::new(), owner.id, None, None, ) .await .map_err(crate::impls::ApiError::from), )?; let room_id = codec_ok(api.public_id_codec.encode_room_id(room.id))?; let error = api_err( api.favorite_room( &outsider.id, synctv_proto::client::FavoriteRoomRequest { room_id }, ) .await, )?; assert!( matches!(error, crate::impls::ApiError::Authorization(ref message) if message.contains(synctv_common::messages::NOT_A_MEMBER_OF_THIS_ROOM)), "expected membership authorization error, got {error:?}" ); Ok(()) } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn room_update_response_preserves_favorite_state() -> TestResult { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_service = std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone())); let owner = create_user(&pool, "favorite_update_owner").await?; let api = test_client_api(pool, user_service); let room = api_ok( api.room_service .create_room( "Favorite update response room".to_string(), String::new(), owner.id, None, None, ) .await .map_err(crate::impls::ApiError::from), )?; let room_id = codec_ok(api.public_id_codec.encode_room_id(room.id))?; api_ok( api.favorite_room( &owner.id, synctv_proto::client::FavoriteRoomRequest { room_id }, ) .await, )?; let response = api_ok(api.room_response_after_room_update(&room, &owner.id).await)?; assert!(response.favorited); Ok(()) } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn leaving_room_removes_favorite_room() -> TestResult { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_service = std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone())); let owner = create_user(&pool, "favorite_leave_owner").await?; let member = create_user(&pool, "favorite_leave_member").await?; let api = test_client_api(pool, user_service); let room = api_ok( api.room_service .create_room( "Favorite leave room".to_string(), String::new(), owner.id, None, None, ) .await .map_err(crate::impls::ApiError::from), )?; api_ok( api.room_service .add_member(room.id, owner.id, member.id, RoomRole::Member, false) .await .map_err(crate::impls::ApiError::from), )?; let room_id = codec_ok(api.public_id_codec.encode_room_id(room.id))?; api_ok( api.favorite_room( &member.id, synctv_proto::client::FavoriteRoomRequest { room_id: room_id.clone(), }, ) .await, )?; api_ok( api.user_service .block_user(&member.id, &owner.id) .await .map(|_| ()) .map_err(crate::impls::ApiError::from), )?; let favorites_before_leave = api_ok( api.list_favorite_rooms( &member.id, synctv_proto::client::ListFavoriteRoomsRequest { page: 1, page_size: 20, search: String::new(), }, ) .await, )?; assert_eq!(favorites_before_leave.total, 1); assert_eq!(favorites_before_leave.rooms.len(), 1); assert!(favorites_before_leave.rooms[0].creator_blocked); let discovery = api_ok( api.get_room_discovery( &member.id, synctv_proto::client::GetRoomDiscoveryRequest { room_id: room_id.clone(), }, ) .await, )?; assert!(discovery .room .as_ref() .is_some_and(|room| room.creator_blocked)); api_ok(api.leave_room(&member.id, &room_id).await)?; let favorites_after_leave = api_ok( api.list_favorite_rooms( &member.id, synctv_proto::client::ListFavoriteRoomsRequest { page: 1, page_size: 20, search: String::new(), }, ) .await, )?; assert_eq!(favorites_after_leave.total, 0); assert!(favorites_after_leave.rooms.is_empty()); assert!(!api .room_service .is_room_favorited_by_user(&member.id, &room.id) .await .map_err(|error| test_error(error.to_string()))?); Ok(()) } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn banned_room_remains_visible_to_members_and_rejects_entry() -> TestResult { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_service = std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone())); let owner = create_user(&pool, "banned_room_visible_owner").await?; let member = create_user(&pool, "banned_room_visible_member").await?; let outsider = create_user(&pool, "banned_room_visible_outsider").await?; let api = test_client_api(pool, user_service); let room = api_ok( api.room_service .create_room( "Banned room remains visible".to_string(), String::new(), owner.id, None, None, ) .await .map_err(crate::impls::ApiError::from), )?; api_ok( api.room_service .add_member(room.id, owner.id, member.id, RoomRole::Member, false) .await .map_err(crate::impls::ApiError::from), )?; api_ok( api.room_service .ban_room(&room.id, &owner.id) .await .map_err(crate::impls::ApiError::from), )?; let room_id = codec_ok(api.public_id_codec.encode_room_id(room.id))?; let my_rooms = api_ok( api.list_my_rooms( &member.id, synctv_proto::client::ListMyRoomsRequest::default(), ) .await, )?; assert_eq!(my_rooms.total, 1); let listed_room = my_rooms.rooms[0] .room .as_ref() .ok_or_else(|| test_error("my room response should include the room"))?; assert!(listed_room.is_banned); assert_eq!( listed_room.availability, synctv_proto::client::ResourceAvailability::CreatorInactive as i32 ); let room_detail = api_ok(api.get_room(&member.id, &room_id).await)?; let detail_room = room_detail .room .as_ref() .ok_or_else(|| test_error("room detail should include the room"))?; assert_eq!( detail_room.availability, synctv_proto::client::ResourceAvailability::CreatorInactive as i32 ); let discovery_request = synctv_proto::client::GetRoomDiscoveryRequest { room_id: room_id.clone(), }; let discovery = api_ok( api.get_room_discovery(&member.id, discovery_request.clone()) .await, )?; assert!(discovery.joined); assert!(!discovery.can_join); assert_eq!( discovery.access, synctv_proto::client::RoomDiscoveryAccess::Unavailable as i32 ); let outsider_error = api_err( api.get_room_discovery(&outsider.id, discovery_request.clone()) .await, )?; assert!(matches!( outsider_error, crate::impls::ApiError::NotFound(_) )); let public_error = api_err(api.get_public_room_discovery(discovery_request).await)?; assert!(matches!(public_error, crate::impls::ApiError::NotFound(_))); let ticket_error = api_err( api.create_websocket_ticket_with_control( &member.id, 0, synctv_proto::client::CreateWebSocketTicketRequest { room_id: room_id.clone(), }, None, ) .await, )?; assert!(matches!( ticket_error, crate::impls::ApiError::Authorization(ref message) if message.contains("banned") )); let leave = api_ok(api.leave_room(&member.id, &room_id).await)?; assert!(leave.success); let deleted = api_ok(api.delete_room(&owner.id, &room_id).await)?; assert!(deleted.success); Ok(()) } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn room_with_banned_creator_remains_in_member_list_as_unavailable() -> TestResult { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_service = std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone())); let owner = create_user(&pool, "banned_creator_visible_owner").await?; let member = create_user(&pool, "banned_creator_visible_member").await?; let api = test_client_api(pool, user_service); let room = api_ok( api.room_service .create_room( "Banned creator room remains visible".to_string(), String::new(), owner.id, None, None, ) .await .map_err(crate::impls::ApiError::from), )?; api_ok( api.room_service .add_member(room.id, owner.id, member.id, RoomRole::Member, false) .await .map_err(crate::impls::ApiError::from), )?; api_ok( api.user_service .ban_user( &owner.id, Some(&member.id), Some("creator lifecycle test".to_string()), ) .await .map_err(crate::impls::ApiError::from), )?; let my_rooms = api_ok( api.list_my_rooms( &member.id, synctv_proto::client::ListMyRoomsRequest::default(), ) .await, )?; assert_eq!(my_rooms.total, 1); let listed_room = my_rooms.rooms[0] .room .as_ref() .ok_or_else(|| test_error("my room response should include the room"))?; assert!(!listed_room.is_banned); assert_eq!( listed_room.availability, synctv_proto::client::ResourceAvailability::CreatorInactive as i32 ); Ok(()) } #[test] fn build_room_discovery_query_uses_popularity_inputs() -> TestResult { let public_id_codec = synctv_adapter::PublicIdCodec::plain(); let query = api_ok(build_room_discovery_query( synctv_proto::client::DiscoverRoomsRequest { page: 0, page_size: 0, search: "alpha".to_string(), category_id: String::new(), label_ids: Vec::new(), }, &public_id_codec, ))?; assert_eq!(query.pagination.page, 1); assert_eq!(query.pagination.page_size, 20); assert_eq!(query.search.as_deref(), Some("alpha")); assert_eq!(query.status, Some(synctv_core::models::RoomStatus::Active)); assert_eq!(query.is_banned, Some(false)); assert_eq!( query.sort_by, synctv_core::models::RoomListSortBy::LastActivityAt ); assert_eq!( query.sort_direction, synctv_core::models::SortDirection::Desc ); Ok(()) } #[test] fn room_discovery_marks_pending_join_request_as_non_joinable() { let (can_join, access) = room_discovery_access( RoomDiscoveryViewerState { pending_join_request: true, ..RoomDiscoveryViewerState::default() }, &RoomSettings::default(), 1, ClientResourceAvailability::Available, false, ); assert!(!can_join); assert_eq!( access, synctv_proto::client::RoomDiscoveryAccess::PendingApproval as i32 ); } #[test] fn room_discovery_prioritizes_resource_unavailability_for_joined_viewer() { let (can_join, access) = room_discovery_access( RoomDiscoveryViewerState { joined: true, ..RoomDiscoveryViewerState::default() }, &RoomSettings::default(), 1, ClientResourceAvailability::CreatorInactive, false, ); assert!(!can_join); assert_eq!( access, synctv_proto::client::RoomDiscoveryAccess::Unavailable as i32 ); } #[test] fn public_room_discovery_guest_access_requires_all_guest_conditions() { let mut settings = RoomSettings::default(); settings.allow_guest_join = synctv_core::models::room_settings::AllowGuestJoin(true); assert!(public_room_discovery_access( true, &settings, ClientResourceAvailability::Available, false, )); assert!(!public_room_discovery_access( false, &settings, ClientResourceAvailability::Available, false, )); assert!(!public_room_discovery_access( true, &settings, ClientResourceAvailability::Available, true, )); settings.allow_guest_join = synctv_core::models::room_settings::AllowGuestJoin(false); assert!(!public_room_discovery_access( true, &settings, ClientResourceAvailability::Available, false, )); assert!(!public_room_discovery_access( true, &RoomSettings::default(), ClientResourceAvailability::CreatorInactive, false, )); } #[test] fn build_my_room_list_query_maps_filters_sorting_and_defaults() -> TestResult { let query = api_ok(build_my_room_list_query( synctv_proto::client::ListMyRoomsRequest { page: 0, page_size: 0, search: "alpha".to_string(), status: synctv_proto::common::RoomStatus::Closed as i32, is_banned: Some(false), relation: synctv_proto::client::MyRoomRelation::Participating as i32, sort_by: synctv_proto::client::MyRoomListSortBy::Name as i32, sort_direction: synctv_proto::client::SortDirection::Asc as i32, }, ))?; assert_eq!(query.pagination.page, 1); assert_eq!(query.pagination.page_size, 20); assert_eq!(query.search.as_deref(), Some("alpha")); assert_eq!(query.status, Some(synctv_core::models::RoomStatus::Closed)); assert_eq!(query.is_banned, Some(false)); assert_eq!( query.relation, synctv_core::models::MyRoomRelation::Participating ); assert_eq!(query.sort_by, synctv_core::models::MyRoomListSortBy::Name); assert_eq!( query.sort_direction, synctv_core::models::SortDirection::Asc ); Ok(()) } #[test] fn build_my_room_list_query_defaults_relation_to_all() -> TestResult { let query = api_ok(build_my_room_list_query( synctv_proto::client::ListMyRoomsRequest { page: 1, page_size: 20, search: String::new(), status: synctv_proto::common::RoomStatus::Unspecified as i32, is_banned: None, relation: synctv_proto::client::MyRoomRelation::Unspecified as i32, sort_by: synctv_proto::client::MyRoomListSortBy::Unspecified as i32, sort_direction: synctv_proto::client::SortDirection::Unspecified as i32, }, ))?; assert_eq!(query.relation, synctv_core::models::MyRoomRelation::All); assert_eq!( query.sort_by, synctv_core::models::MyRoomListSortBy::Frequent ); assert_eq!( query.sort_direction, synctv_core::models::SortDirection::Desc ); Ok(()) } #[test] fn build_my_room_list_query_rejects_unknown_room_status() -> TestResult { let error = api_err(build_my_room_list_query( synctv_proto::client::ListMyRoomsRequest { page: 1, page_size: 20, search: String::new(), status: 99, is_banned: None, relation: synctv_proto::client::MyRoomRelation::Unspecified as i32, sort_by: synctv_proto::client::MyRoomListSortBy::Unspecified as i32, sort_direction: synctv_proto::client::SortDirection::Unspecified as i32, }, ))?; assert_invalid_argument_contains(&error, "status")?; Ok(()) } #[test] fn my_room_query_rejects_unknown_sort_and_relation_enums() -> TestResult { let my_room_relation_error = api_err(build_my_room_list_query( synctv_proto::client::ListMyRoomsRequest { page: 1, page_size: 20, search: String::new(), status: synctv_proto::common::RoomStatus::Unspecified as i32, is_banned: None, relation: 99, sort_by: synctv_proto::client::MyRoomListSortBy::Unspecified as i32, sort_direction: synctv_proto::client::SortDirection::Unspecified as i32, }, ))?; assert_invalid_argument_contains(&my_room_relation_error, "relation")?; let my_room_sort_error = api_err(build_my_room_list_query( synctv_proto::client::ListMyRoomsRequest { page: 1, page_size: 20, search: String::new(), status: synctv_proto::common::RoomStatus::Unspecified as i32, is_banned: None, relation: synctv_proto::client::MyRoomRelation::Unspecified as i32, sort_by: synctv_proto::client::MyRoomListSortBy::Unspecified as i32, sort_direction: 99, }, ))?; assert_invalid_argument_contains(&my_room_sort_error, "sort_direction")?; Ok(()) } #[test] fn build_my_room_list_query_rejects_too_long_search() -> TestResult { let error = api_err(build_my_room_list_query( synctv_proto::client::ListMyRoomsRequest { page: 1, page_size: 20, search: "a".repeat(101), status: synctv_proto::common::RoomStatus::Unspecified as i32, is_banned: None, relation: synctv_proto::client::MyRoomRelation::Unspecified as i32, sort_by: synctv_proto::client::MyRoomListSortBy::Unspecified as i32, sort_direction: synctv_proto::client::SortDirection::Unspecified as i32, }, ))?; assert_invalid_argument_contains(&error, "search")?; Ok(()) } #[test] fn build_room_discovery_query_rejects_invalid_proto_request() -> TestResult { let public_id_codec = synctv_adapter::PublicIdCodec::plain(); let error = api_err(build_room_discovery_query( synctv_proto::client::DiscoverRoomsRequest { page: -1, page_size: 101, search: "a".repeat(101), category_id: String::new(), label_ids: Vec::new(), }, &public_id_codec, ))?; assert!(error.is_invalid_argument(), "{error:?}"); let message = error.message(); assert!(message.contains("page"), "{message}"); assert!(message.contains("page_size"), "{message}"); assert!(message.contains("search"), "{message}"); Ok(()) } #[test] fn build_transfer_room_ownership_request_rejects_invalid_new_owner_user_id() -> TestResult { let codec = synctv_adapter::PublicIdCodec::plain(); let error = api_err(build_transfer_room_ownership_request( synctv_proto::client::TransferRoomOwnershipRequest { new_owner_user_id: "bad-id".to_string(), }, &codec, ))?; assert_invalid_argument_contains(&error, "new_owner_user_id")?; Ok(()) } #[test] fn build_create_websocket_ticket_request_rejects_invalid_room_id() -> TestResult { let codec = synctv_adapter::PublicIdCodec::plain(); let error = api_err(build_create_websocket_ticket_request( &synctv_proto::client::CreateWebSocketTicketRequest { room_id: "bad-room".to_string(), }, &codec, ))?; assert_invalid_argument_contains(&error, "room_id")?; Ok(()) } #[test] fn build_create_websocket_ticket_request_parses_proto_validated_room_id() -> TestResult { let codec = synctv_adapter::PublicIdCodec::plain(); let room_id = synctv_core::models::RoomId::expect_positive(123); let room_public_id = codec_ok(codec.encode_room_id(room_id))?; let parsed = api_ok(build_create_websocket_ticket_request( &synctv_proto::client::CreateWebSocketTicketRequest { room_id: room_public_id, }, &codec, ))?; assert_eq!(parsed, room_id); Ok(()) } #[test] fn build_create_websocket_ticket_request_rejects_proto_valid_but_undecodable_room_id( ) -> TestResult { let codec = synctv_adapter::PublicIdCodec::plain(); let error = api_err(build_create_websocket_ticket_request( &synctv_proto::client::CreateWebSocketTicketRequest { room_id: "room_abc".to_string(), }, &codec, ))?; assert_invalid_argument_contains(&error, "RoomId")?; Ok(()) } #[test] fn build_get_chat_history_request_rejects_invalid_limit() -> TestResult { let error = api_err(build_get_chat_history_request( &synctv_proto::client::GetChatHistoryRequest { limit: 101, cursor: String::new(), include_message_types: Vec::new(), }, ))?; assert_invalid_argument_contains(&error, "limit")?; Ok(()) } #[test] fn required_room_availability_rejects_missing_batch_entry() -> TestResult { let room_id = synctv_core::models::RoomId::expect_positive(456); let map = HashMap::from([(room_id, ClientResourceAvailability::CreatorInactive)]); assert_eq!( api_ok(required_room_availability(&map, &room_id))?, ClientResourceAvailability::CreatorInactive ); let missing_room_id = synctv_core::models::RoomId::expect_positive(457); let error = api_err(required_room_availability(&map, &missing_room_id))?; assert!(matches!( error, crate::impls::ApiError::Internal(message) if message.contains("Missing client availability for room") )); Ok(()) } #[test] fn build_get_chat_history_request_rejects_invalid_cursor() -> TestResult { let error = api_err(build_get_chat_history_request( &synctv_proto::client::GetChatHistoryRequest { limit: 50, cursor: "not-a-cursor".to_string(), include_message_types: Vec::new(), }, ))?; match error { crate::impls::ApiError::InvalidInput(message) => { assert!(message.contains("Invalid cursor format"), "{message}"); } other => return Err(test_error(format!("expected invalid input, got {other:?}"))), } Ok(()) } #[test] fn optional_trimmed_string_normalizes_idempotency_keys() { assert_eq!( optional_trimmed_string(" client-key ").as_deref(), Some("client-key") ); assert!(optional_trimmed_string(" \n\t ").is_none()); } #[test] fn chat_playback_window_seconds_validate_explicit_values() -> TestResult { assert_eq!( api_ok(optional_positive_window_seconds(0.0, 30.0, "after_seconds"))?, 30.0 ); assert_eq!( api_ok(optional_positive_window_seconds( 12.5, 30.0, "after_seconds" ))?, 12.5 ); assert!(matches!( optional_positive_window_seconds(-1.0, 30.0, "after_seconds"), Err(crate::impls::ApiError::InvalidInput(message)) if message.contains("after_seconds") )); assert!(matches!( optional_positive_window_seconds(f64::NAN, 30.0, "after_seconds"), Err(crate::impls::ApiError::InvalidInput(message)) if message.contains("after_seconds") )); Ok(()) } #[test] fn chat_playback_limit_validates_explicit_values() -> TestResult { assert_eq!(api_ok(optional_positive_limit(0, 200, 500, "limit"))?, 200); assert_eq!(api_ok(optional_positive_limit(50, 200, 500, "limit"))?, 50); assert!(matches!( optional_positive_limit(-1, 200, 500, "limit"), Err(crate::impls::ApiError::InvalidInput(message)) if message.contains("limit") )); assert!(matches!( optional_positive_limit(501, 200, 500, "limit"), Err(crate::impls::ApiError::InvalidInput(message)) if message.contains("limit") )); Ok(()) } #[test] fn chat_playback_position_seconds_is_required_valid_value() -> TestResult { assert_eq!(api_ok(required_playback_position_seconds(42.5))?, 42.5); assert!(matches!( required_playback_position_seconds(-0.1), Err(crate::impls::ApiError::InvalidInput(message)) if message.contains("position_seconds") )); assert!(matches!( required_playback_position_seconds(f64::INFINITY), Err(crate::impls::ApiError::InvalidInput(message)) if message.contains("position_seconds") )); Ok(()) } #[tokio::test] #[ignore = "Requires Docker-backed PostgreSQL"] async fn chat_pin_event_response_preserves_optional_message_username() -> TestResult { let (_postgres, pool) = synctv_core_testing::create_test_pool().await; let user_service = std::sync::Arc::new(synctv_core_testing::create_test_user_service(pool.clone())); let user = UserRepository::new(pool.clone()) .create(&User::new( "pin_response_author".to_string(), SignupMethod::Password, )) .await .map_err(|error| test_error(error.to_string()))?; let api = test_client_api(pool, user_service); let room_id = RoomId::expect_positive(7); let occurred_at = synctv_core::SystemClock.now(); let mut message = ChatMessage::new(room_id, user.id, "pinned body".to_string()); message.id = 42; message.created_at = occurred_at; let pin = ChatMessagePin { room_id, message_id: message.id, message_created_at: message.created_at, pinned_by: Some(user.id), pinned_by_username: Some(user.username.clone()), note: None, pinned_at: occurred_at, }; let event = ChatPinEvent { event_id: "pin-response-event".to_string(), sequence: 5, room_id, actor_user_id: user.id, kind: ChatPinEventKind::Pinned, message: ChatMessageWithAttachments { message, attachments: Vec::new(), reactions: Vec::new(), mentions: Vec::new(), pin: Some(pin.clone()), }, pin: Some(pin), occurred_at, }; let proto = api_ok(chat_pin_event_to_proto(&api, event).await)?; let message = proto .message .ok_or_else(|| test_error("pin event response should contain message"))?; assert_eq!(message.username.as_deref(), Some("pin_response_author")); let mut missing_author = ChatMessage::new(room_id, user.id, "system body".to_string()); missing_author.id = 43; missing_author.user_id = None; missing_author.created_at = occurred_at; let event = ChatPinEvent { event_id: "pin-response-without-author".to_string(), sequence: 6, room_id, actor_user_id: user.id, kind: ChatPinEventKind::MessageDeleted, message: ChatMessageWithAttachments { message: missing_author, attachments: Vec::new(), reactions: Vec::new(), mentions: Vec::new(), pin: None, }, pin: None, occurred_at, }; let proto = api_ok(chat_pin_event_to_proto(&api, event).await)?; let message = proto .message .ok_or_else(|| test_error("pin event response should contain message"))?; assert_eq!(message.username, None); let mut system_message = ChatMessage::new(room_id, user.id, "playback changed".to_string()); system_message.id = 44; system_message.message_type = synctv_core::models::ChatMessageType::SystemPlaybackChanged; system_message.created_at = occurred_at; let event = ChatPinEvent { event_id: "pin-response-system-message".to_string(), sequence: 7, room_id, actor_user_id: user.id, kind: ChatPinEventKind::Pinned, message: ChatMessageWithAttachments { message: system_message, attachments: Vec::new(), reactions: Vec::new(), mentions: Vec::new(), pin: None, }, pin: None, occurred_at, }; let proto = api_ok(chat_pin_event_to_proto(&api, event).await)?; let message = proto .message .ok_or_else(|| test_error("pin event response should contain message"))?; assert_eq!(message.username, None); Ok(()) } #[test] fn chat_attachment_display_proto_hides_upload_token_metadata() -> TestResult { let metadata = synctv_core::models::FileMetadata { width: Some(640), height: Some(480), blurhash: Some("abc".to_string()), ..Default::default() }; let attachment = synctv_core::models::NewStoredFile { filename: None, id: "attachment-1".to_string(), storage_backend: "database".to_string(), object_key: "rooms/1/chat/2/attachment-1".to_string(), object_access: None, url: Some("https://cdn.example.test/rooms/1/chat/2/attachment-1.webp".to_string()), mime_type: Some("image/webp".to_string()), size_bytes: Some(1024), width: Some(640), height: Some(480), metadata: metadata.clone(), }; let proto = api_ok(super::new_chat_attachment_to_proto(&attachment))?; assert_eq!(proto.id, "attachment-1"); assert_eq!( proto.url, "https://cdn.example.test/rooms/1/chat/2/attachment-1.webp" ); let proto_metadata = proto.metadata.expect("metadata should be present"); assert_eq!(proto_metadata.width, Some(640)); assert_eq!(proto_metadata.height, Some(480)); Ok(()) } #[test] fn chat_attachment_upload_session_returns_submit_reference() -> TestResult { let attachment = synctv_core::models::NewStoredFile { filename: None, id: "attachment-1".to_string(), storage_backend: "database".to_string(), object_key: "rooms/1/chat/2/attachment-1".to_string(), object_access: None, url: None, mime_type: Some("image/webp".to_string()), size_bytes: Some(1024), width: Some(640), height: Some(480), metadata: synctv_core::models::FileMetadata { upload_token: Some("v1.payload.signature".to_string()), ..Default::default() }, }; let proto = api_ok(super::upload_session_chat_attachment_to_proto(&attachment))?; let parsed = api_ok(parse_proto_chat_attachments(std::slice::from_ref(&proto)))?; assert_eq!(proto.id, "attachment-1"); assert_eq!( proto.kind, synctv_proto::client::ChatAttachmentReferenceKind::Upload as i32 ); assert_eq!(parsed.len(), 1); assert_eq!(parsed[0].id, "attachment-1"); assert!(matches!( parsed[0].kind, synctv_core::models::SubmittedFileReferenceKind::Upload )); Ok(()) } #[test] fn chat_attachment_reuse_reference_parses_to_core() -> TestResult { let parsed = api_ok(parse_proto_chat_attachments(&[ synctv_proto::client::ChatAttachmentReference { id: "reuse-token".to_string(), kind: synctv_proto::client::ChatAttachmentReferenceKind::Reuse as i32, }, ]))?; assert_eq!(parsed.len(), 1); assert_eq!(parsed[0].id, "reuse-token"); assert!(matches!( parsed[0].kind, synctv_core::models::SubmittedFileReferenceKind::Reuse )); Ok(()) } #[test] fn chat_attachment_upload_session_requires_upload_metadata_when_upload_required() { let session = synctv_core::models::FileUploadSession { file: synctv_core::models::NewStoredFile { filename: None, id: "attachment-1".to_string(), storage_backend: "database".to_string(), object_key: "rooms/1/chat/2/attachment-1".to_string(), object_access: None, url: None, mime_type: Some("image/webp".to_string()), size_bytes: Some(1024), width: Some(640), height: Some(480), metadata: synctv_core::models::FileMetadata { upload_token: Some("v1.payload.signature".to_string()), ..Default::default() }, }, encoded_object_key: "encoded-attachment-1".to_string(), upload_required: true, ownership_proof_required: false, ownership_proof_nonce: None, ownership_proof_ranges: Vec::new(), upload_object_access: None, upload_url: Some("https://upload.example.test/attachment-1".to_string()), upload_method: None, upload_headers: Default::default(), expires_at: Some(synctv_core::SystemClock.now()), max_size_bytes: 1024 * 1024, resumable: true, part_size_bytes: 4 * 1024 * 1024, uploaded_size_bytes: 0, uploaded_parts: Vec::new(), upload_id: None, part_urls: Vec::new(), }; assert!(matches!( super::upload_session_to_proto(session), Err(crate::impls::ApiError::Internal(message)) if message.contains("upload_method") )); } #[test] fn chat_attachment_upload_session_accepts_multipart_upload_targets() { let session = synctv_core::models::FileUploadSession { file: synctv_core::models::NewStoredFile { filename: None, id: "attachment-1".to_string(), storage_backend: "s3".to_string(), object_key: "rooms/1/chat/2/attachment-1".to_string(), object_access: None, url: None, mime_type: Some("image/webp".to_string()), size_bytes: Some(1024), width: Some(640), height: Some(480), metadata: synctv_core::models::FileMetadata { upload_token: Some("v1.payload.signature".to_string()), ..Default::default() }, }, encoded_object_key: "encoded-attachment-1".to_string(), upload_required: true, ownership_proof_required: false, ownership_proof_nonce: None, ownership_proof_ranges: Vec::new(), upload_object_access: None, upload_url: Some("https://upload.example.test/attachment-1".to_string()), upload_method: Some("PUT".to_string()), upload_headers: Default::default(), expires_at: Some(synctv_core::SystemClock.now()), max_size_bytes: 1024 * 1024, resumable: true, part_size_bytes: 4 * 1024 * 1024, uploaded_size_bytes: 0, uploaded_parts: Vec::new(), upload_id: Some("upload-id".to_string()), part_urls: vec![synctv_core::models::FileUploadPartUrl { part_number: 1, offset_bytes: 0, size_bytes: 1024, upload_url: "https://upload.example.test/part-1".to_string(), upload_method: "PUT".to_string(), upload_headers: Default::default(), expires_at: Some(synctv_core::SystemClock.now()), }], }; let proto = api_ok(super::upload_session_to_proto(session)) .expect("multipart upload session should convert"); assert_eq!( proto.upload_url.as_deref(), Some("https://upload.example.test/attachment-1") ); assert_eq!(proto.upload_method.as_deref(), Some("PUT")); assert_eq!(proto.part_urls.len(), 1); assert_eq!(proto.part_urls[0].upload_method, "PUT"); } #[test] fn edit_chat_message_request_maps_client_operation_id() -> TestResult { let request = synctv_proto::client::EditChatMessageRequest { message_id: "42".to_string(), content: "hello".to_string(), expected_version: 7, metadata: None, client_operation_id: " edit-op-42 ".to_string(), }; let core = api_ok(edit_chat_message_request_to_core( synctv_core::models::RoomId::expect_positive(9), synctv_core::models::UserId::expect_positive(11), request, ))?; assert_eq!( core.room_id, synctv_core::models::RoomId::expect_positive(9) ); assert_eq!( core.user_id, synctv_core::models::UserId::expect_positive(11) ); assert_eq!(core.message_id, 42); assert_eq!(core.client_operation_id.as_deref(), Some("edit-op-42")); assert_eq!(core.expected_version, Some(7)); Ok(()) } #[test] fn delete_chat_message_request_maps_client_operation_id() -> TestResult { let request = synctv_proto::client::DeleteChatMessageRequest { message_id: "42".to_string(), expected_version: 7, reason: " cleanup ".to_string(), client_operation_id: " delete-op-42 ".to_string(), }; let core = api_ok(delete_chat_message_request_to_core( synctv_core::models::RoomId::expect_positive(9), synctv_core::models::UserId::expect_positive(11), &request, ))?; assert_eq!( core.room_id, synctv_core::models::RoomId::expect_positive(9) ); assert_eq!( core.user_id, synctv_core::models::UserId::expect_positive(11) ); assert_eq!(core.message_id, 42); assert_eq!(core.client_operation_id.as_deref(), Some("delete-op-42")); assert_eq!(core.reason.as_deref(), Some("cleanup")); assert_eq!(core.expected_version, Some(7)); Ok(()) } #[test] fn edit_chat_message_request_accepts_absent_expected_version() -> TestResult { let core = api_ok(edit_chat_message_request_to_core( synctv_core::models::RoomId::expect_positive(9), synctv_core::models::UserId::expect_positive(11), synctv_proto::client::EditChatMessageRequest { message_id: "42".to_string(), content: "hello".to_string(), expected_version: 0, metadata: None, client_operation_id: String::new(), }, ))?; assert_eq!(core.expected_version, None); Ok(()) } #[test] fn delete_chat_message_request_accepts_absent_expected_version() -> TestResult { let request = synctv_proto::client::DeleteChatMessageRequest { message_id: "42".to_string(), expected_version: 0, reason: String::new(), client_operation_id: String::new(), }; let core = api_ok(delete_chat_message_request_to_core( synctv_core::models::RoomId::expect_positive(9), synctv_core::models::UserId::expect_positive(11), &request, ))?; assert_eq!(core.expected_version, None); Ok(()) } #[test] fn chat_message_request_rejects_negative_expected_version() -> TestResult { let edit_error = api_err(edit_chat_message_request_to_core( synctv_core::models::RoomId::expect_positive(9), synctv_core::models::UserId::expect_positive(11), synctv_proto::client::EditChatMessageRequest { message_id: "42".to_string(), content: "hello".to_string(), expected_version: -1, metadata: None, client_operation_id: String::new(), }, ))?; assert!(matches!( edit_error, crate::impls::ApiError::InvalidInput(message) if message.contains("expected_version") )); let delete_request = synctv_proto::client::DeleteChatMessageRequest { message_id: "42".to_string(), expected_version: -1, reason: String::new(), client_operation_id: String::new(), }; let delete_error = api_err(delete_chat_message_request_to_core( synctv_core::models::RoomId::expect_positive(9), synctv_core::models::UserId::expect_positive(11), &delete_request, ))?; assert!(matches!( delete_error, crate::impls::ApiError::InvalidInput(message) if message.contains("expected_version") )); Ok(()) } #[test] fn chat_reaction_summary_rejects_empty_key() -> TestResult { let reaction = synctv_core::models::ChatReactionSummary { key: " ".to_string(), count: 1, reacted_by_me: false, }; let error = api_err(super::chat_reaction_summary_to_proto(&reaction))?; assert!(matches!( error, crate::impls::ApiError::Internal(message) if message.contains("reaction summary key is empty") )); Ok(()) } #[test] fn chat_reaction_summary_rejects_negative_count() -> TestResult { let reaction = synctv_core::models::ChatReactionSummary { key: "like".to_string(), count: -1, reacted_by_me: true, }; let error = api_err(super::chat_reaction_summary_to_proto(&reaction))?; assert!(matches!( error, crate::impls::ApiError::Internal(message) if message.contains("negative count") )); Ok(()) } #[test] fn chat_reaction_count_rejects_overflow() -> TestResult { let reactions = vec![ synctv_proto::client::ChatReactionSummary { key: "a".to_string(), count: i64::MAX, reacted_by_me: false, }, synctv_proto::client::ChatReactionSummary { key: "b".to_string(), count: 1, reacted_by_me: false, }, ]; let error = api_err(super::chat_reaction_count(&reactions))?; assert!(matches!( error, crate::impls::ApiError::Internal(message) if message.contains("reaction count exceeds") )); Ok(()) } #[test] fn get_public_settings_missing_registry_is_service_unavailable() { let err = runtime_settings_store_unavailable_error(); assert!(matches!(err.classify(), ErrorKind::ServiceUnavailable)); assert_eq!( err.message(), "Public settings are not available on this server." ); } #[test] fn get_server_time_echoes_client_timestamp_and_sets_server_window() { let clock = synctv_core::SyncedClock::system(); let before = clock.now_nanos(); let response = super::build_server_time_response( synctv_proto::client::GetServerTimeRequest { client_sent_at_nanos: 1_700_000_000_123_456_789, }, &clock, ); let after = clock.now_nanos(); assert_eq!(response.client_sent_at_nanos, 1_700_000_000_123_456_789); assert!(response.server_received_at_nanos >= before); assert!(response.server_received_at_nanos <= after); assert!(response.server_sent_at_nanos >= response.server_received_at_nanos); assert!(response.server_sent_at_nanos <= after); } #[test] fn room_password_set_validation_rejects_whitespace_only_password() -> TestResult { let err = api_err(super::validate_room_password_for_set(" "))?; assert!(matches!(err, crate::impls::ApiError::InvalidInput(_))); Ok(()) } #[test] fn room_password_set_validation_counts_trimmed_password_length() -> TestResult { let err = api_err(super::validate_room_password_for_set(" abc "))?; assert!(matches!(err, crate::impls::ApiError::InvalidInput(_))); api_ok(super::validate_room_password_for_set(" abcd "))?; Ok(()) } #[test] fn parse_optional_client_ip_accepts_valid_ip() -> TestResult { let parsed = api_ok(super::parse_optional_client_ip(Some("203.0.113.42")))?; let expected = "203.0.113.42".parse()?; assert_eq!(parsed, Some(expected)); Ok(()) } #[test] fn parse_optional_client_ip_rejects_invalid_ip() -> TestResult { let error = api_err(super::parse_optional_client_ip(Some("not-an-ip")))?; assert!(matches!( error, crate::impls::ApiError::InvalidInput(message) if message.contains("Invalid client IP address") )); Ok(()) } }