A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
97
fork

Configure Feed

Select the types of activity you want to include in your feed.

Include artists in song and scrobble schemas

Add artists array ref to lexicon defs and types. Populate artists by
querying artist records (split on track.artist) in getSong and
getScrobble and include ArtistViewBasic[] in the API presentation.
Also switch to type-only imports and clean up some variable typings.

+96 -10
+7
apps/api/lexicons/scrobble/defs.json
··· 130 130 "scrobbles": { 131 131 "type": "integer", 132 132 "description": "The number of scrobbles for this song" 133 + }, 134 + "artists": { 135 + "type": "array", 136 + "items": { 137 + "type": "ref", 138 + "ref": "app.rocksky.artist.defs#artistViewBasic" 139 + } 133 140 } 134 141 } 135 142 }
+7
apps/api/lexicons/song/defs.json
··· 163 163 "type": "string", 164 164 "description": "The timestamp when the song was created.", 165 165 "format": "datetime" 166 + }, 167 + "artists": { 168 + "type": "array", 169 + "items": { 170 + "type": "ref", 171 + "ref": "app.rocksky.artist.defs#artistViewBasic" 172 + } 166 173 } 167 174 } 168 175 }
+7
apps/api/pkl/defs/scrobble/defs.pkl
··· 153 153 type = "integer" 154 154 description = "The number of scrobbles for this song" 155 155 } 156 + 157 + ["artists"] = new Array { 158 + type = "array" 159 + items = new Ref { 160 + ref = "app.rocksky.artist.defs#artistViewBasic" 161 + } 162 + } 156 163 } 157 164 } 158 165 }
+6
apps/api/pkl/defs/song/defs.pkl
··· 165 165 format = "datetime" 166 166 description = "The timestamp when the song was created." 167 167 } 168 + ["artists"] = new Array { 169 + type = "array" 170 + items = new Ref { 171 + ref = "app.rocksky.artist.defs#artistViewBasic" 172 + } 173 + } 168 174 } 169 175 } 170 176 }
+14
apps/api/src/lexicon/lexicons.ts
··· 4716 4716 type: "integer", 4717 4717 description: "The number of scrobbles for this song", 4718 4718 }, 4719 + artists: { 4720 + type: "array", 4721 + items: { 4722 + type: "ref", 4723 + ref: "lex:app.rocksky.artist.defs#artistViewBasic", 4724 + }, 4725 + }, 4719 4726 }, 4720 4727 }, 4721 4728 }, ··· 5652 5659 type: "string", 5653 5660 description: "The timestamp when the song was created.", 5654 5661 format: "datetime", 5662 + }, 5663 + artists: { 5664 + type: "array", 5665 + items: { 5666 + type: "ref", 5667 + ref: "lex:app.rocksky.artist.defs#artistViewBasic", 5668 + }, 5655 5669 }, 5656 5670 }, 5657 5671 },
+2
apps/api/src/lexicon/types/app/rocksky/scrobble/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 AppRockskyArtistDefs from "../artist/defs"; 8 9 9 10 export interface ScrobbleViewBasic { 10 11 /** The unique identifier of the scrobble. */ ··· 77 78 listeners?: number; 78 79 /** The number of scrobbles for this song */ 79 80 scrobbles?: number; 81 + artists?: AppRockskyArtistDefs.ArtistViewBasic[]; 80 82 [k: string]: unknown; 81 83 } 82 84
+2
apps/api/src/lexicon/types/app/rocksky/song/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 AppRockskyArtistDefs from "../artist/defs"; 8 9 9 10 export interface SongViewBasic { 10 11 /** The unique identifier of the song. */ ··· 89 90 tags?: string[]; 90 91 /** The timestamp when the song was created. */ 91 92 createdAt?: string; 93 + artists?: AppRockskyArtistDefs.ArtistViewBasic[]; 92 94 [k: string]: unknown; 93 95 } 94 96
+6 -6
apps/api/src/opengraph/app.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { ctx } from "context"; 3 3 import users from "schema/users"; 4 - import albums, { SelectAlbum } from "schema/albums"; 5 - import artists, { SelectArtist } from "schema/artists"; 6 - import tracks, { SelectTrack } from "schema/tracks"; 4 + import albums, { type SelectAlbum } from "schema/albums"; 5 + import artists, { type SelectArtist } from "schema/artists"; 6 + import tracks, { type SelectTrack } from "schema/tracks"; 7 7 import scrobbles from "schema/scrobbles"; 8 8 import { eq, or } from "drizzle-orm"; 9 9 ··· 99 99 return c.text("OG Service: record not found.", 404); 100 100 } 101 101 102 - let title = undefined; 103 - let description = undefined; 104 - let image = undefined; 102 + let title; 103 + let description; 104 + let image; 105 105 const url = `https://rocksky.app/${did}/${kind}/${rkey}`; 106 106 107 107 if (kind === "album") {
+25 -3
apps/api/src/xrpc/app/rocksky/scrobble/getScrobble.ts
··· 14 14 import type { SelectArtist } from "schema/artists"; 15 15 16 16 export default function (server: Server, ctx: Context) { 17 - const getScrobble = (params) => 17 + const getScrobble = (params: QueryParams) => 18 18 pipe( 19 19 { params, ctx }, 20 20 retrieve, ··· 43 43 }: { 44 44 params: QueryParams; 45 45 ctx: Context; 46 - }): Effect.Effect<[Scrobble | undefined, number, number], Error> => { 46 + }): Effect.Effect< 47 + [Scrobble | undefined, SelectArtist[], number, number], 48 + Error 49 + > => { 47 50 return Effect.tryPromise({ 48 51 try: async () => { 49 52 const scrobble = await ctx.db ··· 59 62 .where(eq(tables.scrobbles.uri, params.uri)) 60 63 .execute() 61 64 .then((rows) => rows[0]); 65 + 66 + const artists = await Promise.all( 67 + scrobble.tracks.artist.split(",").map((name) => 68 + ctx.db 69 + .select() 70 + .from(tables.artists) 71 + .where(eq(tables.artists.name, name.trim())) 72 + .execute() 73 + .then(([row]) => row), 74 + ), 75 + ); 76 + 62 77 return Promise.all([ 63 78 Promise.resolve(scrobble), 79 + Promise.resolve(artists), 64 80 // count the number of listeners 65 81 ctx.db 66 82 .select({ ··· 94 110 95 111 const presentation = ([ 96 112 { scrobbles, tracks, users, albums, artists }, 113 + trackArtists, 97 114 listeners, 98 115 scrobblesCount, 99 - ]: [Scrobble | undefined, number, number]): Effect.Effect< 116 + ]: [Scrobble | undefined, SelectArtist[], number, number]): Effect.Effect< 100 117 ScrobbleViewDetailed, 101 118 never 102 119 > => { 103 120 return Effect.sync(() => ({ 104 121 ...R.omit(["albumArt", "id", "albumUri"], tracks), 122 + artists: trackArtists.map((item) => ({ 123 + ...item, 124 + createdAt: item.createdAt.toISOString(), 125 + updatedAt: item.updatedAt.toISOString(), 126 + })), 105 127 albumUri: albums.uri, 106 128 cover: tracks.albumArt, 107 129 date: scrobbles.timestamp.toISOString(),
+20 -1
apps/api/src/xrpc/app/rocksky/song/getSong.ts
··· 55 55 ) 56 56 .execute() 57 57 .then(([row]) => row); 58 + 59 + const artists = await Promise.all( 60 + track.artist.split(",").map((name) => 61 + ctx.db 62 + .select() 63 + .from(tables.artists) 64 + .where(eq(tables.artists.name, name.trim())) 65 + .execute() 66 + .then(([row]) => row), 67 + ), 68 + ); 69 + 58 70 return Promise.all([ 59 71 Promise.resolve(track), 60 72 Promise.resolve(artist), 73 + Promise.resolve(artists), 61 74 ctx.db 62 75 .select({ 63 76 count: count(), ··· 78 91 }); 79 92 }; 80 93 81 - const presentation = ([track, artist, uniqueListeners, playCount]: [ 94 + const presentation = ([track, artist, artists, uniqueListeners, playCount]: [ 82 95 SelectTrack, 83 96 SelectArtist, 97 + SelectArtist[], 84 98 number, 85 99 number, 86 100 ]): Effect.Effect<SongViewDetailed, never> => { 87 101 return Effect.sync(() => ({ 88 102 ...track, 89 103 tags: artist?.genres || [], 104 + artists: artists.map((item) => ({ 105 + ...item, 106 + createdAt: item.createdAt.toISOString(), 107 + updatedAt: item.updatedAt.toISOString(), 108 + })), 90 109 playCount, 91 110 uniqueListeners, 92 111 createdAt: track.createdAt.toISOString(),