123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361 |
- #!/usr/bin/env node
- import {EOL} from 'os';
- import {readFileSync, writeFileSync} from 'fs';
- import {dirname, join, resolve, sep} from 'path';
- import {Command} from 'commander';
- import gradient from 'gradient-string';
- import {getUser, initDeezerApi, searchMusic, parseInfo, getDiscography} from 'd-fi-core';
- import prompts from 'prompts';
- import logUpdate from 'log-update';
- import PQueue from 'p-queue';
- import chalk from 'chalk';
- import {trueCasePathSync} from 'true-case-path';
- import signale from './lib/signale';
- import downloadTrack from './lib/download-track';
- import Config from './lib/config';
- import updateCheck from './lib/update-check';
- import autoUpdater from './lib/auto-updater';
- import {commonPath, formatSecondsReadable, sanitizeFilename} from './lib/util';
- import pkg from '../package.json';
- import type {artistType, trackType, albumType, playlistInfo, playlistInfoMinimal} from 'd-fi-core/dist/types';
- // App info
- console.log(
- gradient('red', 'yellow', 'orange')(` ♥ d-fi - ${pkg.version} ♥ `) +
- '\n' +
- gradient('orange', 'yellow', 'red')(' ──────────────────────────────────────────────') +
- '\n' +
- gradient('red', 'yellow', 'orange')(' │ repo https://notabug.org/sayem314/d-fi │ ') +
- '\n' +
- gradient('red', 'yellow', 'orange')(' │ github https://github.com/sayem314 │ ') +
- '\n' +
- gradient('red', 'yellow', 'orange')(' │ coffee https://ko-fi.com/sayemchowdhury │ ') +
- '\n' +
- gradient('red', 'yellow', 'orange')(' ──────────────────────────────────────────────'),
- );
- const cmd = new Command()
- .option('-q, --quality <quality>', 'The quality of the files to download: 128/320/flac ')
- .option('-o, --output <template>', 'Output filename template')
- .option('-u, --url <url>', 'Deezer album/artist/playlist/track url')
- .option('-i, --input-file <file>', 'Downloads all urls listed in text file')
- .option('-c, --concurrency <number>', 'Download concurrency for album, artists and playlist')
- .option('-a, --set-arl <string>', 'Set arl cookie')
- .option('-d, --headless', 'Run in headless mode for scripting automation', false)
- .option('-conf, --config-file <file>', 'Custom location to your config file', 'd-fi.config.json')
- .option('-rfp, --resolve-full-path', 'Use absolute path for playlists')
- .option('-cp, --create-playlist', 'Force create a playlist file for non playlists');
- if ((process as any).pkg) {
- cmd.option('-U, --update', 'Update this program to latest version');
- }
- const options = cmd.parse(process.argv).opts();
- if (!options.url && cmd.args[0]) {
- options.url = cmd.args[0];
- }
- if (options.headless && !options.quality) {
- console.error(signale.error('Missing parameters --quality'));
- console.error(signale.note('Quality must be provided with headless mode'));
- process.exit(1);
- }
- if (options.headless && !options.url && !options.inputFile) {
- console.error(signale.error('Missing parameters --url'));
- console.error(signale.note('URL must be provided with headless mode'));
- process.exit(1);
- }
- const conf = new Config(options.configFile);
- if (conf.userConfigLocation) {
- console.log(signale.info('Config loaded --> ' + conf.userConfigLocation));
- }
- const queue = new PQueue({concurrency: Number(options.concurrency || conf.get('concurrency'))});
- const urlRegex = /https?:\/\/.*\w+\.\w+\/\w+/;
- const onCancel = () => {
- console.info(signale.note('Aborted!'));
- process.exit();
- };
- const startDownload = async (saveLayout: any, url: string, skipPrompt: boolean) => {
- try {
- if (!options.quality) {
- const {musicQuality} = await prompts(
- [
- {
- type: 'select',
- name: 'musicQuality',
- message: 'Select music quality:',
- choices: [
- {title: 'MP3 - 128 kbps', value: '128'},
- {title: 'MP3 - 320 kbps', value: '320'},
- {title: 'FLAC - 1411 kbps', value: 'flac'},
- ],
- initial: 1,
- },
- ],
- {onCancel},
- );
- options.quality = musicQuality;
- }
- if (!url) {
- const {query} = await prompts(
- [
- {
- type: 'text',
- name: 'query',
- message: 'Enter URL or search:',
- validate: (value) => (value ? true : false),
- },
- ],
- {onCancel},
- );
- url = query;
- }
- let searchData: {
- info: {type: 'track'; id: string};
- linktype: 'track';
- /* eslint-disable-next-line */
- linkinfo: {};
- tracks: trackType[];
- } | null = null;
- if (!url.match(urlRegex)) {
- if (options.headless) {
- throw new Error('Please provide a valid URL. Unknown URL: ' + url);
- }
- if (url.startsWith('artist:')) {
- const {ARTIST} = await searchMusic(url.replace('artist:', ''), ['ARTIST'], 50);
- const choice: {items: artistType} = await prompts(
- [
- {
- type: 'select',
- name: 'items',
- message: `Select one artist. (found ${ARTIST.data.length} artists)`,
- choices: ARTIST.data.map((a) => ({
- title: a.ART_NAME,
- value: a,
- description: `${a.NB_FAN} fans`,
- })),
- },
- ],
- {onCancel},
- );
- console.log(signale.info('Fetching data. Please hold on.'));
- url = `https://deezer.com/us/artist/${choice.items.ART_ID}`;
- } else if (url.startsWith('album:')) {
- const {ALBUM} = await searchMusic(url.replace('album:', ''), ['ALBUM'], 50);
- const choice: {items: albumType} = await prompts(
- [
- {
- type: 'select',
- name: 'items',
- message: `Select one album. (found ${ALBUM.data.length} albums)`,
- choices: ALBUM.data.map((a) => ({
- title: a.ALB_TITLE,
- value: a,
- description: `by ${a.ART_NAME}, ${a.NUMBER_TRACK} tracks`,
- })),
- },
- ],
- {onCancel},
- );
- url = `https://deezer.com/us/album/${choice.items.ALB_ID}`;
- } else if (url.startsWith('playlist:')) {
- const {PLAYLIST} = await searchMusic(url.replace('playlist:', ''), ['PLAYLIST'], 50);
- const choice: {items: playlistInfoMinimal} = await prompts(
- [
- {
- type: 'select',
- name: 'items',
- message: `Select one playlist. (found ${PLAYLIST.data.length} playlists)`,
- choices: PLAYLIST.data.map((p) => ({
- title: p.TITLE,
- value: p,
- description: `by ${p.PARENT_USERNAME}, ${p.NB_SONG} tracks`,
- })),
- },
- ],
- {onCancel},
- );
- url = `https://deezer.com/us/playlist/${choice.items.PLAYLIST_ID}`;
- } else {
- const {TRACK} = await searchMusic(url, ['TRACK']);
- searchData = {
- info: {type: 'track', id: url},
- linktype: 'track',
- linkinfo: {},
- tracks: TRACK.data.map((t) => {
- if (t.VERSION && !t.SNG_TITLE.includes(t.VERSION)) {
- t.SNG_TITLE += ' ' + t.VERSION;
- }
- return t;
- }),
- };
- }
- } else if (url.match(/playlist|artist/)) {
- console.log(signale.info('Fetching data. Please hold on.'));
- }
- const data = searchData ? searchData : await parseInfo(url);
- if (!options.headless && data.tracks.length > 1) {
- const choices: {items: trackType[]} = await prompts(
- [
- {
- type: 'multiselect',
- name: 'items',
- message: `Select songs to download. Total of ${data.tracks.length} tracks.`,
- choices: data.tracks.map((t) => ({
- title: t.SNG_TITLE,
- value: t,
- description: `Artist: ${t.ART_NAME}\nAlbum: ${t.ALB_TITLE}\nDuration: ${formatSecondsReadable(
- Number(t.DURATION),
- )}`,
- })),
- },
- ],
- {onCancel},
- );
- data.tracks = choices.items;
- }
- if (data && data.tracks.length > 0) {
- console.log(signale.info(`Proceeding to download ${data.tracks.length} tracks. Be patient.`));
- if (data.linktype === 'playlist') {
- const filteredTracks = data.tracks.filter(
- (item, index, self) => index === self.findIndex((t) => t.SNG_ID === item.SNG_ID),
- );
- const duplicateTracks = data.tracks.length - filteredTracks.length;
- if (duplicateTracks > 0) {
- data.tracks = filteredTracks
- .sort((a: any, b: any) => a.TRACK_POSITION - b.TRACK_POSITION)
- .map((t, i) => {
- t.TRACK_POSITION = i + 1;
- return t;
- });
- console.log(
- signale.warn(`Removed ${duplicateTracks} duplicate ${duplicateTracks > 1 ? 'tracks' : 'track'}.`),
- );
- }
- }
- const coverSizes = conf.get('coverSize') as any;
- const trackNumber = conf.get('trackNumber', true) as boolean;
- const fallbackTrack = conf.get('fallbackTrack', true) as boolean;
- const fallbackQuality = conf.get('fallbackQuality', true) as boolean;
- const resolveFullPath: boolean = options.resolveFullPath ?? conf.get('playlist.resolveFullPath');
- const savedFiles: string[] = [];
- let m3u8: string[] = [];
- await queue.addAll(
- data.tracks.map((track, index) => {
- return async () => {
- const savedPath = await downloadTrack({
- track,
- quality: options.quality,
- info: (data as any).linkinfo,
- coverSizes,
- path: options.output ? options.output : saveLayout[(data as any).linktype],
- totalTracks: data ? data.tracks.length : 10,
- trackNumber,
- fallbackTrack,
- fallbackQuality,
- message: `(${index}/${(data as any).tracks.length})`,
- });
- // Add to saved list
- if (savedPath) {
- m3u8.push(resolve(process.env.SIMULATE ? savedPath : trueCasePathSync(savedPath)));
- savedFiles.push(savedPath);
- }
- };
- }),
- );
- // Display downloaded location
- if (savedFiles.length > 0) {
- const savedIn = new Set(savedFiles.map((l) => dirname(l)));
- console.log(signale.info('Saved in ' + [...savedIn].map((d) => chalk.bgGreen(d)).join(', ')));
- }
- if ((options.createPlaylist || data.linktype === 'playlist') && !process.env.SIMULATE && m3u8.length > 1) {
- const playlistDir = commonPath([...new Set(savedFiles.map(dirname))]);
- const playlistFile = join(
- playlistDir,
- sanitizeFilename((data.linkinfo as any).TITLE || (data.linkinfo as any).ALB_TITLE),
- );
- if (!resolveFullPath) {
- const resolvedPlaylistDir = resolve(playlistDir) + sep;
- m3u8 = m3u8.map((file) => file.replace(resolvedPlaylistDir, ''));
- }
- const m3u8Content = '#EXTM3U' + EOL + m3u8.sort().join(EOL);
- writeFileSync(playlistFile + '.m3u8', m3u8Content, {encoding: 'utf-8'});
- }
- } else {
- console.log(signale.info('No items to download!'));
- }
- } catch (err: any) {
- console.error(signale.error(err.message));
- }
- // Ask for new download
- if (!options.headless && !skipPrompt) {
- startDownload(saveLayout, '', skipPrompt);
- }
- };
- /**
- * Application init.
- */
- const initApp = async () => {
- if (options.setArl) {
- const configPath = conf.set('cookies.arl', options.setArl);
- console.log(signale.info('cookies.arl set to --> ' + options.setArl));
- console.log(signale.note(configPath));
- process.exit();
- }
- logUpdate(signale.pending('Initializing session...'));
- const arl = conf.get('cookies.arl') as string;
- logUpdate(signale.pending('Verifying session...'));
- await initDeezerApi(arl);
- const {BLOG_NAME} = await getUser();
- logUpdate(signale.success('Logged in as ' + BLOG_NAME));
- logUpdate.done();
- const saveLayout: any = conf.get('saveLayout');
- if (options.inputFile) {
- const lines = readFileSync(options.inputFile, 'utf-8').split(/\r?\n/);
- for await (const line of lines) {
- if (line && line.match(urlRegex)) {
- console.log(signale.info('Starting download: ' + line));
- await startDownload(saveLayout, line.trim(), true);
- }
- }
- } else {
- startDownload(saveLayout, options.url, false);
- }
- };
- if (options.update) {
- autoUpdater(pkg).catch((err) => {
- console.error(signale.error(err.message));
- process.exit(1);
- });
- } else {
- // Check for update
- updateCheck(pkg);
- // Init interface
- initApp().catch((err) => {
- console.error(signale.error(err.message));
- process.exit(1);
- });
- }
|