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

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