A decentralized music tracking and discovery platform built on AT Protocol 馃幍 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
at main 136 lines 4.0 kB view raw
1import { consola } from "consola"; 2import { ctx } from "context"; 3import { eq, isNull } from "drizzle-orm"; 4import { decrypt } from "lib/crypto"; 5import { env } from "lib/env"; 6import _ from "lodash"; 7import tables from "schema"; 8 9async function getSpotifyToken(): Promise<string> { 10 const spotifyTokens = await ctx.db 11 .select() 12 .from(tables.spotifyTokens) 13 .leftJoin( 14 tables.spotifyAccounts, 15 eq(tables.spotifyAccounts.userId, tables.spotifyTokens.userId), 16 ) 17 .leftJoin( 18 tables.spotifyApps, 19 eq(tables.spotifyApps.spotifyAppId, tables.spotifyTokens.spotifyAppId), 20 ) 21 .where(eq(tables.spotifyAccounts.isBetaUser, true)) 22 .execute(); 23 24 const record = 25 spotifyTokens[Math.floor(Math.random() * spotifyTokens.length)]; 26 const refreshToken = decrypt( 27 record.spotify_tokens.refreshToken, 28 env.SPOTIFY_ENCRYPTION_KEY, 29 ); 30 31 const accessToken = await fetch("https://accounts.spotify.com/api/token", { 32 method: "POST", 33 headers: { 34 "Content-Type": "application/x-www-form-urlencoded", 35 }, 36 body: new URLSearchParams({ 37 grant_type: "refresh_token", 38 refresh_token: refreshToken, 39 client_id: record.spotify_apps.spotifyAppId, 40 client_secret: decrypt( 41 record.spotify_apps.spotifySecret, 42 env.SPOTIFY_ENCRYPTION_KEY, 43 ), 44 }), 45 }) 46 .then((res) => res.json() as Promise<{ access_token: string }>) 47 .then((data) => data.access_token); 48 49 return accessToken; 50} 51 52async function getGenresAndPicture(artists) { 53 for (const artist of artists) { 54 do { 55 try { 56 const token = await getSpotifyToken(); 57 // search artist by name on spotify 58 const result = await fetch( 59 `https://api.spotify.com/v1/search?q=${encodeURIComponent(artist.name)}&type=artist&limit=1`, 60 { 61 headers: { 62 Authorization: `Bearer ${token}`, 63 }, 64 }, 65 ) 66 .then( 67 (res) => 68 res.json() as Promise<{ 69 artists: { 70 items: Array<{ 71 id: string; 72 name: string; 73 genres: string[]; 74 images: Array<{ url: string }>; 75 }>; 76 }; 77 }>, 78 ) 79 .then(async (data) => _.get(data, "artists.items.0")); 80 81 if (result) { 82 consola.info(JSON.stringify(result, null, 2), "\n"); 83 if (result.genres && result.genres.length > 0) { 84 await ctx.db 85 .update(tables.artists) 86 .set({ genres: result.genres }) 87 .where(eq(tables.artists.id, artist.id)) 88 .execute(); 89 } 90 // update artist picture if not set 91 if (!artist.picture && result.images && result.images.length > 0) { 92 await ctx.db 93 .update(tables.artists) 94 .set({ picture: result.images[0].url }) 95 .where(eq(tables.artists.id, artist.id)) 96 .execute(); 97 } 98 } 99 break; // exit the retry loop on success 100 } catch (error) { 101 consola.error("Error fetching genres for artist:", artist.name, error); 102 // wait for a while before retrying 103 await new Promise((resolve) => setTimeout(resolve, 1000)); 104 } 105 // biome-ignore lint/correctness/noConstantCondition: true 106 } while (true); 107 108 // sleep for a while to avoid rate limiting 109 await new Promise((resolve) => setTimeout(resolve, 1000)); 110 } 111} 112 113const PAGE_SIZE = 1000; 114 115const count = await ctx.db 116 .select() 117 .from(tables.artists) 118 .where(isNull(tables.artists.genres)) 119 .execute() 120 .then((res) => res.length); 121 122for (let offset = 0; offset < count; offset += PAGE_SIZE) { 123 const artists = await ctx.db 124 .select() 125 .from(tables.artists) 126 .where(isNull(tables.artists.genres)) 127 .offset(offset) 128 .limit(PAGE_SIZE) 129 .execute(); 130 131 await getGenresAndPicture(artists); 132} 133 134consola.info(`Artists without genres: ${count}`); 135 136process.exit(0);