Baseline: full tree snapshot after git repository loss

The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
target/
node_modules/
dist/
src-tauri/gen/
*.log
Generated
+6248
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
[workspace]
resolver = "2"
members = [
"src-tauri",
"crates/soulseek-proto",
"crates/soulseek-client",
"crates/player",
"crates/tagger",
"crates/metadata",
"crates/library",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.77.2"
authors = ["Trifecta Solutions"]
[workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "sync", "time", "macros", "fs"] }
bytes = "1"
thiserror = "2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
[profile.dev]
# Peer connections + audio decode are unusably slow at opt-level 0.
opt-level = 1
[profile.dev.package."*"]
opt-level = 2
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "library"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
rusqlite = { version = "0.32", features = ["bundled"] }
serde.workspace = true
thiserror.workspace = true
log.workspace = true
+724
View File
@@ -0,0 +1,724 @@
//! Music library: SQLite-backed albums and tracks. All methods are
//! synchronous — call from a blocking context.
use rusqlite::Connection;
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
#[derive(Debug, thiserror::Error)]
pub enum LibraryError {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
}
pub type Result<T> = std::result::Result<T, LibraryError>;
#[derive(Debug, Clone, Serialize)]
pub struct Album {
pub id: i64,
pub artist: String,
pub title: String,
pub year: Option<u32>,
pub art_path: Option<String>,
pub artist_art_path: Option<String>,
pub track_count: u32,
pub added_at: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Track {
pub id: i64,
pub album_id: i64,
pub track_no: Option<u32>,
pub title: String,
pub artist: String,
pub duration_secs: Option<u32>,
pub bitrate_kbps: Option<u32>,
pub sample_rate: Option<u32>,
pub bit_depth: Option<u32>,
pub size: Option<u64>,
pub path: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Playlist {
pub id: i64,
pub name: String,
pub track_count: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlaylistTrack {
pub position: i64,
pub title: String,
pub artist: String,
pub duration_secs: Option<u32>,
pub path: String,
pub art_path: Option<String>,
}
/// Cumulative listening stats for one artist (drives the artist-page chips).
#[derive(Debug, Clone, Serialize)]
pub struct PlayStats {
pub seconds: u64,
pub last_played_at: Option<i64>,
/// Most played track, ranked by listened time (not session count, so ten
/// instant skips never beat one album's worth of real listening).
pub top_title: Option<String>,
/// Listening sessions of the top track — the "N×" the chip displays.
pub top_count: u32,
}
pub struct Library {
conn: Mutex<Connection>,
}
impl Library {
pub fn open(db_path: &Path) -> Result<Library> {
let conn = Connection::open(db_path)?;
conn.execute_batch(
"
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY,
artist TEXT NOT NULL,
title TEXT NOT NULL,
year INTEGER,
art_path TEXT,
artist_art_path TEXT,
mbid TEXT,
added_at INTEGER NOT NULL,
UNIQUE(artist COLLATE NOCASE, title COLLATE NOCASE)
);
CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY,
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
track_no INTEGER,
title TEXT NOT NULL,
artist TEXT NOT NULL,
duration_secs INTEGER,
bitrate_kbps INTEGER,
sample_rate INTEGER,
bit_depth INTEGER,
size INTEGER,
path TEXT NOT NULL UNIQUE,
added_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tracks_album ON tracks(album_id);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS playlist_tracks (
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
title TEXT NOT NULL,
artist TEXT NOT NULL,
duration_secs INTEGER,
path TEXT NOT NULL,
art_path TEXT,
PRIMARY KEY (playlist_id, position)
);
CREATE TABLE IF NOT EXISTS plays (
id INTEGER PRIMARY KEY,
artist TEXT NOT NULL,
title TEXT NOT NULL,
started_at INTEGER NOT NULL,
seconds INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE);
",
)?;
// Migrate databases created before artist portraits were added.
let _ = conn.execute("ALTER TABLE albums ADD COLUMN artist_art_path TEXT", []);
// Migrate databases created before per-track audio props were stored.
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN sample_rate INTEGER", []);
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN bit_depth INTEGER", []);
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN size INTEGER", []);
Ok(Library {
conn: Mutex::new(conn),
})
}
/// Insert or fetch the album, updating year/art/mbid when newly known.
pub fn upsert_album(
&self,
artist: &str,
title: &str,
year: Option<u32>,
art_path: Option<&str>,
artist_art_path: Option<&str>,
mbid: Option<&str>,
now: i64,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO albums (artist, title, year, art_path, artist_art_path, mbid, added_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(artist COLLATE NOCASE, title COLLATE NOCASE) DO UPDATE SET
year = COALESCE(albums.year, excluded.year),
art_path = COALESCE(albums.art_path, excluded.art_path),
artist_art_path = COALESCE(albums.artist_art_path, excluded.artist_art_path),
mbid = COALESCE(albums.mbid, excluded.mbid)",
rusqlite::params![artist, title, year, art_path, artist_art_path, mbid, now],
)?;
let id = conn.query_row(
"SELECT id FROM albums WHERE artist = ?1 COLLATE NOCASE AND title = ?2 COLLATE NOCASE",
rusqlite::params![artist, title],
|r| r.get(0),
)?;
Ok(id)
}
pub fn set_album_art(&self, album_id: i64, art_path: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE albums SET art_path = ?2 WHERE id = ?1",
rusqlite::params![album_id, art_path],
)?;
Ok(())
}
pub fn set_artist_art(&self, artist: &str, art_path: &str) -> Result<usize> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute(
"UPDATE albums SET artist_art_path = ?2 WHERE artist = ?1 COLLATE NOCASE",
rusqlite::params![artist, art_path],
)?)
}
// ---- play history ----
// One row per listening session. The frontend opens a row when a track
// first accumulates listened time and tops up its seconds while it plays,
// so a skipped track never writes a row and per-play counts stay honest.
pub fn record_play(&self, artist: &str, title: &str, now: i64) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO plays (artist, title, started_at, seconds) VALUES (?1, ?2, ?3, 0)",
rusqlite::params![artist, title, now],
)?;
Ok(conn.last_insert_rowid())
}
pub fn add_play_seconds(&self, play_id: i64, secs: u32) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE plays SET seconds = seconds + ?2 WHERE id = ?1",
rusqlite::params![play_id, secs],
)?;
Ok(())
}
pub fn artist_play_stats(&self, artist: &str) -> Result<PlayStats> {
let conn = self.conn.lock().unwrap();
let (seconds, last_played_at) = conn.query_row(
"SELECT COALESCE(SUM(seconds), 0), MAX(started_at)
FROM plays WHERE artist = ?1 COLLATE NOCASE",
rusqlite::params![artist],
|r| Ok((r.get::<_, i64>(0)?.max(0) as u64, r.get(1)?)),
)?;
// Sessions shorter than 30s are noise (previews, near-instant skips);
// excluding them keeps the count aligned with the scrobbling norm.
let top = conn
.query_row(
"SELECT title, COUNT(*) FROM plays
WHERE artist = ?1 COLLATE NOCASE AND seconds >= 30
GROUP BY title COLLATE NOCASE
ORDER BY SUM(seconds) DESC LIMIT 1",
rusqlite::params![artist],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u32)),
)
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})?;
let (top_title, top_count) = top.map_or((None, 0), |(t, c)| (Some(t), c));
Ok(PlayStats {
seconds,
last_played_at,
top_title,
top_count,
})
}
#[allow(clippy::too_many_arguments)]
pub fn upsert_track(
&self,
album_id: i64,
track_no: Option<u32>,
title: &str,
artist: &str,
duration_secs: Option<u32>,
bitrate_kbps: Option<u32>,
sample_rate: Option<u32>,
bit_depth: Option<u32>,
size: Option<u64>,
path: &str,
now: i64,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO tracks (album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, added_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(path) DO UPDATE SET
album_id = excluded.album_id,
track_no = excluded.track_no,
title = excluded.title,
artist = excluded.artist,
duration_secs = excluded.duration_secs,
bitrate_kbps = excluded.bitrate_kbps,
sample_rate = excluded.sample_rate,
bit_depth = excluded.bit_depth,
size = excluded.size",
rusqlite::params![album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path, now],
)?;
let id = conn.query_row(
"SELECT id FROM tracks WHERE path = ?1",
rusqlite::params![path],
|r| r.get(0),
)?;
Ok(id)
}
pub fn has_track_path(&self, path: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM tracks WHERE path = ?1",
rusqlite::params![path],
|r| r.get(0),
)?;
Ok(n > 0)
}
/// Whether this song is already represented in the library, independent
/// of its on-disk path. Downloads are organized with a " (2)" suffix to
/// avoid overwriting files, so a path-only check cannot prevent a second
/// save of the same track from becoming a duplicate library row.
pub fn has_track(&self, artist: &str, album: &str, title: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row(
"SELECT COUNT(*)
FROM tracks t
JOIN albums a ON a.id = t.album_id
WHERE a.artist = ?1 COLLATE NOCASE
AND a.title = ?2 COLLATE NOCASE
AND t.title = ?3 COLLATE NOCASE",
rusqlite::params![artist, album, title],
|r| r.get(0),
)?;
Ok(n > 0)
}
pub fn get_album(&self, album_id: i64) -> Result<Option<(Album, Option<String>)>> {
let conn = self.conn.lock().unwrap();
let row = conn
.query_row(
"SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at, a.mbid,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id)
FROM albums a WHERE a.id = ?1",
[album_id],
|r| {
Ok((
Album {
id: r.get(0)?,
artist: r.get(1)?,
title: r.get(2)?,
year: r.get(3)?,
art_path: r.get(4)?,
artist_art_path: r.get(5)?,
added_at: r.get(6)?,
track_count: r.get::<_, i64>(8)? as u32,
},
r.get::<_, Option<String>>(7)?,
))
},
)
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})?;
Ok(row)
}
pub fn list_albums(&self) -> Result<Vec<Album>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT a.id, a.artist, a.title, a.year, a.art_path, a.artist_art_path, a.added_at,
(SELECT COUNT(*) FROM tracks t WHERE t.album_id = a.id)
FROM albums a
ORDER BY a.added_at DESC, a.artist, a.title",
)?;
let rows = stmt.query_map([], |r| {
Ok(Album {
id: r.get(0)?,
artist: r.get(1)?,
title: r.get(2)?,
year: r.get(3)?,
art_path: r.get(4)?,
artist_art_path: r.get(5)?,
added_at: r.get(6)?,
track_count: r.get::<_, i64>(7)? as u32,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn album_tracks(&self, album_id: i64) -> Result<Vec<Track>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, album_id, track_no, title, artist, duration_secs, bitrate_kbps, sample_rate, bit_depth, size, path
FROM tracks WHERE album_id = ?1
ORDER BY track_no IS NULL, track_no, title",
)?;
let rows = stmt.query_map([album_id], |r| {
Ok(Track {
id: r.get(0)?,
album_id: r.get(1)?,
track_no: r.get(2)?,
title: r.get(3)?,
artist: r.get(4)?,
duration_secs: r.get(5)?,
bitrate_kbps: r.get(6)?,
sample_rate: r.get(7)?,
bit_depth: r.get(8)?,
size: r.get::<_, Option<i64>>(9)?.map(|n| n as u64),
path: r.get(10)?,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// Paths of tracks missing the audio-props backfill (size is the sentinel:
/// always available from the filesystem, so a non-null size means scanned).
pub fn tracks_missing_props(&self) -> Result<Vec<(i64, String)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, path FROM tracks WHERE size IS NULL")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// Backfill audio props for one track (found via tracks_missing_props).
pub fn set_track_props(
&self,
id: i64,
sample_rate: Option<u32>,
bit_depth: Option<u32>,
size: Option<u64>,
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE tracks SET sample_rate = ?2, bit_depth = ?3, size = ?4 WHERE id = ?1",
rusqlite::params![id, sample_rate, bit_depth, size.map(|n| n as i64)],
)?;
Ok(())
}
// ---- playlists ----
pub fn create_playlist(&self, name: &str, now: i64) -> Result<i64> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO playlists (name, created_at) VALUES (?1, ?2)",
rusqlite::params![name, now],
)?;
Ok(conn.last_insert_rowid())
}
pub fn rename_playlist(&self, id: i64, name: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE playlists SET name = ?2 WHERE id = ?1",
rusqlite::params![id, name],
)?;
Ok(())
}
pub fn delete_playlist(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM playlist_tracks WHERE playlist_id = ?1", [id])?;
conn.execute("DELETE FROM playlists WHERE id = ?1", [id])?;
Ok(())
}
/// (id, name, track_count), newest first.
pub fn list_playlists(&self) -> Result<Vec<Playlist>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT p.id, p.name,
(SELECT COUNT(*) FROM playlist_tracks t WHERE t.playlist_id = p.id)
FROM playlists p ORDER BY p.created_at DESC",
)?;
let rows = stmt.query_map([], |r| {
Ok(Playlist {
id: r.get(0)?,
name: r.get(1)?,
track_count: r.get::<_, i64>(2)? as u32,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn add_to_playlist(&self, playlist_id: i64, t: &PlaylistTrack) -> Result<()> {
let conn = self.conn.lock().unwrap();
let next: i64 = conn.query_row(
"SELECT COALESCE(MAX(position), -1) + 1 FROM playlist_tracks WHERE playlist_id = ?1",
[playlist_id],
|r| r.get(0),
)?;
conn.execute(
"INSERT INTO playlist_tracks
(playlist_id, position, title, artist, duration_secs, path, art_path)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
playlist_id,
next,
t.title,
t.artist,
t.duration_secs,
t.path,
t.art_path
],
)?;
Ok(())
}
pub fn playlist_tracks(&self, playlist_id: i64) -> Result<Vec<PlaylistTrack>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT position, title, artist, duration_secs, path, art_path
FROM playlist_tracks WHERE playlist_id = ?1 ORDER BY position",
)?;
let rows = stmt.query_map([playlist_id], |r| {
Ok(PlaylistTrack {
position: r.get(0)?,
title: r.get(1)?,
artist: r.get(2)?,
duration_secs: r.get(3)?,
path: r.get(4)?,
art_path: r.get(5)?,
})
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
pub fn remove_from_playlist(&self, playlist_id: i64, position: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM playlist_tracks WHERE playlist_id = ?1 AND position = ?2",
rusqlite::params![playlist_id, position],
)?;
Ok(())
}
/// Drop tracks whose files vanished; returns how many were removed.
pub fn prune_missing(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let paths: Vec<(i64, String)> = conn
.prepare("SELECT id, path FROM tracks")?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.filter_map(|r| r.ok())
.collect();
let mut removed = 0;
for (id, path) in paths {
if !Path::new(&path).exists() {
conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
removed += 1;
}
}
conn.execute(
"DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)",
[],
)?;
Ok(removed)
}
/// Repoint stale artwork references to an identically named file in the
/// current app's art cache. This covers app-identifier/data-directory
/// migrations without requiring artwork to be downloaded again.
pub fn repair_art_paths(&self, art_dir: &Path) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let rows: Vec<(i64, Option<String>, Option<String>)> = conn
.prepare("SELECT id, art_path, artist_art_path FROM albums")?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.filter_map(|r| r.ok())
.collect();
let mut repaired = 0;
for (id, art_path, artist_art_path) in rows {
if let Some(path) = art_path.and_then(|p| cached_art_path(&p, art_dir)) {
conn.execute(
"UPDATE albums SET art_path = ?2 WHERE id = ?1",
rusqlite::params![id, path],
)?;
repaired += 1;
}
if let Some(path) = artist_art_path.and_then(|p| cached_art_path(&p, art_dir)) {
conn.execute(
"UPDATE albums SET artist_art_path = ?2 WHERE id = ?1",
rusqlite::params![id, path],
)?;
repaired += 1;
}
}
Ok(repaired)
}
/// Remove rows accidentally created for non-audio download extras (cover
/// images, playlists, text files, etc.). The library should only ever
/// expose playable audio tracks.
pub fn prune_non_audio(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
let rows: Vec<(i64, String)> = conn
.prepare("SELECT id, path FROM tracks")?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.filter_map(|r| r.ok())
.collect();
let mut removed = 0;
for (id, path) in rows {
let audio = Path::new(&path)
.extension()
.and_then(|e| e.to_str())
.map(|e| {
matches!(
e.to_ascii_lowercase().as_str(),
"flac" | "mp3" | "m4a" | "ogg" | "opus" | "wav" | "aac" | "wma"
)
})
.unwrap_or(false);
if !audio {
conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
removed += 1;
}
}
conn.execute(
"DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)",
[],
)?;
Ok(removed)
}
}
fn cached_art_path(path: &str, art_dir: &Path) -> Option<String> {
if Path::new(path).is_file() {
return None;
}
let name = Path::new(path).file_name()?;
let candidate = art_dir.join(name);
candidate
.is_file()
.then(|| candidate.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let dir = std::env::temp_dir().join("ssplus-lib-test");
std::fs::create_dir_all(&dir).unwrap();
let db = dir.join("t.db");
let _ = std::fs::remove_file(&db);
let lib = Library::open(&db).unwrap();
let album = lib
.upsert_album(
"Radiohead",
"OK Computer",
Some(1997),
None,
None,
None,
100,
)
.unwrap();
// Same album, case-insensitive → same id, art fills in.
let again = lib
.upsert_album(
"radiohead",
"ok computer",
None,
Some("art.jpg"),
None,
None,
200,
)
.unwrap();
assert_eq!(album, again);
lib.upsert_track(
album,
Some(1),
"Airbag",
"Radiohead",
Some(287),
Some(1024),
Some(44100),
Some(16),
Some(30_000_000),
"C:\\x\\01.flac",
100,
)
.unwrap();
lib.upsert_track(
album,
Some(2),
"Paranoid Android",
"Radiohead",
None,
None,
None,
None,
None,
"C:\\x\\02.flac",
100,
)
.unwrap();
let albums = lib.list_albums().unwrap();
assert_eq!(albums.len(), 1);
assert_eq!(albums[0].track_count, 2);
assert_eq!(albums[0].art_path.as_deref(), Some("art.jpg"));
let tracks = lib.album_tracks(album).unwrap();
assert_eq!(tracks[0].title, "Airbag");
assert!(lib.has_track_path("C:\\x\\01.flac").unwrap());
assert!(lib.has_track("radiohead", "ok computer", "airbag").unwrap());
assert!(!lib
.has_track("Radiohead", "OK Computer", "Karma Police")
.unwrap());
let art_dir = dir.join("art");
std::fs::create_dir_all(&art_dir).unwrap();
let cover = art_dir.join("cover.jpg");
let artist = art_dir.join("artist.jpg");
std::fs::write(&cover, []).unwrap();
std::fs::write(&artist, []).unwrap();
let stale = lib
.upsert_album(
"Stale Artist",
"Stale Album",
None,
Some("C:\\old-app-data\\art\\cover.jpg"),
Some("C:\\old-app-data\\art\\artist.jpg"),
None,
100,
)
.unwrap();
assert_eq!(lib.repair_art_paths(&art_dir).unwrap(), 2);
let repaired = lib.get_album(stale).unwrap().unwrap().0;
assert_eq!(
repaired.art_path.as_deref(),
Some(cover.to_string_lossy().as_ref())
);
assert_eq!(
repaired.artist_art_path.as_deref(),
Some(artist.to_string_lossy().as_ref())
);
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "metadata"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
futures = "0.3"
image.workspace = true
reqwest = { version = "0.12", features = ["json"] }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
thiserror.workspace = true
log.workspace = true
@@ -0,0 +1,123 @@
//! Latency harness for the album-description path (`album_info_for_artist_id`).
//!
//! This is the description counterpart to `discography_audit`: it measures the
//! call the artist page's expander and background hint harvest both make, so
//! optimization work has a before/after number instead of an impression.
//!
//! Usage:
//! cargo run -p metadata --example album_info_bench -- "Artist" "Title" "Title" ...
//!
//! Reports, in order:
//! 1. COLD SEQUENTIAL — one fresh client, titles one at a time. This is what
//! the frontend harvest does today (`for (const title of titles) await`),
//! so its total is the wall-clock the user waits for the tabs to settle.
//! 2. WARM SEQUENTIAL — same client again, exercising the per-source caches.
//! 3. COLD CONCURRENT — a second fresh client, all titles at once. The gap
//! against (1) is the ceiling available from parallelizing the harvest.
use metadata::MetadataClient;
use std::sync::Arc;
use std::time::Instant;
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let Some(artist) = args.next() else {
eprintln!("usage: album_info_bench -- \"Artist\" \"Title\" [\"Title\" ...]");
return;
};
let titles: Vec<String> = args.collect();
if titles.is_empty() {
eprintln!("give at least one album title");
return;
}
let client = Arc::new(MetadataClient::new());
let search = client.catalog_search(&artist).await;
let artist_id = search
.artists
.iter()
.find(|a| normalize(&a.name) == normalize(&artist))
.map(|a| a.id);
println!(
"artist {artist:?} -> id {artist_id:?}, {} titles\n",
titles.len()
);
let cold = run_sequential(&client, artist_id, &artist, &titles, "COLD SEQUENTIAL").await;
let warm = run_sequential(&client, artist_id, &artist, &titles, "WARM SEQUENTIAL").await;
// A second client so the concurrent run is genuinely cold; sharing the
// first one would measure the cache, not the network path.
let fresh = Arc::new(MetadataClient::new());
let started = Instant::now();
let mut tasks = Vec::new();
for title in &titles {
let fresh = Arc::clone(&fresh);
let artist = artist.clone();
let title = title.clone();
tasks.push(tokio::spawn(async move {
let at = Instant::now();
let info = fresh
.album_info_for_artist_id(artist_id, &artist, &title)
.await;
(title, at.elapsed().as_millis(), info.type_hint)
}));
}
let mut rows = Vec::new();
for task in tasks {
if let Ok(row) = task.await {
rows.push(row);
}
}
let concurrent = started.elapsed().as_millis();
println!("== COLD CONCURRENT ==");
for (title, ms, hint) in &rows {
println!(" {ms:>6} ms {title} hint={hint:?}");
}
println!(" total {concurrent} ms\n");
println!("== SUMMARY ==");
println!(" cold sequential : {cold:>6} ms <- today's harvest wall-clock");
println!(" warm sequential : {warm:>6} ms <- per-source cache hit");
println!(" cold concurrent : {concurrent:>6} ms <- ceiling from fan-out");
if concurrent > 0 {
println!(
" speedup : {:.1}x",
cold as f64 / concurrent as f64
);
}
}
async fn run_sequential(
client: &Arc<MetadataClient>,
artist_id: Option<u64>,
artist: &str,
titles: &[String],
label: &str,
) -> u128 {
println!("== {label} ==");
let started = Instant::now();
for title in titles {
let at = Instant::now();
let info = client
.album_info_for_artist_id(artist_id, artist, title)
.await;
println!(
" {:>6} ms {title} hint={:?} source={:?}",
at.elapsed().as_millis(),
info.type_hint,
info.source,
);
}
let total = started.elapsed().as_millis();
println!(" total {total} ms\n");
total
}
fn normalize(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
@@ -0,0 +1,44 @@
//! Live probe for identity-scoped release descriptions + type hints.
//!
//! Usage: cargo run -p metadata --example album_info_probe -- "Artist|Title" ...
use metadata::MetadataClient;
use std::sync::Arc;
#[tokio::main]
async fn main() {
let client = Arc::new(MetadataClient::new());
let requests: Vec<String> = std::env::args().skip(1).collect();
for request in requests {
let Some((artist, title)) = request.split_once('|') else {
eprintln!("expected Artist|Title, got {request}");
continue;
};
let search = client.catalog_search(artist).await;
let id = search
.artists
.iter()
.find(|a| normalize(&a.name) == normalize(artist))
.map(|a| a.id);
let info = client.album_info_for_artist_id(id, artist, title).await;
println!(
"{} | {} (id {:?})\n source={:?} hint={:?}\n desc={}\n",
artist,
title,
id,
info.source,
info.type_hint,
info.description
.as_deref()
.map(|d| d.chars().take(140).collect::<String>())
.unwrap_or_else(|| "<none>".into()),
);
}
}
fn normalize(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
@@ -0,0 +1,119 @@
//! Latency harness for the whole discography-load pipeline — the three
//! backend stages the artist page runs, in the order the frontend runs them:
//!
//! 1. `catalog_artist_overview` — the first-paint content stage
//! 2. `catalog_artist` — the exhaustive classification page
//! 3. `catalog_artist_identity` — the MusicBrainz supplement/type-hint pass
//!
//! Companion to `album_info_bench` (descriptions). Run with
//! `SOULSTREAM_STAGE_TRACE=1` to attribute time inside each stage.
//!
//! Usage:
//! cargo run -p metadata --example artist_page_bench -- "Artist" ["Artist" ...]
use metadata::MetadataClient;
use std::sync::Arc;
use std::time::Instant;
#[tokio::main]
async fn main() {
let artists: Vec<String> = std::env::args().skip(1).collect();
if artists.is_empty() {
eprintln!("usage: artist_page_bench -- \"Artist\" [\"Artist\" ...]");
return;
}
for artist in &artists {
let client = Arc::new(MetadataClient::new());
let search = client.catalog_search(artist).await;
let Some(id) = search
.artists
.iter()
.find(|a| normalize(&a.name) == normalize(artist))
.map(|a| a.id)
else {
println!("== {artist}: no exact-name artist found, skipping ==\n");
continue;
};
println!("== {artist} (deezer {id}) ==");
// The frontend overlaps overview + page, then runs the identity pass
// after the page lands. Reproduce exactly that shape.
let cold_started = Instant::now();
let (overview, page) = tokio::join!(
async {
let t = Instant::now();
let overview = client.catalog_artist_overview(id).await;
(t.elapsed().as_millis(), overview)
},
async {
let t = Instant::now();
let page = client.catalog_artist(id).await;
(t.elapsed().as_millis(), page)
},
);
let overlap_ms = cold_started.elapsed().as_millis();
println!(
" cold overview: {} ms ({} albums) cold page: {} ms ({} albums) overlapped: {overlap_ms} ms",
overview.0,
overview.1.as_ref().map(|o| o.albums.len()).unwrap_or(0),
page.0,
page.1.as_ref().map(|p| p.albums.len()).unwrap_or(0),
);
let t = Instant::now();
let identity = client.catalog_artist_identity(id, artist).await;
let identity_ms = t.elapsed().as_millis();
println!(
" cold identity pass: {identity_ms} ms (supplement {} albums, {} type hints)",
identity.albums.len(),
identity.type_hints.len(),
);
println!(
" COLD TOTAL to fully sorted (page ∥ overview, then identity): {} ms",
overlap_ms + identity_ms
);
let t = Instant::now();
let _ = client.catalog_artist(id).await;
println!(" warm page: {} ms", t.elapsed().as_millis());
let t = Instant::now();
let _ = client.catalog_artist_identity(id, artist).await;
println!(" warm identity: {} ms", t.elapsed().as_millis());
// The new frontend shape: the identity pass is kicked off BESIDE the
// page load instead of after it, so cold time-to-sorted is max(), not
// sum(). A fresh client so nothing above warms it.
let fresh = Arc::new(MetadataClient::new());
let t = Instant::now();
let (_page, _identity) = tokio::join!(
fresh.catalog_artist(id),
fresh.catalog_artist_identity(id, artist),
);
println!(
" cold page ∥ identity (new frontend overlap): {} ms",
t.elapsed().as_millis()
);
// Restart warmth: two separate clients sharing one disk cache dir —
// the second models an app relaunch.
let dir = std::env::temp_dir().join("soulstream-artist-page-bench-cache");
let _ = std::fs::remove_dir_all(&dir);
let writer = Arc::new(MetadataClient::new().with_disk_cache(dir.clone()));
let _ = writer.catalog_artist_identity(id, artist).await;
let restarted = Arc::new(MetadataClient::new().with_disk_cache(dir.clone()));
let t = Instant::now();
let _ = restarted.catalog_artist_identity(id, artist).await;
println!(
" identity after app restart (disk cache): {} ms\n",
t.elapsed().as_millis()
);
}
}
fn normalize(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
+44
View File
@@ -0,0 +1,44 @@
//! Hardened cover-resolution report across a whole discography.
//! cargo run -p metadata --example cover_probe -- input.json output.json
//!
//! input.json: { "albums": [ { "artist": "...", "title": "...", "cover": "<url>" }, ... ] }
//! output.json: [ CoverReport, ... ] (reference + candidates + chosen, with dHash distances)
#[derive(serde::Deserialize)]
struct Album {
artist: String,
title: String,
cover: Option<String>,
}
#[derive(serde::Deserialize)]
struct Input {
albums: Vec<Album>,
}
#[tokio::main]
async fn main() {
let in_path = std::env::args().nth(1).expect("input.json");
let out_path = std::env::args().nth(2).expect("output.json");
let raw = std::fs::read_to_string(&in_path).expect("read input");
let input: Input =
serde_json::from_str(raw.trim_start_matches('\u{feff}')).expect("parse input");
let client = metadata::MetadataClient::new();
let mut reports = Vec::new();
let total = input.albums.len();
for (i, a) in input.albums.iter().enumerate() {
let reference = a.cover.clone().unwrap_or_default();
let report = client.cover_report(&a.artist, &a.title, &reference).await;
eprintln!(
"[{}/{}] {} :: chosen={:?} ({} candidates)",
i + 1,
total,
a.title,
report.chosen_source,
report.candidates.len()
);
reports.push(report);
}
std::fs::write(&out_path, serde_json::to_string(&reports).unwrap()).expect("write output");
eprintln!("wrote {out_path}");
}
+26
View File
@@ -0,0 +1,26 @@
//! Decode an image with the exact decoder the hero pipeline uses (the `image`
//! crate) and write lossless PNG. Lets the live-GPU hero prototype consume
//! bit-identical pixels to the baked path — browsers decode JPEG chroma
//! differently, which showed up as a subtle tint in A/B comparisons.
//!
//! Usage: decode_to_png <input.jpg> <output.png>
fn main() {
let mut args = std::env::args().skip(1);
let (Some(input), Some(output)) = (args.next(), args.next()) else {
eprintln!("usage: decode_to_png <input> <output.png>");
std::process::exit(2);
};
let bytes = std::fs::read(&input).expect("read input");
let decoded = image::load_from_memory(&bytes).expect("decode");
decoded
.save_with_format(&output, image::ImageFormat::Png)
.expect("write png");
println!(
"{} {}x{} -> {}",
input,
decoded.width(),
decoded.height(),
output
);
}
@@ -0,0 +1,124 @@
//! Broad discography audit over the PRODUCTION artist pipeline.
//!
//! Runs `catalog_artist` (Deezer + Apple supplement, dedup, edition folding)
//! plus the deferred `catalog_artist_identity` MusicBrainz pass for each
//! requested artist, and prints one JSON object per artist (JSONL) with the
//! final album rows and MB type hints — exactly the data the artist page
//! files into tabs. An external analyzer replicates the frontend's category
//! rules over this dump to flag duplicates and misfiled releases.
//!
//! Usage:
//! cargo run -p metadata --example discography_audit -- "Artist" ...
//! cargo run -p metadata --example discography_audit -- --broad
use metadata::MetadataClient;
use std::sync::Arc;
const BROAD_ARTISTS: [&str; 44] = [
"Weezer",
"Radiohead",
"Queen",
"The Beatles",
"Taylor Swift",
"Beyoncé",
"Coldplay",
"Nirvana",
"Fleetwood Mac",
"Elton John",
"David Bowie",
"Pink Floyd",
"Led Zeppelin",
"Bob Dylan",
"Miles Davis",
"Daft Punk",
"deadmau5",
"Fatboy Slim",
"Bonobo",
"Four Tet",
"Calvin Harris",
"Aphex Twin",
"The Chemical Brothers",
"Crazy P",
"Moby",
"Björk",
"Depeche Mode",
"Kanye West",
"Kendrick Lamar",
"Eminem",
"Drake",
"Travis Scott",
"Hans Zimmer",
"John Williams",
"Danny Elfman",
"Ludwig Göransson",
"Nine Inch Nails",
"MGMT",
"Panda Bear",
"Tame Impala",
"Mac DeMarco",
"Gorillaz",
"Metallica",
"ABBA",
];
#[tokio::main]
async fn main() {
let mut requested: Vec<String> = std::env::args().skip(1).collect();
if requested.iter().any(|a| a == "--broad") {
requested = BROAD_ARTISTS.iter().map(|s| s.to_string()).collect();
}
if requested.is_empty() {
eprintln!("pass artist names or --broad");
std::process::exit(2);
}
let client = Arc::new(MetadataClient::new());
for name in requested {
let search = client.catalog_search(&name).await;
let want = normalize(&name);
let artist = search
.artists
.iter()
.filter(|a| normalize(&a.name) == want)
.max_by_key(|a| a.fans)
.or_else(|| search.artists.first());
let Some(artist) = artist else {
println!(
"{}",
serde_json::json!({ "requested": name, "error": "not_found" })
);
continue;
};
let id = artist.id;
let display = artist.name.clone();
let fans = artist.fans;
let page = client.catalog_artist(id).await;
let Some(page) = page else {
println!(
"{}",
serde_json::json!({ "requested": name, "artist_id": id, "error": "page_failed" })
);
continue;
};
let identity = client.catalog_artist_identity(id, &display).await;
println!(
"{}",
serde_json::json!({
"requested": name,
"artist_id": id,
"artist": display,
"fans": fans,
"albums": page.albums,
"type_hints": identity.type_hints,
"identity_albums": identity.albums,
})
);
}
}
fn normalize(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
@@ -0,0 +1,162 @@
//! Live diagnostic for the production artist-art resolver.
//!
//! Usage:
//! cargo run -p metadata --example hero_source_probe -- [output-dir] [artist ...]
//! cargo run -p metadata --example hero_source_probe -- [output-dir] --extended
//! cargo run -p metadata --example hero_source_probe -- [output-dir] --bing-only [artist ...]
//!
//! With no artists, the five core sourcing fixtures are used. `--extended`
//! adds ten lesser-known stress fixtures. Passing `-` as the output directory
//! prints URLs without saving the image bytes.
use metadata::{CatalogArtist, MetadataClient};
use std::path::PathBuf;
const DEFAULT_ARTISTS: [&str; 5] = [
"Camping in Alaska",
"Partiboi69",
"Hermeth",
"Billy Cobb",
"Fishmans",
];
const EXTENDED_ARTISTS: [&str; 10] = [
"Wednesday",
"Equipment",
"Hana Vu",
"Jane Remover",
"DJ Sabrina The Teenage DJ",
"Nikki Nair",
"MSPAINT",
"Horse Jumper of Love",
"They Are Gutting a Body of Water",
"Sword II",
];
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let output = args.next().filter(|value| value != "-").map(PathBuf::from);
let mut requested: Vec<String> = args.collect();
let bing_only = requested.iter().any(|value| value == "--bing-only");
requested.retain(|value| value != "--bing-only");
let artists: Vec<String> = if requested.is_empty() {
DEFAULT_ARTISTS
.iter()
.map(|name| name.to_string())
.collect()
} else if requested.len() == 1 && requested[0] == "--extended" {
DEFAULT_ARTISTS
.iter()
.chain(EXTENDED_ARTISTS.iter())
.map(|name| name.to_string())
.collect()
} else {
requested
};
if let Some(output) = output.as_ref() {
if let Err(error) = std::fs::create_dir_all(output) {
eprintln!("could not create {}: {error}", output.display());
std::process::exit(2);
}
}
let client = MetadataClient::new().with_hero_diagnostics();
for requested in artists {
// Bing-only diagnostics do not need a Deezer id. Keeping that path
// independent means catalog rate limits cannot turn a scraper probe
// into an immediate, misleading NOT_FOUND for every requested name.
let (artist_id, artist_name, fans) = if bing_only {
(None, requested, 0)
} else {
let search = client.catalog_search(&requested).await;
let Some(artist) = best_exact_artist(&search.artists, &requested) else {
println!("ARTIST\t{requested}\tNOT_FOUND");
continue;
};
(
Some(artist.id),
artist.name.clone(),
artist.fans.unwrap_or(0),
)
};
println!(
"ARTIST\t{}\t{}\t{}",
artist_id
.map(|id| id.to_string())
.unwrap_or_else(|| "BING_ONLY".to_string()),
artist_name,
fans,
);
let banners = if let Some(artist_id) = artist_id {
let Some(art) = client.catalog_artist_art(artist_id).await else {
println!("ERROR\t{}\tartist art unavailable", artist_name);
continue;
};
art.banners
} else {
client.debug_bing_hero_banners(&artist_name).await
};
if banners.is_empty() {
println!("BANNER\t{}\tNONE", artist_name);
continue;
}
for (index, url) in banners.iter().enumerate() {
println!("BANNER\t{}\t{}\t{}", artist_name, index + 1, url);
let Some(output) = output.as_ref() else {
continue;
};
let Some(bytes) = client.fetch_image_bytes(url).await else {
println!(
"SAVE_ERROR\t{}\t{}\tdownload failed",
artist_name,
index + 1
);
continue;
};
let extension = match image::guess_format(&bytes) {
Ok(image::ImageFormat::Png) => "png",
Ok(image::ImageFormat::WebP) => "webp",
_ => "jpg",
};
let path = output.join(format!(
"{}-{}.{}",
file_stem(&artist_name),
index + 1,
extension
));
match std::fs::write(&path, bytes) {
Ok(()) => println!("SAVED\t{}", path.display()),
Err(error) => println!("SAVE_ERROR\t{}\t{}", path.display(), error),
}
}
}
}
fn best_exact_artist<'a>(
artists: &'a [CatalogArtist],
requested: &str,
) -> Option<&'a CatalogArtist> {
artists
.iter()
.filter(|artist| artist.name.trim().eq_ignore_ascii_case(requested.trim()))
.max_by_key(|artist| artist.fans.unwrap_or(0))
}
fn file_stem(name: &str) -> String {
name.to_lowercase()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-")
}
+33
View File
@@ -0,0 +1,33 @@
//! Live check: MusicBrainz lookup + art fetch.
//! cargo run -p metadata --example lookup -- "Nirvana" "Nevermind"
#[tokio::main]
async fn main() {
let artist = std::env::args().nth(1).unwrap_or_else(|| "Nirvana".into());
let album = std::env::args()
.nth(2)
.unwrap_or_else(|| "Nevermind".into());
let client = metadata::MetadataClient::new();
match client.lookup_album(&artist, &album).await {
Some(meta) => {
println!(
"MB: {} — {} ({:?}) mbid={:?}",
meta.artist, meta.album, meta.year, meta.release_mbid
);
match client
.fetch_art(
meta.release_mbid.as_deref(),
meta.release_group_mbid.as_deref(),
&meta.artist,
&meta.album,
)
.await
{
Some(bytes) => println!("ART: {} bytes (starts {:02x?})", bytes.len(), &bytes[..4]),
None => println!("ART: none found"),
}
}
None => println!("MB: no match"),
}
}
@@ -0,0 +1,221 @@
//! Go/no-go experiment for merging the identity pass's two release-group
//! queries into one (theory 5 of the 2026-07-22 load audit).
//!
//! Current scheme (2 queries):
//! STUDIO: arid:X AND primarytype:album AND -secondarytype:* AND status:official (1 page)
//! HINTS: arid:X AND primarytype:album AND secondarytype:* (≤5 pages)
//!
//! Candidate merged scheme (1 query):
//! ALL-OFFICIAL: arid:X AND primarytype:album AND status:official (≤5 pages)
//! → client-side split: no secondary types ⇒ studio, some ⇒ hint.
//!
//! The merge is adoptable ONLY if, for every probed artist, the split
//! reproduces BOTH current sets exactly. The known hazard: the hints query
//! has no status filter today, so status:official could drop bootleg-only
//! compilation groups from the hint set — a classification change.
//!
//! Usage: cargo run -p metadata --example mb_query_merge_probe -- "Artist" ...
use std::collections::BTreeSet;
use std::time::Duration;
const UA: &str = "SoulSeekPlus/0.1 (dev build)";
#[tokio::main]
async fn main() {
let artists: Vec<String> = std::env::args().skip(1).collect();
if artists.is_empty() {
eprintln!("usage: mb_query_merge_probe -- \"Artist\" ...");
return;
}
let http = reqwest::Client::builder()
.user_agent(UA)
.timeout(Duration::from_secs(15))
.build()
.expect("client");
let meta = metadata::MetadataClient::new();
let mut all_ok = true;
for artist in &artists {
let search = meta.catalog_search(artist).await;
let Some(deezer_id) = search
.artists
.iter()
.find(|a| norm(&a.name) == norm(artist))
.map(|a| a.id)
else {
println!("== {artist}: no exact Deezer artist, skipped ==");
continue;
};
let Some(mbid) = linked_mbid(&http, deezer_id).await else {
println!("== {artist}: no linked MBID, skipped ==");
continue;
};
let studio_q =
format!("arid:{mbid} AND primarytype:album AND -secondarytype:* AND status:official");
let hints_q = format!("arid:{mbid} AND primarytype:album AND secondarytype:*");
let merged_q = format!("arid:{mbid} AND primarytype:album AND status:official");
// Current studio behavior is a SINGLE page.
let studio_now: BTreeSet<String> = fetch_groups(&http, &studio_q, 1)
.await
.into_iter()
.filter(|g| g.secondary.is_empty())
.map(|g| g.title)
.collect();
let hints_now: BTreeSet<String> = fetch_groups(&http, &hints_q, 5)
.await
.into_iter()
.map(|g| format!("{} :: {:?}", g.title, g.secondary))
.collect();
let merged = fetch_groups(&http, &merged_q, 5).await;
let studio_merged: BTreeSet<String> = merged
.iter()
.filter(|g| g.secondary.is_empty())
.map(|g| g.title.clone())
.collect();
let hints_merged: BTreeSet<String> = merged
.iter()
.filter(|g| !g.secondary.is_empty())
.map(|g| format!("{} :: {:?}", g.title, g.secondary))
.collect();
let studio_ok = studio_now == studio_merged;
let hints_ok = hints_now == hints_merged;
all_ok &= studio_ok && hints_ok;
println!(
"== {artist} == studio {}/{} {} hints {}/{} {}",
studio_now.len(),
studio_merged.len(),
if studio_ok { "IDENTICAL" } else { "DIFFERS" },
hints_now.len(),
hints_merged.len(),
if hints_ok { "IDENTICAL" } else { "DIFFERS" },
);
if !studio_ok {
for extra in studio_merged.difference(&studio_now) {
println!(" studio only-in-merged: {extra}");
}
for missing in studio_now.difference(&studio_merged) {
println!(" studio missing-from-merged: {missing}");
}
}
if !hints_ok {
for extra in hints_merged.difference(&hints_now) {
println!(" hint only-in-merged: {extra}");
}
for missing in hints_now.difference(&hints_merged) {
println!(" hint missing-from-merged: {missing}");
}
}
}
println!(
"\nVERDICT: {}",
if all_ok {
"merged query reproduced both sets exactly for every probed artist"
} else {
"NOT identical — do not merge"
}
);
}
struct Group {
title: String,
secondary: Vec<String>,
}
async fn fetch_groups(http: &reqwest::Client, query: &str, max_pages: usize) -> Vec<Group> {
let mut out = Vec::new();
for page in 0..max_pages {
throttle().await;
let url = format!(
"https://musicbrainz.org/ws/2/release-group/?query={}&limit=100&offset={}&fmt=json",
urlencoding(query),
page * 100
);
let Ok(resp) = http.get(&url).send().await else {
break;
};
let Ok(body) = resp.json::<serde_json::Value>().await else {
break;
};
let Some(groups) = body.get("release-groups").and_then(|v| v.as_array()) else {
break;
};
if groups.is_empty() {
break;
}
let page_len = groups.len();
for g in groups {
let Some(title) = g.get("title").and_then(|v| v.as_str()) else {
continue;
};
let secondary = g
.get("secondary-types")
.and_then(|v| v.as_array())
.map(|a| {
let mut s: Vec<String> = a
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
s.sort();
s
})
.unwrap_or_default();
out.push(Group {
title: title.to_string(),
secondary,
});
}
if page_len < 100 {
break;
}
}
out
}
async fn linked_mbid(http: &reqwest::Client, deezer_id: u64) -> Option<String> {
throttle().await;
let resource = urlencoding(&format!("https://www.deezer.com/artist/{deezer_id}"));
let url =
format!("https://musicbrainz.org/ws/2/url?resource={resource}&inc=artist-rels&fmt=json");
let body = http
.get(&url)
.send()
.await
.ok()?
.json::<serde_json::Value>()
.await
.ok()?;
body.get("relations")?.as_array()?.iter().find_map(|r| {
r.pointer("/artist/id")
.and_then(|v| v.as_str())
.map(String::from)
})
}
async fn throttle() {
tokio::time::sleep(Duration::from_millis(1100)).await;
}
fn urlencoding(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn norm(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "player"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
rodio = { version = "0.21", features = ["symphonia-all"] }
symphonia = { version = "0.5", features = ["all"] }
thiserror.workspace = true
log.workspace = true
[dev-dependencies]
env_logger = "0.11"
+41
View File
@@ -0,0 +1,41 @@
//! Local playback smoke test: play a file, seek, report positions.
//!
//! cargo run -p player --example play -- "C:\path\to\file.flac"
use player::{Player, PlayerEvent};
use std::time::Duration;
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init();
let path = std::env::args().nth(1).expect("usage: play <file>");
let (player, events) = Player::spawn();
player.play_local(&path, None);
let mut last_pos = 0.0;
let start = std::time::Instant::now();
let mut seeked = false;
while start.elapsed() < Duration::from_secs(12) {
match events.recv_timeout(Duration::from_millis(500)) {
Ok(PlayerEvent::Playing) => println!("PLAYING"),
Ok(PlayerEvent::Position { secs, duration }) => {
last_pos = secs;
println!("pos={secs:.1} dur={duration:?}");
if secs > 3.0 && !seeked {
seeked = true;
println!("SEEK to 60s…");
player.seek(60.0);
}
}
Ok(PlayerEvent::Error(e)) => {
println!("ERROR: {e}");
return;
}
Ok(ev) => println!("{ev:?}"),
Err(_) => {}
}
}
println!("final pos: {last_pos:.1} (expect > 60 if seek worked)");
player.stop();
}
+100
View File
@@ -0,0 +1,100 @@
//! Phase 0.3 — native transport probe (isolated diagnostic, not feature code).
//!
//! Answers the native column's core questions with the existing crate, no new
//! dependencies:
//!
//! 1. Can a SECOND `Player` instance coexist with a "main" one — two sinks,
//! two event channels, no shared state?
//! 2. Does a real Deezer preview (MPEG-1 Layer III, 128k CBR, ~480 KB)
//! decode and play through rodio/symphonia?
//! 3. Do pause / resume / stop behave on the second instance while the
//! first is mid-track, without cross-talk on either event channel?
//!
//! The preview file is downloaded OUTSIDE this probe (curl) and passed as
//! argv[1]; argv[2] is any long local track for the "main" side. Run:
//!
//! cargo run -p player --example preview_probe -- preview.mp3 main.mp3
use std::sync::mpsc::RecvTimeoutError;
use std::time::{Duration, Instant};
use player::{Player, PlayerEvent};
fn drain(
label: &str,
rx: &std::sync::mpsc::Receiver<PlayerEvent>,
for_ms: u64,
log: &mut Vec<String>,
) {
let end = Instant::now() + Duration::from_millis(for_ms);
loop {
let left = end.saturating_duration_since(Instant::now());
if left.is_zero() {
break;
}
match rx.recv_timeout(left) {
Ok(ev) => log.push(format!("{label}: {ev:?}")),
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => {
log.push(format!("{label}: channel closed"));
break;
}
}
}
}
fn main() {
let mut args = std::env::args().skip(1);
let preview = args.next().expect("argv[1]: downloaded preview mp3");
let main_track = args.next();
let mut log = Vec::new();
// "Main" player — only if a second file was provided; the coexistence
// half of the probe is skipped otherwise.
let main_pair = main_track.map(|p| {
let (main, main_rx) = Player::spawn();
main.play_local(&p, None);
(main, main_rx, p)
});
// Preview player: a wholly separate instance and event channel.
let (prev, prev_rx) = Player::spawn();
let t0 = Instant::now();
prev.play_local(&preview, Some(30.0));
drain("preview", &prev_rx, 2500, &mut log);
let started = log.iter().any(|l| l.contains("Playing"));
println!("PROBE preview-starts: {started} ({} ms to first events)", t0.elapsed().as_millis());
prev.pause();
drain("preview", &prev_rx, 600, &mut log);
let paused = log.iter().any(|l| l.contains("Paused"));
println!("PROBE preview-pauses: {paused}");
prev.resume();
drain("preview", &prev_rx, 900, &mut log);
if let Some((_main, main_rx, ref path)) = main_pair {
let mut main_log = Vec::new();
drain("main", &main_rx, 1200, &mut main_log);
let main_alive = main_log.iter().any(|l| l.contains("Playing") || l.contains("Position"));
println!("PROBE main-coexists: {main_alive} ({path})");
// cross-talk check: no preview-channel event may appear on main's
// channel or vice versa — separate receivers make this structural,
// so what we actually assert is that both channels stayed live.
println!("PROBE channels-independent: true (separate mpsc receivers by construction)");
} else {
println!("PROBE main-coexists: skipped (no second file)");
}
prev.stop();
drain("preview", &prev_rx, 600, &mut log);
let stopped = log.iter().any(|l| l.contains("Stopped"));
println!("PROBE preview-stops: {stopped}");
println!("--- event log ---");
for l in &log {
println!("{l}");
}
}
+17
View File
@@ -0,0 +1,17 @@
//! Print an ASCII preview of a file's waveform shape.
//! cargo run -p player --example wf -- "C:\path\to\file.flac"
fn main() {
let path = std::path::PathBuf::from(std::env::args().nth(1).expect("usage: wf <file>"));
let t = std::time::Instant::now();
let peaks = player::compute_waveform(&path, 80);
println!("{} buckets in {:?}", peaks.len(), t.elapsed());
let glyphs = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let bars: String = peaks
.iter()
.map(|&v| glyphs[((v * 8.0) as usize).min(8)])
.collect();
println!("{bars}");
let flat = peaks.iter().filter(|&&v| v > 0.95).count();
println!("buckets >0.95: {flat}/{}", peaks.len());
}
+904
View File
@@ -0,0 +1,904 @@
//! Audio playback: rodio/symphonia on a dedicated audio thread, with a
//! growing-file source for play-while-downloading.
//!
//! The audio thread owns the output device and stays responsive: decoder
//! construction (which blocks until enough of the file exists) happens on
//! short-lived helper threads, tagged with a generation counter so stale
//! decodes are discarded when the user has already moved on.
mod source;
mod waveform;
pub use source::StreamProgress;
pub use waveform::compute_waveform;
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use rodio::{buffer::SamplesBuffer, Decoder, Sink, Source};
use source::GrowingFile;
#[derive(Debug, Clone)]
pub enum PlayerEvent {
/// Decoder is being set up (streaming: waiting for first bytes).
Buffering,
Playing,
Paused,
Stopped,
Position {
secs: f64,
duration: Option<f64>,
},
/// A track ended. `gapless` means the next decoder was already queued in
/// the audio sink and has begun without an output-device interruption.
TrackEnded {
gapless: bool,
},
Error(String),
}
enum Cmd {
Play {
path: PathBuf,
stream: Option<Arc<StreamProgress>>,
total_bytes: u64,
duration_hint: Option<f64>,
},
/// Decode and append the next local track before the active one ends.
Preload {
path: PathBuf,
duration_hint: Option<f64>,
},
Pause,
Resume,
Seek(f64),
Scratch {
secs: f64,
rate: f32,
},
EndScratch {
secs: f64,
},
Volume(f32),
SetOutputDevice {
device: Option<String>,
reply: mpsc::Sender<Result<(), String>>,
},
Stop,
}
#[derive(Clone)]
struct TrackSource {
path: PathBuf,
stream: Option<Arc<StreamProgress>>,
total_bytes: u64,
duration_hint: Option<f64>,
}
struct ReadyDecoder {
generation: u64,
decoder: Decoder<Box<dyn source::MediaRead>>,
track: TrackSource,
preload: bool,
resume_at: Option<Duration>,
start_paused: bool,
}
impl ReadyDecoder {
/// True duration for the timeline/seek range. The peer's search-metadata
/// hint can be wildly wrong (a bogus 30s on a 41-minute live track), and
/// on its own it would truncate the seek bar. The decoder reads the real
/// length from the container (FLAC STREAMINFO, an MP3's byte length), so
/// we take whichever is LONGER: a short hint can't truncate a genuinely
/// long file, and a still-partial decoder can't under-report against a
/// good hint.
fn track_duration(&self) -> Option<f64> {
reconcile_duration(
self.track.duration_hint,
self.decoder.total_duration().map(|d| d.as_secs_f64()),
)
}
}
/// Pick the timeline duration from the peer's metadata hint and the decoder's
/// own reading: take whichever is longer. See [`ReadyDecoder::track_duration`].
fn reconcile_duration(hint: Option<f64>, decoded: Option<f64>) -> Option<f64> {
match (hint, decoded) {
(Some(h), Some(d)) => Some(h.max(d)),
(h, d) => h.or(d),
}
}
struct PendingTrack {
duration: Option<f64>,
track: TrackSource,
}
/// Forwards a boundary event from rodio's real-time source iterator exactly
/// when a decoder is exhausted. This lets the UI change its metadata after
/// the next queued source has already started producing samples.
struct EndNotifier<S> {
inner: S,
queued: Arc<AtomicUsize>,
event_tx: mpsc::Sender<PlayerEvent>,
sent: bool,
}
impl<S> Iterator for EndNotifier<S>
where
S: Source,
{
type Item = S::Item;
fn next(&mut self) -> Option<Self::Item> {
match self.inner.next() {
Some(sample) => Some(sample),
None => {
if !self.sent {
self.sent = true;
let remaining = self.queued.fetch_sub(1, Ordering::SeqCst);
let _ = self.event_tx.send(PlayerEvent::TrackEnded {
gapless: remaining > 1,
});
}
None
}
}
}
}
impl<S> Source for EndNotifier<S>
where
S: Source,
{
fn current_span_len(&self) -> Option<usize> {
self.inner.current_span_len()
}
fn channels(&self) -> rodio::ChannelCount {
self.inner.channels()
}
fn sample_rate(&self) -> rodio::SampleRate {
self.inner.sample_rate()
}
fn total_duration(&self) -> Option<Duration> {
self.inner.total_duration()
}
// The gapless boundary wrapper must remain transparent to transport
// controls. Without this delegation, Source's default implementation
// reports `NotSupported` and makes every seek a no-op.
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
self.inner.try_seek(pos)
}
}
#[derive(Clone)]
pub struct Player {
cmd_tx: mpsc::Sender<Cmd>,
}
impl Player {
/// Spawn the audio thread. Events stream out on the receiver.
pub fn spawn() -> (Player, mpsc::Receiver<PlayerEvent>) {
Self::spawn_with_device(None)
}
/// Spawn with a persisted output-device preference. `None` follows the
/// operating system's default output device.
pub fn spawn_with_device(
output_device: Option<String>,
) -> (Player, mpsc::Receiver<PlayerEvent>) {
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
let (event_tx, event_rx) = mpsc::channel::<PlayerEvent>();
std::thread::Builder::new()
.name("audio".into())
.spawn(move || audio_thread(cmd_rx, event_tx, output_device))
.expect("spawn audio thread");
(Player { cmd_tx }, event_rx)
}
pub fn play_local(&self, path: impl Into<PathBuf>, duration_hint: Option<f64>) {
let _ = self.cmd_tx.send(Cmd::Play {
path: path.into(),
stream: None,
total_bytes: 0,
duration_hint,
});
}
/// Queue a local file behind the current source. The decoder is prepared
/// on the helper thread, so disk I/O never interrupts audible playback.
pub fn preload_local(&self, path: impl Into<PathBuf>, duration_hint: Option<f64>) {
let _ = self.cmd_tx.send(Cmd::Preload {
path: path.into(),
duration_hint,
});
}
/// Play a file that is still being downloaded. `progress` must be kept
/// updated by the download; `total_bytes` is the expected final size.
pub fn play_stream(
&self,
path: impl Into<PathBuf>,
progress: Arc<StreamProgress>,
total_bytes: u64,
duration_hint: Option<f64>,
) {
let _ = self.cmd_tx.send(Cmd::Play {
path: path.into(),
stream: Some(progress),
total_bytes,
duration_hint,
});
}
pub fn pause(&self) {
let _ = self.cmd_tx.send(Cmd::Pause);
}
pub fn resume(&self) {
let _ = self.cmd_tx.send(Cmd::Resume);
}
pub fn seek(&self, secs: f64) {
let _ = self.cmd_tx.send(Cmd::Seek(secs));
}
pub fn scratch(&self, secs: f64, rate: f32) {
let _ = self.cmd_tx.send(Cmd::Scratch { secs, rate });
}
pub fn end_scratch(&self, secs: f64) {
let _ = self.cmd_tx.send(Cmd::EndScratch { secs });
}
pub fn set_volume(&self, volume: f32) {
let _ = self.cmd_tx.send(Cmd::Volume(volume.clamp(0.0, 2.0)));
}
/// Change devices immediately. The audio thread acknowledges only after
/// the endpoint has opened successfully, so callers can safely persist it.
pub fn set_output_device(&self, device: Option<String>) -> Result<(), String> {
let (reply, result) = mpsc::channel();
self.cmd_tx
.send(Cmd::SetOutputDevice { device, reply })
.map_err(|_| "audio thread is unavailable".to_string())?;
result
.recv_timeout(Duration::from_secs(5))
.map_err(|_| "timed out while opening the audio device".to_string())?
}
pub fn stop(&self) {
let _ = self.cmd_tx.send(Cmd::Stop);
}
}
/// Names reported by the platform audio host, suitable for a device picker.
pub fn output_device_names() -> Result<Vec<String>, String> {
let host = rodio::cpal::default_host();
let mut names: Vec<String> = host
.output_devices()
.map_err(|e| format!("could not list audio devices: {e}"))?
.filter_map(|device| device.name().ok())
.collect();
names.sort_by_key(|name| name.to_lowercase());
names.dedup();
Ok(names)
}
fn default_output_device_name() -> Option<String> {
rodio::cpal::default_host()
.default_output_device()?
.name()
.ok()
}
fn open_output_stream(device_name: Option<&str>) -> Result<(rodio::OutputStream, String), String> {
let host = rodio::cpal::default_host();
let device = match device_name {
Some(wanted) => host
.output_devices()
.map_err(|e| format!("could not list audio devices: {e}"))?
.find(|device| device.name().ok().as_deref() == Some(wanted))
.ok_or_else(|| format!("audio device not found: {wanted}"))?,
None => host
.default_output_device()
.ok_or_else(|| "Windows has no default audio output device".to_string())?,
};
let actual_name = device.name().unwrap_or_else(|_| "Unknown device".into());
let builder = rodio::OutputStreamBuilder::from_device(device)
.map_err(|e| format!("could not configure {actual_name}: {e}"))?;
let stream = builder
.open_stream_or_fallback()
.map_err(|e| format!("could not open {actual_name}: {e}"))?;
Ok((stream, actual_name))
}
fn start_decoder(
track: TrackSource,
generation: u64,
preload: bool,
resume_at: Option<Duration>,
start_paused: bool,
tx: mpsc::Sender<Result<ReadyDecoder, (u64, String)>>,
) {
std::thread::Builder::new()
.name(
if preload {
"decode-preload"
} else {
"decode-setup"
}
.into(),
)
.spawn(move || {
let (reader, byte_len): (Box<dyn source::MediaRead>, u64) = match track.stream.clone() {
Some(progress) => {
match GrowingFile::open(&track.path, track.total_bytes, progress) {
Ok(g) => (Box::new(g), track.total_bytes),
Err(e) => {
let _ = tx.send(Err((generation, e.to_string())));
return;
}
}
}
None => match std::fs::File::open(&track.path) {
Ok(f) => {
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
(Box::new(std::io::BufReader::new(f)), len)
}
Err(e) => {
let _ = tx.send(Err((generation, e.to_string())));
return;
}
},
};
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
if byte_len > 0 {
builder = builder.with_byte_len(byte_len);
}
match builder.build() {
Ok(decoder) => {
let _ = tx.send(Ok(ReadyDecoder {
generation,
decoder,
track,
preload,
resume_at,
start_paused,
}));
}
Err(e) => {
let _ = tx.send(Err((generation, format!("decode failed: {e}"))));
}
}
})
.ok();
}
fn audio_thread(
cmd_rx: mpsc::Receiver<Cmd>,
event_tx: mpsc::Sender<PlayerEvent>,
initial_device: Option<String>,
) {
// Open the output device lazily and keep it across tracks. If the device
// is momentarily busy (e.g. another instance still releasing it), we
// retry on the next Play instead of killing the thread for the session.
let (mut output_stream, mut current_device_name) =
match open_output_stream(initial_device.as_deref()) {
Ok((stream, name)) => (Some(stream), Some(name)),
Err(e) => {
log::warn!("could not open configured audio output: {e}");
(None, None)
}
};
let mut selected_device = initial_device;
let mut last_default_check = Instant::now();
let (decoder_tx, decoder_rx) = mpsc::channel::<Result<ReadyDecoder, (u64, String)>>();
let mut sink: Option<Sink> = None;
let mut generation: u64 = 0;
let mut duration: Option<f64> = None;
let mut volume: f32 = 0.7;
let mut playing = false;
// The active stream source, so we can abort its blocking reads.
let mut active_stream: Option<Arc<StreamProgress>> = None;
// Number of sources handed to the sink but not yet exhausted. Kept in an
// atomic because the rodio mixer consumes sources on its own audio thread.
let queued_sources = Arc::new(AtomicUsize::new(0));
let mut waiting_preload: Option<ReadyDecoder> = None;
// Durations of sources queued behind the active one. When a gapless
// boundary passes, the front entry becomes the new track's duration —
// otherwise Position events keep reporting the finished track's length.
let mut pending_tracks: VecDeque<PendingTrack> = VecDeque::new();
let mut last_queued: usize = 0;
// Scratch playback is possible only for a stable, complete local file.
let mut local_path: Option<PathBuf> = None;
let mut active_track: Option<TrackSource> = None;
let mut scratching = false;
let mut scratch_was_playing = false;
let emit = |ev: PlayerEvent| {
let _ = event_tx.send(ev);
};
loop {
let mut output_change: Option<(Option<String>, Option<mpsc::Sender<Result<(), String>>>)> =
None;
// Commands, with a 60 ms tick for a visibly smooth playhead without
// flooding the UI with frame-rate IPC events.
match cmd_rx.recv_timeout(Duration::from_millis(60)) {
Ok(Cmd::Play {
path,
stream,
total_bytes,
duration_hint,
}) => {
// Abort whatever was playing or buffering.
generation += 1;
if let Some(s) = active_stream.take() {
s.abort();
}
if let Some(s) = sink.take() {
s.stop();
}
queued_sources.store(0, Ordering::SeqCst);
waiting_preload = None;
pending_tracks.clear();
playing = false;
scratching = false;
let track = TrackSource {
path,
stream,
total_bytes,
duration_hint,
};
local_path = track.stream.is_none().then(|| track.path.clone());
duration = duration_hint;
emit(PlayerEvent::Buffering);
active_stream = track.stream.clone();
active_track = Some(track.clone());
start_decoder(track, generation, false, None, false, decoder_tx.clone());
}
Ok(Cmd::Preload {
path,
duration_hint,
}) => {
// Streams are intentionally excluded: a growing file can
// block while waiting for bytes, which would defeat a clean
// album transition. Local files are fully available.
start_decoder(
TrackSource {
path,
stream: None,
total_bytes: 0,
duration_hint,
},
generation,
true,
None,
false,
decoder_tx.clone(),
);
}
Ok(Cmd::Pause) => {
if let Some(s) = &sink {
s.pause();
playing = false;
emit(PlayerEvent::Paused);
}
}
Ok(Cmd::Resume) => {
if let Some(s) = &sink {
s.play();
playing = true;
emit(PlayerEvent::Playing);
}
}
Ok(Cmd::Seek(secs)) => {
if let Some(s) = &sink {
if let Err(e) = s.try_seek(Duration::from_secs_f64(secs.max(0.0))) {
log::debug!("seek failed: {e}");
}
}
}
Ok(Cmd::Scratch { secs, rate }) => {
let (Some(path), Some(out)) = (local_path.as_ref(), output_stream.as_ref()) else {
continue;
};
if !scratching {
// Discard any asynchronous decoder/preload that was
// prepared for the normal gapless path. Scratch owns the
// sink until release and must never mix in the next song.
generation += 1;
scratch_was_playing = playing;
scratching = true;
queued_sources.store(0, Ordering::SeqCst);
waiting_preload = None;
pending_tracks.clear();
last_queued = 0;
}
if let Some(old) = sink.take() {
old.stop();
}
match decode_scratch_window(path, secs, rate) {
Ok((samples, speed)) => {
let s = Sink::connect_new(out.mixer());
s.set_volume(volume);
s.set_speed(speed);
s.append(samples);
s.play();
sink = Some(s);
// The UI owns position while the platter is dragged.
playing = true;
}
Err(e) => log::debug!("scratch decode failed: {e}"),
}
}
Ok(Cmd::EndScratch { secs }) => {
if !scratching {
continue;
}
scratching = false;
if let Some(old) = sink.take() {
old.stop();
}
queued_sources.store(0, Ordering::SeqCst);
pending_tracks.clear();
last_queued = 0;
let (Some(path), Some(out)) = (local_path.as_ref(), output_stream.as_ref()) else {
continue;
};
match local_decoder(path) {
Ok(decoder) => {
let s = Sink::connect_new(out.mixer());
s.set_volume(volume);
queued_sources.store(1, Ordering::SeqCst);
s.append(EndNotifier {
inner: decoder,
queued: queued_sources.clone(),
event_tx: event_tx.clone(),
sent: false,
});
// Seek through the sink rather than before append so
// Sink::get_pos continues to report the track's real
// absolute position after a scratch release.
if let Err(e) = s.try_seek(Duration::from_secs_f64(secs.max(0.0))) {
log::debug!("scratch release seek failed: {e}");
}
if scratch_was_playing {
s.play();
playing = true;
emit(PlayerEvent::Playing);
} else {
s.pause();
playing = false;
emit(PlayerEvent::Paused);
}
sink = Some(s);
}
Err(e) => emit(PlayerEvent::Error(e)),
}
}
Ok(Cmd::Volume(v)) => {
volume = v;
if let Some(s) = &sink {
s.set_volume(v);
}
}
Ok(Cmd::SetOutputDevice { device, reply }) => {
output_change = Some((device, Some(reply)));
}
Ok(Cmd::Stop) => {
generation += 1;
if let Some(s) = active_stream.take() {
s.abort();
}
if let Some(s) = sink.take() {
s.stop();
}
queued_sources.store(0, Ordering::SeqCst);
waiting_preload = None;
pending_tracks.clear();
playing = false;
scratching = false;
local_path = None;
active_track = None;
emit(PlayerEvent::Stopped);
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
// In system-default mode, periodically compare endpoint names. CPAL's
// existing stream remains attached to the old endpoint when Windows'
// default changes, so reopening is required.
if output_change.is_none()
&& selected_device.is_none()
&& !scratching
&& last_default_check.elapsed() >= Duration::from_secs(1)
{
last_default_check = Instant::now();
if let Some(default_name) = default_output_device_name() {
if current_device_name.as_deref() != Some(default_name.as_str()) {
output_change = Some((None, None));
}
}
}
if let Some((requested, reply)) = output_change {
if scratching {
if let Some(reply) = reply {
let _ =
reply.send(Err("finish scratching before changing audio devices".into()));
}
} else {
match open_output_stream(requested.as_deref()) {
Err(e) => {
if let Some(reply) = reply {
let _ = reply.send(Err(e));
} else {
log::warn!("could not follow Windows audio output change: {e}");
}
}
Ok((new_stream, name)) => {
let resume_at = sink.as_ref().map(|s| s.get_pos());
let start_paused = sink.is_some() && !playing;
if active_track.is_some() {
generation += 1;
}
if let Some(old) = sink.take() {
old.stop();
}
queued_sources.store(0, Ordering::SeqCst);
waiting_preload = None;
pending_tracks.clear();
last_queued = 0;
output_stream = Some(new_stream);
current_device_name = Some(name);
selected_device = requested;
if let Some(track) = active_track.clone() {
playing = false;
emit(PlayerEvent::Buffering);
start_decoder(
track,
generation,
false,
resume_at,
start_paused,
decoder_tx.clone(),
);
}
if let Some(reply) = reply {
let _ = reply.send(Ok(()));
}
}
}
}
}
// Finished decoder setups.
while let Ok(result) = decoder_rx.try_recv() {
match result {
Ok(ready) if ready.generation == generation => {
// (Re)acquire the output device if we don't have it yet.
if output_stream.is_none() {
match open_output_stream(selected_device.as_deref()) {
Ok((new_stream, name)) => {
output_stream = Some(new_stream);
current_device_name = Some(name);
}
Err(e) => log::warn!("could not acquire audio output: {e}"),
}
}
let Some(out) = output_stream.as_ref() else {
emit(PlayerEvent::Error(
"audio device unavailable — is another app using it?".into(),
));
continue;
};
if ready.preload && sink.is_none() {
// Decoder setup can beat the first decoder on a fast
// disk. Hold it until the active sink exists.
waiting_preload = Some(ready);
continue;
}
let is_preload = ready.preload;
let track_duration = ready.track_duration();
if !is_preload {
let s = Sink::connect_new(out.mixer());
s.set_volume(volume);
sink = Some(s);
active_track = Some(ready.track.clone());
active_stream = ready.track.stream.clone();
local_path = ready
.track
.stream
.is_none()
.then(|| ready.track.path.clone());
}
let target = sink
.as_ref()
.expect("sink exists for active or preloaded decoder");
queued_sources.fetch_add(1, Ordering::SeqCst);
target.append(EndNotifier {
inner: ready.decoder,
queued: queued_sources.clone(),
event_tx: event_tx.clone(),
sent: false,
});
if let Some(pos) = ready.resume_at {
if let Err(e) = target.try_seek(pos) {
log::warn!("could not resume after audio-device change: {e}");
}
}
if is_preload {
// Nothing changes visually yet; samples will switch at
// the exact decoder boundary.
pending_tracks.push_back(PendingTrack {
duration: track_duration,
track: ready.track,
});
} else {
duration = track_duration;
if ready.start_paused {
target.pause();
playing = false;
emit(PlayerEvent::Paused);
} else {
target.play();
playing = true;
emit(PlayerEvent::Playing);
}
if let Some(next) = waiting_preload.take() {
let next_duration = next.track_duration();
pending_tracks.push_back(PendingTrack {
duration: next_duration,
track: next.track.clone(),
});
queued_sources.fetch_add(1, Ordering::SeqCst);
target.append(EndNotifier {
inner: next.decoder,
queued: queued_sources.clone(),
event_tx: event_tx.clone(),
sent: false,
});
}
}
}
Ok(_) => {} // stale generation: user moved on
Err((gen, msg)) if gen == generation => emit(PlayerEvent::Error(msg)),
Err(_) => {}
}
}
// A queued source finished while the sink kept playing (gapless
// boundary): adopt the next track's duration for Position events.
let q = queued_sources.load(Ordering::SeqCst);
if q < last_queued && q > 0 {
for _ in 0..(last_queued - q) {
if let Some(next) = pending_tracks.pop_front() {
duration = next.duration;
local_path = next.track.stream.is_none().then(|| next.track.path.clone());
active_stream = next.track.stream.clone();
active_track = Some(next.track);
}
}
}
last_queued = q;
// Position tick / end detection.
if let Some(s) = &sink {
if playing && !scratching {
if s.empty() {
playing = false;
sink = None;
if let Some(st) = active_stream.take() {
st.abort();
}
active_track = None;
local_path = None;
// EndNotifier emits TrackEnded from the audio callback;
// do not emit a second, late event from this polling tick.
} else {
emit(PlayerEvent::Position {
secs: s.get_pos().as_secs_f64(),
duration,
});
}
}
}
}
}
/// Build a seekable decoder for a complete local file. Growing network
/// downloads stay on ordinary seek behavior because reverse windows need
/// stable bytes on disk.
fn local_decoder(path: &Path) -> Result<Decoder<Box<dyn source::MediaRead>>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
let mut builder = Decoder::builder()
.with_data(Box::new(std::io::BufReader::new(file)) as Box<dyn source::MediaRead>)
.with_seekable(true);
if len > 0 {
builder = builder.with_byte_len(len);
}
builder.build().map_err(|e| format!("decode failed: {e}"))
}
/// Decode ~180 ms around the jog point. Reversing complete interleaved frames
/// (not individual samples) preserves stereo while producing true reverse
/// audio; the sink rate carries the hand-speed/pitch relationship.
fn decode_scratch_window(
path: &Path,
secs: f64,
rate: f32,
) -> Result<(SamplesBuffer, f32), String> {
let mut decoder = local_decoder(path)?;
let channels = decoder.channels();
let sample_rate = decoder.sample_rate();
let speed = rate.abs().clamp(0.25, 4.0);
let window_secs = 0.18 * f64::from(speed);
let start = if rate < 0.0 {
(secs - window_secs).max(0.0)
} else {
secs.max(0.0)
};
decoder
.try_seek(Duration::from_secs_f64(start))
.map_err(|e| format!("scratch seek failed: {e}"))?;
let count = (window_secs * f64::from(sample_rate) * f64::from(channels)) as usize;
let mut data: Vec<f32> = decoder
.by_ref()
.take(count.max(channels as usize))
.collect();
if data.is_empty() {
return Err("no audio at scratch position".into());
}
if rate < 0.0 {
let frame = channels as usize;
data = data
.chunks(frame)
.rev()
.flat_map(|f| f.iter().copied())
.collect();
}
Ok((SamplesBuffer::new(channels, sample_rate, data), speed))
}
#[cfg(test)]
mod tests {
use super::reconcile_duration;
#[test]
fn bogus_short_hint_cannot_truncate_a_long_file() {
// The reported bug: peer metadata said 30s for a 41-minute live FLAC
// whose decoder reads the true length. The real duration must win.
assert_eq!(reconcile_duration(Some(30.0), Some(2491.0)), Some(2491.0));
}
#[test]
fn partial_decoder_cannot_underreport_against_a_good_hint() {
// A still-downloading source whose decoder only sees part of the file
// must not shrink a correct hint.
assert_eq!(reconcile_duration(Some(240.0), Some(45.0)), Some(240.0));
}
#[test]
fn falls_back_to_whichever_exists() {
assert_eq!(reconcile_duration(None, Some(180.0)), Some(180.0));
assert_eq!(reconcile_duration(Some(180.0), None), Some(180.0));
assert_eq!(reconcile_duration(None, None), None);
}
}
+218
View File
@@ -0,0 +1,218 @@
//! Growing-file media source: reads a file that is still being written,
//! blocking (instead of returning EOF) when playback catches up to the
//! download's write head.
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
/// Everything rodio's Decoder needs from a source.
pub trait MediaRead: Read + Seek + Send + Sync {}
impl<T: Read + Seek + Send + Sync> MediaRead for T {}
#[derive(Debug, Default)]
struct ProgressState {
received: u64,
done: bool,
failed: Option<String>,
aborted: bool,
}
/// Shared write-progress of an in-flight download. The downloader calls
/// [`update`](Self::update); the audio thread's reads wait on it.
#[derive(Debug, Default)]
pub struct StreamProgress {
state: Mutex<ProgressState>,
cv: Condvar,
}
impl StreamProgress {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn update(&self, received: u64, done: bool, failed: Option<String>) {
let mut st = self.state.lock().unwrap();
st.received = st.received.max(received);
st.done |= done;
if st.failed.is_none() {
st.failed = failed;
}
self.cv.notify_all();
}
/// Unblock any waiting reads permanently (track switched / stopped).
pub fn abort(&self) {
let mut st = self.state.lock().unwrap();
st.aborted = true;
self.cv.notify_all();
}
/// Wait until at least `wanted` bytes exist (or terminal state).
/// Returns Ok(available bytes) or Err(reason) on failure/abort.
fn wait_for(&self, wanted: u64) -> Result<u64, String> {
let mut st = self.state.lock().unwrap();
loop {
if st.aborted {
return Err("stream aborted".into());
}
if let Some(reason) = &st.failed {
return Err(reason.clone());
}
if st.done || st.received >= wanted {
return Ok(st.received);
}
let (guard, _) = self
.cv
.wait_timeout(st, Duration::from_millis(250))
.map_err(|_| "progress lock poisoned".to_string())?;
st = guard;
}
}
fn snapshot(&self) -> (u64, bool) {
let st = self.state.lock().unwrap();
(st.received, st.done)
}
}
/// A file being written by a download, exposed as a blocking Read + Seek.
pub struct GrowingFile {
file: std::fs::File,
pos: u64,
total: u64,
progress: Arc<StreamProgress>,
}
impl GrowingFile {
pub fn open(
path: &Path,
total: u64,
progress: Arc<StreamProgress>,
) -> std::io::Result<GrowingFile> {
// The file may not exist until the remote queue clears; wait for
// the first bytes before opening.
progress.wait_for(1).map_err(std::io::Error::other)?;
let file = std::fs::File::open(path)?;
Ok(GrowingFile {
file,
pos: 0,
total,
progress,
})
}
}
impl Read for GrowingFile {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let (received, done) = self.progress.snapshot();
// At the write head and still downloading: wait for more.
if self.pos >= received && !done {
match self.progress.wait_for(self.pos + 1) {
Ok(_) => {}
Err(e) if e == "stream aborted" => return Ok(0), // graceful EOF
Err(e) => return Err(std::io::Error::other(e)),
}
}
let n = self.file.read(buf)?;
if n == 0 {
// Raced the writer: data was promised but the handle hasn't
// caught up; only report EOF once the download is done.
let (_, done) = self.progress.snapshot();
if !done {
std::thread::sleep(Duration::from_millis(50));
let n2 = self.file.read(buf)?;
self.pos += n2 as u64;
return Ok(n2);
}
}
self.pos += n as u64;
Ok(n)
}
}
impl Seek for GrowingFile {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
// Normalize End against the *final* size so seek math is stable
// even while the file is still growing.
let target = match pos {
SeekFrom::Start(o) => o,
SeekFrom::Current(d) => self.pos.saturating_add_signed(d),
SeekFrom::End(d) => self.total.saturating_add_signed(d),
};
// Block until the target region exists, so decoder probes into
// not-yet-downloaded space stall instead of erroring.
if target > 0 {
self.progress
.wait_for(target.min(self.total))
.map_err(std::io::Error::other)?;
}
self.pos = self.file.seek(SeekFrom::Start(target))?;
Ok(self.pos)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn growing_read_blocks_until_bytes_arrive() {
let dir = std::env::temp_dir().join("ssplus-player-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("grow.bin");
std::fs::write(&path, b"").unwrap();
let progress = StreamProgress::new();
let writer_progress = progress.clone();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(&writer_path)
.unwrap();
for chunk in 0..4u8 {
std::thread::sleep(Duration::from_millis(60));
f.write_all(&[chunk; 100]).unwrap();
f.flush().unwrap();
writer_progress.update((chunk as u64 + 1) * 100, chunk == 3, None);
}
});
let mut g = GrowingFile::open(&path, 400, progress).unwrap();
let mut out = Vec::new();
g.read_to_end(&mut out).unwrap();
writer.join().unwrap();
assert_eq!(out.len(), 400);
assert_eq!(out[350], 3);
}
#[test]
fn abort_unblocks_reader() {
let dir = std::env::temp_dir().join("ssplus-player-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("abort.bin");
std::fs::write(&path, b"xx").unwrap();
let progress = StreamProgress::new();
progress.update(2, false, None);
let mut g = GrowingFile::open(&path, 1000, progress.clone()).unwrap();
let killer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(100));
progress.abort();
});
let mut out = Vec::new();
// Reads the 2 bytes, then blocks at the write head until abort.
g.read_to_end(&mut out).unwrap();
killer.join().unwrap();
assert_eq!(out.len(), 2);
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Waveform extraction: decode an audio file and downsample it to a small
//! array of normalized 0..1 loudness values, for a waveform scrubber.
//!
//! Uses RMS energy per window, not peak amplitude: modern masters are
//! limited so hard that nearly every window's *peak* touches the digital
//! ceiling, which renders as a clipped brick. RMS tracks perceived
//! loudness and keeps the verse/chorus dynamics visible.
use std::path::Path;
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
/// Peaks folded over this many frames before resampling to the final bucket
/// count. Keeps memory bounded regardless of track length.
const WINDOW: usize = 1024;
/// Decode `path` and return `buckets` normalized peak values (0.0..=1.0).
/// Returns an empty vec on any decode failure.
pub fn compute_waveform(path: &Path, buckets: usize) -> Vec<f32> {
match try_compute(path, buckets) {
Ok(v) => v,
Err(e) => {
log::debug!("waveform for {} failed: {e}", path.display());
Vec::new()
}
}
}
fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
// Stream partials are "<name>.<ext>.partial" — hint the codec ext.
let ext = if ext.eq_ignore_ascii_case("partial") {
path.file_stem()
.map(Path::new)
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.unwrap_or(ext)
} else {
ext
};
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| e.to_string())?;
let mut format = probed.format;
let track = format
.default_track()
.ok_or_else(|| "no default track".to_string())?;
let track_id = track.id;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| e.to_string())?;
let mut sample_buf: Option<SampleBuffer<f32>> = None;
let mut coarse: Vec<f32> = Vec::new();
let mut win_energy = 0f64;
let mut win_count = 0usize;
loop {
let packet = match format.next_packet() {
Ok(p) => p,
Err(_) => break, // EOF or error → stop with what we have
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
Err(_) => break,
};
let spec = *decoded.spec();
let channels = spec.channels.count().max(1);
if sample_buf.is_none() {
sample_buf = Some(SampleBuffer::new(decoded.capacity() as u64, spec));
}
let sb = sample_buf.as_mut().unwrap();
sb.copy_interleaved_ref(decoded);
for frame in sb.samples().chunks(channels) {
let energy =
frame.iter().map(|&s| (s as f64) * (s as f64)).sum::<f64>() / channels as f64;
win_energy += energy;
win_count += 1;
if win_count >= WINDOW {
coarse.push((win_energy / WINDOW as f64).sqrt() as f32);
win_energy = 0.0;
win_count = 0;
}
}
}
if win_count > 0 {
coarse.push((win_energy / win_count as f64).sqrt() as f32);
}
Ok(resample(&coarse, buckets))
}
/// Downsample `coarse` RMS values to exactly `buckets`, averaging each
/// span, then normalize so the loudest bucket is 1.0.
fn resample(coarse: &[f32], buckets: usize) -> Vec<f32> {
let mut out = vec![0f32; buckets];
if coarse.is_empty() || buckets == 0 {
return out;
}
for (b, slot) in out.iter_mut().enumerate() {
let start = b * coarse.len() / buckets;
let end = (((b + 1) * coarse.len() / buckets).max(start + 1)).min(coarse.len());
let span = &coarse[start..end];
*slot = span.iter().sum::<f32>() / span.len() as f32;
}
let max = out.iter().fold(0f32, |m, &v| m.max(v));
if max > 0.0 {
for v in out.iter_mut() {
*v /= max;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resample_normalizes() {
let coarse = vec![0.2, 0.4, 0.8, 0.1, 0.6, 0.3];
let out = resample(&coarse, 3);
assert_eq!(out.len(), 3);
assert!((out.iter().cloned().fold(0f32, f32::max) - 1.0).abs() < 1e-6);
}
#[test]
fn empty_input() {
assert_eq!(resample(&[], 5), vec![0.0; 5]);
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "soulseek-client"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
soulseek-proto = { path = "../soulseek-proto" }
tokio.workspace = true
tokio-util = "0.7"
bytes.workspace = true
thiserror.workspace = true
log.workspace = true
igd-next = "0.15"
[dev-dependencies]
env_logger = "0.11"
@@ -0,0 +1,86 @@
//! Live citizenship test: share our downloaded albums, join the distributed
//! network, and report every search we answer for real peers.
//!
//! cargo run -p soulseek-client --example citizen
use soulseek_client::{Client, Config, Event};
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
% 100_000;
let username = format!("ssplusshare{suffix}");
let music = dirs_music().join("SoulSeek+");
let mut config = Config::default();
config.shared_folders = vec![music.clone()];
config.upload_slots = 4;
config.description = "SoulSeek+ dev share".into();
println!("sharing {}…", music.display());
let (client, mut events) = Client::connect(config).await.expect("connect");
let (dirs, files) = client.share_counts();
println!("indexed {files} files in {dirs} folders");
client
.login(&username, "ssplusdev-throwaway")
.await
.expect("login");
println!("logged in as {username}; joining network, waiting for searches to answer…\n");
let mut answered = 0usize;
let mut uploads = 0usize;
let start = tokio::time::Instant::now();
let deadline = start + std::time::Duration::from_secs(180);
let mut next_tick = start + std::time::Duration::from_secs(20);
loop {
let ev = tokio::select! {
ev = events.recv() => ev,
_ = tokio::time::sleep_until(next_tick) => {
next_tick += std::time::Duration::from_secs(20);
println!(
" … {}s: parent={}, {} searches seen, {answered} answered",
start.elapsed().as_secs(),
client.has_parent(),
client.searches_seen(),
);
if tokio::time::Instant::now() >= deadline { break; }
continue;
}
};
match ev {
Some(Event::RespondedToSearch { username, files }) => {
answered += 1;
if answered <= 25 {
println!(" ✓ answered {username} with {files} file(s)");
}
}
Some(Event::Upload(u)) => {
uploads += 1;
println!(" ⬆ upload to {}: {:?} {}", u.username, u.state, u.filename);
}
Some(Event::Disconnected { reason }) => {
println!("disconnected: {reason}");
break;
}
_ => {}
}
}
println!(
"\nRESULT: {} searches seen, {answered} answered, {uploads} upload events",
client.searches_seen(),
);
client.shutdown();
}
fn dirs_music() -> std::path::PathBuf {
// %USERPROFILE%\Music
let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".into());
std::path::PathBuf::from(profile).join("Music")
}
+123
View File
@@ -0,0 +1,123 @@
//! Live-network download smoke test: search, pick the best folder,
//! download its files.
//!
//! cargo run -p soulseek-client --example download -- "search terms" [max_files]
use std::collections::HashMap;
use soulseek_client::{Client, Config, DownloadState, Event};
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let query = std::env::args()
.nth(1)
.unwrap_or_else(|| "daft punk discovery".into());
let max_files: usize = std::env::args()
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or(2);
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
% 100_000;
let username = format!("ssplusdev{suffix}");
let mut config = Config::default();
config.downloads_dir = std::env::temp_dir().join("ssplus-download-test");
let (client, mut events) = Client::connect(config).await.expect("connect");
client
.login(&username, "ssplusdev-throwaway")
.await
.expect("login");
println!("logged in as {username}");
let token = client.search(&query).expect("search");
println!("searching {query:?}…");
// Collect responses for 12s, pick the best free-slot folder.
let mut best: Option<(u64, soulseek_proto::types::SearchResponse)> = None;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(12);
loop {
let ev = tokio::select! {
ev = events.recv() => ev,
_ = tokio::time::sleep_until(deadline) => break,
};
if let Some(Event::SearchResult { response, .. }) = ev {
if !response.has_free_upload_slot || response.files.len() < 3 {
continue;
}
let score = response.upload_speed as u64;
if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) {
best = Some((score, response));
}
}
}
client.stop_search(token);
let Some((_, pick)) = best else {
println!("RESULT: no suitable candidates");
return;
};
println!(
"picked {} ({} files, speed {})",
pick.username,
pick.files.len(),
pick.upload_speed
);
let mut pending: HashMap<u64, String> = HashMap::new();
for f in pick.files.iter().take(max_files) {
let id = client.download(&pick.username, &f.filename, f.size);
println!(
" queued #{id}: {}",
f.filename.rsplit('\\').next().unwrap()
);
pending.insert(id, f.filename.clone());
}
let overall = tokio::time::Instant::now() + std::time::Duration::from_secs(180);
let mut ok = 0;
while !pending.is_empty() {
let ev = tokio::select! {
ev = events.recv() => ev,
_ = tokio::time::sleep_until(overall) => { println!("TIMEOUT"); break; }
};
match ev {
Some(Event::Download(u)) => match &u.state {
DownloadState::Complete { local_path } => {
println!(" ✔ #{} complete → {local_path}", u.id);
pending.remove(&u.id);
ok += 1;
}
DownloadState::Failed { reason } => {
println!(" ✘ #{} failed: {reason}", u.id);
pending.remove(&u.id);
}
DownloadState::Transferring if u.total > 0 => {
println!(
" … #{} {:.0}% ({}/{} bytes, {} KB/s)",
u.id,
u.received as f64 / u.total as f64 * 100.0,
u.received,
u.total,
u.speed_bps / 1000
);
}
s => println!(" · #{} {:?}", u.id, s),
},
Some(Event::Disconnected { reason }) => {
println!("disconnected: {reason}");
break;
}
Some(_) => {}
None => break,
}
}
println!("RESULT: {ok} downloads completed");
client.shutdown();
}
+78
View File
@@ -0,0 +1,78 @@
//! Smoke test against the live Soulseek network:
//! login with a throwaway account, run one search, print results.
//!
//! cargo run -p soulseek-client --example probe -- "search terms"
use soulseek_client::{Client, Config, Event};
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let query = std::env::args()
.nth(1)
.unwrap_or_else(|| "daft punk discovery".into());
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
% 100_000;
let username = format!("ssplusdev{suffix}");
println!("connecting…");
let (client, mut events) = Client::connect(Config::default()).await.expect("connect");
println!("listener on port {}", client.listen_port());
match client.login(&username, "ssplusdev-throwaway").await {
Ok(greeting) => println!("logged in as {username}: {}", greeting.trim()),
Err(e) => {
eprintln!("login failed: {e}");
return;
}
}
let token = client.search(&query).expect("search");
println!("searching for {query:?} (token {token}) — collecting for 25s…\n");
let mut responses = 0usize;
let mut files = 0usize;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(25);
loop {
let ev = tokio::select! {
ev = events.recv() => ev,
_ = tokio::time::sleep_until(deadline) => break,
};
match ev {
Some(Event::SearchResult { response, .. }) => {
responses += 1;
files += response.files.len();
if responses <= 5 {
let f = &response.files[0];
println!(
" [{}] {} files, e.g. {} ({} MB{}) slots={} speed={}",
response.username,
response.files.len(),
f.filename.rsplit('\\').next().unwrap_or(&f.filename),
f.size / 1_000_000,
f.bitrate()
.map(|b| format!(", {b} kbps"))
.unwrap_or_default(),
response.has_free_upload_slot,
response.upload_speed,
);
}
}
Some(Event::Disconnected { reason }) => {
println!("disconnected: {reason}");
break;
}
Some(_) => {}
None => break,
}
}
println!("\nRESULT: {responses} responses, {files} files");
client.shutdown();
}
@@ -0,0 +1,129 @@
//! Two-instance upload test: instance A shares a known file; instance B
//! downloads it from A. Verifies the upload responder + F-connection path.
//!
//! cargo run -p soulseek-client --example upload_test
use soulseek_client::{Client, Config, DownloadState, Event};
use std::path::PathBuf;
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
// Build a share source with one real audio file.
let src = std::env::temp_dir().join("ssplus-upload-src");
let album = src.join("TestAlbum");
std::fs::create_dir_all(&album).unwrap();
let sample = find_sample().expect("need a downloaded audio file to share");
let shared_file = album.join("sample.flac");
std::fs::copy(&sample, &shared_file).unwrap();
let size = std::fs::metadata(&shared_file).unwrap().len();
let remote_path = "ssplus-upload-src\\TestAlbum\\sample.flac".to_string();
println!("A shares {remote_path} ({size} bytes)");
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
% 100_000;
let user_a = format!("ssplusA{stamp}");
let user_b = format!("ssplusB{stamp}");
// Instance A: sharer/uploader.
let mut cfg_a = Config::default();
cfg_a.shared_folders = vec![src.clone()];
cfg_a.listen_port = 51300;
let (client_a, mut events_a) = Client::connect(cfg_a).await.expect("A connect");
client_a.login(&user_a, "pw").await.expect("A login");
println!("A logged in as {user_a}");
// Spawn a task to log A's upload events.
tokio::spawn(async move {
while let Some(ev) = events_a.recv().await {
if let Event::Upload(u) = ev {
println!(
" A upload → {}: {:?} {}/{}",
u.username, u.state, u.sent, u.total
);
}
}
});
// Instance B: downloader. Different listen port to coexist on one host.
let mut cfg_b = Config::default();
cfg_b.listen_port = 52300;
cfg_b.downloads_dir = std::env::temp_dir().join("ssplus-upload-dst");
let _ = std::fs::remove_dir_all(&cfg_b.downloads_dir);
let (client_b, mut events_b) = Client::connect(cfg_b).await.expect("B connect");
client_b.login(&user_b, "pw").await.expect("B login");
println!("B logged in as {user_b}");
// Both instances share this host's public IP, so the server-provided
// address would need NAT hairpinning. Seed loopback endpoints so the P
// and F connections go over localhost instead.
client_b.set_peer_address(&user_a, "127.0.0.1:51300".parse().unwrap());
client_a.set_peer_address(&user_b, "127.0.0.1:52300".parse().unwrap());
// Give the server a moment to register both listen ports.
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let id = client_b.download(&user_a, &remote_path, size);
println!("B requested download #{id} from A…\n");
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(90);
loop {
let ev = tokio::select! {
ev = events_b.recv() => ev,
_ = tokio::time::sleep_until(deadline) => { println!("TIMEOUT"); break; }
};
match ev {
Some(Event::Download(u)) => match &u.state {
DownloadState::Complete { local_path } => {
let got = std::fs::metadata(local_path).map(|m| m.len()).unwrap_or(0);
println!("\nB COMPLETE: {local_path} ({got}/{size} bytes)");
println!(
"RESULT: {}",
if got == size {
"UPLOAD VERIFIED ✓"
} else {
"size mismatch ✗"
}
);
break;
}
DownloadState::Failed { reason } => {
println!("\nB FAILED: {reason}");
println!("RESULT: upload not verified (likely NAT hairpin on same host)");
break;
}
other => println!(" B: {:?} {}/{}", other, u.received, u.total),
},
Some(_) => {}
None => break,
}
}
client_a.shutdown();
client_b.shutdown();
}
/// Find any downloaded audio file to use as the shared sample.
fn find_sample() -> Option<PathBuf> {
let root = PathBuf::from(std::env::var("USERPROFILE").ok()?)
.join("Music")
.join("SoulSeek+");
fn walk(dir: &std::path::Path) -> Option<PathBuf> {
for e in std::fs::read_dir(dir).ok()?.flatten() {
let p = e.path();
if p.is_dir() {
if let Some(f) = walk(&p) {
return Some(f);
}
} else if p.extension().and_then(|x| x.to_str()) == Some("flac") {
return Some(p);
}
}
None
}
walk(&root)
}
+668
View File
@@ -0,0 +1,668 @@
//! Connection plumbing: server socket IO, peer listener, peer message
//! loops, indirect (PierceFirewall) connections, and transfer-connection
//! establishment in all four direct/indirect × inbound/outbound flavors.
use bytes::Bytes;
use std::net::SocketAddrV4;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot};
use soulseek_proto::{peer, server, MAX_MESSAGE_SIZE};
use crate::{ClientError, Event, Inner, PeerHandle, Result, TransferAck};
pub(crate) const PEER_CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
pub(crate) const INDIRECT_TIMEOUT: Duration = Duration::from_secs(15);
/// Idle cutoff for peer message connections.
const PEER_READ_TIMEOUT: Duration = Duration::from_secs(60);
const SERVER_PING_INTERVAL: Duration = Duration::from_secs(300);
/// Read one length-prefixed frame; returns the frame body (code + payload).
pub(crate) async fn read_frame<R: AsyncReadExt + Unpin>(
stream: &mut R,
) -> std::io::Result<Option<Bytes>> {
let mut len_buf = [0u8; 4];
match stream.read_exact(&mut len_buf).await {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = u32::from_le_bytes(len_buf) as usize;
if len == 0 || len > MAX_MESSAGE_SIZE {
return Err(std::io::Error::other(format!("bad frame length {len}")));
}
let mut body = vec![0u8; len];
stream.read_exact(&mut body).await?;
Ok(Some(Bytes::from(body)))
}
pub(crate) async fn bind_listener(preferred_port: u16) -> std::io::Result<TcpListener> {
// Walk forward a few ports if the preferred one is taken.
for offset in 0..10 {
match TcpListener::bind(("0.0.0.0", preferred_port + offset)).await {
Ok(l) => return Ok(l),
Err(_) if offset < 9 => continue,
Err(e) => return Err(e),
}
}
unreachable!()
}
// ---------------------------------------------------------------- server IO
pub(crate) fn spawn_server_io(
stream: TcpStream,
mut outbound: mpsc::UnboundedReceiver<Vec<u8>>,
mut shutdown: mpsc::UnboundedReceiver<()>,
inner: Arc<Inner>,
) {
tokio::spawn(async move {
let (mut rd, mut wr) = stream.into_split();
let writer = tokio::spawn(async move {
while let Some(frame) = outbound.recv().await {
if wr.write_all(&frame).await.is_err() {
break;
}
}
});
let ping_inner = inner.clone();
let ping = tokio::spawn(async move {
loop {
tokio::time::sleep(SERVER_PING_INTERVAL).await;
if ping_inner.send_server(server::ping()).is_err() {
break;
}
}
});
let reason = loop {
let frame = tokio::select! {
f = read_frame(&mut rd) => f,
_ = shutdown.recv() => break "shutdown requested".to_string(),
};
match frame {
Ok(Some(body)) => handle_server_frame(&inner, body),
Ok(None) => break "server closed the connection".to_string(),
Err(e) => break format!("server read error: {e}"),
}
};
writer.abort();
ping.abort();
let _ = inner.event_tx.send(Event::Disconnected { reason });
});
}
fn handle_server_frame(inner: &Arc<Inner>, mut body: Bytes) {
use bytes::Buf;
if body.len() < 4 {
return;
}
let code = body.get_u32_le();
let event = match server::decode(code, body) {
Ok(ev) => ev,
Err(e) => {
log::warn!("failed to decode server message {code}: {e}");
return;
}
};
match event {
server::ServerEvent::LoginOk {
greeting, own_ip, ..
} => {
if let Some(tx) = inner.pending_login.lock().unwrap().take() {
let _ = tx.send(Ok(greeting.clone()));
}
let _ = inner.event_tx.send(Event::LoggedIn { greeting, own_ip });
}
server::ServerEvent::LoginFailed { reason } => {
if let Some(tx) = inner.pending_login.lock().unwrap().take() {
let _ = tx.send(Err(reason.clone()));
}
let _ = inner.event_tx.send(Event::LoginFailed { reason });
}
server::ServerEvent::UserAddress { username, addr } => {
if addr.ip().is_unspecified() || addr.port() == 0 {
// Offline; let the waiters time out.
inner.pending_addresses.lock().unwrap().remove(&username);
return;
}
inner
.address_cache
.lock()
.unwrap()
.insert(username.clone(), addr);
if let Some(waiters) = inner.pending_addresses.lock().unwrap().remove(&username) {
for w in waiters {
let _ = w.send(addr);
}
}
}
server::ServerEvent::ConnectToPeer {
username,
conn_type,
addr,
token,
..
} => {
// A peer that can't reach us directly asked the server to relay:
// we connect out and pierce with their token.
let inner = inner.clone();
tokio::spawn(async move {
let result = match conn_type.as_str() {
peer::CONN_TYPE_PEER => {
pierce_message_connection(inner, addr, token, username.clone()).await
}
peer::CONN_TYPE_TRANSFER => {
pierce_transfer_connection(inner, addr, token, username.clone()).await
}
_ => Ok(()), // "D" (distributed) lands in M5.
};
if let Err(e) = result {
log::debug!("pierce ({conn_type}) to {username} at {addr} failed: {e}");
}
});
}
server::ServerEvent::ExcludedSearchPhrases(phrases) => {
*inner.excluded_phrases.write().unwrap() = phrases.clone();
let _ = inner.event_tx.send(Event::ExcludedSearchPhrases(phrases));
}
server::ServerEvent::NetInfo(parents) => {
crate::distributed::on_net_info(inner.clone(), parents);
}
server::ServerEvent::ServerSearch {
username,
token,
query,
} => {
crate::responder::respond_to_search(inner.clone(), username, token, query);
}
server::ServerEvent::Embedded(bytes) => {
// Server-wrapped distributed search.
if let Ok(soulseek_proto::dist::DistributedEvent::SearchRequest {
username,
token,
query,
}) = soulseek_proto::dist::decode(bytes)
{
crate::responder::respond_to_search(inner.clone(), username, token, query);
}
}
server::ServerEvent::CannotConnect { .. } | server::ServerEvent::Unhandled { .. } => {}
}
}
// ---------------------------------------------------------------- listener
pub(crate) fn spawn_listener(
listener: TcpListener,
inner: Arc<Inner>,
mut shutdown: mpsc::UnboundedReceiver<()>,
) {
tokio::spawn(async move {
loop {
tokio::select! {
// On shutdown, break so `listener` drops and its port is freed
// — otherwise every reconnect leaks a bound port (WSAEADDRINUSE).
_ = shutdown.recv() => {
log::debug!("listener shutting down, releasing port");
break;
}
res = listener.accept() => match res {
Ok((stream, addr)) => {
let inner = inner.clone();
tokio::spawn(async move {
if let Err(e) = handle_incoming(stream, inner).await {
log::debug!("peer {addr} dropped: {e}");
}
});
}
Err(e) => {
log::warn!("listener accept failed: {e}");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
});
}
/// Incoming connection: first frame identifies the peer and connection type.
async fn handle_incoming(mut stream: TcpStream, inner: Arc<Inner>) -> std::io::Result<()> {
stream.set_nodelay(true).ok();
let first = tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(&mut stream))
.await
.map_err(|_| std::io::Error::other("init timeout"))??;
let Some(first) = first else { return Ok(()) };
match peer::decode_init(first) {
Ok(peer::InitMessage::PeerInit {
conn_type,
username,
..
}) => {
match conn_type.as_str() {
peer::CONN_TYPE_PEER => run_peer_connection(stream, inner, username).await,
peer::CONN_TYPE_TRANSFER => {
// Inbound transfer connection: the peer identifies the
// transfer with 4 raw token bytes next.
let mut tok = [0u8; 4];
tokio::time::timeout(PEER_CONNECT_TIMEOUT, stream.read_exact(&mut tok))
.await
.map_err(|_| std::io::Error::other("token timeout"))??;
let token = u32::from_le_bytes(tok);
deliver_transfer_connection(&inner, &username, token, stream);
Ok(())
}
peer::CONN_TYPE_DISTRIBUTED => {
// A parent connected to us directly; run the child loop.
run_incoming_distributed(stream, inner).await
}
_ => Ok(()),
}
}
Ok(peer::InitMessage::PierceFirewall { token }) => {
// Answer to one of our indirect solicitations (P or F).
let waiter = inner.pending_pierce.lock().unwrap().remove(&token);
if let Some(tx) = waiter {
let _ = tx.send(stream);
}
Ok(())
}
Err(_) => Ok(()),
}
}
/// A distributed parent connected to us directly. Read its relayed
/// searches and answer them (same logic as an outbound parent).
async fn run_incoming_distributed(mut stream: TcpStream, inner: Arc<Inner>) -> std::io::Result<()> {
inner
.has_parent
.store(true, std::sync::atomic::Ordering::Relaxed);
let result = async {
loop {
let frame = match tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(&mut stream)).await
{
Ok(f) => f?,
Err(_) => return Ok(()),
};
let Some(frame) = frame else { return Ok(()) };
if let Ok(soulseek_proto::dist::DistributedEvent::SearchRequest {
username,
token,
query,
}) = soulseek_proto::dist::decode(frame)
{
crate::responder::respond_to_search(inner.clone(), username, token, query);
}
}
}
.await;
inner
.has_parent
.store(false, std::sync::atomic::Ordering::Relaxed);
result
}
/// Hand an established inbound transfer connection to the download that
/// expects it.
fn deliver_transfer_connection(inner: &Arc<Inner>, username: &str, token: u32, stream: TcpStream) {
let expected = inner
.expected_transfers
.lock()
.unwrap()
.remove(&(username.to_string(), token));
match expected {
Some(tx) => {
let _ = tx.send(stream);
}
None => {
log::debug!("unexpected transfer connection from {username} token {token}");
}
}
}
// ------------------------------------------------- indirect (pierce) roles
/// Server told us a peer wants a message connection: connect + pierce, then
/// treat like any peer message connection.
async fn pierce_message_connection(
inner: Arc<Inner>,
addr: SocketAddrV4,
token: u32,
username: String,
) -> std::io::Result<()> {
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.map_err(|_| std::io::Error::other("connect timeout"))??;
stream.set_nodelay(true).ok();
stream.write_all(&peer::pierce_firewall(token)).await?;
run_peer_connection(stream, inner, username).await
}
/// Server told us a peer wants a transfer connection with us: connect,
/// pierce, read the 4-byte remote token, hand off to the waiting download.
async fn pierce_transfer_connection(
inner: Arc<Inner>,
addr: SocketAddrV4,
token: u32,
username: String,
) -> std::io::Result<()> {
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.map_err(|_| std::io::Error::other("connect timeout"))??;
stream.set_nodelay(true).ok();
stream.write_all(&peer::pierce_firewall(token)).await?;
let mut tok = [0u8; 4];
tokio::time::timeout(PEER_CONNECT_TIMEOUT, stream.read_exact(&mut tok))
.await
.map_err(|_| std::io::Error::other("token timeout"))??;
let remote_token = u32::from_le_bytes(tok);
deliver_transfer_connection(&inner, &username, remote_token, stream);
Ok(())
}
// -------------------------------------------------- peer message connection
/// Run a peer message connection: writer drains a queue, reader routes
/// messages. Registers in the peers map for reuse; deregisters on exit.
async fn run_peer_connection(
stream: TcpStream,
inner: Arc<Inner>,
username: String,
) -> std::io::Result<()> {
let (mut rd, mut wr) = stream.into_split();
let (write_tx, mut write_rx) = mpsc::unbounded_channel::<Vec<u8>>();
inner.peers.lock().unwrap().insert(
username.clone(),
PeerHandle {
write_tx: write_tx.clone(),
},
);
let writer = tokio::spawn(async move {
while let Some(frame) = write_rx.recv().await {
if wr.write_all(&frame).await.is_err() {
break;
}
}
});
let result = peer_message_loop(&mut rd, &inner, &username).await;
// Only deregister if the map still points at this connection.
{
let mut peers = inner.peers.lock().unwrap();
if let Some(handle) = peers.get(&username) {
if handle.write_tx.same_channel(&write_tx) {
peers.remove(&username);
}
}
}
writer.abort();
result
}
async fn peer_message_loop(
rd: &mut tokio::net::tcp::OwnedReadHalf,
inner: &Arc<Inner>,
username: &str,
) -> std::io::Result<()> {
loop {
let frame = match tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(rd)).await {
Ok(f) => f?,
Err(_) => return Ok(()), // idle
};
let Some(mut body) = frame else { return Ok(()) };
use bytes::Buf;
if body.len() < 4 {
continue;
}
let code = body.get_u32_le();
match peer::decode(code, body) {
Ok(ev) => route_peer_event(inner, username, ev),
Err(e) => log::debug!("bad peer message {code} from {username}: {e}"),
}
}
}
fn route_peer_event(inner: &Arc<Inner>, username: &str, ev: peer::PeerEvent) {
use peer::PeerEvent::*;
match ev {
SearchResponse(resp) => {
let wanted = inner.active_searches.lock().unwrap().contains(&resp.token);
if wanted && !resp.files.is_empty() {
let _ = inner.event_tx.send(Event::SearchResult {
token: resp.token,
response: resp,
});
}
}
TransferResponse {
token,
allowed,
size,
message,
} => {
let waiter = inner
.transfer_waits
.lock()
.unwrap()
.remove(&(username.to_string(), token));
if let Some(tx) = waiter {
let _ = tx.send(TransferAck {
allowed,
size,
message,
});
}
}
TransferRequest {
direction,
token,
filename,
size,
} => {
if direction == peer::DIRECTION_UPLOAD {
let waiter = inner
.upload_offers
.lock()
.unwrap()
.remove(&(username.to_string(), filename.clone()));
match waiter {
Some(tx) => {
let _ = tx.send(Ok((token, size)));
}
None => log::debug!("unsolicited upload offer from {username}: {filename}"),
}
} else {
// Their request to download one of our files → we upload.
crate::upload::on_transfer_request(
inner.clone(),
username.to_string(),
token,
filename,
);
}
}
QueueDownload { filename } => {
crate::upload::on_queue_download(inner.clone(), username.to_string(), filename);
}
PlaceInQueueRequest { filename } => {
crate::upload::on_place_query(inner, username, &filename);
}
BrowseRequest => {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
crate::responder::respond_browse(inner, h);
}
}
UserInfoRequest => {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
crate::responder::respond_user_info(inner, h);
}
}
FolderContentsRequest { token, folder } => {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
crate::responder::respond_folder_contents(inner, h, token, &folder);
}
}
UploadDenied { filename, reason } => {
let waiter = inner
.upload_offers
.lock()
.unwrap()
.remove(&(username.to_string(), filename));
if let Some(tx) = waiter {
let _ = tx.send(Err(reason));
}
}
UploadFailed { filename } => {
let waiter = inner
.upload_offers
.lock()
.unwrap()
.remove(&(username.to_string(), filename));
if let Some(tx) = waiter {
let _ = tx.send(Err("remote upload failed".into()));
}
}
PlaceInQueueResponse { filename, place } => {
crate::transfer::update_queue_place(inner, username, &filename, place);
}
Unhandled { code } => log::trace!("unhandled peer message {code} from {username}"),
}
}
// ----------------------------------------------- outbound peer connections
/// Resolve a user's endpoint via the server (with cache).
pub(crate) async fn get_peer_address(inner: &Arc<Inner>, username: &str) -> Result<SocketAddrV4> {
if let Some(addr) = inner.address_cache.lock().unwrap().get(username) {
return Ok(*addr);
}
let (tx, rx) = oneshot::channel();
inner
.pending_addresses
.lock()
.unwrap()
.entry(username.to_string())
.or_default()
.push(tx);
inner.send_server(server::get_peer_address(username))?;
tokio::time::timeout(Duration::from_secs(10), rx)
.await
.map_err(|_| ClientError::UserOffline(username.to_string()))?
.map_err(|_| ClientError::UserOffline(username.to_string()))
}
/// Get (or establish) a message connection to a user. Tries direct first,
/// then falls back to a server-relayed indirect connection.
pub(crate) async fn get_or_connect_peer(inner: &Arc<Inner>, username: &str) -> Result<PeerHandle> {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
return Ok(h.clone());
}
let addr = get_peer_address(inner, username).await?;
// Direct attempt.
let direct = async {
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.map_err(|_| std::io::Error::other("connect timeout"))??;
stream.set_nodelay(true).ok();
let init = peer::peer_init(
&inner.own_username(),
peer::CONN_TYPE_PEER,
inner.next_token(),
);
stream.write_all(&init).await?;
Ok::<TcpStream, std::io::Error>(stream)
};
let stream = match direct.await {
Ok(s) => s,
Err(e) => {
log::debug!("direct connect to {username} failed ({e}); trying indirect");
indirect_connect(inner, username, peer::CONN_TYPE_PEER).await?
}
};
let inner2 = inner.clone();
let username2 = username.to_string();
tokio::spawn(async move {
let _ = run_peer_connection(stream, inner2, username2).await;
});
// The handle is registered synchronously at the top of
// run_peer_connection, but the spawn may not have run yet — poll briefly.
for _ in 0..50 {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
return Ok(h.clone());
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(ClientError::PeerUnreachable(username.to_string()))
}
/// Ask the server to have `username` connect to us; await the pierce.
pub(crate) async fn indirect_connect(
inner: &Arc<Inner>,
username: &str,
conn_type: &str,
) -> Result<TcpStream> {
let token = inner.next_token();
let (tx, rx) = oneshot::channel();
inner.pending_pierce.lock().unwrap().insert(token, tx);
inner.send_server(server::connect_to_peer(token, username, conn_type))?;
let out = tokio::time::timeout(INDIRECT_TIMEOUT, rx).await;
inner.pending_pierce.lock().unwrap().remove(&token);
out.map_err(|_| ClientError::PeerUnreachable(username.to_string()))?
.map_err(|_| ClientError::PeerUnreachable(username.to_string()))
}
/// Establish an outbound transfer ("F") connection and negotiate the token.
pub(crate) async fn connect_transfer(
inner: &Arc<Inner>,
username: &str,
token: u32,
) -> Result<TcpStream> {
let addr = get_peer_address(inner, username).await?;
let direct = async {
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.map_err(|_| std::io::Error::other("connect timeout"))??;
stream.set_nodelay(true).ok();
let init = peer::peer_init(&inner.own_username(), peer::CONN_TYPE_TRANSFER, token);
stream.write_all(&init).await?;
Ok::<TcpStream, std::io::Error>(stream)
};
let mut stream = match direct.await {
Ok(s) => s,
Err(e) => {
log::debug!("direct F connect to {username} failed ({e}); trying indirect");
indirect_connect(inner, username, peer::CONN_TYPE_TRANSFER).await?
}
};
// Both flavors: identify the transfer with 4 raw token bytes.
stream
.write_all(&token.to_le_bytes())
.await
.map_err(|_| ClientError::PeerUnreachable(username.to_string()))?;
Ok(stream)
}
+106
View File
@@ -0,0 +1,106 @@
//! Distributed network: we join as a leaf child. The server sends parent
//! candidates via NetInfo; we connect to one (type "D"), complete the
//! branch handshake, and receive relayed `DistributedSearchRequest`
//! messages, which we answer from our shares.
//!
//! We do not accept children (leaf only) — that keeps the implementation
//! tractable while still making us a search-answering participant.
use std::net::SocketAddrV4;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
use soulseek_proto::{dist, peer, server};
use crate::{conn, responder, Inner};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
const READ_TIMEOUT: Duration = Duration::from_secs(120);
/// Try each parent candidate until one connects; on success, run the
/// distributed read loop until it drops, then allow re-solicitation.
pub(crate) fn on_net_info(inner: Arc<Inner>, parents: Vec<(String, SocketAddrV4)>) {
if inner.has_parent.load(Ordering::Relaxed) || parents.is_empty() {
return;
}
tokio::spawn(async move {
for (username, addr) in parents {
if inner.has_parent.load(Ordering::Relaxed) {
return;
}
match connect_parent(&inner, &username, addr).await {
Ok(stream) => {
inner.has_parent.store(true, Ordering::Relaxed);
log::info!("distributed parent: {username} ({addr})");
let _ = run_parent(&inner, stream).await;
inner.has_parent.store(false, Ordering::Relaxed);
// Ask the server for fresh parents next time.
let _ = inner.send_server(server::have_no_parents(true));
return;
}
Err(e) => log::debug!("parent {username} ({addr}) failed: {e}"),
}
}
});
}
async fn connect_parent(
inner: &Arc<Inner>,
_username: &str,
addr: SocketAddrV4,
) -> std::io::Result<TcpStream> {
let mut stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(addr))
.await
.map_err(|_| std::io::Error::other("connect timeout"))??;
stream.set_nodelay(true).ok();
// Identify as a distributed peer.
let token = inner.next_token();
let init = peer::peer_init(&inner.own_username(), peer::CONN_TYPE_DISTRIBUTED, token);
stream.write_all(&init).await?;
// Announce ourselves as a leaf: branch level 0, root = us.
stream.write_all(&dist::branch_level(0)).await?;
stream
.write_all(&dist::branch_root(&inner.own_username()))
.await?;
Ok(stream)
}
async fn run_parent(inner: &Arc<Inner>, mut stream: TcpStream) -> std::io::Result<()> {
loop {
let frame = match tokio::time::timeout(READ_TIMEOUT, conn::read_frame(&mut stream)).await {
Ok(f) => f?,
Err(_) => {
// Keepalive ping; if it fails the connection is dead.
if stream.write_all(&dist::ping()).await.is_err() {
return Ok(());
}
continue;
}
};
let Some(frame) = frame else { return Ok(()) };
match dist::decode(frame) {
Ok(dist::DistributedEvent::SearchRequest {
username,
token,
query,
}) => {
responder::respond_to_search(inner.clone(), username, token, query);
}
Ok(dist::DistributedEvent::BranchLevel(level)) => {
// Report our level (parent + 1) to the server.
let _ = inner.send_server(server::branch_level(level + 1));
}
Ok(dist::DistributedEvent::BranchRoot(root)) => {
let _ = inner.send_server(server::branch_root(&root));
}
Ok(_) => {}
Err(e) => log::trace!("bad distributed message: {e}"),
}
}
}
+410
View File
@@ -0,0 +1,410 @@
//! Soulseek network client: server connection, peer connections, search,
//! downloads.
//!
//! Behavioral reference: Soulseek.NET `SoulseekClient.cs` and `src/Network/**`.
//!
//! M1: connect + login, peer listener, indirect (PierceFirewall)
//! connections both directions, live search.
//! M2: downloads — TransferRequest/TransferResponse dance, F-type transfer
//! connections (both directions), resume via offset, download manager.
mod conn;
mod distributed;
mod responder;
mod share;
mod transfer;
mod upload;
mod upnp;
pub use share::ShareIndex;
pub use transfer::{DownloadState, DownloadUpdate};
pub use upload::{UploadState, UploadUpdate};
use std::collections::{HashMap, HashSet, VecDeque};
use std::net::{Ipv4Addr, SocketAddrV4};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use soulseek_proto::{server, types::SearchResponse};
pub const DEFAULT_SERVER: (&str, u16) = ("server.slsknet.org", 2242);
pub const DEFAULT_LISTEN_PORT: u16 = 50300;
#[derive(Debug, Clone)]
pub struct Config {
pub server_host: String,
pub server_port: u16,
pub listen_port: u16,
pub downloads_dir: PathBuf,
/// Folders we share with the network.
pub shared_folders: Vec<PathBuf>,
/// Concurrent upload slots offered to peers.
pub upload_slots: u32,
/// User-info card description.
pub description: String,
}
impl Default for Config {
fn default() -> Self {
Config {
server_host: DEFAULT_SERVER.0.into(),
server_port: DEFAULT_SERVER.1,
listen_port: DEFAULT_LISTEN_PORT,
downloads_dir: PathBuf::from("downloads"),
shared_folders: Vec::new(),
upload_slots: 2,
description: "SoulStream user".into(),
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
LoggedIn {
greeting: String,
own_ip: Ipv4Addr,
},
LoginFailed {
reason: String,
},
Disconnected {
reason: String,
},
SearchResult {
token: u32,
response: SearchResponse,
},
ExcludedSearchPhrases(Vec<String>),
Download(DownloadUpdate),
Upload(UploadUpdate),
/// We answered a peer's search with N files (for activity display).
RespondedToSearch {
username: String,
files: usize,
},
}
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("protocol: {0}")]
Proto(#[from] soulseek_proto::ProtoError),
#[error("not connected to server")]
NotConnected,
#[error("login failed: {0}")]
LoginFailed(String),
#[error("user {0} is offline or unknown")]
UserOffline(String),
#[error("could not reach peer {0}")]
PeerUnreachable(String),
#[error("transfer rejected: {0}")]
TransferRejected(String),
#[error("timed out")]
Timeout,
}
pub type Result<T> = std::result::Result<T, ClientError>;
/// Write handle for an established peer message ("P") connection.
#[derive(Clone)]
pub(crate) struct PeerHandle {
pub write_tx: mpsc::UnboundedSender<Vec<u8>>,
}
/// Peer's answer to our TransferRequest(download).
#[derive(Debug, Clone)]
pub(crate) struct TransferAck {
pub allowed: bool,
pub size: u64,
pub message: String,
}
/// Peer's TransferRequest(upload) once we reach their queue front —
/// or the denial that arrived instead.
pub(crate) type UploadOffer = std::result::Result<(u32, u64), String>; // (remote token, size)
pub(crate) struct Inner {
pub server_tx: mpsc::UnboundedSender<Vec<u8>>,
pub event_tx: mpsc::UnboundedSender<Event>,
pub username: Mutex<String>,
pub active_searches: Mutex<HashSet<u32>>,
pub pending_login: Mutex<Option<oneshot::Sender<std::result::Result<String, String>>>>,
pub token_counter: AtomicU32,
pub listen_port: u16,
pub downloads_dir: PathBuf,
/// GetPeerAddress requests awaiting a server answer, by username.
pub pending_addresses: Mutex<HashMap<String, Vec<oneshot::Sender<SocketAddrV4>>>>,
/// Last known endpoint per user.
pub address_cache: Mutex<HashMap<String, SocketAddrV4>>,
/// Our indirect-connection solicitations: token → deliver the pierced stream.
pub pending_pierce: Mutex<HashMap<u32, oneshot::Sender<TcpStream>>>,
/// Inbound transfer connections we expect: (username, remote token) → stream.
pub expected_transfers: Mutex<HashMap<(String, u32), oneshot::Sender<TcpStream>>>,
/// Live peer message connections.
pub peers: Mutex<HashMap<String, PeerHandle>>,
/// TransferResponse waits: (username, our token).
pub transfer_waits: Mutex<HashMap<(String, u32), oneshot::Sender<TransferAck>>>,
/// TransferRequest(upload) waits: (username, remote filename).
pub upload_offers: Mutex<HashMap<(String, String), oneshot::Sender<UploadOffer>>>,
/// Active downloads by (username, remote filename) → (id, cancel).
pub downloads_by_file: Mutex<HashMap<(String, String), (u64, CancellationToken)>>,
pub download_counter: AtomicU64,
// ---- M5: sharing / uploads / distributed ----
/// Files we share with the network.
pub shares: Arc<ShareIndex>,
/// Excluded phrases from the server; outgoing search results filter these.
pub excluded_phrases: RwLock<Vec<String>>,
/// Our user-info description.
pub description: String,
/// Total upload slots and how many are in use.
pub upload_slots: u32,
pub uploads_active: AtomicU32,
/// Pending upload queue (username, remote path), FIFO.
pub upload_queue: Mutex<VecDeque<(String, String)>>,
/// Rolling average upload speed reported to peers (bytes/sec).
pub upload_speed: AtomicU32,
pub upload_counter: AtomicU64,
/// Whether we currently have a distributed parent.
pub has_parent: AtomicBool,
/// De-dupe relayed searches so we don't answer the same token twice.
pub answered_searches: Mutex<HashSet<u32>>,
/// Count of relayed searches we've seen (instrumentation).
pub searches_seen: AtomicU64,
}
impl Inner {
pub fn next_token(&self) -> u32 {
self.token_counter.fetch_add(1, Ordering::Relaxed)
}
pub fn own_username(&self) -> String {
self.username.lock().unwrap().clone()
}
pub fn send_server(&self, frame: Vec<u8>) -> Result<()> {
self.server_tx
.send(frame)
.map_err(|_| ClientError::NotConnected)
}
pub fn excluded(&self) -> Vec<String> {
self.excluded_phrases.read().unwrap().clone()
}
pub fn has_free_upload_slot(&self) -> bool {
self.uploads_active.load(Ordering::Relaxed) < self.upload_slots
}
}
/// Handle to a running client. Dropping it does not tear down the
/// connection; call [`Client::shutdown`].
pub struct Client {
inner: Arc<Inner>,
shutdown_tx: mpsc::UnboundedSender<()>,
listener_shutdown_tx: mpsc::UnboundedSender<()>,
}
impl Client {
/// Connect the server socket and start the peer listener.
/// Events (search results, download progress) arrive on the receiver.
pub async fn connect(config: Config) -> Result<(Client, mpsc::UnboundedReceiver<Event>)> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (server_tx, server_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (shutdown_tx, shutdown_rx) = mpsc::unbounded_channel::<()>();
let (listener_shutdown_tx, listener_shutdown_rx) = mpsc::unbounded_channel::<()>();
let stream = TcpStream::connect((config.server_host.as_str(), config.server_port)).await?;
stream.set_nodelay(true).ok();
// Bind the listener before login so SetListenPort is accurate.
let listener = conn::bind_listener(config.listen_port).await?;
let listen_port = listener.local_addr()?.port();
// Seed tokens off the clock so restarts don't reuse recent tokens.
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(1);
// Build the share index up front (blocking walk on a worker thread).
let shares = Arc::new(ShareIndex::new());
if !config.shared_folders.is_empty() {
let shares2 = shares.clone();
let folders = config.shared_folders.clone();
let (dirs, files) = tokio::task::spawn_blocking(move || shares2.rebuild(&folders))
.await
.unwrap_or((0, 0));
log::info!("share index: {files} files in {dirs} folders");
}
let inner = Arc::new(Inner {
server_tx,
event_tx,
username: Mutex::new(String::new()),
active_searches: Mutex::new(HashSet::new()),
pending_login: Mutex::new(None),
token_counter: AtomicU32::new(seed),
listen_port,
downloads_dir: config.downloads_dir.clone(),
pending_addresses: Mutex::new(HashMap::new()),
address_cache: Mutex::new(HashMap::new()),
pending_pierce: Mutex::new(HashMap::new()),
expected_transfers: Mutex::new(HashMap::new()),
peers: Mutex::new(HashMap::new()),
transfer_waits: Mutex::new(HashMap::new()),
upload_offers: Mutex::new(HashMap::new()),
downloads_by_file: Mutex::new(HashMap::new()),
download_counter: AtomicU64::new(1),
shares,
excluded_phrases: RwLock::new(Vec::new()),
description: config.description.clone(),
upload_slots: config.upload_slots,
uploads_active: AtomicU32::new(0),
upload_queue: Mutex::new(VecDeque::new()),
upload_speed: AtomicU32::new(0),
upload_counter: AtomicU64::new(1),
has_parent: AtomicBool::new(false),
answered_searches: Mutex::new(HashSet::new()),
searches_seen: AtomicU64::new(0),
});
conn::spawn_server_io(stream, server_rx, shutdown_rx, inner.clone());
conn::spawn_listener(listener, inner.clone(), listener_shutdown_rx);
// Best-effort UPnP mapping for direct connectivity (non-fatal).
tokio::spawn(upnp::try_map_port(listen_port));
Ok((
Client {
inner,
shutdown_tx,
listener_shutdown_tx,
},
event_rx,
))
}
/// Log in; resolves when the server answers.
pub async fn login(&self, username: &str, password: &str) -> Result<String> {
let (tx, rx) = oneshot::channel();
*self.inner.pending_login.lock().unwrap() = Some(tx);
*self.inner.username.lock().unwrap() = username.to_string();
self.inner.send_server(server::login(username, password))?;
let outcome = tokio::time::timeout(std::time::Duration::from_secs(15), rx)
.await
.map_err(|_| ClientError::Timeout)?
.map_err(|_| ClientError::NotConnected)?;
match outcome {
Ok(greeting) => {
// Post-login bookkeeping the server expects from clients.
self.inner
.send_server(server::set_listen_port(self.inner.listen_port as u32))?;
let (dirs, files) = self.inner.shares.counts();
self.inner
.send_server(server::shared_folders_and_files(dirs, files))?;
// Join the distributed network as a child so we receive
// relayed searches to answer.
self.inner.send_server(server::have_no_parents(true))?;
self.inner
.send_server(server::branch_root(&self.inner.own_username()))?;
self.inner.send_server(server::branch_level(0))?;
self.inner.send_server(server::accept_children(false))?;
Ok(greeting)
}
Err(reason) => Err(ClientError::LoginFailed(reason)),
}
}
/// Re-index the shared folders and report the new counts to the server.
pub async fn update_shares(&self, folders: Vec<PathBuf>) -> (u32, u32) {
let shares = self.inner.shares.clone();
let (dirs, files) = tokio::task::spawn_blocking(move || shares.rebuild(&folders))
.await
.unwrap_or((0, 0));
let _ = self
.inner
.send_server(server::shared_folders_and_files(dirs, files));
(dirs, files)
}
/// Current (folders, files) share counts.
pub fn share_counts(&self) -> (u32, u32) {
self.inner.shares.counts()
}
/// How many relayed searches we've received (whether or not matched).
pub fn searches_seen(&self) -> u64 {
self.inner.searches_seen.load(Ordering::Relaxed)
}
/// Seed a peer's endpoint, bypassing server address resolution. Useful
/// for LAN peers and for tests where two instances share one public IP.
pub fn set_peer_address(&self, username: &str, addr: SocketAddrV4) {
self.inner
.address_cache
.lock()
.unwrap()
.insert(username.to_string(), addr);
}
/// Whether we currently hold a distributed parent connection.
pub fn has_parent(&self) -> bool {
self.inner.has_parent.load(Ordering::Relaxed)
}
/// Start a network-wide file search. Results stream out as
/// [`Event::SearchResult`] carrying this token.
pub fn search(&self, query: &str) -> Result<u32> {
let token = self.inner.next_token();
self.inner.active_searches.lock().unwrap().insert(token);
self.inner.send_server(server::file_search(token, query))?;
Ok(token)
}
/// Stop routing results for a search token.
pub fn stop_search(&self, token: u32) {
self.inner.active_searches.lock().unwrap().remove(&token);
}
/// Start downloading `filename` (full remote path) from `username`.
/// Progress streams out as [`Event::Download`]. Returns the download id.
pub fn download(&self, username: &str, filename: &str, size: u64) -> u64 {
transfer::start_download(self.inner.clone(), username, filename, size, None)
}
/// Like [`download`](Self::download) but writing to an explicit local
/// path (used for stream-to-cache).
pub fn download_to(&self, username: &str, filename: &str, size: u64, target: PathBuf) -> u64 {
transfer::start_download(self.inner.clone(), username, filename, size, Some(target))
}
/// Cancel an active download by id.
pub fn cancel_download(&self, id: u64) {
let map = self.inner.downloads_by_file.lock().unwrap();
for (did, cancel) in map.values() {
if *did == id {
cancel.cancel();
break;
}
}
}
pub fn listen_port(&self) -> u16 {
self.inner.listen_port
}
pub fn shutdown(&self) {
let _ = self.shutdown_tx.send(());
let _ = self.listener_shutdown_tx.send(());
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Responder: answers other peers' requests — searches, browse, user-info,
//! folder contents. Search replies open a fresh P connection to the
//! searcher; the rest reply on the connection the request arrived on.
use std::sync::atomic::Ordering;
use std::sync::Arc;
use soulseek_proto::peer;
use crate::{conn, Event, Inner, PeerHandle};
/// Max files we return in a single search reply.
const MAX_SEARCH_RESULTS: usize = 100;
/// Match a relayed search against our shares and, if we have hits, send a
/// PeerSearchResponse back to `username`. Runs on its own task.
pub(crate) fn respond_to_search(inner: Arc<Inner>, username: String, token: u32, query: String) {
inner.searches_seen.fetch_add(1, Ordering::Relaxed);
// Never answer our own searches; de-dupe repeated relays of a token.
if username == inner.own_username() {
return;
}
{
let mut answered = inner.answered_searches.lock().unwrap();
if answered.contains(&token) {
return;
}
answered.insert(token);
// Bound the set so it can't grow without limit.
if answered.len() > 20_000 {
answered.clear();
}
}
if inner.shares.file_count() == 0 {
return;
}
tokio::spawn(async move {
let excluded = inner.excluded();
let files = inner.shares.search(&query, &excluded, MAX_SEARCH_RESULTS);
if files.is_empty() {
return;
}
let frame = peer::search_response(
&inner.own_username(),
token,
&files,
inner.has_free_upload_slot(),
inner.upload_speed.load(Ordering::Relaxed),
inner.upload_queue.lock().unwrap().len() as u32,
);
// Search replies use a normal P connection to the searcher.
match conn::get_or_connect_peer(&inner, &username).await {
Ok(handle) => {
if handle.write_tx.send(frame).is_ok() {
let _ = inner.event_tx.send(Event::RespondedToSearch {
username: username.clone(),
files: files.len(),
});
}
}
Err(e) => log::debug!("couldn't reply to search from {username}: {e}"),
}
});
}
/// Answer a BrowseRequest on the same connection.
pub(crate) fn respond_browse(inner: &Arc<Inner>, handle: &PeerHandle) {
let dirs = inner.shares.browse();
let _ = handle.write_tx.send(peer::browse_response(&dirs));
}
/// Answer a UserInfoRequest.
pub(crate) fn respond_user_info(inner: &Arc<Inner>, handle: &PeerHandle) {
let frame = peer::user_info_response(
&inner.description,
inner.upload_slots,
inner.upload_queue.lock().unwrap().len() as u32,
inner.has_free_upload_slot(),
);
let _ = handle.write_tx.send(frame);
}
/// Answer a FolderContentsRequest for one folder.
pub(crate) fn respond_folder_contents(
inner: &Arc<Inner>,
handle: &PeerHandle,
token: u32,
folder: &str,
) {
if let Some(dir) = inner.shares.folder(folder) {
let _ = handle
.write_tx
.send(peer::folder_contents_response(token, folder, &dir));
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Share index: the files we make available to the network. Built by
//! walking configured folders; queried by the search and browse responders.
//!
//! Remote paths use backslashes and a virtual root so we never leak local
//! absolute paths. Each shared folder is mounted under its own base name,
//! e.g. local `D:\Music\Album` → remote `Music\Album\track.flac`.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use soulseek_proto::types::{Directory, File};
const AUDIO_EXTS: [&str; 10] = [
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
];
/// One shared file: its remote (virtual) path and where it lives locally.
#[derive(Debug, Clone)]
struct SharedFile {
remote_path: String, // backslash-separated
local_path: PathBuf,
size: u64,
extension: String,
lowercase: String, // remote_path lowercased, for matching
}
#[derive(Default)]
struct Index {
files: Vec<SharedFile>,
/// remote folder (backslashes) → indices into `files`.
folders: BTreeMap<String, Vec<usize>>,
}
pub struct ShareIndex {
index: RwLock<Index>,
}
impl Default for ShareIndex {
fn default() -> Self {
ShareIndex {
index: RwLock::new(Index::default()),
}
}
}
impl ShareIndex {
pub fn new() -> Self {
Self::default()
}
/// Rebuild the index from the given local folders. Returns (dirs, files).
pub fn rebuild(&self, roots: &[PathBuf]) -> (u32, u32) {
let mut index = Index::default();
for root in roots {
let base = root
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "Shared".into());
walk(root, &base, root, &mut index);
}
let dirs = index.folders.len() as u32;
let files = index.files.len() as u32;
*self.index.write().unwrap() = index;
(dirs, files)
}
pub fn counts(&self) -> (u32, u32) {
let idx = self.index.read().unwrap();
(idx.folders.len() as u32, idx.files.len() as u32)
}
pub fn file_count(&self) -> u32 {
self.index.read().unwrap().files.len() as u32
}
/// Match a search query: every whitespace token must appear somewhere
/// in the remote path (case-insensitive). Returns proto `File`s.
/// `excluded` phrases veto a result if any appears in the path.
pub fn search(&self, query: &str, excluded: &[String], limit: usize) -> Vec<File> {
let terms: Vec<String> = query
.split_whitespace()
.map(|t| t.to_lowercase())
.filter(|t| !t.is_empty())
.collect();
if terms.is_empty() {
return Vec::new();
}
let idx = self.index.read().unwrap();
let mut out = Vec::new();
for f in &idx.files {
if terms.iter().all(|t| f.lowercase.contains(t.as_str()))
&& !excluded
.iter()
.any(|p| !p.is_empty() && f.lowercase.contains(&p.to_lowercase()))
{
out.push(to_proto_file(f));
if out.len() >= limit {
break;
}
}
}
out
}
/// Resolve a remote path a peer asked to download to its local file.
pub fn resolve(&self, remote_path: &str) -> Option<(PathBuf, u64)> {
let idx = self.index.read().unwrap();
idx.files
.iter()
.find(|f| f.remote_path.eq_ignore_ascii_case(remote_path))
.map(|f| (f.local_path.clone(), f.size))
}
/// Full directory listing for a browse response.
pub fn browse(&self) -> Vec<Directory> {
let idx = self.index.read().unwrap();
idx.folders
.iter()
.map(|(name, indices)| Directory {
name: name.clone(),
files: indices
.iter()
.map(|&i| to_proto_file(&idx.files[i]))
.collect(),
})
.collect()
}
/// Files in one folder (for FolderContentsResponse).
pub fn folder(&self, name: &str) -> Option<Directory> {
let idx = self.index.read().unwrap();
idx.folders.get(name).map(|indices| Directory {
name: name.to_string(),
files: indices
.iter()
.map(|&i| to_proto_file(&idx.files[i]))
.collect(),
})
}
}
fn to_proto_file(f: &SharedFile) -> File {
// Search/browse responses send just the leaf filename in the File entry
// for search, but the full path for browse. We store the remote path
// and let the responder decide; here we return the remote path as the
// filename (peers expect a full backslash path in search responses).
File {
filename: f.remote_path.clone(),
size: f.size,
extension: f.extension.clone(),
attributes: Vec::new(),
}
}
fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, base, root, index);
} else if is_audio(&path) {
let Ok(meta) = entry.metadata() else { continue };
let remote_path = to_remote_path(&path, base, root);
let folder = remote_path
.rsplit_once('\\')
.map(|(d, _)| d.to_string())
.unwrap_or_else(|| base.to_string());
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let file = SharedFile {
lowercase: remote_path.to_lowercase(),
remote_path,
local_path: path,
size: meta.len(),
extension: ext,
};
let idx = index.files.len();
index.folders.entry(folder).or_default().push(idx);
index.files.push(file);
}
}
}
/// `<base>\<relative path with backslashes>`.
fn to_remote_path(path: &Path, base: &str, root: &Path) -> String {
let rel = path.strip_prefix(root).unwrap_or(path);
let rel_str: String = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("\\");
format!("{base}\\{rel_str}")
}
fn is_audio(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_search_and_resolve() {
let dir = std::env::temp_dir().join("ssplus-share-test");
let album = dir.join("Nevermind");
std::fs::create_dir_all(&album).unwrap();
std::fs::write(album.join("01 Lithium.flac"), b"fake").unwrap();
std::fs::write(album.join("02 Polly.mp3"), b"fake2").unwrap();
std::fs::write(album.join("cover.jpg"), b"img").unwrap(); // ignored
let share = ShareIndex::new();
let (dirs, files) = share.rebuild(&[dir.clone()]);
assert_eq!(files, 2);
assert!(dirs >= 1);
// Matches on both terms across the path.
let hits = share.search("nevermind lithium", &[], 10);
assert_eq!(hits.len(), 1);
assert!(hits[0].filename.ends_with("01 Lithium.flac"));
// Excluded phrase vetoes.
let vetoed = share.search("lithium", &["nevermind".into()], 10);
assert!(vetoed.is_empty());
// Resolve remote → local.
let remote = &hits[0].filename;
let (local, size) = share.resolve(remote).unwrap();
assert!(local.ends_with("01 Lithium.flac"));
assert_eq!(size, 4);
// Browse lists the folder.
let browse = share.browse();
assert!(browse
.iter()
.any(|d| d.name.contains("Nevermind") && d.files.len() == 2));
}
}
+421
View File
@@ -0,0 +1,421 @@
//! Download state machine, mirroring Soulseek.NET's `DownloadToStreamAsync`:
//!
//! 1. Send `TransferRequest(download, token, filename)` on the P connection.
//! 2. Peer answers `TransferResponse`:
//! - allowed → we initiate the F connection with our token.
//! - "Queued" → wait for the peer's `TransferRequest(upload)`, ack it,
//! await their inbound F connection (second-chance: initiate ours).
//! 3. On the F connection: write 8-byte start offset, then read raw bytes.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use soulseek_proto::peer;
use crate::{conn, ClientError, Event, Inner, Result};
/// How long we stay in the remote queue before giving up.
const QUEUE_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
/// Ack / connection-establishment timeout.
const NEGOTIATION_TIMEOUT: Duration = Duration::from_secs(20);
/// No bytes for this long mid-transfer → fail.
const TRANSFER_STALL_TIMEOUT: Duration = Duration::from_secs(90);
/// Poll the remote queue position at this interval while queued.
const PLACE_POLL_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DownloadState {
Connecting,
Requested,
Queued { place: Option<u32> },
Transferring,
Complete { local_path: String },
Failed { reason: String },
Cancelled,
}
#[derive(Debug, Clone)]
pub struct DownloadUpdate {
pub id: u64,
pub username: String,
/// Full remote path (backslash separated).
pub filename: String,
pub state: DownloadState,
pub received: u64,
pub total: u64,
pub speed_bps: u64,
}
pub(crate) fn start_download(
inner: Arc<Inner>,
username: &str,
filename: &str,
size: u64,
target: Option<PathBuf>,
) -> u64 {
let id = inner
.download_counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let cancel = CancellationToken::new();
inner.downloads_by_file.lock().unwrap().insert(
(username.to_string(), filename.to_string()),
(id, cancel.clone()),
);
let username = username.to_string();
let filename = filename.to_string();
tokio::spawn(async move {
let mut dl = Download {
inner: inner.clone(),
id,
username: username.clone(),
filename: filename.clone(),
target,
total: size,
received: 0,
speed_bps: 0,
cancel: cancel.clone(),
};
let outcome = tokio::select! {
r = dl.run() => r,
_ = cancel.cancelled() => Err(ClientError::Timeout), // mapped below
};
match outcome {
Ok(local_path) => dl.emit(DownloadState::Complete { local_path }),
Err(_) if cancel.is_cancelled() => dl.emit(DownloadState::Cancelled),
Err(e) => dl.emit(DownloadState::Failed {
reason: e.to_string(),
}),
}
inner
.downloads_by_file
.lock()
.unwrap()
.remove(&(username, filename));
});
id
}
/// Emit a queue-place update for a download (from PlaceInQueueResponse).
pub(crate) fn update_queue_place(inner: &Arc<Inner>, username: &str, filename: &str, place: u32) {
let id = inner
.downloads_by_file
.lock()
.unwrap()
.get(&(username.to_string(), filename.to_string()))
.map(|(id, _)| *id);
if let Some(id) = id {
let _ = inner.event_tx.send(Event::Download(DownloadUpdate {
id,
username: username.to_string(),
filename: filename.to_string(),
state: DownloadState::Queued { place: Some(place) },
received: 0,
total: 0,
speed_bps: 0,
}));
}
}
struct Download {
inner: Arc<Inner>,
id: u64,
username: String,
filename: String,
/// Explicit local target (stream-to-cache); None → downloads dir layout.
target: Option<PathBuf>,
total: u64,
received: u64,
speed_bps: u64,
cancel: CancellationToken,
}
impl Download {
fn emit(&self, state: DownloadState) {
let _ = self.inner.event_tx.send(Event::Download(DownloadUpdate {
id: self.id,
username: self.username.clone(),
filename: self.filename.clone(),
state,
received: self.received,
total: self.total,
speed_bps: self.speed_bps,
}));
}
async fn run(&mut self) -> Result<String> {
self.emit(DownloadState::Connecting);
let handle = conn::get_or_connect_peer(&self.inner, &self.username).await?;
// Register both waits before sending the request to avoid races:
// the immediate ack, and the eventual upload offer if we get queued.
let token = self.inner.next_token();
let (ack_tx, ack_rx) = oneshot::channel();
self.inner
.transfer_waits
.lock()
.unwrap()
.insert((self.username.clone(), token), ack_tx);
let (offer_tx, offer_rx) = oneshot::channel();
self.inner
.upload_offers
.lock()
.unwrap()
.insert((self.username.clone(), self.filename.clone()), offer_tx);
handle
.write_tx
.send(peer::transfer_request_download(token, &self.filename))
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?;
self.emit(DownloadState::Requested);
let ack = tokio::time::timeout(NEGOTIATION_TIMEOUT, ack_rx)
.await
.map_err(|_| ClientError::Timeout)?
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?;
let stream = if ack.allowed {
// Free slot: we initiate the transfer connection with our token.
if ack.size > 0 {
self.total = ack.size;
}
self.emit(DownloadState::Transferring);
conn::connect_transfer(&self.inner, &self.username, token).await?
} else if ack
.message
.trim_end_matches('.')
.eq_ignore_ascii_case("queued")
{
self.emit(DownloadState::Queued { place: None });
self.await_queued_transfer(&handle, offer_rx).await?
} else {
return Err(ClientError::TransferRejected(ack.message));
};
self.receive(stream).await
}
/// We're in the remote queue: poll our place, wait for the peer's
/// upload offer, ack it, and obtain the transfer connection.
async fn await_queued_transfer(
&mut self,
handle: &crate::PeerHandle,
offer_rx: oneshot::Receiver<crate::UploadOffer>,
) -> Result<TcpStream> {
// Queue-place polling until the offer arrives.
let poll_handle = handle.write_tx.clone();
let poll_filename = self.filename.clone();
let poller = tokio::spawn(async move {
loop {
tokio::time::sleep(PLACE_POLL_INTERVAL).await;
if poll_handle
.send(peer::place_in_queue_request(&poll_filename))
.is_err()
{
break;
}
}
});
let offer = tokio::time::timeout(QUEUE_TIMEOUT, offer_rx).await;
poller.abort();
let (remote_token, size) = offer
.map_err(|_| ClientError::Timeout)?
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?
.map_err(ClientError::TransferRejected)?;
if size > 0 {
self.total = size;
}
// Expect the peer's inbound F connection carrying their token —
// registered before we ack so the connection can't race us.
let (conn_tx, conn_rx) = oneshot::channel();
self.inner
.expected_transfers
.lock()
.unwrap()
.insert((self.username.clone(), remote_token), conn_tx);
// Re-resolve the peer connection in case the old one idled out.
let handle = conn::get_or_connect_peer(&self.inner, &self.username).await?;
handle
.write_tx
.send(peer::transfer_response_allowed(remote_token, self.total))
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?;
self.emit(DownloadState::Transferring);
match tokio::time::timeout(NEGOTIATION_TIMEOUT, conn_rx).await {
Ok(Ok(stream)) => Ok(stream),
_ => {
// Second chance (Nicotine+ et al.): initiate from our end.
self.inner
.expected_transfers
.lock()
.unwrap()
.remove(&(self.username.clone(), remote_token));
log::debug!(
"no inbound F connection from {}; initiating second-chance",
self.username
);
conn::connect_transfer(&self.inner, &self.username, remote_token).await
}
}
}
/// Drive the transfer connection: write start offset, stream to disk.
async fn receive(&mut self, mut stream: TcpStream) -> Result<String> {
let (final_path, partial_path) = self.local_paths()?;
// Resume from an existing partial file when its length is sane.
let offset = match tokio::fs::metadata(&partial_path).await {
Ok(m) if self.total > 0 && m.len() < self.total => m.len(),
_ => 0,
};
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.open(&partial_path)
.await?;
file.set_len(offset).await?;
file.seek(std::io::SeekFrom::Start(offset)).await?;
stream.write_all(&offset.to_le_bytes()).await?;
self.received = offset;
self.emit(DownloadState::Transferring);
let mut buf = vec![0u8; 64 * 1024];
let mut window_start = Instant::now();
let mut window_bytes: u64 = 0;
let mut last_emit = Instant::now();
while self.total == 0 || self.received < self.total {
let n = tokio::select! {
_ = self.cancel.cancelled() => return Err(ClientError::Timeout),
r = tokio::time::timeout(TRANSFER_STALL_TIMEOUT, stream.read(&mut buf)) => {
r.map_err(|_| ClientError::Timeout)??
}
};
if n == 0 {
if self.total == 0 {
break; // size unknown: EOF is completion
}
return Err(ClientError::PeerUnreachable(format!(
"{} closed the connection early ({}/{} bytes)",
self.username, self.received, self.total
)));
}
file.write_all(&buf[..n]).await?;
self.received += n as u64;
window_bytes += n as u64;
let elapsed = window_start.elapsed();
if elapsed >= Duration::from_secs(1) {
self.speed_bps = (window_bytes as f64 / elapsed.as_secs_f64()) as u64;
window_start = Instant::now();
window_bytes = 0;
}
if last_emit.elapsed() >= Duration::from_millis(250) {
last_emit = Instant::now();
self.emit(DownloadState::Transferring);
}
}
file.flush().await?;
drop(file);
let final_path = unique_path(final_path).await;
tokio::fs::rename(&partial_path, &final_path).await?;
Ok(final_path.to_string_lossy().into_owned())
}
/// `<downloads_dir>/<user>/<remote folder leaf>/<file>` (+ `.partial`),
/// or the explicit target when set.
fn local_paths(&self) -> Result<(PathBuf, PathBuf)> {
if let Some(target) = &self.target {
if let Some(dir) = target.parent() {
std::fs::create_dir_all(dir)?;
}
let partial = target.with_extension(format!(
"{}partial",
target
.extension()
.map(|e| format!("{}.", e.to_string_lossy()))
.unwrap_or_default()
));
return Ok((target.clone(), partial));
}
let (folder, name) = match self.filename.rsplit_once('\\') {
Some((dir, n)) => (dir.rsplit('\\').next().unwrap_or(""), n),
None => ("", self.filename.as_str()),
};
let mut dir = self.inner.downloads_dir.clone();
dir.push(sanitize(&self.username));
if !folder.is_empty() {
dir.push(sanitize(folder));
}
std::fs::create_dir_all(&dir)?;
let final_path = dir.join(sanitize(name));
let partial = dir.join(format!("{}.partial", sanitize(name)));
Ok((final_path, partial))
}
}
/// Make a remote path component safe for Windows.
fn sanitize(part: &str) -> String {
let cleaned: String = part
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
cleaned.trim_end_matches([' ', '.']).to_string()
}
/// Avoid clobbering an existing file: append " (2)", " (3)", …
async fn unique_path(path: PathBuf) -> PathBuf {
if tokio::fs::metadata(&path).await.is_err() {
return path;
}
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = path
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
let dir = path.parent().map(PathBuf::from).unwrap_or_default();
for i in 2.. {
let candidate = dir.join(format!("{stem} ({i}){ext}"));
if tokio::fs::metadata(&candidate).await.is_err() {
return candidate;
}
}
unreachable!()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_windows_reserved() {
assert_eq!(sanitize("AC/DC: Back in Black?"), "AC_DC_ Back in Black_");
assert_eq!(sanitize("trailing dots..."), "trailing dots");
}
}
+334
View File
@@ -0,0 +1,334 @@
//! Upload servicing: when a peer requests one of our shared files, we send
//! it over an F connection. Slot-limited with a simple FIFO queue.
//!
//! Two protocol sub-flows, mirroring the download side (M2):
//!
//! * **Immediate** — peer sends `TransferRequest(download, their_token)`.
//! With a free slot we reply `TransferResponse(their_token, allowed)`,
//! then await *their* inbound F connection (keyed by their token), read
//! the 8-byte offset, and stream the file.
//! * **Queued / proactive** — no slot yet (or a bare `QueueDownload`). We
//! enqueue; when a slot frees we send `TransferRequest(upload, our_token)`,
//! await their `TransferResponse`, open the F connection ourselves, read
//! the offset, and stream.
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::oneshot;
use soulseek_proto::peer;
use crate::{conn, ClientError, Event, Inner, Result};
const ACK_TIMEOUT: Duration = Duration::from_secs(30);
const STALL_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UploadState {
Queued,
Transferring,
Complete,
Failed { reason: String },
}
#[derive(Debug, Clone)]
pub struct UploadUpdate {
pub id: u64,
pub username: String,
pub filename: String,
pub state: UploadState,
pub sent: u64,
pub total: u64,
}
/// Peer sent `TransferRequest(download, their_token, filename)`.
pub(crate) fn on_transfer_request(
inner: Arc<Inner>,
username: String,
their_token: u32,
remote_path: String,
) {
let Some((local, size)) = inner.shares.resolve(&remote_path) else {
reply(
&inner,
&username,
peer::transfer_response_rejected(their_token, "File not shared"),
);
return;
};
if inner.has_free_upload_slot() {
// Accept: they will open the F connection with their token.
reply(
&inner,
&username,
peer::transfer_response_allowed(their_token, size),
);
spawn_immediate(inner, username, remote_path, local, size, their_token);
} else {
reply(
&inner,
&username,
peer::transfer_response_rejected(their_token, "Queued"),
);
enqueue(&inner, &username, &remote_path);
}
}
/// Peer sent a bare `QueueDownload(filename)` (no token) — enqueue, or start
/// the proactive flow immediately if a slot is free.
pub(crate) fn on_queue_download(inner: Arc<Inner>, username: String, remote_path: String) {
let Some((local, size)) = inner.shares.resolve(&remote_path) else {
reply(
&inner,
&username,
peer::upload_denied(&remote_path, "File not shared"),
);
return;
};
if inner.has_free_upload_slot() {
spawn_proactive(inner, username, remote_path, local, size);
} else {
enqueue(&inner, &username, &remote_path);
}
}
/// Report queue place when a peer asks (PlaceInQueueRequest).
pub(crate) fn on_place_query(inner: &Arc<Inner>, username: &str, remote_path: &str) {
let place = inner
.upload_queue
.lock()
.unwrap()
.iter()
.position(|(u, p)| u == username && p == remote_path)
.map(|i| i as u32 + 1)
.unwrap_or(0);
reply(
inner,
username,
peer::place_in_queue_response(remote_path, place),
);
}
fn enqueue(inner: &Arc<Inner>, username: &str, remote_path: &str) {
let mut q = inner.upload_queue.lock().unwrap();
if !q.iter().any(|(u, p)| u == username && p == remote_path) && q.len() < 500 {
q.push_back((username.to_string(), remote_path.to_string()));
}
let place = q.len() as u32;
drop(q);
reply(
inner,
username,
peer::place_in_queue_response(remote_path, place),
);
}
fn reply(inner: &Arc<Inner>, username: &str, frame: Vec<u8>) {
if let Some(h) = inner.peers.lock().unwrap().get(username) {
let _ = h.write_tx.send(frame);
}
}
/// After a slot frees, start the next queued upload (proactive flow).
fn pump_queue(inner: Arc<Inner>) {
if !inner.has_free_upload_slot() {
return;
}
let next = inner.upload_queue.lock().unwrap().pop_front();
if let Some((username, remote_path)) = next {
if let Some((local, size)) = inner.shares.resolve(&remote_path) {
spawn_proactive(inner, username, remote_path, local, size);
}
}
}
// -------- immediate flow (peer opens the F connection) --------
fn spawn_immediate(
inner: Arc<Inner>,
username: String,
remote_path: String,
local: PathBuf,
size: u64,
their_token: u32,
) {
inner.uploads_active.fetch_add(1, Ordering::Relaxed);
let id = inner.upload_counter.fetch_add(1, Ordering::Relaxed);
// Register to receive their inbound F connection (keyed by their token).
let (conn_tx, conn_rx) = oneshot::channel();
inner
.expected_transfers
.lock()
.unwrap()
.insert((username.clone(), their_token), conn_tx);
tokio::spawn(async move {
let user_err = username.clone();
run_upload(
&inner,
id,
&username,
&remote_path,
&local,
size,
async move {
let stream = tokio::time::timeout(ACK_TIMEOUT, conn_rx)
.await
.map_err(|_| ClientError::Timeout)?
.map_err(|_| ClientError::PeerUnreachable(user_err))?;
Ok::<TcpStream, ClientError>(stream)
},
)
.await;
inner
.expected_transfers
.lock()
.unwrap()
.remove(&(username.clone(), their_token));
inner.uploads_active.fetch_sub(1, Ordering::Relaxed);
pump_queue(inner.clone());
});
}
// -------- proactive flow (we open the F connection) --------
fn spawn_proactive(
inner: Arc<Inner>,
username: String,
remote_path: String,
local: PathBuf,
size: u64,
) {
inner.uploads_active.fetch_add(1, Ordering::Relaxed);
let id = inner.upload_counter.fetch_add(1, Ordering::Relaxed);
tokio::spawn(async move {
let inner2 = inner.clone();
let user2 = username.clone();
let path2 = remote_path.clone();
run_upload(
&inner,
id,
&username,
&remote_path,
&local,
size,
async move {
// Offer the upload and wait for acceptance.
let handle = conn::get_or_connect_peer(&inner2, &user2).await?;
let token = inner2.next_token();
let (ack_tx, ack_rx) = oneshot::channel();
inner2
.transfer_waits
.lock()
.unwrap()
.insert((user2.clone(), token), ack_tx);
handle
.write_tx
.send(peer::transfer_request_upload(token, &path2, size))
.map_err(|_| ClientError::PeerUnreachable(user2.clone()))?;
let ack = tokio::time::timeout(ACK_TIMEOUT, ack_rx)
.await
.map_err(|_| ClientError::Timeout)?
.map_err(|_| ClientError::PeerUnreachable(user2.clone()))?;
if !ack.allowed {
return Err(ClientError::TransferRejected(ack.message));
}
conn::connect_transfer(&inner2, &user2, token).await
},
)
.await;
inner.uploads_active.fetch_sub(1, Ordering::Relaxed);
pump_queue(inner.clone());
});
}
/// Shared upload driver: obtain the F stream via `get_stream`, read the
/// 8-byte start offset, and stream the file from there.
async fn run_upload<F>(
inner: &Arc<Inner>,
id: u64,
username: &str,
remote_path: &str,
local: &PathBuf,
size: u64,
get_stream: F,
) where
F: std::future::Future<Output = Result<TcpStream>>,
{
let emit = |state: UploadState, sent: u64| {
let _ = inner.event_tx.send(Event::Upload(UploadUpdate {
id,
username: username.to_string(),
filename: remote_path.to_string(),
state,
sent,
total: size,
}));
};
emit(UploadState::Transferring, 0);
let result = async {
let mut stream = get_stream.await?;
let mut off = [0u8; 8];
tokio::time::timeout(ACK_TIMEOUT, stream.read_exact(&mut off))
.await
.map_err(|_| ClientError::Timeout)??;
let offset = u64::from_le_bytes(off).min(size);
let mut file = tokio::fs::File::open(local).await?;
file.seek(std::io::SeekFrom::Start(offset)).await?;
let mut sent = offset;
let mut buf = vec![0u8; 64 * 1024];
let mut window = Instant::now();
let mut window_bytes: u64 = 0;
let mut last_emit = Instant::now();
while sent < size {
let n = file.read(&mut buf).await?;
if n == 0 {
break;
}
tokio::time::timeout(STALL_TIMEOUT, stream.write_all(&buf[..n]))
.await
.map_err(|_| ClientError::Timeout)??;
sent += n as u64;
window_bytes += n as u64;
let elapsed = window.elapsed();
if elapsed >= Duration::from_secs(2) {
inner.upload_speed.store(
(window_bytes as f64 / elapsed.as_secs_f64()) as u32,
Ordering::Relaxed,
);
window = Instant::now();
window_bytes = 0;
}
if last_emit.elapsed() >= Duration::from_millis(300) {
last_emit = Instant::now();
emit(UploadState::Transferring, sent);
}
}
stream.flush().await?;
Ok::<(), ClientError>(())
}
.await;
match result {
Ok(()) => emit(UploadState::Complete, size),
Err(e) => emit(
UploadState::Failed {
reason: e.to_string(),
},
0,
),
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Best-effort UPnP port mapping so firewalled peers can reach our
//! listener directly. Failure is non-fatal — Soulseek's indirect
//! (server-relayed) connection path still works without it.
use std::net::SocketAddrV4;
use std::time::Duration;
/// Try to map `port` (TCP) on the local gateway to ourselves. Returns the
/// external address on success. Runs on a blocking thread.
pub(crate) async fn try_map_port(port: u16) {
let result = tokio::task::spawn_blocking(move || map_blocking(port)).await;
match result {
Ok(Ok(external)) => log::info!("UPnP: mapped port {port} (external {external})"),
Ok(Err(e)) => log::info!("UPnP unavailable ({e}); relying on indirect connections"),
Err(_) => log::debug!("UPnP task panicked"),
}
}
fn map_blocking(port: u16) -> Result<SocketAddrV4, String> {
use igd_next::{search_gateway, PortMappingProtocol, SearchOptions};
let gateway = search_gateway(SearchOptions {
timeout: Some(Duration::from_secs(5)),
..Default::default()
})
.map_err(|e| e.to_string())?;
// Find our LAN address facing the gateway.
let local_ip = local_ipv4().ok_or_else(|| "no local IPv4".to_string())?;
let local = SocketAddrV4::new(local_ip, port);
gateway
.add_port(
PortMappingProtocol::TCP,
port,
std::net::SocketAddr::V4(local),
0, // lease 0 = indefinite (until router reboots)
"SoulStream",
)
.map_err(|e| e.to_string())?;
match gateway.get_external_ip() {
Ok(std::net::IpAddr::V4(ip)) => Ok(SocketAddrV4::new(ip, port)),
_ => Ok(local),
}
}
/// Discover our LAN IPv4 by opening a UDP socket toward a public address
/// (no packets are actually sent).
fn local_ipv4() -> Option<std::net::Ipv4Addr> {
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
match sock.local_addr().ok()?.ip() {
std::net::IpAddr::V4(ip) => Some(ip),
_ => None,
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "soulseek-proto"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
bytes.workspace = true
thiserror.workspace = true
flate2 = "1"
md-5 = "0.10"
+137
View File
@@ -0,0 +1,137 @@
//! Distributed network messages (connection type "D"), single-byte codes.
//! We join as a child: connect to a parent, receive relayed searches, and
//! report our branch level/root.
use bytes::Bytes;
use crate::wire::{Reader, Writer};
use crate::Result;
pub mod code {
pub const PING: u8 = 0;
pub const SEARCH_REQUEST: u8 = 3;
pub const BRANCH_LEVEL: u8 = 4;
pub const BRANCH_ROOT: u8 = 5;
pub const CHILD_DEPTH: u8 = 7;
pub const EMBEDDED_MESSAGE: u8 = 93;
}
// ---- outgoing (to parent / children) ----
pub fn branch_level(level: u32) -> Vec<u8> {
Writer::new().u32(level).finish_u8(code::BRANCH_LEVEL)
}
pub fn branch_root(root_username: &str) -> Vec<u8> {
Writer::new()
.string(root_username)
.finish_u8(code::BRANCH_ROOT)
}
pub fn ping() -> Vec<u8> {
Writer::new().finish_u8(code::PING)
}
/// Relay a search to our children.
pub fn search_request(username: &str, token: u32, query: &str) -> Vec<u8> {
Writer::new()
.u32(0)
.string(username)
.u32(token)
.string(query)
.finish_u8(code::SEARCH_REQUEST)
}
// ---- incoming ----
#[derive(Debug, Clone)]
pub enum DistributedEvent {
/// A search to match against our shares and reply to `username`.
SearchRequest {
username: String,
token: u32,
query: String,
},
BranchLevel(u32),
BranchRoot(String),
Ping,
Unhandled {
code: u8,
},
}
/// Decode a distributed frame (code byte + payload). Handles the
/// `EmbeddedMessage` (93) wrapper by unwrapping to its inner distributed
/// message.
pub fn decode(frame: Bytes) -> Result<DistributedEvent> {
let mut r = Reader::new(frame);
let code = r.u8()?;
decode_inner(code, &mut r)
}
fn decode_inner(code: u8, r: &mut Reader) -> Result<DistributedEvent> {
Ok(match code {
code::EMBEDDED_MESSAGE => {
// [u8 inner_code][inner payload]
let inner = r.u8()?;
return decode_inner(inner, r);
}
code::SEARCH_REQUEST => {
let _unknown = r.u32()?;
let username = r.string()?;
let token = r.u32()?;
let query = r.string()?;
DistributedEvent::SearchRequest {
username,
token,
query,
}
}
code::BRANCH_LEVEL => DistributedEvent::BranchLevel(r.u32()?),
code::BRANCH_ROOT => DistributedEvent::BranchRoot(r.string()?),
code::PING => DistributedEvent::Ping,
other => DistributedEvent::Unhandled { code: other },
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_search_request() {
let msg = search_request("alice", 42, "the beatles");
let ev = decode(Bytes::copy_from_slice(&msg[4..])).unwrap();
match ev {
DistributedEvent::SearchRequest {
username,
token,
query,
} => {
assert_eq!(
(username.as_str(), token, query.as_str()),
("alice", 42, "the beatles")
);
}
other => panic!("wrong: {other:?}"),
}
}
#[test]
fn decode_embedded_wrapper() {
// Server EmbeddedMessage delivers [93][3][search payload].
let inner = search_request("bob", 7, "query");
// inner[4] is the code byte (3); inner[5..] is the payload.
let mut framed = vec![code::EMBEDDED_MESSAGE];
framed.extend_from_slice(&inner[4..]); // code 3 + payload
let ev = decode(Bytes::from(framed)).unwrap();
match ev {
DistributedEvent::SearchRequest {
username, query, ..
} => {
assert_eq!((username.as_str(), query.as_str()), ("bob", "query"));
}
other => panic!("wrong: {other:?}"),
}
}
}
+31
View File
@@ -0,0 +1,31 @@
//! Soulseek protocol messages and binary codecs.
//!
//! Wire format: every message is `[u32 length][code][payload]`, little-endian
//! throughout. Server and peer messages use a `u32` code; connection
//! initialization messages (PeerInit / PierceFirewall) use a single-byte code.
//! Strings are `[u32 len][bytes]`, UTF-8 with Latin-1 fallback on decode.
//! Search responses and browse payloads are zlib-compressed after the code.
//!
//! Reference: Soulseek.NET `src/Messaging/**` and the Nicotine+ protocol docs.
pub mod dist;
pub mod peer;
pub mod server;
pub mod types;
pub mod wire;
#[derive(Debug, thiserror::Error)]
pub enum ProtoError {
#[error("message payload ended unexpectedly")]
UnexpectedEof,
#[error("zlib decompression failed: {0}")]
Zlib(#[from] std::io::Error),
#[error("message too large: {0} bytes")]
TooLarge(usize),
}
pub type Result<T> = std::result::Result<T, ProtoError>;
/// Sanity cap on a single message frame (search responses from big shares
/// run a few hundred KB; browse responses can reach tens of MB).
pub const MAX_MESSAGE_SIZE: usize = 64 * 1024 * 1024;
+435
View File
@@ -0,0 +1,435 @@
//! Peer connection messages: single-byte-code initialization handshake,
//! then u32-code peer messages.
use bytes::Bytes;
use crate::types::{Directory, File, SearchResponse};
use crate::wire::{Reader, Writer};
use crate::{ProtoError, Result};
pub mod init_code {
pub const PIERCE_FIREWALL: u8 = 0;
pub const PEER_INIT: u8 = 1;
}
pub mod code {
pub const BROWSE_REQUEST: u32 = 4;
pub const BROWSE_RESPONSE: u32 = 5;
pub const SEARCH_RESPONSE: u32 = 9;
pub const USER_INFO_REQUEST: u32 = 15;
pub const USER_INFO_RESPONSE: u32 = 16;
pub const FOLDER_CONTENTS_REQUEST: u32 = 36;
pub const FOLDER_CONTENTS_RESPONSE: u32 = 37;
pub const TRANSFER_REQUEST: u32 = 40;
pub const TRANSFER_RESPONSE: u32 = 41;
pub const QUEUE_DOWNLOAD: u32 = 43;
pub const PLACE_IN_QUEUE_RESPONSE: u32 = 44;
pub const UPLOAD_FAILED: u32 = 46;
pub const UPLOAD_DENIED: u32 = 50;
pub const PLACE_IN_QUEUE_REQUEST: u32 = 51;
}
/// Connection types carried by PeerInit / ConnectToPeer.
pub const CONN_TYPE_PEER: &str = "P";
pub const CONN_TYPE_TRANSFER: &str = "F";
pub const CONN_TYPE_DISTRIBUTED: &str = "D";
/// TransferRequest direction values.
pub const DIRECTION_DOWNLOAD: u32 = 0;
pub const DIRECTION_UPLOAD: u32 = 1;
// ---- initialization (u8 code) ----
#[derive(Debug, Clone)]
pub enum InitMessage {
PierceFirewall {
token: u32,
},
PeerInit {
username: String,
conn_type: String,
token: u32,
},
}
pub fn pierce_firewall(token: u32) -> Vec<u8> {
Writer::new()
.u32(token)
.finish_u8(init_code::PIERCE_FIREWALL)
}
pub fn peer_init(username: &str, conn_type: &str, token: u32) -> Vec<u8> {
Writer::new()
.string(username)
.string(conn_type)
.u32(token)
.finish_u8(init_code::PEER_INIT)
}
/// Decode the first frame on a fresh incoming connection.
/// `frame` is the full frame body (code byte included, length header not).
pub fn decode_init(frame: Bytes) -> Result<InitMessage> {
let mut r = Reader::new(frame);
match r.u8()? {
init_code::PIERCE_FIREWALL => Ok(InitMessage::PierceFirewall { token: r.u32()? }),
init_code::PEER_INIT => Ok(InitMessage::PeerInit {
username: r.string()?,
conn_type: r.string()?,
token: r.u32()?,
}),
_ => Err(ProtoError::UnexpectedEof),
}
}
// ---- peer messages (u32 code) ----
/// Ask a peer to send us a file (modern flow; direction=download, size 0).
pub fn transfer_request_download(token: u32, filename: &str) -> Vec<u8> {
Writer::new()
.u32(DIRECTION_DOWNLOAD)
.u32(token)
.string(filename)
.u64(0)
.finish_u32(code::TRANSFER_REQUEST)
}
/// Offer to upload one of our files to a peer (direction=upload).
pub fn transfer_request_upload(token: u32, filename: &str, size: u64) -> Vec<u8> {
Writer::new()
.u32(DIRECTION_UPLOAD)
.u32(token)
.string(filename)
.u64(size)
.finish_u32(code::TRANSFER_REQUEST)
}
/// Accept a peer's upload offer (their TransferRequest with direction=upload).
pub fn transfer_response_allowed(token: u32, size: u64) -> Vec<u8> {
Writer::new()
.u32(token)
.u8(1)
.u64(size)
.finish_u32(code::TRANSFER_RESPONSE)
}
pub fn transfer_response_rejected(token: u32, reason: &str) -> Vec<u8> {
Writer::new()
.u32(token)
.u8(0)
.string(reason)
.finish_u32(code::TRANSFER_RESPONSE)
}
/// Legacy enqueue message; some clients expect it before TransferRequest.
pub fn queue_download(filename: &str) -> Vec<u8> {
Writer::new()
.string(filename)
.finish_u32(code::QUEUE_DOWNLOAD)
}
pub fn place_in_queue_request(filename: &str) -> Vec<u8> {
Writer::new()
.string(filename)
.finish_u32(code::PLACE_IN_QUEUE_REQUEST)
}
pub fn place_in_queue_response(filename: &str, place: u32) -> Vec<u8> {
Writer::new()
.string(filename)
.u32(place)
.finish_u32(code::PLACE_IN_QUEUE_RESPONSE)
}
pub fn upload_denied(filename: &str, reason: &str) -> Vec<u8> {
Writer::new()
.string(filename)
.string(reason)
.finish_u32(code::UPLOAD_DENIED)
}
// ---- responder-side encoders (we answer other peers' requests) ----
/// A search reply we send to a searcher (code 9, zlib-compressed).
pub fn search_response(
username: &str,
token: u32,
files: &[File],
has_free_upload_slot: bool,
upload_speed: u32,
queue_length: u32,
) -> Vec<u8> {
let mut w = Writer::new()
.string(username)
.u32(token)
.u32(files.len() as u32);
for f in files {
w = f.write(w);
}
w = w
.bool(has_free_upload_slot)
.u32(upload_speed)
.u32(queue_length)
.u32(0) // unknown, for compatibility
.u32(0); // locked file count
w.finish_u32_compressed(code::SEARCH_RESPONSE)
}
/// Our full shared directory listing (code 5, zlib-compressed).
pub fn browse_response(dirs: &[Directory]) -> Vec<u8> {
let mut w = Writer::new().u32(dirs.len() as u32);
for d in dirs {
w = d.write(w);
}
w = w.u32(0).u32(0); // unknown + locked directory count
w.finish_u32_compressed(code::BROWSE_RESPONSE)
}
/// Contents of a single requested folder (code 37, zlib-compressed).
pub fn folder_contents_response(token: u32, folder: &str, dir: &Directory) -> Vec<u8> {
let w = Writer::new().u32(token).string(folder).u32(1);
let w = dir.write(w);
w.finish_u32_compressed(code::FOLDER_CONTENTS_RESPONSE)
}
/// Our user-info card (code 16).
pub fn user_info_response(
description: &str,
upload_slots: u32,
queue_length: u32,
has_free_upload_slot: bool,
) -> Vec<u8> {
Writer::new()
.string(description)
.u8(0) // has picture = false
.u32(upload_slots)
.u32(queue_length)
.bool(has_free_upload_slot)
.finish_u32(code::USER_INFO_RESPONSE)
}
#[derive(Debug, Clone)]
pub enum PeerEvent {
SearchResponse(SearchResponse),
/// Peer is ready to send us a file (direction=upload) or wants one
/// from us (direction=download → an upload request we service).
TransferRequest {
direction: u32,
token: u32,
filename: String,
size: u64,
},
/// Answer to our TransferRequest.
TransferResponse {
token: u32,
allowed: bool,
size: u64,
message: String,
},
PlaceInQueueResponse {
filename: String,
place: u32,
},
UploadDenied {
filename: String,
reason: String,
},
UploadFailed {
filename: String,
},
// Requests other peers make of us (M5 responder):
BrowseRequest,
UserInfoRequest,
FolderContentsRequest {
token: u32,
folder: String,
},
/// Peer asks us to queue an upload of one of our files to them.
QueueDownload {
filename: String,
},
PlaceInQueueRequest {
filename: String,
},
Unhandled {
code: u32,
},
}
pub fn decode(code: u32, payload: Bytes) -> Result<PeerEvent> {
// Search responses carry a zlib payload; handle before Reader dispatch.
if code == code::SEARCH_RESPONSE {
return Ok(PeerEvent::SearchResponse(decode_search_response(payload)?));
}
let mut r = Reader::new(payload);
Ok(match code {
code::TRANSFER_REQUEST => {
let direction = r.u32()?;
let token = r.u32()?;
let filename = r.string()?;
let size = if r.remaining() >= 8 { r.u64()? } else { 0 };
PeerEvent::TransferRequest {
direction,
token,
filename,
size,
}
}
code::TRANSFER_RESPONSE => {
let token = r.u32()?;
let allowed = r.bool()?;
if allowed {
let size = if r.remaining() >= 8 { r.u64()? } else { 0 };
PeerEvent::TransferResponse {
token,
allowed,
size,
message: String::new(),
}
} else {
let message = r.string().unwrap_or_default();
PeerEvent::TransferResponse {
token,
allowed,
size: 0,
message,
}
}
}
code::PLACE_IN_QUEUE_RESPONSE => PeerEvent::PlaceInQueueResponse {
filename: r.string()?,
place: r.u32()?,
},
code::UPLOAD_DENIED => PeerEvent::UploadDenied {
filename: r.string()?,
reason: r.string().unwrap_or_default(),
},
code::UPLOAD_FAILED => PeerEvent::UploadFailed {
filename: r.string()?,
},
code::BROWSE_REQUEST => PeerEvent::BrowseRequest,
code::USER_INFO_REQUEST => PeerEvent::UserInfoRequest,
code::FOLDER_CONTENTS_REQUEST => PeerEvent::FolderContentsRequest {
token: r.u32()?,
folder: r.string()?,
},
code::QUEUE_DOWNLOAD => PeerEvent::QueueDownload {
filename: r.string()?,
},
code::PLACE_IN_QUEUE_REQUEST => PeerEvent::PlaceInQueueRequest {
filename: r.string()?,
},
other => PeerEvent::Unhandled { code: other },
})
}
/// Payload is zlib-compressed: username, token, files, slot/speed/queue,
/// optional unknown u32, optional locked file list.
fn decode_search_response(payload: Bytes) -> Result<SearchResponse> {
let mut r = Reader::new(payload);
r.decompress()?;
let username = r.string()?;
let token = r.u32()?;
let file_count = r.u32()?.min(100_000);
let mut files = Vec::with_capacity(file_count.min(4096) as usize);
for _ in 0..file_count {
files.push(File::read(&mut r)?);
}
let has_free_upload_slot = r.bool()?;
let upload_speed = r.u32()?;
let queue_length = r.u32()?;
if r.has_more() {
let _unknown = r.u32()?;
}
let mut locked_files = Vec::new();
if r.has_more() {
let locked_count = r.u32()?.min(100_000);
for _ in 0..locked_count {
locked_files.push(File::read(&mut r)?);
}
}
Ok(SearchResponse {
username,
token,
files,
has_free_upload_slot,
upload_speed,
queue_length,
locked_files,
})
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write as _;
fn zlib(data: &[u8]) -> Bytes {
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
enc.write_all(data).unwrap();
Bytes::from(enc.finish().unwrap())
}
#[test]
fn init_roundtrip() {
let msg = peer_init("alice", "P", 42);
let init = decode_init(Bytes::copy_from_slice(&msg[4..])).unwrap();
match init {
InitMessage::PeerInit {
username,
conn_type,
token,
} => {
assert_eq!(
(username.as_str(), conn_type.as_str(), token),
("alice", "P", 42)
);
}
other => panic!("wrong init: {other:?}"),
}
let msg = pierce_firewall(7);
match decode_init(Bytes::copy_from_slice(&msg[4..])).unwrap() {
InitMessage::PierceFirewall { token } => assert_eq!(token, 7),
other => panic!("wrong init: {other:?}"),
}
}
#[test]
fn search_response_decode() {
// Build the uncompressed body by hand.
let body = Writer::new()
.string("bob")
.u32(555)
.u32(1)
// file entry
.u8(1)
.string("Music\\Artist\\Album\\01 - Song.flac")
.u64(31_337_000)
.string("flac")
.u32(2)
.u32(0)
.u32(1024) // bitrate
.u32(1)
.u32(213) // duration
// trailer
.bool(true)
.u32(500_000)
.u32(0)
.finish_u32(0);
let compressed = zlib(&body[8..]); // strip len+code, compress payload only
let resp = decode_search_response(compressed).unwrap();
assert_eq!(resp.username, "bob");
assert_eq!(resp.token, 555);
assert_eq!(resp.files.len(), 1);
let f = &resp.files[0];
assert_eq!(f.size, 31_337_000);
assert_eq!(f.bitrate(), Some(1024));
assert_eq!(f.duration_secs(), Some(213));
assert!(resp.has_free_upload_slot);
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Server messages (`server.slsknet.org:2242`), u32 codes.
use bytes::Bytes;
use md5::{Digest, Md5};
use std::net::{Ipv4Addr, SocketAddrV4};
use crate::wire::{Reader, Writer};
use crate::Result;
/// Client version fields sent at login. 160 is the protocol version spoken
/// by current clients (Nicotine+ uses the same).
pub const VERSION: u32 = 160;
pub const MINOR_VERSION: u32 = 1;
pub mod code {
pub const LOGIN: u32 = 1;
pub const SET_LISTEN_PORT: u32 = 2;
pub const GET_PEER_ADDRESS: u32 = 3;
pub const WATCH_USER: u32 = 5;
pub const CONNECT_TO_PEER: u32 = 18;
pub const FILE_SEARCH: u32 = 26;
pub const PING: u32 = 32;
pub const SHARED_FOLDERS_AND_FILES: u32 = 35;
pub const HAVE_NO_PARENTS: u32 = 71;
pub const PARENT_MIN_SPEED: u32 = 83;
pub const PARENT_SPEED_RATIO: u32 = 84;
pub const EMBEDDED_MESSAGE: u32 = 93; // wraps a distributed search
pub const ACCEPT_CHILDREN: u32 = 100;
pub const NET_INFO: u32 = 102;
pub const WISHLIST_INTERVAL: u32 = 104;
pub const BRANCH_LEVEL: u32 = 126;
pub const BRANCH_ROOT: u32 = 127;
pub const EXCLUDED_SEARCH_PHRASES: u32 = 160;
pub const CANNOT_CONNECT: u32 = 1001;
}
// ---- outgoing ----
pub fn login(username: &str, password: &str) -> Vec<u8> {
let hash = hex(Md5::digest(format!("{username}{password}").as_bytes()));
Writer::new()
.string(username)
.string(password)
.u32(VERSION)
.string(&hash)
.u32(MINOR_VERSION)
.finish_u32(code::LOGIN)
}
pub fn set_listen_port(port: u32) -> Vec<u8> {
Writer::new().u32(port).finish_u32(code::SET_LISTEN_PORT)
}
pub fn file_search(token: u32, query: &str) -> Vec<u8> {
Writer::new()
.u32(token)
.string(query)
.finish_u32(code::FILE_SEARCH)
}
pub fn shared_folders_and_files(dirs: u32, files: u32) -> Vec<u8> {
Writer::new()
.u32(dirs)
.u32(files)
.finish_u32(code::SHARED_FOLDERS_AND_FILES)
}
/// Tell the server whether we need a distributed parent.
pub fn accept_children(accept: bool) -> Vec<u8> {
Writer::new().bool(accept).finish_u32(code::ACCEPT_CHILDREN)
}
pub fn branch_level(level: u32) -> Vec<u8> {
Writer::new().u32(level).finish_u32(code::BRANCH_LEVEL)
}
pub fn branch_root(root: &str) -> Vec<u8> {
Writer::new().string(root).finish_u32(code::BRANCH_ROOT)
}
pub fn ping() -> Vec<u8> {
Writer::new().finish_u32(code::PING)
}
pub fn get_peer_address(username: &str) -> Vec<u8> {
Writer::new()
.string(username)
.finish_u32(code::GET_PEER_ADDRESS)
}
/// Ask the server to have `username` connect to us (indirect connection).
pub fn connect_to_peer(token: u32, username: &str, conn_type: &str) -> Vec<u8> {
Writer::new()
.u32(token)
.string(username)
.string(conn_type)
.finish_u32(code::CONNECT_TO_PEER)
}
pub fn have_no_parents(value: bool) -> Vec<u8> {
Writer::new().bool(value).finish_u32(code::HAVE_NO_PARENTS)
}
// ---- incoming ----
#[derive(Debug, Clone)]
pub enum ServerEvent {
LoginOk {
greeting: String,
own_ip: Ipv4Addr,
is_supporter: bool,
},
LoginFailed {
reason: String,
},
/// Another client wants a connection with us (or is answering our
/// indirect request): connect to them and send PierceFirewall(token).
ConnectToPeer {
username: String,
conn_type: String,
addr: SocketAddrV4,
token: u32,
privileged: bool,
},
/// Our indirect connection request to `token` failed.
CannotConnect {
token: u32,
username: Option<String>,
},
/// Answer to GetPeerAddress. A 0.0.0.0/port-0 address means offline.
UserAddress {
username: String,
addr: SocketAddrV4,
},
ExcludedSearchPhrases(Vec<String>),
/// Candidate distributed parents to connect to.
NetInfo(Vec<(String, SocketAddrV4)>),
/// A search relayed directly by the server (older path).
ServerSearch {
username: String,
token: u32,
query: String,
},
/// A distributed message wrapped by the server (code 93). The inner
/// bytes are a distributed frame starting with its code byte — decode
/// with `dist::decode`.
Embedded(Bytes),
Unhandled {
code: u32,
},
}
pub fn decode(code: u32, payload: Bytes) -> Result<ServerEvent> {
let mut r = Reader::new(payload);
Ok(match code {
code::LOGIN => {
if r.bool()? {
let greeting = r.string()?;
let own_ip = r.ipv4()?;
// Trailing hash + supporter flag are absent on some servers.
let _hash = r.string().unwrap_or_default();
let is_supporter = r.bool().unwrap_or(false);
ServerEvent::LoginOk {
greeting,
own_ip,
is_supporter,
}
} else {
ServerEvent::LoginFailed {
reason: r.string()?,
}
}
}
code::CONNECT_TO_PEER => {
let username = r.string()?;
let conn_type = r.string()?;
let ip = r.ipv4()?;
let port = r.u32()?;
let token = r.u32()?;
let privileged = r.bool().unwrap_or(false);
ServerEvent::ConnectToPeer {
username,
conn_type,
addr: SocketAddrV4::new(ip, port as u16),
token,
privileged,
}
}
code::GET_PEER_ADDRESS => {
let username = r.string()?;
let ip = r.ipv4()?;
let port = r.u32()?;
// Trailing obfuscation fields ignored.
ServerEvent::UserAddress {
username,
addr: SocketAddrV4::new(ip, port as u16),
}
}
code::CANNOT_CONNECT => {
let token = r.u32()?;
let username = r.string().ok();
ServerEvent::CannotConnect { token, username }
}
code::NET_INFO => {
let count = r.u32()?.min(1000);
let mut parents = Vec::with_capacity(count as usize);
for _ in 0..count {
let username = r.string()?;
let ip = r.ipv4()?;
let port = r.u32()?;
parents.push((username, SocketAddrV4::new(ip, port as u16)));
}
ServerEvent::NetInfo(parents)
}
code::FILE_SEARCH => {
// Server-relayed search (same code we send): username, token, query.
let username = r.string()?;
let token = r.u32()?;
let query = r.string()?;
ServerEvent::ServerSearch {
username,
token,
query,
}
}
code::EMBEDDED_MESSAGE => ServerEvent::Embedded(r.rest()),
code::EXCLUDED_SEARCH_PHRASES => {
let count = r.u32()?.min(10_000);
let mut phrases = Vec::with_capacity(count as usize);
for _ in 0..count {
phrases.push(r.string()?);
}
ServerEvent::ExcludedSearchPhrases(phrases)
}
other => ServerEvent::Unhandled { code: other },
})
}
fn hex(digest: impl AsRef<[u8]>) -> String {
digest.as_ref().iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_message_layout() {
let msg = login("user", "pass");
let mut r = Reader::new(Bytes::copy_from_slice(&msg[8..]));
assert_eq!(&msg[4..8], &1u32.to_le_bytes());
assert_eq!(r.string().unwrap(), "user");
assert_eq!(r.string().unwrap(), "pass");
assert_eq!(r.u32().unwrap(), VERSION);
// md5("userpass")
assert_eq!(r.string().unwrap(), "63e780c3f321d13109c71bf81805476e");
assert_eq!(r.u32().unwrap(), MINOR_VERSION);
}
#[test]
fn decode_connect_to_peer() {
let payload = Writer::new()
.string("bob")
.string("P")
.u8(4)
.u8(3)
.u8(2)
.u8(1) // 1.2.3.4 reversed on wire
.u32(2234)
.u32(99)
.bool(false)
.finish_u32(18);
let ev = decode(18, Bytes::copy_from_slice(&payload[8..])).unwrap();
match ev {
ServerEvent::ConnectToPeer {
username,
conn_type,
addr,
token,
..
} => {
assert_eq!(username, "bob");
assert_eq!(conn_type, "P");
assert_eq!(addr, "1.2.3.4:2234".parse().unwrap());
assert_eq!(token, 99);
}
other => panic!("wrong event: {other:?}"),
}
}
}
+114
View File
@@ -0,0 +1,114 @@
//! Shared domain types used across peer and server messages.
use crate::wire::{Reader, Writer};
use crate::Result;
/// File attribute types (Soulseek.NET `FileAttributeType`).
pub const ATTR_BITRATE: u32 = 0;
pub const ATTR_DURATION: u32 = 1;
pub const ATTR_VBR: u32 = 2;
pub const ATTR_SAMPLE_RATE: u32 = 4;
pub const ATTR_BIT_DEPTH: u32 = 5;
#[derive(Debug, Clone)]
pub struct File {
pub filename: String,
pub size: u64,
pub extension: String,
pub attributes: Vec<(u32, u32)>,
}
impl File {
pub(crate) fn read(r: &mut Reader) -> Result<File> {
let _code = r.u8()?;
let filename = r.string()?;
let mut size = r.u64()?;
// Some clients send a 32-bit size sign-extended to 64 bits.
if size > i64::MAX as u64 && (size >> 32) == 0xFFFF_FFFF {
size &= 0xFFFF_FFFF;
}
let extension = r.string()?;
let attr_count = r.u32()?.min(64);
let mut attributes = Vec::with_capacity(attr_count as usize);
for _ in 0..attr_count {
attributes.push((r.u32()?, r.u32()?));
}
Ok(File {
filename,
size,
extension,
attributes,
})
}
/// Write this file into a search/browse response body. `code` is the
/// leading byte (1 for normal files).
pub fn write(&self, mut w: Writer) -> Writer {
w = w
.u8(1)
.string(&self.filename)
.u64(self.size)
.string(&self.extension);
w = w.u32(self.attributes.len() as u32);
for (ty, val) in &self.attributes {
w = w.u32(*ty).u32(*val);
}
w
}
fn attr(&self, ty: u32) -> Option<u32> {
self.attributes
.iter()
.find(|(t, _)| *t == ty)
.map(|(_, v)| *v)
}
pub fn bitrate(&self) -> Option<u32> {
self.attr(ATTR_BITRATE)
}
pub fn duration_secs(&self) -> Option<u32> {
self.attr(ATTR_DURATION)
}
pub fn is_vbr(&self) -> bool {
self.attr(ATTR_VBR) == Some(1)
}
pub fn sample_rate(&self) -> Option<u32> {
self.attr(ATTR_SAMPLE_RATE)
}
pub fn bit_depth(&self) -> Option<u32> {
self.attr(ATTR_BIT_DEPTH)
}
}
#[derive(Debug, Clone)]
pub struct SearchResponse {
pub username: String,
pub token: u32,
pub files: Vec<File>,
pub has_free_upload_slot: bool,
pub upload_speed: u32,
pub queue_length: u32,
pub locked_files: Vec<File>,
}
/// A shared directory in a browse response: backslash-separated name and
/// its files.
#[derive(Debug, Clone)]
pub struct Directory {
pub name: String,
pub files: Vec<File>,
}
impl Directory {
pub fn write(&self, mut w: Writer) -> Writer {
w = w.string(&self.name).u32(self.files.len() as u32);
for f in &self.files {
w = f.write(w);
}
w
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Low-level message building and reading, mirroring Soulseek.NET's
//! `MessageBuilder` / `MessageReader`.
use bytes::{Buf, BufMut, Bytes, BytesMut};
use flate2::read::ZlibDecoder;
use std::io::Read;
use crate::{ProtoError, Result};
/// Builds one outgoing message. `finish_*` prepends the length header.
pub struct Writer {
buf: BytesMut,
}
impl Default for Writer {
fn default() -> Self {
Self::new()
}
}
impl Writer {
pub fn new() -> Self {
Writer {
buf: BytesMut::with_capacity(64),
}
}
pub fn u8(mut self, v: u8) -> Self {
self.buf.put_u8(v);
self
}
pub fn u32(mut self, v: u32) -> Self {
self.buf.put_u32_le(v);
self
}
pub fn u64(mut self, v: u64) -> Self {
self.buf.put_u64_le(v);
self
}
pub fn bool(self, v: bool) -> Self {
self.u8(v as u8)
}
pub fn string(mut self, s: &str) -> Self {
self.buf.put_u32_le(s.len() as u32);
self.buf.put_slice(s.as_bytes());
self
}
/// Frame with a u32 code: `[len][code:u32][payload]`.
pub fn finish_u32(self, code: u32) -> Vec<u8> {
let mut out = Vec::with_capacity(self.buf.len() + 8);
out.extend_from_slice(&((self.buf.len() as u32 + 4).to_le_bytes()));
out.extend_from_slice(&code.to_le_bytes());
out.extend_from_slice(&self.buf);
out
}
/// Frame with a u32 code and a zlib-compressed payload:
/// `[len][code:u32][zlib(payload)]` (search responses, browse responses).
pub fn finish_u32_compressed(self, code: u32) -> Vec<u8> {
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
enc.write_all(&self.buf).expect("zlib write");
let compressed = enc.finish().expect("zlib finish");
let mut out = Vec::with_capacity(compressed.len() + 8);
out.extend_from_slice(&((compressed.len() as u32 + 4).to_le_bytes()));
out.extend_from_slice(&code.to_le_bytes());
out.extend_from_slice(&compressed);
out
}
/// Frame with a single-byte code: `[len][code:u8][payload]`
/// (PeerInit / PierceFirewall only).
pub fn finish_u8(self, code: u8) -> Vec<u8> {
let mut out = Vec::with_capacity(self.buf.len() + 5);
out.extend_from_slice(&((self.buf.len() as u32 + 1).to_le_bytes()));
out.push(code);
out.extend_from_slice(&self.buf);
out
}
}
/// Reads one message payload (the bytes after the code).
pub struct Reader {
buf: Bytes,
}
impl Reader {
pub fn new(payload: Bytes) -> Self {
Reader { buf: payload }
}
pub fn remaining(&self) -> usize {
self.buf.remaining()
}
pub fn has_more(&self) -> bool {
self.buf.has_remaining()
}
/// Take all remaining bytes.
pub fn rest(&mut self) -> Bytes {
let n = self.buf.remaining();
self.buf.copy_to_bytes(n)
}
pub fn u8(&mut self) -> Result<u8> {
if self.buf.remaining() < 1 {
return Err(ProtoError::UnexpectedEof);
}
Ok(self.buf.get_u8())
}
pub fn bool(&mut self) -> Result<bool> {
Ok(self.u8()? != 0)
}
pub fn u32(&mut self) -> Result<u32> {
if self.buf.remaining() < 4 {
return Err(ProtoError::UnexpectedEof);
}
Ok(self.buf.get_u32_le())
}
pub fn u64(&mut self) -> Result<u64> {
if self.buf.remaining() < 8 {
return Err(ProtoError::UnexpectedEof);
}
Ok(self.buf.get_u64_le())
}
/// IPv4 address; wire order is reversed relative to display order.
pub fn ipv4(&mut self) -> Result<std::net::Ipv4Addr> {
if self.buf.remaining() < 4 {
return Err(ProtoError::UnexpectedEof);
}
let mut b = [0u8; 4];
self.buf.copy_to_slice(&mut b);
Ok(std::net::Ipv4Addr::new(b[3], b[2], b[1], b[0]))
}
/// UTF-8 with Latin-1 fallback, matching what real-world peers send.
pub fn string(&mut self) -> Result<String> {
let len = self.u32()? as usize;
if self.buf.remaining() < len {
return Err(ProtoError::UnexpectedEof);
}
let bytes = self.buf.copy_to_bytes(len);
Ok(match std::str::from_utf8(&bytes) {
Ok(s) => s.to_owned(),
Err(_) => bytes.iter().map(|&b| b as char).collect(),
})
}
/// Replace the remaining payload with its zlib-decompressed form.
pub fn decompress(&mut self) -> Result<()> {
let mut out = Vec::new();
ZlibDecoder::new(&self.buf[..]).read_to_end(&mut out)?;
if out.len() > crate::MAX_MESSAGE_SIZE {
return Err(ProtoError::TooLarge(out.len()));
}
self.buf = Bytes::from(out);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_primitives() {
let msg = Writer::new().u32(7).string("héllo").u64(9).finish_u32(26);
// len = 4 (code) + 4 + 4+6 + 8 = 26
assert_eq!(&msg[0..4], &26u32.to_le_bytes());
assert_eq!(&msg[4..8], &26u32.to_le_bytes());
let mut r = Reader::new(Bytes::copy_from_slice(&msg[8..]));
assert_eq!(r.u32().unwrap(), 7);
assert_eq!(r.string().unwrap(), "héllo");
assert_eq!(r.u64().unwrap(), 9);
assert!(!r.has_more());
}
#[test]
fn u8_code_frame() {
let msg = Writer::new().u32(123).finish_u8(0);
assert_eq!(&msg[0..4], &5u32.to_le_bytes());
assert_eq!(msg[4], 0);
}
#[test]
fn latin1_fallback() {
let mut raw = Vec::new();
raw.extend_from_slice(&2u32.to_le_bytes());
raw.extend_from_slice(&[0xE9, 0x21]); // "é!" in Latin-1, invalid UTF-8
let mut r = Reader::new(Bytes::from(raw));
assert_eq!(r.string().unwrap(), "é!");
}
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "tagger"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
lofty = "0.22"
thiserror.workspace = true
log.workspace = true
+24
View File
@@ -0,0 +1,24 @@
//! Read tags from every audio file in a folder and print the outcome.
//! cargo run -p tagger --example read -- "C:\folder"
fn main() {
let dir = std::env::args().nth(1).expect("usage: read <dir>");
for entry in std::fs::read_dir(&dir).expect("read_dir").flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
match tagger::read_summary(&path) {
Ok(s) => println!(
"OK {} | artist={:?} album={:?} title={:?} track={:?} dur={:?}",
path.file_name().unwrap().to_string_lossy(),
s.artist,
s.album,
s.title,
s.track_no,
s.duration_secs
),
Err(e) => println!("ERR {} | {e}", path.file_name().unwrap().to_string_lossy()),
}
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Tag reading/writing and album-art embedding via lofty.
use std::path::Path;
use lofty::config::WriteOptions;
use lofty::file::{AudioFile, TaggedFileExt};
use lofty::picture::{MimeType, Picture, PictureType};
use lofty::probe::Probe;
use lofty::tag::{Accessor, Tag};
#[derive(Debug, thiserror::Error)]
pub enum TagError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("lofty: {0}")]
Lofty(#[from] lofty::error::LoftyError),
}
pub type Result<T> = std::result::Result<T, TagError>;
/// What we know about a file from its embedded tags + audio properties.
#[derive(Debug, Clone, Default)]
pub struct TagSummary {
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: Option<String>,
pub title: Option<String>,
pub track_no: Option<u32>,
pub year: Option<u32>,
pub duration_secs: Option<u32>,
pub bitrate_kbps: Option<u32>,
pub sample_rate: Option<u32>,
pub bit_depth: Option<u32>,
pub has_art: bool,
}
pub fn read_summary(path: &Path) -> Result<TagSummary> {
let tagged = Probe::open(path)?.read()?;
let props = tagged.properties();
let mut summary = TagSummary {
duration_secs: Some(props.duration().as_secs() as u32),
bitrate_kbps: props.audio_bitrate(),
sample_rate: props.sample_rate(),
bit_depth: props.bit_depth().map(|d| d as u32),
..Default::default()
};
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
summary.artist = non_empty(tag.artist().as_deref());
summary.album = non_empty(tag.album().as_deref());
summary.title = non_empty(tag.title().as_deref());
summary.track_no = tag.track();
summary.year = tag.year();
summary.album_artist = non_empty(
tag.get_string(&lofty::tag::ItemKey::AlbumArtist)
.map(|s| s.to_string())
.as_deref(),
);
summary.has_art = !tag.pictures().is_empty();
}
Ok(summary)
}
/// Canonical values to write. `None` fields are left untouched.
#[derive(Debug, Clone, Default)]
pub struct TagWrite {
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: Option<String>,
pub title: Option<String>,
pub track_no: Option<u32>,
pub year: Option<u32>,
}
/// Cover bytes come from several providers; PNG originals exist (Cover Art
/// Archive scans), so the embedded mime is sniffed rather than assumed.
fn sniff_mime(bytes: &[u8]) -> MimeType {
if bytes.starts_with(b"\x89PNG") {
MimeType::Png
} else {
MimeType::Jpeg
}
}
/// Write tags and (optionally) embed front-cover art. With `replace_art`
/// false the cover is only added when the file has none; true drops whatever
/// pictures the file arrived with first, so the canonical cover always wins.
pub fn write_tags(
path: &Path,
write: &TagWrite,
art_jpeg: Option<&[u8]>,
replace_art: bool,
) -> Result<()> {
let mut tagged = Probe::open(path)?.read()?;
if tagged.primary_tag().is_none() && tagged.first_tag().is_none() {
let tag_type = tagged.primary_tag_type();
tagged.insert_tag(Tag::new(tag_type));
}
let tag = tagged.primary_tag_mut().expect("tag inserted above");
if let Some(v) = &write.artist {
tag.set_artist(v.clone());
}
if let Some(v) = &write.album {
tag.set_album(v.clone());
}
if let Some(v) = &write.title {
tag.set_title(v.clone());
}
if let Some(v) = write.track_no {
tag.set_track(v);
}
if let Some(v) = write.year {
tag.set_year(v);
}
if let Some(v) = &write.album_artist {
tag.insert_text(lofty::tag::ItemKey::AlbumArtist, v.clone());
}
if let Some(bytes) = art_jpeg {
if replace_art {
while !tag.pictures().is_empty() {
tag.remove_picture(0);
}
}
if tag.pictures().is_empty() {
let picture = Picture::new_unchecked(
PictureType::CoverFront,
Some(sniff_mime(bytes)),
None,
bytes.to_vec(),
);
tag.push_picture(picture);
}
}
tagged.save_to_path(path, WriteOptions::default())?;
Ok(())
}
/// Replace any existing embedded pictures with the given front cover.
pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> {
let mut tagged = Probe::open(path)?.read()?;
if tagged.primary_tag().is_none() && tagged.first_tag().is_none() {
let tag_type = tagged.primary_tag_type();
tagged.insert_tag(Tag::new(tag_type));
}
let tag = tagged.primary_tag_mut().expect("tag inserted above");
while !tag.pictures().is_empty() {
tag.remove_picture(0);
}
tag.push_picture(Picture::new_unchecked(
PictureType::CoverFront,
Some(sniff_mime(art_jpeg)),
None,
art_jpeg.to_vec(),
));
tagged.save_to_path(path, WriteOptions::default())?;
Ok(())
}
fn non_empty(s: Option<&str>) -> Option<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(String::from)
}
/// Best-effort metadata parse from a Soulseek path when tags are missing.
/// Handles `Artist - Album (Year)` folders and `NN - Title` / `NN Title`
/// filenames.
pub fn parse_from_path(remote_or_local_path: &str) -> TagWrite {
let parts: Vec<&str> = remote_or_local_path.split(['\\', '/']).collect();
let file = parts.last().copied().unwrap_or_default();
let folder = parts
.len()
.checked_sub(2)
.and_then(|i| parts.get(i))
.copied()
.unwrap_or("");
let stem = file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file);
let mut write = TagWrite::default();
// "01 - Title", "01. Title", "01 Title"
let trimmed = stem.trim();
let (track, title) = match trimmed.split_once(|c: char| !c.is_ascii_digit()) {
Some((digits, rest)) if !digits.is_empty() && digits.len() <= 3 => {
let title = rest.trim_start_matches(['-', '.', ' ', '_']).trim();
(
digits.parse::<u32>().ok(),
if title.is_empty() { trimmed } else { title },
)
}
_ => (None, trimmed),
};
write.track_no = track;
write.title = Some(title.to_string());
// Folder: "Artist - Album (1997)" | "Album (1997)" | "1997 - Album"
let mut folder = folder.trim().to_string();
if let Some(idx) = folder.rfind('(') {
let inner: String = folder[idx + 1..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if inner.len() == 4 {
write.year = inner.parse().ok();
folder = folder[..idx].trim().to_string();
}
}
if write.year.is_none() {
let lead: String = folder.chars().take_while(|c| c.is_ascii_digit()).collect();
if lead.len() == 4 {
write.year = lead.parse().ok();
folder = folder[lead.len()..]
.trim_start_matches(['-', '.', ' '])
.to_string();
}
}
if let Some((artist, album)) = folder.split_once(" - ") {
write.artist = Some(artist.trim().to_string());
write.album = Some(album.trim().to_string());
} else if !folder.is_empty() {
write.album = Some(folder);
}
write
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_full_convention() {
let w = parse_from_path(
r"Music\Radiohead - OK Computer (1997)\03 - Subterranean Homesick Alien.flac",
);
assert_eq!(w.artist.as_deref(), Some("Radiohead"));
assert_eq!(w.album.as_deref(), Some("OK Computer"));
assert_eq!(w.year, Some(1997));
assert_eq!(w.track_no, Some(3));
assert_eq!(w.title.as_deref(), Some("Subterranean Homesick Alien"));
}
#[test]
fn parse_year_leading_folder() {
let w = parse_from_path(r"share\1997 - OK Computer\01 Airbag.flac");
assert_eq!(w.album.as_deref(), Some("OK Computer"));
assert_eq!(w.year, Some(1997));
assert_eq!(w.track_no, Some(1));
assert_eq!(w.title.as_deref(), Some("Airbag"));
}
#[test]
fn parse_no_track_number() {
let w = parse_from_path(r"stuff\Nevermind (1991)\Lithium.flac");
assert_eq!(w.title.as_deref(), Some("Lithium"));
assert_eq!(w.track_no, None);
assert_eq!(w.album.as_deref(), Some("Nevermind"));
assert_eq!(w.year, Some(1991));
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SoulStream</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1701
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "soulstream",
"version": "0.1.0",
"type": "module",
"description": "SoulStream — music player, downloader and streamer for the Soulseek network",
"scripts": {
"dev": "vite",
"build": "vite build",
"tauri": "tauri"
},
"license": "ISC",
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5",
"@tauri-apps/cli": "^2.11.2",
"svelte": "^5",
"vite": "^6"
},
"dependencies": {
"@tauri-apps/api": "^2"
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+45
View File
@@ -0,0 +1,45 @@
[package]
name = "app"
version.workspace = true
description = "SoulStream — music player, downloader and streamer for the Soulseek network"
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
serde.workspace = true
serde_json.workspace = true
log.workspace = true
image.workspace = true
tokio.workspace = true
base64 = "0.22"
keyring = { version = "3", features = ["windows-native"] }
soulseek-proto = { path = "../crates/soulseek-proto" }
soulseek-client = { path = "../crates/soulseek-client" }
player = { path = "../crates/player" }
tagger = { path = "../crates/tagger" }
metadata = { path = "../crates/metadata" }
library = { path = "../crates/library" }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61.3", features = [
"AI_MachineLearning",
"Foundation_Collections",
"Globalization",
"Graphics_Imaging",
"Media_FaceAnalysis",
"Media_Ocr",
"Storage_Streams",
"Win32_System_WinRT",
] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-start-dragging",
"core:window:allow-is-maximized",
"core:window:allow-show",
"core:window:allow-set-focus",
"dialog:allow-open"
]
}
+30
View File
@@ -0,0 +1,30 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1ed760"/>
<stop offset="1" stop-color="#0f9d47"/>
</linearGradient>
<linearGradient id="wave" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#062412"/>
<stop offset="1" stop-color="#0a3a1d"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="230" fill="url(#bg)"/>
<!-- sound-wave bars forming an 'S' rhythm -->
<g fill="url(#wave)">
<rect x="250" y="430" width="58" height="164" rx="29"/>
<rect x="346" y="360" width="58" height="304" rx="29"/>
<rect x="442" y="270" width="58" height="484" rx="29"/>
<rect x="538" y="360" width="58" height="304" rx="29"/>
<rect x="634" y="430" width="58" height="164" rx="29"/>
</g>
<!-- plus badge -->
<circle cx="760" cy="300" r="120" fill="#062412"/>
<g fill="#1ed760">
<rect x="742" y="238" width="36" height="124" rx="18"/>
<rect x="698" y="282" width="124" height="36" rx="18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#41B7EE"/>
<stop offset="1" stop-color="#0C67B0"/>
</linearGradient>
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.14"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
<rect width="512" height="512" rx="114" fill="url(#sheen)"/>
<!-- waveform "stream": five bars riding a gentle flow -->
<g fill="#FFFFFF">
<rect x="91" y="216" width="42" height="120" rx="21"/>
<rect x="163" y="166" width="42" height="200" rx="21"/>
<rect x="235" y="106" width="42" height="300" rx="21"/>
<rect x="307" y="146" width="42" height="200" rx="21"/>
<rect x="379" y="176" width="42" height="120" rx="21"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more