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.
This commit is contained in:
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user