Skip to content

Instantly share code, notes, and snippets.

@misterhat
Created September 7, 2024 04:49
Show Gist options
  • Save misterhat/30f1bcf2becc1f60059835d22dc18eb2 to your computer and use it in GitHub Desktop.
Save misterhat/30f1bcf2becc1f60059835d22dc18eb2 to your computer and use it in GitHub Desktop.
#!/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