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

Add names filter to artist getArtists

Introduce an optional "names" parameter and propagate it through
lexicon, pkl, TypeScript types, XRPC handler, and analytics types. Also
apply assorted formatting, trailing-comma, and import fixes.

+434 -361
+4
apps/api/lexicons/artist/getArtists.json
··· 17 "type": "integer", 18 "description": "The offset for pagination", 19 "minimum": 0 20 } 21 } 22 },
··· 17 "type": "integer", 18 "description": "The offset for pagination", 19 "minimum": 0 20 + }, 21 + "names": { 22 + "type": "string", 23 + "description": "The names of the artists to return" 24 } 25 } 26 },
+4
apps/api/pkl/defs/artist/getArtists.pkl
··· 18 description = "The offset for pagination" 19 minimum = 0 20 } 21 } 22 } 23 output {
··· 18 description = "The offset for pagination" 19 minimum = 0 20 } 21 + ["names"] = new StringType { 22 + type = "string" 23 + description = "The names of the artists to return" 24 + } 25 } 26 } 27 output {
+1 -1
apps/api/src/auth/client.ts
··· 29 client_id: publicUrl 30 ? `${url}/oauth-client-metadata.json` 31 : `http://localhost?redirect_uri=${enc( 32 - `${url}/oauth/callback` 33 )}&scope=${enc("atproto transition:generic")}`, 34 client_uri: url, 35 redirect_uris: [`${url}/oauth/callback`],
··· 29 client_id: publicUrl 30 ? `${url}/oauth-client-metadata.json` 31 : `http://localhost?redirect_uri=${enc( 32 + `${url}/oauth/callback`, 33 )}&scope=${enc("atproto transition:generic")}`, 34 client_uri: url, 35 redirect_uris: [`${url}/oauth/callback`],
+1 -1
apps/api/src/auth/storage.ts
··· 47 .insertInto("auth_session") 48 .values({ key, session, expiresAt: val.tokenSet.expires_at }) 49 .onConflict((oc) => 50 - oc.doUpdateSet({ session, expiresAt: val.tokenSet.expires_at }) 51 ) 52 .execute(); 53 }
··· 47 .insertInto("auth_session") 48 .values({ key, session, expiresAt: val.tokenSet.expires_at }) 49 .onConflict((oc) => 50 + oc.doUpdateSet({ session, expiresAt: val.tokenSet.expires_at }), 51 ) 52 .execute(); 53 }
+12 -9
apps/api/src/bsky/app.ts
··· 81 ? Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 1000 82 : Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, 83 }, 84 - env.JWT_SECRET 85 ); 86 ctx.kv.set(did, token); 87 } catch (err) { ··· 93 .select() 94 .from(spotifyAccounts) 95 .where( 96 - and(eq(spotifyAccounts.userId, did), eq(spotifyAccounts.isBetaUser, true)) 97 ) 98 .limit(1) 99 .execute(); ··· 179 180 ctx.nc.publish( 181 "rocksky.user", 182 - Buffer.from(JSON.stringify(deepSnakeCaseKeys(user))) 183 ); 184 185 await ctx.kv.set("lastUser", lastUser[0].id); ··· 192 .where( 193 and( 194 eq(spotifyAccounts.userId, did), 195 - eq(spotifyAccounts.isBetaUser, true) 196 - ) 197 ) 198 .limit(1) 199 .execute(), ··· 209 .where( 210 and( 211 eq(googleDriveAccounts.userId, did), 212 - eq(googleDriveAccounts.isBetaUser, true) 213 - ) 214 ) 215 .limit(1) 216 .execute(), ··· 220 .where( 221 and( 222 eq(dropboxAccounts.userId, did), 223 - eq(dropboxAccounts.isBetaUser, true) 224 - ) 225 ) 226 .limit(1) 227 .execute(),
··· 81 ? Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 1000 82 : Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, 83 }, 84 + env.JWT_SECRET, 85 ); 86 ctx.kv.set(did, token); 87 } catch (err) { ··· 93 .select() 94 .from(spotifyAccounts) 95 .where( 96 + and( 97 + eq(spotifyAccounts.userId, did), 98 + eq(spotifyAccounts.isBetaUser, true), 99 + ), 100 ) 101 .limit(1) 102 .execute(); ··· 182 183 ctx.nc.publish( 184 "rocksky.user", 185 + Buffer.from(JSON.stringify(deepSnakeCaseKeys(user))), 186 ); 187 188 await ctx.kv.set("lastUser", lastUser[0].id); ··· 195 .where( 196 and( 197 eq(spotifyAccounts.userId, did), 198 + eq(spotifyAccounts.isBetaUser, true), 199 + ), 200 ) 201 .limit(1) 202 .execute(), ··· 212 .where( 213 and( 214 eq(googleDriveAccounts.userId, did), 215 + eq(googleDriveAccounts.isBetaUser, true), 216 + ), 217 ) 218 .limit(1) 219 .execute(), ··· 223 .where( 224 and( 225 eq(dropboxAccounts.userId, did), 226 + eq(dropboxAccounts.isBetaUser, true), 227 + ), 228 ) 229 .limit(1) 230 .execute(),
+3 -3
apps/api/src/db.ts
··· 131 132 export const refreshSessionsAboutToExpire = async ( 133 db: Database, 134 - ctx: Context 135 ) => { 136 const now = new Date().toISOString(); 137 ··· 147 console.log( 148 "Session about to expire:", 149 chalk.cyan(session.key), 150 - session.expiresAt 151 ); 152 const agent = await createAgent(ctx.oauthClient, session.key); 153 // Trigger a token refresh by fetching preferences ··· 156 } 157 158 console.log( 159 - `Found ${chalk.yellowBright(sessions.length)} sessions to refresh` 160 ); 161 }; 162
··· 131 132 export const refreshSessionsAboutToExpire = async ( 133 db: Database, 134 + ctx: Context, 135 ) => { 136 const now = new Date().toISOString(); 137 ··· 147 console.log( 148 "Session about to expire:", 149 chalk.cyan(session.key), 150 + session.expiresAt, 151 ); 152 const agent = await createAgent(ctx.oauthClient, session.key); 153 // Trigger a token refresh by fetching preferences ··· 156 } 157 158 console.log( 159 + `Found ${chalk.yellowBright(sessions.length)} sessions to refresh`, 160 ); 161 }; 162
+3 -3
apps/api/src/index.ts
··· 50 rateLimiter({ 51 limit: 1000, 52 window: 30, // 👈 30 seconds 53 - }) 54 ); 55 56 app.use("*", async (c, next) => { ··· 165 ctx.redis.get(`nowplaying:${user.did}:status`), 166 ]); 167 return c.json( 168 - nowPlaying ? { ...JSON.parse(nowPlaying), is_playing: status === "1" } : {} 169 ); 170 }); 171 ··· 317 listeners: 1, 318 sha256: item.track.sha256, 319 id: item.scrobble.id, 320 - })) 321 ); 322 }); 323
··· 50 rateLimiter({ 51 limit: 1000, 52 window: 30, // 👈 30 seconds 53 + }), 54 ); 55 56 app.use("*", async (c, next) => { ··· 165 ctx.redis.get(`nowplaying:${user.did}:status`), 166 ]); 167 return c.json( 168 + nowPlaying ? { ...JSON.parse(nowPlaying), is_playing: status === "1" } : {}, 169 ); 170 }); 171 ··· 317 listeners: 1, 318 sha256: item.track.sha256, 319 id: item.scrobble.id, 320 + })), 321 ); 322 }); 323
+39 -39
apps/api/src/lexicon/index.ts
··· 22 import type * as AppRockskyActorGetActorSongs from "./types/app/rocksky/actor/getActorSongs"; 23 import type * as AppRockskyActorGetProfile from "./types/app/rocksky/actor/getProfile"; 24 import type * as AppRockskyAlbumGetAlbum from "./types/app/rocksky/album/getAlbum"; 25 - import type * as AppRockskyAlbumGetAlbums from "./types/app/rocksky/album/getAlbums"; 26 import type * as AppRockskyAlbumGetAlbumTracks from "./types/app/rocksky/album/getAlbumTracks"; 27 import type * as AppRockskyApikeyCreateApikey from "./types/app/rocksky/apikey/createApikey"; 28 import type * as AppRockskyApikeyGetApikeys from "./types/app/rocksky/apikey/getApikeys"; 29 import type * as AppRockskyApikeyRemoveApikey from "./types/app/rocksky/apikey/removeApikey"; 30 import type * as AppRockskyApikeyUpdateApikey from "./types/app/rocksky/apikey/updateApikey"; 31 - import type * as AppRockskyArtistGetArtistAlbums from "./types/app/rocksky/artist/getArtistAlbums"; 32 import type * as AppRockskyArtistGetArtist from "./types/app/rocksky/artist/getArtist"; 33 import type * as AppRockskyArtistGetArtistListeners from "./types/app/rocksky/artist/getArtistListeners"; 34 import type * as AppRockskyArtistGetArtists from "./types/app/rocksky/artist/getArtists"; 35 - import type * as AppRockskyArtistGetArtistTracks from "./types/app/rocksky/artist/getArtistTracks"; 36 import type * as AppRockskyChartsGetScrobblesChart from "./types/app/rocksky/charts/getScrobblesChart"; 37 import type * as AppRockskyDropboxDownloadFile from "./types/app/rocksky/dropbox/downloadFile"; 38 import type * as AppRockskyDropboxGetFiles from "./types/app/rocksky/dropbox/getFiles"; ··· 53 import type * as AppRockskyPlayerGetPlaybackQueue from "./types/app/rocksky/player/getPlaybackQueue"; 54 import type * as AppRockskyPlayerNext from "./types/app/rocksky/player/next"; 55 import type * as AppRockskyPlayerPause from "./types/app/rocksky/player/pause"; 56 import type * as AppRockskyPlayerPlayDirectory from "./types/app/rocksky/player/playDirectory"; 57 import type * as AppRockskyPlayerPlayFile from "./types/app/rocksky/player/playFile"; 58 - import type * as AppRockskyPlayerPlay from "./types/app/rocksky/player/play"; 59 import type * as AppRockskyPlayerPrevious from "./types/app/rocksky/player/previous"; 60 import type * as AppRockskyPlayerSeek from "./types/app/rocksky/player/seek"; 61 import type * as AppRockskyPlaylistCreatePlaylist from "./types/app/rocksky/playlist/createPlaylist"; ··· 365 return this._server.xrpc.method(nsid, cfg); 366 } 367 368 - getAlbums<AV extends AuthVerifier>( 369 cfg: ConfigOf< 370 AV, 371 - AppRockskyAlbumGetAlbums.Handler<ExtractAuth<AV>>, 372 - AppRockskyAlbumGetAlbums.HandlerReqCtx<ExtractAuth<AV>> 373 >, 374 ) { 375 - const nsid = "app.rocksky.album.getAlbums"; // @ts-ignore 376 return this._server.xrpc.method(nsid, cfg); 377 } 378 379 - getAlbumTracks<AV extends AuthVerifier>( 380 cfg: ConfigOf< 381 AV, 382 - AppRockskyAlbumGetAlbumTracks.Handler<ExtractAuth<AV>>, 383 - AppRockskyAlbumGetAlbumTracks.HandlerReqCtx<ExtractAuth<AV>> 384 >, 385 ) { 386 - const nsid = "app.rocksky.album.getAlbumTracks"; // @ts-ignore 387 return this._server.xrpc.method(nsid, cfg); 388 } 389 } ··· 447 this._server = server; 448 } 449 450 - getArtistAlbums<AV extends AuthVerifier>( 451 cfg: ConfigOf< 452 AV, 453 - AppRockskyArtistGetArtistAlbums.Handler<ExtractAuth<AV>>, 454 - AppRockskyArtistGetArtistAlbums.HandlerReqCtx<ExtractAuth<AV>> 455 >, 456 ) { 457 - const nsid = "app.rocksky.artist.getArtistAlbums"; // @ts-ignore 458 return this._server.xrpc.method(nsid, cfg); 459 } 460 461 - getArtist<AV extends AuthVerifier>( 462 cfg: ConfigOf< 463 AV, 464 - AppRockskyArtistGetArtist.Handler<ExtractAuth<AV>>, 465 - AppRockskyArtistGetArtist.HandlerReqCtx<ExtractAuth<AV>> 466 >, 467 ) { 468 - const nsid = "app.rocksky.artist.getArtist"; // @ts-ignore 469 return this._server.xrpc.method(nsid, cfg); 470 } 471 ··· 480 return this._server.xrpc.method(nsid, cfg); 481 } 482 483 - getArtists<AV extends AuthVerifier>( 484 cfg: ConfigOf< 485 AV, 486 - AppRockskyArtistGetArtists.Handler<ExtractAuth<AV>>, 487 - AppRockskyArtistGetArtists.HandlerReqCtx<ExtractAuth<AV>> 488 >, 489 ) { 490 - const nsid = "app.rocksky.artist.getArtists"; // @ts-ignore 491 return this._server.xrpc.method(nsid, cfg); 492 } 493 494 - getArtistTracks<AV extends AuthVerifier>( 495 cfg: ConfigOf< 496 AV, 497 - AppRockskyArtistGetArtistTracks.Handler<ExtractAuth<AV>>, 498 - AppRockskyArtistGetArtistTracks.HandlerReqCtx<ExtractAuth<AV>> 499 >, 500 ) { 501 - const nsid = "app.rocksky.artist.getArtistTracks"; // @ts-ignore 502 return this._server.xrpc.method(nsid, cfg); 503 } 504 } ··· 770 return this._server.xrpc.method(nsid, cfg); 771 } 772 773 playDirectory<AV extends AuthVerifier>( 774 cfg: ConfigOf< 775 AV, ··· 789 >, 790 ) { 791 const nsid = "app.rocksky.player.playFile"; // @ts-ignore 792 - return this._server.xrpc.method(nsid, cfg); 793 - } 794 - 795 - play<AV extends AuthVerifier>( 796 - cfg: ConfigOf< 797 - AV, 798 - AppRockskyPlayerPlay.Handler<ExtractAuth<AV>>, 799 - AppRockskyPlayerPlay.HandlerReqCtx<ExtractAuth<AV>> 800 - >, 801 - ) { 802 - const nsid = "app.rocksky.player.play"; // @ts-ignore 803 return this._server.xrpc.method(nsid, cfg); 804 } 805
··· 22 import type * as AppRockskyActorGetActorSongs from "./types/app/rocksky/actor/getActorSongs"; 23 import type * as AppRockskyActorGetProfile from "./types/app/rocksky/actor/getProfile"; 24 import type * as AppRockskyAlbumGetAlbum from "./types/app/rocksky/album/getAlbum"; 25 import type * as AppRockskyAlbumGetAlbumTracks from "./types/app/rocksky/album/getAlbumTracks"; 26 + import type * as AppRockskyAlbumGetAlbums from "./types/app/rocksky/album/getAlbums"; 27 import type * as AppRockskyApikeyCreateApikey from "./types/app/rocksky/apikey/createApikey"; 28 import type * as AppRockskyApikeyGetApikeys from "./types/app/rocksky/apikey/getApikeys"; 29 import type * as AppRockskyApikeyRemoveApikey from "./types/app/rocksky/apikey/removeApikey"; 30 import type * as AppRockskyApikeyUpdateApikey from "./types/app/rocksky/apikey/updateApikey"; 31 import type * as AppRockskyArtistGetArtist from "./types/app/rocksky/artist/getArtist"; 32 + import type * as AppRockskyArtistGetArtistAlbums from "./types/app/rocksky/artist/getArtistAlbums"; 33 import type * as AppRockskyArtistGetArtistListeners from "./types/app/rocksky/artist/getArtistListeners"; 34 + import type * as AppRockskyArtistGetArtistTracks from "./types/app/rocksky/artist/getArtistTracks"; 35 import type * as AppRockskyArtistGetArtists from "./types/app/rocksky/artist/getArtists"; 36 import type * as AppRockskyChartsGetScrobblesChart from "./types/app/rocksky/charts/getScrobblesChart"; 37 import type * as AppRockskyDropboxDownloadFile from "./types/app/rocksky/dropbox/downloadFile"; 38 import type * as AppRockskyDropboxGetFiles from "./types/app/rocksky/dropbox/getFiles"; ··· 53 import type * as AppRockskyPlayerGetPlaybackQueue from "./types/app/rocksky/player/getPlaybackQueue"; 54 import type * as AppRockskyPlayerNext from "./types/app/rocksky/player/next"; 55 import type * as AppRockskyPlayerPause from "./types/app/rocksky/player/pause"; 56 + import type * as AppRockskyPlayerPlay from "./types/app/rocksky/player/play"; 57 import type * as AppRockskyPlayerPlayDirectory from "./types/app/rocksky/player/playDirectory"; 58 import type * as AppRockskyPlayerPlayFile from "./types/app/rocksky/player/playFile"; 59 import type * as AppRockskyPlayerPrevious from "./types/app/rocksky/player/previous"; 60 import type * as AppRockskyPlayerSeek from "./types/app/rocksky/player/seek"; 61 import type * as AppRockskyPlaylistCreatePlaylist from "./types/app/rocksky/playlist/createPlaylist"; ··· 365 return this._server.xrpc.method(nsid, cfg); 366 } 367 368 + getAlbumTracks<AV extends AuthVerifier>( 369 cfg: ConfigOf< 370 AV, 371 + AppRockskyAlbumGetAlbumTracks.Handler<ExtractAuth<AV>>, 372 + AppRockskyAlbumGetAlbumTracks.HandlerReqCtx<ExtractAuth<AV>> 373 >, 374 ) { 375 + const nsid = "app.rocksky.album.getAlbumTracks"; // @ts-ignore 376 return this._server.xrpc.method(nsid, cfg); 377 } 378 379 + getAlbums<AV extends AuthVerifier>( 380 cfg: ConfigOf< 381 AV, 382 + AppRockskyAlbumGetAlbums.Handler<ExtractAuth<AV>>, 383 + AppRockskyAlbumGetAlbums.HandlerReqCtx<ExtractAuth<AV>> 384 >, 385 ) { 386 + const nsid = "app.rocksky.album.getAlbums"; // @ts-ignore 387 return this._server.xrpc.method(nsid, cfg); 388 } 389 } ··· 447 this._server = server; 448 } 449 450 + getArtist<AV extends AuthVerifier>( 451 cfg: ConfigOf< 452 AV, 453 + AppRockskyArtistGetArtist.Handler<ExtractAuth<AV>>, 454 + AppRockskyArtistGetArtist.HandlerReqCtx<ExtractAuth<AV>> 455 >, 456 ) { 457 + const nsid = "app.rocksky.artist.getArtist"; // @ts-ignore 458 return this._server.xrpc.method(nsid, cfg); 459 } 460 461 + getArtistAlbums<AV extends AuthVerifier>( 462 cfg: ConfigOf< 463 AV, 464 + AppRockskyArtistGetArtistAlbums.Handler<ExtractAuth<AV>>, 465 + AppRockskyArtistGetArtistAlbums.HandlerReqCtx<ExtractAuth<AV>> 466 >, 467 ) { 468 + const nsid = "app.rocksky.artist.getArtistAlbums"; // @ts-ignore 469 return this._server.xrpc.method(nsid, cfg); 470 } 471 ··· 480 return this._server.xrpc.method(nsid, cfg); 481 } 482 483 + getArtistTracks<AV extends AuthVerifier>( 484 cfg: ConfigOf< 485 AV, 486 + AppRockskyArtistGetArtistTracks.Handler<ExtractAuth<AV>>, 487 + AppRockskyArtistGetArtistTracks.HandlerReqCtx<ExtractAuth<AV>> 488 >, 489 ) { 490 + const nsid = "app.rocksky.artist.getArtistTracks"; // @ts-ignore 491 return this._server.xrpc.method(nsid, cfg); 492 } 493 494 + getArtists<AV extends AuthVerifier>( 495 cfg: ConfigOf< 496 AV, 497 + AppRockskyArtistGetArtists.Handler<ExtractAuth<AV>>, 498 + AppRockskyArtistGetArtists.HandlerReqCtx<ExtractAuth<AV>> 499 >, 500 ) { 501 + const nsid = "app.rocksky.artist.getArtists"; // @ts-ignore 502 return this._server.xrpc.method(nsid, cfg); 503 } 504 } ··· 770 return this._server.xrpc.method(nsid, cfg); 771 } 772 773 + play<AV extends AuthVerifier>( 774 + cfg: ConfigOf< 775 + AV, 776 + AppRockskyPlayerPlay.Handler<ExtractAuth<AV>>, 777 + AppRockskyPlayerPlay.HandlerReqCtx<ExtractAuth<AV>> 778 + >, 779 + ) { 780 + const nsid = "app.rocksky.player.play"; // @ts-ignore 781 + return this._server.xrpc.method(nsid, cfg); 782 + } 783 + 784 playDirectory<AV extends AuthVerifier>( 785 cfg: ConfigOf< 786 AV, ··· 800 >, 801 ) { 802 const nsid = "app.rocksky.player.playFile"; // @ts-ignore 803 return this._server.xrpc.method(nsid, cfg); 804 } 805
+95 -91
apps/api/src/lexicon/lexicons.ts
··· 1267 }, 1268 }, 1269 }, 1270 - AppRockskyAlbumGetAlbums: { 1271 lexicon: 1, 1272 - id: "app.rocksky.album.getAlbums", 1273 defs: { 1274 main: { 1275 type: "query", 1276 - description: "Get albums", 1277 parameters: { 1278 type: "params", 1279 properties: { 1280 - limit: { 1281 - type: "integer", 1282 - description: "The maximum number of albums to return", 1283 - minimum: 1, 1284 - }, 1285 - offset: { 1286 - type: "integer", 1287 - description: "The offset for pagination", 1288 - minimum: 0, 1289 }, 1290 }, 1291 }, ··· 1294 schema: { 1295 type: "object", 1296 properties: { 1297 - albums: { 1298 type: "array", 1299 items: { 1300 type: "ref", 1301 - ref: "lex:app.rocksky.album.defs#albumViewBasic", 1302 }, 1303 }, 1304 }, ··· 1307 }, 1308 }, 1309 }, 1310 - AppRockskyAlbumGetAlbumTracks: { 1311 lexicon: 1, 1312 - id: "app.rocksky.album.getAlbumTracks", 1313 defs: { 1314 main: { 1315 type: "query", 1316 - description: "Get tracks for an album", 1317 parameters: { 1318 type: "params", 1319 - required: ["uri"], 1320 properties: { 1321 - uri: { 1322 - type: "string", 1323 - description: "The URI of the album to retrieve tracks from", 1324 - format: "at-uri", 1325 }, 1326 }, 1327 }, ··· 1330 schema: { 1331 type: "object", 1332 properties: { 1333 - tracks: { 1334 type: "array", 1335 items: { 1336 type: "ref", 1337 - ref: "lex:app.rocksky.song.defs#songViewBasic", 1338 }, 1339 }, 1340 }, ··· 1737 }, 1738 }, 1739 }, 1740 AppRockskyArtistGetArtistAlbums: { 1741 lexicon: 1, 1742 id: "app.rocksky.artist.getArtistAlbums", ··· 1768 }, 1769 }, 1770 }, 1771 - }, 1772 - }, 1773 - }, 1774 - }, 1775 - }, 1776 - AppRockskyArtistGetArtist: { 1777 - lexicon: 1, 1778 - id: "app.rocksky.artist.getArtist", 1779 - defs: { 1780 - main: { 1781 - type: "query", 1782 - description: "Get artist details", 1783 - parameters: { 1784 - type: "params", 1785 - required: ["uri"], 1786 - properties: { 1787 - uri: { 1788 - type: "string", 1789 - description: "The URI of the artist to retrieve details from", 1790 - format: "at-uri", 1791 - }, 1792 - }, 1793 - }, 1794 - output: { 1795 - encoding: "application/json", 1796 - schema: { 1797 - type: "ref", 1798 - ref: "lex:app.rocksky.artist.defs#artistViewDetailed", 1799 }, 1800 }, 1801 }, ··· 1845 }, 1846 }, 1847 }, 1848 - AppRockskyArtistGetArtists: { 1849 lexicon: 1, 1850 - id: "app.rocksky.artist.getArtists", 1851 defs: { 1852 main: { 1853 type: "query", 1854 - description: "Get artists", 1855 parameters: { 1856 type: "params", 1857 properties: { 1858 limit: { 1859 type: "integer", 1860 - description: "The maximum number of artists to return", 1861 minimum: 1, 1862 }, 1863 offset: { ··· 1872 schema: { 1873 type: "object", 1874 properties: { 1875 - artists: { 1876 type: "array", 1877 items: { 1878 type: "ref", 1879 - ref: "lex:app.rocksky.artist.defs#artistViewBasic", 1880 }, 1881 }, 1882 }, ··· 1885 }, 1886 }, 1887 }, 1888 - AppRockskyArtistGetArtistTracks: { 1889 lexicon: 1, 1890 - id: "app.rocksky.artist.getArtistTracks", 1891 defs: { 1892 main: { 1893 type: "query", 1894 - description: "Get artist's tracks", 1895 parameters: { 1896 type: "params", 1897 properties: { 1898 - uri: { 1899 - type: "string", 1900 - description: "The URI of the artist to retrieve albums from", 1901 - format: "at-uri", 1902 - }, 1903 limit: { 1904 type: "integer", 1905 - description: "The maximum number of tracks to return", 1906 minimum: 1, 1907 }, 1908 offset: { 1909 type: "integer", 1910 description: "The offset for pagination", 1911 minimum: 0, 1912 }, 1913 }, 1914 }, ··· 1917 schema: { 1918 type: "object", 1919 properties: { 1920 - tracks: { 1921 type: "array", 1922 items: { 1923 type: "ref", 1924 - ref: "lex:app.rocksky.song.defs#songViewBasic", 1925 }, 1926 }, 1927 }, ··· 2766 }, 2767 }, 2768 }, 2769 AppRockskyPlayerPlayDirectory: { 2770 lexicon: 1, 2771 id: "app.rocksky.player.playDirectory", ··· 2812 type: "string", 2813 }, 2814 fileId: { 2815 - type: "string", 2816 - }, 2817 - }, 2818 - }, 2819 - }, 2820 - }, 2821 - }, 2822 - AppRockskyPlayerPlay: { 2823 - lexicon: 1, 2824 - id: "app.rocksky.player.play", 2825 - defs: { 2826 - main: { 2827 - type: "procedure", 2828 - description: "Resume playback of the currently paused track", 2829 - parameters: { 2830 - type: "params", 2831 - properties: { 2832 - playerId: { 2833 type: "string", 2834 }, 2835 }, ··· 5058 AppRockskyAlbum: "app.rocksky.album", 5059 AppRockskyAlbumDefs: "app.rocksky.album.defs", 5060 AppRockskyAlbumGetAlbum: "app.rocksky.album.getAlbum", 5061 - AppRockskyAlbumGetAlbums: "app.rocksky.album.getAlbums", 5062 AppRockskyAlbumGetAlbumTracks: "app.rocksky.album.getAlbumTracks", 5063 AppRockskyApikeyCreateApikey: "app.rocksky.apikey.createApikey", 5064 AppRockskyApikeyDefs: "app.rocksky.apikey.defs", 5065 AppRockskyApikeysDefs: "app.rocksky.apikeys.defs", ··· 5068 AppRockskyApikeyUpdateApikey: "app.rocksky.apikey.updateApikey", 5069 AppRockskyArtist: "app.rocksky.artist", 5070 AppRockskyArtistDefs: "app.rocksky.artist.defs", 5071 - AppRockskyArtistGetArtistAlbums: "app.rocksky.artist.getArtistAlbums", 5072 AppRockskyArtistGetArtist: "app.rocksky.artist.getArtist", 5073 AppRockskyArtistGetArtistListeners: "app.rocksky.artist.getArtistListeners", 5074 - AppRockskyArtistGetArtists: "app.rocksky.artist.getArtists", 5075 AppRockskyArtistGetArtistTracks: "app.rocksky.artist.getArtistTracks", 5076 AppRockskyChartsDefs: "app.rocksky.charts.defs", 5077 AppRockskyChartsGetScrobblesChart: "app.rocksky.charts.getScrobblesChart", 5078 AppRockskyDropboxDefs: "app.rocksky.dropbox.defs", ··· 5099 AppRockskyPlayerGetPlaybackQueue: "app.rocksky.player.getPlaybackQueue", 5100 AppRockskyPlayerNext: "app.rocksky.player.next", 5101 AppRockskyPlayerPause: "app.rocksky.player.pause", 5102 AppRockskyPlayerPlayDirectory: "app.rocksky.player.playDirectory", 5103 AppRockskyPlayerPlayFile: "app.rocksky.player.playFile", 5104 - AppRockskyPlayerPlay: "app.rocksky.player.play", 5105 AppRockskyPlayerPrevious: "app.rocksky.player.previous", 5106 AppRockskyPlayerSeek: "app.rocksky.player.seek", 5107 AppRockskyPlaylistCreatePlaylist: "app.rocksky.playlist.createPlaylist",
··· 1267 }, 1268 }, 1269 }, 1270 + AppRockskyAlbumGetAlbumTracks: { 1271 lexicon: 1, 1272 + id: "app.rocksky.album.getAlbumTracks", 1273 defs: { 1274 main: { 1275 type: "query", 1276 + description: "Get tracks for an album", 1277 parameters: { 1278 type: "params", 1279 + required: ["uri"], 1280 properties: { 1281 + uri: { 1282 + type: "string", 1283 + description: "The URI of the album to retrieve tracks from", 1284 + format: "at-uri", 1285 }, 1286 }, 1287 }, ··· 1290 schema: { 1291 type: "object", 1292 properties: { 1293 + tracks: { 1294 type: "array", 1295 items: { 1296 type: "ref", 1297 + ref: "lex:app.rocksky.song.defs#songViewBasic", 1298 }, 1299 }, 1300 }, ··· 1303 }, 1304 }, 1305 }, 1306 + AppRockskyAlbumGetAlbums: { 1307 lexicon: 1, 1308 + id: "app.rocksky.album.getAlbums", 1309 defs: { 1310 main: { 1311 type: "query", 1312 + description: "Get albums", 1313 parameters: { 1314 type: "params", 1315 properties: { 1316 + limit: { 1317 + type: "integer", 1318 + description: "The maximum number of albums to return", 1319 + minimum: 1, 1320 + }, 1321 + offset: { 1322 + type: "integer", 1323 + description: "The offset for pagination", 1324 + minimum: 0, 1325 }, 1326 }, 1327 }, ··· 1330 schema: { 1331 type: "object", 1332 properties: { 1333 + albums: { 1334 type: "array", 1335 items: { 1336 type: "ref", 1337 + ref: "lex:app.rocksky.album.defs#albumViewBasic", 1338 }, 1339 }, 1340 }, ··· 1737 }, 1738 }, 1739 }, 1740 + AppRockskyArtistGetArtist: { 1741 + lexicon: 1, 1742 + id: "app.rocksky.artist.getArtist", 1743 + defs: { 1744 + main: { 1745 + type: "query", 1746 + description: "Get artist details", 1747 + parameters: { 1748 + type: "params", 1749 + required: ["uri"], 1750 + properties: { 1751 + uri: { 1752 + type: "string", 1753 + description: "The URI of the artist to retrieve details from", 1754 + format: "at-uri", 1755 + }, 1756 + }, 1757 + }, 1758 + output: { 1759 + encoding: "application/json", 1760 + schema: { 1761 + type: "ref", 1762 + ref: "lex:app.rocksky.artist.defs#artistViewDetailed", 1763 + }, 1764 + }, 1765 + }, 1766 + }, 1767 + }, 1768 AppRockskyArtistGetArtistAlbums: { 1769 lexicon: 1, 1770 id: "app.rocksky.artist.getArtistAlbums", ··· 1796 }, 1797 }, 1798 }, 1799 }, 1800 }, 1801 }, ··· 1844 }, 1845 }, 1846 }, 1847 + AppRockskyArtistGetArtistTracks: { 1848 lexicon: 1, 1849 + id: "app.rocksky.artist.getArtistTracks", 1850 defs: { 1851 main: { 1852 type: "query", 1853 + description: "Get artist's tracks", 1854 parameters: { 1855 type: "params", 1856 properties: { 1857 + uri: { 1858 + type: "string", 1859 + description: "The URI of the artist to retrieve albums from", 1860 + format: "at-uri", 1861 + }, 1862 limit: { 1863 type: "integer", 1864 + description: "The maximum number of tracks to return", 1865 minimum: 1, 1866 }, 1867 offset: { ··· 1876 schema: { 1877 type: "object", 1878 properties: { 1879 + tracks: { 1880 type: "array", 1881 items: { 1882 type: "ref", 1883 + ref: "lex:app.rocksky.song.defs#songViewBasic", 1884 }, 1885 }, 1886 }, ··· 1889 }, 1890 }, 1891 }, 1892 + AppRockskyArtistGetArtists: { 1893 lexicon: 1, 1894 + id: "app.rocksky.artist.getArtists", 1895 defs: { 1896 main: { 1897 type: "query", 1898 + description: "Get artists", 1899 parameters: { 1900 type: "params", 1901 properties: { 1902 limit: { 1903 type: "integer", 1904 + description: "The maximum number of artists to return", 1905 minimum: 1, 1906 }, 1907 offset: { 1908 type: "integer", 1909 description: "The offset for pagination", 1910 minimum: 0, 1911 + }, 1912 + names: { 1913 + type: "string", 1914 + description: "The names of the artists to return", 1915 }, 1916 }, 1917 }, ··· 1920 schema: { 1921 type: "object", 1922 properties: { 1923 + artists: { 1924 type: "array", 1925 items: { 1926 type: "ref", 1927 + ref: "lex:app.rocksky.artist.defs#artistViewBasic", 1928 }, 1929 }, 1930 }, ··· 2769 }, 2770 }, 2771 }, 2772 + AppRockskyPlayerPlay: { 2773 + lexicon: 1, 2774 + id: "app.rocksky.player.play", 2775 + defs: { 2776 + main: { 2777 + type: "procedure", 2778 + description: "Resume playback of the currently paused track", 2779 + parameters: { 2780 + type: "params", 2781 + properties: { 2782 + playerId: { 2783 + type: "string", 2784 + }, 2785 + }, 2786 + }, 2787 + }, 2788 + }, 2789 + }, 2790 AppRockskyPlayerPlayDirectory: { 2791 lexicon: 1, 2792 id: "app.rocksky.player.playDirectory", ··· 2833 type: "string", 2834 }, 2835 fileId: { 2836 type: "string", 2837 }, 2838 }, ··· 5061 AppRockskyAlbum: "app.rocksky.album", 5062 AppRockskyAlbumDefs: "app.rocksky.album.defs", 5063 AppRockskyAlbumGetAlbum: "app.rocksky.album.getAlbum", 5064 AppRockskyAlbumGetAlbumTracks: "app.rocksky.album.getAlbumTracks", 5065 + AppRockskyAlbumGetAlbums: "app.rocksky.album.getAlbums", 5066 AppRockskyApikeyCreateApikey: "app.rocksky.apikey.createApikey", 5067 AppRockskyApikeyDefs: "app.rocksky.apikey.defs", 5068 AppRockskyApikeysDefs: "app.rocksky.apikeys.defs", ··· 5071 AppRockskyApikeyUpdateApikey: "app.rocksky.apikey.updateApikey", 5072 AppRockskyArtist: "app.rocksky.artist", 5073 AppRockskyArtistDefs: "app.rocksky.artist.defs", 5074 AppRockskyArtistGetArtist: "app.rocksky.artist.getArtist", 5075 + AppRockskyArtistGetArtistAlbums: "app.rocksky.artist.getArtistAlbums", 5076 AppRockskyArtistGetArtistListeners: "app.rocksky.artist.getArtistListeners", 5077 AppRockskyArtistGetArtistTracks: "app.rocksky.artist.getArtistTracks", 5078 + AppRockskyArtistGetArtists: "app.rocksky.artist.getArtists", 5079 AppRockskyChartsDefs: "app.rocksky.charts.defs", 5080 AppRockskyChartsGetScrobblesChart: "app.rocksky.charts.getScrobblesChart", 5081 AppRockskyDropboxDefs: "app.rocksky.dropbox.defs", ··· 5102 AppRockskyPlayerGetPlaybackQueue: "app.rocksky.player.getPlaybackQueue", 5103 AppRockskyPlayerNext: "app.rocksky.player.next", 5104 AppRockskyPlayerPause: "app.rocksky.player.pause", 5105 + AppRockskyPlayerPlay: "app.rocksky.player.play", 5106 AppRockskyPlayerPlayDirectory: "app.rocksky.player.playDirectory", 5107 AppRockskyPlayerPlayFile: "app.rocksky.player.playFile", 5108 AppRockskyPlayerPrevious: "app.rocksky.player.previous", 5109 AppRockskyPlayerSeek: "app.rocksky.player.seek", 5110 AppRockskyPlaylistCreatePlaylist: "app.rocksky.playlist.createPlaylist",
+2
apps/api/src/lexicon/types/app/rocksky/artist/getArtists.ts
··· 14 limit?: number; 15 /** The offset for pagination */ 16 offset?: number; 17 } 18 19 export type InputSchema = undefined;
··· 14 limit?: number; 15 /** The offset for pagination */ 16 offset?: number; 17 + /** The names of the artists to return */ 18 + names?: string; 19 } 20 21 export type InputSchema = undefined;
+46 -46
apps/api/src/nowplaying/nowplaying.service.ts
··· 26 27 export async function putArtistRecord( 28 track: Track, 29 - agent: Agent 30 ): Promise<string | null> { 31 const rkey = TID.nextStr(); 32 const record: Artist.Record = { ··· 62 63 export async function putAlbumRecord( 64 track: Track, 65 - agent: Agent 66 ): Promise<string | null> { 67 const rkey = TID.nextStr(); 68 ··· 103 104 export async function putSongRecord( 105 track: Track, 106 - agent: Agent 107 ): Promise<string | null> { 108 const rkey = TID.nextStr(); 109 ··· 158 159 async function putScrobbleRecord( 160 track: Track, 161 - agent: Agent 162 ): Promise<string | null> { 163 const rkey = TID.nextStr(); 164 ··· 276 .where( 277 and( 278 eq(artistAlbums.albumId, scrobble.album.id), 279 - eq(artistAlbums.artistId, scrobble.artist.id) 280 - ) 281 ) 282 .limit(1) 283 .then((rows) => rows[0]), ··· 440 }, 441 }), 442 null, 443 - 2 444 ); 445 446 ctx.nc.publish( 447 "rocksky.scrobble", 448 - Buffer.from(message.replaceAll("sha_256", "sha256")) 449 ); 450 451 const trackMessage = JSON.stringify( ··· 492 xata_createdat: artist_album.createdAt.toISOString(), 493 xata_updatedat: artist_album.updatedAt.toISOString(), 494 }, 495 - }) 496 ); 497 498 ctx.nc.publish( 499 "rocksky.track", 500 - Buffer.from(trackMessage.replaceAll("sha_256", "sha256")) 501 ); 502 } 503 ··· 505 ctx: Context, 506 track: Track, 507 agent: Agent, 508 - userDid: string 509 ): Promise<void> { 510 // check if scrobble already exists (user did + timestamp) 511 const scrobbleTime = dayjs.unix(track.timestamp || dayjs().unix()); ··· 524 eq(tracks.title, track.title), 525 eq(tracks.artist, track.artist), 526 gte(scrobbles.timestamp, scrobbleTime.subtract(60, "seconds").toDate()), 527 - lte(scrobbles.timestamp, scrobbleTime.add(60, "seconds").toDate()) 528 - ) 529 ) 530 .limit(1) 531 .then((rows) => rows[0]); ··· 533 if (existingScrobble) { 534 console.log( 535 `Scrobble already exists for ${chalk.cyan(track.title)} at ${chalk.cyan( 536 - scrobbleTime.format("YYYY-MM-DD HH:mm:ss") 537 - )}` 538 ); 539 return; 540 } ··· 547 tracks.sha256, 548 createHash("sha256") 549 .update( 550 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 551 ) 552 - .digest("hex") 553 - ) 554 ) 555 .limit(1) 556 .then((rows) => rows[0]); ··· 564 albums.sha256, 565 createHash("sha256") 566 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 567 - .digest("hex") 568 - ) 569 ) 570 .limit(1) 571 .then((rows) => rows[0]); ··· 586 artists.sha256, 587 createHash("sha256") 588 .update(track.albumArtist.toLowerCase()) 589 - .digest("hex") 590 - ) 591 ) 592 .limit(1) 593 .then((rows) => rows[0]); ··· 618 artist: track.artist.split(",").map((a) => ({ name: a.trim() })), 619 name: track.title, 620 album: track.album, 621 - } 622 ); 623 624 if (!mbTrack?.trackMBID) { ··· 647 albums.sha256, 648 createHash("sha256") 649 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 650 - .digest("hex") 651 - ) 652 ) 653 .limit(1) 654 .then((rows) => rows[0]); ··· 664 tracks.sha256, 665 createHash("sha256") 666 .update( 667 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 668 ) 669 - .digest("hex") 670 - ) 671 ) 672 .limit(1) 673 .then((rows) => rows[0]); ··· 681 682 if (existingTrack) { 683 console.log( 684 - `Song found: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries` 685 ); 686 } 687 ··· 694 artists.sha256, 695 createHash("sha256") 696 .update(track.albumArtist.toLowerCase()) 697 - .digest("hex") 698 ), 699 eq( 700 artists.sha256, 701 - createHash("sha256").update(track.artist.toLowerCase()).digest("hex") 702 - ) 703 - ) 704 ) 705 .limit(1) 706 .then((rows) => rows[0]); ··· 715 .innerJoin(artists, eq(userArtists.artistId, artists.id)) 716 .innerJoin(users, eq(userArtists.userId, users.id)) 717 .where( 718 - and(eq(artists.id, existingArtist?.id || ""), eq(users.did, userDid)) 719 ) 720 .limit(1) 721 .then((rows) => rows[0]); ··· 750 tracks.sha256, 751 createHash("sha256") 752 .update( 753 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 754 ) 755 - .digest("hex") 756 - ) 757 ) 758 .limit(1) 759 .then((rows) => rows[0]); 760 761 while (!existingTrack?.artistUri && !existingTrack?.albumUri && tries < 30) { 762 console.log( 763 - `Artist uri not ready, trying again: ${chalk.magenta(tries + 1)}` 764 ); 765 existingTrack = await ctx.db 766 .select() ··· 770 tracks.sha256, 771 createHash("sha256") 772 .update( 773 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 774 ) 775 - .digest("hex") 776 - ) 777 ) 778 .limit(1) 779 .then((rows) => rows[0]); ··· 788 artists.sha256, 789 createHash("sha256") 790 .update(track.albumArtist.toLowerCase()) 791 - .digest("hex") 792 - ) 793 ) 794 .limit(1) 795 .then((rows) => rows[0]); ··· 812 albums.sha256, 813 createHash("sha256") 814 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 815 - .digest("hex") 816 - ) 817 ) 818 .limit(1) 819 .then((rows) => rows[0]); ··· 843 844 if (existingTrack?.artistUri) { 845 console.log( 846 - `Artist uri ready: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries` 847 ); 848 } 849
··· 26 27 export async function putArtistRecord( 28 track: Track, 29 + agent: Agent, 30 ): Promise<string | null> { 31 const rkey = TID.nextStr(); 32 const record: Artist.Record = { ··· 62 63 export async function putAlbumRecord( 64 track: Track, 65 + agent: Agent, 66 ): Promise<string | null> { 67 const rkey = TID.nextStr(); 68 ··· 103 104 export async function putSongRecord( 105 track: Track, 106 + agent: Agent, 107 ): Promise<string | null> { 108 const rkey = TID.nextStr(); 109 ··· 158 159 async function putScrobbleRecord( 160 track: Track, 161 + agent: Agent, 162 ): Promise<string | null> { 163 const rkey = TID.nextStr(); 164 ··· 276 .where( 277 and( 278 eq(artistAlbums.albumId, scrobble.album.id), 279 + eq(artistAlbums.artistId, scrobble.artist.id), 280 + ), 281 ) 282 .limit(1) 283 .then((rows) => rows[0]), ··· 440 }, 441 }), 442 null, 443 + 2, 444 ); 445 446 ctx.nc.publish( 447 "rocksky.scrobble", 448 + Buffer.from(message.replaceAll("sha_256", "sha256")), 449 ); 450 451 const trackMessage = JSON.stringify( ··· 492 xata_createdat: artist_album.createdAt.toISOString(), 493 xata_updatedat: artist_album.updatedAt.toISOString(), 494 }, 495 + }), 496 ); 497 498 ctx.nc.publish( 499 "rocksky.track", 500 + Buffer.from(trackMessage.replaceAll("sha_256", "sha256")), 501 ); 502 } 503 ··· 505 ctx: Context, 506 track: Track, 507 agent: Agent, 508 + userDid: string, 509 ): Promise<void> { 510 // check if scrobble already exists (user did + timestamp) 511 const scrobbleTime = dayjs.unix(track.timestamp || dayjs().unix()); ··· 524 eq(tracks.title, track.title), 525 eq(tracks.artist, track.artist), 526 gte(scrobbles.timestamp, scrobbleTime.subtract(60, "seconds").toDate()), 527 + lte(scrobbles.timestamp, scrobbleTime.add(60, "seconds").toDate()), 528 + ), 529 ) 530 .limit(1) 531 .then((rows) => rows[0]); ··· 533 if (existingScrobble) { 534 console.log( 535 `Scrobble already exists for ${chalk.cyan(track.title)} at ${chalk.cyan( 536 + scrobbleTime.format("YYYY-MM-DD HH:mm:ss"), 537 + )}`, 538 ); 539 return; 540 } ··· 547 tracks.sha256, 548 createHash("sha256") 549 .update( 550 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 551 ) 552 + .digest("hex"), 553 + ), 554 ) 555 .limit(1) 556 .then((rows) => rows[0]); ··· 564 albums.sha256, 565 createHash("sha256") 566 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 567 + .digest("hex"), 568 + ), 569 ) 570 .limit(1) 571 .then((rows) => rows[0]); ··· 586 artists.sha256, 587 createHash("sha256") 588 .update(track.albumArtist.toLowerCase()) 589 + .digest("hex"), 590 + ), 591 ) 592 .limit(1) 593 .then((rows) => rows[0]); ··· 618 artist: track.artist.split(",").map((a) => ({ name: a.trim() })), 619 name: track.title, 620 album: track.album, 621 + }, 622 ); 623 624 if (!mbTrack?.trackMBID) { ··· 647 albums.sha256, 648 createHash("sha256") 649 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 650 + .digest("hex"), 651 + ), 652 ) 653 .limit(1) 654 .then((rows) => rows[0]); ··· 664 tracks.sha256, 665 createHash("sha256") 666 .update( 667 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 668 ) 669 + .digest("hex"), 670 + ), 671 ) 672 .limit(1) 673 .then((rows) => rows[0]); ··· 681 682 if (existingTrack) { 683 console.log( 684 + `Song found: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries`, 685 ); 686 } 687 ··· 694 artists.sha256, 695 createHash("sha256") 696 .update(track.albumArtist.toLowerCase()) 697 + .digest("hex"), 698 ), 699 eq( 700 artists.sha256, 701 + createHash("sha256").update(track.artist.toLowerCase()).digest("hex"), 702 + ), 703 + ), 704 ) 705 .limit(1) 706 .then((rows) => rows[0]); ··· 715 .innerJoin(artists, eq(userArtists.artistId, artists.id)) 716 .innerJoin(users, eq(userArtists.userId, users.id)) 717 .where( 718 + and(eq(artists.id, existingArtist?.id || ""), eq(users.did, userDid)), 719 ) 720 .limit(1) 721 .then((rows) => rows[0]); ··· 750 tracks.sha256, 751 createHash("sha256") 752 .update( 753 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 754 ) 755 + .digest("hex"), 756 + ), 757 ) 758 .limit(1) 759 .then((rows) => rows[0]); 760 761 while (!existingTrack?.artistUri && !existingTrack?.albumUri && tries < 30) { 762 console.log( 763 + `Artist uri not ready, trying again: ${chalk.magenta(tries + 1)}`, 764 ); 765 existingTrack = await ctx.db 766 .select() ··· 770 tracks.sha256, 771 createHash("sha256") 772 .update( 773 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 774 ) 775 + .digest("hex"), 776 + ), 777 ) 778 .limit(1) 779 .then((rows) => rows[0]); ··· 788 artists.sha256, 789 createHash("sha256") 790 .update(track.albumArtist.toLowerCase()) 791 + .digest("hex"), 792 + ), 793 ) 794 .limit(1) 795 .then((rows) => rows[0]); ··· 812 albums.sha256, 813 createHash("sha256") 814 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 815 + .digest("hex"), 816 + ), 817 ) 818 .limit(1) 819 .then((rows) => rows[0]); ··· 843 844 if (existingTrack?.artistUri) { 845 console.log( 846 + `Artist uri ready: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries`, 847 ); 848 } 849
+1 -3
apps/api/src/schema/spotify-accounts.ts
··· 9 import users from "./users"; 10 11 const spotifyAccounts = pgTable("spotify_accounts", { 12 - id: text("xata_id") 13 - .primaryKey() 14 - .default(sql`xata_id()`), 15 xataVersion: integer("xata_version"), 16 email: text("email").notNull(), 17 userId: text("user_id")
··· 9 import users from "./users"; 10 11 const spotifyAccounts = pgTable("spotify_accounts", { 12 + id: text("xata_id").primaryKey().default(sql`xata_id()`), 13 xataVersion: integer("xata_version"), 14 email: text("email").notNull(), 15 userId: text("user_id")
+1 -3
apps/api/src/schema/spotify-apps.ts
··· 2 import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 3 4 const spotifyApps = pgTable("spotify_apps", { 5 - id: text("xata_id") 6 - .primaryKey() 7 - .default(sql`xata_id()`), 8 xataVersion: integer("xata_version"), 9 spotifyAppId: text("spotify_app_id").unique().notNull(), 10 spotifySecret: text("spotify_secret").notNull(),
··· 2 import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 3 4 const spotifyApps = pgTable("spotify_apps", { 5 + id: text("xata_id").primaryKey().default(sql`xata_id()`), 6 xataVersion: integer("xata_version"), 7 spotifyAppId: text("spotify_app_id").unique().notNull(), 8 spotifySecret: text("spotify_secret").notNull(),
+1 -3
apps/api/src/schema/spotify-tokens.ts
··· 3 import users from "./users"; 4 5 const spotifyTokens = pgTable("spotify_tokens", { 6 - id: text("xata_id") 7 - .primaryKey() 8 - .default(sql`xata_id()`), 9 xataVersion: integer("xata_version"), 10 accessToken: text("access_token").notNull(), 11 refreshToken: text("refresh_token").notNull(),
··· 3 import users from "./users"; 4 5 const spotifyTokens = pgTable("spotify_tokens", { 6 + id: text("xata_id").primaryKey().default(sql`xata_id()`), 7 xataVersion: integer("xata_version"), 8 accessToken: text("access_token").notNull(), 9 refreshToken: text("refresh_token").notNull(),
+6 -6
apps/api/src/scripts/genres.ts
··· 11 .from(tables.spotifyTokens) 12 .leftJoin( 13 tables.spotifyAccounts, 14 - eq(tables.spotifyAccounts.userId, tables.spotifyTokens.userId) 15 ) 16 .leftJoin( 17 tables.spotifyApps, 18 - eq(tables.spotifyApps.spotifyAppId, tables.spotifyTokens.spotifyAppId) 19 ) 20 .where(eq(tables.spotifyAccounts.isBetaUser, true)) 21 .execute(); ··· 24 spotifyTokens[Math.floor(Math.random() * spotifyTokens.length)]; 25 const refreshToken = decrypt( 26 record.spotify_tokens.refreshToken, 27 - env.SPOTIFY_ENCRYPTION_KEY 28 ); 29 30 const accessToken = await fetch("https://accounts.spotify.com/api/token", { ··· 38 client_id: record.spotify_apps.spotifyAppId, 39 client_secret: decrypt( 40 record.spotify_apps.spotifySecret, 41 - env.SPOTIFY_ENCRYPTION_KEY 42 ), 43 }), 44 }) ··· 60 headers: { 61 Authorization: `Bearer ${token}`, 62 }, 63 - } 64 ) 65 .then( 66 (res) => ··· 73 images: Array<{ url: string }>; 74 }>; 75 }; 76 - }> 77 ) 78 .then(async (data) => _.get(data, "artists.items.0")); 79
··· 11 .from(tables.spotifyTokens) 12 .leftJoin( 13 tables.spotifyAccounts, 14 + eq(tables.spotifyAccounts.userId, tables.spotifyTokens.userId), 15 ) 16 .leftJoin( 17 tables.spotifyApps, 18 + eq(tables.spotifyApps.spotifyAppId, tables.spotifyTokens.spotifyAppId), 19 ) 20 .where(eq(tables.spotifyAccounts.isBetaUser, true)) 21 .execute(); ··· 24 spotifyTokens[Math.floor(Math.random() * spotifyTokens.length)]; 25 const refreshToken = decrypt( 26 record.spotify_tokens.refreshToken, 27 + env.SPOTIFY_ENCRYPTION_KEY, 28 ); 29 30 const accessToken = await fetch("https://accounts.spotify.com/api/token", { ··· 38 client_id: record.spotify_apps.spotifyAppId, 39 client_secret: decrypt( 40 record.spotify_apps.spotifySecret, 41 + env.SPOTIFY_ENCRYPTION_KEY, 42 ), 43 }), 44 }) ··· 60 headers: { 61 Authorization: `Bearer ${token}`, 62 }, 63 + }, 64 ) 65 .then( 66 (res) => ··· 73 images: Array<{ url: string }>; 74 }>; 75 }; 76 + }>, 77 ) 78 .then(async (data) => _.get(data, "artists.items.0")); 79
+2 -2
apps/api/src/scripts/spotify.ts
··· 10 11 if (!clientId || !clientSecret) { 12 console.error( 13 - "Please provide Spotify Client ID and Client Secret as command line arguments" 14 ); 15 console.log( 16 - chalk.greenBright("Usage: ts-node spotify.ts <client_id> <client_secret>") 17 ); 18 process.exit(1); 19 }
··· 10 11 if (!clientId || !clientSecret) { 12 console.error( 13 + "Please provide Spotify Client ID and Client Secret as command line arguments", 14 ); 15 console.log( 16 + chalk.greenBright("Usage: ts-node spotify.ts <client_id> <client_secret>"), 17 ); 18 process.exit(1); 19 }
+1 -1
apps/api/src/scripts/sync.ts
··· 143 } catch (err) { 144 console.error( 145 `Failed to sync scrobble ${chalk.cyan(scrobble.id)}:`, 146 - err 147 ); 148 } 149 }
··· 143 } catch (err) { 144 console.error( 145 `Failed to sync scrobble ${chalk.cyan(scrobble.id)}:`, 146 + err, 147 ); 148 } 149 }
+1 -1
apps/api/src/server.ts
··· 31 32 app.listen(process.env.ROCKSKY_XPRC_PORT || 3004, () => { 33 console.log( 34 - `Rocksky XRPC API is running on port ${process.env.ROCKSKY_XRPC_PORT || 3004}` 35 ); 36 });
··· 31 32 app.listen(process.env.ROCKSKY_XPRC_PORT || 3004, () => { 33 console.log( 34 + `Rocksky XRPC API is running on port ${process.env.ROCKSKY_XRPC_PORT || 3004}`, 35 ); 36 });
+30 -30
apps/api/src/spotify/app.ts
··· 24 limit: 10, // max Spotify API calls 25 window: 15, // per 10 seconds 26 keyPrefix: "spotify-ratelimit", 27 - }) 28 ); 29 30 app.get("/login", async (c) => { ··· 58 .leftJoin(users, eq(spotifyAccounts.userId, users.id)) 59 .leftJoin( 60 spotifyApps, 61 - eq(spotifyAccounts.spotifyAppId, spotifyApps.spotifyAppId) 62 ) 63 .where( 64 and( 65 eq(spotifyAccounts.userId, user.id), 66 - eq(spotifyAccounts.isBetaUser, true) 67 - ) 68 ) 69 .limit(1) 70 .then((rows) => rows[0]); ··· 74 const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${spotifyAccount?.spotify_apps?.spotifyAppId}&response_type=code&redirect_uri=${env.SPOTIFY_REDIRECT_URI}&scope=user-read-private%20user-read-email%20user-read-playback-state%20user-read-currently-playing%20user-modify-playback-state%20playlist-modify-public%20playlist-modify-private%20playlist-read-private%20playlist-read-collaborative&state=${state}`; 75 c.header( 76 "Set-Cookie", 77 - `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure` 78 ); 79 return c.json({ redirectUrl }); 80 }); ··· 110 .from(spotifyAccounts) 111 .leftJoin( 112 spotifyApps, 113 - eq(spotifyAccounts.spotifyAppId, spotifyApps.spotifyAppId) 114 ) 115 .where( 116 and( 117 eq(spotifyAccounts.userId, user.id), 118 - eq(spotifyAccounts.isBetaUser, true) 119 - ) 120 ) 121 .limit(1) 122 .then((rows) => rows[0]); ··· 179 .where( 180 and( 181 eq(spotifyAccounts.userId, user.id), 182 - eq(spotifyAccounts.isBetaUser, true) 183 - ) 184 ) 185 .limit(1) 186 .then((rows) => rows[0]); ··· 230 appId: spotifyApps.id, 231 spotifyAppId: spotifyApps.spotifyAppId, 232 accountCount: sql<number>`COUNT(${spotifyAccounts.id})`.as( 233 - "account_count" 234 ), 235 }) 236 .from(spotifyApps) ··· 311 } 312 313 const cached = await ctx.redis.get( 314 - `${spotifyAccount.spotifyAccount.email}:current` 315 ); 316 if (!cached) { 317 return c.json({}); ··· 321 322 const sha256 = createHash("sha256") 323 .update( 324 - `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase() 325 ) 326 .digest("hex"); 327 ··· 385 .from(spotifyTokens) 386 .leftJoin( 387 spotifyApps, 388 - eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 389 ) 390 .where(eq(spotifyTokens.userId, user.id)) 391 .limit(1) ··· 398 399 const refreshToken = decrypt( 400 spotifyToken.spotify_tokens.refreshToken, 401 - env.SPOTIFY_ENCRYPTION_KEY 402 ); 403 404 // get new access token ··· 413 client_id: spotifyToken.spotify_apps.spotifyAppId, 414 client_secret: decrypt( 415 spotifyToken.spotify_apps.spotifySecret, 416 - env.SPOTIFY_ENCRYPTION_KEY 417 ), 418 }), 419 }); ··· 468 .from(spotifyTokens) 469 .leftJoin( 470 spotifyApps, 471 - eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 472 ) 473 .where(eq(spotifyTokens.userId, user.id)) 474 .limit(1) ··· 481 482 const refreshToken = decrypt( 483 spotifyToken.spotify_tokens.refreshToken, 484 - env.SPOTIFY_ENCRYPTION_KEY 485 ); 486 487 // get new access token ··· 496 client_id: spotifyToken.spotify_apps.spotifyAppId, 497 client_secret: decrypt( 498 spotifyToken.spotify_apps.spotifySecret, 499 - env.SPOTIFY_ENCRYPTION_KEY 500 ), 501 }), 502 }); ··· 551 .from(spotifyTokens) 552 .leftJoin( 553 spotifyApps, 554 - eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 555 ) 556 .where(eq(spotifyTokens.userId, user.id)) 557 .limit(1) ··· 564 565 const refreshToken = decrypt( 566 spotifyToken.spotify_tokens.refreshToken, 567 - env.SPOTIFY_ENCRYPTION_KEY 568 ); 569 570 // get new access token ··· 579 client_id: spotifyToken.spotify_apps.spotifyAppId, 580 client_secret: decrypt( 581 spotifyToken.spotify_apps.spotifySecret, 582 - env.SPOTIFY_ENCRYPTION_KEY 583 ), 584 }), 585 }); ··· 634 .from(spotifyTokens) 635 .leftJoin( 636 spotifyApps, 637 - eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 638 ) 639 .where(eq(spotifyTokens.userId, user.id)) 640 .limit(1) ··· 647 648 const refreshToken = decrypt( 649 spotifyToken.spotify_tokens.refreshToken, 650 - env.SPOTIFY_ENCRYPTION_KEY 651 ); 652 653 // get new access token ··· 662 client_id: spotifyToken.spotify_apps.spotifyAppId, 663 client_secret: decrypt( 664 spotifyToken.spotify_apps.spotifySecret, 665 - env.SPOTIFY_ENCRYPTION_KEY 666 ), 667 }), 668 }); ··· 678 headers: { 679 Authorization: `Bearer ${access_token}`, 680 }, 681 - } 682 ); 683 684 if (response.status === 403) { ··· 720 .from(spotifyTokens) 721 .leftJoin( 722 spotifyApps, 723 - eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 724 ) 725 .where(eq(spotifyTokens.userId, user.id)) 726 .limit(1) ··· 733 734 const refreshToken = decrypt( 735 spotifyToken.spotify_tokens.refreshToken, 736 - env.SPOTIFY_ENCRYPTION_KEY 737 ); 738 739 // get new access token ··· 748 client_id: spotifyToken.spotify_apps.spotifyAppId, 749 client_secret: decrypt( 750 spotifyToken.spotify_apps.spotifySecret, 751 - env.SPOTIFY_ENCRYPTION_KEY 752 ), 753 }), 754 }); ··· 765 headers: { 766 Authorization: `Bearer ${access_token}`, 767 }, 768 - } 769 ); 770 771 if (response.status === 403) {
··· 24 limit: 10, // max Spotify API calls 25 window: 15, // per 10 seconds 26 keyPrefix: "spotify-ratelimit", 27 + }), 28 ); 29 30 app.get("/login", async (c) => { ··· 58 .leftJoin(users, eq(spotifyAccounts.userId, users.id)) 59 .leftJoin( 60 spotifyApps, 61 + eq(spotifyAccounts.spotifyAppId, spotifyApps.spotifyAppId), 62 ) 63 .where( 64 and( 65 eq(spotifyAccounts.userId, user.id), 66 + eq(spotifyAccounts.isBetaUser, true), 67 + ), 68 ) 69 .limit(1) 70 .then((rows) => rows[0]); ··· 74 const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${spotifyAccount?.spotify_apps?.spotifyAppId}&response_type=code&redirect_uri=${env.SPOTIFY_REDIRECT_URI}&scope=user-read-private%20user-read-email%20user-read-playback-state%20user-read-currently-playing%20user-modify-playback-state%20playlist-modify-public%20playlist-modify-private%20playlist-read-private%20playlist-read-collaborative&state=${state}`; 75 c.header( 76 "Set-Cookie", 77 + `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure`, 78 ); 79 return c.json({ redirectUrl }); 80 }); ··· 110 .from(spotifyAccounts) 111 .leftJoin( 112 spotifyApps, 113 + eq(spotifyAccounts.spotifyAppId, spotifyApps.spotifyAppId), 114 ) 115 .where( 116 and( 117 eq(spotifyAccounts.userId, user.id), 118 + eq(spotifyAccounts.isBetaUser, true), 119 + ), 120 ) 121 .limit(1) 122 .then((rows) => rows[0]); ··· 179 .where( 180 and( 181 eq(spotifyAccounts.userId, user.id), 182 + eq(spotifyAccounts.isBetaUser, true), 183 + ), 184 ) 185 .limit(1) 186 .then((rows) => rows[0]); ··· 230 appId: spotifyApps.id, 231 spotifyAppId: spotifyApps.spotifyAppId, 232 accountCount: sql<number>`COUNT(${spotifyAccounts.id})`.as( 233 + "account_count", 234 ), 235 }) 236 .from(spotifyApps) ··· 311 } 312 313 const cached = await ctx.redis.get( 314 + `${spotifyAccount.spotifyAccount.email}:current`, 315 ); 316 if (!cached) { 317 return c.json({}); ··· 321 322 const sha256 = createHash("sha256") 323 .update( 324 + `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase(), 325 ) 326 .digest("hex"); 327 ··· 385 .from(spotifyTokens) 386 .leftJoin( 387 spotifyApps, 388 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), 389 ) 390 .where(eq(spotifyTokens.userId, user.id)) 391 .limit(1) ··· 398 399 const refreshToken = decrypt( 400 spotifyToken.spotify_tokens.refreshToken, 401 + env.SPOTIFY_ENCRYPTION_KEY, 402 ); 403 404 // get new access token ··· 413 client_id: spotifyToken.spotify_apps.spotifyAppId, 414 client_secret: decrypt( 415 spotifyToken.spotify_apps.spotifySecret, 416 + env.SPOTIFY_ENCRYPTION_KEY, 417 ), 418 }), 419 }); ··· 468 .from(spotifyTokens) 469 .leftJoin( 470 spotifyApps, 471 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), 472 ) 473 .where(eq(spotifyTokens.userId, user.id)) 474 .limit(1) ··· 481 482 const refreshToken = decrypt( 483 spotifyToken.spotify_tokens.refreshToken, 484 + env.SPOTIFY_ENCRYPTION_KEY, 485 ); 486 487 // get new access token ··· 496 client_id: spotifyToken.spotify_apps.spotifyAppId, 497 client_secret: decrypt( 498 spotifyToken.spotify_apps.spotifySecret, 499 + env.SPOTIFY_ENCRYPTION_KEY, 500 ), 501 }), 502 }); ··· 551 .from(spotifyTokens) 552 .leftJoin( 553 spotifyApps, 554 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), 555 ) 556 .where(eq(spotifyTokens.userId, user.id)) 557 .limit(1) ··· 564 565 const refreshToken = decrypt( 566 spotifyToken.spotify_tokens.refreshToken, 567 + env.SPOTIFY_ENCRYPTION_KEY, 568 ); 569 570 // get new access token ··· 579 client_id: spotifyToken.spotify_apps.spotifyAppId, 580 client_secret: decrypt( 581 spotifyToken.spotify_apps.spotifySecret, 582 + env.SPOTIFY_ENCRYPTION_KEY, 583 ), 584 }), 585 }); ··· 634 .from(spotifyTokens) 635 .leftJoin( 636 spotifyApps, 637 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), 638 ) 639 .where(eq(spotifyTokens.userId, user.id)) 640 .limit(1) ··· 647 648 const refreshToken = decrypt( 649 spotifyToken.spotify_tokens.refreshToken, 650 + env.SPOTIFY_ENCRYPTION_KEY, 651 ); 652 653 // get new access token ··· 662 client_id: spotifyToken.spotify_apps.spotifyAppId, 663 client_secret: decrypt( 664 spotifyToken.spotify_apps.spotifySecret, 665 + env.SPOTIFY_ENCRYPTION_KEY, 666 ), 667 }), 668 }); ··· 678 headers: { 679 Authorization: `Bearer ${access_token}`, 680 }, 681 + }, 682 ); 683 684 if (response.status === 403) { ··· 720 .from(spotifyTokens) 721 .leftJoin( 722 spotifyApps, 723 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId), 724 ) 725 .where(eq(spotifyTokens.userId, user.id)) 726 .limit(1) ··· 733 734 const refreshToken = decrypt( 735 spotifyToken.spotify_tokens.refreshToken, 736 + env.SPOTIFY_ENCRYPTION_KEY, 737 ); 738 739 // get new access token ··· 748 client_id: spotifyToken.spotify_apps.spotifyAppId, 749 client_secret: decrypt( 750 spotifyToken.spotify_apps.spotifySecret, 751 + env.SPOTIFY_ENCRYPTION_KEY, 752 ), 753 }), 754 }); ··· 765 headers: { 766 Authorization: `Bearer ${access_token}`, 767 }, 768 + }, 769 ); 770 771 if (response.status === 403) {
+6 -8
apps/api/src/tealfm/index.ts
··· 35 record?.trackName === track.name)) && 36 // diff in seconds less than 60 37 Math.abs( 38 - new Date(record.playedTime).getTime() - 39 - new Date(track.timestamp).getTime(), 40 - ) < 60000 41 ); 42 }); 43 if (alreadyPlayed) { 44 console.log( 45 - `Track ${chalk.cyan(track.name)} by ${ 46 - chalk.cyan( 47 - track.artist.map((a) => a.name).join(", "), 48 - ) 49 - } already played recently. Skipping...`, 50 ); 51 return; 52 }
··· 35 record?.trackName === track.name)) && 36 // diff in seconds less than 60 37 Math.abs( 38 + new Date(record.playedTime).getTime() - 39 + new Date(track.timestamp).getTime(), 40 + ) < 60000 41 ); 42 }); 43 if (alreadyPlayed) { 44 console.log( 45 + `Track ${chalk.cyan(track.name)} by ${chalk.cyan( 46 + track.artist.map((a) => a.name).join(", "), 47 + )} already played recently. Skipping...`, 48 ); 49 return; 50 }
+9 -9
apps/api/src/xrpc/app/rocksky/actor/getProfile.ts
··· 33 Effect.catchAll((err) => { 34 console.error(err); 35 return Effect.succeed({}); 36 - }) 37 ); 38 server.app.rocksky.actor.getProfile({ 39 auth: ctx.authVerifier, ··· 194 .from(tables.spotifyAccounts) 195 .leftJoin( 196 tables.users, 197 - eq(tables.spotifyAccounts.userId, tables.users.id) 198 ) 199 .where(eq(tables.users.did, did)) 200 .execute() ··· 204 .from(tables.spotifyTokens) 205 .leftJoin( 206 tables.users, 207 - eq(tables.spotifyTokens.userId, tables.users.id) 208 ) 209 .where(eq(tables.users.did, did)) 210 .execute() ··· 214 .from(tables.googleDriveAccounts) 215 .leftJoin( 216 tables.users, 217 - eq(tables.googleDriveAccounts.userId, tables.users.id) 218 ) 219 .where(eq(tables.users.did, did)) 220 .execute() ··· 224 .from(tables.dropboxAccounts) 225 .leftJoin( 226 tables.users, 227 - eq(tables.dropboxAccounts.userId, tables.users.id) 228 ) 229 .where(eq(tables.users.did, did)) 230 .execute() ··· 280 xata_createdat: profile.user.createdAt.toISOString(), 281 xata_updatedat: profile.user.updatedAt.toISOString(), 282 xata_version: 1, 283 - }) 284 - ) 285 ); 286 } else { 287 // Update existing user in background if handle or avatar or displayName changed ··· 314 xata_createdat: profile.user.createdAt.toISOString(), 315 xata_updatedat: new Date().toISOString(), 316 xata_version: (profile.user.xataVersion || 1) + 1, 317 - }) 318 - ) 319 ); 320 } 321 }
··· 33 Effect.catchAll((err) => { 34 console.error(err); 35 return Effect.succeed({}); 36 + }), 37 ); 38 server.app.rocksky.actor.getProfile({ 39 auth: ctx.authVerifier, ··· 194 .from(tables.spotifyAccounts) 195 .leftJoin( 196 tables.users, 197 + eq(tables.spotifyAccounts.userId, tables.users.id), 198 ) 199 .where(eq(tables.users.did, did)) 200 .execute() ··· 204 .from(tables.spotifyTokens) 205 .leftJoin( 206 tables.users, 207 + eq(tables.spotifyTokens.userId, tables.users.id), 208 ) 209 .where(eq(tables.users.did, did)) 210 .execute() ··· 214 .from(tables.googleDriveAccounts) 215 .leftJoin( 216 tables.users, 217 + eq(tables.googleDriveAccounts.userId, tables.users.id), 218 ) 219 .where(eq(tables.users.did, did)) 220 .execute() ··· 224 .from(tables.dropboxAccounts) 225 .leftJoin( 226 tables.users, 227 + eq(tables.dropboxAccounts.userId, tables.users.id), 228 ) 229 .where(eq(tables.users.did, did)) 230 .execute() ··· 280 xata_createdat: profile.user.createdAt.toISOString(), 281 xata_updatedat: profile.user.updatedAt.toISOString(), 282 xata_version: 1, 283 + }), 284 + ), 285 ); 286 } else { 287 // Update existing user in background if handle or avatar or displayName changed ··· 314 xata_createdat: profile.user.createdAt.toISOString(), 315 xata_updatedat: new Date().toISOString(), 316 xata_version: (profile.user.xataVersion || 1) + 1, 317 + }), 318 + ), 319 ); 320 } 321 }
+1
apps/api/src/xrpc/app/rocksky/artist/getArtists.ts
··· 46 skip: params.offset || 0, 47 take: params.limit || 100, 48 }, 49 }); 50 return { data: response.data, ctx }; 51 },
··· 46 skip: params.offset || 0, 47 take: params.limit || 100, 48 }, 49 + names: params.names, 50 }); 51 return { data: response.data, ctx }; 52 },
+4 -4
apps/api/src/xrpc/app/rocksky/scrobble/getScrobble.ts
··· 6 import type { QueryParams } from "lexicon/types/app/rocksky/scrobble/getScrobble"; 7 import * as R from "ramda"; 8 import tables from "schema"; 9 - import { SelectAlbum } from "schema/albums"; 10 import type { SelectScrobble } from "schema/scrobbles"; 11 import type { SelectTrack } from "schema/tracks"; 12 import type { SelectUser } from "schema/users"; ··· 22 Effect.catchAll((err) => { 23 console.error("Error retrieving scrobble:", err); 24 return Effect.succeed({}); 25 - }) 26 ); 27 server.app.rocksky.scrobble.getScrobble({ 28 handler: async ({ params }) => { ··· 63 .from(tables.scrobbles) 64 .leftJoin( 65 tables.tracks, 66 - eq(tables.tracks.id, tables.scrobbles.trackId) 67 ) 68 .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)) 69 .where(eq(tables.scrobbles.trackId, scrobble?.tracks.id)) ··· 75 .from(tables.scrobbles) 76 .leftJoin( 77 tables.tracks, 78 - eq(tables.scrobbles.trackId, tables.tracks.id) 79 ) 80 .where(eq(tables.scrobbles.trackId, scrobble?.tracks.id)) 81 .execute()
··· 6 import type { QueryParams } from "lexicon/types/app/rocksky/scrobble/getScrobble"; 7 import * as R from "ramda"; 8 import tables from "schema"; 9 + import type { SelectAlbum } from "schema/albums"; 10 import type { SelectScrobble } from "schema/scrobbles"; 11 import type { SelectTrack } from "schema/tracks"; 12 import type { SelectUser } from "schema/users"; ··· 22 Effect.catchAll((err) => { 23 console.error("Error retrieving scrobble:", err); 24 return Effect.succeed({}); 25 + }), 26 ); 27 server.app.rocksky.scrobble.getScrobble({ 28 handler: async ({ params }) => { ··· 63 .from(tables.scrobbles) 64 .leftJoin( 65 tables.tracks, 66 + eq(tables.tracks.id, tables.scrobbles.trackId), 67 ) 68 .leftJoin(tables.users, eq(tables.scrobbles.userId, tables.users.id)) 69 .where(eq(tables.scrobbles.trackId, scrobble?.tracks.id)) ··· 75 .from(tables.scrobbles) 76 .leftJoin( 77 tables.tracks, 78 + eq(tables.scrobbles.trackId, tables.tracks.id), 79 ) 80 .where(eq(tables.scrobbles.trackId, scrobble?.tracks.id)) 81 .execute()
+7 -4
apps/api/src/xrpc/app/rocksky/spotify/next.ts
··· 23 Effect.catchAll((err) => { 24 console.error(err); 25 return Effect.succeed({}); 26 - }) 27 ); 28 server.app.rocksky.spotify.next({ 29 auth: ctx.authVerifier, ··· 71 .from(tables.spotifyTokens) 72 .leftJoin( 73 tables.spotifyApps, 74 - eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId) 75 ) 76 .where(eq(tables.spotifyTokens.userId, user.id)) 77 .execute() 78 .then(([spotifyToken]) => [ 79 decrypt( 80 spotifyToken.spotify_tokens.refreshToken, 81 - env.SPOTIFY_ENCRYPTION_KEY 82 ), 83 decrypt( 84 spotifyToken.spotify_apps.spotifySecret, 85 - env.SPOTIFY_ENCRYPTION_KEY 86 ), 87 spotifyToken.spotify_apps.spotifyAppId, 88 ])
··· 23 Effect.catchAll((err) => { 24 console.error(err); 25 return Effect.succeed({}); 26 + }), 27 ); 28 server.app.rocksky.spotify.next({ 29 auth: ctx.authVerifier, ··· 71 .from(tables.spotifyTokens) 72 .leftJoin( 73 tables.spotifyApps, 74 + eq( 75 + tables.spotifyTokens.spotifyAppId, 76 + tables.spotifyApps.spotifyAppId, 77 + ), 78 ) 79 .where(eq(tables.spotifyTokens.userId, user.id)) 80 .execute() 81 .then(([spotifyToken]) => [ 82 decrypt( 83 spotifyToken.spotify_tokens.refreshToken, 84 + env.SPOTIFY_ENCRYPTION_KEY, 85 ), 86 decrypt( 87 spotifyToken.spotify_apps.spotifySecret, 88 + env.SPOTIFY_ENCRYPTION_KEY, 89 ), 90 spotifyToken.spotify_apps.spotifyAppId, 91 ])
+7 -4
apps/api/src/xrpc/app/rocksky/spotify/pause.ts
··· 23 Effect.catchAll((err) => { 24 console.error(err); 25 return Effect.succeed({}); 26 - }) 27 ); 28 server.app.rocksky.spotify.pause({ 29 auth: ctx.authVerifier, ··· 71 .from(tables.spotifyTokens) 72 .leftJoin( 73 tables.spotifyApps, 74 - eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId) 75 ) 76 .where(eq(tables.spotifyTokens.userId, user.id)) 77 .execute() 78 .then(([spotifyToken]) => [ 79 decrypt( 80 spotifyToken.spotify_tokens.refreshToken, 81 - env.SPOTIFY_ENCRYPTION_KEY 82 ), 83 decrypt( 84 spotifyToken.spotify_apps.spotifySecret, 85 - env.SPOTIFY_ENCRYPTION_KEY 86 ), 87 spotifyToken.spotify_apps.spotifyAppId, 88 ])
··· 23 Effect.catchAll((err) => { 24 console.error(err); 25 return Effect.succeed({}); 26 + }), 27 ); 28 server.app.rocksky.spotify.pause({ 29 auth: ctx.authVerifier, ··· 71 .from(tables.spotifyTokens) 72 .leftJoin( 73 tables.spotifyApps, 74 + eq( 75 + tables.spotifyTokens.spotifyAppId, 76 + tables.spotifyApps.spotifyAppId, 77 + ), 78 ) 79 .where(eq(tables.spotifyTokens.userId, user.id)) 80 .execute() 81 .then(([spotifyToken]) => [ 82 decrypt( 83 spotifyToken.spotify_tokens.refreshToken, 84 + env.SPOTIFY_ENCRYPTION_KEY, 85 ), 86 decrypt( 87 spotifyToken.spotify_apps.spotifySecret, 88 + env.SPOTIFY_ENCRYPTION_KEY, 89 ), 90 spotifyToken.spotify_apps.spotifyAppId, 91 ])
+7 -4
apps/api/src/xrpc/app/rocksky/spotify/play.ts
··· 23 Effect.catchAll((err) => { 24 console.error(err); 25 return Effect.succeed({}); 26 - }) 27 ); 28 server.app.rocksky.spotify.play({ 29 auth: ctx.authVerifier, ··· 71 .from(tables.spotifyTokens) 72 .leftJoin( 73 tables.spotifyApps, 74 - eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId) 75 ) 76 .where(eq(tables.spotifyTokens.userId, user.id)) 77 .execute() 78 .then(([spotifyToken]) => [ 79 decrypt( 80 spotifyToken.spotify_tokens.refreshToken, 81 - env.SPOTIFY_ENCRYPTION_KEY 82 ), 83 decrypt( 84 spotifyToken.spotify_apps.spotifySecret, 85 - env.SPOTIFY_ENCRYPTION_KEY 86 ), 87 spotifyToken.spotify_apps.spotifyAppId, 88 ])
··· 23 Effect.catchAll((err) => { 24 console.error(err); 25 return Effect.succeed({}); 26 + }), 27 ); 28 server.app.rocksky.spotify.play({ 29 auth: ctx.authVerifier, ··· 71 .from(tables.spotifyTokens) 72 .leftJoin( 73 tables.spotifyApps, 74 + eq( 75 + tables.spotifyTokens.spotifyAppId, 76 + tables.spotifyApps.spotifyAppId, 77 + ), 78 ) 79 .where(eq(tables.spotifyTokens.userId, user.id)) 80 .execute() 81 .then(([spotifyToken]) => [ 82 decrypt( 83 spotifyToken.spotify_tokens.refreshToken, 84 + env.SPOTIFY_ENCRYPTION_KEY, 85 ), 86 decrypt( 87 spotifyToken.spotify_apps.spotifySecret, 88 + env.SPOTIFY_ENCRYPTION_KEY, 89 ), 90 spotifyToken.spotify_apps.spotifyAppId, 91 ])
+7 -4
apps/api/src/xrpc/app/rocksky/spotify/previous.ts
··· 23 Effect.catchAll((err) => { 24 console.error(err); 25 return Effect.succeed({}); 26 - }) 27 ); 28 server.app.rocksky.spotify.previous({ 29 auth: ctx.authVerifier, ··· 71 .from(tables.spotifyTokens) 72 .leftJoin( 73 tables.spotifyApps, 74 - eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId) 75 ) 76 .where(eq(tables.spotifyTokens.userId, user.id)) 77 .execute() 78 .then(([spotifyToken]) => [ 79 decrypt( 80 spotifyToken.spotify_tokens.refreshToken, 81 - env.SPOTIFY_ENCRYPTION_KEY 82 ), 83 decrypt( 84 spotifyToken.spotify_apps.spotifySecret, 85 - env.SPOTIFY_ENCRYPTION_KEY 86 ), 87 spotifyToken.spotify_apps.spotifyAppId, 88 ])
··· 23 Effect.catchAll((err) => { 24 console.error(err); 25 return Effect.succeed({}); 26 + }), 27 ); 28 server.app.rocksky.spotify.previous({ 29 auth: ctx.authVerifier, ··· 71 .from(tables.spotifyTokens) 72 .leftJoin( 73 tables.spotifyApps, 74 + eq( 75 + tables.spotifyTokens.spotifyAppId, 76 + tables.spotifyApps.spotifyAppId, 77 + ), 78 ) 79 .where(eq(tables.spotifyTokens.userId, user.id)) 80 .execute() 81 .then(([spotifyToken]) => [ 82 decrypt( 83 spotifyToken.spotify_tokens.refreshToken, 84 + env.SPOTIFY_ENCRYPTION_KEY, 85 ), 86 decrypt( 87 spotifyToken.spotify_apps.spotifySecret, 88 + env.SPOTIFY_ENCRYPTION_KEY, 89 ), 90 spotifyToken.spotify_apps.spotifyAppId, 91 ])
+8 -5
apps/api/src/xrpc/app/rocksky/spotify/seek.ts
··· 23 Effect.catchAll((err) => { 24 console.error(err); 25 return Effect.succeed({}); 26 - }) 27 ); 28 server.app.rocksky.spotify.seek({ 29 auth: ctx.authVerifier, ··· 74 .from(tables.spotifyTokens) 75 .leftJoin( 76 tables.spotifyApps, 77 - eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId) 78 ) 79 .where(eq(tables.spotifyTokens.userId, user.id)) 80 .execute() 81 .then(([spotifyToken]) => [ 82 decrypt( 83 spotifyToken.spotify_tokens.refreshToken, 84 - env.SPOTIFY_ENCRYPTION_KEY 85 ), 86 decrypt( 87 spotifyToken.spotify_apps.spotifySecret, 88 - env.SPOTIFY_ENCRYPTION_KEY 89 ), 90 spotifyToken.spotify_apps.spotifyAppId, 91 ]) ··· 150 headers: { 151 Authorization: `Bearer ${accessToken}`, 152 }, 153 - } 154 ).then((res) => res.status), 155 catch: (error) => new Error(`Failed to handle next action: ${error}`), 156 });
··· 23 Effect.catchAll((err) => { 24 console.error(err); 25 return Effect.succeed({}); 26 + }), 27 ); 28 server.app.rocksky.spotify.seek({ 29 auth: ctx.authVerifier, ··· 74 .from(tables.spotifyTokens) 75 .leftJoin( 76 tables.spotifyApps, 77 + eq( 78 + tables.spotifyTokens.spotifyAppId, 79 + tables.spotifyApps.spotifyAppId, 80 + ), 81 ) 82 .where(eq(tables.spotifyTokens.userId, user.id)) 83 .execute() 84 .then(([spotifyToken]) => [ 85 decrypt( 86 spotifyToken.spotify_tokens.refreshToken, 87 + env.SPOTIFY_ENCRYPTION_KEY, 88 ), 89 decrypt( 90 spotifyToken.spotify_apps.spotifySecret, 91 + env.SPOTIFY_ENCRYPTION_KEY, 92 ), 93 spotifyToken.spotify_apps.spotifyAppId, 94 ]) ··· 153 headers: { 154 Authorization: `Bearer ${accessToken}`, 155 }, 156 + }, 157 ).then((res) => res.status), 158 catch: (error) => new Error(`Failed to handle next action: ${error}`), 159 });
+124 -77
crates/analytics/src/handlers/artists.rs
··· 10 }; 11 use actix_web::{web, HttpRequest, HttpResponse}; 12 use anyhow::Error; 13 - use duckdb::Connection; 14 use tokio_stream::StreamExt; 15 16 use crate::read_payload; ··· 26 let offset = pagination.skip.unwrap_or(0); 27 let limit = pagination.take.unwrap_or(20); 28 let did = params.user_did; 29 30 let conn = conn.lock().unwrap(); 31 - let mut stmt = match did { 32 - Some(_) => conn.prepare( 33 - r#" 34 - SELECT a.*, 35 - COUNT(*) AS play_count, 36 - COUNT(DISTINCT s.user_id) AS unique_listeners 37 - FROM user_artists ua 38 - LEFT JOIN artists a ON ua.artist_id = a.id 39 - LEFT JOIN users u ON ua.user_id = u.id 40 - LEFT JOIN scrobbles s ON s.artist_id = a.id 41 - WHERE u.did = ? OR u.handle = ? 42 - GROUP BY a.* 43 - ORDER BY play_count DESC OFFSET ? LIMIT ?; 44 - "#, 45 - )?, 46 - None => conn.prepare( 47 - "SELECT a.*, 48 - COUNT(*) AS play_count, 49 - COUNT(DISTINCT s.user_id) AS unique_listeners 50 - FROM artists a 51 - LEFT JOIN scrobbles s ON s.artist_id = a.id 52 - GROUP BY a.* 53 - ORDER BY play_count DESC OFFSET ? LIMIT ?", 54 - )?, 55 - }; 56 57 - match did { 58 - Some(did) => { 59 - let artists = stmt.query_map( 60 - [&did, &did, &limit.to_string(), &offset.to_string()], 61 - |row| { 62 - Ok(Artist { 63 - id: row.get(0)?, 64 - name: row.get(1)?, 65 - biography: row.get(2)?, 66 - born: row.get(3)?, 67 - born_in: row.get(4)?, 68 - died: row.get(5)?, 69 - picture: row.get(6)?, 70 - sha256: row.get(7)?, 71 - spotify_link: row.get(8)?, 72 - tidal_link: row.get(9)?, 73 - youtube_link: row.get(10)?, 74 - apple_music_link: row.get(11)?, 75 - uri: row.get(12)?, 76 - play_count: row.get(13)?, 77 - unique_listeners: row.get(14)?, 78 - }) 79 - }, 80 - )?; 81 82 - let artists: Result<Vec<_>, _> = artists.collect(); 83 - Ok(HttpResponse::Ok().json(artists?)) 84 - } 85 - None => { 86 - let artists = stmt.query_map([limit, offset], |row| { 87 - Ok(Artist { 88 - id: row.get(0)?, 89 - name: row.get(1)?, 90 - biography: row.get(2)?, 91 - born: row.get(3)?, 92 - born_in: row.get(4)?, 93 - died: row.get(5)?, 94 - picture: row.get(6)?, 95 - sha256: row.get(7)?, 96 - spotify_link: row.get(8)?, 97 - tidal_link: row.get(9)?, 98 - youtube_link: row.get(10)?, 99 - apple_music_link: row.get(11)?, 100 - uri: row.get(12)?, 101 - play_count: row.get(13)?, 102 - unique_listeners: row.get(14)?, 103 - }) 104 - })?; 105 106 - let artists: Result<Vec<_>, _> = artists.collect(); 107 - Ok(HttpResponse::Ok().json(artists?)) 108 - } 109 - } 110 } 111 112 pub async fn get_top_artists(
··· 10 }; 11 use actix_web::{web, HttpRequest, HttpResponse}; 12 use anyhow::Error; 13 + use duckdb::{params_from_iter, Connection}; 14 use tokio_stream::StreamExt; 15 16 use crate::read_payload; ··· 26 let offset = pagination.skip.unwrap_or(0); 27 let limit = pagination.take.unwrap_or(20); 28 let did = params.user_did; 29 + let names = params.names; 30 31 let conn = conn.lock().unwrap(); 32 33 + // Build dynamic query and params based on filters 34 + let (query, params_vec): (String, Vec<Box<dyn duckdb::ToSql>>) = 35 + match (did.as_ref(), names.as_ref()) { 36 + // Both did and names provided 37 + (Some(d), Some(n)) if !n.is_empty() => { 38 + let placeholders = vec!["?"; n.len()].join(", "); 39 + let query = format!( 40 + r#" 41 + SELECT a.*, 42 + COUNT(*) AS play_count, 43 + COUNT(DISTINCT s.user_id) AS unique_listeners 44 + FROM user_artists ua 45 + LEFT JOIN artists a ON ua.artist_id = a.id 46 + LEFT JOIN users u ON ua.user_id = u.id 47 + LEFT JOIN scrobbles s ON s.artist_id = a.id 48 + WHERE (u.did = ? OR u.handle = ?) 49 + AND a.name IN ({}) 50 + GROUP BY a.* 51 + ORDER BY play_count DESC 52 + LIMIT ? OFFSET ? 53 + "#, 54 + placeholders 55 + ); 56 + let mut params: Vec<Box<dyn duckdb::ToSql>> = 57 + vec![Box::new(d.clone()), Box::new(d.clone())]; 58 + for name in n { 59 + params.push(Box::new(name.clone())); 60 + } 61 + params.push(Box::new(limit)); 62 + params.push(Box::new(offset)); 63 + (query, params) 64 + } 65 + // Only did provided 66 + (Some(d), _) => { 67 + let query = r#" 68 + SELECT a.*, 69 + COUNT(*) AS play_count, 70 + COUNT(DISTINCT s.user_id) AS unique_listeners 71 + FROM user_artists ua 72 + LEFT JOIN artists a ON ua.artist_id = a.id 73 + LEFT JOIN users u ON ua.user_id = u.id 74 + LEFT JOIN scrobbles s ON s.artist_id = a.id 75 + WHERE u.did = ? OR u.handle = ? 76 + GROUP BY a.* 77 + ORDER BY play_count DESC 78 + LIMIT ? OFFSET ? 79 + "# 80 + .to_string(); 81 + ( 82 + query, 83 + vec![ 84 + Box::new(d.clone()), 85 + Box::new(d.clone()), 86 + Box::new(limit), 87 + Box::new(offset), 88 + ], 89 + ) 90 + } 91 + // Only names provided 92 + (None, Some(n)) if !n.is_empty() => { 93 + let placeholders = vec!["?"; n.len()].join(", "); 94 + let query = format!( 95 + r#" 96 + SELECT a.*, 97 + COUNT(*) AS play_count, 98 + COUNT(DISTINCT s.user_id) AS unique_listeners 99 + FROM artists a 100 + LEFT JOIN scrobbles s ON s.artist_id = a.id 101 + WHERE a.name IN ({}) 102 + GROUP BY a.* 103 + ORDER BY play_count DESC 104 + LIMIT ? OFFSET ? 105 + "#, 106 + placeholders 107 + ); 108 + let mut params: Vec<Box<dyn duckdb::ToSql>> = vec![]; 109 + for name in n { 110 + params.push(Box::new(name.clone())); 111 + } 112 + params.push(Box::new(limit)); 113 + params.push(Box::new(offset)); 114 + (query, params) 115 + } 116 + // No filters 117 + (None, _) => { 118 + let query = r#" 119 + SELECT a.*, 120 + COUNT(*) AS play_count, 121 + COUNT(DISTINCT s.user_id) AS unique_listeners 122 + FROM artists a 123 + LEFT JOIN scrobbles s ON s.artist_id = a.id 124 + GROUP BY a.* 125 + ORDER BY play_count DESC 126 + LIMIT ? OFFSET ? 127 + "# 128 + .to_string(); 129 + (query, vec![Box::new(limit), Box::new(offset)]) 130 + } 131 + }; 132 133 + // Prepare and execute query 134 + let mut stmt = conn.prepare(&query)?; 135 + let artists = stmt.query_map(params_from_iter(params_vec.iter()), |row| { 136 + Ok(Artist { 137 + id: row.get(0)?, 138 + name: row.get(1)?, 139 + biography: row.get(2)?, 140 + born: row.get(3)?, 141 + born_in: row.get(4)?, 142 + died: row.get(5)?, 143 + picture: row.get(6)?, 144 + sha256: row.get(7)?, 145 + spotify_link: row.get(8)?, 146 + tidal_link: row.get(9)?, 147 + youtube_link: row.get(10)?, 148 + apple_music_link: row.get(11)?, 149 + uri: row.get(12)?, 150 + play_count: row.get(14)?, 151 + unique_listeners: row.get(15)?, 152 + }) 153 + })?; 154 155 + let artists: Result<Vec<_>, _> = artists.collect(); 156 + Ok(HttpResponse::Ok().json(artists?)) 157 } 158 159 pub async fn get_top_artists(
+1
crates/analytics/src/types/artist.rs
··· 60 pub struct GetArtistsParams { 61 pub user_did: Option<String>, 62 pub pagination: Option<Pagination>, 63 } 64 65 #[derive(Debug, Serialize, Deserialize, Default)]
··· 60 pub struct GetArtistsParams { 61 pub user_did: Option<String>, 62 pub pagination: Option<Pagination>, 63 + pub names: Option<Vec<String>>, 64 } 65 66 #[derive(Debug, Serialize, Deserialize, Default)]