Add Last.fm music stats to about page

This commit is contained in:
SirBlob committed 2026-11-01 01:54:15 -04:00
1 parent 3122bc4bf9
commit 2d84d68c46
27 files changed
+1618 -4

No files matched your search

@@ -0,0 +1,24 @@
<script lang="ts">
import type { ListeningProfile } from '#lib/lastfm/types';
import Panel from '../../ui/Panel.svelte';
import BarChart from './BarChart.svelte';
import TabRow from './TabRow.svelte';
let { listening }: { listening: ListeningProfile } = $props();
const views = [
{ id: 'daily', label: '30 days', color: 'accent' },
{ id: 'monthly', label: '12 months', color: 'blue' },
{ id: 'hourly', label: 'By hour', color: 'magenta' }
] as const;
let selectedView = $state<(typeof views)[number]['id']>('daily');
const selected = $derived(views.find((view) => view.id === selectedView)!);
const buckets = $derived(listening[selectedView]);
</script>
<Panel title="Listening activity" icon="mdi:chart-bar">
<TabRow tabs={views} bind:selected={selectedView} label="Activity range" />
<BarChart {buckets} color={selected.color} />
</Panel>
@@ -0,0 +1,71 @@
<script lang="ts">
import type { CountBucket } from '#lib/lastfm/types';
let { buckets, color = 'accent', unit = 'plays' }: { buckets: CountBucket[]; color?: string; unit?: string } = $props();
const highest = $derived(Math.max(1, ...buckets.map((bucket) => bucket.count)));
const peak = $derived(buckets.reduce((best, bucket) => (bucket.count > best.count ? bucket : best), buckets[0]));
</script>
<div class="chart" role="img" aria-label="Bar chart, peak {peak?.title} with {peak?.count} {unit}">
<div class="bars">
{#each buckets as bucket, index (index)}
<div class="column" title="{bucket.title}: {bucket.count} {unit}">
<div class="bar" style:height="{(bucket.count / highest) * 100}%" style:background="var(--{color})"></div>
</div>
{/each}
</div>
<div class="labels">
{#each buckets as bucket, index (index)}
<span>{bucket.label}</span>
{/each}
</div>
{#if peak?.count}
<p class="muted peak">peak: {peak.title} ({peak.count} {unit})</p>
{/if}
</div>
<style>
.bars,
.labels {
display: grid;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
gap: 2px;
}
.bars {
height: 110px;
border-bottom: 1px dashed color-mix(in srgb, var(--foreground) 30%, transparent);
}
.column {
display: flex;
align-items: flex-end;
height: 100%;
}
.column:hover .bar {
filter: brightness(1.3);
}
.bar {
width: 100%;
min-height: 1px;
opacity: 0.85;
transition: height 0.3s ease;
}
.labels {
margin-top: 0.25rem;
color: var(--muted);
font-size: 0.65rem;
text-align: center;
white-space: nowrap;
}
.peak {
margin: 0.4rem 0 0;
font-size: 0.75rem;
}
</style>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { ListeningProfile } from '#lib/lastfm/types';
let { listening }: { listening: ListeningProfile } = $props();
const hostName = 'last.fm';
const numberFormat = new Intl.NumberFormat('en-US');
const memberSince = $derived(
listening.registeredAt
? new Date(listening.registeredAt * 1000).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
: null
);
const rows = $derived(
[
['Scrobbles', numberFormat.format(listening.totals.scrobbles)],
['Artists', listening.totals.artists ? numberFormat.format(listening.totals.artists) : null],
['Albums', listening.totals.albums ? numberFormat.format(listening.totals.albums) : null],
['Tracks', listening.totals.tracks ? numberFormat.format(listening.totals.tracks) : null],
['Daily avg', `${listening.averagePerDay} plays (30 days)`],
['Streak', `${listening.currentStreak} days (best ${listening.longestStreak})`],
['Since', memberSince]
].filter((row): row is [string, string] => row[1] !== null)
);
</script>
<div class="listening-fetch">
<a class="heading" href={listening.profileUrl} target="_blank" rel="noopener noreferrer">
<span class="user">{listening.userName.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
</a>
<div class="separator">{'-'.repeat(listening.userName.length + hostName.length + 1)}</div>
{#each rows as [label, value] (label)}
<div class="row"><span class="label">{label}</span>: {value}</div>
{/each}
</div>
<style>
.listening-fetch {
line-height: 1.5;
}
.user,
.host {
color: var(--accent);
font-weight: 700;
}
.heading:hover .user,
.heading:hover .host {
text-decoration: underline;
}
.at,
.separator {
color: var(--muted);
}
.label {
color: var(--blue);
font-weight: 700;
}
</style>
@@ -0,0 +1,73 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ListeningProfile } from '#lib/lastfm/types';
import LinkButton from '../../ui/LinkButton.svelte';
import Panel from '../../ui/Panel.svelte';
import ActivityPanel from './ActivityPanel.svelte';
import ListeningFetch from './ListeningFetch.svelte';
import RecentTracks from './RecentTracks.svelte';
import TopCharts from './TopCharts.svelte';
import TrendingArtists from './TrendingArtists.svelte';
let { listening }: { listening: ListeningProfile } = $props();
</script>
<div class="overview">
<Panel title="last.fm" icon="simple-icons:lastdotfm">
<ListeningFetch {listening} />
</Panel>
<Panel title={listening.nowPlaying ? 'Now playing' : 'Recently played'} icon="mdi:headphones">
<RecentTracks nowPlaying={listening.nowPlaying} recentTracks={listening.recentTracks} />
</Panel>
</div>
<div class="overview">
<ActivityPanel {listening} />
<Panel title="Trending this week" icon="mdi:trending-up">
<TrendingArtists artists={listening.trending} />
</Panel>
</div>
<h3 class="subheading"><Icon icon="mdi:trophy-outline" width="16" /> Top charts</h3>
<TopCharts top={listening.top} />
<div class="actions">
<LinkButton href={listening.profileUrl} icon="simple-icons:lastdotfm">Full history on Last.fm</LinkButton>
<span class="muted stored">{listening.storedScrobbles.toLocaleString('en-US')} scrobbles collected</span>
</div>
<style>
.overview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
.stored {
font-size: 0.8rem;
}
@container (max-width: 800px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,157 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { PlayedTrack } from '#lib/lastfm/types';
let { nowPlaying, recentTracks }: { nowPlaying: PlayedTrack | null; recentTracks: PlayedTrack[] } = $props();
const shownCount = 5;
const tracks = $derived(
[...(nowPlaying ? [nowPlaying] : []), ...recentTracks].slice(0, shownCount)
);
function timeAgo(playedAt: number | null): string {
if (playedAt === null) return 'now';
const minutes = Math.floor((Date.now() / 1000 - playedAt) / 60);
if (minutes < 60) return `${Math.max(1, minutes)}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
</script>
{#if tracks.length}
<ul class="tracks">
{#each tracks as track, index (`${track.playedAt}-${index}`)}
<li>
<a class="track" class:playing={track.playedAt === null} href={track.url} target="_blank" rel="noopener noreferrer">
{#if track.image}
<img src={track.image} alt={track.album} loading="lazy" />
{:else}
<span class="cover-fallback"><Icon icon="mdi:music-note" width="18" /></span>
{/if}
<span class="text">
<span class="name">{track.track}</span>
<span class="muted artist">{track.artist}</span>
</span>
<span class="when">
{#if track.playedAt === null}
<span class="equalizer" aria-label="Now playing"><span></span><span></span><span></span></span>
{:else}
<span class="muted">{timeAgo(track.playedAt)}</span>
{/if}
</span>
</a>
</li>
{/each}
</ul>
{:else}
<p class="muted">No scrobbles yet.</p>
{/if}
<style>
.tracks {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0;
padding: 0;
list-style: none;
}
.track {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.3rem;
border: 1px solid transparent;
}
.track:hover {
border-color: var(--accent);
}
.track.playing {
border-color: color-mix(in srgb, var(--green) 50%, transparent);
}
img,
.cover-fallback {
flex-shrink: 0;
width: 40px;
height: 40px;
object-fit: cover;
}
.cover-fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--muted);
}
.text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.name,
.artist {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.name {
font-size: 0.88rem;
}
.artist,
.when {
font-size: 0.75rem;
}
.when {
flex-shrink: 0;
}
.equalizer {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 14px;
}
.equalizer span {
width: 3px;
background: var(--green);
animation: equalize 0.9s ease-in-out infinite;
}
.equalizer span:nth-child(2) {
animation-delay: 0.2s;
}
.equalizer span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes equalize {
0%,
100% {
height: 30%;
}
50% {
height: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.equalizer span {
animation: none;
height: 70%;
}
}
</style>
@@ -0,0 +1,41 @@
<script lang="ts" generics="TabId extends string">
let {
tabs,
selected = $bindable(),
label
}: { tabs: readonly { id: TabId; label: string }[]; selected: TabId; label: string } = $props();
</script>
<div class="tabs" role="tablist" aria-label={label}>
{#each tabs as tab (tab.id)}
<button role="tab" aria-selected={selected === tab.id} class:active={selected === tab.id} onclick={() => (selected = tab.id)}>
{tab.label}
</button>
{/each}
</div>
<style>
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
button {
padding: 0.15rem 0.6rem;
background: none;
border: 1px dashed color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--muted);
font-size: 0.8rem;
}
button:hover {
color: var(--foreground);
}
button.active {
border-style: solid;
border-color: var(--accent);
color: var(--accent);
}
</style>
@@ -0,0 +1,236 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ChartItem, ListeningPeriod, TopCharts } from '#lib/lastfm/types';
import Panel from '../../ui/Panel.svelte';
import TabRow from './TabRow.svelte';
let { top }: { top: Record<ListeningPeriod, TopCharts> } = $props();
const periods: { id: ListeningPeriod; label: string }[] = [
{ id: 'week', label: '7 days' },
{ id: 'month', label: '30 days' },
{ id: 'year', label: '12 months' },
{ id: 'all', label: 'All time' }
];
let selectedPeriod = $state<ListeningPeriod>('month');
const charts = $derived(top[selectedPeriod]);
const lists = $derived([
{ title: 'Top artists', icon: 'mdi:account-music', items: charts.artists, showCovers: false },
{ title: 'Top tracks', icon: 'mdi:music-note', items: charts.tracks, showCovers: true }
]);
</script>
<div class="top-charts">
<TabRow tabs={periods} bind:selected={selectedPeriod} label="Chart period" />
<div class="album-grid">
{#each charts.albums as album (album.url)}
<a class="album" href={album.url} target="_blank" rel="noopener noreferrer" title="{album.name} by {album.artist}">
<div class="cover">
{#if album.image}
<img src={album.image} alt={album.name} loading="lazy" />
{:else}
<span class="cover-fallback"><Icon icon="mdi:album" width="32" /></span>
{/if}
<span class="plays">{album.count}</span>
</div>
<span class="album-name">{album.name}</span>
<span class="muted album-artist">{album.artist}</span>
</a>
{:else}
<p class="muted">No albums in this period.</p>
{/each}
</div>
<div class="lists">
{#each lists as list (list.title)}
<Panel title={list.title} icon={list.icon}>
{@render chartList(list.items, list.showCovers)}
</Panel>
{/each}
</div>
</div>
{#snippet chartList(items: ChartItem[], showCovers: boolean)}
{#if items.length}
<ol class="chart-list">
{#each items as item, index (item.url)}
<li>
<span class="rank">{index + 1}</span>
{#if showCovers}
{#if item.image}
<img class="thumb" src={item.image} alt="" loading="lazy" />
{:else}
<span class="thumb thumb-fallback"><Icon icon="mdi:music-note" width="16" /></span>
{/if}
{/if}
<a class="item" href={item.url} target="_blank" rel="noopener noreferrer">
<span class="item-name">{item.name}</span>
{#if item.artist}
<span class="muted item-artist">{item.artist}</span>
{/if}
</a>
<span class="muted">{item.count}</span>
</li>
{/each}
</ol>
{:else}
<p class="muted">Nothing here yet.</p>
{/if}
{/snippet}
<style>
.top-charts {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.9rem 0.75rem;
}
.album {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.cover {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
.album:hover .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.album:hover img {
transform: scale(1.05);
}
.cover-fallback {
display: grid;
place-items: center;
height: 100%;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--muted);
}
.plays {
position: absolute;
top: 0.3rem;
right: 0.3rem;
padding: 0 0.35rem;
background: var(--background);
color: var(--accent);
font-size: 0.72rem;
}
.album-name,
.album-artist,
.item-name,
.item-artist {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.album-name {
margin-top: 0.15rem;
font-size: 0.8rem;
}
.album:hover .album-name {
color: var(--accent);
}
.album-artist {
font-size: 0.72rem;
}
.lists {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.chart-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.rank {
flex-shrink: 0;
width: 1.4rem;
color: var(--muted);
text-align: right;
}
.thumb {
flex-shrink: 0;
width: 36px;
height: 36px;
object-fit: cover;
}
.thumb-fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--muted);
}
.item {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.item:hover .item-name {
color: var(--accent);
}
.item-artist {
font-size: 0.72rem;
}
@container (max-width: 800px) {
.lists {
grid-template-columns: minmax(0, 1fr);
}
}
@container (max-width: 520px) {
.album-grid {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
}
}
</style>
@@ -0,0 +1,103 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { TrendingArtist } from '#lib/lastfm/types';
let { artists }: { artists: TrendingArtist[] } = $props();
const highest = $derived(Math.max(1, ...artists.map((artist) => artist.count)));
function changeOf(artist: TrendingArtist) {
if (artist.previousCount === 0) return null;
const percent = Math.round(((artist.count - artist.previousCount) / artist.previousCount) * 100);
if (percent > 0) return { icon: 'mdi:arrow-up', color: 'green', text: `${percent}%` };
if (percent < 0) return { icon: 'mdi:arrow-down', color: 'red', text: `${Math.abs(percent)}%` };
return { icon: 'mdi:minus', color: 'muted', text: 'same' };
}
</script>
{#if artists.length}
<ol class="trending">
{#each artists as artist, index (artist.name)}
{@const change = changeOf(artist)}
<li>
<span class="rank">{index + 1}</span>
<a class="name" href={artist.url} target="_blank" rel="noopener noreferrer">{artist.name}</a>
<span class="meter"><span class="fill" style:width="{(artist.count / highest) * 100}%"></span></span>
<span class="count">{artist.count}</span>
<span class="change" style:color={change ? `var(--${change.color})` : undefined} title="{artist.previousCount} plays the week before">
{#if change}
<Icon icon={change.icon} width="14" />{change.text}
{/if}
</span>
</li>
{/each}
</ol>
{:else}
<p class="muted">Nothing played this week.</p>
{/if}
<style>
.trending {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
display: grid;
grid-template-columns: 1.4rem minmax(0, 1.2fr) minmax(0, 1fr) 2.5rem 4rem;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.rank {
color: var(--muted);
text-align: right;
}
.name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.name:hover {
color: var(--accent);
}
.meter {
height: 6px;
background: color-mix(in srgb, var(--foreground) 12%, transparent);
}
.fill {
display: block;
height: 100%;
background: var(--accent);
}
.count {
text-align: right;
}
.change {
display: inline-flex;
align-items: center;
gap: 0.15rem;
font-size: 0.75rem;
}
@container (max-width: 520px) {
li {
grid-template-columns: 1.4rem minmax(0, 1fr) 2.5rem 4rem;
}
.meter {
display: none;
}
}
</style>
+1
View File
@@ -139,6 +139,7 @@ export interface RatedTitle {
export interface AboutContent {
anilistUsername?: string;
lastfmUsername?: string;
hobbies: Hobby[];
games: Game[];
shows: RatedTitle[];
+63
View File
@@ -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<ListeningPeriod, TopCharts>;
storedScrobbles: number;
syncedAt: number | null;
}
+134
View File
@@ -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<T>(method: string, params: Record<string, string>): Promise<T> {
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<UserInfo> {
const { user } = await callLastfm<RawUserInfo>('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<RecentTracksPage> {
const params: Record<string, string> = { user: userName, page: `${page}`, limit: `${pageSize}`, to: `${range.to}` };
if (range.from) params.from = `${range.from}`;
const { recenttracks } = await callLastfm<RawRecentTracks>('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<PlayedTrack | null> {
const { recenttracks } = await callLastfm<RawRecentTracks>('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<string | null> {
const { track: info } = await callLastfm<RawTrackInfo>('track.getinfo', { artist, track, autocorrect: '1' });
return largestImage(info?.album?.image);
}
+50
View File
@@ -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<number, { dayKey: string; hour: number }>();
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' });
}
+140
View File
@@ -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<string, string> = {};
let trackCovers: Record<string, string> = {};
const savedImages = new Map<string, string>();
const queuedDownloads = new Set<string>();
const queuedLookups = new Map<string, { artist: string; track: string }>();
let loading: Promise<void> | 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<Record<string, string>> {
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<void> {
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;
}
+126
View File
@@ -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<ListeningProfile, 'userName' | 'profileUrl' | 'registeredAt' | 'totals' | 'nowPlaying' | 'syncedAt'>;
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<UserInfo | null> {
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<PlayedTrack | null> {
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<ListeningProfile | null> {
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
};
}
+57
View File
@@ -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<void> | null = null;
async function readTextFile(filePath: string): Promise<string> {
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<void> {
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;
}
+64
View File
@@ -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);
}
+159
View File
@@ -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<ListeningPeriod, number | null> = { 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<string, { count: number; sample: Scrobble }>();
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<ListeningPeriod, TopCharts> {
const entries = Object.entries(periodDays).map(([period, days]) => [
period,
topCharts(scrobblesSince(scrobbles, days, nowSeconds))
]);
return Object.fromEntries(entries) as Record<ListeningPeriod, TopCharts>;
}
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<string, number> {
const counts = new Map<string, number>();
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<string, number>): 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<string, number>): CountBucket[] {
const monthCounts = new Map<string, number>();
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<number>(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<string, number>): { 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 };
}