d-fi.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. #!/usr/bin/env node
  2. import {EOL} from 'os';
  3. import {readFileSync, writeFileSync} from 'fs';
  4. import {dirname, join, resolve, sep} from 'path';
  5. import {Command} from 'commander';
  6. import gradient from 'gradient-string';
  7. import {getUser, initDeezerApi, searchMusic, parseInfo, getDiscography} from 'd-fi-core';
  8. import prompts from 'prompts';
  9. import logUpdate from 'log-update';
  10. import PQueue from 'p-queue';
  11. import chalk from 'chalk';
  12. import {trueCasePathSync} from 'true-case-path';
  13. import signale from './lib/signale';
  14. import downloadTrack from './lib/download-track';
  15. import Config from './lib/config';
  16. import updateCheck from './lib/update-check';
  17. import autoUpdater from './lib/auto-updater';
  18. import {commonPath, formatSecondsReadable, sanitizeFilename} from './lib/util';
  19. import pkg from '../package.json';
  20. import type {artistType, trackType, albumType, playlistInfo, playlistInfoMinimal} from 'd-fi-core/dist/types';
  21. // App info
  22. console.log(
  23. gradient('red', 'yellow', 'orange')(` ♥ d-fi - ${pkg.version} ♥ `) +
  24. '\n' +
  25. gradient('orange', 'yellow', 'red')(' ──────────────────────────────────────────────') +
  26. '\n' +
  27. gradient('red', 'yellow', 'orange')(' │ repo https://notabug.org/sayem314/d-fi │ ') +
  28. '\n' +
  29. gradient('red', 'yellow', 'orange')(' │ github https://github.com/sayem314 │ ') +
  30. '\n' +
  31. gradient('red', 'yellow', 'orange')(' │ coffee https://ko-fi.com/sayemchowdhury │ ') +
  32. '\n' +
  33. gradient('red', 'yellow', 'orange')(' ──────────────────────────────────────────────'),
  34. );
  35. const cmd = new Command()
  36. .option('-q, --quality <quality>', 'The quality of the files to download: 128/320/flac ')
  37. .option('-o, --output <template>', 'Output filename template')
  38. .option('-u, --url <url>', 'Deezer album/artist/playlist/track url')
  39. .option('-i, --input-file <file>', 'Downloads all urls listed in text file')
  40. .option('-c, --concurrency <number>', 'Download concurrency for album, artists and playlist')
  41. .option('-a, --set-arl <string>', 'Set arl cookie')
  42. .option('-d, --headless', 'Run in headless mode for scripting automation', false)
  43. .option('-conf, --config-file <file>', 'Custom location to your config file', 'd-fi.config.json')
  44. .option('-rfp, --resolve-full-path', 'Use absolute path for playlists')
  45. .option('-cp, --create-playlist', 'Force create a playlist file for non playlists');
  46. if ((process as any).pkg) {
  47. cmd.option('-U, --update', 'Update this program to latest version');
  48. }
  49. const options = cmd.parse(process.argv).opts();
  50. if (!options.url && cmd.args[0]) {
  51. options.url = cmd.args[0];
  52. }
  53. if (options.headless && !options.quality) {
  54. console.error(signale.error('Missing parameters --quality'));
  55. console.error(signale.note('Quality must be provided with headless mode'));
  56. process.exit(1);
  57. }
  58. if (options.headless && !options.url && !options.inputFile) {
  59. console.error(signale.error('Missing parameters --url'));
  60. console.error(signale.note('URL must be provided with headless mode'));
  61. process.exit(1);
  62. }
  63. const conf = new Config(options.configFile);
  64. if (conf.userConfigLocation) {
  65. console.log(signale.info('Config loaded --> ' + conf.userConfigLocation));
  66. }
  67. const queue = new PQueue({concurrency: Number(options.concurrency || conf.get('concurrency'))});
  68. const urlRegex = /https?:\/\/.*\w+\.\w+\/\w+/;
  69. const onCancel = () => {
  70. console.info(signale.note('Aborted!'));
  71. process.exit();
  72. };
  73. const startDownload = async (saveLayout: any, url: string, skipPrompt: boolean) => {
  74. try {
  75. if (!options.quality) {
  76. const {musicQuality} = await prompts(
  77. [
  78. {
  79. type: 'select',
  80. name: 'musicQuality',
  81. message: 'Select music quality:',
  82. choices: [
  83. {title: 'MP3 - 128 kbps', value: '128'},
  84. {title: 'MP3 - 320 kbps', value: '320'},
  85. {title: 'FLAC - 1411 kbps', value: 'flac'},
  86. ],
  87. initial: 1,
  88. },
  89. ],
  90. {onCancel},
  91. );
  92. options.quality = musicQuality;
  93. }
  94. if (!url) {
  95. const {query} = await prompts(
  96. [
  97. {
  98. type: 'text',
  99. name: 'query',
  100. message: 'Enter URL or search:',
  101. validate: (value) => (value ? true : false),
  102. },
  103. ],
  104. {onCancel},
  105. );
  106. url = query;
  107. }
  108. let searchData: {
  109. info: {type: 'track'; id: string};
  110. linktype: 'track';
  111. /* eslint-disable-next-line */
  112. linkinfo: {};
  113. tracks: trackType[];
  114. } | null = null;
  115. if (!url.match(urlRegex)) {
  116. if (options.headless) {
  117. throw new Error('Please provide a valid URL. Unknown URL: ' + url);
  118. }
  119. if (url.startsWith('artist:')) {
  120. const {ARTIST} = await searchMusic(url.replace('artist:', ''), ['ARTIST'], 50);
  121. const choice: {items: artistType} = await prompts(
  122. [
  123. {
  124. type: 'select',
  125. name: 'items',
  126. message: `Select one artist. (found ${ARTIST.data.length} artists)`,
  127. choices: ARTIST.data.map((a) => ({
  128. title: a.ART_NAME,
  129. value: a,
  130. description: `${a.NB_FAN} fans`,
  131. })),
  132. },
  133. ],
  134. {onCancel},
  135. );
  136. console.log(signale.info('Fetching data. Please hold on.'));
  137. url = `https://deezer.com/us/artist/${choice.items.ART_ID}`;
  138. } else if (url.startsWith('album:')) {
  139. const {ALBUM} = await searchMusic(url.replace('album:', ''), ['ALBUM'], 50);
  140. const choice: {items: albumType} = await prompts(
  141. [
  142. {
  143. type: 'select',
  144. name: 'items',
  145. message: `Select one album. (found ${ALBUM.data.length} albums)`,
  146. choices: ALBUM.data.map((a) => ({
  147. title: a.ALB_TITLE,
  148. value: a,
  149. description: `by ${a.ART_NAME}, ${a.NUMBER_TRACK} tracks`,
  150. })),
  151. },
  152. ],
  153. {onCancel},
  154. );
  155. url = `https://deezer.com/us/album/${choice.items.ALB_ID}`;
  156. } else if (url.startsWith('playlist:')) {
  157. const {PLAYLIST} = await searchMusic(url.replace('playlist:', ''), ['PLAYLIST'], 50);
  158. const choice: {items: playlistInfoMinimal} = await prompts(
  159. [
  160. {
  161. type: 'select',
  162. name: 'items',
  163. message: `Select one playlist. (found ${PLAYLIST.data.length} playlists)`,
  164. choices: PLAYLIST.data.map((p) => ({
  165. title: p.TITLE,
  166. value: p,
  167. description: `by ${p.PARENT_USERNAME}, ${p.NB_SONG} tracks`,
  168. })),
  169. },
  170. ],
  171. {onCancel},
  172. );
  173. url = `https://deezer.com/us/playlist/${choice.items.PLAYLIST_ID}`;
  174. } else {
  175. const {TRACK} = await searchMusic(url, ['TRACK']);
  176. searchData = {
  177. info: {type: 'track', id: url},
  178. linktype: 'track',
  179. linkinfo: {},
  180. tracks: TRACK.data.map((t) => {
  181. if (t.VERSION && !t.SNG_TITLE.includes(t.VERSION)) {
  182. t.SNG_TITLE += ' ' + t.VERSION;
  183. }
  184. return t;
  185. }),
  186. };
  187. }
  188. } else if (url.match(/playlist|artist/)) {
  189. console.log(signale.info('Fetching data. Please hold on.'));
  190. }
  191. const data = searchData ? searchData : await parseInfo(url);
  192. if (!options.headless && data.tracks.length > 1) {
  193. const choices: {items: trackType[]} = await prompts(
  194. [
  195. {
  196. type: 'multiselect',
  197. name: 'items',
  198. message: `Select songs to download. Total of ${data.tracks.length} tracks.`,
  199. choices: data.tracks.map((t) => ({
  200. title: t.SNG_TITLE,
  201. value: t,
  202. description: `Artist: ${t.ART_NAME}\nAlbum: ${t.ALB_TITLE}\nDuration: ${formatSecondsReadable(
  203. Number(t.DURATION),
  204. )}`,
  205. })),
  206. },
  207. ],
  208. {onCancel},
  209. );
  210. data.tracks = choices.items;
  211. }
  212. if (data && data.tracks.length > 0) {
  213. console.log(signale.info(`Proceeding to download ${data.tracks.length} tracks. Be patient.`));
  214. if (data.linktype === 'playlist') {
  215. const filteredTracks = data.tracks.filter(
  216. (item, index, self) => index === self.findIndex((t) => t.SNG_ID === item.SNG_ID),
  217. );
  218. const duplicateTracks = data.tracks.length - filteredTracks.length;
  219. if (duplicateTracks > 0) {
  220. data.tracks = filteredTracks
  221. .sort((a: any, b: any) => a.TRACK_POSITION - b.TRACK_POSITION)
  222. .map((t, i) => {
  223. t.TRACK_POSITION = i + 1;
  224. return t;
  225. });
  226. console.log(
  227. signale.warn(`Removed ${duplicateTracks} duplicate ${duplicateTracks > 1 ? 'tracks' : 'track'}.`),
  228. );
  229. }
  230. }
  231. const coverSizes = conf.get('coverSize') as any;
  232. const trackNumber = conf.get('trackNumber', true) as boolean;
  233. const fallbackTrack = conf.get('fallbackTrack', true) as boolean;
  234. const fallbackQuality = conf.get('fallbackQuality', true) as boolean;
  235. const resolveFullPath: boolean = options.resolveFullPath ?? conf.get('playlist.resolveFullPath');
  236. const savedFiles: string[] = [];
  237. let m3u8: string[] = [];
  238. await queue.addAll(
  239. data.tracks.map((track, index) => {
  240. return async () => {
  241. const savedPath = await downloadTrack({
  242. track,
  243. quality: options.quality,
  244. info: (data as any).linkinfo,
  245. coverSizes,
  246. path: options.output ? options.output : saveLayout[(data as any).linktype],
  247. totalTracks: data ? data.tracks.length : 10,
  248. trackNumber,
  249. fallbackTrack,
  250. fallbackQuality,
  251. message: `(${index}/${(data as any).tracks.length})`,
  252. });
  253. // Add to saved list
  254. if (savedPath) {
  255. m3u8.push(resolve(process.env.SIMULATE ? savedPath : trueCasePathSync(savedPath)));
  256. savedFiles.push(savedPath);
  257. }
  258. };
  259. }),
  260. );
  261. // Display downloaded location
  262. if (savedFiles.length > 0) {
  263. const savedIn = new Set(savedFiles.map((l) => dirname(l)));
  264. console.log(signale.info('Saved in ' + [...savedIn].map((d) => chalk.bgGreen(d)).join(', ')));
  265. }
  266. if ((options.createPlaylist || data.linktype === 'playlist') && !process.env.SIMULATE && m3u8.length > 1) {
  267. const playlistDir = commonPath([...new Set(savedFiles.map(dirname))]);
  268. const playlistFile = join(
  269. playlistDir,
  270. sanitizeFilename((data.linkinfo as any).TITLE || (data.linkinfo as any).ALB_TITLE),
  271. );
  272. if (!resolveFullPath) {
  273. const resolvedPlaylistDir = resolve(playlistDir) + sep;
  274. m3u8 = m3u8.map((file) => file.replace(resolvedPlaylistDir, ''));
  275. }
  276. const m3u8Content = '#EXTM3U' + EOL + m3u8.sort().join(EOL);
  277. writeFileSync(playlistFile + '.m3u8', m3u8Content, {encoding: 'utf-8'});
  278. }
  279. } else {
  280. console.log(signale.info('No items to download!'));
  281. }
  282. } catch (err: any) {
  283. console.error(signale.error(err.message));
  284. }
  285. // Ask for new download
  286. if (!options.headless && !skipPrompt) {
  287. startDownload(saveLayout, '', skipPrompt);
  288. }
  289. };
  290. /**
  291. * Application init.
  292. */
  293. const initApp = async () => {
  294. if (options.setArl) {
  295. const configPath = conf.set('cookies.arl', options.setArl);
  296. console.log(signale.info('cookies.arl set to --> ' + options.setArl));
  297. console.log(signale.note(configPath));
  298. process.exit();
  299. }
  300. logUpdate(signale.pending('Initializing session...'));
  301. const arl = conf.get('cookies.arl') as string;
  302. logUpdate(signale.pending('Verifying session...'));
  303. await initDeezerApi(arl);
  304. const {BLOG_NAME} = await getUser();
  305. logUpdate(signale.success('Logged in as ' + BLOG_NAME));
  306. logUpdate.done();
  307. const saveLayout: any = conf.get('saveLayout');
  308. if (options.inputFile) {
  309. const lines = readFileSync(options.inputFile, 'utf-8').split(/\r?\n/);
  310. for await (const line of lines) {
  311. if (line && line.match(urlRegex)) {
  312. console.log(signale.info('Starting download: ' + line));
  313. await startDownload(saveLayout, line.trim(), true);
  314. }
  315. }
  316. } else {
  317. startDownload(saveLayout, options.url, false);
  318. }
  319. };
  320. if (options.update) {
  321. autoUpdater(pkg).catch((err) => {
  322. console.error(signale.error(err.message));
  323. process.exit(1);
  324. });
  325. } else {
  326. // Check for update
  327. updateCheck(pkg);
  328. // Init interface
  329. initApp().catch((err) => {
  330. console.error(signale.error(err.message));
  331. process.exit(1);
  332. });
  333. }