|
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; |
|
|
|
|
|
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' }) |
|
|
|
|
|
|
|
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 |
|
} |