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 }