+59
apps/api/lexicons/artist/defs.json
+59
apps/api/lexicons/artist/defs.json
···
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
+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
+71
apps/api/pkl/defs/artist/defs.pkl
···
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
+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
+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
+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
+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
}
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
+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
+126
-1
crates/analytics/src/handlers/artists.rs
···
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
+
[¶ms.artist_id, ¶ms.artist_id, ¶ms.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
+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
+29
crates/analytics/src/types/artist.rs
···
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
+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
+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
+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
+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
+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
+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;
History
2 rounds
0 comments
tsiry-sandratraina.com
submitted
#1
5 commits
expand
collapse
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
tsiry-sandratraina.com
submitted
#0
1 commit
expand
collapse
feat: add listenerViewBasic schema and getArtistListeners endpoint with associated types