Merge pull request #82 from michel/lib/listing-attributes
feat(lib): carry audio attributes in browse and folder listings
This commit is contained in:
@@ -344,7 +344,7 @@ the message field empty. `serve`'s `status` is `queued`, `uploading`,
|
|||||||
| Command | Extra fields |
|
| Command | Extra fields |
|
||||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `search` | `duration`, `slots`, `speed`, `free_slot` |
|
| `search` | `duration`, `slots`, `speed`, `free_slot` |
|
||||||
| `browse` | `directory` |
|
| `browse` | `directory`, `bitrate`, `duration` |
|
||||||
| `download` / `get` | `user`, remote `path`, `size`, beside the local `file` |
|
| `download` / `get` | `user`, remote `path`, `size`, beside the local `file` |
|
||||||
| `whoami` | `listening`, `listen_port`, `download_dir`, `privilege_seconds` |
|
| `whoami` | `listening`, `listen_port`, `download_dir`, `privilege_seconds` |
|
||||||
| `user` | `privileged`, `shared_folders` |
|
| `user` | `privileged`, `shared_folders` |
|
||||||
|
|||||||
@@ -30,12 +30,24 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use soulseek_rs::message::Message;
|
use soulseek_rs::message::Message;
|
||||||
use soulseek_rs::message::peer::{
|
use soulseek_rs::message::peer::{
|
||||||
FileEntry, SharedDirectory, build_file_search_response,
|
FileEntry, SharedDirectory, SharedFileEntry, build_file_search_response,
|
||||||
build_shared_file_list,
|
build_shared_file_list,
|
||||||
};
|
};
|
||||||
use soulseek_rs::message::server::MessageFactory;
|
use soulseek_rs::message::server::MessageFactory;
|
||||||
use soulseek_rs::{Client, ClientSettings, ClientVersion, PeerAddress};
|
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<SharedFileEntry> {
|
||||||
|
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.
|
/// Files matching this tag are what every mock seeder answers searches for.
|
||||||
const TAG: &str = "zqxstress";
|
const TAG: &str = "zqxstress";
|
||||||
|
|
||||||
@@ -536,7 +548,7 @@ impl SeederState {
|
|||||||
fn send_listing(&self, p: &mut TcpStream, shared: bool) -> bool {
|
fn send_listing(&self, p: &mut TcpStream, shared: bool) -> bool {
|
||||||
let dir = SharedDirectory {
|
let dir = SharedDirectory {
|
||||||
name: format!("stress\\{}", self.name),
|
name: format!("stress\\{}", self.name),
|
||||||
files: self.files.clone(),
|
files: entries(&self.files),
|
||||||
};
|
};
|
||||||
let list = build_shared_file_list(std::slice::from_ref(&dir));
|
let list = build_shared_file_list(std::slice::from_ref(&dir));
|
||||||
self.reply(p, shared, &list.get_buffer())
|
self.reply(p, shared, &list.get_buffer())
|
||||||
|
|||||||
@@ -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.
|
/// ticks proves the actor drains the socket rather than sipping 1 KiB a tick.
|
||||||
fn big_listing() -> Vec<u8> {
|
fn big_listing() -> Vec<u8> {
|
||||||
let files = (0..400u64)
|
let files = (0..400u64)
|
||||||
.map(|i| {
|
.map(|i| crate::message::peer::SharedFileEntry {
|
||||||
(
|
name: format!("track-{i:03}-{}.flac", "x".repeat(i as usize % 17)),
|
||||||
format!("track-{i:03}-{}.flac", "x".repeat(i as usize % 17)),
|
size: i,
|
||||||
i,
|
attributes: Vec::new(),
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let dir = crate::message::peer::SharedDirectory {
|
let dir = crate::message::peer::SharedDirectory {
|
||||||
|
|||||||
@@ -763,7 +763,7 @@ impl Client {
|
|||||||
&requester_key,
|
&requester_key,
|
||||||
|ctx| {
|
|ctx| {
|
||||||
crate::message::peer::build_shared_file_list(
|
crate::message::peer::build_shared_file_list(
|
||||||
&shared_directories(ctx),
|
&ctx.shares.directories(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -792,7 +792,9 @@ impl Client {
|
|||||||
&client_context,
|
&client_context,
|
||||||
&requester_key,
|
&requester_key,
|
||||||
|ctx| {
|
|ctx| {
|
||||||
let dirs: Vec<_> = shared_directories(ctx)
|
let dirs: Vec<_> = ctx
|
||||||
|
.shares
|
||||||
|
.directories()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|dir| dir.name == folder)
|
.filter(|dir| dir.name == folder)
|
||||||
.collect();
|
.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<crate::message::peer::SharedDirectory> {
|
|
||||||
ctx.shares
|
|
||||||
.directories()
|
|
||||||
.into_iter()
|
|
||||||
.map(|(name, files)| crate::message::peer::SharedDirectory {
|
|
||||||
name,
|
|
||||||
files,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pub mod prelude {
|
|||||||
pub use actor::server_actor::{PeerAddress, UserMessage};
|
pub use actor::server_actor::{PeerAddress, UserMessage};
|
||||||
pub use client::{Client, ClientSettings};
|
pub use client::{Client, ClientSettings};
|
||||||
pub use error::{Result, SoulseekRs};
|
pub use error::{Result, SoulseekRs};
|
||||||
pub use message::peer::SharedDirectory;
|
pub use message::peer::{SharedDirectory, SharedFileEntry};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ClientVersion, DownloadStatus, File, RoomEvent, RoomInfo, Search,
|
ClientVersion, DownloadStatus, File, RoomEvent, RoomInfo, Search,
|
||||||
SearchResult, SessionLoss, Transfer, UploadInfo, UploadStatus, UserInfo,
|
SearchResult, SessionLoss, Transfer, UploadInfo, UploadStatus, UserInfo,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
//! folder" in other clients asks for.
|
//! folder" in other clients asks for.
|
||||||
|
|
||||||
use crate::message::peer::SharedDirectory;
|
use crate::message::peer::SharedDirectory;
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::message::peer::SharedFileEntry;
|
||||||
use crate::message::peer::shared_file_list::{
|
use crate::message::peer::shared_file_list::{
|
||||||
decompress_body, read_directories, write_directories,
|
decompress_body, read_directories, write_directories,
|
||||||
};
|
};
|
||||||
@@ -56,7 +58,18 @@ pub fn parse_folder_contents(
|
|||||||
fn a_folder_listing_round_trips() {
|
fn a_folder_listing_round_trips() {
|
||||||
let dirs = vec![SharedDirectory {
|
let dirs = vec![SharedDirectory {
|
||||||
name: "music\\album".to_string(),
|
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 built = build_folder_contents(7, "music\\album", &dirs);
|
||||||
let mut message = Message::new_with_data(built.get_data());
|
let mut message = Message::new_with_data(built.get_data());
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ pub use place_in_queue_request::PlaceInQueueRequest;
|
|||||||
pub use place_in_queue_response::PlaceInQueueResponse;
|
pub use place_in_queue_response::PlaceInQueueResponse;
|
||||||
pub use queue_upload::QueueUploadHandler;
|
pub use queue_upload::QueueUploadHandler;
|
||||||
pub use shared_file_list::{
|
pub use shared_file_list::{
|
||||||
SharedDirectory, SharedFileListResponseHandler, build_shared_file_list,
|
SharedDirectory, SharedFileEntry, SharedFileListResponseHandler,
|
||||||
parse_shared_file_list,
|
build_shared_file_list, parse_shared_file_list,
|
||||||
};
|
};
|
||||||
pub use transfer_request::TransferRequest;
|
pub use transfer_request::TransferRequest;
|
||||||
pub use transfer_response::TransferResponse;
|
pub use transfer_response::TransferResponse;
|
||||||
|
|||||||
@@ -7,11 +7,32 @@ use crate::peer::PeerMessage;
|
|||||||
use crate::utils::zlib::{deflate, inflate};
|
use crate::utils::zlib::{deflate, inflate};
|
||||||
use std::sync::mpsc::Sender;
|
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<u32> {
|
||||||
|
self.attributes
|
||||||
|
.iter()
|
||||||
|
.find(|&&(c, _)| c == code)
|
||||||
|
.map(|&(_, v)| v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One shared directory and the files directly in it.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SharedDirectory {
|
pub struct SharedDirectory {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub files: Vec<(String, u64)>,
|
pub files: Vec<SharedFileEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Receives a peer's `SharedFileListResponse` (peer code 5) when browsing them.
|
/// 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
|
payload
|
||||||
.write_string(&dir.name)
|
.write_string(&dir.name)
|
||||||
.write_int32(dir.files.len() as u32);
|
.write_int32(dir.files.len() as u32);
|
||||||
for (name, size) in &dir.files {
|
for file in &dir.files {
|
||||||
payload
|
payload
|
||||||
.write_int8(1)
|
.write_int8(1)
|
||||||
.write_string(name)
|
.write_string(&file.name)
|
||||||
.write_int64(*size)
|
.write_int64(file.size)
|
||||||
.write_string("") // extension
|
.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<SharedDirectory> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
body.read_int8(); // code
|
body.read_int8(); // code
|
||||||
let filename = body.read_string();
|
let name = body.read_string();
|
||||||
let file_size = body.read_int64();
|
let size = body.read_int64();
|
||||||
body.read_string(); // extension
|
body.read_string(); // extension
|
||||||
let attr_count = body.read_int32();
|
let attr_count = body.read_int32();
|
||||||
|
let mut attributes = Vec::new();
|
||||||
for _ in 0..attr_count {
|
for _ in 0..attr_count {
|
||||||
// Each attribute is two int32s (8 bytes); read_int32 does not
|
// Each attribute is two int32s (8 bytes); read_int32 does not
|
||||||
// advance past the end, so bound the loop explicitly.
|
// advance past the end, so bound the loop explicitly.
|
||||||
if body.get_pointer() + 8 > body.get_size() {
|
if body.get_pointer() + 8 > body.get_size() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
body.read_int32();
|
attributes.push((body.read_int32(), body.read_int32()));
|
||||||
body.read_int32();
|
|
||||||
}
|
}
|
||||||
files.push((filename, file_size));
|
files.push(SharedFileEntry {
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
attributes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
dirs.push(SharedDirectory { name, files });
|
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());
|
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]
|
#[test]
|
||||||
fn shared_file_list_roundtrips() {
|
fn shared_file_list_roundtrips() {
|
||||||
let dirs = vec![
|
let dirs = vec![
|
||||||
SharedDirectory {
|
SharedDirectory {
|
||||||
name: "music\\album".to_string(),
|
name: "music\\album".to_string(),
|
||||||
files: vec![
|
files: vec![
|
||||||
("song one.flac".to_string(), 123),
|
SharedFileEntry {
|
||||||
("song two.flac".to_string(), 456),
|
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 {
|
SharedDirectory {
|
||||||
name: "music".to_string(),
|
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);
|
let message = build_shared_file_list(&dirs);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
|
|
||||||
|
use crate::message::peer::{SharedDirectory, SharedFileEntry};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -129,12 +130,13 @@ impl Shares {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Files grouped by their virtual directory (everything before the final
|
/// Files grouped by their virtual directory (everything before the final
|
||||||
/// backslash), as `(directory, [(basename, size)])`. Used to build a
|
/// backslash), in the form a browse or folder listing carries.
|
||||||
/// SharedFileListResponse.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn directories(&self) -> Vec<(String, Vec<(String, u64)>)> {
|
pub fn directories(&self) -> Vec<SharedDirectory> {
|
||||||
let mut by_dir: std::collections::BTreeMap<String, Vec<(String, u64)>> =
|
let mut by_dir: std::collections::BTreeMap<
|
||||||
std::collections::BTreeMap::new();
|
String,
|
||||||
|
Vec<SharedFileEntry>,
|
||||||
|
> = std::collections::BTreeMap::new();
|
||||||
for file in &self.files {
|
for file in &self.files {
|
||||||
let (dir, base) = file
|
let (dir, base) = file
|
||||||
.virtual_path
|
.virtual_path
|
||||||
@@ -143,9 +145,16 @@ impl Shares {
|
|||||||
by_dir
|
by_dir
|
||||||
.entry(dir.to_string())
|
.entry(dir.to_string())
|
||||||
.or_default()
|
.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]
|
#[must_use]
|
||||||
|
|||||||
@@ -1838,7 +1838,7 @@ fn a_runtime_share_update_is_visible_to_browsers() {
|
|||||||
assert!(
|
assert!(
|
||||||
directories
|
directories
|
||||||
.iter()
|
.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"
|
"the listing should include the file shared at runtime"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1888,7 +1888,7 @@ fn browse_a_peers_shared_files() {
|
|||||||
assert!(
|
assert!(
|
||||||
directories
|
directories
|
||||||
.iter()
|
.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"
|
"the listing should include the shared file"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1949,7 +1949,7 @@ fn browse_a_firewalled_peer_via_broker() {
|
|||||||
assert!(
|
assert!(
|
||||||
directories
|
directories
|
||||||
.iter()
|
.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"
|
"the brokered listing should include the shared file"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2019,7 +2019,7 @@ fn a_third_party_client_browses_our_shares_directly() {
|
|||||||
assert!(
|
assert!(
|
||||||
directories
|
directories
|
||||||
.iter()
|
.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:?}"
|
"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!(
|
assert!(
|
||||||
directories
|
directories
|
||||||
.iter()
|
.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:?}"
|
"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!(
|
assert!(
|
||||||
directories
|
directories
|
||||||
.iter()
|
.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:?}"
|
"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);
|
assert_eq!(echoed, folder);
|
||||||
let mut names: Vec<(String, Vec<String>)> = directories
|
let mut names: Vec<(String, Vec<String>)> = directories
|
||||||
.into_iter()
|
.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();
|
.collect();
|
||||||
for (_, files) in &mut names {
|
for (_, files) in &mut names {
|
||||||
files.sort();
|
files.sort();
|
||||||
@@ -3572,3 +3572,48 @@ fn a_shared_mp3_advertises_its_bitrate_and_duration() {
|
|||||||
|
|
||||||
let _ = std::fs::remove_dir_all(share_dir);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -948,21 +948,8 @@
|
|||||||
"SharedDirectoryDto": {
|
"SharedDirectoryDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"files": {
|
"files": {
|
||||||
"description": "Basename and size; join with the directory to get a downloadable path.",
|
|
||||||
"items": {
|
"items": {
|
||||||
"maxItems": 2,
|
"$ref": "#/components/schemas/SharedFileEntryDto"
|
||||||
"minItems": 2,
|
|
||||||
"prefixItems": [
|
|
||||||
{
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"format": "uint64",
|
|
||||||
"minimum": 0,
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"type": "array"
|
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
@@ -976,6 +963,46 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"SharesStatus": {
|
||||||
"description": "What the share index holds, from `shares.status` / `shares.reindex`.",
|
"description": "What the share index holds, from `shares.status` / `shares.reindex`.",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1313,7 +1340,7 @@
|
|||||||
"name": "MIT"
|
"name": "MIT"
|
||||||
},
|
},
|
||||||
"title": "soulseek-rs daemon",
|
"title": "soulseek-rs daemon",
|
||||||
"version": "1"
|
"version": "2"
|
||||||
},
|
},
|
||||||
"methods": [
|
"methods": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
wishlist later rather than repeating the same search.
|
||||||
|
|
||||||
**`browse <user>`** — one object per file the peer shares: `user`, `directory`,
|
**`browse <user>`** — 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 <name>`** — one object: `user`, `status` (`online`/`away`/`offline`),
|
**`user <name>`** — one object: `user`, `status` (`online`/`away`/`offline`),
|
||||||
`privileged`, `average_speed`, `shared_files`, `shared_folders`. Any field may
|
`privileged`, `average_speed`, `shared_files`, `shared_folders`. Any field may
|
||||||
|
|||||||
@@ -61,13 +61,15 @@ pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult {
|
|||||||
|
|
||||||
let mut files = 0usize;
|
let mut files = 0usize;
|
||||||
for directory in &directories {
|
for directory in &directories {
|
||||||
for (basename, size) in &directory.files {
|
for file in &directory.files {
|
||||||
files += 1;
|
files += 1;
|
||||||
ctx.out.emit(&BrowseRecord {
|
ctx.out.emit(&BrowseRecord {
|
||||||
user: args.user.clone(),
|
user: args.user.clone(),
|
||||||
directory: directory.name.clone(),
|
directory: directory.name.clone(),
|
||||||
path: full_path(&directory.name, basename),
|
path: full_path(&directory.name, &file.name),
|
||||||
size: *size,
|
size: file.size,
|
||||||
|
bitrate: file.attribute(super::transfer::ATTR_BITRATE),
|
||||||
|
duration: file.attribute(super::transfer::ATTR_DURATION),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ use std::sync::{Arc, Mutex};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// Soulseek file attribute codes the network agrees on.
|
/// Soulseek file attribute codes the network agrees on.
|
||||||
const ATTR_BITRATE: u32 = 0;
|
pub const ATTR_BITRATE: u32 = 0;
|
||||||
const ATTR_DURATION: u32 = 1;
|
pub const ATTR_DURATION: u32 = 1;
|
||||||
|
|
||||||
/// What to keep out of the raw result set. Every field is a further
|
/// What to keep out of the raw result set. Every field is a further
|
||||||
/// restriction: a candidate has to satisfy all of them.
|
/// restriction: a candidate has to satisfy all of them.
|
||||||
@@ -388,10 +388,10 @@ fn resolve_sizes(
|
|||||||
listing
|
listing
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|directory| {
|
.flat_map(|directory| {
|
||||||
directory.files.iter().map(|(basename, size)| {
|
directory.files.iter().map(|file| {
|
||||||
(
|
(
|
||||||
social::full_path(&directory.name, basename),
|
social::full_path(&directory.name, &file.name),
|
||||||
*size,
|
file.size,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use soulseek_rs::types::{
|
|||||||
};
|
};
|
||||||
use soulseek_rs::{
|
use soulseek_rs::{
|
||||||
DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss,
|
DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss,
|
||||||
SharedDirectory, UploadInfo, UploadStatus, UserInfo, UserMessage,
|
SharedDirectory, SharedFileEntry, UploadInfo, UploadStatus, UserInfo,
|
||||||
|
UserMessage,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -553,15 +554,32 @@ impl From<UserInfoDto> for UserInfo {
|
|||||||
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
#[cfg_attr(test, derive(schemars::JsonSchema))]
|
||||||
pub struct SharedDirectoryDto {
|
pub struct SharedDirectoryDto {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Basename and size; join with the directory to get a downloadable path.
|
pub files: Vec<SharedFileEntryDto>,
|
||||||
pub files: Vec<(String, u64)>,
|
}
|
||||||
|
|
||||||
|
#[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 {
|
impl From<&SharedDirectory> for SharedDirectoryDto {
|
||||||
fn from(directory: &SharedDirectory) -> Self {
|
fn from(directory: &SharedDirectory) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: directory.name.clone(),
|
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<SharedDirectoryDto> for SharedDirectory {
|
|||||||
fn from(dto: SharedDirectoryDto) -> Self {
|
fn from(dto: SharedDirectoryDto) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
files: dto.files,
|
files: dto
|
||||||
|
.files
|
||||||
|
.into_iter()
|
||||||
|
.map(|file| SharedFileEntry {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
attributes: file.attributes,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
/// Bumped when a change would break a client written against the old shape.
|
/// 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
|
/// Sent in the `auth` reply so a mismatch is caught at connect time rather
|
||||||
/// than as a confusing failure three calls later.
|
/// 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
|
/// The OpenRPC document, served by `rpc.discover` so a generator can point at
|
||||||
/// a running daemon instead of needing this repository.
|
/// a running daemon instead of needing this repository.
|
||||||
@@ -322,7 +322,11 @@ mod tests {
|
|||||||
fn a_shared_listing_survives_the_round_trip() {
|
fn a_shared_listing_survives_the_round_trip() {
|
||||||
let directory = SharedDirectory {
|
let directory = SharedDirectory {
|
||||||
name: "@@x\\Music".into(),
|
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))
|
let json = serde_json::to_string(&SharedDirectoryDto::from(&directory))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -123,13 +123,13 @@ pub fn build_browse_tree(directories: &[SharedDirectory]) -> BuiltTree {
|
|||||||
|
|
||||||
// File paths come from the original name so downloads match exactly.
|
// File paths come from the original name so downloads match exactly.
|
||||||
let dir_path = dir.name.trim_end_matches('\\');
|
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() {
|
let file_path = if dir_path.is_empty() {
|
||||||
basename.clone()
|
file.name.clone()
|
||||||
} else {
|
} 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 {
|
fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory {
|
||||||
SharedDirectory {
|
SharedDirectory {
|
||||||
name: name.to_string(),
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,8 @@ pub struct BrowseRecord {
|
|||||||
pub directory: String,
|
pub directory: String,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
|
pub bitrate: Option<u32>,
|
||||||
|
pub duration: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Record for BrowseRecord {
|
impl Record for BrowseRecord {
|
||||||
@@ -667,6 +669,8 @@ mod tests {
|
|||||||
directory: "@@music".into(),
|
directory: "@@music".into(),
|
||||||
path: "@@music\\a.flac".into(),
|
path: "@@music\\a.flac".into(),
|
||||||
size: 12,
|
size: 12,
|
||||||
|
bitrate: Some(320),
|
||||||
|
duration: None,
|
||||||
};
|
};
|
||||||
assert_eq!(record.text(), "bob\t12\t@@music\\a.flac");
|
assert_eq!(record.text(), "bob\t12\t@@music\\a.flac");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,14 +227,18 @@ mod tests {
|
|||||||
Event::Browse,
|
Event::Browse,
|
||||||
json!({
|
json!({
|
||||||
"username": "bob",
|
"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());
|
assert!(mirror.take_browse("sue").is_none());
|
||||||
let listing = mirror.take_browse("bob").expect("bob answered");
|
let listing = mirror.take_browse("bob").expect("bob answered");
|
||||||
assert_eq!(listing[0].name, "@@x");
|
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");
|
assert!(mirror.take_browse("bob").is_none(), "taken means taken");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1628,7 +1628,13 @@ mod tests {
|
|||||||
tui.state.browse.open("bob");
|
tui.state.browse.open("bob");
|
||||||
let listing = vec![soulseek_rs::SharedDirectory {
|
let listing = vec![soulseek_rs::SharedDirectory {
|
||||||
name: "Music".to_string(),
|
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
|
tui.state
|
||||||
.browse
|
.browse
|
||||||
|
|||||||
Reference in New Issue
Block a user