Files
website/src/lib/server/lastfm/api.ts
T

128 lines
3.6 KiB
TypeScript

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