UNPKG

@southctrl/musixmatch-lyrics

Version:

Unofficial Musixmatch lyrics API wrapper for Node.js

411 lines (367 loc) 12.5 kB
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;