display artist's top listeners #5

merged
opened by tsiry-sandratraina.com targeting main from feat/artist-listeners
Changed files
+449 -1
apps
api
lexicons
pkl
src
lexicon
crates
analytics
src
handlers
types
+30
apps/api/lexicons/artist/defs.json
···
"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"
+
}
+
}
}
}
}
+38
apps/api/lexicons/artist/getArtistListeners.json
···
+
{
+
"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"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+36
apps/api/pkl/defs/artist/defs.pkl
···
}
}
+
+
["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"
+
}
+
+
}
+
}
}
+33
apps/api/pkl/defs/artist/getArtistListeners.pkl
···
+
amends "../../schema/lexicon.pkl"
+
+
lexicon = 1
+
id = "app.rocksky.artist.getArtistListeners"
+
defs = new Mapping<String, Query> {
+
["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<String, Array> {
+
["listeners"] = new Array {
+
type = "array"
+
items = new Ref {
+
ref = "app.rocksky.artist.defs#listenerViewBasic"
+
}
+
}
+
}
+
}
+
}
+
}
+
}
+12
apps/api/src/lexicon/index.ts
···
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'
···
return this._server.xrpc.method(nsid, cfg)
}
+
getArtistListeners<AV extends AuthVerifier>(
+
cfg: ConfigOf<
+
AV,
+
AppRockskyArtistGetArtistListeners.Handler<ExtractAuth<AV>>,
+
AppRockskyArtistGetArtistListeners.HandlerReqCtx<ExtractAuth<AV>>
+
>,
+
) {
+
const nsid = 'app.rocksky.artist.getArtistListeners' // @ts-ignore
+
return this._server.xrpc.method(nsid, cfg)
+
}
+
getArtists<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
+67
apps/api/src/lexicon/lexicons.ts
···
},
},
},
+
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: {
···
},
},
},
+
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',
···
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',
+28
apps/api/src/lexicon/types/app/rocksky/artist/defs.ts
···
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. */
···
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)
+
}
+48
apps/api/src/lexicon/types/app/rocksky/artist/getArtistListeners.ts
···
+
/**
+
* 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<HA extends HandlerAuth = never> = {
+
auth: HA
+
params: QueryParams
+
input: HandlerInput
+
req: express.Request
+
res: express.Response
+
resetRouteRateLimits: () => Promise<void>
+
}
+
export type Handler<HA extends HandlerAuth = never> = (
+
ctx: HandlerReqCtx<HA>,
+
) => Promise<HandlerOutput> | HandlerOutput
+126 -1
crates/analytics/src/handlers/artists.rs
···
use crate::types::{
album::Album,
artist::{
-
Artist, GetArtistAlbumsParams, GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams,
+
Artist, ArtistListener, GetArtistAlbumsParams, GetArtistListenersParams,
+
GetArtistTracksParams, GetArtistsParams, GetTopArtistsParams,
},
track::Track,
};
···
let albums: Result<Vec<_>, _> = albums.collect();
Ok(HttpResponse::Ok().json(albums?))
}
+
+
pub async fn get_artist_listeners(
+
payload: &mut web::Payload,
+
_req: &HttpRequest,
+
conn: Arc<Mutex<Connection>>,
+
) -> Result<HttpResponse, Error> {
+
let body = read_payload!(payload);
+
let params = serde_json::from_slice::<GetArtistListenersParams>(&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(
+
[&params.artist_id, &params.artist_id, &params.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::<ArtistListener>::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<Vec<_>, _> = listeners.collect();
+
Ok(HttpResponse::Ok().json(listeners?))
+
}
+3
crates/analytics/src/handlers/mod.rs
···
};
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;
···
"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")),
}
}
+28
crates/analytics/src/types/artist.rs
···
pub unique_listeners: Option<i32>,
}
+
#[derive(Debug, Serialize, Deserialize, Default)]
+
pub struct ArtistBasic {
+
pub id: String,
+
pub name: String,
+
pub uri: Option<String>,
+
}
+
+
#[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<String>,
···
pub struct GetArtistAlbumsParams {
pub artist_id: String,
}
+
+
#[derive(Debug, Serialize, Deserialize, Default)]
+
pub struct GetArtistListenersParams {
+
pub artist_id: String,
+
pub pagination: Option<Pagination>,
+
}