Files
muzick/workers/src/integrations/lastfm.client.ts
T

190 lines
5.9 KiB
TypeScript

// 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, string | number>): 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<T>(method: string, params: Record<string, string | number>): Promise<T> {
return requestJson<T>(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<SimilarArtist[]> {
if (!this.enabled || artist.trim() === '') return [];
try {
const data = await this.get<LfmSimilarArtistsResponse>('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<ArtistTag[]> {
if (!this.enabled || artist.trim() === '') return [];
try {
const data = await this.get<LfmTopTagsResponse>('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<string | null> {
if (!this.enabled || artist.trim() === '') return null;
try {
const data = await this.get<LfmArtistInfoResponse>('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<SimilarTrack[]> {
if (!this.enabled || artist.trim() === '' || track.trim() === '') return [];
try {
const data = await this.get<LfmSimilarTracksResponse>('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 [];
}
}
}