@manhgdev/spotifyweb
Version:
Spotify library in typescript without using the Spotify Web API. No authentication required with automatic internal token generation.
429 lines (428 loc) • 17.6 kB
JavaScript
import fs from 'fs';
import path from 'path';
import os from 'os';
class MusixMatchLyrics {
tokenUrl;
searchTermUrl;
lyricsUrl;
lyricsAlternativeUrl;
tokenFile;
constructor() {
this.tokenUrl = 'https://apic-desktop.musixmatch.com/ws/1.1/token.get?app_id=web-desktop-app-v1.0';
this.searchTermUrl = 'https://apic-desktop.musixmatch.com/ws/1.1/macro.search?app_id=web-desktop-app-v1.0&page_size=5&page=1&s_track_rating=desc&quorum_factor=1.0';
this.lyricsUrl = 'https://apic-desktop.musixmatch.com/ws/1.1/track.subtitle.get?app_id=web-desktop-app-v1.0&subtitle_format=lrc';
this.lyricsAlternativeUrl = 'https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get?format=json&namespace=lyrics_richsynched&subtitle_format=mxm&app_id=web-desktop-app-v1.0';
this.tokenFile = path.join(os.tmpdir(), 'musix_token.json');
}
/**
* Make HTTP GET request
*/
async get(url) {
try {
const headers = {
'authority': 'apic-desktop.musixmatch.com',
'accept': 'application/json, text/plain, */*',
'accept-language': 'en-US,en;q=0.9',
'cache-control': 'no-cache',
'cookie': 'AWSELBCORS=0; AWSELB=0;',
'origin': 'https://www.musixmatch.com',
'pragma': 'no-cache',
'referer': 'https://www.musixmatch.com/',
'sec-ch-ua': '"Google Chrome";v="119", "Chromium";v="119", "Not?A_Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"macOS"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
};
// Add a random delay to mimic human behavior
const delay = Math.floor(Math.random() * 1500) + 500;
await new Promise(resolve => setTimeout(resolve, delay));
const response = await fetch(url, {
method: 'GET',
headers: headers
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
}
catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
/**
* Get and cache access token
*/
async getToken() {
try {
const result = await this.get(this.tokenUrl);
if (!result) {
throw new Error('Failed to retrieve the access token.');
}
const tokenJson = JSON.parse(result);
if (tokenJson.message.header.status_code !== 200) {
if (tokenJson.message.header.hint === 'captcha') {
throw new Error('Musixmatch API is requesting CAPTCHA verification. ' +
'This typically happens when there are too many requests from the same IP address. ' +
'Try again later or visit the Musixmatch website directly to solve the CAPTCHA.');
}
throw new Error(`Failed to get token: Status code ${tokenJson.message.header.status_code}`);
}
const currentTime = Math.floor(Date.now() / 1000);
const newToken = tokenJson.message.body.user_token;
const expirationTime = currentTime + 600; // 10 minutes
const tokenData = {
user_token: newToken,
expiration_time: expirationTime,
created_at: currentTime
};
// Write token to file
fs.writeFileSync(this.tokenFile, JSON.stringify(tokenData, null, 2));
return newToken;
}
catch (error) {
throw new Error(`Token retrieval failed: ${error.message}`);
}
}
/**
* Check if token is expired and refresh if needed
*/
async checkTokenExpire() {
let tokenExists = false;
let timeNow = Math.floor(Date.now() / 1000);
let timeLeft = 0;
let tokenData = null;
try {
tokenExists = fs.existsSync(this.tokenFile);
if (tokenExists) {
try {
tokenData = JSON.parse(fs.readFileSync(this.tokenFile, 'utf8'));
timeLeft = tokenData?.expiration_time || 0;
}
catch (e) {
tokenExists = false;
}
}
const tokenExpired = !tokenExists || timeLeft <= timeNow;
const tokenMissing = !tokenExists || !tokenData || !tokenData.user_token;
if (tokenExpired || tokenMissing) {
await this.getToken();
return true;
}
else {
return false;
}
}
catch (error) {
await this.getToken();
return true;
}
}
/**
* Get stored token
*/
getStoredToken() {
try {
if (!fs.existsSync(this.tokenFile)) {
throw new Error('Token file not found');
}
const fileContent = fs.readFileSync(this.tokenFile, 'utf8');
if (!fileContent || fileContent.trim() === '') {
throw new Error('Token file is empty');
}
const tokenData = JSON.parse(fileContent);
if (!tokenData || !tokenData.user_token) {
throw new Error('Token data is invalid');
}
return tokenData.user_token;
}
catch (error) {
throw new Error(`Failed to get stored token: ${error.message}`);
}
}
/**
* Search for track and return track ID
*/
async searchTrack(query) {
const tokenRefreshed = await this.checkTokenExpire();
const token = this.getStoredToken();
const formattedUrl = `${this.searchTermUrl}&q=${encodeURIComponent(query)}&usertoken=${token}`;
try {
const result = await this.get(formattedUrl);
const listResult = JSON.parse(result);
if (listResult.message && listResult.message.header) {
const statusCode = listResult.message.header.status_code;
const hint = listResult.message.header.hint || 'Unknown error';
if (statusCode === 401) {
if (hint === 'captcha') {
throw new Error('Musixmatch API is requesting CAPTCHA verification. ' +
'This typically happens when there are too many requests from the same IP address. ' +
'Try again later or visit the Musixmatch website directly to solve the CAPTCHA.');
}
if (!tokenRefreshed) {
await this.getToken();
const newToken = this.getStoredToken();
const newUrl = `${this.searchTermUrl}&q=${encodeURIComponent(query)}&usertoken=${newToken}`;
const newResult = await this.get(newUrl);
return this.processSearchResult(newResult, query);
}
else {
throw new Error(`Authentication failed with status code ${statusCode}: ${hint}`);
}
}
if (statusCode !== 200) {
throw new Error(`API returned status code ${statusCode}: ${hint}`);
}
}
return this.processSearchResult(result, query);
}
catch (error) {
throw new Error(`Search failed: ${error.message}`);
}
}
/**
* Process search result and extract track ID
*/
processSearchResult(resultData, query) {
const listResult = JSON.parse(resultData);
if (!listResult.message || !listResult.message.body ||
!listResult.message.body.macro_result_list ||
!listResult.message.body.macro_result_list.track_list) {
throw new Error('Invalid API response structure');
}
// Find the best match
for (const track of listResult.message.body.macro_result_list.track_list) {
const trackObj = track.track;
const trackName = `${trackObj.track_name} ${trackObj.artist_name}`;
if (query.toLowerCase().includes(trackName.toLowerCase()) ||
trackName.toLowerCase().includes(query.toLowerCase())) {
return trackObj.track_id;
}
}
// If no exact match, return the first result
return listResult.message.body.macro_result_list.track_list[0].track.track_id;
}
/**
* Get lyrics by track ID
*/
async getLyrics(trackId) {
await this.checkTokenExpire();
const token = this.getStoredToken();
const formattedUrl = `${this.lyricsUrl}&track_id=${trackId}&usertoken=${token}`;
try {
const result = await this.get(formattedUrl);
const lyricsData = JSON.parse(result);
if (!lyricsData.message.body.subtitle) {
throw new Error('No lyrics found for this track');
}
return lyricsData.message.body.subtitle.subtitle_body;
}
catch (error) {
throw new Error(`Failed to get lyrics: ${error.message}`);
}
}
/**
* Get lyrics using alternative method (by title, artist, and optional duration)
*/
async getLyricsAlternative(title, artist, duration = null) {
await this.checkTokenExpire();
const token = this.getStoredToken();
let formattedUrl;
if (duration !== null) {
formattedUrl = `${this.lyricsAlternativeUrl}&usertoken=${token}&q_album=&q_artist=${encodeURIComponent(artist)}&q_artists=${encodeURIComponent(artist)}&q_track=${encodeURIComponent(title)}&q_duration=${duration}&f_subtitle_length=${duration}`;
}
else {
formattedUrl = `${this.lyricsAlternativeUrl}&usertoken=${token}&q_album=&q_artist=${encodeURIComponent(artist)}&q_artists=${encodeURIComponent(artist)}&q_track=${encodeURIComponent(title)}`;
}
try {
const result = await this.get(formattedUrl);
const lyrics = JSON.parse(result);
const subtitleData = lyrics.message.body.macro_calls['track.subtitles.get'];
if (!subtitleData.message.body.subtitle_list || subtitleData.message.body.subtitle_list.length === 0) {
throw new Error('No lyrics found for this track');
}
const track2 = subtitleData.message.body.subtitle_list[0].subtitle.subtitle_body;
return this.getLrcLyrics(track2);
}
catch (error) {
throw new Error(`Failed to get lyrics (alternative): ${error.message}`);
}
}
/**
* Convert subtitle data to LRC format
*/
getLrcLyrics(lyricsData) {
try {
const data = JSON.parse(lyricsData);
let lrc = '';
if (data && Array.isArray(data)) {
for (const item of data) {
const minutes = String(item.time.minutes).padStart(2, '0');
const seconds = String(item.time.seconds).padStart(2, '0');
const hundredths = String(item.time.hundredths).padStart(2, '0');
const text = item.text || '♪';
lrc += `[${minutes}:${seconds}.${hundredths}]${text}\n`;
}
}
return lrc;
}
catch (error) {
throw new Error(`Failed to parse lyrics: ${error.message}`);
}
}
/**
* Convert duration from mm:ss format to seconds
*/
static convertDuration(time) {
if (!time || typeof time !== 'string') {
return null;
}
const parts = time.split(':');
if (parts.length !== 2) {
return null;
}
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
return (minutes * 60) + seconds;
}
/**
* Get lyrics using default method (search by query)
*/
async getLyricsDefault(query) {
try {
const trackId = await this.searchTrack(query);
return await this.getLyrics(trackId);
}
catch (error) {
throw new Error(`Failed to get lyrics (default method): ${error.message}`);
}
}
}
/**
* MusixMatch API wrapper class
*/
class MusixMatchAPI {
musix;
constructor() {
this.musix = new MusixMatchLyrics();
}
/**
* Get lyrics using default method
*/
async getLyricsDefault(query) {
try {
if (!query || typeof query !== 'string' || query.trim() === '') {
return {
success: false,
error: 'Search query cannot be empty',
isError: true
};
}
const lyrics = await this.musix.getLyricsDefault(query);
return {
success: true,
lyrics: lyrics,
isError: false,
query: query
};
}
catch (error) {
let userFriendlyMessage = error.message;
if (error.message.includes('captcha')) {
userFriendlyMessage = 'The Musixmatch API is temporarily unavailable due to CAPTCHA protection. Please try again later.';
}
else if (error.message.includes('401')) {
userFriendlyMessage = 'Authentication error with the Musixmatch API. Please try again later.';
}
else if (error.message.includes('No tracks found') || error.message.includes('Invalid API response structure')) {
userFriendlyMessage = `No lyrics found for "${query}". Please try a different search term.`;
}
return {
success: false,
error: userFriendlyMessage,
originalError: error.message,
isError: true,
query: query
};
}
}
/**
* Get lyrics using alternative method
*/
async getLyricsAlternative(title, artist, duration = null) {
try {
let durationSeconds = null;
if (duration) {
// Try to convert if it's in mm:ss format
if (typeof duration === 'string' && duration.includes(':')) {
durationSeconds = MusixMatchLyrics.convertDuration(duration);
}
else {
durationSeconds = parseInt(duration.toString(), 10);
}
}
const lyrics = await this.musix.getLyricsAlternative(title, artist, durationSeconds);
return {
success: true,
lyrics: lyrics,
isError: false
};
}
catch (error) {
return {
success: false,
error: error.message,
isError: true
};
}
}
}
// Create global instance
const musixAPI = new MusixMatchAPI();
export var Musixmatch;
(function (Musixmatch) {
async function search(terms) {
// Legacy method - keeping for backward compatibility
const searchResponse = await (await fetch(`https://www.musixmatch.com/search/?query=${encodeURIComponent(terms)}`)).text();
const buildId = searchResponse.match(/"buildId":"([^"]+)"/);
const query = searchResponse.match(/"query":"([^"]+)"/);
const trackResponse = await (await fetch(`https://www.musixmatch.com/_next/data/${buildId[1]}/en/search.json?query${encodeURIComponent(query[1])}`)).json();
// console.log(trackResponse);
return trackResponse.pageProps.data.comSearchBrowseGet.data.mxmComSearchBrowse;
}
Musixmatch.search = search;
async function getLyricsFromUrl(url) {
// Legacy method - keeping for backward compatibility
const trackResponse = await (await fetch(url)).text();
const lyricsMatch = trackResponse.match(/"body":"([^"]+)"/);
if (!lyricsMatch)
throw new Error("Not found lyrics");
return lyricsMatch[1].split("\\n");
}
Musixmatch.getLyricsFromUrl = getLyricsFromUrl;
async function searchLyrics(terms) {
// New implementation using MusixMatch API
try {
const result = await musixAPI.getLyricsDefault(terms);
return result.lyrics ? result.lyrics.split('\n') : [];
}
catch (error) {
throw new Error(error.message || "Failed to get lyrics");
}
}
Musixmatch.searchLyrics = searchLyrics;
async function getLyricsAlternative(title, artist, duration) {
// New implementation using MusixMatch API alternative method
try {
const result = await musixAPI.getLyricsAlternative(title, artist, duration || null);
// console.log(result);
return result.lyrics ? result.lyrics.split('\n') : [];
}
catch (error) {
throw new Error(error.message || "Failed to get lyrics");
}
}
Musixmatch.getLyricsAlternative = getLyricsAlternative;
})(Musixmatch || (Musixmatch = {}));