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

+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 };
}