UNPKG

@mjba/lyrics

Version:

A TypeScript library for fetching song lyrics from Musixmatch

668 lines (664 loc) 22.2 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { LyricsClient: () => LyricsClient, lyricsClient: () => lyricsClient }); module.exports = __toCommonJS(index_exports); var import_node_path2 = require("path"); // src/utils.ts var import_promises = require("fs/promises"); var import_node_os = require("os"); var import_node_path = require("path"); var import_fetch_cookie = __toESM(require("fetch-cookie"), 1); var import_node_fetch = __toESM(require("node-fetch"), 1); function isNode() { return typeof process !== "undefined" && process.versions !== void 0 && typeof process.versions.node === "string"; } function isBun() { return typeof globalThis.Bun !== "undefined"; } function isDeno() { return typeof globalThis.Deno !== "undefined"; } function isBrowser() { return typeof globalThis.window !== "undefined" && typeof globalThis.document !== "undefined"; } async function mkdir(path) { if (isNode() || isBun()) { try { await (0, import_promises.mkdir)(path, { recursive: true }); } catch (error) { const nodeError = error; if (nodeError.code !== "EEXIST") { throw error; } } } else if (isDeno()) { try { const deno = globalThis.Deno; if (deno) { await deno.mkdir(path, { recursive: true }); } } catch (error) { const deno = globalThis.Deno; if (deno && !(error instanceof deno.errors.AlreadyExists)) { throw error; } } } } async function writeFile(path, data) { if (isNode() || isBun()) { await (0, import_promises.writeFile)(path, data, "utf-8"); } else if (isDeno()) { const deno = globalThis.Deno; if (deno) { await deno.writeTextFile(path, data); } } } async function readFile(path) { if (isNode() || isBun()) { return await (0, import_promises.readFile)(path, "utf-8"); } else if (isDeno()) { const deno = globalThis.Deno; if (deno) { return await deno.readTextFile(path); } } throw new Error("File system operations not supported in browser"); } function getCachePath(appName) { if (isNode() || isBun()) { return (0, import_node_path.join)((0, import_node_os.homedir)(), ".cache", appName); } else if (isDeno()) { const deno = globalThis.Deno; if (deno) { const homeDir = deno.env.get("HOME") || deno.env.get("USERPROFILE") || "/tmp"; return `${homeDir}/.cache/${appName}`; } } return appName; } var fetchInstance = null; async function getFetch() { if (fetchInstance) { return fetchInstance; } if (isBrowser()) { fetchInstance = globalThis.fetch; return fetchInstance; } if (isDeno()) { fetchInstance = globalThis.fetch; return fetchInstance; } if (isNode() || isBun()) { try { fetchInstance = (0, import_fetch_cookie.default)(import_node_fetch.default); return fetchInstance; } catch (_error) { try { fetchInstance = import_node_fetch.default; return fetchInstance; } catch (_fallbackError) { throw new Error( "node-fetch is required for Node.js/Bun environments. Please install it: npm install node-fetch" ); } } } throw new Error("No fetch implementation available"); } // src/index.ts var LyricsClient = class { constructor() { this.ROOT_URL = "https://apic-desktop.musixmatch.com/ws/1.1/"; this.token = null; } parseSubtitleToSyncedLyrics(subtitleBody) { const lines = subtitleBody.split("\n"); const timestampMap = /* @__PURE__ */ new Map(); for (const line of lines) { const trimmedLine = line.trim(); if (!trimmedLine) continue; const match = trimmedLine.match(/\[(\d{2}):(\d{2})\.(\d{2})\]\s*(.+)/); if (match?.[1] && match[2] && match[3] && match[4]) { const [, minutes, seconds, hundredths, text] = match; const timestampKey = `${minutes}:${seconds}.${hundredths}`; if (!timestampMap.has(timestampKey)) { timestampMap.set(timestampKey, []); } const existingTexts = timestampMap.get(timestampKey); if (existingTexts) { existingTexts.push(text.trim()); } } } const syncedLyrics = []; for (const [timestampKey, textParts] of timestampMap) { const match = timestampKey.match(/(\d{2}):(\d{2})\.(\d{2})/); if (!match || !match[1] || !match[2] || !match[3]) continue; const [, minutes, seconds, hundredths] = match; const minutesNum = parseInt(minutes, 10); const secondsNum = parseInt(seconds, 10); const hundredthsNum = parseInt(hundredths, 10); const msNum = hundredthsNum * 10; const totalSeconds = minutesNum * 60 + secondsNum + hundredthsNum / 100; const combinedText = textParts.filter((text) => text.length > 0).join(" ").trim(); if (combinedText) { syncedLyrics.push({ text: combinedText, time: { total: totalSeconds, minutes: minutesNum, seconds: secondsNum, ms: msNum } }); } } syncedLyrics.sort((a, b) => a.time.total - b.time.total); return syncedLyrics; } parseRichSyncToSyncedLyrics(richsyncBody) { try { const richsyncData = JSON.parse(richsyncBody); const timestampMap = /* @__PURE__ */ new Map(); if (Array.isArray(richsyncData)) { for (const item of richsyncData) { if (item.ts && item.l && Array.isArray(item.l)) { const startTime = parseFloat(item.ts); for (const lyricItem of item.l) { if (lyricItem.c) { if (!timestampMap.has(startTime)) { timestampMap.set(startTime, []); } const existingTexts = timestampMap.get(startTime); if (existingTexts) { existingTexts.push(lyricItem.c); } } } } } } const syncedLyrics = []; for (const [startTime, textParts] of timestampMap) { const minutes = Math.floor(startTime / 60); const seconds = Math.floor(startTime % 60); const ms = Math.floor(startTime % 1 * 1e3); const combinedText = textParts.filter((text) => text.trim().length > 0).join(" ").trim(); if (combinedText) { syncedLyrics.push({ text: combinedText, time: { total: startTime, minutes, seconds, ms } }); } } syncedLyrics.sort((a, b) => a.time.total - b.time.total); return syncedLyrics; } catch { return []; } } async get(action, query = []) { if (action !== "token.get" && this.token === null) { await this.getToken(); } query.push(["app_id", "web-desktop-app-v1.0"]); if (this.token !== null) { query.push(["usertoken", this.token]); } const timestamp = Date.now().toString(); query.push(["t", timestamp]); const url = this.ROOT_URL + action; const params = new URLSearchParams(query); try { const fetch = await getFetch(); const response = await fetch(`${url}?${params.toString()}`, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/115.0.0.0 Safari/537.36", Accept: "application/json, text/javascript, */*; q=0.01", Referer: "https://www.musixmatch.com/", Origin: "https://www.musixmatch.com" } }); return response; } catch (error) { console.error("Error making Musixmatch request:", error); throw error; } } async getToken() { if (isNode() || isBun()) { const tokenPath = (0, import_node_path2.join)( getCachePath("syncedlyrics"), "musixmatch_token.json" ); const currentTime = Math.floor(Date.now() / 1e3); try { const tokenData = await readFile(tokenPath); const cachedTokenData = JSON.parse(tokenData); const cachedToken = cachedTokenData.token; const expirationTime = cachedTokenData.expiration_time; if (cachedToken && expirationTime && currentTime < expirationTime) { this.token = cachedToken; return; } } catch { console.warn("No valid cached token found, fetching a new one."); } try { const response = await this.get("token.get", [["user_language", "en"]]); const data = await response.json(); if (data.message.header.status_code === 401) { await new Promise((resolve) => setTimeout(resolve, 1e4)); return this.getToken(); } const newToken = data.message.body.user_token; const expirationTime = currentTime + 600; this.token = newToken; const tokenData = { token: newToken, expiration_time: expirationTime }; try { const tokenDir = (0, import_node_path2.dirname)(tokenPath); await mkdir(tokenDir); await writeFile(tokenPath, JSON.stringify(tokenData)); } catch (writeError) { console.warn("Could not cache token:", writeError); } } catch (error) { console.error("Error getting Musixmatch token:", error); throw error; } } else { try { const response = await this.get("token.get", [["user_language", "en"]]); const data = await response.json(); if (data.message.header.status_code === 401) { await new Promise((resolve) => setTimeout(resolve, 1e4)); return this.getToken(); } this.token = data.message.body.user_token; } catch (error) { console.error("Error getting Musixmatch token:", error); throw error; } } } /** * Get synced lyrics with timestamps by ISRC code * @param isrc - International Standard Recording Code * @returns Promise<LyricsResponse> */ async getSyncedLyricsByISRC(isrc) { try { const trackResponse = await this.get("track.get", [["track_isrc", isrc]]); const trackData = await trackResponse.json(); if (trackData.message.header.status_code !== 200) { return { success: false, error: `Track not found for ISRC: ${isrc}` }; } const track = trackData.message.body.track; const trackId = track.track_id; try { const richsyncResponse = await this.get("track.richsync.get", [ ["track_id", trackId.toString()] ]); const richsyncData = await richsyncResponse.json(); if (richsyncData.message.header.status_code === 200) { const richsync = richsyncData.message.body.richsync; const syncedLyrics = this.parseRichSyncToSyncedLyrics( richsync.richsync_body ); if (syncedLyrics.length > 0) { return { success: true, syncedLyrics, hasTimestamps: true, songInfo: { title: track.track_name, artist: track.artist_name, album: track.album_name, duration: track.track_length } }; } } } catch { } try { const subtitleResponse = await this.get("track.subtitle.get", [ ["track_id", trackId.toString()] ]); const subtitleData = await subtitleResponse.json(); if (subtitleData.message.header.status_code === 200) { const subtitle = subtitleData.message.body.subtitle; const syncedLyrics = this.parseSubtitleToSyncedLyrics( subtitle.subtitle_body ); if (syncedLyrics.length > 0) { return { success: true, syncedLyrics, hasTimestamps: true, songInfo: { title: track.track_name, artist: track.artist_name, album: track.album_name, duration: track.track_length } }; } } } catch { } const lyricsResponse = await this.get("track.lyrics.get", [ ["track_id", trackId.toString()] ]); const lyricsData = await lyricsResponse.json(); if (lyricsData.message.header.status_code !== 200) { return { success: false, error: "No lyrics found for this track" }; } const lyrics = lyricsData.message.body.lyrics; return { success: true, lyrics: lyrics.lyrics_body, hasTimestamps: false, songInfo: { title: track.track_name, artist: track.artist_name, album: track.album_name, duration: track.track_length } }; } catch (error) { console.error("Error fetching synced lyrics by ISRC:", error); return { success: false, error: error instanceof Error ? error.message : "Unknown error occurred" }; } } /** * Search for track and get synced lyrics with timestamps * @param query - Search query (artist and track name) * @returns Promise<LyricsResponse> */ async searchAndGetSyncedLyrics(query) { try { const searchResponse = await this.get("track.search", [ ["q", query], ["page_size", "1"], ["s_track_rating", "desc"] ]); const searchData = await searchResponse.json(); if (searchData.message.header.status_code !== 200 || !searchData.message.body.track_list || searchData.message.body.track_list.length === 0) { return { success: false, error: `No tracks found for query: ${query}` }; } const trackList = searchData.message.body.track_list; const firstTrack = trackList[0]; if (!firstTrack) { return { success: false, error: "No tracks found in search results" }; } const track = firstTrack.track; const trackId = track.track_id; try { const richsyncResponse = await this.get("track.richsync.get", [ ["track_id", trackId.toString()] ]); const richsyncData = await richsyncResponse.json(); if (richsyncData.message.header.status_code === 200) { const richsync = richsyncData.message.body.richsync; const syncedLyrics = this.parseRichSyncToSyncedLyrics( richsync.richsync_body ); if (syncedLyrics.length > 0) { return { success: true, syncedLyrics, hasTimestamps: true, songInfo: { title: track.track_name, artist: track.artist_name, album: track.album_name, duration: track.track_length } }; } } } catch { } try { const subtitleResponse = await this.get("track.subtitle.get", [ ["track_id", trackId.toString()] ]); const subtitleData = await subtitleResponse.json(); if (subtitleData.message.header.status_code === 200) { const subtitle = subtitleData.message.body.subtitle; const syncedLyrics = this.parseSubtitleToSyncedLyrics( subtitle.subtitle_body ); if (syncedLyrics.length > 0) { return { success: true, syncedLyrics, hasTimestamps: true, songInfo: { title: track.track_name, artist: track.artist_name, album: track.album_name, duration: track.track_length } }; } } } catch { } const lyricsResponse = await this.get("track.lyrics.get", [ ["track_id", trackId.toString()] ]); const lyricsData = await lyricsResponse.json(); if (lyricsData.message.header.status_code !== 200) { return { success: false, error: "No lyrics found for this track" }; } const lyrics = lyricsData.message.body.lyrics; return { success: true, lyrics: lyrics.lyrics_body, hasTimestamps: false, songInfo: { title: track.track_name, artist: track.artist_name, album: track.album_name, duration: track.track_length } }; } catch (error) { console.error("Error searching and fetching synced lyrics:", error); return { success: false, error: error instanceof Error ? error.message : "Unknown error occurred" }; } } /** * Get lyrics by ISRC code * @param isrc - International Standard Recording Code * @returns Promise<LyricsResponse> */ async getLyricsByISRC(isrc) { try { const trackResponse = await this.get("track.get", [["track_isrc", isrc]]); const trackData = await trackResponse.json(); if (trackData.message.header.status_code !== 200) { return { success: false, error: `Track not found for ISRC: ${isrc}` }; } const track = trackData.message.body.track; const trackId = track.track_id; const lyricsResponse = await this.get("track.lyrics.get", [ ["track_id", trackId.toString()] ]); const lyricsData = await lyricsResponse.json(); if (lyricsData.message.header.status_code !== 200) { return { success: false, error: "Lyrics not found for this track" }; } const lyrics = lyricsData.message.body.lyrics; return { success: true, lyrics: lyrics.lyrics_body, songInfo: { title: track.track_name, artist: track.artist_name, album: track.album_name, duration: track.track_length } }; } catch (error) { console.error("Error fetching lyrics by ISRC:", error); return { success: false, error: error instanceof Error ? error.message : "Unknown error occurred" }; } } /** * Search for track and get lyrics * @param query - Search query (artist and track name) * @returns Promise<LyricsResponse> */ async searchAndGetLyrics(query) { try { const searchResponse = await this.get("track.search", [ ["q", query], ["page_size", "1"], ["s_track_rating", "desc"] ]); const searchData = await searchResponse.json(); if (searchData.message.header.status_code !== 200 || !searchData.message.body.track_list || searchData.message.body.track_list.length === 0) { return { success: false, error: `No tracks found for query: ${query}` }; } const trackList = searchData.message.body.track_list; const firstTrack = trackList[0]; if (!firstTrack) { return { success: false, error: "No tracks found in search results" }; } const track = firstTrack.track; const trackId = track.track_id; const lyricsResponse = await this.get("track.lyrics.get", [ ["track_id", trackId.toString()] ]); const lyricsData = await lyricsResponse.json(); if (lyricsData.message.header.status_code !== 200) { return { success: false, error: "Lyrics not found for this track" }; } const lyrics = lyricsData.message.body.lyrics; return { success: true, lyrics: lyrics.lyrics_body, songInfo: { title: track.track_name, artist: track.artist_name, album: track.album_name, duration: track.track_length } }; } catch (error) { console.error("Error searching and fetching lyrics:", error); return { success: false, error: error instanceof Error ? error.message : "Unknown error occurred" }; } } /** * Get track information by ISRC without lyrics * @param isrc - International Standard Recording Code * @returns Promise<Track> */ async getTrackByISRC(isrc) { try { const response = await this.get("track.get", [["track_isrc", isrc]]); const data = await response.json(); if (data.message.header.status_code !== 200) { throw new Error(`Track not found for ISRC: ${isrc}`); } return data.message.body.track; } catch (error) { console.error("Error fetching track by ISRC:", error); throw error; } } }; var lyricsClient = new LyricsClient(); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { LyricsClient, lyricsClient });