diff --git a/package.json b/package.json index 87c4a25..31b982b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baa0b16..b255b1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/streamers/deezer/constants.ts b/src/streamers/deezer/constants.ts new file mode 100644 index 0000000..5f33c00 --- /dev/null +++ b/src/streamers/deezer/constants.ts @@ -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' diff --git a/src/streamers/deezer/main.ts b/src/streamers/deezer/main.ts new file mode 100644 index 0000000..9dc10cb --- /dev/null +++ b/src/streamers/deezer/main.ts @@ -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 = new Set() + + constructor(options?: DeezerOptions) { + if (options?.arl) this.#loginViaArl(options.arl) + } + + async #apiCall(method: T, data?: any): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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: type, id: parseInt(id) } + } + + async getTypeFromUrl(url: string): Promise { + let urlObj = new URL(url) + let { type } = await this.#getInfoFromUrl(urlObj) + return type + } + + /* ---------- GET BY URL ---------- */ + + // Artist + + async #getArtist(id: number): Promise { + 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 { + return ( + await this.#apiCall('deezer.pageTrack', { + sng_id: id + }) + ).DATA + } + + async #getStream(track: DeezerTrack): Promise { + 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 = 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 { + 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 { + 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 { + 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' + } + } +} diff --git a/src/streamers/deezer/parse.ts b/src/streamers/deezer/parse.ts new file mode 100644 index 0000000..27e8660 --- /dev/null +++ b/src/streamers/deezer/parse.ts @@ -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 + })) +}