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

+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());
}
}