A decentralized music tracking and discovery platform built on AT Protocol 🎵
0
fork

Configure Feed

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

Merge branch 'main' into feat/feed-generator

+766 -195
+11 -5
README.md
··· 52 52 - Docker 53 53 - Wasm Pack https://rustwasm.github.io/wasm-pack/installer/ 54 54 - DuckDB https://duckdb.org/docs/installation `1.2.0` 55 + - Spotify `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` from setup in [Spotify developer dashboard](https://developer.spotify.com/documentation/web-api/tutorials/getting-started) 55 56 56 57 ## 🚀 Getting Started 57 58 ··· 80 81 ```bash 81 82 turbo db:migrate --filter=@rocksky/api 82 83 ``` 83 - 6. Populate database (Optional): 84 + 6. Setup Spotify App: 85 + ```bash 86 + # don't forget to set SPOTIFY_ENCRYPTION_KEY and SPOTIFY_ENCRYPTION_IV environment variables 87 + bun run spotify <client_id> <client_secret> 88 + ``` 89 + 7. Populate database (Optional): 84 90 ```bash 85 91 bun run db:pgpull 86 92 ``` 87 93 88 - 7. Start Analytics API: 94 + 8. Start Analytics API: 89 95 ```bash 90 96 bun run dev:analytics 91 97 ``` 92 - 8. Start jetstream: 98 + 9. Start jetstream: 93 99 ```bash 94 100 bun run dev:jetstream 95 101 ``` 96 - 9. Start musicbrainz: 102 + 10. Start musicbrainz: 97 103 ```bash 98 104 bun run mb 99 105 ``` 100 - 10. Start the development server: 106 + 11. Start the development server: 101 107 ```bash 102 108 turbo dev --filter=@rocksky/api --filter=@rocksky/web 103 109 ```
+10
apps/api/drizzle/0004_long_zzzax.sql
··· 1 + CREATE TABLE "spotify_apps" ( 2 + "xata_id" text PRIMARY KEY DEFAULT xata_id() NOT NULL, 3 + "xata_version" integer, 4 + "spotify_app_id" text NOT NULL, 5 + "xata_createdat" timestamp DEFAULT now() NOT NULL, 6 + "xata_updatedat" timestamp DEFAULT now() NOT NULL 7 + ); 8 + --> statement-breakpoint 9 + ALTER TABLE "spotify_accounts" ADD COLUMN "spotify_app_id" text NOT NULL;--> statement-breakpoint 10 + ALTER TABLE "spotify_tokens" ADD COLUMN "spotify_app_id" text NOT NULL;
+2
apps/api/drizzle/0005_same_hydra.sql
··· 1 + ALTER TABLE "spotify_accounts" ALTER COLUMN "spotify_app_id" DROP NOT NULL;--> statement-breakpoint 2 + ALTER TABLE "spotify_apps" ADD COLUMN "spotify_secret" text NOT NULL;
+59 -1
apps/api/drizzle/meta/0004_snapshot.json
··· 1 1 { 2 - "id": "944a6989-c0aa-432f-9b42-4bf221d0c597", 2 + "id": "3294c136-37d7-4c61-8633-26d17c05a059", 3 3 "prevId": "015093fe-a66e-4ec3-b5b6-5466c6266a39", 4 4 "version": "7", 5 5 "dialect": "postgresql", ··· 2384 2384 "notNull": true, 2385 2385 "default": false 2386 2386 }, 2387 + "spotify_app_id": { 2388 + "name": "spotify_app_id", 2389 + "type": "text", 2390 + "primaryKey": false, 2391 + "notNull": true 2392 + }, 2387 2393 "xata_createdat": { 2388 2394 "name": "xata_createdat", 2389 2395 "type": "timestamp", ··· 2421 2427 "checkConstraints": {}, 2422 2428 "isRLSEnabled": false 2423 2429 }, 2430 + "public.spotify_apps": { 2431 + "name": "spotify_apps", 2432 + "schema": "", 2433 + "columns": { 2434 + "xata_id": { 2435 + "name": "xata_id", 2436 + "type": "text", 2437 + "primaryKey": true, 2438 + "notNull": true, 2439 + "default": "xata_id()" 2440 + }, 2441 + "xata_version": { 2442 + "name": "xata_version", 2443 + "type": "integer", 2444 + "primaryKey": false, 2445 + "notNull": false 2446 + }, 2447 + "spotify_app_id": { 2448 + "name": "spotify_app_id", 2449 + "type": "text", 2450 + "primaryKey": false, 2451 + "notNull": true 2452 + }, 2453 + "xata_createdat": { 2454 + "name": "xata_createdat", 2455 + "type": "timestamp", 2456 + "primaryKey": false, 2457 + "notNull": true, 2458 + "default": "now()" 2459 + }, 2460 + "xata_updatedat": { 2461 + "name": "xata_updatedat", 2462 + "type": "timestamp", 2463 + "primaryKey": false, 2464 + "notNull": true, 2465 + "default": "now()" 2466 + } 2467 + }, 2468 + "indexes": {}, 2469 + "foreignKeys": {}, 2470 + "compositePrimaryKeys": {}, 2471 + "uniqueConstraints": {}, 2472 + "policies": {}, 2473 + "checkConstraints": {}, 2474 + "isRLSEnabled": false 2475 + }, 2424 2476 "public.spotify_tokens": { 2425 2477 "name": "spotify_tokens", 2426 2478 "schema": "", ··· 2452 2504 }, 2453 2505 "user_id": { 2454 2506 "name": "user_id", 2507 + "type": "text", 2508 + "primaryKey": false, 2509 + "notNull": true 2510 + }, 2511 + "spotify_app_id": { 2512 + "name": "spotify_app_id", 2455 2513 "type": "text", 2456 2514 "primaryKey": false, 2457 2515 "notNull": true
+66 -2
apps/api/drizzle/meta/0005_snapshot.json
··· 1 1 { 2 - "id": "cb5fb4bd-9dcd-4dcb-a88f-0b8a9deffe67", 3 - "prevId": "944a6989-c0aa-432f-9b42-4bf221d0c597", 2 + "id": "b4f141d3-3e14-4aaf-b3f1-b3165d009289", 3 + "prevId": "3294c136-37d7-4c61-8633-26d17c05a059", 4 4 "version": "7", 5 5 "dialect": "postgresql", 6 6 "tables": { ··· 2390 2390 "notNull": true, 2391 2391 "default": false 2392 2392 }, 2393 + "spotify_app_id": { 2394 + "name": "spotify_app_id", 2395 + "type": "text", 2396 + "primaryKey": false, 2397 + "notNull": false 2398 + }, 2393 2399 "xata_createdat": { 2394 2400 "name": "xata_createdat", 2395 2401 "type": "timestamp", ··· 2427 2433 "checkConstraints": {}, 2428 2434 "isRLSEnabled": false 2429 2435 }, 2436 + "public.spotify_apps": { 2437 + "name": "spotify_apps", 2438 + "schema": "", 2439 + "columns": { 2440 + "xata_id": { 2441 + "name": "xata_id", 2442 + "type": "text", 2443 + "primaryKey": true, 2444 + "notNull": true, 2445 + "default": "xata_id()" 2446 + }, 2447 + "xata_version": { 2448 + "name": "xata_version", 2449 + "type": "integer", 2450 + "primaryKey": false, 2451 + "notNull": false 2452 + }, 2453 + "spotify_app_id": { 2454 + "name": "spotify_app_id", 2455 + "type": "text", 2456 + "primaryKey": false, 2457 + "notNull": true 2458 + }, 2459 + "spotify_secret": { 2460 + "name": "spotify_secret", 2461 + "type": "text", 2462 + "primaryKey": false, 2463 + "notNull": true 2464 + }, 2465 + "xata_createdat": { 2466 + "name": "xata_createdat", 2467 + "type": "timestamp", 2468 + "primaryKey": false, 2469 + "notNull": true, 2470 + "default": "now()" 2471 + }, 2472 + "xata_updatedat": { 2473 + "name": "xata_updatedat", 2474 + "type": "timestamp", 2475 + "primaryKey": false, 2476 + "notNull": true, 2477 + "default": "now()" 2478 + } 2479 + }, 2480 + "indexes": {}, 2481 + "foreignKeys": {}, 2482 + "compositePrimaryKeys": {}, 2483 + "uniqueConstraints": {}, 2484 + "policies": {}, 2485 + "checkConstraints": {}, 2486 + "isRLSEnabled": false 2487 + }, 2430 2488 "public.spotify_tokens": { 2431 2489 "name": "spotify_tokens", 2432 2490 "schema": "", ··· 2458 2516 }, 2459 2517 "user_id": { 2460 2518 "name": "user_id", 2519 + "type": "text", 2520 + "primaryKey": false, 2521 + "notNull": true 2522 + }, 2523 + "spotify_app_id": { 2524 + "name": "spotify_app_id", 2461 2525 "type": "text", 2462 2526 "primaryKey": false, 2463 2527 "notNull": true
+4 -4
apps/api/drizzle/meta/_journal.json
··· 33 33 { 34 34 "idx": 4, 35 35 "version": "7", 36 - "when": 1760169551153, 37 - "tag": "0004_whole_greymalkin", 36 + "when": 1761572359505, 37 + "tag": "0004_long_zzzax", 38 38 "breakpoints": true 39 39 }, 40 40 { 41 41 "idx": 5, 42 42 "version": "7", 43 - "when": 1760250369731, 44 - "tag": "0005_parched_thor_girl", 43 + "when": 1761625200550, 44 + "tag": "0005_same_hydra", 45 45 "breakpoints": true 46 46 } 47 47 ]
+1
apps/api/package.json
··· 14 14 "sync:library": "tsx ./src/scripts/sync-library.ts", 15 15 "avatar": "tsx ./src/scripts/avatar.ts", 16 16 "genres": "tsx ./src/scripts/genres.ts", 17 + "spotify": "tsx ./src/scripts/spotify.ts", 17 18 "exp": "tsx ./src/scripts/exp.ts", 18 19 "pkl:eval": "pkl eval -f json", 19 20 "pkl:gen": "tsx ./scripts/pkl.ts",
+2
apps/api/src/schema/index.ts
··· 23 23 import shoutReports from "./shout-reports"; 24 24 import shouts from "./shouts"; 25 25 import spotifyAccounts from "./spotify-accounts"; 26 + import spotifyApps from "./spotify-apps"; 26 27 import spotifyTokens from "./spotify-tokens"; 27 28 import tracks from "./tracks"; 28 29 import userAlbums from "./user-albums"; ··· 54 55 lovedTracks, 55 56 spotifyAccounts, 56 57 spotifyTokens, 58 + spotifyApps, 57 59 artistTracks, 58 60 artistAlbums, 59 61 dropboxAccounts,
+4 -1
apps/api/src/schema/spotify-accounts.ts
··· 9 9 import users from "./users"; 10 10 11 11 const spotifyAccounts = pgTable("spotify_accounts", { 12 - id: text("xata_id").primaryKey().default(sql`xata_id()`), 12 + id: text("xata_id") 13 + .primaryKey() 14 + .default(sql`xata_id()`), 13 15 xataVersion: integer("xata_version"), 14 16 email: text("email").notNull(), 15 17 userId: text("user_id") 16 18 .notNull() 17 19 .references(() => users.id), 18 20 isBetaUser: boolean("is_beta_user").default(false).notNull(), 21 + spotifyAppId: text("spotify_app_id"), 19 22 createdAt: timestamp("xata_createdat").defaultNow().notNull(), 20 23 updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 21 24 });
+18
apps/api/src/schema/spotify-apps.ts
··· 1 + import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm"; 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").notNull(), 10 + spotifySecret: text("spotify_secret").notNull(), 11 + createdAt: timestamp("xata_createdat").defaultNow().notNull(), 12 + updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 13 + }); 14 + 15 + export type SelectSpotifyApp = InferSelectModel<typeof spotifyApps>; 16 + export type InsertSpotifyApp = InferInsertModel<typeof spotifyApps>; 17 + 18 + export default spotifyApps;
+4 -1
apps/api/src/schema/spotify-tokens.ts
··· 3 3 import users from "./users"; 4 4 5 5 const spotifyTokens = pgTable("spotify_tokens", { 6 - id: text("xata_id").primaryKey().default(sql`xata_id()`), 6 + id: text("xata_id") 7 + .primaryKey() 8 + .default(sql`xata_id()`), 7 9 xataVersion: integer("xata_version"), 8 10 accessToken: text("access_token").notNull(), 9 11 refreshToken: text("refresh_token").notNull(), 10 12 userId: text("user_id") 11 13 .notNull() 12 14 .references(() => users.id), 15 + spotifyAppId: text("spotify_app_id").notNull(), 13 16 createdAt: timestamp("xata_createdat").defaultNow().notNull(), 14 17 updatedAt: timestamp("xata_updatedat").defaultNow().notNull(), 15 18 });
+17 -8
apps/api/src/scripts/genres.ts
··· 11 11 .from(tables.spotifyTokens) 12 12 .leftJoin( 13 13 tables.spotifyAccounts, 14 - eq(tables.spotifyAccounts.userId, tables.spotifyTokens.userId), 14 + eq(tables.spotifyAccounts.userId, tables.spotifyTokens.userId) 15 + ) 16 + .leftJoin( 17 + tables.spotifyApps, 18 + eq(tables.spotifyApps.spotifyAppId, tables.spotifyTokens.spotifyAppId) 15 19 ) 16 20 .where(eq(tables.spotifyAccounts.isBetaUser, true)) 17 - .execute() 18 - .then((res) => res.map(({ spotify_tokens }) => spotify_tokens)); 21 + .execute(); 19 22 20 23 const record = 21 24 spotifyTokens[Math.floor(Math.random() * spotifyTokens.length)]; 22 - const refreshToken = decrypt(record.refreshToken, env.SPOTIFY_ENCRYPTION_KEY); 25 + const refreshToken = decrypt( 26 + record.spotify_tokens.refreshToken, 27 + env.SPOTIFY_ENCRYPTION_KEY 28 + ); 23 29 24 30 const accessToken = await fetch("https://accounts.spotify.com/api/token", { 25 31 method: "POST", ··· 29 35 body: new URLSearchParams({ 30 36 grant_type: "refresh_token", 31 37 refresh_token: refreshToken, 32 - client_id: env.SPOTIFY_CLIENT_ID, 33 - client_secret: env.SPOTIFY_CLIENT_SECRET, 38 + client_id: record.spotify_apps.spotifyAppId, 39 + client_secret: decrypt( 40 + record.spotify_apps.spotifySecret, 41 + env.SPOTIFY_ENCRYPTION_KEY 42 + ), 34 43 }), 35 44 }) 36 45 .then((res) => res.json() as Promise<{ access_token: string }>) ··· 51 60 headers: { 52 61 Authorization: `Bearer ${token}`, 53 62 }, 54 - }, 63 + } 55 64 ) 56 65 .then( 57 66 (res) => ··· 64 73 images: Array<{ url: string }>; 65 74 }>; 66 75 }; 67 - }>, 76 + }> 68 77 ) 69 78 .then(async (data) => _.get(data, "artists.items.0")); 70 79
+30
apps/api/src/scripts/spotify.ts
··· 1 + import chalk from "chalk"; 2 + import { ctx } from "context"; 3 + import { encrypt } from "lib/crypto"; 4 + import { env } from "lib/env"; 5 + import tables from "schema"; 6 + 7 + const args = process.argv.slice(2); 8 + const clientId = args[0]; 9 + const clientSecret = args[1]; 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 + } 20 + 21 + await ctx.db 22 + .insert(tables.spotifyApps) 23 + .values([ 24 + { 25 + spotifyAppId: clientId, 26 + spotifySecret: encrypt(clientSecret, env.SPOTIFY_ENCRYPTION_KEY), 27 + }, 28 + ]) 29 + .onConflictDoNothing() 30 + .execute();
+163 -59
apps/api/src/spotify/app.ts
··· 1 1 import { ctx } from "context"; 2 - import { and, eq, or } from "drizzle-orm"; 2 + import { and, eq, or, sql } from "drizzle-orm"; 3 3 import { Hono } from "hono"; 4 4 import jwt from "jsonwebtoken"; 5 5 import { decrypt, encrypt } from "lib/crypto"; 6 6 import { env } from "lib/env"; 7 + import _ from "lodash"; 7 8 import { requestCounter } from "metrics"; 8 9 import crypto, { createHash } from "node:crypto"; 9 10 import { rateLimiter } from "ratelimiter"; 10 11 import lovedTracks from "schema/loved-tracks"; 11 12 import spotifyAccounts from "schema/spotify-accounts"; 13 + import spotifyApps from "schema/spotify-apps"; 12 14 import spotifyTokens from "schema/spotify-tokens"; 13 15 import tracks from "schema/tracks"; 14 16 import users from "schema/users"; ··· 22 24 limit: 10, // max Spotify API calls 23 25 window: 15, // per 10 seconds 24 26 keyPrefix: "spotify-ratelimit", 25 - }), 27 + }) 26 28 ); 27 29 28 30 app.get("/login", async (c) => { ··· 50 52 return c.text("Unauthorized"); 51 53 } 52 54 55 + const spotifyAccount = await ctx.db 56 + .select() 57 + .from(spotifyAccounts) 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]); 71 + 53 72 const state = crypto.randomBytes(16).toString("hex"); 54 73 ctx.kv.set(state, did); 55 - const redirectUrl = `https://accounts.spotify.com/en/authorize?client_id=${env.SPOTIFY_CLIENT_ID}&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}`; 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}`; 56 75 c.header( 57 76 "Set-Cookie", 58 - `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure`, 77 + `session-id=${state}; Path=/; HttpOnly; SameSite=Strict; Secure` 59 78 ); 60 79 return c.json({ redirectUrl }); 61 80 }); ··· 65 84 const params = new URLSearchParams(c.req.url.split("?")[1]); 66 85 const { code, state } = Object.fromEntries(params.entries()); 67 86 68 - const response = await fetch("https://accounts.spotify.com/api/token", { 69 - method: "POST", 70 - headers: { 71 - "Content-Type": "application/x-www-form-urlencoded", 72 - }, 73 - body: new URLSearchParams({ 74 - grant_type: "authorization_code", 75 - code, 76 - redirect_uri: env.SPOTIFY_REDIRECT_URI, 77 - client_id: env.SPOTIFY_CLIENT_ID, 78 - client_secret: env.SPOTIFY_CLIENT_SECRET, 79 - }), 80 - }); 81 - const { access_token, refresh_token } = await response.json<{ 82 - access_token: string; 83 - refresh_token: string; 84 - }>(); 85 - 86 87 if (!state) { 87 88 return c.redirect(env.FRONTEND_URL); 88 89 } ··· 104 105 return c.redirect(env.FRONTEND_URL); 105 106 } 106 107 108 + const spotifyAccount = await ctx.db 109 + .select() 110 + .from(spotifyAccounts) 111 + .where( 112 + and( 113 + eq(spotifyAccounts.userId, user.id), 114 + eq(spotifyAccounts.isBetaUser, true) 115 + ) 116 + ) 117 + .limit(1) 118 + .then((rows) => rows[0]); 119 + 120 + const spotifyAppId = spotifyAccount.spotifyAppId 121 + ? spotifyAccount.spotifyAppId 122 + : env.SPOTIFY_CLIENT_ID; 123 + 124 + const spotifyAppToken = await ctx.db 125 + .select() 126 + .from(spotifyTokens) 127 + .leftJoin( 128 + spotifyApps, 129 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 130 + ) 131 + .where(eq(spotifyTokens.spotifyAppId, spotifyAppId)) 132 + .limit(1) 133 + .then((rows) => rows[0]); 134 + 135 + const response = await fetch("https://accounts.spotify.com/api/token", { 136 + method: "POST", 137 + headers: { 138 + "Content-Type": "application/x-www-form-urlencoded", 139 + }, 140 + body: new URLSearchParams({ 141 + grant_type: "authorization_code", 142 + code, 143 + redirect_uri: env.SPOTIFY_REDIRECT_URI, 144 + client_id: spotifyAppId, 145 + client_secret: spotifyAppToken?.spotify_apps 146 + ? decrypt( 147 + spotifyAppToken.spotify_apps.spotifySecret, 148 + env.SPOTIFY_ENCRYPTION_KEY 149 + ) 150 + : env.SPOTIFY_CLIENT_SECRET, 151 + }), 152 + }); 153 + const { 154 + access_token, 155 + refresh_token, 156 + }: { 157 + access_token: string; 158 + refresh_token: string; 159 + } = await response.json(); 160 + 107 161 const existingSpotifyToken = await ctx.db 108 162 .select() 109 163 .from(spotifyTokens) ··· 124 178 userId: user.id, 125 179 accessToken: encrypt(access_token, env.SPOTIFY_ENCRYPTION_KEY), 126 180 refreshToken: encrypt(refresh_token, env.SPOTIFY_ENCRYPTION_KEY), 181 + spotifyAppId, 127 182 }); 128 183 } 129 184 ··· 133 188 .where( 134 189 and( 135 190 eq(spotifyAccounts.userId, user.id), 136 - eq(spotifyAccounts.isBetaUser, true), 137 - ), 191 + eq(spotifyAccounts.isBetaUser, true) 192 + ) 138 193 ) 139 194 .limit(1) 140 195 .then((rows) => rows[0]); ··· 176 231 177 232 if (parsed.error) { 178 233 c.status(400); 179 - return c.text("Invalid email: " + parsed.error.message); 234 + return c.text(`Invalid email: ${parsed.error.message}`); 180 235 } 181 236 237 + const apps = await ctx.db 238 + .select({ 239 + appId: spotifyApps.id, 240 + spotifyAppId: spotifyApps.spotifyAppId, 241 + accountCount: sql<number>`COUNT(${spotifyAccounts.id})`.as( 242 + "account_count" 243 + ), 244 + }) 245 + .from(spotifyApps) 246 + .leftJoin(spotifyAccounts, eq(spotifyApps.id, spotifyAccounts.spotifyAppId)) 247 + .groupBy(spotifyApps.id) 248 + .having(sql`COUNT(${spotifyAccounts.id}) < 25`); 249 + 182 250 const { email } = parsed.data; 183 251 184 252 try { ··· 186 254 userId: user.id, 187 255 email, 188 256 isBetaUser: false, 257 + spotifyAppId: _.get(apps, "[0].spotifyAppId"), 189 258 }); 190 259 } catch (e) { 191 260 if (!e.message.includes("duplicate key value violates unique constraint")) { ··· 251 320 } 252 321 253 322 const cached = await ctx.redis.get( 254 - `${spotifyAccount.spotifyAccount.email}:current`, 323 + `${spotifyAccount.spotifyAccount.email}:current` 255 324 ); 256 325 if (!cached) { 257 326 return c.json({}); ··· 261 330 262 331 const sha256 = createHash("sha256") 263 332 .update( 264 - `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase(), 333 + `${track.item.name} - ${track.item.artists.map((x) => x.name).join(", ")} - ${track.item.album.name}`.toLowerCase() 265 334 ) 266 335 .digest("hex"); 267 336 ··· 323 392 const spotifyToken = await ctx.db 324 393 .select() 325 394 .from(spotifyTokens) 395 + .leftJoin( 396 + spotifyApps, 397 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 398 + ) 326 399 .where(eq(spotifyTokens.userId, user.id)) 327 400 .limit(1) 328 401 .then((rows) => rows[0]); ··· 333 406 } 334 407 335 408 const refreshToken = decrypt( 336 - spotifyToken.refreshToken, 337 - env.SPOTIFY_ENCRYPTION_KEY, 409 + spotifyToken.spotify_tokens.refreshToken, 410 + env.SPOTIFY_ENCRYPTION_KEY 338 411 ); 339 412 340 413 // get new access token ··· 346 419 body: new URLSearchParams({ 347 420 grant_type: "refresh_token", 348 421 refresh_token: refreshToken, 349 - client_id: env.SPOTIFY_CLIENT_ID, 350 - client_secret: env.SPOTIFY_CLIENT_SECRET, 422 + client_id: spotifyToken.spotify_apps.spotifyAppId, 423 + client_secret: decrypt( 424 + spotifyToken.spotify_apps.spotifySecret, 425 + env.SPOTIFY_ENCRYPTION_KEY 426 + ), 351 427 }), 352 428 }); 353 429 354 - const { access_token } = await newAccessToken.json<{ 430 + const { access_token } = (await newAccessToken.json()) as { 355 431 access_token: string; 356 - }>(); 432 + }; 357 433 358 434 const response = await fetch("https://api.spotify.com/v1/me/player/pause", { 359 435 method: "PUT", ··· 399 475 const spotifyToken = await ctx.db 400 476 .select() 401 477 .from(spotifyTokens) 478 + .leftJoin( 479 + spotifyApps, 480 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 481 + ) 402 482 .where(eq(spotifyTokens.userId, user.id)) 403 483 .limit(1) 404 484 .then((rows) => rows[0]); ··· 409 489 } 410 490 411 491 const refreshToken = decrypt( 412 - spotifyToken.refreshToken, 413 - env.SPOTIFY_ENCRYPTION_KEY, 492 + spotifyToken.spotify_tokens.refreshToken, 493 + env.SPOTIFY_ENCRYPTION_KEY 414 494 ); 415 495 416 496 // get new access token ··· 422 502 body: new URLSearchParams({ 423 503 grant_type: "refresh_token", 424 504 refresh_token: refreshToken, 425 - client_id: env.SPOTIFY_CLIENT_ID, 426 - client_secret: env.SPOTIFY_CLIENT_SECRET, 505 + client_id: spotifyToken.spotify_apps.spotifyAppId, 506 + client_secret: decrypt( 507 + spotifyToken.spotify_apps.spotifySecret, 508 + env.SPOTIFY_ENCRYPTION_KEY 509 + ), 427 510 }), 428 511 }); 429 512 430 - const { access_token } = await newAccessToken.json<{ 513 + const { access_token } = (await newAccessToken.json()) as { 431 514 access_token: string; 432 - }>(); 515 + }; 433 516 434 517 const response = await fetch("https://api.spotify.com/v1/me/player/play", { 435 518 method: "PUT", ··· 475 558 const spotifyToken = await ctx.db 476 559 .select() 477 560 .from(spotifyTokens) 561 + .leftJoin( 562 + spotifyApps, 563 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 564 + ) 478 565 .where(eq(spotifyTokens.userId, user.id)) 479 566 .limit(1) 480 567 .then((rows) => rows[0]); ··· 485 572 } 486 573 487 574 const refreshToken = decrypt( 488 - spotifyToken.refreshToken, 489 - env.SPOTIFY_ENCRYPTION_KEY, 575 + spotifyToken.spotify_tokens.refreshToken, 576 + env.SPOTIFY_ENCRYPTION_KEY 490 577 ); 491 578 492 579 // get new access token ··· 498 585 body: new URLSearchParams({ 499 586 grant_type: "refresh_token", 500 587 refresh_token: refreshToken, 501 - client_id: env.SPOTIFY_CLIENT_ID, 502 - client_secret: env.SPOTIFY_CLIENT_SECRET, 588 + client_id: spotifyToken.spotify_apps.spotifyAppId, 589 + client_secret: decrypt( 590 + spotifyToken.spotify_apps.spotifySecret, 591 + env.SPOTIFY_ENCRYPTION_KEY 592 + ), 503 593 }), 504 594 }); 505 595 506 - const { access_token } = await newAccessToken.json<{ 596 + const { access_token } = (await newAccessToken.json()) as { 507 597 access_token: string; 508 - }>(); 598 + }; 509 599 510 600 const response = await fetch("https://api.spotify.com/v1/me/player/next", { 511 601 method: "POST", ··· 551 641 const spotifyToken = await ctx.db 552 642 .select() 553 643 .from(spotifyTokens) 644 + .leftJoin( 645 + spotifyApps, 646 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 647 + ) 554 648 .where(eq(spotifyTokens.userId, user.id)) 555 649 .limit(1) 556 650 .then((rows) => rows[0]); ··· 561 655 } 562 656 563 657 const refreshToken = decrypt( 564 - spotifyToken.refreshToken, 565 - env.SPOTIFY_ENCRYPTION_KEY, 658 + spotifyToken.spotify_tokens.refreshToken, 659 + env.SPOTIFY_ENCRYPTION_KEY 566 660 ); 567 661 568 662 // get new access token ··· 574 668 body: new URLSearchParams({ 575 669 grant_type: "refresh_token", 576 670 refresh_token: refreshToken, 577 - client_id: env.SPOTIFY_CLIENT_ID, 578 - client_secret: env.SPOTIFY_CLIENT_SECRET, 671 + client_id: spotifyToken.spotify_apps.spotifyAppId, 672 + client_secret: decrypt( 673 + spotifyToken.spotify_apps.spotifySecret, 674 + env.SPOTIFY_ENCRYPTION_KEY 675 + ), 579 676 }), 580 677 }); 581 678 582 - const { access_token } = await newAccessToken.json<{ 679 + const { access_token } = (await newAccessToken.json()) as { 583 680 access_token: string; 584 - }>(); 681 + }; 585 682 586 683 const response = await fetch( 587 684 "https://api.spotify.com/v1/me/player/previous", ··· 590 687 headers: { 591 688 Authorization: `Bearer ${access_token}`, 592 689 }, 593 - }, 690 + } 594 691 ); 595 692 596 693 if (response.status === 403) { ··· 630 727 const spotifyToken = await ctx.db 631 728 .select() 632 729 .from(spotifyTokens) 730 + .leftJoin( 731 + spotifyApps, 732 + eq(spotifyTokens.spotifyAppId, spotifyApps.spotifyAppId) 733 + ) 633 734 .where(eq(spotifyTokens.userId, user.id)) 634 735 .limit(1) 635 736 .then((rows) => rows[0]); ··· 640 741 } 641 742 642 743 const refreshToken = decrypt( 643 - spotifyToken.refreshToken, 644 - env.SPOTIFY_ENCRYPTION_KEY, 744 + spotifyToken.spotify_tokens.refreshToken, 745 + env.SPOTIFY_ENCRYPTION_KEY 645 746 ); 646 747 647 748 // get new access token ··· 653 754 body: new URLSearchParams({ 654 755 grant_type: "refresh_token", 655 756 refresh_token: refreshToken, 656 - client_id: env.SPOTIFY_CLIENT_ID, 657 - client_secret: env.SPOTIFY_CLIENT_SECRET, 757 + client_id: spotifyToken.spotify_apps.spotifyAppId, 758 + client_secret: decrypt( 759 + spotifyToken.spotify_apps.spotifySecret, 760 + env.SPOTIFY_ENCRYPTION_KEY 761 + ), 658 762 }), 659 763 }); 660 764 661 - const { access_token } = await newAccessToken.json<{ 765 + const { access_token } = (await newAccessToken.json()) as { 662 766 access_token: string; 663 - }>(); 767 + }; 664 768 665 769 const position = c.req.query("position_ms"); 666 770 const response = await fetch( ··· 670 774 headers: { 671 775 Authorization: `Bearer ${access_token}`, 672 776 }, 673 - }, 777 + } 674 778 ); 675 779 676 780 if (response.status === 403) {
+25 -11
apps/api/src/xrpc/app/rocksky/spotify/next.ts
··· 23 23 Effect.catchAll((err) => { 24 24 console.error(err); 25 25 return Effect.succeed({}); 26 - }), 26 + }) 27 27 ); 28 28 server.app.rocksky.spotify.next({ 29 29 auth: ctx.authVerifier, ··· 69 69 ctx.db 70 70 .select() 71 71 .from(tables.spotifyTokens) 72 + .leftJoin( 73 + tables.spotifyApps, 74 + eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId) 75 + ) 72 76 .where(eq(tables.spotifyTokens.userId, user.id)) 73 77 .execute() 74 - .then(([spotifyToken]) => 75 - decrypt(spotifyToken.refreshToken, env.SPOTIFY_ENCRYPTION_KEY), 76 - ) 77 - .then((refreshToken) => ({ 78 - user, 79 - ctx, 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 + ]) 89 + .then(([refreshToken, clientSecret, clientId]) => ({ 80 90 refreshToken, 91 + clientId, 92 + clientSecret, 81 93 })), 82 94 catch: (error) => 83 95 new Error(`Failed to retrieve Spotify Refresh token: ${error}`), ··· 86 98 87 99 const withSpotifyToken = ({ 88 100 refreshToken, 89 - ctx, 101 + clientId, 102 + clientSecret, 90 103 }: { 91 104 refreshToken: string; 92 - ctx: Context; 105 + clientId: string; 106 + clientSecret; 93 107 }) => { 94 108 return Effect.tryPromise({ 95 109 try: () => ··· 101 115 body: new URLSearchParams({ 102 116 grant_type: "refresh_token", 103 117 refresh_token: refreshToken, 104 - client_id: env.SPOTIFY_CLIENT_ID, 105 - client_secret: env.SPOTIFY_CLIENT_SECRET, 118 + client_id: clientId, 119 + client_secret: clientSecret, 106 120 }), 107 121 }) 108 122 .then((res) => res.json() as Promise<{ access_token: string }>)
+25 -10
apps/api/src/xrpc/app/rocksky/spotify/pause.ts
··· 23 23 Effect.catchAll((err) => { 24 24 console.error(err); 25 25 return Effect.succeed({}); 26 - }), 26 + }) 27 27 ); 28 28 server.app.rocksky.spotify.pause({ 29 29 auth: ctx.authVerifier, ··· 69 69 ctx.db 70 70 .select() 71 71 .from(tables.spotifyTokens) 72 + .leftJoin( 73 + tables.spotifyApps, 74 + eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId) 75 + ) 72 76 .where(eq(tables.spotifyTokens.userId, user.id)) 73 77 .execute() 74 - .then(([spotifyToken]) => 75 - decrypt(spotifyToken.refreshToken, env.SPOTIFY_ENCRYPTION_KEY), 76 - ) 77 - .then((refreshToken) => ({ 78 - user, 79 - ctx, 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 + ]) 89 + .then(([refreshToken, clientSecret, clientId]) => ({ 80 90 refreshToken, 91 + clientId, 92 + clientSecret, 81 93 })), 82 94 catch: (error) => 83 95 new Error(`Failed to retrieve Spotify Refresh token: ${error}`), ··· 86 98 87 99 const withSpotifyToken = ({ 88 100 refreshToken, 101 + clientId, 102 + clientSecret, 89 103 }: { 90 104 refreshToken: string; 91 - ctx: Context; 105 + clientId: string; 106 + clientSecret; 92 107 }) => { 93 108 return Effect.tryPromise({ 94 109 try: () => ··· 100 115 body: new URLSearchParams({ 101 116 grant_type: "refresh_token", 102 117 refresh_token: refreshToken, 103 - client_id: env.SPOTIFY_CLIENT_ID, 104 - client_secret: env.SPOTIFY_CLIENT_SECRET, 118 + client_id: clientId, 119 + client_secret: clientSecret, 105 120 }), 106 121 }) 107 122 .then((res) => res.json() as Promise<{ access_token: string }>)
+25 -11
apps/api/src/xrpc/app/rocksky/spotify/play.ts
··· 23 23 Effect.catchAll((err) => { 24 24 console.error(err); 25 25 return Effect.succeed({}); 26 - }), 26 + }) 27 27 ); 28 28 server.app.rocksky.spotify.play({ 29 29 auth: ctx.authVerifier, ··· 69 69 ctx.db 70 70 .select() 71 71 .from(tables.spotifyTokens) 72 + .leftJoin( 73 + tables.spotifyApps, 74 + eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId) 75 + ) 72 76 .where(eq(tables.spotifyTokens.userId, user.id)) 73 77 .execute() 74 - .then(([spotifyToken]) => 75 - decrypt(spotifyToken.refreshToken, env.SPOTIFY_ENCRYPTION_KEY), 76 - ) 77 - .then((refreshToken) => ({ 78 - user, 79 - ctx, 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 + ]) 89 + .then(([refreshToken, clientSecret, clientId]) => ({ 80 90 refreshToken, 91 + clientId, 92 + clientSecret, 81 93 })), 82 94 catch: (error) => 83 95 new Error(`Failed to retrieve Spotify Refresh token: ${error}`), ··· 86 98 87 99 const withSpotifyToken = ({ 88 100 refreshToken, 89 - ctx, 101 + clientId, 102 + clientSecret, 90 103 }: { 91 104 refreshToken: string; 92 - ctx: Context; 105 + clientId: string; 106 + clientSecret; 93 107 }) => { 94 108 return Effect.tryPromise({ 95 109 try: () => ··· 101 115 body: new URLSearchParams({ 102 116 grant_type: "refresh_token", 103 117 refresh_token: refreshToken, 104 - client_id: env.SPOTIFY_CLIENT_ID, 105 - client_secret: env.SPOTIFY_CLIENT_SECRET, 118 + client_id: clientId, 119 + client_secret: clientSecret, 106 120 }), 107 121 }) 108 122 .then((res) => res.json() as Promise<{ access_token: string }>)
+30 -8
apps/api/src/xrpc/app/rocksky/spotify/previous.ts
··· 23 23 Effect.catchAll((err) => { 24 24 console.error(err); 25 25 return Effect.succeed({}); 26 - }), 26 + }) 27 27 ); 28 28 server.app.rocksky.spotify.previous({ 29 29 auth: ctx.authVerifier, ··· 69 69 ctx.db 70 70 .select() 71 71 .from(tables.spotifyTokens) 72 + .leftJoin( 73 + tables.spotifyApps, 74 + eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId) 75 + ) 72 76 .where(eq(tables.spotifyTokens.userId, user.id)) 73 77 .execute() 74 - .then(([spotifyToken]) => 75 - decrypt(spotifyToken.refreshToken, env.SPOTIFY_ENCRYPTION_KEY), 76 - ) 77 - .then((refreshToken) => ({ 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 + ]) 89 + .then(([refreshToken, clientSecret, clientId]) => ({ 78 90 refreshToken, 91 + clientId, 92 + clientSecret, 79 93 })), 80 94 catch: (error) => 81 95 new Error(`Failed to retrieve Spotify Refresh token: ${error}`), 82 96 }); 83 97 }; 84 98 85 - const withSpotifyToken = ({ refreshToken }: { refreshToken: string }) => { 99 + const withSpotifyToken = ({ 100 + refreshToken, 101 + clientId, 102 + clientSecret, 103 + }: { 104 + refreshToken: string; 105 + clientId: string; 106 + clientSecret; 107 + }) => { 86 108 return Effect.tryPromise({ 87 109 try: () => 88 110 fetch("https://accounts.spotify.com/api/token", { ··· 93 115 body: new URLSearchParams({ 94 116 grant_type: "refresh_token", 95 117 refresh_token: refreshToken, 96 - client_id: env.SPOTIFY_CLIENT_ID, 97 - client_secret: env.SPOTIFY_CLIENT_SECRET, 118 + client_id: clientId, 119 + client_secret: clientSecret, 98 120 }), 99 121 }) 100 122 .then((res) => res.json() as Promise<{ access_token: string }>)
+26 -8
apps/api/src/xrpc/app/rocksky/spotify/seek.ts
··· 23 23 Effect.catchAll((err) => { 24 24 console.error(err); 25 25 return Effect.succeed({}); 26 - }), 26 + }) 27 27 ); 28 28 server.app.rocksky.spotify.seek({ 29 29 auth: ctx.authVerifier, ··· 72 72 ctx.db 73 73 .select() 74 74 .from(tables.spotifyTokens) 75 + .leftJoin( 76 + tables.spotifyApps, 77 + eq(tables.spotifyTokens.spotifyAppId, tables.spotifyApps.spotifyAppId) 78 + ) 75 79 .where(eq(tables.spotifyTokens.userId, user.id)) 76 80 .execute() 77 - .then(([spotifyToken]) => 78 - decrypt(spotifyToken.refreshToken, env.SPOTIFY_ENCRYPTION_KEY), 79 - ) 80 - .then((refreshToken) => ({ 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 + ]) 92 + .then(([refreshToken, clientSecret, clientId]) => ({ 81 93 refreshToken, 94 + clientId, 95 + clientSecret, 82 96 params, 83 97 })), 84 98 catch: (error) => ··· 88 102 89 103 const withSpotifyToken = ({ 90 104 refreshToken, 105 + clientSecret, 106 + clientId, 91 107 params, 92 108 }: { 93 109 refreshToken: string; 110 + clientSecret: string; 111 + clientId: string; 94 112 params: QueryParams; 95 113 }) => { 96 114 return Effect.tryPromise({ ··· 103 121 body: new URLSearchParams({ 104 122 grant_type: "refresh_token", 105 123 refresh_token: refreshToken, 106 - client_id: env.SPOTIFY_CLIENT_ID, 107 - client_secret: env.SPOTIFY_CLIENT_SECRET, 124 + client_id: clientId, 125 + client_secret: clientSecret, 108 126 }), 109 127 }) 110 128 .then((res) => res.json() as Promise<{ access_token: string }>) ··· 132 150 headers: { 133 151 Authorization: `Bearer ${accessToken}`, 134 152 }, 135 - }, 153 + } 136 154 ).then((res) => res.status), 137 155 catch: (error) => new Error(`Failed to handle next action: ${error}`), 138 156 });
+9 -1
crates/playlists/src/core.rs
··· 136 136 pool: &Pool<Postgres>, 137 137 offset: usize, 138 138 limit: usize, 139 - ) -> Result<Vec<(String, String, String, String)>, Error> { 139 + ) -> Result<Vec<(String, String, String, String, String, String)>, Error> { 140 140 let results: Vec<SpotifyTokenWithEmail> = sqlx::query_as( 141 141 r#" 142 142 SELECT * FROM spotify_tokens 143 143 LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id 144 144 LEFT JOIN users ON spotify_accounts.user_id = users.xata_id 145 + LEFT JOIN spotify_apps ON spotify_tokens.spotify_app_id = spotify_apps.spotify_app_id 146 + WHERE spotify_accounts.is_beta_user = true 145 147 LIMIT $1 OFFSET $2 146 148 "#, 147 149 ) ··· 157 159 &result.refresh_token, 158 160 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 159 161 )?; 162 + let spotify_secret = decrypt_aes_256_ctr( 163 + &result.spotify_secret, 164 + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 165 + )?; 160 166 user_tokens.push(( 161 167 result.email.clone(), 162 168 token, 163 169 result.did.clone(), 164 170 result.user_id.clone(), 171 + result.spotify_app_id.clone(), 172 + spotify_secret.clone(), 165 173 )); 166 174 } 167 175
+3 -1
crates/playlists/src/lib.rs
··· 53 53 let token = user.1.clone(); 54 54 let did = user.2.clone(); 55 55 let user_id = user.3.clone(); 56 - let playlists = get_user_playlists(token).await?; 56 + let client_id = user.4.clone(); 57 + let client_secret = user.5.clone(); 58 + let playlists = get_user_playlists(token, client_id, client_secret).await?; 57 59 save_playlists(&pool, conn.clone(), nc.clone(), playlists, &user_id, &did).await?; 58 60 } 59 61
+11 -10
crates/playlists/src/spotify.rs
··· 5 5 6 6 use crate::types::{self, token::AccessToken}; 7 7 8 - pub async fn refresh_token(token: &str) -> Result<AccessToken, Error> { 9 - if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { 10 - panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); 11 - } 12 - 13 - let client_id = env::var("SPOTIFY_CLIENT_ID")?; 14 - let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; 15 - 8 + pub async fn refresh_token( 9 + token: &str, 10 + client_id: &str, 11 + client_secret: &str, 12 + ) -> Result<AccessToken, Error> { 16 13 let client = Client::new(); 17 14 18 15 let response = client ··· 29 26 Ok(token) 30 27 } 31 28 32 - pub async fn get_user_playlists(token: String) -> Result<Vec<types::playlist::Playlist>, Error> { 33 - let token = refresh_token(&token).await?; 29 + pub async fn get_user_playlists( 30 + token: String, 31 + client_id: String, 32 + client_secret: String, 33 + ) -> Result<Vec<types::playlist::Playlist>, Error> { 34 + let token = refresh_token(&token, &client_id, &client_secret).await?; 34 35 let client = Client::new(); 35 36 let response = client 36 37 .get("https://api.spotify.com/v1/me/playlists")
+4
crates/playlists/src/types/spotify_token.rs
··· 12 12 pub user_id: String, 13 13 pub access_token: String, 14 14 pub refresh_token: String, 15 + pub spotify_app_id: String, 16 + pub spotify_secret: String, 15 17 } 16 18 17 19 #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] ··· 27 29 pub refresh_token: String, 28 30 pub email: String, 29 31 pub did: String, 32 + pub spotify_app_id: String, 33 + pub spotify_secret: String, 30 34 }
+1
crates/scrobbler/src/repo/spotify_account.rs
··· 11 11 r#" 12 12 SELECT * FROM spotify_accounts 13 13 LEFT JOIN users ON spotify_accounts.user_id = users.xata_id 14 + LEFT JOIN spotify_apps ON spotify_accounts.spotify_app_id = spotify_apps.spotify_app_id 14 15 WHERE users.did = $1 15 16 "#, 16 17 )
+2 -1
crates/scrobbler/src/repo/spotify_token.rs
··· 12 12 SELECT * FROM spotify_tokens 13 13 LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id 14 14 LEFT JOIN users ON spotify_accounts.user_id = users.xata_id 15 - WHERE is_beta_user = true 15 + LEFT JOIN spotify_apps ON spotify_tokens.spotify_app_id = spotify_apps.spotify_app_id 16 16 WHERE users.did = $1 17 17 "#, 18 18 ) ··· 36 36 SELECT * FROM spotify_tokens 37 37 LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id 38 38 LEFT JOIN users ON spotify_accounts.user_id = users.xata_id 39 + LEFT JOIN spotify_apps ON spotify_tokens.spotify_app_id = spotify_apps.spotify_app_id 39 40 LIMIT $1 40 41 "#, 41 42 )
+21 -3
crates/scrobbler/src/scrobbler.rs
··· 188 188 let mut rng = rand::rng(); 189 189 let random_index = rng.random_range(0..spofity_tokens.len()); 190 190 let spotify_token = &spofity_tokens[random_index]; 191 + let client_id = spotify_token.spotify_app_id.clone(); 192 + 193 + let client_secret = decrypt_aes_256_ctr( 194 + &spotify_token.spotify_secret, 195 + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 196 + )?; 191 197 192 198 let spotify_token = decrypt_aes_256_ctr( 193 199 &spotify_token.refresh_token, 194 200 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 195 201 )?; 196 202 197 - let spotify_token = refresh_token(&spotify_token).await?; 203 + let spotify_token = refresh_token(&spotify_token, &client_id, &client_secret).await?; 198 204 let spotify_client = SpotifyClient::new(&spotify_token.access_token); 199 205 200 206 let result = spotify_client ··· 362 368 let mut rng = rand::rng(); 363 369 let random_index = rng.random_range(0..spofity_tokens.len()); 364 370 let spotify_token = &spofity_tokens[random_index]; 371 + let client_id = spotify_token.spotify_app_id.clone(); 372 + 373 + let client_secret = decrypt_aes_256_ctr( 374 + &spotify_token.spotify_secret, 375 + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 376 + )?; 365 377 366 378 let spotify_token = decrypt_aes_256_ctr( 367 379 &spotify_token.refresh_token, 368 380 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 369 381 )?; 370 382 371 - let spotify_token = refresh_token(&spotify_token).await?; 383 + let spotify_token = refresh_token(&spotify_token, &client_id, &client_secret).await?; 372 384 let spotify_client = SpotifyClient::new(&spotify_token.access_token); 373 385 374 386 let result = spotify_client ··· 615 627 let random_index = rng.random_range(0..spofity_tokens.len()); 616 628 let spotify_token = &spofity_tokens[random_index]; 617 629 630 + let client_id = spotify_token.spotify_app_id.clone(); 631 + let client_secret = decrypt_aes_256_ctr( 632 + &spotify_token.spotify_secret, 633 + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 634 + )?; 635 + 618 636 let spotify_token = decrypt_aes_256_ctr( 619 637 &spotify_token.refresh_token, 620 638 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 621 639 )?; 622 640 623 - let spotify_token = refresh_token(&spotify_token).await?; 641 + let spotify_token = refresh_token(&spotify_token, &client_id, &client_secret).await?; 624 642 let spotify_client = SpotifyClient::new(&spotify_token.access_token); 625 643 626 644 let result = spotify_client
+5 -10
crates/scrobbler/src/spotify/mod.rs
··· 1 - use std::env; 2 - 3 1 use anyhow::Error; 4 2 use reqwest::Client; 5 3 use types::AccessToken; ··· 7 5 pub mod client; 8 6 pub mod types; 9 7 10 - pub async fn refresh_token(token: &str) -> Result<AccessToken, Error> { 11 - if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { 12 - panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); 13 - } 14 - 15 - let client_id = env::var("SPOTIFY_CLIENT_ID")?; 16 - let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; 17 - 8 + pub async fn refresh_token( 9 + token: &str, 10 + client_id: &str, 11 + client_secret: &str, 12 + ) -> Result<AccessToken, Error> { 18 13 let client = Client::new(); 19 14 20 15 let response = client
+1
crates/scrobbler/src/xata/mod.rs
··· 2 2 pub mod api_key; 3 3 pub mod artist; 4 4 pub mod spotify_account; 5 + pub mod spotify_apps; 5 6 pub mod spotify_token; 6 7 pub mod track; 7 8 pub mod user;
+2
crates/scrobbler/src/xata/spotify_account.rs
··· 12 12 pub email: String, 13 13 pub user_id: String, 14 14 pub is_beta_user: bool, 15 + pub spotify_app_id: Option<String>, 16 + pub spotify_secret: Option<String>, 15 17 }
+14
crates/scrobbler/src/xata/spotify_apps.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] 5 + pub struct SpotifyApp { 6 + pub xata_id: String, 7 + pub xata_version: i32, 8 + #[serde(with = "chrono::serde::ts_seconds")] 9 + pub xata_createdat: DateTime<Utc>, 10 + #[serde(with = "chrono::serde::ts_seconds")] 11 + pub xata_updatedat: DateTime<Utc>, 12 + pub spotify_app_id: String, 13 + pub spotify_secret: String, 14 + }
+4
crates/scrobbler/src/xata/spotify_token.rs
··· 12 12 pub user_id: String, 13 13 pub access_token: String, 14 14 pub refresh_token: String, 15 + pub spotify_app_id: String, 16 + pub spotify_secret: String, 15 17 } 16 18 17 19 #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] ··· 27 29 pub refresh_token: String, 28 30 pub email: String, 29 31 pub did: String, 32 + pub spotify_app_id: String, 33 + pub spotify_secret: String, 30 34 }
-4
crates/spotify/Cargo.toml
··· 6 6 license.workspace = true 7 7 repository.workspace = true 8 8 9 - [[bin]] 10 - name = "spotify" 11 - path = "src/main.rs" 12 - 13 9 [dependencies] 14 10 aes = "0.8.4" 15 11 anyhow = "1.0.96"
+109 -23
crates/spotify/src/lib.rs
··· 58 58 let email = user.0.clone(); 59 59 let token = user.1.clone(); 60 60 let did = user.2.clone(); 61 + let client_id = user.3.clone(); 62 + let client_secret = user.4.clone(); 61 63 let stop_flag = Arc::new(AtomicBool::new(false)); 62 64 let cache = cache.clone(); 63 65 let nc = nc.clone(); ··· 71 73 thread::spawn(move || { 72 74 let rt = tokio::runtime::Runtime::new().unwrap(); 73 75 match rt.block_on(async { 74 - watch_currently_playing(email.clone(), token, did, stop_flag, cache.clone()) 75 - .await?; 76 + watch_currently_playing( 77 + email.clone(), 78 + token, 79 + did, 80 + stop_flag, 81 + cache.clone(), 82 + client_id, 83 + client_secret, 84 + ) 85 + .await?; 76 86 Ok::<(), Error>(()) 77 87 }) { 78 88 Ok(_) => {} ··· 140 150 let email = user.0.clone(); 141 151 let token = user.1.clone(); 142 152 let did = user.2.clone(); 153 + let client_id = user.3.clone(); 154 + let client_secret = user.4.clone(); 143 155 let cache = cache.clone(); 144 156 145 157 thread::spawn(move || { ··· 151 163 did, 152 164 new_stop_flag, 153 165 cache.clone(), 166 + client_id, 167 + client_secret, 154 168 ) 155 169 .await?; 156 170 Ok::<(), Error>(()) ··· 178 192 let email = user.0.clone(); 179 193 let token = user.1.clone(); 180 194 let did = user.2.clone(); 195 + let client_id = user.3.clone(); 196 + let client_secret = user.4.clone(); 181 197 let stop_flag = Arc::new(AtomicBool::new(false)); 182 198 let cache = cache.clone(); 183 199 let nc = nc.clone(); ··· 193 209 did, 194 210 stop_flag, 195 211 cache.clone(), 212 + client_id, 213 + client_secret, 196 214 ) 197 215 .await?; 198 216 Ok::<(), Error>(()) ··· 227 245 Ok(()) 228 246 } 229 247 230 - pub async fn refresh_token(token: &str) -> Result<AccessToken, Error> { 231 - if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { 232 - panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); 233 - } 234 - 235 - let client_id = env::var("SPOTIFY_CLIENT_ID")?; 236 - let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; 237 - 248 + pub async fn refresh_token( 249 + token: &str, 250 + client_id: &str, 251 + client_secret: &str, 252 + ) -> Result<AccessToken, Error> { 238 253 let client = Client::new(); 239 254 240 255 let response = client ··· 255 270 cache: Cache, 256 271 user_id: &str, 257 272 token: &str, 273 + client_id: &str, 274 + client_secret: &str, 258 275 ) -> Result<Option<(CurrentlyPlaying, bool)>, Error> { 259 276 if let Ok(Some(data)) = cache.get(user_id) { 260 277 println!( ··· 329 346 return Ok(Some((data, changed))); 330 347 } 331 348 332 - let token = refresh_token(token).await?; 349 + let token = refresh_token(token, client_id, client_secret).await?; 333 350 let client = Client::new(); 334 351 let response = client 335 352 .get(format!("{}/me/player/currently-playing", BASE_URL)) ··· 529 546 cache: Cache, 530 547 artist_id: &str, 531 548 token: &str, 549 + client_id: &str, 550 + client_secret: &str, 532 551 ) -> Result<Option<Artist>, Error> { 533 552 if let Ok(Some(data)) = cache.get(artist_id) { 534 553 return Ok(Some(serde_json::from_str(&data)?)); 535 554 } 536 555 537 - let token = refresh_token(token).await?; 556 + let token = refresh_token(token, client_id, client_secret).await?; 538 557 let client = Client::new(); 539 558 let response = client 540 559 .get(&format!("{}/artists/{}", BASE_URL, artist_id)) ··· 569 588 Ok(Some(serde_json::from_str(&data)?)) 570 589 } 571 590 572 - pub async fn get_album(cache: Cache, album_id: &str, token: &str) -> Result<Option<Album>, Error> { 591 + pub async fn get_album( 592 + cache: Cache, 593 + album_id: &str, 594 + token: &str, 595 + client_id: &str, 596 + client_secret: &str, 597 + ) -> Result<Option<Album>, Error> { 573 598 if let Ok(Some(data)) = cache.get(album_id) { 574 599 return Ok(Some(serde_json::from_str(&data)?)); 575 600 } 576 601 577 - let token = refresh_token(token).await?; 602 + let token = refresh_token(token, client_id, client_secret).await?; 578 603 let client = Client::new(); 579 604 let response = client 580 605 .get(&format!("{}/albums/{}", BASE_URL, album_id)) ··· 613 638 cache: Cache, 614 639 album_id: &str, 615 640 token: &str, 641 + client_id: &str, 642 + client_secret: &str, 616 643 ) -> Result<AlbumTracks, Error> { 617 644 if let Ok(Some(data)) = cache.get(&format!("{}:tracks", album_id)) { 618 645 return Ok(serde_json::from_str(&data)?); 619 646 } 620 647 621 - let token = refresh_token(token).await?; 648 + let token = refresh_token(token, client_id, client_secret).await?; 622 649 let client = Client::new(); 623 650 let mut all_tracks = Vec::new(); 624 651 let mut offset = 0; ··· 678 705 pool: &Pool<Postgres>, 679 706 offset: usize, 680 707 limit: usize, 681 - ) -> Result<Vec<(String, String, String, String)>, Error> { 708 + ) -> Result<Vec<(String, String, String, String, String, String)>, Error> { 682 709 let results: Vec<SpotifyTokenWithEmail> = sqlx::query_as( 683 710 r#" 684 711 SELECT * FROM spotify_tokens 685 712 LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id 686 713 LEFT JOIN users ON spotify_accounts.user_id = users.xata_id 714 + LEFT JOIN spotify_apps ON spotify_tokens.spotify_app_id = spotify_apps.spotify_app_id 687 715 LIMIT $1 OFFSET $2 688 716 "#, 689 717 ) ··· 699 727 &result.refresh_token, 700 728 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 701 729 )?; 730 + let spotify_secret = decrypt_aes_256_ctr( 731 + &result.spotify_secret, 732 + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 733 + )?; 702 734 user_tokens.push(( 703 735 result.email.clone(), 704 736 token, 705 737 result.did.clone(), 706 738 result.user_id.clone(), 739 + result.spotify_app_id.clone(), 740 + spotify_secret, 707 741 )); 708 742 } 709 743 ··· 713 747 pub async fn find_spotify_user( 714 748 pool: &Pool<Postgres>, 715 749 email: &str, 716 - ) -> Result<Option<(String, String, String)>, Error> { 750 + ) -> Result<Option<(String, String, String, String, String)>, Error> { 717 751 let result: Vec<SpotifyTokenWithEmail> = sqlx::query_as( 718 752 r#" 719 753 SELECT * FROM spotify_tokens 720 754 LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id 721 755 LEFT JOIN users ON spotify_accounts.user_id = users.xata_id 756 + LEFT JOIN spotify_apps ON spotify_tokens.spotify_app_id = spotify_apps.spotify_app_id 722 757 WHERE spotify_accounts.email = $1 723 758 "#, 724 759 ) ··· 732 767 &result.refresh_token, 733 768 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 734 769 )?; 735 - Ok(Some((result.email.clone(), token, result.did.clone()))) 770 + let spotify_secret = decrypt_aes_256_ctr( 771 + &result.spotify_secret, 772 + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 773 + )?; 774 + Ok(Some(( 775 + result.email.clone(), 776 + token, 777 + result.did.clone(), 778 + result.spotify_app_id.clone(), 779 + spotify_secret, 780 + ))) 736 781 } 737 782 None => Ok(None), 738 783 } ··· 744 789 did: String, 745 790 stop_flag: Arc<AtomicBool>, 746 791 cache: Cache, 792 + client_id: String, 793 + client_secret: String, 747 794 ) -> Result<(), Error> { 748 795 println!( 749 796 "{} {}", ··· 832 879 let token = token.clone(); 833 880 let did = did.clone(); 834 881 let cache = cache.clone(); 882 + let client_id = client_id.clone(); 883 + let client_secret = client_secret.clone(); 835 884 836 - let currently_playing = get_currently_playing(cache.clone(), &spotify_email, &token).await; 885 + let currently_playing = get_currently_playing( 886 + cache.clone(), 887 + &spotify_email, 888 + &token, 889 + &client_id, 890 + &client_secret, 891 + ) 892 + .await; 837 893 let currently_playing = match currently_playing { 838 894 Ok(currently_playing) => currently_playing, 839 895 Err(e) => { ··· 867 923 ); 868 924 869 925 if changed { 870 - scrobble(cache.clone(), &spotify_email, &did, &token).await?; 926 + scrobble( 927 + cache.clone(), 928 + &spotify_email, 929 + &did, 930 + &token, 931 + &client_id, 932 + &client_secret, 933 + ) 934 + .await?; 871 935 872 936 thread::spawn(move || { 873 937 let rt = tokio::runtime::Runtime::new().unwrap(); 874 938 match rt.block_on(async { 875 - get_album_tracks(cache.clone(), &data_item.album.id, &token).await?; 876 - get_album(cache.clone(), &data_item.album.id, &token).await?; 877 - update_library(cache.clone(), &spotify_email, &did, &token).await?; 939 + get_album_tracks( 940 + cache.clone(), 941 + &data_item.album.id, 942 + &token, 943 + &client_id, 944 + &client_secret, 945 + ) 946 + .await?; 947 + get_album( 948 + cache.clone(), 949 + &data_item.album.id, 950 + &token, 951 + &client_id, 952 + &client_secret, 953 + ) 954 + .await?; 955 + update_library( 956 + cache.clone(), 957 + &spotify_email, 958 + &did, 959 + &token, 960 + &client_id, 961 + &client_secret, 962 + ) 963 + .await?; 878 964 Ok::<(), Error>(()) 879 965 }) { 880 966 Ok(_) => {}
+14 -1
crates/spotify/src/rocksky.rs
··· 18 18 spotify_email: &str, 19 19 did: &str, 20 20 refresh_token: &str, 21 + client_id: &str, 22 + client_secret: &str, 21 23 ) -> Result<(), Error> { 22 24 let cached = cache.get(spotify_email)?; 23 25 if cached.is_none() { ··· 40 42 cache.clone(), 41 43 &track_item.artists.first().unwrap().id, 42 44 &refresh_token, 45 + &client_id, 46 + &client_secret, 43 47 ) 44 48 .await?; 45 49 ··· 98 102 spotify_email: &str, 99 103 did: &str, 100 104 refresh_token: &str, 105 + client_id: &str, 106 + client_secret: &str, 101 107 ) -> Result<(), Error> { 102 108 let cached = cache.get(spotify_email)?; 103 109 if cached.is_none() { ··· 105 111 "No currently playing song is cached for {}, refreshing", 106 112 spotify_email 107 113 ); 108 - get_currently_playing(cache.clone(), &spotify_email, &refresh_token).await?; 114 + get_currently_playing( 115 + cache.clone(), 116 + &spotify_email, 117 + &refresh_token, 118 + client_id, 119 + client_secret, 120 + ) 121 + .await?; 109 122 } 110 123 111 124 let cached = cache.get(spotify_email)?;
+2
crates/spotify/src/types/spotify_account.rs
··· 12 12 pub email: String, 13 13 pub user_id: String, 14 14 pub is_beta_user: bool, 15 + pub spotify_app_id: Option<String>, 16 + pub spotify_secret: Option<String>, 15 17 }
+4
crates/spotify/src/types/spotify_token.rs
··· 12 12 pub user_id: String, 13 13 pub access_token: String, 14 14 pub refresh_token: String, 15 + pub spotify_app_id: String, 16 + pub spotify_secret: String, 15 17 } 16 18 17 19 #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] ··· 27 29 pub refresh_token: String, 28 30 pub email: String, 29 31 pub did: String, 32 + pub spotify_app_id: String, 33 + pub spotify_secret: String, 30 34 }
+1
crates/webscrobbler/src/repo/spotify_account.rs
··· 9 9 let results: Vec<SpotifyAccount> = sqlx::query_as( 10 10 r#" 11 11 SELECT * FROM spotify_accounts 12 + LEFT JOIN spotify_apps ON spotify_accounts.spotify_app_id = spotify_apps.spotify_app_id 12 13 WHERE user_id = $1 13 14 "#, 14 15 )
+2
crates/webscrobbler/src/repo/spotify_token.rs
··· 12 12 SELECT * FROM spotify_tokens 13 13 LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id 14 14 LEFT JOIN users ON spotify_accounts.user_id = users.xata_id 15 + LEFT JOIN spotify_apps ON spotify_tokens.spotify_app_id = spotify_apps.spotify_app_id 15 16 WHERE users.did = $1 16 17 "#, 17 18 ) ··· 35 36 SELECT * FROM spotify_tokens 36 37 LEFT JOIN spotify_accounts ON spotify_tokens.user_id = spotify_accounts.user_id 37 38 LEFT JOIN users ON spotify_accounts.user_id = users.xata_id 39 + LEFT JOIN spotify_apps ON spotify_tokens.spotify_app_id = spotify_apps.spotify_app_id 38 40 WHERE is_beta_user = true 39 41 LIMIT $1 40 42 "#,
+7 -1
crates/webscrobbler/src/scrobbler.rs
··· 84 84 let mut rng = rand::rng(); 85 85 let random_index = rng.random_range(0..spofity_tokens.len()); 86 86 let spotify_token = &spofity_tokens[random_index]; 87 + let client_id = spotify_token.spotify_app_id.clone(); 88 + 89 + let client_secret = decrypt_aes_256_ctr( 90 + &spotify_token.spotify_secret, 91 + &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 92 + )?; 87 93 88 94 let spotify_token = decrypt_aes_256_ctr( 89 95 &spotify_token.refresh_token, 90 96 &hex::decode(env::var("SPOTIFY_ENCRYPTION_KEY")?)?, 91 97 )?; 92 98 93 - let spotify_token = refresh_token(&spotify_token).await?; 99 + let spotify_token = refresh_token(&spotify_token, &client_id, &client_secret).await?; 94 100 let spotify_client = SpotifyClient::new(&spotify_token.access_token); 95 101 96 102 let query = match scrobble.data.song.parsed.artist.contains(" x ") {
+5 -10
crates/webscrobbler/src/spotify/mod.rs
··· 1 - use std::env; 2 - 3 1 use anyhow::Error; 4 2 use reqwest::Client; 5 3 use types::AccessToken; ··· 7 5 pub mod client; 8 6 pub mod types; 9 7 10 - pub async fn refresh_token(token: &str) -> Result<AccessToken, Error> { 11 - if env::var("SPOTIFY_CLIENT_ID").is_err() || env::var("SPOTIFY_CLIENT_SECRET").is_err() { 12 - panic!("Please set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET environment variables"); 13 - } 14 - 15 - let client_id = env::var("SPOTIFY_CLIENT_ID")?; 16 - let client_secret = env::var("SPOTIFY_CLIENT_SECRET")?; 17 - 8 + pub async fn refresh_token( 9 + token: &str, 10 + client_id: &str, 11 + client_secret: &str, 12 + ) -> Result<AccessToken, Error> { 18 13 let client = Client::new(); 19 14 20 15 let response = client
+1
crates/webscrobbler/src/xata/mod.rs
··· 1 1 pub mod album; 2 2 pub mod artist; 3 3 pub mod spotify_account; 4 + pub mod spotify_apps; 4 5 pub mod spotify_token; 5 6 pub mod track; 6 7 pub mod user;
+2
crates/webscrobbler/src/xata/spotify_account.rs
··· 12 12 pub email: String, 13 13 pub user_id: String, 14 14 pub is_beta_user: bool, 15 + pub spotify_app_id: Option<String>, 16 + pub spotify_secret: Option<String>, 15 17 }
+14
crates/webscrobbler/src/xata/spotify_apps.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::Deserialize; 3 + 4 + #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] 5 + pub struct SpotifyApp { 6 + pub xata_id: String, 7 + pub xata_version: i32, 8 + #[serde(with = "chrono::serde::ts_seconds")] 9 + pub xata_createdat: DateTime<Utc>, 10 + #[serde(with = "chrono::serde::ts_seconds")] 11 + pub xata_updatedat: DateTime<Utc>, 12 + pub spotify_app_id: String, 13 + pub spotify_secret: String, 14 + }
+4
crates/webscrobbler/src/xata/spotify_token.rs
··· 12 12 pub user_id: String, 13 13 pub access_token: String, 14 14 pub refresh_token: String, 15 + pub spotify_app_id: String, 16 + pub spotify_secret: String, 15 17 } 16 18 17 19 #[derive(Debug, Deserialize, sqlx::FromRow, Default, Clone)] ··· 27 29 pub refresh_token: String, 28 30 pub email: String, 29 31 pub did: String, 32 + pub spotify_app_id: String, 33 + pub spotify_secret: String, 30 34 }
+2 -1
package.json
··· 27 27 "dev:webscrobbler": "cargo run -p rockskyd --release -- webscrobbler", 28 28 "dev:tracklist": "cargo run -p rockskyd --release -- tracklist", 29 29 "db:pgpull": "cargo run -p rockskyd --release -- pull && rm -f rocksky-analytics.ddb* rocksky-feed.ddb* && curl -o rocksky-analytics.ddb https://backup.rocksky.app/rocksky-analytics.ddb && curl -o rocksky-feed.ddb https://backup.rocksky.app/rocksky-feed.ddb", 30 - "mb": "cd musicbrainz && go run main.go" 30 + "mb": "cd musicbrainz && go run main.go", 31 + "spotidy": "cd apps/api && bun run spotify" 31 32 }, 32 33 "workspaces": [ 33 34 "apps/api",