mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
b4470850bf
commit
f6c505907a
14 files changed
+737
-36
No files matched your search
@@ -120,14 +120,17 @@ visitor address reaches pl8list or a CDN.
|
|||||||
| swipe up / down | ↓ j · ↑ k | next / previous |
|
| swipe up / down | ↓ j · ↑ k | next / previous |
|
||||||
| drag waveform | drag waveform · ← → | seek |
|
| drag waveform | drag waveform · ← → | seek |
|
||||||
| follow button | f | follow the artist |
|
| follow button | f | follow the artist |
|
||||||
|
| radio button | r | artist radio (more like them) |
|
||||||
| ✕ button | x | not for me (skip, show less) |
|
| ✕ button | x | not for me (skip, show less) |
|
||||||
| share button | s | share / copy link |
|
| share button | s | share / copy link |
|
||||||
| | m | mute |
|
| | m | mute |
|
||||||
| | ? · Esc | help · close panels |
|
| | ? · Esc | help · close panels |
|
||||||
|
|
||||||
Lock-screen, notification and headphone buttons control playback too (Media Session API).
|
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)
|
"likes" lists your likes newest first; ▶ plays one right away. The filters panel picks the
|
||||||
are remembered per browser. The site can be installed to the home screen as an app.
|
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
|
## 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
|
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
|
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
|
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
|
row. Weights live in a `Ranker`, which can also narrow the candidates and add a bonus;
|
||||||
can be added next to `Classic`.
|
`?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
|
**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
|
pl8list as a guest, so users hear previews of paid tracks and full free tracks, the same as a
|
||||||
|
|||||||
+34
-6
@@ -28,16 +28,19 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// Search needs at least 3 characters. Genre-ish words find lots of artist handles.
|
/// 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] = &[
|
const SEARCH_TERMS: &[&str] = &[
|
||||||
"dub", "bass", "dnb", "jungle", "garage", "grime", "riddim", "dubstep", "trap", "techno",
|
"dub", "bass", "dnb", "jungle", "garage", "grime", "trap", "techno", "house", "beats", "music",
|
||||||
"house", "beats", "music", "sound", "prod", "audio", "records", "drill", "breaks", "wave",
|
"sound", "prod", "audio", "records", "drill", "breaks", "wave", "deep", "dark", "the",
|
||||||
"deep", "dark", "the", "official", "dj", "mc", "lofi", "halftime", "neuro", "tek",
|
"official", "dj", "mc", "lofi", "neuro", "tek",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lower number = sooner.
|
// Lower number = sooner.
|
||||||
const P_FEEDBACK: i64 = 0;
|
const P_FEEDBACK: i64 = 0;
|
||||||
const P_SEED: i64 = 10;
|
const P_SEED: i64 = 10;
|
||||||
const P_ARTIST_FIRST: i64 = 20;
|
const P_ARTIST_FIRST: i64 = 20;
|
||||||
|
const P_STYLE: i64 = 15;
|
||||||
const P_RELATED: i64 = 30;
|
const P_RELATED: i64 = 30;
|
||||||
const P_ARTIST_MORE: i64 = 40;
|
const P_ARTIST_MORE: i64 = 40;
|
||||||
const P_SEARCH: i64 = 60;
|
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 -------------------------------------------------------------
|
// Main loop -------------------------------------------------------------
|
||||||
|
|
||||||
pub fn start(self: &Arc<Self>) {
|
pub fn start(self: &Arc<Self>) {
|
||||||
@@ -181,6 +202,9 @@ impl Crawler {
|
|||||||
self.push(P_SEED, Job::Home);
|
self.push(P_SEED, Job::Home);
|
||||||
self.push(P_SEED, Job::Ticker);
|
self.push(P_SEED, Job::Ticker);
|
||||||
self.push(P_SEED, Job::BestArtists);
|
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 {
|
for term in SEARCH_TERMS {
|
||||||
self.push(P_SEARCH, Job::Search(term.to_string()));
|
self.push(P_SEARCH, Job::Search(term.to_string()));
|
||||||
}
|
}
|
||||||
@@ -206,8 +230,9 @@ impl Crawler {
|
|||||||
)?;
|
)?;
|
||||||
let related: Vec<(i64, String, String)> = c
|
let related: Vec<(i64, String, String)> = c
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, handle, slug FROM tracks WHERE related_fetched=0 \
|
"SELECT id, handle, slug FROM tracks t WHERE related_fetched=0 ORDER BY \
|
||||||
ORDER BY saves + downloads DESC LIMIT 10",
|
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)?)))?
|
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||||
.collect::<Result<_, _>>()?;
|
.collect::<Result<_, _>>()?;
|
||||||
@@ -312,6 +337,8 @@ impl Crawler {
|
|||||||
let rows = normalize::related(&d);
|
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();
|
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?;
|
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 {
|
for (h, name, img, verified) in artists {
|
||||||
self.add_artist(h, "related", Some(name), img, verified, P_RELATED).await?;
|
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 };
|
let Some(a) = item.get("artist") else { continue };
|
||||||
if let Some(h) = s(a, "handle") {
|
if let Some(h) = s(a, "handle") {
|
||||||
let verified = a.get("isVerified").and_then(Value::as_bool).unwrap_or(false);
|
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?;
|
self.add_artist(h, "search", s(a, "name"), s(a, "img"), verified, prio).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-5
@@ -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 {
|
pub fn now() -> f64 {
|
||||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_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 {
|
impl Db {
|
||||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
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, "journal_mode", "WAL")?;
|
||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||||
migrate(&conn)?;
|
migrate(&conn)?;
|
||||||
|
let tagged = retag_if_stale(&mut conn)?;
|
||||||
|
if tagged > 0 {
|
||||||
|
tracing::info!("tagged {tagged} tracks");
|
||||||
|
}
|
||||||
Ok(Db(Arc::new(Mutex::new(conn))))
|
Ok(Db(Arc::new(Mutex::new(conn))))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,9 +189,104 @@ fn migrate(conn: &Connection) -> rusqlite::Result<()> {
|
|||||||
if version < 3 {
|
if version < 3 {
|
||||||
conn.execute_batch(&format!("BEGIN; {SCHEMA_V3} PRAGMA user_version = 3; COMMIT;"))?;
|
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(())
|
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 ------------------------------------------------------------------
|
// Tracks ------------------------------------------------------------------
|
||||||
|
|
||||||
/// One row of `tracks`, as normalised from pl8list or read back.
|
/// 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))?;
|
insert.execute(params_from_iter(vals))?;
|
||||||
}
|
}
|
||||||
artist.execute(params![r.handle, r.artist_name, r.artist_img, r.artist_verified as i64])?;
|
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()?;
|
tx.commit()?;
|
||||||
@@ -622,10 +745,35 @@ pub fn purge(conn: &Connection) -> rusqlite::Result<(usize, usize)> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn mem() -> Connection {
|
use super::memory as mem;
|
||||||
let c = Connection::open_in_memory().unwrap();
|
|
||||||
migrate(&c).unwrap();
|
#[test]
|
||||||
c
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -61,9 +61,14 @@ pub async fn feed(State(app): St, Extension(current): Extension<CurrentUser>, Qu
|
|||||||
Ok(u) => u.clone(),
|
Ok(u) => u.clone(),
|
||||||
Err(r) => return Ok(r),
|
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"));
|
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 n = num(&q, "n").map(|n| n as usize).unwrap_or(10).clamp(1, 30);
|
||||||
let filters = ranker::Filters {
|
let filters = ranker::Filters {
|
||||||
free_only: q.get("free").map(String::as_str) == Some("1"),
|
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 deadline = Instant::now() + FEED_WAIT;
|
||||||
let uid = user.id;
|
let uid = user.id;
|
||||||
let mut tracks = loop {
|
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 {
|
if !tracks.is_empty() || Instant::now() >= deadline {
|
||||||
break tracks;
|
break tracks;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_eq!(app.sync.key.sealer(&new, 5).open(&new_blob.unwrap()).unwrap().expose_secret(), "PL8");
|
||||||
assert!(old_blob.is_none());
|
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);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ mod pl8;
|
|||||||
mod ranker;
|
mod ranker;
|
||||||
mod ratelimit;
|
mod ratelimit;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
mod tags;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
|
|||||||
+257
-8
@@ -11,8 +11,8 @@
|
|||||||
//! exploration Gumbel noise, so the feed never freezes into one niche
|
//! exploration Gumbel noise, so the feed never freezes into one niche
|
||||||
//!
|
//!
|
||||||
//! The batch is then picked greedily with a penalty for repeating an artist.
|
//! 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
|
//! A `Ranker` supplies the weights, may narrow the candidates and may add a bonus.
|
||||||
//! are further `Ranker`s chosen with `?mode=`.
|
//! Discovery modes (`Mode`) are further `Ranker`s chosen with `?mode=`.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
@@ -40,6 +40,14 @@ pub trait Ranker: Send + Sync {
|
|||||||
fn condition(&self) -> Option<&str> {
|
fn condition(&self) -> Option<&str> {
|
||||||
None
|
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.
|
/// The original mix.
|
||||||
@@ -62,11 +70,161 @@ impl Ranker for Classic {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn by_name(mode: Option<&str>) -> Option<&'static dyn Ranker> {
|
/// Discovery modes, chosen with `?mode=` (and `&artist=` for an artist radio).
|
||||||
match mode.unwrap_or("") {
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
"" | "classic" => Some(&Classic),
|
pub enum Mode {
|
||||||
_ => None,
|
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.
|
/// 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_str(c);
|
||||||
sql.push(')');
|
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() {
|
if cands.is_empty() {
|
||||||
return Ok(vec![]);
|
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.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.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.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)
|
(s, r)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -233,9 +393,19 @@ pub fn pick(conn: &Connection, user_id: i64, n: usize, f: Filters, ranker: &dyn
|
|||||||
seen.push(r.handle.clone());
|
seen.push(r.handle.clone());
|
||||||
out.push(serialize(&r, Some(s)));
|
out.push(serialize(&r, Some(s)));
|
||||||
}
|
}
|
||||||
|
add_tags(conn, &mut out)?;
|
||||||
Ok(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 {
|
pub fn serialize(r: &Track, score: Option<f64>) -> Value {
|
||||||
let preview = !r.full_access;
|
let preview = !r.full_access;
|
||||||
let wave = if preview && r.preview_waveform.is_some() { &r.preview_waveform } else { &r.waveform };
|
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));
|
out.push(serialize(&t, None));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
add_tags(conn, &mut out)?;
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +456,84 @@ pub fn likes(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<Value>> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn tempo_counts_half_and_double_time() {
|
fn tempo_counts_half_and_double_time() {
|
||||||
assert!(tempo_match(Some(140.0), &[140.0]) > 0.99);
|
assert!(tempo_match(Some(140.0), &[140.0]) > 0.99);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<ul>
|
<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</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><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>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
Alle sind für den von Ihnen gewünschten Dienst unbedingt erforderlich (§ 25 Abs. 2 Nr. 2 TDDDG).
|
Alle sind für den von Ihnen gewünschten Dienst unbedingt erforderlich (§ 25 Abs. 2 Nr. 2 TDDDG).
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
|
|
||||||
<header id="bar" hidden>
|
<header id="bar" hidden>
|
||||||
<span class="logo">pl8list <b>∞</b></span>
|
<span class="logo">pl8list <b>∞</b></span>
|
||||||
|
<button id="mode-chip" type="button" title="back to your feed" hidden></button>
|
||||||
<span id="stats"></span>
|
<span id="stats"></span>
|
||||||
<button id="filters-btn" type="button" aria-controls="filters" aria-expanded="false">filters</button>
|
<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>
|
<button id="likes-btn" type="button" aria-controls="likes" aria-expanded="false">likes</button>
|
||||||
@@ -80,6 +81,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="filters" class="panel" hidden>
|
<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><input type="checkbox" name="free"> free only</label>
|
||||||
<label>bpm <input type="number" name="bpm_min" min="40" max="250" placeholder="min"></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>
|
<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>↓ j · ↑ k · wheel</dt><dd>next / previous</dd>
|
||||||
<dt>← →</dt><dd>seek 10 s</dd>
|
<dt>← →</dt><dd>seek 10 s</dd>
|
||||||
<dt>f</dt><dd>follow artist</dd>
|
<dt>f</dt><dd>follow artist</dd>
|
||||||
|
<dt>r</dt><dd>artist radio</dd>
|
||||||
<dt>x</dt><dd>not for me</dd>
|
<dt>x</dt><dd>not for me</dd>
|
||||||
<dt>s</dt><dd>share</dd>
|
<dt>s</dt><dd>share</dd>
|
||||||
<dt>m</dt><dd>mute</dd>
|
<dt>m</dt><dd>mute</dd>
|
||||||
@@ -163,6 +172,7 @@
|
|||||||
<div class="byline">
|
<div class="byline">
|
||||||
<a class="artist" target="_blank" rel="noopener"></a>
|
<a class="artist" target="_blank" rel="noopener"></a>
|
||||||
<button type="button" class="follow" title="follow artist (f)">follow</button>
|
<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>
|
||||||
<div class="chips"></div>
|
<div class="chips"></div>
|
||||||
<p class="desc"></p>
|
<p class="desc"></p>
|
||||||
|
|||||||
+14
-2
@@ -22,6 +22,8 @@ export interface Track {
|
|||||||
upload_id: string | null;
|
upload_id: string | null;
|
||||||
waveform: number[] | null;
|
waveform: number[] | null;
|
||||||
followed?: boolean;
|
followed?: boolean;
|
||||||
|
/// From titles, descriptions and BPM, see backend/src/tags.rs.
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stats {
|
export interface Stats {
|
||||||
@@ -49,6 +51,12 @@ export interface Me {
|
|||||||
|
|
||||||
export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max", string>>;
|
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 type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike";
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
@@ -84,8 +92,12 @@ export function sendEvent(track: Track, kind: EventKind, seconds?: number): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const feed = (filters: Filters) =>
|
export function feed(filters: Filters, mode: Mode) {
|
||||||
getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + new URLSearchParams({ n: "10", ...filters }));
|
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");
|
export const likes = () => getJSON<{ tracks: Track[] }>("/local/likes");
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface CardActions {
|
|||||||
share(card: Card): void;
|
share(card: Card): void;
|
||||||
next(card: Card): void;
|
next(card: Card): void;
|
||||||
follow(card: Card): void;
|
follow(card: Card): void;
|
||||||
|
radio(card: Card): void;
|
||||||
/// Seeking only works on the playing card.
|
/// Seeking only works on the playing card.
|
||||||
canSeek(card: Card): boolean;
|
canSeek(card: Card): boolean;
|
||||||
duration(card: Card): number;
|
duration(card: Card): number;
|
||||||
@@ -88,6 +89,8 @@ export class Card {
|
|||||||
if (t.key) chip(t.key);
|
if (t.key) chip(t.key);
|
||||||
chip(t.price ? `$${t.price}` : "free", t.price ? "" : "free");
|
chip(t.price ? `$${t.price}` : "free", t.price ? "" : "free");
|
||||||
if (t.preview) chip("preview", "preview");
|
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 =
|
$(".counts").textContent =
|
||||||
`${t.saves} saves · ${t.downloads} downloads · ${t.comments} comments` +
|
`${t.saves} saves · ${t.downloads} downloads · ${t.comments} comments` +
|
||||||
@@ -98,6 +101,7 @@ export class Card {
|
|||||||
$(".share").addEventListener("click", () => this.actions.share(this));
|
$(".share").addEventListener("click", () => this.actions.share(this));
|
||||||
$(".next").addEventListener("click", () => this.actions.next(this));
|
$(".next").addEventListener("click", () => this.actions.next(this));
|
||||||
$(".follow").addEventListener("click", () => this.actions.follow(this));
|
$(".follow").addEventListener("click", () => this.actions.follow(this));
|
||||||
|
$(".radio").addEventListener("click", () => this.actions.radio(this));
|
||||||
|
|
||||||
this.el = el;
|
this.el = el;
|
||||||
this.canvas = $<HTMLCanvasElement>(".wave");
|
this.canvas = $<HTMLCanvasElement>(".wave");
|
||||||
|
|||||||
+61
-6
@@ -1,7 +1,7 @@
|
|||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
import * as api from "./api";
|
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 { Card, type CardActions, cleanTitle } from "./cards";
|
||||||
import { Pager } from "./pager";
|
import { Pager } from "./pager";
|
||||||
import { Player } from "./player";
|
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 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 KEEP_BUILT = 3; // cards further than this from the current one drop their elements
|
||||||
const FILTERS_KEY = "pl8inf.filters";
|
const FILTERS_KEY = "pl8inf.filters";
|
||||||
|
const MODE_KEY = "pl8inf.mode";
|
||||||
const HINT_KEY = "pl8inf.hintShown";
|
const HINT_KEY = "pl8inf.hintShown";
|
||||||
|
|
||||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||||
@@ -39,6 +40,7 @@ const visual = new Visualizer(player);
|
|||||||
const cards: Card[] = [];
|
const cards: Card[] = [];
|
||||||
let active = -1;
|
let active = -1;
|
||||||
let loading = false;
|
let loading = false;
|
||||||
|
let feedGen = 0; // bumped when the feed changes, so answers for the old one are dropped
|
||||||
let started = false;
|
let started = false;
|
||||||
let listened = 0; // seconds actually heard on the active card
|
let listened = 0; // seconds actually heard on the active card
|
||||||
let lastTime = 0;
|
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 advanced = false; // already mixed into the next track
|
||||||
let preloadTimer = 0;
|
let preloadTimer = 0;
|
||||||
let filters: Filters = loadFilters();
|
let filters: Filters = loadFilters();
|
||||||
|
let mode: Mode = loadMode();
|
||||||
let stats: Stats | null = null;
|
let stats: Stats | null = null;
|
||||||
|
|
||||||
const current = () => cards[active] as Card | undefined;
|
const current = () => cards[active] as Card | undefined;
|
||||||
@@ -68,14 +71,18 @@ async function loadMore(): Promise<void> {
|
|||||||
if (loading) return;
|
if (loading) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
if (!cards.length) setStatus("crawling pl8list, first tracks in a few seconds…");
|
if (!cards.length) setStatus("crawling pl8list, first tracks in a few seconds…");
|
||||||
|
const gen = feedGen;
|
||||||
try {
|
try {
|
||||||
const data = await api.feed(filters);
|
const data = await api.feed(filters, mode);
|
||||||
|
if (gen !== feedGen) return;
|
||||||
showStats(data.stats);
|
showStats(data.stats);
|
||||||
if (!data.tracks.length) {
|
if (!data.tracks.length) {
|
||||||
setStatus(Object.keys(filters).length
|
setStatus(mode.mode === "artist"
|
||||||
? "nothing new matches your filters right now. loosen them, or check back in a bit."
|
? `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.");
|
: "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;
|
return;
|
||||||
}
|
}
|
||||||
setStatus("");
|
setStatus("");
|
||||||
@@ -83,6 +90,7 @@ async function loadMore(): Promise<void> {
|
|||||||
pager.refresh();
|
pager.refresh();
|
||||||
if (active < 0) activate(0);
|
if (active < 0) activate(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (gen !== feedGen) return;
|
||||||
if (err instanceof api.HttpError && err.status === 401) {
|
if (err instanceof api.HttpError && err.status === 401) {
|
||||||
loading = false;
|
loading = false;
|
||||||
showLogin();
|
showLogin();
|
||||||
@@ -164,6 +172,7 @@ const actions: CardActions = {
|
|||||||
share: (c) => share(c),
|
share: (c) => share(c),
|
||||||
next: (c) => go(cards.indexOf(c) + 1),
|
next: (c) => go(cards.indexOf(c) + 1),
|
||||||
follow: (c) => setFollow(c, !c.track.followed),
|
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,
|
canSeek: (c) => c === current() && !!player.audio.duration,
|
||||||
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
|
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
|
||||||
seek: (_, s) => player.seek(s),
|
seek: (_, s) => player.seek(s),
|
||||||
@@ -468,12 +477,56 @@ function applyFilters(): void {
|
|||||||
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
|
||||||
showFilters();
|
showFilters();
|
||||||
closePanels();
|
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();
|
for (const c of cards.splice(active + 1)) c.release();
|
||||||
pager.refresh();
|
pager.refresh();
|
||||||
|
feedGen++;
|
||||||
|
loading = false;
|
||||||
loadMore();
|
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));
|
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
|
||||||
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
|
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
|
||||||
$("filters-clear").addEventListener("click", () => { filtersForm.reset(); 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 === "ArrowLeft") player.seek(a.currentTime - 10);
|
||||||
else if (k === "l" && card) setLike(card, !card.liked);
|
else if (k === "l" && card) setLike(card, !card.liked);
|
||||||
else if (k === "f" && card) setFollow(card, !card.track.followed);
|
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 === "x" && card) dislike(card);
|
||||||
else if (k === "s" && card) share(card);
|
else if (k === "s" && card) share(card);
|
||||||
else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); }
|
else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); }
|
||||||
@@ -659,6 +713,7 @@ deleteForm.addEventListener("submit", async (e) => {
|
|||||||
try {
|
try {
|
||||||
await api.postJSON("/account/delete", { confirm: deleteConfirm.value.trim() });
|
await api.postJSON("/account/delete", { confirm: deleteConfirm.value.trim() });
|
||||||
localStorage.removeItem(FILTERS_KEY);
|
localStorage.removeItem(FILTERS_KEY);
|
||||||
|
localStorage.removeItem(MODE_KEY);
|
||||||
localStorage.removeItem(HINT_KEY);
|
localStorage.removeItem(HINT_KEY);
|
||||||
location.reload();
|
location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+22
-2
@@ -82,6 +82,13 @@ button {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
#bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
|
#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 {
|
.panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -95,7 +102,19 @@ button {
|
|||||||
box-shadow: 0 16px 40px #000a;
|
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] {
|
#filters input[type=number] {
|
||||||
width: 4.5em;
|
width: 4.5em;
|
||||||
background: #222;
|
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; }
|
.byline { display: flex; align-items: center; gap: 0.6rem; }
|
||||||
.artist { color: var(--muted); text-decoration: none; }
|
.artist { color: var(--muted); text-decoration: none; }
|
||||||
.artist:hover { text-decoration: underline; }
|
.artist:hover { text-decoration: underline; }
|
||||||
.follow {
|
.follow, .radio {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.15em 0.75em;
|
padding: 0.15em 0.75em;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -285,6 +304,7 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
|
|||||||
.chips .free { background: #1f7a4d; }
|
.chips .free { background: #1f7a4d; }
|
||||||
.chips span:first-child { background: color-mix(in oklab, var(--card-accent) 35%, transparent); }
|
.chips span:first-child { background: color-mix(in oklab, var(--card-accent) 35%, transparent); }
|
||||||
.chips .preview { background: #6a4b16; }
|
.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 {
|
.desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Reference in new issue
Block a user