diff --git a/README.md b/README.md index 3a01869..bf8ad92 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,16 @@ try { const track = await api.getTrackInfo(song_id); // Parse download URL for 128kbps -const url = api.getTrackDownloadUrl(track, 1); +const url = await api.getTrackDownloadUrl(track, 1); -// Download encrypted track +// Download track const {data} = await axios.get(url, {responseType: 'arraybuffer'}); -// Decrypt track -const decryptedTrack = api.decryptDownload(data, track.SNG_ID); +// Decrypt track if needed +let decryptedTrack = data; +if (api.isTrackEncrypted(url)) { + decryptedTrack = api.decryptDownload(data, track.SNG_ID); +} // Add id3 metadata const trackWithMetadata = await api.addTrackTags(decryptedTrack, track, 500); diff --git a/__tests__/api.ts b/__tests__/api.ts index 88a5c5b..260dbd1 100644 --- a/__tests__/api.ts +++ b/__tests__/api.ts @@ -1,9 +1,10 @@ import test from 'ava'; import axios from 'axios'; import * as api from '../src'; -import {decryptDownload, getTrackDownloadUrl} from '../src/lib/decrypt'; +import {decryptDownload, trackIsEncrypted} from '../src/lib/decrypt'; import {downloadAlbumCover} from '../src/metadata-writer/abumCover'; import {getLyricsMusixmatch} from '../src/metadata-writer/musixmatchLyrics'; +import {getTrackDownloadUrl} from '../src/lib/get-url'; // Harder, Better, Faster, Stronger by Daft Punk const SNG_ID = '3135556'; @@ -139,14 +140,20 @@ test('SEARCH TRACK, ALBUM & ARTIST', async (t) => { if (process.env.CI) { test('DOWNLOAD TRACK128 & ADD METADATA', async (t) => { const track = await api.getTrackInfo(SNG_ID); - const url = getTrackDownloadUrl(track, 1); + const urlGen = await getTrackDownloadUrl(track, 1); + const url = urlGen ? urlGen : ''; const {data} = await axios.get(url, {responseType: 'arraybuffer'}); t.truthy(data); t.true(Buffer.isBuffer(data)); t.is(data.length, 3596119); - const decryptedTrack = decryptDownload(data, track.SNG_ID); + let decryptedTrack: Buffer; + if (trackIsEncrypted(url)) { + decryptedTrack = decryptDownload(data, track.SNG_ID); + } else { + decryptedTrack = data; + } t.true(Buffer.isBuffer(decryptedTrack)); t.is(decryptedTrack.length, 3596119); @@ -157,14 +164,20 @@ if (process.env.CI) { test('TRACK128 WITHOUT ALBUM INFO', async (t) => { const track = await api.getTrackInfo('912254892'); - const url = getTrackDownloadUrl(track, 1); + const urlGen = await getTrackDownloadUrl(track, 1); + const url = urlGen ? urlGen : ''; const {data} = await axios.get(url, {responseType: 'arraybuffer'}); t.truthy(data); t.true(Buffer.isBuffer(data)); t.is(data.length, 3262170); - const decryptedTrack = decryptDownload(data, track.SNG_ID); + let decryptedTrack: Buffer; + if (trackIsEncrypted(url)) { + decryptedTrack = decryptDownload(data, track.SNG_ID); + } else { + decryptedTrack = data; + } t.true(Buffer.isBuffer(decryptedTrack)); t.is(decryptedTrack.length, 3262170); @@ -177,14 +190,20 @@ if (process.env.CI) { test('DOWNLOAD TRACK320 & ADD METADATA', async (t) => { const track = await api.getTrackInfo(SNG_ID); - const url = getTrackDownloadUrl(track, 3); + const urlGen = await getTrackDownloadUrl(track, 3); + const url = urlGen ? urlGen : ''; const {data} = await axios.get(url, {responseType: 'arraybuffer'}); t.truthy(data); t.true(Buffer.isBuffer(data)); t.is(data.length, 8990301); - const decryptedTrack = decryptDownload(data, track.SNG_ID); + let decryptedTrack: Buffer; + if (trackIsEncrypted(url)) { + decryptedTrack = decryptDownload(data, track.SNG_ID); + } else { + decryptedTrack = data; + } t.true(Buffer.isBuffer(decryptedTrack)); t.is(decryptedTrack.length, 8990301); @@ -195,14 +214,20 @@ if (process.env.CI) { test('DOWNLOAD TRACK1411 & ADD METADATA', async (t) => { const track = await api.getTrackInfo(SNG_ID); - const url = getTrackDownloadUrl(track, 9); + const urlGen = await getTrackDownloadUrl(track, 9); + const url = urlGen ? urlGen : ''; const {data} = await axios.get(url, {responseType: 'arraybuffer'}); t.truthy(data); t.true(Buffer.isBuffer(data)); t.is(data.length, 25418289); - const decryptedTrack = decryptDownload(data, track.SNG_ID); + let decryptedTrack: Buffer; + if (trackIsEncrypted(url)) { + decryptedTrack = decryptDownload(data, track.SNG_ID); + } else { + decryptedTrack = data; + } t.true(Buffer.isBuffer(decryptedTrack)); t.is(data.length, 25418289); diff --git a/src/lib/decrypt.ts b/src/lib/decrypt.ts index 57cc4cc..bca37a9 100644 --- a/src/lib/decrypt.ts +++ b/src/lib/decrypt.ts @@ -7,7 +7,7 @@ const md5 = (data: string, type: crypto.Encoding = 'ascii') => { return md5sum.digest('hex'); }; -const getSongFileName = ({MD5_ORIGIN, SNG_ID, MEDIA_VERSION}: trackType, quality: number) => { +export const getSongFileName = ({MD5_ORIGIN, SNG_ID, MEDIA_VERSION}: trackType, quality: number) => { const step1 = [MD5_ORIGIN, quality, SNG_ID, MEDIA_VERSION].join('¤'); let step2 = md5(step1) + '¤' + step1 + '¤'; @@ -68,12 +68,6 @@ export const decryptDownload = (source: Buffer, trackId: string) => { return destBuffer; }; -/** - * @param track Track info json returned from `getTrackInfo` - * @param quality 1 = 128kbps, 3 = 320kbps and 9 = flac (around 1411kbps) - */ -export const getTrackDownloadUrl = (track: trackType, quality: number) => { - const cdn = track.MD5_ORIGIN[0]; // cdn destination - const filename = getSongFileName(track, quality); // encrypted file name - return `http://e-cdn-proxy-${cdn}.deezer.com/api/1/${filename}`; -}; +export const trackIsEncrypted = (url: string) => { + return url.includes("/mobile/") || url.includes("/media/"); +} diff --git a/src/lib/get-url.ts b/src/lib/get-url.ts index b82a8ab..e69db81 100644 --- a/src/lib/get-url.ts +++ b/src/lib/get-url.ts @@ -1,52 +1,49 @@ import axios from 'axios'; +import {getSongFileName} from '../lib/decrypt'; +import instance from '../lib/request'; +import type {trackType} from '../types' -interface getUrlType { - data: [ - { - media: [ - { - cipher: { - type: 'BF_CBC_STRIPE'; - }; - exp: number; - format: string; - media_type: 'FULL'; - nbf: number; - sources: [ - { - provider: 'ec'; - url: string; - }, - { - provider: 'ak'; - url: string; - }, - ]; - }, - ]; - }, - ]; +interface userData { + license_token: string, + can_stream_lossless: boolean, + can_stream_hq: boolean, } -let license_token: string | null = null; +class WrongLicense extends Error { + constructor(format: string) { + super() + this.name = "WrongLicense" + this.message = `Your account can't stream ${format} tracks` + } +} -const getLicenseToken = async (): Promise => { - const {data} = await axios.get('https://www.deezer.com/ajax/gw-light.php', { +let user_data: userData | null = null; + +const dzAuthenticate = async (): Promise => { + const {data} = await instance.get('https://www.deezer.com/ajax/gw-light.php', { params: { method: 'deezer.getUserData', api_version: '1.0', - api_token: '', - cid: Math.floor(1e9 * Math.random()), + api_token: 'null', }, }); - license_token = data.results.USER.OPTIONS.license_token; - return data.results.USER.OPTIONS.license_token; + user_data = { + license_token: data.results.USER.OPTIONS.license_token, + can_stream_lossless: data.results.USER.OPTIONS.web_lossless || data.results.USER.OPTIONS.mobile_loseless, + can_stream_hq: data.results.USER.OPTIONS.web_hq || data.results.USER.OPTIONS.mobile_hq, + } + return user_data; }; -export const getTrackUrlFromServer = async (track_token: string, format: string): Promise => { - const token = license_token ? license_token : await getLicenseToken(); - const {data} = await axios.post('https://media.deezer.com/v1/get_url', { - license_token: token, +const getTrackUrlFromServer = async (track_token: string, format: string): Promise => { + const user = user_data ? user_data : await dzAuthenticate(); + if ( + format === "FLAC" && !user.can_stream_lossless || + format === "MP3_320" && !user.can_stream_hq + ) throw new WrongLicense(format); + + const {data} = await instance.post('https://media.deezer.com/v1/get_url', { + license_token: user.license_token, media: [ { type: 'FULL', @@ -56,5 +53,66 @@ export const getTrackUrlFromServer = async (track_token: string, format: string) track_tokens: [track_token], }); - return data; + if (data.data.length) { + if (data.data[0].errors) { + throw new Error(`Deezer error: ${JSON.stringify(data)}`) + } + return data.data[0].media.length > 0 ? data.data[0].media[0].sources[0].url : null; + } + return null; }; + +/** + * @param track Track info json returned from `getTrackInfo` + * @param quality 1 = 128kbps, 3 = 320kbps and 9 = flac (around 1411kbps) + */ +export const getTrackDownloadUrl = async (track: trackType, quality: number): Promise => { + let url: string | null = null; + let wrongLicense; + let formatName; + if (quality === 9) { + formatName = 'FLAC'; + } else if (quality === 3) { + formatName = 'MP3_320'; + } else { + formatName = 'MP3_128'; + } + + // Get URL with the official API + try { + url = await getTrackUrlFromServer(track.TRACK_TOKEN, formatName) + if (url) { + let isUrlOk = await testUrl(url) + if (isUrlOk) return url; + } + url = null; + } catch (e) { + if (e instanceof WrongLicense) { + wrongLicense = true; + } else { + throw e; + } + url = null; + } + // Fallback to the old method + if (!url) { + const cdn = track.MD5_ORIGIN[0]; // cdn destination + const filename = getSongFileName(track, quality); // encrypted file name + url = `https://e-cdns-proxy-${cdn}.dzcdn.net/mobile/1/${filename}`; + let isUrlOk = await testUrl(url) + if (isUrlOk) return url + url = null + } + if (wrongLicense) throw new Error(`Your account can't stream ${formatName} tracks`); + return url +} + +const testUrl = async (url: string): Promise => { + try { + let response = await axios.head(url); + if (Number(response.headers["content-length"]) > 0) return true; + return false; + } catch (e) { + return false; + } +}