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