//! 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(crate::paths::app_data(app).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}" — and so does a /// dash-led edition tail — "Pinkerton - Deluxe Edition", "Foo - EP" — 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. pub(crate) fn norm(s: &str) -> String { let s = strip_edition_tail(s); 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 } /// The catalogue spells an edition after a spaced dash where a folder would /// bracket it — "Pinkerton - Deluxe Edition" is the gap card, "Pinkerton /// (Deluxe Edition)" the folder — so a dash-led tail that reads as an /// edition goes the way brackets do, and only that tail: "Boards of Canada - /// Geogaddi" is a name with a dash in it and stays whole. Found 2026-09-09 /// when the deluxe card beside a marked Pinkerton carried no "has it". fn strip_edition_tail(s: &str) -> &str { let mut s = s.trim_end(); loop { let Some((at, len)) = [" - ", " – ", " — "] .iter() .filter_map(|d| s.rfind(d).map(|i| (i, d.len()))) .max_by_key(|(i, _)| *i) else { return s; }; let tail: String = s[at + len..] .chars() .filter(|c| c.is_alphanumeric()) .flat_map(|c| c.to_lowercase()) .collect(); if !is_edition_tail(&tail) { return s; } s = s[..at].trim_end(); } } fn is_edition_tail(t: &str) -> bool { matches!(t, "ep" | "single") || ["deluxe", "remaster", "edition", "expanded", "anniversary", "reissue", "bonus", "explicit"] .iter() .any(|w| t.contains(w)) } 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 } pub(crate) 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. pub(crate) 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")); } #[test] fn dash_led_editions_go_the_way_brackets_do() { assert_eq!(norm("Pinkerton - Deluxe Edition"), "pinkerton"); assert_eq!(norm("Pinkerton – Deluxe Edition"), "pinkerton"); assert_eq!(norm("Pinkerton — Deluxe Edition"), "pinkerton"); assert_eq!(norm("Pinkerton - Deluxe Edition (2010)"), "pinkerton"); assert_eq!(norm("Album - Deluxe Edition - Remastered"), "album"); assert_eq!(norm("Kid A - 2019 Remaster"), "kida"); assert_eq!(norm("Foo - EP"), "foo"); assert_eq!(norm("Foo - Single"), "foo"); assert_eq!(norm("Pinkerton (Deluxe Edition) [2010]"), norm("Pinkerton - Deluxe Edition")); // A dash inside a name is not an edition. assert_eq!(norm("Boards of Canada - Geogaddi"), "boardsofcanadageogaddi"); assert_eq!(norm("Album - Live"), "albumlive"); assert_eq!(norm("1996 - Expert Knob Twiddlers"), "expertknobtwiddlers"); } }