Spaces:
Runtime error
Runtime error
| import crypto from 'node:crypto' | |
| import { Buffer } from 'node:buffer' | |
| import { PassThrough } from 'node:stream' | |
| import config from '../../config.js' | |
| import { debugLog, makeRequest, encodeTrack } from '../utils.js' | |
| let sourceInfo = { | |
| licenseToken: null, | |
| csrfToken: null, | |
| mediaUrl: null, | |
| Cookie: null, | |
| jwtToken: null | |
| } | |
| const bufferSize = 2048 | |
| const IV = Buffer.from(Array.from({ length: 8 }, (_i, x) => x)) | |
| async function init() { | |
| if (sourceInfo.licenseToken) return; | |
| // TODO: Need to reset when timestamp is expired | |
| debugLog('deezer', 5, { type: 1, message: 'Fetching user data...' }) | |
| const res = await makeRequest(`https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token=${config.search.sources.deezer.apiToken}`, { | |
| method: 'GET', | |
| getCookies: true | |
| }) | |
| sourceInfo.Cookie = res.headers['set-cookie'].join('; ') | |
| if (config.search.sources.deezer.arl !== 'DISABLED') { | |
| sourceInfo.Cookie += `; arl=${config.search.sources.deezer.arl}` | |
| const { body: jwtInfo } = await makeRequest('https://auth.deezer.com/login/arl?jo=p&rto=c&i=c', { | |
| headers: { | |
| Cookie: sourceInfo.Cookie | |
| }, | |
| method: 'POST' | |
| }) | |
| sourceInfo.jwtToken = JSON.parse(jwtInfo).jwt | |
| } | |
| sourceInfo.licenseToken = res.body.results.USER.OPTIONS.license_token | |
| sourceInfo.csrfToken = res.body.results.checkForm | |
| sourceInfo.mediaUrl = res.body.results.URL_MEDIA | |
| debugLog('deezer', 5, { type: 1, message: 'Successfully fetched user data.' }) | |
| } | |
| async function loadFrom(query, type) { | |
| let endpoint | |
| switch (type[1]) { | |
| case 'track': | |
| endpoint = `track/${type[2]}` | |
| break | |
| case 'playlist': | |
| endpoint = `playlist/${type[2]}` | |
| break | |
| case 'album': | |
| endpoint = `album/${type[2]}` | |
| break | |
| default: { | |
| return { | |
| loadType: 'empty', | |
| data: {} | |
| } | |
| } | |
| } | |
| debugLog('loadtracks', 4, { type: 1, loadType: type[1], sourceName: 'Deezer', query }) | |
| const { body: data } = await makeRequest(`https://api.deezer.com/2.0/${endpoint}`, { method: 'GET' }) | |
| if (data.error) { | |
| if (data.error.code === 800) { | |
| return { | |
| loadType: 'empty', | |
| data: {} | |
| } | |
| } | |
| return { | |
| loadType: 'error', | |
| data: { | |
| message: data.error.message, | |
| severity: 'fault', | |
| cause: 'Unknown' | |
| } | |
| } | |
| } | |
| switch (type[1]) { | |
| case 'track': { | |
| const track = { | |
| identifier: data.id.toString(), | |
| isSeekable: true, | |
| author: data.artist.name, | |
| length: data.duration * 1000, | |
| isStream: false, | |
| position: 0, | |
| title: data.title, | |
| uri: data.link, | |
| artworkUrl: data.album.cover_xl, | |
| isrc: data.isrc, | |
| sourceName: 'deezer' | |
| } | |
| debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'Deezer', track, query }) | |
| return { | |
| loadType: 'track', | |
| data: { | |
| encoded: encodeTrack(track), | |
| info: track, | |
| pluginInfo: {} | |
| } | |
| } | |
| } | |
| case 'album': | |
| case 'playlist': { | |
| const tracks = [] | |
| if (data.tracks.data.length > config.options.maxAlbumPlaylistLength) | |
| data.tracks.data = data.tracks.data.slice(0, config.options.maxAlbumPlaylistLength) | |
| data.tracks.data.forEach(async (item, i) => { | |
| const track = { | |
| identifier: item.id.toString(), | |
| isSeekable: true, | |
| author: item.artist.name, | |
| length: item.duration * 1000, | |
| isStream: false, | |
| position: 0, | |
| title: item.title, | |
| uri: item.link, | |
| artworkUrl: type[1] === 'album' ? data.cover_xl : data.picture_xl, | |
| isrc: null, | |
| sourceName: 'deezer' | |
| } | |
| tracks.push({ | |
| encoded: encodeTrack(track), | |
| info: track, | |
| pluginInfo: {} | |
| }) | |
| }) | |
| debugLog('loadtracks', 4, { type: 2, loadType: type[1], sourceName: 'Deezer', playlistName: data.title }) | |
| return { | |
| loadType: type[1], | |
| data: { | |
| info: { | |
| name: data.title, | |
| selectedTrack: 0 | |
| }, | |
| pluginInfo: {}, | |
| tracks | |
| } | |
| } | |
| } | |
| } | |
| } | |
| async function search(query, shouldLog) { | |
| if (shouldLog) debugLog('search', 4, { type: 1, sourceName: 'Deezer', query }) | |
| const { body: data } = await makeRequest(`https://api.deezer.com/2.0/search?q=${encodeURI(query)}`, { method: 'GET' }) | |
| // This API doesn't give ISRC, must change to internal API | |
| if (data.error) { | |
| return { | |
| loadType: 'error', | |
| data: { | |
| message: data.error.message, | |
| severity: 'fault', | |
| cause: 'Unknown' | |
| } | |
| } | |
| } | |
| if (data.total === 0) { | |
| if (shouldLog) debugLog('search', 4, { type: 3, sourceName: 'Deezer', query, message: 'No matches found.' }) | |
| return { | |
| loadType: 'empty', | |
| data: {} | |
| } | |
| } | |
| const tracks = [] | |
| if (data.data.length > config.options.maxResultsLength) | |
| data.data = data.data.filter((item, i) => i < config.options.maxResultsLength || item.type === 'track') | |
| data.data.forEach(async (item) => { | |
| const track = { | |
| identifier: item.id.toString(), | |
| isSeekable: true, | |
| author: item.artist.name, | |
| length: item.duration * 1000, | |
| isStream: false, | |
| position: 0, | |
| title: item.title, | |
| uri: item.link, | |
| artworkUrl: item.album.cover_xl, | |
| isrc: item.isrc, | |
| sourceName: 'deezer' | |
| } | |
| tracks.push({ | |
| encoded: encodeTrack(track), | |
| info: track, | |
| pluginInfo: {} | |
| }) | |
| }) | |
| if (shouldLog) | |
| debugLog('search', 4, { type: 2, sourceName: 'Deezer', tracksLen: tracks.length, query }) | |
| return { | |
| loadType: 'search', | |
| data: tracks | |
| } | |
| } | |
| async function retrieveStream(identifier, title) { | |
| const { body: data } = await makeRequest(`https://www.deezer.com/ajax/gw-light.php?method=song.getListData&input=3&api_version=1.0&api_token=${sourceInfo.csrfToken}`, { | |
| body: { | |
| sng_ids: [ identifier ] | |
| }, | |
| headers: { | |
| Cookie: sourceInfo.Cookie | |
| }, | |
| method: 'POST', | |
| disableBodyCompression: true | |
| }) | |
| if (data.error.length !== 0) { | |
| const errorMessage = Object.keys(data.error).map((err) => data.error[err]).join('; ') | |
| debugLog('retrieveStream', 4, { type: 2, sourceName: 'Deezer', query: title, message: errorMessage }) | |
| return { | |
| exception: { | |
| message: errorMessage, | |
| severity: 'fault', | |
| cause: 'Unknown' | |
| } | |
| } | |
| } | |
| const trackInfo = data.results.data[0] | |
| const { body: streamData } = await makeRequest('https://media.deezer.com/v1/get_url', { | |
| body: { | |
| license_token: sourceInfo.licenseToken, | |
| media: [{ | |
| type: 'FULL', | |
| formats: [{ | |
| cipher: 'BF_CBC_STRIPE', | |
| format: 'FLAC' | |
| }, { | |
| cipher: 'BF_CBC_STRIPE', | |
| format: 'MP3_256' | |
| }, { | |
| cipher: 'BF_CBC_STRIPE', | |
| format: 'MP3_128' | |
| }, { | |
| cipher: 'BF_CBC_STRIPE', | |
| format: 'MP3_MISC' | |
| }] | |
| }], | |
| track_tokens: [ trackInfo.TRACK_TOKEN ] | |
| }, | |
| method: 'POST', | |
| disableBodyCompression: true | |
| }) | |
| return { | |
| url: streamData.data[0].media[0].sources[0].url, | |
| protocol: 'https', | |
| format: 'arbitrary', | |
| additionalData: trackInfo | |
| } | |
| } | |
| async function loadLyrics(decodedTrack, _language) { | |
| const { body: video } = await makeRequest('https://pipe.deezer.com/api', { | |
| headers: { | |
| Cookie: sourceInfo.Cookie, | |
| Authorization: `Bearer ${sourceInfo.jwtToken}` | |
| }, | |
| body: { | |
| operationName: 'SynchronizedTrackLyrics', | |
| query: 'query SynchronizedTrackLyrics($trackId: String!) {\n track(trackId: $trackId) {\n ...SynchronizedTrackLyrics\n __typename\n }\n}\n\nfragment SynchronizedTrackLyrics on Track {\n id\n lyrics {\n ...Lyrics\n __typename\n }\n album {\n cover {\n small: urls(pictureRequest: {width: 100, height: 100})\n medium: urls(pictureRequest: {width: 264, height: 264})\n large: urls(pictureRequest: {width: 800, height: 800})\n explicitStatus\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment Lyrics on Lyrics {\n id\n copyright\n text\n writers\n synchronizedLines {\n ...LyricsSynchronizedLines\n __typename\n }\n __typename\n}\n\nfragment LyricsSynchronizedLines on LyricsSynchronizedLine {\n lrcTimestamp\n line\n lineTranslated\n milliseconds\n duration\n __typename\n}', | |
| variables: { | |
| trackId: decodedTrack.identifier | |
| } | |
| }, | |
| method: 'POST', | |
| disableBodyCompression: true | |
| }) | |
| if (video.errors) { | |
| const errorMessage = video.errors.map((err) => `${err.message} (${err.type})`).join('; ') | |
| debugLog('loadlyrics', 4, { type: 3, track: decodedTrack, sourceName: 'Deezer', message: errorMessage }) | |
| return { | |
| loadType: 'error', | |
| data: { | |
| message: errorMessage, | |
| severity: 'common', | |
| cause: 'Unknown' | |
| } | |
| } | |
| } | |
| const lyricsEvents = video.data.track.lyrics.synchronizedLines.map((event) => { | |
| return { | |
| startTime: event.milliseconds, | |
| endTime: event.milliseconds + event.duration, | |
| text: event.line | |
| } | |
| }) | |
| return { | |
| loadType: 'lyricsSingle', | |
| data: { | |
| name: 'original', | |
| synced: true, | |
| data: lyricsEvents, | |
| rtl: false | |
| } | |
| } | |
| } | |
| function _calculateKey(songId) { | |
| const key = config.search.sources.deezer.decryptionKey | |
| const songIdHash = crypto.createHash('md5').update(songId, 'ascii').digest('hex') | |
| const trackKey = Buffer.alloc(16) | |
| for (let i = 0; i < 16; i++) { | |
| trackKey.writeInt8(songIdHash[i].charCodeAt(0) ^ songIdHash[i + 16].charCodeAt(0) ^ key[i].charCodeAt(0), i) | |
| } | |
| return trackKey | |
| } | |
| function loadTrack(title, url, trackInfos) { | |
| return new Promise(async (resolve) => { | |
| const stream = new PassThrough() | |
| const trackKey = _calculateKey(trackInfos.SNG_ID) | |
| let buf = Buffer.alloc(0) | |
| let i = 0 | |
| const res = await makeRequest(url, { | |
| method: 'GET', | |
| streamOnly: true | |
| }) | |
| res.stream.on('end', () => stream.end()) | |
| res.stream.on('error', (error) => { | |
| debugLog('retrieveStream', 4, { type: 2, sourceName: 'Deezer', query: title, message: error.message }) | |
| resolve({ | |
| status: 1, | |
| exception: { | |
| message: error.message, | |
| severity: 'fault', | |
| cause: 'Unknown' | |
| } | |
| }) | |
| }) | |
| res.stream.on('readable', () => { | |
| let chunk = null | |
| while (1) { | |
| chunk = res.stream.read(bufferSize) | |
| if (!chunk) { | |
| if (res.stream.readableLength) { | |
| chunk = res.stream.read(res.stream.readableLength) | |
| buf = Buffer.concat([ buf, chunk ]) | |
| } | |
| break | |
| } else { | |
| buf = Buffer.concat([ buf, chunk ]) | |
| } | |
| while (buf.length >= bufferSize) { | |
| const bufferSized = buf.subarray(0, bufferSize) | |
| if (i % 3 === 0) { | |
| const decipher = crypto.createDecipheriv('bf-cbc', trackKey, IV).setAutoPadding(false) | |
| stream.push(decipher.update(bufferSized)) | |
| stream.push(decipher.final()) | |
| } else { | |
| stream.push(bufferSized) | |
| } | |
| i++ | |
| buf = buf.subarray(bufferSize) | |
| } | |
| } | |
| resolve(stream) | |
| }) | |
| }) | |
| } | |
| export default { | |
| init, | |
| loadFrom, | |
| search, | |
| retrieveStream, | |
| loadLyrics, | |
| loadTrack | |
| } |