|
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 |
|
} |