add deezer module

Co-authored-by: bernzrdo <bernardovs2003@gmail.com>
This commit is contained in:
uh wot 2024-04-11 14:15:26 +01:00
parent bdfd0d1b18
commit a5a254467a
No known key found for this signature in database
GPG Key ID: CB2454984587B781
5 changed files with 625 additions and 0 deletions

View File

@ -29,6 +29,7 @@
},
"license": "OQL",
"dependencies": {
"blowfish-cbc": "^1.0.1",
"image-size": "^1.1.1",
"librespot": "^0.2.4",
"node-fetch": "^3.3.2",

16
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ importers:
.:
dependencies:
blowfish-cbc:
specifier: ^1.0.1
version: 1.0.1
image-size:
specifier: ^1.1.1
version: 1.1.1
@ -225,6 +228,9 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
blowfish-cbc@1.0.1:
resolution: {integrity: sha512-o1JN6g6+ATW/4k7q1BZzy14VqLxwYa1mCii47qT4kmAaYD0NAfdM6/pz6uizbhTra/xunsPQI27LZt08OQS4sA==}
brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
@ -503,6 +509,10 @@ packages:
node-addon-api@1.7.2:
resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
node-addon-api@8.1.0:
resolution: {integrity: sha512-yBY+qqWSv3dWKGODD6OGE6GnTX7Q2r+4+DfpqxHSHh8x0B4EKP9+wVGLS6U/AM1vxSNNmUEuIV5EGhYwPpfOwQ==}
engines: {node: ^18 || ^20 || >= 21}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@ -879,6 +889,10 @@ snapshots:
balanced-match@1.0.2: {}
blowfish-cbc@1.0.1:
dependencies:
node-addon-api: 8.1.0
brace-expansion@1.1.11:
dependencies:
balanced-match: 1.0.2
@ -1182,6 +1196,8 @@ snapshots:
node-addon-api@1.7.2: {}
node-addon-api@8.1.0: {}
node-domexception@1.0.0: {}
node-fetch@3.3.2:

View File

@ -0,0 +1,6 @@
export const GW_LIGHT_URL = 'https://www.deezer.com/ajax/gw-light.php'
export const CLIENT_ID = '447462'
export const CLIENT_SECRET = 'a83bf7f38ad2f137e444727cfc3775cf'
export const SIZES = [32, 64, 128, 256, 512, 1024]
export const BLOWFISH_SECRET = 'g4el58wc0zvf9na1'

View File

@ -0,0 +1,471 @@
import fetch from 'node-fetch'
import {
Album,
Artist,
GetByUrlResponse,
GetStreamResponse,
ItemType,
SearchResults,
StreamerAccount,
StreamerWithLogin,
Track
} from '../../types.js'
import { BLOWFISH_SECRET, CLIENT_ID, CLIENT_SECRET, GW_LIGHT_URL } from './constants.js'
import { createHash } from 'crypto'
import {
DeezerAlbum,
DeezerArtist,
DeezerFormat,
DeezerTrack,
DeezerUserData,
parseAlbum,
parseArtist,
parseTrack
} from './parse.js'
import { Transform } from 'stream'
import { Blowfish } from 'blowfish-cbc'
interface DeezerOptions {
arl?: string
}
interface APIMethod {
'deezer.getUserData': DeezerUserData
'deezer.pageArtist': {
DATA: DeezerArtist
TOP: { data?: DeezerTrack[] }
ALBUMS: { data?: DeezerAlbum[] }
}
'deezer.pageAlbum': {
DATA: DeezerAlbum
SONGS: { data: DeezerTrack[] }
}
'deezer.pageTrack': {
DATA: DeezerTrack
}
'user.getArl': string
'search.music': { data: (DeezerArtist | DeezerAlbum | DeezerTrack)[] }
'song.getListData': { data: DeezerTrack[] }
'song.getData': DeezerTrack
}
export default class Deezer implements StreamerWithLogin {
hostnames = ['deezer.com', 'www.deezer.com', 'deezer.page.link']
headers: { [header: string]: string } = {
Accept: '*/*',
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36',
'Content-Type': 'text/plain;charset=UTF-8',
Origin: 'https://www.deezer.com',
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-Mode': 'same-origin',
'Sec-Fetch-Dest': 'empty',
Referer: 'https://www.deezer.com/',
'Accept-Language': 'en-US,en;q=0.9'
}
arl?: string
apiToken?: string
licenseToken?: string
renewTimestamp?: number
country?: string
language?: string
availableFormats: Set<DeezerFormat> = new Set()
constructor(options?: DeezerOptions) {
if (options?.arl) this.#loginViaArl(options.arl)
}
async #apiCall<T extends keyof APIMethod>(method: T, data?: any): Promise<APIMethod[T]> {
let apiToken = this.apiToken
if (method == 'deezer.getUserData' || method == 'user.getArl') apiToken = ''
let url = `${GW_LIGHT_URL}?${new URLSearchParams({
method,
input: '3',
api_version: '1.0',
api_token: apiToken ?? '',
cid: Math.floor(Math.random() * 1e9).toString()
})}`
let body = data ? JSON.stringify(data) : undefined
let req = await fetch(url, { method: 'POST', body, headers: this.headers })
let { results: res, error, payload } = (await req.json()) as any
if (error.constructor.name == 'Object') {
let [type, msg] = Object.entries(error)[0]
throw new Error(`API Error: ${type}\n${msg}\n\nPayload: ${payload}`)
}
if (method == 'deezer.getUserData') {
const setCookie = req.headers.get('Set-Cookie') ?? ''
const sid = setCookie.match(/sid=(fr[0-9a-f]+)/)![1]
this.headers['Cookie'] += `arl=${this.arl}; sid=${sid}`
this.apiToken = res?.checkForm
this.licenseToken = res?.USER?.OPTIONS?.license_token
this.country = res?.COUNTRY
this.language = res?.USER?.SETTING?.global?.language
this.availableFormats = new Set([DeezerFormat.MP3_128])
if (res?.USER?.OPTIONS?.web_hq) this.availableFormats.add(DeezerFormat.MP3_320)
if (res?.USER?.OPTIONS?.web_lossless) this.availableFormats.add(DeezerFormat.FLAC)
this.renewTimestamp = Date.now()
}
return res
}
async #loginViaArl(arl: string) {
this.arl = arl
this.headers['Cookie'] = `arl=${arl}`
let userData = await this.#apiCall('deezer.getUserData')
if (userData.USER.USER_ID == 0) {
delete this.headers['Cookie']
this.arl = undefined
throw new Error('Invalid ARL')
}
return userData
}
#md5(str: string) {
return createHash('md5').update(str).digest('hex')
}
async login(username: string, password: string): Promise<void> {
if (this.arl) {
return
}
const resp = await fetch('https://www.deezer.com/', { headers: this.headers })
const setCookie = resp.headers.get('Set-Cookie') ?? ''
const sid = setCookie.match(/sid=(fr[0-9a-f]+)/)![1]
this.headers['Cookie'] = `sid=${sid}`
password = this.#md5(password)
let loginReq = await fetch(
`https://connect.deezer.com/oauth/user_auth.php?${new URLSearchParams({
app_id: CLIENT_ID,
login: username,
password,
hash: this.#md5(CLIENT_ID + username + password + CLIENT_SECRET)
})}`,
{ headers: this.headers }
)
const { error } = (await loginReq.json()) as any
if (error) throw new Error('Error while getting access token, check your credentials')
let arl = await this.#apiCall('user.getArl')
await this.#loginViaArl(arl)
}
/* ---------- SEARCH ---------- */
async search(query: string, limit: number): Promise<SearchResults> {
let results = await Promise.all([
this.#searchArtists(query, limit),
this.#searchAlbums(query, limit),
this.#searchTracks(query, limit)
])
let [artists, albums, tracks] = results
return { query, albums, tracks, artists }
}
async #searchArtists(query: string, limit: number): Promise<Artist[]> {
let { data } = await this.#apiCall('search.music', {
query,
start: 0,
nb: limit,
filter: 'ALL',
output: 'ARTIST'
})
return data.map((a) => parseArtist(a as DeezerArtist))
}
async #searchAlbums(query: string, limit: number): Promise<Album[]> {
let { data } = await this.#apiCall('search.music', {
query,
start: 0,
nb: limit,
filter: 'ALL',
output: 'ALBUM'
})
return data.map((a) => parseAlbum(a as DeezerAlbum))
}
async #searchTracks(query: string, limit: number): Promise<Track[]> {
let { data } = await this.#apiCall('search.music', {
query,
start: 0,
nb: limit,
filter: 'ALL',
output: 'TRACK'
})
return data.map((t) => parseTrack(t as DeezerTrack))
}
/* ---------- GET INFO FROM URL ---------- */
async #unshortenUrl(url: URL): Promise<URL> {
let res = await fetch(url, { redirect: 'manual' })
let location = res.headers.get('Location')
if (res.status != 302 || !location) throw new Error('URL not supported')
return new URL(location)
}
async #getInfoFromUrl(url: URL): Promise<{ type: ItemType; id: number }> {
if (url.hostname == 'deezer.page.link') url = await this.#unshortenUrl(url)
let match = url.pathname.match(/^\/(?:[a-z]{2}\/)?(track|album|artist)\/(\d+)\/?$/)
if (!match) throw new Error('URL not supported')
let [, type, id] = match
return { type: <ItemType>type, id: parseInt(id) }
}
async getTypeFromUrl(url: string): Promise<ItemType> {
let urlObj = new URL(url)
let { type } = await this.#getInfoFromUrl(urlObj)
return type
}
/* ---------- GET BY URL ---------- */
// Artist
async #getArtist(id: number): Promise<Artist> {
let { DATA, TOP, ALBUMS } = await this.#apiCall('deezer.pageArtist', {
art_id: id,
lang: 'en'
})
return parseArtist(DATA, TOP.data, ALBUMS.data)
}
// Album
async #getAlbum(id: number): Promise<{ metadata: Album; tracks: Track[] }> {
let { DATA, SONGS } = await this.#apiCall('deezer.pageAlbum', {
alb_id: id,
lang: 'en'
})
return {
metadata: parseAlbum(DATA),
tracks: SONGS.data.map(parseTrack)
}
}
// Track
async #getTrackData(id: number): Promise<DeezerTrack> {
return (
await this.#apiCall('deezer.pageTrack', {
sng_id: id
})
).DATA
}
async #getStream(track: DeezerTrack): Promise<GetStreamResponse> {
if ('FALLBACK' in track) track = track.FALLBACK!
const countries = track.AVAILABLE_COUNTRIES.STREAM_ADS
if (!countries.length) throw new Error('Track not available in any country')
if (!countries.includes(this.country!))
throw new Error("Track not available in the account's country")
let format: DeezerFormat = DeezerFormat.MP3_128
const formatsToCheck = [
{
format: DeezerFormat.FLAC,
filesize: track.FILESIZE_FLAC
},
{
format: DeezerFormat.MP3_320,
filesize: track.FILESIZE_MP3_320
}
]
for (const f of formatsToCheck) {
if (f.filesize != '0' && this.availableFormats.has(f.format)) {
format = f.format
break
}
}
const id = track.SNG_ID
const trackToken = track.TRACK_TOKEN
const trackTokenExpiry = track.TRACK_TOKEN_EXPIRE
let mimeType = ''
switch (format) {
case (DeezerFormat.MP3_128, DeezerFormat.MP3_320):
mimeType = 'audio/mpeg'
break
case DeezerFormat.FLAC:
mimeType = 'audio/flac'
}
// download
const url = await this.#getTrackUrl(id, trackToken, trackTokenExpiry, format)
const streamResp = await fetch(url)
if (!streamResp.ok)
throw new Error(`Failed to get track stream. Status code: ${streamResp.status}`)
const decryptionKey = this.#getTrackDecryptionKey(id)
const blowfish = new Blowfish(decryptionKey)
const chunkSize = 2048 * 3
const buf = Buffer.alloc(chunkSize)
let bufSize = 0
const decryption = new Transform({
transform(c, _, callback) {
const chunk = <Buffer>c
let chunkBytesRead = 0
while (chunkBytesRead != chunk.length) {
let slice = chunk.subarray(
chunkBytesRead,
chunkBytesRead + (chunkSize - (bufSize % chunkSize))
)
chunkBytesRead += slice.length
slice.copy(buf, bufSize)
bufSize += slice.length
if (bufSize == chunkSize) {
bufSize = 0
const copy = Buffer.alloc(chunkSize)
buf.copy(copy, 0)
if (copy.length >= 2048) {
const encryptedChunk = copy.subarray(0, 2048)
blowfish.decryptChunk(encryptedChunk)
}
this.push(copy)
}
}
callback()
},
flush(callback) {
if (bufSize != 0) {
const final = buf.subarray(0, bufSize)
if (final.length >= 2048) {
const encryptedChunk = final.subarray(0, 2048)
blowfish.decryptChunk(encryptedChunk)
}
this.push(final)
}
callback()
}
})
return {
stream: streamResp.body!.pipe(decryption),
mimeType
}
}
async #getTrackUrl(
id: string,
trackToken: string,
trackTokenExpiry: number,
format: DeezerFormat
): Promise<string> {
if (Date.now() - (this.renewTimestamp ?? 0) >= 3600)
// renew license token
await this.#apiCall('deezer.getUserData')
if (Date.now() - trackTokenExpiry >= 0)
// renew track token
trackToken = (
await this.#apiCall('song.getData', {
sng_id: id,
array_default: ['TRACK_TOKEN']
})
).TRACK_TOKEN
let req = await fetch('https://media.deezer.com/v1/get_url', {
method: 'POST',
body: JSON.stringify({
license_token: this.licenseToken,
media: [
{
type: 'FULL',
formats: [{ cipher: 'BF_CBC_STRIPE', format: DeezerFormat[format] }]
}
],
track_tokens: [trackToken]
})
})
let res = (await req.json()) as any
return res.data[0].media[0].sources[0].url
}
#getTrackDecryptionKey(id: string): Uint8Array {
const hash = this.#md5(id)
const key = new Uint8Array(16)
for (let i = 0; i < 16; i++) {
key[i] = hash.charCodeAt(i) ^ hash.charCodeAt(i + 16) ^ BLOWFISH_SECRET.charCodeAt(i)
}
return key
}
async getByUrl(url: string, limit?: number | undefined): Promise<GetByUrlResponse> {
let { type, id } = await this.#getInfoFromUrl(new URL(url))
switch (type) {
case 'artist':
return {
type: 'artist',
metadata: await this.#getArtist(id)
}
case 'album':
return {
type: 'album',
...(await this.#getAlbum(id))
}
case 'track':
let track = await this.#getTrackData(id)
return {
type: 'track',
metadata: parseTrack(track),
getStream: () => {
return this.#getStream(track)
}
}
default:
throw new Error('URL unrecognised')
}
}
async getAccountInfo(): Promise<StreamerAccount> {
const userData = await this.#apiCall('deezer.getUserData')
return {
valid: userData.USER.USER_ID != 0,
premium: userData.OFFER_ID != 0,
country: userData.COUNTRY,
explicit: userData.USER.EXPLICIT_CONTENT_LEVEL != 'explicit_hide'
}
}
}

View File

@ -0,0 +1,131 @@
import { Album, Artist, CoverArtwork, Track } from '../../types.js'
import { SIZES } from './constants.js'
export enum DeezerFormat {
MP3_128 = 1,
MP3_320 = 3,
FLAC = 9
}
export interface DeezerUserData {
USER: {
USER_ID: number
EXPLICIT_CONTENT_LEVEL: string
}
OFFER_ID: number
COUNTRY: string
}
export interface DeezerArtist {
ART_ID: string
ART_NAME: string
ART_PICTURE: string
}
export function parseArtist(
artist: DeezerArtist,
tracks?: DeezerTrack[],
albums?: DeezerAlbum[]
): Artist {
let pictures
if (artist.ART_PICTURE)
pictures = SIZES.map(
(s) =>
`https://e-cdns-images.dzcdn.net/images/artist/${artist.ART_PICTURE}/${s}x${s}-000000-80-0-0.jpg`
)
let parsedTracks
if (tracks) parsedTracks = tracks.map(parseTrack)
let parsedAlbums
if (albums) parsedAlbums = albums.map(parseAlbum)
return {
id: artist.ART_ID,
url: `https://www.deezer.com/artist/${artist.ART_ID}`,
pictures,
name: artist.ART_NAME,
tracks: parsedTracks,
albums: parsedAlbums
}
}
export interface DeezerAlbum {
ALB_ID: string
ALB_TITLE: string
ALB_PICTURE: string
ARTISTS?: DeezerArtist[]
ORIGINAL_RELEASE_DATE?: string
NUMBER_TRACK?: string
}
export function parseAlbum(album: DeezerAlbum): Album {
return {
title: album.ALB_TITLE,
id: album.ALB_ID,
url: `https://www.deezer.com/album/${album.ALB_ID}`,
trackCount: album.NUMBER_TRACK ? parseInt(album.NUMBER_TRACK) : undefined,
releaseDate: album.ORIGINAL_RELEASE_DATE ? new Date(album.ORIGINAL_RELEASE_DATE) : undefined,
coverArtwork: parseArtwork(album.ALB_PICTURE),
artists: album.ARTISTS ? album.ARTISTS.map((a) => parseArtist(a)) : undefined
}
}
export interface DeezerTrack {
SNG_ID: string
SNG_TITLE: string
EXPLICIT_LYRICS: '0' | '1'
TRACK_NUMBER: string
DISK_NUMBER: string
ARTISTS: DeezerArtist[]
ISRC: string
SNG_CONTRIBUTORS: { [role: string]: string[] }
ALB_ID: string
ALB_TITLE: string
ALB_PICTURE: string
DURATION: string
AVAILABLE_COUNTRIES: { STREAM_ADS: string }
COPYRIGHT: string
TRACK_TOKEN: string
TRACK_TOKEN_EXPIRE: number
MD5_ORIGIN: string
MEDIA_VERSION: string
FILESIZE_MP3_320: string
FILESIZE_FLAC: string
FALLBACK?: DeezerTrack
}
export function parseTrack(track: DeezerTrack): Track {
return {
title: track.SNG_TITLE,
id: track.SNG_ID,
url: `https://www.deezer.com/track/${track.SNG_ID}`,
explicit: track.EXPLICIT_LYRICS == '1',
trackNumber: parseInt(track.TRACK_NUMBER),
discNumber: parseInt(track.DISK_NUMBER),
copyright: track.COPYRIGHT,
artists: track.ARTISTS.map((a) => parseArtist(a)),
isrc: track.ISRC,
producers: track.SNG_CONTRIBUTORS?.producer,
composers: track.SNG_CONTRIBUTORS?.composer,
lyricists: track.SNG_CONTRIBUTORS?.lyricist,
album: parseAlbum({
ALB_ID: track.ALB_ID,
ALB_PICTURE: track.ALB_PICTURE,
ALB_TITLE: track.ALB_TITLE
}),
durationMs: parseInt(track.DURATION) * 1e3,
coverArtwork: parseArtwork(track.ALB_PICTURE)
}
}
export function parseArtwork(picture: string): CoverArtwork[] {
return SIZES.map((size) => ({
url: `https://e-cdns-images.dzcdn.net/images/cover/${picture}/${size}x${size}-000000-80-0-0.jpg`,
height: size,
width: size
}))
}