Created
September 7, 2024 04:49
-
-
Save misterhat/30f1bcf2becc1f60059835d22dc18eb2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
const fetch = require('node-fetch-commonjs'); | |
const path = require('path'); | |
const readline = require('readline/promises'); | |
const { spawn } = require('child_process'); | |
const { promises: fs } = require('fs'); | |
const { stdin, stdout } = require('process'); | |
const concat = require('concat-stream'); | |
const ITUNES_ALBUM_URL = | |
'https://itunes.apple.com/search?term=%s&country=us&entity=album&limit=25'; | |
const DIRTY_MUSIC_DIRECTORY = '/home/zorian/music-dirty'; | |
const CLEAN_MUSIC_DIRECTORY = '/home/zorian/music-clean'; | |
const ALBUM_TAGS = ['artist', 'album', 'genre', 'date']; | |
const REQUIRED_TAGS = new Set([...ALBUM_TAGS, 'title', 'track']); | |
const VALID_EXTENSIONS = /(mp3|flac|opus|m4a|ogg)$/; | |
function pspawn(command, args) { | |
return new Promise((resolve) => { | |
const spawned = spawn(command, args); | |
const concatStream = concat((buffer) => resolve(buffer.toString())); | |
spawned.stderr.pipe(concatStream); | |
}); | |
} | |
async function getITunesMeta(artist, album) { | |
const res = await fetch( | |
ITUNES_ALBUM_URL.replace('%s', `${artist} - ${album}`) | |
); | |
try { | |
const { results } = await res.json(); | |
for (const collection of results) { | |
if ( | |
collection.artistName.toLowerCase() === artist.toLowerCase() && | |
collection.collectionName.toLowerCase() === album.toLowerCase() | |
) { | |
return { | |
albumArtURL: collection.artworkUrl100.replace( | |
'100x100', | |
'100000x100000' | |
), | |
genre: collection.primaryGenreName | |
}; | |
} | |
} | |
} catch (e) { | |
console.error(e); | |
return ''; | |
} | |
return ''; | |
} | |
async function getMetadata(file) { | |
const metadata = {}; | |
const stderr = await pspawn('ffprobe', ['-i', file]); | |
for (const line of stderr.split('\n')) { | |
let [name, value] = line.split(':').map((chunk) => chunk.trim()); | |
name = name.toLowerCase(); | |
if (REQUIRED_TAGS.has(name) && !metadata[name]) { | |
metadata[name] = value; | |
} | |
} | |
return metadata; | |
} | |
function getAlbumDirectory(metadata) { | |
return `${metadata.artist} - ${metadata.album.replace(/\//g, '-')} (${ | |
metadata.date | |
})`; | |
} | |
async function questionDefault(rl, question, defaultValue) { | |
return ( | |
await Promise.all([rl.question(question), rl.write(defaultValue)]) | |
)[0].trim(); | |
} | |
async function askMetadata(rl, metadata) { | |
const newMetadata = {}; | |
let newAlbumArtURL = ''; | |
for (const key of ALBUM_TAGS) { | |
let defaultValue = metadata[key] || ''; | |
if (key === 'genre') { | |
const { albumArtURL, genre } = await getITunesMeta( | |
newMetadata.artist, | |
newMetadata.album | |
); | |
defaultValue = genre || defaultValue; | |
newAlbumArtURL = await questionDefault( | |
rl, | |
'album art: ', | |
albumArtURL | |
); | |
} | |
const newValue = await questionDefault(rl, `${key}: `, defaultValue); | |
if (newValue === 'r') { | |
return askMetadata(rl, metadata); | |
} else { | |
newMetadata[key] = newValue; | |
} | |
} | |
return { metadata: newMetadata, albumArtURL: newAlbumArtURL }; | |
} | |
async function applyMetadata(file, newPath, metadata) { | |
const extension = path.extname(file); | |
const newFile = `${newPath}/${metadata.track | |
.toString() | |
.padStart(2, '0')} - ${metadata.title.replace( | |
/\//g, | |
'-' | |
)}.new${extension}`; | |
const args = [ | |
'-y', | |
'-i', | |
file, | |
'-map_metadata', | |
'-1', | |
'-c:a', | |
'copy', | |
'-vn' | |
]; | |
for (const key of REQUIRED_TAGS) { | |
const value = metadata[key] || ''; | |
args.push('-metadata', `${key}=${value}`); | |
} | |
args.push(newFile); | |
await pspawn('ffmpeg', args); | |
try { | |
//await fs.unlink(file); | |
await fs.rename(newFile, newFile.replace('.new', '')); | |
} catch (e) { | |
console.error(e); | |
} | |
} | |
(async () => { | |
const rl = readline.createInterface({ input: stdin, output: stdout }); | |
const directories = await fs.readdir(DIRTY_MUSIC_DIRECTORY); | |
for (const directory of directories) { | |
console.log(`-- ${directory} --`); | |
if (!/^y/i.test(await questionDefault(rl, 're-tag? ', 'y'))) { | |
continue; | |
} | |
const oldAlbumPath = `${DIRTY_MUSIC_DIRECTORY}/${directory}`; | |
const files = (await fs.readdir(oldAlbumPath)).filter((file) => | |
VALID_EXTENSIONS.test(file) | |
); | |
let albumMetadata = {}; | |
let albumPath = oldAlbumPath; | |
for (const [i, file] of files.entries()) { | |
const trackPath = `${oldAlbumPath}/${file}`; | |
const oldMetadata = await getMetadata(trackPath); | |
if (i === 0) { | |
const { metadata, albumArtURL } = await askMetadata( | |
rl, | |
oldMetadata | |
); | |
albumMetadata = metadata; | |
const newDirectory = getAlbumDirectory(albumMetadata); | |
albumPath = `${CLEAN_MUSIC_DIRECTORY}/${newDirectory}`; | |
try { | |
await fs.mkdir(albumPath); | |
} catch (e) { | |
// pass | |
} | |
if (albumArtURL.length) { | |
const res = await fetch(albumArtURL); | |
const albumArt = Buffer.from(await res.arrayBuffer()); | |
await fs.writeFile(`${albumPath}/cover.jpg`, albumArt); | |
} | |
} | |
const trackMetadata = { | |
...albumMetadata, | |
track: Number.parseInt(oldMetadata.track, 10), | |
title: oldMetadata.title | |
}; | |
if (!trackMetadata.title) { | |
trackMetadata.title = await rl.question(`${file} title: `); | |
} | |
if (!trackMetadata.track) { | |
trackMetadata.track = await rl.question(`${file} track: `); | |
} | |
await applyMetadata( | |
`${oldAlbumPath}/${file}`, | |
albumPath, | |
trackMetadata | |
); | |
} | |
} | |
rl.close(); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment