Add discovery modes, track tags and an artist graph

Tracks get tags from their title, description, artist name and BPM
(styles such as riddim, versions such as VIP/dub/flip, and tempo), stored
in track_tags and shown on cards. Related pages the crawler reads are kept
in track_related, which links artists to each other.

The feed takes ?mode=: riddim (140 BPM, riddim artists and their graph
neighbours first), fresh, deep cuts, and an artist radio that plays an
artist and the artists around them. Opening a radio crawls that artist's
related pages first. The crawler searches riddim-adjacent terms first.

The filters panel picks the feed, each card has a radio button (r), and a
chip in the top bar returns to the normal feed.
This commit is contained in:
nvrl committed 2026-11-01 13:46:52 +01:00
1 parent b4470850bf
commit f6c505907a
14 files changed
+737 -36

No files matched your search

+30 -4
View File
@@ -120,14 +120,17 @@ visitor address reaches pl8list or a CDN.
| swipe up / down | ↓ j · ↑ k | next / previous |
| drag waveform | drag waveform · ← → | seek |
| follow button | f | follow the artist |
| radio button | r | artist radio (more like them) |
| ✕ button | x | not for me (skip, show less) |
| share button | s | share / copy link |
| | m | mute |
| | ? · Esc | help · close panels |
Lock-screen, notification and headphone buttons control playback too (Media Session API).
"likes" lists your likes newest first; ▶ plays one right away. Filters (free only, BPM range)
are remembered per browser. The site can be installed to the home screen as an app.
"likes" lists your likes newest first; ▶ plays one right away. The filters panel picks the
feed (for you, riddim, fresh, deep cuts); "radio" on a card starts an artist radio, and the chip
in the top bar goes back to your feed. Feed and filters (free only, BPM range) are remembered
per browser. The site can be installed to the home screen as an app.
## Front end
@@ -183,8 +186,31 @@ its artist's catalogue are crawled first.
freshness, artist affinity (from their likes, completions, skips, "not for me" and follows), closeness to the BPM and
key of tracks they liked, whether it is related to something they liked, and some random
exploration. Artists are then spread out so you don't get five tracks from one person in a
row. Weights live in a `Ranker`; `?mode=` on `/local/feed` picks one, so discovery modes
can be added next to `Classic`.
row. Weights live in a `Ranker`, which can also narrow the candidates and add a bonus;
`?mode=` on `/local/feed` picks one:
| mode | what it plays |
| -------------------------- | ------------------------------------------------------------------------------ |
| (none) / `classic` | the mix above |
| `riddim` | 140 BPM (or 70) tracks, riddim first |
| `fresh` | uploads of the last 45 days, newest weighted up |
| `deep` | tracks with 15 saves or fewer; popularity counts against a track |
| `artist&artist=<handle>` | artist radio: the artist and their neighbours in the artist graph (two hops) |
**Tags** (`backend/src/tags.rs`). pl8list has no genre field, so tags come from words in the
title, description and artist name, and from the BPM: styles (`riddim`, `tearout`, `wonk`, `dnb`,
…), versions (`vip`, `dub`, `dubpack`, `remix`, `flip`, `special`, `collab`, `wip` for v1/v2…)
and tempo (`140`, `170`). They are stored in `track_tags`, sent with each track and shown on the
card. Changing the rules means bumping `tags::VERSION`; the catalogue is retagged on the next
start.
**Artist graph.** Every related page the crawler reads is stored in `track_related` (which
artists pl8list lists next to a track). Two artists are linked by how often that happens, in
either direction. The artist radio uses the graph directly. Riddim mode uses it to spread the
sparse `riddim` tag: an artist counts as riddim by their own tagged tracks (three count fully)
or by half of what the artists next to them have. Opening a radio crawls that artist and the
related pages of their top tracks first, so a new radio fills in within seconds. The crawler
also searches riddim-adjacent terms first and prefers related pages of riddim tracks.
**Audio.** The browser only talks to this server. HLS playlists and segments are proxied from
pl8list as a guest, so users hear previews of paid tracks and full free tracks, the same as a
+34 -6
View File
@@ -28,16 +28,19 @@ use crate::{
};
/// Search needs at least 3 characters. Genre-ish words find lots of artist handles.
/// Riddim and its neighbours are searched first.
const STYLE_TERMS: &[&str] = &["riddim", "dubstep", "tearout", "dubz", "wonk", "deathstep", "brostep", "halftime", "140"];
const SEARCH_TERMS: &[&str] = &[
"dub", "bass", "dnb", "jungle", "garage", "grime", "riddim", "dubstep", "trap", "techno",
"house", "beats", "music", "sound", "prod", "audio", "records", "drill", "breaks", "wave",
"deep", "dark", "the", "official", "dj", "mc", "lofi", "halftime", "neuro", "tek",
"dub", "bass", "dnb", "jungle", "garage", "grime", "trap", "techno", "house", "beats", "music",
"sound", "prod", "audio", "records", "drill", "breaks", "wave", "deep", "dark", "the",
"official", "dj", "mc", "lofi", "neuro", "tek",
];
// Lower number = sooner.
const P_FEEDBACK: i64 = 0;
const P_SEED: i64 = 10;
const P_ARTIST_FIRST: i64 = 20;
const P_STYLE: i64 = 15;
const P_RELATED: i64 = 30;
const P_ARTIST_MORE: i64 = 40;
const P_SEARCH: i64 = 60;
@@ -144,6 +147,24 @@ impl Crawler {
}
}
/// An artist radio was opened: crawl the artist, and the related pages of their
/// best tracks that were not read yet, to fill in the artist graph around them.
pub async fn explore_artist(&self, handle: String) {
self.push(P_FEEDBACK, Job::Artist(handle.clone()));
let h = handle.clone();
let tracks = self
.db
.call(move |c| {
c.prepare("SELECT id, slug FROM tracks WHERE handle=? AND related_fetched=0 ORDER BY saves + downloads DESC LIMIT 3")?
.query_map([&h], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?
.collect::<Result<Vec<_>, _>>()
})
.await;
for (id, slug) in tracks.unwrap_or_default() {
self.push(P_FEEDBACK, Job::Related(id, handle.clone(), slug));
}
}
// Main loop -------------------------------------------------------------
pub fn start(self: &Arc<Self>) {
@@ -181,6 +202,9 @@ impl Crawler {
self.push(P_SEED, Job::Home);
self.push(P_SEED, Job::Ticker);
self.push(P_SEED, Job::BestArtists);
for term in STYLE_TERMS {
self.push(P_STYLE, Job::Search(term.to_string()));
}
for term in SEARCH_TERMS {
self.push(P_SEARCH, Job::Search(term.to_string()));
}
@@ -206,8 +230,9 @@ impl Crawler {
)?;
let related: Vec<(i64, String, String)> = c
.prepare(
"SELECT id, handle, slug FROM tracks WHERE related_fetched=0 \
ORDER BY saves + downloads DESC LIMIT 10",
"SELECT id, handle, slug FROM tracks t WHERE related_fetched=0 ORDER BY \
EXISTS (SELECT 1 FROM track_tags g WHERE g.track_id = t.id AND g.tag = 'riddim') DESC, \
saves + downloads DESC LIMIT 10",
)?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<Result<_, _>>()?;
@@ -312,6 +337,8 @@ impl Crawler {
let rows = normalize::related(&d);
let artists: Vec<_> = rows.iter().map(|r| (r.handle.clone(), r.artist_name.clone(), r.artist_img.clone(), r.artist_verified)).collect();
self.upsert(rows, Some(track_id)).await?;
let handles: Vec<String> = artists.iter().map(|a| a.0.clone()).collect();
self.db.call(move |c| db::add_related(c, track_id, &handles)).await?;
for (h, name, img, verified) in artists {
self.add_artist(h, "related", Some(name), img, verified, P_RELATED).await?;
}
@@ -384,7 +411,8 @@ impl Crawler {
let Some(a) = item.get("artist") else { continue };
if let Some(h) = s(a, "handle") {
let verified = a.get("isVerified").and_then(Value::as_bool).unwrap_or(false);
let prio = if verified { P_ARTIST_FIRST } else { P_SEARCH + 5 };
let riddim = s(a, "name").into_iter().chain([h.clone()]).any(|n| n.to_lowercase().contains("riddim"));
let prio = if verified || riddim { P_ARTIST_FIRST } else { P_SEARCH + 5 };
self.add_artist(h, "search", s(a, "name"), s(a, "img"), verified, prio).await?;
}
}
+153 -5
View File
@@ -107,6 +107,29 @@ CREATE TABLE IF NOT EXISTS follows (
);
"#;
// track_tags: see tags.rs. track_related: the artists on a track's "related" page;
// the artist graph is read from it. meta: small settings, like the tagger version.
const SCHEMA_V4: &str = r#"
CREATE TABLE IF NOT EXISTS track_tags (
track_id INTEGER NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (track_id, tag)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS track_tags_tag ON track_tags(tag, track_id);
CREATE TABLE IF NOT EXISTS track_related (
track_id INTEGER NOT NULL,
handle TEXT NOT NULL,
PRIMARY KEY (track_id, handle)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS track_related_handle ON track_related(handle);
INSERT OR IGNORE INTO track_related (track_id, handle)
SELECT related_of, handle FROM tracks WHERE related_of IS NOT NULL;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value
);
"#;
pub fn now() -> f64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64()
}
@@ -116,11 +139,15 @@ pub struct Db(Arc<Mutex<Connection>>);
impl Db {
pub fn open(path: &Path) -> anyhow::Result<Self> {
let conn = Connection::open(path)?;
let mut conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.busy_timeout(std::time::Duration::from_secs(5))?;
migrate(&conn)?;
let tagged = retag_if_stale(&mut conn)?;
if tagged > 0 {
tracing::info!("tagged {tagged} tracks");
}
Ok(Db(Arc::new(Mutex::new(conn))))
}
@@ -162,9 +189,104 @@ fn migrate(conn: &Connection) -> rusqlite::Result<()> {
if version < 3 {
conn.execute_batch(&format!("BEGIN; {SCHEMA_V3} PRAGMA user_version = 3; COMMIT;"))?;
}
if version < 4 {
conn.execute_batch(&format!("BEGIN; {SCHEMA_V4} PRAGMA user_version = 4; COMMIT;"))?;
}
Ok(())
}
/// An empty in-memory database, for tests.
#[cfg(test)]
pub fn memory() -> Connection {
let c = Connection::open_in_memory().unwrap();
migrate(&c).unwrap();
c
}
// Tags and the artist graph ---------------------------------------------------
fn tags_of(t: &Track) -> Vec<&'static str> {
crate::tags::of(&t.title, t.description.as_deref(), &t.artist_name, &t.handle, t.bpm)
}
fn set_tags(conn: &Connection, track_id: i64, tags: &[&str]) -> rusqlite::Result<()> {
conn.prepare_cached("DELETE FROM track_tags WHERE track_id=?")?.execute([track_id])?;
let mut insert = conn.prepare_cached("INSERT INTO track_tags (track_id, tag) VALUES (?,?)")?;
for tag in tags {
insert.execute(params![track_id, tag])?;
}
Ok(())
}
/// Tag the whole catalogue again if the rules in tags.rs changed. Returns how many
/// tracks were tagged.
pub fn retag_if_stale(conn: &mut Connection) -> rusqlite::Result<usize> {
let done: Option<i64> = conn.query_row("SELECT value FROM meta WHERE key='tagger'", [], |r| r.get(0)).optional()?;
if done == Some(crate::tags::VERSION) {
return Ok(0);
}
let tx = conn.transaction()?;
let tracks: Vec<Track> = tx.prepare("SELECT * FROM tracks")?.query_map([], Track::from_row)?.collect::<Result<_, _>>()?;
tx.execute("DELETE FROM track_tags", [])?;
for t in &tracks {
set_tags(&tx, t.id, &tags_of(t))?;
}
tx.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('tagger', ?)", [crate::tags::VERSION])?;
tx.commit()?;
Ok(tracks.len())
}
pub fn track_tags(conn: &Connection, track_id: i64) -> rusqlite::Result<Vec<String>> {
conn.prepare_cached("SELECT tag FROM track_tags WHERE track_id=? ORDER BY tag")?.query_map([track_id], |r| r.get(0))?.collect()
}
/// Remember which artists showed up on a track's related page.
pub fn add_related(conn: &mut Connection, track_id: i64, handles: &[String]) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
let mut insert = tx.prepare_cached("INSERT OR IGNORE INTO track_related (track_id, handle) VALUES (?,?)")?;
for h in handles {
insert.execute(params![track_id, h])?;
}
}
tx.commit()
}
/// Artist graph edges: (artist, related artist, how many of the first one's tracks
/// listed the second). Self links are left out.
pub fn artist_links(conn: &Connection) -> rusqlite::Result<Vec<(String, String, i64)>> {
conn.prepare_cached(
"SELECT t.handle, r.handle, COUNT(*) FROM track_related r JOIN tracks t ON t.id = r.track_id \
WHERE t.handle <> r.handle GROUP BY t.handle, r.handle",
)?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect()
}
/// Artists linked to `handle` in either direction, with the summed link counts.
pub fn artist_neighbours(conn: &Connection, handle: &str) -> rusqlite::Result<Vec<(String, i64)>> {
conn.prepare_cached(
"SELECT h, SUM(n) FROM ( \
SELECT r.handle AS h, COUNT(*) AS n FROM track_related r JOIN tracks t ON t.id = r.track_id \
WHERE t.handle = ?1 AND r.handle <> ?1 GROUP BY r.handle \
UNION ALL \
SELECT t.handle, COUNT(*) FROM track_related r JOIN tracks t ON t.id = r.track_id \
WHERE r.handle = ?1 AND t.handle <> ?1 GROUP BY t.handle \
) GROUP BY h",
)?
.query_map([handle], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect()
}
/// How many tracks each artist has with `tag`.
pub fn tag_counts(conn: &Connection, tag: &str) -> rusqlite::Result<Vec<(String, i64)>> {
conn.prepare_cached(
"SELECT t.handle, COUNT(*) FROM track_tags g JOIN tracks t ON t.id = g.track_id WHERE g.tag=? GROUP BY t.handle",
)?
.query_map([tag], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect()
}
// Tracks ------------------------------------------------------------------
/// One row of `tracks`, as normalised from pl8list or read back.
@@ -300,6 +422,7 @@ pub fn upsert_tracks(conn: &mut Connection, rows: &[Track], related_of: Option<i
insert.execute(params_from_iter(vals))?;
}
artist.execute(params![r.handle, r.artist_name, r.artist_img, r.artist_verified as i64])?;
set_tags(&tx, r.id, &tags_of(r))?;
}
}
tx.commit()?;
@@ -622,10 +745,35 @@ pub fn purge(conn: &Connection) -> rusqlite::Result<(usize, usize)> {
mod tests {
use super::*;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
migrate(&c).unwrap();
c
use super::memory as mem;
#[test]
fn upsert_tags_tracks_and_retag_follows_the_tagger_version() {
let mut c = mem();
let t = Track { id: 1, handle: "h".into(), artist_name: "H".into(), slug: "s".into(), title: "Big VIP".into(), bpm: Some(140.0), ..Default::default() };
upsert_tracks(&mut c, &[t], None).unwrap();
assert_eq!(track_tags(&c, 1).unwrap(), ["140", "vip"]);
c.execute("DELETE FROM track_tags", []).unwrap();
assert_eq!(retag_if_stale(&mut c).unwrap(), 1);
assert_eq!(retag_if_stale(&mut c).unwrap(), 0);
assert_eq!(track_tags(&c, 1).unwrap(), ["140", "vip"]);
}
#[test]
fn artist_graph_reads_related_pages_both_ways() {
let mut c = mem();
let track = |id, h: &str| Track { id, handle: h.into(), artist_name: h.into(), slug: format!("s{id}"), title: "T".into(), ..Default::default() };
upsert_tracks(&mut c, &[track(1, "a"), track(2, "a"), track(3, "b")], None).unwrap();
add_related(&mut c, 1, &["b".into(), "c".into(), "a".into()]).unwrap();
add_related(&mut c, 2, &["b".into()]).unwrap();
add_related(&mut c, 3, &["d".into()]).unwrap();
let mut n = artist_neighbours(&c, "a").unwrap();
n.sort();
assert_eq!(n, [("b".to_string(), 2), ("c".to_string(), 1)]);
let mut n = artist_neighbours(&c, "b").unwrap();
n.sort();
assert_eq!(n, [("a".to_string(), 2), ("d".to_string(), 1)]);
assert_eq!(artist_links(&c).unwrap().len(), 3);
}
#[test]
+8 -2
View File
@@ -61,9 +61,14 @@ pub async fn feed(State(app): St, Extension(current): Extension<CurrentUser>, Qu
Ok(u) => u.clone(),
Err(r) => return Ok(r),
};
let Some(ranker) = ranker::by_name(q.get("mode").map(String::as_str)) else {
let artist = q.get("artist").map(String::as_str).filter(|h| valid_handle(h));
let Some(mode) = ranker::Mode::parse(q.get("mode").map(String::as_str), artist) else {
return Ok(json_error(StatusCode::BAD_REQUEST, "unknown mode"));
};
// The artist graph is thin at first: crawl the radio's artist and their related pages.
if let ranker::Mode::Artist(h) = &mode {
app.crawler.explore_artist(h.clone()).await;
}
let n = num(&q, "n").map(|n| n as usize).unwrap_or(10).clamp(1, 30);
let filters = ranker::Filters {
free_only: q.get("free").map(String::as_str) == Some("1"),
@@ -74,7 +79,8 @@ pub async fn feed(State(app): St, Extension(current): Extension<CurrentUser>, Qu
let deadline = Instant::now() + FEED_WAIT;
let uid = user.id;
let mut tracks = loop {
let tracks = app.db.call(move |c| ranker::pick(c, uid, n, filters, ranker)).await?;
let mode = mode.clone();
let tracks = app.db.call(move |c| ranker::pick(c, uid, n, filters, &*mode.ranker(c)?)).await?;
if !tracks.is_empty() || Instant::now() >= deadline {
break tracks;
}
+37
View File
@@ -380,3 +380,40 @@ async fn token_rotation_keeps_the_pl8list_connection() {
assert_eq!(app.sync.key.sealer(&new, 5).open(&new_blob.unwrap()).unwrap().expose_secret(), "PL8");
assert!(old_blob.is_none());
}
#[tokio::test]
async fn feed_modes_filter_and_tag_tracks() {
let (r, app, _) = setup("modes", "http://127.0.0.1:9").await;
let token = app
.db
.call(|c| {
let t = |id, title: &str, bpm| crate::db::Track {
id,
handle: "h".into(),
artist_name: "H".into(),
slug: format!("s{id}"),
title: title.into(),
bpm: Some(bpm),
full_access: true,
..Default::default()
};
crate::db::upsert_tracks(c, &[t(1, "Riddim VIP", 140.0), t(2, "Roller", 174.0)], None)?;
crate::db::upsert_user(c, 5, "tester", None, None)?;
crate::db::create_session(c, 5)
})
.await
.unwrap();
let cookie = format!("__Host-pl8inf={token}");
let get = |uri: &str| r.clone().oneshot(req("GET", uri, None, Some(&cookie), "203.0.113.1"));
let res = get("/local/feed?mode=riddim").await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = json_body(res).await;
let tracks = body["tracks"].as_array().unwrap();
assert_eq!(tracks.len(), 1);
assert_eq!(tracks[0]["tags"], serde_json::json!(["140", "riddim", "vip"]));
for bad in ["/local/feed?mode=nope", "/local/feed?mode=artist", "/local/feed?mode=artist&artist=..%2Fx"] {
assert_eq!(get(bad).await.unwrap().status(), StatusCode::BAD_REQUEST, "{bad}");
}
let res = get("/local/feed?mode=artist&artist=h").await.unwrap();
assert_eq!(json_body(res).await["tracks"].as_array().unwrap().len(), 1);
}
+1
View File
@@ -23,6 +23,7 @@ mod pl8;
mod ranker;
mod ratelimit;
mod sync;
mod tags;
use std::{
io::{Read, Write},
+257 -8
View File
@@ -11,8 +11,8 @@
//! exploration Gumbel noise, so the feed never freezes into one niche
//!
//! The batch is then picked greedily with a penalty for repeating an artist.
//! A `Ranker` supplies the weights and may narrow the candidates; discovery modes
//! are further `Ranker`s chosen with `?mode=`.
//! A `Ranker` supplies the weights, may narrow the candidates and may add a bonus.
//! Discovery modes (`Mode`) are further `Ranker`s chosen with `?mode=`.
use std::collections::{HashMap, HashSet};
@@ -40,6 +40,14 @@ pub trait Ranker: Send + Sync {
fn condition(&self) -> Option<&str> {
None
}
/// Drop candidates the SQL condition cannot express.
fn keep(&self, _track: &Track) -> bool {
true
}
/// Added to a candidate's score.
fn bonus(&self, _track: &Track) -> f64 {
0.0
}
}
/// The original mix.
@@ -62,11 +70,161 @@ impl Ranker for Classic {
}
}
pub fn by_name(mode: Option<&str>) -> Option<&'static dyn Ranker> {
match mode.unwrap_or("") {
"" | "classic" => Some(&Classic),
_ => None,
/// Discovery modes, chosen with `?mode=` (and `&artist=` for an artist radio).
#[derive(Clone, Debug, PartialEq)]
pub enum Mode {
Classic,
/// 140 BPM tracks, riddim first: tagged riddim, or by artists who make it or sit
/// next to them in the artist graph.
Riddim,
/// Little-known tracks: popularity counts against a track.
Deep,
/// Uploads of the last few weeks.
Fresh,
/// "More like this artist": the artist and their neighbours in the artist graph.
Artist(String),
}
impl Mode {
pub fn parse(mode: Option<&str>, artist: Option<&str>) -> Option<Mode> {
Some(match mode.unwrap_or("") {
"" | "classic" => Mode::Classic,
"riddim" => Mode::Riddim,
"deep" => Mode::Deep,
"fresh" => Mode::Fresh,
"artist" => Mode::Artist(artist?.to_string()),
_ => return None,
})
}
pub fn ranker(&self, conn: &Connection) -> rusqlite::Result<Box<dyn Ranker>> {
Ok(match self {
Mode::Classic => Box::new(Classic),
Mode::Riddim => Box::new(Riddim { affinity: tag_affinity(conn, "riddim")? }),
Mode::Deep => Box::new(Deep),
Mode::Fresh => Box::new(Fresh),
Mode::Artist(h) => Box::new(ArtistRadio { seed: h.clone(), near: neighbourhood(conn, h)? }),
})
}
}
struct Riddim {
affinity: HashMap<String, f64>,
}
const RIDDIM: Weights = Weights { popularity: 0.8, ..CLASSIC };
impl Ranker for Riddim {
fn weights(&self) -> &Weights {
&RIDDIM
}
fn condition(&self) -> Option<&str> {
Some("EXISTS (SELECT 1 FROM track_tags g WHERE g.track_id = t.id AND g.tag = '140')")
}
fn bonus(&self, t: &Track) -> f64 {
2.0 * self.affinity.get(&t.handle).copied().unwrap_or(0.0)
}
}
struct Deep;
const DEEP: Weights = Weights {
popularity: -0.8,
freshness: 0.3,
artist: 0.6,
tempo: 1.0,
key: 0.3,
related: 0.5,
unknown_artist: 1.0,
exploration: 0.8,
};
impl Ranker for Deep {
fn weights(&self) -> &Weights {
&DEEP
}
fn condition(&self) -> Option<&str> {
Some("t.saves <= 15")
}
}
struct Fresh;
const FRESH: Weights = Weights { freshness: 3.0, popularity: 0.6, ..CLASSIC };
impl Ranker for Fresh {
fn weights(&self) -> &Weights {
&FRESH
}
// pl8list dates look like 2026-10-31T18:34:13.657Z, so they compare as text.
fn condition(&self) -> Option<&str> {
Some("t.created_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-45 days')")
}
}
struct ArtistRadio {
seed: String,
/// Neighbours, 0..1 by how closely they are linked.
near: HashMap<String, f64>,
}
const RADIO: Weights = Weights { artist: 0.5, related: 0.5, ..CLASSIC };
impl Ranker for ArtistRadio {
fn weights(&self) -> &Weights {
&RADIO
}
fn keep(&self, t: &Track) -> bool {
t.handle == self.seed || self.near.contains_key(&t.handle)
}
// The artist themselves now and then; mostly the ones around them.
fn bonus(&self, t: &Track) -> f64 {
if t.handle == self.seed { 1.0 } else { 2.0 * self.near.get(&t.handle).copied().unwrap_or(0.0) }
}
}
/// Neighbours of an artist up to two hops away, scored 0..1. A second-hop artist
/// scores at most 0.4 of the link that leads to it.
fn neighbourhood(conn: &Connection, seed: &str) -> rusqlite::Result<HashMap<String, f64>> {
let scaled = |links: Vec<(String, i64)>| {
let max = links.iter().map(|l| l.1).max().unwrap_or(1).max(1) as f64;
links.into_iter().map(|(h, n)| (h, (n as f64 / max).sqrt())).collect::<Vec<_>>()
};
let first = scaled(crate::db::artist_neighbours(conn, seed)?);
let mut near: HashMap<String, f64> = first.iter().cloned().collect();
let mut by_score = first;
by_score.sort_by(|a, b| b.1.total_cmp(&a.1));
for (h, w) in by_score.into_iter().take(10) {
for (h2, w2) in scaled(crate::db::artist_neighbours(conn, &h)?) {
if h2 != seed {
let s = near.entry(h2).or_default();
*s = s.max(0.4 * w * w2);
}
}
}
Ok(near)
}
/// How strongly each artist is linked to `tag`, 0..1: their own tagged tracks (three
/// or more count fully), or half of what the artists next to them in the graph have.
pub fn tag_affinity(conn: &Connection, tag: &str) -> rusqlite::Result<HashMap<String, f64>> {
let own: HashMap<String, f64> = crate::db::tag_counts(conn, tag)?.into_iter().map(|(h, n)| (h, (n as f64 / 3.0).min(1.0))).collect();
let mut spread: HashMap<String, (f64, f64)> = HashMap::new();
for (a, b, n) in crate::db::artist_links(conn)? {
let n = n as f64;
for (from, to) in [(&a, &b), (&b, &a)] {
let e = spread.entry(to.clone()).or_default();
e.0 += n * own.get(from).copied().unwrap_or(0.0);
e.1 += n;
}
}
let mut out = own;
for (h, (sum, total)) in spread {
let s = out.entry(h).or_default();
*s = s.max(0.5 * sum / total);
}
out.retain(|_, v| *v > 0.0);
Ok(out)
}
/// Per-event contributions to artist affinity.
@@ -183,7 +341,8 @@ pub fn pick(conn: &Connection, user_id: i64, n: usize, f: Filters, ranker: &dyn
sql.push_str(c);
sql.push(')');
}
let cands: Vec<Track> = conn.prepare(&sql)?.query_map(params_from_iter(args), Track::from_row)?.collect::<Result<_, _>>()?;
let mut cands: Vec<Track> = conn.prepare(&sql)?.query_map(params_from_iter(args), Track::from_row)?.collect::<Result<_, _>>()?;
cands.retain(|t| ranker.keep(t));
if cands.is_empty() {
return Ok(vec![]);
}
@@ -217,7 +376,8 @@ pub fn pick(conn: &Connection, user_id: i64, n: usize, f: Filters, ranker: &dyn
+ w.key * if r.key.as_ref().is_some_and(|k| t.keys.contains(k)) { 1.0 } else { 0.0 }
+ w.related * if r.related_of.is_some_and(|id| t.positive_ids.contains(&id)) { 1.0 } else { 0.0 }
+ w.unknown_artist * if aff.is_none() { 1.0 } else { 0.0 }
+ w.exploration * -(-u.ln()).ln();
+ w.exploration * -(-u.ln()).ln()
+ ranker.bonus(&r);
(s, r)
})
.collect();
@@ -233,9 +393,19 @@ pub fn pick(conn: &Connection, user_id: i64, n: usize, f: Filters, ranker: &dyn
seen.push(r.handle.clone());
out.push(serialize(&r, Some(s)));
}
add_tags(conn, &mut out)?;
Ok(out)
}
fn add_tags(conn: &Connection, tracks: &mut [Value]) -> rusqlite::Result<()> {
for t in tracks {
if let Some(id) = t["id"].as_i64() {
t["tags"] = json!(crate::db::track_tags(conn, id)?);
}
}
Ok(())
}
pub fn serialize(r: &Track, score: Option<f64>) -> Value {
let preview = !r.full_access;
let wave = if preview && r.preview_waveform.is_some() { &r.preview_waveform } else { &r.waveform };
@@ -278,6 +448,7 @@ pub fn likes(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<Value>> {
out.push(serialize(&t, None));
}
}
add_tags(conn, &mut out)?;
Ok(out)
}
@@ -285,6 +456,84 @@ pub fn likes(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<Value>> {
mod tests {
use super::*;
use crate::db;
fn track(id: i64, handle: &str, title: &str, bpm: f64, saves: i64) -> Track {
Track {
id,
handle: handle.into(),
artist_name: handle.into(),
slug: format!("s{id}"),
title: title.into(),
bpm: Some(bpm),
saves,
full_access: true,
created_at: Some("2020-01-01T00:00:00.000Z".into()),
..Default::default()
}
}
fn ids(conn: &Connection, mode: Mode, n: usize) -> Vec<i64> {
let r = mode.ranker(conn).unwrap();
let mut out: Vec<i64> = pick(conn, 1, n, Filters::default(), &*r).unwrap().iter().map(|t| t["id"].as_i64().unwrap()).collect();
out.sort();
out
}
#[test]
fn riddim_mode_keeps_140_and_ranks_riddim_artists_and_their_neighbours_first() {
let mut c = db::memory();
let rows = [
track(1, "rid", "Wobble riddim", 140.0, 0),
track(2, "rid", "Other", 140.0, 0),
track(3, "near", "Plain", 140.0, 0),
track(4, "far", "Plain", 140.0, 500),
track(5, "dnb", "Roller", 174.0, 900),
];
db::upsert_tracks(&mut c, &rows, None).unwrap();
db::add_related(&mut c, 3, &["rid".into()]).unwrap();
let aff = tag_affinity(&c, "riddim").unwrap();
assert!((aff["rid"] - 1.0 / 3.0).abs() < 1e-9);
assert!(aff["near"] > 0.0 && aff["near"] < aff["rid"]);
assert!(!aff.contains_key("far"));
assert_eq!(ids(&c, Mode::Riddim, 10), [1, 2, 3, 4]);
let r = Mode::Riddim.ranker(&c).unwrap();
assert!(r.bonus(&rows[0]) > r.bonus(&rows[2]) && r.bonus(&rows[2]) > r.bonus(&rows[3]));
}
#[test]
fn artist_radio_stays_near_the_artist() {
let mut c = db::memory();
let rows = [track(1, "a", "T", 140.0, 0), track(2, "b", "T", 140.0, 0), track(3, "c", "T", 140.0, 0), track(4, "z", "T", 140.0, 0)];
db::upsert_tracks(&mut c, &rows, None).unwrap();
db::add_related(&mut c, 1, &["b".into()]).unwrap();
db::add_related(&mut c, 2, &["c".into()]).unwrap();
let near = neighbourhood(&c, "a").unwrap();
assert_eq!(near["b"], 1.0);
assert!((near["c"] - 0.4).abs() < 1e-9);
assert_eq!(ids(&c, Mode::Artist("a".into()), 10), [1, 2, 3]);
}
#[test]
fn deep_and_fresh_narrow_the_catalogue() {
let mut c = db::memory();
let mut new = track(3, "c", "T", 140.0, 0);
new.created_at = Some(c.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day')", [], |r| r.get(0)).unwrap());
db::upsert_tracks(&mut c, &[track(1, "a", "T", 140.0, 2), track(2, "b", "T", 140.0, 400), new], None).unwrap();
assert_eq!(ids(&c, Mode::Deep, 10), [1, 3]);
assert_eq!(ids(&c, Mode::Fresh, 10), [3]);
assert_eq!(ids(&c, Mode::Classic, 10), [1, 2, 3]);
}
#[test]
fn modes_parse() {
assert_eq!(Mode::parse(None, None), Some(Mode::Classic));
assert_eq!(Mode::parse(Some("riddim"), None), Some(Mode::Riddim));
assert_eq!(Mode::parse(Some("artist"), Some("x")), Some(Mode::Artist("x".into())));
assert_eq!(Mode::parse(Some("artist"), None), None);
assert_eq!(Mode::parse(Some("nope"), None), None);
}
#[test]
fn tempo_counts_half_and_double_time() {
assert!(tempo_match(Some(140.0), &[140.0]) > 0.99);
+105
View File
@@ -0,0 +1,105 @@
//! Tags for a track, read from its title, description, artist name and BPM.
//!
//! pl8list has no genre field, so this is keyword work. Producers name their files
//! after what they are ("… VIP", "… [DUBPACK]", "… (X FLIP) v2.wav"), which says a
//! lot about versions, and a little about style. Style tags are sparse: most riddim
//! is not called riddim. `ranker::tag_affinity` spreads them from tagged tracks to
//! their artists and the artists pl8list relates them to.
//!
//! Bump `VERSION` when the rules change; the catalogue is retagged on the next start.
pub const VERSION: i64 = 2;
fn words(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(str::to_lowercase)
.collect()
}
/// Tags of one track, sorted and without duplicates.
pub fn of(title: &str, description: Option<&str>, artist: &str, handle: &str, bpm: Option<f64>) -> Vec<&'static str> {
let amp = title.contains(" & ");
// File names: "x_y-z.wav" splits into words like any other title.
let title = words(title);
let mut text = title.clone();
text.extend(words(description.unwrap_or("")));
let has = |w: &str| text.iter().any(|t| t == w);
let pair = |a: &[&str], b: &[&str]| text.windows(2).any(|p| a.contains(&p[0].as_str()) && b.contains(&p[1].as_str()));
let triple = |a: &str, b: &[&str], c: &str| text.windows(3).any(|p| p[0] == a && b.contains(&p[1].as_str()) && p[2] == c);
let dnb = has("dnb") || pair(&["drum"], &["bass"]) || triple("drum", &["and", "n"], "bass");
let mut tags = vec![];
let mut tag = |on: bool, t: &'static str| {
if on {
tags.push(t);
}
};
let name = format!("{artist} {handle}").to_lowercase();
tag(has("riddim") || has("riddims") || has("riddimz") || name.contains("riddim"), "riddim");
tag(has("tearout") || pair(&["tear"], &["out"]), "tearout");
tag(has("deathstep"), "deathstep");
tag(has("brostep"), "brostep");
tag(has("colourbass") || has("colorbass") || pair(&["colour", "color"], &["bass"]), "colourbass");
tag(has("melodic"), "melodic");
tag(has("wonk") || has("wonky"), "wonk");
tag(has("halftime") || pair(&["half"], &["time"]), "halftime");
tag(dnb, "dnb");
tag(has("jungle"), "jungle");
tag(has("garage") || has("ukg") || has("2step"), "garage");
tag(has("grime"), "grime");
tag(has("trap"), "trap");
tag(has("vip"), "vip");
tag(has("dubpack") || pair(&["dub"], &["pack"]), "dubpack");
tag(has("dub") && !pair(&["dub"], &["pack"]), "dub");
tag(has("remix") || has("rmx"), "remix");
tag(has("flip"), "flip");
tag(has("bootleg"), "bootleg");
tag(has("edit"), "edit");
tag(has("spesh") || has("special"), "special");
let between = |w: &str| title.len() > 2 && title[1..title.len() - 1].iter().any(|t| t == w);
tag(has("collab") || has("ft") || has("feat") || between("x") || between("vs") || amp && !dnb, "collab");
tag(has("wip") || has("unfinished") || text.iter().any(|w| w.len() > 1 && w.starts_with('v') && w[1..].bytes().all(|b| b.is_ascii_digit())), "wip");
if let Some(b) = bpm.filter(|b| *b > 0.0) {
tag((138.0..=152.0).contains(&b) || (69.0..=76.0).contains(&b), "140");
tag((165.0..=180.0).contains(&b) || (82.5..=90.0).contains(&b), "170");
}
tags.sort_unstable();
tags.dedup();
tags
}
#[cfg(test)]
mod tests {
use super::*;
fn t(title: &str) -> Vec<&'static str> {
of(title, None, "Someone", "someone", None)
}
#[test]
fn reads_versions_from_file_names() {
assert_eq!(t("THRAX x JADHA - MR.WAYNE V1.wav"), ["collab", "wip"]);
assert_eq!(t("4. VIRTU - GANG VIP [THE VAULT DUBPACK VOL. 1].wav"), ["dubpack", "vip"]);
assert_eq!(t("KOH - FRACTURE [Cmadden SPESH].wav"), ["special"]);
assert_eq!(t("MOTUS & LOWER - FEENZ"), ["collab"]);
assert_eq!(t("LV VS 5CHIZO - HARDIONICFLOW.wav"), ["collab"]);
assert_eq!(t("Drum & Bass Roller"), ["dnb"]);
assert_eq!(t("Riddimo - Stacked Vip v1.mp3"), ["vip", "wip"]);
assert_eq!(t("SOMETHING (DUB)"), ["dub"]);
// Words inside other words do not count.
assert_eq!(t("VIPER - EDITION X"), Vec::<&str>::new());
}
#[test]
fn style_from_text_and_artist() {
assert_eq!(of("Track", Some("#Khmer #DNB #174bpm"), "A", "a", Some(174.0)), ["170", "dnb"]);
assert_eq!(of("Heavy one", None, "Riddim Lord", "lord", Some(70.0)), ["140", "riddim"]);
assert_eq!(of("tear out weapon", Some("drum n bass"), "A", "a", None), ["dnb", "tearout"]);
assert_eq!(of("x", None, "A", "a", Some(100.0)), Vec::<&str>::new());
}
}
+1 -1
View File
@@ -134,7 +134,7 @@
<ul>
<li><code>__Host-pl8inf</code>: Ihre Sitzung, bis zu 30 Tage ohne Nutzung. Wenn Sie verbunden bleiben, ist es auch Teil des Schlüssels für Ihre pl8list-Sitzung (Abschnitt 5).</li>
<li><code>__Host-pl8inf_login</code>: verbindet die beiden Schritte der Anmeldung, 15 Minuten.</li>
<li>Im lokalen Speicher Ihres Browsers: Ihre Filter (z. B. BPM-Bereich) und ob Sie den Bedienhinweis schon gesehen haben.</li>
<li>Im lokalen Speicher Ihres Browsers: Ihre Filter (z. B. BPM-Bereich), der gewählte Feed (z. B. „riddim“ oder ein Künstler-Radio) und ob Sie den Bedienhinweis schon gesehen haben.</li>
</ul>
<p>
Alle sind für den von Ihnen gewünschten Dienst unbedingt erforderlich (§ 25 Abs. 2 Nr. 2 TDDDG).
+10
View File
@@ -31,6 +31,7 @@
<header id="bar" hidden>
<span class="logo">pl8list <b>∞</b></span>
<button id="mode-chip" type="button" title="back to your feed" hidden></button>
<span id="stats"></span>
<button id="filters-btn" type="button" aria-controls="filters" aria-expanded="false">filters</button>
<button id="likes-btn" type="button" aria-controls="likes" aria-expanded="false">likes</button>
@@ -80,6 +81,13 @@
</div>
<form id="filters" class="panel" hidden>
<fieldset class="modes">
<legend>feed</legend>
<label><input type="radio" name="mode" value="classic"> for you</label>
<label><input type="radio" name="mode" value="riddim"> riddim</label>
<label><input type="radio" name="mode" value="fresh"> fresh</label>
<label><input type="radio" name="mode" value="deep"> deep cuts</label>
</fieldset>
<label><input type="checkbox" name="free"> free only</label>
<label>bpm <input type="number" name="bpm_min" min="40" max="250" placeholder="min"></label>
<label>– <input type="number" name="bpm_max" min="40" max="250" placeholder="max"></label>
@@ -137,6 +145,7 @@
<dt>↓ j · ↑ k · wheel</dt><dd>next / previous</dd>
<dt>← →</dt><dd>seek 10 s</dd>
<dt>f</dt><dd>follow artist</dd>
<dt>r</dt><dd>artist radio</dd>
<dt>x</dt><dd>not for me</dd>
<dt>s</dt><dd>share</dd>
<dt>m</dt><dd>mute</dd>
@@ -163,6 +172,7 @@
<div class="byline">
<a class="artist" target="_blank" rel="noopener"></a>
<button type="button" class="follow" title="follow artist (f)">follow</button>
<button type="button" class="radio" title="more like this artist (r)">radio</button>
</div>
<div class="chips"></div>
<p class="desc"></p>
+14 -2
View File
@@ -22,6 +22,8 @@ export interface Track {
upload_id: string | null;
waveform: number[] | null;
followed?: boolean;
/// From titles, descriptions and BPM, see backend/src/tags.rs.
tags?: string[];
}
export interface Stats {
@@ -49,6 +51,12 @@ export interface Me {
export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max", string>>;
/// Which part of the catalogue the feed draws from. `artist` is an artist radio:
/// that artist and the ones pl8list relates to them.
export type Mode =
| { mode: "classic" | "riddim" | "fresh" | "deep" }
| { mode: "artist"; artist: string; name: string };
export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike";
export class HttpError extends Error {
@@ -84,8 +92,12 @@ export function sendEvent(track: Track, kind: EventKind, seconds?: number): void
}
}
export const feed = (filters: Filters) =>
getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + new URLSearchParams({ n: "10", ...filters }));
export function feed(filters: Filters, mode: Mode) {
const p = new URLSearchParams({ n: "10", ...filters });
if (mode.mode !== "classic") p.set("mode", mode.mode);
if (mode.mode === "artist") p.set("artist", mode.artist);
return getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + p);
}
export const likes = () => getJSON<{ tracks: Track[] }>("/local/likes");
+4
View File
@@ -10,6 +10,7 @@ export interface CardActions {
share(card: Card): void;
next(card: Card): void;
follow(card: Card): void;
radio(card: Card): void;
/// Seeking only works on the playing card.
canSeek(card: Card): boolean;
duration(card: Card): number;
@@ -88,6 +89,8 @@ export class Card {
if (t.key) chip(t.key);
chip(t.price ? `$${t.price}` : "free", t.price ? "" : "free");
if (t.preview) chip("preview", "preview");
// Tempo tags repeat the BPM chip.
for (const tag of (t.tags ?? []).filter((g) => !/^\d+$/.test(g)).slice(0, 4)) chip(tag, "tag");
$(".counts").textContent =
`${t.saves} saves · ${t.downloads} downloads · ${t.comments} comments` +
@@ -98,6 +101,7 @@ export class Card {
$(".share").addEventListener("click", () => this.actions.share(this));
$(".next").addEventListener("click", () => this.actions.next(this));
$(".follow").addEventListener("click", () => this.actions.follow(this));
$(".radio").addEventListener("click", () => this.actions.radio(this));
this.el = el;
this.canvas = $<HTMLCanvasElement>(".wave");
+61 -6
View File
@@ -1,7 +1,7 @@
import "./style.css";
import * as api from "./api";
import type { Filters, Pl8Status, Stats, Track, User } from "./api";
import type { Filters, Mode, Pl8Status, Stats, Track, User } from "./api";
import { Card, type CardActions, cleanTitle } from "./cards";
import { Pager } from "./pager";
import { Player } from "./player";
@@ -14,6 +14,7 @@ const DOUBLE_TAP_MS = 300;
const PRELOAD_AFTER_MS = 2500; // start buffering the next track once this one has played a bit
const KEEP_BUILT = 3; // cards further than this from the current one drop their elements
const FILTERS_KEY = "pl8inf.filters";
const MODE_KEY = "pl8inf.mode";
const HINT_KEY = "pl8inf.hintShown";
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
@@ -39,6 +40,7 @@ const visual = new Visualizer(player);
const cards: Card[] = [];
let active = -1;
let loading = false;
let feedGen = 0; // bumped when the feed changes, so answers for the old one are dropped
let started = false;
let listened = 0; // seconds actually heard on the active card
let lastTime = 0;
@@ -46,6 +48,7 @@ let reported = false; // final event already sent for the active card
let advanced = false; // already mixed into the next track
let preloadTimer = 0;
let filters: Filters = loadFilters();
let mode: Mode = loadMode();
let stats: Stats | null = null;
const current = () => cards[active] as Card | undefined;
@@ -68,14 +71,18 @@ async function loadMore(): Promise<void> {
if (loading) return;
loading = true;
if (!cards.length) setStatus("crawling pl8list, first tracks in a few seconds…");
const gen = feedGen;
try {
const data = await api.feed(filters);
const data = await api.feed(filters, mode);
if (gen !== feedGen) return;
showStats(data.stats);
if (!data.tracks.length) {
setStatus(Object.keys(filters).length
? "nothing new matches your filters right now. loosen them, or check back in a bit."
setStatus(mode.mode === "artist"
? `nothing more around ${mode.name} yet. the crawler is looking up who they're related to, try again in a bit.`
: mode.mode !== "classic" || Object.keys(filters).length
? "nothing new matches this feed and your filters right now. loosen them, or check back in a bit."
: "nothing new right now, the crawler is still digging. try again in a bit.");
setTimeout(() => { loading = false; }, 8000);
setTimeout(() => { if (gen === feedGen) loading = false; }, 8000);
return;
}
setStatus("");
@@ -83,6 +90,7 @@ async function loadMore(): Promise<void> {
pager.refresh();
if (active < 0) activate(0);
} catch (err) {
if (gen !== feedGen) return;
if (err instanceof api.HttpError && err.status === 401) {
loading = false;
showLogin();
@@ -164,6 +172,7 @@ const actions: CardActions = {
share: (c) => share(c),
next: (c) => go(cards.indexOf(c) + 1),
follow: (c) => setFollow(c, !c.track.followed),
radio: (c) => setMode({ mode: "artist", artist: c.track.handle, name: c.track.artist_name }),
canSeek: (c) => c === current() && !!player.audio.duration,
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
seek: (_, s) => player.seek(s),
@@ -468,12 +477,56 @@ function applyFilters(): void {
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
showFilters();
closePanels();
// Drop cards after the current one so the new filter applies right away.
refill();
}
/// Drop cards after the current one so a new filter or mode applies right away.
function refill(): void {
for (const c of cards.splice(active + 1)) c.release();
pager.refresh();
feedGen++;
loading = false;
loadMore();
}
// Feed modes --------------------------------------------------------------
const modeChip = $("mode-chip");
const MODE_NAMES = { classic: "for you", riddim: "riddim", fresh: "fresh", deep: "deep cuts" };
function loadMode(): Mode {
try {
const m = JSON.parse(localStorage.getItem(MODE_KEY) || "null");
if (m?.mode === "artist" ? typeof m.artist === "string" && typeof m.name === "string" : m?.mode in MODE_NAMES) return m;
} catch { /* fall through */ }
return { mode: "classic" };
}
const modeName = (m: Mode) => (m.mode === "artist" ? `${m.name} radio` : MODE_NAMES[m.mode]);
function showMode(): void {
modeChip.hidden = mode.mode === "classic";
modeChip.textContent = `${modeName(mode)} ×`;
for (const input of filtersForm.querySelectorAll<HTMLInputElement>("input[name=mode]")) input.checked = input.value === mode.mode;
}
function setMode(m: Mode): void {
if (JSON.stringify(m) === JSON.stringify(mode)) return;
mode = m;
localStorage.setItem(MODE_KEY, JSON.stringify(mode));
showMode();
closePanels();
toast(m.mode === "classic" ? "back to your feed" : `now playing: ${modeName(m)}`);
refill();
}
modeChip.addEventListener("click", () => setMode({ mode: "classic" }));
filtersForm.addEventListener("change", (e) => {
const input = e.target as HTMLInputElement;
if (input.name === "mode") setMode({ mode: input.value as "classic" | "riddim" | "fresh" | "deep" });
});
showMode();
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
$("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); });
@@ -503,6 +556,7 @@ document.addEventListener("keydown", (e) => {
else if (k === "ArrowLeft") player.seek(a.currentTime - 10);
else if (k === "l" && card) setLike(card, !card.liked);
else if (k === "f" && card) setFollow(card, !card.track.followed);
else if (k === "r" && card) actions.radio(card);
else if (k === "x" && card) dislike(card);
else if (k === "s" && card) share(card);
else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); }
@@ -659,6 +713,7 @@ deleteForm.addEventListener("submit", async (e) => {
try {
await api.postJSON("/account/delete", { confirm: deleteConfirm.value.trim() });
localStorage.removeItem(FILTERS_KEY);
localStorage.removeItem(MODE_KEY);
localStorage.removeItem(HINT_KEY);
location.reload();
} catch (err) {
+22 -2
View File
@@ -82,6 +82,13 @@ button {
vertical-align: middle;
}
#bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
#mode-chip {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: color-mix(in oklab, var(--accent) 40%, transparent);
}
.panel {
position: fixed;
@@ -95,7 +102,19 @@ button {
box-shadow: 0 16px 40px #000a;
}
#filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
#filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; max-width: 24rem; }
.modes { display: flex; gap: 0.4rem; flex-wrap: wrap; flex-basis: 100%; margin: 0; padding: 0; border: 0; }
.modes legend { color: var(--muted); font-size: 0.8rem; padding: 0; margin-bottom: 0.3rem; }
.modes label { cursor: pointer; }
.modes input { position: absolute; opacity: 0; }
.modes label:has(input) {
padding: 0.25em 0.8em;
border-radius: 999px;
border: 1px solid #ffffff40;
font-size: 0.85rem;
}
.modes label:has(input:checked) { background: var(--accent); border-color: transparent; color: #000; }
.modes label:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
#filters input[type=number] {
width: 4.5em;
background: #222;
@@ -266,7 +285,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
.byline { display: flex; align-items: center; gap: 0.6rem; }
.artist { color: var(--muted); text-decoration: none; }
.artist:hover { text-decoration: underline; }
.follow {
.follow, .radio {
font-size: 0.75rem;
padding: 0.15em 0.75em;
border-radius: 999px;
@@ -285,6 +304,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
.chips .free { background: #1f7a4d; }
.chips span:first-child { background: color-mix(in oklab, var(--card-accent) 35%, transparent); }
.chips .preview { background: #6a4b16; }
.chips .tag { background: transparent; box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--card-accent) 60%, transparent); }
.desc {
margin: 0;