From 2d84d68c460d590b2fa0d837304c7c881274af71 Mon Sep 17 00:00:00 2001 From: SirBlob Date: Sun, 1 Nov 2026 01:54:15 -0400 Subject: [PATCH] Add Last.fm music stats to about page --- .env.example | 1 + .gitignore | 1 + Dockerfile | 2 +- content/about.json | 1 + docker-compose.yml | 5 + src/env.ts | 4 + src/hooks.server.ts | 3 + .../about/listening/ActivityPanel.svelte | 24 ++ .../about/listening/BarChart.svelte | 71 ++++++ .../about/listening/ListeningFetch.svelte | 64 +++++ .../about/listening/ListeningSection.svelte | 73 ++++++ .../about/listening/RecentTracks.svelte | 157 ++++++++++++ .../components/about/listening/TabRow.svelte | 41 +++ .../about/listening/TopCharts.svelte | 236 ++++++++++++++++++ .../about/listening/TrendingArtists.svelte | 103 ++++++++ src/lib/content/types.ts | 1 + src/lib/lastfm/types.ts | 63 +++++ src/lib/server/lastfm/api.ts | 134 ++++++++++ src/lib/server/lastfm/calendar.ts | 50 ++++ src/lib/server/lastfm/covers.ts | 140 +++++++++++ src/lib/server/lastfm/profile.ts | 126 ++++++++++ src/lib/server/lastfm/store.ts | 57 +++++ src/lib/server/lastfm/sync.ts | 64 +++++ src/lib/server/lastfm/trends.ts | 159 ++++++++++++ src/routes/about/+page.server.ts | 8 +- src/routes/about/+page.svelte | 8 + src/routes/api/lastfm/covers/[id]/+server.ts | 26 ++ 27 files changed, 1618 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/about/listening/ActivityPanel.svelte create mode 100644 src/lib/components/about/listening/BarChart.svelte create mode 100644 src/lib/components/about/listening/ListeningFetch.svelte create mode 100644 src/lib/components/about/listening/ListeningSection.svelte create mode 100644 src/lib/components/about/listening/RecentTracks.svelte create mode 100644 src/lib/components/about/listening/TabRow.svelte create mode 100644 src/lib/components/about/listening/TopCharts.svelte create mode 100644 src/lib/components/about/listening/TrendingArtists.svelte create mode 100644 src/lib/lastfm/types.ts create mode 100644 src/lib/server/lastfm/api.ts create mode 100644 src/lib/server/lastfm/calendar.ts create mode 100644 src/lib/server/lastfm/covers.ts create mode 100644 src/lib/server/lastfm/profile.ts create mode 100644 src/lib/server/lastfm/store.ts create mode 100644 src/lib/server/lastfm/sync.ts create mode 100644 src/lib/server/lastfm/trends.ts create mode 100644 src/routes/api/lastfm/covers/[id]/+server.ts 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 @@ + + + + + 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 @@ + + +
+ + {listening.userName.toLowerCase()}@{hostName} + +
{'-'.repeat(listening.userName.length + hostName.length + 1)}
+ + {#each rows as [label, value] (label)} +
{label}: {value}
+ {/each} +
+ + 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 charts.albums as album (album.url)} + +
+ {#if album.image} + {album.name} + {:else} + + {/if} + {album.count} +
+ {album.name} + {album.artist} +
+ {:else} +

No albums in this period.

+ {/each} +
+ +
+ {#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)} +
  1. + {index + 1} + {#if showCovers} + {#if item.image} + + {:else} + + {/if} + {/if} + + {item.name} + {#if item.artist} + {item.artist} + {/if} + + {item.count} +
  2. + {/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} + +{: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' + } + }); +};