use crate::model::{ user_command::UserCommand, ActiveUserConnectionChange, ConfigType, LibraryScanProgressEvent, PermissionSet, PlaylistUpdateProgressEvent, PlaylistUpdateRunStateEvent, QueueRevision, RecordingTaskDto, StatusCheck, StreamMeterEntry, SystemInfo, }; use bytes::Bytes; use serde::{Deserialize, Serialize}; use std::{io, sync::Arc}; // Version 4 carries correlated run objects in PlaylistUpdateResponse. Version 3 // clients expect a bare state and must reconnect with an updated frontend. pub const PROTOCOL_VERSION: u8 = 4; #[derive(Default, PartialOrd, PartialEq, Debug, Clone)] pub enum UserRole { #[default] Unauthorized, Admin, User, } impl UserRole { pub fn is_admin(&self) -> bool { self.eq(&UserRole::Admin) } pub fn is_user(&self) -> bool { self.eq(&UserRole::User) } } #[derive(Default)] pub struct ProtocolHandlerMemory { pub token: Option, pub permissions: PermissionSet, pub role: UserRole, pub subject_id: Option, pub stream_meter_subscribed: bool, } pub enum ProtocolHandler { Version(u8), Default(ProtocolHandlerMemory), } pub enum WsCloseCode { // Normal, // Away, Protocol, // Unsupported, // Abnormal, // Invalid, // Policy, // Size, // Extension, // Error, // Restart, // Again, // Tls, } impl WsCloseCode { pub fn code(&self) -> u16 { match self { // WsCloseCode::Normal => 1000, // WsCloseCode::Away => 1001, WsCloseCode::Protocol => 1002, // WsCloseCode::Unsupported => 1003, // WsCloseCode::Abnormal => 1006, // WsCloseCode::Invalid => 1007, // WsCloseCode::Policy => 1008, // WsCloseCode::Size => 1009, // WsCloseCode::Extension => 1010, // WsCloseCode::Error => 1011, // WsCloseCode::Restart => 1012, // WsCloseCode::Again => 1013, // WsCloseCode::Tls => 1015, } } } #[allow(clippy::large_enum_variant)] #[derive(Serialize, Deserialize, Debug)] pub enum ProtocolMessage { Unauthorized, Error(String), Version(u8), Auth(String), Authorized, StreamMeterSubscribe, StreamMeterUnsubscribe, ServerError(String), StatusRequest(String), UserAction(UserCommand), // Responses StatusResponse(StatusCheck), ActiveUserResponse(ActiveUserConnectionChange), ActiveProviderResponse(Arc, usize), // single provider ActiveProviderCountRequest(String), ActiveProviderCountResponse(usize), ConfigChangeResponse(ConfigType), PlaylistUpdateResponse(PlaylistUpdateRunStateEvent), PlaylistUpdateProgressResponse(PlaylistUpdateProgressEvent), UserActionResponse(bool), SystemInfoResponse(SystemInfo), LibraryScanProgressResponse(LibraryScanProgressEvent), StreamMeterBatchResponse(Vec), /// Owner-filtered recording snapshot. The frontend requests one on /// connect and after a revision gap; the server publishes a full /// filtered list on every change. There is deliberately no delta /// message: a partial list must never be mistaken for a snapshot. RecordingSnapshotRequest, RecordingSnapshotResponse { revision: QueueRevision, /// Whether the DVR can accept work at all. Carried here so a /// client never has to poll a separate availability route. available: bool, quota: crate::model::RecordingQuotaSummaryDto, tasks: Vec, }, /// Notification that the rule repository changed. The frontend /// re-fetches `/api/v1/recording/rules` on receipt. No payload /// — the rule list is small and the GET is cheap. RecordingRulesChanged, /// Inputs in maintenance after a change. Carries the whole map: it is small, /// and a client never has to merge. MaintenanceChangedResponse(crate::model::MaintenanceEntries), /// The socket cannot serve recordings to this session, and the reason /// is actionable. /// /// Without this frame the socket answered every refusal with an empty /// task list, so a client whose token predated a permission-schema /// bump could not tell "you have no recordings" from "your token is /// too old to be trusted" — and sat on an empty library forever. /// `code` is the same stable code the REST routes return /// (`recording_token_refresh_required`, `recording_disabled`), so the /// frontend maps both surfaces through one table. RecordingWsError { code: String, }, } impl ProtocolMessage { pub fn to_bytes(&self) -> io::Result { if let ProtocolMessage::Version(version) = self { Ok(Bytes::from(vec![*version])) } else { //let encoded = bincode_serialize(self)?; let json = serde_json::to_string(self).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; Ok(Bytes::from(json.into_bytes())) } } pub fn from_bytes(bytes: Bytes) -> io::Result { if bytes.len() == 1 { Ok(ProtocolMessage::Version(bytes[0])) } else { //bincode_deserialize::(bytes.as_ref()) let s = std::str::from_utf8(&bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; serde_json::from_str(s).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } } } #[cfg(test)] mod tests { use super::{ProtocolMessage, PROTOCOL_VERSION}; use crate::model::{PlaylistUpdateRunStateEvent, PlaylistUpdateState}; use bytes::Bytes; #[test] fn playlist_update_protocol_version_distinguishes_legacy_clients() { assert_eq!(PROTOCOL_VERSION, 4); assert_eq!(ProtocolMessage::Version(PROTOCOL_VERSION).to_bytes().unwrap().as_ref(), &[4]); let ProtocolMessage::Version(legacy) = ProtocolMessage::from_bytes(Bytes::from_static(&[3])).unwrap() else { panic!("expected a protocol handshake"); }; assert_ne!(legacy, PROTOCOL_VERSION, "the server's version gate must reject legacy clients"); } #[test] fn playlist_update_protocol_preserves_correlated_frames_and_legacy_decoding() { for state in [PlaylistUpdateState::Success, PlaylistUpdateState::Partial, PlaylistUpdateState::Failure] { let event = PlaylistUpdateRunStateEvent::correlated("review-run".into(), 17.into(), state); let bytes = ProtocolMessage::PlaylistUpdateResponse(event.clone()).to_bytes().unwrap(); let ProtocolMessage::PlaylistUpdateResponse(decoded) = ProtocolMessage::from_bytes(bytes).unwrap() else { panic!("expected a correlated playlist update frame"); }; assert_eq!(decoded, event); let legacy = serde_json::to_vec(&serde_json::json!({"PlaylistUpdateResponse": state})).unwrap(); let ProtocolMessage::PlaylistUpdateResponse(decoded) = ProtocolMessage::from_bytes(Bytes::from(legacy)).unwrap() else { panic!("expected a legacy playlist update frame"); }; assert_eq!(decoded, PlaylistUpdateRunStateEvent::uncorrelated(state)); } } }