From 140e85af34e8a836712b7c7504035dbe5b6db9ef Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Wed, 24 Sep 2025 08:52:20 +0300 Subject: [PATCH] feat: add listenerViewBasic schema and getArtistListeners endpoint with associated types --- apps/api/lexicons/artist/defs.json | 30 +++++ .../lexicons/artist/getArtistListeners.json | 38 ++++++ apps/api/pkl/defs/artist/defs.pkl | 36 +++++ .../pkl/defs/artist/getArtistListeners.pkl | 33 +++++ apps/api/src/lexicon/index.ts | 12 ++ apps/api/src/lexicon/lexicons.ts | 67 +++++++++ .../lexicon/types/app/rocksky/artist/defs.ts | 28 ++++ .../app/rocksky/artist/getArtistListeners.ts | 48 +++++++ crates/analytics/src/handlers/artists.rs | 127 +++++++++++++++++- crates/analytics/src/handlers/mod.rs | 3 + crates/analytics/src/types/artist.rs | 28 ++++ 11 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 apps/api/lexicons/artist/getArtistListeners.json create mode 100644 apps/api/pkl/defs/artist/getArtistListeners.pkl create mode 100644 apps/api/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts diff --git a/apps/api/lexicons/artist/defs.json b/apps/api/lexicons/artist/defs.json index a025c86..d21b2fc 100644 --- a/apps/api/lexicons/artist/defs.json +++ b/apps/api/lexicons/artist/defs.json @@ -73,6 +73,36 @@ "minimum": 0 } } + }, + "listenerViewBasic": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the actor." + }, + "did": { + "type": "string", + "description": "The DID of the listener." + }, + "handle": { + "type": "string", + "description": "The handle of the listener." + }, + "displayName": { + "type": "string", + "description": "The display name of the listener." + }, + "avatar": { + "type": "string", + "description": "The URL of the listener's avatar image.", + "format": "uri" + }, + "mostListenedSong": { + "type": "ref", + "ref": "app.rocksky.song.defs#songViewBasic" + } + } } } } diff --git a/apps/api/lexicons/artist/getArtistListeners.json b/apps/api/lexicons/artist/getArtistListeners.json new file mode 100644 index 0000000..b7eb3b9 --- /dev/null +++ b/apps/api/lexicons/artist/getArtistListeners.json @@ -0,0 +1,38 @@ +{ + "lexicon": 1, + "id": "app.rocksky.artist.getArtistListeners", + "defs": { + "main": { + "type": "query", + "description": "Get artist listeners", + "parameters": { + "type": "params", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "description": "The URI of the artist to retrieve listeners from", + "format": "at-uri" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "properties": { + "listeners": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.rocksky.artist.defs#listenerViewBasic" + } + } + } + } + } + } + } +} diff --git a/apps/api/pkl/defs/artist/defs.pkl b/apps/api/pkl/defs/artist/defs.pkl index 7b935ac..4424f2b 100644 --- a/apps/api/pkl/defs/artist/defs.pkl +++ b/apps/api/pkl/defs/artist/defs.pkl @@ -90,4 +90,40 @@ defs = new Mapping { } } + + ["listenerViewBasic"] { + type = "object" + properties { + ["id"] = new StringType { + type = "string" + description = "The unique identifier of the actor." + } + + ["did"] = new StringType { + type = "string" + description = "The DID of the listener." + } + + ["handle"] = new StringType { + type = "string" + description = "The handle of the listener." + } + + ["displayName"] = new StringType { + type = "string" + description = "The display name of the listener." + } + + ["avatar"] = new StringType { + type = "string" + format = "uri" + description = "The URL of the listener's avatar image." + } + + ["mostListenedSong"] = new Ref { + ref = "app.rocksky.song.defs#songViewBasic" + } + + } + } } diff --git a/apps/api/pkl/defs/artist/getArtistListeners.pkl b/apps/api/pkl/defs/artist/getArtistListeners.pkl new file mode 100644 index 0000000..f31809a --- /dev/null +++ b/apps/api/pkl/defs/artist/getArtistListeners.pkl @@ -0,0 +1,33 @@ +amends "../../schema/lexicon.pkl" + +lexicon = 1 +id = "app.rocksky.artist.getArtistListeners" +defs = new Mapping { + ["main"] { + type = "query" + description = "Get artist listeners" + parameters = new Params { + required = List("uri") + properties { + ["uri"] = new StringType { + description = "The URI of the artist to retrieve listeners from" + format = "at-uri" + } + } + } + output { + encoding = "application/json" + schema = new ObjectType { + type = "object" + properties = new Mapping { + ["listeners"] = new Array { + type = "array" + items = new Ref { + ref = "app.rocksky.artist.defs#listenerViewBasic" + } + } + } + } + } + } +} diff --git a/apps/api/src/lexicon/index.ts b/apps/api/src/lexicon/index.ts index 3e61c23..614c925 100644 --- a/apps/api/src/lexicon/index.ts +++ b/apps/api/src/lexicon/index.ts @@ -25,6 +25,7 @@ import type * as AppRockskyApikeyRemoveApikey from './types/app/rocksky/apikey/r import type * as AppRockskyApikeyUpdateApikey from './types/app/rocksky/apikey/updateApikey' import type * as AppRockskyArtistGetArtistAlbums from './types/app/rocksky/artist/getArtistAlbums' import type * as AppRockskyArtistGetArtist from './types/app/rocksky/artist/getArtist' +import type * as AppRockskyArtistGetArtistListeners from './types/app/rocksky/artist/getArtistListeners' import type * as AppRockskyArtistGetArtists from './types/app/rocksky/artist/getArtists' import type * as AppRockskyArtistGetArtistTracks from './types/app/rocksky/artist/getArtistTracks' import type * as AppRockskyChartsGetScrobblesChart from './types/app/rocksky/charts/getScrobblesChart' @@ -358,6 +359,17 @@ export class AppRockskyArtistNS { return this._server.xrpc.method(nsid, cfg) } + getArtistListeners( + cfg: ConfigOf< + AV, + AppRockskyArtistGetArtistListeners.Handler>, + AppRockskyArtistGetArtistListeners.HandlerReqCtx> + >, + ) { + const nsid = 'app.rocksky.artist.getArtistListeners' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getArtists( cfg: ConfigOf< AV, diff --git a/apps/api/src/lexicon/lexicons.ts b/apps/api/src/lexicon/lexicons.ts index d117ccf..73f39cf 100644 --- a/apps/api/src/lexicon/lexicons.ts +++ b/apps/api/src/lexicon/lexicons.ts @@ -1066,6 +1066,36 @@ export const schemaDict = { }, }, }, + listenerViewBasic: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The unique identifier of the actor.', + }, + did: { + type: 'string', + description: 'The DID of the listener.', + }, + handle: { + type: 'string', + description: 'The handle of the listener.', + }, + displayName: { + type: 'string', + description: 'The display name of the listener.', + }, + avatar: { + type: 'string', + description: "The URL of the listener's avatar image.", + format: 'uri', + }, + mostListenedSong: { + type: 'ref', + ref: 'lex:app.rocksky.song.defs#songViewBasic', + }, + }, + }, }, }, AppRockskyArtistGetArtistAlbums: { @@ -1132,6 +1162,42 @@ export const schemaDict = { }, }, }, + AppRockskyArtistGetArtistListeners: { + lexicon: 1, + id: 'app.rocksky.artist.getArtistListeners', + defs: { + main: { + type: 'query', + description: 'Get artist listeners', + parameters: { + type: 'params', + required: ['uri'], + properties: { + uri: { + type: 'string', + description: 'The URI of the artist to retrieve listeners from', + format: 'at-uri', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + listeners: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.rocksky.artist.defs#listenerViewBasic', + }, + }, + }, + }, + }, + }, + }, + }, AppRockskyArtistGetArtists: { lexicon: 1, id: 'app.rocksky.artist.getArtists', @@ -4321,6 +4387,7 @@ export const ids = { AppRockskyArtistDefs: 'app.rocksky.artist.defs', AppRockskyArtistGetArtistAlbums: 'app.rocksky.artist.getArtistAlbums', AppRockskyArtistGetArtist: 'app.rocksky.artist.getArtist', + AppRockskyArtistGetArtistListeners: 'app.rocksky.artist.getArtistListeners', AppRockskyArtistGetArtists: 'app.rocksky.artist.getArtists', AppRockskyArtistGetArtistTracks: 'app.rocksky.artist.getArtistTracks', AppRockskyChartsDefs: 'app.rocksky.charts.defs', diff --git a/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts b/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts index b3d1e35..248aefb 100644 --- a/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts +++ b/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts @@ -5,6 +5,7 @@ import { type ValidationResult, BlobRef } from '@atproto/lexicon' import { lexicons } from '../../../../lexicons' import { isObj, hasProp } from '../../../../util' import { CID } from 'multiformats/cid' +import type * as AppRockskySongDefs from '../song/defs' export interface ArtistViewBasic { /** The unique identifier of the artist. */ @@ -65,3 +66,30 @@ export function isArtistViewDetailed(v: unknown): v is ArtistViewDetailed { export function validateArtistViewDetailed(v: unknown): ValidationResult { return lexicons.validate('app.rocksky.artist.defs#artistViewDetailed', v) } + +export interface ListenerViewBasic { + /** The unique identifier of the actor. */ + id?: string + /** The DID of the listener. */ + did?: string + /** The handle of the listener. */ + handle?: string + /** The display name of the listener. */ + displayName?: string + /** The URL of the listener's avatar image. */ + avatar?: string + mostListenedSong?: AppRockskySongDefs.SongViewBasic + [k: string]: unknown +} + +export function isListenerViewBasic(v: unknown): v is ListenerViewBasic { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.rocksky.artist.defs#listenerViewBasic' + ) +} + +export function validateListenerViewBasic(v: unknown): ValidationResult { + return lexicons.validate('app.rocksky.artist.defs#listenerViewBasic', v) +} diff --git a/apps/api/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts b/apps/api/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts new file mode 100644 index 0000000..62b85f7 --- /dev/null +++ b/apps/api/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import type express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import type { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server' +import type * as AppRockskyArtistDefs from './defs' + +export interface QueryParams { + /** The URI of the artist to retrieve listeners from */ + uri: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + listeners?: AppRockskyArtistDefs.ListenerViewBasic[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response + resetRouteRateLimits: () => Promise +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/crates/analytics/src/handlers/artists.rs b/crates/analytics/src/handlers/artists.rs index f4a5dd8..0671abd 100644 --- a/crates/analytics/src/handlers/artists.rs +++ b/crates/analytics/src/handlers/artists.rs @@ -3,7 +3,8 @@ use std::sync::{Arc, Mutex}; use crate::types::{ album::Album, artist::{ - Artist, GetArtistAlbumsParams, GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams, + Artist, ArtistListener, GetArtistAlbumsParams, GetArtistListenersParams, + GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams, }, track::Track, }; @@ -364,3 +365,127 @@ pub async fn get_artist_albums( let albums: Result, _> = albums.collect(); Ok(HttpResponse::Ok().json(albums?)) } + +pub async fn get_artist_listeners( + payload: &mut web::Payload, + _req: &HttpRequest, + conn: Arc>, +) -> Result { + let body = read_payload!(payload); + let params = serde_json::from_slice::(&body)?; + let pagination = params.pagination.unwrap_or_default(); + let offset = pagination.skip.unwrap_or(0); + let limit = pagination.take.unwrap_or(10); + + let conn = conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT id, name, uri FROM artists WHERE id = ? OR uri = ? OR name = ?")?; + let artist = stmt.query_row( + [¶ms.artist_id, ¶ms.artist_id, ¶ms.artist_id], + |row| { + Ok(crate::types::artist::ArtistBasic { + id: row.get(0)?, + name: row.get(1)?, + uri: row.get(2)?, + }) + }, + )?; + + if artist.id.is_empty() { + return Ok(HttpResponse::Ok().json(Vec::::new())); + } + + let mut stmt = conn.prepare( + r#" + WITH user_track_counts AS ( + SELECT + s.user_id, + s.track_id, + t.artist, + t.title as track_title, + t.uri as track_uri, + COUNT(*) as play_count + FROM scrobbles s + JOIN tracks t ON s.track_id = t.id + WHERE t.artist = ? + GROUP BY s.user_id, s.track_id, t.artist, t.title, t.uri + ), + user_top_tracks AS ( + SELECT + user_id, + artist, + track_id, + track_title, + track_uri, + play_count, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY play_count DESC, track_title) as rn + FROM user_track_counts + ), + artist_listener_counts AS ( + SELECT + user_id, + artist, + SUM(play_count) as total_artist_plays + FROM user_track_counts + GROUP BY user_id, artist + ), + top_artist_listeners AS ( + SELECT + user_id, + artist, + total_artist_plays, + ROW_NUMBER() OVER (ORDER BY total_artist_plays DESC) as listener_rank + FROM artist_listener_counts + ), + paginated_listeners AS ( + SELECT + user_id, + artist, + total_artist_plays, + listener_rank + FROM top_artist_listeners + ORDER BY listener_rank + LIMIT ? OFFSET ? + ) + SELECT + pl.artist, + pl.listener_rank, + u.id as user_id, + u.display_name, + u.did, + u.handle, + u.avatar, + pl.total_artist_plays, + utt.track_title as most_played_track, + utt.track_uri as most_played_track_uri, + utt.play_count as track_play_count + FROM paginated_listeners pl + JOIN users u ON pl.user_id = u.id + JOIN user_top_tracks utt ON pl.user_id = utt.user_id + AND utt.rn = 1 + ORDER BY pl.listener_rank; + "#, + )?; + + let listeners = stmt.query_map( + [&artist.name, &limit.to_string(), &offset.to_string()], + |row| { + Ok(ArtistListener { + artist: row.get(0)?, + listener_rank: row.get(1)?, + user_id: row.get(2)?, + display_name: row.get(3)?, + did: row.get(4)?, + handle: row.get(5)?, + avatar: row.get(6)?, + total_artist_plays: row.get(7)?, + most_played_track: row.get(8)?, + most_played_track_uri: row.get(9)?, + track_play_count: row.get(10)?, + }) + }, + )?; + + let listeners: Result, _> = listeners.collect(); + Ok(HttpResponse::Ok().json(listeners?)) +} diff --git a/crates/analytics/src/handlers/mod.rs b/crates/analytics/src/handlers/mod.rs index 9900bdd..6568a79 100644 --- a/crates/analytics/src/handlers/mod.rs +++ b/crates/analytics/src/handlers/mod.rs @@ -12,6 +12,8 @@ use stats::{ }; use tracks::{get_loved_tracks, get_top_tracks, get_tracks}; +use crate::handlers::artists::get_artist_listeners; + pub mod albums; pub mod artists; pub mod scrobbles; @@ -58,6 +60,7 @@ pub async fn handle( "library.getAlbumTracks" => get_album_tracks(payload, req, conn.clone()).await, "library.getArtistAlbums" => get_artist_albums(payload, req, conn.clone()).await, "library.getArtistTracks" => get_artist_tracks(payload, req, conn.clone()).await, + "library.getArtistListeners" => get_artist_listeners(payload, req, conn.clone()).await, _ => return Err(anyhow::anyhow!("Method not found")), } } diff --git a/crates/analytics/src/types/artist.rs b/crates/analytics/src/types/artist.rs index 805983a..10bad27 100644 --- a/crates/analytics/src/types/artist.rs +++ b/crates/analytics/src/types/artist.rs @@ -33,6 +33,28 @@ pub struct Artist { pub unique_listeners: Option, } +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ArtistBasic { + pub id: String, + pub name: String, + pub uri: Option, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ArtistListener { + pub artist: String, + pub listener_rank: i64, + pub user_id: String, + pub display_name: String, + pub did: String, + pub handle: String, + pub avatar: String, + pub total_artist_plays: i64, + pub most_played_track: String, + pub most_played_track_uri: String, + pub track_play_count: i64, +} + #[derive(Debug, Serialize, Deserialize, Default)] pub struct GetArtistsParams { pub user_did: Option, @@ -55,3 +77,9 @@ pub struct GetArtistTracksParams { pub struct GetArtistAlbumsParams { pub artist_id: String, } + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct GetArtistListenersParams { + pub artist_id: String, + pub pagination: Option, +} -- 2.43.0 From 32eecdc3e588886b5e8adf56914f88ecb04a9ab5 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Wed, 24 Sep 2025 13:36:10 +0300 Subject: [PATCH] feat: add songViewBasic schema and getArtistListeners endpoint with associated types --- apps/api/lexicons/artist/defs.json | 31 ++++++- apps/api/pkl/defs/artist/defs.pkl | 37 ++++++++- apps/api/src/lexicon/lexicons.ts | 32 ++++++- .../lexicon/types/app/rocksky/artist/defs.ts | 29 ++++++- .../app/rocksky/artist/getArtistListeners.ts | 83 +++++++++++++++++++ 5 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/xrpc/app/rocksky/artist/getArtistListeners.ts diff --git a/apps/api/lexicons/artist/defs.json b/apps/api/lexicons/artist/defs.json index d21b2fc..ab76a1b 100644 --- a/apps/api/lexicons/artist/defs.json +++ b/apps/api/lexicons/artist/defs.json @@ -74,6 +74,25 @@ } } }, + "songViewBasic": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "The URI of the song.", + "format": "at-uri" + }, + "title": { + "type": "string", + "description": "The title of the song." + }, + "playCount": { + "type": "integer", + "description": "The number of times the song has been played.", + "minimum": 0 + } + } + }, "listenerViewBasic": { "type": "object", "properties": { @@ -100,7 +119,17 @@ }, "mostListenedSong": { "type": "ref", - "ref": "app.rocksky.song.defs#songViewBasic" + "ref": "app.rocksky.artist.defs#songViewBasic" + }, + "totalPlays": { + "type": "integer", + "description": "The total number of plays by the listener.", + "minimum": 0 + }, + "rank": { + "type": "integer", + "description": "The rank of the listener among all listeners of the artist.", + "minimum": 1 } } } diff --git a/apps/api/pkl/defs/artist/defs.pkl b/apps/api/pkl/defs/artist/defs.pkl index 4424f2b..10a4d39 100644 --- a/apps/api/pkl/defs/artist/defs.pkl +++ b/apps/api/pkl/defs/artist/defs.pkl @@ -91,6 +91,29 @@ defs = new Mapping { } } + ["songViewBasic"] { + type = "object" + properties { + ["uri"] = new StringType { + type = "string" + format = "at-uri" + description = "The URI of the song." + } + + ["title"] = new StringType { + type = "string" + description = "The title of the song." + } + + ["playCount"] = new IntegerType { + type = "integer" + description = "The number of times the song has been played." + minimum = 0 + } + + } + } + ["listenerViewBasic"] { type = "object" properties { @@ -121,7 +144,19 @@ defs = new Mapping { } ["mostListenedSong"] = new Ref { - ref = "app.rocksky.song.defs#songViewBasic" + ref = "app.rocksky.artist.defs#songViewBasic" + } + + ["totalPlays"] = new IntegerType { + type = "integer" + description = "The total number of plays by the listener." + minimum = 0 + } + + ["rank"] = new IntegerType { + type = "integer" + description = "The rank of the listener among all listeners of the artist." + minimum = 1 } } diff --git a/apps/api/src/lexicon/lexicons.ts b/apps/api/src/lexicon/lexicons.ts index 73f39cf..2109914 100644 --- a/apps/api/src/lexicon/lexicons.ts +++ b/apps/api/src/lexicon/lexicons.ts @@ -1066,6 +1066,25 @@ export const schemaDict = { }, }, }, + songViewBasic: { + type: 'object', + properties: { + uri: { + type: 'string', + description: 'The URI of the song.', + format: 'at-uri', + }, + title: { + type: 'string', + description: 'The title of the song.', + }, + playCount: { + type: 'integer', + description: 'The number of times the song has been played.', + minimum: 0, + }, + }, + }, listenerViewBasic: { type: 'object', properties: { @@ -1092,7 +1111,18 @@ export const schemaDict = { }, mostListenedSong: { type: 'ref', - ref: 'lex:app.rocksky.song.defs#songViewBasic', + ref: 'lex:app.rocksky.artist.defs#songViewBasic', + }, + totalPlays: { + type: 'integer', + description: 'The total number of plays by the listener.', + minimum: 0, + }, + rank: { + type: 'integer', + description: + 'The rank of the listener among all listeners of the artist.', + minimum: 1, }, }, }, diff --git a/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts b/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts index 248aefb..2d0c3c5 100644 --- a/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts +++ b/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts @@ -5,7 +5,6 @@ import { type ValidationResult, BlobRef } from '@atproto/lexicon' import { lexicons } from '../../../../lexicons' import { isObj, hasProp } from '../../../../util' import { CID } from 'multiformats/cid' -import type * as AppRockskySongDefs from '../song/defs' export interface ArtistViewBasic { /** The unique identifier of the artist. */ @@ -67,6 +66,28 @@ export function validateArtistViewDetailed(v: unknown): ValidationResult { return lexicons.validate('app.rocksky.artist.defs#artistViewDetailed', v) } +export interface SongViewBasic { + /** The URI of the song. */ + uri?: string + /** The title of the song. */ + title?: string + /** The number of times the song has been played. */ + playCount?: number + [k: string]: unknown +} + +export function isSongViewBasic(v: unknown): v is SongViewBasic { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'app.rocksky.artist.defs#songViewBasic' + ) +} + +export function validateSongViewBasic(v: unknown): ValidationResult { + return lexicons.validate('app.rocksky.artist.defs#songViewBasic', v) +} + export interface ListenerViewBasic { /** The unique identifier of the actor. */ id?: string @@ -78,7 +99,11 @@ export interface ListenerViewBasic { displayName?: string /** The URL of the listener's avatar image. */ avatar?: string - mostListenedSong?: AppRockskySongDefs.SongViewBasic + mostListenedSong?: SongViewBasic + /** The total number of plays by the listener. */ + totalPlays?: number + /** The rank of the listener among all listeners of the artist. */ + rank?: number [k: string]: unknown } diff --git a/apps/api/src/xrpc/app/rocksky/artist/getArtistListeners.ts b/apps/api/src/xrpc/app/rocksky/artist/getArtistListeners.ts new file mode 100644 index 0000000..3214ccf --- /dev/null +++ b/apps/api/src/xrpc/app/rocksky/artist/getArtistListeners.ts @@ -0,0 +1,83 @@ +import type { Context } from "context"; +import { Effect, pipe } from "effect"; +import type { Server } from "lexicon"; +import type { ListenerViewBasic } from "lexicon/types/app/rocksky/artist/defs"; +import type { QueryParams } from "lexicon/types/app/rocksky/artist/getArtistListeners"; + +export default function (server: Server, ctx: Context) { + const getArtistListeners = (params) => + pipe( + { params, ctx }, + retrieve, + Effect.flatMap(presentation), + Effect.retry({ times: 3 }), + Effect.timeout("10 seconds"), + Effect.catchAll((err) => { + console.error(err); + return Effect.succeed({ listeners: [] }); + }) + ); + server.app.rocksky.artist.getArtistListeners({ + handler: async ({ params }) => { + const result = await Effect.runPromise(getArtistListeners(params)); + return { + encoding: "application/json", + body: result, + }; + }, + }); +} + +const retrieve = ({ + params, + ctx, +}: { + params: QueryParams; + ctx: Context; +}): Effect.Effect<{ data: ArtistListener[] }, Error> => { + return Effect.tryPromise({ + try: () => + ctx.analytics.post("library.getArtistListeners", { + artist_id: params.uri, + }), + catch: (error) => + new Error(`Failed to retrieve artist's listeners: ${error}`), + }); +}; + +const presentation = ({ + data, +}: { + data: ArtistListener[]; +}): Effect.Effect<{ listeners: ListenerViewBasic[] }, never> => { + return Effect.sync(() => ({ + listeners: data.map((item) => ({ + id: item.user_id, + did: item.did, + handle: item.handle, + displayName: item.display_name, + avatar: item.avatar, + mostListenedSong: { + title: item.most_played_track, + uri: item.most_played_track_uri, + playCount: item.track_play_count, + }, + totalPlays: item.total_artist_plays, + rank: item.listener_rank, + })), + })); +}; + +type ArtistListener = { + artist: string; + avatar: string; + did: string; + display_name: string; + handle: string; + listener_rank: number; + most_played_track: string; + most_played_track_uri: string; + total_artist_plays: number; + track_play_count: number; + user_id: string; +}; -- 2.43.0 From 649aa352cf28352b92ec1efdc1d54086c7739a3c Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Wed, 24 Sep 2025 14:12:58 +0300 Subject: [PATCH] feat: add getArtistListeners function to the server endpoint --- apps/api/src/xrpc/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/src/xrpc/index.ts b/apps/api/src/xrpc/index.ts index 8ab54e3..b28cc10 100644 --- a/apps/api/src/xrpc/index.ts +++ b/apps/api/src/xrpc/index.ts @@ -16,6 +16,7 @@ import removeApikey from "./app/rocksky/apikey/removeApikey"; import updateApikey from "./app/rocksky/apikey/updateApikey"; import getArtist from "./app/rocksky/artist/getArtist"; import getArtistAlbums from "./app/rocksky/artist/getArtistAlbums"; +import getArtistListeners from "./app/rocksky/artist/getArtistListeners"; import getArtists from "./app/rocksky/artist/getArtists"; import getArtistTracks from "./app/rocksky/artist/getArtistTracks"; import getScrobblesChart from "./app/rocksky/charts/getScrobblesChart"; @@ -91,6 +92,7 @@ export default function (server: Server, ctx: Context) { getArtist(server, ctx); getArtistAlbums(server, ctx); getArtists(server, ctx); + getArtistListeners(server, ctx); getArtistTracks(server, ctx); getScrobblesChart(server, ctx); downloadFileFromDropbox(server, ctx); -- 2.43.0 From 08a45697ad8472a96c7bc8a86c196b4febca23d7 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Wed, 24 Sep 2025 14:36:33 +0300 Subject: [PATCH] feat: make most_played_track_uri optional in ArtistListener struct --- crates/analytics/src/types/artist.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/analytics/src/types/artist.rs b/crates/analytics/src/types/artist.rs index 10bad27..1e3e8c2 100644 --- a/crates/analytics/src/types/artist.rs +++ b/crates/analytics/src/types/artist.rs @@ -51,7 +51,8 @@ pub struct ArtistListener { pub avatar: String, pub total_artist_plays: i64, pub most_played_track: String, - pub most_played_track_uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub most_played_track_uri: Option, pub track_play_count: i64, } -- 2.43.0 From 1666db4103b91d07f207f79e18b4646de6b95b78 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Wed, 24 Sep 2025 15:34:56 +0300 Subject: [PATCH] feat: add ArtistListeners component and integrate with Artist page --- apps/web/src/api/library.ts | 18 +- apps/web/src/hooks/useLibrary.tsx | 153 +++---- apps/web/src/pages/artist/Artist.tsx | 379 +++++++++--------- .../ArtistListeners/ArtistListeners.tsx | 74 ++++ .../pages/artist/ArtistListeners/index.tsx | 3 + 5 files changed, 362 insertions(+), 265 deletions(-) create mode 100644 apps/web/src/pages/artist/ArtistListeners/ArtistListeners.tsx create mode 100644 apps/web/src/pages/artist/ArtistListeners/index.tsx diff --git a/apps/web/src/api/library.ts b/apps/web/src/api/library.ts index 93d9466..85e9d2a 100644 --- a/apps/web/src/api/library.ts +++ b/apps/web/src/api/library.ts @@ -29,7 +29,7 @@ export const getSongByUri = async (uri: string) => { export const getArtistTracks = async ( uri: string, - limit = 10, + limit = 10 ): Promise< { id: string; @@ -45,14 +45,14 @@ export const getArtistTracks = async ( > => { const response = await client.get( "/xrpc/app.rocksky.artist.getArtistTracks", - { params: { uri, limit } }, + { params: { uri, limit } } ); return response.data.tracks; }; export const getArtistAlbums = async ( uri: string, - limit = 10, + limit = 10 ): Promise< { id: string; @@ -65,7 +65,7 @@ export const getArtistAlbums = async ( > => { const response = await client.get( "/xrpc/app.rocksky.artist.getArtistAlbums", - { params: { uri, limit } }, + { params: { uri, limit } } ); return response.data.albums; }; @@ -96,7 +96,7 @@ export const getLovedTracks = async (did: string, offset = 0, limit = 20) => { "/xrpc/app.rocksky.actor.getActorLovedSongs", { params: { did, limit, offset }, - }, + } ); return response.data.tracks; }; @@ -114,3 +114,11 @@ export const getArtist = async (did: string, rkey: string) => { }); return response.data; }; + +export const getArtistListeners = async (uri: string, limit: number) => { + const response = await client.get( + "/xrpc/app.rocksky.artist.getArtistListeners", + { params: { uri, limit } } + ); + return response.data; +}; diff --git a/apps/web/src/hooks/useLibrary.tsx b/apps/web/src/hooks/useLibrary.tsx index ba3a3e4..153c69d 100644 --- a/apps/web/src/hooks/useLibrary.tsx +++ b/apps/web/src/hooks/useLibrary.tsx @@ -1,93 +1,102 @@ import { useQuery } from "@tanstack/react-query"; import { - getAlbum, - getAlbums, - getArtist, - getArtistAlbums, - getArtists, - getArtistTracks, - getLovedTracks, - getSongByUri, - getTracks, + getAlbum, + getAlbums, + getArtist, + getArtistAlbums, + getArtistListeners, + getArtists, + getArtistTracks, + getLovedTracks, + getSongByUri, + getTracks, } from "../api/library"; export const useSongByUriQuery = (uri: string) => - useQuery({ - queryKey: ["songByUri", uri], - queryFn: () => getSongByUri(uri), - enabled: !!uri, - }); + useQuery({ + queryKey: ["songByUri", uri], + queryFn: () => getSongByUri(uri), + enabled: !!uri, + }); export const useArtistTracksQuery = (uri: string, limit = 10) => - useQuery({ - queryKey: ["artistTracks", uri, limit], - queryFn: () => getArtistTracks(uri, limit), - enabled: !!uri, - }); + useQuery({ + queryKey: ["artistTracks", uri, limit], + queryFn: () => getArtistTracks(uri, limit), + enabled: !!uri, + }); export const useArtistAlbumsQuery = (uri: string, limit = 10) => - useQuery({ - queryKey: ["artistAlbums", uri, limit], - queryFn: () => getArtistAlbums(uri, limit), - enabled: !!uri, - }); + useQuery({ + queryKey: ["artistAlbums", uri, limit], + queryFn: () => getArtistAlbums(uri, limit), + enabled: !!uri, + }); export const useArtistsQuery = (did: string, offset = 0, limit = 30) => - useQuery({ - queryKey: ["artists", did, offset, limit], - queryFn: () => getArtists(did, offset, limit), - enabled: !!did, - select: (data) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data?.artists.map((x: any) => ({ - ...x, - scrobbles: x.playCount, - })), - }); + useQuery({ + queryKey: ["artists", did, offset, limit], + queryFn: () => getArtists(did, offset, limit), + enabled: !!did, + select: (data) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?.artists.map((x: any) => ({ + ...x, + scrobbles: x.playCount, + })), + }); export const useAlbumsQuery = (did: string, offset = 0, limit = 12) => - useQuery({ - queryKey: ["albums", did, offset, limit], - queryFn: () => getAlbums(did, offset, limit), - enabled: !!did, - select: (data) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data?.albums.map((x: any) => ({ - ...x, - scrobbles: x.playCount, - })), - }); + useQuery({ + queryKey: ["albums", did, offset, limit], + queryFn: () => getAlbums(did, offset, limit), + enabled: !!did, + select: (data) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?.albums.map((x: any) => ({ + ...x, + scrobbles: x.playCount, + })), + }); export const useTracksQuery = (did: string, offset = 0, limit = 20) => - useQuery({ - queryKey: ["tracks", did, offset, limit], - queryFn: () => getTracks(did, offset, limit), - enabled: !!did, - select: (data) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data?.tracks.map((x: any) => ({ - ...x, - scrobbles: x.playCount, - })), - }); + useQuery({ + queryKey: ["tracks", did, offset, limit], + queryFn: () => getTracks(did, offset, limit), + enabled: !!did, + select: (data) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?.tracks.map((x: any) => ({ + ...x, + scrobbles: x.playCount, + })), + }); export const useLovedTracksQuery = (did: string, offset = 0, limit = 20) => - useQuery({ - queryKey: ["lovedTracks", did, offset, limit], - queryFn: () => getLovedTracks(did, offset, limit), - enabled: !!did, - }); + useQuery({ + queryKey: ["lovedTracks", did, offset, limit], + queryFn: () => getLovedTracks(did, offset, limit), + enabled: !!did, + }); export const useAlbumQuery = (did: string, rkey: string) => - useQuery({ - queryKey: ["album", did, rkey], - queryFn: () => getAlbum(did, rkey), - enabled: !!did && !!rkey, - }); + useQuery({ + queryKey: ["album", did, rkey], + queryFn: () => getAlbum(did, rkey), + enabled: !!did && !!rkey, + }); export const useArtistQuery = (did: string, rkey: string) => - useQuery({ - queryKey: ["artist", did, rkey], - queryFn: () => getArtist(did, rkey), - enabled: !!did && !!rkey, - }); + useQuery({ + queryKey: ["artist", did, rkey], + queryFn: () => getArtist(did, rkey), + enabled: !!did && !!rkey, + }); + +export const useArtistListenersQuery = (uri: string, limit = 10) => + useQuery({ + queryKey: ["artistListeners", uri, limit], + queryFn: () => getArtistListeners(uri, limit), + enabled: !!uri, + select: (data) => data.listeners, + }); diff --git a/apps/web/src/pages/artist/Artist.tsx b/apps/web/src/pages/artist/Artist.tsx index 345ad60..a86a207 100644 --- a/apps/web/src/pages/artist/Artist.tsx +++ b/apps/web/src/pages/artist/Artist.tsx @@ -10,12 +10,14 @@ import { artistAtom } from "../../atoms/artist"; import ArtistIcon from "../../components/Icons/Artist"; import Shout from "../../components/Shout/Shout"; import { - useArtistAlbumsQuery, - useArtistQuery, - useArtistTracksQuery, + useArtistAlbumsQuery, + useArtistListenersQuery, + useArtistQuery, + useArtistTracksQuery, } from "../../hooks/useLibrary"; import Main from "../../layouts/Main"; import Albums from "./Albums"; +import ArtistListeners from "./ArtistListeners"; import PopularSongs from "./PopularSongs"; const Group = styled.div` @@ -26,191 +28,192 @@ const Group = styled.div` `; const Artist = () => { - const { did, rkey } = useParams({ strict: false }); - - const uri = `at://${did}/app.rocksky.artist/${rkey}`; - const artistResult = useArtistQuery(did!, rkey!); - const artistTracksResult = useArtistTracksQuery(uri); - const artistAlbumsResult = useArtistAlbumsQuery(uri); - - const artist = useAtomValue(artistAtom); - const setArtist = useSetAtom(artistAtom); - const [topTracks, setTopTracks] = useState< - { - id: string; - title: string; - artist: string; - albumArtist: string; - albumArt: string; - uri: string; - scrobbles: number; - albumUri?: string; - artistUri?: string; - }[] - >([]); - const [topAlbums, setTopAlbums] = useState< - { - id: string; - title: string; - artist: string; - albumArt: string; - artistUri: string; - uri: string; - }[] - >([]); - - useEffect(() => { - if (artistResult.isLoading || artistResult.isError) { - return; - } - - if (!artistResult.data || !did) { - return; - } - - setArtist({ - id: artistResult.data.id, - name: artistResult.data.name, - born: artistResult.data.born, - bornIn: artistResult.data.bornIn, - died: artistResult.data.died, - listeners: artistResult.data.uniqueListeners, - scrobbles: artistResult.data.playCount, - picture: artistResult.data.picture, - tags: artistResult.data.tags, - uri: artistResult.data.uri, - spotifyLink: artistResult.data.spotifyLink, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [artistResult.data, artistResult.isLoading, artistResult.isError, did]); - - useEffect(() => { - if (artistTracksResult.isLoading || artistTracksResult.isError) { - return; - } - - if (!artistTracksResult.data || !did) { - return; - } - - setTopTracks( - artistTracksResult.data.map((track) => ({ - ...track, - scrobbles: track.playCount || 1, - })), - ); - }, [ - artistTracksResult.data, - artistTracksResult.isLoading, - artistTracksResult.isError, - did, - ]); - - useEffect(() => { - if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) { - return; - } - - if (!artistAlbumsResult.data || !did) { - return; - } - - setTopAlbums(artistAlbumsResult.data); - }, [ - artistAlbumsResult.data, - artistAlbumsResult.isLoading, - artistAlbumsResult.isError, - did, - ]); - - const loading = - artistResult.isLoading || - artistTracksResult.isLoading || - artistAlbumsResult.isLoading; - return ( -
-
- -
- {artist?.picture && !loading && ( - - )} - {!artist?.picture && !loading && ( -
-
- -
-
- )} -
- {artist && !loading && ( -
- - {artist?.name} - -
-
- - Listeners - - - {numeral(artist?.listeners).format("0,0")} - -
-
- - Scrobbles - - - {numeral(artist?.scrobbles).format("0,0")} - -
- -
-
- )} -
- - - - - -
-
- ); + const { did, rkey } = useParams({ strict: false }); + + const uri = `at://${did}/app.rocksky.artist/${rkey}`; + const artistResult = useArtistQuery(did!, rkey!); + const artistTracksResult = useArtistTracksQuery(uri); + const artistAlbumsResult = useArtistAlbumsQuery(uri); + const artistListenersResult = useArtistListenersQuery(uri); + + const artist = useAtomValue(artistAtom); + const setArtist = useSetAtom(artistAtom); + const [topTracks, setTopTracks] = useState< + { + id: string; + title: string; + artist: string; + albumArtist: string; + albumArt: string; + uri: string; + scrobbles: number; + albumUri?: string; + artistUri?: string; + }[] + >([]); + const [topAlbums, setTopAlbums] = useState< + { + id: string; + title: string; + artist: string; + albumArt: string; + artistUri: string; + uri: string; + }[] + >([]); + + useEffect(() => { + if (artistResult.isLoading || artistResult.isError) { + return; + } + + if (!artistResult.data || !did) { + return; + } + + setArtist({ + id: artistResult.data.id, + name: artistResult.data.name, + born: artistResult.data.born, + bornIn: artistResult.data.bornIn, + died: artistResult.data.died, + listeners: artistResult.data.uniqueListeners, + scrobbles: artistResult.data.playCount, + picture: artistResult.data.picture, + tags: artistResult.data.tags, + uri: artistResult.data.uri, + spotifyLink: artistResult.data.spotifyLink, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [artistResult.data, artistResult.isLoading, artistResult.isError, did]); + + useEffect(() => { + if (artistTracksResult.isLoading || artistTracksResult.isError) { + return; + } + + if (!artistTracksResult.data || !did) { + return; + } + + setTopTracks( + artistTracksResult.data.map((track) => ({ + ...track, + scrobbles: track.playCount || 1, + })), + ); + }, [ + artistTracksResult.data, + artistTracksResult.isLoading, + artistTracksResult.isError, + did, + ]); + + useEffect(() => { + if (artistAlbumsResult.isLoading || artistAlbumsResult.isError) { + return; + } + + if (!artistAlbumsResult.data || !did) { + return; + } + + setTopAlbums(artistAlbumsResult.data); + }, [ + artistAlbumsResult.data, + artistAlbumsResult.isLoading, + artistAlbumsResult.isError, + did, + ]); + + const loading = + artistResult.isLoading || + artistTracksResult.isLoading || + artistAlbumsResult.isLoading; + return ( +
+
+ +
+ {artist?.picture && !loading && ( + + )} + {!artist?.picture && !loading && ( +
+
+ +
+
+ )} +
+ {artist && !loading && ( +
+ + {artist?.name} + +
+
+ + Listeners + + + {numeral(artist?.listeners).format("0,0")} + +
+
+ + Scrobbles + + + {numeral(artist?.scrobbles).format("0,0")} + +
+ +
+
+ )} +
+ + + + + +
+
+ ); }; export default Artist; diff --git a/apps/web/src/pages/artist/ArtistListeners/ArtistListeners.tsx b/apps/web/src/pages/artist/ArtistListeners/ArtistListeners.tsx new file mode 100644 index 0000000..29e3fdb --- /dev/null +++ b/apps/web/src/pages/artist/ArtistListeners/ArtistListeners.tsx @@ -0,0 +1,74 @@ +import { Link } from "@tanstack/react-router"; +import { Avatar } from "baseui/avatar"; +import { HeadingSmall } from "baseui/typography"; + +interface ArtistListenersProps { + listeners: { + id: string; + did: string; + handle: string; + displayName: string; + avatar: string; + mostListenedSong: { + title: string; + uri: string; + playCount: number; + }; + totalPlays: number; + rank: number; + }[]; +} + +function ArtistListeners(props: ArtistListenersProps) { + return ( + <> + + Listeners + + {props.listeners?.map((item) => ( +
+ + + +
+ + @{item.handle} + +
+ Listens to{" "} + {item.mostListenedSong.uri && ( + + {item.mostListenedSong.title} + + )} + {!item.mostListenedSong.uri && ( +
+ {item.mostListenedSong.title} +
+ )}{" "} + a lot +
+
+
+ ))} + + ); +} + +export default ArtistListeners; diff --git a/apps/web/src/pages/artist/ArtistListeners/index.tsx b/apps/web/src/pages/artist/ArtistListeners/index.tsx new file mode 100644 index 0000000..8fea841 --- /dev/null +++ b/apps/web/src/pages/artist/ArtistListeners/index.tsx @@ -0,0 +1,3 @@ +import ArtistListeners from "./ArtistListeners"; + +export default ArtistListeners; -- 2.43.0