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