Merge branch 'develop'

This commit is contained in:
Michel de Graaf
2026-09-08 02:07:54 +02:00
43 changed files with 2904 additions and 249 deletions
+42 -15
View File
@@ -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": [
{
+2 -1
View File
@@ -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 <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`),
`privileged`, `average_speed`, `shared_files`, `shared_folders`. Any field may
+6
View File
@@ -172,7 +172,11 @@ pub struct Cli {
pub log_file: Option<PathBuf>,
}
/// Non-exhaustive: this enum is public through the crate's library target so
/// the integration tests can reach it, and a new subcommand must not read as a
/// breaking change of that library.
#[derive(Subcommand, Debug)]
#[non_exhaustive]
pub enum Commands {
/// Search the network and print matching files
Search(SearchArgs),
@@ -254,6 +258,7 @@ pub struct DaemonArgs {
}
#[derive(Subcommand, Debug)]
#[non_exhaustive]
pub enum DaemonCommand {
/// Print the token remote clients authenticate with
Token,
@@ -266,6 +271,7 @@ pub enum DaemonCommand {
}
#[derive(Subcommand, Debug)]
#[non_exhaustive]
pub enum CompletionsCommand {
/// Write the completion script where the shell looks for it
Install(CompletionsArgs),
+5 -3
View File
@@ -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),
});
}
}
+5 -5
View File
@@ -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,
)
})
})
+31 -5
View File
@@ -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<UserInfoDto> 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<SharedFileEntryDto>,
}
#[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<SharedDirectoryDto> 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(),
}
}
}
+6 -2
View File
@@ -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();
+12 -5
View File
@@ -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(),
}
}
+4
View File
@@ -207,6 +207,8 @@ pub struct BrowseRecord {
pub directory: String,
pub path: String,
pub size: u64,
pub bitrate: Option<u32>,
pub duration: Option<u32>,
}
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");
}
+6 -2
View File
@@ -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");
}
+7 -1
View File
@@ -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
+40 -1
View File
@@ -1084,8 +1084,11 @@ impl FilterFixture {
fn new(server: &TestServer, sharer: &str) -> (Self, Client) {
let share = Scratch::new("filter-share");
// The mp3 is a real 128 kbps stream so the bitrate filter has
// something to read; the others are padding of a known size.
std::fs::write(share.path().join("flt_alpha_keepme.mp3"), cbr_mp3(10))
.expect("share file");
for (name, size) in [
("flt_alpha_keepme.mp3", Self::MEDIUM),
("flt_beta_skipme.flac", Self::SMALL),
("flt_gamma_keepme.ogg", Self::LARGE),
] {
@@ -1098,6 +1101,18 @@ impl FilterFixture {
}
}
/// A constant-bitrate MP3: `frames` MPEG-1 Layer III frames at 128 kbps and
/// 44.1 kHz, 417 bytes each; ten of them land between the fixture's SMALL
/// and LARGE sizes.
fn cbr_mp3(frames: usize) -> Vec<u8> {
let mut out = Vec::new();
for _ in 0..frames {
out.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
out.resize(out.len() + 413, 0);
}
out
}
/// Search for the fixture as a fresh user and return the matching file names,
/// sorted so the assertion does not depend on result order.
///
@@ -1155,6 +1170,30 @@ fn extension_keeps_one_type_and_repeats_into_a_union() {
assert_eq!(several, ["flt_beta_skipme.flac", "flt_gamma_keepme.ogg"]);
}
#[test]
fn a_bitrate_floor_keeps_only_files_that_advertise_at_least_that_much() {
let server = server_or_skip!();
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_kbps");
// Only the mp3 carries a bitrate; the padding files have none and an
// unknown bitrate cannot satisfy a floor.
let (exit, enough) = filtered_names(
&server,
"cli_e2e_seek_kbps_128",
&["--min-bitrate", "128"],
);
assert_eq!(exit, EXIT_OK);
assert_eq!(enough, ["flt_alpha_keepme.mp3"]);
let (exit, none) = filtered_names(
&server,
"cli_e2e_seek_kbps_320",
&["--min-bitrate", "320"],
);
assert_eq!(exit, EXIT_NO_RESULTS);
assert!(none.is_empty());
}
#[test]
fn size_bounds_narrow_the_result_set_from_either_end() {
let server = server_or_skip!();