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:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
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');
|
||||
};
|
||||
29
src/routes/api/spotify/connect/+server.ts
Normal file
29
src/routes/api/spotify/connect/+server.ts
Normal 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));
|
||||
};
|
||||
@@ -15,6 +15,7 @@ const staticPaths = [
|
||||
'/lists',
|
||||
'/trackers/books',
|
||||
'/trackers/cars',
|
||||
'/trackers/music',
|
||||
'/projects/game-tracker',
|
||||
'/projects/game-tracker/pokemon',
|
||||
'/projects/game-tracker/osu',
|
||||
|
||||
28
src/routes/trackers/music/+page.server.ts
Normal file
28
src/routes/trackers/music/+page.server.ts
Normal 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.'
|
||||
}
|
||||
};
|
||||
};
|
||||
251
src/routes/trackers/music/+page.svelte
Normal file
251
src/routes/trackers/music/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user