411 lines
15 KiB
Rust
411 lines
15 KiB
Rust
//! 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");
|
||
}
|
||
}
|