music: self-hosted scrobbler (Spotify poller, Last.fm + streaming-history importers, /trackers/music, homepage listening card)
All checks were successful
Deploy production / deploy (push) Successful in 2m37s
All checks were successful
Deploy production / deploy (push) Successful in 2m37s
- 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
This commit is contained in:
47
src/routes/api/spotify/callback/+server.ts
Normal file
47
src/routes/api/spotify/callback/+server.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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');
|
||||
};
|
||||
Reference in New Issue
Block a user