diff --git a/common/async.js b/common/async.js new file mode 100644 index 0000000..1997a3f --- /dev/null +++ b/common/async.js @@ -0,0 +1,63 @@ +import {exec} from 'child_process' +import fs from 'fs-extra' + +function filewalker (dir, done) { + let results = [] + + fs.readdir(dir, function (err, list) { + if (err) return done(err) + + let pending = list.length + + if (!pending) return done(null, results) + + list.forEach(function (file) { + file = path.resolve(dir, file) + + fs.stat(file, function (err, stat) { + if (err) return done(err) + + // If directory, execute a recursive call + if (stat && stat.isDirectory()) { + filewalker(file, function (err, res) { + if (err) return done(err) + results = results.concat(res) + if (!--pending) done(null, results) + }) + } else { + results.push(file) + + if (!--pending) done(null, results) + } + }) + }) + }) +} + +function getFiles (dir) { + return new Promise((resolve, reject) => { + filewalker(dir, (err, files) => { + if (err) return reject(err) + resolve(files) + }) + }) +} + +function promiseExec (cmd, opts) { + return new Promise((resolve, reject) => { + exec(cmd, opts, function (err, stdout, stderr) { + if (err) return reject(err) + resolve({stdout, stderr}) + }) + }) +} + +function askAsync (rl, q) { + return new Promise((resolve, reject) => { + rl.question(q, resolve) + }) +} + +let a = {getFiles, promiseExec, askAsync} + +export default a diff --git a/common/download.js b/common/download.js new file mode 100644 index 0000000..ff3965d --- /dev/null +++ b/common/download.js @@ -0,0 +1,124 @@ +import {spawn} from 'child_process' +import path from 'path' +import asn from './async' + +function parseTitle (data) { + let tt = data.title + + // Remove []'s from the beginning + tt = tt.replace(/^\[\w+\]\s?/i, '') + + // Remove "Official Video/Audio" tag + tt = tt.replace(/\s?(?:[\(\)\[\]])?Official\s?[\w]+(?:[\(\)\[\]])?/i, '') + + // Remove "Audio" tag + tt = tt.replace(/\s?(?:[\(\)\[\]])Audio?(?:[\(\)\[\]])/i, '') + + // Remove "lyrics" tag + tt = tt.replace(/\s?(?:[\(\)\[\]])?lyrics?\s?(?:[\w]+)?(?:[\(\)\[\]])?\s?/i, '') + + // Artist / Title split + let at = tt.split(' - ', 2) + let artist + let title + + if (at.length > 1) { + artist = at[0] + title = tt.substring(artist.length + 3) + } else { + artist = data.artist + title = tt + } + + data.title = title + data.artist = artist + + return data +} + +function getVideoInfo (arg) { + let yt = spawn('youtube-dl', ['--no-playlist', '--playlist-end', 1, '-j', '-f', 'bestaudio/best', arg]) + + let output = '' + yt.stdout.on('data', function (chunk) { + output += chunk.toString('utf8') + }) + + return new Promise((resolve, reject) => { + yt.on('close', function () { + let data = JSON.parse(output) + delete data.formats + resolve(data) + }) + }) +} + +function fetchVideo (data) { + return new Promise((resolve, reject) => { + if (data.acodec !== 'mp3' || data.vcodec !== 'none') { + let tempName = path.join(__dirname, `/tmp.yt.${data.id}.mp3`) + let ffmpeg = spawn('ffmpeg', ['-hide_banner', '-i', data.url, '-codec:a', 'libmp3lame', '-q:a', 2, '-joint_stereo', 1, '-y', tempName]) + + ffmpeg.stdout.pipe(process.stderr) + ffmpeg.stderr.pipe(process.stderr) + + ffmpeg.on('error', function (e) { + reject(e) + }) + + ffmpeg.on('close', function () { + resolve({ + title: data.title, + artist: data.uploader, + url: data.webpage_url, + art: data.thumbnail, + source: tempName + }) + }) + } else { + reject(new Error('Invalid format returned.')) + } + }) +} + +async function getInfos (file) { + let id3 = await asn.promiseExec(`id3 "${file}"`) + let prds = id3.stdout.split('\n') + let data = {} + + // Get id3 tags + for (let i in prds) { + let line = prds[i] + let parts = line.split(': ') + if (parts.length) { + let tagtype = parts[0].toLowerCase() + let tagdata = line.substring(parts[0].length + 2) + + if (tagtype === '') continue + if (tagtype === 'metadata' && tagdata === 'none found') throw new Error(`No metadata for file "${file}"!`) + if (tagtype === 'track' || tagtype === 'year') { + if (tagdata.indexOf('/') !== -1) { + tagdata = tagdata.split('/')[0] + } + + tagdata = parseInt(tagdata) + } + + data[tagtype] = tagdata + } + } + + if (!data.title) { + let parsed = path.parse(file) + data.title = parsed.name + } + + // Get track length + let len = await asn.promiseExec(`ffprobe -i "${file}" -show_entries format=duration -v quiet -of csv="p=0"`) + len = parseFloat(len.stdout) + data.duration = len + + return data +} + +export default {parseTitle, getVideoInfo, fetchVideo, getInfos} diff --git a/dbpopulate.js b/dbpopulate.js index df47dfb..2587e60 100755 --- a/dbpopulate.js +++ b/dbpopulate.js @@ -1,9 +1,12 @@ #!/usr/bin/env babel-node +import fs from 'fs-extra' import path from 'path' import sqlite from 'sqlite' import Promise from 'bluebird' -import fs from 'fs-extra' -import {exec} from 'child_process' +import readline from 'readline' + +import asn from './common/async' +import dl from './common/download' const values = require(path.join(__dirname, 'values.json')) const musicdir = path.resolve(values.directory) @@ -12,163 +15,28 @@ const dbPromise = Promise.resolve() .then(() => sqlite.open(path.join(__dirname, values.database), { Promise, cache: true })) .then(db => db.migrate()) -const readline = require('readline') - // ffprobe -i -show_entries format=duration -v quiet -of csv="p=0" -function filewalker (dir, done) { - let results = [] - - fs.readdir(dir, function (err, list) { - if (err) return done(err) - - var pending = list.length - - if (!pending) return done(null, results) - - list.forEach(function (file) { - file = path.resolve(dir, file) - - fs.stat(file, function (err, stat) { - if (err) return done(err) - - // If directory, execute a recursive call - if (stat && stat.isDirectory()) { - //results.push(file) - - filewalker(file, function (err, res) { - if (err) return done(err) - results = results.concat(res) - if (!--pending) done(null, results) - }) - } else { - results.push(file) - - if (!--pending) done(null, results) - } - }) - }) - }) -} - -function getFiles (dir) { - return new Promise((resolve, reject) => { - filewalker(dir, (err, files) => { - if (err) return reject(err) - resolve(files) - }) - }) -} - -function promiseExec (cmd, opts) { - return new Promise((resolve, reject) => { - exec(cmd, opts, function (err, stdout, stderr) { - if (err) return reject(err) - resolve({stdout, stderr}) - }) - }) -} - -async function getInfos (file) { - let id3 = await promiseExec(`id3 "${file}"`) - let prds = id3.stdout.split('\n') - let data = {} - - // Get id3 tags - for (let i in prds) { - let line = prds[i] - let parts = line.split(': ') - if (parts.length) { - let tagtype = parts[0].toLowerCase() - let tagdata = line.substring(parts[0].length + 2) - - if (tagtype === '') continue - if (tagtype === 'metadata' && tagdata === 'none found') throw new Error(`No metadata for file "${file}"!`) - if (tagtype === 'track' || tagtype === 'year') { - if (tagdata.indexOf('/') !== -1) { - tagdata = tagdata.split('/')[0] - } - - tagdata = parseInt(tagdata) - } - - data[tagtype] = tagdata - } - } - - if (!data.title) { - let parsed = path.parse(file) - data.title = parsed.name - } - - // Get track length - let len = await promiseExec(`ffprobe -i "${file}" -show_entries format=duration -v quiet -of csv="p=0"`) - len = parseFloat(len.stdout) - data.duration = len - - return data -} - -function parseTitle(data) { - let tt = data.title - - // Remove []'s from the beginning - tt = tt.replace(/^\[\w+\]\s?/i, '') - - // Remove "Official Video/Audio" tag - tt = tt.replace(/\s?(?:[\(\)\[\]])?Official\s?[\w]+(?:[\(\)\[\]])?/i, '') - - // Remove "Audio" tag - tt = tt.replace(/\s?(?:[\(\)\[\]])Audio?(?:[\(\)\[\]])/i, '') - - // Remove "lyrics" tag - tt = tt.replace(/\s?(?:[\(\)\[\]])?lyrics?\s?(?:[\w]+)?(?:[\(\)\[\]])?\s?/i, '') - - // Artist / Title split - let at = tt.split(' - ', 2) - let artist - let title - - if (at.length > 1) { - artist = at[0] - title = tt.substring(artist.length + 3) - } else { - artist = data.artist - title = tt - } - - data.title = title - data.artist = artist - - return data -} - -function askAsync (rl, q) { - return new Promise((resolve, reject) => { - rl.question(q, resolve) - }) -} - async function interactive (fpath, db) { let rl = readline.createInterface({ input: process.stdin, output: process.stdout }) - console.log('No metadata found for specified file! Interactive mode enabled.\n') + console.log('=> No metadata found for specified file! Interactive mode enabled.\n') let pt = path.parse(fpath) let track = { file: fpath, title: pt.name } - let clean = parseTitle(track) + let clean = dl.parseTitle(track) - console.log('Determined Title: ' + clean.title) - console.log('Determined Artist: ' + clean.artist) + console.log('== Determined Title: ' + clean.title) + console.log('== Determined Artist: ' + clean.artist) - let newTitle = await askAsync(rl, `Title [${clean.title}] ? `) - let newArtist = await askAsync(rl, `Artist [${clean.artist}] ? `) + let newTitle = await asn.askAsync(rl, `Title [${clean.title}] ? `) + let newArtist = await asn.askAsync(rl, `Artist [${clean.artist}] ? `) if (newTitle.trim() !== '') track.title = newTitle @@ -176,13 +44,7 @@ async function interactive (fpath, db) { if (newArtist.trim() !== '') track.artist = newArtist - let ensure = await db.get('SELECT * FROM Track WHERE title=? AND artist=?', track.title, track.artist) - if (ensure) { - console.error('A track of this description already exists in the database.') - return rl.close() - } - - let len = await promiseExec(`ffprobe -i "${track.file}" -show_entries format=duration -v quiet -of csv="p=0"`) + let len = await asn.promiseExec(`ffprobe -i "${track.file}" -show_entries format=duration -v quiet -of csv="p=0"`) track.duration = parseFloat(len) rl.close() @@ -198,7 +60,7 @@ async function run () { let trackinf try { - trackinf = await getInfos(filePath) + trackinf = await dl.getInfos(filePath) } catch (e) { trackinf = await interactive(filePath, db) } @@ -208,11 +70,17 @@ async function run () { return } + let ensure = await db.get('SELECT * FROM Track WHERE title=? AND artist=?', trackinf.title, trackinf.artist) + if (ensure) { + console.error('A track of this description already exists in the database.') + return + } + await db.run('INSERT INTO Track VALUES (NULL,?,?,?,?,?,?,?,?)', [trackinf.title, trackinf.artist, trackinf.file, trackinf.album || null, trackinf.genre || null, trackinf.track || null, trackinf.year || null, Math.floor(trackinf.duration)]) - console.log('Done.') + console.log('=> Done.') return } @@ -225,7 +93,7 @@ async function run () { // if (cleanTrackData.length > 10) break // debug purposes process.stdout.write(`\rProcessing file ${parseInt(i) + 1} of ${files.length}.. (${skips} skipped)`) try { - let fd = await getInfos(file) + let fd = await dl.getInfos(file) cleanTrackData.push(fd) } catch (e) { skips++ @@ -251,7 +119,7 @@ async function run () { } } - console.log(`\n${entries} tracks were successfully added to the cache!`) + console.log(`=> \n${entries} tracks were successfully added to the cache!`) } run() diff --git a/download.js b/download.js index addd6f2..a6767de 100755 --- a/download.js +++ b/download.js @@ -1,134 +1,63 @@ -#!/usr/bin/env node +#!/usr/bin/env babel-node 'use strict' -const spawn = require('child_process').spawn -const fs = require('fs') +import fs from 'fs-extra' +import readline from 'readline' +import path from 'path' -const readline = require('readline') +import asn from './common/async' +import dl from './common/download' + +const values = require(path.join(__dirname, 'values.json')) +const musicdir = path.resolve(values.directory) const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) -function download (arg, handleCb) { - let yt = spawn('youtube-dl', ['--no-playlist', '--playlist-end', 1, '-j', '-f', 'bestaudio/best', arg]) +async function download (furl) { + console.log('=> Getting information..') + let data = await dl.getVideoInfo(furl) - let output = '' + console.log('=> Downloading file..') + let file = await dl.fetchVideo(data) - yt.stdout.on('data', function (chunk) { - output += chunk.toString('utf8') - }) - yt.on('close', function () { - let data = JSON.parse(output) - delete data.formats - fetchVideo(data, handleCb) - }) -} - -function fetchVideo (data, cb) { - console.log('audio codec:', data.acodec) - if (data.acodec !== 'mp3' || data.vcodec !== 'none') { - let tempName = __dirname + '/tmp.yt.' + data.id + '.mp3' - let ffmpeg = spawn('ffmpeg', ['-hide_banner', '-i', data.url, '-codec:a', 'libmp3lame', '-q:a', 2, '-joint_stereo', 1, '-y', tempName]) - - ffmpeg.stdout.pipe(process.stderr) - ffmpeg.stderr.pipe(process.stderr) - data.filename = tempName - console.log('Downloading ' + data.title + '...') - - ffmpeg.on('close', function () { - outputVideo(data, cb) - }) - } else { - console.log('Invalid format returned.') - cb(null) - } -} - -function outputVideo (video, cb) { - cb({ - title: video.title, - artist: video.uploader, - url: video.webpage_url, - art: video.thumbnail, - source: video.filename - }) -} - -function parseTitle(data) { - let tt = data.title - - // Remove []'s from the beginning - tt = tt.replace(/^\[\w+\]\s?/i, '') - - // Remove "Official Video/Audio" tag - tt = tt.replace(/\s?(?:[\(\)\[\]])?Official\s?[\w]+(?:[\(\)\[\]])?/i, '') - - // Remove "Audio" tag - tt = tt.replace(/\s?(?:[\(\)\[\]])Audio?(?:[\(\)\[\]])/i, '') - - // Remove "lyrics" tag - tt = tt.replace(/\s?(?:[\(\)\[\]])?lyrics?\s?(?:[\w]+)?(?:[\(\)\[\]])?\s?/i, '') - - // Artist / Title split - let at = tt.split(' - ', 2) - let artist - let title - - if (at.length > 1) { - artist = at[0] - title = tt.substring(artist.length + 3) - } else { - artist = data.artist - title = tt - } - - data.title = title - data.artist = artist - - return data -} - -download(process.argv[2], function (s) { - let clean = parseTitle(s) + console.log('=> Cleaning up..') + let clean = dl.parseTitle(file) let filename = clean.artist + ' - ' + clean.title + '.mp3' - console.log('Cleaning up..') - console.log('Original Title: ' + s.title + '\n') - console.log('Determined Title: ' + clean.title) - console.log('Determined Artist: ' + clean.artist) - console.log('Determined File Name: ' + filename) + console.log('=> Original Title: ' + file.title + '\n') + console.log('== Determined Title: ' + clean.title) + console.log('== Determined Artist: ' + clean.artist) + console.log('== Determined File Name: ' + filename) - rl.question(`Title [${clean.title}] ? `, (answer) => { - if (answer && answer.trim() !== '') { - clean.title = answer - } + let titleAnsw = await asn.askAsync(rl, `Title [${clean.title}] ? `) + let artistAnsw = await asn.askAsync(rl, `Artist [${clean.artist}] ? `) + let fileAnsw = await asn.askAsync(rl, `File [${filename}] ? `) - rl.question(`Artist [${clean.artist}] ? `, (answer) => { - if (answer && answer.trim() !== '') { - clean.artist = answer - } + if (titleAnsw && titleAnsw.trim() !== '') { + clean.title = titleAnsw + } - rl.question(`File [${filename}] ? `, (answer) => { - if (answer && answer.trim() !== '') { - filename = answer - } + if (artistAnsw && artistAnsw.trim() !== '') { + clean.artist = artistAnsw + } - let fn = __dirname + '/' + filename - fs.rename(s.source, fn, function (err) { - if (err) { - console.error(err) - return rl.close() - } + if (fileAnsw && fileAnsw.trim() !== '') { + filename = fileAnsw + } - let id3 = spawn('id3', ['-a', clean.artist, '-t', clean.title, fn]) - id3.on('close', () => { - console.log('Saved as ' + fn) - rl.close() - }) - }) - }) - }) - }) + let fn = path.join(musicdir, filename) + await fs.rename(file.source, fn) + + let id3 = await asn.promiseExec(`id3 -a "${clean.artist}" -t "${clean.title}" "${fn}"`) + + console.log('=> Done.') + rl.close() +} + +download(process.argv[2]).catch((e) => { + console.error(e.message) + rl.close() })