sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane

This commit is contained in:
KeelrLeek committed 2026-07-31 18:21:04 -05:00
1 parent 606250c361
commit eba29824c9
12 files changed
+1595 -281

No files matched your search

+173 -4
View File
@@ -1,7 +1,7 @@
//! Music library: SQLite-backed albums and tracks. All methods are
//! synchronous — call from a blocking context.
use rusqlite::Connection;
use rusqlite::{Connection, OptionalExtension};
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
@@ -70,6 +70,37 @@ pub struct PlayStats {
pub top_count: u32,
}
/// One album in the sidebar's recently-played list.
///
/// Two kinds, because most listening here is STREAMED and never touches the
/// local library. A streamed play is anchored on the catalog album id — an
/// exact id, not a name — and carries its own remote cover.
#[derive(Debug, Clone, Serialize)]
pub struct RecentAlbum {
/// "library" or "catalog" — decides which page a click opens.
pub kind: String,
pub album_id: Option<i64>,
pub catalog_album_id: Option<String>,
pub artist: String,
pub title: String,
/// Local cover, a file path (library rows).
pub art_path: Option<String>,
/// Remote cover URL (catalog rows).
pub art_url: Option<String>,
/// Newest play of this album, so the frontend can group by day.
pub last_played_at: i64,
}
/// Catalog identity for a streamed play. `album_id` is the anchor; the rest
/// ride along because a streamed album has no local row to read them back from.
#[derive(Debug, Clone, Copy, Default)]
pub struct CatalogPlay<'a> {
pub album_id: Option<&'a str>,
pub title: Option<&'a str>,
pub artist: Option<&'a str>,
pub cover: Option<&'a str>,
}
pub struct Library {
conn: Mutex<Connection>,
}
@@ -129,10 +160,50 @@ impl Library {
seconds INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_plays_artist ON plays(artist COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_plays_started ON plays(started_at DESC);
",
)?;
// Migrate databases created before artist portraits were added.
let _ = conn.execute("ALTER TABLE albums ADD COLUMN artist_art_path TEXT", []);
// Migrate databases created before plays remembered which album they
// belonged to. Rows written before this stay NULL and are simply absent
// from the recents list — they cannot be recovered by matching on name,
// and guessing an album from a title is exactly what the identity rules
// forbid.
let _ = conn.execute("ALTER TABLE plays ADD COLUMN album_id INTEGER", []);
// Streamed listening never touches the local library, so it needs its
// own anchor: the catalog album id. Exact id, never a name match. The
// title/artist/cover ride along because a streamed album has no local
// row to read them back from.
let _ = conn.execute("ALTER TABLE plays ADD COLUMN catalog_album_id TEXT", []);
let _ = conn.execute("ALTER TABLE plays ADD COLUMN cat_title TEXT", []);
let _ = conn.execute("ALTER TABLE plays ADD COLUMN cat_artist TEXT", []);
let _ = conn.execute("ALTER TABLE plays ADD COLUMN cat_cover TEXT", []);
let _ = conn.execute(
"CREATE INDEX IF NOT EXISTS idx_plays_catalog ON plays(catalog_album_id)",
[],
);
let _ = conn.execute(
"CREATE INDEX IF NOT EXISTS idx_plays_album ON plays(album_id)",
[],
);
// Backfill history that predates the column — but ONLY where the
// (artist, title) pair resolves to exactly ONE album in the library.
// A title that appears on both an album and a compilation is genuinely
// ambiguous, so it is left NULL rather than guessed at: a name is not
// an identity, and a wrong cover is worse than no row. Streamed plays
// have no library track at all and stay NULL forever, which is correct.
let _ = conn.execute(
"UPDATE plays SET album_id = (
SELECT t.album_id FROM tracks t
WHERE t.title = plays.title COLLATE NOCASE
AND t.artist = plays.artist COLLATE NOCASE
GROUP BY t.title
HAVING COUNT(DISTINCT t.album_id) = 1
)
WHERE album_id IS NULL",
[],
);
// Migrate databases created before per-track audio props were stored.
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN sample_rate INTEGER", []);
let _ = conn.execute("ALTER TABLE tracks ADD COLUMN bit_depth INTEGER", []);
@@ -194,15 +265,113 @@ impl Library {
// first accumulates listened time and tops up its seconds while it plays,
// so a skipped track never writes a row and per-play counts stay honest.
pub fn record_play(&self, artist: &str, title: &str, now: i64) -> Result<i64> {
/// `path` is the track's file path, and it is the ONLY thing used to find
/// the album: `tracks.path` is UNIQUE, so this is an exact anchor. Never
/// resolve the album by artist/title — a name match is not an identity.
/// A streamed track with no library row simply records album_id NULL.
#[allow(clippy::too_many_arguments)]
pub fn record_play(
&self,
artist: &str,
title: &str,
path: Option<&str>,
cat: CatalogPlay<'_>,
now: i64,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
let album_id: Option<i64> = match path {
Some(p) => conn
.query_row(
"SELECT album_id FROM tracks WHERE path = ?1",
rusqlite::params![p],
|r| r.get(0),
)
.optional()?,
None => None,
};
conn.execute(
"INSERT INTO plays (artist, title, started_at, seconds) VALUES (?1, ?2, ?3, 0)",
rusqlite::params![artist, title, now],
"INSERT INTO plays
(artist, title, started_at, seconds, album_id,
catalog_album_id, cat_title, cat_artist, cat_cover)
VALUES (?1, ?2, ?3, 0, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![
artist,
title,
now,
album_id,
cat.album_id,
cat.title,
cat.artist,
cat.cover
],
)?;
Ok(conn.last_insert_rowid())
}
/// The sidebar's recently-played list: one row per album, most recent first.
///
/// `min_secs` is not optional in spirit. A play row is opened after ~1s of
/// audio, so every skip writes one; without a floor this is a skip log, not
/// a history. Measured on the live table: 183 grouped runs, 135 of which
/// survive a 45-second floor.
/// Count of play rows, so a destructive call can report what it will take.
pub fn play_row_count(&self) -> Result<i64> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT COUNT(*) FROM plays", [], |r| r.get(0))?)
}
/// Wipe all listening history. Returns the number of rows removed.
/// Only `plays` — albums, tracks and playlists are untouched.
pub fn clear_play_history(&self) -> Result<usize> {
let conn = self.conn.lock().unwrap();
Ok(conn.execute("DELETE FROM plays", [])?)
}
pub fn recent_albums(&self, limit: u32, min_secs: u32) -> Result<Vec<RecentAlbum>> {
let conn = self.conn.lock().unwrap();
// Two sources, one list. The catalog arm excludes rows that already
// have a library album so a local track with catalog identity cannot
// appear twice. Within one catalog_album_id the title/artist/cover are
// identical on every row, so MAX() is just "pick one".
let mut stmt = conn.prepare(
"SELECT * FROM (
SELECT 'library' AS kind, a.id AS lib_id, NULL AS cat_id,
a.artist AS artist, a.title AS title,
a.art_path AS art_path, NULL AS art_url,
MAX(p.started_at) AS last_at
FROM plays p
JOIN albums a ON a.id = p.album_id
WHERE p.album_id IS NOT NULL AND p.seconds >= ?2
GROUP BY a.id
UNION ALL
SELECT 'catalog', NULL, p.catalog_album_id,
MAX(p.cat_artist), MAX(p.cat_title),
NULL, MAX(p.cat_cover),
MAX(p.started_at)
FROM plays p
WHERE p.catalog_album_id IS NOT NULL
AND p.album_id IS NULL
AND p.seconds >= ?2
GROUP BY p.catalog_album_id
)
ORDER BY last_at DESC
LIMIT ?1",
)?;
let rows = stmt.query_map(rusqlite::params![limit, min_secs], |r| {
Ok(RecentAlbum {
kind: r.get(0)?,
album_id: r.get(1)?,
catalog_album_id: r.get(2)?,
artist: r.get::<_, Option<String>>(3)?.unwrap_or_default(),
title: r.get::<_, Option<String>>(4)?.unwrap_or_default(),
art_path: r.get(5)?,
art_url: r.get(6)?,
last_played_at: r.get(7)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
}
pub fn add_play_seconds(&self, play_id: i64, secs: u32) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
+171
View File
@@ -0,0 +1,171 @@
//! Cold-start bug audit for the artist-page pipeline. Companion to
//! `artist_page_bench` (latency) — this one checks INVARIANTS across a spread
//! of artist shapes and prints a statistics row per artist:
//!
//! - overview / page / identity wall-clock, run the way the frontend runs
//! them (all three overlapped, one fresh client per artist, no disk cache)
//! - album and popular-row counts at each stage
//! - listeners coverage (the Last.fm anchor working end-to-end)
//! - release-date coverage (the MusicBrainz dates pass reaching the page)
//! - BUG lines for anything that violates an invariant
//!
//! Artists are audited SEQUENTIALLY — each fresh client has its own
//! MusicBrainz pacer, and overlapping artists would multiply request rate
//! against the 1 req/s policy (and contaminate the numbers, see the
//! paced-probe rule).
//!
//! Usage:
//! cargo run -p metadata --release --example cold_audit -- ["Artist" ...]
//! (no args = the built-in spread)
use metadata::MetadataClient;
use std::sync::Arc;
use std::time::Instant;
#[tokio::main]
async fn main() {
let mut artists: Vec<String> = std::env::args().skip(1).collect();
if artists.is_empty() {
artists = [
"Alvvays", // small, clean MB identity
"Weezer", // medium, deep catalog
"Queen", // huge, 1119 MB pressings
"Death Grips", // mixtape/street edge
"King Gizzard & The Lizard Wizard", // Last.fm mbid-index miss + '&'
"Boards of Canada", // electronic, sparse Deezer
"Duster", // namesake-collision shape
]
.into_iter()
.map(String::from)
.collect();
}
let mut bugs: Vec<String> = Vec::new();
println!(
"{:<36} {:>8} {:>8} {:>8} {:>6} {:>6} {:>7} {:>9} {:>8}",
"artist", "overview", "page", "identity", "ov-alb", "pg-alb", "popular", "listeners", "dates"
);
for artist in &artists {
let client = Arc::new(MetadataClient::new());
let search = client.catalog_search(artist).await;
let Some(found) = search
.artists
.iter()
.find(|a| normalize(&a.name) == normalize(artist))
else {
bugs.push(format!("{artist}: no exact-name artist in catalog search"));
continue;
};
let id = found.id;
// The frontend shape: overview, page and the identity warm-up all in
// flight together on one client.
let (overview, page, identity) = tokio::join!(
async {
let t = Instant::now();
let overview = client.catalog_artist_overview(id).await;
(t.elapsed().as_millis(), overview)
},
async {
let t = Instant::now();
let page = client.catalog_artist(id).await;
(t.elapsed().as_millis(), page)
},
async {
let t = Instant::now();
let identity = client.catalog_artist_identity(id, artist).await;
(t.elapsed().as_millis(), identity)
},
);
let (ov_ms, overview) = overview;
let (pg_ms, page) = page;
let (id_ms, identity) = identity;
let mut flag = |what: &str| bugs.push(format!("{artist}: {what}"));
let (ov_albums, _ov_popular) = match &overview {
Some(ov) => {
if ov.artist.id != id {
flag("overview echoed a different artist id");
}
if ov.artist.name.is_empty() {
flag("overview artist name empty");
}
if ov.albums.is_empty() {
flag("overview albums empty");
}
if ov.popular_tracks.is_empty() {
flag("overview popular empty");
}
(ov.albums.len(), ov.popular_tracks.len())
}
None => {
flag("overview returned None");
(0, 0)
}
};
let (pg_albums, pg_popular, with_listeners, with_dates) = match &page {
Some(pg) => {
if pg.artist.id != id {
flag("page echoed a different artist id");
}
if pg.albums.is_empty() {
flag("page albums empty");
}
if pg.popular_tracks.is_empty() {
flag("page popular empty");
}
let listeners = pg
.popular_tracks
.iter()
.filter(|t| t.listeners.is_some())
.count();
let dates = pg
.albums
.iter()
.filter(|a| a.release_date.is_some())
.count();
// The page waits for real supplements; zero listener counts on
// a popular row set means the Last.fm anchor died end-to-end.
if !pg.popular_tracks.is_empty() && listeners == 0 {
flag("page popular has zero Last.fm listener counts");
}
(pg.albums.len(), pg.popular_tracks.len(), listeners, dates)
}
None => {
flag("page returned None");
(0, 0, 0, 0)
}
};
if identity.artist_id != id {
flag("identity echoed a different artist id");
}
println!(
"{:<36} {:>6}ms {:>6}ms {:>6}ms {:>6} {:>6} {:>7} {:>6}/{:<2} {:>4}/{:<3}",
artist, ov_ms, pg_ms, id_ms, ov_albums, pg_albums, pg_popular,
with_listeners, pg_popular, with_dates, pg_albums
);
}
println!();
if bugs.is_empty() {
println!("NO INVARIANT FAILURES");
} else {
println!("FAILURES ({}):", bugs.len());
for bug in &bugs {
println!(" BUG {bug}");
}
}
}
fn normalize(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
+294 -61
View File
@@ -361,6 +361,15 @@ const MB_RELEASE_GROUP_PAGES: usize = 3;
/// The failures this exists for are transient by nature — a 503 out of the
/// 1 req/s limiter, or this machine having throttled itself with probes — and
/// the cost of giving up is the whole date + attribution pass for that artist.
/// How long the overview holds first paint for the supplements chain (linked
/// MBID → Last.fm ranks + original release dates). Warm the chain answers in
/// single-digit milliseconds and the budget never bites; cold it is 2.2s+ of
/// MusicBrainz behind the 1 req/s gate, and the overview paints without it
/// rather than becoming the slow page it exists to preempt. Wide enough for
/// a disk-cache read on a busy runtime, an order of magnitude under a cold
/// network chain.
const OVERVIEW_SUPPLEMENTS_BUDGET: Duration = Duration::from_millis(300);
/// Two retries behind a widening backoff is enough to ride out a blip without
/// turning a genuine outage into a slow page: the request rides the same
/// 1 req/s gate as everything else, and the identity pass is deferred, so
@@ -1110,6 +1119,13 @@ pub struct MetadataClient {
/// The fast artwork response and full page intentionally overlap, so this
/// prevents their common profile/provider lookups from being duplicated.
catalog_inflight: Mutex<HashMap<String, CatalogFetch>>,
/// URLs whose most recent failure was a 4xx — an answer about the
/// RESOURCE (it does not exist), not about the request. `cached_json`
/// deliberately caches no failures, so its `None` conflates "MusicBrainz
/// has no such entity" with "MusicBrainz hiccuped"; this marker is what
/// lets a caller retry the hiccup without punishing every legitimately
/// absent resource with the same ladder. Entries clear on later success.
definitive_miss: Mutex<HashSet<String>>,
/// Artist name → (fetched-at, resolved art). Art resolution hits two
/// providers (TheAudioDB + Fanart.tv); this keeps repeat artist visits
/// instant and keeps us polite to both services.
@@ -1209,6 +1225,7 @@ impl MetadataClient {
deezer_gate: Mutex::new(VecDeque::new()),
catalog_cache: Mutex::new(HashMap::new()),
catalog_inflight: Mutex::new(HashMap::new()),
definitive_miss: Mutex::new(HashSet::new()),
art_cache: Mutex::new(HashMap::new()),
art_inflight: Mutex::new(HashMap::new()),
provider_art_cache: Mutex::new(HashMap::new()),
@@ -1600,6 +1617,13 @@ impl MetadataClient {
Some(Instant::now() + retry_after);
return Err(());
}
// A 4xx is definitive — the resource is absent,
// not the server unwell. Mark it so retry ladders
// upstream can tell the two apart (see
// `definitive_miss`).
if status.is_client_error() {
self.definitive_miss.lock().await.insert(url.to_string());
}
net_trace(url, &format!("http-{status}"));
return Ok(None);
}
@@ -1625,6 +1649,7 @@ impl MetadataClient {
let Some(body) = body else {
return None;
};
self.definitive_miss.lock().await.remove(url);
if body.get("error").is_none() {
if let Some(path) = disk.as_deref() {
write_disk_cache(path, url, &body);
@@ -3453,24 +3478,72 @@ impl MetadataClient {
/// artist id is known, so a cold page pays one network round rather than
/// waiting for provider artwork or the exhaustive detail/classification
/// pass used by `catalog_artist`.
pub async fn catalog_artist_overview(&self, artist_id: u64) -> Option<CatalogArtistOverview> {
pub async fn catalog_artist_overview(
self: &Arc<Self>,
artist_id: u64,
) -> Option<CatalogArtistOverview> {
let overview_started = Instant::now();
let profile_url = format!("https://api.deezer.com/artist/{artist_id}");
let albums_url =
format!("https://api.deezer.com/artist/{artist_id}/albums?limit=50&index=0");
let popular_url =
format!("https://api.deezer.com/artist/{artist_id}/top?limit={POPULAR_POOL_LIMIT}");
// The Last.fm chain needs only the artist id, so it rides in this join
// rather than extending the critical path behind it. Both of its hops
// are disk cached for a day; a cold page overlaps them with the Apple
// identity work below, which takes longer.
let (profile, album_page, popular, supplements) = tokio::join!(
// The supplements chain (linked MBID → Last.fm ranks + original
// release dates) rides this join under a BUDGET. Warm — memory or
// same-day disk cache, every visit after the first — it answers in
// single-digit milliseconds and rides for free, and the page is the
// page it always was. Cold it is a MusicBrainz chain serialized
// behind the 1 req/s gate, measured 2.2–2.6s on a THREE-album artist
// against ~300ms for everything else in this join — it had quietly
// become the whole critical path of the stage that exists to paint
// fast. A missed budget cancels nothing: the task is spawned, keeps
// filling the exact caches the full page is about to read, and that
// page — which genuinely needs the dates for classification and
// replaces this stage's content wholesale — collects the answer
// moments later. The discography FLIP already glides that correction.
// Overview: no recordings browse (the `false`). It paints fast and
// the full page replaces its Popular list wholesale.
let supplements_task = tokio::spawn({
let client = Arc::clone(self);
async move { client.artist_supplements(artist_id, false).await }
});
// Candidate discovery needs only the name; the identity SELECTION
// needs this join's album evidence and is pure CPU. Split exactly the
// way the full page splits it, so the Apple round trip rides the join
// instead of queueing serially behind it.
let apple_fut = async {
let name = self
.deezer(&profile_url)
.await
.and_then(|profile| Some(profile.get("name")?.as_str()?.to_string()));
match name {
Some(name) => self.apple_artist_candidates(&name).await,
None => Arc::new(Vec::new()),
}
};
let (profile, album_page, popular, apple_candidates, supplements) = tokio::join!(
self.deezer(&profile_url),
self.deezer(&albums_url),
self.deezer(&popular_url),
// Overview: no recordings browse. It paints fast and the full page
// replaces its Popular list wholesale.
self.artist_supplements(artist_id, false),
apple_fut,
async {
let supplements_started = Instant::now();
match tokio::time::timeout(OVERVIEW_SUPPLEMENTS_BUDGET, supplements_task).await {
Ok(Ok(supplements)) => {
stage_trace("overview.supplements", supplements_started, "within-budget");
supplements
}
Ok(Err(_)) => ArtistSupplements::default(),
Err(_) => {
stage_trace(
"overview.supplements",
supplements_started,
"budget-missed, detached",
);
ArtistSupplements::default()
}
}
},
);
stage_trace("overview.deezer_join", overview_started, "");
let mut artist = parse_artist(&profile?)?;
@@ -3481,13 +3554,13 @@ impl MetadataClient {
.iter()
.filter_map(deezer_release_evidence)
.collect();
// Resolve every exact-name Apple performer, then use the requested
// Deezer id's weighted release evidence to identify the right one.
// This adds no MusicBrainz traffic and shares its Apple I/O with the
// full page when both frontend stages overlap.
// Select among the exact-name Apple performers fetched in the join,
// using the requested Deezer id's weighted release evidence to
// identify the right one. This adds no MusicBrainz traffic and shares
// its Apple I/O with the full page when both frontend stages overlap.
let apple_started = Instant::now();
let apple = self
.apple_catalog_for_deezer(artist_id, &artist.name, &evidence)
.apple_catalog_for_deezer_candidates(artist_id, &artist.name, apple_candidates, &evidence)
.await;
stage_trace("overview.apple", apple_started, "");
stage_trace("overview.total", overview_started, "");
@@ -3527,14 +3600,91 @@ impl MetadataClient {
})
}
/// Popular's fast lane. The overview paints the section immediately under
/// its supplements BUDGET — cold, that means Deezer's order with no
/// listener counts — and the REAL list (Last.fm order + counts) used to
/// arrive only with the full page, queued behind the dates and recordings
/// walks it never reads. This waits on exactly what Popular consumes: the
/// linked MBID and one Last.fm call. Every Deezer/Apple input rides the
/// caches the overview just filled, and the ranks chain singleflights
/// with the overview's detached supplements task, so this usually JOINS
/// in-flight work rather than adding any. Cold it answers in ~2s against
/// the full page's 6–9s; warm it is the overview's own list.
///
/// The full page still owns the final word — re-attribution and the
/// Last.fm-ranked tracks Deezer does not sell arrive with it.
pub async fn catalog_artist_popular(
self: &Arc<Self>,
artist_id: u64,
) -> Option<Vec<CatalogTrack>> {
let popular_started = Instant::now();
let profile_url = format!("https://api.deezer.com/artist/{artist_id}");
let albums_url =
format!("https://api.deezer.com/artist/{artist_id}/albums?limit=50&index=0");
let popular_url =
format!("https://api.deezer.com/artist/{artist_id}/top?limit={POPULAR_POOL_LIMIT}");
let ranks_fut = async {
let mbid = self.deezer_linked_mbid(artist_id).await?;
self.lastfm_listener_ranks(&mbid).await
};
let apple_fut = async {
let name = self
.deezer(&profile_url)
.await
.and_then(|profile| Some(profile.get("name")?.as_str()?.to_string()));
match name {
Some(name) => self.apple_artist_candidates(&name).await,
None => Arc::new(Vec::new()),
}
};
let (profile, album_page, popular, apple_candidates, ranks) = tokio::join!(
self.deezer(&profile_url),
self.deezer(&albums_url),
self.deezer(&popular_url),
apple_fut,
ranks_fut,
);
let artist = parse_artist(&profile?)?;
let album_page = album_page?;
let album_items = album_page.get("data")?.as_array()?;
let evidence: Vec<DeezerReleaseEvidence> = album_items
.iter()
.filter_map(deezer_release_evidence)
.collect();
let apple = self
.apple_catalog_for_deezer_candidates(
artist_id,
&artist.name,
apple_candidates,
&evidence,
)
.await;
if apple.identity_unavailable {
return None;
}
// The same foreign filter the overview applies to its pool.
let mut foreign_album_ids = HashSet::new();
for item in album_items {
let Some(album) = parse_album(item) else { continue };
let genre_id = item.get("genre_id").and_then(|id| id.as_u64());
if apple_release_is_foreign(&apple, &album.title, genre_id) {
foreign_album_ids.insert(album.id);
}
}
let pool = deezer_popular_pool(popular.as_ref(), artist_id, &foreign_album_ids, &apple);
let ranks = ranks.unwrap_or_default();
let tracks = apply_lastfm_order(pool, &ranks.by_key, POPULAR_KEEP);
stage_trace(
"popular.total",
popular_started,
&format!("{} rows, ranked={}", tracks.len(), !ranks.by_key.is_empty()),
);
(!tracks.is_empty()).then_some(tracks)
}
pub async fn catalog_artist(self: &Arc<Self>, artist_id: u64) -> Option<CatalogArtistPage> {
let page_started = Instant::now();
let profile = self
.deezer(&format!("https://api.deezer.com/artist/{artist_id}"))
.await?;
stage_trace("page.profile", page_started, "");
let mut artist = parse_artist(&profile)?;
self.apply_known_portrait(&mut artist);
let profile_url = format!("https://api.deezer.com/artist/{artist_id}");
// Provider artwork is an independent frontend stage. Do not make the
// exhaustive release list wait for its Bing hero fallback: that used to
@@ -3601,8 +3751,20 @@ impl MetadataClient {
albums, evidence,
))
};
let q = urlencode(&format!("artist:\"{}\"", artist.name));
// Only the search and Apple legs need the NAME. They ride the same
// singleflighted profile fetch inside the join instead of queueing the
// whole gather behind a serial profile round trip — the id-keyed legs
// (curated, popular, and above all the cold supplements chain, which
// is the join's long pole) start one Deezer round trip sooner.
let search_fut = async {
let Some(name) = self
.deezer(&profile_url)
.await
.and_then(|profile| Some(profile.get("name")?.as_str()?.to_string()))
else {
return Vec::new(); // no profile, no page — the join's own bail
};
let q = urlencode(&format!("artist:\"{name}\""));
let mut candidates = Vec::new();
let mut index = 0;
for _ in 0..2 {
@@ -3637,20 +3799,48 @@ impl MetadataClient {
}
candidates
};
let apple_fut = async {
let name = self
.deezer(&profile_url)
.await
.and_then(|profile| Some(profile.get("name")?.as_str()?.to_string()));
match name {
Some(name) => self.apple_artist_candidates(&name).await,
None => Arc::new(Vec::new()),
}
};
let popular_url =
format!("https://api.deezer.com/artist/{artist_id}/top?limit={POPULAR_POOL_LIMIT}");
let gather_started = Instant::now();
// Rides the gather join for the same reason it does in the overview:
// it needs only the artist id, and both its hops are disk cached, so
// overlapping costs nothing the exhaustive album walk was not paying.
let (curated, search_candidates, popular, apple_candidates, supplements) = tokio::join!(
// The supplements chain (cold: several MusicBrainz requests behind
// the mandatory 1 req/s gate — the longest thing this function does)
// is SPAWNED here and collected beside the album-detail wave below,
// the only other multi-second leg. Its output is first consumed after
// the details land (dates, attribution, popular), so holding the
// gather for it bought nothing — audited on Queen, the details wave
// cost 5.3s serialized after a gather that was itself pinned to the
// supplements chain. Spawned, not merely deferred: an
// identity-unavailable bail below must leave it warming the caches
// the identity pass reads next, exactly like the overview's budget
// miss does.
let supplements_task = tokio::spawn({
let client = Arc::clone(self);
async move { client.artist_supplements(artist_id, true).await }
});
let (profile, curated, search_candidates, popular, apple_candidates) = tokio::join!(
async {
let profile = self.deezer(&profile_url).await;
stage_trace("page.profile", page_started, "");
profile
},
curated_fut,
search_fut,
self.deezer(&popular_url),
self.apple_artist_candidates(&artist.name),
self.artist_supplements(artist_id, true),
apple_fut,
);
stage_trace("page.gather_join", gather_started, "");
let mut artist = parse_artist(&profile?)?;
self.apply_known_portrait(&mut artist);
let (curated, evidence) = curated?;
let apple_started = Instant::now();
let apple = self
@@ -3838,7 +4028,7 @@ impl MetadataClient {
}
let details_started = Instant::now();
let detail_count = need.len();
let fetched = stream::iter(need.iter().copied())
let details_fut = stream::iter(need.iter().copied())
.map(|id| {
let url = format!("https://api.deezer.com/album/{id}");
async move { self.deezer(&url).await }
@@ -3847,12 +4037,26 @@ impl MetadataClient {
// request completes. Fixed 25-item waves made every later album
// wait for the single slowest response in the preceding wave.
.buffer_unordered(25)
.collect::<Vec<_>>()
.await;
stage_trace(
"page.details",
details_started,
&format!("count={detail_count}"),
.collect::<Vec<_>>();
// Collected together: the Deezer detail wave and the MusicBrainz
// supplements tail are the two remaining multi-second legs, and
// neither reads the other. Each leg stamps its own trace, so an audit
// still sees which of the two the join was actually waiting on.
let (fetched, supplements) = tokio::join!(
async {
let fetched = details_fut.await;
stage_trace(
"page.details",
details_started,
&format!("count={detail_count}"),
);
fetched
},
async {
let supplements = supplements_task.await.unwrap_or_default();
stage_trace("page.supplements_wait", details_started, "");
supplements
}
);
let mut details: HashMap<u64, serde_json::Value> = HashMap::new();
for d in fetched.into_iter().flatten() {
@@ -4412,27 +4616,35 @@ impl MetadataClient {
return empty_catalog_artist_identity(artist_id);
};
stage_trace("identity.linked_mbid", identity_started, "");
let ownership_started = Instant::now();
let ownership = self
.artist_release_ownership(artist_id, artist, &mbid)
.await;
stage_trace(
"identity.ownership",
ownership_started,
&format!(
"owned={} foreign={} fused={}",
ownership.owned.len(),
ownership.foreign.len(),
ownership.cotenancy_proven
),
);
let join_started = Instant::now();
// Socials are footer content, not sorting evidence, yet every request
// in this join serializes behind MusicBrainz's 1 req/s gate — so the
// socials lookup was ~1 s of extra time-to-correctly-sorted on every
// cold artist. They now ride [`Self::catalog_artist_socials`], which
// the frontend calls only after this merge-critical response lands.
let (albums, mixtapes, type_hints) = tokio::join!(
// Ownership rides the join too: its MusicBrainz legs share the gate
// with the three walks either way (the request count is what the
// policy prices), but serialized ahead of them its non-MusicBrainz
// leg — the Apple candidate fetch — added a tail the gate never
// required.
let (ownership, albums, mixtapes, type_hints) = tokio::join!(
async {
let ownership_started = Instant::now();
let ownership = self
.artist_release_ownership(artist_id, artist, &mbid)
.await;
stage_trace(
"identity.ownership",
ownership_started,
&format!(
"owned={} foreign={} fused={}",
ownership.owned.len(),
ownership.foreign.len(),
ownership.cotenancy_proven
),
);
ownership
},
self.musicbrainz_studio_albums(artist, &mbid),
self.musicbrainz_mixtapes(artist, &mbid),
self.musicbrainz_type_hints(&mbid)
@@ -5045,7 +5257,31 @@ impl MetadataClient {
let url = format!(
"https://musicbrainz.org/ws/2/url?resource={resource}&inc=artist-rels&fmt=json"
);
let body = self.musicbrainz_json(&url).await?;
let mut body = self.musicbrainz_json(&url).await;
// THE anchor request: dates, attribution, listeners and the identity
// supplement all hang off this one lookup, so a single transient
// failure here used to orphan the whole visit from MusicBrainz with
// no second ask — observed live: a 242ms failure at open left a page
// with 0/24 dates and an unranked Popular, while the identical
// lookup answered normally two seconds later. Retried on the same
// ladder the dates fetch runs — but ONLY when the miss was not a
// 4xx: "MusicBrainz has no Deezer link for this artist" is a real
// answer, and an artist without one must not pay two gated requests
// and 3.6s of backoff on every cold visit to hear it again.
if body.is_none() && !self.definitive_miss.lock().await.contains(&url) {
for attempt in 0..MB_RETRY_ATTEMPTS {
tokio::time::sleep(MB_RETRY_BACKOFF * (attempt + 1)).await;
net_trace(&url, &format!("mb-retry {}", attempt + 1));
body = self.musicbrainz_json(&url).await;
if body.is_some() {
break;
}
if self.definitive_miss.lock().await.contains(&url) {
break;
}
}
}
let body = body?;
body.get("relations")?
.as_array()?
.iter()
@@ -5948,9 +6184,17 @@ impl MetadataClient {
artist_id: u64,
with_recordings: bool,
) -> ArtistSupplements {
// Traced separately from the joined legs below: this hop is serial —
// nothing downstream can start without the MBID — and it was invisible
// in the stage audit that found the overview's critical path, because
// every leg it gates IS traced and the ~1.5s cold gap between them and
// the join total belonged to no stage.
let mbid_started = Instant::now();
let Some(mbid) = self.deezer_linked_mbid(artist_id).await else {
stage_trace("supplements.linked_mbid", mbid_started, "no-mbid");
return ArtistSupplements::default();
};
stage_trace("supplements.linked_mbid", mbid_started, "");
let (ranks, (first_released, musicbrainz_unavailable), recordings) = tokio::join!(
self.lastfm_listener_ranks(&mbid),
self.musicbrainz_first_release_dates(&mbid),
@@ -6815,17 +7059,6 @@ impl MetadataClient {
catalog
}
async fn apple_catalog_for_deezer(
&self,
deezer_id: u64,
artist: &str,
evidence: &[DeezerReleaseEvidence],
) -> Arc<AppleArtistCatalog> {
let candidates = self.apple_artist_candidates(artist).await;
self.apple_catalog_for_deezer_candidates(deezer_id, artist, candidates, evidence)
.await
}
async fn apple_catalog_for_deezer_candidates(
&self,
deezer_id: u64,
+63 -1
View File
@@ -2520,6 +2520,19 @@ pub async fn catalog_artist_overview(
.ok_or_else(|| "artist overview lookup failed".to_string())
}
#[tauri::command]
pub async fn catalog_artist_popular(
state: State<'_, LibraryState>,
artist_id: u64,
) -> Result<Vec<metadata::CatalogTrack>, String> {
state.touch_catalog_activity();
state
.metadata
.catalog_artist_popular(artist_id)
.await
.ok_or_else(|| "artist popular lookup failed".to_string())
}
#[tauri::command]
pub async fn catalog_related(
state: State<'_, LibraryState>,
@@ -3957,20 +3970,69 @@ pub async fn open_external(url: String) -> Result<(), String> {
// ---- play history ----
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn record_play(
lib: State<'_, LibraryState>,
artist: String,
title: String,
path: Option<String>,
catalog_album_id: Option<String>,
album_title: Option<String>,
album_artist: Option<String>,
cover_url: Option<String>,
) -> Result<i64, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.record_play(&artist, &title, now_secs())
let cat = library::CatalogPlay {
album_id: catalog_album_id.as_deref(),
title: album_title.as_deref(),
artist: album_artist.as_deref(),
cover: cover_url.as_deref(),
};
l.record_play(&artist, &title, path.as_deref(), cat, now_secs())
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
/// Wipe listening history. Destructive and not undoable — `plays` only;
/// albums, tracks and playlists are untouched. Returns rows removed.
#[tauri::command]
pub async fn clear_play_history(lib: State<'_, LibraryState>) -> Result<usize, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || {
l.clear_play_history().map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn play_row_count(lib: State<'_, LibraryState>) -> Result<i64, String> {
let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || l.play_row_count().map_err(|e| e.to_string()))
.await
.map_err(|e| e.to_string())?
}
/// The sidebar's recently-played list. `min_secs` keeps skips out of it.
#[tauri::command]
pub async fn recent_albums(
lib: State<'_, LibraryState>,
limit: Option<u32>,
min_secs: Option<u32>,
) -> Result<Vec<library::RecentAlbum>, String> {
let l = lib.library.clone();
let limit = limit.unwrap_or(20);
let min_secs = min_secs.unwrap_or(45);
tauri::async_runtime::spawn_blocking(move || {
l.recent_albums(limit, min_secs).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn add_play_seconds(
lib: State<'_, LibraryState>,
+4
View File
@@ -73,6 +73,9 @@ pub fn run() {
commands::record_play,
commands::add_play_seconds,
commands::artist_play_stats,
commands::recent_albums,
commands::clear_play_history,
commands::play_row_count,
commands::list_playlists,
commands::create_playlist,
commands::delete_playlist,
@@ -93,6 +96,7 @@ pub fn run() {
commands::catalog_ocr_experiment,
commands::catalog_artist_hero_seed,
commands::catalog_artist_overview,
commands::catalog_artist_popular,
commands::pick_artist_hero,
commands::clear_artist_hero,
commands::catalog_related,
+4
View File
@@ -15,6 +15,7 @@
import Downloads from "./views/Downloads.svelte";
import Settings from "./views/Settings.svelte";
import Playlist from "./views/Playlist.svelte";
import Playlists from "./views/Playlists.svelte";
import CatalogSearch from "./views/CatalogSearch.svelte";
import CatalogArtist from "./views/CatalogArtist.svelte";
import CatalogAlbum from "./views/CatalogAlbum.svelte";
@@ -24,6 +25,7 @@
autoConnect,
refreshLibrary,
refreshPlaylists,
refreshRecents,
} from "./lib/state.svelte.js";
import { floatScroll } from "./lib/scrollbar.js";
@@ -34,6 +36,7 @@
downloads: Downloads,
settings: Settings,
playlist: Playlist,
playlists: Playlists,
catalog: CatalogSearch,
catalogArtist: CatalogArtist,
catalogAlbum: CatalogAlbum,
@@ -56,6 +59,7 @@
autoConnect();
refreshLibrary();
refreshPlaylists();
refreshRecents();
</script>
+1
View File
@@ -13,6 +13,7 @@
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
playlist: '<path d="M21 15V6"/><path d="M18.5 18a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"/><path d="M12 12H3"/><path d="M16 6H3"/><path d="M12 18H3"/>',
minus: '<path d="M5 12h14"/>',
square: '<rect width="16" height="16" x="4" y="4" rx="2"/>',
restore: '<rect width="12" height="12" x="4" y="8" rx="2"/><path d="M8 4h10a2 2 0 0 1 2 2v10"/>',
+646 -174
View File
@@ -1,5 +1,19 @@
<script>
import { app, ui, network, playlistStore, refreshPlaylists, toggleSidebar, goToView } from "../lib/state.svelte.js";
import {
app,
ui,
network,
player,
downloads,
playlistStore,
recentsStore,
refreshPlaylists,
refreshRecents,
toggleSidebar,
goToView,
openCatalogAlbum,
getArt,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
@@ -10,82 +24,242 @@
goToView(id, id !== "library" ? { albumId: null } : {});
}
async function newPlaylist() {
async function newPlaylist(e) {
e.stopPropagation(); // the + rides the Playlists row; don't also navigate
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
}
function openPlaylist(id) {
const name = playlistStore.playlists.find((p) => p.id === id)?.name;
goToView("playlist", { playlistId: id, label: name ?? "Playlist" });
}
const items = [
{ id: "home", label: "Home", icon: "home" },
{ id: "search", label: "Search", icon: "search" },
{ id: "library", label: "Your Library", icon: "library" },
{ id: "downloads", label: "Downloads", icon: "download" },
{ id: "settings", label: "Settings", icon: "settings" },
{ id: "playlists", label: "Playlists", icon: "playlist" },
];
// ---- the Soulseek status, moved here out of the title bar ----
function statusClick() {
if (app.connection === "online") {
api.disconnect();
app.connection = "offline";
} else if (app.connection === "offline") {
app.showLogin = true;
}
}
// Two letters of the username, so the tile reads as an identity before any
// portrait exists. `app.avatarUrl` is the hook the PFP feature will fill;
// until then the <img> has no src and the initials show through.
const initials = $derived(
(app.username || "").replace(/[^a-z0-9]/gi, "").slice(0, 2).toLowerCase() || "··",
);
// ---- recently played ----
// Day buckets are computed from local calendar dates, not from elapsed
// seconds: something played at 00:30 is "Today", not "8 hours ago".
function dayKey(unixSecs) {
const d = new Date(unixSecs * 1000);
const now = new Date();
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const t = d.getTime();
if (t >= midnight) return "Today";
if (t >= midnight - 86400000) return "Yesterday";
return "Earlier";
}
// One flat list with day markers folded in, so the markup is a single pass
// and there is no nesting to keep aligned.
const recentRows = $derived.by(() => {
const rows = [];
let day = null;
for (const a of recentsStore.albums) {
const k = dayKey(a.last_played_at);
if (k !== day) {
day = k;
rows.push({ kind: "day", label: k, key: `d:${k}` });
}
rows.push({ kind: "album", album: a, key: `a:${a.album_id}` });
}
return rows;
});
// Covers resolve asynchronously through the shared art cache; keep the
// resolved data URLs keyed by path so a re-render never re-requests.
let artUrls = $state({});
$effect(() => {
for (const a of recentsStore.albums) {
const p = a.art_path;
if (!p || artUrls[p] !== undefined) continue;
artUrls[p] = null;
getArt(p).then((u) => (artUrls[p] = u)).catch(() => {});
}
});
// A catalog row's cover is already a URL and needs no read; a library row's
// is a file path that has to go through the art cache.
function coverOf(a) {
return a.art_url ?? (a.art_path ? artUrls[a.art_path] : null);
}
// The same set of states the title bar's ring counts as "in flight".
const DL_ACTIVE = new Set(["connecting", "requested", "queued", "transferring"]);
const downloadCount = $derived(
Object.values(downloads.items).filter((d) => DL_ACTIVE.has(d.state)).length,
);
// Which record is playing, so its row can carry the equaliser. The queue
// item carries the cover path the play was recorded against — matching on
// artist/album NAME would be exactly the identity mistake the app forbids,
// so a row lights up only when its art path is the one playing.
const nowItem = $derived(player.queue?.[player.queueIndex] ?? null);
const playingArt = $derived(nowItem?.art ?? null);
const playingCatalogId = $derived(
player.catalogRef?.albumId != null ? String(player.catalogRef.albumId) : null,
);
// Anchored on ids, never on a name.
function isPlaying(a) {
if (a.kind === "catalog") {
return !!a.catalog_album_id && a.catalog_album_id === playingCatalogId;
}
return !!a.art_path && a.art_path === playingArt;
}
function openRecent(a) {
if (a.kind === "catalog" && a.catalog_album_id) {
// The catalog page, not the old library album view — streamed
// records live in the catalog. The seed paints title/artist/cover
// immediately instead of flashing a skeleton.
openCatalogAlbum(a.catalog_album_id, {
seed: {
id: a.catalog_album_id,
title: a.title,
artist: a.artist,
cover: a.art_url ?? null,
},
});
return;
}
goToView("library", { albumId: a.album_id });
}
$effect(() => {
if (!recentsStore.loaded) refreshRecents();
});
</script>
<nav class="sidebar" class:collapsed={ui.sidebarCollapsed}>
<div class="sb-top">
<!-- One element, outside the list, so it never fades with the content. -->
<div class="gbar">
<button
class="collapse-btn"
title={ui.sidebarCollapsed ? "Expand" : "Collapse"}
onclick={toggleSidebar}
class="status"
data-state={app.connection}
onclick={statusClick}
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
>
<Icon name="chevron-left" size={20} />
<span class="avcol">
<span class="av">
<span class="avi">
{#if app.avatarUrl}
<img class="pfp" src={app.avatarUrl} alt="" draggable="false" />
{/if}
{initials}
</span>
</span>
</span>
<span class="tx">
<span class="nm">
{#if app.connection === "online"}{app.username}
{:else if app.connection === "connecting"}Signing in
{:else}Not connected{/if}
</span>
<span class="sub">
{#if app.connection === "online"}{network.shareFiles} files shared
{:else if app.connection === "connecting"}one moment
{:else}click to sign in{/if}
</span>
</span>
</button>
</div>
<!-- right:18 puts it dead centre at 64px — (64-28)/2 = 18 — so the
collapsed position falls out of the geometry, not a second rule. -->
<button
class="gicon"
title={ui.sidebarCollapsed ? "Expand" : "Collapse"}
onclick={toggleSidebar}
>
<!-- 16, not 20: measured against NowPlaying's .np-icon, whose button is
the same 28x28 radius-6 box but carries a 16px glyph. -->
<Icon name="chevron-left" size={16} />
</button>
<ul>
<!-- ONE list. Not two views cross-fading: two views means two copies of
every icon and cover painting at once through the whole crossfade, and
any difference between the copies reads as doubling. Here nothing fades
but text, and no glyph can move — every x comes from padding, which
does not depend on the panel's width. -->
<div class="view">
{#each items as item (item.id)}
<li>
<button
class="nav-item"
class:active={ui.view === item.id}
title={ui.sidebarCollapsed ? item.label : null}
onclick={() => navigate(item.id)}
>
<span class="icon"><Icon name={item.icon} size={22} /></span>
<span class="label">{item.label}</span>
</button>
</li>
{/each}
</ul>
<div class="playlists">
<div class="pl-head">
<span class="label">Playlists</span>
<button class="pl-new" title="New playlist" onclick={newPlaylist}>
<Icon name="plus" size={16} />
<button
class="nav-item"
class:active={ui.view === item.id}
class:has-count={item.id === "downloads" && downloadCount > 0}
title={ui.sidebarCollapsed ? item.label : null}
onclick={() => navigate(item.id)}
>
<span class="ic"><Icon name={item.icon} size={22} /></span>
<span class="lbl">{item.label}</span>
{#if item.id === "downloads" && downloadCount > 0}
<span class="bdg">{downloadCount > 99 ? "99+" : downloadCount}</span>
{/if}
{#if item.id === "playlists"}
<span
class="rowplus"
role="button"
tabindex="0"
title="New playlist"
onclick={newPlaylist}
onkeydown={(e) => e.key === "Enter" && newPlaylist(e)}
>
<Icon name="plus" size={13} />
</span>
{/if}
</button>
</div>
<div class="pl-list">
{#each playlistStore.playlists as pl (pl.id)}
<button
class="pl-item"
class:active={ui.view === "playlist" && ui.playlistId === pl.id}
onclick={() => openPlaylist(pl.id)}
>
<span class="pl-name">{pl.name}</span>
<span class="pl-count">{pl.track_count}</span>
</button>
{/each}
</div>
</div>
{/each}
<div class="footer">
{#if app.connection === "online" && network.shareFiles > 0}
<div class="share-info" title="Files you share with the network">
sharing {network.shareFiles} files
</div>
{/if}
<div class="rule"></div>
{#each recentRows as row (row.key)}
{#if row.kind === "day"}
<div class="day"><span>{row.label}</span></div>
{:else}
<button
class="rec"
class:on={isPlaying(row.album)}
title="{row.album.title} — {row.album.artist}"
onclick={() => openRecent(row.album)}
>
{#if coverOf(row.album)}
<img src={coverOf(row.album)} alt="" draggable="false" />
{:else}
<span class="art-placeholder"><Icon name="music" size={15} /></span>
{/if}
<span class="tx">
<span class="n">
{#if isPlaying(row.album)}
<span class="eq"><i></i><i></i><i></i></span>
{/if}
{row.album.title}
</span>
<span class="s">{row.album.artist}</span>
</span>
</button>
{/if}
{/each}
</div>
</nav>
@@ -94,184 +268,482 @@
grid-area: sidebar;
background: var(--bg-base);
border-radius: var(--radius);
display: flex;
flex-direction: column;
padding: 12px 8px;
position: relative;
overflow: hidden;
}
ul {
list-style: none;
}
.sb-top {
/* ── the glass bar ── */
.gbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 46px;
display: flex;
justify-content: flex-end;
padding: 0 4px 6px;
align-items: center;
padding: 0 11px;
z-index: 5;
background: rgba(20, 20, 24, 0.28);
backdrop-filter: blur(12px) saturate(1.7);
-webkit-backdrop-filter: blur(12px) saturate(1.7);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
}
.collapse-btn {
width: 30px;
height: 30px;
border-radius: 6px;
.gicon {
position: absolute;
right: 18px;
top: 9px;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: var(--text-secondary);
flex: 0 0 auto;
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), color 0.15s;
z-index: 6;
transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
background 120ms,
color 120ms;
}
.collapse-btn:hover {
color: var(--text-primary);
.gicon:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
/* Label collapse: fades and zeroes width so icons stay put. */
.label {
white-space: nowrap;
overflow: hidden;
max-width: 160px;
opacity: 1;
transition: opacity 0.15s ease, max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ---- collapsed icon-rail state ---- */
.sidebar.collapsed .sb-top {
justify-content: center;
}
.sidebar.collapsed .collapse-btn {
.sidebar.collapsed .gicon {
transform: rotate(180deg);
}
.sidebar.collapsed .label {
opacity: 0;
max-width: 0;
}
.sidebar.collapsed .nav-item {
justify-content: center;
gap: 0;
padding: 10px 0;
}
.sidebar.collapsed .pl-head {
justify-content: center;
padding: 0 0 8px;
}
.sidebar.collapsed .pl-list {
display: none;
}
.sidebar.collapsed .footer {
opacity: 0;
}
.playlists {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.pl-head {
/* ── the Soulseek status ──
Not painted with --accent: the accent is crossfaded from the playing
album's art, so "online" would change colour with the music and currently
renders red. Green means connected, whatever is playing. */
.status {
--av: 26px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px 8px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
gap: 11px;
min-width: 0;
max-width: 175px;
flex: 1;
/* 11 (bar padding) + 1 = 12, the image column's left edge */
padding: 2px 10px 2px 1px;
border-radius: 9px;
text-align: left;
transition:
background 0.14s,
opacity 0.26s ease;
}
.status:hover {
background: rgba(255, 255, 255, 0.07);
}
.sidebar.collapsed .status {
opacity: 0;
pointer-events: none;
}
.pl-new {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 16px;
/* A 40px slot with its left edge at 12 — the same construction as a nav
icon — so the avatar is centred on x=32 at ANY size, in line with every
icon and cover below it. */
.avcol {
width: 40px;
flex: none;
display: grid;
place-items: center;
}
.pl-new:hover {
color: var(--text-primary);
background: var(--bg-press);
.av {
position: relative;
flex: none;
width: var(--av);
height: var(--av);
}
.pl-list {
overflow-y: auto;
flex: 1;
/* Album art is 40px at radius 4 — a ratio of exactly .1. Deriving the
radius from the size holds that ratio at any size, so the avatar reads as
the same object as a cover and a real photo drops straight in. */
.avi {
position: absolute;
inset: 0;
border-radius: calc(var(--av) * 0.1);
overflow: hidden;
display: grid;
place-items: center;
background: linear-gradient(150deg, #3c3c45, #24242a);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
font-size: calc(var(--av) * 0.4);
font-weight: 700;
color: #d8d8e0;
}
.pl-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.pfp {
position: absolute;
inset: 0;
width: 100%;
padding: 7px 12px;
border-radius: 5px;
color: var(--text-secondary);
font-size: 13px;
text-align: left;
height: 100%;
object-fit: cover;
}
/* The state dot hangs off the corner, so it rides .av (unclipped) and never
.avi (which clips, for the photo). */
.av::after {
content: "";
position: absolute;
right: -2px;
bottom: -2px;
width: 9px;
height: 9px;
border-radius: 50%;
background: #7c7c85;
box-shadow: 0 0 0 2.5px #131316;
}
.status[data-state="online"] .av::after {
background: #3ecf7e;
}
.status[data-state="connecting"] .av::after {
background: #e8a33d;
animation: breathe 1.15s ease-in-out infinite;
}
@keyframes breathe {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.pl-item:hover {
color: var(--text-primary);
.status .tx {
min-width: 0;
display: flex;
flex-direction: column;
}
.pl-item.active {
background: var(--bg-highlight);
color: var(--text-primary);
}
.pl-name {
.status .nm {
font-size: 12.5px;
font-weight: 600;
color: #fff;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pl-count {
font-size: 11px;
.status .sub {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
flex-shrink: 0;
line-height: 1.2;
white-space: nowrap;
}
.status[data-state="online"] .sub {
color: #3ecf7e;
}
.status[data-state="connecting"] .sub {
color: #e8a33d;
}
/* ── the list ──
The 46px inset is the bar's height; the extra 8 stops the first row's
highlight sitting flush against it. */
.view {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 54px 6px 12px 6px;
scrollbar-width: none;
}
.view::-webkit-scrollbar {
width: 0;
display: none;
}
/* EVERY GLYPH SHARES ONE CENTRE, x=32, AND NONE OF THEM MOVE.
row starts at 6 (view pad) + 6 (row pad) -> content at 12
cover 40px at 12 -> 12..52, centre 32
icon 22px centred in a 40px slot at 12 -> 21..43, centre 32
At 64px the panel's centre is also 32, so the rail centring falls out of
the same arithmetic — it is not a second rule. These x values come from
padding, which does not depend on the panel's width, so they are
invariant through the whole collapse. Verified at 232/200/168/136/104/80/64. */
.nav-item,
.rec {
margin-bottom: 5px;
}
.nav-item {
display: flex;
align-items: center;
gap: 14px;
gap: 11px;
width: 100%;
padding: 10px 12px;
height: 42px;
padding: 0 6px;
border-radius: 6px;
color: var(--text-secondary);
font-weight: 600;
transition: color 0.15s;
font-size: 14px;
white-space: nowrap;
transition:
color 0.15s,
background 0.15s;
}
.nav-item .ic {
width: 40px;
flex: none;
display: grid;
place-items: center;
position: relative;
}
/* Fixed width so the label never reflows or re-ellipsises as the panel
narrows — it fades, and what is left is clipped by the panel. 118 lands
the count and the + a clear 12-16px inside the highlight. */
.lbl {
width: 118px;
flex: none;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.26s ease;
}
.nav-item:hover {
color: var(--text-primary);
}
.nav-item.active {
color: var(--text-primary);
/* --bg-highlight, matching NowPlaying's .row:hover exactly. The frosted
version existed to let the album wash run underneath it; with the wash
gone it has nothing to reveal, and an opaque highlight is what the right
panel already uses. Active is distinguished by text colour, which is how
this sidebar has always done it. */
.nav-item:hover,
.rec:hover,
.nav-item.active,
.rec.on {
background: var(--bg-highlight);
}
.nav-item.active,
.rec.on {
color: var(--text-primary);
}
.icon {
width: 22px;
/* margin-left:0, never auto — auto depends on the row's width, so the count
and the + would slide across the panel while fading out. */
.bdg,
.rowplus {
margin-left: 0;
transition: opacity 0.26s ease;
}
.bdg {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
font-size: 10px;
font-weight: 700;
display: grid;
place-items: center;
flex: none;
font-variant-numeric: tabular-nums;
background: rgba(255, 255, 255, 0.15);
color: #fff;
backdrop-filter: blur(8px) saturate(1.4);
-webkit-backdrop-filter: blur(8px) saturate(1.4);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.13);
}
/* Collapsed there is no room for a numeral, and an 18px one on the glyph
covers it. An 8px dot at (44..52) clears the glyph, which ends at 43. */
.nav-item.has-count .ic::after {
content: "";
position: absolute;
top: 5px;
right: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.26s ease;
}
.sidebar.collapsed .nav-item.has-count .ic::after {
opacity: 1;
}
.footer {
padding: 8px 4px;
/* Draws nothing until you point at the row — the right weight for a
secondary action, and it stops a grey dot floating on the wash. */
.rowplus {
width: 22px;
height: 22px;
border-radius: 50%;
flex: none;
display: grid;
place-items: center;
background: none;
color: transparent;
transition:
opacity 0.26s ease,
background 0.13s,
color 0.13s,
transform 0.13s;
}
.nav-item:hover .rowplus {
color: var(--text-secondary);
}
.rowplus:hover {
background: rgba(255, 255, 255, 0.17);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
transform: scale(1.08);
}
.share-info {
padding: 2px 8px;
font-size: 11px;
.rule {
height: 1px;
background: var(--border);
margin: 12px 6px;
}
/* No "Recently played" eyebrow — the rule runs straight into the history.
Collapsed, the day word leaves a hairline so the list keeps its
structure instead of becoming an undifferentiated column. */
.day {
position: relative;
height: 28px;
display: flex;
align-items: flex-end;
padding: 0 6px 5px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-muted);
white-space: nowrap;
}
.day span {
transition: opacity 0.26s ease;
}
.day::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
height: 1px;
background: rgba(255, 255, 255, 0.09);
opacity: 0;
transition: opacity 0.26s ease;
}
.sidebar.collapsed .day::after {
opacity: 1;
}
.rec {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
height: 52px;
padding: 0 6px;
border-radius: 6px;
text-align: left;
transition: background 0.15s;
}
.rec img,
.art-placeholder {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
flex: none;
transition: transform var(--art-motion);
}
.art-placeholder {
display: grid;
place-items: center;
background: var(--bg-highlight);
color: var(--text-muted);
}
.rec:hover img {
transform: scale(var(--art-grow));
}
.rec .tx {
width: 157px;
flex: none;
overflow: hidden;
transition: opacity 0.26s ease;
}
.rec .n {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 6px;
}
.rec:hover .n {
color: var(--text-primary);
}
.rec.on .n {
color: var(--accent-hover);
}
.rec .s {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
/* Only text fades. Nothing here changes a position. */
.sidebar.collapsed .lbl,
.sidebar.collapsed .rec .tx,
.sidebar.collapsed .day span,
.sidebar.collapsed .nav-item > .bdg,
.sidebar.collapsed .rowplus {
opacity: 0;
}
.eq {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 10px;
flex: none;
}
.eq i {
width: 2.5px;
background: var(--accent-hover);
border-radius: 1px;
animation: eq 0.9s ease-in-out infinite;
}
.eq i:nth-child(2) {
animation-delay: -0.2s;
}
.eq i:nth-child(3) {
animation-delay: -0.55s;
}
@keyframes eq {
0%,
100% {
height: 35%;
}
50% {
height: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.gicon,
.lbl,
.rec .tx,
.day span,
.bdg,
.rowplus {
transition: none;
}
.eq i,
.status[data-state="connecting"] .av::after {
animation: none;
}
}
</style>
+20 -39
View File
@@ -189,24 +189,31 @@
}
}
// onResized fires on EVERY frame of a window drag, and answering each one
// with isMaximized() is an IPC round trip to the Rust side per frame — on
// the main thread, while the compositor is already busy re-rasterizing every
// backdrop-filter in the app at the new size. Maximized state cannot change
// part-way through a drag anyway: it only changes at the end of one, or on a
// discrete button press. So answer once, after the box settles.
let resizeSettle = null;
function syncMaximizedSoon() {
clearTimeout(resizeSettle);
resizeSettle = setTimeout(syncMaximized, 150);
}
$effect(() => {
let unlisten = null;
syncMaximized();
api.win
.onResized(syncMaximized)
.onResized(syncMaximizedSoon)
.then((f) => (unlisten = f))
.catch(() => {});
return () => unlisten?.();
return () => {
clearTimeout(resizeSettle);
unlisten?.();
};
});
function statusClick() {
if (app.connection === "online") {
api.disconnect();
app.connection = "offline";
} else if (app.connection === "offline") {
app.showLogin = true;
}
}
</script>
<header class="titlebar" data-tauri-drag-region>
@@ -214,13 +221,8 @@
<img class="logo" src="/logo.png" alt="" draggable="false" />
<span class="name">SoulStream</span>
</div>
<button
class="status {app.connection}"
onclick={statusClick}
title={app.connection === "online" ? "Click to disconnect" : "Click to connect"}
>
● {app.connection === "online" ? app.username : app.connection}
</button>
<!-- The Soulseek status lives in the sidebar's glass bar now: it is an
account presence, and it belongs next to the library it feeds. -->
<form class="bar-search" onsubmit={submitSearch} onfocusout={searchFocusOut}>
<Icon name="search" size={13} />
<input
@@ -336,8 +338,7 @@
/* The visible strip above the panels is the 30px bar row plus the 8px
grid gap; nudge the left cluster down so it centers optically in that
full strip. The window controls stay flush with the top edge. */
.brand,
.status {
.brand {
position: relative;
top: 4px;
}
@@ -361,26 +362,6 @@
font-size: 13px;
}
.status {
font-size: 12px;
color: var(--text-muted);
padding: 3px 9px;
border-radius: 10px;
}
.status:hover {
background: var(--bg-highlight);
color: var(--text-secondary);
}
.status.online {
color: var(--accent-hover);
}
.status.connecting {
color: #e8a33d;
}
/* Centered on the window regardless of the left cluster's width; the
same +4px optical nudge as the brand. Dragging still works on the
uncovered bar either side. */
+24 -1
View File
@@ -67,7 +67,25 @@ export const playerControl = (action, value, rate) =>
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
export const libraryAlbums = () => invoke("library_albums");
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
export const recordPlay = (artist, title) => invoke("record_play", { artist, title });
// `path` anchors the play to a library album via tracks.path (UNIQUE). Without
// it the row still records, but with no album — so it cannot appear in recents.
// `cat` carries catalog identity for a STREAMED play, which never touches the
// local library: { albumId, albumTitle, albumArtist, coverUrl }.
export const recordPlay = (artist, title, path, cat = {}) =>
invoke("record_play", {
artist,
title,
path: path ?? null,
catalogAlbumId: cat.albumId != null ? String(cat.albumId) : null,
albumTitle: cat.albumTitle ?? null,
albumArtist: cat.albumArtist ?? null,
coverUrl: cat.coverUrl ?? null,
});
// Destructive, not undoable: wipes `plays` only. Returns rows removed.
export const clearPlayHistory = () => invoke("clear_play_history");
export const playRowCount = () => invoke("play_row_count");
export const recentAlbums = (limit, minSecs) =>
invoke("recent_albums", { limit: limit ?? null, minSecs: minSecs ?? null });
export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs });
export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist });
// The WebView blocks target="_blank"; external links open via the backend.
@@ -110,6 +128,11 @@ export const catalogArtistHeroSeed = (artistId) =>
invoke("catalog_artist_hero_seed", { artistId });
export const catalogArtistOverview = (artistId) =>
checkedArtistPayload("catalog_artist_overview", artistId);
// Popular's fast lane: the Last.fm-ordered rows with listener counts, waiting
// on nothing but the linked MBID and one Last.fm call. Patched over the
// overview's budgeted (cold: unranked) list; the full page still lands last.
export const catalogArtistPopular = (artistId) =>
invoke("catalog_artist_popular", { artistId });
export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId });
export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });
export const catalogRelated = (artistId) => invoke("catalog_related", { artistId });
+63 -1
View File
@@ -105,6 +105,27 @@ export async function refreshPlaylists() {
}
}
// ---- recently played (the sidebar's list) ----
// A play row is opened after ~1s of audio, so without a floor this list is a
// log of skips. 45s is the floor the live table was measured against.
export const RECENTS_FLOOR_SECS = 45;
const RECENTS_LIMIT = 20;
export const recentsStore = $state({
albums: [], // [{ album_id, artist, title, art_path, last_played_at }]
loaded: false,
});
export async function refreshRecents() {
try {
recentsStore.albums = await api.recentAlbums(RECENTS_LIMIT, RECENTS_FLOOR_SECS);
} catch {
// backend not available
} finally {
recentsStore.loaded = true;
}
}
const MIB = 1024 * 1024;
function isPending(value) {
@@ -2071,6 +2092,26 @@ export async function openCatalogArtist(artistId, seedArtist = null) {
})
.catch(() => {});
// Popular's fast lane. The overview paints the section immediately (its
// supplements are budgeted), so a cold page's rows start in Deezer's
// order with no listener counts. The real list — Last.fm order and
// counts — needs none of the dates/recordings work the full page also
// waits for, so it is asked for on its own and patched in the moment it
// lands (~2s cold, instant warm). Last writer wins against the overview:
// warm, the two lists are computed from identical inputs and cannot
// differ. The full page stays authoritative once it arrives, and a
// replayed page already shows the last visit's FINAL list — patching a
// pool-shaped list over either would be a downgrade.
void api.catalogArtistPopular(artistId)
.then((tracks) => {
if (!active() || !tracks?.length) return;
const current = catalog.artistPage;
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
if (current.art_preview === false || current.replayed) return;
catalog.artistPage = { ...current, popular_tracks: tracks };
})
.catch(() => {});
try {
// Overview, identity art, and the first hero all remain immediate.
// Give rapid back-to-back navigation a brief chance to settle before
@@ -4116,7 +4157,12 @@ function listenTick(positionSecs) {
if (!title || !artist || artist.includes("…")) return;
const key = `${artist}\u0000${title}`;
if (listenSession?.key !== key) {
// The outgoing track's final seconds go in here — which is the moment
// its album can cross the floor. Re-read once that write has landed;
// waiting only on the fixed timer below meant a track that crossed 45s
// late never showed up until something else happened to refresh.
listenFlush();
setTimeout(refreshRecents, 1500);
listenSession = { key, artist, title, pending: 0, playIdP: null };
listenLastPos = null;
}
@@ -4125,8 +4171,24 @@ function listenTick(positionSecs) {
if (delta <= 0 || delta > 3) return;
listenSession.pending += delta;
if (!listenSession.playIdP && listenSession.pending >= 1) {
listenSession.playIdP = api.recordPlay(artist, title);
// Two possible anchors, both exact ids, never a name. A local track is
// tied to its library album through tracks.path; a STREAMED one — which
// is most listening here — through the catalog album id, carrying its
// own title/artist/cover because there is no local row to read them
// back from.
const q = player.queue?.[player.queueIndex] ?? null;
listenSession.playIdP = api.recordPlay(artist, title, t?.path ?? null, {
albumId: player.catalogRef?.albumId ?? null,
albumTitle: q?.album ?? null,
albumArtist: q?.artist ?? null,
coverUrl: q?.artUrl ?? null,
});
listenSession.playIdP.catch(() => {});
// The row is opened at ~1s but only earns its place in recents once it
// passes the floor, so re-read after the track has had time to count.
listenSession.playIdP
.then(() => setTimeout(refreshRecents, RECENTS_FLOOR_SECS * 1000 + 2000))
.catch(() => {});
}
if (listenSession.pending >= LISTEN_FLUSH_SECS) listenFlush();
}
+132
View File
@@ -0,0 +1,132 @@
<script>
// The sidebar's Playlists row needs a destination. Playlists used to be
// listed in the sidebar itself; with the sidebar showing listening history
// instead, this is where they live.
import { playlistStore, refreshPlaylists, goToView } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import Icon from "./../components/Icon.svelte";
async function newPlaylist() {
const name = `New Playlist ${playlistStore.playlists.length + 1}`;
const id = await api.createPlaylist(name);
await refreshPlaylists();
goToView("playlist", { playlistId: id, label: name });
}
function open(pl) {
goToView("playlist", { playlistId: pl.id, label: pl.name });
}
</script>
<div class="page">
<header>
<h1>Playlists</h1>
<button class="new" onclick={newPlaylist}>
<Icon name="plus" size={15} /> New playlist
</button>
</header>
{#if playlistStore.playlists.length === 0}
<p class="empty">No playlists yet.</p>
{:else}
<ul>
{#each playlistStore.playlists as pl (pl.id)}
<li>
<button class="row" onclick={() => open(pl)}>
<span class="ic"><Icon name="playlist" size={18} /></span>
<span class="name">{pl.name}</span>
<span class="count">
{pl.track_count}
{pl.track_count === 1 ? "track" : "tracks"}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.page {
padding: 8px 4px 24px;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
.new {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-highlight);
transition:
background 0.14s,
color 0.14s;
}
.new:hover {
background: var(--bg-press);
color: var(--text-primary);
}
ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.row {
display: flex;
align-items: center;
gap: 13px;
width: 100%;
padding: 11px 12px;
border-radius: 7px;
text-align: left;
color: var(--text-secondary);
transition: background 0.14s;
}
.row:hover {
background: var(--bg-highlight);
color: var(--text-primary);
}
.ic {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 5px;
background: var(--bg-highlight);
color: var(--text-muted);
flex: none;
}
.name {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.count {
font-size: 12px;
color: var(--text-muted);
flex: none;
}
.empty {
color: var(--text-muted);
font-size: 13px;
}
</style>