Add Last.fm music stats to about page
This commit is contained in:
1 parent
3122bc4bf9
commit
2d84d68c46
27 files changed
+1618
-4
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user