index.js

import blowfish from "blowfish-js";
import { createHash } from "crypto";
import fetch from "node-fetch";

// Constants
const CBC_KEY = "g4el58wc0zvf9na1";
const ENTITY_TYPES = ["track", "album", "artist", "playlist"];
const SESSION_EXPIRE = 60000 * 15;

class Deezer {
    #arl;
    #currentSessionTimestamp = 0;
    #sessionID;
    #apiToken;
    #isPremium = false;
    #licenseToken;

    /**
     * Constructs the Deezer class.
     * @param {string} [arl] The Deezer ARL cookie, for authenticating as a Deezer Premium account
     * @returns {Object} The Deezer class instance
     */
    constructor(arl) {
        if (typeof arl === "string") this.#arl = arl;
    }

    async #request(url, options = {}) {
        const { buffer, ...fetchOptions } = options;
        if (this.#arl && !fetchOptions.headers?.cookie) {
            fetchOptions.headers = { ...fetchOptions.headers, cookie: `arl=${this.#arl}` };
        }
        const res = await fetch(url, fetchOptions);
        if (buffer) return Buffer.from(await res.arrayBuffer());
        const text = await res.text();
        try {
            return JSON.parse(text);
        } catch (e) {
            console.error(`Error parsing body as JSON: ${text}`);
            throw e;
        }
    }

    async #ensureSession() {
        if (this.#currentSessionTimestamp + SESSION_EXPIRE > Date.now()) return;
        const data = await this.#request(
            "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token="
        );
        const results = data.results;
        this.#currentSessionTimestamp = Date.now();
        this.#sessionID = results.SESSION_ID;
        this.#apiToken = results.checkForm;
        this.#isPremium = results.OFFER_NAME !== "Deezer Free";
        this.#licenseToken = results.USER.OPTIONS.license_token;
    }

    /**
     * Does a request to the Deezer API.
     * @param {string} method The Deezer API method
     * @param {Object} body The JSON body
     * @returns {Promise<Object>} The response
     */
    async api(method, body) {
        if (typeof method !== "string") throw new TypeError("`method` must be a string.");
        if (body?.constructor !== Object) throw new TypeError("`body` must be an object.");

        await this.#ensureSession();

        return this.#request(
            `https://www.deezer.com/ajax/gw-light.php?method=${method}&input=3&api_version=1.0&api_token=${this.#apiToken}`,
            {
                method: "POST",
                headers: { cookie: `sid=${this.#sessionID}` },
                body: JSON.stringify(body),
            }
        );
    }

    /**
     * Searches for entities.
     * @param {string} query The query
     * @param {EntityType} [type = "track"] The entity type
     * @returns {Promise.<Array>} An array of search results, depending on the entity type
     */
    async search(query, type) {
        if (typeof query !== "string") throw new TypeError("`query` must be a string.");
        type = ENTITY_TYPES.includes(type?.toLowerCase?.()) ? type.toLowerCase() : "track";
        const res = await this.api("deezer.pageSearch", { query, start: 0, nb: 200, top_tracks: true });
        return res.results[type.toUpperCase()]?.data ?? [];
    }

    /**
     * Gets an entity by ID or URL.
     * @param {string} idOrURL The entity ID or URL
     * @param {EntityType} [type] The entity type
     * @returns {Promise.<Entity | null>} The {@link Entity} object, or null if no entity was found
     */
    async get(idOrURL, type) {
        if (typeof idOrURL !== "string") throw new TypeError("`idOrURL` must be a string.");
        if (type) {
            if (typeof type !== "string") throw new TypeError("`type` must be a string.");
            type = ENTITY_TYPES.includes(type.toLowerCase()) ? type.toLowerCase() : "track";
        } else {
            idOrURL = idOrURL.replace(/\/+$/, "");
            type = ENTITY_TYPES.find(e => idOrURL.toLowerCase().includes(e)) ?? "track";
            idOrURL = idOrURL.split("/").pop().split("?")[0];
            if (!/^[0-9]+$/.test(idOrURL)) return null;
        }
        const data = { type };
        switch (type) {
            case "track": {
                const track = (await this.api("song.getListData", { sng_ids: [idOrURL] })).results.data[0];
                Object.assign(data, { info: track, tracks: [track] });
                break;
            }
            case "album": {
                const album = (await this.api("deezer.pageAlbum", { alb_id: idOrURL, nb: 200, lang: "us" })).results;
                Object.assign(data, { info: album.DATA, tracks: album.SONGS?.data ?? [] });
                break;
            }
            case "artist": {
                const artist = (await this.api("deezer.pageArtist", { art_id: idOrURL, lang: "us" })).results;
                Object.assign(data, { info: artist.DATA, tracks: artist.TOP?.data ?? [] });
                break;
            }
            case "playlist": {
                const playlist = (await this.api("deezer.pagePlaylist", { playlist_id: idOrURL, nb: 200 })).results;
                Object.assign(data, { info: playlist.DATA, tracks: playlist.SONGS?.data ?? [] });
                break;
            }
        }
        return data.info ? data : null;
    }

    /**
     * Gets a track buffer and decrypts it. By default, the track is in MP3.
     * @param {Object} track The track object
     * @param {boolean} [flac = false] Whether to get the track in FLAC. Only works for Deezer Premium accounts
     * @returns {Promise.<Buffer>} The decrypted track buffer
     */
    async getAndDecryptTrack(track, flac = false) {
        if (track?.constructor !== Object) throw new TypeError("`track` must be an object.");

        await this.#ensureSession();

        if (!Number(track.FILESIZE) && track.FALLBACK) {
            console.info(`Audio is unavailable for track ${track.SNG_ID}. Using fallback track ${track.FALLBACK.SNG_ID}...`);
            track = track.FALLBACK;
        }

        if (flac) {
            if (!this.#isPremium)
                throw new Error("FLAC is only supported on Deezer Premium accounts. Please provide the Deezer ARL cookie to the constructor.");

            if (!Number(track.FILESIZE_FLAC)) throw new Error(`FLAC audio is unavailable for track ${track.SNG_ID}.`);
        }

        const format = flac
            ? "FLAC"
            : ["MP3_320", "MP3_256", "MP3_128", "MP3_64"].find(e => Number(track[`FILESIZE_${e}`]));
        if (!format) throw new Error(`Audio is unavailable for track ${track.SNG_ID}.`);

        const data = await this.#request("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 }] }],
                track_tokens: [track.TRACK_TOKEN],
            }),
        });
        const url = data?.data?.[0]?.media?.[0]?.sources?.[0]?.url;
        if (!url) throw new Error(`Could not get track ${track.SNG_ID}'s audio source URL: ${data?.errors?.[0]?.message ?? "Unknown error"}`);

        const buffer = await this.#request(url, { buffer: true });
        const md5 = createHash("md5").update(track.SNG_ID).digest("hex");
        const blowfishKey = blowfish.key(
            Array(16)
                .fill(0)
                .reduce((acc, _, i) => acc + String.fromCharCode(md5.charCodeAt(i) ^ md5.charCodeAt(i + 16) ^ CBC_KEY.charCodeAt(i)), "")
        );
        const decryptedBuffer = Buffer.alloc(buffer.length);
        let i = 0,
            position = 0;
        while (position < buffer.length) {
            const chunkSize = Math.min(2048, buffer.length - position);

            let chunk = Buffer.alloc(chunkSize);
            buffer.copy(chunk, 0, position, position + chunkSize);

            chunk =
                i % 3 || chunkSize < 2048
                    ? chunk.toString("binary")
                    : blowfish.cbc(blowfishKey, Buffer.from([0, 1, 2, 3, 4, 5, 6, 7]), chunk, true).toString("binary");

            decryptedBuffer.write(chunk, position, chunk.length, "binary");

            position += chunkSize;
            i++;
        }

        return decryptedBuffer;
    }
}

module.exports = Deezer;