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 }