Files
SoulStream/src-tauri/src/overlap.rs
T

411 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 `<app_data>/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<Pair>,
pub albums_in_common_count: u32,
pub artists_in_common: Vec<String>,
pub artists_in_common_count: u32,
/// Albums they have by artists you have, that you lack.
pub gaps: Vec<Pair>,
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<String, Overlap>,
}
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
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<Overlap, String> {
let lib = app.state::<LibraryState>();
let albums = lib.library.list_albums().map_err(|e| e.to_string())?;
// norm artist → (display artist, { norm album → display album })
let mut mine: BTreeMap<String, (String, BTreeMap<String, String>)> = 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<Pair> = Vec::new();
let mut gaps: Vec<Pair> = Vec::new();
let mut artists: BTreeSet<String> = 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<String> = 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<Overlap, String> {
let fut = {
let state = app.state::<AppState>();
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<Overlap> {
let mut v: Vec<Overlap> = 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<Overlap> {
list(&app)
}
#[tauri::command]
pub async fn overlap_compute(app: tauri::AppHandle, _state: State<'_, AppState>, username: String) -> Result<Overlap, String> {
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");
}
}