diff --git a/crates/soulseek-client/src/conn.rs b/crates/soulseek-client/src/conn.rs index 2979d80..f554f18 100644 --- a/crates/soulseek-client/src/conn.rs +++ b/crates/soulseek-client/src/conn.rs @@ -277,6 +277,23 @@ fn handle_server_frame(inner: &Arc, mut body: Bytes) { dir_count, }); } + server::ServerEvent::UserInterests { username, likes, hates } => { + let waiters = inner + .pending_interests + .lock() + .unwrap() + .remove(&username) + .unwrap_or_default(); + for tx in waiters { + let _ = tx.send((likes.clone(), hates.clone())); + } + } + server::ServerEvent::SimilarUsers { users } => { + let waiters: Vec<_> = inner.pending_similar.lock().unwrap().drain(..).collect(); + for tx in waiters { + let _ = tx.send(users.clone()); + } + } server::ServerEvent::CannotConnect { .. } | server::ServerEvent::Unhandled { .. } => {} } } @@ -608,6 +625,21 @@ fn route_peer_event(inner: &Arc, username: &str, ev: peer::PeerEvent) { } } } + UserInfoResponse { description, picture, total_uploads, queue_size, has_free_slot } => { + let info = crate::UserInfo { description, picture, total_uploads, queue_size, has_free_slot }; + let waiters = inner + .pending_user_infos + .lock() + .unwrap() + .remove(username) + .unwrap_or_default(); + if waiters.is_empty() { + log::debug!("unsolicited user info from {username}"); + } + for tx in waiters { + let _ = tx.send(info.clone()); + } + } UserInfoRequest => { if let Some(h) = inner.peers.lock().unwrap().get(username) { crate::responder::respond_user_info(inner, h); diff --git a/crates/soulseek-client/src/lib.rs b/crates/soulseek-client/src/lib.rs index 30b7ffd..597ca9f 100644 --- a/crates/soulseek-client/src/lib.rs +++ b/crates/soulseek-client/src/lib.rs @@ -184,6 +184,17 @@ pub(crate) struct TransferAck { /// or the denial that arrived instead. pub(crate) type UploadOffer = std::result::Result<(u32, u64), String>; // (remote token, size) +/// A peer's user-info card as they sent it. The picture is untouched bytes: +/// the app decides whether and how to decode it. +#[derive(Debug, Clone)] +pub struct UserInfo { + pub description: String, + pub picture: Option>, + pub total_uploads: u32, + pub queue_size: u32, + pub has_free_slot: bool, +} + pub(crate) struct Inner { pub server_tx: mpsc::UnboundedSender>, pub event_tx: mpsc::UnboundedSender, @@ -206,6 +217,9 @@ pub(crate) struct Inner { pub peers: Mutex>, /// Browse requests awaiting a peer's share listing, by username. pub pending_browses: Mutex>>>>, + pub pending_user_infos: Mutex>>>, + pub pending_interests: Mutex, Vec)>>>>, + pub pending_similar: Mutex>>>, /// TransferResponse waits: (username, our token). pub transfer_waits: Mutex>>, /// TransferRequest(upload) waits: (username, remote filename). @@ -317,6 +331,9 @@ impl Client { expected_transfers: Mutex::new(HashMap::new()), peers: Mutex::new(HashMap::new()), pending_browses: Mutex::new(HashMap::new()), + pending_user_infos: Mutex::new(HashMap::new()), + pending_interests: Mutex::new(HashMap::new()), + pending_similar: Mutex::new(Vec::new()), transfer_waits: Mutex::new(HashMap::new()), upload_offers: Mutex::new(HashMap::new()), downloads_by_file: Mutex::new(HashMap::new()), @@ -549,6 +566,81 @@ impl Client { } } + /// A user's info card over a peer connection — spawn-shaped like + /// `browse`: drop whatever lock guarded the `Client` before awaiting. + pub fn user_info( + &self, + username: &str, + ) -> impl std::future::Future> + Send + 'static { + let inner = self.inner.clone(); + let username = username.to_string(); + async move { + let handle = conn::get_or_connect_peer(&inner, &username).await?; + let (tx, rx) = oneshot::channel(); + inner + .pending_user_infos + .lock() + .unwrap() + .entry(username.clone()) + .or_default() + .push(tx); + handle + .write_tx + .send(peer::user_info_request()) + .map_err(|_| ClientError::PeerUnreachable(username.clone()))?; + match tokio::time::timeout(std::time::Duration::from_secs(30), rx).await { + Ok(Ok(info)) => Ok(info), + _ => { + inner.pending_user_infos.lock().unwrap().remove(&username); + Err(ClientError::Timeout) + } + } + } + } + + /// A user's declared likes and hates, from the server. Spawn-shaped. + pub fn user_interests( + &self, + username: &str, + ) -> impl std::future::Future, Vec)>> + Send + 'static { + let inner = self.inner.clone(); + let username = username.to_string(); + async move { + let (tx, rx) = oneshot::channel(); + inner + .pending_interests + .lock() + .unwrap() + .entry(username.clone()) + .or_default() + .push(tx); + inner.send_server(server::user_interests(&username))?; + match tokio::time::timeout(std::time::Duration::from_secs(20), rx).await { + Ok(Ok(v)) => Ok(v), + _ => { + inner.pending_interests.lock().unwrap().remove(&username); + Err(ClientError::Timeout) + } + } + } + } + + /// Users the server ranks as similar to us by shared interests. Spawn-shaped. + pub fn similar_users( + &self, + ) -> impl std::future::Future>> + Send + 'static { + let inner = self.inner.clone(); + async move { + let (tx, rx) = oneshot::channel(); + inner.pending_similar.lock().unwrap().push(tx); + inner.send_server(server::similar_users())?; + match tokio::time::timeout(std::time::Duration::from_secs(20), rx).await { + Ok(Ok(v)) => Ok(v), + _ => Err(ClientError::Timeout), + } + } + } + pub fn listen_port(&self) -> u16 { self.inner.listen_port } diff --git a/crates/soulseek-proto/src/peer.rs b/crates/soulseek-proto/src/peer.rs index 63074ab..8efebfc 100644 --- a/crates/soulseek-proto/src/peer.rs +++ b/crates/soulseek-proto/src/peer.rs @@ -125,6 +125,11 @@ pub fn browse_request() -> Vec { Writer::new().finish_u32(code::BROWSE_REQUEST) } +/// Ask a peer for their user-info card (code 15; answered with code 16). +pub fn user_info_request() -> Vec { + Writer::new().finish_u32(code::USER_INFO_REQUEST) +} + /// Legacy enqueue message; some clients expect it before TransferRequest. pub fn queue_download(filename: &str) -> Vec { Writer::new() @@ -247,6 +252,15 @@ pub enum PeerEvent { UploadFailed { filename: String, }, + /// A peer's user-info card, the answer to our user_info_request. + UserInfoResponse { + description: String, + /// Raw bytes as sent — hostile input until decoded under limits. + picture: Option>, + total_uploads: u32, + queue_size: u32, + has_free_slot: bool, + }, // Requests other peers make of us (M5 responder): BrowseRequest, UserInfoRequest, @@ -320,6 +334,17 @@ pub fn decode(code: u32, payload: Bytes) -> Result { code::UPLOAD_FAILED => PeerEvent::UploadFailed { filename: r.string()?, }, + code::USER_INFO_RESPONSE => { + let description = r.string()?; + let picture = if r.bool()? { Some(r.bytes()?.to_vec()) } else { None }; + PeerEvent::UserInfoResponse { + description, + picture, + total_uploads: r.u32()?, + queue_size: r.u32()?, + has_free_slot: r.bool().unwrap_or(false), + } + } code::BROWSE_REQUEST => PeerEvent::BrowseRequest, code::USER_INFO_REQUEST => PeerEvent::UserInfoRequest, code::FOLDER_CONTENTS_REQUEST => PeerEvent::FolderContentsRequest { diff --git a/crates/soulseek-proto/src/server.rs b/crates/soulseek-proto/src/server.rs index e52ffea..51e2244 100644 --- a/crates/soulseek-proto/src/server.rs +++ b/crates/soulseek-proto/src/server.rs @@ -31,6 +31,8 @@ pub mod code { pub const PING: u32 = 32; pub const SHARED_FOLDERS_AND_FILES: u32 = 35; pub const GET_USER_STATS: u32 = 36; + pub const USER_INTERESTS: u32 = 57; + pub const SIMILAR_USERS: u32 = 110; pub const ROOM_LIST: u32 = 64; pub const HAVE_NO_PARENTS: u32 = 71; pub const PARENT_MIN_SPEED: u32 = 83; @@ -136,6 +138,27 @@ pub fn get_user_stats(username: &str) -> Vec { .finish_u32(code::GET_USER_STATS) } +/// A user's declared likes and hates (code 57). +pub fn user_interests(username: &str) -> Vec { + Writer::new() + .string(username) + .finish_u32(code::USER_INTERESTS) +} + +/// Users the server ranks as similar to us by shared interests (code 110). +pub fn similar_users() -> Vec { + Writer::new().finish_u32(code::SIMILAR_USERS) +} + +fn string_list(r: &mut Reader) -> Result> { + let n = r.u32()?.min(1024) as usize; + let mut v = Vec::with_capacity(n.min(64)); + for _ in 0..n { + v.push(r.string()?); + } + Ok(v) +} + pub fn say_chatroom(room: &str, message: &str) -> Vec { Writer::new() .string(room) @@ -226,6 +249,16 @@ pub enum ServerEvent { Embedded(Bytes), /// Public rooms and their user counts (pushed after login, and on /// request). Private-room sections in the payload are ignored. + /// Answer to user_interests: what they declared, verbatim. + UserInterests { + username: String, + likes: Vec, + hates: Vec, + }, + /// Answer to similar_users: (username, rating) as the server ranks them. + SimilarUsers { + users: Vec<(String, u32)>, + }, RoomList { rooms: Vec<(String, u32)>, }, @@ -473,6 +506,20 @@ pub fn decode(code: u32, payload: Bytes) -> Result { dir_count: r.u32()?, } } + code::USER_INTERESTS => { + let username = r.string()?; + let likes = string_list(&mut r)?; + let hates = string_list(&mut r)?; + ServerEvent::UserInterests { username, likes, hates } + } + code::SIMILAR_USERS => { + let n = r.u32()?.min(4096) as usize; + let mut users = Vec::with_capacity(n.min(256)); + for _ in 0..n { + users.push((r.string()?, r.u32()?)); + } + ServerEvent::SimilarUsers { users } + } code::ROOM_LIST => { // Public names + counts; the private-room sections that follow // are deliberately not parsed. diff --git a/crates/soulseek-proto/src/wire.rs b/crates/soulseek-proto/src/wire.rs index 7bf1916..6b9238b 100644 --- a/crates/soulseek-proto/src/wire.rs +++ b/crates/soulseek-proto/src/wire.rs @@ -160,6 +160,16 @@ impl Reader { }) } + /// A length-prefixed raw byte string (a user picture, for one): the same + /// framing as `string`, without the UTF-8 interpretation. + pub fn bytes(&mut self) -> Result { + let len = self.u32()? as usize; + if self.buf.remaining() < len { + return Err(ProtoError::UnexpectedEof); + } + Ok(self.buf.copy_to_bytes(len)) + } + /// Replace the remaining payload with its zlib-decompressed form. pub fn decompress(&mut self) -> Result<()> { let mut out = Vec::new(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7bd86ba..3cf369c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1084,6 +1084,94 @@ pub struct BrowseDirDto { /// Walk a user's shares. Long-running: the client lock is held only long /// enough to start the request. +#[derive(Serialize)] +pub struct UserInfoDto { + pub username: String, + pub description: String, + /// The picture ONLY after the avatar pipeline's bounded decode + resize + /// (profile.rs): a peer's bytes are hostile input, never rendered raw. + pub picture_png_base64: Option, + pub picture_note: Option, + pub total_uploads: u32, + pub queue_size: u32, + pub has_free_slot: bool, +} + +/// A peer's native user-info card: description, picture, upload state. +#[tauri::command] +pub async fn slsk_user_info( + state: State<'_, AppState>, + username: String, +) -> Result { + let fut = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.user_info(&username) + }; + let info = fut.await.map_err(|e| e.to_string())?; + let (picture_png_base64, picture_note) = match info.picture.as_deref() { + None => (None, None), + Some(bytes) => match crate::profile::normalize_avatar(bytes) { + Ok(png) => { + use base64::Engine; + (Some(base64::engine::general_purpose::STANDARD.encode(png)), None) + } + Err(e) => (None, Some(format!("picture refused: {e}"))), + }, + }; + Ok(UserInfoDto { + username, + description: info.description, + picture_png_base64, + picture_note, + total_uploads: info.total_uploads, + queue_size: info.queue_size, + has_free_slot: info.has_free_slot, + }) +} + +#[derive(Serialize)] +pub struct InterestsDto { + pub likes: Vec, + pub hates: Vec, +} + +/// A user's declared Soulseek interests — raw likes and hates, not follows. +#[tauri::command] +pub async fn slsk_user_interests( + state: State<'_, AppState>, + username: String, +) -> Result { + let fut = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.user_interests(&username) + }; + let (likes, hates) = fut.await.map_err(|e| e.to_string())?; + Ok(InterestsDto { likes, hates }) +} + +#[derive(Serialize)] +pub struct SimilarUserDto { + pub username: String, + pub rating: u32, +} + +/// Users the server ranks as similar to us by shared interests. +#[tauri::command] +pub async fn slsk_similar_users(state: State<'_, AppState>) -> Result, String> { + let fut = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.similar_users() + }; + let users = fut.await.map_err(|e| e.to_string())?; + Ok(users + .into_iter() + .map(|(username, rating)| SimilarUserDto { username, rating }) + .collect()) +} + #[tauri::command] pub async fn slsk_browse_user( state: State<'_, AppState>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 66ed24c..912314c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -120,6 +120,9 @@ pub fn run() { commands::slsk_message_user, commands::slsk_room_list, commands::slsk_browse_user, + commands::slsk_user_info, + commands::slsk_user_interests, + commands::slsk_similar_users, commands::buddy_list, commands::buddy_add, commands::buddy_remove, diff --git a/src/lib/api.js b/src/lib/api.js index a3f5c2c..a913626 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -148,6 +148,9 @@ export const slskMessageUser = (username, message) => invoke("slsk_message_user", { username, message }); export const slskRoomList = () => invoke("slsk_room_list"); export const slskBrowseUser = (username) => invoke("slsk_browse_user", { username }); +export const slskUserInfo = (username) => invoke("slsk_user_info", { username }); +export const slskUserInterests = (username) => invoke("slsk_user_interests", { username }); +export const slskSimilarUsers = () => invoke("slsk_similar_users"); export const buddyList = () => invoke("buddy_list"); export const buddyAdd = (username) => invoke("buddy_add", { username }); export const buddyRemove = (username) => invoke("buddy_remove", { username }); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index b7451f5..3d8d2f5 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -52,6 +52,13 @@ export const community = $state({ roomFavs: readJson(LS_FAVS, []), /** Which person the drawer shows, or null. */ drawer: null, + /** username → native Soulseek card ({...UserInfoDto} | {error}) for + * the session; fetched only when a profile is surfaced (the drawer). */ + userInfo: {}, + /** username → {likes, hates} | {error}, same lifetime. */ + interests: {}, + /** The server's similar-users answer, or null before it was asked. */ + similar: null, /** key → true while an action runs (buttons disable on it). */ busy: {}, /** The last action's error, shown where it happened. */ @@ -299,3 +306,37 @@ export async function initCommunity() { await Promise.all([refreshFreenet(), loadContacts()]); await refreshPersona(); } + +// ---- native Soulseek cards --------------------------------------------------------- +/** Fetch a person's native card and interests once per session, both in + * flight together; errors are kept per person so the drawer can say so. */ +export async function loadUserInfo(username) { + if (!username) return; + if (!community.userInfo[username]) { + community.userInfo[username] = { pending: true }; + api.slskUserInfo(username) + .then((v) => (community.userInfo[username] = v)) + .catch((e) => (community.userInfo[username] = { error: String(e) })); + } + if (!community.interests[username]) { + community.interests[username] = { pending: true }; + api.slskUserInterests(username) + .then((v) => (community.interests[username] = v)) + .catch((e) => (community.interests[username] = { error: String(e) })); + } +} +export async function loadSimilar(force = false) { + if (community.similar && !force) return; + community.busy.similar = true; + try { + community.similar = { users: await api.slskSimilarUsers() }; + } catch (e) { + community.similar = { users: [], error: String(e) }; + } finally { + delete community.busy.similar; + } +} + +// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store +// through this, never through a bare import (which would be a second copy). +if (import.meta.env.DEV) globalThis.__community = community; diff --git a/src/views/Community.svelte b/src/views/Community.svelte index 708ddf7..603b33a 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -56,6 +56,8 @@ setContactMeta, toggleRoomFav, personaKey, + loadUserInfo, + loadSimilar, } from "../lib/community.svelte.js"; onMount(() => { @@ -385,6 +387,21 @@ noteDraft = m?.note ?? ""; }); }); + // The native card is fetched only for a surfaced profile (the design's + // rule), once per session per person, and only while signed in. + $effect(() => { + const u = drawerUser; + if (u && online) untrack(() => loadUserInfo(u)); + }); + const drawerInfo = $derived(drawerUser ? community.userInfo[drawerUser] : null); + const drawerInterests = $derived(drawerUser ? community.interests[drawerUser] : null); + const drawerPhoto = $derived( + drawerInfo?.picture_png_base64 ? `data:image/png;base64,${drawerInfo.picture_png_base64}` : null, + ); + // Discover's similar-users shelf asks the server once per session. + $effect(() => { + if (community.section === "discover" && online) untrack(() => loadSimilar()); + }); function saveMeta(patch = {}) { if (!drawerUser) return; void setContactMeta({ @@ -835,6 +852,27 @@
Nobody yet. People who send you files, or take files from you, appear here for the session.
{/if} +
+
+

Similar Soulseek interests

+ the server's match on declared likes · not follows +
+ {#if community.similar?.users?.length} +
+ {#each community.similar.users.slice(0, 24) as su (su.username)} + + {/each} +
+ {:else if community.busy.similar} +
Asking the server…
+ {:else} +
{community.similar?.error ? "The server did not answer." : online ? "The server matches people on the likes you declare on Soulseek — SoulStream has none set for you yet, so this shelf is empty." : "Sign in to Soulseek to ask the server."}
{#if online}{/if}
+ {/if} +

Library overlap

@@ -1087,7 +1125,7 @@