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

@@ -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>