Files
soulseek-rs-modded/soulseek-rs/src/daemon/proto/mod.rs
T
Michel de Graaf f6423a2ed6 feat(lib): carry audio attributes in browse and folder listings
A browse listed each file as a name and a size, so a searcher who saw a
bitrate in a search reply lost it the moment they opened the sharer's
files. Listings (codes 5 and 37) now carry each file's attributes the
way Nicotine+ sends them, as a SharedFileEntry with name, size and
attributes in place of the (name, size) pair; the CLI's browse record
gains bitrate and duration, and the daemon protocol moves to version 2
for the new listing shape.
2026-09-08 01:44:49 +02:00

416 lines
14 KiB
Rust

//! The wire contract: JSON-RPC 2.0 framing and the data types that cross it.
//!
//! Library types never travel as themselves. Every payload is a DTO defined
//! here with an explicit conversion, so the wire format is versioned
//! independently of the library: a refactor inside `soulseek-rs-lib` cannot
//! silently change what a third-party script receives, and the compiler points
//! at this file when one would.
//!
//! The schemas published in `docs/openrpc.json` are derived from these types
//! (see the tests at the bottom), which is what keeps the advertised contract
//! and the running daemon in agreement.
/// Bumped when a change would break a client written against the old shape.
/// Sent in the `auth` reply so a mismatch is caught at connect time rather
/// than as a confusing failure three calls later.
pub const PROTOCOL_VERSION: u32 = 2;
/// The OpenRPC document, served by `rpc.discover` so a generator can point at
/// a running daemon instead of needing this repository.
pub const OPENRPC: &str = include_str!("../../../docs/openrpc.json");
mod dto;
mod envelope;
mod method;
pub use dto::*;
pub use envelope::*;
pub use method::*;
/// Declare a wire vocabulary once: the enum, the complete list, and the
/// name mapping in both directions.
///
/// Everything that consumes one of these matches on the enum, so the match is
/// exhaustive and adding a line here is a compile error everywhere the new
/// member still has to be handled — the dispatcher, the published schema, the
/// remote client. A `&[&str]` table checked by a test would only fail after
/// the fact, and only if someone remembered to write the test.
macro_rules! wire_vocabulary {
($(#[$meta:meta])* $name:ident { $($variant:ident => $wire:literal),* $(,)? }) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum $name {
$($variant),*
}
impl $name {
/// Every member, in published order.
pub const ALL: &'static [Self] = &[$(Self::$variant),*];
#[must_use]
pub const fn wire_name(self) -> &'static str {
match self {
$(Self::$variant => $wire),*
}
}
#[must_use]
pub fn from_wire(name: &str) -> Option<Self> {
match name {
$($wire => Some(Self::$variant),)*
_ => None,
}
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.wire_name())
}
}
};
}
wire_vocabulary! {
/// Every method the daemon answers.
Method {
Auth => "auth",
DaemonStatus => "daemon.status",
DaemonStop => "daemon.stop",
RpcDiscover => "rpc.discover",
SearchStart => "search.start",
SearchResults => "search.results",
SearchList => "search.list",
SearchForget => "search.forget",
SearchWishlist => "search.wishlist",
SearchWishlistInterval => "search.wishlist_interval",
DownloadStart => "download.start",
DownloadList => "download.list",
DownloadPause => "download.pause",
DownloadResume => "download.resume",
DownloadRemove => "download.remove",
DownloadRemoveQueued => "download.remove_queued",
DownloadCancel => "download.cancel",
DownloadSetDir => "download.set_dir",
UploadList => "upload.list",
UploadCancel => "upload.cancel",
UploadSlots => "upload.slots",
PrivilegesCheck => "privileges.check",
PrivilegesOwn => "privileges.own",
RoomListRequest => "room.list_request",
RoomJoin => "room.join",
RoomLeave => "room.leave",
RoomSay => "room.say",
RoomMembers => "room.members",
RoomMemberStats => "room.member_stats",
MessageSend => "message.send",
MessageHistory => "message.history",
BrowseUser => "browse.user",
UserRequest => "user.request",
UserInfoOf => "user.info",
UserWatch => "user.watch",
UserUnwatch => "user.unwatch",
UserWatched => "user.watched",
SharesStatusOf => "shares.status",
SharesSet => "shares.set",
SharesReindex => "shares.reindex",
}
}
wire_vocabulary! {
/// Events the daemon pushes without being asked.
///
/// These exist because the library's `take_*` accessors are destructive:
/// with two clients attached, whichever drained first would steal the
/// others' events. The daemon is the single drainer and fans out.
Event {
Room => "event.room",
Message => "event.message",
Upload => "event.upload",
DownloadStatus => "event.download_status",
Browse => "event.browse",
SessionLoss => "event.session_loss",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::Exit;
use soulseek_rs::types::{UserPresence, UserStats, UserStatus};
use soulseek_rs::{
DownloadStatus, File, RoomEvent, RoomInfo, SearchResult,
SharedDirectory, UploadStatus, UserInfo, UserMessage,
};
use std::collections::HashMap;
#[test]
fn a_search_result_survives_the_round_trip() {
let mut attribs = HashMap::new();
attribs.insert(0, 320);
attribs.insert(1, 210);
let original = SearchResult {
token: 7,
slots: 2,
speed: 900,
username: "bob".into(),
files: vec![File {
username: "bob".into(),
name: "@@x\\a.mp3".into(),
size: 4096,
attribs,
}],
};
let json = serde_json::to_string(&SearchResultDto::from(&original))
.expect("a DTO serializes");
let back: SearchResult = serde_json::from_str::<SearchResultDto>(&json)
.unwrap()
.into();
assert_eq!(back.token, original.token);
assert_eq!(back.username, original.username);
assert_eq!(back.slots, original.slots);
assert_eq!(back.speed, original.speed);
assert_eq!(back.files.len(), 1);
assert_eq!(back.files[0].name, "@@x\\a.mp3");
assert_eq!(back.files[0].size, 4096);
assert_eq!(back.files[0].attribs.get(&0), Some(&320));
// The per-file username is carried by the parent result, so it has to
// be restored rather than lost.
assert_eq!(back.files[0].username, "bob");
}
#[test]
fn every_download_status_survives_the_round_trip() {
let statuses = [
DownloadStatus::Queued,
DownloadStatus::InProgress {
bytes_downloaded: 10,
total_bytes: 100,
speed_bytes_per_sec: 1.5,
},
DownloadStatus::Paused {
bytes_downloaded: 10,
total_bytes: 100,
},
DownloadStatus::Completed,
DownloadStatus::Cancelled,
DownloadStatus::Failed(Some("gone".into())),
DownloadStatus::Failed(None),
DownloadStatus::TimedOut,
];
for status in &statuses {
let dto = DownloadStatusDto::from(status);
let json = serde_json::to_string(&dto).unwrap();
let back: DownloadStatusDto = serde_json::from_str(&json).unwrap();
assert_eq!(dto, back, "{status:?} must survive the wire");
assert_eq!(
DownloadStatusDto::from(&DownloadStatus::from(back)),
dto,
"{status:?} must convert back to the same library value"
);
}
}
#[test]
fn every_upload_status_survives_the_round_trip() {
let statuses = [
UploadStatus::Queued(3),
UploadStatus::InProgress,
UploadStatus::Completed,
UploadStatus::Cancelled,
UploadStatus::Failed("nope".into()),
];
for status in &statuses {
let dto = UploadStatusDto::from(status);
let json = serde_json::to_string(&dto).unwrap();
let back: UploadStatus =
serde_json::from_str::<UploadStatusDto>(&json)
.unwrap()
.into();
assert_eq!(&back, status);
}
}
#[test]
fn every_room_event_survives_the_round_trip() {
let events = [
RoomEvent::List(vec![RoomInfo {
name: "lobby".into(),
user_count: 42,
}]),
RoomEvent::Joined {
room: "lobby".into(),
users: vec!["bob".into()],
},
RoomEvent::Left {
room: "lobby".into(),
},
RoomEvent::Message {
room: "lobby".into(),
username: "bob".into(),
message: "hi".into(),
},
RoomEvent::UserJoined {
room: "lobby".into(),
username: "sue".into(),
},
RoomEvent::UserLeft {
room: "lobby".into(),
username: "sue".into(),
},
];
for event in &events {
let json =
serde_json::to_string(&RoomEventDto::from(event)).unwrap();
let back: RoomEvent =
serde_json::from_str::<RoomEventDto>(&json).unwrap().into();
assert_eq!(&back, event);
}
}
#[test]
fn a_partly_answered_user_stays_partly_answered() {
// "the server has not said" must not arrive as "offline, shares
// nothing": that is the difference between waiting and giving up.
let pending = UserInfo::pending("bob".into());
let json = serde_json::to_string(&UserInfoDto::from(&pending)).unwrap();
let back: UserInfo =
serde_json::from_str::<UserInfoDto>(&json).unwrap().into();
assert_eq!(back.username, "bob");
assert!(back.presence.is_none());
assert!(back.stats.is_none());
assert!(!back.is_complete());
}
#[test]
fn a_complete_user_survives_the_round_trip() {
let mut info = UserInfo::pending("bob".into());
info.presence = Some(UserPresence {
status: UserStatus::Away,
privileged: true,
});
info.stats = Some(UserStats {
average_speed: 500,
shared_files: 10,
shared_folders: 2,
});
let json = serde_json::to_string(&UserInfoDto::from(&info)).unwrap();
let back: UserInfo =
serde_json::from_str::<UserInfoDto>(&json).unwrap().into();
assert_eq!(back, info);
}
#[test]
fn a_private_message_survives_the_round_trip() {
let message =
UserMessage::new(9, 1234, "bob".into(), "hi".into(), true);
let json =
serde_json::to_string(&UserMessageDto::from(&message)).unwrap();
let back: UserMessage = serde_json::from_str::<UserMessageDto>(&json)
.unwrap()
.into();
assert_eq!(back.id(), 9);
assert_eq!(back.timestamp(), 1234);
assert_eq!(back.username(), "bob");
assert_eq!(back.message(), "hi");
}
#[test]
fn a_shared_listing_survives_the_round_trip() {
let directory = SharedDirectory {
name: "@@x\\Music".into(),
files: vec![soulseek_rs::SharedFileEntry {
name: "a.mp3".into(),
size: 4096,
attributes: vec![(0, 320)],
}],
};
let json = serde_json::to_string(&SharedDirectoryDto::from(&directory))
.unwrap();
let back: SharedDirectory =
serde_json::from_str::<SharedDirectoryDto>(&json)
.unwrap()
.into();
assert_eq!(back.name, directory.name);
assert_eq!(back.files, directory.files);
}
#[test]
fn an_error_carries_the_exit_code_a_local_run_would_have_produced() {
for exit in [
Exit::Usage,
Exit::Connection,
Exit::NoResults,
Exit::Timeout,
Exit::Transfer,
Exit::SessionLost,
] {
let error = RpcError::application(exit, "nope");
let json = serde_json::to_string(&error).unwrap();
let back: RpcError = serde_json::from_str(&json).unwrap();
assert_eq!(back.exit(), exit);
}
}
#[test]
fn an_error_without_an_exit_code_is_a_generic_failure() {
let error = RpcError::new(CODE_METHOD_NOT_FOUND, "no such method");
assert_eq!(error.exit(), Exit::Failure);
}
#[test]
fn a_request_parses_without_optional_fields() {
let request: Request = serde_json::from_str(
r#"{"jsonrpc":"2.0","method":"daemon.status"}"#,
)
.expect("params and id are optional");
assert_eq!(request.method, "daemon.status");
assert!(request.id.is_none());
assert!(request.params.is_null());
}
#[test]
fn an_error_always_carries_an_id_even_when_there_was_none() {
// JSON-RPC requires it, and without it a client following the
// documented "no id means a notification" rule drops the error and
// waits out its timeout for a reply that already arrived.
let json = serde_json::to_string(&Response::failure(
None,
RpcError::new(CODE_PARSE, "unparseable"),
))
.expect("a response is plain data");
assert!(json.contains("\"id\":null"), "{json}");
}
#[test]
fn a_notification_carries_no_id_so_no_reply_is_expected() {
let notification =
Response::notification("event.room", serde_json::json!({"a": 1}));
let json = serde_json::to_string(&notification).unwrap();
assert!(!json.contains("\"id\""), "{json}");
assert!(json.contains("\"method\":\"event.room\""), "{json}");
}
#[test]
fn a_response_serializes_exactly_one_of_result_and_error() {
let ok = serde_json::to_string(&Response::result(
Some(serde_json::json!(1)),
serde_json::json!({}),
))
.unwrap();
assert!(ok.contains("\"result\""));
assert!(!ok.contains("\"error\""));
let failed = serde_json::to_string(&Response::failure(
Some(serde_json::json!(1)),
RpcError::application(Exit::NoResults, "nothing"),
))
.unwrap();
assert!(failed.contains("\"error\""));
assert!(!failed.contains("\"result\""));
}
}