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.
No files matched your search
@@ -0,0 +1,5 @@
|
||||
target/
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/gen/
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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("-")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(), "é!");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -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",
|
||||
] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 76 KiB |
@@ -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 |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |