diff --git a/apps/api/lexicons/artist/defs.json b/apps/api/lexicons/artist/defs.json index d21b2fc8..ab76a1b8 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 4424f2be..10a4d395 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 73f39cfc..21099146 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 248aefb1..2d0c3c54 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 00000000..3214ccf6 --- /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; +};