mirror of
https://github.com/narrrl/pl8list-rewind.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
6e961d3e0e
commit
53ac54b178
4 files changed
+79
-2
No files matched your search
+21
-1
@@ -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<CurrentUser>, 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<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.
|
||||
const SHEET_NEIGHBOURS: usize = 12;
|
||||
const FEW_NEIGHBOURS: usize = 3;
|
||||
|
||||
@@ -322,6 +322,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/track", get(app::track))
|
||||
.route("/local/artist", get(app::artist))
|
||||
.route("/local/event", post(app::event))
|
||||
.route("/local/follow", post(app::follow))
|
||||
|
||||
@@ -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;
|
||||
|
||||
+14
-1
@@ -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<Taste> {
|
||||
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<String, f64> = HashMap::new();
|
||||
// (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.
|
||||
pub fn likes(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<Value>> {
|
||||
let ids = taste(conn, user_id)?.liked_ids;
|
||||
|
||||
Reference in new issue
Block a user