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.
This commit is contained in:
nvrl committed 2026-11-01 19:11:57 +01:00
1 parent 6e961d3e0e
commit 53ac54b178
4 files changed
+79 -2

No files matched your search

+21 -1
View File
@@ -14,7 +14,7 @@ use tokio::time::Instant;
use super::{App, CurrentUser, Res, St, json_error, no_store}; use super::{App, CurrentUser, Res, St, json_error, no_store};
use crate::{db, ranker, sync}; 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. /// On a cold start the crawler needs a few seconds before there is anything to serve.
const FEED_WAIT: Duration = Duration::from_secs(10); const FEED_WAIT: Duration = Duration::from_secs(10);
@@ -144,6 +144,26 @@ pub async fn event(State(app): St, Extension(current): Extension<CurrentUser>, J
Ok((no_store(), Json(json!({ "ok": true }))).into_response()) 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<CurrentUser>, Query(q): Query<HashMap<String, String>>) -> 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. /// How many neighbours the radio sheet shows, and how few count as still exploring.
const SHEET_NEIGHBOURS: usize = 12; const SHEET_NEIGHBOURS: usize = 12;
const FEW_NEIGHBOURS: usize = 3; const FEW_NEIGHBOURS: usize = 3;
+1
View File
@@ -322,6 +322,7 @@ pub fn router(app: Arc<App>) -> Router {
.route("/local/me", get(app::me)) .route("/local/me", get(app::me))
.route("/local/feed", get(app::feed)) .route("/local/feed", get(app::feed))
.route("/local/likes", get(app::likes)) .route("/local/likes", get(app::likes))
.route("/local/track", get(app::track))
.route("/local/artist", get(app::artist)) .route("/local/artist", get(app::artist))
.route("/local/event", post(app::event)) .route("/local/event", post(app::event))
.route("/local/follow", post(app::follow)) .route("/local/follow", post(app::follow))
+43
View File
@@ -418,6 +418,49 @@ async fn feed_modes_filter_and_tag_tracks() {
assert_eq!(json_body(res).await["tracks"].as_array().unwrap().len(), 1); 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] #[tokio::test]
async fn artist_sheet_and_radio_reasons() { async fn artist_sheet_and_radio_reasons() {
let (r, app, _) = setup("radio", "http://127.0.0.1:9").await; let (r, app, _) = setup("radio", "http://127.0.0.1:9").await;
+14 -1
View File
@@ -263,6 +263,8 @@ fn event_signal(kind: &str) -> f64 {
"complete" => 1.5, "complete" => 1.5,
"skip" => -1.0, "skip" => -1.0,
"dislike" => -3.0, "dislike" => -3.0,
// Taking a dislike back.
"undislike" => 3.0,
_ => 0.0, _ => 0.0,
} }
} }
@@ -300,7 +302,7 @@ pub struct Taste {
pub fn taste(conn: &Connection, user_id: i64) -> rusqlite::Result<Taste> { pub fn taste(conn: &Connection, user_id: i64) -> rusqlite::Result<Taste> {
let mut stmt = conn.prepare_cached( 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 \ "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<String, f64> = HashMap::new(); let mut artist: HashMap<String, f64> = HashMap::new();
// (track id, bpm, key), in the order they became positive. // (track id, bpm, key), in the order they became positive.
@@ -478,6 +480,17 @@ pub fn serialize(r: &Track, score: Option<f64>) -> 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<Option<Value>> {
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. /// Liked tracks, newest like first.
pub fn likes(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<Value>> { pub fn likes(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<Value>> {
let ids = taste(conn, user_id)?.liked_ids; let ids = taste(conn, user_id)?.liked_ids;