Files
tuliprox/shared/src/model/web_socket.rs
T
33ed942c16 feat(update): add per-cluster quality safeguards (#855)
* feat(update): add per-input actions and pipeline transparency

Squash feature/cluster-update-quality onto upstream/develop.

Include request-local update policies, persisted input and cluster status, library rescan and target rebuild integration, and the update-card view. Align the M3U request regression with the existing Force capability.

* del(build): temporary dev changes

* fix(update): address playlist review findings

* fix(update): rename Shows to Series

* Playlist update refactor

* Playlist update refactor

---------

Co-authored-by: euzu <euzu@proton.me>
2026-09-14 16:44:23 +02:00

195 lines
6.9 KiB
Rust

use crate::model::{
user_command::UserCommand, ActiveUserConnectionChange, ConfigType, DownloadsDelta, DownloadsResponse,
FileDownloadDto, LibraryScanProgressEvent, PermissionSet, PlaylistUpdateProgressEvent, PlaylistUpdateRunStateEvent,
QueueRevision, 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<String>,
pub permissions: PermissionSet,
pub role: UserRole,
pub subject_id: Option<String>,
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,
DownloadsRequest,
ServerError(String),
StatusRequest(String),
UserAction(UserCommand),
// Responses
StatusResponse(StatusCheck),
ActiveUserResponse(ActiveUserConnectionChange),
ActiveProviderResponse(Arc<str>, usize), // single provider
ActiveProviderCountRequest(String),
ActiveProviderCountResponse(usize),
ConfigChangeResponse(ConfigType),
PlaylistUpdateResponse(PlaylistUpdateRunStateEvent),
PlaylistUpdateProgressResponse(PlaylistUpdateProgressEvent),
UserActionResponse(bool),
SystemInfoResponse(SystemInfo),
LibraryScanProgressResponse(LibraryScanProgressEvent),
StreamMeterBatchResponse(Vec<StreamMeterEntry>),
DownloadsResponse(DownloadsResponse),
DownloadsDeltaResponse(DownloadsDelta),
// Recording-scoped snapshot + delta. The frontend requests a
// snapshot on connect (or after a revision gap) and receives
// filtered snapshots/deltas per session.
RecordingSnapshotRequest,
RecordingSnapshotResponse {
revision: QueueRevision,
tasks: Vec<FileDownloadDto>,
},
RecordingDeltaResponse {
revision: QueueRevision,
tasks: Vec<FileDownloadDto>,
},
/// 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,
},
}
impl ProtocolMessage {
pub fn to_bytes(&self) -> io::Result<Bytes> {
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<Self> {
if bytes.len() == 1 {
Ok(ProtocolMessage::Version(bytes[0]))
} else {
//bincode_deserialize::<ProtocolMessage>(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));
}
}
}