@southctrl/musixmatch-lyrics
Version:
Unofficial Musixmatch lyrics API wrapper for Node.js
411 lines (367 loc) • 12.5 kB
JavaScript
import { unlink } from 'node:fs/promises';
import { readToken, saveToken } from './utils/cache.js';
import { cleanLyrics, parseSubtitles } from './utils/parser.js';
import { ENDPOINTS, HEADERS, REGEX } from './utils/constants.js';
let defaultFetch = null;
let cookieFetch = null;
async function getFetch(withCookies = true) {
if (withCookies) {
if (!cookieFetch) {
const fetchCookie = await import('fetch-cookie').then(m => m.default || m);
if (typeof fetchCookie !== 'function') {
throw new Error('Invalid fetch-cookie export');
}
cookieFetch = fetchCookie(globalThis.fetch);
}
return cookieFetch;
}
if (!defaultFetch) {
if (typeof globalThis.fetch === 'function') {
defaultFetch = globalThis.fetch;
} else {
throw new Error('No global fetch available');
}
}
return defaultFetch;
}
function resetCookieFetch() {
cookieFetch = null;
}
class HttpError extends Error {
constructor(status, message) {
super(message || `HTTP ${status}`);
this.status = status;
}
}
class MxmApiError extends Error {
constructor(code, hint) {
super(hint || `Musixmatch API error ${code}`);
this.code = code;
this.hint = hint;
}
}
export class Musixmatch {
constructor(opts = {}) {
this.APP_ID = 'web-desktop-app-v1.0';
// Token handling
this.tokenData = null;
this.tokenPromise = null;
this.TOKEN_TTL = 55_000; // 55s (sliding)
this.TOKEN_FILE = 'musixmatch_token.json';
this.lastTokenPersist = 0;
this.TOKEN_PERSIST_MIN_INTERVAL = 5_000; // debounce disk writes
// Requests
this.requestTimeoutMs = opts.requestTimeoutMs || 10_000; // tighter timeout for speed
// Simple in-memory cache for repeated queries
this.cache = new Map();
this.CACHE_TTL = opts.cacheTTL || (5 * 60_000); // 5 minutes
}
buildUrl(base, params) {
const url = new URL(base);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) url.searchParams.set(key, value);
});
return url.toString();
}
async readTokenFromFile() {
try {
const data = await readToken(this.TOKEN_FILE);
if (data?.value && data?.expires) return data;
} catch {}
return null;
}
async saveTokenToFile(token, expires) {
try {
await saveToken(this.TOKEN_FILE, token, expires);
} catch (error) {
// non-fatal
console.error('Failed to save token to file:', error);
}
}
async apiGet(url, opts = {}) {
const fetch = await getFetch(opts.withCookies !== false);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
try {
const res = await fetch(url, {
headers: { ...HEADERS, ...(opts.headers || {}) },
signal: controller.signal
});
if (!res.ok) {
throw new HttpError(res.status, `API request failed: ${res.status}`);
}
return res.json();
} finally {
clearTimeout(timer);
}
}
async fetchTokenAttempt(withCookies = true) {
const url = this.buildUrl(ENDPOINTS.TOKEN, { app_id: this.APP_ID });
const data = await this.apiGet(url, { withCookies });
const header = data?.message?.header;
if (header?.status_code !== 200) {
throw new MxmApiError(header?.status_code ?? 0, header?.hint || 'Invalid token response');
}
return data.message.body.user_token;
}
async resetToken(hard = false) {
this.tokenData = null;
this.tokenPromise = null;
if (hard) {
try {
await unlink(this.TOKEN_FILE);
} catch {
// ignore if not present
}
}
}
// Sliding TTL, debounced persistence, captcha-aware retry
async getToken(force = false) {
const now = Date.now();
if (!this.tokenData) {
this.tokenData = await this.readTokenFromFile();
}
if (!force && this.tokenData && now < this.tokenData.expires) {
// Touch (sliding TTL) and debounce persistence
this.tokenData.expires = now + this.TOKEN_TTL;
if (now - this.lastTokenPersist > this.TOKEN_PERSIST_MIN_INTERVAL) {
this.lastTokenPersist = now;
this.saveTokenToFile(this.tokenData.value, this.tokenData.expires).catch(() => {});
}
return this.tokenData.value;
}
if (this.tokenPromise) return this.tokenPromise;
this.tokenPromise = (async () => {
try {
// First attempt
try {
const token = await this.fetchTokenAttempt(true);
const expires = Date.now() + this.TOKEN_TTL;
this.tokenData = { value: token, expires };
await this.saveTokenToFile(token, expires);
return token;
} catch (err) {
const isCaptcha =
err instanceof MxmApiError &&
(err.code === 401 || err.code === 403 || /captcha/i.test(err.hint || ''));
const isAuthErr = err instanceof MxmApiError && (err.code === 401 || err.code === 403);
if (isCaptcha || isAuthErr) {
// Reset token and cookie jar on first captcha/auth error, then retry once
await this.resetToken(true);
resetCookieFetch();
const token = await this.fetchTokenAttempt(true);
const expires = Date.now() + this.TOKEN_TTL;
this.tokenData = { value: token, expires };
await this.saveTokenToFile(token, expires);
return token;
}
throw err;
}
} finally {
this.tokenPromise = null;
}
})();
return this.tokenPromise;
}
async callMxm(makeUrl, retry = true) {
const doCall = async (token) => {
const data = await this.apiGet(makeUrl(token));
const header = data?.message?.header;
const body = data?.message?.body;
if (!header || header.status_code !== 200) {
throw new MxmApiError(header?.status_code ?? 0, header?.hint);
}
return { header, body };
};
try {
const token = await this.getToken();
const { body } = await doCall(token);
return body;
} catch (err) {
const code = err instanceof MxmApiError ? err.code : err instanceof HttpError ? err.status : 0;
const tokenLikelyInvalid = code === 401 || code === 403;
const isCaptcha = err instanceof MxmApiError && typeof err.hint === 'string' && /captcha/i.test(err.hint);
if (retry && (tokenLikelyInvalid || isCaptcha)) {
await this.resetToken(isCaptcha /* hard reset on captcha */);
if (isCaptcha) resetCookieFetch();
const newToken = await this.getToken(true);
const { body } = await doCall(newToken);
return body;
}
throw err;
}
}
parseQuery(query) {
const cleaned = query
.replace(/\b(?:VEVO|Official(?: Music)? Video|Lyrics)\b/gi, '')
.replace(/\s*\([^)]*\)|\s*\[[^\]]*\]/g, '') // strip brackets like (Official) [Lyric Video]
.trim();
const m = cleaned.match(REGEX.ARTIST_TITLE);
if (m) {
return { artist: m[1].trim(), title: m[2].trim() };
}
return { title: cleaned };
}
async searchTrack(title, artist) {
const body = await this.callMxm((token) =>
this.buildUrl(ENDPOINTS.SEARCH, {
app_id: this.APP_ID,
page_size: '1', // smaller for speed
page: '1',
s_track_rating: 'desc',
q_track: title,
q_artist: artist,
usertoken: token
})
);
return body?.track_list?.[0]?.track || null;
}
async getAltLyrics(title, artist) {
const body = await this.callMxm((token) =>
this.buildUrl(ENDPOINTS.ALT_LYRICS, {
format: 'json',
namespace: 'lyrics_richsynched',
subtitle_format: 'mxm',
app_id: this.APP_ID,
usertoken: token,
q_artist: artist || undefined,
q_track: title
})
);
const calls = body?.macro_calls || {};
const result = {
lyrics: calls['track.lyrics.get']?.message?.body?.lyrics?.lyrics_body || null,
track: calls['matcher.track.get']?.message?.body?.track || null,
subtitles: calls['track.subtitles.get']?.message?.body?.subtitle_list?.[0]?.subtitle?.subtitle_body || null
};
return result;
}
async getLyricsFromTrack(trackData) {
try {
const body = await this.callMxm((token) =>
this.buildUrl(ENDPOINTS.LYRICS, {
app_id: this.APP_ID,
subtitle_format: 'mxm',
track_id: String(trackData.track_id),
usertoken: token
})
);
const subtitles = body?.subtitle?.subtitle_body || null;
return { subtitles, lyrics: null };
} catch {
return null;
}
}
formatResult(subtitles, lyrics, trackData) {
const lines = subtitles ? parseSubtitles(subtitles) : null;
const text = lyrics ? cleanLyrics(lyrics, REGEX) : lines ? lines.map(l => l.line).join('\n') : null;
return {
text,
lines,
track: {
title: trackData?.track_name || '',
author: trackData?.artist_name || '',
albumArt:
trackData?.album_coverart_800x800 ||
trackData?.album_coverart_350x350 ||
trackData?.album_coverart_100x100
},
source: 'Musixmatch'
};
}
cacheKey(parsed) {
return `${(parsed.artist || '').toLowerCase().trim()}|${parsed.title.toLowerCase().trim()}`;
}
getFromCache(key) {
const hit = this.cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value;
if (hit) this.cache.delete(key);
return undefined;
}
setCache(key, value) {
this.cache.set(key, { value, expires: Date.now() + this.CACHE_TTL });
if (this.cache.size > 100) {
const firstKey = this.cache.keys().next().value;
if (firstKey) this.cache.delete(firstKey);
}
}
async firstTruthy(promises) {
return new Promise((resolve) => {
if (promises.length === 0) return resolve(null);
let pending = promises.length;
let settled = false;
for (const p of promises) {
p.then((val) => {
if (!settled && val) {
settled = true;
resolve(val);
} else if (--pending === 0 && !settled) {
resolve(null);
}
}).catch(() => {
if (--pending === 0 && !settled) resolve(null);
});
}
});
}
async findLyrics(query) {
const parsed = this.parseQuery(query);
const key = this.cacheKey(parsed);
const cached = this.getFromCache(key);
if (cached !== undefined) return cached;
if (parsed.artist) {
const altP = this.getAltLyrics(parsed.title, parsed.artist).then((alt) =>
alt ? this.formatResult(alt.subtitles || null, alt.lyrics || null, alt.track || {}) : null
);
const searchP = this.searchTrack(parsed.title, parsed.artist).then(async (track) => {
if (!track) return null;
const lyricsData = await this.getLyricsFromTrack(track);
if (lyricsData?.subtitles || lyricsData?.lyrics) {
return this.formatResult(lyricsData.subtitles || null, lyricsData.lyrics || null, track);
}
return null;
});
const winner = await this.firstTruthy([altP, searchP]);
if (winner) {
this.setCache(key, winner);
return winner;
}
} else {
const searchOnly = this.searchTrack(parsed.title).then(async (track) => {
if (!track) return null;
const lyricsData = await this.getLyricsFromTrack(track);
if (lyricsData?.subtitles || lyricsData?.lyrics) {
return this.formatResult(lyricsData.subtitles || null, lyricsData.lyrics || null, track);
}
return null;
});
const sRes = await searchOnly;
if (sRes) {
this.setCache(key, sRes);
return sRes;
}
}
const altTitleOnly = await this.getAltLyrics(parsed.title, undefined);
if (altTitleOnly) {
const out = this.formatResult(
altTitleOnly.subtitles || null,
altTitleOnly.lyrics || null,
altTitleOnly.track || {}
);
this.setCache(key, out);
return out;
}
this.setCache(key, null);
return null;
}
async getLrc(query) {
const result = await this.findLyrics(query);
if (!result) return null;
return {
synced: result.lines ? result.lines.map(l => `[${new Date(l.range.start).toISOString().substr(14, 8)}]${l.line}`).join('\n') : null,
unsynced: result.text,
track: result.track
};
}
}
export default Musixmatch;