A decentralized music tracking and discovery platform built on AT Protocol 馃幍
rocksky.app
spotify
atproto
lastfm
musicbrainz
scrobbling
listenbrainz
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);