Files
website/src/routes/api/spotify/callback/+server.ts
Stan Runge b757269151
All checks were successful
Deploy production / deploy (push) Successful in 2m37s
music: self-hosted scrobbler (Spotify poller, Last.fm + streaming-history importers, /trackers/music, homepage listening card)
- music schema: artists/albums/tracks (+album art), plays with source + dedupe, now_playing,
  spotify_auth, import_state; plays_deduped view hides Last.fm scrobbles Spotify also has
- in-app Spotify poller (60s, leased) started from hooks init; no new role or env needed
- /api/spotify/connect + callback (owner only), /trackers/music public stats
- scripts: music-poll, music-import-lastfm (resumable), music-import-history
- deploy/music-collector: optional host timer with its own role
2026-10-24 15:57:40 +00:00

48 lines
2.8 KiB
TypeScript

import { error, redirect } from '@sveltejs/kit';
import { client } from '#lib/server/db/index.js';
import { requireOwner } from '#lib/server/owner.js';
import { exchangeCode, spotifyGet } from '#lib/music/collector.server.js';
import { CALLBACK_PATH, publicOrigin, safeEqual, STATE_COOKIE } from '#lib/server/music/oauth.js';
import { pollNow, spotifyCredentials } from '#lib/server/music/poller.js';
import type { RequestHandler } from './$types';
// Spotify redirects the owner here after /api/spotify/connect. Stores the refresh token in
// music.spotify_auth (app role only) and kicks off the first poll.
export const GET: RequestHandler = async (event) => {
requireOwner(event);
const { url, cookies, fetch } = event;
const expected = cookies.get(STATE_COOKIE) ?? '';
cookies.delete(STATE_COOKIE, { path: '/api/spotify' });
const state = url.searchParams.get('state') ?? '';
if (!expected || !safeEqual(state, expected)) error(400, 'Invalid or expired state, try again.');
if (url.searchParams.get('error'))
redirect(303, `/trackers/music?spotify=${encodeURIComponent(url.searchParams.get('error')!)}`);
const code = url.searchParams.get('code');
if (!code) error(400, 'Missing code');
const creds = spotifyCredentials();
if (!creds.clientId || !creds.clientSecret)
error(503, 'Spotify is not configured on this server.');
const tokens = await exchangeCode(fetch, creds, code, `${publicOrigin(url)}${CALLBACK_PATH}`);
if (!tokens.refresh_token) error(502, 'Spotify did not return a refresh token.');
const me = (await spotifyGet(fetch, tokens.access_token, 'me')) as Record<string, unknown> | null;
const userId = typeof me?.id === 'string' ? me.id : 'unknown';
const name = typeof me?.display_name === 'string' ? me.display_name : null;
await client`
insert into music.spotify_auth (id, spotify_user_id, display_name, refresh_token, access_token,
access_expires_at, scope, needs_reauth, last_error, connected_at, updated_at)
values (1, ${userId}, ${name}, ${tokens.refresh_token}, ${tokens.access_token},
now() + make_interval(secs => ${tokens.expires_in}), ${tokens.scope ?? null}, false, null, now(), now())
on conflict (id) do update set
spotify_user_id = excluded.spotify_user_id, display_name = excluded.display_name,
refresh_token = excluded.refresh_token, access_token = excluded.access_token,
access_expires_at = excluded.access_expires_at, scope = excluded.scope,
needs_reauth = false, last_error = null, lease_until = null,
-- another account starts a fresh recently-played cursor
recent_cursor = case when music.spotify_auth.spotify_user_id = excluded.spotify_user_id
then music.spotify_auth.recent_cursor end,
connected_at = now(), updated_at = now()`;
void pollNow();
redirect(303, '/trackers/music?spotify=connected');
};