From 00a2bf051830f9a484d67a8746120bfc26a45a5f Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Tue, 6 Jan 2026 23:10:11 +0300 Subject: [PATCH] Add date filters and DuckDB-friendly bindings Bind optional start_date/end_date as Option<&str> so None becomes SQL NULL and use CAST(? AS TIMESTAMP) to filter ranges. Return MAX(s.created_at) as created_at (last played) and fix play_count/unique_listeners column indexes. Use i64 pagination values and duckdb::params! for parameter binding; place LIMIT before OFFSET in queries. --- crates/analytics/src/handlers/albums.rs | 195 +++++++++++++---------- crates/analytics/src/handlers/artists.rs | 154 ++++++++++-------- crates/analytics/src/handlers/tracks.rs | 135 +++++++++++----- 3 files changed, 292 insertions(+), 192 deletions(-) diff --git a/crates/analytics/src/handlers/albums.rs b/crates/analytics/src/handlers/albums.rs index 3ea94e5e..f3af993a 100644 --- a/crates/analytics/src/handlers/albums.rs +++ b/crates/analytics/src/handlers/albums.rs @@ -115,79 +115,68 @@ pub async fn get_top_albums( ) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; - let pagination = params.pagination.unwrap_or_default(); - let offset = pagination.skip.unwrap_or(0); - let limit = pagination.take.unwrap_or(20); - let did = params.user_did; - tracing::info!(limit, offset, user_did = ?did, "Get top albums"); + + let pagination = params.pagination.clone().unwrap_or_default(); + let offset: i64 = pagination.skip.unwrap_or(0) as i64; + let limit: i64 = pagination.take.unwrap_or(20) as i64; + + let did = params.user_did.clone(); + + // Bind Option<&str> so None becomes SQL NULL (DuckDB-friendly) + let start_date: Option<&str> = params.start_date.as_deref(); + let end_date: Option<&str> = params.end_date.as_deref(); + + tracing::info!( + limit, + offset, + user_did = ?did, + start_date = ?params.start_date, + end_date = ?params.end_date, + "Get top albums" + ); let conn = conn.lock().unwrap(); - let mut stmt = match did { - Some(_) => conn.prepare( - r#" - SELECT - s.album_id AS id, - a.title AS title, - ar.name AS artist, - ar.uri AS artist_uri, - a.album_art AS album_art, - a.release_date, - a.year, - a.uri, - a.sha256, - COUNT(DISTINCT s.created_at) AS play_count, - COUNT(DISTINCT s.user_id) AS unique_listeners - FROM - scrobbles s - LEFT JOIN - albums a ON s.album_id = a.id - LEFT JOIN - artists ar ON a.artist = ar.name - LEFT JOIN - users u ON s.user_id = u.id - WHERE s.album_id IS NOT NULL AND (u.did = ? OR u.handle = ?) AND ar.name IS NOT NULL - GROUP BY - s.album_id, a.title, ar.name, a.release_date, a.year, a.uri, a.album_art, a.sha256, ar.uri - ORDER BY - play_count DESC - OFFSET ? - LIMIT ?; - "#, - )?, - None => conn.prepare( - r#" - SELECT - s.album_id AS id, - a.title AS title, - ar.name AS artist, - ar.uri AS artist_uri, - a.album_art AS album_art, - a.release_date, - a.year, - a.uri, - a.sha256, - COUNT(*) AS play_count, - COUNT(DISTINCT s.user_id) AS unique_listeners - FROM - scrobbles s - LEFT JOIN - albums a ON s.album_id = a.id - LEFT JOIN - artists ar ON a.artist = ar.name WHERE s.album_id IS NOT NULL - GROUP BY - s.album_id, a.title, ar.name, a.release_date, a.year, a.uri, a.album_art, a.sha256, ar.uri - ORDER BY - play_count DESC - OFFSET ? - LIMIT ?; - "#, - )?, - }; match did { Some(did) => { + let mut stmt = conn.prepare( + r#" + SELECT + s.album_id AS id, + a.title AS title, + ar.name AS artist, + ar.uri AS artist_uri, + a.album_art AS album_art, + a.release_date, + a.year, + a.uri, + a.sha256, + -- return "last played" (scrobble time), not album row time + MAX(s.created_at) AS created_at, + COUNT(DISTINCT s.created_at) AS play_count, + COUNT(DISTINCT s.user_id) AS unique_listeners + FROM scrobbles s + LEFT JOIN albums a ON s.album_id = a.id + LEFT JOIN artists ar ON a.artist = ar.name + LEFT JOIN users u ON s.user_id = u.id + WHERE + s.album_id IS NOT NULL + AND (u.did = ? OR u.handle = ?) + AND ar.name IS NOT NULL + AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) + AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) + GROUP BY + s.album_id, a.title, ar.name, a.release_date, a.year, a.uri, a.album_art, a.sha256, ar.uri + ORDER BY play_count DESC + LIMIT ? + OFFSET ?; + "#, + )?; + let albums = stmt.query_map( - [&did, &did, &limit.to_string(), &offset.to_string()], + duckdb::params![ + did, did, start_date, start_date, end_date, end_date, limit, offset + ], |row| { Ok(Album { id: row.get(0)?, @@ -199,32 +188,68 @@ pub async fn get_top_albums( year: row.get(6)?, uri: row.get(7)?, sha256: row.get(8)?, - play_count: Some(row.get(9)?), - unique_listeners: Some(row.get(10)?), + play_count: Some(row.get(10)?), + unique_listeners: Some(row.get(11)?), ..Default::default() }) }, )?; + let albums: Result, _> = albums.collect(); Ok(HttpResponse::Ok().json(web::Json(albums?))) } + None => { - let albums = stmt.query_map([limit, offset], |row| { - Ok(Album { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - artist_uri: row.get(3)?, - album_art: row.get(4)?, - release_date: row.get(5)?, - year: row.get(6)?, - uri: row.get(7)?, - sha256: row.get(8)?, - play_count: Some(row.get(9)?), - unique_listeners: Some(row.get(10)?), - ..Default::default() - }) - })?; + let mut stmt = conn.prepare( + r#" + SELECT + s.album_id AS id, + a.title AS title, + ar.name AS artist, + ar.uri AS artist_uri, + a.album_art AS album_art, + a.release_date, + a.year, + a.uri, + a.sha256, + MAX(s.created_at) AS created_at, + COUNT(*) AS play_count, + COUNT(DISTINCT s.user_id) AS unique_listeners + FROM scrobbles s + LEFT JOIN albums a ON s.album_id = a.id + LEFT JOIN artists ar ON a.artist = ar.name + WHERE + s.album_id IS NOT NULL + AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) + AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) + GROUP BY + s.album_id, a.title, ar.name, a.release_date, a.year, a.uri, a.album_art, a.sha256, ar.uri + ORDER BY play_count DESC + LIMIT ? + OFFSET ?; + "#, + )?; + + let albums = stmt.query_map( + duckdb::params![start_date, start_date, end_date, end_date, limit, offset], + |row| { + Ok(Album { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + artist_uri: row.get(3)?, + album_art: row.get(4)?, + release_date: row.get(5)?, + year: row.get(6)?, + uri: row.get(7)?, + sha256: row.get(8)?, + play_count: Some(row.get(10)?), + unique_listeners: Some(row.get(11)?), + ..Default::default() + }) + }, + )?; + let albums: Result, _> = albums.collect(); Ok(HttpResponse::Ok().json(web::Json(albums?))) } diff --git a/crates/analytics/src/handlers/artists.rs b/crates/analytics/src/handlers/artists.rs index de1b85da..be06d0de 100644 --- a/crates/analytics/src/handlers/artists.rs +++ b/crates/analytics/src/handlers/artists.rs @@ -165,15 +165,32 @@ pub async fn get_top_artists( ) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; - let pagination = params.pagination.unwrap_or_default(); - let offset = pagination.skip.unwrap_or(0); - let limit = pagination.take.unwrap_or(20); - let did = params.user_did; + + let pagination = params.pagination.clone().unwrap_or_default(); + let offset: i64 = pagination.skip.unwrap_or(0) as i64; + let limit: i64 = pagination.take.unwrap_or(20) as i64; + + let did = params.user_did.clone(); + + // DuckDB-friendly optional bindings (None -> SQL NULL) + let start_date: Option<&str> = params.start_date.as_deref(); + let end_date: Option<&str> = params.end_date.as_deref(); + + tracing::info!( + limit, + offset, + user_did = ?did, + start_date = ?params.start_date, + end_date = ?params.end_date, + "Get top artists" + ); let conn = conn.lock().unwrap(); - let mut stmt = match did { - Some(_) => conn.prepare( - r#" + + match did { + Some(did) => { + let mut stmt = conn.prepare( + r#" SELECT s.artist_id AS id, ar.name AS artist_name, @@ -181,26 +198,61 @@ pub async fn get_top_artists( ar.sha256 AS sha256, ar.uri AS uri, ar.genres AS genres, + -- "created_at" reflects scrobble time (last played) + MAX(s.created_at) AS created_at, COUNT(DISTINCT s.created_at) AS play_count, COUNT(DISTINCT s.user_id) AS unique_listeners - FROM - scrobbles s - LEFT JOIN - artists ar ON s.artist_id = ar.id - LEFT JOIN - users u ON s.user_id = u.id + FROM scrobbles s + LEFT JOIN artists ar ON s.artist_id = ar.id + LEFT JOIN users u ON s.user_id = u.id WHERE - s.artist_id IS NOT NULL AND (u.did = ? OR u.handle = ?) AND ar.name != 'Various Artists' + s.artist_id IS NOT NULL + AND (u.did = ? OR u.handle = ?) + AND ar.name != 'Various Artists' + AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) + AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) GROUP BY s.artist_id, ar.name, ar.uri, ar.picture, ar.sha256, ar.genres - ORDER BY - play_count DESC - OFFSET ? - LIMIT ?; - "#, - )?, - None => conn.prepare( - r#" + ORDER BY play_count DESC + LIMIT ? + OFFSET ?; + "#, + )?; + + let artists = stmt.query_map( + duckdb::params![ + did, did, start_date, start_date, end_date, end_date, limit, offset + ], + |row| { + let genres = extract_genres_from_value(row.get(5)?); + Ok(Artist { + id: row.get(0)?, + name: row.get(1)?, + biography: None, + born: None, + born_in: None, + died: None, + picture: row.get(2)?, + sha256: row.get(3)?, + spotify_link: None, + tidal_link: None, + youtube_link: None, + apple_music_link: None, + uri: row.get(4)?, + genres, + play_count: Some(row.get(7)?), + unique_listeners: Some(row.get(8)?), + }) + }, + )?; + + let artists: Result, _> = artists.collect(); + Ok(HttpResponse::Ok().json(artists?)) + } + + None => { + let mut stmt = conn.prepare( + r#" SELECT s.artist_id AS id, ar.name AS artist_name, @@ -208,28 +260,26 @@ pub async fn get_top_artists( ar.sha256 AS sha256, ar.uri AS uri, ar.genres AS genres, + MAX(s.created_at) AS created_at, COUNT(*) AS play_count, COUNT(DISTINCT s.user_id) AS unique_listeners - FROM - scrobbles s - LEFT JOIN - artists ar ON s.artist_id = ar.id + FROM scrobbles s + LEFT JOIN artists ar ON s.artist_id = ar.id WHERE - s.artist_id IS NOT NULL AND ar.name != 'Various Artists' + s.artist_id IS NOT NULL + AND ar.name != 'Various Artists' + AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) + AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) GROUP BY s.artist_id, ar.name, ar.uri, ar.picture, ar.sha256, ar.genres - ORDER BY - play_count DESC - OFFSET ? - LIMIT ?; - "#, - )?, - }; + ORDER BY play_count DESC + LIMIT ? + OFFSET ?; + "#, + )?; - match did { - Some(did) => { let artists = stmt.query_map( - [&did, &did, &limit.to_string(), &offset.to_string()], + duckdb::params![start_date, start_date, end_date, end_date, limit, offset], |row| { let genres = extract_genres_from_value(row.get(5)?); Ok(Artist { @@ -247,38 +297,12 @@ pub async fn get_top_artists( apple_music_link: None, uri: row.get(4)?, genres, - play_count: Some(row.get(6)?), - unique_listeners: Some(row.get(7)?), + play_count: Some(row.get(7)?), + unique_listeners: Some(row.get(8)?), }) }, )?; - let artists: Result, _> = artists.collect(); - Ok(HttpResponse::Ok().json(artists?)) - } - None => { - let artists = stmt.query_map([limit, offset], |row| { - let genres = extract_genres_from_value(row.get(5)?); - Ok(Artist { - id: row.get(0)?, - name: row.get(1)?, - biography: None, - born: None, - born_in: None, - died: None, - picture: row.get(2)?, - sha256: row.get(3)?, - spotify_link: None, - tidal_link: None, - youtube_link: None, - apple_music_link: None, - uri: row.get(4)?, - genres, - play_count: Some(row.get(6)?), - unique_listeners: Some(row.get(7)?), - }) - })?; - let artists: Result, _> = artists.collect(); Ok(HttpResponse::Ok().json(artists?)) } diff --git a/crates/analytics/src/handlers/tracks.rs b/crates/analytics/src/handlers/tracks.rs index bfa264e0..f8b36d14 100644 --- a/crates/analytics/src/handlers/tracks.rs +++ b/crates/analytics/src/handlers/tracks.rs @@ -259,16 +259,31 @@ pub async fn get_top_tracks( ) -> Result { let body = read_payload!(payload); let params = serde_json::from_slice::(&body)?; - let pagination = params.pagination.unwrap_or_default(); - let offset = pagination.skip.unwrap_or(0); - let limit = pagination.take.unwrap_or(20); - let did = params.user_did; - tracing::info!(limit, offset, user_did = ?did, "Get top tracks"); + + let pagination = params.pagination.clone().unwrap_or_default(); + let offset: i64 = pagination.skip.unwrap_or(0) as i64; + let limit: i64 = pagination.take.unwrap_or(20) as i64; + + let did = params.user_did.clone(); + + let start_date: Option<&str> = params.start_date.as_deref(); + let end_date: Option<&str> = params.end_date.as_deref(); + + tracing::info!( + limit, + offset, + user_did = ?did, + start_date = ?params.start_date, + end_date = ?params.end_date, + "Get top tracks" + ); let conn = conn.lock().unwrap(); + match did { Some(did) => { - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT t.id, t.title, @@ -291,14 +306,32 @@ pub async fn get_top_tracks( LEFT JOIN artists ar ON s.artist_id = ar.id LEFT JOIN albums a ON s.album_id = a.id LEFT JOIN users u ON s.user_id = u.id - WHERE u.did = ? OR u.handle = ? - GROUP BY t.id, s.track_id, t.title, ar.name, a.title, t.artist, t.uri, t.album_art, t.duration, t.disc_number, t.track_number, t.artist_uri, t.album_uri, t.created_at, t.sha256, t.album_artist, t.album + WHERE + (u.did = ? OR u.handle = ?) + AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) + AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) + GROUP BY + t.id, s.track_id, t.title, ar.name, a.title, t.artist, t.uri, + t.album_art, t.duration, t.disc_number, t.track_number, + t.artist_uri, t.album_uri, t.created_at, t.sha256, + t.album_artist, t.album ORDER BY play_count DESC - OFFSET ? - LIMIT ?; - "#)?; - let top_tracks = stmt.query_map( - [&did, &did, &limit.to_string(), &offset.to_string()], + LIMIT ? + OFFSET ?; + "#, + )?; + + let rows = stmt.query_map( + duckdb::params![ + did, // u.did = ? + did, // u.handle = ? + start_date, // ? IS NULL + start_date, // CAST(? AS TIMESTAMP) + end_date, // ? IS NULL + end_date, // CAST(? AS TIMESTAMP) + limit, // LIMIT ? + offset // OFFSET ? + ], |row| { Ok(Track { id: row.get(0)?, @@ -321,11 +354,14 @@ pub async fn get_top_tracks( }) }, )?; - let top_tracks: Result, _> = top_tracks.collect(); + + let top_tracks: Result, _> = rows.collect(); Ok(HttpResponse::Ok().json(top_tracks?)) } + None => { - let mut stmt = conn.prepare(r#" + let mut stmt = conn.prepare( + r#" SELECT t.id, t.title, @@ -347,34 +383,49 @@ pub async fn get_top_tracks( LEFT JOIN tracks t ON s.track_id = t.id LEFT JOIN artists ar ON s.artist_id = ar.id LEFT JOIN albums a ON s.album_id = a.id - WHERE s.track_id IS NOT NULL AND s.artist_id IS NOT NULL AND s.album_id IS NOT NULL - GROUP BY t.id, s.track_id, t.title, ar.name, a.title, t.artist, t.uri, t.album_art, t.duration, t.disc_number, t.track_number, t.artist_uri, t.album_uri, t.created_at, t.sha256, t.album_artist, t.album + WHERE + s.track_id IS NOT NULL + AND s.artist_id IS NOT NULL + AND s.album_id IS NOT NULL + AND (? IS NULL OR s.created_at >= CAST(? AS TIMESTAMP)) + AND (? IS NULL OR s.created_at <= CAST(? AS TIMESTAMP)) + GROUP BY + t.id, s.track_id, t.title, ar.name, a.title, t.artist, t.uri, + t.album_art, t.duration, t.disc_number, t.track_number, + t.artist_uri, t.album_uri, t.created_at, t.sha256, + t.album_artist, t.album ORDER BY play_count DESC - OFFSET ? - LIMIT ?; - "#)?; - let top_tracks = stmt.query_map([limit, offset], |row| { - Ok(Track { - id: row.get(0)?, - title: row.get(1)?, - artist: row.get(2)?, - album_artist: row.get(3)?, - album: row.get(4)?, - uri: row.get(5)?, - album_art: row.get(6)?, - duration: row.get(7)?, - disc_number: row.get(8)?, - track_number: row.get(9)?, - artist_uri: row.get(10)?, - album_uri: row.get(11)?, - sha256: row.get(12)?, - created_at: row.get(13)?, - play_count: row.get(14)?, - unique_listeners: row.get(15)?, - ..Default::default() - }) - })?; - let top_tracks: Result, _> = top_tracks.collect(); + LIMIT ? + OFFSET ?; + "#, + )?; + + let rows = stmt.query_map( + duckdb::params![start_date, start_date, end_date, end_date, limit, offset], + |row| { + Ok(Track { + id: row.get(0)?, + title: row.get(1)?, + artist: row.get(2)?, + album_artist: row.get(3)?, + album: row.get(4)?, + uri: row.get(5)?, + album_art: row.get(6)?, + duration: row.get(7)?, + disc_number: row.get(8)?, + track_number: row.get(9)?, + artist_uri: row.get(10)?, + album_uri: row.get(11)?, + sha256: row.get(12)?, + created_at: row.get(13)?, + play_count: row.get(14)?, + unique_listeners: row.get(15)?, + ..Default::default() + }) + }, + )?; + + let top_tracks: Result, _> = rows.collect(); Ok(HttpResponse::Ok().json(top_tracks?)) } } -- 2.51.2