diff --git a/.env.example b/.env.example
index 6d00e88..ebea37b 100644
--- a/.env.example
+++ b/.env.example
@@ -2,3 +2,4 @@ DISCORD_TOKEN=
DISCORD_USER_ID=
DISCORD_GUILD_ID=
OMDB_API_KEY=
+LASTFM_API_KEY=
diff --git a/.gitignore b/.gitignore
index b23ffe6..896d20c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ vite.config.ts.timestamp-*
bun.lock
__pycache__/
.cache/
+data/
Update.md
PROJECT.md
blogs/
diff --git a/Dockerfile b/Dockerfile
index 239c536..e273478 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,7 +18,7 @@ COPY package.json ./
RUN bun install --production --ignore-scripts
COPY --from=build /app/build ./build
-RUN mkdir -p /app/.cache/music && chown -R bun:bun /app/.cache
+RUN mkdir -p /app/.cache/music /app/data/lastfm && chown -R bun:bun /app/.cache /app/data
ENV NODE_ENV=production
ENV PORT=3000
diff --git a/content/about.json b/content/about.json
index 0db5814..232751a 100644
--- a/content/about.json
+++ b/content/about.json
@@ -1,5 +1,6 @@
{
"anilistUsername": "SirBlob",
+ "lastfmUsername": "Sir_Blob_",
"hobbies": [
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
diff --git a/docker-compose.yml b/docker-compose.yml
index f4e38c6..6820bdf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -9,3 +9,8 @@ services:
PORT: 3000
ports:
- "${HOST_PORT:-3000}:3000"
+ volumes:
+ - lastfm-data:/app/data/lastfm
+
+volumes:
+ lastfm-data:
diff --git a/src/env.ts b/src/env.ts
index 0c532ac..77624b0 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -18,5 +18,9 @@ export const variables = defineEnvVars({
OMDB_API_KEY: {
schema: optional,
description: 'OMDb API key used to load posters and details for shows and movies'
+ },
+ LASTFM_API_KEY: {
+ schema: optional,
+ description: 'Last.fm API key used to collect scrobbles for the listening stats'
}
});
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index 71f6173..3012cf0 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -1,8 +1,11 @@
import type { ServerInit } from '@sveltejs/kit';
import { getDiscordClient } from '#lib/server/discord';
import { refreshMusicCache } from '#lib/server/musicCache';
+import { startLastfmSync } from '#lib/server/lastfm/sync';
+import { about } from '#lib/content';
export const init: ServerInit = () => {
getDiscordClient();
refreshMusicCache();
+ startLastfmSync(about.lastfmUsername);
};
diff --git a/src/lib/components/about/listening/ActivityPanel.svelte b/src/lib/components/about/listening/ActivityPanel.svelte
new file mode 100644
index 0000000..a2f9a93
--- /dev/null
+++ b/src/lib/components/about/listening/ActivityPanel.svelte
@@ -0,0 +1,24 @@
+
+
+
+
+
+
diff --git a/src/lib/components/about/listening/BarChart.svelte b/src/lib/components/about/listening/BarChart.svelte
new file mode 100644
index 0000000..3a383d6
--- /dev/null
+++ b/src/lib/components/about/listening/BarChart.svelte
@@ -0,0 +1,71 @@
+
+
+
+
+ {#each buckets as bucket, index (index)}
+
+ {/each}
+
+
+ {#each buckets as bucket, index (index)}
+ {bucket.label}
+ {/each}
+
+ {#if peak?.count}
+
peak: {peak.title} ({peak.count} {unit})
+ {/if}
+
+
+
diff --git a/src/lib/components/about/listening/ListeningFetch.svelte b/src/lib/components/about/listening/ListeningFetch.svelte
new file mode 100644
index 0000000..d10a496
--- /dev/null
+++ b/src/lib/components/about/listening/ListeningFetch.svelte
@@ -0,0 +1,64 @@
+
+
+
+
+
diff --git a/src/lib/components/about/listening/ListeningSection.svelte b/src/lib/components/about/listening/ListeningSection.svelte
new file mode 100644
index 0000000..cc4d54a
--- /dev/null
+++ b/src/lib/components/about/listening/ListeningSection.svelte
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+ Top charts
+
+
+
+ Full history on Last.fm
+ {listening.storedScrobbles.toLocaleString('en-US')} scrobbles collected
+
+
+
diff --git a/src/lib/components/about/listening/RecentTracks.svelte b/src/lib/components/about/listening/RecentTracks.svelte
new file mode 100644
index 0000000..14f1b3f
--- /dev/null
+++ b/src/lib/components/about/listening/RecentTracks.svelte
@@ -0,0 +1,157 @@
+
+
+{#if tracks.length}
+
+{:else}
+ No scrobbles yet.
+{/if}
+
+
diff --git a/src/lib/components/about/listening/TabRow.svelte b/src/lib/components/about/listening/TabRow.svelte
new file mode 100644
index 0000000..c6b4fb0
--- /dev/null
+++ b/src/lib/components/about/listening/TabRow.svelte
@@ -0,0 +1,41 @@
+
+
+
+ {#each tabs as tab (tab.id)}
+
+ {/each}
+
+
+
diff --git a/src/lib/components/about/listening/TopCharts.svelte b/src/lib/components/about/listening/TopCharts.svelte
new file mode 100644
index 0000000..2457e0e
--- /dev/null
+++ b/src/lib/components/about/listening/TopCharts.svelte
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
+
+ {#each lists as list (list.title)}
+
+ {@render chartList(list.items, list.showCovers)}
+
+ {/each}
+
+
+
+{#snippet chartList(items: ChartItem[], showCovers: boolean)}
+ {#if items.length}
+
+ {#each items as item, index (item.url)}
+ -
+ {index + 1}
+ {#if showCovers}
+ {#if item.image}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+ {item.name}
+ {#if item.artist}
+ {item.artist}
+ {/if}
+
+ {item.count}
+
+ {/each}
+
+ {:else}
+ Nothing here yet.
+ {/if}
+{/snippet}
+
+
diff --git a/src/lib/components/about/listening/TrendingArtists.svelte b/src/lib/components/about/listening/TrendingArtists.svelte
new file mode 100644
index 0000000..3d68850
--- /dev/null
+++ b/src/lib/components/about/listening/TrendingArtists.svelte
@@ -0,0 +1,103 @@
+
+
+{#if artists.length}
+
+ {#each artists as artist, index (artist.name)}
+ {@const change = changeOf(artist)}
+ -
+ {index + 1}
+ {artist.name}
+
+ {artist.count}
+
+ {#if change}
+ {change.text}
+ {/if}
+
+
+ {/each}
+
+{:else}
+ Nothing played this week.
+{/if}
+
+
diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts
index 6aca701..0c4da1e 100644
--- a/src/lib/content/types.ts
+++ b/src/lib/content/types.ts
@@ -139,6 +139,7 @@ export interface RatedTitle {
export interface AboutContent {
anilistUsername?: string;
+ lastfmUsername?: string;
hobbies: Hobby[];
games: Game[];
shows: RatedTitle[];
diff --git a/src/lib/lastfm/types.ts b/src/lib/lastfm/types.ts
new file mode 100644
index 0000000..d7107b5
--- /dev/null
+++ b/src/lib/lastfm/types.ts
@@ -0,0 +1,63 @@
+export type ListeningPeriod = 'week' | 'month' | 'year' | 'all';
+
+export interface PlayedTrack {
+ track: string;
+ artist: string;
+ album: string;
+ url: string;
+ image: string | null;
+ playedAt: number | null;
+}
+
+export interface CountBucket {
+ label: string;
+ title: string;
+ count: number;
+}
+
+export interface ChartItem {
+ name: string;
+ artist: string | null;
+ count: number;
+ url: string;
+ image: string | null;
+}
+
+export interface TopCharts {
+ artists: ChartItem[];
+ albums: ChartItem[];
+ tracks: ChartItem[];
+}
+
+export interface TrendingArtist {
+ name: string;
+ url: string;
+ count: number;
+ previousCount: number;
+}
+
+export interface ListeningTotals {
+ scrobbles: number;
+ artists: number;
+ albums: number;
+ tracks: number;
+}
+
+export interface ListeningProfile {
+ userName: string;
+ profileUrl: string;
+ registeredAt: number | null;
+ totals: ListeningTotals;
+ nowPlaying: PlayedTrack | null;
+ recentTracks: PlayedTrack[];
+ daily: CountBucket[];
+ monthly: CountBucket[];
+ hourly: CountBucket[];
+ averagePerDay: number;
+ currentStreak: number;
+ longestStreak: number;
+ trending: TrendingArtist[];
+ top: Record;
+ storedScrobbles: number;
+ syncedAt: number | null;
+}
diff --git a/src/lib/server/lastfm/api.ts b/src/lib/server/lastfm/api.ts
new file mode 100644
index 0000000..67697a4
--- /dev/null
+++ b/src/lib/server/lastfm/api.ts
@@ -0,0 +1,134 @@
+import { LASTFM_API_KEY } from '$app/env/private';
+import type { PlayedTrack } from '#lib/lastfm/types';
+
+const apiUrl = 'https://ws.audioscrobbler.com/2.0/';
+const pageSize = 200;
+const placeholderImageId = '2a96cbd8b46e442fc41c2b86b821562f';
+
+interface RawImage {
+ size: string;
+ '#text': string;
+}
+
+interface RawTrack {
+ name: string;
+ url: string;
+ artist: { '#text': string };
+ album: { '#text': string };
+ image?: RawImage[];
+ date?: { uts: string };
+ '@attr'?: { nowplaying?: string };
+}
+
+interface RawRecentTracks {
+ recenttracks: {
+ track: RawTrack[] | RawTrack;
+ '@attr': { totalPages: string };
+ };
+}
+
+interface RawUserInfo {
+ user: {
+ name: string;
+ url: string;
+ playcount: string;
+ artist_count: string;
+ album_count: string;
+ track_count: string;
+ registered?: { unixtime: string };
+ };
+}
+
+interface RawTrackInfo {
+ track?: { album?: { image?: RawImage[] } };
+}
+
+export interface UserInfo {
+ name: string;
+ url: string;
+ scrobbles: number;
+ artists: number;
+ albums: number;
+ tracks: number;
+ registeredAt: number | null;
+}
+
+export interface RecentTracksPage {
+ tracks: PlayedTrack[];
+ nowPlaying: PlayedTrack | null;
+ totalPages: number;
+}
+
+export function hasLastfmKey(): boolean {
+ return Boolean(LASTFM_API_KEY);
+}
+
+async function callLastfm(method: string, params: Record): Promise {
+ const query = new URLSearchParams({ method, api_key: LASTFM_API_KEY ?? '', format: 'json', ...params });
+ const response = await fetch(`${apiUrl}?${query}`, { headers: { 'User-Agent': 'sirblob-website' } });
+ if (!response.ok) throw new Error(`Last.fm ${method} returned ${response.status}`);
+
+ const body = await response.json();
+ if (body.error) throw new Error(`Last.fm ${method} failed: ${body.message}`);
+ return body as T;
+}
+
+function largestImage(images: RawImage[] | undefined): string | null {
+ const url = images?.at(-1)?.['#text'];
+ return url && !url.includes(placeholderImageId) ? url : null;
+}
+
+function toPlayedTrack(raw: RawTrack): PlayedTrack {
+ return {
+ track: raw.name,
+ artist: raw.artist['#text'],
+ album: raw.album['#text'],
+ url: raw.url,
+ image: largestImage(raw.image),
+ playedAt: raw.date ? Number(raw.date.uts) : null
+ };
+}
+
+export async function fetchUserInfo(userName: string): Promise {
+ const { user } = await callLastfm('user.getinfo', { user: userName });
+ return {
+ name: user.name,
+ url: user.url,
+ scrobbles: Number(user.playcount),
+ artists: Number(user.artist_count),
+ albums: Number(user.album_count),
+ tracks: Number(user.track_count),
+ registeredAt: user.registered ? Number(user.registered.unixtime) : null
+ };
+}
+
+export async function fetchRecentTracksPage(
+ userName: string,
+ page: number,
+ range: { from?: number; to: number }
+): Promise {
+ const params: Record = { user: userName, page: `${page}`, limit: `${pageSize}`, to: `${range.to}` };
+ if (range.from) params.from = `${range.from}`;
+
+ const { recenttracks } = await callLastfm('user.getrecenttracks', params);
+ const rawTracks = Array.isArray(recenttracks.track) ? recenttracks.track : [recenttracks.track];
+ const nowPlayingRaw = rawTracks.find((raw) => raw['@attr']?.nowplaying === 'true');
+
+ return {
+ tracks: rawTracks.filter((raw) => raw.date).map(toPlayedTrack),
+ nowPlaying: nowPlayingRaw ? toPlayedTrack(nowPlayingRaw) : null,
+ totalPages: Number(recenttracks['@attr'].totalPages)
+ };
+}
+
+export async function fetchNowPlaying(userName: string): Promise {
+ const { recenttracks } = await callLastfm('user.getrecenttracks', { user: userName, limit: '1' });
+ const rawTracks = Array.isArray(recenttracks.track) ? recenttracks.track : [recenttracks.track];
+ const nowPlayingRaw = rawTracks.find((raw) => raw['@attr']?.nowplaying === 'true');
+ return nowPlayingRaw ? toPlayedTrack(nowPlayingRaw) : null;
+}
+
+export async function fetchTrackImage(artist: string, track: string): Promise {
+ const { track: info } = await callLastfm('track.getinfo', { artist, track, autocorrect: '1' });
+ return largestImage(info?.album?.image);
+}
diff --git a/src/lib/server/lastfm/calendar.ts b/src/lib/server/lastfm/calendar.ts
new file mode 100644
index 0000000..9d902e0
--- /dev/null
+++ b/src/lib/server/lastfm/calendar.ts
@@ -0,0 +1,50 @@
+const timeZone = 'America/New_York';
+const secondsPerHour = 3600;
+const millisecondsPerDay = 24 * 60 * 60 * 1000;
+
+const partsFormatter = new Intl.DateTimeFormat('en-CA', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ hourCycle: 'h23'
+});
+
+const hourCache = new Map();
+
+export function localTime(unixSeconds: number): { dayKey: string; hour: number } {
+ const hourIndex = Math.floor(unixSeconds / secondsPerHour);
+ const cached = hourCache.get(hourIndex);
+ if (cached) return cached;
+
+ const parts = Object.fromEntries(
+ partsFormatter.formatToParts(new Date(hourIndex * secondsPerHour * 1000)).map((part) => [part.type, part.value])
+ );
+ const result = { dayKey: `${parts.year}-${parts.month}-${parts.day}`, hour: Number(parts.hour) };
+ hourCache.set(hourIndex, result);
+ return result;
+}
+
+export function todayKey(): string {
+ return localTime(Math.floor(Date.now() / 1000)).dayKey;
+}
+
+export function shiftDayKey(dayKey: string, days: number): string {
+ const date = new Date(`${dayKey}T00:00:00Z`);
+ return new Date(date.getTime() + days * millisecondsPerDay).toISOString().slice(0, 10);
+}
+
+export function shiftMonthKey(monthKey: string, months: number): string {
+ const [year, month] = monthKey.split('-').map(Number);
+ const date = new Date(Date.UTC(year, month - 1 + months, 1));
+ return date.toISOString().slice(0, 7);
+}
+
+export function daysBetween(firstKey: string, secondKey: string): number {
+ return Math.round((Date.parse(`${secondKey}T00:00:00Z`) - Date.parse(`${firstKey}T00:00:00Z`)) / millisecondsPerDay);
+}
+
+export function formatDayLabel(dayKey: string, options: Intl.DateTimeFormatOptions): string {
+ return new Date(`${dayKey}T12:00:00Z`).toLocaleDateString('en-US', { ...options, timeZone: 'UTC' });
+}
diff --git a/src/lib/server/lastfm/covers.ts b/src/lib/server/lastfm/covers.ts
new file mode 100644
index 0000000..26d153b
--- /dev/null
+++ b/src/lib/server/lastfm/covers.ts
@@ -0,0 +1,140 @@
+import { createHash } from 'node:crypto';
+import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fetchTrackImage } from './api';
+
+const dataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm');
+const albumCoversPath = path.join(dataDirectory, 'covers.json');
+const trackCoversPath = path.join(dataDirectory, 'track-covers.json');
+const imageDirectory = path.join(dataDirectory, 'images');
+const imageIdPattern = /^[0-9a-f]{16}$/;
+const lookupDelay = 300;
+const notFound = '';
+
+let albumCovers: Record = {};
+let trackCovers: Record = {};
+const savedImages = new Map();
+const queuedDownloads = new Set();
+const queuedLookups = new Map();
+let loading: Promise | null = null;
+let workerRunning = false;
+let version = 0;
+
+const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
+const pairKey = (first: string, second: string) => `${first}\n${second}`;
+const imageId = (url: string) => createHash('sha1').update(url).digest('hex').slice(0, 16);
+
+async function readJson(filePath: string): Promise> {
+ try {
+ return JSON.parse(await readFile(filePath, 'utf8'));
+ } catch {
+ return {};
+ }
+}
+
+async function loadFromDisk() {
+ await mkdir(imageDirectory, { recursive: true });
+ albumCovers = await readJson(albumCoversPath);
+ trackCovers = await readJson(trackCoversPath);
+ for (const fileName of await readdir(imageDirectory)) {
+ savedImages.set(path.parse(fileName).name, fileName);
+ }
+}
+
+export function loadCovers(): Promise {
+ loading ??= loadFromDisk();
+ return loading;
+}
+
+export function coversVersion(): number {
+ return version;
+}
+
+function servedImage(url: string): string {
+ const id = imageId(url);
+ if (savedImages.has(id)) return `/api/lastfm/covers/${id}`;
+ queuedDownloads.add(url);
+ runWorker();
+ return url;
+}
+
+export function coverFor(artist: string, album: string, track?: string): string | null {
+ const url = (album && albumCovers[pairKey(artist, album)]) || (track && trackCovers[pairKey(artist, track)]) || null;
+ return url ? servedImage(url) : null;
+}
+
+export async function rememberAlbumCovers(entries: { artist: string; album: string; image: string | null }[]) {
+ let changed = false;
+ for (const { artist, album, image } of entries) {
+ const key = pairKey(artist, album);
+ if (!album || !image || albumCovers[key] === image) continue;
+ albumCovers[key] = image;
+ changed = true;
+ }
+ if (!changed) return;
+ version++;
+ await writeFile(albumCoversPath, JSON.stringify(albumCovers));
+}
+
+export function requestTrackCovers(tracks: { artist: string; track: string }[]) {
+ for (const { artist, track } of tracks) {
+ const key = pairKey(artist, track);
+ if (key in trackCovers || queuedLookups.has(key)) continue;
+ queuedLookups.set(key, { artist, track });
+ }
+ runWorker();
+}
+
+async function downloadImage(url: string) {
+ const id = imageId(url);
+ if (savedImages.has(id)) return;
+
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`cover download returned ${response.status}`);
+
+ const fileName = `${id}${path.extname(new URL(url).pathname) || '.jpg'}`;
+ await writeFile(path.join(imageDirectory, fileName), Buffer.from(await response.arrayBuffer()));
+ savedImages.set(id, fileName);
+ version++;
+}
+
+async function lookUpTrackCover(key: string, artist: string, track: string) {
+ const image = await fetchTrackImage(artist, track);
+ trackCovers[key] = image ?? notFound;
+ await writeFile(trackCoversPath, JSON.stringify(trackCovers));
+ if (image) version++;
+}
+
+async function processQueues() {
+ while (queuedLookups.size || queuedDownloads.size) {
+ const [lookupKey, lookup] = queuedLookups.entries().next().value ?? [];
+ if (lookupKey && lookup) {
+ queuedLookups.delete(lookupKey);
+ await lookUpTrackCover(lookupKey, lookup.artist, lookup.track).catch((error) =>
+ console.error(`Last.fm cover lookup failed for ${lookup.track}:`, error instanceof Error ? error.message : error)
+ );
+ await wait(lookupDelay);
+ }
+
+ const url = queuedDownloads.values().next().value;
+ if (url) {
+ queuedDownloads.delete(url);
+ await downloadImage(url).catch((error) =>
+ console.error('Last.fm cover download failed:', error instanceof Error ? error.message : error)
+ );
+ }
+ }
+}
+
+function runWorker() {
+ if (workerRunning) return;
+ workerRunning = true;
+ loadCovers()
+ .then(processQueues)
+ .finally(() => (workerRunning = false));
+}
+
+export function savedCoverPath(id: string): string | null {
+ const fileName = imageIdPattern.test(id) ? savedImages.get(id) : undefined;
+ return fileName ? path.join(imageDirectory, fileName) : null;
+}
diff --git a/src/lib/server/lastfm/profile.ts b/src/lib/server/lastfm/profile.ts
new file mode 100644
index 0000000..28321e5
--- /dev/null
+++ b/src/lib/server/lastfm/profile.ts
@@ -0,0 +1,126 @@
+import type { ListeningProfile, PlayedTrack } from '#lib/lastfm/types';
+import { fetchNowPlaying, fetchUserInfo, hasLastfmKey, type UserInfo } from './api';
+import { loadScrobbleStore, storedScrobbles } from './store';
+import { coverFor, coversVersion, loadCovers, requestTrackCovers } from './covers';
+import { lastfmSyncedAt } from './sync';
+import {
+ countsByDay,
+ dailyBuckets,
+ hourlyBuckets,
+ listeningStreaks,
+ monthlyBuckets,
+ topChartsByPeriod,
+ trendingArtists
+} from './trends';
+
+const userInfoDuration = 30 * 60 * 1000;
+const nowPlayingDuration = 60 * 1000;
+const summaryDuration = 5 * 60 * 1000;
+const recentCount = 8;
+
+type Summary = Omit;
+
+let userInfoCache: { info: UserInfo; fetchedAt: number } | null = null;
+let nowPlayingCache: { track: PlayedTrack | null; fetchedAt: number } | null = null;
+let summaryCache: { summary: Summary; scrobbleCount: number; coversVersion: number; builtAt: number } | null = null;
+
+async function cachedUserInfo(userName: string): Promise {
+ if (userInfoCache && Date.now() - userInfoCache.fetchedAt < userInfoDuration) return userInfoCache.info;
+ try {
+ userInfoCache = { info: await fetchUserInfo(userName), fetchedAt: Date.now() };
+ } catch (error) {
+ console.error('Last.fm user lookup failed:', error instanceof Error ? error.message : error);
+ }
+ return userInfoCache?.info ?? null;
+}
+
+async function cachedNowPlaying(userName: string): Promise {
+ if (nowPlayingCache && Date.now() - nowPlayingCache.fetchedAt < nowPlayingDuration) return nowPlayingCache.track;
+ try {
+ nowPlayingCache = { track: await fetchNowPlaying(userName), fetchedAt: Date.now() };
+ } catch (error) {
+ console.error('Last.fm now playing failed:', error instanceof Error ? error.message : error);
+ }
+ return nowPlayingCache?.track ?? null;
+}
+
+function recentTracks(): PlayedTrack[] {
+ return storedScrobbles()
+ .slice(-recentCount)
+ .reverse()
+ .map((scrobble) => ({
+ ...scrobble,
+ url: `https://www.last.fm/music/${encodeURIComponent(scrobble.artist)}/_/${encodeURIComponent(scrobble.track)}`,
+ image: coverFor(scrobble.artist, scrobble.album, scrobble.track)
+ }));
+}
+
+function buildSummary(): Summary {
+ const scrobbles = storedScrobbles();
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const dayCounts = countsByDay(scrobbles);
+ const daily = dailyBuckets(dayCounts);
+ const streaks = listeningStreaks(dayCounts);
+
+ return {
+ recentTracks: recentTracks(),
+ daily,
+ monthly: monthlyBuckets(dayCounts),
+ hourly: hourlyBuckets(scrobbles),
+ averagePerDay: Math.round(daily.reduce((total, bucket) => total + bucket.count, 0) / daily.length),
+ currentStreak: streaks.current,
+ longestStreak: streaks.longest,
+ trending: trendingArtists(scrobbles, nowSeconds),
+ top: topChartsByPeriod(scrobbles, nowSeconds),
+ storedScrobbles: scrobbles.length
+ };
+}
+
+function tracksMissingCovers(summary: Summary) {
+ return Object.values(summary.top)
+ .flatMap((charts) => charts.tracks)
+ .concat(summary.recentTracks.map((track) => ({ ...track, name: track.track, count: 0 })))
+ .filter((item) => !item.image && item.artist)
+ .map((item) => ({ artist: item.artist!, track: item.name }));
+}
+
+function cachedSummary(): Summary {
+ const scrobbleCount = storedScrobbles().length;
+ const currentCoversVersion = coversVersion();
+ const isFresh =
+ summaryCache &&
+ summaryCache.scrobbleCount === scrobbleCount &&
+ summaryCache.coversVersion === currentCoversVersion &&
+ Date.now() - summaryCache.builtAt < summaryDuration;
+
+ if (!isFresh) {
+ const summary = buildSummary();
+ summaryCache = { summary, scrobbleCount, coversVersion: currentCoversVersion, builtAt: Date.now() };
+ requestTrackCovers(tracksMissingCovers(summary));
+ }
+ return summaryCache!.summary;
+}
+
+export async function getListeningProfile(userName: string): Promise {
+ if (!hasLastfmKey()) return null;
+
+ await Promise.all([loadScrobbleStore(), loadCovers()]);
+ const [info, nowPlaying] = await Promise.all([cachedUserInfo(userName), cachedNowPlaying(userName)]);
+ if (!info && !storedScrobbles().length) return null;
+
+ const summary = cachedSummary();
+ return {
+ userName: info?.name ?? userName,
+ profileUrl: info?.url ?? `https://www.last.fm/user/${userName}`,
+ registeredAt: info?.registeredAt ?? null,
+ totals: {
+ scrobbles: info?.scrobbles ?? summary.storedScrobbles,
+ artists: info?.artists ?? 0,
+ albums: info?.albums ?? 0,
+ tracks: info?.tracks ?? 0
+ },
+ nowPlaying,
+ syncedAt: lastfmSyncedAt(),
+ ...summary
+ };
+}
diff --git a/src/lib/server/lastfm/store.ts b/src/lib/server/lastfm/store.ts
new file mode 100644
index 0000000..3fb77fa
--- /dev/null
+++ b/src/lib/server/lastfm/store.ts
@@ -0,0 +1,57 @@
+import { appendFile, mkdir, readFile } from 'node:fs/promises';
+import path from 'node:path';
+
+export interface Scrobble {
+ playedAt: number;
+ artist: string;
+ track: string;
+ album: string;
+}
+
+const dataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm');
+const scrobblesPath = path.join(dataDirectory, 'scrobbles.jsonl');
+
+const scrobbles: Scrobble[] = [];
+let loading: Promise | null = null;
+
+async function readTextFile(filePath: string): Promise {
+ try {
+ return await readFile(filePath, 'utf8');
+ } catch {
+ return '';
+ }
+}
+
+async function loadFromDisk() {
+ await mkdir(dataDirectory, { recursive: true });
+
+ const lines = (await readTextFile(scrobblesPath)).split('\n').filter(Boolean);
+ scrobbles.push(...lines.map((line) => JSON.parse(line) as Scrobble));
+ scrobbles.sort((first, second) => first.playedAt - second.playedAt);
+}
+
+export function loadScrobbleStore(): Promise {
+ loading ??= loadFromDisk();
+ return loading;
+}
+
+export function storedScrobbles(): readonly Scrobble[] {
+ return scrobbles;
+}
+
+export function latestPlayedAt(): number {
+ return scrobbles.at(-1)?.playedAt ?? 0;
+}
+
+export async function appendScrobbles(newScrobbles: Scrobble[]) {
+ const latest = latestPlayedAt();
+ const fresh = newScrobbles
+ .filter((scrobble) => scrobble.playedAt > latest)
+ .sort((first, second) => first.playedAt - second.playedAt);
+ if (!fresh.length) return 0;
+
+ const lines = fresh.map((scrobble) => JSON.stringify(scrobble)).join('\n');
+ await appendFile(scrobblesPath, `${lines}\n`);
+ scrobbles.push(...fresh);
+ return fresh.length;
+}
diff --git a/src/lib/server/lastfm/sync.ts b/src/lib/server/lastfm/sync.ts
new file mode 100644
index 0000000..eec28f1
--- /dev/null
+++ b/src/lib/server/lastfm/sync.ts
@@ -0,0 +1,64 @@
+import type { PlayedTrack } from '#lib/lastfm/types';
+import { fetchRecentTracksPage, hasLastfmKey, type RecentTracksPage } from './api';
+import { appendScrobbles, latestPlayedAt, loadScrobbleStore } from './store';
+import { loadCovers, rememberAlbumCovers } from './covers';
+
+const syncInterval = 5 * 60 * 1000;
+const pageDelay = 300;
+
+let syncStarted = false;
+let syncRunning = false;
+let lastSyncedAt: number | null = null;
+
+const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+function toScrobble(track: PlayedTrack) {
+ return { playedAt: track.playedAt ?? 0, artist: track.artist, track: track.track, album: track.album };
+}
+
+async function savePage(page: RecentTracksPage) {
+ await rememberAlbumCovers(page.tracks);
+ return appendScrobbles(page.tracks.map(toScrobble));
+}
+
+async function syncScrobbles(userName: string) {
+ await Promise.all([loadScrobbleStore(), loadCovers()]);
+ const latest = latestPlayedAt();
+ const range = { from: latest ? latest + 1 : undefined, to: Math.floor(Date.now() / 1000) };
+
+ const firstPage = await fetchRecentTracksPage(userName, 1, range);
+ let added = 0;
+
+ for (let pageNumber = firstPage.totalPages; pageNumber > 1; pageNumber--) {
+ added += await savePage(await fetchRecentTracksPage(userName, pageNumber, range));
+ await wait(pageDelay);
+ }
+ added += await savePage(firstPage);
+
+ lastSyncedAt = Date.now();
+ if (added) console.log(`Last.fm stored ${added} new scrobbles`);
+}
+
+export function lastfmSyncedAt(): number | null {
+ return lastSyncedAt;
+}
+
+export function startLastfmSync(userName: string | undefined) {
+ if (syncStarted || !userName || !hasLastfmKey()) return;
+ syncStarted = true;
+
+ const runSync = async () => {
+ if (syncRunning) return;
+ syncRunning = true;
+ try {
+ await syncScrobbles(userName);
+ } catch (error) {
+ console.error('Last.fm sync failed:', error instanceof Error ? error.message : error);
+ } finally {
+ syncRunning = false;
+ }
+ };
+
+ runSync();
+ setInterval(runSync, syncInterval);
+}
diff --git a/src/lib/server/lastfm/trends.ts b/src/lib/server/lastfm/trends.ts
new file mode 100644
index 0000000..694d2b7
--- /dev/null
+++ b/src/lib/server/lastfm/trends.ts
@@ -0,0 +1,159 @@
+import type { ChartItem, CountBucket, ListeningPeriod, TopCharts, TrendingArtist } from '#lib/lastfm/types';
+import { daysBetween, formatDayLabel, localTime, shiftDayKey, shiftMonthKey, todayKey } from './calendar';
+import { coverFor } from './covers';
+import type { Scrobble } from './store';
+
+const secondsPerDay = 24 * 60 * 60;
+const dailyRange = 30;
+const monthlyRange = 12;
+const chartSize = 10;
+const trendingSize = 8;
+const periodDays: Record = { week: 7, month: 30, year: 365, all: null };
+
+const musicUrl = (...names: string[]) =>
+ `https://www.last.fm/music/${names.map((name) => encodeURIComponent(name)).join('/_/')}`;
+const albumUrl = (artist: string, album: string) =>
+ `https://www.last.fm/music/${encodeURIComponent(artist)}/${encodeURIComponent(album)}`;
+
+function countBy(scrobbles: readonly Scrobble[], keyOf: (scrobble: Scrobble) => string | null) {
+ const counts = new Map();
+ for (const scrobble of scrobbles) {
+ const key = keyOf(scrobble);
+ if (!key) continue;
+ const entry = counts.get(key);
+ if (entry) entry.count++;
+ else counts.set(key, { count: 1, sample: scrobble });
+ }
+ return [...counts.values()].sort((first, second) => second.count - first.count);
+}
+
+function scrobblesSince(scrobbles: readonly Scrobble[], days: number | null, nowSeconds: number) {
+ if (days === null) return scrobbles;
+ const cutoff = nowSeconds - days * secondsPerDay;
+ return scrobbles.filter((scrobble) => scrobble.playedAt >= cutoff);
+}
+
+function topCharts(scrobbles: readonly Scrobble[]): TopCharts {
+ const artists: ChartItem[] = countBy(scrobbles, (scrobble) => scrobble.artist)
+ .slice(0, chartSize)
+ .map(({ count, sample }) => ({ name: sample.artist, artist: null, count, url: musicUrl(sample.artist), image: null }));
+
+ const albums: ChartItem[] = countBy(scrobbles, (scrobble) => (scrobble.album ? `${scrobble.artist}\n${scrobble.album}` : null))
+ .slice(0, chartSize)
+ .map(({ count, sample }) => ({
+ name: sample.album,
+ artist: sample.artist,
+ count,
+ url: albumUrl(sample.artist, sample.album),
+ image: coverFor(sample.artist, sample.album)
+ }));
+
+ const tracks: ChartItem[] = countBy(scrobbles, (scrobble) => `${scrobble.artist}\n${scrobble.track}`)
+ .slice(0, chartSize)
+ .map(({ count, sample }) => ({
+ name: sample.track,
+ artist: sample.artist,
+ count,
+ url: musicUrl(sample.artist, sample.track),
+ image: coverFor(sample.artist, sample.album, sample.track)
+ }));
+
+ return { artists, albums, tracks };
+}
+
+export function topChartsByPeriod(scrobbles: readonly Scrobble[], nowSeconds: number): Record {
+ const entries = Object.entries(periodDays).map(([period, days]) => [
+ period,
+ topCharts(scrobblesSince(scrobbles, days, nowSeconds))
+ ]);
+ return Object.fromEntries(entries) as Record;
+}
+
+export function trendingArtists(scrobbles: readonly Scrobble[], nowSeconds: number): TrendingArtist[] {
+ const weekStart = nowSeconds - 7 * secondsPerDay;
+ const previousWeekStart = weekStart - 7 * secondsPerDay;
+ const thisWeek = scrobbles.filter((scrobble) => scrobble.playedAt >= weekStart);
+ const previousWeek = scrobbles.filter(
+ (scrobble) => scrobble.playedAt >= previousWeekStart && scrobble.playedAt < weekStart
+ );
+ const previousCounts = new Map(countBy(previousWeek, (scrobble) => scrobble.artist).map((entry) => [entry.sample.artist, entry.count]));
+
+ return countBy(thisWeek, (scrobble) => scrobble.artist)
+ .slice(0, trendingSize)
+ .map(({ count, sample }) => ({
+ name: sample.artist,
+ url: musicUrl(sample.artist),
+ count,
+ previousCount: previousCounts.get(sample.artist) ?? 0
+ }));
+}
+
+export function countsByDay(scrobbles: readonly Scrobble[]): Map {
+ const counts = new Map();
+ for (const scrobble of scrobbles) {
+ const { dayKey } = localTime(scrobble.playedAt);
+ counts.set(dayKey, (counts.get(dayKey) ?? 0) + 1);
+ }
+ return counts;
+}
+
+export function dailyBuckets(dayCounts: Map): CountBucket[] {
+ const today = todayKey();
+ return Array.from({ length: dailyRange }, (_, index) => {
+ const dayKey = shiftDayKey(today, index - dailyRange + 1);
+ return {
+ label: formatDayLabel(dayKey, { day: 'numeric' }),
+ title: formatDayLabel(dayKey, { weekday: 'short', month: 'short', day: 'numeric' }),
+ count: dayCounts.get(dayKey) ?? 0
+ };
+ });
+}
+
+export function monthlyBuckets(dayCounts: Map): CountBucket[] {
+ const monthCounts = new Map();
+ for (const [dayKey, count] of dayCounts) {
+ const monthKey = dayKey.slice(0, 7);
+ monthCounts.set(monthKey, (monthCounts.get(monthKey) ?? 0) + count);
+ }
+
+ const currentMonth = todayKey().slice(0, 7);
+ return Array.from({ length: monthlyRange }, (_, index) => {
+ const monthKey = shiftMonthKey(currentMonth, index - monthlyRange + 1);
+ return {
+ label: formatDayLabel(`${monthKey}-01`, { month: 'narrow' }),
+ title: formatDayLabel(`${monthKey}-01`, { month: 'long', year: 'numeric' }),
+ count: monthCounts.get(monthKey) ?? 0
+ };
+ });
+}
+
+export function hourlyBuckets(scrobbles: readonly Scrobble[]): CountBucket[] {
+ const counts = Array(24).fill(0);
+ for (const scrobble of scrobbles) counts[localTime(scrobble.playedAt).hour]++;
+
+ return counts.map((count, hour) => {
+ const hourLabel = `${hour % 12 || 12}${hour < 12 ? 'am' : 'pm'}`;
+ return { label: hour % 6 === 0 ? hourLabel : '', title: hourLabel, count };
+ });
+}
+
+export function listeningStreaks(dayCounts: Map): { current: number; longest: number } {
+ const today = todayKey();
+ let current = 0;
+ let cursor = dayCounts.has(today) ? today : shiftDayKey(today, -1);
+ while (dayCounts.has(cursor)) {
+ current++;
+ cursor = shiftDayKey(cursor, -1);
+ }
+
+ let longest = 0;
+ let running = 0;
+ let previousKey: string | null = null;
+ for (const dayKey of [...dayCounts.keys()].sort()) {
+ running = previousKey && daysBetween(previousKey, dayKey) === 1 ? running + 1 : 1;
+ longest = Math.max(longest, running);
+ previousKey = dayKey;
+ }
+
+ return { current, longest };
+}
diff --git a/src/routes/about/+page.server.ts b/src/routes/about/+page.server.ts
index fcb8564..6210c59 100644
--- a/src/routes/about/+page.server.ts
+++ b/src/routes/about/+page.server.ts
@@ -1,15 +1,17 @@
import { about } from '#lib/content';
import { getAnimeProfile } from '#lib/server/anilist';
import { getMediaDetails } from '#lib/server/omdb';
+import { getListeningProfile } from '#lib/server/lastfm/profile';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
- const [anime, mediaDetails] = await Promise.all([
+ const [anime, mediaDetails, listening] = await Promise.all([
about.anilistUsername ? getAnimeProfile(about.anilistUsername) : null,
- getMediaDetails(mediaUrls)
+ getMediaDetails(mediaUrls),
+ about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null
]);
- return { anime, mediaDetails };
+ return { anime, mediaDetails, listening };
};
diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte
index ba42432..5cdde60 100644
--- a/src/routes/about/+page.svelte
+++ b/src/routes/about/+page.svelte
@@ -8,6 +8,7 @@
import AnimeSection from '#lib/components/about/AnimeSection.svelte';
import MediaGrid from '#lib/components/about/MediaGrid.svelte';
import GameCard from '#lib/components/about/GameCard.svelte';
+ import ListeningSection from '#lib/components/about/listening/ListeningSection.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -25,6 +26,7 @@
{ id: 'games', label: 'Games', color: 'orange' },
{ id: 'shows', label: 'Shows', color: 'yellow' },
{ id: 'movies', label: 'Movies', color: 'green' },
+ ...(data.listening ? [{ id: 'music', label: 'Music', color: 'red' }] : []),
...(data.anime ? [{ id: 'anime', label: 'Anime', color: 'magenta' }] : [])
]);
@@ -59,6 +61,12 @@
{/each}
+ {#if data.listening}
+
+ {/if}
+
{#if data.anime}
diff --git a/src/routes/api/lastfm/covers/[id]/+server.ts b/src/routes/api/lastfm/covers/[id]/+server.ts
new file mode 100644
index 0000000..a0c1089
--- /dev/null
+++ b/src/routes/api/lastfm/covers/[id]/+server.ts
@@ -0,0 +1,26 @@
+import { error } from '@sveltejs/kit';
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import { loadCovers, savedCoverPath } from '#lib/server/lastfm/covers';
+import type { RequestHandler } from './$types';
+
+const contentTypes: Record = {
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.png': 'image/png',
+ '.webp': 'image/webp',
+ '.gif': 'image/gif'
+};
+
+export const GET: RequestHandler = async ({ params }) => {
+ await loadCovers();
+ const filePath = savedCoverPath(params.id);
+ if (!filePath) error(404, 'Cover not found');
+
+ return new Response(await readFile(filePath), {
+ headers: {
+ 'Content-Type': contentTypes[path.extname(filePath)] ?? 'image/jpeg',
+ 'Cache-Control': 'public, max-age=31536000, immutable'
+ }
+ });
+};