community: the library overlap shelf, their share folders against your albums on this device after a browse

This commit is contained in:
KeelrLeek committed 2026-09-02 17:08:25 -05:00
1 parent d7bc92fda7
commit 0846b39d45
6 files changed
+451 -7

No files matched your search

+4
View File
@@ -1182,6 +1182,7 @@ pub async fn slsk_similar_users(state: State<'_, AppState>) -> Result<Vec<Simila
#[tauri::command] #[tauri::command]
pub async fn slsk_browse_user( pub async fn slsk_browse_user(
app: tauri::AppHandle,
state: State<'_, AppState>, state: State<'_, AppState>,
username: String, username: String,
) -> Result<Vec<BrowseDirDto>, String> { ) -> Result<Vec<BrowseDirDto>, String> {
@@ -1191,6 +1192,9 @@ pub async fn slsk_browse_user(
client.browse(&username) client.browse(&username)
}; };
let dirs = fut.await.map_err(|e| e.to_string())?; 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 Ok(dirs
.into_iter() .into_iter()
.map(|d| { .map(|d| {
+4
View File
@@ -28,6 +28,7 @@ mod slsk_link;
mod smtc; mod smtc;
mod spaces; mod spaces;
mod proposals; mod proposals;
mod overlap;
mod state; mod state;
mod thumbbar; mod thumbbar;
mod vocal_sep; mod vocal_sep;
@@ -163,6 +164,9 @@ pub fn run() {
proposals::space_board, proposals::space_board,
proposals::space_propose, proposals::space_propose,
proposals::space_decide, proposals::space_decide,
overlap::overlap_list,
overlap::overlap_compute,
overlap::overlap_forget,
commands::buddy_list, commands::buddy_list,
commands::buddy_add, commands::buddy_add,
commands::buddy_remove, commands::buddy_remove,
+355
View File
@@ -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"));
}
}
+3
View File
@@ -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 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 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 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 buddyList = () => invoke("buddy_list");
export const buddyAdd = (username) => invoke("buddy_add", { username }); export const buddyAdd = (username) => invoke("buddy_add", { username });
export const buddyRemove = (username) => invoke("buddy_remove", { username }); export const buddyRemove = (username) => invoke("buddy_remove", { username });
+29 -2
View File
@@ -83,6 +83,8 @@ export const community = $state({
/** space_board: the proposal board beside the open Space's pinned playlist, or null. */ /** space_board: the proposal board beside the open Space's pinned playlist, or null. */
board: null, board: null,
boardError: "", boardError: "",
/** overlap_list: library comparisons computed on this device, best first. */
overlap: [],
/** persona key → ProfileView | {none} | {error} | {pending}: other /** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */ * people's signed profiles, read once per session when surfaced. */
remoteProfiles: {}, remoteProfiles: {},
@@ -335,11 +337,12 @@ export async function initCommunity() {
void refreshShares(); void refreshShares();
void refreshPublished(); void refreshPublished();
}); });
api.onEvent("community:overlap", () => void refreshOverlap());
api.onEvent("soulseek:user-state", (p) => { api.onEvent("soulseek:user-state", (p) => {
if (p.status === 1 || p.status === 2) noteSeen(p.username); 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(); await refreshPersona();
} }
@@ -581,6 +584,30 @@ export const leaveSpace = (owner, space) =>
await refreshSpaces(); 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 --------------------------------------------------------- // ---- the proposal board ---------------------------------------------------------
/** Read the board beside the Space's pinned playlist; the playlist's owner also /** Read the board beside the Space's pinned playlist; the playlist's owner also
* brings its roster in line with the members here. */ * 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 // Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
// through this, never through a bare import (which would be a second copy). // 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.__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 };
+56 -5
View File
@@ -91,6 +91,9 @@
openBoard, openBoard,
proposeToBoard, proposeToBoard,
decideProposal, decideProposal,
overlapFor,
computeOverlap,
forgetOverlap,
} from "../lib/community.svelte.js"; } from "../lib/community.svelte.js";
onMount(() => { onMount(() => {
@@ -583,6 +586,7 @@
const decideOn = (p, accept) => 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(() => {}); 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 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() { async function importPinned() {
if (!sv?.pinned_uuid) return; 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" }); 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 @@
<h2 class="ax-title">Library overlap</h2> <h2 class="ax-title">Library overlap</h2>
<span class="prov"><Icon name="library" size={12} /> computed on this device, after an explicit browse</span> <span class="prov"><Icon name="library" size={12} /> computed on this device, after an explicit browse</span>
</div> </div>
<div class="emptyshelf"> {#if community.overlap.length}
<Icon name="folder" size={22} /> <div class="track">
<div><b>Nothing computed yet.</b> 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.</div> {#each community.overlap.slice(0, 24) as o (o.username)}
<button class="ax-ghost sm" onclick={() => setSection("people")}>Browse someone</button> <button class="pcard" onclick={() => (community.drawer = o.username)}>
</div> <span class="face" style:background="hsl({hueOf(o.username)} 30% 14%)"><Identicon seed={o.username} size={84} presence={presenceOf(o.username)} /></span>
<span class="nm">{display(o.username)}</span>
<span class="why">
{#if o.albums_in_common_count}shares <b>{o.albums_in_common_count}</b> {o.albums_in_common_count === 1 ? "album" : "albums"} you own {#if o.gaps_count}· <b>{o.gaps_count}</b> you lack{/if}
{:else if o.artists_in_common_count}<b>{o.artists_in_common_count}</b> {o.artists_in_common_count === 1 ? "artist" : "artists"} in common · <b>{o.gaps_count}</b> albums you lack
{:else if o.their_albums}nothing in common · {fmtCount(o.their_albums)} albums browsed
{:else}no album folders found{/if}
</span>
</button>
{/each}
</div>
<p class="hint pad">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.</p>
{:else}
<div class="emptyshelf">
<Icon name="folder" size={22} />
<div><b>Nothing computed yet.</b> Browse a person's shares and SoulStream compares them with your library locally — nothing is uploaded to do it.</div>
<button class="ax-ghost sm" onclick={() => setSection("people")}>Browse someone</button>
</div>
{/if}
</div> </div>
<div class="hint wide"> <div class="hint wide">
There is no global directory here and no "people you may know". Every shelf says where its people came from. Freenet 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 @@
</div> </div>
<textarea class="note-in" rows="2" placeholder="A private note — only you see it" bind:value={noteDraft} onchange={() => saveMeta()}></textarea> <textarea class="note-in" rows="2" placeholder="A private note — only you see it" bind:value={noteDraft} onchange={() => saveMeta()}></textarea>
<div class="dsec">
<h4>Library overlap <span class="src slsk"><Icon name="library" size={10} />this device</span></h4>
{#if drawerOverlap}
<p class="hint">
{#if drawerOverlap.their_albums}<b>{drawerOverlap.albums_in_common_count}</b> of their {fmtCount(drawerOverlap.their_albums)} albums are in your library · <b>{drawerOverlap.artists_in_common_count}</b> {drawerOverlap.artists_in_common_count === 1 ? "artist" : "artists"} in common · <b>{drawerOverlap.gaps_count}</b> you lack{:else}No album folders among {fmtCount(drawerOverlap.audio_files)} audio files.{/if}
· compared {fmtWhen(drawerOverlap.computed_at * 1000)}
</p>
{#if drawerOverlap.albums_in_common.length}
<div class="ovl"><span class="lbl">In common</span>{#each drawerOverlap.albums_in_common.slice(0, 8) as a, i (i)}<span class="ov"><b>{a.album}</b> · {a.artist}</span>{/each}{#if drawerOverlap.albums_in_common_count > 8}<span class="ov more">+{drawerOverlap.albums_in_common_count - 8} more</span>{/if}</div>
{/if}
{#if drawerOverlap.gaps.length}
<div class="ovl"><span class="lbl">You lack</span>{#each drawerOverlap.gaps.slice(0, 8) as a, i (i)}<span class="ov gap"><b>{a.album}</b> · {a.artist}</span>{/each}{#if drawerOverlap.gaps_count > 8}<span class="ov more">+{drawerOverlap.gaps_count - 8} more</span>{/if}</div>
{/if}
<div class="bbtns">
<button class="ax-ghost sm" onclick={() => computeOverlap(drawerUser)} disabled={!online || !!community.busy["overlap:" + drawerUser]}><Icon name="refresh" size={12} /> {community.busy["overlap:" + drawerUser] ? "Browsing…" : "Compare again"}</button>
<button class="lnk" onclick={() => forgetOverlap(drawerUser)} disabled={!!community.busy["overlap:" + drawerUser]}>forget</button>
</div>
{:else}
<p class="hint">Not compared yet. Their share folders against your albums, on this device — nothing is uploaded to do it.</p>
<div class="bbtns"><button class="ax-ghost sm" onclick={() => computeOverlap(drawerUser)} disabled={!online || !!community.busy["overlap:" + drawerUser]}><Icon name="library" size={12} /> {community.busy["overlap:" + drawerUser] ? "Browsing their shares…" : "Compare with my library"}</button></div>
{/if}
</div>
<div class="dsec"> <div class="dsec">
<h4>Native Soulseek profile <span class="src slsk"><Icon name="globe" size={10} />Soulseek</span></h4> <h4>Native Soulseek profile <span class="src slsk"><Icon name="globe" size={10} />Soulseek</span></h4>
<dl class="kv"> <dl class="kv">
@@ -2045,6 +2089,13 @@
.sphead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; } .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; } .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; } .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 */ /* the proposal board */
.bbtns { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; } .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; } .dlist { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }