A decentralized music tracking and discovery platform built on AT Protocol ๐ŸŽต
listenbrainz spotify atproto lastfm musicbrainz scrobbling

display artist's top listeners #5

merged opened by tsiry-sandratraina.com targeting main from feat/artist-listeners
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:7vdlgi2bflelz7mmuxoqjfcr/sh.tangled.repo.pull/3lzkqspbbc622
+449 -1
Diff #0
+30
apps/api/lexicons/artist/defs.json
··· 73 "minimum": 0 74 } 75 } 76 } 77 } 78 }
··· 73 "minimum": 0 74 } 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 + } 106 } 107 } 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 91 } 92 } 93 }
··· 90 91 } 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 + } 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 import type * as AppRockskyApikeyUpdateApikey from './types/app/rocksky/apikey/updateApikey' 26 import type * as AppRockskyArtistGetArtistAlbums from './types/app/rocksky/artist/getArtistAlbums' 27 import type * as AppRockskyArtistGetArtist from './types/app/rocksky/artist/getArtist' 28 import type * as AppRockskyArtistGetArtists from './types/app/rocksky/artist/getArtists' 29 import type * as AppRockskyArtistGetArtistTracks from './types/app/rocksky/artist/getArtistTracks' 30 import type * as AppRockskyChartsGetScrobblesChart from './types/app/rocksky/charts/getScrobblesChart' ··· 358 return this._server.xrpc.method(nsid, cfg) 359 } 360 361 getArtists<AV extends AuthVerifier>( 362 cfg: ConfigOf< 363 AV,
··· 25 import type * as AppRockskyApikeyUpdateApikey from './types/app/rocksky/apikey/updateApikey' 26 import type * as AppRockskyArtistGetArtistAlbums from './types/app/rocksky/artist/getArtistAlbums' 27 import type * as AppRockskyArtistGetArtist from './types/app/rocksky/artist/getArtist' 28 + import type * as AppRockskyArtistGetArtistListeners from './types/app/rocksky/artist/getArtistListeners' 29 import type * as AppRockskyArtistGetArtists from './types/app/rocksky/artist/getArtists' 30 import type * as AppRockskyArtistGetArtistTracks from './types/app/rocksky/artist/getArtistTracks' 31 import type * as AppRockskyChartsGetScrobblesChart from './types/app/rocksky/charts/getScrobblesChart' ··· 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 370 + return this._server.xrpc.method(nsid, cfg) 371 + } 372 + 373 getArtists<AV extends AuthVerifier>( 374 cfg: ConfigOf< 375 AV,
+67
apps/api/src/lexicon/lexicons.ts
··· 1066 }, 1067 }, 1068 }, 1069 }, 1070 }, 1071 AppRockskyArtistGetArtistAlbums: { ··· 1132 }, 1133 }, 1134 }, 1135 AppRockskyArtistGetArtists: { 1136 lexicon: 1, 1137 id: 'app.rocksky.artist.getArtists', ··· 4321 AppRockskyArtistDefs: 'app.rocksky.artist.defs', 4322 AppRockskyArtistGetArtistAlbums: 'app.rocksky.artist.getArtistAlbums', 4323 AppRockskyArtistGetArtist: 'app.rocksky.artist.getArtist', 4324 AppRockskyArtistGetArtists: 'app.rocksky.artist.getArtists', 4325 AppRockskyArtistGetArtistTracks: 'app.rocksky.artist.getArtistTracks', 4326 AppRockskyChartsDefs: 'app.rocksky.charts.defs',
··· 1066 }, 1067 }, 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 + }, 1099 }, 1100 }, 1101 AppRockskyArtistGetArtistAlbums: { ··· 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 + }, 1196 + }, 1197 + }, 1198 + }, 1199 + }, 1200 + }, 1201 AppRockskyArtistGetArtists: { 1202 lexicon: 1, 1203 id: 'app.rocksky.artist.getArtists', ··· 4387 AppRockskyArtistDefs: 'app.rocksky.artist.defs', 4388 AppRockskyArtistGetArtistAlbums: 'app.rocksky.artist.getArtistAlbums', 4389 AppRockskyArtistGetArtist: 'app.rocksky.artist.getArtist', 4390 + AppRockskyArtistGetArtistListeners: 'app.rocksky.artist.getArtistListeners', 4391 AppRockskyArtistGetArtists: 'app.rocksky.artist.getArtists', 4392 AppRockskyArtistGetArtistTracks: 'app.rocksky.artist.getArtistTracks', 4393 AppRockskyChartsDefs: 'app.rocksky.charts.defs',
+28
apps/api/src/lexicon/types/app/rocksky/artist/defs.ts
··· 5 import { lexicons } from '../../../../lexicons' 6 import { isObj, hasProp } from '../../../../util' 7 import { CID } from 'multiformats/cid' 8 9 export interface ArtistViewBasic { 10 /** The unique identifier of the artist. */ ··· 65 export function validateArtistViewDetailed(v: unknown): ValidationResult { 66 return lexicons.validate('app.rocksky.artist.defs#artistViewDetailed', v) 67 }
··· 5 import { lexicons } from '../../../../lexicons' 6 import { isObj, hasProp } from '../../../../util' 7 import { CID } from 'multiformats/cid' 8 + import type * as AppRockskySongDefs from '../song/defs' 9 10 export interface ArtistViewBasic { 11 /** The unique identifier of the artist. */ ··· 66 export function validateArtistViewDetailed(v: unknown): ValidationResult { 67 return lexicons.validate('app.rocksky.artist.defs#artistViewDetailed', v) 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 use crate::types::{ 4 album::Album, 5 artist::{ 6 - Artist, GetArtistAlbumsParams, GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams, 7 }, 8 track::Track, 9 }; ··· 364 let albums: Result<Vec<_>, _> = albums.collect(); 365 Ok(HttpResponse::Ok().json(albums?)) 366 }
··· 3 use crate::types::{ 4 album::Album, 5 artist::{ 6 + Artist, ArtistListener, GetArtistAlbumsParams, GetArtistListenersParams, 7 + GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams, 8 }, 9 track::Track, 10 }; ··· 365 let albums: Result<Vec<_>, _> = albums.collect(); 366 Ok(HttpResponse::Ok().json(albums?)) 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 }; 13 use tracks::{get_loved_tracks, get_top_tracks, get_tracks}; 14 15 pub mod albums; 16 pub mod artists; 17 pub mod scrobbles; ··· 58 "library.getAlbumTracks" => get_album_tracks(payload, req, conn.clone()).await, 59 "library.getArtistAlbums" => get_artist_albums(payload, req, conn.clone()).await, 60 "library.getArtistTracks" => get_artist_tracks(payload, req, conn.clone()).await, 61 _ => return Err(anyhow::anyhow!("Method not found")), 62 } 63 }
··· 12 }; 13 use tracks::{get_loved_tracks, get_top_tracks, get_tracks}; 14 15 + use crate::handlers::artists::get_artist_listeners; 16 + 17 pub mod albums; 18 pub mod artists; 19 pub mod scrobbles; ··· 60 "library.getAlbumTracks" => get_album_tracks(payload, req, conn.clone()).await, 61 "library.getArtistAlbums" => get_artist_albums(payload, req, conn.clone()).await, 62 "library.getArtistTracks" => get_artist_tracks(payload, req, conn.clone()).await, 63 + "library.getArtistListeners" => get_artist_listeners(payload, req, conn.clone()).await, 64 _ => return Err(anyhow::anyhow!("Method not found")), 65 } 66 }
+28
crates/analytics/src/types/artist.rs
··· 33 pub unique_listeners: Option<i32>, 34 } 35 36 #[derive(Debug, Serialize, Deserialize, Default)] 37 pub struct GetArtistsParams { 38 pub user_did: Option<String>, ··· 55 pub struct GetArtistAlbumsParams { 56 pub artist_id: String, 57 }
··· 33 pub unique_listeners: Option<i32>, 34 } 35 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)] 59 pub struct GetArtistsParams { 60 pub user_did: Option<String>, ··· 77 pub struct GetArtistAlbumsParams { 78 pub artist_id: String, 79 } 80 + 81 + #[derive(Debug, Serialize, Deserialize, Default)] 82 + pub struct GetArtistListenersParams { 83 + pub artist_id: String, 84 + pub pagination: Option<Pagination>, 85 + }

History

2 rounds 0 comments
sign up or login to add to the discussion
5 commits
expand
feat: add listenerViewBasic schema and getArtistListeners endpoint with associated types
feat: add songViewBasic schema and getArtistListeners endpoint with associated types
feat: add getArtistListeners function to the server endpoint
feat: make most_played_track_uri optional in ArtistListener struct
feat: add ArtistListeners component and integrate with Artist page
expand 0 comments
pull request successfully merged
1 commit
expand
feat: add listenerViewBasic schema and getArtistListeners endpoint with associated types
expand 0 comments