From f6423a2ed6d77644056b8a5a7cd3413a37513db9 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 7 Sep 2026 23:39:58 +0200 Subject: [PATCH] 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. --- README.md | 2 +- soulseek-rs-lib/examples/stress.rs | 16 +++- soulseek-rs-lib/src/actor/peer_actor/tests.rs | 9 +- soulseek-rs-lib/src/client/operations.rs | 20 +--- soulseek-rs-lib/src/lib.rs | 2 +- .../src/message/peer/folder_contents.rs | 15 ++- soulseek-rs-lib/src/message/peer/mod.rs | 4 +- .../src/message/peer/shared_file_list.rs | 93 ++++++++++++++++--- soulseek-rs-lib/src/shares/mod.rs | 23 +++-- soulseek-rs-lib/tests/e2e.rs | 59 ++++++++++-- soulseek-rs/docs/openrpc.json | 57 +++++++++--- soulseek-rs/src/SKILL.md | 3 +- soulseek-rs/src/commands/social.rs | 8 +- soulseek-rs/src/commands/transfer.rs | 10 +- soulseek-rs/src/daemon/proto/dto.rs | 36 ++++++- soulseek-rs/src/daemon/proto/mod.rs | 8 +- soulseek-rs/src/models/browse.rs | 17 +++- soulseek-rs/src/output.rs | 4 + soulseek-rs/src/remote/mirror.rs | 8 +- soulseek-rs/src/ui/main_tui/mod.rs | 8 +- 20 files changed, 307 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index 3628402..0dd16d2 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ the message field empty. `serve`'s `status` is `queued`, `uploading`, | Command | Extra fields | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | | `search` | `duration`, `slots`, `speed`, `free_slot` | -| `browse` | `directory` | +| `browse` | `directory`, `bitrate`, `duration` | | `download` / `get` | `user`, remote `path`, `size`, beside the local `file` | | `whoami` | `listening`, `listen_port`, `download_dir`, `privilege_seconds` | | `user` | `privileged`, `shared_folders` | diff --git a/soulseek-rs-lib/examples/stress.rs b/soulseek-rs-lib/examples/stress.rs index 56b4321..6952ce1 100644 --- a/soulseek-rs-lib/examples/stress.rs +++ b/soulseek-rs-lib/examples/stress.rs @@ -30,12 +30,24 @@ use std::time::{Duration, Instant}; use soulseek_rs::message::Message; use soulseek_rs::message::peer::{ - FileEntry, SharedDirectory, build_file_search_response, + FileEntry, SharedDirectory, SharedFileEntry, build_file_search_response, build_shared_file_list, }; use soulseek_rs::message::server::MessageFactory; use soulseek_rs::{Client, ClientSettings, ClientVersion, PeerAddress}; +/// A mock's `(name, size)` files in listing form; mocks advertise no attributes. +fn entries(files: &[(String, u64)]) -> Vec { + files + .iter() + .map(|(name, size)| SharedFileEntry { + name: name.clone(), + size: *size, + attributes: Vec::new(), + }) + .collect() +} + /// Files matching this tag are what every mock seeder answers searches for. const TAG: &str = "zqxstress"; @@ -536,7 +548,7 @@ impl SeederState { fn send_listing(&self, p: &mut TcpStream, shared: bool) -> bool { let dir = SharedDirectory { name: format!("stress\\{}", self.name), - files: self.files.clone(), + files: entries(&self.files), }; let list = build_shared_file_list(std::slice::from_ref(&dir)); self.reply(p, shared, &list.get_buffer()) diff --git a/soulseek-rs-lib/src/actor/peer_actor/tests.rs b/soulseek-rs-lib/src/actor/peer_actor/tests.rs index be62ec0..de8d7c8 100644 --- a/soulseek-rs-lib/src/actor/peer_actor/tests.rs +++ b/soulseek-rs-lib/src/actor/peer_actor/tests.rs @@ -100,11 +100,10 @@ fn tick_reaps_a_peer_idle_past_the_deadline() { /// ticks proves the actor drains the socket rather than sipping 1 KiB a tick. fn big_listing() -> Vec { let files = (0..400u64) - .map(|i| { - ( - format!("track-{i:03}-{}.flac", "x".repeat(i as usize % 17)), - i, - ) + .map(|i| crate::message::peer::SharedFileEntry { + name: format!("track-{i:03}-{}.flac", "x".repeat(i as usize % 17)), + size: i, + attributes: Vec::new(), }) .collect(); let dir = crate::message::peer::SharedDirectory { diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index 7eb0e25..099e3a5 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -763,7 +763,7 @@ impl Client { &requester_key, |ctx| { crate::message::peer::build_shared_file_list( - &shared_directories(ctx), + &ctx.shares.directories(), ) }, ); @@ -792,7 +792,9 @@ impl Client { &client_context, &requester_key, |ctx| { - let dirs: Vec<_> = shared_directories(ctx) + let dirs: Vec<_> = ctx + .shares + .directories() .into_iter() .filter(|dir| dir.name == folder) .collect(); @@ -896,17 +898,3 @@ impl Client { } } } - -/// Our shares in the wire form a browse or folder reply carries. -fn shared_directories( - ctx: &ClientContext, -) -> Vec { - ctx.shares - .directories() - .into_iter() - .map(|(name, files)| crate::message::peer::SharedDirectory { - name, - files, - }) - .collect() -} diff --git a/soulseek-rs-lib/src/lib.rs b/soulseek-rs-lib/src/lib.rs index 518c9d7..3fb0fba 100644 --- a/soulseek-rs-lib/src/lib.rs +++ b/soulseek-rs-lib/src/lib.rs @@ -30,7 +30,7 @@ pub mod prelude { pub use actor::server_actor::{PeerAddress, UserMessage}; pub use client::{Client, ClientSettings}; pub use error::{Result, SoulseekRs}; -pub use message::peer::SharedDirectory; +pub use message::peer::{SharedDirectory, SharedFileEntry}; pub use types::{ ClientVersion, DownloadStatus, File, RoomEvent, RoomInfo, Search, SearchResult, SessionLoss, Transfer, UploadInfo, UploadStatus, UserInfo, diff --git a/soulseek-rs-lib/src/message/peer/folder_contents.rs b/soulseek-rs-lib/src/message/peer/folder_contents.rs index 93f7077..cf0f6f8 100644 --- a/soulseek-rs-lib/src/message/peer/folder_contents.rs +++ b/soulseek-rs-lib/src/message/peer/folder_contents.rs @@ -3,6 +3,8 @@ //! folder" in other clients asks for. use crate::message::peer::SharedDirectory; +#[cfg(test)] +use crate::message::peer::SharedFileEntry; use crate::message::peer::shared_file_list::{ decompress_body, read_directories, write_directories, }; @@ -56,7 +58,18 @@ pub fn parse_folder_contents( fn a_folder_listing_round_trips() { let dirs = vec![SharedDirectory { name: "music\\album".to_string(), - files: vec![("one.flac".to_string(), 40), ("two.flac".to_string(), 50)], + files: vec![ + SharedFileEntry { + name: "one.flac".to_string(), + size: 40, + attributes: vec![(0, 992)], + }, + SharedFileEntry { + name: "two.flac".to_string(), + size: 50, + attributes: Vec::new(), + }, + ], }]; let built = build_folder_contents(7, "music\\album", &dirs); let mut message = Message::new_with_data(built.get_data()); diff --git a/soulseek-rs-lib/src/message/peer/mod.rs b/soulseek-rs-lib/src/message/peer/mod.rs index 4c37b1a..b3bba95 100644 --- a/soulseek-rs-lib/src/message/peer/mod.rs +++ b/soulseek-rs-lib/src/message/peer/mod.rs @@ -25,8 +25,8 @@ pub use place_in_queue_request::PlaceInQueueRequest; pub use place_in_queue_response::PlaceInQueueResponse; pub use queue_upload::QueueUploadHandler; pub use shared_file_list::{ - SharedDirectory, SharedFileListResponseHandler, build_shared_file_list, - parse_shared_file_list, + SharedDirectory, SharedFileEntry, SharedFileListResponseHandler, + build_shared_file_list, parse_shared_file_list, }; pub use transfer_request::TransferRequest; pub use transfer_response::TransferResponse; diff --git a/soulseek-rs-lib/src/message/peer/shared_file_list.rs b/soulseek-rs-lib/src/message/peer/shared_file_list.rs index 6632797..cc60817 100644 --- a/soulseek-rs-lib/src/message/peer/shared_file_list.rs +++ b/soulseek-rs-lib/src/message/peer/shared_file_list.rs @@ -7,11 +7,32 @@ use crate::peer::PeerMessage; use crate::utils::zlib::{deflate, inflate}; use std::sync::mpsc::Sender; -/// One shared directory and the files directly in it (basename + size). +/// One file in a shared directory: its basename, size and the `(code, value)` +/// audio attributes the owner advertises for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharedFileEntry { + pub name: String, + pub size: u64, + pub attributes: Vec<(u32, u32)>, +} + +impl SharedFileEntry { + /// The value of attribute `code` (0 bitrate, 1 duration, 2 VBR, 4 sample + /// rate, 5 bit depth), if the owner advertised it. + #[must_use] + pub fn attribute(&self, code: u32) -> Option { + self.attributes + .iter() + .find(|&&(c, _)| c == code) + .map(|&(_, v)| v) + } +} + +/// One shared directory and the files directly in it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SharedDirectory { pub name: String, - pub files: Vec<(String, u64)>, + pub files: Vec, } /// Receives a peer's `SharedFileListResponse` (peer code 5) when browsing them. @@ -58,13 +79,16 @@ pub fn write_directories(payload: &mut Message, dirs: &[SharedDirectory]) { payload .write_string(&dir.name) .write_int32(dir.files.len() as u32); - for (name, size) in &dir.files { + for file in &dir.files { payload .write_int8(1) - .write_string(name) - .write_int64(*size) + .write_string(&file.name) + .write_int64(file.size) .write_string("") // extension - .write_int32(0); // attribute count + .write_int32(file.attributes.len() as u32); + for &(code, value) in &file.attributes { + payload.write_int32(code).write_int32(value); + } } } } @@ -87,20 +111,24 @@ pub fn read_directories(body: &mut Message) -> Vec { break; } body.read_int8(); // code - let filename = body.read_string(); - let file_size = body.read_int64(); + let name = body.read_string(); + let size = body.read_int64(); body.read_string(); // extension let attr_count = body.read_int32(); + let mut attributes = Vec::new(); for _ in 0..attr_count { // Each attribute is two int32s (8 bytes); read_int32 does not // advance past the end, so bound the loop explicitly. if body.get_pointer() + 8 > body.get_size() { break; } - body.read_int32(); - body.read_int32(); + attributes.push((body.read_int32(), body.read_int32())); } - files.push((filename, file_size)); + files.push(SharedFileEntry { + name, + size, + attributes, + }); } dirs.push(SharedDirectory { name, files }); } @@ -127,19 +155,56 @@ fn hostile_dir_count_does_not_hang() { assert!(parse_shared_file_list(&mut message).is_empty()); } +#[test] +fn a_listing_carries_each_files_attributes() { + let dirs = vec![SharedDirectory { + name: "music\\album".to_string(), + files: vec![ + SharedFileEntry { + name: "one.mp3".to_string(), + size: 40, + attributes: vec![(0, 320), (1, 187)], + }, + SharedFileEntry { + name: "two.flac".to_string(), + size: 50, + attributes: Vec::new(), + }, + ], + }]; + let mut message = crate::message::framed(|m| { + m.write_raw_bytes( + build_shared_file_list(&dirs).get_data()[4..].to_vec(), + ); + }); + assert_eq!(parse_shared_file_list(&mut message), dirs); +} + #[test] fn shared_file_list_roundtrips() { let dirs = vec![ SharedDirectory { name: "music\\album".to_string(), files: vec![ - ("song one.flac".to_string(), 123), - ("song two.flac".to_string(), 456), + SharedFileEntry { + name: "song one.flac".to_string(), + size: 123, + attributes: vec![(1, 300), (4, 44_100), (5, 16)], + }, + SharedFileEntry { + name: "song two.flac".to_string(), + size: 456, + attributes: Vec::new(), + }, ], }, SharedDirectory { name: "music".to_string(), - files: vec![("top.mp3".to_string(), 789)], + files: vec![SharedFileEntry { + name: "top.mp3".to_string(), + size: 789, + attributes: Vec::new(), + }], }, ]; let message = build_shared_file_list(&dirs); diff --git a/soulseek-rs-lib/src/shares/mod.rs b/soulseek-rs-lib/src/shares/mod.rs index c7846b3..65dfcd2 100644 --- a/soulseek-rs-lib/src/shares/mod.rs +++ b/soulseek-rs-lib/src/shares/mod.rs @@ -6,6 +6,7 @@ mod audio; +use crate::message::peer::{SharedDirectory, SharedFileEntry}; use std::collections::{HashMap, HashSet}; use std::io; use std::path::{Path, PathBuf}; @@ -129,12 +130,13 @@ impl Shares { } /// Files grouped by their virtual directory (everything before the final - /// backslash), as `(directory, [(basename, size)])`. Used to build a - /// SharedFileListResponse. + /// backslash), in the form a browse or folder listing carries. #[must_use] - pub fn directories(&self) -> Vec<(String, Vec<(String, u64)>)> { - let mut by_dir: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); + pub fn directories(&self) -> Vec { + let mut by_dir: std::collections::BTreeMap< + String, + Vec, + > = std::collections::BTreeMap::new(); for file in &self.files { let (dir, base) = file .virtual_path @@ -143,9 +145,16 @@ impl Shares { by_dir .entry(dir.to_string()) .or_default() - .push((base.to_string(), file.size)); + .push(SharedFileEntry { + name: base.to_string(), + size: file.size, + attributes: file.attributes.clone(), + }); } - by_dir.into_iter().collect() + by_dir + .into_iter() + .map(|(name, files)| SharedDirectory { name, files }) + .collect() } #[must_use] diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 5f3090c..2701379 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -1838,7 +1838,7 @@ fn a_runtime_share_update_is_visible_to_browsers() { assert!( directories .iter() - .any(|d| d.files.iter().any(|(name, _)| name == "late.mp3")), + .any(|d| d.files.iter().any(|f| f.name == "late.mp3")), "the listing should include the file shared at runtime" ); @@ -1888,7 +1888,7 @@ fn browse_a_peers_shared_files() { assert!( directories .iter() - .any(|d| { d.files.iter().any(|(name, _)| name == "track.flac") }), + .any(|d| { d.files.iter().any(|f| f.name == "track.flac") }), "the listing should include the shared file" ); @@ -1949,7 +1949,7 @@ fn browse_a_firewalled_peer_via_broker() { assert!( directories .iter() - .any(|d| d.files.iter().any(|(name, _)| name == "hidden.flac")), + .any(|d| d.files.iter().any(|f| f.name == "hidden.flac")), "the brokered listing should include the shared file" ); @@ -2019,7 +2019,7 @@ fn a_third_party_client_browses_our_shares_directly() { assert!( directories .iter() - .any(|d| d.files.iter().any(|(name, _)| name == "track.flac")), + .any(|d| d.files.iter().any(|f| f.name == "track.flac")), "the listing should include the shared file, got {directories:?}" ); @@ -2127,7 +2127,7 @@ fn a_third_party_client_browses_our_shares_via_the_server_broker() { assert!( directories .iter() - .any(|d| d.files.iter().any(|(name, _)| name == "brokered.flac")), + .any(|d| d.files.iter().any(|f| f.name == "brokered.flac")), "the brokered listing should include the shared file, got {directories:?}" ); @@ -2180,7 +2180,7 @@ fn a_stalled_peer_connection_does_not_wedge_the_listener() { assert!( directories .iter() - .any(|d| d.files.iter().any(|(name, _)| name == "still.flac")), + .any(|d| d.files.iter().any(|f| f.name == "still.flac")), "the listing should include the shared file, got {directories:?}" ); @@ -3255,7 +3255,7 @@ fn a_third_party_client_fetches_one_folder_of_our_shares() { assert_eq!(echoed, folder); let mut names: Vec<(String, Vec)> = directories .into_iter() - .map(|d| (d.name, d.files.into_iter().map(|(name, _)| name).collect())) + .map(|d| (d.name, d.files.into_iter().map(|f| f.name).collect())) .collect(); for (_, files) in &mut names { files.sort(); @@ -3572,3 +3572,48 @@ fn a_shared_mp3_advertises_its_bitrate_and_duration() { let _ = std::fs::remove_dir_all(share_dir); } + +// A browse shows what a search shows: the bitrate and duration a searcher +// filters on are in the listing too, as Nicotine+ sends them. +#[test] +fn a_browse_listing_carries_the_files_attributes() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::write(share_dir.join("listed.mp3"), cbr_mp3(1000)).unwrap(); + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_lattr_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_lattr_browser", "pw") + .expect("third-party client logs in"); + + let directories = third_party_browse( + &format!("127.0.0.1:{sharer_port}"), + "e2e_lattr_browser", + Duration::ZERO, + Duration::from_secs(15), + ) + .expect("a listing"); + let listed = directories + .iter() + .flat_map(|d| d.files.iter()) + .find(|f| f.name == "listed.mp3") + .expect("the mp3 is listed"); + assert!( + listed.attributes.contains(&(0, 128)), + "{:?}", + listed.attributes + ); + assert!( + listed.attributes.contains(&(1, 26)), + "{:?}", + listed.attributes + ); + + let _ = std::fs::remove_dir_all(share_dir); +} diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index 365ebe3..d9ab8e0 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -948,21 +948,8 @@ "SharedDirectoryDto": { "properties": { "files": { - "description": "Basename and size; join with the directory to get a downloadable path.", "items": { - "maxItems": 2, - "minItems": 2, - "prefixItems": [ - { - "type": "string" - }, - { - "format": "uint64", - "minimum": 0, - "type": "integer" - } - ], - "type": "array" + "$ref": "#/components/schemas/SharedFileEntryDto" }, "type": "array" }, @@ -976,6 +963,46 @@ ], "type": "object" }, + "SharedFileEntryDto": { + "properties": { + "attributes": { + "description": "`(code, value)` audio attributes the owner advertises.", + "items": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + ], + "type": "array" + }, + "type": "array" + }, + "name": { + "description": "Basename; join with the directory to get a downloadable path.", + "type": "string" + }, + "size": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "size", + "attributes" + ], + "type": "object" + }, "SharesStatus": { "description": "What the share index holds, from `shares.status` / `shares.reindex`.", "properties": { @@ -1313,7 +1340,7 @@ "name": "MIT" }, "title": "soulseek-rs daemon", - "version": "1" + "version": "2" }, "methods": [ { diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index 220d3e0..b416efd 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -131,7 +131,8 @@ something that is not on the network right now: add it as a wish, then run the wishlist later rather than repeating the same search. **`browse `** — one object per file the peer shares: `user`, `directory`, -`path`, `size`. +`path`, `size`, `bitrate`, `duration`. The last two are `null` when the peer +advertises no audio attributes for the file. **`user `** — one object: `user`, `status` (`online`/`away`/`offline`), `privileged`, `average_speed`, `shared_files`, `shared_folders`. Any field may diff --git a/soulseek-rs/src/commands/social.rs b/soulseek-rs/src/commands/social.rs index 4bedea4..f9b897c 100644 --- a/soulseek-rs/src/commands/social.rs +++ b/soulseek-rs/src/commands/social.rs @@ -61,13 +61,15 @@ pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult { let mut files = 0usize; for directory in &directories { - for (basename, size) in &directory.files { + for file in &directory.files { files += 1; ctx.out.emit(&BrowseRecord { user: args.user.clone(), directory: directory.name.clone(), - path: full_path(&directory.name, basename), - size: *size, + path: full_path(&directory.name, &file.name), + size: file.size, + bitrate: file.attribute(super::transfer::ATTR_BITRATE), + duration: file.attribute(super::transfer::ATTR_DURATION), }); } } diff --git a/soulseek-rs/src/commands/transfer.rs b/soulseek-rs/src/commands/transfer.rs index bd37b5b..b97b054 100644 --- a/soulseek-rs/src/commands/transfer.rs +++ b/soulseek-rs/src/commands/transfer.rs @@ -19,8 +19,8 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; /// Soulseek file attribute codes the network agrees on. -const ATTR_BITRATE: u32 = 0; -const ATTR_DURATION: u32 = 1; +pub const ATTR_BITRATE: u32 = 0; +pub const ATTR_DURATION: u32 = 1; /// What to keep out of the raw result set. Every field is a further /// restriction: a candidate has to satisfy all of them. @@ -388,10 +388,10 @@ fn resolve_sizes( listing .iter() .flat_map(|directory| { - directory.files.iter().map(|(basename, size)| { + directory.files.iter().map(|file| { ( - social::full_path(&directory.name, basename), - *size, + social::full_path(&directory.name, &file.name), + file.size, ) }) }) diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index 304ecfb..82ea25a 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -12,7 +12,8 @@ use soulseek_rs::types::{ }; use soulseek_rs::{ DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss, - SharedDirectory, UploadInfo, UploadStatus, UserInfo, UserMessage, + SharedDirectory, SharedFileEntry, UploadInfo, UploadStatus, UserInfo, + UserMessage, }; use std::collections::HashMap; @@ -553,15 +554,32 @@ impl From for UserInfo { #[cfg_attr(test, derive(schemars::JsonSchema))] pub struct SharedDirectoryDto { pub name: String, - /// Basename and size; join with the directory to get a downloadable path. - pub files: Vec<(String, u64)>, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharedFileEntryDto { + /// Basename; join with the directory to get a downloadable path. + pub name: String, + pub size: u64, + /// `(code, value)` audio attributes the owner advertises. + pub attributes: Vec<(u32, u32)>, } impl From<&SharedDirectory> for SharedDirectoryDto { fn from(directory: &SharedDirectory) -> Self { Self { name: directory.name.clone(), - files: directory.files.clone(), + files: directory + .files + .iter() + .map(|file| SharedFileEntryDto { + name: file.name.clone(), + size: file.size, + attributes: file.attributes.clone(), + }) + .collect(), } } } @@ -570,7 +588,15 @@ impl From for SharedDirectory { fn from(dto: SharedDirectoryDto) -> Self { Self { name: dto.name, - files: dto.files, + files: dto + .files + .into_iter() + .map(|file| SharedFileEntry { + name: file.name, + size: file.size, + attributes: file.attributes, + }) + .collect(), } } } diff --git a/soulseek-rs/src/daemon/proto/mod.rs b/soulseek-rs/src/daemon/proto/mod.rs index 7a3fea7..e0e243a 100644 --- a/soulseek-rs/src/daemon/proto/mod.rs +++ b/soulseek-rs/src/daemon/proto/mod.rs @@ -13,7 +13,7 @@ /// 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 = 1; +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. @@ -322,7 +322,11 @@ mod tests { fn a_shared_listing_survives_the_round_trip() { let directory = SharedDirectory { name: "@@x\\Music".into(), - files: vec![("a.mp3".into(), 4096)], + 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(); diff --git a/soulseek-rs/src/models/browse.rs b/soulseek-rs/src/models/browse.rs index c20bf16..305558f 100644 --- a/soulseek-rs/src/models/browse.rs +++ b/soulseek-rs/src/models/browse.rs @@ -123,13 +123,13 @@ pub fn build_browse_tree(directories: &[SharedDirectory]) -> BuiltTree { // File paths come from the original name so downloads match exactly. let dir_path = dir.name.trim_end_matches('\\'); - for (basename, size) in &dir.files { + for file in &dir.files { let file_path = if dir_path.is_empty() { - basename.clone() + file.name.clone() } else { - format!("{dir_path}\\{basename}") + format!("{dir_path}\\{}", file.name) }; - node.files.push((basename.clone(), *size, file_path)); + node.files.push((file.name.clone(), file.size, file_path)); } } @@ -416,7 +416,14 @@ mod tests { fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory { SharedDirectory { name: name.to_string(), - files: files.iter().map(|(n, s)| ((*n).to_string(), *s)).collect(), + files: files + .iter() + .map(|(n, s)| soulseek_rs::SharedFileEntry { + name: (*n).to_string(), + size: *s, + attributes: Vec::new(), + }) + .collect(), } } diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs index 64d2c09..abe9d61 100644 --- a/soulseek-rs/src/output.rs +++ b/soulseek-rs/src/output.rs @@ -207,6 +207,8 @@ pub struct BrowseRecord { pub directory: String, pub path: String, pub size: u64, + pub bitrate: Option, + pub duration: Option, } impl Record for BrowseRecord { @@ -667,6 +669,8 @@ mod tests { directory: "@@music".into(), path: "@@music\\a.flac".into(), size: 12, + bitrate: Some(320), + duration: None, }; assert_eq!(record.text(), "bob\t12\t@@music\\a.flac"); } diff --git a/soulseek-rs/src/remote/mirror.rs b/soulseek-rs/src/remote/mirror.rs index 10472f4..d7439f0 100644 --- a/soulseek-rs/src/remote/mirror.rs +++ b/soulseek-rs/src/remote/mirror.rs @@ -227,14 +227,18 @@ mod tests { Event::Browse, json!({ "username": "bob", - "directories": [{ "name": "@@x", "files": [["a.mp3", 42]] }], + "directories": [{ + "name": "@@x", + "files": [{ "name": "a.mp3", "size": 42, "attributes": [[0, 320]] }], + }], }), ); assert!(mirror.take_browse("sue").is_none()); let listing = mirror.take_browse("bob").expect("bob answered"); assert_eq!(listing[0].name, "@@x"); - assert_eq!(listing[0].files, [("a.mp3".to_string(), 42)]); + assert_eq!(listing[0].files[0].name, "a.mp3"); + assert_eq!(listing[0].files[0].attribute(0), Some(320)); assert!(mirror.take_browse("bob").is_none(), "taken means taken"); } diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index bfffd32..75ee091 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -1628,7 +1628,13 @@ mod tests { tui.state.browse.open("bob"); let listing = vec![soulseek_rs::SharedDirectory { name: "Music".to_string(), - files: (0..files).map(|i| (format!("{i:02}.mp3"), 1)).collect(), + files: (0..files) + .map(|i| soulseek_rs::SharedFileEntry { + name: format!("{i:02}.mp3"), + size: 1, + attributes: Vec::new(), + }) + .collect(), }]; tui.state .browse