Turn artist radio into a station sheet with reasons on each track

The radio button (r) opens a sheet with the artist, their tags and their closest
neighbours instead of switching the feed at once; tapping a neighbour walks the
artist graph. Starting jumps to the radio's first track, the top-bar chip shows the
station, each radio card says why it is there, and an end card offers the way back
when the radio runs dry.

GET /local/artist feeds the sheet and starts crawling a new artist's neighbourhood
(at most every 10 minutes per artist). Artist-radio tracks carry why: seed, related,
or via the neighbour that leads to them.
This commit is contained in:
nvrl committed 2026-11-01 14:32:51 +01:00
1 parent e8c0f40128
commit 76cfbb12ac
13 files changed
+654 -42

No files matched your search

+41
View File
@@ -144,6 +144,47 @@ pub async fn event(State(app): St, Extension(current): Extension<CurrentUser>, J
Ok((no_store(), Json(json!({ "ok": true }))).into_response())
}
/// How many neighbours the radio sheet shows, and how few count as still exploring.
const SHEET_NEIGHBOURS: usize = 12;
const FEW_NEIGHBOURS: usize = 3;
/// An artist and the ones next to them in the artist graph, for the radio sheet.
/// Starts crawling the neighbourhood when it is new, so the sheet can poll while
/// `exploring` is true.
pub async fn artist(State(app): St, Extension(current): Extension<CurrentUser>, Query(q): Query<HashMap<String, String>>) -> Res {
let uid = match current.require() {
Ok(u) => u.id,
Err(r) => return Ok(r),
};
let Some(handle) = q.get("handle").filter(|h| valid_handle(h)).cloned() else {
return Ok(StatusCode::BAD_REQUEST.into_response());
};
app.crawler.explore_artist(handle.clone()).await;
let body = app
.db
.call(move |c| {
let Some(artist) = db::artist(c, &handle)? else { return Ok(None) };
let mut neighbours = vec![];
for (h, weight) in ranker::scaled_neighbours(c, &handle)?.into_iter().take(SHEET_NEIGHBOURS) {
if let Some(a) = db::artist(c, &h)? {
neighbours.push(json!({ "handle": a.handle, "name": a.name, "img": a.img, "weight": weight }));
}
}
Ok(Some(json!({
"followed": db::follows(c, uid)?.contains(&handle),
"tags": db::artist_tags(c, &handle, 4)?,
"exploring": neighbours.len() < FEW_NEIGHBOURS,
"artist": artist,
"neighbours": neighbours,
})))
})
.await?;
Ok(match body {
Some(b) => (no_store(), Json(b)).into_response(),
None => json_error(StatusCode::NOT_FOUND, "unknown artist"),
})
}
/// Valid pl8list handles, as seen in every handle crawled so far.
fn valid_handle(h: &str) -> bool {
(1..=64).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) && h != "." && h != ".."
+1
View File
@@ -316,6 +316,7 @@ pub fn router(app: Arc<App>) -> Router {
.route("/local/me", get(app::me))
.route("/local/feed", get(app::feed))
.route("/local/likes", get(app::likes))
.route("/local/artist", get(app::artist))
.route("/local/event", post(app::event))
.route("/local/follow", post(app::follow))
.route("/account/export", get(account::export))
+47 -1
View File
@@ -1,6 +1,6 @@
//! End-to-end tests of the HTTP API against a fake pl8list.
use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
use axum::{
Router,
@@ -417,3 +417,49 @@ async fn feed_modes_filter_and_tag_tracks() {
let res = get("/local/feed?mode=artist&artist=h").await.unwrap();
assert_eq!(json_body(res).await["tracks"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn artist_sheet_and_radio_reasons() {
let (r, app, _) = setup("radio", "http://127.0.0.1:9").await;
let token = app
.db
.call(|c| {
let t = |id, handle: &str, title: &str| crate::db::Track {
id,
handle: handle.into(),
artist_name: handle.to_uppercase(),
slug: format!("s{id}"),
title: title.into(),
bpm: Some(140.0),
full_access: true,
..Default::default()
};
crate::db::upsert_tracks(c, &[t(1, "a", "Riddim one"), t(2, "b", "Two"), t(3, "c", "Three")], None)?;
crate::db::add_related(c, 1, &["b".into()])?;
crate::db::add_related(c, 2, &["c".into()])?;
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/artist?handle=a").await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = json_body(res).await;
assert_eq!(body["artist"]["name"], "A");
assert_eq!(body["tags"], serde_json::json!(["riddim"]));
assert_eq!(body["neighbours"][0]["handle"], "b");
assert_eq!(body["exploring"], true);
assert_eq!(get("/local/artist?handle=..").await.unwrap().status(), StatusCode::BAD_REQUEST);
assert_eq!(get("/local/artist?handle=nobody").await.unwrap().status(), StatusCode::NOT_FOUND);
let res = get("/local/feed?mode=artist&artist=a").await.unwrap();
let body = json_body(res).await;
let why: HashMap<i64, serde_json::Value> =
body["tracks"].as_array().unwrap().iter().map(|t| (t["id"].as_i64().unwrap(), t["why"].clone())).collect();
assert_eq!(why[&1], serde_json::json!({ "kind": "seed" }));
assert_eq!(why[&2], serde_json::json!({ "kind": "related" }));
assert_eq!(why[&3], serde_json::json!({ "kind": "via", "handle": "b", "name": "B" }));
}