From 53ac54b1785c7d6de46752b3b2b9fb528b58a885 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 1 Nov 2026 19:11:57 +0100 Subject: [PATCH] Let a dislike be taken back and look tracks up by their pl8list path An undislike event cancels a dislike in the taste profile, for the undo on the dislike toast. GET /local/track?handle=&slug= returns one known track, so a link into the app (/?t=handle/slug) can open on it. --- backend/src/http/app.rs | 22 +++++++++++++++++++- backend/src/http/mod.rs | 1 + backend/src/http/tests.rs | 43 +++++++++++++++++++++++++++++++++++++++ backend/src/ranker.rs | 15 +++++++++++++- 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/backend/src/http/app.rs b/backend/src/http/app.rs index 57d9a4f..c73af86 100644 --- a/backend/src/http/app.rs +++ b/backend/src/http/app.rs @@ -14,7 +14,7 @@ use tokio::time::Instant; use super::{App, CurrentUser, Res, St, json_error, no_store}; use crate::{db, ranker, sync}; -const EVENT_KINDS: &[&str] = &["play", "like", "unlike", "skip", "complete", "dislike"]; +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. const FEED_WAIT: Duration = Duration::from_secs(10); @@ -144,6 +144,26 @@ pub async fn event(State(app): St, Extension(current): Extension, J Ok((no_store(), Json(json!({ "ok": true }))).into_response()) } +/// One track, for a link into the app (`/?t=handle/slug`). +pub async fn track(State(app): St, Extension(current): Extension, Query(q): Query>) -> Res { + let uid = match current.require() { + Ok(u) => u.id, + Err(r) => return Ok(r), + }; + let handle = q.get("handle").filter(|h| valid_handle(h)).cloned(); + let slug = q.get("slug").filter(|s| valid_handle(s)).cloned(); + let (Some(handle), Some(slug)) = (handle, slug) else { + return Ok(StatusCode::BAD_REQUEST.into_response()); + }; + let Some(track) = app.db.call(move |c| ranker::track_by_path(c, &handle, &slug)).await? else { + return Ok(json_error(StatusCode::NOT_FOUND, "no such track here (yet)")); + }; + let mut tracks = [track]; + mark_followed(&app, uid, &mut tracks).await?; + let [track] = tracks; + Ok((no_store(), Json(json!({ "track": track }))).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; diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs index 1d5405e..193bdc7 100644 --- a/backend/src/http/mod.rs +++ b/backend/src/http/mod.rs @@ -322,6 +322,7 @@ pub fn router(app: Arc) -> Router { .route("/local/me", get(app::me)) .route("/local/feed", get(app::feed)) .route("/local/likes", get(app::likes)) + .route("/local/track", get(app::track)) .route("/local/artist", get(app::artist)) .route("/local/event", post(app::event)) .route("/local/follow", post(app::follow)) diff --git a/backend/src/http/tests.rs b/backend/src/http/tests.rs index 2f0a690..06beb93 100644 --- a/backend/src/http/tests.rs +++ b/backend/src/http/tests.rs @@ -418,6 +418,49 @@ async fn feed_modes_filter_and_tag_tracks() { assert_eq!(json_body(res).await["tracks"].as_array().unwrap().len(), 1); } +#[tokio::test] +async fn track_links_and_taking_a_dislike_back() { + let (r, app, _) = setup("track", "http://127.0.0.1:9").await; + let token = app + .db + .call(|c| { + let t = crate::db::Track { + id: 1, + handle: "a".into(), + artist_name: "A".into(), + slug: "riddim-one".into(), + title: "Riddim one".into(), + bpm: Some(140.0), + full_access: true, + ..Default::default() + }; + crate::db::upsert_tracks(c, &[t], 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/track?handle=a&slug=riddim-one").await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = json_body(res).await; + assert_eq!(body["track"]["id"], 1); + assert_eq!(body["track"]["followed"], false); + assert_eq!(get("/local/track?handle=a&slug=nope").await.unwrap().status(), StatusCode::NOT_FOUND); + assert_eq!(get("/local/track?handle=a&slug=..").await.unwrap().status(), StatusCode::BAD_REQUEST); + let anon = r.clone().oneshot(req("GET", "/local/track?handle=a&slug=riddim-one", None, None, "203.0.113.1")).await.unwrap(); + assert_eq!(anon.status(), StatusCode::UNAUTHORIZED); + + for kind in ["dislike", "undislike"] { + let body = format!(r#"{{"track_id":1,"kind":"{kind}"}}"#); + let res = r.clone().oneshot(req("POST", "/local/event", Some(&body), Some(&cookie), "203.0.113.1")).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK, "{kind}"); + } + let taste = app.db.call(|c| crate::ranker::taste(c, 5)).await.unwrap(); + assert_eq!(taste.artist.get("a").copied().unwrap_or(0.0), 0.0); +} + #[tokio::test] async fn artist_sheet_and_radio_reasons() { let (r, app, _) = setup("radio", "http://127.0.0.1:9").await; diff --git a/backend/src/ranker.rs b/backend/src/ranker.rs index a226f8c..b41dec1 100644 --- a/backend/src/ranker.rs +++ b/backend/src/ranker.rs @@ -263,6 +263,8 @@ fn event_signal(kind: &str) -> f64 { "complete" => 1.5, "skip" => -1.0, "dislike" => -3.0, + // Taking a dislike back. + "undislike" => 3.0, _ => 0.0, } } @@ -300,7 +302,7 @@ pub struct Taste { pub fn taste(conn: &Connection, user_id: i64) -> rusqlite::Result { let mut stmt = conn.prepare_cached( "SELECT e.track_id, e.kind, t.handle, t.bpm, t.key FROM events e JOIN tracks t ON t.id = e.track_id \ - WHERE e.user_id = ? AND e.kind IN ('like', 'unlike', 'complete', 'skip', 'dislike') ORDER BY e.ts", + WHERE e.user_id = ? AND e.kind IN ('like', 'unlike', 'complete', 'skip', 'dislike', 'undislike') ORDER BY e.ts", )?; let mut artist: HashMap = HashMap::new(); // (track id, bpm, key), in the order they became positive. @@ -478,6 +480,17 @@ pub fn serialize(r: &Track, score: Option) -> Value { }) } +/// One track by its pl8list path, for links into the app. +pub fn track_by_path(conn: &Connection, handle: &str, slug: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare_cached("SELECT * FROM tracks WHERE handle=? AND slug=?")?; + let Some(t) = rusqlite::OptionalExtension::optional(stmt.query_row([handle, slug], Track::from_row))? else { + return Ok(None); + }; + let mut out = vec![serialize(&t, None)]; + add_tags(conn, &mut out)?; + Ok(out.pop()) +} + /// Liked tracks, newest like first. pub fn likes(conn: &Connection, user_id: i64) -> rusqlite::Result> { let ids = taste(conn, user_id)?.liked_ids;