A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz

feat: add listenerViewBasic schema and getArtistListeners endpoint with associated types

+449 -1
+30
apps/api/lexicons/artist/defs.json
··· 73 73 "minimum": 0 74 74 } 75 75 } 76 + }, 77 + "listenerViewBasic": { 78 + "type": "object", 79 + "properties": { 80 + "id": { 81 + "type": "string", 82 + "description": "The unique identifier of the actor." 83 + }, 84 + "did": { 85 + "type": "string", 86 + "description": "The DID of the listener." 87 + }, 88 + "handle": { 89 + "type": "string", 90 + "description": "The handle of the listener." 91 + }, 92 + "displayName": { 93 + "type": "string", 94 + "description": "The display name of the listener." 95 + }, 96 + "avatar": { 97 + "type": "string", 98 + "description": "The URL of the listener's avatar image.", 99 + "format": "uri" 100 + }, 101 + "mostListenedSong": { 102 + "type": "ref", 103 + "ref": "app.rocksky.song.defs#songViewBasic" 104 + } 105 + } 76 106 } 77 107 } 78 108 }
+38
apps/api/lexicons/artist/getArtistListeners.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.rocksky.artist.getArtistListeners", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get artist listeners", 8 + "parameters": { 9 + "type": "params", 10 + "required": [ 11 + "uri" 12 + ], 13 + "properties": { 14 + "uri": { 15 + "type": "string", 16 + "description": "The URI of the artist to retrieve listeners from", 17 + "format": "at-uri" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "properties": { 26 + "listeners": { 27 + "type": "array", 28 + "items": { 29 + "type": "ref", 30 + "ref": "app.rocksky.artist.defs#listenerViewBasic" 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + } 38 + }
+36
apps/api/pkl/defs/artist/defs.pkl
··· 90 90 91 91 } 92 92 } 93 + 94 + ["listenerViewBasic"] { 95 + type = "object" 96 + properties { 97 + ["id"] = new StringType { 98 + type = "string" 99 + description = "The unique identifier of the actor." 100 + } 101 + 102 + ["did"] = new StringType { 103 + type = "string" 104 + description = "The DID of the listener." 105 + } 106 + 107 + ["handle"] = new StringType { 108 + type = "string" 109 + description = "The handle of the listener." 110 + } 111 + 112 + ["displayName"] = new StringType { 113 + type = "string" 114 + description = "The display name of the listener." 115 + } 116 + 117 + ["avatar"] = new StringType { 118 + type = "string" 119 + format = "uri" 120 + description = "The URL of the listener's avatar image." 121 + } 122 + 123 + ["mostListenedSong"] = new Ref { 124 + ref = "app.rocksky.song.defs#songViewBasic" 125 + } 126 + 127 + } 128 + } 93 129 }
+33
apps/api/pkl/defs/artist/getArtistListeners.pkl
··· 1 + amends "../../schema/lexicon.pkl" 2 + 3 + lexicon = 1 4 + id = "app.rocksky.artist.getArtistListeners" 5 + defs = new Mapping<String, Query> { 6 + ["main"] { 7 + type = "query" 8 + description = "Get artist listeners" 9 + parameters = new Params { 10 + required = List("uri") 11 + properties { 12 + ["uri"] = new StringType { 13 + description = "The URI of the artist to retrieve listeners from" 14 + format = "at-uri" 15 + } 16 + } 17 + } 18 + output { 19 + encoding = "application/json" 20 + schema = new ObjectType { 21 + type = "object" 22 + properties = new Mapping<String, Array> { 23 + ["listeners"] = new Array { 24 + type = "array" 25 + items = new Ref { 26 + ref = "app.rocksky.artist.defs#listenerViewBasic" 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+12
apps/api/src/lexicon/index.ts
··· 25 25 import type * as AppRockskyApikeyUpdateApikey from './types/app/rocksky/apikey/updateApikey' 26 26 import type * as AppRockskyArtistGetArtistAlbums from './types/app/rocksky/artist/getArtistAlbums' 27 27 import type * as AppRockskyArtistGetArtist from './types/app/rocksky/artist/getArtist' 28 + import type * as AppRockskyArtistGetArtistListeners from './types/app/rocksky/artist/getArtistListeners' 28 29 import type * as AppRockskyArtistGetArtists from './types/app/rocksky/artist/getArtists' 29 30 import type * as AppRockskyArtistGetArtistTracks from './types/app/rocksky/artist/getArtistTracks' 30 31 import type * as AppRockskyChartsGetScrobblesChart from './types/app/rocksky/charts/getScrobblesChart' ··· 355 356 >, 356 357 ) { 357 358 const nsid = 'app.rocksky.artist.getArtist' // @ts-ignore 359 + return this._server.xrpc.method(nsid, cfg) 360 + } 361 + 362 + getArtistListeners<AV extends AuthVerifier>( 363 + cfg: ConfigOf< 364 + AV, 365 + AppRockskyArtistGetArtistListeners.Handler<ExtractAuth<AV>>, 366 + AppRockskyArtistGetArtistListeners.HandlerReqCtx<ExtractAuth<AV>> 367 + >, 368 + ) { 369 + const nsid = 'app.rocksky.artist.getArtistListeners' // @ts-ignore 358 370 return this._server.xrpc.method(nsid, cfg) 359 371 } 360 372
+67
apps/api/src/lexicon/lexicons.ts
··· 1066 1066 }, 1067 1067 }, 1068 1068 }, 1069 + listenerViewBasic: { 1070 + type: 'object', 1071 + properties: { 1072 + id: { 1073 + type: 'string', 1074 + description: 'The unique identifier of the actor.', 1075 + }, 1076 + did: { 1077 + type: 'string', 1078 + description: 'The DID of the listener.', 1079 + }, 1080 + handle: { 1081 + type: 'string', 1082 + description: 'The handle of the listener.', 1083 + }, 1084 + displayName: { 1085 + type: 'string', 1086 + description: 'The display name of the listener.', 1087 + }, 1088 + avatar: { 1089 + type: 'string', 1090 + description: "The URL of the listener's avatar image.", 1091 + format: 'uri', 1092 + }, 1093 + mostListenedSong: { 1094 + type: 'ref', 1095 + ref: 'lex:app.rocksky.song.defs#songViewBasic', 1096 + }, 1097 + }, 1098 + }, 1069 1099 }, 1070 1100 }, 1071 1101 AppRockskyArtistGetArtistAlbums: { ··· 1127 1157 schema: { 1128 1158 type: 'ref', 1129 1159 ref: 'lex:app.rocksky.artist.defs#artistViewDetailed', 1160 + }, 1161 + }, 1162 + }, 1163 + }, 1164 + }, 1165 + AppRockskyArtistGetArtistListeners: { 1166 + lexicon: 1, 1167 + id: 'app.rocksky.artist.getArtistListeners', 1168 + defs: { 1169 + main: { 1170 + type: 'query', 1171 + description: 'Get artist listeners', 1172 + parameters: { 1173 + type: 'params', 1174 + required: ['uri'], 1175 + properties: { 1176 + uri: { 1177 + type: 'string', 1178 + description: 'The URI of the artist to retrieve listeners from', 1179 + format: 'at-uri', 1180 + }, 1181 + }, 1182 + }, 1183 + output: { 1184 + encoding: 'application/json', 1185 + schema: { 1186 + type: 'object', 1187 + properties: { 1188 + listeners: { 1189 + type: 'array', 1190 + items: { 1191 + type: 'ref', 1192 + ref: 'lex:app.rocksky.artist.defs#listenerViewBasic', 1193 + }, 1194 + }, 1195 + }, 1130 1196 }, 1131 1197 }, 1132 1198 }, ··· 4321 4387 AppRockskyArtistDefs: 'app.rocksky.artist.defs', 4322 4388 AppRockskyArtistGetArtistAlbums: 'app.rocksky.artist.getArtistAlbums', 4323 4389 AppRockskyArtistGetArtist: 'app.rocksky.artist.getArtist', 4390 + AppRockskyArtistGetArtistListeners: 'app.rocksky.artist.getArtistListeners', 4324 4391 AppRockskyArtistGetArtists: 'app.rocksky.artist.getArtists', 4325 4392 AppRockskyArtistGetArtistTracks: 'app.rocksky.artist.getArtistTracks', 4326 4393 AppRockskyChartsDefs: 'app.rocksky.charts.defs',
+28
apps/api/src/lexicon/types/app/rocksky/artist/defs.ts
··· 5 5 import { lexicons } from '../../../../lexicons' 6 6 import { isObj, hasProp } from '../../../../util' 7 7 import { CID } from 'multiformats/cid' 8 + import type * as AppRockskySongDefs from '../song/defs' 8 9 9 10 export interface ArtistViewBasic { 10 11 /** The unique identifier of the artist. */ ··· 65 66 export function validateArtistViewDetailed(v: unknown): ValidationResult { 66 67 return lexicons.validate('app.rocksky.artist.defs#artistViewDetailed', v) 67 68 } 69 + 70 + export interface ListenerViewBasic { 71 + /** The unique identifier of the actor. */ 72 + id?: string 73 + /** The DID of the listener. */ 74 + did?: string 75 + /** The handle of the listener. */ 76 + handle?: string 77 + /** The display name of the listener. */ 78 + displayName?: string 79 + /** The URL of the listener's avatar image. */ 80 + avatar?: string 81 + mostListenedSong?: AppRockskySongDefs.SongViewBasic 82 + [k: string]: unknown 83 + } 84 + 85 + export function isListenerViewBasic(v: unknown): v is ListenerViewBasic { 86 + return ( 87 + isObj(v) && 88 + hasProp(v, '$type') && 89 + v.$type === 'app.rocksky.artist.defs#listenerViewBasic' 90 + ) 91 + } 92 + 93 + export function validateListenerViewBasic(v: unknown): ValidationResult { 94 + return lexicons.validate('app.rocksky.artist.defs#listenerViewBasic', v) 95 + }
+48
apps/api/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import type express from 'express' 5 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 6 + import { lexicons } from '../../../../lexicons' 7 + import { isObj, hasProp } from '../../../../util' 8 + import { CID } from 'multiformats/cid' 9 + import type { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' 10 + import type * as AppRockskyArtistDefs from './defs' 11 + 12 + export interface QueryParams { 13 + /** The URI of the artist to retrieve listeners from */ 14 + uri: string 15 + } 16 + 17 + export type InputSchema = undefined 18 + 19 + export interface OutputSchema { 20 + listeners?: AppRockskyArtistDefs.ListenerViewBasic[] 21 + [k: string]: unknown 22 + } 23 + 24 + export type HandlerInput = undefined 25 + 26 + export interface HandlerSuccess { 27 + encoding: 'application/json' 28 + body: OutputSchema 29 + headers?: { [key: string]: string } 30 + } 31 + 32 + export interface HandlerError { 33 + status: number 34 + message?: string 35 + } 36 + 37 + export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough 38 + export type HandlerReqCtx<HA extends HandlerAuth = never> = { 39 + auth: HA 40 + params: QueryParams 41 + input: HandlerInput 42 + req: express.Request 43 + res: express.Response 44 + resetRouteRateLimits: () => Promise<void> 45 + } 46 + export type Handler<HA extends HandlerAuth = never> = ( 47 + ctx: HandlerReqCtx<HA>, 48 + ) => Promise<HandlerOutput> | HandlerOutput
+126 -1
crates/analytics/src/handlers/artists.rs
··· 3 3 use crate::types::{ 4 4 album::Album, 5 5 artist::{ 6 - Artist, GetArtistAlbumsParams, GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams, 6 + Artist, ArtistListener, GetArtistAlbumsParams, GetArtistListenersParams, 7 + GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams, 7 8 }, 8 9 track::Track, 9 10 }; ··· 364 365 let albums: Result<Vec<_>, _> = albums.collect(); 365 366 Ok(HttpResponse::Ok().json(albums?)) 366 367 } 368 + 369 + pub async fn get_artist_listeners( 370 + payload: &mut web::Payload, 371 + _req: &HttpRequest, 372 + conn: Arc<Mutex<Connection>>, 373 + ) -> Result<HttpResponse, Error> { 374 + let body = read_payload!(payload); 375 + let params = serde_json::from_slice::<GetArtistListenersParams>(&body)?; 376 + let pagination = params.pagination.unwrap_or_default(); 377 + let offset = pagination.skip.unwrap_or(0); 378 + let limit = pagination.take.unwrap_or(10); 379 + 380 + let conn = conn.lock().unwrap(); 381 + let mut stmt = 382 + conn.prepare("SELECT id, name, uri FROM artists WHERE id = ? OR uri = ? OR name = ?")?; 383 + let artist = stmt.query_row( 384 + [&params.artist_id, &params.artist_id, &params.artist_id], 385 + |row| { 386 + Ok(crate::types::artist::ArtistBasic { 387 + id: row.get(0)?, 388 + name: row.get(1)?, 389 + uri: row.get(2)?, 390 + }) 391 + }, 392 + )?; 393 + 394 + if artist.id.is_empty() { 395 + return Ok(HttpResponse::Ok().json(Vec::<ArtistListener>::new())); 396 + } 397 + 398 + let mut stmt = conn.prepare( 399 + r#" 400 + WITH user_track_counts AS ( 401 + SELECT 402 + s.user_id, 403 + s.track_id, 404 + t.artist, 405 + t.title as track_title, 406 + t.uri as track_uri, 407 + COUNT(*) as play_count 408 + FROM scrobbles s 409 + JOIN tracks t ON s.track_id = t.id 410 + WHERE t.artist = ? 411 + GROUP BY s.user_id, s.track_id, t.artist, t.title, t.uri 412 + ), 413 + user_top_tracks AS ( 414 + SELECT 415 + user_id, 416 + artist, 417 + track_id, 418 + track_title, 419 + track_uri, 420 + play_count, 421 + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY play_count DESC, track_title) as rn 422 + FROM user_track_counts 423 + ), 424 + artist_listener_counts AS ( 425 + SELECT 426 + user_id, 427 + artist, 428 + SUM(play_count) as total_artist_plays 429 + FROM user_track_counts 430 + GROUP BY user_id, artist 431 + ), 432 + top_artist_listeners AS ( 433 + SELECT 434 + user_id, 435 + artist, 436 + total_artist_plays, 437 + ROW_NUMBER() OVER (ORDER BY total_artist_plays DESC) as listener_rank 438 + FROM artist_listener_counts 439 + ), 440 + paginated_listeners AS ( 441 + SELECT 442 + user_id, 443 + artist, 444 + total_artist_plays, 445 + listener_rank 446 + FROM top_artist_listeners 447 + ORDER BY listener_rank 448 + LIMIT ? OFFSET ? 449 + ) 450 + SELECT 451 + pl.artist, 452 + pl.listener_rank, 453 + u.id as user_id, 454 + u.display_name, 455 + u.did, 456 + u.handle, 457 + u.avatar, 458 + pl.total_artist_plays, 459 + utt.track_title as most_played_track, 460 + utt.track_uri as most_played_track_uri, 461 + utt.play_count as track_play_count 462 + FROM paginated_listeners pl 463 + JOIN users u ON pl.user_id = u.id 464 + JOIN user_top_tracks utt ON pl.user_id = utt.user_id 465 + AND utt.rn = 1 466 + ORDER BY pl.listener_rank; 467 + "#, 468 + )?; 469 + 470 + let listeners = stmt.query_map( 471 + [&artist.name, &limit.to_string(), &offset.to_string()], 472 + |row| { 473 + Ok(ArtistListener { 474 + artist: row.get(0)?, 475 + listener_rank: row.get(1)?, 476 + user_id: row.get(2)?, 477 + display_name: row.get(3)?, 478 + did: row.get(4)?, 479 + handle: row.get(5)?, 480 + avatar: row.get(6)?, 481 + total_artist_plays: row.get(7)?, 482 + most_played_track: row.get(8)?, 483 + most_played_track_uri: row.get(9)?, 484 + track_play_count: row.get(10)?, 485 + }) 486 + }, 487 + )?; 488 + 489 + let listeners: Result<Vec<_>, _> = listeners.collect(); 490 + Ok(HttpResponse::Ok().json(listeners?)) 491 + }
+3
crates/analytics/src/handlers/mod.rs
··· 12 12 }; 13 13 use tracks::{get_loved_tracks, get_top_tracks, get_tracks}; 14 14 15 + use crate::handlers::artists::get_artist_listeners; 16 + 15 17 pub mod albums; 16 18 pub mod artists; 17 19 pub mod scrobbles; ··· 58 60 "library.getAlbumTracks" => get_album_tracks(payload, req, conn.clone()).await, 59 61 "library.getArtistAlbums" => get_artist_albums(payload, req, conn.clone()).await, 60 62 "library.getArtistTracks" => get_artist_tracks(payload, req, conn.clone()).await, 63 + "library.getArtistListeners" => get_artist_listeners(payload, req, conn.clone()).await, 61 64 _ => return Err(anyhow::anyhow!("Method not found")), 62 65 } 63 66 }
+28
crates/analytics/src/types/artist.rs
··· 34 34 } 35 35 36 36 #[derive(Debug, Serialize, Deserialize, Default)] 37 + pub struct ArtistBasic { 38 + pub id: String, 39 + pub name: String, 40 + pub uri: Option<String>, 41 + } 42 + 43 + #[derive(Debug, Serialize, Deserialize, Default)] 44 + pub struct ArtistListener { 45 + pub artist: String, 46 + pub listener_rank: i64, 47 + pub user_id: String, 48 + pub display_name: String, 49 + pub did: String, 50 + pub handle: String, 51 + pub avatar: String, 52 + pub total_artist_plays: i64, 53 + pub most_played_track: String, 54 + pub most_played_track_uri: String, 55 + pub track_play_count: i64, 56 + } 57 + 58 + #[derive(Debug, Serialize, Deserialize, Default)] 37 59 pub struct GetArtistsParams { 38 60 pub user_did: Option<String>, 39 61 pub pagination: Option<Pagination>, ··· 55 77 pub struct GetArtistAlbumsParams { 56 78 pub artist_id: String, 57 79 } 80 + 81 + #[derive(Debug, Serialize, Deserialize, Default)] 82 + pub struct GetArtistListenersParams { 83 + pub artist_id: String, 84 + pub pagination: Option<Pagination>, 85 + }