A decentralized music tracking and discovery platform built on AT Protocol 🎵

finish analytics API implementation

+1434 -111
+1
Cargo.lock
··· 277 277 "clap", 278 278 "dotenv", 279 279 "duckdb", 280 + "futures-util", 280 281 "owo-colors", 281 282 "polars", 282 283 "serde",
+1
crates/analytics/Cargo.toml
··· 27 27 polars = "0.46.0" 28 28 clap = "4.5.31" 29 29 actix-web = "4.9.0" 30 + futures-util = "0.3.31"
+34 -10
crates/analytics/src/cmd/serve.rs
··· 1 1 use std::env; 2 2 3 - use actix_web::{get, App, HttpRequest, HttpServer}; 3 + use actix_web::{get, post, web::{self, Data}, App, HttpRequest, HttpServer, Responder}; 4 4 use duckdb::Connection; 5 5 use anyhow::Error; 6 6 use owo_colors::OwoColorize; 7 + use std::sync::{Arc, Mutex}; 8 + 9 + use crate::handlers::handle; 7 10 8 11 #[get("/")] 9 12 async fn index(_req: HttpRequest) -> String { 10 13 "Hello world!".to_owned() 11 14 } 12 15 13 - pub async fn serve(_conn: &Connection) -> Result<(), Error> { 14 - let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 15 - let port = env::var("PORT").unwrap_or_else(|_| "7879".to_string()); 16 + #[post("/{method}")] 17 + async fn call_method( 18 + data: web::Data<Arc<Mutex<Connection>>>, 19 + mut payload: web::Payload, 20 + req: HttpRequest) -> Result<impl Responder, actix_web::Error> { 21 + let method = req.match_info().get("method").unwrap_or("unknown"); 22 + println!("Method: {}", method.bright_green()); 23 + 24 + let conn = data.get_ref().clone(); 25 + handle(method, &mut payload, &req, conn).await 26 + .map_err(actix_web::error::ErrorInternalServerError) 27 + } 28 + 29 + 30 + pub async fn serve(conn: Arc<Mutex<Connection>>) -> Result<(), Error> { 31 + let host = env::var("ANALYTICS_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 32 + let port = env::var("ANALYTICS_PORT").unwrap_or_else(|_| "7879".to_string()); 16 33 let addr = format!("{}:{}", host, port); 17 34 18 35 let url = format!("http://{}", addr); 19 36 println!("Listening on {}", url.bright_green()); 20 37 21 - HttpServer::new(|| App::new().service(index)) 22 - .bind(&addr)? 23 - .run() 24 - .await 25 - .map_err(Error::new)?; 38 + let conn = conn.clone(); 39 + HttpServer::new(move || { 40 + App::new() 41 + .app_data(Data::new( conn.clone())) 42 + .service(index) 43 + .service(call_method) 44 + }) 45 + .bind(&addr)? 46 + .run() 47 + .await 48 + .map_err(Error::new)?; 49 + 26 50 Ok(()) 27 - } 51 + }
+14 -12
crates/analytics/src/cmd/sync.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + 1 3 use anyhow::Error; 2 4 use duckdb::Connection; 3 5 use sqlx::{Pool, Postgres}; 4 6 use crate::core::*; 5 7 6 - pub async fn sync(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 7 - load_tracks(conn, pool).await?; 8 - load_artists(conn, pool).await?; 9 - load_albums(conn, pool).await?; 10 - load_users(conn, pool).await?; 11 - load_scrobbles(conn, pool).await?; 12 - load_album_tracks(conn, pool).await?; 13 - load_loved_tracks(conn, pool).await?; 14 - load_artist_tracks(conn, pool).await?; 15 - load_user_albums(conn, pool).await?; 16 - load_user_artists(conn, pool).await?; 17 - load_user_tracks(conn, pool).await?; 8 + pub async fn sync(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 9 + load_tracks(conn.clone(), pool).await?; 10 + load_artists(conn.clone(), pool).await?; 11 + load_albums(conn.clone(), pool).await?; 12 + load_users(conn.clone(), pool).await?; 13 + load_scrobbles(conn.clone(), pool).await?; 14 + load_album_tracks(conn.clone(), pool).await?; 15 + load_loved_tracks(conn.clone(), pool).await?; 16 + load_artist_tracks(conn.clone(), pool).await?; 17 + load_user_albums(conn.clone(), pool).await?; 18 + load_user_artists(conn.clone(), pool).await?; 19 + load_user_tracks(conn.clone(), pool).await?; 18 20 19 21 Ok(()) 20 22 }
+24 -11
crates/analytics/src/core.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + 1 3 use duckdb::{params, Connection}; 2 4 use anyhow::Error; 3 5 use owo_colors::OwoColorize; ··· 174 176 Ok(()) 175 177 } 176 178 177 - pub async fn load_tracks(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 179 + pub async fn load_tracks(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 180 + let conn = conn.lock().unwrap(); 178 181 let tracks: Vec<xata::track::Track> = sqlx::query_as(r#" 179 182 SELECT * FROM tracks 180 183 "#) ··· 246 249 Ok(()) 247 250 } 248 251 249 - pub async fn load_artists(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 252 + pub async fn load_artists(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 253 + let conn = conn.lock().unwrap(); 250 254 let artists: Vec<xata::artist::Artist> = sqlx::query_as(r#" 251 255 SELECT * FROM artists 252 256 "#) ··· 308 312 Ok(()) 309 313 } 310 314 311 - pub async fn load_albums(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 315 + pub async fn load_albums(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 316 + let conn = conn.lock().unwrap(); 312 317 let albums: Vec<xata::album::Album> = sqlx::query_as(r#" 313 318 SELECT * FROM albums 314 319 "#) ··· 370 375 Ok(()) 371 376 } 372 377 373 - pub async fn load_users(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 378 + pub async fn load_users(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 379 + let conn = conn.lock().unwrap(); 374 380 let users: Vec<xata::user::User> = sqlx::query_as(r#" 375 381 SELECT * FROM users 376 382 "#) ··· 408 414 Ok(()) 409 415 } 410 416 411 - pub async fn load_scrobbles(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 417 + pub async fn load_scrobbles(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 418 + let conn = conn.lock().unwrap(); 412 419 let scrobbles: Vec<xata::scrobble::Scrobble> = sqlx::query_as(r#" 413 420 SELECT * FROM scrobbles 414 421 "#) ··· 457 464 Ok(()) 458 465 } 459 466 460 - pub async fn load_album_tracks(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 467 + pub async fn load_album_tracks(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 468 + let conn = conn.lock().unwrap(); 461 469 let album_tracks: Vec<xata::album_track::AlbumTrack> = sqlx::query_as(r#" 462 470 SELECT * FROM album_tracks 463 471 "#) ··· 488 496 Ok(()) 489 497 } 490 498 491 - pub async fn load_loved_tracks(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 499 + pub async fn load_loved_tracks(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 500 + let conn = conn.lock().unwrap(); 492 501 let loved_tracks: Vec<xata::user_track::UserTrack> = sqlx::query_as(r#" 493 502 SELECT * FROM loved_tracks 494 503 "#) ··· 523 532 Ok(()) 524 533 } 525 534 526 - pub async fn load_artist_tracks(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 535 + pub async fn load_artist_tracks(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 536 + let conn = conn.lock().unwrap(); 527 537 let artist_tracks: Vec<xata::artist_track::ArtistTrack> = sqlx::query_as(r#" 528 538 SELECT * FROM artist_tracks 529 539 "#) ··· 550 560 Ok(()) 551 561 } 552 562 553 - pub async fn load_user_albums(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 563 + pub async fn load_user_albums(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 564 + let conn = conn.lock().unwrap(); 554 565 let user_albums: Vec<xata::user_album::UserAlbum> = sqlx::query_as(r#" 555 566 SELECT * FROM user_albums 556 567 "#) ··· 577 588 Ok(()) 578 589 } 579 590 580 - pub async fn load_user_artists(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 591 + pub async fn load_user_artists(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 592 + let conn = conn.lock().unwrap(); 581 593 let user_artists: Vec<xata::user_artist::UserArtist> = sqlx::query_as(r#" 582 594 SELECT * FROM user_artists 583 595 "#) ··· 604 616 Ok(()) 605 617 } 606 618 607 - pub async fn load_user_tracks(conn: &Connection, pool: &Pool<Postgres>) -> Result<(), Error> { 619 + pub async fn load_user_tracks(conn: Arc<Mutex<Connection>>, pool: &Pool<Postgres>) -> Result<(), Error> { 620 + let conn = conn.lock().unwrap(); 608 621 let user_tracks: Vec<xata::user_track::UserTrack> = sqlx::query_as(r#" 609 622 SELECT * FROM user_tracks 610 623 "#)
+187
crates/analytics/src/handlers/albums.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + 3 + use actix_web::{web, HttpRequest, HttpResponse}; 4 + use analytics::types::album::{Album, GetAlbumsParams, GetTopAlbumsParams}; 5 + use duckdb::Connection; 6 + use anyhow::Error; 7 + use futures_util::StreamExt; 8 + 9 + use crate::read_payload; 10 + 11 + pub async fn get_albums(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 12 + let body = read_payload!(payload); 13 + let params = serde_json::from_slice::<GetAlbumsParams>(&body)?; 14 + let pagination = params.pagination.unwrap_or_default(); 15 + let offset = pagination.skip.unwrap_or(0); 16 + let limit = pagination.take.unwrap_or(20); 17 + let did = params.user_did; 18 + 19 + let conn = conn.lock().unwrap(); 20 + let mut stmt = match did { 21 + Some(_) => { 22 + conn.prepare(r#" 23 + SELECT a.* FROM user_albums ua 24 + LEFT JOIN albums a ON ua.album_id = a.id 25 + LEFT JOIN users u ON ua.user_id = u.id 26 + WHERE u.did = ? 27 + ORDER BY a.title ASC OFFSET ? LIMIT ?; 28 + "#)? 29 + }, 30 + None => { 31 + conn.prepare("SELECT * FROM albums ORDER BY title ASC OFFSET ? LIMIT ?")? 32 + } 33 + }; 34 + 35 + match did { 36 + Some(did) => { 37 + let albums_iter = stmt.query_map([did, limit.to_string(), offset.to_string()], |row| { 38 + Ok(Album { 39 + id: row.get(0)?, 40 + title: row.get(1)?, 41 + artist: row.get(2)?, 42 + release_date: row.get(3)?, 43 + album_art: row.get(4)?, 44 + year: row.get(5)?, 45 + spotify_link: row.get(6)?, 46 + tidal_link: row.get(7)?, 47 + youtube_link: row.get(8)?, 48 + apple_music_link: row.get(9)?, 49 + sha256: row.get(10)?, 50 + uri: row.get(11)?, 51 + artist_uri: row.get(12)?, 52 + ..Default::default() 53 + }) 54 + })?; 55 + 56 + let albums: Result<Vec<_>, _> = albums_iter.collect(); 57 + Ok(HttpResponse::Ok().json(web::Json(albums?))) 58 + }, 59 + None => { 60 + let albums_iter = stmt.query_map([limit, offset], |row| { 61 + Ok(Album { 62 + id: row.get(0)?, 63 + title: row.get(1)?, 64 + artist: row.get(2)?, 65 + release_date: row.get(3)?, 66 + album_art: row.get(4)?, 67 + year: row.get(5)?, 68 + spotify_link: row.get(6)?, 69 + tidal_link: row.get(7)?, 70 + youtube_link: row.get(8)?, 71 + apple_music_link: row.get(9)?, 72 + sha256: row.get(10)?, 73 + uri: row.get(11)?, 74 + artist_uri: row.get(12)?, 75 + ..Default::default() 76 + }) 77 + })?; 78 + 79 + let albums: Result<Vec<_>, _> = albums_iter.collect(); 80 + Ok(HttpResponse::Ok().json(web::Json(albums?))) 81 + } 82 + } 83 + } 84 + 85 + 86 + pub async fn get_top_albums(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 87 + let body = read_payload!(payload); 88 + let params = serde_json::from_slice::<GetTopAlbumsParams>(&body)?; 89 + let pagination = params.pagination.unwrap_or_default(); 90 + let offset = pagination.skip.unwrap_or(0); 91 + let limit = pagination.take.unwrap_or(20); 92 + let did = params.user_did; 93 + 94 + let conn = conn.lock().unwrap(); 95 + let mut stmt = match did { 96 + Some(_) => conn.prepare(r#" 97 + SELECT 98 + s.album_id AS id, 99 + a.title AS title, 100 + ar.name AS artist, 101 + a.album_art AS album_art, 102 + a.release_date, 103 + a.year, 104 + a.uri AS uri, 105 + COUNT(*) AS play_count, 106 + COUNT(DISTINCT s.user_id) AS unique_listeners 107 + FROM 108 + scrobbles s 109 + LEFT JOIN 110 + albums a ON s.album_id = a.id 111 + LEFT JOIN 112 + artists ar ON a.artist_uri = ar.uri 113 + LEFT JOIN 114 + users u ON s.user_id = u.id 115 + WHERE s.album_id IS NOT NULL AND u.did = ? 116 + GROUP BY 117 + s.album_id, a.title, ar.name, a.release_date, a.year, a.uri, a.album_art 118 + ORDER BY 119 + play_count DESC 120 + OFFSET ? 121 + LIMIT ?; 122 + "#)?, 123 + None => conn.prepare(r#" 124 + SELECT 125 + s.album_id AS id, 126 + a.title AS title, 127 + ar.name AS artist, 128 + a.album_art AS album_art, 129 + a.release_date, 130 + a.year, 131 + a.uri AS uri, 132 + COUNT(*) AS play_count, 133 + COUNT(DISTINCT s.user_id) AS unique_listeners 134 + FROM 135 + scrobbles s 136 + LEFT JOIN 137 + albums a ON s.album_id = a.id 138 + LEFT JOIN 139 + artists ar ON a.artist_uri = ar.uri WHERE s.album_id IS NOT NULL 140 + GROUP BY 141 + s.album_id, a.title, ar.name, a.release_date, a.year, a.uri, a.album_art 142 + ORDER BY 143 + play_count DESC 144 + OFFSET ? 145 + LIMIT ?; 146 + "#)? 147 + }; 148 + 149 + match did { 150 + Some(did) => { 151 + let albums = stmt.query_map([did, limit.to_string(), offset.to_string()], |row| { 152 + Ok(Album { 153 + id: row.get(0)?, 154 + title: row.get(1)?, 155 + artist: row.get(2)?, 156 + album_art: row.get(3)?, 157 + release_date: row.get(4)?, 158 + year: row.get(5)?, 159 + uri: row.get(6)?, 160 + play_count: Some(row.get(7)?), 161 + unique_listeners: Some(row.get(8)?), 162 + ..Default::default() 163 + }) 164 + })?; 165 + let albums: Result<Vec<_>, _> = albums.collect(); 166 + Ok(HttpResponse::Ok().json(web::Json(albums?))) 167 + }, 168 + None => { 169 + let albums = stmt.query_map([limit, offset], |row| { 170 + Ok(Album { 171 + id: row.get(0)?, 172 + title: row.get(1)?, 173 + artist: row.get(2)?, 174 + album_art: row.get(3)?, 175 + release_date: row.get(4)?, 176 + year: row.get(5)?, 177 + uri: row.get(6)?, 178 + play_count: Some(row.get(7)?), 179 + unique_listeners: Some(row.get(8)?), 180 + ..Default::default() 181 + }) 182 + })?; 183 + let albums: Result<Vec<_>, _> = albums.collect(); 184 + Ok(HttpResponse::Ok().json(web::Json(albums?))) 185 + } 186 + } 187 + }
+199
crates/analytics/src/handlers/artists.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + 3 + use actix_web::{web, HttpRequest, HttpResponse}; 4 + use analytics::types::artist::{Artist, GetTopArtistsParams}; 5 + use duckdb::Connection; 6 + use anyhow::Error; 7 + use futures_util::StreamExt; 8 + 9 + use crate::{read_payload, types::artist::GetArtistsParams}; 10 + 11 + pub async fn get_artists(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 12 + let body = read_payload!(payload); 13 + let params = serde_json::from_slice::<GetArtistsParams>(&body)?; 14 + let pagination = params.pagination.unwrap_or_default(); 15 + let offset = pagination.skip.unwrap_or(0); 16 + let limit = pagination.take.unwrap_or(20); 17 + let did = params.user_did; 18 + 19 + let conn = conn.lock().unwrap(); 20 + let mut stmt = match did { 21 + Some(_) => { 22 + conn.prepare(r#" 23 + SELECT a.* FROM user_artists ua 24 + LEFT JOIN artists a ON ua.artist_id = a.id 25 + LEFT JOIN users u ON ua.user_id = u.id 26 + WHERE u.did = ? 27 + ORDER BY a.name ASC OFFSET ? LIMIT ?; 28 + "#)? 29 + }, 30 + None => { 31 + conn.prepare("SELECT * FROM artists ORDER BY name ASC OFFSET ? LIMIT ?")? 32 + } 33 + }; 34 + 35 + match did { 36 + Some(did) => { 37 + let artists = stmt.query_map([did, limit.to_string(), offset.to_string()], |row| { 38 + Ok(Artist { 39 + id: row.get(0)?, 40 + name: row.get(1)?, 41 + biography: row.get(2)?, 42 + born: row.get(3)?, 43 + born_in: row.get(4)?, 44 + died: row.get(5)?, 45 + picture: row.get(6)?, 46 + sha256: row.get(7)?, 47 + spotify_link: row.get(8)?, 48 + tidal_link: row.get(9)?, 49 + youtube_link: row.get(10)?, 50 + apple_music_link: row.get(11)?, 51 + uri: row.get(12)?, 52 + play_count: None, 53 + unique_listeners: None, 54 + }) 55 + })?; 56 + 57 + let artists: Result<Vec<_>, _> = artists.collect(); 58 + Ok(HttpResponse::Ok().json(artists?)) 59 + }, 60 + None => { 61 + let artists = stmt.query_map([limit, offset], |row| { 62 + Ok(Artist { 63 + id: row.get(0)?, 64 + name: row.get(1)?, 65 + biography: row.get(2)?, 66 + born: row.get(3)?, 67 + born_in: row.get(4)?, 68 + died: row.get(5)?, 69 + picture: row.get(6)?, 70 + sha256: row.get(7)?, 71 + spotify_link: row.get(8)?, 72 + tidal_link: row.get(9)?, 73 + youtube_link: row.get(10)?, 74 + apple_music_link: row.get(11)?, 75 + uri: row.get(12)?, 76 + play_count: None, 77 + unique_listeners: None, 78 + }) 79 + })?; 80 + 81 + let artists: Result<Vec<_>, _> = artists.collect(); 82 + Ok(HttpResponse::Ok().json(artists?)) 83 + } 84 + } 85 + } 86 + 87 + pub async fn get_top_artists(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 88 + let body = read_payload!(payload); 89 + let params = serde_json::from_slice::<GetTopArtistsParams>(&body)?; 90 + let pagination = params.pagination.unwrap_or_default(); 91 + let offset = pagination.skip.unwrap_or(0); 92 + let limit = pagination.take.unwrap_or(20); 93 + let did = params.user_did; 94 + 95 + let conn = conn.lock().unwrap(); 96 + let mut stmt = match did { 97 + Some(_) => { 98 + conn.prepare(r#" 99 + SELECT 100 + s.artist_id AS id, 101 + ar.name AS artist_name, 102 + ar.picture AS picture, 103 + ar.sha256 AS sha256, 104 + ar.uri AS uri, 105 + COUNT(*) AS play_count, 106 + COUNT(DISTINCT s.user_id) AS unique_listeners 107 + FROM 108 + scrobbles s 109 + LEFT JOIN 110 + artists ar ON s.artist_id = ar.id 111 + LEFT JOIN 112 + users u ON s.user_id = u.id 113 + WHERE 114 + s.artist_id IS NOT NULL AND u.did = ? 115 + GROUP BY 116 + s.artist_id, ar.name, ar.uri, ar.picture, ar.sha256 117 + ORDER BY 118 + play_count DESC 119 + OFFSET ? 120 + LIMIT ?; 121 + "#)? 122 + }, 123 + None => { 124 + conn.prepare(r#" 125 + SELECT 126 + s.artist_id AS id, 127 + ar.name AS artist_name, 128 + ar.picture AS picture, 129 + ar.sha256 AS sha256, 130 + ar.uri AS uri, 131 + COUNT(*) AS play_count, 132 + COUNT(DISTINCT s.user_id) AS unique_listeners 133 + FROM 134 + scrobbles s 135 + LEFT JOIN 136 + artists ar ON s.artist_id = ar.id 137 + WHERE 138 + s.artist_id IS NOT NULL 139 + GROUP BY 140 + s.artist_id, ar.name, ar.uri, ar.picture, ar.sha256 141 + ORDER BY 142 + play_count DESC 143 + OFFSET ? 144 + LIMIT ?; 145 + "#)? 146 + } 147 + }; 148 + 149 + match did { 150 + Some(did) => { 151 + let artists = stmt.query_map([did, limit.to_string(), offset.to_string()], |row| { 152 + Ok(Artist { 153 + id: row.get(0)?, 154 + name: row.get(1)?, 155 + biography: None, 156 + born: None, 157 + born_in: None, 158 + died: None, 159 + picture: row.get(2)?, 160 + sha256: row.get(3)?, 161 + spotify_link: None, 162 + tidal_link: None, 163 + youtube_link: None, 164 + apple_music_link: None, 165 + uri: row.get(4)?, 166 + play_count: Some(row.get(5)?), 167 + unique_listeners: Some(row.get(6)?), 168 + }) 169 + })?; 170 + 171 + let artists: Result<Vec<_>, _> = artists.collect(); 172 + Ok(HttpResponse::Ok().json(artists?)) 173 + }, 174 + None => { 175 + let artists = stmt.query_map([limit, offset], |row| { 176 + Ok(Artist { 177 + id: row.get(0)?, 178 + name: row.get(1)?, 179 + biography: None, 180 + born: None, 181 + born_in: None, 182 + died: None, 183 + picture: row.get(2)?, 184 + sha256: row.get(3)?, 185 + spotify_link: None, 186 + tidal_link: None, 187 + youtube_link: None, 188 + apple_music_link: None, 189 + uri: row.get(4)?, 190 + play_count: Some(row.get(5)?), 191 + unique_listeners: Some(row.get(6)?), 192 + }) 193 + })?; 194 + 195 + let artists: Result<Vec<_>, _> = artists.collect(); 196 + Ok(HttpResponse::Ok().json(artists?)) 197 + } 198 + } 199 + }
+50
crates/analytics/src/handlers/mod.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + 3 + use actix_web::{web, HttpRequest, HttpResponse}; 4 + use albums::{get_albums, get_top_albums}; 5 + use artists::{get_artists, get_top_artists}; 6 + use duckdb::Connection; 7 + use scrobbles::get_scrobbles; 8 + use stats::{get_scrobbles_per_day, get_scrobbles_per_month, get_scrobbles_per_year, get_stats}; 9 + use tracks::{get_loved_tracks, get_top_tracks, get_tracks}; 10 + use anyhow::Error; 11 + 12 + pub mod albums; 13 + pub mod artists; 14 + pub mod scrobbles; 15 + pub mod tracks; 16 + pub mod stats; 17 + 18 + 19 + #[macro_export] 20 + macro_rules! read_payload { 21 + ($payload:expr) => {{ 22 + let mut body = Vec::new(); 23 + while let Some(chunk) = $payload.next().await { 24 + match chunk { 25 + Ok(bytes) => body.extend_from_slice(&bytes), 26 + Err(err) => return Err(err.into()), 27 + } 28 + } 29 + body 30 + }}; 31 + } 32 + 33 + 34 + pub async fn handle(method: &str, payload: &mut web::Payload, req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 35 + match method { 36 + "library.getAlbums" => get_albums(payload, req, conn.clone()).await, 37 + "library.getArtists" => get_artists(payload, req, conn.clone()).await, 38 + "library.getTracks" => get_tracks(payload, req, conn.clone()).await, 39 + "library.getScrobbles" => get_scrobbles(payload, req, conn.clone()).await, 40 + "library.getLovedTracks" => get_loved_tracks(payload, req, conn.clone()).await, 41 + "library.getStats" => get_stats(payload, req, conn.clone()).await, 42 + "library.getTopAlbums" => get_top_albums(payload, req, conn.clone()).await, 43 + "library.getTopArtists" => get_top_artists(payload, req, conn.clone()).await, 44 + "library.getTopTracks" => get_top_tracks(payload, req, conn.clone()).await, 45 + "library.getScrobblesPerDay" => get_scrobbles_per_day(payload, req, conn.clone()).await, 46 + "library.getScrobblesPerMonth" => get_scrobbles_per_month(payload, req, conn.clone()).await, 47 + "library.getScrobblesPerYear" => get_scrobbles_per_year(payload, req, conn.clone()).await, 48 + _ => return Err(anyhow::anyhow!("Method not found")), 49 + } 50 + }
+117
crates/analytics/src/handlers/scrobbles.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + 3 + use actix_web::{web, HttpRequest, HttpResponse}; 4 + use analytics::types::scrobble::{GetScrobblesParams, ScrobbleTrack}; 5 + use duckdb::Connection; 6 + use anyhow::Error; 7 + use futures_util::StreamExt; 8 + 9 + use crate::read_payload; 10 + 11 + pub async fn get_scrobbles(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 12 + let body = read_payload!(payload); 13 + let params = serde_json::from_slice::<GetScrobblesParams>(&body)?; 14 + let pagination = params.pagination.unwrap_or_default(); 15 + let offset = pagination.skip.unwrap_or(0); 16 + let limit = pagination.take.unwrap_or(20); 17 + let did = params.user_did; 18 + 19 + let conn = conn.lock().unwrap(); 20 + let mut stmt = match did { 21 + Some(_) => conn.prepare(r#" 22 + SELECT 23 + s.id, 24 + t.id as track_id, 25 + t.title, 26 + t.artist, 27 + t.album_artist, 28 + t.album, 29 + t.album_art, 30 + u.handle, 31 + s.uri, 32 + t.uri as track_uri, 33 + a.uri as artist_uri, 34 + al.uri as album_uri, 35 + s.created_at 36 + FROM scrobbles s 37 + LEFT JOIN artists a ON s.artist_id = a.id 38 + LEFT JOIN albums al ON s.album_id = al.id 39 + LEFT JOIN tracks t ON s.track_id = t.id 40 + LEFT JOIN users u ON s.user_id = u.id 41 + WHERE u.did = ? 42 + GROUP BY s.id, s.created_at, t.id, t.title, t.artist, t.album_artist, t.album, t.album_art, s.uri, t.uri, u.handle, a.uri, al.uri, s.created_at 43 + ORDER BY s.created_at DESC 44 + OFFSET ? 45 + LIMIT ?; 46 + "#)?, 47 + None => conn.prepare(r#" 48 + SELECT 49 + s.id, 50 + t.id as track_id, 51 + t.title, 52 + t.artist, 53 + t.album_artist, 54 + t.album, 55 + t.album_art, 56 + u.handle, 57 + s.uri, 58 + t.uri as track_uri, 59 + a.uri as artist_uri, 60 + al.uri as album_uri, 61 + s.created_at 62 + FROM scrobbles s 63 + LEFT JOIN artists a ON s.artist_id = a.id 64 + LEFT JOIN albums al ON s.album_id = al.id 65 + LEFT JOIN tracks t ON s.track_id = t.id 66 + LEFT JOIN users u ON s.user_id = u.id 67 + GROUP BY s.id, s.created_at, t.id, t.title, t.artist, t.album_artist, t.album, t.album_art, s.uri, t.uri, u.handle, a.uri, al.uri, s.created_at 68 + ORDER BY s.created_at DESC 69 + OFFSET ? 70 + LIMIT ?; 71 + "#)?, 72 + }; 73 + match did { 74 + Some(did) => { 75 + let scrobbles = stmt.query_map([did, limit.to_string(), offset.to_string()], |row| { 76 + Ok(ScrobbleTrack { 77 + id: row.get(0)?, 78 + track_id: row.get(1)?, 79 + title: row.get(2)?, 80 + artist: row.get(3)?, 81 + album_artist: row.get(4)?, 82 + album: row.get(5)?, 83 + album_art: row.get(6)?, 84 + handle: row.get(7)?, 85 + uri: row.get(8)?, 86 + track_uri: row.get(9)?, 87 + artist_uri: row.get(10)?, 88 + album_uri: row.get(11)?, 89 + created_at: row.get(12)?, 90 + }) 91 + })?; 92 + let scrobbles: Result<Vec<_>, _> = scrobbles.collect(); 93 + Ok(HttpResponse::Ok().json(scrobbles?)) 94 + }, 95 + None => { 96 + let scrobbles = stmt.query_map([limit, offset], |row| { 97 + Ok(ScrobbleTrack { 98 + id: row.get(0)?, 99 + track_id: row.get(1)?, 100 + title: row.get(2)?, 101 + artist: row.get(3)?, 102 + album_artist: row.get(4)?, 103 + album: row.get(5)?, 104 + album_art: row.get(6)?, 105 + handle: row.get(7)?, 106 + uri: row.get(8)?, 107 + track_uri: row.get(9)?, 108 + artist_uri: row.get(10)?, 109 + album_uri: row.get(11)?, 110 + created_at: row.get(12)?, 111 + }) 112 + })?; 113 + let scrobbles: Result<Vec<_>, _> = scrobbles.collect(); 114 + Ok(HttpResponse::Ok().json(scrobbles?)) 115 + } 116 + } 117 + }
+222
crates/analytics/src/handlers/stats.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + 3 + use actix_web::{web, HttpRequest, HttpResponse}; 4 + use analytics::types::{scrobble::{ScrobblesPerDay, ScrobblesPerMonth, ScrobblesPerYear}, stats::{GetScrobblesPerDayParams, GetScrobblesPerMonthParams, GetScrobblesPerYearParams, GetStatsParams}}; 5 + use duckdb::Connection; 6 + use anyhow::Error; 7 + use serde_json::json; 8 + use futures_util::StreamExt; 9 + use crate::read_payload; 10 + 11 + pub async fn get_stats(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 12 + let body = read_payload!(payload); 13 + let params = serde_json::from_slice::<GetStatsParams>(&body)?; 14 + 15 + let conn = conn.lock().unwrap(); 16 + let mut stmt = conn.prepare("SELECT COUNT(*) FROM scrobbles s LEFT JOIN users u ON s.user_id = u.id WHERE u.did = ?")?; 17 + let scrobbles: i64 = stmt.query_row([&params.user_did], |row| row.get(0))?; 18 + 19 + let mut stmt = conn.prepare("SELECT COUNT(*) FROM user_artists LEFT JOIN users u ON user_artists.user_id = u.id WHERE u.did = ?")?; 20 + let artists: i64 = stmt.query_row([&params.user_did], |row| row.get(0))?; 21 + 22 + let mut stmt = conn.prepare("SELECT COUNT(*) FROM loved_tracks LEFT JOIN users u ON loved_tracks.user_id = u.id WHERE u.did = ?")?; 23 + let loved_tracks: i64 = stmt.query_row([&params.user_did], |row| row.get(0))?; 24 + 25 + let mut stmt = conn.prepare("SELECT COUNT(*) FROM user_albums LEFT JOIN users u ON user_albums.user_id = u.id WHERE u.did = ?")?; 26 + let albums: i64 = stmt.query_row([&params.user_did], |row| row.get(0))?; 27 + 28 + let mut stmt = conn.prepare("SELECT COUNT(*) FROM user_tracks LEFT JOIN users u ON user_tracks.user_id = u.id WHERE u.did = ?")?; 29 + let tracks: i64 = stmt.query_row([&params.user_did], |row| row.get(0))?; 30 + 31 + Ok(HttpResponse::Ok().json(json!({ 32 + "scrobbles": scrobbles, 33 + "artists": artists, 34 + "loved_tracks": loved_tracks, 35 + "albums": albums, 36 + "tracks": tracks, 37 + }))) 38 + } 39 + 40 + pub async fn get_scrobbles_per_day(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 41 + let body = read_payload!(payload); 42 + let params = serde_json::from_slice::<GetScrobblesPerDayParams>(&body)?; 43 + let start = params.start.unwrap_or(GetScrobblesPerDayParams::default().start.unwrap()); 44 + let end = params.end.unwrap_or(GetScrobblesPerDayParams::default().end.unwrap()); 45 + let did = params.user_did; 46 + 47 + let conn = conn.lock().unwrap(); 48 + match did { 49 + Some(did) => { 50 + let mut stmt = conn.prepare(r#" 51 + SELECT 52 + date_trunc('day', created_at) AS date, 53 + COUNT(track_id) AS count 54 + FROM 55 + scrobbles 56 + LEFT JOIN users u ON scrobbles.user_id = u.id 57 + WHERE 58 + u.did = ? 59 + AND created_at BETWEEN ? AND ? 60 + GROUP BY 61 + date_trunc('day', created_at) 62 + ORDER BY 63 + date; 64 + "#)?; 65 + let scrobbles = stmt.query_map([did, start, end], |row| { 66 + Ok(ScrobblesPerDay { 67 + date: row.get(0)?, 68 + count: row.get(1)?, 69 + }) 70 + })?; 71 + let scrobbles: Result<Vec<_>, _> = scrobbles.collect(); 72 + Ok(HttpResponse::Ok().json(scrobbles?)) 73 + }, 74 + None => { 75 + let mut stmt = conn.prepare(r#" 76 + SELECT 77 + date_trunc('day', created_at) AS date, 78 + COUNT(track_id) AS count 79 + FROM 80 + scrobbles 81 + WHERE 82 + created_at BETWEEN ? AND ? 83 + GROUP BY 84 + date_trunc('day', created_at) 85 + ORDER BY 86 + date; 87 + "#)?; 88 + let scrobbles = stmt.query_map([start, end], |row| { 89 + Ok(ScrobblesPerDay { 90 + date: row.get(0)?, 91 + count: row.get(1)?, 92 + }) 93 + })?; 94 + let scrobbles: Result<Vec<_>, _> = scrobbles.collect(); 95 + Ok(HttpResponse::Ok().json(scrobbles?)) 96 + } 97 + } 98 + } 99 + 100 + pub async fn get_scrobbles_per_month(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 101 + let body = read_payload!(payload); 102 + let params = serde_json::from_slice::<GetScrobblesPerMonthParams>(&body)?; 103 + let start = params.start.unwrap_or(GetScrobblesPerDayParams::default().start.unwrap()); 104 + let end = params.end.unwrap_or(GetScrobblesPerDayParams::default().end.unwrap()); 105 + let did = params.user_did; 106 + 107 + let conn = conn.lock().unwrap(); 108 + match did { 109 + Some(did) => { 110 + let mut stmt = conn.prepare(r#" 111 + SELECT 112 + EXTRACT(YEAR FROM created_at) || '-' || 113 + LPAD(EXTRACT(MONTH FROM created_at)::VARCHAR, 2, '0') AS year_month, 114 + COUNT(*) AS count 115 + FROM 116 + scrobbles 117 + LEFT JOIN users u ON scrobbles.user_id = u.id 118 + WHERE 119 + u.did = ? 120 + AND created_at BETWEEN ? AND ? 121 + GROUP BY 122 + EXTRACT(YEAR FROM created_at), 123 + EXTRACT(MONTH FROM created_at) 124 + ORDER BY 125 + year_month; 126 + "#)?; 127 + let scrobbles = stmt.query_map([did, start, end], |row| { 128 + Ok(ScrobblesPerMonth { 129 + year_month: row.get(0)?, 130 + count: row.get(1)?, 131 + }) 132 + })?; 133 + let scrobbles: Result<Vec<_>, _> = scrobbles.collect(); 134 + Ok(HttpResponse::Ok().json(scrobbles?)) 135 + }, 136 + None => { 137 + let mut stmt = conn.prepare(r#" 138 + SELECT 139 + EXTRACT(YEAR FROM created_at) || '-' || 140 + LPAD(EXTRACT(MONTH FROM created_at)::VARCHAR, 2, '0') AS year_month, 141 + COUNT(*) AS count 142 + FROM 143 + scrobbles 144 + WHERE 145 + created_at BETWEEN ? AND ? 146 + GROUP BY 147 + EXTRACT(YEAR FROM created_at), 148 + EXTRACT(MONTH FROM created_at) 149 + ORDER BY 150 + year_month; 151 + "#)?; 152 + let scrobbles = stmt.query_map([start, end], |row| { 153 + Ok(ScrobblesPerMonth { 154 + year_month: row.get(0)?, 155 + count: row.get(1)?, 156 + }) 157 + })?; 158 + let scrobbles: Result<Vec<_>, _> = scrobbles.collect(); 159 + Ok(HttpResponse::Ok().json(scrobbles?)) 160 + } 161 + } 162 + } 163 + 164 + pub async fn get_scrobbles_per_year(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 165 + let body = read_payload!(payload); 166 + let params = serde_json::from_slice::<GetScrobblesPerYearParams>(&body)?; 167 + let start = params.start.unwrap_or(GetScrobblesPerDayParams::default().start.unwrap()); 168 + let end = params.end.unwrap_or(GetScrobblesPerDayParams::default().end.unwrap()); 169 + let did = params.user_did; 170 + 171 + let conn = conn.lock().unwrap(); 172 + match did { 173 + Some(did) => { 174 + let mut stmt = conn.prepare(r#" 175 + SELECT 176 + EXTRACT(YEAR FROM created_at) AS year, 177 + COUNT(*) AS count 178 + FROM 179 + scrobbles 180 + LEFT JOIN users u ON scrobbles.user_id = u.id 181 + WHERE 182 + u.did = ? 183 + AND created_at BETWEEN ? AND ? 184 + GROUP BY 185 + EXTRACT(YEAR FROM created_at) 186 + ORDER BY 187 + year; 188 + "#)?; 189 + let scrobbles = stmt.query_map([did, start, end], |row| { 190 + Ok(ScrobblesPerYear { 191 + year: row.get(0)?, 192 + count: row.get(1)?, 193 + }) 194 + })?; 195 + let scrobbles: Result<Vec<_>, _> = scrobbles.collect(); 196 + Ok(HttpResponse::Ok().json(scrobbles?)) 197 + }, 198 + None => { 199 + let mut stmt = conn.prepare(r#" 200 + SELECT 201 + EXTRACT(YEAR FROM created_at) AS year, 202 + COUNT(*) AS count 203 + FROM 204 + scrobbles 205 + WHERE 206 + created_at BETWEEN ? AND ? 207 + GROUP BY 208 + EXTRACT(YEAR FROM created_at) 209 + ORDER BY 210 + year; 211 + "#)?; 212 + let scrobbles = stmt.query_map([start, end], |row| { 213 + Ok(ScrobblesPerYear { 214 + year: row.get(0)?, 215 + count: row.get(1)?, 216 + }) 217 + })?; 218 + let scrobbles: Result<Vec<_>, _> = scrobbles.collect(); 219 + Ok(HttpResponse::Ok().json(scrobbles?)) 220 + } 221 + } 222 + }
+344
crates/analytics/src/handlers/tracks.rs
··· 1 + use std::sync::{Arc, Mutex}; 2 + 3 + use actix_web::{web, HttpRequest, HttpResponse}; 4 + use analytics::types::track::{GetLovedTracksParams, GetTopTracksParams, GetTracksParams, Track}; 5 + use duckdb::Connection; 6 + use anyhow::Error; 7 + use futures_util::StreamExt; 8 + 9 + use crate::read_payload; 10 + 11 + pub async fn get_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>> 12 + ) -> Result<HttpResponse, Error> { 13 + let body = read_payload!(payload); 14 + let params = serde_json::from_slice::<GetTracksParams>(&body)?; 15 + let pagination = params.pagination.unwrap_or_default(); 16 + let offset = pagination.skip.unwrap_or(0); 17 + let limit = pagination.take.unwrap_or(20); 18 + let did = params.user_did; 19 + 20 + let conn = conn.lock().unwrap(); 21 + match did { 22 + Some(did) => { 23 + let mut stmt = conn.prepare(r#" 24 + SELECT 25 + t.id, 26 + t.title, 27 + t.artist, 28 + t.album_artist, 29 + t.album_art, 30 + t.album, 31 + t.track_number, 32 + t.duration, 33 + t.mb_id, 34 + t.youtube_link, 35 + t.spotify_link, 36 + t.tidal_link, 37 + t.apple_music_link, 38 + t.sha256, 39 + t.composer, 40 + t.genre, 41 + t.disc_number, 42 + t.label, 43 + t.uri, 44 + t.copyright_message, 45 + t.artist_uri, 46 + t.album_uri, 47 + t.created_at, 48 + FROM tracks t 49 + LEFT JOIN user_tracks ut ON t.id = ut.track_id 50 + LEFT JOIN users u ON ut.user_id = u.id 51 + WHERE u.did = ? 52 + ORDER BY t.title ASC 53 + OFFSET ? 54 + LIMIT ?; 55 + "#)?; 56 + let tracks = stmt.query_map([did, limit.to_string(), offset.to_string()], |row| { 57 + Ok(Track { 58 + id: row.get(0)?, 59 + title: row.get(1)?, 60 + artist: row.get(2)?, 61 + album_artist: row.get(3)?, 62 + album_art: row.get(4)?, 63 + album: row.get(5)?, 64 + track_number: row.get(6)?, 65 + duration: row.get(7)?, 66 + mb_id: row.get(8)?, 67 + youtube_link: row.get(9)?, 68 + spotify_link: row.get(10)?, 69 + tidal_link: row.get(11)?, 70 + apple_music_link: row.get(12)?, 71 + sha256: row.get(13)?, 72 + composer: row.get(14)?, 73 + genre: row.get(15)?, 74 + disc_number: row.get(16)?, 75 + label: row.get(17)?, 76 + uri: row.get(18)?, 77 + copyright_message: row.get(19)?, 78 + artist_uri: row.get(20)?, 79 + album_uri: row.get(21)?, 80 + created_at: row.get(22)?, 81 + ..Default::default() 82 + }) 83 + })?; 84 + let tracks: Result<Vec<_>, _> = tracks.collect(); 85 + Ok(HttpResponse::Ok().json(tracks?)) 86 + }, 87 + None => { 88 + let mut stmt = conn.prepare(r#" 89 + SELECT 90 + id, 91 + title, 92 + artist, 93 + album_artist, 94 + album_art, 95 + album, 96 + track_number, 97 + duration, 98 + mb_id, 99 + youtube_link, 100 + spotify_link, 101 + tidal_link, 102 + apple_music_link, 103 + sha256, 104 + composer, 105 + genre, 106 + disc_number, 107 + label, 108 + uri, 109 + copyright_message, 110 + artist_uri, 111 + album_uri, 112 + created_at, 113 + FROM tracks 114 + ORDER BY title ASC 115 + OFFSET ? 116 + LIMIT ?; 117 + "#)?; 118 + let tracks = stmt.query_map([limit, offset], |row| { 119 + Ok(Track { 120 + id: row.get(0)?, 121 + title: row.get(1)?, 122 + artist: row.get(2)?, 123 + album_artist: row.get(3)?, 124 + album_art: row.get(4)?, 125 + album: row.get(5)?, 126 + track_number: row.get(6)?, 127 + duration: row.get(7)?, 128 + mb_id: row.get(8)?, 129 + youtube_link: row.get(9)?, 130 + spotify_link: row.get(10)?, 131 + tidal_link: row.get(11)?, 132 + apple_music_link: row.get(12)?, 133 + sha256: row.get(13)?, 134 + composer: row.get(14)?, 135 + genre: row.get(15)?, 136 + disc_number: row.get(16)?, 137 + label: row.get(17)?, 138 + uri: row.get(18)?, 139 + copyright_message: row.get(19)?, 140 + artist_uri: row.get(20)?, 141 + album_uri: row.get(21)?, 142 + created_at: row.get(22)?, 143 + ..Default::default() 144 + }) 145 + })?; 146 + let tracks: Result<Vec<_>, _> = tracks.collect(); 147 + Ok(HttpResponse::Ok().json(tracks?)) 148 + } 149 + } 150 + } 151 + 152 + pub async fn get_loved_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 153 + let body = read_payload!(payload); 154 + let params = serde_json::from_slice::<GetLovedTracksParams>(&body)?; 155 + let pagination = params.pagination.unwrap_or_default(); 156 + let offset = pagination.skip.unwrap_or(0); 157 + let limit = pagination.take.unwrap_or(20); 158 + let did = params.user_did; 159 + 160 + let conn = conn.lock().unwrap(); 161 + let mut stmt = conn.prepare(r#" 162 + SELECT 163 + t.id, 164 + t.title, 165 + t.artist, 166 + t.album, 167 + t.album_artist, 168 + t.album_art, 169 + t.album_uri, 170 + t.artist_uri, 171 + t.composer, 172 + t.copyright_message, 173 + t.disc_number, 174 + t.duration, 175 + t.track_number, 176 + t.label, 177 + t.spotify_link, 178 + t.tidal_link, 179 + t.youtube_link, 180 + t.apple_music_link, 181 + t.sha256, 182 + t.uri, 183 + u.handle, 184 + u.did, 185 + l.created_at 186 + FROM loved_tracks l 187 + LEFT JOIN users u ON l.user_id = u.id 188 + LEFT JOIN tracks t ON l.track_id = t.id 189 + WHERE u.did = ? 190 + ORDER BY l.created_at DESC 191 + OFFSET ? 192 + LIMIT ?; 193 + "#)?; 194 + let loved_tracks = stmt.query_map([did, limit.to_string(), offset.to_string()], |row| { 195 + Ok(Track { 196 + id: row.get(0)?, 197 + title: row.get(1)?, 198 + artist: row.get(2)?, 199 + album: row.get(3)?, 200 + album_artist: row.get(4)?, 201 + album_art: row.get(5)?, 202 + album_uri: row.get(6)?, 203 + artist_uri: row.get(7)?, 204 + composer: row.get(8)?, 205 + copyright_message: row.get(9)?, 206 + disc_number: row.get(10)?, 207 + duration: row.get(11)?, 208 + track_number: row.get(12)?, 209 + label: row.get(13)?, 210 + spotify_link: row.get(14)?, 211 + tidal_link: row.get(15)?, 212 + youtube_link: row.get(16)?, 213 + apple_music_link: row.get(17)?, 214 + sha256: row.get(18)?, 215 + uri: row.get(19)?, 216 + handle: row.get(20)?, 217 + did: row.get(21)?, 218 + created_at: row.get(22)?, 219 + ..Default::default() 220 + }) 221 + })?; 222 + let loved_tracks: Result<Vec<_>, _> = loved_tracks.collect(); 223 + Ok(HttpResponse::Ok().json(loved_tracks?)) 224 + } 225 + 226 + pub async fn get_top_tracks(payload: &mut web::Payload, _req: &HttpRequest, conn: Arc<Mutex<Connection>>) -> Result<HttpResponse, Error> { 227 + let body = read_payload!(payload); 228 + let params = serde_json::from_slice::<GetTopTracksParams>(&body)?; 229 + let pagination = params.pagination.unwrap_or_default(); 230 + let offset = pagination.skip.unwrap_or(0); 231 + let limit = pagination.take.unwrap_or(20); 232 + let did = params.user_did; 233 + 234 + let conn = conn.lock().unwrap(); 235 + match did { 236 + Some(did) => { 237 + let mut stmt = conn.prepare(r#" 238 + SELECT 239 + t.id, 240 + t.title, 241 + t.artist, 242 + t.album_artist, 243 + t.album, 244 + t.uri, 245 + t.album_art, 246 + t.duration, 247 + t.disc_number, 248 + t.track_number, 249 + t.artist_uri, 250 + t.album_uri, 251 + t.sha256, 252 + t.created_at, 253 + COUNT(*) AS play_count, 254 + COUNT(DISTINCT s.user_id) AS unique_listeners 255 + FROM scrobbles s 256 + LEFT JOIN tracks t ON s.track_id = t.id 257 + LEFT JOIN artists ar ON s.artist_id = ar.id 258 + LEFT JOIN albums a ON s.album_id = a.id 259 + LEFT JOIN users u ON s.user_id = u.id 260 + WHERE u.did = ? 261 + 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 262 + ORDER BY play_count DESC 263 + OFFSET ? 264 + LIMIT ?; 265 + "#)?; 266 + let top_tracks = stmt.query_map([did, limit.to_string(), offset.to_string()], |row| { 267 + Ok(Track { 268 + id: row.get(0)?, 269 + title: row.get(1)?, 270 + artist: row.get(2)?, 271 + album_artist: row.get(3)?, 272 + album: row.get(4)?, 273 + uri: row.get(5)?, 274 + album_art: row.get(6)?, 275 + duration: row.get(7)?, 276 + disc_number: row.get(8)?, 277 + track_number: row.get(9)?, 278 + artist_uri: row.get(10)?, 279 + album_uri: row.get(11)?, 280 + sha256: row.get(12)?, 281 + created_at: row.get(13)?, 282 + play_count: row.get(14)?, 283 + unique_listeners: row.get(15)?, 284 + ..Default::default() 285 + }) 286 + })?; 287 + let top_tracks: Result<Vec<_>, _> = top_tracks.collect(); 288 + Ok(HttpResponse::Ok().json(top_tracks?)) 289 + }, 290 + None => { 291 + let mut stmt = conn.prepare(r#" 292 + SELECT 293 + t.id, 294 + t.title, 295 + t.artist, 296 + t.album_artist, 297 + t.album, 298 + t.uri, 299 + t.album_art, 300 + t.duration, 301 + t.disc_number, 302 + t.track_number, 303 + t.artist_uri, 304 + t.album_uri, 305 + t.sha256, 306 + t.created_at, 307 + COUNT(*) AS play_count, 308 + COUNT(DISTINCT s.user_id) AS unique_listeners 309 + FROM scrobbles s 310 + LEFT JOIN tracks t ON s.track_id = t.id 311 + LEFT JOIN artists ar ON s.artist_id = ar.id 312 + LEFT JOIN albums a ON s.album_id = a.id 313 + WHERE s.track_id IS NOT NULL AND s.artist_id IS NOT NULL AND s.album_id IS NOT NULL 314 + 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 315 + ORDER BY play_count DESC 316 + OFFSET ? 317 + LIMIT ?; 318 + "#)?; 319 + let top_tracks = stmt.query_map([limit, offset], |row| { 320 + Ok(Track { 321 + id: row.get(0)?, 322 + title: row.get(1)?, 323 + artist: row.get(2)?, 324 + album_artist: row.get(3)?, 325 + album: row.get(4)?, 326 + uri: row.get(5)?, 327 + album_art: row.get(6)?, 328 + duration: row.get(7)?, 329 + disc_number: row.get(8)?, 330 + track_number: row.get(9)?, 331 + artist_uri: row.get(10)?, 332 + album_uri: row.get(11)?, 333 + sha256: row.get(12)?, 334 + created_at: row.get(13)?, 335 + play_count: row.get(14)?, 336 + unique_listeners: row.get(15)?, 337 + ..Default::default() 338 + }) 339 + })?; 340 + let top_tracks: Result<Vec<_>, _> = top_tracks.collect(); 341 + Ok(HttpResponse::Ok().json(top_tracks?)) 342 + } 343 + } 344 + }
+7 -5
crates/analytics/src/main.rs
··· 1 1 use core::create_tables; 2 - use std::env; 2 + use std::{env, sync::{Arc, Mutex}}; 3 3 4 4 use clap::Command; 5 5 use cmd::{serve::serve, sync::sync}; ··· 10 10 pub mod types; 11 11 pub mod xata; 12 12 pub mod cmd; 13 - pub mod core; 13 + pub mod core; 14 + pub mod handlers; 14 15 15 16 fn cli() -> Command { 16 17 Command::new("analytics") ··· 37 38 create_tables(&conn).await?; 38 39 39 40 let args = cli().get_matches(); 41 + let conn = Arc::new(Mutex::new(conn)); 40 42 41 43 match args.subcommand() { 42 - Some(("sync", _)) => sync(&conn, &pool).await?, 43 - Some(("serve", _)) => serve(&conn).await?, 44 - _ => serve(&conn).await?, 44 + Some(("sync", _)) => sync(conn, &pool).await?, 45 + Some(("serve", _)) => serve(conn).await?, 46 + _ => serve(conn).await?, 45 47 } 46 48 47 49 Ok(())
+25 -12
crates/analytics/src/types/album.rs
··· 1 - use actix_web::{body::BoxBody, http::header::ContentType, HttpRequest, HttpResponse, Responder}; 2 1 use serde::{Deserialize, Serialize}; 3 2 4 - #[derive(Debug, Serialize, Deserialize)] 3 + use super::pagination::Pagination; 4 + 5 + #[derive(Debug, Serialize, Deserialize, Default)] 5 6 pub struct Album { 6 7 pub id: String, 7 8 pub title: String, 8 9 pub artist: String, 10 + #[serde(skip_serializing_if = "Option::is_none")] 9 11 pub release_date: Option<String>, 12 + #[serde(skip_serializing_if = "Option::is_none")] 10 13 pub album_art: Option<String>, 14 + #[serde(skip_serializing_if = "Option::is_none")] 11 15 pub year: Option<i32>, 16 + #[serde(skip_serializing_if = "Option::is_none")] 12 17 pub spotify_link: Option<String>, 18 + #[serde(skip_serializing_if = "Option::is_none")] 13 19 pub tidal_link: Option<String>, 20 + #[serde(skip_serializing_if = "Option::is_none")] 14 21 pub youtube_link: Option<String>, 22 + #[serde(skip_serializing_if = "Option::is_none")] 15 23 pub apple_music_link: Option<String>, 16 24 pub sha256: String, 25 + #[serde(skip_serializing_if = "Option::is_none")] 17 26 pub uri: Option<String>, 27 + #[serde(skip_serializing_if = "Option::is_none")] 18 28 pub artist_uri: Option<String>, 29 + #[serde(skip_serializing_if = "Option::is_none")] 30 + pub play_count: Option<i32>, 31 + #[serde(skip_serializing_if = "Option::is_none")] 32 + pub unique_listeners: Option<i32>, 19 33 } 20 34 21 - impl Responder for Album { 22 - type Body = BoxBody; 23 - 24 - fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> { 25 - let body = serde_json::to_string(&self).unwrap(); 35 + #[derive(Debug, Serialize, Deserialize, Default)] 36 + pub struct GetAlbumsParams { 37 + pub user_did: Option<String>, 38 + pub pagination: Option<Pagination>, 39 + } 26 40 27 - // Create response and set content type 28 - HttpResponse::Ok() 29 - .content_type(ContentType::json()) 30 - .body(body) 31 - } 41 + #[derive(Debug, Serialize, Deserialize, Default)] 42 + pub struct GetTopAlbumsParams { 43 + pub user_did: Option<String>, 44 + pub pagination: Option<Pagination>, 32 45 }
crates/analytics/src/types/album_track.rs

This is a binary file and will not be displayed.

+25 -12
crates/analytics/src/types/artist.rs
··· 1 - use actix_web::{body::BoxBody, http::header::ContentType, HttpRequest, HttpResponse, Responder}; 1 + use super::pagination::Pagination; 2 2 use chrono::NaiveDate; 3 3 use serde::{Deserialize, Serialize}; 4 4 5 - #[derive(Debug, Serialize, Deserialize)] 5 + #[derive(Debug, Serialize, Deserialize, Default)] 6 6 pub struct Artist { 7 7 pub id: String, 8 8 pub name: String, 9 + #[serde(skip_serializing_if = "Option::is_none")] 9 10 pub biography: Option<String>, 11 + #[serde(skip_serializing_if = "Option::is_none")] 10 12 pub born: Option<NaiveDate>, 13 + #[serde(skip_serializing_if = "Option::is_none")] 11 14 pub born_in: Option<String>, 15 + #[serde(skip_serializing_if = "Option::is_none")] 12 16 pub died: Option<NaiveDate>, 17 + #[serde(skip_serializing_if = "Option::is_none")] 13 18 pub picture: Option<String>, 14 19 pub sha256: String, 20 + #[serde(skip_serializing_if = "Option::is_none")] 15 21 pub spotify_link: Option<String>, 22 + #[serde(skip_serializing_if = "Option::is_none")] 16 23 pub tidal_link: Option<String>, 24 + #[serde(skip_serializing_if = "Option::is_none")] 17 25 pub youtube_link: Option<String>, 26 + #[serde(skip_serializing_if = "Option::is_none")] 18 27 pub apple_music_link: Option<String>, 28 + #[serde(skip_serializing_if = "Option::is_none")] 19 29 pub uri: Option<String>, 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub play_count: Option<i32>, 32 + #[serde(skip_serializing_if = "Option::is_none")] 33 + pub unique_listeners: Option<i32>, 20 34 } 21 35 22 - impl Responder for Artist { 23 - type Body = BoxBody; 24 - 25 - fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> { 26 - let body = serde_json::to_string(&self).unwrap(); 36 + #[derive(Debug, Serialize, Deserialize, Default)] 37 + pub struct GetArtistsParams { 38 + pub user_did: Option<String>, 39 + pub pagination: Option<Pagination>, 40 + } 27 41 28 - // Create response and set content type 29 - HttpResponse::Ok() 30 - .content_type(ContentType::json()) 31 - .body(body) 32 - } 42 + #[derive(Debug, Serialize, Deserialize, Default)] 43 + pub struct GetTopArtistsParams { 44 + pub user_did: Option<String>, 45 + pub pagination: Option<Pagination>, 33 46 }
crates/analytics/src/types/artist_album.rs

This is a binary file and will not be displayed.

crates/analytics/src/types/artist_track.rs

This is a binary file and will not be displayed.

+8
crates/analytics/src/types/filters.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Debug, Clone, Serialize, Deserialize, Default)] 4 + pub struct Filters { 5 + pub user_did: Option<String>, 6 + pub order_by: Option<String>, 7 + pub asc: Option<bool>, 8 + }
crates/analytics/src/types/loved_track.rs

This is a binary file and will not be displayed.

+3 -6
crates/analytics/src/types/mod.rs
··· 1 1 pub mod album; 2 - pub mod album_track; 3 2 pub mod artist; 4 - pub mod artist_track; 3 + pub mod filters; 4 + pub mod pagination; 5 5 pub mod playlist; 6 - pub mod playlist_track; 7 6 pub mod scrobble; 7 + pub mod stats; 8 8 pub mod track; 9 9 pub mod user; 10 - pub mod user_album; 11 - pub mod user_artist; 12 - pub mod user_playlist;
+16
crates/analytics/src/types/pagination.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + #[derive(Debug, Clone, Serialize, Deserialize)] 4 + pub struct Pagination { 5 + pub skip: Option<u32>, 6 + pub take: Option<u32>, 7 + } 8 + 9 + impl Default for Pagination { 10 + fn default() -> Self { 11 + Pagination { 12 + skip: Some(0), 13 + take: Some(20), 14 + } 15 + } 16 + }
+2 -15
crates/analytics/src/types/playlist.rs
··· 1 - use actix_web::{body::BoxBody, http::header::ContentType, HttpRequest, HttpResponse, Responder}; 2 1 use chrono::NaiveDateTime; 3 2 use serde::{Deserialize, Serialize}; 4 3 5 - #[derive(Debug, Serialize, Deserialize)] 4 + #[derive(Debug, Serialize, Deserialize, Default)] 6 5 pub struct Playlist { 7 6 pub id: String, 8 7 pub name: String, ··· 12 11 pub updated_at: NaiveDateTime, 13 12 pub uri: Option<String>, 14 13 pub created_by: String, 15 - } 16 - 17 - impl Responder for Playlist { 18 - type Body = BoxBody; 19 - 20 - fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> { 21 - let body = serde_json::to_string(&self).unwrap(); 22 - 23 - // Create response and set content type 24 - HttpResponse::Ok() 25 - .content_type(ContentType::json()) 26 - .body(body) 27 - } 14 + pub listeners: Option<i32>, 28 15 }
crates/analytics/src/types/playlist_track.rs

This is a binary file and will not be displayed.

+45 -2
crates/analytics/src/types/scrobble.rs
··· 1 - use chrono::NaiveDateTime; 1 + use chrono::{NaiveDate, NaiveDateTime}; 2 2 use serde::{Deserialize, Serialize}; 3 3 4 - #[derive(Debug, Serialize, Deserialize)] 4 + use super::pagination::Pagination; 5 + 6 + #[derive(Debug, Serialize, Deserialize, Default)] 5 7 pub struct Scrobble { 6 8 pub id: String, 7 9 pub user_id: String, ··· 11 13 pub uri: Option<String>, 12 14 pub created_at: NaiveDateTime, 13 15 } 16 + 17 + #[derive(Debug, Serialize, Deserialize, Default)] 18 + pub struct ScrobbleTrack { 19 + pub id: String, 20 + pub track_id: String, 21 + pub title: String, 22 + pub artist: String, 23 + pub album_artist: String, 24 + pub album_art: Option<String>, 25 + pub album: String, 26 + pub handle: String, 27 + pub uri: Option<String>, 28 + pub track_uri: Option<String>, 29 + pub artist_uri: Option<String>, 30 + pub album_uri: Option<String>, 31 + pub created_at: NaiveDateTime, 32 + } 33 + 34 + #[derive(Debug, Serialize, Deserialize, Default)] 35 + pub struct ScrobblesPerDay { 36 + pub date: NaiveDate, 37 + pub count: i32, 38 + } 39 + 40 + #[derive(Debug, Serialize, Deserialize, Default)] 41 + pub struct ScrobblesPerMonth { 42 + pub year_month: String, 43 + pub count: i32, 44 + } 45 + 46 + #[derive(Debug, Serialize, Deserialize, Default)] 47 + pub struct ScrobblesPerYear { 48 + pub year: i32, 49 + pub count: i32, 50 + } 51 + 52 + #[derive(Debug, Serialize, Deserialize, Default)] 53 + pub struct GetScrobblesParams { 54 + pub user_did: Option<String>, 55 + pub pagination: Option<Pagination>, 56 + }
+70
crates/analytics/src/types/stats.rs
··· 1 + use super::pagination::Pagination; 2 + 3 + use chrono::{Datelike, Duration, NaiveDate, Utc}; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + #[derive(Debug, Serialize, Deserialize, Default)] 7 + pub struct GetStatsParams { 8 + pub user_did: String, 9 + pub pagination: Option<Pagination>, 10 + } 11 + 12 + #[derive(Debug, Serialize, Deserialize)] 13 + pub struct GetScrobblesPerDayParams { 14 + pub user_did: Option<String>, 15 + pub start: Option<String>, 16 + pub end: Option<String>, 17 + } 18 + 19 + impl Default for GetScrobblesPerDayParams { 20 + fn default() -> Self { 21 + let current_date = Utc::now().naive_utc(); 22 + let date_30_days_ago = current_date - Duration::days(30); 23 + 24 + GetScrobblesPerDayParams { 25 + user_did: None, 26 + start: Some(date_30_days_ago.to_string()), 27 + end: Some(current_date.to_string()), 28 + } 29 + } 30 + } 31 + 32 + #[derive(Debug, Serialize, Deserialize)] 33 + pub struct GetScrobblesPerMonthParams { 34 + pub user_did: Option<String>, 35 + pub start: Option<String>, 36 + pub end: Option<String>, 37 + } 38 + 39 + impl Default for GetScrobblesPerMonthParams { 40 + fn default() -> Self { 41 + let current_date = Utc::now().naive_utc(); 42 + let january = NaiveDate::from_ymd_opt(current_date.year(), 1, 1).unwrap(); 43 + 44 + GetScrobblesPerMonthParams { 45 + user_did: None, 46 + start: Some(january.to_string()), 47 + end: Some(current_date.to_string()), 48 + } 49 + } 50 + } 51 + 52 + #[derive(Debug, Serialize, Deserialize)] 53 + pub struct GetScrobblesPerYearParams { 54 + pub user_did: Option<String>, 55 + pub start: Option<String>, 56 + pub end: Option<String>, 57 + } 58 + 59 + impl Default for GetScrobblesPerYearParams { 60 + fn default() -> Self { 61 + let current_date = Utc::now().naive_utc(); 62 + let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(); 63 + 64 + GetScrobblesPerYearParams { 65 + user_did: None, 66 + start: Some(start.to_string()), 67 + end: Some(current_date.to_string()), 68 + } 69 + } 70 + }
+39 -11
crates/analytics/src/types/track.rs
··· 1 - use actix_web::{body::BoxBody, http::header::ContentType, HttpRequest, HttpResponse, Responder}; 1 + use super::pagination::Pagination; 2 + 2 3 use chrono::NaiveDateTime; 3 4 use serde::{Deserialize, Serialize}; 4 5 5 - #[derive(Debug, Serialize, Deserialize)] 6 + #[derive(Debug, Serialize, Deserialize, Default)] 6 7 pub struct Track { 7 8 pub id: String, 8 9 pub title: String, 9 10 pub artist: String, 10 11 pub album_artist: String, 12 + #[serde(skip_serializing_if = "Option::is_none")] 11 13 pub album_art: Option<String>, 12 14 pub album: String, 13 15 pub track_number: i32, 14 16 pub duration: i32, 17 + #[serde(skip_serializing_if = "Option::is_none")] 15 18 pub mb_id: Option<String>, 19 + #[serde(skip_serializing_if = "Option::is_none")] 16 20 pub youtube_link: Option<String>, 21 + #[serde(skip_serializing_if = "Option::is_none")] 17 22 pub spotify_link: Option<String>, 23 + #[serde(skip_serializing_if = "Option::is_none")] 18 24 pub tidal_link: Option<String>, 25 + #[serde(skip_serializing_if = "Option::is_none")] 19 26 pub apple_music_link: Option<String>, 20 27 pub sha256: String, 28 + #[serde(skip_serializing_if = "Option::is_none")] 21 29 pub lyrics: Option<String>, 30 + #[serde(skip_serializing_if = "Option::is_none")] 22 31 pub composer: Option<String>, 32 + #[serde(skip_serializing_if = "Option::is_none")] 23 33 pub genre: Option<String>, 24 34 pub disc_number: i32, 35 + #[serde(skip_serializing_if = "Option::is_none")] 25 36 pub copyright_message: Option<String>, 37 + #[serde(skip_serializing_if = "Option::is_none")] 26 38 pub label: Option<String>, 39 + #[serde(skip_serializing_if = "Option::is_none")] 27 40 pub uri: Option<String>, 41 + #[serde(skip_serializing_if = "Option::is_none")] 28 42 pub artist_uri: Option<String>, 43 + #[serde(skip_serializing_if = "Option::is_none")] 29 44 pub album_uri: Option<String>, 45 + #[serde(skip_serializing_if = "Option::is_none")] 46 + pub handle: Option<String>, 47 + #[serde(skip_serializing_if = "Option::is_none")] 48 + pub did: Option<String>, 30 49 pub created_at: NaiveDateTime, 50 + #[serde(skip_serializing_if = "Option::is_none")] 51 + pub play_count: Option<i32>, 52 + #[serde(skip_serializing_if = "Option::is_none")] 53 + pub unique_listeners: Option<i32>, 31 54 } 32 55 33 - impl Responder for Track { 34 - type Body = BoxBody; 56 + #[derive(Debug, Serialize, Deserialize, Default)] 57 + pub struct GetTracksParams { 58 + pub user_did: Option<String>, 59 + pub pagination: Option<Pagination>, 60 + } 35 61 36 - fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> { 37 - let body = serde_json::to_string(&self).unwrap(); 62 + #[derive(Debug, Serialize, Deserialize, Default)] 63 + pub struct GetTopTracksParams { 64 + pub user_did: Option<String>, 65 + pub pagination: Option<Pagination>, 66 + } 38 67 39 - // Create response and set content type 40 - HttpResponse::Ok() 41 - .content_type(ContentType::json()) 42 - .body(body) 43 - } 68 + #[derive(Debug, Serialize, Deserialize, Default)] 69 + pub struct GetLovedTracksParams { 70 + pub user_did: String, 71 + pub pagination: Option<Pagination>, 44 72 }
+1 -15
crates/analytics/src/types/user.rs
··· 1 - use actix_web::{body::BoxBody, http::header::ContentType, HttpRequest, HttpResponse, Responder}; 2 1 use serde::{Deserialize, Serialize}; 3 2 4 - #[derive(Debug, Serialize, Deserialize)] 3 + #[derive(Debug, Serialize, Deserialize, Default)] 5 4 pub struct User { 6 5 pub id: String, 7 6 pub display_name: String, ··· 9 8 pub handle: String, 10 9 pub avatar: String, 11 10 } 12 - 13 - impl Responder for User { 14 - type Body = BoxBody; 15 - 16 - fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> { 17 - let body = serde_json::to_string(&self).unwrap(); 18 - 19 - // Create response and set content type 20 - HttpResponse::Ok() 21 - .content_type(ContentType::json()) 22 - .body(body) 23 - } 24 - }
crates/analytics/src/types/user_album.rs

This is a binary file and will not be displayed.

crates/analytics/src/types/user_artist.rs

This is a binary file and will not be displayed.

crates/analytics/src/types/user_playlist.rs

This is a binary file and will not be displayed.

crates/analytics/src/types/user_track.rs

This is a binary file and will not be displayed.