├── .eslintignore ├── src ├── api │ ├── index.ts │ ├── cache.ts │ ├── request.ts │ └── api.ts ├── converter │ ├── index.ts │ ├── deezer.ts │ ├── youtube.ts │ ├── spotify.ts │ ├── parse.ts │ └── tidal.ts ├── index.ts ├── types │ ├── radio.ts │ ├── index.ts │ ├── user.ts │ ├── profile.ts │ ├── artist.ts │ ├── channel.ts │ ├── playlist.ts │ ├── search.ts │ ├── album.ts │ ├── playlist-channel.ts │ ├── show.ts │ └── tracks.ts ├── metadata-writer │ ├── getTrackLyrics.ts │ ├── abumCover.ts │ ├── musixmatchLyrics.ts │ ├── index.ts │ ├── flacmetata.ts │ ├── id3.ts │ └── useragents.ts └── lib │ ├── decrypt.ts │ ├── request.ts │ ├── fast-lru.ts │ ├── get-url.ts │ └── metaflac-js.ts ├── .prettierrc.js ├── tsconfig.json ├── .github └── workflows │ └── test.yml ├── docs ├── faq.md ├── parse.md ├── tidal.md └── spotify.md ├── .eslintrc.js ├── __tests__ ├── converter │ ├── deezer.ts │ ├── youtube.ts │ ├── spotify.ts │ ├── tidal.ts │ └── parse.ts └── api.ts ├── LICENSE ├── package.json ├── .gitignore └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api'; 2 | export * from './request'; 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | bracketSpacing: false, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | endOfLine: 'lf', 7 | }; 8 | -------------------------------------------------------------------------------- /src/converter/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parse'; 2 | export * from './deezer'; 3 | export * as tidal from './tidal'; 4 | export * as spotify from './spotify'; 5 | export * as youtube from './youtube'; 6 | -------------------------------------------------------------------------------- /src/api/cache.ts: -------------------------------------------------------------------------------- 1 | import FastLRU from '../lib/fast-lru'; 2 | 3 | // Expire cache in 60 minutes 4 | const lru = new FastLRU({ 5 | maxSize: 1000, 6 | ttl: 60 * 60000, 7 | }); 8 | 9 | export default lru; 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {initDeezerApi} from './lib/request'; 2 | export * from './api'; 3 | export * from './converter'; 4 | export * from './lib/decrypt'; 5 | export * from './lib/get-url'; 6 | export * from './metadata-writer'; 7 | -------------------------------------------------------------------------------- /src/types/radio.ts: -------------------------------------------------------------------------------- 1 | export interface radioType { 2 | RADIO_ID: string; // '39081' 3 | RADIO_PICTURE: string; // '69518b4d1cf2942171e90546ae1ad81a' 4 | TITLE: string; // 'Axe Forro' 5 | TAGS: string[]; 6 | __TYPE__: 'radio'; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './album'; 2 | export * from './artist'; 3 | export * from './show'; 4 | export * from './playlist'; 5 | export * from './playlist-channel'; 6 | export * from './channel'; 7 | export * from './profile'; 8 | export * from './search'; 9 | export * from './tracks'; 10 | export * from './user'; 11 | -------------------------------------------------------------------------------- /src/types/user.ts: -------------------------------------------------------------------------------- 1 | export interface userType { 2 | USER_ID: string; 3 | EMAIL: string; 4 | FIRSTNAME: string; 5 | LASTNAME: string; 6 | BIRTHDAY: string; 7 | BLOG_NAME: string; 8 | SEX: string; 9 | ADDRESS?: string; 10 | CITY?: string; 11 | ZIP?: string; 12 | COUNTRY: string; 13 | LANG: string; 14 | PHONE?: string; 15 | __TYPE__: 'user'; 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "commonjs", 5 | "target": "es2019", // Node.js 12 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "declaration": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [12.x, 14.x, 16.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: yarn install 20 | - run: yarn test 21 | env: 22 | CI: true 23 | HIFI_ARL: ${{ secrets.HIFI_ARL }} 24 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | ## Frequently Asked Questions (FAQ) 2 | 3 | - How to get arl cookie? 4 | 5 | > Open Google Chrome in PC 6 | > 7 | > - Go to www.deezer.com and log into your account 8 | > - After logging in press F12 to open up Developer Tools 9 | > - Go under the Application tab (if you don't see it click the double arrow) 10 | > - Open the cookie dropdown 11 | > - Select www.deezer.com 12 | > - Find the arl cookie (It should be 192 chars long) 13 | > - That's your ARL, now you can use it in the app 14 | 15 | Here is a sample gif tutorial. 16 | 17 | ![](https://media.giphy.com/media/igsGY1z84qpAPGjj1r/giphy.gif) 18 | -------------------------------------------------------------------------------- /docs/parse.md: -------------------------------------------------------------------------------- 1 | ## Parse 2 | 3 | Parse Deezer, Spotify and Tidal URLs to downloadable data. 4 | 5 | ## Usage 6 | 7 | `parseInfo` parses information as json data. Throws `Error`, make sure to catch error on your side. 8 | 9 | ```ts 10 | import {parseInfo} from 'd-fi-core'; 11 | 12 | // Get link information 13 | const info = await parseInfo(url); 14 | console.log(info); 15 | ``` 16 | 17 | Please take a look at [`src/converter/parse.ts`](https://github.com/d-fi/d-fi-core/blob/master/src/converter/parse.ts) and [`__tests__/converter/parse.ts`](https://github.com/d-fi/d-fi-core/blob/master/__tests__/converter/parse.ts) to understand more. 18 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | }, 5 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 6 | parser: '@typescript-eslint/parser', 7 | parserOptions: { 8 | ecmaVersion: 8, 9 | }, 10 | plugins: ['@typescript-eslint', 'prettier'], 11 | rules: { 12 | 'prettier/prettier': ['error'], 13 | '@typescript-eslint/explicit-module-boundary-types': 'off', 14 | '@typescript-eslint/no-explicit-any': 'off', 15 | '@typescript-eslint/no-unused-vars': 'off', 16 | '@typescript-eslint/ban-ts-comment': 'off', 17 | 'no-empty': 'off', 18 | 'no-case-declarations': 'off', 19 | 'no-useless-escape': 'off', 20 | 'no-irregular-whitespace': 'off', 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/metadata-writer/getTrackLyrics.ts: -------------------------------------------------------------------------------- 1 | import {getLyricsMusixmatch} from './musixmatchLyrics'; 2 | import {getLyrics} from '../api'; 3 | import type {lyricsType, trackType} from '../types'; 4 | 5 | const getTrackLyricsWeb = async (track: trackType): Promise => { 6 | try { 7 | const LYRICS_TEXT = await getLyricsMusixmatch(`${track.ART_NAME} - ${track.SNG_TITLE}`); 8 | return {LYRICS_TEXT}; 9 | } catch (err) { 10 | return null; 11 | } 12 | }; 13 | 14 | export const getTrackLyrics = async (track: trackType): Promise => { 15 | if (track.LYRICS_ID > 0) { 16 | try { 17 | return await getLyrics(track.SNG_ID); 18 | } catch (err) { 19 | return await getTrackLyricsWeb(track); 20 | } 21 | } 22 | 23 | return await getTrackLyricsWeb(track); 24 | }; 25 | -------------------------------------------------------------------------------- /src/types/profile.ts: -------------------------------------------------------------------------------- 1 | import type {albumTracksType} from './album'; 2 | 3 | export interface profileTypeMinimal { 4 | USER_ID: string; 5 | FIRSTNAME: string; 6 | LASTNAME: string; 7 | BLOG_NAME: string; 8 | USER_PICTURE?: string; 9 | IS_FOLLOW: boolean; 10 | __TYPE__: 'user'; 11 | } 12 | 13 | export interface profileType { 14 | IS_FOLLOW: boolean; 15 | NB_ARTISTS: number; 16 | NB_FOLLOWERS: number; 17 | NB_FOLLOWINGS: number; 18 | NB_MP3S: number; 19 | TOP_TRACK: albumTracksType; 20 | USER: { 21 | USER_ID: string; // '2064440442' 22 | BLOG_NAME: string; // 'sayem314' 23 | SEX?: string; // '' 24 | COUNTRY: string; // 'BD' 25 | USER_PICTURE?: string; // '' 26 | COUNTRY_NAME: string; // 'Bangladesh' 27 | PRIVATE: boolean; 28 | DISPLAY_NAME: string; // 'sayem314' 29 | __TYPE__: 'user'; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /__tests__/converter/deezer.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as api from '../../src'; 3 | 4 | // Harder, Better, Faster, Stronger by Daft Punk 5 | const SNG_TITLE = 'Harder, Better, Faster, Stronger'; 6 | const ISRC = 'GBDUW0000059'; 7 | 8 | // Discovery by Daft Punk 9 | const ALB_TITLE = 'Discovery'; 10 | const UPC = '724384960650'; 11 | 12 | test.serial('GET TRACK ISRC', async (t) => { 13 | const response = await api.isrc2deezer(SNG_TITLE, ISRC); 14 | 15 | t.is(response.SNG_TITLE, SNG_TITLE); 16 | t.is(response.ISRC, ISRC); 17 | t.is(response.MD5_ORIGIN, '51afcde9f56a132096c0496cc95eb24b'); 18 | t.is(response.__TYPE__, 'song'); 19 | }); 20 | 21 | test.serial('GET ALBUM UPC', async (t) => { 22 | const [album, tracks] = await api.upc2deezer(ALB_TITLE, UPC); 23 | 24 | t.is(album.ALB_TITLE, ALB_TITLE); 25 | t.is(album.UPC, UPC); 26 | t.is(album.__TYPE__, 'album'); 27 | 28 | t.is(Number(album.NUMBER_TRACK), tracks.length); 29 | }); 30 | -------------------------------------------------------------------------------- /__tests__/converter/youtube.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {youtube} from '../../src'; 3 | 4 | // The Weeknd - I Feel It Coming ft. Daft Punk (Official Video) 5 | const VALID_VIDEO = 'qFLhGq0060w'; 6 | 7 | // youtube-dl test video "'/\ä↭𝕐 8 | const INVALID_VIDEO = 'BaW_jenozKc'; 9 | 10 | if (!process.env.CI) { 11 | test('GET TRACK INFO', async (t) => { 12 | const response = await youtube.track2deezer(VALID_VIDEO); 13 | 14 | t.is(response.SNG_ID, '136889434'); 15 | t.is(response.SNG_TITLE, 'I Feel It Coming'); 16 | t.is(response.ALB_TITLE, 'Starboy'); 17 | t.is(response.ISRC, 'USUG11601012'); 18 | }); 19 | 20 | test('FAIL INVALID VIDEO', async (t) => { 21 | try { 22 | await youtube.track2deezer(INVALID_VIDEO); 23 | t.fail(); 24 | } catch (err: any) { 25 | t.true(err.message.includes('No track found for youtube video ' + INVALID_VIDEO)); 26 | } 27 | }); 28 | } else { 29 | test('SKIP YOUTUBE ON CI', async (t) => { 30 | t.pass(); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/types/artist.ts: -------------------------------------------------------------------------------- 1 | interface localesType { 2 | [key: string]: { 3 | name: string; 4 | }; 5 | } 6 | 7 | export interface artistType { 8 | ART_ID: string; // '27' 9 | ROLE_ID: string; // '0' 10 | ARTISTS_SONGS_ORDER: string; // '0' 11 | ART_NAME: string; // 'Daft Punk' 12 | ARTIST_IS_DUMMY: boolean; // false 13 | ART_PICTURE: string; // 'f2bc007e9133c946ac3c3907ddc5d2ea' 14 | RANK: string; // '836071' 15 | LOCALES?: localesType; 16 | __TYPE__: 'artist'; 17 | } 18 | 19 | export interface artistInfoTypeMinimal { 20 | ART_ID: string; 21 | ART_NAME: string; 22 | ART_PICTURE: string; 23 | NB_FAN: number; 24 | LOCALES: []; 25 | ARTIST_IS_DUMMY: boolean; 26 | __TYPE__: 'artist'; 27 | } 28 | 29 | export interface artistInfoType { 30 | ART_ID: string; // "293585", 31 | ART_NAME: string; // "Avicii", 32 | ARTIST_IS_DUMMY: boolean; 33 | ART_PICTURE: string; // "82e214b0cb39316f4a12a082fded54f6", 34 | FACEBOOK?: string; // "https://www.facebook.com/avicii?fref=ts", 35 | NB_FAN: number; // 7140516, 36 | TWITTER?: string; // "https://twitter.com/Avicii", 37 | __TYPE__: 'artist'; 38 | } 39 | -------------------------------------------------------------------------------- /src/types/channel.ts: -------------------------------------------------------------------------------- 1 | interface picturesType { 2 | md5: string; // '8e211af480caea6fc4fa5378c1757e16' 3 | type: string; // 'misc' 4 | } 5 | 6 | interface dataType { 7 | type: string; // 'channel'; 8 | id: string; // 'ff7f8b9a-2cff-48e4-9228-7d4136ce4aa8'; 9 | name: string; // 'Asian music'; 10 | title: string; // 'Asian music'; 11 | logo: null | string; 12 | description: null | string; 13 | slug: string; // 'asian'; 14 | background_color: string; // '#3ABEA7'; 15 | pictures: picturesType[]; 16 | __TYPE__: 'channel'; 17 | } 18 | 19 | interface channelDataType { 20 | item_id: string; // 'item_type=channel,item_id=bab5f0dc-1eec-4ff8-a297-b23a10bd8d87,item_position=0' 21 | id: string; // 'bab5f0dc-1eec-4ff8-a297-b23a10bd8d87' 22 | type: string; // 'channel' 23 | data: dataType[]; 24 | target: string; //'/channels/booklovers' 25 | title: string; // 'For book lovers'; 26 | pictures: picturesType[]; 27 | weight: number; // 1 28 | background_color: string; // '#FFAE2E' 29 | } 30 | 31 | export interface channelSearchType { 32 | data: channelDataType[]; 33 | count: number; 34 | total: number; 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 sayem314 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/metadata-writer/abumCover.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import FastLRU from '../lib/fast-lru'; 3 | import type {trackType} from '../types'; 4 | 5 | type coverSize = 56 | 250 | 500 | 1000 | 1500 | 1800 | number; 6 | 7 | // expire cache in 30 minutes 8 | const lru = new FastLRU({ 9 | maxSize: 50, 10 | ttl: 30 * 60000, 11 | }); 12 | 13 | /** 14 | * 15 | * @param {Object} track track info json from deezer api 16 | * @param {Number} albumCoverSize in pixel, between 56-1800 17 | */ 18 | export const downloadAlbumCover = async (track: trackType, albumCoverSize: coverSize): Promise => { 19 | if (!track.ALB_PICTURE) { 20 | return null; 21 | } 22 | 23 | const cache = lru.get(track.ALB_PICTURE + albumCoverSize); 24 | if (cache) { 25 | return cache; 26 | } 27 | 28 | try { 29 | const url = `https://e-cdns-images.dzcdn.net/images/cover/${track.ALB_PICTURE}/${albumCoverSize}x${albumCoverSize}-000000-80-0-0.jpg`; 30 | const {data} = await axios.get(url, {responseType: 'arraybuffer'}); 31 | lru.set(track.ALB_PICTURE + albumCoverSize, data); 32 | return data; 33 | } catch (err) { 34 | return null; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /docs/tidal.md: -------------------------------------------------------------------------------- 1 | ## Tidal to Deezer 2 | 3 | `d-fi-core` exports Tidal api to easily convert tracks, albums, artists and playlist to deezer via matching ISRC and UPC code. 4 | 5 | ## Usage 6 | 7 | Here's a simple example. All method returns `Object` or throws `Error`. Make sure to catch error on your side. 8 | 9 | ```ts 10 | import {tidal} from 'd-fi-core'; 11 | 12 | // Convert single track to deezer 13 | const track = await tidal.track2deezer(song_id); 14 | console.log(track); 15 | 16 | // Convert album and tracks to deezer 17 | const [album, tracks] = await tidal.album2deezer(album_id); 18 | console.log(album); 19 | console.log(tracks); 20 | 21 | // Convert playlist and tracks to deezer 22 | const [playlist, tracks] = await tidal.playlist2Deezer(playlist_id); 23 | console.log(playlist); 24 | console.log(tracks); 25 | 26 | // Convert artist tracks to deezer (limited to 10 tracks) 27 | const tracks = await tidal.artist2Deezer(artist_id); 28 | console.log(tracks); 29 | ``` 30 | 31 | There are more methods available. Take a look at [`src/converter/tidal.ts`](https://github.com/d-fi/d-fi-core/blob/master/src/converter/tidal.ts) and [`__tests__/converter/tidal.ts`](https://github.com/d-fi/d-fi-core/blob/master/__tests__/converter/tidal.ts) to understand more. 32 | -------------------------------------------------------------------------------- /docs/spotify.md: -------------------------------------------------------------------------------- 1 | ## Spotify to Deezer 2 | 3 | `d-fi-core` exports Spotify api to easily convert tracks, albums, artists and playlist to deezer via matching ISRC and UPC code. 4 | 5 | ## Usage 6 | 7 | Here's a simple example. All method returns `Object` or throws `Error`. Make sure to catch error on your side. 8 | 9 | ```ts 10 | import {spotify} from 'd-fi-core'; 11 | 12 | // Set token first to bypass some limits 13 | await spotify.setSpotifyAnonymousToken(); 14 | 15 | // Convert single track to deezer 16 | const track = await spotify.track2deezer(song_id); 17 | console.log(track); 18 | 19 | // Convert album and tracks to deezer 20 | const [album, tracks] = await spotify.album2deezer(album_id); 21 | console.log(album); 22 | console.log(tracks); 23 | 24 | // Convert playlist and tracks to deezer 25 | const [playlist, tracks] = await spotify.playlist2Deezer(playlist_id); 26 | console.log(playlist); 27 | console.log(tracks); 28 | 29 | // Convert artist tracks to deezer (limited to 10 tracks) 30 | const tracks = await spotify.artist2Deezer(artist_id); 31 | console.log(tracks); 32 | ``` 33 | 34 | Take a look at [`src/converter/spotify.ts`](https://github.com/d-fi/d-fi-core/blob/master/src/converter/spotify.ts) and [`__tests__/converter/spotify.ts`](https://github.com/d-fi/d-fi-core/blob/master/__tests__/converter/spotify.ts) to understand more. 35 | -------------------------------------------------------------------------------- /src/metadata-writer/musixmatchLyrics.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import {parse} from 'node-html-parser'; 3 | import {randomUseragent} from './useragents'; 4 | 5 | const baseUrl = 'https://musixmatch.com'; 6 | 7 | const getUrlMusixmatch = async (query: string) => { 8 | const {data} = await axios.get(`${baseUrl}/search/${encodeURI(query)}/tracks`, { 9 | headers: { 10 | 'User-Agent': randomUseragent(), 11 | referer: 'https://l.facebook.com/', 12 | }, 13 | }); 14 | 15 | const childNode = parse(data).querySelector('h2')?.childNodes.at(0); 16 | const url: string | undefined = (childNode as any)?.attributes.href.replace('/add', ''); 17 | if (url && url.includes('/lyrics/')) { 18 | return url.startsWith('/lyrics/') ? baseUrl + url : url; 19 | } 20 | 21 | throw new Error('No song found!'); 22 | }; 23 | 24 | export const getLyricsMusixmatch = async (query: string): Promise => { 25 | const url = await getUrlMusixmatch(query); 26 | const {data} = await axios.get(url, { 27 | headers: { 28 | 'User-Agent': randomUseragent(), 29 | referer: baseUrl + '/', 30 | }, 31 | }); 32 | 33 | let lyrics = data.match(/("body":".*","language")/)[0]; 34 | lyrics = lyrics.replace('"body":"', '').replace('","language"', ''); 35 | 36 | return lyrics.split('\\n').join('\n'); 37 | }; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d-fi-core", 3 | "version": "1.3.5", 4 | "description": "Core module for d-fi", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "lint": "eslint .", 8 | "prepare": "eslint . && tsc", 9 | "prebuild": "eslint .", 10 | "build": "tsc", 11 | "test": "ava" 12 | }, 13 | "engines": { 14 | "node": ">=12" 15 | }, 16 | "repository": "https://github.com/d-fi/d-fi-core", 17 | "author": "Sayem Chowdhury", 18 | "license": "MIT", 19 | "dependencies": { 20 | "axios": "^0.26.1", 21 | "browser-id3-writer": "^4.4.0", 22 | "delay": "^5.0.0", 23 | "node-html-parser": "^5.3.3", 24 | "p-queue": "^6.6.2", 25 | "spotify-uri": "^2.2.0", 26 | "spotify-web-api-node": "^5.0.2" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^17.0.23", 30 | "@types/spotify-web-api-node": "^5.0.3", 31 | "@typescript-eslint/eslint-plugin": "^5.16.0", 32 | "@typescript-eslint/parser": "^5.16.0", 33 | "ava": "^4.1.0", 34 | "eslint": "^8.11.0", 35 | "eslint-plugin-prettier": "^4.0.0", 36 | "prettier": "^2.6.0", 37 | "ts-node": "^10.7.0", 38 | "typescript": "^4.6.3" 39 | }, 40 | "ava": { 41 | "extensions": [ 42 | "ts" 43 | ], 44 | "files": [ 45 | "!dist" 46 | ], 47 | "require": [ 48 | "ts-node/register" 49 | ], 50 | "timeout": "2m", 51 | "verbose": true 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /__tests__/converter/spotify.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {spotify} from '../../src'; 3 | 4 | const SNG_ID = '7FIWs0pqAYbP91WWM0vlTQ'; 5 | const ALB_ID = '6t7956yu5zYf5A829XRiHC'; 6 | const PLAYLIST_TITLE = 'This Is Eminem'; 7 | const PLAYLIST_ID = '37i9dQZF1DX1clOuib1KtQ'; 8 | const ARTIST_ID = '7dGJo4pcD2V6oG8kP0tJRR'; 9 | 10 | test.serial('SET ANONYMOUS TOKEN', async (t) => { 11 | const response = await spotify.setSpotifyAnonymousToken(); 12 | 13 | t.truthy(response.accessToken, 'string'); 14 | t.true(response.isAnonymous); 15 | }); 16 | 17 | test('GET TRACK INFO', async (t) => { 18 | const track = await spotify.track2deezer(SNG_ID); 19 | 20 | t.is(track.SNG_ID, '854914322'); 21 | t.is(track.ISRC, 'USUM72000788'); 22 | t.is(track.MD5_ORIGIN, '6f542518431052368a1c48d14c10d37e'); 23 | t.is(track.__TYPE__, 'song'); 24 | }); 25 | 26 | test('GET ALBUM INFO', async (t) => { 27 | const [album, tracks] = await spotify.album2deezer(ALB_ID); 28 | 29 | t.is(album.ALB_ID, '125748'); 30 | t.is(album.UPC, '606949062927'); 31 | t.is(album.__TYPE__, 'album'); 32 | t.is(tracks.length, 18); 33 | }); 34 | 35 | test('GET ARTIST TO DEEZER TRACKS', async (t) => { 36 | const tracks = await spotify.artist2Deezer(ARTIST_ID); 37 | 38 | t.is(tracks.length, 10); 39 | }); 40 | 41 | if (process.env.CI) { 42 | test('GET PLAYLIST TO DEEZER TRACKS', async (t) => { 43 | const [playlist, tracks] = await spotify.playlist2Deezer(PLAYLIST_ID); 44 | 45 | t.is(playlist.TITLE, PLAYLIST_TITLE); 46 | t.true(tracks.length > 50); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /src/metadata-writer/index.ts: -------------------------------------------------------------------------------- 1 | import {downloadAlbumCover} from './abumCover'; 2 | import {getTrackLyrics} from './getTrackLyrics'; 3 | import {writeMetadataMp3} from './id3'; 4 | import {writeMetadataFlac} from './flacmetata'; 5 | import {getAlbumInfoPublicApi} from '../api'; 6 | import type {trackType} from '../types'; 7 | 8 | const albumInfo = async (track: trackType) => { 9 | try { 10 | return await getAlbumInfoPublicApi(track.ALB_ID); 11 | } catch (err) { 12 | return null; 13 | } 14 | }; 15 | 16 | /** 17 | * Add metdata to the mp3 18 | * @param {Buffer} trackBuffer decrypted track buffer 19 | * @param {Object} track json containing track infos 20 | * @param {Number} albumCoverSize album cover size in pixel 21 | */ 22 | export const addTrackTags = async (trackBuffer: Buffer, track: trackType, albumCoverSize = 1000): Promise => { 23 | const [cover, lyrics, album] = await Promise.all([ 24 | downloadAlbumCover(track, albumCoverSize), 25 | getTrackLyrics(track), 26 | albumInfo(track), 27 | ]); 28 | 29 | if (lyrics) { 30 | track.LYRICS = lyrics; 31 | } 32 | 33 | if (track.ART_NAME.toLowerCase() === 'various') { 34 | track.ART_NAME = 'Various Artists'; 35 | } 36 | if (album && album.record_type) { 37 | album.record_type = 38 | album.record_type === 'ep' ? 'EP' : album.record_type.charAt(0).toUpperCase() + album.record_type.slice(1); 39 | } 40 | 41 | const isFlac = trackBuffer.slice(0, 4).toString('ascii') === 'fLaC'; 42 | return isFlac 43 | ? writeMetadataFlac(trackBuffer, track, album, albumCoverSize, cover) 44 | : writeMetadataMp3(trackBuffer, track, album, cover); 45 | }; 46 | -------------------------------------------------------------------------------- /src/types/playlist.ts: -------------------------------------------------------------------------------- 1 | import type {trackType} from './tracks'; 2 | 3 | export interface playlistInfoMinimal { 4 | PLAYLIST_ID: string; 5 | PARENT_PLAYLIST_ID: string; 6 | TYPE: string; // '0' 7 | TITLE: string; 8 | PARENT_USER_ID: string; 9 | PARENT_USERNAME: string; 10 | PARENT_USER_PICTURE?: string; 11 | STATUS: string; // 0 12 | PLAYLIST_PICTURE: string; 13 | PICTURE_TYPE: string; // 'playlist' 14 | NB_SONG: number; // 180 15 | HAS_ARTIST_LINKED: boolean; 16 | DATE_ADD: string; // '2021-01-29 20:54:13' 17 | DATE_MOD: string; // '2021-02-01 05:52:40' 18 | __TYPE__: 'playlist'; 19 | } 20 | 21 | export interface playlistInfo { 22 | PLAYLIST_ID: string; // '4523119944' 23 | DESCRIPTION?: string; // '' 24 | PARENT_USERNAME: string; // 'sayem314' 25 | PARENT_USER_PICTURE?: string; // '' 26 | PARENT_USER_ID: string; // '2064440442' 27 | PICTURE_TYPE: string; // 'cover' 28 | PLAYLIST_PICTURE: string; // 'e206dafb59a3d378d7ffacc989bc4e35' 29 | TITLE: string; // 'wtf playlist ' 30 | TYPE: string; // '0' 31 | STATUS: string; // 0 32 | USER_ID: string; // '2064440442' 33 | DATE_ADD: string; // '2018-09-08 19:13:57' 34 | DATE_MOD: string; //'2018-09-08 19:14:11' 35 | DATE_CREATE: string; // '2018-05-31 00:01:05' 36 | NB_SONG: number; // 3 37 | NB_FAN: number; // 0 38 | CHECKSUM: string; // 'c185d123834444e3c8869e235dd6f0a6' 39 | HAS_ARTIST_LINKED: boolean; 40 | IS_SPONSORED: boolean; 41 | IS_EDITO: boolean; 42 | __TYPE__: 'playlist'; 43 | } 44 | 45 | export interface playlistTracksType { 46 | data: trackType[]; 47 | count: number; 48 | total: number; 49 | filtered_count: number; 50 | filtered_items?: number[]; 51 | next?: number; 52 | } 53 | -------------------------------------------------------------------------------- /src/converter/deezer.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import delay from 'delay'; 3 | import {getAlbumInfo, getAlbumTracks, getTrackInfo} from '../api'; 4 | import type {albumType, trackType} from '../types'; 5 | 6 | const instance = axios.create({baseURL: 'https://api.deezer.com/', timeout: 15000}); 7 | 8 | export const isrc2deezer = async (name: string, isrc?: string) => { 9 | if (!isrc) { 10 | throw new Error('ISRC code not found for ' + name); 11 | } 12 | 13 | const {data} = await instance.get('track/isrc:' + isrc); 14 | if (data.error) { 15 | throw new Error(`No match on deezer for ${name} (ISRC: ${isrc})`); 16 | } 17 | 18 | return await getTrackInfo(data.id); 19 | }; 20 | 21 | export const upc2deezer = async (name: string, upc?: string): Promise<[albumType, trackType[]]> => { 22 | if (!upc) { 23 | throw new Error('UPC code not found for ' + name); 24 | } else if (upc.length > 12 && upc.startsWith('0')) { 25 | upc = upc.slice(-12); 26 | } 27 | 28 | const {data} = await instance.get('album/upc:' + upc); 29 | if (data.error) { 30 | throw new Error(`No match on deezer for ${name} (UPC: ${upc})`); 31 | } 32 | 33 | const albumInfo = await getAlbumInfo(data.id); 34 | const albumTracks = await getAlbumTracks(data.id); 35 | return [albumInfo, albumTracks.data]; 36 | }; 37 | 38 | // Retry on rate limit error 39 | instance.interceptors.response.use(async (response: Record) => { 40 | if (response.data.error && Object.keys(response.data.error).length > 0) { 41 | if (response.data.error.code === 4) { 42 | await delay.range(1000, 1500); 43 | return await instance(response.config); 44 | } 45 | } 46 | 47 | return response; 48 | }); 49 | -------------------------------------------------------------------------------- /src/lib/decrypt.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import type {trackType} from '../types'; 3 | 4 | const md5 = (data: string, type: crypto.Encoding = 'ascii') => { 5 | const md5sum = crypto.createHash('md5'); 6 | md5sum.update(data.toString(), type); 7 | return md5sum.digest('hex'); 8 | }; 9 | 10 | export const getSongFileName = ({MD5_ORIGIN, SNG_ID, MEDIA_VERSION}: trackType, quality: number) => { 11 | const step1 = [MD5_ORIGIN, quality, SNG_ID, MEDIA_VERSION].join('¤'); 12 | 13 | let step2 = md5(step1) + '¤' + step1 + '¤'; 14 | while (step2.length % 16 > 0) step2 += ' '; 15 | 16 | return crypto.createCipheriv('aes-128-ecb', 'jo6aey6haid2Teih', '').update(step2, 'ascii', 'hex'); 17 | }; 18 | 19 | const getBlowfishKey = (trackId: string) => { 20 | const SECRET = 'g4el58wc' + '0zvf9na1'; 21 | const idMd5 = md5(trackId); 22 | let bfKey = ''; 23 | for (let i = 0; i < 16; i++) { 24 | bfKey += String.fromCharCode(idMd5.charCodeAt(i) ^ idMd5.charCodeAt(i + 16) ^ SECRET.charCodeAt(i)); 25 | } 26 | return bfKey; 27 | }; 28 | 29 | const decryptChunk = (chunk: Buffer, blowFishKey: string) => { 30 | const cipher = crypto.createDecipheriv('bf-cbc', blowFishKey, Buffer.from([0, 1, 2, 3, 4, 5, 6, 7])); 31 | cipher.setAutoPadding(false); 32 | return cipher.update(chunk as any, 'binary', 'binary') + cipher.final(); 33 | }; 34 | 35 | /** 36 | * 37 | * @param source Downloaded song from `getTrackDownloadUrl` 38 | * @param trackId Song ID as string 39 | */ 40 | export const decryptDownload = (source: Buffer, trackId: string) => { 41 | // let part_size = 0x1800; 42 | let chunk_size = 2048; 43 | const blowFishKey = getBlowfishKey(trackId); 44 | let i = 0; 45 | let position = 0; 46 | 47 | const destBuffer = Buffer.alloc(source.length); 48 | destBuffer.fill(0); 49 | 50 | while (position < source.length) { 51 | const chunk = Buffer.alloc(chunk_size); 52 | const size = source.length - position; 53 | chunk_size = size >= 2048 ? 2048 : size; 54 | 55 | let chunkString; 56 | chunk.fill(0); 57 | source.copy(chunk, 0, position, position + chunk_size); 58 | if (i % 3 > 0 || chunk_size < 2048) chunkString = chunk.toString('binary'); 59 | else chunkString = decryptChunk(chunk, blowFishKey); 60 | 61 | destBuffer.write(chunkString, position, chunkString.length, 'binary'); 62 | position += chunk_size; 63 | i++; 64 | } 65 | 66 | return destBuffer; 67 | }; 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | -------------------------------------------------------------------------------- /src/types/search.ts: -------------------------------------------------------------------------------- 1 | import type {albumType, albumTypeMinimal} from './album'; 2 | import type {artistInfoTypeMinimal, artistType} from './artist'; 3 | import type {playlistInfo, playlistInfoMinimal} from './playlist'; 4 | import type {trackType} from './tracks'; 5 | import type {profileTypeMinimal} from './profile'; 6 | import type {channelSearchType} from './channel'; 7 | import type {radioType} from './radio'; 8 | import type {showEpisodeType} from './show'; 9 | 10 | interface searchTypeCommon { 11 | count: number; 12 | total: number; 13 | filtered_count: number; 14 | filtered_items: number[]; 15 | next: number; 16 | } 17 | 18 | interface albumSearchType extends searchTypeCommon { 19 | data: albumTypeMinimal[]; 20 | } 21 | 22 | interface artistSearchType extends searchTypeCommon { 23 | data: artistInfoTypeMinimal[]; 24 | } 25 | 26 | interface playlistSearchType extends searchTypeCommon { 27 | data: playlistInfoMinimal[]; 28 | } 29 | 30 | interface trackSearchType extends searchTypeCommon { 31 | data: trackType[]; 32 | } 33 | 34 | interface profileSearchType extends searchTypeCommon { 35 | data: profileTypeMinimal[]; 36 | } 37 | 38 | interface radioSearchType extends searchTypeCommon { 39 | data: radioType[]; 40 | } 41 | 42 | interface liveSearchType extends searchTypeCommon { 43 | data: unknown[]; 44 | } 45 | 46 | interface showSearchType extends searchTypeCommon { 47 | data: showEpisodeType[]; 48 | } 49 | 50 | export interface discographyType { 51 | data: albumType[]; 52 | count: number; // 109, 53 | total: number; // 109, 54 | cache_version: number; // 2, 55 | filtered_count: number; // 0, 56 | art_id: number; // 1424821, 57 | start: number; // 0, 58 | nb: number; // 500 59 | } 60 | 61 | export interface searchType { 62 | QUERY: string; //; 63 | FUZZINNESS: boolean; 64 | AUTOCORRECT: boolean; 65 | TOP_RESULT: [albumType | artistType | trackType | playlistInfo | artistType | unknown] | []; 66 | ORDER: [ 67 | 'TOP_RESULT', 68 | 'TRACK', 69 | 'PLAYLIST', 70 | 'ALBUM', 71 | 'ARTIST', 72 | 'LIVESTREAM', 73 | 'EPISODE', 74 | 'SHOW', 75 | 'CHANNEL', 76 | 'RADIO', 77 | 'USER', 78 | 'LYRICS', 79 | ]; 80 | ALBUM: albumSearchType; 81 | ARTIST: artistSearchType; 82 | TRACK: trackSearchType; 83 | PLAYLIST: playlistSearchType; 84 | RADIO: radioSearchType; 85 | SHOW: showSearchType; 86 | USER: profileSearchType; 87 | LIVESTREAM: liveSearchType; 88 | CHANNEL: channelSearchType; 89 | } 90 | -------------------------------------------------------------------------------- /src/converter/youtube.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import {parse} from 'node-html-parser'; 3 | import {searchAlternative, searchMusic} from '../api'; 4 | 5 | const getTrack = async (id: string) => { 6 | const response = await axios.get(`https://www.youtube.com/watch?v=${id}&hl=en`); 7 | const script = parse(response.data) 8 | .querySelectorAll('script') 9 | .find((script) => script.childNodes.find((node) => node.rawText.includes('responseText'))); 10 | 11 | if (script) { 12 | const info = script.text.split('= '); 13 | info.shift(); 14 | if (info) { 15 | let jsonData = info.join('= ').trim(); 16 | if (jsonData.endsWith(';')) { 17 | jsonData = jsonData.slice(0, -1); 18 | } 19 | const json = JSON.parse(jsonData); 20 | 21 | try { 22 | const data = 23 | json.contents.twoColumnWatchNextResults.results.results.contents[1].videoSecondaryInfoRenderer 24 | .metadataRowContainer.metadataRowContainerRenderer; 25 | if (data.rows && data.rows.length > 0) { 26 | const song = data.rows.find( 27 | (row: any) => row.metadataRowRenderer && row.metadataRowRenderer.title.simpleText === 'Song', 28 | ); 29 | const artist = data.rows.find( 30 | (row: any) => row.metadataRowRenderer && row.metadataRowRenderer.title.simpleText === 'Artist', 31 | ); 32 | 33 | if (song && artist) { 34 | const {TRACK} = await searchAlternative( 35 | artist.metadataRowRenderer.contents[0].runs[0].text, 36 | song.metadataRowRenderer.contents[0].simpleText, 37 | 1, 38 | ); 39 | if (TRACK.data[0]) { 40 | return TRACK.data[0]; 41 | } 42 | } 43 | } 44 | } catch (err) { 45 | const title = (json.videoDetails.title as string) 46 | .toLowerCase() 47 | .replace(/\(Off.*\)/i, '') 48 | .replace(/ft.*/i, '') 49 | .replace(/[,-\.]/g, '') 50 | .replace(/ +/g, ' ') 51 | .trim(); 52 | const {TRACK} = await searchMusic(title, ['TRACK'], 20); 53 | const data = TRACK.data.filter((track) => title.includes(track.ART_NAME.toLowerCase())); 54 | if (data[0]) { 55 | return TRACK.data[0]; 56 | } 57 | } 58 | } 59 | } 60 | }; 61 | 62 | /** 63 | * Convert a youtube video to track by video id 64 | * @param {String} id - video id 65 | */ 66 | export const track2deezer = async (id: string) => { 67 | const track = await getTrack(id); 68 | if (track) { 69 | return track; 70 | } 71 | 72 | throw new Error('No track found for youtube video ' + id); 73 | }; 74 | -------------------------------------------------------------------------------- /src/lib/request.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import delay from 'delay'; 3 | 4 | let user_arl = 5 | 'c973964816688562722418b5200c1515dffaad15a42643ebf87cc72824a54612ec51c2ad42d566743f9e424c774e98ccae7737770acff59251328e6cd598c7bcac38ca269adf78bfb88ec5bbad6cd800db3c0b88b2af645bb22b99e71de26416'; 6 | 7 | const instance = axios.create({ 8 | baseURL: 'https://api.deezer.com/1.0', 9 | withCredentials: true, 10 | timeout: 15000, 11 | headers: { 12 | Accept: '*/*', 13 | 'Accept-Encoding': 'gzip, deflate', 14 | 'Accept-Language': 'en-US', 15 | 'Cache-Control': 'no-cache', 16 | 'Content-Type': 'application/json; charset=UTF-8', 17 | 'User-Agent': 'Deezer/8.32.0.2 (iOS; 14.4; Mobile; en; iPhone10_5)', 18 | }, 19 | params: { 20 | version: '8.32.0', 21 | api_key: 'ZAIVAHCEISOHWAICUQUEXAEPICENGUAFAEZAIPHAELEEVAHPHUCUFONGUAPASUAY', 22 | output: 3, 23 | input: 3, 24 | buildId: 'ios12_universal', 25 | screenHeight: '480', 26 | screenWidth: '320', 27 | lang: 'en', 28 | }, 29 | }); 30 | 31 | const getApiToken = async (): Promise => { 32 | const {data} = await instance.get('https://www.deezer.com/ajax/gw-light.php', { 33 | params: { 34 | method: 'deezer.getUserData', 35 | api_version: '1.0', 36 | api_token: 'null', 37 | }, 38 | }); 39 | instance.defaults.params.sid = data.results.SESSION_ID; 40 | instance.defaults.params.api_token = data.results.checkForm; 41 | return data.results.checkForm; 42 | }; 43 | 44 | export const initDeezerApi = async (arl: string): Promise => { 45 | if (arl.length !== 192) { 46 | throw new Error(`Invalid arl. Length should be 192 characters. You have provided ${arl.length} characters.`); 47 | } 48 | user_arl = arl; 49 | const {data} = await instance.get('https://www.deezer.com/ajax/gw-light.php', { 50 | params: {method: 'deezer.ping', api_version: '1.0', api_token: ''}, 51 | headers: {cookie: 'arl=' + arl}, 52 | }); 53 | instance.defaults.params.sid = data.results.SESSION; 54 | return data.results.SESSION; 55 | }; 56 | 57 | let token_retry = 0; 58 | 59 | // Add a request interceptor 60 | instance.interceptors.response.use(async (response: Record) => { 61 | if (response.data.error && Object.keys(response.data.error).length > 0) { 62 | if (response.data.error.NEED_API_AUTH_REQUIRED) { 63 | await initDeezerApi(user_arl); 64 | return await instance(response.config); 65 | } else if (response.data.error.code === 4) { 66 | await delay.range(1000, 1500); 67 | return await instance(response.config); 68 | } else if (response.data.error.GATEWAY_ERROR || (response.data.error.VALID_TOKEN_REQUIRED && token_retry < 15)) { 69 | await getApiToken(); 70 | // Prevent dead loop 71 | token_retry += 1; 72 | return await instance(response.config); 73 | } 74 | } 75 | 76 | return response; 77 | }); 78 | 79 | export default instance; 80 | -------------------------------------------------------------------------------- /src/api/request.ts: -------------------------------------------------------------------------------- 1 | import axios from '../lib/request'; 2 | import lru from './cache'; 3 | 4 | /** 5 | * Make POST requests to deezer api 6 | * @param {Object} body post body 7 | * @param {String} method request method 8 | */ 9 | export const request = async (body: object, method: string) => { 10 | const cacheKey = method + ':' + Object.entries(body).join(':'); 11 | const cache = lru.get(cacheKey); 12 | if (cache) { 13 | return cache; 14 | } 15 | 16 | const { 17 | data: {error, results}, 18 | } = await axios.post('/gateway.php', body, {params: {method}}); 19 | 20 | if (Object.keys(results).length > 0) { 21 | lru.set(cacheKey, results); 22 | return results; 23 | } 24 | 25 | const errorMessage = Object.entries(error).join(', '); 26 | throw new Error(errorMessage); 27 | }; 28 | 29 | /** 30 | * Make POST requests to deezer api 31 | * @param {Object} body post body 32 | * @param {String} method request method 33 | */ 34 | export const requestLight = async (body: object, method: string) => { 35 | const cacheKey = method + ':' + Object.entries(body).join(':'); 36 | const cache = lru.get(cacheKey); 37 | if (cache) { 38 | return cache; 39 | } 40 | 41 | const { 42 | data: {error, results}, 43 | } = await axios.post('https://www.deezer.com/ajax/gw-light.php', body, { 44 | params: {method, api_version: '1.0'}, 45 | }); 46 | if (Object.keys(results).length > 0) { 47 | lru.set(cacheKey, results); 48 | return results; 49 | } 50 | 51 | const errorMessage = Object.entries(error).join(', '); 52 | throw new Error(errorMessage); 53 | }; 54 | 55 | /** 56 | * Make GET requests to deezer public api 57 | * @param {String} method request method 58 | * @param {Object} params request parameters 59 | */ 60 | export const requestGet = async (method: string, params: Record = {}, key = 'get_request') => { 61 | const cacheKey = method + key; 62 | const cache = lru.get(cacheKey); 63 | if (cache) { 64 | return cache; 65 | } 66 | 67 | const { 68 | data: {error, results}, 69 | } = await axios.get('/gateway.php', {params: {method, ...params}}); 70 | 71 | if (Object.keys(results).length > 0) { 72 | lru.set(cacheKey, results); 73 | return results; 74 | } 75 | 76 | const errorMessage = Object.entries(error).join(', '); 77 | throw new Error(errorMessage); 78 | }; 79 | 80 | /** 81 | * Make GET requests to deezer public api 82 | * @param {String} slug endpoint 83 | */ 84 | export const requestPublicApi = async (slug: string) => { 85 | const cache = lru.get(slug); 86 | if (cache) { 87 | return cache; 88 | } 89 | 90 | const {data} = await axios.get('https://api.deezer.com' + slug); 91 | 92 | if (data.error) { 93 | const errorMessage = Object.entries(data.error).join(', '); 94 | throw new Error(errorMessage); 95 | } 96 | 97 | lru.set(slug, data); 98 | return data; 99 | }; 100 | -------------------------------------------------------------------------------- /__tests__/converter/tidal.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {tidal} from '../../src'; 3 | 4 | // Work (feat. Drake) 5 | const SNG_TITLE = 'Work'; 6 | const SNG_ID = '56681096'; 7 | const ISRC = 'QM5FT1600116'; 8 | 9 | // ANTI (Deluxe) (feat. SZA) 10 | const ALB_TITLE = 'ANTI (Deluxe)'; 11 | const ALB_ID = '56681092'; 12 | const UPC = '00851365006554'; 13 | 14 | // Rihanna 15 | const ART_ID = '10665'; 16 | 17 | // Playlists 18 | const PLAYLIST_TITLE = 'Albums'; 19 | const PLAYLIST_ID = 'ed004d2b-b494-42be-8506-b1d23cd3bb80'; 20 | 21 | test('GET TRACK INFO', async (t) => { 22 | const response = await tidal.getTrack(SNG_ID); 23 | 24 | t.is(response.id.toString(), SNG_ID); 25 | t.is(response.title, SNG_TITLE); 26 | t.is(response.isrc, ISRC); 27 | }); 28 | 29 | test('GET TRACK --> DEEZER', async (t) => { 30 | const track = await tidal.track2deezer(SNG_ID); 31 | 32 | t.is(track.SNG_ID, '118190298'); 33 | t.is(track.ISRC, ISRC); 34 | t.is(track.MD5_ORIGIN, '28045ff090360486d41c4a1cc5929a96'); 35 | t.is(track.__TYPE__, 'song'); 36 | }); 37 | 38 | test('GET ALBUM INFO', async (t) => { 39 | const response = await tidal.getAlbum(ALB_ID); 40 | 41 | t.is(response.id.toString(), ALB_ID); 42 | t.is(response.title, ALB_TITLE); 43 | t.is(response.upc, UPC); 44 | }); 45 | 46 | test('GET ALBUM --> DEEZER', async (t) => { 47 | const [album, tracks] = await tidal.album2deezer(ALB_ID); 48 | 49 | t.is(album.ALB_ID, '12279688'); 50 | t.is(album.UPC, UPC.slice(2)); 51 | t.is(album.__TYPE__, 'album'); 52 | t.is(tracks.length, 16); 53 | }); 54 | 55 | test('GET ALBUM TRACKS', async (t) => { 56 | const response = await tidal.getAlbumTracks(ALB_ID); 57 | 58 | t.is(response.items.length, response.totalNumberOfItems); 59 | t.is(response.totalNumberOfItems, 16); 60 | }); 61 | 62 | test('GET ARTIST ALBUMS', async (t) => { 63 | const response = await tidal.getArtistAlbums(ART_ID); 64 | 65 | t.true(response.totalNumberOfItems >= response.items.length); 66 | t.true(response.totalNumberOfItems > 30); 67 | }); 68 | 69 | test('GET ARTIST TOP TRACKS', async (t) => { 70 | const response = await tidal.getArtistAlbums(ART_ID); 71 | 72 | t.true(response.totalNumberOfItems >= response.items.length); 73 | t.true(response.totalNumberOfItems > 30); 74 | }); 75 | 76 | test('GET PLAYLIST INFO', async (t) => { 77 | const response = await tidal.getPlaylist(PLAYLIST_ID); 78 | 79 | t.is(response.title, PLAYLIST_TITLE); 80 | t.is(response.type, 'USER'); 81 | }); 82 | 83 | test('GET PLAYLIST TRACKS', async (t) => { 84 | const response = await tidal.getPlaylistTracks(PLAYLIST_ID); 85 | 86 | t.is(response.items.length, response.totalNumberOfItems); 87 | t.true(response.items.length > 50); 88 | }); 89 | 90 | if (process.env.CI) { 91 | test('GET ARTISTS TO DEEZER TRACKS', async (t) => { 92 | const response = await tidal.artist2Deezer(ART_ID); 93 | 94 | t.true(response.length > 250); 95 | }); 96 | 97 | test('GET PLAYLIST TO DEEZER TRACKS', async (t) => { 98 | const response = await tidal.getPlaylistTracks(PLAYLIST_ID); 99 | 100 | t.is(response.items.length, response.totalNumberOfItems); 101 | t.true(response.totalNumberOfItems > 150); 102 | }); 103 | } 104 | -------------------------------------------------------------------------------- /src/lib/fast-lru.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Fast LRU & TTL cache 3 | * @param {Integer} options.max - Max entries in the cache. @default Infinity 4 | * @param {Integer} options.ttl - Timeout before removing entries. @default Infinity 5 | */ 6 | class FastLRU { 7 | _max: number; 8 | _ttl: number; 9 | _cache: Map; 10 | _meta: { 11 | [key: string]: any; 12 | }; 13 | constructor({maxSize = Infinity, ttl = 0}) { 14 | // Default options 15 | this._max = maxSize; 16 | this._ttl = ttl; 17 | this._cache = new Map(); 18 | 19 | // Metadata for entries 20 | this._meta = {}; 21 | } 22 | 23 | /** 24 | * Add new entry 25 | */ 26 | set(key: string, value: any, ttl: number = this._ttl) { 27 | // Execution time 28 | const time = Date.now(); 29 | 30 | // Remvove least recently used elements if exceeds max bytes 31 | if (this._cache.size >= this._max) { 32 | const items = Object.values(this._meta); 33 | if (this._ttl > 0) { 34 | for (const item of items) { 35 | if (item.expire < time) { 36 | this.delete(item.key); 37 | } 38 | } 39 | } 40 | 41 | if (this._cache.size >= this._max) { 42 | const least = items.sort((a, b) => a.hits - b.hits)[0]; 43 | this.delete(least.key); 44 | } 45 | } 46 | 47 | // Override if key already set 48 | this._cache.set(key, value); 49 | this._meta[key] = { 50 | key, 51 | hits: 0, 52 | expire: time + ttl, 53 | }; 54 | } 55 | 56 | /** 57 | * Get entry 58 | */ 59 | get(key: string) { 60 | if (this._cache.has(key)) { 61 | const item = this._cache.get(key); 62 | if (this._ttl > 0) { 63 | const time = Date.now(); 64 | if (this._meta[key].expire < time) { 65 | this.delete(key); 66 | return undefined; 67 | } 68 | } 69 | 70 | this._meta[key].hits++; 71 | return item; 72 | } 73 | } 74 | 75 | /** 76 | * Get without hitting hits 77 | */ 78 | peek(key: string) { 79 | return this._cache.get(key); 80 | } 81 | 82 | /** 83 | * Remove entry 84 | */ 85 | delete(key: string) { 86 | delete this._meta[key]; 87 | this._cache.delete(key); 88 | } 89 | 90 | /** 91 | * Remove all entries 92 | */ 93 | clear() { 94 | this._cache.clear(); 95 | this._meta = {}; 96 | } 97 | 98 | /** 99 | * Check has entry 100 | */ 101 | has(key: string) { 102 | return this._cache.has(key); 103 | } 104 | 105 | /** 106 | * Get all kies 107 | * @returns {Iterator} Iterator on all kies 108 | */ 109 | keys() { 110 | return this._cache.keys(); 111 | } 112 | 113 | /** 114 | * Iterate over values 115 | */ 116 | values() { 117 | return this._cache.values(); 118 | } 119 | 120 | /** 121 | * Iterate over entries 122 | */ 123 | entries() { 124 | return this._cache.entries(); 125 | } 126 | 127 | /** 128 | * For each 129 | */ 130 | forEach(cb: any) { 131 | return this._cache.forEach(cb); 132 | } 133 | 134 | /** 135 | * Entries total size 136 | */ 137 | get size() { 138 | return this._cache.size; 139 | } 140 | } 141 | 142 | export default FastLRU; 143 | -------------------------------------------------------------------------------- /src/metadata-writer/flacmetata.ts: -------------------------------------------------------------------------------- 1 | import Metaflac from '../lib/metaflac-js'; 2 | import type {albumTypePublicApi, trackType} from '../types'; 3 | 4 | export const writeMetadataFlac = ( 5 | buffer: Buffer, 6 | track: trackType, 7 | album: albumTypePublicApi | null, 8 | dimension: number, 9 | cover?: Buffer | null, 10 | ): Buffer => { 11 | const flac = new Metaflac(buffer); 12 | const RELEASE_YEAR = album ? album.release_date.split('-')[0] : null; 13 | 14 | flac.setTag('TITLE=' + track.SNG_TITLE); 15 | flac.setTag('ALBUM=' + track.ALB_TITLE); 16 | flac.setTag('ARTIST=' + track.ARTISTS.map((a) => a.ART_NAME).join(', ')); 17 | flac.setTag('TRACKNUMBER=' + track.TRACK_NUMBER.toLocaleString('en-US', {minimumIntegerDigits: 2})); 18 | 19 | if (album) { 20 | const TOTALTRACKS = album.nb_tracks.toLocaleString('en-US', {minimumIntegerDigits: 2}); 21 | if (album.genres.data.length > 0) { 22 | for (const genre of album.genres.data) { 23 | flac.setTag('GENRE=' + genre.name); 24 | } 25 | } 26 | flac.setTag('TRACKTOTAL=' + TOTALTRACKS); 27 | flac.setTag('TOTALTRACKS=' + TOTALTRACKS); 28 | flac.setTag('RELEASETYPE=' + album.record_type); 29 | flac.setTag('ALBUMARTIST=' + album.artist.name); 30 | flac.setTag('BARCODE=' + album.upc); 31 | flac.setTag('LABEL=' + album.label); 32 | flac.setTag('DATE=' + album.release_date); 33 | flac.setTag('YEAR=' + RELEASE_YEAR); 34 | flac.setTag(`COMPILATION=${album.artist.name.match(/various/i) ? '1' : '0'}`); 35 | } 36 | 37 | if (track.DISK_NUMBER) { 38 | flac.setTag('DISCNUMBER=' + track.DISK_NUMBER); 39 | } 40 | 41 | flac.setTag('ISRC=' + track.ISRC); 42 | flac.setTag('LENGTH=' + track.DURATION); 43 | flac.setTag('MEDIA=Digital Media'); 44 | 45 | if (track.LYRICS) { 46 | flac.setTag('LYRICS=' + track.LYRICS.LYRICS_TEXT); 47 | } 48 | if (track.EXPLICIT_LYRICS) { 49 | flac.setTag('EXPLICIT=' + track.EXPLICIT_LYRICS); 50 | } 51 | 52 | if (track.SNG_CONTRIBUTORS && !Array.isArray(track.SNG_CONTRIBUTORS)) { 53 | if (track.SNG_CONTRIBUTORS.main_artist) { 54 | flac.setTag(`COPYRIGHT=${RELEASE_YEAR ? RELEASE_YEAR + ' ' : ''}${track.SNG_CONTRIBUTORS.main_artist[0]}`); 55 | } 56 | if (track.SNG_CONTRIBUTORS.publisher) { 57 | flac.setTag('ORGANIZATION=' + track.SNG_CONTRIBUTORS.publisher.join(', ')); 58 | } 59 | if (track.SNG_CONTRIBUTORS.composer) { 60 | flac.setTag('COMPOSER=' + track.SNG_CONTRIBUTORS.composer.join(', ')); 61 | } 62 | if (track.SNG_CONTRIBUTORS.publisher) { 63 | flac.setTag('ORGANIZATION=' + track.SNG_CONTRIBUTORS.publisher.join(', ')); 64 | } 65 | if (track.SNG_CONTRIBUTORS.producer) { 66 | flac.setTag('PRODUCER=' + track.SNG_CONTRIBUTORS.producer.join(', ')); 67 | } 68 | if (track.SNG_CONTRIBUTORS.engineer) { 69 | flac.setTag('ENGINEER=' + track.SNG_CONTRIBUTORS.engineer.join(', ')); 70 | } 71 | if (track.SNG_CONTRIBUTORS.writer) { 72 | flac.setTag('WRITER=' + track.SNG_CONTRIBUTORS.writer.join(', ')); 73 | } 74 | if (track.SNG_CONTRIBUTORS.author) { 75 | flac.setTag('AUTHOR=' + track.SNG_CONTRIBUTORS.author.join(', ')); 76 | } 77 | if (track.SNG_CONTRIBUTORS.mixer) { 78 | flac.setTag('MIXER=' + track.SNG_CONTRIBUTORS.mixer.join(', ')); 79 | } 80 | } 81 | 82 | if (cover) { 83 | flac.importPicture(cover, dimension, 'image/jpeg'); 84 | } 85 | 86 | flac.setTag('SOURCE=Deezer'); 87 | flac.setTag('SOURCEID=' + track.SNG_ID); 88 | 89 | return flac.getBuffer(); 90 | }; 91 | -------------------------------------------------------------------------------- /src/metadata-writer/id3.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import id3Writer from 'browser-id3-writer'; 3 | import type {albumTypePublicApi, trackType} from '../types'; 4 | 5 | export const writeMetadataMp3 = ( 6 | buffer: Buffer, 7 | track: trackType, 8 | album: albumTypePublicApi | null, 9 | cover?: Buffer | null, 10 | ): Buffer => { 11 | const writer = new id3Writer(buffer); 12 | const RELEASE_DATES = album && album.release_date.split('-'); 13 | 14 | writer 15 | .setFrame('TIT2', track.SNG_TITLE) 16 | .setFrame('TALB', track.ALB_TITLE) 17 | .setFrame( 18 | 'TPE1', 19 | track.ARTISTS.map((a) => a.ART_NAME), 20 | ) 21 | .setFrame('TLEN', Number(track.DURATION) * 1000) 22 | .setFrame('TSRC', track.ISRC); 23 | 24 | if (album) { 25 | if (album.genres.data.length > 0) { 26 | writer.setFrame( 27 | 'TCON', 28 | album.genres.data.map((g) => g.name), 29 | ); 30 | } 31 | if (RELEASE_DATES) { 32 | writer.setFrame('TYER', RELEASE_DATES[0]).setFrame('TDAT', RELEASE_DATES[2] + RELEASE_DATES[1]); 33 | } 34 | writer 35 | .setFrame('TPE2', album.artist.name) 36 | .setFrame('TXXX', { 37 | description: 'RELEASETYPE', 38 | value: album.record_type, 39 | }) 40 | .setFrame('TXXX', { 41 | description: 'BARCODE', 42 | value: album.upc, 43 | }) 44 | .setFrame('TXXX', { 45 | description: 'LABEL', 46 | value: album.label, 47 | }) 48 | .setFrame('TXXX', { 49 | description: 'COMPILATION', 50 | value: album.artist.name.match(/various/i) ? '1' : '0', 51 | }); 52 | } 53 | 54 | writer 55 | .setFrame('TMED', 'Digital Media') 56 | .setFrame('TXXX', { 57 | description: 'SOURCE', 58 | value: 'Deezer', 59 | }) 60 | .setFrame('TXXX', { 61 | description: 'SOURCEID', 62 | value: track.SNG_ID, 63 | }); 64 | 65 | if (track.DISK_NUMBER) { 66 | const TRACK_NUMBER = track.TRACK_NUMBER.toLocaleString('en-US', {minimumIntegerDigits: 2}); 67 | writer.setFrame('TPOS', track.DISK_NUMBER).setFrame( 68 | 'TRCK', 69 | album 70 | ? `${TRACK_NUMBER}/${album.nb_tracks.toLocaleString('en-US', { 71 | minimumIntegerDigits: 2, 72 | })}` 73 | : TRACK_NUMBER, 74 | ); 75 | } 76 | 77 | if (track.SNG_CONTRIBUTORS && !Array.isArray(track.SNG_CONTRIBUTORS)) { 78 | if (track.SNG_CONTRIBUTORS.main_artist) { 79 | writer.setFrame('TCOP', `${RELEASE_DATES ? RELEASE_DATES[0] + ' ' : ''}${track.SNG_CONTRIBUTORS.main_artist[0]}`); 80 | } 81 | if (track.SNG_CONTRIBUTORS.publisher) { 82 | writer.setFrame('TPUB', track.SNG_CONTRIBUTORS.publisher.join(', ')); 83 | } 84 | if (track.SNG_CONTRIBUTORS.composer) { 85 | writer.setFrame('TCOM', track.SNG_CONTRIBUTORS.composer); 86 | } 87 | 88 | if (track.SNG_CONTRIBUTORS.writer) { 89 | writer.setFrame('TXXX', { 90 | description: 'LYRICIST', 91 | value: track.SNG_CONTRIBUTORS.writer.join(', '), 92 | }); 93 | } 94 | if (track.SNG_CONTRIBUTORS.author) { 95 | writer.setFrame('TXXX', { 96 | description: 'AUTHOR', 97 | value: track.SNG_CONTRIBUTORS.author.join(', '), 98 | }); 99 | } 100 | if (track.SNG_CONTRIBUTORS.mixer) { 101 | writer.setFrame('TXXX', { 102 | description: 'MIXARTIST', 103 | value: track.SNG_CONTRIBUTORS.mixer.join(', '), 104 | }); 105 | } 106 | if (track.SNG_CONTRIBUTORS.producer && track.SNG_CONTRIBUTORS.engineer) { 107 | writer.setFrame('TXXX', { 108 | description: 'INVOLVEDPEOPLE', 109 | value: track.SNG_CONTRIBUTORS.producer.concat(track.SNG_CONTRIBUTORS.engineer).join(', '), 110 | }); 111 | } 112 | } 113 | 114 | if (track.LYRICS) { 115 | writer.setFrame('USLT', { 116 | description: '', 117 | lyrics: track.LYRICS.LYRICS_TEXT, 118 | }); 119 | } 120 | if (track.EXPLICIT_LYRICS) { 121 | writer.setFrame('TXXX', { 122 | description: 'EXPLICIT', 123 | value: track.EXPLICIT_LYRICS, 124 | }); 125 | } 126 | 127 | if (cover) { 128 | writer.setFrame('APIC', { 129 | type: 3, 130 | data: cover, 131 | description: '', 132 | }); 133 | } 134 | 135 | writer.addTag(); 136 | return Buffer.from(writer.arrayBuffer); 137 | }; 138 | -------------------------------------------------------------------------------- /src/types/album.ts: -------------------------------------------------------------------------------- 1 | import type {artistType} from './artist'; 2 | import type {trackType, contributorsPublicApi} from './tracks'; 3 | 4 | export interface albumTypeMinimal { 5 | ALB_ID: string; 6 | ALB_TITLE: string; 7 | ALB_PICTURE: string; 8 | ARTISTS: artistType[]; 9 | AVAILABLE: boolean; 10 | VERSION: string; // '' 11 | ART_ID: string; 12 | ART_NAME: string; 13 | EXPLICIT_ALBUM_CONTENT: { 14 | EXPLICIT_LYRICS_STATUS: number; // 1 15 | EXPLICIT_COVER_STATUS: number; //2 16 | }; 17 | PHYSICAL_RELEASE_DATE: string; 18 | TYPE: string; // '0' 19 | ARTIST_IS_DUMMY: boolean; 20 | NUMBER_TRACK: number; // '1'; 21 | __TYPE__: 'album'; 22 | } 23 | 24 | export interface albumType { 25 | ALB_CONTRIBUTORS: { 26 | main_artist: string[]; // ['Avicii'] 27 | }; 28 | ALB_ID: string; // '9188269' 29 | ALB_PICTURE: string; // '6e58a99f59a150e9b4aefbeb2d6fc856' 30 | EXPLICIT_ALBUM_CONTENT: { 31 | EXPLICIT_LYRICS_STATUS: number; // 0 32 | EXPLICIT_COVER_STATUS: number; // 0 33 | }; 34 | ALB_TITLE: string; // 'The Days / Nights' 35 | ARTISTS: artistType[]; 36 | ART_ID: string; // '293585' 37 | ART_NAME: string; // 'Avicii' 38 | ARTIST_IS_DUMMY: boolean; 39 | DIGITAL_RELEASE_DATE: string; //'2014-12-01' 40 | EXPLICIT_LYRICS?: string; // '0' 41 | NB_FAN: number; // 36285 42 | NUMBER_DISK: string; // '1' 43 | NUMBER_TRACK: string; // '4' 44 | PHYSICAL_RELEASE_DATE?: string; // '2014-01-01' 45 | PRODUCER_LINE: string; // '℗ 2014 Avicii Music AB' 46 | PROVIDER_ID: string; // '427' 47 | RANK: string; // '601128' 48 | RANK_ART: string; // '861905' 49 | STATUS: string; // '1' 50 | TYPE: string; // '1' 51 | UPC: string; // '602547151544' 52 | __TYPE__: 'album'; 53 | } 54 | 55 | export interface albumTracksType { 56 | data: trackType[]; 57 | count: number; 58 | total: number; 59 | filtered_count: number; 60 | filtered_items?: number[]; 61 | next?: number; 62 | } 63 | 64 | interface trackDataPublicApi { 65 | id: number; // 3135556 66 | readable: boolean; 67 | title: string; // 'Harder, Better, Faster, Stronger' 68 | title_short: string; // 'Harder, Better, Faster, Stronger' 69 | title_version?: string; // '' 70 | link: 'https://www.deezer.com/track/3135556'; 71 | duration: number; // 224 72 | rank: number; // 956167 73 | explicit_lyrics: boolean; 74 | explicit_content_lyrics: number; // 0 75 | explicit_content_cover: number; // 0 76 | preview: string; // 'https://cdns-preview-d.dzcdn.net/stream/c-deda7fa9316d9e9e880d2c6207e92260-8.mp3' 77 | md5_image: string; // '2e018122cb56986277102d2041a592c8' 78 | artist: { 79 | id: number; // 27 80 | name: number; // 'Daft Punk' 81 | tracklist: string; // 'https://api.deezer.com/artist/27/top?limit=50' 82 | type: 'artist'; 83 | }; 84 | type: 'track'; 85 | } 86 | 87 | interface genreTypePublicApi { 88 | id: number; // 113 89 | name: string; // 'Dance' 90 | picture: string; // 'https://api.deezer.com/genre/113/image' 91 | type: 'genre'; 92 | } 93 | 94 | export interface albumTypePublicApi { 95 | id: number; // 302127' 96 | title: 'Discovery'; 97 | upc: string; // '724384960650' 98 | link: string; // 'https://www.deezer.com/album/302127' 99 | share: string; // 'https://www.deezer.com/album/302127?utm_source=deezer&utm_content=album-302127&utm_term=0_1614940071&utm_medium=web' 100 | cover: string; // 'https://api.deezer.com/album/302127/image' 101 | cover_small: string; // 'https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/56x56-000000-80-0-0.jpg' 102 | cover_medium: string; // 'https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/250x250-000000-80-0-0.jpg' 103 | cover_big: string; // 'https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/500x500-000000-80-0-0.jpg' 104 | cover_xl: string; // 'https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/1000x1000-000000-80-0-0.jpg' 105 | md5_image: string; // '2e018122cb56986277102d2041a592c8' 106 | genre_id: number; // 113; 107 | genres: { 108 | data: genreTypePublicApi[]; 109 | }; 110 | label: string; // 'Parlophone (France)' 111 | nb_tracks: number; // 14; 112 | duration: number; // 3660; 113 | fans: number; // 229369 114 | rating: number; // 0 115 | release_date: string; // '2001-03-07' 116 | record_type: string; // 'album' 117 | available: boolean; 118 | tracklist: string; // 'https://api.deezer.com/album/302127/tracks' 119 | explicit_lyrics: boolean; 120 | explicit_content_lyrics: number; // 7 121 | explicit_content_cover: number; // 0 122 | contributors: contributorsPublicApi[]; 123 | artist: contributorsPublicApi; 124 | type: 'album'; 125 | tracks: { 126 | data: trackDataPublicApi[]; 127 | }; 128 | } 129 | -------------------------------------------------------------------------------- /src/lib/get-url.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import {getSongFileName} from '../lib/decrypt'; 3 | import instance from '../lib/request'; 4 | import type {trackType} from '../types'; 5 | 6 | interface userData { 7 | license_token: string; 8 | can_stream_lossless: boolean; 9 | can_stream_hq: boolean; 10 | country: string; 11 | } 12 | 13 | export class WrongLicense extends Error { 14 | constructor(format: string) { 15 | super(); 16 | this.name = 'WrongLicense'; 17 | this.message = `Your account can't stream ${format} tracks`; 18 | } 19 | } 20 | 21 | export class GeoBlocked extends Error { 22 | constructor(country: string) { 23 | super(); 24 | this.name = 'GeoBlocked'; 25 | this.message = `This track is not available in your country (${country})`; 26 | } 27 | } 28 | 29 | let user_data: userData | null = null; 30 | 31 | const dzAuthenticate = async (): Promise => { 32 | const {data} = await instance.get('https://www.deezer.com/ajax/gw-light.php', { 33 | params: { 34 | method: 'deezer.getUserData', 35 | api_version: '1.0', 36 | api_token: 'null', 37 | }, 38 | }); 39 | user_data = { 40 | license_token: data.results.USER.OPTIONS.license_token, 41 | can_stream_lossless: data.results.USER.OPTIONS.web_lossless || data.results.USER.OPTIONS.mobile_loseless, 42 | can_stream_hq: data.results.USER.OPTIONS.web_hq || data.results.USER.OPTIONS.mobile_hq, 43 | country: data.results.COUNTRY, 44 | }; 45 | return user_data; 46 | }; 47 | 48 | const getTrackUrlFromServer = async (track_token: string, format: string): Promise => { 49 | const user = user_data ? user_data : await dzAuthenticate(); 50 | if ((format === 'FLAC' && !user.can_stream_lossless) || (format === 'MP3_320' && !user.can_stream_hq)) { 51 | throw new WrongLicense(format); 52 | } 53 | 54 | const {data} = await instance.post('https://media.deezer.com/v1/get_url', { 55 | license_token: user.license_token, 56 | media: [ 57 | { 58 | type: 'FULL', 59 | formats: [{format, cipher: 'BF_CBC_STRIPE'}], 60 | }, 61 | ], 62 | track_tokens: [track_token], 63 | }); 64 | 65 | if (data.data.length > 0) { 66 | if (data.data[0].errors) { 67 | if (data.data[0].errors[0].code === 2002) { 68 | throw new GeoBlocked(user.country); 69 | } 70 | throw new Error(Object.entries(data.data[0].errors[0]).join(', ')); 71 | } 72 | return data.data[0].media.length > 0 ? data.data[0].media[0].sources[0].url : null; 73 | } 74 | return null; 75 | }; 76 | 77 | /** 78 | * @param track Track info json returned from `getTrackInfo` 79 | * @param quality 1 = 128kbps, 3 = 320kbps and 9 = flac (around 1411kbps) 80 | */ 81 | export const getTrackDownloadUrl = async ( 82 | track: trackType, 83 | quality: number, 84 | ): Promise<{trackUrl: string; isEncrypted: boolean; fileSize: number} | null> => { 85 | let wrongLicense: WrongLicense | null = null; 86 | let geoBlocked: GeoBlocked | null = null; 87 | let formatName: string; 88 | switch (quality) { 89 | case 9: 90 | formatName = 'FLAC'; 91 | break; 92 | case 3: 93 | formatName = 'MP3_320'; 94 | break; 95 | case 1: 96 | formatName = 'MP3_128'; 97 | break; 98 | default: 99 | throw new Error(`Unknown quality ${quality}`); 100 | } 101 | 102 | // Get URL with the official API 103 | try { 104 | const url = await getTrackUrlFromServer(track.TRACK_TOKEN, formatName); 105 | if (url) { 106 | const fileSize = await testUrl(url); 107 | if (fileSize > 0) { 108 | return { 109 | trackUrl: url, 110 | isEncrypted: url.includes('/mobile/') || url.includes('/media/'), 111 | fileSize: fileSize, 112 | }; 113 | } 114 | } 115 | } catch (err) { 116 | if (err instanceof WrongLicense) { 117 | wrongLicense = err; 118 | } else if (err instanceof GeoBlocked) { 119 | geoBlocked = err; 120 | } else { 121 | throw err; 122 | } 123 | } 124 | 125 | // Fallback to the old method 126 | const filename = getSongFileName(track, quality); // encrypted file name 127 | const url = `https://e-cdns-proxy-${track.MD5_ORIGIN[0]}.dzcdn.net/mobile/1/${filename}`; 128 | const fileSize = await testUrl(url); 129 | if (fileSize > 0) { 130 | return { 131 | trackUrl: url, 132 | isEncrypted: url.includes('/mobile/') || url.includes('/media/'), 133 | fileSize: fileSize, 134 | }; 135 | } 136 | if (wrongLicense) { 137 | throw wrongLicense; 138 | } 139 | if (geoBlocked) { 140 | throw geoBlocked; 141 | } 142 | return null; 143 | }; 144 | 145 | const testUrl = async (url: string): Promise => { 146 | try { 147 | const {headers} = await axios.head(url); 148 | return Number(headers['content-length']); 149 | } catch (err) { 150 | return 0; 151 | } 152 | }; 153 | -------------------------------------------------------------------------------- /src/types/playlist-channel.ts: -------------------------------------------------------------------------------- 1 | // Just for references 2 | interface nativeAdsType { 3 | advertising_data: { 4 | page_id_android: string; // '663470' 5 | page_id_android_tablet: string; // '663474' 6 | page_id_ipad: string; // '663478' 7 | page_id_iphone: string; // '663481' 8 | page_id_web: string; // '631827' 9 | }; 10 | data: null; 11 | id: string; // '606d7288988f0' 12 | item_id: string; // 'page_id=channels/country,render_id=3e3de0f8ce6ce9ade953a64d0359fb45,version=channel-cms.1.0,section_id=ads,section_position=4,module_id=e9eedefd-d570-4e0d-97aa-453bd59fec98,module_type=ad,section_content=native%3A606d7288988f0,item_type=native,item_id=606d7288988f0,item_position=0' 13 | type: 'native'; 14 | weight: number; // 1 15 | } 16 | 17 | type itemType = 'album' | 'playlist' | 'radio' | 'show' | 'livestream'; 18 | 19 | export interface playlistChannelItemsType { 20 | item_id: string; // 'page_id=channels/dance,render_id=608a93738223d615200e6390ea48f8f3,version=channel-cms.1.0,section_id=long-card-horizontal-grid,section_position=0,module_id=be55d60e-e3c3-421a-9ea9-aef6f0c63c6c,module_type=playlists,layout=playlists_layout_long-card-horizontal-grid,content_source=playlists_content-source_custom,content_source_count=0,content_programming_count=8,section_content=playlist%3A1291471565%3Bplaylist%3A2249258602%3Bplaylist%3A2113355604%3Bplaylist%3A1950512362%3Bplaylist%3A1495242491%3Bplaylist%3A6090195324%3Bplaylist%3A706093725%3Bplaylist%3A7837492422,item_type=playlist,item_id=1291471565,item_position=0' 21 | id: string; // '1291471565' 22 | type: itemType; 23 | data: 24 | | { 25 | NB_FAN: number; // 231133 26 | NB_SONG: number; // 50 27 | PARENT_USER_ID: string; // '2834392844' 28 | PICTURE_TYPE: string; // 'playlist'; 29 | PLAYLIST_ID: string; // '1291471565' 30 | PLAYLIST_PICTURE: string; // 'c5eb1bf10a83734c032e983ef190105e' 31 | STATUS: number; // 0 32 | TITLE: string; // 'Dance Party' 33 | TYPE: string; // '0' 34 | DATE_MOD: string; // '2021-04-02 19:57:27' 35 | DATE_ADD: string; // '2020-12-15 19:31:07' 36 | DESCRIPTION: string; // 'The biggest dance hits to keep the party going!' 37 | __TYPE__: itemType; 38 | } 39 | | { 40 | __TYPE__: itemType; 41 | background_color: string; // '#ffffff'; 42 | description: string | null; 43 | id: string; // '75092dd5-4857-4219-be1d-89074a716982'; 44 | logo: string | null; // '440dc61fe940f0e20b78a540d1484c3c'; 45 | name: string; // 'NBC News'; 46 | pictures: [ 47 | { 48 | md5: string; 49 | type: string; 50 | }, 51 | ]; 52 | slug: string; // 'nbcnews'; 53 | title: string; // 'NBC News'; 54 | type: itemType; 55 | } 56 | | null; 57 | target: string; // '/playlist/1291471565' 58 | title: string; // 'Dance Party' 59 | subtitle: string; // '50 tracks' 60 | description: string; // 'The biggest dance hits to keep the party going!' 61 | pictures: [ 62 | { 63 | md5: string; // 'c5eb1bf10a83734c032e983ef190105e' 64 | type: string; // 'playlist'; 65 | }, 66 | ]; 67 | weight: number; // 1 68 | layout_parameters: { 69 | cta: { 70 | type: 'browse'; 71 | label: 'BROWSE'; 72 | }; 73 | }; 74 | } 75 | 76 | export interface playlistChannelSectionsType { 77 | layout: string; // 'long-card-horizontal-grid' 78 | section_id: string; // 'page_id=channels/dance,render_id=608a93738223d615200e6390ea48f8f3,version=channel-cms.1.0,section_id=long-card-horizontal-grid,section_position=0,module_id=be55d60e-e3c3-421a-9ea9-aef6f0c63c6c,module_type=playlists,layout=playlists_layout_long-card-horizontal-grid,content_source=playlists_content-source_custom,content_source_count=0,content_programming_count=8,section_content=playlist%3A1291471565%3Bplaylist%3A2249258602%3Bplaylist%3A2113355604%3Bplaylist%3A1950512362%3Bplaylist%3A1495242491%3Bplaylist%3A6090195324%3Bplaylist%3A706093725%3Bplaylist%3A7837492422' 79 | items: playlistChannelItemsType[]; 80 | title: string; // 'Top Dance & EDM playlists' 81 | target: string; // '/channels/module/be55d60e-e3c3-421a-9ea9-aef6f0c63c6c' 82 | related: { 83 | target: string; // '/channels/module/be55d60e-e3c3-421a-9ea9-aef6f0c63c6c' 84 | label: string; // 'View all' 85 | mandatory: boolean; 86 | }; 87 | alignment: string; // 'left' 88 | group_id: string; // '606c10eda2d91' 89 | hasMoreItems: boolean; 90 | } 91 | 92 | export interface playlistChannelType { 93 | version: string; // '2.3' 94 | page_id: string; // 'page_id=channels/dance,render_id=608a93738223d615200e6390ea48f8f3,version=channel-cms.1.0' 95 | ga: { 96 | screen_name: string; // 'page-dance' 97 | }; 98 | title: string; // 'Dance & EDM' 99 | persistent: boolean; 100 | sections: playlistChannelSectionsType[]; 101 | expire: number; // 1617707131 102 | } 103 | -------------------------------------------------------------------------------- /src/converter/spotify.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import SpotifyWebApi from 'spotify-web-api-node'; 3 | import PQueue from 'p-queue'; 4 | import {isrc2deezer, upc2deezer} from './deezer'; 5 | import type {playlistInfo, trackType} from '../types'; 6 | 7 | // type spotifyTypes = 'track' | 'episode' | 'album' | 'artist' | 'playlist' | 'show'; 8 | 9 | type tokensType = { 10 | clientId: string; 11 | accessToken: string; 12 | accessTokenExpirationTimestampMs: number; 13 | isAnonymous: true; 14 | }; 15 | 16 | /** 17 | * Parse offset number 18 | * @param {String} next next page url 19 | * @returns {Number} 20 | */ 21 | const getOffset = (next: null | string): number => { 22 | if (next) { 23 | const o = next.split('&').find((p) => p.includes('offset=')); 24 | return o ? Number(o.split('=')[1]) : 0; 25 | } 26 | 27 | return 0; 28 | }; 29 | 30 | /** 31 | * Limit process concurrency 32 | */ 33 | const queue = new PQueue({concurrency: 25}); 34 | 35 | /** 36 | * Export core spotify module 37 | */ 38 | export const spotifyApi = new SpotifyWebApi(); 39 | 40 | /** 41 | * Set spotify tokens anonymously. This is required to bypass api limits. 42 | * @returns {tokensType} 43 | */ 44 | export const setSpotifyAnonymousToken = async () => { 45 | const {data} = await axios.get( 46 | 'https://open.spotify.com/get_access_token?reason=transport&productType=embed', 47 | ); 48 | spotifyApi.setAccessToken(data.accessToken); 49 | return data; 50 | }; 51 | 52 | /** 53 | * Convert spotify songs to deezer 54 | * @param {String} id Spotify track id 55 | * @returns {trackType} 56 | */ 57 | export const track2deezer = async (id: string) => { 58 | const {body} = await spotifyApi.getTrack(id); 59 | return await isrc2deezer(body.name, body.external_ids.isrc); 60 | }; 61 | 62 | /** 63 | * Convert spotify albums to deezer 64 | * @param {String} id Spotify track id 65 | */ 66 | export const album2deezer = async (id: string) => { 67 | const {body} = await spotifyApi.getAlbum(id); 68 | return await upc2deezer(body.name, body.external_ids.upc); 69 | }; 70 | 71 | /** 72 | * Convert playlist to deezer 73 | * @param {String} id Spotify track id 74 | */ 75 | export const playlist2Deezer = async ( 76 | id: string, 77 | onError?: (item: SpotifyApi.PlaylistTrackObject, index: number, err: Error) => void, 78 | ): Promise<[playlistInfo, trackType[]]> => { 79 | const {body} = await spotifyApi.getPlaylist(id); 80 | const tracks: trackType[] = []; 81 | let items = body.tracks.items; 82 | let offset = getOffset(body.tracks.next); 83 | 84 | while (offset !== 0) { 85 | const {body} = await spotifyApi.getPlaylistTracks(id, {limit: 100, offset: offset ? offset : 0}); 86 | offset = getOffset(body.next); 87 | items = [...items, ...body.items]; 88 | } 89 | 90 | await queue.addAll( 91 | items.map((item, index) => { 92 | return async () => { 93 | try { 94 | if (item.track) { 95 | const track = await isrc2deezer(item.track.name, item.track.external_ids.isrc); 96 | track.TRACK_POSITION = index + 1; 97 | tracks.push(track); 98 | } 99 | } catch (err: any) { 100 | if (onError) { 101 | onError(item, index, err); 102 | } 103 | } 104 | }; 105 | }), 106 | ); 107 | 108 | const dateCreated = new Date().toISOString(); 109 | const playlistInfoData: playlistInfo = { 110 | PLAYLIST_ID: body.id, 111 | PARENT_USERNAME: body.owner.id, 112 | PARENT_USER_ID: body.owner.id, 113 | PICTURE_TYPE: 'cover', 114 | PLAYLIST_PICTURE: body.images[0].url, 115 | TITLE: body.name, 116 | TYPE: '0', 117 | STATUS: '0', 118 | USER_ID: body.owner.id, 119 | DATE_ADD: dateCreated, 120 | DATE_MOD: dateCreated, 121 | DATE_CREATE: dateCreated, 122 | NB_SONG: body.tracks.total, 123 | NB_FAN: 0, 124 | CHECKSUM: body.id, 125 | HAS_ARTIST_LINKED: false, 126 | IS_SPONSORED: false, 127 | IS_EDITO: false, 128 | __TYPE__: 'playlist', 129 | }; 130 | 131 | return [playlistInfoData, tracks]; 132 | }; 133 | 134 | /** 135 | * Convert artist songs to deezer. Maxium of 10 tracks. 136 | * @param {String} id Spotify track id 137 | */ 138 | export const artist2Deezer = async ( 139 | id: string, 140 | onError?: (item: SpotifyApi.TrackObjectFull, index: number, err: Error) => void, 141 | ): Promise => { 142 | // Artist tracks are limited to 10 items 143 | const {body} = await spotifyApi.getArtistTopTracks(id, 'GB'); 144 | const tracks: trackType[] = []; 145 | 146 | await queue.addAll( 147 | body.tracks.map((item, index) => { 148 | return async () => { 149 | try { 150 | const track = await isrc2deezer(item.name, item.external_ids.isrc); 151 | tracks.push(track); 152 | } catch (err: any) { 153 | if (onError) { 154 | onError(item, index, err); 155 | } 156 | } 157 | }; 158 | }), 159 | ); 160 | 161 | return tracks; 162 | }; 163 | -------------------------------------------------------------------------------- /src/types/show.ts: -------------------------------------------------------------------------------- 1 | interface generesType { 2 | GENRE_ID: number; // 232; 3 | GENRE_NAME: string; // 'Technology'; 4 | } 5 | 6 | export interface showEpisodeType { 7 | EPISODE_ID: string; // '294961882'; 8 | EPISODE_STATUS: string; // '1'; 9 | AVAILABLE: boolean; 10 | SHOW_ID: string; // '1235862'; 11 | SHOW_NAME: string; // 'Masters of Scale with Reid Hoffman'; 12 | SHOW_ART_MD5: string; // '52d6e09bccf1369d5758e7a45ee98b7e'; 13 | SHOW_DESCRIPTION: string; // 'The best startup advice from Silicon Valley & beyond. Iconic CEOs — from Nike to Netflix, Starbucks to Slack — share the stories & strategies that helped them grow from startups into global brands.On each episode, host Reid Hoffman — LinkedIn cofounder, Greylock partner and legendary Silicon Valley investor — proves an unconventional theory about how businesses scale, while his guests share the story of how I built this company. Reid and guests talk entrepreneurship, leadership, strategy, management, fundraising. But they also talk about the human journey — with all its failures and setbacks. With original, cinematic music and hilariously honest stories, Masters of Scale is a business podcast that doesn’t sound like a business podcast. Guests on Masters of Scale have included the founders and CEOs of Netflix, Google, Facebook, Starbucks, Nike, Fiat, Spotify, Instagram, Airbnb, Uber, Paypal, Huffington Post, Twitter, Medium, Bumble, Yahoo, Slack, Spanx, Shake Shack, Dropbox, TaskRabbit, 23&Me, Mailchimp, Evite, Flickr, CharityWater, Endeavor, IAC and many more.'; 14 | SHOW_IS_EXPLICIT: string; // '2'; 15 | EPISODE_TITLE: string; // '87. Frustration is your friend, w/Houzz founder Adi Tatarko'; 16 | EPISODE_DESCRIPTION: string; // "Frustration is an important signal: it indicates an opportunity, a problem to be solved. And if your solution also builds a community, you've unlocked a path to scale. Adi Tatarko founded the online home-design site Houzz with her husband as a hacked-together tool to find and share home design ideas, after their own home renovation turned into a frustrating time-waster. But by flipping frustration on its head, Houzz has grown into a bustling platform and marketplace with more than 40 million users, an essential (and delightful!) resource for homeowners, designers, architects, craftspeople. Learn how to identify frustration – and flip it. Special guests: Puzzle master Karen Kavett, Eventbrite cofounder Julia Hartz."; 17 | MD5_ORIGIN: string; // ''; 18 | FILESIZE_MP3_32: string; // '0'; 19 | FILESIZE_MP3_64: string; // '0'; 20 | EPISODE_DIRECT_STREAM_URL: string; // 'https://chrt.fm/track/E341G/dts.podtrac.com/redirect.mp3/rss.art19.com/episodes/64115aa6-bbc1-4049-ba95-25fc89423c2a.mp3'; 21 | SHOW_IS_DIRECT_STREAM: string; // '1'; 22 | DURATION: string; // '2022'; 23 | EPISODE_PUBLISHED_TIMESTAMP: string; // '2021-04-20 09:00:00'; 24 | EPISODE_UPDATE_TIMESTAMP: string; // '2021-04-20 10:23:21'; 25 | SHOW_IS_ADVERTISING_ALLOWED: string; // '1'; 26 | SHOW_IS_DOWNLOAD_ALLOWED: string; // '1'; 27 | TRACK_TOKEN: string; // 'AAAAAWB_DVFggCaRuFmnQCdtCBDT4qsvXkmIXNGbdgtfOekQu4TZccv8ha9pAwV0moJnJArr8sTr2jocnFSBNE7WWaMU'; 28 | TRACK_TOKEN_EXPIRE: string; // 1619011217; 29 | __TYPE__: 'episode'; 30 | } 31 | 32 | export interface showType { 33 | DATA: { 34 | AVAILABLE: boolean; 35 | SHOW_IS_EXPLICIT: string; // '2'; 36 | LABEL_ID: string; // '35611'; 37 | LABEL_NAME: string; // 'Art19'; 38 | LANGUAGE_CD: string; // 'en'; 39 | SHOW_IS_DIRECT_STREAM: string; // '1'; 40 | SHOW_IS_ADVERTISING_ALLOWED: string; // '1'; 41 | SHOW_IS_DOWNLOAD_ALLOWED: string; // '1'; 42 | SHOW_EPISODE_DISPLAY_COUNT: string; // '0'; 43 | SHOW_ID: string; // '1235862'; 44 | SHOW_ART_MD5: string; // '52d6e09bccf1369d5758e7a45ee98b7e'; 45 | SHOW_NAME: string; // 'Masters of Scale with Reid Hoffman'; 46 | SHOW_DESCRIPTION: string; // 'The best startup advice from Silicon Valley & beyond. Iconic CEOs — from Nike to Netflix, Starbucks to Slack — share the stories & strategies that helped them grow from startups into global brands.On each episode, host Reid Hoffman — LinkedIn cofounder, Greylock partner and legendary Silicon Valley investor — proves an unconventional theory about how businesses scale, while his guests share the story of how I built this company. Reid and guests talk entrepreneurship, leadership, strategy, management, fundraising. But they also talk about the human journey — with all its failures and setbacks. With original, cinematic music and hilariously honest stories, Masters of Scale is a business podcast that doesn’t sound like a business podcast. Guests on Masters of Scale have included the founders and CEOs of Netflix, Google, Facebook, Starbucks, Nike, Fiat, Spotify, Instagram, Airbnb, Uber, Paypal, Huffington Post, Twitter, Medium, Bumble, Yahoo, Slack, Spanx, Shake Shack, Dropbox, TaskRabbit, 23&Me, Mailchimp, Evite, Flickr, CharityWater, Endeavor, IAC and many more.'; 47 | SHOW_STATUS: string; // '1'; 48 | SHOW_TYPE: string; // '0'; 49 | GENRES: generesType[]; 50 | NB_FAN: number; // 658; 51 | NB_RATE: number; // 0; 52 | RATING: string; // '0'; 53 | __TYPE__: 'show'; 54 | }; 55 | FAVORITE_STATUS: boolean; // false; 56 | EPISODES: { 57 | data: showEpisodeType[]; 58 | count: number; // 1; 59 | total: number; // 174; 60 | filtered_count: number; // 0; 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## d-fi-core [![Test](https://github.com/d-fi/d-fi-core/workflows/Test/badge.svg)](https://github.com/d-fi/d-fi-core/actions) 2 | 3 | d-fi is a streaming music downloader. This core module is designed to be used on future version of d-fi. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | $ yarn add d-fi-core 9 | ``` 10 | 11 | ## Usage 12 | 13 | Here's a simple example to download tracks. 14 | 15 | ```ts 16 | import axios from 'axios'; 17 | import fs from 'fs'; 18 | import * as api from 'd-fi-core'; 19 | 20 | // Init api with arl from cookie 21 | await api.initDeezerApi(arl_cookie); 22 | 23 | // Verify user 24 | try { 25 | const user = await api.getUser(); 26 | // Successfully logged in 27 | console.log('Logged in as ' + user.BLOG_NAME); 28 | } catch (err) { 29 | // Invalid arl cookie set 30 | console.error(err.message); 31 | } 32 | 33 | // GET Track Object 34 | const track = await api.getTrackInfo(song_id); 35 | 36 | // Parse download URL for 128kbps 37 | const trackData = await api.getTrackDownloadUrl(track, 1); 38 | 39 | // Download track 40 | const {data} = await axios.get(trackdata.trackUrl, {responseType: 'arraybuffer'}); 41 | 42 | // Decrypt track if needed 43 | const outFile = trackData.isEncrypted ? api.decryptDownload(data, track.SNG_ID) : data; 44 | 45 | // Add id3 metadata 46 | const trackWithMetadata = await api.addTrackTags(outFile, track, 500); 47 | 48 | // Save file to disk 49 | fs.writeFileSync(track.SNG_TITLE + '.mp3', trackWithMetadata); 50 | ``` 51 | 52 | ### [Read FAQ](https://github.com/d-fi/d-fi-core/blob/master/docs/faq.md) 53 | 54 | ## Methods 55 | 56 | All method returns `Object` or throws `Error`. Make sure to catch error on your side. 57 | 58 | ### `.initDeezerApi(arl_cookie);` 59 | 60 | > It is recommended that you first init the app with this method using your arl cookie. 61 | 62 | | Parameters | Required | Type | 63 | | ------------ | :------: | -------: | 64 | | `arl_cookie` | Yes | `string` | 65 | 66 | ### `.getTrackInfo(track_id);` 67 | 68 | | Parameters | Required | Type | 69 | | ---------- | :------: | -------: | 70 | | `track_id` | Yes | `string` | 71 | 72 | ### `.getLyrics(track_id);` 73 | 74 | | Parameters | Required | Type | 75 | | ---------- | :------: | -------: | 76 | | `track_id` | Yes | `string` | 77 | 78 | ### `.getAlbumInfo(album_id);` 79 | 80 | | Parameters | Required | Type | 81 | | ---------- | :------: | -------: | 82 | | `album_id` | Yes | `string` | 83 | 84 | ### `.getAlbumTracks(album_id);` 85 | 86 | | Parameters | Required | Type | 87 | | ---------- | :------: | -------: | 88 | | `album_id` | Yes | `string` | 89 | 90 | ### `.getPlaylistInfo(playlist_id);` 91 | 92 | | Parameters | Required | Type | 93 | | ------------- | :------: | -------: | 94 | | `playlist_id` | Yes | `string` | 95 | 96 | ### `.getPlaylistTracks(playlist_id);` 97 | 98 | | Parameters | Required | Type | 99 | | ------------- | :------: | -------: | 100 | | `playlist_id` | Yes | `string` | 101 | 102 | ### `.getArtistInfo(artist_id);` 103 | 104 | | Parameters | Required | Type | 105 | | ----------- | :------: | -------: | 106 | | `artist_id` | Yes | `string` | 107 | 108 | ### `.getDiscography(artist_id, limit);` 109 | 110 | | Parameters | Required | Type | Default | Description | 111 | | ----------- | :------: | -------: | ------: | ----------------------: | 112 | | `artist_id` | Yes | `string` | - | artist id | 113 | | `limit` | No | `number` | 500 | maximum tracks to fetch | 114 | 115 | ### `.getProfile(user_id);` 116 | 117 | | Parameters | Required | Type | 118 | | ---------- | :------: | -------: | 119 | | `user_id` | Yes | `string` | 120 | 121 | ### `.searchAlternative(artist_name, song_name);` 122 | 123 | | Parameters | Required | Type | 124 | | ------------- | :------: | -------: | 125 | | `artist_name` | Yes | `string` | 126 | | `song_name` | Yes | `string` | 127 | 128 | ### `.searchMusic(query, types, limit);` 129 | 130 | | Parameters | Required | Type | Default | Description | 131 | | ---------- | :------: | -------: | --------: | ------------------------------: | 132 | | `query` | Yes | `string` | - | search query | 133 | | `types` | No | `array` | ['TRACK'] | array of search types | 134 | | `limit` | No | `number` | 15 | maximum item to fetch per types | 135 | 136 | ### `.getTrackDownloadUrl(track, quality);` 137 | 138 | | Parameters | Required | Type | Description | 139 | | ---------- | :------: | ----------: | ---------------------------------: | 140 | | `track` | Yes | `string` | track object | 141 | | `quality` | Yes | `1, 3 or 9` | 1 = 128kbps, 3 = 320kbps, 9 = flac | 142 | 143 | ### `.decryptDownload(data, song_id);` 144 | 145 | | Parameters | Required | Type | Description | 146 | | ---------- | :------: | -------: | ---------------------: | 147 | | `data` | Yes | `buffer` | downloaded song buffer | 148 | | `song_id` | Yes | `string` | track id | 149 | 150 | ### `.addTrackTags(data, track,coverSize)` 151 | 152 | | Parameters | Required | Type | Description | 153 | | ----------- | :------: | --------: | ---------------------: | 154 | | `data` | Yes | `buffer` | downloaded song buffer | 155 | | `track` | Yes | `string` | track object | 156 | | `coverSize` | No | `56-1800` | cover art size | 157 | 158 | ### Donations 159 | 160 | If you want to show your appreciation, you can donate me on [ko-fi](https://ko-fi.com/Z8Z5KDA6) or [buy me a coffee](https://www.buymeacoffee.com/sayem). Thanks! 161 | 162 | > Made with :heart: & :coffee: by Sayem 163 | -------------------------------------------------------------------------------- /__tests__/converter/parse.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import {parseInfo} from '../../src'; 3 | 4 | // Tracks 5 | test('PARSE DEEZER TRACK', async (t) => { 6 | const url = 'https://www.deezer.com/en/track/3135556'; 7 | const response = await parseInfo(url); 8 | 9 | t.deepEqual(response.info, {id: '3135556', type: 'track'}); 10 | t.deepEqual(response.linkinfo, {}); 11 | t.is(response.linktype, 'track'); 12 | t.is(response.tracks.length, 1); 13 | }); 14 | 15 | test('PARSE SPOTIFY TRACK', async (t) => { 16 | const url = 'https://open.spotify.com/track/3UmaczJpikHgJFyBTAJVoz?si=50a837f4ed354b16'; 17 | const response = await parseInfo(url); 18 | 19 | t.deepEqual(response.info, {id: '3UmaczJpikHgJFyBTAJVoz', type: 'spotify-track'}); 20 | t.deepEqual(response.linkinfo, {}); 21 | t.is(response.linktype, 'track'); 22 | t.is(response.tracks.length, 1); 23 | }); 24 | 25 | test('PARSE TIDAL TRACK', async (t) => { 26 | const url = 'https://tidal.com/browse/track/56681099'; 27 | const response = await parseInfo(url); 28 | 29 | t.deepEqual(response.info, {id: '56681099', type: 'tidal-track'}); 30 | t.deepEqual(response.linkinfo, {}); 31 | t.is(response.linktype, 'track'); 32 | t.is(response.tracks.length, 1); 33 | }); 34 | 35 | // Albums 36 | test('PARSE DEEZER ALBUM', async (t) => { 37 | const url = 'https://www.deezer.com/en/album/6575789'; 38 | const response = await parseInfo(url); 39 | 40 | t.deepEqual(response.info, {id: '6575789', type: 'album'}); 41 | t.true(Object.keys(response.linkinfo).includes('ALB_TITLE')); 42 | t.is(response.linktype, 'album'); 43 | t.is(response.tracks.length, 13); 44 | }); 45 | 46 | test('PARSE SPOTIFY ALBUM', async (t) => { 47 | const url = 'https://open.spotify.com/album/6t7956yu5zYf5A829XRiHC'; 48 | const response = await parseInfo(url); 49 | 50 | t.deepEqual(response.info, {id: '6t7956yu5zYf5A829XRiHC', type: 'spotify-album'}); 51 | t.true(Object.keys(response.linkinfo).includes('ALB_TITLE')); 52 | t.is(response.linktype, 'album'); 53 | t.is(response.tracks.length, 18); 54 | }); 55 | 56 | test('PARSE TIDAL ALBUM', async (t) => { 57 | const url = 'https://tidal.com/browse/album/56681092'; 58 | const response = await parseInfo(url); 59 | 60 | t.deepEqual(response.info, {id: '56681092', type: 'tidal-album'}); 61 | t.true(Object.keys(response.linkinfo).includes('ALB_TITLE')); 62 | t.is(response.linktype, 'album'); 63 | t.is(response.tracks.length, 16); 64 | }); 65 | 66 | // Playlists 67 | test('PARSE DEEZER PLAYLISTS', async (t) => { 68 | const url = 'https://www.deezer.com/en/playlist/4523119944'; 69 | const response = await parseInfo(url); 70 | 71 | t.deepEqual(response.info, {id: '4523119944', type: 'playlist'}); 72 | t.true(Object.keys(response.linkinfo).includes('TITLE')); 73 | t.is(response.linktype, 'playlist'); 74 | t.is(response.tracks.length, 3); 75 | }); 76 | 77 | if (process.env.CI) { 78 | test('PARSE SPOTIFY PLAYLISTS', async (t) => { 79 | const url = 'https://open.spotify.com/playlist/37i9dQZF1DX1clOuib1KtQ'; 80 | const response = await parseInfo(url); 81 | 82 | t.deepEqual(response.info, {id: '37i9dQZF1DX1clOuib1KtQ', type: 'spotify-playlist'}); 83 | t.true(Object.keys(response.linkinfo).includes('TITLE')); 84 | t.is(response.linktype, 'playlist'); 85 | t.true(response.tracks.length > 50); 86 | }); 87 | 88 | test('PARSE TIDAL PLAYLISTS', async (t) => { 89 | const url = 'https://tidal.com/browse/playlist/ed004d2b-b494-42be-8506-b1d23cd3bb80'; 90 | const response = await parseInfo(url); 91 | 92 | t.deepEqual(response.info, {id: 'ed004d2b-b494-42be-8506-b1d23cd3bb80', type: 'tidal-playlist'}); 93 | t.true(Object.keys(response.linkinfo).includes('TITLE')); 94 | t.is(response.linktype, 'playlist'); 95 | t.true(response.tracks.length > 150); 96 | }); 97 | } 98 | 99 | // Artists 100 | test('PARSE DEEZER ARTIST', async (t) => { 101 | const url = 'https://www.deezer.com/us/artist/13'; 102 | const response = await parseInfo(url); 103 | 104 | t.deepEqual(response.info, {id: '13', type: 'artist'}); 105 | t.is(response.linktype, 'artist'); 106 | t.true(response.tracks.length > 400); 107 | }); 108 | 109 | test('PARSE SPOTIFY ARTIST', async (t) => { 110 | const url = 'https://open.spotify.com/artist/5WUlDfRSoLAfcVSX1WnrxN?si=6c99fb147fe848ee'; 111 | const response = await parseInfo(url); 112 | 113 | t.deepEqual(response.info, {id: '5WUlDfRSoLAfcVSX1WnrxN', type: 'spotify-artist'}); 114 | t.is(response.linktype, 'artist'); 115 | t.true(response.tracks.length > 5); 116 | }); 117 | 118 | test('PARSE TIDAL ARTIST', async (t) => { 119 | const url = 'https://tidal.com/browse/artist/10665'; 120 | const response = await parseInfo(url); 121 | 122 | t.deepEqual(response.info, {id: '10665', type: 'tidal-artist'}); 123 | t.is(response.linktype, 'artist'); 124 | t.true(response.tracks.length > 5); 125 | }); 126 | 127 | if (!process.env.CI) { 128 | test('PARSE YOUTUBE TRACK', async (t) => { 129 | const url = 'https://www.youtube.com/watch?v=4NRXx6U8ABQ'; 130 | const response = await parseInfo(url); 131 | 132 | t.deepEqual(response.info, {id: '4NRXx6U8ABQ', type: 'youtube-track'}); 133 | t.deepEqual(response.linkinfo, {}); 134 | t.is(response.linktype, 'track'); 135 | t.is(response.tracks.length, 1); 136 | }); 137 | } 138 | 139 | // Fail Tests 140 | test('SHOULD FAIL STRING', async (t) => { 141 | const str = 'hello there'; 142 | try { 143 | await parseInfo(str); 144 | t.fail(); 145 | } catch (err: any) { 146 | t.is(err.message, 'Unknown URL: ' + str); 147 | } 148 | }); 149 | 150 | test('SHOULD FAIL URL', async (t) => { 151 | const url = 'https://example.com/browse/track/56681099'; 152 | try { 153 | await parseInfo(url); 154 | t.fail(); 155 | } catch (err: any) { 156 | t.is(err.message, 'Unknown URL: ' + url); 157 | } 158 | }); 159 | -------------------------------------------------------------------------------- /src/converter/parse.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAlbumInfo, 3 | getAlbumTracks, 4 | getArtistInfo, 5 | getDiscography, 6 | getPlaylistInfo, 7 | getPlaylistTracks, 8 | getTrackInfo, 9 | } from '../'; 10 | import spotifyUri from 'spotify-uri'; 11 | import axios from 'axios'; 12 | import * as spotify from './spotify'; 13 | import * as tidal from './tidal'; 14 | import * as youtube from './youtube'; 15 | import PQueue from 'p-queue'; 16 | import type {albumType, artistInfoType, playlistInfo, trackType} from '../types'; 17 | 18 | type linkType = 'track' | 'album' | 'artist' | 'playlist'; 19 | 20 | export type urlPartsType = { 21 | id: string; 22 | type: 23 | | 'track' 24 | | 'album' 25 | | 'audiobook' 26 | | 'artist' 27 | | 'playlist' 28 | | 'spotify-track' 29 | | 'spotify-album' 30 | | 'spotify-playlist' 31 | | 'spotify-artist' 32 | | 'tidal-track' 33 | | 'tidal-album' 34 | | 'tidal-playlist' 35 | | 'tidal-artist' 36 | | 'youtube-track'; 37 | }; 38 | 39 | const queue = new PQueue({concurrency: 10}); 40 | 41 | export const getUrlParts = async (url: string, setToken = false): Promise => { 42 | if (url.startsWith('spotify:')) { 43 | const spotify = url.split(':'); 44 | url = 'https://open.spotify.com/' + spotify[1] + '/' + spotify[2]; 45 | } 46 | 47 | const site = url.match(/deezer|spotify|tidal|youtu\.?be/); 48 | if (!site) { 49 | throw new Error('Unknown URL: ' + url); 50 | } 51 | 52 | switch (site[0]) { 53 | case 'deezer': 54 | if (url.includes('page.link')) { 55 | const {request} = await axios.head(url); 56 | url = request.res.responseUrl; 57 | } 58 | const deezerUrlParts = url.split(/\/(\w+)\/(\d+)/); 59 | return {type: deezerUrlParts[1] as any, id: deezerUrlParts[2]}; 60 | 61 | case 'spotify': 62 | const spotifyUrlParts = spotifyUri.parse(url); 63 | if (setToken) { 64 | await spotify.setSpotifyAnonymousToken(); 65 | } 66 | return {type: ('spotify-' + spotifyUrlParts.type) as any, id: (spotifyUrlParts as any).id}; 67 | 68 | case 'tidal': 69 | const tidalUrlParts = url.split(/\/(\w+)\/(\d+|\w+-\w+-\w+-\w+-\w+)/); 70 | return {type: ('tidal-' + tidalUrlParts[1]) as any, id: tidalUrlParts[2]}; 71 | 72 | case 'youtube': 73 | let yotubeId = url.split('v=')[1]; 74 | if (yotubeId.includes('&')) { 75 | yotubeId = yotubeId.split('&')[0]; 76 | } 77 | return {type: 'youtube-track', id: yotubeId}; 78 | 79 | case 'youtu.be': 80 | return {type: 'youtube-track', id: url.split('/').pop() as string}; 81 | 82 | default: 83 | throw new Error('Unable to parse URL: ' + url); 84 | } 85 | }; 86 | 87 | /** 88 | * Deezer, Spotify or Tidal links only 89 | * @param {String} url 90 | */ 91 | export const parseInfo = async (url: string) => { 92 | const info = await getUrlParts(url, true); 93 | if (!info.id) { 94 | throw new Error('Unable to parse id'); 95 | } 96 | 97 | let linktype: linkType = 'track'; 98 | let linkinfo: trackType | albumType | playlistInfo | artistInfoType | Record = {}; 99 | let tracks: trackType[] = []; 100 | 101 | switch (info.type) { 102 | case 'track': { 103 | tracks.push(await getTrackInfo(info.id)); 104 | break; 105 | } 106 | 107 | case 'album': 108 | case 'audiobook': 109 | linkinfo = await getAlbumInfo(info.id); 110 | linktype = 'album'; 111 | const albumTracks = await getAlbumTracks(info.id); 112 | tracks = albumTracks.data; 113 | break; 114 | 115 | case 'playlist': 116 | linkinfo = await getPlaylistInfo(info.id); 117 | linktype = 'playlist'; 118 | const playlistTracks = await getPlaylistTracks(info.id); 119 | tracks = playlistTracks.data; 120 | break; 121 | 122 | case 'artist': 123 | linkinfo = await getArtistInfo(info.id); 124 | linktype = 'artist'; 125 | const artistAlbums = await getDiscography(info.id); 126 | await queue.addAll( 127 | artistAlbums.data.map((album) => { 128 | return async () => { 129 | if (album.ARTISTS.find((a) => a.ART_ID === info.id)) { 130 | const albumTracks = await getAlbumTracks(album.ALB_ID); 131 | tracks = [...tracks, ...albumTracks.data.filter((t) => t.ART_ID === info.id)]; 132 | } 133 | }; 134 | }), 135 | ); 136 | break; 137 | 138 | case 'spotify-track': 139 | tracks.push(await spotify.track2deezer(info.id)); 140 | break; 141 | 142 | case 'spotify-album': 143 | const [spotifyAlbumInfo, spotifyTracks] = await spotify.album2deezer(info.id); 144 | tracks = spotifyTracks; 145 | linkinfo = spotifyAlbumInfo; 146 | linktype = 'album'; 147 | break; 148 | 149 | case 'spotify-playlist': 150 | const [spotifyPlaylistInfo, spotifyPlaylistTracks] = await spotify.playlist2Deezer(info.id); 151 | tracks = spotifyPlaylistTracks; 152 | linkinfo = spotifyPlaylistInfo; 153 | linktype = 'playlist'; 154 | break; 155 | 156 | case 'spotify-artist': 157 | tracks = await spotify.artist2Deezer(info.id); 158 | linktype = 'artist'; 159 | break; 160 | 161 | case 'tidal-track': 162 | tracks.push(await tidal.track2deezer(info.id)); 163 | break; 164 | 165 | case 'tidal-album': 166 | const [tidalAlbumInfo, tidalAlbumTracks] = await tidal.album2deezer(info.id); 167 | tracks = tidalAlbumTracks; 168 | linkinfo = tidalAlbumInfo; 169 | linktype = 'album'; 170 | break; 171 | 172 | case 'tidal-playlist': 173 | const [tidalPlaylistInfo, tidalPlaylistTracks] = await tidal.playlist2Deezer(info.id); 174 | tracks = tidalPlaylistTracks; 175 | linkinfo = tidalPlaylistInfo; 176 | linktype = 'playlist'; 177 | break; 178 | 179 | case 'tidal-artist': 180 | tracks = await tidal.artist2Deezer(info.id); 181 | linktype = 'artist'; 182 | break; 183 | 184 | case 'youtube-track': 185 | tracks.push(await youtube.track2deezer(info.id)); 186 | break; 187 | 188 | default: 189 | throw new Error('Unknown type: ' + info.type); 190 | } 191 | 192 | return { 193 | info, 194 | linktype, 195 | linkinfo, 196 | tracks: tracks.map((t) => { 197 | if (t.VERSION && !t.SNG_TITLE.includes(t.VERSION)) { 198 | t.SNG_TITLE += ' ' + t.VERSION; 199 | } 200 | return t; 201 | }), 202 | }; 203 | }; 204 | -------------------------------------------------------------------------------- /src/types/tracks.ts: -------------------------------------------------------------------------------- 1 | import type {artistType} from './artist'; 2 | 3 | interface mediaType { 4 | TYPE: 'preview'; 5 | HREF: string; // 'https://cdns-preview-d.dzcdn.net/stream/c-deda7fa9316d9e9e880d2c6207e92260-8.mp3'; 6 | } 7 | 8 | interface lyricsSync { 9 | lrc_timestamp: string; //'[00:03.58]', 10 | milliseconds: string; // '3580', 11 | duration: string; // '8660', 12 | line: string; // "Hey brother! There's an endless road to rediscover" 13 | } 14 | 15 | export interface lyricsType { 16 | LYRICS_ID?: string; // '2310758', 17 | LYRICS_SYNC_JSON?: lyricsSync[]; 18 | LYRICS_TEXT: string; 19 | LYRICS_COPYRIGHTS?: string; 20 | LYRICS_WRITERS?: string; 21 | } 22 | 23 | interface songType { 24 | ALB_ID: string; // '302127' 25 | ALB_TITLE: string; // 'Discovery' 26 | ALB_PICTURE: string; // '2e018122cb56986277102d2041a592c8' 27 | ARTISTS: artistType[]; 28 | ART_ID: string; // '27' 29 | ART_NAME: string; // 'Daft Punk' 30 | ARTIST_IS_DUMMY: boolean; // false 31 | ART_PICTURE: string; //'f2bc007e9133c946ac3c3907ddc5d2ea' 32 | DATE_START: string; // '0000-00-00' 33 | DISK_NUMBER?: string; // '1' 34 | DURATION: string; // '224' 35 | EXPLICIT_TRACK_CONTENT: { 36 | EXPLICIT_LYRICS_STATUS: number; // 0 37 | EXPLICIT_COVER_STATUS: number; // 0 38 | }; 39 | ISRC: string; // 'GBDUW0000059' 40 | LYRICS_ID: number; // 2780622 41 | LYRICS?: lyricsType; 42 | EXPLICIT_LYRICS?: string; 43 | RANK: string; // '787708' 44 | SMARTRADIO: string; // 0 45 | SNG_ID: string; // '3135556' 46 | SNG_TITLE: string; // 'Harder, Better, Faster, Stronger' 47 | SNG_CONTRIBUTORS?: 48 | | { 49 | main_artist: string[]; //['Daft Punk'] 50 | author?: string[]; // ['Edwin Birdsong', 'Guy-Manuel de Homem-Christo', 'Thomas Bangalter'] 51 | composer?: string[]; 52 | musicpublisher?: string[]; 53 | producer?: string[]; 54 | publisher: string[]; 55 | engineer?: string[]; 56 | writer?: string[]; 57 | mixer?: string[]; 58 | } 59 | | []; 60 | STATUS: number; // 3 61 | S_MOD: number; // 0 62 | S_PREMIUM: number; // 0 63 | TRACK_NUMBER: number; // '4' 64 | URL_REWRITING: string; // 'daft-punk' 65 | VERSION?: string; // '(Extended Club Mix Edit)' 66 | MD5_ORIGIN: string; // '51afcde9f56a132096c0496cc95eb24b' 67 | FILESIZE_AAC_64: '0'; 68 | FILESIZE_MP3_64: string; // '1798059' 69 | FILESIZE_MP3_128: string; // '3596119' 70 | FILESIZE_MP3_256: '0'; 71 | FILESIZE_MP3_320: '0'; 72 | FILESIZE_MP4_RA1: '0'; 73 | FILESIZE_MP4_RA2: '0'; 74 | FILESIZE_MP4_RA3: '0'; 75 | FILESIZE_FLAC: '0'; 76 | FILESIZE: string; //'3596119' 77 | GAIN: string; // '-12.4' 78 | MEDIA_VERSION: string; // '8' 79 | TRACK_TOKEN: string; // 'AAAAAWAzlaRgNK7kyEh8dI3tpyObkIpy15hgDXr4GGiFTJakRmh5F7rMVf6-cYTWZNUIq4TLZj6x68mFstAqp9bml_eUzbfFbvIkpmx_hhDRZJhqLsHe-aBRZ9VdHEBr7LYSE3qKpmpTdDp6Odkrw3f-pNQW' 80 | TRACK_TOKEN_EXPIRE: number; // 1614065380 81 | MEDIA: [mediaType]; 82 | RIGHTS: { 83 | STREAM_ADS_AVAILABLE?: boolean; 84 | STREAM_ADS?: string; // '2000-01-01' 85 | STREAM_SUB_AVAILABLE?: boolean; // true, 86 | STREAM_SUB?: string; // '2000-01-01' 87 | }; 88 | PROVIDER_ID: string; // '3' 89 | __TYPE__: 'song'; 90 | } 91 | 92 | export interface trackType extends songType { 93 | FALLBACK?: songType; 94 | TRACK_POSITION?: number; 95 | } 96 | 97 | export interface contributorsPublicApi { 98 | id: number; // 27 99 | name: string; // 'Daft Punk' 100 | link: string; // 'https://www.deezer.com/artist/27' 101 | share: string; // 'https://www.deezer.com/artist/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1614937516&utm_medium=web' 102 | picture: string; // 'https://api.deezer.com/artist/27/image' 103 | picture_small: string; // 'https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/56x56-000000-80-0-0.jpg' 104 | picture_medium: string; // 'https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/250x250-000000-80-0-0.jpg' 105 | picture_big: string; // 'https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/500x500-000000-80-0-0.jpg' 106 | picture_xl: string; // 'https://e-cdns-images.dzcdn.net/images/artist/f2bc007e9133c946ac3c3907ddc5d2ea/1000x1000-000000-80-0-0.jpg' 107 | radio: boolean; 108 | tracklist: string; // 'https://api.deezer.com/artist/27/top?limit=50' 109 | type: string; // 'artist' 110 | role: string; // 'Main' 111 | } 112 | 113 | export interface trackTypePublicApi { 114 | id: number; // 3135556; 115 | readable: boolean; 116 | title: string; // 'Harder, Better, Faster, Stronger' 117 | title_short: string; // 'Harder, Better, Faster, Stronger' 118 | title_version?: string; // '' 119 | isrc: string; // 'GBDUW0000059' 120 | link: string; // 'https://www.deezer.com/track/3135556' 121 | share: string; // 'https://www.deezer.com/track/3135556?utm_source=deezer&utm_content=track-3135556&utm_term=0_1614937516&utm_medium=web' 122 | duration: number; // 224 123 | track_position: number; // 4 124 | disk_number: number; // 1 125 | rank: number; // 956167 126 | release_date: string; // '2001-03-07' 127 | explicit_lyrics: boolean; 128 | explicit_content_lyrics: number; // 0 129 | explicit_content_cover: number; // 0 130 | preview: string; // 'https://cdns-preview-d.dzcdn.net/stream/c-deda7fa9316d9e9e880d2c6207e92260-8.mp3' 131 | bpm: number; // 123.4 132 | gain: number; // -12.4 133 | available_countries: string[]; 134 | contributors: contributorsPublicApi[]; 135 | md5_image: string; // '2e018122cb56986277102d2041a592c8' 136 | artist: contributorsPublicApi; 137 | album: { 138 | id: number; // 302127 139 | title: string; // 'Discovery' 140 | link: string; // 'https://www.deezer.com/album/302127' 141 | cover: string; // 'https://api.deezer.com/album/302127/image' 142 | cover_small: string; // 'https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/56x56-000000-80-0-0.jpg' 143 | cover_medium: string; // 'https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/250x250-000000-80-0-0.jpg' 144 | cover_big: string; // 'https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/500x500-000000-80-0-0.jpg' 145 | cover_xl: string; // 'https://e-cdns-images.dzcdn.net/images/cover/2e018122cb56986277102d2041a592c8/1000x1000-000000-80-0-0.jpg' 146 | md5_image: string; // '2e018122cb56986277102d2041a592c8' 147 | release_date: string; // '2001-03-07'; 148 | tracklist: string; // 'https://api.deezer.com/album/302127/tracks' 149 | type: 'album'; 150 | }; 151 | type: 'track'; 152 | } 153 | -------------------------------------------------------------------------------- /src/api/api.ts: -------------------------------------------------------------------------------- 1 | import {request, requestLight, requestGet, requestPublicApi} from './request'; 2 | import type { 3 | albumType, 4 | trackType, 5 | lyricsType, 6 | albumTracksType, 7 | showType, 8 | playlistInfo, 9 | playlistTracksType, 10 | playlistChannelType, 11 | channelSearchType, 12 | artistInfoType, 13 | discographyType, 14 | profileType, 15 | searchType, 16 | trackTypePublicApi, 17 | albumTypePublicApi, 18 | userType, 19 | } from '../types'; 20 | 21 | /** 22 | * @param {String} sng_id song id 23 | */ 24 | export const getTrackInfoPublicApi = (sng_id: string): Promise => 25 | requestPublicApi('/track/' + sng_id); 26 | 27 | /** 28 | * @param {String} alb_id album id 29 | */ 30 | export const getAlbumInfoPublicApi = (alb_id: string): Promise => 31 | requestPublicApi('/album/' + alb_id); 32 | 33 | /** 34 | * @param {String} sng_id song id 35 | */ 36 | export const getTrackInfo = (sng_id: string): Promise => request({sng_id}, 'song.getData'); 37 | 38 | /** 39 | * @param {String} sng_id song id 40 | */ 41 | export const getLyrics = (sng_id: string): Promise => request({sng_id}, 'song.getLyrics'); 42 | 43 | /** 44 | * @param {String} alb_id album id 45 | */ 46 | export const getAlbumInfo = (alb_id: string): Promise => request({alb_id}, 'album.getData'); 47 | 48 | /** 49 | * @param {String} alb_id user id 50 | */ 51 | export const getAlbumTracks = async (alb_id: string): Promise => 52 | request({alb_id, lang: 'us', nb: -1}, 'song.getListByAlbum'); 53 | 54 | /** 55 | * @param {String} playlist_id playlist id 56 | */ 57 | export const getPlaylistInfo = (playlist_id: string): Promise => 58 | request({playlist_id, lang: 'en'}, 'playlist.getData'); 59 | 60 | /** 61 | * @param {String} playlist_id playlist id 62 | */ 63 | export const getPlaylistTracks = async (playlist_id: string): Promise => { 64 | const playlistTracks: playlistTracksType = await request( 65 | {playlist_id, lang: 'en', nb: -1, start: 0, tab: 0, tags: true, header: true}, 66 | 'playlist.getSongs', 67 | ); 68 | playlistTracks.data = playlistTracks.data.map((track, index) => { 69 | track.TRACK_POSITION = index + 1; 70 | return track; 71 | }); 72 | return playlistTracks; 73 | }; 74 | 75 | /** 76 | * @param {String} art_id artist id 77 | */ 78 | export const getArtistInfo = (art_id: string): Promise => 79 | request({art_id, filter_role_id: [0], lang: 'en', tab: 0, nb: -1, start: 0}, 'artist.getData'); 80 | 81 | /** 82 | * @param {String} art_id artist id 83 | * @param {String} nb number of total song to fetch 84 | */ 85 | export const getDiscography = (art_id: string, nb = 500): Promise => 86 | request({art_id, filter_role_id: [0], lang: 'en', nb, nb_songs: -1, start: 0}, 'album.getDiscography'); 87 | 88 | /** 89 | * @param {String} user_id user id 90 | */ 91 | export const getProfile = (user_id: string): Promise => 92 | request({user_id, tab: 'loved', nb: -1}, 'mobile.pageUser'); 93 | 94 | /** 95 | * @param {String} artist artist name 96 | * @param {String} song song name 97 | * @param {String} nb number of items to fetch 98 | */ 99 | export const searchAlternative = (artist: string, song: string, nb = 10): Promise => 100 | request( 101 | { 102 | query: `artist:'${artist}' track:'${song}'`, 103 | types: ['TRACK'], 104 | nb, 105 | }, 106 | 'mobile_suggest', 107 | ); 108 | 109 | type searchTypesProp = 'ALBUM' | 'ARTIST' | 'TRACK' | 'PLAYLIST' | 'RADIO' | 'SHOW' | 'USER' | 'LIVESTREAM' | 'CHANNEL'; 110 | /** 111 | * @param {String} query search query 112 | * @param {Array} types search types 113 | * @param {Number} nb number of items to fetch 114 | */ 115 | export const searchMusic = (query: string, types: searchTypesProp[] = ['TRACK'], nb = 15): Promise => 116 | requestLight({query, start: 0, nb, suggest: true, artist_suggest: true, top_tracks: true}, 'deezer.pageSearch'); 117 | 118 | /** 119 | * Get details about current user 120 | */ 121 | export const getUser = async (): Promise => requestGet('user_getInfo'); 122 | 123 | /** 124 | * Get list of channles 125 | */ 126 | export const getChannelList = async (): Promise => request({}, 'search_getChannels'); 127 | 128 | /** 129 | * @param {String} SHOW_ID show id 130 | * @param {Number} NB number of items to fetch 131 | * @param {Number} START start index 132 | */ 133 | export const getShowInfo = (SHOW_ID: string, NB = 1000, START = 0): Promise => 134 | request( 135 | { 136 | SHOW_ID, 137 | NB, 138 | START, 139 | }, 140 | 'mobile.pageShow', 141 | ); 142 | 143 | /** 144 | * Get details about a playlist channel 145 | */ 146 | export const getPlaylistChannel = async (page: string): Promise => { 147 | const gateway_input = { 148 | page, 149 | version: '2.3', 150 | support: { 151 | 'long-card-horizontal-grid': ['album', 'playlist', 'radio', 'show'], 152 | ads: [], 153 | message: [], 154 | highlight: ['generic', 'album', 'artist', 'playlist', 'radio', 'app'], 155 | 'deeplink-list': ['generic', 'deeplink'], 156 | grid: [ 157 | 'generic', 158 | 'album', 159 | 'artist', 160 | 'playlist', 161 | 'radio', 162 | 'channel', 163 | 'show', 164 | 'page', 165 | 'smarttracklist', 166 | 'flow', 167 | 'video-link', 168 | ], 169 | slideshow: ['album', 'artist', 'playlist', 'radio', 'show', 'channel', 'video-link', 'external-link'], 170 | 'large-card': ['generic', 'album', 'artist', 'playlist', 'radio', 'show', 'external-link', 'video-link'], 171 | 'item-highlight': ['radio', 'app'], 172 | 'small-horizontal-grid': ['album', 'artist', 'playlist', 'radio', 'channel', 'show'], 173 | 'grid-preview-two': [ 174 | 'generic', 175 | 'album', 176 | 'artist', 177 | 'playlist', 178 | 'radio', 179 | 'channel', 180 | 'show', 181 | 'page', 182 | 'smarttracklist', 183 | 'flow', 184 | 'video-link', 185 | ], 186 | list: ['generic', 'album', 'artist', 'playlist', 'radio', 'show', 'video-link', 'channel', 'episode'], 187 | 'grid-preview-one': [ 188 | 'generic', 189 | 'album', 190 | 'artist', 191 | 'playlist', 192 | 'radio', 193 | 'channel', 194 | 'show', 195 | 'page', 196 | 'smarttracklist', 197 | 'flow', 198 | 'video-link', 199 | ], 200 | 'horizontal-grid': [ 201 | 'generic', 202 | 'album', 203 | 'artist', 204 | 'playlist', 205 | 'radio', 206 | 'channel', 207 | 'show', 208 | 'video-link', 209 | 'smarttracklist', 210 | 'flow', 211 | ], 212 | }, 213 | lang: 'en', 214 | timezone_offset: '6', 215 | }; 216 | 217 | return await requestGet('app_page_get', {gateway_input}, page); 218 | }; 219 | -------------------------------------------------------------------------------- /src/converter/tidal.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import PQueue from 'p-queue'; 3 | import {isrc2deezer, upc2deezer} from './deezer'; 4 | import type {playlistInfo, trackType} from '../types'; 5 | 6 | interface commonType { 7 | id: number; 8 | title: string; 9 | duration: number; 10 | premiumStreamingOnly: boolean; 11 | trackNumber: number; 12 | copyright: string; 13 | url: string; 14 | explicit: boolean; 15 | audioQuality: string; 16 | artist: { 17 | id: number; 18 | name: string; 19 | type: string; 20 | }; 21 | album: { 22 | id: number; 23 | title: string; 24 | cover: string; 25 | }; 26 | } 27 | 28 | interface tidalTrackType extends commonType { 29 | isrc: string; 30 | editable: boolean; 31 | audioQuality: string; 32 | album: { 33 | id: number; 34 | title: string; 35 | cover: string; 36 | }; 37 | } 38 | 39 | interface tidalAlbumType extends commonType { 40 | cover: string; 41 | videoCover: null | string; 42 | upc: string; 43 | audioQuality: string; 44 | } 45 | 46 | interface tidalPlaylistType { 47 | uuid: string; 48 | title: string; 49 | numberOfTracks: number; 50 | numberOfVideos: number; 51 | creator: { 52 | id: number; 53 | }; 54 | description: string; 55 | duration: number; 56 | lastUpdated: string; 57 | created: string; 58 | type: string; 59 | publicPlaylist: boolean; 60 | url: string; 61 | image: string; 62 | } 63 | 64 | interface listType { 65 | limit: number; 66 | offset: number; 67 | totalNumberOfItems: number; 68 | } 69 | 70 | interface tidalArtistTopTracksType extends listType { 71 | items: tidalTrackType[]; 72 | } 73 | 74 | interface tidalAlbumsTracksType extends listType { 75 | items: tidalAlbumType[]; 76 | } 77 | 78 | interface tidalPlaylistTracksType extends listType { 79 | items: tidalTrackType[]; 80 | } 81 | 82 | const client = axios.create({ 83 | baseURL: 'https://api.tidal.com/v1/', 84 | timeout: 15000, 85 | headers: { 86 | 'user-agent': 'TIDAL/3704 CFNetwork/1220.1 Darwin/20.3.0', 87 | 'x-tidal-token': 'i4ZDjcyhed7Mu47q', 88 | }, 89 | params: {limit: 500, countryCode: 'US'}, 90 | }); 91 | 92 | const queue = new PQueue({concurrency: 25}); 93 | 94 | /** 95 | * Get a track by its id 96 | * @param string} id - track id 97 | * @example tidal.getTrack('64975224') 98 | */ 99 | export const getTrack = async (id: string): Promise => { 100 | const {data} = await client.get(`tracks/${id}`); 101 | return data; 102 | }; 103 | 104 | /** 105 | * Convert a tidal track to deezer 106 | * @param {string} id - track id 107 | */ 108 | export const track2deezer = async (id: string) => { 109 | const track = await getTrack(id); 110 | return await isrc2deezer(track.title, track.isrc); 111 | }; 112 | 113 | /** 114 | * Get an album by its id 115 | * @param {string} id - album id 116 | * @example tidal.getAlbum('80216363') 117 | */ 118 | export const getAlbum = async (id: string): Promise => { 119 | const {data} = await client.get(`albums/${id}`); 120 | return data; 121 | }; 122 | 123 | /** 124 | * Convert a tidal albums to deezer 125 | * @param {string} id - album id 126 | */ 127 | export const album2deezer = async (id: string) => { 128 | const album = await getAlbum(id); 129 | return await upc2deezer(album.title, album.upc); 130 | }; 131 | 132 | /** 133 | * Get album tracks by album id 134 | * @param {string} id - album id 135 | * @example tidal.getAlbumTracks('80216363') 136 | */ 137 | export const getAlbumTracks = async (id: string): Promise => { 138 | const {data} = await client.get(`albums/${id}/tracks`); 139 | return data; 140 | }; 141 | 142 | /** 143 | * Get artist albums by artist id 144 | * @param {string} id - artist id 145 | * @example tidal.getArtistAlbums('3575680') 146 | */ 147 | export const getArtistAlbums = async (id: string): Promise => { 148 | const {data} = await client.get(`artists/${id}/albums`); 149 | data.items = data.items.filter((item: any) => item.artist.id.toString() === id); 150 | return data; 151 | }; 152 | 153 | /** 154 | * Get top tracks by artist 155 | * @param {string} id - artist id 156 | * @example tidal.getArtistTopTracks('3575680') 157 | */ 158 | export const getArtistTopTracks = async (id: string): Promise => { 159 | const {data} = await client.get(`artists/${id}/toptracks`); 160 | data.items = data.items.filter((item: any) => item.artist.id.toString() === id); 161 | return data; 162 | }; 163 | 164 | /** 165 | * Get a playlist by its uuid 166 | * @param {string} uuid - playlist uuid 167 | * @example tidal.getPlaylist('1c5d01ed-4f05-40c4-bd28-0f73099e9648') 168 | */ 169 | export const getPlaylist = async (uuid: string): Promise => { 170 | const {data} = await client.get(`playlists/${uuid}`); 171 | return data; 172 | }; 173 | 174 | /** 175 | * Get playlist tracks by playlist uuid 176 | * @param {string} uuid - playlist uuid 177 | * @example tidal.getPlaylistTracks('1c5d01ed-4f05-40c4-bd28-0f73099e9648') 178 | */ 179 | export const getPlaylistTracks = async (uuid: string): Promise => { 180 | const {data} = await client.get(`playlists/${uuid}/tracks`); 181 | return data; 182 | }; 183 | 184 | /** 185 | * Get valid urls to album art 186 | * @param {string} uuid - album art uuid (can be found as cover property in album object) 187 | * @example tidal.albumArtToUrl('9a56f482-e9cf-46c3-bb21-82710e7854d4') 188 | * @returns {Object} 189 | */ 190 | export const albumArtToUrl = (uuid: string) => { 191 | const baseUrl = `https://resources.tidal.com/images/${uuid.replace(/-/g, '/')}`; 192 | return { 193 | sm: `${baseUrl}/160x160.jpg`, 194 | md: `${baseUrl}/320x320.jpg`, 195 | lg: `${baseUrl}/640x640.jpg`, 196 | xl: `${baseUrl}/1280x1280.jpg`, 197 | }; 198 | }; 199 | 200 | /** 201 | * Find tidal artists tracks on deezer 202 | * @param {string} id - artist id 203 | * @example tidal.artist2Deezer('3575680') 204 | */ 205 | export const artist2Deezer = async ( 206 | id: string, 207 | onError?: (item: tidalTrackType, index: number, err: Error) => void, 208 | ): Promise => { 209 | const {items} = await getArtistTopTracks(id); 210 | const tracks: trackType[] = []; 211 | 212 | await queue.addAll( 213 | items.map((item, index) => { 214 | return async () => { 215 | try { 216 | const track = await isrc2deezer(item.title, item.isrc); 217 | // console.log(signale.success(`Track #${index}: ${item.name}`)); 218 | tracks.push(track); 219 | } catch (err: any) { 220 | if (onError) { 221 | onError(item, index, err); 222 | } 223 | } 224 | }; 225 | }), 226 | ); 227 | 228 | return tracks; 229 | }; 230 | 231 | /** 232 | * Find same set of playlist tracks on deezer 233 | * @param {string} uuid - playlist uuid 234 | * @example tidal.playlist2Deezer('1c5d01ed-4f05-40c4-bd28-0f73099e9648') 235 | */ 236 | export const playlist2Deezer = async ( 237 | uuid: string, 238 | onError?: (item: tidalTrackType, index: number, err: Error) => void, 239 | ): Promise<[playlistInfo, trackType[]]> => { 240 | const body = await getPlaylist(uuid); 241 | const {items} = await getPlaylistTracks(uuid); 242 | const tracks: trackType[] = []; 243 | 244 | await queue.addAll( 245 | items.map((item, index) => { 246 | return async () => { 247 | try { 248 | const track = await isrc2deezer(item.title, item.isrc); 249 | // console.log(signale.success(`Track #${index}: ${item.track.name}`)); 250 | track.TRACK_POSITION = index + 1; 251 | tracks.push(track); 252 | } catch (err: any) { 253 | if (onError) { 254 | onError(item, index, err); 255 | } 256 | } 257 | }; 258 | }), 259 | ); 260 | 261 | const userId = body.creator.id.toString(); 262 | const playlistInfoData: playlistInfo = { 263 | PLAYLIST_ID: body.uuid, 264 | PARENT_USERNAME: userId, 265 | PARENT_USER_ID: userId, 266 | PICTURE_TYPE: 'cover', 267 | PLAYLIST_PICTURE: body.image, 268 | TITLE: body.title, 269 | TYPE: '0', 270 | STATUS: '0', 271 | USER_ID: userId, 272 | DATE_ADD: body.created, 273 | DATE_MOD: body.lastUpdated, 274 | DATE_CREATE: body.created, 275 | NB_SONG: body.numberOfTracks, 276 | NB_FAN: 0, 277 | CHECKSUM: body.created, 278 | HAS_ARTIST_LINKED: false, 279 | IS_SPONSORED: false, 280 | IS_EDITO: false, 281 | __TYPE__: 'playlist', 282 | }; 283 | 284 | return [playlistInfoData, tracks]; 285 | }; 286 | -------------------------------------------------------------------------------- /src/metadata-writer/useragents.ts: -------------------------------------------------------------------------------- 1 | const useragents = [ 2 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 3 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0', 4 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.104 Safari/537.36', 5 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.2 Safari/605.1.15', 6 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:85.0) Gecko/20100101 Firefox/85.0', 7 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', 8 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.146 Safari/537.36', 9 | 'Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0', 10 | 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0', 11 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 12 | 'Mozilla/5.0 (X11; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0', 13 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75', 14 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', 15 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', 16 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 17 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:84.0) Gecko/20100101 Firefox/84.0', 18 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.2 Safari/605.1.15', 19 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', 20 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 21 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', 22 | 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 23 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', 24 | 'Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0', 25 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:84.0) Gecko/20100101 Firefox/84.0', 26 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Safari/605.1.15', 27 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36', 28 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36 Edg/88.0.705.56', 29 | 'Mozilla/5.0 (X11; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0', 30 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', 31 | 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0', 32 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36 Edg/88.0.705.50', 33 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', 34 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 35 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', 36 | 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0', 37 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15', 38 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 OPR/73.0.3856.344', 39 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:85.0) Gecko/20100101 Firefox/85.0', 40 | 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.104 Safari/537.36', 41 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.1 Safari/605.1.15', 42 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:78.0) Gecko/20100101 Firefox/78.0', 43 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:85.0) Gecko/20100101 Firefox/85.0', 44 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63', 45 | 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:85.0) Gecko/20100101 Firefox/85.0', 46 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 47 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.146 Safari/537.36', 48 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36', 49 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.2 Safari/605.1.15', 50 | 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 51 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.146 Safari/537.36', 52 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 53 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.60 YaBrowser/20.12.0.963 Yowser/2.5 Safari/537.36', 54 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 55 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', 56 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', 57 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.1 Safari/605.1.15', 58 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0', 59 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.2 Safari/605.1.15', 60 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36', 61 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 OPR/73.0.3856.329', 62 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:84.0) Gecko/20100101 Firefox/84.0', 63 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', 64 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', 65 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 OPR/73.0.3856.344 (Edition Yx 05)', 66 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0', 67 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', 68 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36', 69 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36 Edg/88.0.705.53', 70 | 'Mozilla/5.0 (Windows NT 6.3; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0', 71 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36', 72 | 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.7113.93 Safari/537.36', 73 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.66', 74 | 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', 75 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.92 Safari/537.36', 76 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 Safari/605.1.15', 77 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.3 Safari/605.1.15', 78 | 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0', 79 | 'Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0', 80 | 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:79.0) Gecko/20100101 Firefox/79.0', 81 | ]; 82 | 83 | export const randomUseragent = () => useragents[Math.floor(Math.random() * useragents.length)]; 84 | -------------------------------------------------------------------------------- /__tests__/api.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import axios from 'axios'; 3 | import * as api from '../src'; 4 | import {decryptDownload} from '../src/lib/decrypt'; 5 | import {downloadAlbumCover} from '../src/metadata-writer/abumCover'; 6 | import {getLyricsMusixmatch} from '../src/metadata-writer/musixmatchLyrics'; 7 | import {getTrackDownloadUrl} from '../src/lib/get-url'; 8 | 9 | // Harder, Better, Faster, Stronger by Daft Punk 10 | const SNG_ID = '3135556'; 11 | 12 | // Discovery by Daft Punk 13 | const ALB_ID = '302127'; 14 | 15 | test.serial('GET USER INFO', async (t) => { 16 | // Init api with hifi account 17 | if (process.env.HIFI_ARL) { 18 | await api.initDeezerApi(process.env.HIFI_ARL as string); 19 | } 20 | 21 | // Now get user info 22 | const response = await api.getUser(); 23 | 24 | t.truthy(response.BLOG_NAME); 25 | t.truthy(response.EMAIL); 26 | t.truthy(response.USER_ID); 27 | t.is(response.__TYPE__, 'user'); 28 | }); 29 | 30 | test('GET TRACK INFO', async (t) => { 31 | const response = await api.getTrackInfo(SNG_ID); 32 | 33 | t.is(response.SNG_ID, SNG_ID); 34 | t.is(response.ISRC, 'GBDUW0000059'); 35 | t.is(response.MD5_ORIGIN, '51afcde9f56a132096c0496cc95eb24b'); 36 | t.is(response.__TYPE__, 'song'); 37 | }); 38 | 39 | test('GET TRACK INFO - PUBLIC API', async (t) => { 40 | const response = await api.getTrackInfoPublicApi(SNG_ID); 41 | 42 | t.is(response.id, Number(SNG_ID)); 43 | t.is(response.isrc, 'GBDUW0000059'); 44 | t.is(response.type, 'track'); 45 | }); 46 | 47 | test('GET TRACK COVER', async (t) => { 48 | const track = await api.getTrackInfo(SNG_ID); 49 | const cover = (await downloadAlbumCover(track, 500)) as Buffer; 50 | 51 | t.truthy(cover); 52 | t.true(Buffer.isBuffer(cover)); 53 | t.is(cover.length, 24573); 54 | }); 55 | 56 | test('GET TRACK LYRICS', async (t) => { 57 | const response = await api.getLyrics(SNG_ID); 58 | 59 | t.is(response.LYRICS_ID, '2780622'); 60 | t.is(response.LYRICS_TEXT.length, 1719); 61 | }); 62 | 63 | test('GET ALBUM INFO', async (t) => { 64 | const response = await api.getAlbumInfo(ALB_ID); 65 | 66 | t.is(response.ALB_ID, ALB_ID); 67 | t.is(response.UPC, '724384960650'); 68 | t.is(response.__TYPE__, 'album'); 69 | }); 70 | 71 | test('GET ALBUM INFO - PUBLIC API', async (t) => { 72 | const response = await api.getAlbumInfoPublicApi(ALB_ID); 73 | 74 | t.is(response.id, Number(ALB_ID)); 75 | t.is(response.upc, '724384960650'); 76 | t.is(response.type, 'album'); 77 | }); 78 | 79 | test('GET ALBUM TRACKS', async (t) => { 80 | const response = await api.getAlbumTracks(ALB_ID); 81 | 82 | t.is(response.count, 14); 83 | t.is(response.data.length, response.count); 84 | }); 85 | 86 | test('GET PLAYLIST INFO', async (t) => { 87 | const PLAYLIST_ID = '4523119944'; 88 | const response = await api.getPlaylistInfo(PLAYLIST_ID); 89 | 90 | t.truthy(response.NB_SONG > 0); 91 | t.is(response.PARENT_USERNAME, 'sayem314'); 92 | t.is(response.__TYPE__, 'playlist'); 93 | }); 94 | 95 | test('GET PLAYLIST TRACKS', async (t) => { 96 | const PLAYLIST_ID = '4523119944'; 97 | const response = await api.getPlaylistTracks(PLAYLIST_ID); 98 | 99 | t.truthy(response.count > 0); 100 | t.is(response.data.length, response.count); 101 | }); 102 | 103 | test('GET ARTIST INFO', async (t) => { 104 | const ART_ID = '13'; 105 | const response = await api.getArtistInfo(ART_ID); 106 | 107 | t.is(response.ART_NAME, 'Eminem'); 108 | t.is(response.__TYPE__, 'artist'); 109 | }); 110 | 111 | test('GET ARTIST TRACKS', async (t) => { 112 | const ART_ID = '13'; 113 | const response = await api.getDiscography(ART_ID, 10); 114 | 115 | t.is(response.count, 10); 116 | t.is(response.data.length, response.count); 117 | }); 118 | 119 | test('GET USER PROFILE', async (t) => { 120 | const USER_ID = '2064440442'; 121 | const response = await api.getProfile(USER_ID); 122 | 123 | t.is(response.USER.BLOG_NAME, 'sayem314'); 124 | t.is(response.USER.__TYPE__, 'user'); 125 | }); 126 | 127 | test('GET TRACK ALTERNATIVE', async (t) => { 128 | const ARTIST = 'Eminem'; 129 | const TRACK = 'The Real Slim Shady'; 130 | const response = await api.searchAlternative(ARTIST, TRACK); 131 | 132 | t.is(response.QUERY, `artist:'${ARTIST.toLowerCase()}' track:'${TRACK.toLowerCase()}'`); 133 | t.is(response.TRACK.data.length, response.TRACK.count); 134 | }); 135 | 136 | test('SEARCH TRACK, ALBUM & ARTIST', async (t) => { 137 | const QUERY = 'Eminem'; 138 | const response = await api.searchMusic(QUERY, ['TRACK', 'ALBUM', 'ARTIST'], 1); 139 | 140 | t.is(response.QUERY, QUERY); 141 | t.truthy(response.TRACK.count > 0); 142 | t.truthy(response.ALBUM.count > 0); 143 | t.truthy(response.ARTIST.count > 0); 144 | }); 145 | 146 | if (process.env.CI) { 147 | test('DOWNLOAD TRACK128 & ADD METADATA', async (t) => { 148 | const track = await api.getTrackInfo(SNG_ID); 149 | const trackData = await getTrackDownloadUrl(track, 1); 150 | if (!trackData) throw new Error('Selected track+quality are unavailable'); 151 | const {data} = await axios.get(trackData.trackUrl, {responseType: 'arraybuffer'}); 152 | 153 | t.truthy(data); 154 | t.true(Buffer.isBuffer(data)); 155 | t.is(data.length, 3596119); 156 | 157 | const decryptedTrack: Buffer = trackData.isEncrypted ? decryptDownload(data, track.SNG_ID) : data; 158 | t.true(Buffer.isBuffer(decryptedTrack)); 159 | t.is(decryptedTrack.length, 3596119); 160 | 161 | const trackWithMetadata = await api.addTrackTags(decryptedTrack, track, 500); 162 | t.true(Buffer.isBuffer(trackWithMetadata)); 163 | t.is(trackWithMetadata.length, 3629133); 164 | }); 165 | 166 | // test('TRACK128 WITHOUT ALBUM INFO', async (t) => { 167 | // const track = await api.getTrackInfo('912254892'); 168 | // const trackData = await getTrackDownloadUrl(track, 1); 169 | // if (!trackData) throw new Error("Selected track+quality are unavailable"); 170 | // const {data} = await axios.get(trackData.trackUrl, {responseType: 'arraybuffer'}); 171 | 172 | // t.truthy(data); 173 | // t.true(Buffer.isBuffer(data)); 174 | // t.is(data.length, 3262170); 175 | 176 | // const decryptedTrack: Buffer = trackData.isEncrypted ? decryptDownload(data, track.SNG_ID) : data; 177 | // t.true(Buffer.isBuffer(decryptedTrack)); 178 | // t.is(decryptedTrack.length, 3262170); 179 | 180 | // if (!process.env.CI) { 181 | // const trackWithMetadata = await api.addTrackTags(decryptedTrack, track, 500); 182 | // t.true(Buffer.isBuffer(trackWithMetadata)); 183 | // t.true(trackWithMetadata.length === 3326050); 184 | // } 185 | // }); 186 | 187 | test('DOWNLOAD TRACK320 & ADD METADATA', async (t) => { 188 | const track = await api.getTrackInfo(SNG_ID); 189 | const trackData = await getTrackDownloadUrl(track, 3); 190 | if (!trackData) throw new Error('Selected track+quality are unavailable'); 191 | const {data} = await axios.get(trackData.trackUrl, {responseType: 'arraybuffer'}); 192 | 193 | t.truthy(data); 194 | t.true(Buffer.isBuffer(data)); 195 | t.is(data.length, 8990301); 196 | 197 | const decryptedTrack: Buffer = trackData.isEncrypted ? decryptDownload(data, track.SNG_ID) : data; 198 | t.true(Buffer.isBuffer(decryptedTrack)); 199 | t.is(decryptedTrack.length, 8990301); 200 | 201 | const trackWithMetadata = await api.addTrackTags(decryptedTrack, track, 500); 202 | t.true(Buffer.isBuffer(trackWithMetadata)); 203 | t.is(trackWithMetadata.length, 9023315); 204 | }); 205 | 206 | test('DOWNLOAD TRACK1411 & ADD METADATA', async (t) => { 207 | const track = await api.getTrackInfo(SNG_ID); 208 | const trackData = await getTrackDownloadUrl(track, 9); 209 | if (!trackData) throw new Error('Selected track+quality are unavailable'); 210 | const {data} = await axios.get(trackData.trackUrl, {responseType: 'arraybuffer'}); 211 | 212 | t.truthy(data); 213 | t.true(Buffer.isBuffer(data)); 214 | t.is(data.length, 25418289); 215 | 216 | const decryptedTrack: Buffer = trackData.isEncrypted ? decryptDownload(data, track.SNG_ID) : data; 217 | t.true(Buffer.isBuffer(decryptedTrack)); 218 | t.is(data.length, 25418289); 219 | 220 | const trackWithMetadata = await api.addTrackTags(decryptedTrack, track, 500); 221 | t.true(Buffer.isBuffer(trackWithMetadata)); 222 | t.is(trackWithMetadata.length, 25453343); 223 | }); 224 | } else { 225 | test('GET MUSIXMATCH LYRICS', async (t) => { 226 | const track = await api.getTrackInfo(SNG_ID); 227 | const lyrics = await getLyricsMusixmatch(`${track.ART_NAME} - ${track.SNG_TITLE}`); 228 | 229 | t.truthy(lyrics); 230 | t.true(lyrics.length > 1600); 231 | t.true(lyrics.length < 1700); 232 | }); 233 | } 234 | 235 | test('GET SHOW LIST', async (t) => { 236 | const show = await api.getShowInfo('338532', 10); 237 | t.is(show.DATA.LABEL_ID, '201952'); 238 | t.is(show.EPISODES.count, 10); 239 | t.true(Array.isArray(show.EPISODES.data)); 240 | }); 241 | 242 | test('GET CHANNEL LIST', async (t) => { 243 | const channel = await api.getChannelList(); 244 | t.is(channel.count, channel.data.length); 245 | t.true(Array.isArray(channel.data)); 246 | }); 247 | 248 | test('GET PLAYLIST CHANNEL', async (t) => { 249 | const channel = await api.getPlaylistChannel('channels/dance'); 250 | t.deepEqual(Object.keys(channel), ['version', 'page_id', 'ga', 'title', 'persistent', 'sections', 'expire']); 251 | t.truthy(channel.title); 252 | t.true(Array.isArray(channel.sections)); 253 | }); 254 | -------------------------------------------------------------------------------- /src/lib/metaflac-js.ts: -------------------------------------------------------------------------------- 1 | // const BLOCK_TYPE = { 2 | // 0: 'STREAMINFO', 3 | // 1: 'PADDING', 4 | // 2: 'APPLICATION', 5 | // 3: 'SEEKTABLE', 6 | // 4: 'VORBIS_COMMENT', // There may be only one VORBIS_COMMENT block in a stream. 7 | // 5: 'CUESHEET', 8 | // 6: 'PICTURE', 9 | // }; 10 | 11 | const STREAMINFO = 0; 12 | const PADDING = 1; 13 | const APPLICATION = 2; 14 | const SEEKTABLE = 3; 15 | const VORBIS_COMMENT = 4; 16 | const CUESHEET = 5; 17 | const PICTURE = 6; 18 | 19 | const formatVorbisComment = (vendorString: string, commentList: []) => { 20 | const bufferArray = []; 21 | const vendorStringBuffer = Buffer.from(vendorString, 'utf8'); 22 | const vendorLengthBuffer = Buffer.alloc(4); 23 | vendorLengthBuffer.writeUInt32LE(vendorStringBuffer.length); 24 | 25 | const userCommentListLengthBuffer = Buffer.alloc(4); 26 | userCommentListLengthBuffer.writeUInt32LE(commentList.length); 27 | 28 | bufferArray.push(vendorLengthBuffer, vendorStringBuffer, userCommentListLengthBuffer); 29 | 30 | for (let i = 0; i < commentList.length; i++) { 31 | const comment = commentList[i]; 32 | const commentBuffer = Buffer.from(comment, 'utf8'); 33 | const lengthBuffer = Buffer.alloc(4); 34 | lengthBuffer.writeUInt32LE(commentBuffer.length); 35 | bufferArray.push(lengthBuffer, commentBuffer); 36 | } 37 | 38 | return Buffer.concat(bufferArray); 39 | }; 40 | 41 | class Metaflac { 42 | buffer: Buffer; 43 | marker: string; 44 | streamInfo: any; 45 | blocks: any[]; 46 | padding: any; 47 | vorbisComment: any; 48 | vendorString: string; 49 | tags: any; 50 | pictures: Buffer[]; 51 | picturesSpecs: object[]; 52 | picturesDatas: string[]; 53 | framesOffset: number; 54 | 55 | constructor(flac: Buffer) { 56 | this.buffer = flac; 57 | this.marker = ''; 58 | this.streamInfo = null; 59 | this.blocks = []; 60 | this.padding = null; 61 | this.vorbisComment = null; 62 | this.vendorString = ''; 63 | this.tags = []; 64 | this.pictures = []; 65 | this.picturesSpecs = []; 66 | this.picturesDatas = []; 67 | this.framesOffset = 0; 68 | this.init(); 69 | } 70 | 71 | init() { 72 | let offset = 4; 73 | let blockType = 0; 74 | let isLastBlock = false; 75 | while (!isLastBlock) { 76 | blockType = this.buffer.readUInt8(offset++); 77 | isLastBlock = blockType >= 128; 78 | blockType = blockType % 128; 79 | 80 | const blockLength = this.buffer.readUIntBE(offset, 3); 81 | offset += 3; 82 | 83 | if (blockType === STREAMINFO) { 84 | this.streamInfo = this.buffer.slice(offset, offset + blockLength); 85 | } 86 | 87 | if (blockType === VORBIS_COMMENT) { 88 | this.vorbisComment = this.buffer.slice(offset, offset + blockLength); 89 | this.parseVorbisComment(); 90 | } 91 | 92 | if ([APPLICATION, SEEKTABLE, CUESHEET].includes(blockType)) { 93 | this.blocks.push([blockType, this.buffer.slice(offset, offset + blockLength)]); 94 | } 95 | offset += blockLength; 96 | } 97 | this.framesOffset = offset; 98 | } 99 | 100 | parseVorbisComment() { 101 | const vendorLength = this.vorbisComment.readUInt32LE(0); 102 | this.vendorString = this.vorbisComment.slice(4, vendorLength + 4).toString('utf8'); 103 | } 104 | 105 | parsePictureBlock() { 106 | this.pictures.forEach((picture: any) => { 107 | let offset = 0; 108 | const type = picture.readUInt32BE(offset); 109 | offset += 4; 110 | const mimeTypeLength = picture.readUInt32BE(offset); 111 | offset += 4; 112 | const mime = picture.slice(offset, offset + mimeTypeLength).toString('ascii'); 113 | offset += mimeTypeLength; 114 | const descriptionLength = picture.readUInt32BE(offset); 115 | offset += 4; 116 | const description = picture.slice(offset, (offset += descriptionLength)).toString('utf8'); 117 | const width = picture.readUInt32BE(offset); 118 | offset += 4; 119 | const height = picture.readUInt32BE(offset); 120 | offset += 4; 121 | const depth = picture.readUInt32BE(offset); 122 | offset += 4; 123 | const colors = picture.readUInt32BE(offset); 124 | offset += 4; 125 | const pictureDataLength = picture.readUInt32BE(offset); 126 | offset += 4; 127 | this.picturesDatas.push(picture.slice(offset, offset + pictureDataLength)); 128 | this.picturesSpecs.push( 129 | this.buildSpecification({ 130 | type, 131 | mime, 132 | description, 133 | width, 134 | height, 135 | depth, 136 | colors, 137 | }), 138 | ); 139 | }); 140 | } 141 | 142 | getPicturesSpecs() { 143 | return this.picturesSpecs; 144 | } 145 | 146 | /** 147 | * Get the MD5 signature from the STREAMINFO block. 148 | */ 149 | getMd5sum() { 150 | return this.streamInfo.slice(18, 34).toString('hex'); 151 | } 152 | 153 | /** 154 | * Get the minimum block size from the STREAMINFO block. 155 | */ 156 | getMinBlocksize() { 157 | return this.streamInfo.readUInt16BE(0); 158 | } 159 | 160 | /** 161 | * Get the maximum block size from the STREAMINFO block. 162 | */ 163 | getMaxBlocksize() { 164 | return this.streamInfo.readUInt16BE(2); 165 | } 166 | 167 | /** 168 | * Get the minimum frame size from the STREAMINFO block. 169 | */ 170 | getMinFramesize() { 171 | return this.streamInfo.readUIntBE(4, 3); 172 | } 173 | 174 | /** 175 | * Get the maximum frame size from the STREAMINFO block. 176 | */ 177 | getMaxFramesize() { 178 | return this.streamInfo.readUIntBE(7, 3); 179 | } 180 | 181 | /** 182 | * Get the sample rate from the STREAMINFO block. 183 | */ 184 | getSampleRate() { 185 | // 20 bits number 186 | return this.streamInfo.readUIntBE(10, 3) >> 4; 187 | } 188 | 189 | /** 190 | * Get the number of channels from the STREAMINFO block. 191 | */ 192 | getChannels() { 193 | // 3 bits 194 | return this.streamInfo.readUIntBE(10, 3) & (0x00000f >> 1); 195 | } 196 | 197 | /** 198 | * Get the # of bits per sample from the STREAMINFO block. 199 | */ 200 | getBps() { 201 | return this.streamInfo.readUIntBE(12, 2) & (0x01f0 >> 4); 202 | } 203 | 204 | /** 205 | * Get the total # of samples from the STREAMINFO block. 206 | */ 207 | getTotalSamples() { 208 | return this.streamInfo.readUIntBE(13, 5) & 0x0fffffffff; 209 | } 210 | 211 | /** 212 | * Show the vendor string from the VORBIS_COMMENT block. 213 | */ 214 | getVendorTag() { 215 | return this.vendorString; 216 | } 217 | 218 | /** 219 | * Get all tags where the the field name matches NAME. 220 | * 221 | * @param {string} name 222 | */ 223 | getTag(name: string) { 224 | return this.tags 225 | .filter((item: string) => { 226 | const itemName = item.split('=')[0]; 227 | return itemName === name; 228 | }) 229 | .join('\n'); 230 | } 231 | 232 | /** 233 | * Remove all tags whose field name is NAME. 234 | * 235 | * @param {string} name 236 | */ 237 | removeTag(name: string) { 238 | this.tags = this.tags.filter((item: string) => { 239 | const itemName = item.split('=')[0]; 240 | return itemName !== name; 241 | }); 242 | } 243 | 244 | /** 245 | * Remove first tag whose field name is NAME. 246 | * 247 | * @param {string} name 248 | */ 249 | removeFirstTag(name: string) { 250 | const found = this.tags.findIndex((item: string) => { 251 | return item.split('=')[0] === name; 252 | }); 253 | if (found !== -1) { 254 | this.tags.splice(found, 1); 255 | } 256 | } 257 | 258 | /** 259 | * Remove all tags, leaving only the vendor string. 260 | */ 261 | removeAllTags() { 262 | this.tags = []; 263 | } 264 | 265 | /** 266 | * Add a tag. 267 | * The FIELD must comply with the Vorbis comment spec, of the form NAME=VALUE. If there is currently no tag block, one will be created. 268 | * 269 | * @param {string} field 270 | */ 271 | setTag(field: string) { 272 | if (field.indexOf('=') === -1) { 273 | throw new Error(`malformed vorbis comment field "${field}", field contains no '=' character`); 274 | } 275 | this.tags.push(field); 276 | } 277 | 278 | /** 279 | * Import a picture and store it in a PICTURE metadata block. 280 | * 281 | * @param {string} filename 282 | */ 283 | importPicture(picture: Buffer, dimension: number, mime: 'image/jpeg' | 'image/png') { 284 | const spec = this.buildSpecification({ 285 | mime, 286 | width: dimension, 287 | height: dimension, 288 | }); 289 | 290 | this.pictures.push(this.buildPictureBlock(picture, spec)); 291 | this.picturesSpecs.push(spec); 292 | } 293 | 294 | /** 295 | * Return all tags. 296 | */ 297 | getAllTags() { 298 | return this.tags; 299 | } 300 | 301 | buildSpecification(spec = {}) { 302 | const defaults = { 303 | type: 3, 304 | mime: 'image/jpeg', 305 | description: '', 306 | width: 0, 307 | height: 0, 308 | depth: 24, 309 | colors: 0, 310 | }; 311 | return Object.assign(defaults, spec); 312 | } 313 | 314 | /** 315 | * Build a picture block. 316 | * 317 | * @param {Buffer} picture 318 | * @param {Object} specification 319 | * @returns {Buffer} 320 | */ 321 | buildPictureBlock(picture: Buffer, specification: any = {}) { 322 | const pictureType = Buffer.alloc(4); 323 | const mimeLength = Buffer.alloc(4); 324 | const mime = Buffer.from(specification.mime, 'ascii'); 325 | const descriptionLength = Buffer.alloc(4); 326 | const description = Buffer.from(specification.description, 'utf8'); 327 | const width = Buffer.alloc(4); 328 | const height = Buffer.alloc(4); 329 | const depth = Buffer.alloc(4); 330 | const colors = Buffer.alloc(4); 331 | const pictureLength = Buffer.alloc(4); 332 | 333 | pictureType.writeUInt32BE(specification.type); 334 | mimeLength.writeUInt32BE(specification.mime.length); 335 | descriptionLength.writeUInt32BE(specification.description.length); 336 | width.writeUInt32BE(specification.width); 337 | height.writeUInt32BE(specification.height); 338 | depth.writeUInt32BE(specification.depth); 339 | colors.writeUInt32BE(specification.colors); 340 | pictureLength.writeUInt32BE(picture.length); 341 | 342 | return Buffer.concat([ 343 | pictureType, 344 | mimeLength, 345 | mime, 346 | descriptionLength, 347 | description, 348 | width, 349 | height, 350 | depth, 351 | colors, 352 | pictureLength, 353 | picture, 354 | ]); 355 | } 356 | 357 | buildMetadataBlock(type: number, block: Buffer, isLast = false) { 358 | const header = Buffer.alloc(4); 359 | if (isLast) { 360 | type += 128; 361 | } 362 | header.writeUIntBE(type, 0, 1); 363 | header.writeUIntBE(block.length, 1, 3); 364 | return Buffer.concat([header, block]); 365 | } 366 | 367 | buildMetadata() { 368 | const bufferArray = []; 369 | bufferArray.push(this.buildMetadataBlock(STREAMINFO, this.streamInfo)); 370 | this.blocks.forEach((block: Buffer) => { 371 | // @ts-ignore 372 | bufferArray.push(this.buildMetadataBlock(...block)); 373 | }); 374 | bufferArray.push(this.buildMetadataBlock(VORBIS_COMMENT, formatVorbisComment(this.vendorString, this.tags))); 375 | this.pictures.forEach((block: Buffer) => { 376 | bufferArray.push(this.buildMetadataBlock(PICTURE, block)); 377 | }); 378 | if (this.padding == null) { 379 | this.padding = Buffer.alloc(16384); 380 | } 381 | bufferArray.push(this.buildMetadataBlock(PADDING, this.padding, true)); 382 | return bufferArray; 383 | } 384 | 385 | buildStream() { 386 | const metadata = this.buildMetadata(); 387 | return [this.buffer.slice(0, 4), ...metadata, this.buffer.slice(this.framesOffset)]; 388 | } 389 | 390 | /** 391 | * Save changes to buffer and return changed buffer 392 | */ 393 | getBuffer() { 394 | return Buffer.from(Buffer.concat(this.buildStream())); 395 | } 396 | } 397 | 398 | export default Metaflac; 399 | --------------------------------------------------------------------------------