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]
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| {
+4
View File
@@ -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,
+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 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 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 buddyAdd = (username) => invoke("buddy_add", { 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. */
board: null,
boardError: "",
/** overlap_list: library comparisons computed on this device, best first. */
overlap: [],
/** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */
remoteProfiles: {},
@@ -335,11 +337,12 @@ export async function initCommunity() {
void refreshShares();
void refreshPublished();
});
api.onEvent("community:overlap", () => void refreshOverlap());
api.onEvent("soulseek:user-state", (p) => {
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();
}
@@ -581,6 +584,30 @@ export const leaveSpace = (owner, space) =>
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 ---------------------------------------------------------
/** Read the board beside the Space's pinned playlist; the playlist's owner also
* 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
// 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.__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,
proposeToBoard,
decideProposal,
overlapFor,
computeOverlap,
forgetOverlap,
} from "../lib/community.svelte.js";
onMount(() => {
@@ -583,6 +586,7 @@
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(() => {});
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() {
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" });
@@ -1135,11 +1139,29 @@
<h2 class="ax-title">Library overlap</h2>
<span class="prov"><Icon name="library" size={12} /> computed on this device, after an explicit browse</span>
</div>
<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. The comparison itself is the next piece of this shelf.</div>
<button class="ax-ghost sm" onclick={() => setSection("people")}>Browse someone</button>
</div>
{#if community.overlap.length}
<div class="track">
{#each community.overlap.slice(0, 24) as o (o.username)}
<button class="pcard" onclick={() => (community.drawer = o.username)}>
<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 class="hint wide">
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>
<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">
<h4>Native Soulseek profile <span class="src slsk"><Icon name="globe" size={10} />Soulseek</span></h4>
<dl class="kv">
@@ -2045,6 +2089,13 @@
.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; }
.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 */
.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; }