From f2cfad589b73038c0df91accdb15768fff319d79 Mon Sep 17 00:00:00 2001 From: Stian Lund Date: Tue, 9 Feb 2021 14:29:45 +0100 Subject: [PATCH] Clone commit --- README.md | 14 ++++ download_album.js | 210 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 11 +++ 3 files changed, 235 insertions(+) create mode 100755 README.md create mode 100755 download_album.js create mode 100755 package.json diff --git a/README.md b/README.md new file mode 100755 index 0000000..641fdf4 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Download entire albums from musify.club +Simple tool to run from command line + +## Installation +Clone and run `npm install` to meet dependencies. + +## Usage +``` +node download_album.js [OPTIONS] ALBUM_URL + +Valid options are: + -h|--help Get this message + -s NUMBER Number of tracks to be downloaded simultaneously (default: 5) +``` diff --git a/download_album.js b/download_album.js new file mode 100755 index 0000000..fb2754c --- /dev/null +++ b/download_album.js @@ -0,0 +1,210 @@ +#!/usr/bin/env node + +const request = require('request-promise-native'); +const unpromisifiedRequest = require('request'); +const url = require('url'); +const cheerio = require('cheerio'); +const fs = require('fs'); +const argv = require('minimist')(process.argv.slice(2)); + +// Handle command line arguments and etc. +function usage(exitCode) { + console.log( + 'Usage:\n\tdownload_album.js [OPTIONS] ALBUM_URL\n\n' + + 'Valid options are:\n' + + '\t-h|--help\t\tGet this message\n' + + '\t-s NUMBER\t\tNumber of tracks to be downloaded simultaneously (default: 5)' + ); + process.exit(exitCode); +} + +let parallelDownloads = 5; +const knownKeys = { + help() { + usage(0); + }, + h() { + this.help(); + }, + s(num) { + typeof num === 'number' ? (parallelDownloads = argv.s) : usage(1); + } +}; + +const providedKeys = Object.keys(argv).filter(key => key !== '_'); +const unknownKeys = providedKeys.filter(key => !knownKeys[key]); + +if (unknownKeys.length || argv._.length !== 1) { + unknownKeys.forEach(key => { + console.log(`Unknown key: ${key}`); + }); + + usage(1); +} + +providedKeys.forEach(key => { + knownKeys[key](argv[key]); +}); + +const albumURL = argv._[0]; +const domain = url.parse(albumURL).hostname; + +function getLinksAndTags(html, domain) { + const $ = cheerio.load(html); + + const [album, artist = 'VA'] = $('h1') + .text() + .trim() + .split(' - ', 2) + .reverse(); + const tracksData = []; + const $tracks = $('.playlist__item'); + const len = $tracks.length; + const coverURL = $('.album-img').attr('data-src'); + + $tracks.each((index, element) => { + let trackNo = $(element) + .find('.playlist__position') + .text() + .trim(); + if (trackNo.length < 2) trackNo = '0' + trackNo; + + tracksData.push({ + url: `https://${domain}${$(element) + .find('.playlist__control.play') + .attr('data-url')}`, + trackNo, + title: $(element) + .find('.playlist__details a.strong') + .text() + .trim(), + artist, + album + }); + }); + + return { tracksData, coverURL }; +} + +function executeInChunks(array, callback, queueSize = 5) { + const execWith = async (element, index) => { + await callback(element); + return index; + }; + + // Form initial queue consisting of promises, which resolve with + // their index number in the queue array. + const queueArray = array.splice(0, queueSize).map(execWith); + + // Recursively get rid of resolved promises in the queue. + // Add new promises preventing queue from emptying. + const keepQueueSize = async () => { + if (array.length) { + try { + const index = await Promise.race(queueArray); + queueArray.splice(index, 1, execWith(array.shift(), index)); + keepQueueSize(); + } catch (error) { + console.log('Cannot assemble another chunk'); + throw error; + } + } + }; + + keepQueueSize(); +} + +function cleanUpSymbols(inputString) { + return inputString.replace(/[:/\"*<>|?]/g, ''); +} + +function downloadFile(url, filename) { + return new Promise((resolve, reject) => { + unpromisifiedRequest({ + url, + headers: { + 'User-Agent': 'request' + } + }) + .on('error', reject) + .pipe( + fs + .createWriteStream(filename) + .on('finish', resolve) + .on('error', reject) + ); + }); +} + +async function downloadTrack({ url, ...trackInfo }) { + Object.keys(trackInfo).forEach( + prop => (trackInfo[prop] = cleanUpSymbols(trackInfo[prop])) + ); + + const { artist, album, trackNo, title } = trackInfo; + const filename = `${artist}/${album}/${trackNo} - ${title}.mp3`; + + console.log(`Starting download: ${trackNo} - ${title}`); + + try { + const file = await downloadFile(url, filename); + console.log(`Download is finished: ${trackNo} - ${title}`); + return file; + } catch (error) { + console.log(`Download is failed: ${trackNo} - ${title}`); + throw error; + } +} + +function prepareAlbumDir(tracksData) { + return new Promise(resolve => { + const artist = cleanUpSymbols(tracksData[0].artist); + const album = cleanUpSymbols(tracksData[0].album); + const albumDir = `${artist}/${album}`; + + // Check the existence of the target directory + fs.access(albumDir, fs.constants.F_OK, error => { + if (error) { + fs.mkdir(`${artist}`, () => { + fs.mkdir(albumDir, () => { + resolve(albumDir); + }); + }); + } else resolve(albumDir); + }); + }); +} + +async function downloadCover(coverURL, albumDir) { + const filename = `${albumDir}/cover.jpg`; + + try { + const cover = await downloadFile(coverURL, filename); + console.log('Cover is downloaded'); + } catch (error) { + console.log('Failed to download cover'); + // throw error; + } +} + +(async () => { + try { + const body = await request({ + url: albumURL, + headers: { + 'User-Agent': 'request' + } + }); + const { tracksData, coverURL } = getLinksAndTags(body, domain); + const albumDir = await prepareAlbumDir(tracksData); + + console.log(`albumURL: ${albumURL}`); + console.log(`domain: ${domain}`); + console.log(`coverURL: ${coverURL}`); + + await downloadCover(coverURL, albumDir); + await executeInChunks(tracksData, downloadTrack, parallelDownloads); + } catch (error) { + console.log(`Failed to download the album: ${error}`); + } +})(); diff --git a/package.json b/package.json new file mode 100755 index 0000000..28be858 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "cheerio": "^0.22.0", + "minimist": "^1.2.0", + "request": "^2.87.0", + "request-promise-native": "^1.0.5" + }, + "devDependencies": { + "prettier": "^1.13.0" + } +}