#!/usr/bin/env node /* eslint-disable no-console */ const fs = require('fs').promises; const os = require('os'); const shitExec = require('child_process').exec; const Path = require('path'); const readline = require('readline'); async function query(p) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return await new Promise((resolve, reject) => { rl.question(`${p}: `, (r) => { resolve(r); rl.close(); }); }); } async function yn(q) { return ['yes', 'y'].includes((await query(q)).toLowerCase()); } async function exec(...args) { return new Promise((resolve, reject) => { shitExec(...args, (error, stdout) => { if (error) { reject(error); } else { resolve(stdout.trim()); } }); }); } const TMP_DIR = `/tmp/mudl_${Math.random().toString(36).substring(7)}`; async function thing(source, excess) { if (!source) { console.log('need a video'); return; } const [pAlbum, pArtist, pTitle] = (excess.length >= 1 ? excess.join(' ').trim().match(/^([^/]+)(?:\/([^/]+)(?:\/([^/]+))?)?$/) || [] : []).slice(1).map((item) => { return item ? item.trim() : item; }); await fs.mkdir(TMP_DIR); let files = []; let inputType = null; if (['/', '~', '.'].includes(source[0])) { console.log('got a str8 filename'); inputType = 'file'; files = [{ image: null, file: source, album: pAlbum || null, artist: pArtist || null, title: pTitle || null, }]; } else { console.log('got a link'); inputType = 'url'; console.log(`Temporarily saving to ${TMP_DIR}`); await exec(`youtube-dl --write-thumbnail -f bestaudio -x -o "${TMP_DIR}/[%(album)s] -- [%(artist)s] -- [%(track_number)s] -- [%(track)s] -- [%(title)s].%(ext)s" ${source}`); console.log('Done saving'); const filesList = await fs.readdir(TMP_DIR); files = (await Promise.all(filesList.map(async (rawFilePath) => { const filePath = Path.parse(rawFilePath); if (['.png', '.jpg', '.webp'].includes(filePath.ext)) return null; const rawImagePath = filesList.find((rawP) => { const p = Path.parse(rawP); return ['.png', '.jpg', '.webp'].includes(p.ext) && p.name === filePath.name; }); let album = pAlbum || null; let artist = pArtist || null; let title = pTitle || null; let trackNum = null; let rawTitle = null; const fullMatch = rawFilePath.match(/^\[(.*?)\] -- \[(.*?)\] -- \[(.*?)\] -- \[(.*?)\] -- \[(.*?)\]\..*?$/); if (fullMatch !== null) { const [yAlbum, yArtist, yTrackNum, yTitle, yRawTitle] = fullMatch.slice(1) .map(item => (item === 'NA' ? null : item)) .map(item => (item !== null ? item.trim() : null)) .map(item => (item !== null ? item.replace(/(^|\s+)(.)_($|\s+)/g, '$1$2$3') : null)); if (yAlbum && !album) album = yAlbum; if (yArtist && !artist) artist = yArtist; if (yTitle && !title) title = yTitle; if (yTrackNum && !trackNum) trackNum = yTrackNum; if (yRawTitle && !rawTitle) rawTitle = yRawTitle; console.log(`Scraped song metadata: ${yAlbum} / ${yArtist} - ${yTitle} # ${yTrackNum}`); console.log(`Using song metadata: ${album} / ${artist} - ${title} # ${trackNum}`); } else { console.log('Unable to scrape any song metadata'); } if (artist === null && title === null) { console.log('Don\'t have an artist and title yet, trying to extrapolate from source name'); const rawTitleMatch = rawTitle.match(/^([^-]+)(?:\s*-\s*(.+))?$/); if (rawTitleMatch[2] !== undefined && rawTitleMatch[2] !== null) { [sArtist, sTitle] = rawTitleMatch.slice(1).map(item => (item ? item.trim() : item)); const ok = await yn(`Is the following correct: ${sArtist} - ${sTitle}`); if (ok) { artist = sArtist; title = sTitle; } } else { sTitle = rawTitleMatch[1].trim(); const ok = await yn(`Is the title: ${sTitle}`); if (ok) { title = sTitle; } } } if (album === null) album = await query('Album'); if (artist === null) artist = await query('Artist'); if (title === null) title = await query('Title'); return { file: Path.join(TMP_DIR, rawFilePath), image: Path.join(TMP_DIR, rawImagePath), album, artist, title, }; }))).filter(p => p !== null); } for (const { album, artist, title, trackNum, file, image, } of files) { const path = `${os.homedir()}/Music/${album}/${artist} - ${title}.mp3`; try { await fs.mkdir(`${os.homedir()}/Music/${album}`, {recursive: true}); } catch (err) {} let encode = true; try { await fs.access(path); let ok = await yn('Song already exists, replace it?'); if (ok) { await fs.unlink(path); } else { encode = false; } } catch (err) {} if (encode) { console.log(`Encoding to: ${path}`); console.log('Checking dimensions of source thumbnail'); let albumArt = image; const imageSizeOut = await exec(`identify -format '%w %h' "${albumArt}"`); const imageSize = imageSizeOut.trim().match(/^(\d+) (\d+)$/).slice(1).map(a => parseInt(a)); if (imageSize[0] > (imageSize[1] * 1.2)) { console.log('Source thumbnail seems like a bad size, trying to scrape for album art elsewhere'); let coverArtPath = `${TMP_DIR}/cover.jpg`; try { await exec(`sacad "${artist}" "${album}" 1500 "${coverArtPath}" -d`); albumArt = coverArtPath; } catch (err) { console.log('Error scraping for album art elsewhere, falling back to source thumbnail'); } } else { console.log('Source thumbnail seems appropriately sized, using as album art'); } if (albumArt != null) { await exec(`ffmpeg -i "${file}" -i "${albumArt}" -map 0:0 -map 1:0 -id3v2_version 3 -metadata:s:v title="Album cover" -metadata:s:v comment="Cover (front)" -q:a 1 "${path}"`); } else { await exec(`ffmpeg -i "${file}" -id3v2_version 3 -q:a 1 "${path}"`); } } console.log('Adding id3v2 tags'); const tagArgs = []; tagArgs.push(['--artist', artist]); tagArgs.push(['--album', album]); tagArgs.push(['--song', title]); if (inputType === 'url') tagArgs.push(['--WXXX', `source:${source.replace(/https:\/\//g, '')}`]); if (trackNum) tagArgs.push(['--track', trackNum]); tagArgs.push(['--TXXX', 'mudlver:mudl 1']); const tagArgsString = tagArgs.map(([tag, val]) => `${tag} "${val}"`).join(' '); await exec(`id3v2 ${tagArgsString} "${path}"`); console.log(`Finished: ${album} / ${artist} - ${title}`); } console.log('Finished everything, cleaning up...'); await fs.rmdir(TMP_DIR, {recursive: true}); console.log('Done!'); } (async () => { try { await thing(process.argv[2], process.argv.length > 3 ? process.argv.slice(3) : []); } catch (err) { console.error(err); await fs.rmdir(TMP_DIR, {recursive: true}); } })(); process.on('SIGINT', async () => { await fs.rmdir(TMP_DIR, {recursive: true}); process.exit(); });