+30
apps/api/lexicons/artist/defs.json
+30
apps/api/lexicons/artist/defs.json
···
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
+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
+36
apps/api/pkl/defs/artist/defs.pkl
···
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
+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,
+67
apps/api/src/lexicon/lexicons.ts
+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
+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
+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
}
+28
crates/analytics/src/types/artist.rs
+28
crates/analytics/src/types/artist.rs
···
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
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