Last active
December 1, 2018 06:35
-
-
Save steelbrain/11ae2f9333b000c5f3f7fa00afa76776 to your computer and use it in GitHub Desktop.
Song metadata filling with ffmpeg node script
This file contains 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 | |
// 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) | |
}) |
This file contains 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 | |
// 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