2026-02-14 12:12:59 +01:00
|
|
|
use crate::model::{
|
2026-10-04 16:02:55 +02:00
|
|
|
user_command::UserCommand, ActiveUserConnectionChange, ConfigType, LibraryScanProgressEvent, PermissionSet,
|
|
|
|
|
PlaylistUpdateProgressEvent, PlaylistUpdateRunStateEvent, QueueRevision, RecordingTaskDto, StatusCheck,
|
|
|
|
|
StreamMeterEntry, SystemInfo,
|
2026-02-14 12:12:59 +01:00
|
|
|
};
|
2025-07-21 22:17:57 +02:00
|
|
|
use bytes::Bytes;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-02-14 12:12:59 +01:00
|
|
|
use std::{io, sync::Arc};
|
2025-07-21 22:17:57 +02:00
|
|
|
|
2026-09-14 16:44:23 +02:00
|
|
|
// 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;
|
2025-07-21 22:17:57 +02:00
|
|
|
|
2025-07-29 18:19:13 +02:00
|
|
|
#[derive(Default, PartialOrd, PartialEq, Debug, Clone)]
|
2025-07-29 18:14:43 +02:00
|
|
|
pub enum UserRole {
|
|
|
|
|
#[default]
|
|
|
|
|
Unauthorized,
|
|
|
|
|
Admin,
|
|
|
|
|
User,
|
2025-07-29 10:44:55 +02:00
|
|
|
}
|
|
|
|
|
|
2025-07-29 18:14:43 +02:00
|
|
|
impl UserRole {
|
2026-02-14 12:12:59 +01:00
|
|
|
pub fn is_admin(&self) -> bool { self.eq(&UserRole::Admin) }
|
|
|
|
|
pub fn is_user(&self) -> bool { self.eq(&UserRole::User) }
|
2025-07-29 18:14:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
pub struct ProtocolHandlerMemory {
|
|
|
|
|
pub token: Option<String>,
|
2026-03-20 15:31:19 +01:00
|
|
|
pub permissions: PermissionSet,
|
2025-07-29 18:14:43 +02:00
|
|
|
pub role: UserRole,
|
2026-08-21 17:50:12 +05:00
|
|
|
pub subject_id: Option<String>,
|
2026-03-21 05:29:59 +01:00
|
|
|
pub stream_meter_subscribed: bool,
|
2025-07-29 10:44:55 +02:00
|
|
|
}
|
|
|
|
|
|
2025-07-21 22:17:57 +02:00
|
|
|
pub enum ProtocolHandler {
|
|
|
|
|
Version(u8),
|
2025-07-29 10:44:55 +02:00
|
|
|
Default(ProtocolHandlerMemory),
|
2025-07-21 22:17:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 05:29:59 +01:00
|
|
|
#[allow(clippy::large_enum_variant)]
|
2025-07-21 22:17:57 +02:00
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
|
|
|
pub enum ProtocolMessage {
|
2025-07-23 12:49:43 +02:00
|
|
|
Unauthorized,
|
|
|
|
|
Error(String),
|
2025-07-21 22:17:57 +02:00
|
|
|
Version(u8),
|
2025-07-29 10:44:55 +02:00
|
|
|
Auth(String),
|
|
|
|
|
Authorized,
|
2026-03-21 05:29:59 +01:00
|
|
|
StreamMeterSubscribe,
|
|
|
|
|
StreamMeterUnsubscribe,
|
2025-08-06 11:28:01 +02:00
|
|
|
ServerError(String),
|
2025-07-21 22:17:57 +02:00
|
|
|
StatusRequest(String),
|
2025-11-06 10:35:23 +01:00
|
|
|
UserAction(UserCommand),
|
|
|
|
|
// Responses
|
2025-07-21 22:17:57 +02:00
|
|
|
StatusResponse(StatusCheck),
|
2025-10-24 18:36:13 +02:00
|
|
|
ActiveUserResponse(ActiveUserConnectionChange),
|
2026-01-20 10:09:58 +01:00
|
|
|
ActiveProviderResponse(Arc<str>, usize), // single provider
|
2025-11-10 01:04:13 +01:00
|
|
|
ActiveProviderCountRequest(String),
|
|
|
|
|
ActiveProviderCountResponse(usize),
|
2025-08-03 10:24:23 +02:00
|
|
|
ConfigChangeResponse(ConfigType),
|
2026-09-14 16:44:23 +02:00
|
|
|
PlaylistUpdateResponse(PlaylistUpdateRunStateEvent),
|
2026-06-18 16:03:09 +02:00
|
|
|
PlaylistUpdateProgressResponse(PlaylistUpdateProgressEvent),
|
2025-11-06 10:35:23 +01:00
|
|
|
UserActionResponse(bool),
|
2025-11-29 15:37:39 +01:00
|
|
|
SystemInfoResponse(SystemInfo),
|
2026-06-18 16:03:09 +02:00
|
|
|
LibraryScanProgressResponse(LibraryScanProgressEvent),
|
2026-03-21 05:29:59 +01:00
|
|
|
StreamMeterBatchResponse(Vec<StreamMeterEntry>),
|
2026-10-04 16:02:55 +02:00
|
|
|
/// 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.
|
2026-08-21 17:50:12 +05:00
|
|
|
RecordingSnapshotRequest,
|
|
|
|
|
RecordingSnapshotResponse {
|
|
|
|
|
revision: QueueRevision,
|
2026-10-04 16:02:55 +02:00
|
|
|
/// 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<RecordingTaskDto>,
|
2026-08-21 17:50:12 +05:00
|
|
|
},
|
|
|
|
|
/// 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,
|
|
|
|
|
/// 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,
|
|
|
|
|
},
|
2025-07-21 22:17:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ProtocolMessage {
|
|
|
|
|
pub fn to_bytes(&self) -> io::Result<Bytes> {
|
2026-08-27 18:36:23 +05:00
|
|
|
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()))
|
2025-07-21 22:17:57 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn from_bytes(bytes: Bytes) -> io::Result<Self> {
|
|
|
|
|
if bytes.len() == 1 {
|
|
|
|
|
Ok(ProtocolMessage::Version(bytes[0]))
|
|
|
|
|
} else {
|
2025-07-23 12:49:43 +02:00
|
|
|
//bincode_deserialize::<ProtocolMessage>(bytes.as_ref())
|
2026-02-14 12:12:59 +01:00
|
|
|
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))
|
2025-07-21 22:17:57 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-14 12:12:59 +01:00
|
|
|
}
|
2026-09-14 16:44:23 +02:00
|
|
|
|
|
|
|
|
#[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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|