diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8e151cd..0cb3296 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1182,6 +1182,7 @@ pub async fn slsk_similar_users(state: State<'_, AppState>) -> Result, username: String, ) -> Result, String> { @@ -1191,6 +1192,9 @@ pub async fn slsk_browse_user( client.browse(&username) }; let dirs = fut.await.map_err(|e| e.to_string())?; + // The library overlap rides along: their folders against your albums, + // on this device, and the Discover shelf hears about it by event. + crate::overlap::note_browse(&app, &username, &dirs); Ok(dirs .into_iter() .map(|d| { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 09c35da..9aba379 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,6 +28,7 @@ mod slsk_link; mod smtc; mod spaces; mod proposals; +mod overlap; mod state; mod thumbbar; mod vocal_sep; @@ -163,6 +164,9 @@ pub fn run() { proposals::space_board, proposals::space_propose, proposals::space_decide, + overlap::overlap_list, + overlap::overlap_compute, + overlap::overlap_forget, commands::buddy_list, commands::buddy_add, commands::buddy_remove, diff --git a/src-tauri/src/overlap.rs b/src-tauri/src/overlap.rs new file mode 100644 index 0000000..aa143d4 --- /dev/null +++ b/src-tauri/src/overlap.rs @@ -0,0 +1,355 @@ +//! Library overlap. After a browse, THEIR share tree is compared with YOUR +//! library on this device — their folder names against your albums and +//! artists — and nothing leaves the machine to do it. The hub's browse +//! computes it as a side effect; "Compare with my library" browses on +//! purpose. Results are kept per username in `/overlap.json`, so +//! the Discover shelf is a shelf and not a fresh walk of everyone's shares. +//! +//! What is compared is names, nothing more: a folder "Artist\Album (2019) +//! [FLAC]" becomes the pair (artist, album) after the brackets go, and it +//! counts as yours when your library has that album under that artist. +//! Loose on purpose (punctuation, case, "The", a year) and honest about +//! it — the shelf says "albums you own", not "identical files". + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use soulseek_proto::types::Directory; +use tauri::{Emitter, Manager, State}; + +use crate::pipeline::LibraryState; +use crate::state::AppState; + +pub const EVENT: &str = "community:overlap"; +/// How many named pairs a record keeps per list; the counts are exact. +const KEEP: usize = 120; +const AUDIO: &[&str] = &[ + "mp3", "flac", "ogg", "oga", "opus", "m4a", "aac", "alac", "wav", "wma", "aiff", "aif", "ape", "wv", "mpc", "dsf", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Pair { + pub artist: String, + pub album: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Overlap { + pub username: String, + pub computed_at: u64, + /// Folders holding at least one audio file. + pub folders: u32, + pub audio_files: u32, + /// Distinct (artist, album) folders on their side. + pub their_albums: u32, + pub albums_in_common: Vec, + pub albums_in_common_count: u32, + pub artists_in_common: Vec, + pub artists_in_common_count: u32, + /// Albums they have by artists you have, that you lack. + pub gaps: Vec, + pub gaps_count: u32, + /// The share of their albums you also have, 0..1. + pub score: f32, +} + +#[derive(Default, Serialize, Deserialize)] +struct Ledger { + #[serde(default)] + people: BTreeMap, +} + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} +fn path(app: &tauri::AppHandle) -> Result { + Ok(app.path().app_data_dir().map_err(|e| e.to_string())?.join("overlap.json")) +} +fn load(app: &tauri::AppHandle) -> Ledger { + path(app) + .ok() + .and_then(|p| std::fs::read(p).ok()) + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} +fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> { + let p = path(app)?; + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(&p, serde_json::to_vec_pretty(l).map_err(|e| e.to_string())?).map_err(|e| e.to_string()) +} +fn key(username: &str) -> String { + username.trim().to_lowercase() +} + +// ---------------------------------------------------------------- names + +/// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — then only letters and +/// digits remain, lowercased; a leading "the" goes too. "Sigur Rós" and +/// "sigur ros" are not the same string here, and that is a known miss. +fn norm(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut depth = 0i32; + for c in s.chars() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth = (depth - 1).max(0), + _ if depth == 0 && c.is_alphanumeric() => out.extend(c.to_lowercase()), + _ => {} + } + } + if out.len() > 4 && out.starts_with("the") { + out = out[3..].to_string(); + } + // "1996 - Expert Knob Twiddlers": a leading year is filing, not a name — + // but only when a name follows it ("1975" stays "1975"). + let year_led = (out.starts_with("19") || out.starts_with("20")) + && out.chars().take(4).all(|c| c.is_ascii_digit()) + && out.chars().nth(4).is_some(); + if year_led { + out = out.chars().skip(4).collect(); + } + out +} + +fn is_disc_folder(s: &str) -> bool { + let n: String = s.to_lowercase().chars().filter(|c| c.is_alphanumeric()).collect(); + for p in ["cd", "disc", "disk", "vol", "volume", "side"] { + if let Some(rest) = n.strip_prefix(p) { + if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) { + return true; + } + } + } + false +} + +fn is_audio(f: &soulseek_proto::types::File) -> bool { + let ext = if f.extension.is_empty() { + f.filename.rsplit('.').next().unwrap_or_default() + } else { + f.extension.as_str() + }; + let ext = ext.to_lowercase(); + AUDIO.contains(&ext.as_str()) +} + +/// The (artist, album) a share folder names, with display spellings. +fn pair_of(dir: &str) -> Option<(String, String)> { + let segs: Vec<&str> = dir + .split(['\\', '/']) + .map(str::trim) + .filter(|s| !s.is_empty() && !s.starts_with("@@")) + .collect(); + if segs.is_empty() { + return None; + } + let mut i = segs.len() - 1; + if is_disc_folder(segs[i]) && i > 0 { + i -= 1; + } + let leaf = segs[i]; + let parent = if i > 0 { Some(segs[i - 1]) } else { None }; + // "Artist - Album" in one folder beats a generic parent ("Music", "FLAC"). + if let Some((a, b)) = leaf.split_once(" - ") { + let generic = parent.map(|p| { + let n = norm(p); + ["music", "flac", "mp3", "albums", "shared", "downloads", "lossless", "audio", "complete"].contains(&n.as_str()) || n.is_empty() + }); + if generic.unwrap_or(true) && !a.trim().is_empty() && !b.trim().is_empty() { + return Some((a.trim().to_string(), b.trim().to_string())); + } + } + let parent = parent?; + // "Aphex Twin\Aphex Twin - 1995 - ...I Care Because You Do": the leaf + // repeats the artist; the album is what follows. + if let Some((a, b)) = leaf.split_once(" - ") { + if norm(a) == norm(parent) && !b.trim().is_empty() { + return Some((parent.to_string(), b.trim().to_string())); + } + } + Some((parent.to_string(), leaf.to_string())) +} + +// ---------------------------------------------------------------- compute + +/// Compare a fetched share tree with the library. Synchronous: call it off +/// the async runtime (it reads the library DB). +pub fn compute_from_dirs(app: &tauri::AppHandle, username: &str, dirs: &[Directory]) -> Result { + let lib = app.state::(); + let albums = lib.library.list_albums().map_err(|e| e.to_string())?; + // norm artist → (display artist, { norm album → display album }) + let mut mine: BTreeMap)> = BTreeMap::new(); + for a in &albums { + let na = norm(&a.artist); + let nt = norm(&a.title); + if na.is_empty() || nt.is_empty() { + continue; + } + let e = mine.entry(na).or_insert_with(|| (a.artist.clone(), BTreeMap::new())); + e.1.entry(nt).or_insert_with(|| a.title.clone()); + } + + let mut folders = 0u32; + let mut audio_files = 0u32; + let mut theirs: BTreeMap<(String, String), (String, String)> = BTreeMap::new(); + for d in dirs { + let n = d.files.iter().filter(|f| is_audio(f)).count() as u32; + if n == 0 { + continue; + } + folders += 1; + audio_files += n; + if let Some((artist, album)) = pair_of(&d.name) { + let (na, nb) = (norm(&artist), norm(&album)); + if na.is_empty() || nb.is_empty() { + continue; + } + theirs.entry((na, nb)).or_insert((artist, album)); + } + } + + let mut common: Vec = Vec::new(); + let mut gaps: Vec = Vec::new(); + let mut artists: BTreeSet = BTreeSet::new(); + for ((na, nb), (artist, album)) in &theirs { + let Some((my_artist, my_albums)) = mine.get(na) else { continue }; + artists.insert(my_artist.clone()); + let hit = my_albums.contains_key(nb) + || (nb.len() >= 5 && my_albums.keys().any(|k| k.len() >= 5 && (k.contains(nb.as_str()) || nb.contains(k.as_str())))); + if hit { + common.push(Pair { artist: artist.clone(), album: album.clone() }); + } else { + gaps.push(Pair { artist: artist.clone(), album: album.clone() }); + } + } + let their_albums = theirs.len() as u32; + let score = if their_albums > 0 { common.len() as f32 / their_albums as f32 } else { 0.0 }; + let counts = (common.len() as u32, gaps.len() as u32, artists.len() as u32); + common.truncate(KEEP); + gaps.truncate(KEEP); + let mut artists: Vec = artists.into_iter().collect(); + artists.truncate(KEEP); + let o = Overlap { + username: username.trim().to_string(), + computed_at: now_secs(), + folders, + audio_files, + their_albums, + albums_in_common: common, + albums_in_common_count: counts.0, + artists_in_common: artists, + artists_in_common_count: counts.2, + gaps, + gaps_count: counts.1, + score, + }; + let mut l = load(app); + l.people.insert(key(username), o.clone()); + save(app, &l)?; + let _ = app.emit(EVENT, ()); + log::info!( + "overlap: {} — {} folders, {} albums, {} in common, {} gaps, {} artists", + o.username, + o.folders, + o.their_albums, + o.albums_in_common_count, + o.gaps_count, + o.artists_in_common_count + ); + Ok(o) +} + +/// The hub's browse hands its tree here: the comparison rides along in the +/// background and the shelf updates by event. +pub fn note_browse(app: &tauri::AppHandle, username: &str, dirs: &[Directory]) { + let app = app.clone(); + let username = username.to_string(); + let dirs = dirs.to_vec(); + tauri::async_runtime::spawn_blocking(move || { + if let Err(e) = compute_from_dirs(&app, &username, &dirs) { + log::debug!("overlap: {username}: {e}"); + } + }); +} + +/// Browse on purpose, then compare. +pub async fn compute(app: &tauri::AppHandle, username: &str) -> Result { + let fut = { + let state = app.state::(); + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.browse(username) + }; + let dirs = fut.await.map_err(|e| e.to_string())?; + let app2 = app.clone(); + let u = username.to_string(); + tauri::async_runtime::spawn_blocking(move || compute_from_dirs(&app2, &u, &dirs)) + .await + .map_err(|e| e.to_string())? +} + +pub fn list(app: &tauri::AppHandle) -> Vec { + let mut v: Vec = load(app).people.into_values().collect(); + v.sort_by(|a, b| { + b.albums_in_common_count + .cmp(&a.albums_in_common_count) + .then(b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)) + .then(b.computed_at.cmp(&a.computed_at)) + }); + v +} + +pub fn forget(app: &tauri::AppHandle, username: &str) -> Result<(), String> { + let mut l = load(app); + l.people.remove(&key(username)); + save(app, &l)?; + let _ = app.emit(EVENT, ()); + Ok(()) +} + +// ---------------------------------------------------------------- commands + +#[tauri::command] +pub fn overlap_list(app: tauri::AppHandle) -> Vec { + list(&app) +} +#[tauri::command] +pub async fn overlap_compute(app: tauri::AppHandle, _state: State<'_, AppState>, username: String) -> Result { + compute(&app, &username).await +} +#[tauri::command] +pub fn overlap_forget(app: tauri::AppHandle, username: String) -> Result<(), String> { + forget(&app, &username) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folder_names_become_pairs() { + assert_eq!(pair_of(r"@@abcd\Music\FLAC\Boards of Canada\Geogaddi (2002) [FLAC]"), Some(("Boards of Canada".into(), "Geogaddi (2002) [FLAC]".into()))); + assert_eq!(pair_of(r"C:\Music\Radiohead - Kid A"), Some(("Radiohead".into(), "Kid A".into()))); + assert_eq!(pair_of(r"Music\Aphex Twin\Drukqs\CD2"), Some(("Aphex Twin".into(), "Drukqs".into()))); + assert_eq!(pair_of("lone"), None); + assert_eq!(pair_of(r"Music\Aphex Twin\Aphex Twin - 1995 - ...I Care Because You Do"), Some(("Aphex Twin".into(), "1995 - ...I Care Because You Do".into()))); + } + + #[test] + fn names_normalize_loosely() { + assert_eq!(norm("Geogaddi (2002) [FLAC]"), "geogaddi"); + assert_eq!(norm("The Cure"), "cure"); + assert_eq!(norm("Kid A"), "kida"); + assert_eq!(norm("1996 - Expert Knob Twiddlers"), "expertknobtwiddlers"); + assert_eq!(norm("The 1975"), "1975"); + assert_eq!(norm("常に (2019)"), "常に"); + assert_eq!(norm("2019常に"), "常に"); + assert_eq!(norm("19x"), "19x"); + assert_eq!(norm("2000 - Spirit They're Gone Spirit They've Vanished"), "spirittheyregonespirittheyvevanished"); + assert!(is_disc_folder("Disc 2") && is_disc_folder("cd1") && !is_disc_folder("Discovery")); + } +} diff --git a/src/lib/api.js b/src/lib/api.js index e6c91c6..b3279d6 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -185,6 +185,9 @@ export const spaceLeave = (owner, space) => invoke("space_leave", { owner, space export const spaceBoard = (owner, space) => invoke("space_board", { owner, space }); export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note }); export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason }); +export const overlapList = () => invoke("overlap_list"); +export const overlapCompute = (username) => invoke("overlap_compute", { username }); +export const overlapForget = (username) => invoke("overlap_forget", { username }); 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 d23d87a..238e047 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -83,6 +83,8 @@ export const community = $state({ /** space_board: the proposal board beside the open Space's pinned playlist, or null. */ board: null, boardError: "", + /** overlap_list: library comparisons computed on this device, best first. */ + overlap: [], /** persona key → ProfileView | {none} | {error} | {pending}: other * people's signed profiles, read once per session when surfaced. */ remoteProfiles: {}, @@ -335,11 +337,12 @@ export async function initCommunity() { void refreshShares(); void refreshPublished(); }); + api.onEvent("community:overlap", () => void refreshOverlap()); api.onEvent("soulseek:user-state", (p) => { if (p.status === 1 || p.status === 2) noteSeen(p.username); }); } - await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox(), refreshSpaces()]); + await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox(), refreshSpaces(), refreshOverlap()]); await refreshPersona(); } @@ -581,6 +584,30 @@ export const leaveSpace = (owner, space) => await refreshSpaces(); }); +// ---- library overlap ------------------------------------------------------------ +export async function refreshOverlap() { + try { + community.overlap = await api.overlapList(); + } catch { + // stays as it was + } +} +export const overlapFor = (username) => { + const k = (username ?? "").trim().toLowerCase(); + return community.overlap.find((o) => o.username.toLowerCase() === k) ?? null; +}; +/** Browse them on purpose and compare — nothing is uploaded to do it. */ +export const computeOverlap = (username) => + run("overlap:" + username, async () => { + await api.overlapCompute(username); + await refreshOverlap(); + }); +export const forgetOverlap = (username) => + run("overlap:" + username, async () => { + await api.overlapForget(username); + await refreshOverlap(); + }); + // ---- the proposal board --------------------------------------------------------- /** Read the board beside the Space's pinned playlist; the playlist's owner also * brings its roster in line with the members here. */ @@ -621,4 +648,4 @@ export const decideProposal = (owner, space, proposal, accept, reason) => // 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; -if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, openBoard, proposeToBoard, decideProposal }; +if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap }; diff --git a/src/views/Community.svelte b/src/views/Community.svelte index 4c33f9b..0d53f34 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -91,6 +91,9 @@ openBoard, proposeToBoard, decideProposal, + overlapFor, + computeOverlap, + forgetOverlap, } from "../lib/community.svelte.js"; onMount(() => { @@ -583,6 +586,7 @@ const decideOn = (p, accept) => decideProposal(sv.owner, sv.space_id, p.id, accept, boardReason[p.id] || (!accept && p.stale ? "stale base — re-submit against the current revision" : "")).catch(() => {}); const whoProposed = (p) => (p.is_me ? "you" : p.username ? display(p.username) : `SoulStream ID ${fpShort(p.author)}`); + const drawerOverlap = $derived(drawerUser ? overlapFor(drawerUser) : null); async function importPinned() { if (!sv?.pinned_uuid) return; const id = await shareImport({ id: "pin:" + sv.pinned_uuid, owner_key: sv.pinned_owner, playlist_uuid: sv.pinned_uuid, name: pinnedPreview?.name ?? "Pinned playlist" }); @@ -1135,11 +1139,29 @@

Library overlap

computed on this device, after an explicit browse -
- -
Nothing computed yet. Browse a person's shares and SoulStream compares them with your library locally — nothing is uploaded to do it. The comparison itself is the next piece of this shelf.
- -
+ {#if community.overlap.length} +
+ {#each community.overlap.slice(0, 24) as o (o.username)} + + {/each} +
+

Names compared, not files: a folder counts as yours when your library has that album under that artist. Browse someone again to refresh their line.

+ {:else} +
+ +
Nothing computed yet. Browse a person's shares and SoulStream compares them with your library locally — nothing is uploaded to do it.
+ +
+ {/if}
There is no global directory here and no "people you may know". Every shelf says where its people came from. Freenet @@ -1667,6 +1689,28 @@
+
+

Library overlap this device

+ {#if drawerOverlap} +

+ {#if drawerOverlap.their_albums}{drawerOverlap.albums_in_common_count} of their {fmtCount(drawerOverlap.their_albums)} albums are in your library · {drawerOverlap.artists_in_common_count} {drawerOverlap.artists_in_common_count === 1 ? "artist" : "artists"} in common · {drawerOverlap.gaps_count} you lack{:else}No album folders among {fmtCount(drawerOverlap.audio_files)} audio files.{/if} + · compared {fmtWhen(drawerOverlap.computed_at * 1000)} +

+ {#if drawerOverlap.albums_in_common.length} +
In common{#each drawerOverlap.albums_in_common.slice(0, 8) as a, i (i)}{a.album} · {a.artist}{/each}{#if drawerOverlap.albums_in_common_count > 8}+{drawerOverlap.albums_in_common_count - 8} more{/if}
+ {/if} + {#if drawerOverlap.gaps.length} +
You lack{#each drawerOverlap.gaps.slice(0, 8) as a, i (i)}{a.album} · {a.artist}{/each}{#if drawerOverlap.gaps_count > 8}+{drawerOverlap.gaps_count - 8} more{/if}
+ {/if} +
+ + +
+ {:else} +

Not compared yet. Their share folders against your albums, on this device — nothing is uploaded to do it.

+
+ {/if} +

Native Soulseek profile Soulseek

@@ -2045,6 +2089,13 @@ .sphead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; } .sphead .sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; max-width: none; } .src.space { background: color-mix(in srgb, var(--space) 14%, transparent); color: #d9a6ff; } + /* library overlap in the drawer */ + .ovl { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; align-items: center; } + .ovl .lbl { font-size: 10px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin-right: 4px; } + .ovl .ov { font-size: 11px; padding: 3px 8px; border-radius: 99px; background: var(--surf-hi); color: var(--text-secondary); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ovl .ov b { color: var(--text-primary); font-weight: 700; } + .ovl .ov.gap { background: color-mix(in srgb, var(--warn) 10%, transparent); } + .ovl .ov.more { background: none; color: var(--text-muted); } /* the proposal board */ .bbtns { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; } .dlist { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }