community: the library overlap shelf, their share folders against your albums on this device after a browse
This commit is contained in:
1 parent
d7bc92fda7
commit
0846b39d45
6 files changed
+451
-7
No files matched your search
@@ -1182,6 +1182,7 @@ pub async fn slsk_similar_users(state: State<'_, AppState>) -> Result<Vec<Simila
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn slsk_browse_user(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
username: String,
|
||||
) -> Result<Vec<BrowseDirDto>, 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| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `<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(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<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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user