initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+112
View File
@@ -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,
},
};
+102
View File
@@ -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;
}
}
+84
View File
@@ -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;
}
}
}
+130
View File
@@ -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;
}
}
}
+69
View File
@@ -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;
}
}
+270
View File
@@ -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} )`;
}
+46
View File
@@ -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';
+98
View File
@@ -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`);
}
+189
View File
@@ -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 [];
}
}
}
+76
View File
@@ -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
);
}
}
+140
View File
@@ -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;
}
}
}