A decentralized music tracking and discovery platform built on AT Protocol 🎵
listenbrainz spotify atproto lastfm musicbrainz scrobbling

feat: add songViewBasic schema and getArtistListeners endpoint with associated types

Changed files
+207 -5
apps
api
lexicons
artist
pkl
defs
artist
src
lexicon
types
app
rocksky
artist
xrpc
app
rocksky
+30 -1
apps/api/lexicons/artist/defs.json
··· 74 74 } 75 75 } 76 76 }, 77 + "songViewBasic": { 78 + "type": "object", 79 + "properties": { 80 + "uri": { 81 + "type": "string", 82 + "description": "The URI of the song.", 83 + "format": "at-uri" 84 + }, 85 + "title": { 86 + "type": "string", 87 + "description": "The title of the song." 88 + }, 89 + "playCount": { 90 + "type": "integer", 91 + "description": "The number of times the song has been played.", 92 + "minimum": 0 93 + } 94 + } 95 + }, 77 96 "listenerViewBasic": { 78 97 "type": "object", 79 98 "properties": { ··· 100 119 }, 101 120 "mostListenedSong": { 102 121 "type": "ref", 103 - "ref": "app.rocksky.song.defs#songViewBasic" 122 + "ref": "app.rocksky.artist.defs#songViewBasic" 123 + }, 124 + "totalPlays": { 125 + "type": "integer", 126 + "description": "The total number of plays by the listener.", 127 + "minimum": 0 128 + }, 129 + "rank": { 130 + "type": "integer", 131 + "description": "The rank of the listener among all listeners of the artist.", 132 + "minimum": 1 104 133 } 105 134 } 106 135 }
+36 -1
apps/api/pkl/defs/artist/defs.pkl
··· 91 91 } 92 92 } 93 93 94 + ["songViewBasic"] { 95 + type = "object" 96 + properties { 97 + ["uri"] = new StringType { 98 + type = "string" 99 + format = "at-uri" 100 + description = "The URI of the song." 101 + } 102 + 103 + ["title"] = new StringType { 104 + type = "string" 105 + description = "The title of the song." 106 + } 107 + 108 + ["playCount"] = new IntegerType { 109 + type = "integer" 110 + description = "The number of times the song has been played." 111 + minimum = 0 112 + } 113 + 114 + } 115 + } 116 + 94 117 ["listenerViewBasic"] { 95 118 type = "object" 96 119 properties { ··· 121 144 } 122 145 123 146 ["mostListenedSong"] = new Ref { 124 - ref = "app.rocksky.song.defs#songViewBasic" 147 + ref = "app.rocksky.artist.defs#songViewBasic" 148 + } 149 + 150 + ["totalPlays"] = new IntegerType { 151 + type = "integer" 152 + description = "The total number of plays by the listener." 153 + minimum = 0 154 + } 155 + 156 + ["rank"] = new IntegerType { 157 + type = "integer" 158 + description = "The rank of the listener among all listeners of the artist." 159 + minimum = 1 125 160 } 126 161 127 162 }
+31 -1
apps/api/src/lexicon/lexicons.ts
··· 1066 1066 }, 1067 1067 }, 1068 1068 }, 1069 + songViewBasic: { 1070 + type: 'object', 1071 + properties: { 1072 + uri: { 1073 + type: 'string', 1074 + description: 'The URI of the song.', 1075 + format: 'at-uri', 1076 + }, 1077 + title: { 1078 + type: 'string', 1079 + description: 'The title of the song.', 1080 + }, 1081 + playCount: { 1082 + type: 'integer', 1083 + description: 'The number of times the song has been played.', 1084 + minimum: 0, 1085 + }, 1086 + }, 1087 + }, 1069 1088 listenerViewBasic: { 1070 1089 type: 'object', 1071 1090 properties: { ··· 1092 1111 }, 1093 1112 mostListenedSong: { 1094 1113 type: 'ref', 1095 - ref: 'lex:app.rocksky.song.defs#songViewBasic', 1114 + ref: 'lex:app.rocksky.artist.defs#songViewBasic', 1115 + }, 1116 + totalPlays: { 1117 + type: 'integer', 1118 + description: 'The total number of plays by the listener.', 1119 + minimum: 0, 1120 + }, 1121 + rank: { 1122 + type: 'integer', 1123 + description: 1124 + 'The rank of the listener among all listeners of the artist.', 1125 + minimum: 1, 1096 1126 }, 1097 1127 }, 1098 1128 },
+27 -2
apps/api/src/lexicon/types/app/rocksky/artist/defs.ts
··· 5 5 import { lexicons } from '../../../../lexicons' 6 6 import { isObj, hasProp } from '../../../../util' 7 7 import { CID } from 'multiformats/cid' 8 - import type * as AppRockskySongDefs from '../song/defs' 9 8 10 9 export interface ArtistViewBasic { 11 10 /** The unique identifier of the artist. */ ··· 67 66 return lexicons.validate('app.rocksky.artist.defs#artistViewDetailed', v) 68 67 } 69 68 69 + export interface SongViewBasic { 70 + /** The URI of the song. */ 71 + uri?: string 72 + /** The title of the song. */ 73 + title?: string 74 + /** The number of times the song has been played. */ 75 + playCount?: number 76 + [k: string]: unknown 77 + } 78 + 79 + export function isSongViewBasic(v: unknown): v is SongViewBasic { 80 + return ( 81 + isObj(v) && 82 + hasProp(v, '$type') && 83 + v.$type === 'app.rocksky.artist.defs#songViewBasic' 84 + ) 85 + } 86 + 87 + export function validateSongViewBasic(v: unknown): ValidationResult { 88 + return lexicons.validate('app.rocksky.artist.defs#songViewBasic', v) 89 + } 90 + 70 91 export interface ListenerViewBasic { 71 92 /** The unique identifier of the actor. */ 72 93 id?: string ··· 78 99 displayName?: string 79 100 /** The URL of the listener's avatar image. */ 80 101 avatar?: string 81 - mostListenedSong?: AppRockskySongDefs.SongViewBasic 102 + mostListenedSong?: SongViewBasic 103 + /** The total number of plays by the listener. */ 104 + totalPlays?: number 105 + /** The rank of the listener among all listeners of the artist. */ 106 + rank?: number 82 107 [k: string]: unknown 83 108 } 84 109
+83
apps/api/src/xrpc/app/rocksky/artist/getArtistListeners.ts
··· 1 + import type { Context } from "context"; 2 + import { Effect, pipe } from "effect"; 3 + import type { Server } from "lexicon"; 4 + import type { ListenerViewBasic } from "lexicon/types/app/rocksky/artist/defs"; 5 + import type { QueryParams } from "lexicon/types/app/rocksky/artist/getArtistListeners"; 6 + 7 + export default function (server: Server, ctx: Context) { 8 + const getArtistListeners = (params) => 9 + pipe( 10 + { params, ctx }, 11 + retrieve, 12 + Effect.flatMap(presentation), 13 + Effect.retry({ times: 3 }), 14 + Effect.timeout("10 seconds"), 15 + Effect.catchAll((err) => { 16 + console.error(err); 17 + return Effect.succeed({ listeners: [] }); 18 + }) 19 + ); 20 + server.app.rocksky.artist.getArtistListeners({ 21 + handler: async ({ params }) => { 22 + const result = await Effect.runPromise(getArtistListeners(params)); 23 + return { 24 + encoding: "application/json", 25 + body: result, 26 + }; 27 + }, 28 + }); 29 + } 30 + 31 + const retrieve = ({ 32 + params, 33 + ctx, 34 + }: { 35 + params: QueryParams; 36 + ctx: Context; 37 + }): Effect.Effect<{ data: ArtistListener[] }, Error> => { 38 + return Effect.tryPromise({ 39 + try: () => 40 + ctx.analytics.post("library.getArtistListeners", { 41 + artist_id: params.uri, 42 + }), 43 + catch: (error) => 44 + new Error(`Failed to retrieve artist's listeners: ${error}`), 45 + }); 46 + }; 47 + 48 + const presentation = ({ 49 + data, 50 + }: { 51 + data: ArtistListener[]; 52 + }): Effect.Effect<{ listeners: ListenerViewBasic[] }, never> => { 53 + return Effect.sync(() => ({ 54 + listeners: data.map((item) => ({ 55 + id: item.user_id, 56 + did: item.did, 57 + handle: item.handle, 58 + displayName: item.display_name, 59 + avatar: item.avatar, 60 + mostListenedSong: { 61 + title: item.most_played_track, 62 + uri: item.most_played_track_uri, 63 + playCount: item.track_play_count, 64 + }, 65 + totalPlays: item.total_artist_plays, 66 + rank: item.listener_rank, 67 + })), 68 + })); 69 + }; 70 + 71 + type ArtistListener = { 72 + artist: string; 73 + avatar: string; 74 + did: string; 75 + display_name: string; 76 + handle: string; 77 + listener_rank: number; 78 + most_played_track: string; 79 + most_played_track_uri: string; 80 + total_artist_plays: number; 81 + track_play_count: number; 82 + user_id: string; 83 + };