initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
// Centralised, typed configuration for the external-integration clients.
|
||||
//
|
||||
// Enrichment is best-effort: a missing required key must NOT crash the worker.
|
||||
// Instead the owning client logs a one-time warning at construction and its
|
||||
// methods short-circuit to null/[] (see musicbrainz.client.ts / lastfm.client.ts).
|
||||
|
||||
export interface MusicBrainzConfig {
|
||||
/** Base URL for the MusicBrainz web service v2. No trailing slash. */
|
||||
baseUrl: string;
|
||||
/**
|
||||
* Contact (email or URL) embedded in the required User-Agent. MusicBrainz
|
||||
* rejects/throttles requests without a meaningful UA. Empty if unset.
|
||||
*/
|
||||
contact: string;
|
||||
/** Minimum interval between requests to the MB host (MB requires <=1 req/sec). */
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface LastFmConfig {
|
||||
/** Base URL for the Last.fm 2.0 API. No trailing slash. */
|
||||
baseUrl: string;
|
||||
/** API key. Empty string when unconfigured -> client degrades to no-ops. */
|
||||
apiKey: string;
|
||||
/** Shared secret (only needed for authenticated/write calls; optional here). */
|
||||
sharedSecret: string;
|
||||
/** Minimum interval between requests to the Last.fm host. */
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface DiscogsConfig {
|
||||
/** Base URL for the Discogs API. No trailing slash. */
|
||||
baseUrl: string;
|
||||
/** Personal access token. Empty string when unconfigured -> client degrades to no-ops. */
|
||||
token: string;
|
||||
/** Minimum interval between requests (Discogs allows ~60 req/min authenticated). */
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface LrcLibConfig {
|
||||
/** Base URL for the LRCLib API. No trailing slash. */
|
||||
baseUrl: string;
|
||||
/** Minimum interval between requests (polite default; no key required). */
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface CoverArtConfig {
|
||||
/** Base URL for the Cover Art Archive. No trailing slash. */
|
||||
baseUrl: string;
|
||||
/** Minimum interval between requests (polite default; no key required). */
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface WikimediaConfig {
|
||||
/** Base URL for the Wikimedia API. No trailing slash. */
|
||||
baseUrl: string;
|
||||
/** Minimum interval between requests (polite default; no key required). */
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface IntegrationsConfig {
|
||||
musicbrainz: MusicBrainzConfig;
|
||||
lastfm: LastFmConfig;
|
||||
discogs: DiscogsConfig;
|
||||
lrclib: LrcLibConfig;
|
||||
coverart: CoverArtConfig;
|
||||
wikimedia: WikimediaConfig;
|
||||
}
|
||||
|
||||
function stripTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function env(name: string, fallback = ''): string {
|
||||
const v = process.env[name];
|
||||
return v && v.trim() !== '' ? v.trim() : fallback;
|
||||
}
|
||||
|
||||
export const integrationsConfig: IntegrationsConfig = {
|
||||
musicbrainz: {
|
||||
baseUrl: stripTrailingSlash(
|
||||
env('MUSICBRAINZ_BASE_URL', 'https://musicbrainz.org/ws/2')
|
||||
),
|
||||
contact: env('MUSICBRAINZ_CONTACT'),
|
||||
minIntervalMs: 1000,
|
||||
},
|
||||
lastfm: {
|
||||
baseUrl: stripTrailingSlash(
|
||||
env('LASTFM_BASE_URL', 'http://ws.audioscrobbler.com/2.0')
|
||||
),
|
||||
apiKey: env('LASTFM_API_KEY'),
|
||||
sharedSecret: env('LASTFM_SHARED_SECRET'),
|
||||
minIntervalMs: 250,
|
||||
},
|
||||
discogs: {
|
||||
baseUrl: stripTrailingSlash(env('DISCOGS_BASE_URL', 'https://api.discogs.com')),
|
||||
token: env('DISCOGS_TOKEN'),
|
||||
// ~60 req/min authenticated -> ~1.1s between requests.
|
||||
minIntervalMs: 1100,
|
||||
},
|
||||
lrclib: {
|
||||
baseUrl: stripTrailingSlash(env('LRCLIB_BASE_URL', 'https://lrclib.net/api')),
|
||||
minIntervalMs: 250,
|
||||
},
|
||||
coverart: {
|
||||
baseUrl: stripTrailingSlash(env('COVERART_BASE_URL', 'https://coverartarchive.org')),
|
||||
minIntervalMs: 250,
|
||||
},
|
||||
wikimedia: {
|
||||
baseUrl: stripTrailingSlash(env('WIKIMEDIA_BASE_URL', 'https://commons.wikimedia.org')),
|
||||
minIntervalMs: 250,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
// Cover Art Archive client.
|
||||
//
|
||||
// No API key is required, so the client is always enabled. A polite per-host
|
||||
// rate limit (~250ms) is enforced via the http base.
|
||||
//
|
||||
// /release/{mbid} returns JSON listing images[]; we pick the front image. CAA
|
||||
// then redirects the image URL to archive.org, but we return the URL string
|
||||
// directly (callers can fetch it; the http base follows redirects by default).
|
||||
// A 404 (no cover art for that release) is treated as a clean miss (null).
|
||||
//
|
||||
// /artist/{mbid} returns artist images (same structure).
|
||||
|
||||
import { integrationsConfig, CoverArtConfig } from './config.js';
|
||||
import { buildUserAgent, requestJson, HttpError } from './http.js';
|
||||
|
||||
// --- Raw CAA response shape (only the fields we read) ----------------------
|
||||
|
||||
interface CaaImage {
|
||||
image?: string;
|
||||
front?: boolean;
|
||||
thumbnails?: { '500'?: string; large?: string; small?: string };
|
||||
}
|
||||
|
||||
interface CaaReleaseResponse {
|
||||
images?: CaaImage[];
|
||||
}
|
||||
|
||||
export class CoverArtClient {
|
||||
private readonly cfg: CoverArtConfig;
|
||||
private readonly userAgent: string;
|
||||
|
||||
constructor(cfg: CoverArtConfig = integrationsConfig.coverart) {
|
||||
this.cfg = cfg;
|
||||
this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact);
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: this.cfg.minIntervalMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the front cover URL for a release MBID (preferring the 500px
|
||||
* thumbnail, falling back to the full image), or null on no art (404) / error.
|
||||
*/
|
||||
async getReleaseCoverUrl(releaseMbid: string): Promise<string | null> {
|
||||
if (releaseMbid.trim() === '') return null;
|
||||
|
||||
const path = `/release/${encodeURIComponent(releaseMbid)}`;
|
||||
|
||||
try {
|
||||
const data = await this.get<CaaReleaseResponse>(path);
|
||||
const images = data.images ?? [];
|
||||
const front = images.find((img) => img.front) ?? images[0];
|
||||
if (!front) return null;
|
||||
return front.thumbnails?.['500'] ?? front.image ?? null;
|
||||
} catch (err) {
|
||||
// 404 means no cover art for this release; treat as a clean miss.
|
||||
if (err instanceof HttpError && err.status === 404) return null;
|
||||
console.warn('[CoverArt] getReleaseCoverUrl failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the front cover URL for a release-group MBID (preferring the 500px
|
||||
* thumbnail, falling back to the full image), or null on no art (404) / error.
|
||||
*
|
||||
* This is the preferred CAA endpoint when a release-group MBID is available:
|
||||
* it aggregates cover art across all releases in the group, so it hits more
|
||||
* often than the per-release endpoint. Now that `albums.mbid` stores
|
||||
* release-group MBIDs (from the MusicBrainz release-group search), this is a
|
||||
* direct, exact lookup — no search step required.
|
||||
*/
|
||||
async getReleaseGroupCoverUrl(releaseGroupMbid: string): Promise<string | null> {
|
||||
if (releaseGroupMbid.trim() === '') return null;
|
||||
|
||||
const path = `/release-group/${encodeURIComponent(releaseGroupMbid)}`;
|
||||
|
||||
try {
|
||||
const data = await this.get<CaaReleaseResponse>(path);
|
||||
const images = data.images ?? [];
|
||||
const front = images.find((img) => img.front) ?? images[0];
|
||||
if (!front) return null;
|
||||
return front.thumbnails?.['500'] ?? front.image ?? null;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 404) return null;
|
||||
console.warn('[CoverArt] getReleaseGroupCoverUrl failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover Art Archive does NOT provide artist images, only release images.
|
||||
* This method always returns null — kept for interface compatibility.
|
||||
*/
|
||||
async getArtistImageUrl(_artistMbid: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Deezer API client (read-only).
|
||||
//
|
||||
// Deezer provides a free, no-auth search API with high-quality cover art
|
||||
// (up to 1000×1000 via the `cover_xl` field). Coverage is very good for
|
||||
// European and mainstream releases.
|
||||
//
|
||||
// Rate limit: Deezer doesn't document a hard limit for the public search API,
|
||||
// but ~50 req/min is a safe polite default, enforced via the http base.
|
||||
//
|
||||
// Best-effort: on any error, methods log a warning and return null rather than
|
||||
// throwing, so enrichment never breaks the worker.
|
||||
|
||||
import { buildUserAgent, requestJson } from './http.js';
|
||||
|
||||
/** A normalised Deezer album search result (only the fields we read). */
|
||||
export interface DeezerAlbum {
|
||||
artistName: string;
|
||||
title: string;
|
||||
/** 1000×1000 cover URL. */
|
||||
coverXl: string;
|
||||
/** 500×500 cover URL (fallback). */
|
||||
coverBig: string;
|
||||
}
|
||||
|
||||
interface DeezerSearchResult {
|
||||
artist?: { name?: string };
|
||||
title?: string;
|
||||
cover_xl?: string;
|
||||
cover_big?: string;
|
||||
}
|
||||
|
||||
interface DeezerSearchResponse {
|
||||
data?: DeezerSearchResult[];
|
||||
}
|
||||
|
||||
export class DeezerClient {
|
||||
private readonly baseUrl = 'https://api.deezer.com';
|
||||
private readonly userAgent: string;
|
||||
private readonly minIntervalMs: number;
|
||||
|
||||
constructor(contact = '', minIntervalMs = 1200) {
|
||||
this.userAgent = buildUserAgent(contact);
|
||||
this.minIntervalMs = minIntervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the Deezer catalog for an album by artist + title and return the
|
||||
* best match, or null if no match / error.
|
||||
*
|
||||
* Deezer's search `q` supports the fielded syntax `artist:"…" album:"…"`
|
||||
* which gives precise matches.
|
||||
*/
|
||||
async searchAlbum(artist: string, album: string): Promise<DeezerAlbum | null> {
|
||||
if (artist.trim() === '' || album.trim() === '') return null;
|
||||
|
||||
const q = `artist:"${artist}" album:"${album}"`;
|
||||
const qs = new URLSearchParams({ q });
|
||||
const url = `${this.baseUrl}/search/album?${qs.toString()}`;
|
||||
|
||||
try {
|
||||
const data = await requestJson<DeezerSearchResponse>(url, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: this.minIntervalMs,
|
||||
});
|
||||
const results = data.data ?? [];
|
||||
if (results.length === 0) return null;
|
||||
|
||||
const best = results[0];
|
||||
const coverXl = best.cover_xl ?? best.cover_big ?? null;
|
||||
const coverBig = best.cover_big ?? best.cover_xl ?? null;
|
||||
if (!coverXl) return null;
|
||||
|
||||
return {
|
||||
artistName: best.artist?.name ?? artist,
|
||||
title: best.title ?? album,
|
||||
coverXl,
|
||||
coverBig: coverBig ?? coverXl,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[Deezer] searchAlbum failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Discogs API client (read-only search).
|
||||
//
|
||||
// Auth is via a personal access token sent as the
|
||||
// `Authorization: Discogs token=<TOKEN>` header (set through the http base).
|
||||
// Discogs allows ~60 requests/minute for authenticated clients, enforced via the
|
||||
// http base's per-host rate limit (minIntervalMs ~1100ms).
|
||||
//
|
||||
// Best-effort: with no token configured, or on any error, methods log a warning
|
||||
// and return null rather than throwing, so enrichment never breaks the worker.
|
||||
|
||||
import { integrationsConfig, DiscogsConfig } from './config.js';
|
||||
import { buildUserAgent, requestJson } from './http.js';
|
||||
|
||||
/** A normalised Discogs release match. */
|
||||
export interface DiscogsRelease {
|
||||
discogsId: number;
|
||||
year: number | null;
|
||||
genres: string[];
|
||||
styles: string[];
|
||||
/** Cover image (full-size) URL, if any. */
|
||||
coverImage: string | null;
|
||||
/** Thumbnail URL, if any. */
|
||||
thumb: string | null;
|
||||
}
|
||||
|
||||
// --- Raw Discogs response shapes (only the fields we read) -----------------
|
||||
|
||||
interface DiscogsSearchResult {
|
||||
id?: number;
|
||||
year?: string | number;
|
||||
genre?: string[];
|
||||
style?: string[];
|
||||
cover_image?: string;
|
||||
thumb?: string;
|
||||
}
|
||||
|
||||
interface DiscogsSearchResponse {
|
||||
results?: DiscogsSearchResult[];
|
||||
}
|
||||
|
||||
interface DiscogsArtistResponse {
|
||||
images?: { uri?: string; uri150?: string; type?: string }[];
|
||||
}
|
||||
|
||||
export class DiscogsClient {
|
||||
private readonly cfg: DiscogsConfig;
|
||||
private readonly userAgent: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(cfg: DiscogsConfig = integrationsConfig.discogs) {
|
||||
this.cfg = cfg;
|
||||
this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact);
|
||||
this.enabled = cfg.token.trim() !== '';
|
||||
if (!this.enabled) {
|
||||
console.warn(
|
||||
'[Discogs] DISCOGS_TOKEN not set; client disabled (methods return null).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: this.cfg.minIntervalMs,
|
||||
headers: { Authorization: `Discogs token=${this.cfg.token}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the database for the best release match for artist/album and return
|
||||
* its id, year, genres/styles and cover image URLs, or null if no match (or
|
||||
* the client is disabled / errors).
|
||||
*/
|
||||
async searchRelease(artist: string, album: string): Promise<DiscogsRelease | null> {
|
||||
if (!this.enabled) return null;
|
||||
if (artist.trim() === '' || album.trim() === '') return null;
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
type: 'release',
|
||||
artist,
|
||||
release_title: album,
|
||||
});
|
||||
const path = `/database/search?${qs.toString()}`;
|
||||
|
||||
try {
|
||||
const data = await this.get<DiscogsSearchResponse>(path);
|
||||
const best = (data.results ?? [])[0];
|
||||
if (!best || typeof best.id !== 'number') return null;
|
||||
|
||||
const year =
|
||||
best.year !== undefined && Number.isFinite(Number(best.year))
|
||||
? Number(best.year)
|
||||
: null;
|
||||
|
||||
return {
|
||||
discogsId: best.id,
|
||||
year,
|
||||
genres: best.genre ?? [],
|
||||
styles: best.style ?? [],
|
||||
coverImage: best.cover_image ?? null,
|
||||
thumb: best.thumb ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[Discogs] searchRelease failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch artist images from Discogs using MusicBrainz ID for exact match.
|
||||
* Does NOT fall back to name-based search (too error-prone for common names).
|
||||
* Returns null if no MBID provided or no match found.
|
||||
*/
|
||||
async getArtistImageUrl(_artistName: string, artistMbid?: string): Promise<string | null> {
|
||||
if (!this.enabled || !artistMbid?.trim()) return null;
|
||||
|
||||
try {
|
||||
const mbPath = `/artists/${encodeURIComponent(artistMbid)}`;
|
||||
const data = await this.get<DiscogsArtistResponse>(mbPath);
|
||||
const images = data.images ?? [];
|
||||
|
||||
// Prefer primary image, then first available
|
||||
const primary = images.find((img) => img.type === 'primary') ?? images[0];
|
||||
return primary?.uri150 ?? primary?.uri ?? null;
|
||||
} catch (err) {
|
||||
console.warn('[Discogs] getArtistImageUrl failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Fanart.tv client for artist images.
|
||||
// API: https://fanart.tv/2015/03/api/
|
||||
// Requires API key (free tier available).
|
||||
|
||||
import { requestJson } from './http.js';
|
||||
|
||||
interface FanartArtistResponse {
|
||||
artistbackground?: Array<{ url: string }>;
|
||||
artistthumb?: Array<{ url: string }>;
|
||||
hdartistclearart?: Array<{ url: string }>;
|
||||
artistlogo?: Array<{ url: string }>;
|
||||
musicbanner?: Array<{ url: string }>;
|
||||
}
|
||||
|
||||
export class FanartClient {
|
||||
private readonly baseUrl = 'https://webservice.fanart.tv/v3/music';
|
||||
private readonly apiKey: string;
|
||||
private readonly userAgent = 'muzick/0.1';
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
this.apiKey = apiKey || process.env.FANART_API_KEY || '';
|
||||
}
|
||||
|
||||
private get enabled(): boolean {
|
||||
return this.apiKey.trim() !== '';
|
||||
}
|
||||
|
||||
async getArtistImages(mbid: string): Promise<{
|
||||
background?: string;
|
||||
thumb?: string;
|
||||
clearart?: string;
|
||||
logo?: string;
|
||||
banner?: string;
|
||||
} | null> {
|
||||
if (!this.enabled) return null;
|
||||
if (!mbid) return null;
|
||||
|
||||
const url = `${this.baseUrl}/${encodeURIComponent(mbid)}?api_key=${encodeURIComponent(this.apiKey)}`;
|
||||
|
||||
try {
|
||||
const data = await requestJson<FanartArtistResponse>(url, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: 1000, // Be respectful
|
||||
});
|
||||
|
||||
return {
|
||||
background: data.artistbackground?.[0]?.url,
|
||||
thumb: data.artistthumb?.[0]?.url,
|
||||
clearart: data.hdartistclearart?.[0]?.url,
|
||||
logo: data.artistlogo?.[0]?.url,
|
||||
banner: data.musicbanner?.[0]?.url,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[Fanart.tv] getArtistImages failed:', (err as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available artist image.
|
||||
* Priority: thumb > clearart > logo > background > banner
|
||||
*/
|
||||
async getBestArtistImage(mbid: string): Promise<string | null> {
|
||||
const images = await this.getArtistImages(mbid);
|
||||
if (!images) return null;
|
||||
|
||||
return images.thumb ?? images.clearart ?? images.logo ?? images.background ?? images.banner ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// HTTP base shared by every external-integration client.
|
||||
//
|
||||
// Provides:
|
||||
// - configurable User-Agent (REQUIRED by MusicBrainz, format:
|
||||
// `muzick/0.1 ( <contact> )`)
|
||||
// - SOCKS5 proxy support via SOCKS_PROXY_URL env var
|
||||
// (e.g. `socks5://127.0.0.1:10808`; no proxy when unset/empty)
|
||||
// - exponential backoff with jitter on 429 / 5xx / network errors,
|
||||
// honouring a `Retry-After` header when present
|
||||
// - a simple per-host rate limit (min interval between requests to the same
|
||||
// host) so e.g. MusicBrainz's <=1 req/sec rule is respected
|
||||
// - typed JSON parsing via generics and a typed error on persistent failure.
|
||||
//
|
||||
// Why not global fetch? Node's built-in fetch doesn't support proxy agents.
|
||||
// When SOCKS_PROXY_URL is set we build a proxy-aware fetcher via
|
||||
// socks-proxy-agent + native https.request. Otherwise we use the global fetch
|
||||
// (faster path, no overhead).
|
||||
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
|
||||
/** Error thrown when a request ultimately fails (non-2xx after all retries). */
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly url: string,
|
||||
readonly body?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Proxy-aware fetch factory ────────────────────────────────────────────────
|
||||
|
||||
const SOCKS_PROXY_URL = process.env.SOCKS_PROXY_URL || '';
|
||||
|
||||
/**
|
||||
* Return a `fetch`-compatible function that routes through the configured
|
||||
* SOCKS5 proxy (if any), or the bare global fetch when no proxy is needed.
|
||||
*/
|
||||
function createFetch(): typeof fetch {
|
||||
if (!SOCKS_PROXY_URL) return globalThis.fetch.bind(globalThis);
|
||||
|
||||
const agent = new SocksProxyAgent(SOCKS_PROXY_URL);
|
||||
|
||||
// Internal helper: issue a single request (no redirect following).
|
||||
function requestOnce(
|
||||
url: string,
|
||||
method: string,
|
||||
headers: Record<string, string> | undefined,
|
||||
body: BodyInit | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Response> {
|
||||
const urlObj = new URL(url);
|
||||
const mod = urlObj.protocol === 'https:' ? https : http;
|
||||
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const req = mod.request(url, { agent, method, headers, signal }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
resolve(
|
||||
new Response(Buffer.concat(chunks), {
|
||||
status: res.statusCode,
|
||||
statusText: res.statusMessage || '',
|
||||
headers: new Headers(
|
||||
Object.entries(res.headers).reduce(
|
||||
(acc, [k, v]) => {
|
||||
acc[k] = Array.isArray(v) ? v.join(', ') : String(v ?? '');
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
return async (input, init): Promise<Response> => {
|
||||
let url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
const method = init?.method || 'GET';
|
||||
const headers = init?.headers as Record<string, string> | undefined;
|
||||
const body = init?.body ?? undefined;
|
||||
const signal = init?.signal ?? undefined;
|
||||
|
||||
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
const res = await requestOnce(url, method, headers, body, signal);
|
||||
const code = res.status;
|
||||
|
||||
// Follow redirect (301, 302, 307, 308) — max 5 hops.
|
||||
if (code === 301 || code === 302 || code === 307 || code === 308) {
|
||||
const location = res.headers.get('location');
|
||||
if (!location) return res;
|
||||
url = new URL(location, url).href;
|
||||
continue;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// Too many redirects.
|
||||
return new Response(null, { status: 508, statusText: 'Loop Detected' });
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton fetcher — created once at module load.
|
||||
const fetchFn = createFetch();
|
||||
|
||||
export interface RequestJsonOptions {
|
||||
/** Full User-Agent header value. Required for MusicBrainz; recommended always. */
|
||||
userAgent: string;
|
||||
/** Minimum interval (ms) between requests to the same host. Default 1000. */
|
||||
minIntervalMs?: number;
|
||||
/** Max retry attempts on transient failures. Default 5. */
|
||||
maxRetries?: number;
|
||||
/** Base backoff delay (ms). Default 500. */
|
||||
baseDelayMs?: number;
|
||||
/** Cap on a single backoff delay (ms). Default 20000. */
|
||||
maxDelayMs?: number;
|
||||
/** Extra request headers (e.g. Accept). */
|
||||
headers?: Record<string, string>;
|
||||
/** Per-request timeout (ms). Default 15000. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
// Per-host timestamp of the last request start, used to enforce the rate limit.
|
||||
// Module-scoped so all clients sharing a host coordinate automatically.
|
||||
const lastRequestAt = new Map<string, number>();
|
||||
|
||||
const delay = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Block until at least `minIntervalMs` has elapsed since this host's last request. */
|
||||
async function throttle(host: string, minIntervalMs: number): Promise<void> {
|
||||
const now = Date.now();
|
||||
const last = lastRequestAt.get(host);
|
||||
if (last !== undefined) {
|
||||
const wait = minIntervalMs - (now - last);
|
||||
if (wait > 0) await delay(wait);
|
||||
}
|
||||
lastRequestAt.set(host, Date.now());
|
||||
}
|
||||
|
||||
/** Parse a Retry-After header (delta-seconds or HTTP date) into ms, or null. */
|
||||
function parseRetryAfter(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const secs = Number(value);
|
||||
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
|
||||
const when = Date.parse(value);
|
||||
if (!Number.isNaN(when)) return Math.max(0, when - Date.now());
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Exponential backoff with full jitter, capped at maxDelayMs. */
|
||||
function backoffDelay(attempt: number, baseDelayMs: number, maxDelayMs: number): number {
|
||||
const exp = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
|
||||
return Math.floor(Math.random() * exp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a URL and parse the JSON body as `T`. Retries transient failures with
|
||||
* backoff + jitter; enforces the per-host rate limit. Throws `HttpError` on a
|
||||
* persistent non-2xx response and rethrows the last network error if all
|
||||
* attempts fail.
|
||||
*/
|
||||
export async function requestJson<T>(
|
||||
url: string,
|
||||
opts: RequestJsonOptions
|
||||
): Promise<T> {
|
||||
const {
|
||||
userAgent,
|
||||
minIntervalMs = 1000,
|
||||
maxRetries = 5,
|
||||
baseDelayMs = 500,
|
||||
maxDelayMs = 20000,
|
||||
headers = {},
|
||||
timeoutMs = 15000,
|
||||
} = opts;
|
||||
|
||||
const host = hostOf(url);
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
await throttle(host, minIntervalMs);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetchFn(url, {
|
||||
headers: {
|
||||
'User-Agent': userAgent,
|
||||
Accept: 'application/json',
|
||||
...headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Retry on 429 (rate limited) and 5xx (transient server errors).
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
if (attempt < maxRetries) {
|
||||
const retryAfter = parseRetryAfter(res.headers.get('retry-after'));
|
||||
await delay(retryAfter ?? backoffDelay(attempt, baseDelayMs, maxDelayMs));
|
||||
continue;
|
||||
}
|
||||
const body = await res.text().catch(() => undefined);
|
||||
throw new HttpError(
|
||||
`Request failed after ${maxRetries} retries: ${res.status} ${res.statusText}`,
|
||||
res.status,
|
||||
url,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => undefined);
|
||||
throw new HttpError(
|
||||
`Request failed: ${res.status} ${res.statusText}`,
|
||||
res.status,
|
||||
url,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
return (await res.json()) as T;
|
||||
} catch (err) {
|
||||
// Non-retryable application errors propagate immediately.
|
||||
if (err instanceof HttpError) throw err;
|
||||
|
||||
// Network / abort errors: retry with backoff, otherwise rethrow.
|
||||
lastError = err;
|
||||
if (attempt < maxRetries) {
|
||||
await delay(backoffDelay(attempt, baseDelayMs, maxDelayMs));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable in practice; satisfies the type checker.
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error('requestJson: exhausted retries');
|
||||
}
|
||||
|
||||
/** Build the MusicBrainz-compliant User-Agent string. */
|
||||
export function buildUserAgent(contact: string): string {
|
||||
const c = contact && contact.trim() !== '' ? contact.trim() : 'no-contact-configured';
|
||||
return `muzick/0.1 ( ${c} )`;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Public surface of the external-integration client layer.
|
||||
export { integrationsConfig } from './config.js';
|
||||
export type {
|
||||
IntegrationsConfig,
|
||||
MusicBrainzConfig,
|
||||
LastFmConfig,
|
||||
DiscogsConfig,
|
||||
LrcLibConfig,
|
||||
CoverArtConfig,
|
||||
WikimediaConfig,
|
||||
} from './config.js';
|
||||
|
||||
export { requestJson, buildUserAgent, HttpError } from './http.js';
|
||||
export type { RequestJsonOptions } from './http.js';
|
||||
|
||||
export { MusicBrainzClient } from './musicbrainz.client.js';
|
||||
export type { RecordingMatch, RecordingDetail, RecordingCredit, ArtistTag as MusicBrainzArtistTag } from './musicbrainz.client.js';
|
||||
|
||||
export { LastFmClient } from './lastfm.client.js';
|
||||
export type {
|
||||
SimilarArtist,
|
||||
SimilarTrack,
|
||||
ArtistTag as LastFmArtistTag,
|
||||
} from './lastfm.client.js';
|
||||
|
||||
export { DiscogsClient } from './discogs.client.js';
|
||||
export type { DiscogsRelease } from './discogs.client.js';
|
||||
|
||||
export { LrcLibClient } from './lrclib.client.js';
|
||||
export type { LyricsResult } from './lrclib.client.js';
|
||||
|
||||
export { CoverArtClient } from './coverart.client.js';
|
||||
|
||||
export { ITunesClient, upscaleITunesArtwork } from './itunes.client.js';
|
||||
export type { ITunesAlbum } from './itunes.client.js';
|
||||
|
||||
export { DeezerClient } from './deezer.client.js';
|
||||
export type { DeezerAlbum } from './deezer.client.js';
|
||||
|
||||
export { WikimediaClient } from './wikimedia.client.js';
|
||||
|
||||
export { WikidataClient } from './wikidata.client.js';
|
||||
|
||||
export { FanartClient } from './fanart.client.js';
|
||||
|
||||
export { TheAudioDbClient } from './theaudiodb.client.js';
|
||||
@@ -0,0 +1,98 @@
|
||||
// iTunes Search API client (read-only).
|
||||
//
|
||||
// The iTunes Search API is free, requires no API key, and has excellent
|
||||
// coverage — nearly every released album is present. We use the album search
|
||||
// to resolve cover art URLs. The default artwork URL returned is 100×100, but
|
||||
// can be upgraded to 600×600 by swapping the size suffix in the URL string.
|
||||
//
|
||||
// Rate limit: Apple asks for "moderate" use (~20 req/min). We enforce a polite
|
||||
// per-host interval via the http base.
|
||||
//
|
||||
// Best-effort: on any error, methods log a warning and return null rather than
|
||||
// throwing, so enrichment never breaks the worker.
|
||||
|
||||
import { buildUserAgent, requestJson } from './http.js';
|
||||
|
||||
/** A normalised iTunes album search result (only the fields we read). */
|
||||
export interface ITunesAlbum {
|
||||
/** Artist canonical name. */
|
||||
artistName: string;
|
||||
/** Album / collection title. */
|
||||
collectionName: string;
|
||||
/** Cover art URL (100×100 — caller upscales). */
|
||||
artworkUrl100: string;
|
||||
}
|
||||
|
||||
interface ITunesSearchResult {
|
||||
artistName?: string;
|
||||
collectionName?: string;
|
||||
artworkUrl100?: string;
|
||||
}
|
||||
|
||||
interface ITunesSearchResponse {
|
||||
results?: ITunesSearchResult[];
|
||||
}
|
||||
|
||||
export class ITunesClient {
|
||||
private readonly baseUrl = 'https://itunes.apple.com';
|
||||
private readonly userAgent: string;
|
||||
private readonly minIntervalMs: number;
|
||||
|
||||
constructor(contact = '', minIntervalMs = 3000) {
|
||||
this.userAgent = buildUserAgent(contact);
|
||||
this.minIntervalMs = minIntervalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the iTunes catalog for an album by artist + title and return the
|
||||
* best match, or null if no match / error. The returned `artworkUrl100` is
|
||||
* the 100×100 thumbnail; callers upscale it to 600×600 via `upscaleArtwork`.
|
||||
*/
|
||||
async searchAlbum(artist: string, album: string): Promise<ITunesAlbum | null> {
|
||||
if (artist.trim() === '' || album.trim() === '') return null;
|
||||
|
||||
// The iTunes search `term` is a free-text query; we quote both values to
|
||||
// narrow the match. entity=album restricts to album collections.
|
||||
const term = `${artist} ${album}`;
|
||||
const qs = new URLSearchParams({ term, entity: 'album', limit: '5' });
|
||||
const url = `${this.baseUrl}/search?${qs.toString()}`;
|
||||
|
||||
try {
|
||||
const data = await requestJson<ITunesSearchResponse>(url, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: this.minIntervalMs,
|
||||
});
|
||||
const results = data.results ?? [];
|
||||
if (results.length === 0) return null;
|
||||
|
||||
// Prefer an exact title match (case-insensitive) to avoid grabbing a
|
||||
// "Greatest Hits" when searching for a studio album, then fall back to
|
||||
// the first result.
|
||||
const lowerAlbum = album.toLowerCase();
|
||||
const exact = results.find(
|
||||
(r) => r.collectionName?.toLowerCase() === lowerAlbum
|
||||
);
|
||||
const best = exact ?? results[0];
|
||||
|
||||
if (!best.artworkUrl100) return null;
|
||||
return {
|
||||
artistName: best.artistName ?? artist,
|
||||
collectionName: best.collectionName ?? album,
|
||||
artworkUrl100: best.artworkUrl100,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[iTunes] searchAlbum failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upscale an iTunes 100×100 artwork URL to a larger size. iTunes serves
|
||||
* artwork from a CDN that supports arbitrary sizes via the `NNNxNNN` path
|
||||
* segment (e.g. 600x600). Returns the input unchanged if it doesn't match the
|
||||
* expected shape.
|
||||
*/
|
||||
export function upscaleITunesArtwork(url: string, size = 600): string {
|
||||
return url.replace(/\/\d+x\d+(bb)?\.(jpg|png)$/, `/${size}x${size}bb.$2`);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// LRCLib client (lyrics lookup).
|
||||
//
|
||||
// No API key is required, so the client is always enabled. A polite per-host
|
||||
// rate limit (~250ms) is enforced via the http base.
|
||||
//
|
||||
// LRCLib's /get endpoint matches on track/artist/album/duration and returns 404
|
||||
// when nothing matches; that 404 is treated as "no lyrics" (null), not an error.
|
||||
// Any other failure also degrades to null so enrichment never breaks the worker.
|
||||
|
||||
import { integrationsConfig, LrcLibConfig } from './config.js';
|
||||
import { buildUserAgent, requestJson, HttpError } from './http.js';
|
||||
|
||||
export interface LyricsResult {
|
||||
/** Plain-text lyrics, if available. */
|
||||
plainLyrics: string | null;
|
||||
/** Time-synced lyrics as an LRC string, if available. */
|
||||
syncedLyrics: string | null;
|
||||
provider: 'lrclib';
|
||||
}
|
||||
|
||||
// --- Raw LRCLib response shape (only the fields we read) -------------------
|
||||
|
||||
interface LrcLibGetResponse {
|
||||
plainLyrics?: string | null;
|
||||
syncedLyrics?: string | null;
|
||||
}
|
||||
|
||||
export class LrcLibClient {
|
||||
private readonly cfg: LrcLibConfig;
|
||||
private readonly userAgent: string;
|
||||
|
||||
constructor(cfg: LrcLibConfig = integrationsConfig.lrclib) {
|
||||
this.cfg = cfg;
|
||||
this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact);
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: this.cfg.minIntervalMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up lyrics for a track. Returns plain + synced lyrics, or null when no
|
||||
* match (404) or on any error.
|
||||
*/
|
||||
async getLyrics(
|
||||
artist: string,
|
||||
title: string,
|
||||
album?: string,
|
||||
durationSec?: number
|
||||
): Promise<LyricsResult | null> {
|
||||
if (artist.trim() === '' || title.trim() === '') return null;
|
||||
|
||||
const qs = new URLSearchParams({ track_name: title, artist_name: artist });
|
||||
if (album && album.trim() !== '') qs.set('album_name', album);
|
||||
if (durationSec !== undefined && Number.isFinite(durationSec)) {
|
||||
qs.set('duration', String(Math.round(durationSec)));
|
||||
}
|
||||
const path = `/get?${qs.toString()}`;
|
||||
|
||||
try {
|
||||
const data = await this.get<LrcLibGetResponse>(path);
|
||||
const plainLyrics = data.plainLyrics ?? null;
|
||||
const syncedLyrics = data.syncedLyrics ?? null;
|
||||
if (plainLyrics === null && syncedLyrics === null) return null;
|
||||
return { plainLyrics, syncedLyrics, provider: 'lrclib' };
|
||||
} catch (err) {
|
||||
// 404 simply means no lyrics matched; treat as a clean miss.
|
||||
if (err instanceof HttpError && err.status === 404) return null;
|
||||
console.warn('[LRCLib] getLyrics failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
// MusicBrainz web-service v2 client.
|
||||
//
|
||||
// IMPORTANT MB requirements (https://musicbrainz.org/doc/MusicBrainz_API):
|
||||
// - A meaningful User-Agent is REQUIRED (set via http base, see buildUserAgent).
|
||||
// - Anonymous clients must not exceed 1 request/second. We enforce this through
|
||||
// the http base's per-host rate limit (minIntervalMs = 1000 by default).
|
||||
//
|
||||
// All methods are best-effort: on missing contact config or any error they log a
|
||||
// warning and return null / [] rather than throwing, so enrichment never breaks
|
||||
// the worker.
|
||||
|
||||
import { integrationsConfig, MusicBrainzConfig } from './config.js';
|
||||
import { buildUserAgent, requestJson } from './http.js';
|
||||
|
||||
/** Result of a recording lookup: the chosen recording plus linked entities. */
|
||||
export interface RecordingMatch {
|
||||
recordingMbid: string;
|
||||
title: string;
|
||||
/** Primary credited artist, if any. */
|
||||
artistMbid: string | null;
|
||||
artistName: string | null;
|
||||
/** First associated release (album), if any. */
|
||||
releaseMbid: string | null;
|
||||
releaseTitle: string | null;
|
||||
/** MB search score (0..100), surfaced for callers that want confidence. */
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** Result of an artist search: the canonical identity for a name query. */
|
||||
export interface ArtistMatch {
|
||||
artistMbid: string;
|
||||
/** MB canonical display name (e.g. "P!nk" not "Pink"). */
|
||||
name: string;
|
||||
/** MB sort-name (e.g. "Pink, P!" / "Beatles, The"). */
|
||||
sortName: string | null;
|
||||
/** MB search score (0..100). */
|
||||
score: number;
|
||||
/** Disambiguation comment when ambiguous (e.g. "US rock band"). */
|
||||
disambiguation: string | null;
|
||||
}
|
||||
|
||||
/** Result of a release-group search: the canonical album identity. */
|
||||
export interface ReleaseGroupMatch {
|
||||
releaseGroupMbid: string;
|
||||
title: string;
|
||||
/** Primary artist MBID on the release-group, if any. */
|
||||
artistMbid: string | null;
|
||||
artistName: string | null;
|
||||
/** First release date as a 4-digit year, if available. */
|
||||
year: number | null;
|
||||
/** First release date as an ISO string (YYYY-MM-DD or YYYY-MM or YYYY), if available. */
|
||||
firstReleaseDate: string | null;
|
||||
/** MB search score (0..100). */
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** A normalised genre/tag with a 0..1 weight. */
|
||||
export interface ArtistTag {
|
||||
name: string;
|
||||
/** Normalised 0..1 weight (relative to the strongest tag for this artist). */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/** One entry in a MusicBrainz artist-credit array. */
|
||||
export interface RecordingCredit {
|
||||
/** The MB artist ID (UUID). */
|
||||
artistMbid: string;
|
||||
/** Canonical display name from the artist object. */
|
||||
artistName: string;
|
||||
/** Name as credited (may differ, e.g. "P!nk" vs "Pink"). */
|
||||
creditName: string;
|
||||
/** Joinphrase that follows this credit (" feat. ", " & ", etc.). */
|
||||
joinphrase?: string;
|
||||
}
|
||||
|
||||
/** Full recording fetched by MBID with inc=artist-credits. */
|
||||
export interface RecordingDetail {
|
||||
recordingMbid: string;
|
||||
title: string;
|
||||
artistCredit: RecordingCredit[];
|
||||
}
|
||||
|
||||
/** Full release-group fetched by MBID with inc=artists. */
|
||||
export interface ReleaseGroupDetail {
|
||||
releaseGroupMbid: string;
|
||||
title: string;
|
||||
artistCredit: RecordingCredit[];
|
||||
}
|
||||
|
||||
/** One artist-relation entry on an artist-rels lookup. */
|
||||
export interface ArtistRelation {
|
||||
/** MB relation type label, e.g. "member of", "is performance name of". */
|
||||
type: string;
|
||||
/** "forward" or "backward". */
|
||||
direction: string;
|
||||
/** The related artist's MBID. */
|
||||
targetMbid: string;
|
||||
/** The related artist's canonical display name. */
|
||||
targetName: string;
|
||||
}
|
||||
|
||||
// --- Raw MB response shapes (only the fields we read) ----------------------
|
||||
|
||||
interface MbArtistCredit {
|
||||
artist?: { id?: string; name?: string };
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface MbReleaseRef {
|
||||
id?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface MbRecording {
|
||||
id: string;
|
||||
title: string;
|
||||
score?: number;
|
||||
'artist-credit'?: MbArtistCredit[];
|
||||
releases?: MbReleaseRef[];
|
||||
}
|
||||
|
||||
interface MbRecordingDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
'artist-credit'?: Array<{
|
||||
artist?: { id?: string; name?: string };
|
||||
name?: string;
|
||||
joinphrase?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MbRecordingSearchResponse {
|
||||
recordings?: MbRecording[];
|
||||
}
|
||||
|
||||
interface MbReleaseGroupDetail {
|
||||
id: string;
|
||||
title?: string;
|
||||
'artist-credit'?: Array<{
|
||||
artist?: { id?: string; name?: string };
|
||||
name?: string;
|
||||
joinphrase?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MbRelation {
|
||||
type?: string;
|
||||
direction?: string;
|
||||
// MusicBrainz returns the related entity under a typed key (`artist` for
|
||||
// artist-rels); recent MB versions may also surface a generic `target`
|
||||
// string. We read `artist.id` / `artist.name` defensively.
|
||||
artist?: { id?: string; name?: string };
|
||||
target?: string;
|
||||
}
|
||||
|
||||
interface MbRelationList {
|
||||
'target-type'?: string;
|
||||
relations?: MbRelation[];
|
||||
}
|
||||
|
||||
interface MbArtistRelationsResponse {
|
||||
id?: string;
|
||||
name?: string;
|
||||
// Modern MB: top-level `relations` array.
|
||||
relations?: MbRelation[];
|
||||
// Older MB: `relation-list` array, each grouping relations by target-type.
|
||||
'relation-list'?: MbRelationList[];
|
||||
}
|
||||
|
||||
interface MbTag {
|
||||
name?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface MbGenre {
|
||||
name?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface MbArtistResponse {
|
||||
tags?: MbTag[];
|
||||
genres?: MbGenre[];
|
||||
}
|
||||
|
||||
interface MbArtistSearch {
|
||||
id: string;
|
||||
name?: string;
|
||||
'sort-name'?: string;
|
||||
score?: number;
|
||||
disambiguation?: string;
|
||||
}
|
||||
|
||||
interface MbArtistSearchResponse {
|
||||
artists?: MbArtistSearch[];
|
||||
}
|
||||
|
||||
interface MbReleaseGroupSearch {
|
||||
id: string;
|
||||
title?: string;
|
||||
score?: number;
|
||||
'first-release-date'?: string;
|
||||
'artist-credit'?: MbArtistCredit[];
|
||||
}
|
||||
|
||||
interface MbReleaseGroupSearchResponse {
|
||||
'release-groups'?: MbReleaseGroupSearch[];
|
||||
}
|
||||
|
||||
export class MusicBrainzClient {
|
||||
private readonly cfg: MusicBrainzConfig;
|
||||
private readonly userAgent: string;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(cfg: MusicBrainzConfig = integrationsConfig.musicbrainz) {
|
||||
this.cfg = cfg;
|
||||
this.userAgent = buildUserAgent(cfg.contact);
|
||||
this.enabled = cfg.contact.trim() !== '';
|
||||
if (!this.enabled) {
|
||||
console.warn(
|
||||
'[MusicBrainz] MUSICBRAINZ_CONTACT not set; client disabled (methods return null/[]).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: this.cfg.minIntervalMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the recording endpoint for the best match for artist/title(/album)
|
||||
* and return its MBID plus the linked artist + first release, or null if no
|
||||
* match (or the client is disabled / errors).
|
||||
*
|
||||
* `minScore` (default 85): MB returns results pre-sorted by a 0..100 search
|
||||
* score. Below this threshold the top hit is more often wrong than right, so
|
||||
* we return null rather than poisoning identity with a bad MBID. Pass a lower
|
||||
* value only for deliberately fuzzy matching.
|
||||
*/
|
||||
async lookupRecording(
|
||||
artist: string,
|
||||
title: string,
|
||||
album?: string,
|
||||
minScore = 85
|
||||
): Promise<RecordingMatch | null> {
|
||||
if (!this.enabled) return null;
|
||||
|
||||
// Build a fielded Lucene query; each value is quoted + escaped.
|
||||
const esc = (v: string): string => v.replace(/(["\\])/g, '\\$1');
|
||||
const terms = [`artist:"${esc(artist)}"`, `recording:"${esc(title)}"`];
|
||||
if (album && album.trim() !== '') terms.push(`release:"${esc(album)}"`);
|
||||
const query = terms.join(' AND ');
|
||||
|
||||
const path = `/recording?query=${encodeURIComponent(query)}&fmt=json&limit=5`;
|
||||
|
||||
try {
|
||||
const data = await this.get<MbRecordingSearchResponse>(path);
|
||||
const recordings = data.recordings ?? [];
|
||||
if (recordings.length === 0) return null;
|
||||
|
||||
// MB returns results pre-sorted by score; take the top entry.
|
||||
const best = recordings[0];
|
||||
const score = typeof best.score === 'number' ? best.score : 0;
|
||||
if (score < minScore) return null;
|
||||
|
||||
const credit = best['artist-credit']?.[0];
|
||||
const release = best.releases?.[0];
|
||||
|
||||
return {
|
||||
recordingMbid: best.id,
|
||||
title: best.title,
|
||||
artistMbid: credit?.artist?.id ?? null,
|
||||
artistName: credit?.artist?.name ?? credit?.name ?? null,
|
||||
releaseMbid: release?.id ?? null,
|
||||
releaseTitle: release?.title ?? null,
|
||||
score,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[MusicBrainz] lookupRecording failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the artist endpoint for the canonical identity matching a name.
|
||||
* Returns the best match above `minScore` (default 85), or null. This is the
|
||||
* correct endpoint for artist canonicalisation — unlike recording search it
|
||||
* returns the authoritative `name` + `sort-name` directly.
|
||||
*/
|
||||
async searchArtist(name: string, minScore = 85): Promise<ArtistMatch | null> {
|
||||
if (!this.enabled) return null;
|
||||
if (name.trim() === '') return null;
|
||||
|
||||
const esc = (v: string): string => v.replace(/(["\\])/g, '\\$1');
|
||||
const path = `/artist?query=${encodeURIComponent(`artist:"${esc(name)}"`)}&fmt=json&limit=5`;
|
||||
|
||||
try {
|
||||
const data = await this.get<MbArtistSearchResponse>(path);
|
||||
const artists = data.artists ?? [];
|
||||
if (artists.length === 0) return null;
|
||||
|
||||
const best = artists[0];
|
||||
const score = typeof best.score === 'number' ? best.score : 0;
|
||||
if (score < minScore) return null;
|
||||
|
||||
return {
|
||||
artistMbid: best.id,
|
||||
name: best.name ?? name,
|
||||
sortName: best['sort-name'] ?? null,
|
||||
score,
|
||||
disambiguation: best.disambiguation ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[MusicBrainz] searchArtist failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the release-group endpoint for the canonical album identity matching
|
||||
* an artist + album title. Returns the best match above `minScore` (default
|
||||
* 80 — album titles vary more than artist names, so a slightly looser
|
||||
* threshold avoids missing legitimate matches), or null.
|
||||
*
|
||||
* A release-group is MB's canonical "album" entity: it groups all
|
||||
* editions/releases of the same album under one stable MBID, making it the
|
||||
* right value for `albums.mbid`.
|
||||
*/
|
||||
async searchReleaseGroup(
|
||||
artist: string,
|
||||
album: string,
|
||||
minScore = 80
|
||||
): Promise<ReleaseGroupMatch | null> {
|
||||
if (!this.enabled) return null;
|
||||
if (artist.trim() === '' || album.trim() === '') return null;
|
||||
|
||||
const esc = (v: string): string => v.replace(/(["\\])/g, '\\$1');
|
||||
const query = `artist:"${esc(artist)}" AND release:"${esc(album)}"`;
|
||||
const path = `/release-group?query=${encodeURIComponent(query)}&fmt=json&limit=5`;
|
||||
|
||||
try {
|
||||
const data = await this.get<MbReleaseGroupSearchResponse>(path);
|
||||
const groups = data['release-groups'] ?? [];
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
const best = groups[0];
|
||||
const score = typeof best.score === 'number' ? best.score : 0;
|
||||
if (score < minScore) return null;
|
||||
|
||||
const credit = best['artist-credit']?.[0];
|
||||
const firstReleaseDate = best['first-release-date'] ?? null;
|
||||
const year = firstReleaseDate
|
||||
? parseInt(firstReleaseDate.slice(0, 4), 10)
|
||||
: null;
|
||||
|
||||
return {
|
||||
releaseGroupMbid: best.id,
|
||||
title: best.title ?? album,
|
||||
artistMbid: credit?.artist?.id ?? null,
|
||||
artistName: credit?.artist?.name ?? credit?.name ?? null,
|
||||
year: Number.isFinite(year) ? year : null,
|
||||
firstReleaseDate,
|
||||
score,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[MusicBrainz] searchReleaseGroup failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an artist's tags + genres and return them normalised to 0..1 weights
|
||||
* (relative to the strongest tag). Returns [] if none / disabled / error.
|
||||
*/
|
||||
async getArtistTags(artistMbid: string): Promise<ArtistTag[]> {
|
||||
if (!this.enabled) return [];
|
||||
if (!artistMbid) return [];
|
||||
|
||||
const path = `/artist/${encodeURIComponent(artistMbid)}?inc=tags+genres&fmt=json`;
|
||||
|
||||
try {
|
||||
const data = await this.get<MbArtistResponse>(path);
|
||||
const raw = [...(data.genres ?? []), ...(data.tags ?? [])];
|
||||
|
||||
// Merge by name (genre + tag lists can overlap), keeping the max count.
|
||||
const byName = new Map<string, number>();
|
||||
for (const t of raw) {
|
||||
const name = t.name?.trim();
|
||||
if (!name) continue;
|
||||
const count = typeof t.count === 'number' ? t.count : 0;
|
||||
byName.set(name, Math.max(byName.get(name) ?? 0, count));
|
||||
}
|
||||
if (byName.size === 0) return [];
|
||||
|
||||
const maxCount = Math.max(...byName.values(), 1);
|
||||
return [...byName.entries()]
|
||||
.map(([name, count]) => ({ name, weight: count / maxCount }))
|
||||
.sort((a, b) => b.weight - a.weight);
|
||||
} catch (err) {
|
||||
console.warn('[MusicBrainz] getArtistTags failed:', (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a recording by its stable MBID with the full artist-credit array.
|
||||
* Unlike lookupRecording (search), this is a direct ID lookup with no search
|
||||
* step, making it the correct endpoint for spine-claim generation.
|
||||
*/
|
||||
async getRecording(recordingMbid: string): Promise<RecordingDetail | null> {
|
||||
if (!this.enabled) return null;
|
||||
if (!recordingMbid) return null;
|
||||
|
||||
const path = `/recording/${encodeURIComponent(recordingMbid)}?inc=artist-credits&fmt=json`;
|
||||
|
||||
try {
|
||||
const data = await this.get<MbRecordingDetail>(path);
|
||||
if (!data || !data.id) return null;
|
||||
|
||||
const credit = (data['artist-credit'] ?? [])
|
||||
.map(c => ({
|
||||
artistMbid: c.artist?.id ?? '',
|
||||
artistName: c.artist?.name ?? c.name ?? '',
|
||||
creditName: c.name ?? c.artist?.name ?? '',
|
||||
joinphrase: c.joinphrase,
|
||||
}))
|
||||
.filter(c => c.artistMbid !== '');
|
||||
|
||||
return {
|
||||
recordingMbid: data.id,
|
||||
title: data.title,
|
||||
artistCredit: credit,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[MusicBrainz] getRecording failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a release-group by its stable MBID with the full artist-credit
|
||||
* array (`inc=artists`). Direct ID lookup — the analogous endpoint to
|
||||
* getRecording() for album spine-claim generation.
|
||||
*/
|
||||
async getReleaseGroup(releaseGroupMbid: string): Promise<ReleaseGroupDetail | null> {
|
||||
if (!this.enabled) return null;
|
||||
if (!releaseGroupMbid) return null;
|
||||
|
||||
const path = `/release-group/${encodeURIComponent(releaseGroupMbid)}?inc=artists&fmt=json`;
|
||||
|
||||
try {
|
||||
const data = await this.get<MbReleaseGroupDetail>(path);
|
||||
if (!data || !data.id) return null;
|
||||
|
||||
const credit = (data['artist-credit'] ?? [])
|
||||
.map(c => ({
|
||||
artistMbid: c.artist?.id ?? '',
|
||||
artistName: c.artist?.name ?? c.name ?? '',
|
||||
creditName: c.name ?? c.artist?.name ?? '',
|
||||
joinphrase: c.joinphrase,
|
||||
}))
|
||||
.filter(c => c.artistMbid !== '');
|
||||
|
||||
return {
|
||||
releaseGroupMbid: data.id,
|
||||
title: data.title ?? '',
|
||||
artistCredit: credit,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[MusicBrainz] getReleaseGroup failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an artist's artist-relations (`inc=artist-rels`) and return the
|
||||
* relation list normalised. Direct ID lookup — the source for `member_of`
|
||||
* and `alias_of` claim generation.
|
||||
*
|
||||
* The MB web service exposes relations both as a modern top-level `relations`
|
||||
* array and the legacy `relation-list` groupings keyed by `target-type`; we
|
||||
* read both defensively. Only entries with a target artist MBID are kept.
|
||||
*/
|
||||
async getArtistRelations(artistMbid: string): Promise<ArtistRelation[]> {
|
||||
if (!this.enabled) return [];
|
||||
if (!artistMbid) return [];
|
||||
|
||||
const path = `/artist/${encodeURIComponent(artistMbid)}?inc=artist-rels&fmt=json`;
|
||||
|
||||
try {
|
||||
const data = await this.get<MbArtistRelationsResponse>(path);
|
||||
if (!data) return [];
|
||||
|
||||
const raw: MbRelation[] = [];
|
||||
if (Array.isArray(data.relations)) {
|
||||
raw.push(...data.relations);
|
||||
}
|
||||
for (const list of data['relation-list'] ?? []) {
|
||||
if (list['target-type'] === 'artist' && Array.isArray(list.relations)) {
|
||||
raw.push(...list.relations);
|
||||
}
|
||||
}
|
||||
|
||||
return raw
|
||||
.map(r => ({
|
||||
type: r.type ?? '',
|
||||
direction: r.direction ?? 'forward',
|
||||
targetMbid: r.artist?.id ?? r.target ?? '',
|
||||
targetName: r.artist?.name ?? '',
|
||||
}))
|
||||
.filter(r => r.targetMbid !== '');
|
||||
} catch (err) {
|
||||
console.warn('[MusicBrainz] getArtistRelations failed:', (err as Error).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// TheAudioDB client for artist images.
|
||||
// API: https://www.theaudiodb.com/api_guide.php
|
||||
// Free tier: 2 requests/second, no key required for basic use.
|
||||
// With API key: higher limits.
|
||||
|
||||
import { requestJson } from './http.js';
|
||||
|
||||
interface AudioDbArtist {
|
||||
idArtist: string;
|
||||
strArtist: string;
|
||||
strArtistThumb?: string;
|
||||
strArtistBanner?: string;
|
||||
strArtistFanart?: string;
|
||||
strArtistLogo?: string;
|
||||
strArtistClearart?: string;
|
||||
strArtistWideThumb?: string;
|
||||
}
|
||||
|
||||
interface AudioDbResponse {
|
||||
artists?: AudioDbArtist[];
|
||||
}
|
||||
|
||||
export class TheAudioDbClient {
|
||||
private readonly baseUrl = 'https://www.theaudiodb.com/api/v1/json';
|
||||
private readonly apiKey: string;
|
||||
private readonly userAgent = 'muzick/0.1';
|
||||
|
||||
constructor(apiKey?: string) {
|
||||
// Free tier uses '1' as key, paid tiers get custom keys
|
||||
this.apiKey = apiKey || process.env.THEAUDIO_DB_API_KEY || '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for artist by name.
|
||||
*/
|
||||
async searchArtist(name: string): Promise<AudioDbArtist | null> {
|
||||
const url = `${this.baseUrl}/${this.apiKey}/search.php?s=${encodeURIComponent(name)}`;
|
||||
|
||||
try {
|
||||
const data = await requestJson<AudioDbResponse>(url, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: 500, // 2 req/sec limit
|
||||
});
|
||||
|
||||
return data.artists?.[0] ?? null;
|
||||
} catch (err) {
|
||||
console.warn('[TheAudioDB] searchArtist failed:', (err as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get artist by MusicBrainz ID.
|
||||
*/
|
||||
async getArtistByMbid(mbid: string): Promise<AudioDbArtist | null> {
|
||||
const url = `${this.baseUrl}/${this.apiKey}/artist-mb.php?i=${encodeURIComponent(mbid)}`;
|
||||
|
||||
try {
|
||||
const data = await requestJson<AudioDbResponse>(url, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: 500,
|
||||
});
|
||||
|
||||
return data.artists?.[0] ?? null;
|
||||
} catch (err) {
|
||||
console.warn('[TheAudioDB] getArtistByMbid failed:', (err as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available artist image.
|
||||
* Priority: thumb > fanart > banner > logo > clearart > wide thumb
|
||||
*/
|
||||
async getBestArtistImage(mbid: string): Promise<string | null> {
|
||||
// Try by MBID first (most accurate)
|
||||
let artist = await this.getArtistByMbid(mbid);
|
||||
|
||||
// Fallback: search by name would need the name, which we don't have here
|
||||
// Caller should handle name-based fallback
|
||||
|
||||
if (!artist) return null;
|
||||
|
||||
return (
|
||||
artist.strArtistThumb ??
|
||||
artist.strArtistFanart ??
|
||||
artist.strArtistBanner ??
|
||||
artist.strArtistLogo ??
|
||||
artist.strArtistClearart ??
|
||||
artist.strArtistWideThumb ??
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Wikidata client for artist images.
|
||||
// Flow: MusicBrainz artist MBID → Wikidata ID (via MB) → Image (P18 property).
|
||||
//
|
||||
// Wikidata API: https://www.wikidata.org/w/api.php
|
||||
// Property P18 = "image" (the main image for an entity).
|
||||
|
||||
import { requestJson } from './http.js';
|
||||
|
||||
interface WikidataEntity {
|
||||
id: string;
|
||||
labels?: Record<string, { value: string }>;
|
||||
claims?: Record<string, Array<{
|
||||
mainsnak?: {
|
||||
datavalue?: { value: string };
|
||||
};
|
||||
}>>;
|
||||
}
|
||||
|
||||
interface WikidataSearchResponse {
|
||||
search?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface WikidataEntitiesResponse {
|
||||
entities?: Record<string, WikidataEntity>;
|
||||
}
|
||||
|
||||
export class WikidataClient {
|
||||
private readonly baseUrl = 'https://www.wikidata.org/w/api.php';
|
||||
private readonly userAgent = 'muzick/0.1 (https://github.com/user/muzick)';
|
||||
|
||||
/**
|
||||
* Get Wikidata ID for a MusicBrainz artist MBID.
|
||||
* Queries Wikidata for entities with the MusicBrainz artist ID (P434).
|
||||
*/
|
||||
async getWikidataIdFromMbid(mbid: string): Promise<string | null> {
|
||||
// Search Wikidata for entities with this MBID (P434 = MusicBrainz artist ID)
|
||||
const url = `${this.baseUrl}?action=wbsearchentities&search=${encodeURIComponent(mbid)}&language=en&format=json&type=item&props=claims&sitefilter=musicbrainz`;
|
||||
|
||||
try {
|
||||
const data = await requestJson<WikidataSearchResponse>(url, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: 500, // Wikidata allows higher rate
|
||||
});
|
||||
|
||||
const results = data.search ?? [];
|
||||
if (results.length > 0) {
|
||||
return results[0].id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Wikidata] getWikidataIdFromMbid failed:', (err as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Wikidata for an artist by name.
|
||||
* Less reliable than MBID → Wikidata, but works as fallback.
|
||||
*/
|
||||
async searchArtistByName(name: string): Promise<string | null> {
|
||||
const url = `${this.baseUrl}?action=wbsearchentities&search=${encodeURIComponent(name)}&language=en&format=json&type=item&limit=5`;
|
||||
|
||||
try {
|
||||
const data = await requestJson<WikidataSearchResponse>(url, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: 500,
|
||||
});
|
||||
|
||||
const results = data.search ?? [];
|
||||
// Filter for music artists (instance of: human, musical artist, band, etc.)
|
||||
for (const result of results) {
|
||||
if (this.looksLikeMusicArtist(result)) {
|
||||
return result.id;
|
||||
}
|
||||
}
|
||||
return results[0]?.id ?? null;
|
||||
} catch (err) {
|
||||
console.warn('[Wikidata] searchArtistByName failed:', (err as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private looksLikeMusicArtist(result: { label: string; description: string }): boolean {
|
||||
const desc = result.description.toLowerCase();
|
||||
const musicKeywords = ['singer', 'musician', 'band', 'artist', 'rapper', 'producer', 'composer', 'dj', 'group'];
|
||||
return musicKeywords.some(k => desc.includes(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main image (P18) for a Wikidata entity.
|
||||
* Returns the image filename (e.g., "Artist_Name.jpg") which can be used
|
||||
* with Wikimedia Commons URL: https://commons.wikimedia.org/wiki/File:...
|
||||
*/
|
||||
async getImage(wikidataId: string): Promise<string | null> {
|
||||
const url = `${this.baseUrl}?action=wbgetentities&ids=${encodeURIComponent(wikidataId)}&props=claims&format=json`;
|
||||
|
||||
try {
|
||||
const data = await requestJson<WikidataEntitiesResponse>(url, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: 500,
|
||||
});
|
||||
|
||||
const entity = data.entities?.[wikidataId];
|
||||
if (!entity?.claims?.P18) return null;
|
||||
|
||||
const imageClaim = entity.claims.P18[0];
|
||||
const imageName = imageClaim?.mainsnak?.datavalue?.value;
|
||||
if (!imageName) return null;
|
||||
|
||||
// Return Wikimedia Commons URL
|
||||
// Format: https://commons.wikimedia.org/wiki/Special:FilePath/filename
|
||||
const encoded = encodeURIComponent(imageName.replace(/ /g, '_'));
|
||||
return `https://commons.wikimedia.org/wiki/Special:FilePath/${encoded}`;
|
||||
} catch (err) {
|
||||
console.warn('[Wikidata] getImage failed:', (err as Error).message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pipeline: MBID → Wikidata ID → Image URL.
|
||||
*/
|
||||
async getArtistImageFromMbid(mbid: string): Promise<string | null> {
|
||||
const wikidataId = await this.getWikidataIdFromMbid(mbid);
|
||||
if (!wikidataId) return null;
|
||||
return this.getImage(wikidataId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pipeline fallback: name → Wikidata ID → Image URL.
|
||||
*/
|
||||
async getArtistImageFromName(name: string): Promise<string | null> {
|
||||
const wikidataId = await this.searchArtistByName(name);
|
||||
if (!wikidataId) return null;
|
||||
return this.getImage(wikidataId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Wikimedia Commons client for artist images.
|
||||
// Uses the MediaWiki API to search for and fetch artist images.
|
||||
// No API key required; polite rate limit (~250ms) enforced via http base.
|
||||
|
||||
import { integrationsConfig, WikimediaConfig } from './config.js';
|
||||
import { buildUserAgent, requestJson, HttpError } from './http.js';
|
||||
|
||||
interface WikimediaSearchResult {
|
||||
title?: string;
|
||||
pageid?: number;
|
||||
thumbnail?: { source?: string; width?: number; height?: number };
|
||||
}
|
||||
|
||||
interface WikimediaSearchResponse {
|
||||
query?: {
|
||||
search?: WikimediaSearchResult[];
|
||||
};
|
||||
}
|
||||
|
||||
interface WikimediaImageInfoResponse {
|
||||
query?: {
|
||||
pages?: {
|
||||
[pageId: string]: {
|
||||
imageinfo?: { url?: string; thumburl?: string; width?: number; height?: number }[];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class WikimediaClient {
|
||||
private readonly cfg: WikimediaConfig;
|
||||
private readonly userAgent: string;
|
||||
|
||||
constructor(cfg: WikimediaConfig = integrationsConfig.wikimedia) {
|
||||
this.cfg = cfg;
|
||||
this.userAgent = buildUserAgent(integrationsConfig.musicbrainz.contact);
|
||||
}
|
||||
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
return requestJson<T>(`${this.cfg.baseUrl}${path}`, {
|
||||
userAgent: this.userAgent,
|
||||
minIntervalMs: this.cfg.minIntervalMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Wikimedia Commons for an artist image and return the best available
|
||||
* thumbnail URL (preferring larger sizes), or null on miss/error.
|
||||
*/
|
||||
async getArtistImageUrl(artistName: string): Promise<string | null> {
|
||||
if (artistName.trim() === '') return null;
|
||||
|
||||
try {
|
||||
// Search for the artist on Wikimedia Commons
|
||||
const searchQs = new URLSearchParams({
|
||||
action: 'query',
|
||||
list: 'search',
|
||||
srsearch: `${artistName} artist`,
|
||||
srnamespace: '6', // File namespace
|
||||
srlimit: '5',
|
||||
format: 'json',
|
||||
});
|
||||
const searchPath = `/w/api.php?${searchQs.toString()}`;
|
||||
const searchData = await this.get<WikimediaSearchResponse>(searchPath);
|
||||
|
||||
const results = searchData.query?.search ?? [];
|
||||
if (results.length === 0) return null;
|
||||
|
||||
// Get image info for the first few results to find the best thumbnail
|
||||
const pageIds = results.slice(0, 3).map((r) => r.pageid).filter((id): id is number => id !== undefined);
|
||||
if (pageIds.length === 0) return null;
|
||||
|
||||
const imageQs = new URLSearchParams({
|
||||
action: 'query',
|
||||
prop: 'imageinfo',
|
||||
iiprop: 'url|thumburl|width|height',
|
||||
iiurlwidth: '300',
|
||||
iiurlheight: '300',
|
||||
pageids: pageIds.join('|'),
|
||||
format: 'json',
|
||||
});
|
||||
const imagePath = `/w/api.php?${imageQs.toString()}`;
|
||||
const imageData = await this.get<WikimediaImageInfoResponse>(imagePath);
|
||||
|
||||
const pages = imageData.query?.pages ?? {};
|
||||
for (const pageId of pageIds) {
|
||||
const page = pages[pageId.toString()];
|
||||
if (page?.imageinfo?.[0]?.thumburl) {
|
||||
return page.imageinfo[0].thumburl;
|
||||
}
|
||||
if (page?.imageinfo?.[0]?.url) {
|
||||
return page.imageinfo[0].url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 404) return null;
|
||||
console.warn('[Wikimedia] getArtistImageUrl failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user