diff --git a/README.md b/README.md index f3230a1..7ee0894 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ Not affiliated with pl8list. - **One track per screen.** Swipe, scroll or press ↓. The next track is already buffered and crossfades in. -- **A feed that learns.** Likes, finished plays, skips and follows shape what - comes next. Four feeds: for you, riddim, fresh and deep cuts. +- **Discovery.** A personalized feed, new tracks, deep cuts, and pl8list's best-seller + and free-download charts for this week, this year, or all time. - **Artist radio.** Start a radio from any artist: them and their neighbours in the artist graph, with a reason on each track. - **Liquid artwork.** A WebGL drop that moves with the kicks, snares and wobble of the track. @@ -176,7 +176,9 @@ visitor address reaches pl8list or a CDN. | | ? · Esc | help · close panels | Lock-screen, notification and headphone buttons control playback too (Media Session API). -The tabs in the top bar pick the feed (for you, riddim, fresh, deep cuts). "likes" lists your +The tabs in the top bar pick the feed (for you, charts, new, deep cuts). Charts keep +pl8list's ordering and include tracks you've heard before. Tempo presets include half and +double time; custom BPM ranges match the exact value. "likes" lists your likes newest first, with search; "play" queues them in order or shuffled, and ▶ plays one right away. "history" lists what played lately, kept on the device only. The filters panel holds the filters and the settings: visuals, ferrofluid spikes, crossfade, bass boost, @@ -266,6 +268,9 @@ row. Weights live in a `Ranker`, which can also narrow the candidates and add a | `deep` | tracks with 15 saves or fewer; popularity counts against a track | | `artist&artist=` | artist radio: the artist and their neighbours in the artist graph (two hops) | +`/local/charts?kind=sales|free&period=week|year|all-time` returns the corresponding +public pl8list chart in its original order, independently of served-track history. + In an artist radio each track carries `why`: `seed`, `related` (a neighbour), or `via` with the neighbour that leads to a second-hop artist. `/local/artist?handle=` returns an artist, their top tags and closest neighbours for the radio sheet, and starts crawling the neighbourhood diff --git a/backend/src/http/app.rs b/backend/src/http/app.rs index ca438c8..4f55297 100644 --- a/backend/src/http/app.rs +++ b/backend/src/http/app.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use tokio::time::Instant; use super::{App, CurrentUser, Res, St, json_error, no_store}; -use crate::{db, ranker, sync}; +use crate::{db, pl8::normalize, ranker, sync}; const EVENT_KINDS: &[&str] = &["play", "like", "unlike", "skip", "complete", "dislike", "undislike"]; /// On a cold start the crawler needs a few seconds before there is anything to serve. @@ -65,6 +65,35 @@ fn num(q: &HashMap, name: &str) -> Option { q.get(name).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) } +pub async fn charts(State(app): St, Extension(current): Extension, Query(q): Query>) -> Res { + let user = match reader(&app, ¤t) { Ok(u) => u, Err(r) => return Ok(r) }; + let period = q.get("period").map(String::as_str).unwrap_or("week"); + if !matches!(period, "week" | "year" | "all-time") { return Ok(json_error(StatusCode::BAD_REQUEST, "unknown chart period")); } + let kind = q.get("kind").map(String::as_str).unwrap_or("sales"); + let section = match kind { "sales" => "bestSellers", "free" => "recommended", _ => return Ok(json_error(StatusCode::BAD_REQUEST, "unknown chart")) }; + let data = match app.pl8.charts(period).await? { Some(d) => d, None => return Ok(json_error(StatusCode::BAD_GATEWAY, "chart unavailable")) }; + let Some(items) = data.get(section).and_then(|v| v.get("items")).and_then(Value::as_array) else { + return Ok(json_error(StatusCode::BAD_GATEWAY, "chart unavailable")); + }; + let rows: Vec<_> = items.iter().enumerate().filter_map(|(i, item)| normalize::item(item).map(|t| (i + 1, t))).collect(); + let all: Vec<_> = rows.iter().map(|(_, t)| t.clone()).collect(); + let uid = user.id; + let mut tracks = app.db.call(move |c| { + db::upsert_tracks(c, &all, None)?; + let mut out = Vec::new(); + for (rank, t) in rows { + if !t.full_access && t.preview_upload_id.is_none() { continue; } + if let Some(mut v) = ranker::track_by_path(c, &t.handle, &t.slug)? { + v["chart_rank"] = json!(rank); + out.push(v); + } + } + Ok(out) + }).await?; + mark_followed(&app, uid, &mut tracks).await?; + Ok((no_store(), Json(json!({ "tracks": tracks }))).into_response()) +} + pub async fn feed(State(app): St, Extension(current): Extension, Query(q): Query>) -> Res { let user = match reader(&app, ¤t) { Ok(u) => u, @@ -83,6 +112,7 @@ pub async fn feed(State(app): St, Extension(current): Extension, Qu free_only: q.get("free").map(String::as_str) == Some("1"), bpm_min: num(&q, "bpm_min"), bpm_max: num(&q, "bpm_max"), + bpm_equiv: q.get("bpm_equiv").map(String::as_str) == Some("1"), }; resync_if_due(&app, ¤t).await; let deadline = Instant::now() + FEED_WAIT; diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs index d191c96..cdbd7bf 100644 --- a/backend/src/http/mod.rs +++ b/backend/src/http/mod.rs @@ -352,6 +352,7 @@ pub fn router(app: Arc) -> Router { .route("/auth/guest", post(auth::guest)) .route("/local/me", get(app::me)) .route("/local/feed", get(app::feed)) + .route("/local/charts", get(app::charts)) .route("/local/likes", get(app::likes)) .route("/local/track", get(app::track)) .route("/local/artist", get(app::artist)) diff --git a/backend/src/http/tests.rs b/backend/src/http/tests.rs index 0996ba2..1d41cff 100644 --- a/backend/src/http/tests.rs +++ b/backend/src/http/tests.rs @@ -382,6 +382,36 @@ async fn token_rotation_keeps_the_pl8list_connection() { assert!(old_blob.is_none()); } +#[tokio::test] +async fn charts_keep_upstream_order_and_include_previously_served_tracks() { + let upstream = MockServer::start().await; + let item = |id: i64| serde_json::json!({ + "track": { "id": id, "slug": format!("s{id}"), "title": format!("track {id}"), "isPublic": true, + "user": { "handle": "artist", "name": "Artist" }, "price": 0 }, + "hasFullAccess": true + }); + Mock::given(method("GET")).and(path("/best-sellers")) + .and(query_param("period", "year")).and(query_param("downloads-period", "year")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "bestSellers": {"items": [item(2), item(1)]}, "recommended": {"items": [item(1), item(2)]} + }))).mount(&upstream).await; + let (r, app, _) = setup("charts", &upstream.uri()).await; + let token = app.db.call(|c| { + crate::db::upsert_user(c, 5, "tester", None, None)?; + crate::db::add_event(c, 5, 2, "served", 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 body = json_body(get("/local/charts?kind=sales&period=year").await.unwrap()).await; + assert_eq!(body["tracks"][0]["id"], 2); + assert_eq!(body["tracks"][0]["chart_rank"], 1); + assert_eq!(body["tracks"][1]["id"], 1); + let body = json_body(get("/local/charts?kind=free&period=year").await.unwrap()).await; + assert_eq!(body["tracks"][0]["id"], 1); + assert_eq!(get("/local/charts?period=month").await.unwrap().status(), StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn feed_modes_filter_and_tag_tracks() { let (r, app, _) = setup("modes", "http://127.0.0.1:9").await; diff --git a/backend/src/pl8/mod.rs b/backend/src/pl8/mod.rs index 84774fe..f29a6a0 100644 --- a/backend/src/pl8/mod.rs +++ b/backend/src/pl8/mod.rs @@ -165,6 +165,11 @@ impl Pl8 { self.get_json(&["best-selling-artists"], &[], Some("routes/best-selling-artists"), None).await } + /// The public charts: `period` controls sales, `downloads-period` free downloads. + pub async fn charts(&self, period: &str) -> anyhow::Result> { + self.get_json(&["best-sellers"], &[("period", period.to_string()), ("downloads-period", period.to_string())], Some("routes/best-sellers"), None).await + } + pub async fn activity_ticker(&self) -> anyhow::Result> { self.get_json(&["api", "activity-ticker"], &[], None, None).await } diff --git a/backend/src/ranker.rs b/backend/src/ranker.rs index 91fad61..d956a06 100644 --- a/backend/src/ranker.rs +++ b/backend/src/ranker.rs @@ -347,6 +347,7 @@ pub struct Filters { pub free_only: bool, pub bpm_min: Option, pub bpm_max: Option, + pub bpm_equiv: bool, } #[cfg(test)] @@ -364,13 +365,18 @@ pub fn pick_with(conn: &Connection, user_id: i64, n: usize, f: Filters, ranker: if f.free_only { sql.push_str(" AND t.price = 0"); } - if let Some(b) = f.bpm_min { - sql.push_str(" AND t.bpm >= ?"); - args.push(Sql::Real(b)); - } - if let Some(b) = f.bpm_max { - sql.push_str(" AND t.bpm <= ?"); - args.push(Sql::Real(b)); + if let (Some(lo), Some(hi), true) = (f.bpm_min, f.bpm_max, f.bpm_equiv) { + sql.push_str(" AND ((t.bpm BETWEEN ? AND ?) OR (t.bpm BETWEEN ? AND ?) OR (t.bpm BETWEEN ? AND ?))"); + for b in [lo, hi, lo / 2.0, hi / 2.0, lo * 2.0, hi * 2.0] { args.push(Sql::Real(b)); } + } else { + if let Some(b) = f.bpm_min { + sql.push_str(" AND t.bpm >= ?"); + args.push(Sql::Real(b)); + } + if let Some(b) = f.bpm_max { + sql.push_str(" AND t.bpm <= ?"); + args.push(Sql::Real(b)); + } } if let Some(c) = ranker.condition() { sql.push_str(" AND ("); @@ -606,6 +612,18 @@ mod tests { assert_eq!(tempo_match(None, &[140.0]), 0.0); } + #[test] + fn tempo_presets_include_half_time_but_custom_ranges_are_exact() { + let mut c = db::memory(); + db::upsert_tracks(&mut c, &[track(1, "a", "T", 140.0, 0), track(2, "b", "T", 70.0, 0), track(3, "c", "T", 174.0, 0)], None).unwrap(); + let f = Filters { bpm_min: Some(138.0), bpm_max: Some(142.0), bpm_equiv: true, ..Default::default() }; + let mut ids: Vec<_> = pick(&c, 1, 10, f, &Classic).unwrap().iter().map(|t| t["id"].as_i64().unwrap()).collect(); + ids.sort(); + assert_eq!(ids, [1, 2]); + let exact = Filters { bpm_equiv: false, ..f }; + assert_eq!(pick(&c, 1, 10, exact, &Classic).unwrap().len(), 1); + } + #[test] fn unknown_dates_count_as_a_year_old() { assert_eq!(age_days(None), 365.0); diff --git a/web/index.html b/web/index.html index 000455a..a31440d 100644 --- a/web/index.html +++ b/web/index.html @@ -42,8 +42,8 @@ + +