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
+1016 -266
Diff #1
+59
apps/api/lexicons/artist/defs.json
··· 73 "minimum": 0 74 } 75 } 76 } 77 } 78 }
··· 73 "minimum": 0 74 } 75 } 76 + }, 77 + "songViewBasic": { 78 + "type": "object", 79 + "properties": { 80 + "uri": { 81 + "type": "string", 82 + "description": "The URI of the song.", 83 + "format": "at-uri" 84 + }, 85 + "title": { 86 + "type": "string", 87 + "description": "The title of the song." 88 + }, 89 + "playCount": { 90 + "type": "integer", 91 + "description": "The number of times the song has been played.", 92 + "minimum": 0 93 + } 94 + } 95 + }, 96 + "listenerViewBasic": { 97 + "type": "object", 98 + "properties": { 99 + "id": { 100 + "type": "string", 101 + "description": "The unique identifier of the actor." 102 + }, 103 + "did": { 104 + "type": "string", 105 + "description": "The DID of the listener." 106 + }, 107 + "handle": { 108 + "type": "string", 109 + "description": "The handle of the listener." 110 + }, 111 + "displayName": { 112 + "type": "string", 113 + "description": "The display name of the listener." 114 + }, 115 + "avatar": { 116 + "type": "string", 117 + "description": "The URL of the listener's avatar image.", 118 + "format": "uri" 119 + }, 120 + "mostListenedSong": { 121 + "type": "ref", 122 + "ref": "app.rocksky.artist.defs#songViewBasic" 123 + }, 124 + "totalPlays": { 125 + "type": "integer", 126 + "description": "The total number of plays by the listener.", 127 + "minimum": 0 128 + }, 129 + "rank": { 130 + "type": "integer", 131 + "description": "The rank of the listener among all listeners of the artist.", 132 + "minimum": 1 133 + } 134 + } 135 } 136 } 137 }
+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 + }
+71
apps/api/pkl/defs/artist/defs.pkl
··· 90 91 } 92 } 93 }
··· 90 91 } 92 } 93 + 94 + ["songViewBasic"] { 95 + type = "object" 96 + properties { 97 + ["uri"] = new StringType { 98 + type = "string" 99 + format = "at-uri" 100 + description = "The URI of the song." 101 + } 102 + 103 + ["title"] = new StringType { 104 + type = "string" 105 + description = "The title of the song." 106 + } 107 + 108 + ["playCount"] = new IntegerType { 109 + type = "integer" 110 + description = "The number of times the song has been played." 111 + minimum = 0 112 + } 113 + 114 + } 115 + } 116 + 117 + ["listenerViewBasic"] { 118 + type = "object" 119 + properties { 120 + ["id"] = new StringType { 121 + type = "string" 122 + description = "The unique identifier of the actor." 123 + } 124 + 125 + ["did"] = new StringType { 126 + type = "string" 127 + description = "The DID of the listener." 128 + } 129 + 130 + ["handle"] = new StringType { 131 + type = "string" 132 + description = "The handle of the listener." 133 + } 134 + 135 + ["displayName"] = new StringType { 136 + type = "string" 137 + description = "The display name of the listener." 138 + } 139 + 140 + ["avatar"] = new StringType { 141 + type = "string" 142 + format = "uri" 143 + description = "The URL of the listener's avatar image." 144 + } 145 + 146 + ["mostListenedSong"] = new Ref { 147 + ref = "app.rocksky.artist.defs#songViewBasic" 148 + } 149 + 150 + ["totalPlays"] = new IntegerType { 151 + type = "integer" 152 + description = "The total number of plays by the listener." 153 + minimum = 0 154 + } 155 + 156 + ["rank"] = new IntegerType { 157 + type = "integer" 158 + description = "The rank of the listener among all listeners of the artist." 159 + minimum = 1 160 + } 161 + 162 + } 163 + } 164 }
+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,
+97
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 + songViewBasic: { 1070 + type: 'object', 1071 + properties: { 1072 + uri: { 1073 + type: 'string', 1074 + description: 'The URI of the song.', 1075 + format: 'at-uri', 1076 + }, 1077 + title: { 1078 + type: 'string', 1079 + description: 'The title of the song.', 1080 + }, 1081 + playCount: { 1082 + type: 'integer', 1083 + description: 'The number of times the song has been played.', 1084 + minimum: 0, 1085 + }, 1086 + }, 1087 + }, 1088 + listenerViewBasic: { 1089 + type: 'object', 1090 + properties: { 1091 + id: { 1092 + type: 'string', 1093 + description: 'The unique identifier of the actor.', 1094 + }, 1095 + did: { 1096 + type: 'string', 1097 + description: 'The DID of the listener.', 1098 + }, 1099 + handle: { 1100 + type: 'string', 1101 + description: 'The handle of the listener.', 1102 + }, 1103 + displayName: { 1104 + type: 'string', 1105 + description: 'The display name of the listener.', 1106 + }, 1107 + avatar: { 1108 + type: 'string', 1109 + description: "The URL of the listener's avatar image.", 1110 + format: 'uri', 1111 + }, 1112 + mostListenedSong: { 1113 + type: 'ref', 1114 + ref: 'lex:app.rocksky.artist.defs#songViewBasic', 1115 + }, 1116 + totalPlays: { 1117 + type: 'integer', 1118 + description: 'The total number of plays by the listener.', 1119 + minimum: 0, 1120 + }, 1121 + rank: { 1122 + type: 'integer', 1123 + description: 1124 + 'The rank of the listener among all listeners of the artist.', 1125 + minimum: 1, 1126 + }, 1127 + }, 1128 + }, 1129 }, 1130 }, 1131 AppRockskyArtistGetArtistAlbums: { ··· 1192 }, 1193 }, 1194 }, 1195 + AppRockskyArtistGetArtistListeners: { 1196 + lexicon: 1, 1197 + id: 'app.rocksky.artist.getArtistListeners', 1198 + defs: { 1199 + main: { 1200 + type: 'query', 1201 + description: 'Get artist listeners', 1202 + parameters: { 1203 + type: 'params', 1204 + required: ['uri'], 1205 + properties: { 1206 + uri: { 1207 + type: 'string', 1208 + description: 'The URI of the artist to retrieve listeners from', 1209 + format: 'at-uri', 1210 + }, 1211 + }, 1212 + }, 1213 + output: { 1214 + encoding: 'application/json', 1215 + schema: { 1216 + type: 'object', 1217 + properties: { 1218 + listeners: { 1219 + type: 'array', 1220 + items: { 1221 + type: 'ref', 1222 + ref: 'lex:app.rocksky.artist.defs#listenerViewBasic', 1223 + }, 1224 + }, 1225 + }, 1226 + }, 1227 + }, 1228 + }, 1229 + }, 1230 + }, 1231 AppRockskyArtistGetArtists: { 1232 lexicon: 1, 1233 id: 'app.rocksky.artist.getArtists', ··· 4417 AppRockskyArtistDefs: 'app.rocksky.artist.defs', 4418 AppRockskyArtistGetArtistAlbums: 'app.rocksky.artist.getArtistAlbums', 4419 AppRockskyArtistGetArtist: 'app.rocksky.artist.getArtist', 4420 + AppRockskyArtistGetArtistListeners: 'app.rocksky.artist.getArtistListeners', 4421 AppRockskyArtistGetArtists: 'app.rocksky.artist.getArtists', 4422 AppRockskyArtistGetArtistTracks: 'app.rocksky.artist.getArtistTracks', 4423 AppRockskyChartsDefs: 'app.rocksky.charts.defs',
+53
apps/api/src/lexicon/types/app/rocksky/artist/defs.ts
··· 65 export function validateArtistViewDetailed(v: unknown): ValidationResult { 66 return lexicons.validate('app.rocksky.artist.defs#artistViewDetailed', v) 67 }
··· 65 export function validateArtistViewDetailed(v: unknown): ValidationResult { 66 return lexicons.validate('app.rocksky.artist.defs#artistViewDetailed', v) 67 } 68 + 69 + export interface SongViewBasic { 70 + /** The URI of the song. */ 71 + uri?: string 72 + /** The title of the song. */ 73 + title?: string 74 + /** The number of times the song has been played. */ 75 + playCount?: number 76 + [k: string]: unknown 77 + } 78 + 79 + export function isSongViewBasic(v: unknown): v is SongViewBasic { 80 + return ( 81 + isObj(v) && 82 + hasProp(v, '$type') && 83 + v.$type === 'app.rocksky.artist.defs#songViewBasic' 84 + ) 85 + } 86 + 87 + export function validateSongViewBasic(v: unknown): ValidationResult { 88 + return lexicons.validate('app.rocksky.artist.defs#songViewBasic', v) 89 + } 90 + 91 + export interface ListenerViewBasic { 92 + /** The unique identifier of the actor. */ 93 + id?: string 94 + /** The DID of the listener. */ 95 + did?: string 96 + /** The handle of the listener. */ 97 + handle?: string 98 + /** The display name of the listener. */ 99 + displayName?: string 100 + /** The URL of the listener's avatar image. */ 101 + avatar?: string 102 + mostListenedSong?: SongViewBasic 103 + /** The total number of plays by the listener. */ 104 + totalPlays?: number 105 + /** The rank of the listener among all listeners of the artist. */ 106 + rank?: number 107 + [k: string]: unknown 108 + } 109 + 110 + export function isListenerViewBasic(v: unknown): v is ListenerViewBasic { 111 + return ( 112 + isObj(v) && 113 + hasProp(v, '$type') && 114 + v.$type === 'app.rocksky.artist.defs#listenerViewBasic' 115 + ) 116 + } 117 + 118 + export function validateListenerViewBasic(v: unknown): ValidationResult { 119 + return lexicons.validate('app.rocksky.artist.defs#listenerViewBasic', v) 120 + }
+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 }
+29
crates/analytics/src/types/artist.rs
··· 34 } 35 36 #[derive(Debug, Serialize, Deserialize, Default)] 37 pub struct GetArtistsParams { 38 pub user_did: Option<String>, 39 ··· 55 pub struct GetArtistAlbumsParams { 56 pub artist_id: String, 57 }
··· 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 + #[serde(skip_serializing_if = "Option::is_none")] 55 + pub most_played_track_uri: Option<String>, 56 + pub track_play_count: i64, 57 + } 58 + 59 + #[derive(Debug, Serialize, Deserialize, Default)] 60 pub struct GetArtistsParams { 61 pub user_did: Option<String>, 62 ··· 78 pub struct GetArtistAlbumsParams { 79 pub artist_id: String, 80 } 81 + 82 + #[derive(Debug, Serialize, Deserialize, Default)] 83 + pub struct GetArtistListenersParams { 84 + pub artist_id: String, 85 + pub pagination: Option<Pagination>, 86 + }
+83
apps/api/src/xrpc/app/rocksky/artist/getArtistListeners.ts
···
··· 1 + import type { Context } from "context"; 2 + import { Effect, pipe } from "effect"; 3 + import type { Server } from "lexicon"; 4 + import type { ListenerViewBasic } from "lexicon/types/app/rocksky/artist/defs"; 5 + import type { QueryParams } from "lexicon/types/app/rocksky/artist/getArtistListeners"; 6 + 7 + export default function (server: Server, ctx: Context) { 8 + const getArtistListeners = (params) => 9 + pipe( 10 + { params, ctx }, 11 + retrieve, 12 + Effect.flatMap(presentation), 13 + Effect.retry({ times: 3 }), 14 + Effect.timeout("10 seconds"), 15 + Effect.catchAll((err) => { 16 + console.error(err); 17 + return Effect.succeed({ listeners: [] }); 18 + }) 19 + ); 20 + server.app.rocksky.artist.getArtistListeners({ 21 + handler: async ({ params }) => { 22 + const result = await Effect.runPromise(getArtistListeners(params)); 23 + return { 24 + encoding: "application/json", 25 + body: result, 26 + }; 27 + }, 28 + }); 29 + } 30 + 31 + const retrieve = ({ 32 + params, 33 + ctx, 34 + }: { 35 + params: QueryParams; 36 + ctx: Context; 37 + }): Effect.Effect<{ data: ArtistListener[] }, Error> => { 38 + return Effect.tryPromise({ 39 + try: () => 40 + ctx.analytics.post("library.getArtistListeners", { 41 + artist_id: params.uri, 42 + }), 43 + catch: (error) => 44 + new Error(`Failed to retrieve artist's listeners: ${error}`), 45 + }); 46 + }; 47 + 48 + const presentation = ({ 49 + data, 50 + }: { 51 + data: ArtistListener[]; 52 + }): Effect.Effect<{ listeners: ListenerViewBasic[] }, never> => { 53 + return Effect.sync(() => ({ 54 + listeners: data.map((item) => ({ 55 + id: item.user_id, 56 + did: item.did, 57 + handle: item.handle, 58 + displayName: item.display_name, 59 + avatar: item.avatar, 60 + mostListenedSong: { 61 + title: item.most_played_track, 62 + uri: item.most_played_track_uri, 63 + playCount: item.track_play_count, 64 + }, 65 + totalPlays: item.total_artist_plays, 66 + rank: item.listener_rank, 67 + })), 68 + })); 69 + }; 70 + 71 + type ArtistListener = { 72 + artist: string; 73 + avatar: string; 74 + did: string; 75 + display_name: string; 76 + handle: string; 77 + listener_rank: number; 78 + most_played_track: string; 79 + most_played_track_uri: string; 80 + total_artist_plays: number; 81 + track_play_count: number; 82 + user_id: string; 83 + };
+2
apps/api/src/xrpc/index.ts
··· 16 import updateApikey from "./app/rocksky/apikey/updateApikey"; 17 import getArtist from "./app/rocksky/artist/getArtist"; 18 import getArtistAlbums from "./app/rocksky/artist/getArtistAlbums"; 19 import getArtists from "./app/rocksky/artist/getArtists"; 20 import getArtistTracks from "./app/rocksky/artist/getArtistTracks"; 21 import getScrobblesChart from "./app/rocksky/charts/getScrobblesChart"; ··· 91 getArtist(server, ctx); 92 getArtistAlbums(server, ctx); 93 getArtists(server, ctx); 94 getArtistTracks(server, ctx); 95 getScrobblesChart(server, ctx); 96 downloadFileFromDropbox(server, ctx);
··· 16 import updateApikey from "./app/rocksky/apikey/updateApikey"; 17 import getArtist from "./app/rocksky/artist/getArtist"; 18 import getArtistAlbums from "./app/rocksky/artist/getArtistAlbums"; 19 + import getArtistListeners from "./app/rocksky/artist/getArtistListeners"; 20 import getArtists from "./app/rocksky/artist/getArtists"; 21 import getArtistTracks from "./app/rocksky/artist/getArtistTracks"; 22 import getScrobblesChart from "./app/rocksky/charts/getScrobblesChart"; ··· 92 getArtist(server, ctx); 93 getArtistAlbums(server, ctx); 94 getArtists(server, ctx); 95 + getArtistListeners(server, ctx); 96 getArtistTracks(server, ctx); 97 getScrobblesChart(server, ctx); 98 downloadFileFromDropbox(server, ctx);
+13 -5
apps/web/src/api/library.ts
··· 29 30 export const getArtistTracks = async ( 31 uri: string, 32 - limit = 10, 33 ): Promise< 34 { 35 id: string; ··· 45 > => { 46 const response = await client.get( 47 "/xrpc/app.rocksky.artist.getArtistTracks", 48 - { params: { uri, limit } }, 49 ); 50 return response.data.tracks; 51 }; 52 53 export const getArtistAlbums = async ( 54 uri: string, 55 - limit = 10, 56 ): Promise< 57 { 58 id: string; ··· 65 > => { 66 const response = await client.get( 67 "/xrpc/app.rocksky.artist.getArtistAlbums", 68 - { params: { uri, limit } }, 69 ); 70 return response.data.albums; 71 }; ··· 96 "/xrpc/app.rocksky.actor.getActorLovedSongs", 97 { 98 params: { did, limit, offset }, 99 - }, 100 ); 101 return response.data.tracks; 102 }; ··· 114 }); 115 return response.data; 116 };
··· 29 30 export const getArtistTracks = async ( 31 uri: string, 32 + limit = 10 33 ): Promise< 34 { 35 id: string; ··· 45 > => { 46 const response = await client.get( 47 "/xrpc/app.rocksky.artist.getArtistTracks", 48 + { params: { uri, limit } } 49 ); 50 return response.data.tracks; 51 }; 52 53 export const getArtistAlbums = async ( 54 uri: string, 55 + limit = 10 56 ): Promise< 57 { 58 id: string; ··· 65 > => { 66 const response = await client.get( 67 "/xrpc/app.rocksky.artist.getArtistAlbums", 68 + { params: { uri, limit } } 69 ); 70 return response.data.albums; 71 }; ··· 96 "/xrpc/app.rocksky.actor.getActorLovedSongs", 97 { 98 params: { did, limit, offset }, 99 + } 100 ); 101 return response.data.tracks; 102 }; ··· 114 }); 115 return response.data; 116 }; 117 + 118 + export const getArtistListeners = async (uri: string, limit: number) => { 119 + const response = await client.get( 120 + "/xrpc/app.rocksky.artist.getArtistListeners", 121 + { params: { uri, limit } } 122 + ); 123 + return response.data; 124 + };
+81 -72
apps/web/src/hooks/useLibrary.tsx
··· 1 import { useQuery } from "@tanstack/react-query"; 2 import { 3 - getAlbum, 4 - getAlbums, 5 - getArtist, 6 - getArtistAlbums, 7 - getArtists, 8 - getArtistTracks, 9 - getLovedTracks, 10 - getSongByUri, 11 - getTracks, 12 } from "../api/library"; 13 14 export const useSongByUriQuery = (uri: string) => 15 - useQuery({ 16 - queryKey: ["songByUri", uri], 17 - queryFn: () => getSongByUri(uri), 18 - enabled: !!uri, 19 - }); 20 21 export const useArtistTracksQuery = (uri: string, limit = 10) => 22 - useQuery({ 23 - queryKey: ["artistTracks", uri, limit], 24 - queryFn: () => getArtistTracks(uri, limit), 25 - enabled: !!uri, 26 - }); 27 28 export const useArtistAlbumsQuery = (uri: string, limit = 10) => 29 - useQuery({ 30 - queryKey: ["artistAlbums", uri, limit], 31 - queryFn: () => getArtistAlbums(uri, limit), 32 - enabled: !!uri, 33 - }); 34 35 export const useArtistsQuery = (did: string, offset = 0, limit = 30) => 36 - useQuery({ 37 - queryKey: ["artists", did, offset, limit], 38 - queryFn: () => getArtists(did, offset, limit), 39 - enabled: !!did, 40 - select: (data) => 41 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 - data?.artists.map((x: any) => ({ 43 - ...x, 44 - scrobbles: x.playCount, 45 - })), 46 - }); 47 48 export const useAlbumsQuery = (did: string, offset = 0, limit = 12) => 49 - useQuery({ 50 - queryKey: ["albums", did, offset, limit], 51 - queryFn: () => getAlbums(did, offset, limit), 52 - enabled: !!did, 53 - select: (data) => 54 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 55 - data?.albums.map((x: any) => ({ 56 - ...x, 57 - scrobbles: x.playCount, 58 - })), 59 - }); 60 61 export const useTracksQuery = (did: string, offset = 0, limit = 20) => 62 - useQuery({ 63 - queryKey: ["tracks", did, offset, limit], 64 - queryFn: () => getTracks(did, offset, limit), 65 - enabled: !!did, 66 - select: (data) => 67 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 68 - data?.tracks.map((x: any) => ({ 69 - ...x, 70 - scrobbles: x.playCount, 71 - })), 72 - }); 73 74 export const useLovedTracksQuery = (did: string, offset = 0, limit = 20) => 75 - useQuery({ 76 - queryKey: ["lovedTracks", did, offset, limit], 77 - queryFn: () => getLovedTracks(did, offset, limit), 78 - enabled: !!did, 79 - }); 80 81 export const useAlbumQuery = (did: string, rkey: string) => 82 - useQuery({ 83 - queryKey: ["album", did, rkey], 84 - queryFn: () => getAlbum(did, rkey), 85 - enabled: !!did && !!rkey, 86 - }); 87 88 export const useArtistQuery = (did: string, rkey: string) => 89 - useQuery({ 90 - queryKey: ["artist", did, rkey], 91 - queryFn: () => getArtist(did, rkey), 92 - enabled: !!did && !!rkey, 93 - });
··· 1 import { useQuery } from "@tanstack/react-query"; 2 import { 3 + getAlbum, 4 + getAlbums, 5 + getArtist, 6 + getArtistAlbums, 7 + getArtistListeners, 8 + getArtists, 9 + getArtistTracks, 10 + getLovedTracks, 11 + getSongByUri, 12 + getTracks, 13 } from "../api/library"; 14 15 export const useSongByUriQuery = (uri: string) => 16 + useQuery({ 17 + queryKey: ["songByUri", uri], 18 + queryFn: () => getSongByUri(uri), 19 + enabled: !!uri, 20 + }); 21 22 export const useArtistTracksQuery = (uri: string, limit = 10) => 23 + useQuery({ 24 + queryKey: ["artistTracks", uri, limit], 25 + queryFn: () => getArtistTracks(uri, limit), 26 + enabled: !!uri, 27 + }); 28 29 export const useArtistAlbumsQuery = (uri: string, limit = 10) => 30 + useQuery({ 31 + queryKey: ["artistAlbums", uri, limit], 32 + queryFn: () => getArtistAlbums(uri, limit), 33 + enabled: !!uri, 34 + }); 35 36 export const useArtistsQuery = (did: string, offset = 0, limit = 30) => 37 + useQuery({ 38 + queryKey: ["artists", did, offset, limit], 39 + queryFn: () => getArtists(did, offset, limit), 40 + enabled: !!did, 41 + select: (data) => 42 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 + data?.artists.map((x: any) => ({ 44 + ...x, 45 + scrobbles: x.playCount, 46 + })), 47 + }); 48 49 export const useAlbumsQuery = (did: string, offset = 0, limit = 12) => 50 + useQuery({ 51 + queryKey: ["albums", did, offset, limit], 52 + queryFn: () => getAlbums(did, offset, limit), 53 + enabled: !!did, 54 + select: (data) => 55 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 + data?.albums.map((x: any) => ({ 57 + ...x, 58 + scrobbles: x.playCount, 59 + })), 60 + }); 61 62 export const useTracksQuery = (did: string, offset = 0, limit = 20) => 63 + useQuery({ 64 + queryKey: ["tracks", did, offset, limit], 65 + queryFn: () => getTracks(did, offset, limit), 66 + enabled: !!did, 67 + select: (data) => 68 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 69 + data?.tracks.map((x: any) => ({ 70 + ...x, 71 + scrobbles: x.playCount, 72 + })), 73 + }); 74 75 export const useLovedTracksQuery = (did: string, offset = 0, limit = 20) => 76 + useQuery({ 77 + queryKey: ["lovedTracks", did, offset, limit], 78 + queryFn: () => getLovedTracks(did, offset, limit), 79 + enabled: !!did, 80 + }); 81 82 export const useAlbumQuery = (did: string, rkey: string) => 83 + useQuery({ 84 + queryKey: ["album", did, rkey], 85 + queryFn: () => getAlbum(did, rkey), 86 + enabled: !!did && !!rkey, 87 + }); 88 89 export const useArtistQuery = (did: string, rkey: string) => 90 + useQuery({ 91 + queryKey: ["artist", did, rkey], 92 + queryFn: () => getArtist(did, rkey), 93 + enabled: !!did && !!rkey, 94 + }); 95 + 96 + export const useArtistListenersQuery = (uri: string, limit = 10) => 97 + useQuery({ 98 + queryKey: ["artistListeners", uri, limit], 99 + queryFn: () => getArtistListeners(uri, limit), 100 + enabled: !!uri, 101 + select: (data) => data.listeners, 102 + });
+191 -188
apps/web/src/pages/artist/Artist.tsx
··· 10 import ArtistIcon from "../../components/Icons/Artist"; 11 import Shout from "../../components/Shout/Shout"; 12 import { 13 - useArtistAlbumsQuery, 14 - useArtistQuery, 15 - useArtistTracksQuery, 16 } from "../../hooks/useLibrary"; 17 import Main from "../../layouts/Main"; 18 import Albums from "./Albums"; 19 import PopularSongs from "./PopularSongs"; 20 21 const Group = styled.div` ··· 26 `; 27 28 const Artist = () => { 29 - const { did, rkey } = useParams({ strict: false }); 30 - 31 - const uri = `at://${did}/app.rocksky.artist/${rkey}`; 32 - const artistResult = useArtistQuery(did!, rkey!); 33 - const artistTracksResult = useArtistTracksQuery(uri); 34 - const artistAlbumsResult = useArtistAlbumsQuery(uri); 35 - 36 - const artist = useAtomValue(artistAtom); 37 - const setArtist = useSetAtom(artistAtom); 38 - const [topTracks, setTopTracks] = useState< 39 - { 40 - id: string; 41 - title: string; 42 - artist: string; 43 - albumArtist: string; 44 - albumArt: string; 45 - uri: string; 46 - scrobbles: number; 47 - albumUri?: string; 48 - artistUri?: string; 49 - }[] 50 - >([]); 51 - const [topAlbums, setTopAlbums] = useState< 52 - { 53 - id: string; 54 - title: string; 55 - artist: string; 56 - albumArt: string; 57 - artistUri: string; 58 - uri: string; 59 - }[] 60 - >([]); 61 - 62 - useEffect(() => { 63 - if (artistResult.isLoading || artistResult.isError) { 64 - return; 65 - } 66 - 67 - if (!artistResult.data || !did) { 68 - return; 69 - } 70 - 71 - setArtist({ 72 - id: artistResult.data.id, 73 - name: artistResult.data.name, 74 - born: artistResult.data.born, 75 - bornIn: artistResult.data.bornIn, 76 - died: artistResult.data.died, 77 - listeners: artistResult.data.uniqueListeners, 78 - scrobbles: artistResult.data.playCount, 79 - picture: artistResult.data.picture, 80 - tags: artistResult.data.tags, 81 - uri: artistResult.data.uri, 82 - spotifyLink: artistResult.data.spotifyLink, 83 - }); 84 - // eslint-disable-next-line react-hooks/exhaustive-deps 85 - }, [artistResult.data, artistResult.isLoading, artistResult.isError, did]); 86 - 87 - useEffect(() => { 88 - if (artistTracksResult.isLoading || artistTracksResult.isError) { 89 - return; 90 - } 91 - 92 - if (!artistTracksResult.data || !did) { 93 - return; 94 - } 95 - 96 - setTopTracks( 97 - artistTracksResult.data.map((track) => ({ 98 - ...track, 99 - scrobbles: track.playCount || 1, 100 - })), 101 - ); 102 - }, [ 103 - artistTracksResult.data, 104 - artistTracksResult.isLoading, 105 - artistTracksResult.isError, 106 - did, 107 - ]); 108 - 109 - useEffect(() => { 110 - if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) { 111 - return; 112 - } 113 - 114 - if (!artistAlbumsResult.data || !did) { 115 - return; 116 - } 117 - 118 - setTopAlbums(artistAlbumsResult.data); 119 - }, [ 120 - artistAlbumsResult.data, 121 - artistAlbumsResult.isLoading, 122 - artistAlbumsResult.isError, 123 - did, 124 - ]); 125 - 126 - const loading = 127 - artistResult.isLoading || 128 - artistTracksResult.isLoading || 129 - artistAlbumsResult.isLoading; 130 - return ( 131 - <Main> 132 - <div className="pb-[100px] pt-[50px]"> 133 - <Group> 134 - <div className="mr-[20px]"> 135 - {artist?.picture && !loading && ( 136 - <Avatar name={artist?.name} src={artist?.picture} size="150px" /> 137 - )} 138 - {!artist?.picture && !loading && ( 139 - <div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center"> 140 - <div 141 - style={{ 142 - height: 60, 143 - width: 60, 144 - }} 145 - > 146 - <ArtistIcon color="rgba(66, 87, 108, 0.65)" /> 147 - </div> 148 - </div> 149 - )} 150 - </div> 151 - {artist && !loading && ( 152 - <div style={{ flex: 1 }}> 153 - <HeadingMedium 154 - marginTop={"20px"} 155 - marginBottom={0} 156 - className="!text-[var(--color-text)]" 157 - > 158 - {artist?.name} 159 - </HeadingMedium> 160 - <div className="mt-[20px] flex flex-row"> 161 - <div className="mr-[20px]"> 162 - <LabelMedium 163 - margin={0} 164 - className="!text-[var(--color-text-muted)]" 165 - > 166 - Listeners 167 - </LabelMedium> 168 - <HeadingXSmall 169 - margin={0} 170 - className="!text-[var(--color-text)]" 171 - > 172 - {numeral(artist?.listeners).format("0,0")} 173 - </HeadingXSmall> 174 - </div> 175 - <div> 176 - <LabelMedium 177 - margin={0} 178 - className="!text-[var(--color-text-muted)]" 179 - > 180 - Scrobbles 181 - </LabelMedium> 182 - <HeadingXSmall 183 - margin={0} 184 - className="!text-[var(--color-text)]" 185 - > 186 - {numeral(artist?.scrobbles).format("0,0")} 187 - </HeadingXSmall> 188 - </div> 189 - <div className="flex items-center justify-end flex-1 mr-[10px]"> 190 - <a 191 - href={`https://pdsls.dev/at/${uri.replace("at://", "")}`} 192 - target="_blank" 193 - className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]" 194 - > 195 - <ExternalLink 196 - size={24} 197 - className="mr-[10px] text-[var(--color-text)]" 198 - /> 199 - View on PDSls 200 - </a> 201 - </div> 202 - </div> 203 - </div> 204 - )} 205 - </Group> 206 - 207 - <PopularSongs topTracks={topTracks} /> 208 - <Albums topAlbums={topAlbums} /> 209 - 210 - <Shout type="artist" /> 211 - </div> 212 - </Main> 213 - ); 214 }; 215 216 export default Artist;
··· 10 import ArtistIcon from "../../components/Icons/Artist"; 11 import Shout from "../../components/Shout/Shout"; 12 import { 13 + useArtistAlbumsQuery, 14 + useArtistListenersQuery, 15 + useArtistQuery, 16 + useArtistTracksQuery, 17 } from "../../hooks/useLibrary"; 18 import Main from "../../layouts/Main"; 19 import Albums from "./Albums"; 20 + import ArtistListeners from "./ArtistListeners"; 21 import PopularSongs from "./PopularSongs"; 22 23 const Group = styled.div` ··· 28 `; 29 30 const Artist = () => { 31 + const { did, rkey } = useParams({ strict: false }); 32 + 33 + const uri = `at://${did}/app.rocksky.artist/${rkey}`; 34 + const artistResult = useArtistQuery(did!, rkey!); 35 + const artistTracksResult = useArtistTracksQuery(uri); 36 + const artistAlbumsResult = useArtistAlbumsQuery(uri); 37 + const artistListenersResult = useArtistListenersQuery(uri); 38 + 39 + const artist = useAtomValue(artistAtom); 40 + const setArtist = useSetAtom(artistAtom); 41 + const [topTracks, setTopTracks] = useState< 42 + { 43 + id: string; 44 + title: string; 45 + artist: string; 46 + albumArtist: string; 47 + albumArt: string; 48 + uri: string; 49 + scrobbles: number; 50 + albumUri?: string; 51 + artistUri?: string; 52 + }[] 53 + >([]); 54 + const [topAlbums, setTopAlbums] = useState< 55 + { 56 + id: string; 57 + title: string; 58 + artist: string; 59 + albumArt: string; 60 + artistUri: string; 61 + uri: string; 62 + }[] 63 + >([]); 64 + 65 + useEffect(() => { 66 + if (artistResult.isLoading || artistResult.isError) { 67 + return; 68 + } 69 + 70 + if (!artistResult.data || !did) { 71 + return; 72 + } 73 + 74 + setArtist({ 75 + id: artistResult.data.id, 76 + name: artistResult.data.name, 77 + born: artistResult.data.born, 78 + bornIn: artistResult.data.bornIn, 79 + died: artistResult.data.died, 80 + listeners: artistResult.data.uniqueListeners, 81 + scrobbles: artistResult.data.playCount, 82 + picture: artistResult.data.picture, 83 + tags: artistResult.data.tags, 84 + uri: artistResult.data.uri, 85 + spotifyLink: artistResult.data.spotifyLink, 86 + }); 87 + // eslint-disable-next-line react-hooks/exhaustive-deps 88 + }, [artistResult.data, artistResult.isLoading, artistResult.isError, did]); 89 + 90 + useEffect(() => { 91 + if (artistTracksResult.isLoading || artistTracksResult.isError) { 92 + return; 93 + } 94 + 95 + if (!artistTracksResult.data || !did) { 96 + return; 97 + } 98 + 99 + setTopTracks( 100 + artistTracksResult.data.map((track) => ({ 101 + ...track, 102 + scrobbles: track.playCount || 1, 103 + })), 104 + ); 105 + }, [ 106 + artistTracksResult.data, 107 + artistTracksResult.isLoading, 108 + artistTracksResult.isError, 109 + did, 110 + ]); 111 + 112 + useEffect(() => { 113 + if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) { 114 + return; 115 + } 116 + 117 + if (!artistAlbumsResult.data || !did) { 118 + return; 119 + } 120 + 121 + setTopAlbums(artistAlbumsResult.data); 122 + }, [ 123 + artistAlbumsResult.data, 124 + artistAlbumsResult.isLoading, 125 + artistAlbumsResult.isError, 126 + did, 127 + ]); 128 + 129 + const loading = 130 + artistResult.isLoading || 131 + artistTracksResult.isLoading || 132 + artistAlbumsResult.isLoading; 133 + return ( 134 + <Main> 135 + <div className="pb-[100px] pt-[50px]"> 136 + <Group> 137 + <div className="mr-[20px]"> 138 + {artist?.picture && !loading && ( 139 + <Avatar name={artist?.name} src={artist?.picture} size="150px" /> 140 + )} 141 + {!artist?.picture && !loading && ( 142 + <div className="w-[150px] h-[150px] rounded-[80px] bg-[rgba(243, 243, 243, 0.725)] flex items-center justify-center"> 143 + <div 144 + style={{ 145 + height: 60, 146 + width: 60, 147 + }} 148 + > 149 + <ArtistIcon color="rgba(66, 87, 108, 0.65)" /> 150 + </div> 151 + </div> 152 + )} 153 + </div> 154 + {artist && !loading && ( 155 + <div style={{ flex: 1 }}> 156 + <HeadingMedium 157 + marginTop={"20px"} 158 + marginBottom={0} 159 + className="!text-[var(--color-text)]" 160 + > 161 + {artist?.name} 162 + </HeadingMedium> 163 + <div className="mt-[20px] flex flex-row"> 164 + <div className="mr-[20px]"> 165 + <LabelMedium 166 + margin={0} 167 + className="!text-[var(--color-text-muted)]" 168 + > 169 + Listeners 170 + </LabelMedium> 171 + <HeadingXSmall 172 + margin={0} 173 + className="!text-[var(--color-text)]" 174 + > 175 + {numeral(artist?.listeners).format("0,0")} 176 + </HeadingXSmall> 177 + </div> 178 + <div> 179 + <LabelMedium 180 + margin={0} 181 + className="!text-[var(--color-text-muted)]" 182 + > 183 + Scrobbles 184 + </LabelMedium> 185 + <HeadingXSmall 186 + margin={0} 187 + className="!text-[var(--color-text)]" 188 + > 189 + {numeral(artist?.scrobbles).format("0,0")} 190 + </HeadingXSmall> 191 + </div> 192 + <div className="flex items-center justify-end flex-1 mr-[10px]"> 193 + <a 194 + href={`https://pdsls.dev/at/${uri.replace("at://", "")}`} 195 + target="_blank" 196 + className="text-[var(--color-text)] no-underline bg-[var(--color-default-button)] rounded-[10px] p-[16px] pl-[25px] pr-[25px]" 197 + > 198 + <ExternalLink 199 + size={24} 200 + className="mr-[10px] text-[var(--color-text)]" 201 + /> 202 + View on PDSls 203 + </a> 204 + </div> 205 + </div> 206 + </div> 207 + )} 208 + </Group> 209 + 210 + <PopularSongs topTracks={topTracks} /> 211 + <Albums topAlbums={topAlbums} /> 212 + <ArtistListeners listeners={artistListenersResult.data} /> 213 + <Shout type="artist" /> 214 + </div> 215 + </Main> 216 + ); 217 }; 218 219 export default Artist;
+74
apps/web/src/pages/artist/ArtistListeners/ArtistListeners.tsx
···
··· 1 + import { Link } from "@tanstack/react-router"; 2 + import { Avatar } from "baseui/avatar"; 3 + import { HeadingSmall } from "baseui/typography"; 4 + 5 + interface ArtistListenersProps { 6 + listeners: { 7 + id: string; 8 + did: string; 9 + handle: string; 10 + displayName: string; 11 + avatar: string; 12 + mostListenedSong: { 13 + title: string; 14 + uri: string; 15 + playCount: number; 16 + }; 17 + totalPlays: number; 18 + rank: number; 19 + }[]; 20 + } 21 + 22 + function ArtistListeners(props: ArtistListenersProps) { 23 + return ( 24 + <> 25 + <HeadingSmall 26 + marginBottom={"15px"} 27 + className="!text-[var(--color-text)] !mb-[30px]" 28 + > 29 + Listeners 30 + </HeadingSmall> 31 + {props.listeners?.map((item) => ( 32 + <div 33 + key={item.id} 34 + className="mb-[30px] flex flex-row items-center gap-[20px]" 35 + > 36 + <Link 37 + to={`/profile/${item.handle}` as string} 38 + className="no-underline" 39 + > 40 + <Avatar src={item.avatar} name={item.displayName} size={"60px"} /> 41 + </Link> 42 + <div> 43 + <Link 44 + to={`/profile/${item.handle}` as string} 45 + className="text-[var(--color-text)] hover:underline no-underline" 46 + style={{ fontWeight: 600 }} 47 + > 48 + @{item.handle} 49 + </Link> 50 + <div className="!text-[14px] mt-[5px]"> 51 + Listens to{" "} 52 + {item.mostListenedSong.uri && ( 53 + <Link 54 + to={`${item.mostListenedSong.uri?.split("at:/")[1].replace("app.rocksky.", "")}`} 55 + className="text-[var(--color-primary)] hover:underline no-underline" 56 + > 57 + {item.mostListenedSong.title} 58 + </Link> 59 + )} 60 + {!item.mostListenedSong.uri && ( 61 + <div style={{ fontWeight: 600 }}> 62 + {item.mostListenedSong.title} 63 + </div> 64 + )}{" "} 65 + a lot 66 + </div> 67 + </div> 68 + </div> 69 + ))} 70 + </> 71 + ); 72 + } 73 + 74 + export default ArtistListeners;
+3
apps/web/src/pages/artist/ArtistListeners/index.tsx
···
··· 1 + import ArtistListeners from "./ArtistListeners"; 2 + 3 + export default ArtistListeners;

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