// Last.fm 2.0 API client (read-only methods). // // Every call appends api_key + format=json and goes through the http base, which // enforces a small per-host rate limit. Best-effort: with no API key configured, // or on any error, methods return [] rather than throwing. import { integrationsConfig, LastFmConfig } from './config.js'; import { buildUserAgent, requestJson } from './http.js'; export interface SimilarArtist { name: string; /** Similarity 0..1 as reported by Last.fm. */ match: number; } export interface ArtistTag { name: string; /** Normalised 0..1 weight (relative to the strongest tag). */ weight: number; } export interface SimilarTrack { name: string; artist: string; /** Similarity 0..1 as reported by Last.fm. */ match: number; } // --- Raw Last.fm response shapes (only the fields we read) ----------------- interface LfmSimilarArtist { name?: string; match?: string | number; } interface LfmSimilarArtistsResponse { similarartists?: { artist?: LfmSimilarArtist[] }; } interface LfmTag { name?: string; count?: string | number; } interface LfmTopTagsResponse { toptags?: { tag?: LfmTag[] }; } interface LfmSimilarTrack { name?: string; match?: string | number; artist?: { name?: string }; } interface LfmSimilarTracksResponse { similartracks?: { track?: LfmSimilarTrack[] }; } interface LfmImage { '#text'?: string; size?: string; } interface LfmArtistInfoResponse { artist?: { image?: LfmImage[] }; } const toNum = (v: string | number | undefined): number => { const n = typeof v === 'number' ? v : Number(v); return Number.isFinite(n) ? n : 0; }; export class LastFmClient { private readonly cfg: LastFmConfig; private readonly userAgent: string; private readonly enabled: boolean; constructor(cfg: LastFmConfig = integrationsConfig.lastfm) { this.cfg = cfg; this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact); this.enabled = cfg.apiKey.trim() !== ''; if (!this.enabled) { console.warn( '[Last.fm] LASTFM_API_KEY not set; client disabled (methods return []).' ); } } /** Build a method URL with the supplied params plus api_key + format=json. */ private url(method: string, params: Record): string { const qs = new URLSearchParams({ method, api_key: this.cfg.apiKey, format: 'json' }); for (const [k, v] of Object.entries(params)) qs.set(k, String(v)); return `${this.cfg.baseUrl}/?${qs.toString()}`; } private async get(method: string, params: Record): Promise { return requestJson(this.url(method, params), { userAgent: this.userAgent, minIntervalMs: this.cfg.minIntervalMs, }); } /** artist.getsimilar -> up to `limit` similar artists with 0..1 match. */ async getSimilarArtists(artist: string, limit = 20): Promise { if (!this.enabled || artist.trim() === '') return []; try { const data = await this.get('artist.getsimilar', { artist, limit, autocorrect: 1, }); const list = data.similarartists?.artist ?? []; return list .filter((a): a is LfmSimilarArtist & { name: string } => !!a.name) .map((a) => ({ name: a.name, match: toNum(a.match) })); } catch (err) { console.warn('[Last.fm] getSimilarArtists failed:', (err as Error).message); return []; } } /** artist.gettoptags -> tags with count normalised to 0..1. */ async getArtistTopTags(artist: string): Promise { if (!this.enabled || artist.trim() === '') return []; try { const data = await this.get('artist.gettoptags', { artist, autocorrect: 1, }); const tags = (data.toptags?.tag ?? []).filter( (t): t is LfmTag & { name: string } => !!t.name ); if (tags.length === 0) return []; const maxCount = Math.max(...tags.map((t) => toNum(t.count)), 1); return tags .map((t) => ({ name: t.name, weight: toNum(t.count) / maxCount })) .sort((a, b) => b.weight - a.weight); } catch (err) { console.warn('[Last.fm] getArtistTopTags failed:', (err as Error).message); return []; } } /** artist.getinfo -> best available image URL, or null. */ async getArtistImageUrl(artist: string): Promise { if (!this.enabled || artist.trim() === '') return null; try { const data = await this.get('artist.getinfo', { artist, autocorrect: 1, }); const images = data.artist?.image ?? []; // Prefer 'extralarge', fall back through sizes in descending order. for (const size of ['extralarge', 'large', 'medium', 'small']) { const img = images.find((i) => i.size === size); const url = img?.['#text']?.trim(); if (url && !url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return url; } return null; } catch (err) { console.warn('[Last.fm] getArtistImageUrl failed:', (err as Error).message); return null; } } /** track.getsimilar -> up to `limit` similar tracks with 0..1 match. */ async getSimilarTracks( artist: string, track: string, limit = 20 ): Promise { if (!this.enabled || artist.trim() === '' || track.trim() === '') return []; try { const data = await this.get('track.getsimilar', { artist, track, limit, autocorrect: 1, }); const list = data.similartracks?.track ?? []; return list .filter((t): t is LfmSimilarTrack & { name: string } => !!t.name) .map((t) => ({ name: t.name, artist: t.artist?.name ?? '', match: toNum(t.match), })); } catch (err) { console.warn('[Last.fm] getSimilarTracks failed:', (err as Error).message); return []; } } }