190 lines
5.9 KiB
TypeScript
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 [];
|
|
}
|
|
}
|
|
}
|