diff --git a/apps/api/lexicons/artist/defs.json b/apps/api/lexicons/artist/defs.json index a025c869..d21b2fc8 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 00000000..b7eb3b9b --- /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 7b935ac0..4424f2be 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 00000000..f31809a2 --- /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 3e61c236..614c9251 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 d117ccfd..73f39cfc 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 b3d1e350..248aefb1 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 00000000..62b85f7d --- /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 f4a5dd8d..0671abd9 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 9900bdd6..6568a79c 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 805983a7..10bad27e 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, +}