Skip to content

Instantly share code, notes, and snippets.

@steelbrain
Last active December 1, 2018 06:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save steelbrain/11ae2f9333b000c5f3f7fa00afa76776 to your computer and use it in GitHub Desktop.
Save steelbrain/11ae2f9333b000c5f3f7fa00afa76776 to your computer and use it in GitHub Desktop.
Song metadata filling with ffmpeg node script
#!/usr/bin/env node
// Usage:
// $ cd dir/to/music
// $ node ../../music_metadata.js
// Files format must be:
// [Artist] - [Song Title].{oga,m4a}
const fs = require('fs')
const os = require('os')
const path = require('path')
const childProcess = require('child_process')
const validExtensions = ['.oga', '.m4a']
const currentDir = process.cwd()
const contents = fs.readdirSync(currentDir)
contents.forEach(function(fileName) {
const extName = path.extname(fileName)
if (!validExtensions.includes(extName) || extName === fileName) return
console.log('Processing', fileName)
const [artist, ...rest] = fileName.slice(0, -extName.length).split(/-/).map(entry => entry.trim())
const songName = rest.join(' ')
if (!artist || !songName) {
console.error(`Unable to get artist/songName for ${fileName}`)
process.exit(1)
}
const metadataContents = [
';FFMETADATA1',
`title=${songName}`,
`artist=${artist}`,
].join('\n')
const tempDir = os.tmpdir()
const outputPath = path.join(currentDir, 'out', fileName)
const metadataFile = path.join(tempDir, 'metadata')
fs.writeFileSync(metadataFile, metadataContents)
childProcess.spawnSync('ffmpeg', ['-i', fileName, '-i', metadataFile, '-map_metadata', '1', '-codec', 'copy', outputPath], {
stdio: 'inherit',
})
fs.unlinkSync(metadataFile)
})
#!/usr/bin/env node
// Dependencies:
// - sb-exec
// - music-metadata
// - trash
function strictIntersection(setA, setB) {
const setC = [];
for (
let i = 0, length = Math.min(setA.length, setB.length);
i < length;
i++
) {
if (setA[i] === setB[i]) {
setC.push(setA[i]);
} else break;
}
return setC;
}
function fancyTrim(str, prefix) {
let newStr = str;
const prefixIndex = newStr.indexOf(prefix);
if (prefixIndex >= 0) {
newStr =
newStr.slice(0, prefixIndex) + newStr.slice(prefixIndex + prefix.length);
newStr = newStr.trim();
}
if (newStr.endsWith("-")) {
newStr = newStr.slice(0, -1);
}
if (newStr.startsWith("-")) {
newStr = newStr.slice(1);
}
return newStr.trim();
}
const fs = require("fs");
const os = require("os");
const path = require("path");
const trash = require("trash");
const sbExec = require("sb-exec");
const musicMetadata = require("music-metadata");
const RECOGNIZED_OPTIONS = [
"remove-prefix",
"rewrite-meta",
"remove-prefix-aggressively",
"rename-files"
];
const RECOGNIZED_EXTENSIONS = [".mp3", ".m4a", ".flac"];
const METADATA_WHITELIST = [
"TIT2",
"TALB",
"TCON",
"TYER",
"TRCK",
"TPE1",
"TLEN",
"TPOS",
"title",
"album",
"artist",
"track",
"year",
"disk"
];
const METADATA_BLACKLIST = ["TENC", "USLT", "COMM", "TXXX", "WXXX"];
const TEMP_PREFIX = "Out - ";
async function main() {
const options = {};
const currentDirectory = process.cwd();
const currentDirectoryContents = fs.readdirSync(currentDirectory);
RECOGNIZED_OPTIONS.forEach(function(option) {
options[option] = process.argv.includes(`--${option}`);
});
let songs = currentDirectoryContents.filter(
i =>
RECOGNIZED_EXTENSIONS.includes(path.extname(i)) && !i.startsWith("Out -")
);
// Removing prefixes
if (options["remove-prefix"]) {
if (songs.length > 1) {
let prefixChunks = songs[0].split("");
for (let i = 1; i < songs.length; i++) {
prefixChunks = strictIntersection(prefixChunks, songs[i].split(""));
}
let prefix = prefixChunks.join("");
if (prefix && !options["remove-prefix-aggressively"]) {
// Read the album name, see if songs are prefixed with it
const firstSongMeta = await musicMetadata.parseFile(songs[0]);
const firstSongAlbum = firstSongMeta.common.album;
const albumNameIndex = firstSongAlbum
? prefix.toLowerCase().indexOf(firstSongAlbum.toLowerCase())
: -1;
if (albumNameIndex >= 0) {
prefix = prefix.slice(0, albumNameIndex).trim();
}
}
if (prefix.length) {
console.log(`Removing prefix '${prefix}'`);
const newSongs = songs.map(i => i.slice(prefix.length).trim());
songs.forEach((song, i) => fs.renameSync(song, newSongs[i]));
// They've been renamed, so future operations should be on new names
songs = newSongs;
} else {
console.warn("Song prefix inferring failed");
}
} else {
console.warn("Song prefix removal requires at least two songs");
}
}
// Remove duplicates from meta
if (options["rewrite-meta"]) {
const songsWithMetadata = await Promise.all(
songs.map(song =>
musicMetadata.parseFile(song, { native: true }).then(metadata => ({
song,
metadata: metadata.native
}))
)
);
const newSongs = await Promise.all(
songsWithMetadata.map(async song => {
const metadataMerged = {};
const metadataByOccurences = {};
Object.keys(song.metadata).forEach(metadataVersion => {
const metadata = song.metadata[metadataVersion];
metadata.forEach(({ id, value }) => {
const normalizedId = id.split(":")[0];
if (value && typeof value !== "object") {
metadataMerged[normalizedId] = value;
if (
!METADATA_WHITELIST.includes(normalizedId) &&
!METADATA_BLACKLIST.includes(normalizedId)
) {
if (!metadataByOccurences[value]) {
metadataByOccurences[value] = [];
}
metadataByOccurences[value].push(normalizedId);
}
}
});
});
const metadataToRemoveUniq = new Set(METADATA_BLACKLIST);
Object.entries(metadataByOccurences).forEach(
([value, metadataKeys]) => {
if (metadataKeys.length < 2) return;
metadataKeys.forEach(item => {
metadataToRemoveUniq.add(item);
Object.keys(metadataMerged).forEach(metadataKeyOrig => {
metadataMerged[metadataKeyOrig] = fancyTrim(
metadataMerged[metadataKeyOrig].toString(),
value
);
});
});
}
);
const metadataToRemove = Array.from(metadataToRemoveUniq);
const metadataContents = [
";FFMETADATA1",
...metadataToRemove.map(item => `${item}=`),
...Object.keys(metadataMerged)
.filter(item => !metadataToRemove.includes(item))
.map(key => `${key}=${metadataMerged[key]}`)
].join("\n");
const metadataFile = path.join(
os.tmpdir(),
`metadata-sb-${song.song}.txt`
);
fs.writeFileSync(metadataFile, metadataContents);
const newSongName = `${TEMP_PREFIX}${song.song}`;
try {
const spawnedProcess = await sbExec.exec("ffmpeg", [
"-i",
song.song,
"-i",
metadataFile,
"-map_metadata",
"1",
"-codec",
"copy",
newSongName,
"-loglevel",
"error"
]);
} finally {
fs.unlinkSync(metadataFile);
}
return newSongName;
})
);
await trash(songs);
newSongs.map(newSong => {
fs.renameSync(newSong, newSong.slice(TEMP_PREFIX.length));
});
}
}
main().catch(console.error);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment