Spaces:
Runtime error
Runtime error
import crypto from 'node:crypto' | |
import config from '../../config.js' | |
import { debugLog, makeRequest, encodeTrack, http1makeRequest } from '../utils.js' | |
import searchWithDefault from './default.js' | |
let playerInfo = {} | |
async function init() { | |
debugLog('spotify', 5, { type: 1, message: 'Fetching token...' }) | |
const { body: token } = await makeRequest('https://open.spotify.com/get_access_token', { | |
headers: { | |
...(config.search.sources.spotify.sp_dc !== 'DISABLED' ? { Cookie: `sp_dc=${config.search.sources.spotify.sp_dc}` } : {}) | |
}, | |
method: 'GET' | |
}) | |
if (typeof token !== 'object') { | |
debugLog('spotify', 5, { type: 2, message: 'Failed to fetch Spotify token.' }) | |
return; | |
} | |
const { body: data } = await http1makeRequest(`https://clienttoken.spotify.com/v1/clienttoken`, { | |
body: { | |
client_data: { | |
client_version: '1.2.9.2269.g2fe25d39', | |
client_id: token.clientId, | |
js_sdk_data: { | |
device_brand: 'unknown', | |
device_model: 'unknown', | |
os: 'linux', | |
os_version: 'unknown', | |
device_id: crypto.randomUUID(), | |
device_type: 'computer' | |
} | |
} | |
}, | |
headers: { | |
'Accept': 'application/json' | |
}, | |
method: 'POST', | |
disableBodyCompression: true | |
}) | |
if (typeof data !== 'object') { | |
debugLog('spotify', 5, { type: 2, message: 'Failed to fetch client token.' }) | |
return; | |
} | |
if (data.response_type !== 'RESPONSE_GRANTED_TOKEN_RESPONSE') { | |
debugLog('spotify', 5, { type: 2, message: 'Failed to fetch client token.' }) | |
return; | |
} | |
playerInfo = { | |
accessToken: token.accessToken, | |
clientToken: data.granted_token.token | |
} | |
debugLog('spotify', 5, { type: 1, message: 'Successfully fetched token.' }) | |
} | |
async function search(query) { | |
return new Promise(async (resolve) => { | |
debugLog('search', 4, { type: 1, sourceName: 'Spotify', query }) | |
const limit = config.options.maxResultsLength >= 50 ? 50 : config.options.maxResultsLength | |
const { body: data } = await makeRequest(`https://api.spotify.com/v1/search?q=${encodeURI(query)}&type=track&limit=${limit}&market=${config.search.sources.spotify.market}`, { | |
method: 'GET', | |
headers: { | |
Authorization: `Bearer ${playerInfo.accessToken}`, | |
'client-token': playerInfo.clientToken, | |
'accept': 'application/json' | |
} | |
}) | |
if (data.tracks.total === 0) { | |
debugLog('search', 4, { type: 3, sourceName: 'Spotify', query, message: 'No matches found.' }) | |
return resolve({ | |
loadType: 'empty', | |
data: {} | |
}) | |
} | |
const tracks = [] | |
data.tracks.items.forEach(async (items) => { | |
const track = { | |
identifier: items.id, | |
isSeekable: true, | |
author: items.artists.map((artist) => artist.name).join(', '), | |
length: items.duration_ms, | |
isStream: false, | |
position: 0, | |
title: items.name, | |
uri: items.href, | |
artworkUrl: items.album.images[0].url, | |
isrc: items.external_ids.isrc, | |
sourceName: 'spotify' | |
} | |
tracks.push({ | |
encoded: encodeTrack(track), | |
info: track, | |
pluginInfo: {} | |
}) | |
}) | |
if (tracks.length === 0) { | |
debugLog('search', 4, { type: 3, sourceName: 'Spotify', query, message: 'No matches found.' }) | |
return resolve({ | |
loadType: 'empty', | |
data: {} | |
}) | |
} | |
debugLog('search', 4, { type: 2, loadType: 'track', sourceName: 'Spotify', tracksLen: tracks.length, query }) | |
return resolve({ | |
loadType: 'search', | |
data: tracks | |
}) | |
}) | |
} | |
async function loadFrom(query, type) { | |
return new Promise(async (resolve) => { | |
let endpoint | |
switch (type[1]) { | |
case 'track': { | |
endpoint = `/tracks/${type[2]}?limit=${config.options.maxResultsLength}` | |
break | |
} | |
case 'playlist': { | |
endpoint = `/playlists/${type[2]}` | |
break | |
} | |
case 'album': { | |
endpoint = `/albums/${type[2]}?limit=${config.options.maxAlbumPlaylistLength < 100 ? config.options.maxAlbumPlaylistLength : 100}` | |
break | |
} | |
case 'episode': { | |
endpoint = `/episodes/${type[2]}?market=${config.search.sources.spotify.market}&limit=${config.options.maxAlbumPlaylistLength < 100 ? config.options.maxAlbumPlaylistLength : 100}` | |
break | |
} | |
case 'show': { | |
endpoint = `/shows/${type[2]}?market=${config.search.sources.spotify.market}&limit=${config.options.maxAlbumPlaylistLength < 100 ? config.options.maxAlbumPlaylistLength : 100}` | |
break | |
} | |
default: { | |
debugLog('loadtracks', 4, { type: 3, loadType: type[1], sourceName: 'Spotify', query, message: 'No matches found.' }) | |
return resolve({ | |
loadType: 'empty', | |
data: {} | |
}) | |
} | |
} | |
debugLog('loadtracks', 4, { type: 1, loadType: type[1], sourceName: 'Spotify', query }) | |
let { body: data } = await makeRequest(`https://api.spotify.com/v1${endpoint}`, { | |
method: 'GET', | |
headers: { | |
Authorization: `Bearer ${playerInfo.accessToken}` | |
} | |
}) | |
if (data.error) { | |
if (data.error.status === 401) { | |
await init() | |
data = await makeRequest(`https://api.spotify.com/v1${endpoint}`, { | |
method: 'GET', | |
headers: { | |
Authorization: `Bearer ${playerInfo.accessToken}` | |
} | |
}) | |
data = data.body | |
} | |
if (data.error?.status === 400) { | |
debugLog('loadtracks', 4, { type: 3, loadType: type[1], sourceName: 'Spotify', query, message: 'No matches found.' }) | |
return resolve({ | |
loadType: 'empty', | |
data: {} | |
}) | |
} | |
if (data.error?.message === 'Invalid playlist Id') { | |
debugLog('loadtracks', 4, { type: 3, loadType: type[1], sourceName: 'Spotify', query, message: 'No matches found.' }) | |
return resolve({ | |
loadType: 'empty', | |
data: {} | |
}) | |
} | |
if (data.error) { | |
debugLog('loadtracks', 4, { type: 3, loadType: type[1], sourceName: 'Spotify', query, message: data.error.message }) | |
return resolve({ | |
loadType: 'error', | |
data: { | |
message: data.error.message, | |
severity: 'fault', | |
cause: 'Unknown' | |
} | |
}) | |
} | |
} | |
switch (type[1]) { | |
case 'track': { | |
const track = { | |
identifier: data.id, | |
isSeekable: true, | |
author: data.artists[0].name, | |
length: data.duration_ms, | |
isStream: false, | |
position: 0, | |
title: data.name, | |
uri: data.external_urls.spotify, | |
artworkUrl: data.album.images[0].url, | |
isrc: data.external_ids?.isrc || null, | |
sourceName: 'spotify' | |
} | |
debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'Spotify', track, query }) | |
return resolve({ | |
loadType: 'track', | |
data: { | |
encoded: encodeTrack(track), | |
info: track, | |
pluginInfo: {} | |
} | |
}) | |
} | |
case 'episode': { | |
const track = { | |
identifier: data.id, | |
isSeekable: true, | |
author: data.show.publisher, | |
length: data.duration_ms, | |
isStream: false, | |
position: 0, | |
title: data.name, | |
uri: data.external_urls.spotify, | |
artworkUrl: data.images[0].url, | |
isrc: data.external_ids?.isrc || null, | |
sourceName: 'spotify' | |
} | |
debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'Spotify', track, query }) | |
return resolve({ | |
loadType: 'track', | |
data: { | |
encoded: encodeTrack(track), | |
info: track, | |
pluginInfo: {} | |
} | |
}) | |
} | |
case 'playlist': | |
case 'album': { | |
const tracks = [] | |
let index = 0 | |
if (data.tracks.total > config.options.maxAlbumPlaylistLength) | |
data.tracks.total = config.options.maxAlbumPlaylistLength | |
const fragments = [] | |
const fragmentLengths = [] | |
for (let i = data.tracks.items.length; i != data.tracks.total;) { | |
const requestLimit = data.tracks.total - i > 100 ? 100 : data.tracks.total - i | |
fragmentLengths.push(requestLimit) | |
i += requestLimit | |
} | |
fragmentLengths.forEach(async (limit, i) => { | |
if (fragmentLengths.length !== 0) { | |
let url = `https://api.spotify.com/v1${endpoint}/tracks?offset=${(i + 1) * 100}&limit=${limit}` | |
const { body: data2 } = await makeRequest(url, { | |
method: 'GET', | |
headers: { | |
Authorization: `Bearer ${playerInfo.accessToken}` | |
} | |
}) | |
fragments[i] = data2.items | |
if (index === fragmentLengths.length - 1) | |
data.tracks.items = data.tracks.items.concat(...fragments) | |
} | |
if (index === fragmentLengths.length - 1) { | |
data.tracks.items.forEach(async (item) => { | |
item = type[1] === 'playlist' ? item.track : item | |
const track = { | |
identifier: item.id || 'unknown', | |
isSeekable: true, | |
author: item.artists[0].name, | |
length: item.duration_ms, | |
isStream: false, | |
position: 0, | |
title: item.name, | |
uri: item.external_urls.spotify, | |
artworkUrl: item.album ? item.album.images[0]?.url : null, | |
isrc: item.external_ids?.isrc || null, | |
sourceName: 'spotify' | |
} | |
tracks.push({ | |
encoded: encodeTrack(track), | |
info: track, | |
pluginInfo: {} | |
}) | |
}) | |
if (tracks.length === 0) { | |
debugLog('loadtracks', 4, { type: 3, sourceName: 'Spotify', query, message: 'No matches found.' }) | |
return resolve({ | |
loadType: 'empty', | |
data: {} | |
}) | |
} | |
debugLog('loadtracks', 4, { type: 2, loadType: 'playlist', sourceName: 'Spotify', playlistName: data.name }) | |
return resolve({ | |
loadType: type[1], | |
data: { | |
info: { | |
name: data.name, | |
selectedTrack: 0 | |
}, | |
pluginInfo: {}, | |
tracks | |
} | |
}) | |
} | |
index++ | |
}) | |
break | |
} | |
case 'show': { | |
const tracks = [] | |
data.episodes.items.forEach(async (episode) => { | |
const track = { | |
identifier: episode.id, | |
isSeekable: true, | |
author: data.publisher, | |
length: episode.duration_ms, | |
isStream: false, | |
position: 0, | |
title: episode.name, | |
uri: episode.external_urls.spotify, | |
artworkUrl: episode.images[0].url, | |
isrc: episode.external_ids?.isrc || null, | |
sourceName: 'spotify' | |
} | |
tracks.push({ | |
encoded: encodeTrack(track), | |
info: track, | |
pluginInfo: {} | |
}) | |
}) | |
if (tracks.length === 0) { | |
debugLog('loadtracks', 4, { type: 3, sourceName: 'Spotify', query, message: 'No matches found.' }) | |
return resolve({ | |
loadType: 'empty', | |
data: {} | |
}) | |
} | |
debugLog('loadtracks', 4, { type: 2, loadType: 'show', sourceName: 'Spotify', playlistName: data.name }) | |
return resolve({ | |
loadType: 'show', | |
data: { | |
info: { | |
name: data.name, | |
selectedTrack: 0 | |
}, | |
pluginInfo: {}, | |
tracks | |
} | |
}) | |
} | |
} | |
}) | |
} | |
async function loadLyrics(decodedTrack, _language) { | |
const identifier = /^https?:\/\/(?:open\.spotify\.com\/|spotify:)(?:[^?]+)?(track|playlist|artist|episode|show|album)[/:]([A-Za-z0-9]+)/.exec(decodedTrack.uri) | |
if (config.search.sources.spotify.sp_dc === 'DISABLED') { | |
debugLog('loadlyrics', 4, { type: 3, sourceName: 'Spotify', message: 'Spotify lyrics are disabled.' }) | |
return null | |
} | |
const { body: data, statusCode } = await makeRequest(`https://spclient.wg.spotify.com/color-lyrics/v2/track/${identifier[2]}?format=json&vocalRemoval=false&market=from_token`, { | |
headers: { | |
'authorization': `Bearer ${playerInfo.accessToken}`, | |
'client-token': playerInfo.clientToken, | |
'app-platform': 'WebPlayer' | |
}, | |
method: 'GET' | |
}) | |
if (statusCode === 404) { | |
debugLog('loadlyrics', 4, { type: 3, sourceName: 'Spotify', message: 'No lyrics found.' }) | |
return null | |
} | |
const lyricsEvents = [] | |
data.lyrics.lines.forEach((event, index) => { | |
if (index === data.lyrics.lines.length - 1) return; | |
lyricsEvents.push({ | |
startTime: Number(event.startTimeMs), | |
endTime: Number(data.lyrics.lines[index + 1] ? data.lyrics.lines[index + 1].startTimeMs : data.lyrics.durationMs), | |
text: event.words | |
}) | |
}) | |
return { | |
loadType: 'lyricsSingle', | |
data: { | |
name: data.lyrics.language, | |
synced: data.lyrics.syncType === 'LINE_SYNCED', | |
data: lyricsEvents, | |
rtl: false | |
} | |
} | |
} | |
export default { | |
init, | |
search, | |
loadFrom, | |
loadLyrics | |
} |