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

- 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:
2026-10-24 15:56:50 +00:00
parent 45c124a365
commit b757269151
37 changed files with 22124 additions and 6 deletions

View File

@@ -1,10 +1,12 @@
import { fail } from '@sveltejs/kit';
import { listSummaries } from '#lib/server/lists.js';
import { getListening } from '#lib/server/music/stats.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
// `done` is a property of the item now (one owner), so progress is the same for every visitor.
return { lists: await listSummaries() };
const [lists, listening] = await Promise.all([listSummaries(), getListening()]);
return { lists, listening };
};
// Retire the old shared-token login; legacy cookies grant no permissions.

View File

@@ -2,6 +2,7 @@
import { resolve } from '$app/paths';
import portrait from '#lib/assets/stan.jpg?url';
import ProgressBar from '#lib/components/ProgressBar.svelte';
import ListeningCard from '#lib/music/ListeningCard.svelte';
import CatalogGrid from '#lib/components/CatalogGrid.svelte';
import { projects, visibleTrackers } from '#lib/projects.js';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
@@ -99,6 +100,11 @@
>email <Mail size={14} aria-hidden="true" /></a
>
</nav>
{#if data.listening}
<div class="mx-auto mt-7 max-w-sm text-left md:mx-0">
<ListeningCard listening={data.listening} />
</div>
{/if}
</div>
<img
src={portrait}

View 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');
};

View File

@@ -0,0 +1,29 @@
import { SPOTIFY_CLIENT_ID } from '$app/env/private';
import { error, redirect } from '@sveltejs/kit';
import { requireOwner } from '#lib/server/owner.js';
import {
authorizeUrl,
CALLBACK_PATH,
publicOrigin,
STATE_COOKIE
} from '#lib/server/music/oauth.js';
import type { RequestHandler } from './$types';
// Owner only: start the Spotify Authorization Code flow for the listening tracker.
export const GET: RequestHandler = (event) => {
requireOwner(event);
const { url, cookies } = event;
// The state cookie must be set on the host Spotify redirects back to.
if (url.hostname === 'www.stanrunge.dev')
redirect(302, 'https://stanrunge.dev/api/spotify/connect');
if (!SPOTIFY_CLIENT_ID) error(503, 'Spotify is not configured on this server.');
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
cookies.set(STATE_COOKIE, state, {
path: '/api/spotify',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 600
});
redirect(302, authorizeUrl(SPOTIFY_CLIENT_ID, `${publicOrigin(url)}${CALLBACK_PATH}`, state));
};

View File

@@ -15,6 +15,7 @@ const staticPaths = [
'/lists',
'/trackers/books',
'/trackers/cars',
'/trackers/music',
'/projects/game-tracker',
'/projects/game-tracker/pokemon',
'/projects/game-tracker/osu',

View File

@@ -0,0 +1,28 @@
import { isOwner } from '#lib/server/owner.js';
import {
getListening,
getMusicStats,
getSpotifyConnection,
parseRange
} from '#lib/server/music/stats.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const range = parseRange(event.url.searchParams.get('range'));
const owner = isOwner(event);
const [stats, listening, connection] = await Promise.all([
getMusicStats(range),
getListening(),
owner ? getSpotifyConnection() : Promise.resolve(null)
]);
return {
stats,
listening,
connection,
spotifyStatus: owner ? event.url.searchParams.get('spotify') : null,
seo: {
title: 'Music',
description: 'What I listen to: top artists, tracks and albums, listening time and streaks.'
}
};
};

View File

@@ -0,0 +1,251 @@
<script lang="ts">
import ListeningCard from '#lib/music/ListeningCard.svelte';
import { relativeTime } from '#lib/relative-time.js';
import Music from 'lucide-svelte/icons/music';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const s = $derived(data.stats);
const ranges = [
['7d', '7 days'],
['30d', '30 days'],
['all', 'all time']
] as const;
const maxHours = $derived(Math.max(0.01, ...s.daily.map((d) => d.hours)));
const fmtHours = (h: number) =>
h >= 10
? Math.round(h).toLocaleString('en-GB')
: h.toLocaleString('en-GB', { maximumFractionDigits: 1 });
const dayLabel = (day: string) =>
new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
const sourceLabel: Record<string, string> = {
spotify: 'Spotify',
lastfm: 'Last.fm',
youtube: 'YouTube',
local: 'local files'
};
const now = Date.now();
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<header class="mb-8">
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">music</h1>
<p class="mt-3 max-w-2xl text-[var(--site-muted)]">
Everything I listen to, scrobbled from Spotify every minute into my own database (a
self-hosted Last.fm), plus my older Last.fm history.
</p>
</header>
{#if data.connection !== null || data.spotifyStatus}
<aside
class="mb-8 rounded-md border border-[var(--site-border)] p-4 text-sm"
aria-label="Spotify connection (only visible to you)"
>
{#if data.spotifyStatus && data.spotifyStatus !== 'connected'}
<p class="mb-2 text-red-600">Spotify said: {data.spotifyStatus}</p>
{/if}
{#if data.connection}
<p>
Spotify: connected as <strong>{data.connection.user}</strong>
{#if data.connection.lastPollAt}· last poll {relativeTime(
data.connection.lastPollAt,
now
)}{/if}
{#if data.connection.needsReauth}· <strong class="text-red-600">needs reconnecting</strong
>{/if}
</p>
{#if data.connection.lastError}
<p class="mt-1 text-red-600">last error: {data.connection.lastError}</p>
{/if}
{/if}
<a href="/api/spotify/connect" class="mt-2 inline-block underline" data-sveltekit-reload
>{data.connection ? 'reconnect Spotify' : 'connect Spotify'}</a
>
</aside>
{/if}
{#if data.listening}
<div class="mb-8 max-w-md">
<ListeningCard listening={data.listening} href={data.listening.url ?? '/trackers/music'} />
</div>
{/if}
<nav class="mb-6 flex flex-wrap gap-2" aria-label="Period">
{#each ranges as [value, label] (value)}
<a
href="?range={value}"
class="rounded-full border px-3 py-1 text-sm {s.range === value
? 'border-[var(--site-text)] font-medium'
: 'border-[var(--site-border)] text-[var(--site-muted)] hover:text-[var(--site-text)]'}"
aria-current={s.range === value ? 'page' : undefined}
data-sveltekit-noscroll>{label}</a
>
{/each}
</nav>
{#if s.totals.plays === 0}
<div
class="rounded-md border border-dashed border-[var(--site-border)] p-10 text-center text-[var(--site-muted)]"
>
<Music size={28} class="mx-auto mb-3" aria-hidden="true" />
No plays {s.range === 'all' ? 'yet' : 'in this period'}. Scrobbling starts as soon as Spotify
is connected.
</div>
{:else}
<dl class="mb-10 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="stat">
<dt>plays</dt>
<dd>{s.totals.plays.toLocaleString('en-GB')}</dd>
</div>
<div class="stat">
<dt>hours</dt>
<dd>{fmtHours(s.totals.hours)}</dd>
</div>
<div class="stat">
<dt>artists</dt>
<dd>{s.totals.artists.toLocaleString('en-GB')}</dd>
</div>
<div class="stat">
<dt>streak</dt>
<dd>
{s.streak.current} day{s.streak.current === 1 ? '' : 's'}
<span class="block text-xs font-normal text-[var(--site-muted)]"
>longest {s.streak.longest}{#if s.streak.longestStart && s.streak.longest > 1}
({dayLabel(s.streak.longestStart)}–{dayLabel(s.streak.longestEnd ?? '')}){/if}</span
>
</dd>
</div>
</dl>
{/if}
<section class="mb-12" aria-labelledby="daily-heading">
<h2 id="daily-heading" class="mb-3 text-xl font-semibold tracking-tight">
hours per day <span class="text-sm font-normal text-[var(--site-muted)]"
>(last {s.daily.length} days)</span
>
</h2>
<div class="flex h-32 items-end gap-px rounded-md border border-[var(--site-border)] p-2">
{#each s.daily as d (d.day)}
<div
class="flex-1 rounded-sm bg-[#1db954]/80"
style="height: {Math.max(d.hours > 0 ? 3 : 0, (d.hours / maxHours) * 100)}%"
title="{dayLabel(d.day)}: {fmtHours(d.hours)} h, {d.plays} plays"
></div>
{/each}
</div>
<div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]">
<span>{dayLabel(s.daily[0]?.day ?? '')}</span><span>today</span>
</div>
</section>
<div class="grid gap-10 lg:grid-cols-3">
{#each [{ id: 'artists', title: 'top artists', rows: s.topArtists.map( (a) => ({ ...a, sub: `${fmtHours(a.hours)} h` }) ) }, { id: 'tracks', title: 'top tracks', rows: s.topTracks.map( (t) => ({ ...t, sub: t.artists }) ) }, { id: 'albums', title: 'top albums', rows: s.topAlbums.map( (a) => ({ ...a, sub: a.artist }) ) }] as list (list.id)}
<section aria-labelledby="{list.id}-heading">
<h2 id="{list.id}-heading" class="mb-3 text-xl font-semibold tracking-tight">
{list.title}
</h2>
{#if list.rows.length === 0}
<p class="text-sm text-[var(--site-muted)]">nothing yet</p>
{:else}
<ol class="space-y-2">
{#each list.rows as row, i (i)}
<li class="flex items-center gap-3">
<span class="w-5 shrink-0 text-right text-sm text-[var(--site-muted)]">{i + 1}</span
>
{#if row.image}
<img
src={row.image}
alt=""
width="40"
height="40"
loading="lazy"
class="size-10 shrink-0 rounded object-cover {list.id === 'artists'
? 'rounded-full'
: ''}"
/>
{:else}
<span class="size-10 shrink-0 rounded bg-[var(--site-border)]"></span>
{/if}
<span class="min-w-0 flex-1">
{#if row.url}
<a
href={row.url}
rel="external"
class="block truncate font-medium hover:underline">{row.name}</a
>
{:else}
<span class="block truncate font-medium">{row.name}</span>
{/if}
<span class="block truncate text-sm text-[var(--site-muted)]">{row.sub}</span>
</span>
<span class="shrink-0 text-sm text-[var(--site-muted)]">{row.plays}</span>
</li>
{/each}
</ol>
{/if}
</section>
{/each}
</div>
<section class="mt-12" aria-labelledby="recent-heading">
<h2 id="recent-heading" class="mb-3 text-xl font-semibold tracking-tight">recent plays</h2>
{#if s.recent.length === 0}
<p class="text-sm text-[var(--site-muted)]">nothing yet</p>
{:else}
<ul class="divide-y divide-[var(--site-border)]">
{#each s.recent as play (play.id)}
<li class="flex items-center gap-3 py-2">
{#if play.image}
<img
src={play.image}
alt=""
width="36"
height="36"
loading="lazy"
class="size-9 shrink-0 rounded object-cover"
/>
{:else}
<span class="size-9 shrink-0 rounded bg-[var(--site-border)]"></span>
{/if}
<span class="min-w-0 flex-1">
{#if play.url}
<a href={play.url} rel="external" class="block truncate hover:underline"
>{play.track}</a
>
{:else}
<span class="block truncate">{play.track}</span>
{/if}
<span class="block truncate text-sm text-[var(--site-muted)]">{play.artists}</span>
</span>
<time datetime={play.at} class="shrink-0 text-sm text-[var(--site-muted)]"
>{relativeTime(play.at, now)}</time
>
</li>
{/each}
</ul>
{/if}
{#if s.sources.length > 1}
<p class="mt-4 text-xs text-[var(--site-muted)]">
sources: {s.sources
.map((x) => `${sourceLabel[x.source] ?? x.source} ${x.plays.toLocaleString('en-GB')}`)
.join(' · ')}
</p>
{/if}
</section>
</div>
<style>
.stat {
border: 1px solid var(--site-border);
border-radius: 0.375rem;
padding: 0.75rem 1rem;
}
.stat dt {
font-size: 0.875rem;
color: var(--site-muted);
}
.stat dd {
font-size: 1.5rem;
font-weight: 600;
}
</style>