UNPKG

mp3tag

Version:

A library for reading/writing mp3 tag data

520 lines (435 loc) 15.7 kB
#!/usr/bin/env node const _ = require('lodash'); const mp3tag = require('./mp3tag'); const File = require('./file'); const TagData = require('./tagdata'); // only needed for intellisense to know the type const Data = require('./data'); const out = require('./output'); const parser = require('./cli/taskParser'); const Interpolator = require('./cli/interpolator'); const options = { verbose: false }; /** Command line interface to the mp3tag library * Primary options: * --in [filename] The source file to read. If not set, an empty file will be genrated. * * --write [filename] Specifies that the changes, made to the tags should be written into filename. If filename is left out, the input file is used. * * Other options: * --export-cover [destination] Export the cover image (if any) to destination (if given, default is "./cover.[mimetype]") * --export-format [format] Defines the format in which to export (eg. json) * --export-title [property] Exports the title into the given property * --export-* [property] ... * --v Output debug info * --show-data Prints out the contained tag data * --set-album [name] Sets/Unsets the album * --set-track [trackNr] Sets/Unsets the track number */ /** @type {Interpolator} */ let resolver; /** @type {string} */ let sourceFile; //Options parser.defineTask('v', { type:'option', help_text: 'Verbose output' }, async function() { options.verbose = true; out.config().debug = true; // Enable debug logger return; }); //Sources parser.defineTask('in', { args:1, type:'source', arg_display:'[filename]', help_text: 'The source file to read. If not set, an empty file will be generated.' }, async function() { sourceFile = this.args[0]; //Set the source from which the file has been read const tagData = await getHeader(sourceFile); resolver = new Interpolator(sourceFile, tagData); return tagData; }); //Read operations const exportProperties = {}; // The collected properties to export parser.defineTask('export-album', { max_args:1, type:'read', arg_display: '[property name]', help_text: 'exports the album name' }, async function(tagData) { const property = this.args[0] || 'album'; const buffer = tagData.getFrameBuffer('TALB'); exportProperties[property] = buffer ? tagData.decoder.decodeString(buffer) : ''; }); parser.defineTask('export-artist', { max_args: 1, type: 'read', arg_display: '[property name]', help_text: 'exports the artist' }, async function(tagData) { const property = this.args[0] || 'artist'; const buffer = tagData.getFrameBuffer('TPE1'); exportProperties[property] = buffer ? tagData.decoder.decodeString(buffer) : ''; }); parser.defineTask('export-band', { max_args: 1, type: 'read', arg_display: '[property name]', help_text: 'exports the band name' }, async function(tagData) { const property = this.args[0] || 'band'; const buffer = tagData.getFrameBuffer('TPE2'); exportProperties[property] = buffer ? tagData.decoder.decodeString(buffer) : ''; }); parser.defineTask('export-cover', { max_args:1, type:'read', arg_display:'[destination]', help_text: 'Export the cover image (if any) to destination (if given, default is "./cover.[mimetype]")' }, async function(tagData) { const res = await exportCover(tagData, this.args[0]); out.info(`Exported cover picture to: ${res.filename} (${res.bytes} bytes written)`); }); parser.defineTask('export-title', { max_args: 1, type: 'read', arg_display: '[property name]', help_text: 'exports the title' }, async function(tagData) { const property = this.args[0] || 'title'; const buffer = tagData.getFrameBuffer('TIT2'); exportProperties[property] = buffer ? tagData.decoder.decodeString(buffer) : ''; }); parser.defineTask('export-track', { max_args: 1, type: 'read', arg_display: '[property name]', help_text: 'exports the track' }, async function(tagData) { const property = this.args[0] || 'track'; const buffer = tagData.getFrameBuffer('TRCK'); exportProperties[property] = buffer ? tagData.decoder.decodeString(buffer) : ''; }); parser.defineTask('export-publisher', { max_args: 1, type: 'read', arg_display: '[property name]', help_text: 'exports the publisher' }, async function(tagData) { const property = this.args[0] || 'publisher'; const buffer = tagData.getFrameBuffer('TPUB'); exportProperties[property] = buffer ? tagData.decoder.decodeString(buffer) : ''; }); parser.defineTask('export-comment-lang', { max_args: 1, type: 'read', arg_display: '[property name]', help_text: 'exports the comment\'s language' }, async function(tagData) { const property = this.args[0] || 'comment-lang'; const buffer = tagData.getFrameBuffer('COMM'); exportProperties[property] = buffer ? tagData.decoder.decodeComment(buffer).language : ''; }); parser.defineTask('export-comment-short', { max_args: 1, type: 'read', arg_display: '[property name]', help_text: 'exports the comment\'s short text' }, async function(tagData) { const property = this.args[0] || 'comment-short'; const buffer = tagData.getFrameBuffer('COMM'); exportProperties[property] = buffer ? tagData.decoder.decodeComment(buffer).short : ''; }); parser.defineTask('export-comment-long', { max_args: 1, type: 'read', arg_display: '[property name]', help_text: 'exports the comment text' }, async function(tagData) { const property = this.args[0] || 'comment-long'; const buffer = tagData.getFrameBuffer('COMM'); exportProperties[property] = buffer ? tagData.decoder.decodeComment(buffer).long : ''; }); parser.defineTask('export-comment', { max_args: 1, type: 'read', arg_display: '[property name]', help_text: 'exports the comment' }, async function(tagData) { const property = this.args[0] || 'comment'; const buffer = tagData.getFrameBuffer('COMM'); exportProperties[property] = buffer ? tagData.decoder.decodeComment(buffer) : ''; }); parser.defineTask('export-text-frame', { min_args: 1, max_args: 2, type: 'read', arg_display: '[frame id] [property name]', help_text: 'Display the text content of a frame specified by id' }, async function(tagData) { const frameId = this.args[0]; const property = this.args[1] || frameId; // could be multiple frames with the same id const frameStrings = _.map(tagData.getFrameBuffers(frameId), function(buffer) { return tagData.decoder.decodeString(buffer); }); if (frameStrings.length == 0) { exportProperties[property] = null; } else if (frameStrings.length == 1) { exportProperties[property] = frameStrings[0]; } else { exportProperties[property] = frameStrings; } }); parser.defineTask('export-format', { max_args: 1, type: 'read', arg_display: '[format]', help_text: 'actually exports the properties, which were exported via export-* tasks. Supported formats are: json,...' }, async function(tagData) { const format = this.args[0] || 'json'; if (format === 'json') { console.log(JSON.stringify(exportProperties)); } //TODO: support other formats aswell }); parser.defineTask('show-data', { type:'read', help_text:'Prints out the contained tag data' }, async function(tagData) { showData(tagData); }); // Write operations parser.defineTask('set-album', { max_args: 1, type: 'write', arg_display: '[name]', help_text: 'Sets/Unsets album name' }, async function(tagData) { setFrameString(tagData, 'TALB', this.args[0]); }); parser.defineTask('set-artist', { max_args: 1, type: 'write', arg_display: '[name]', help_text: 'Sets/Unsets artist name' }, async function(tagData) { const argString = await resolver.interpolate(this.args[0]); setFrameString(tagData, 'TPE1', argString); }); parser.defineTask('set-cover', { max_args: 2, type: 'write', arg_display: '[filename] [mime type]', help_text: 'Sets/Unsets the cover picture (with optional mime type provided)' }, async function(tagData) { await writeCover(tagData, this.args[0], this.args[1]); }); parser.defineTask('set-band', { max_args: 1, type: 'write', arg_display: '[name]', help_text: 'Sets/Unsets band name' }, async function(tagData) { setFrameString(tagData, 'TPE2', this.args[0]); }); parser.defineTask('set-title', { max_args: 1, type: 'write', arg_display: '[title]', help_text: 'Sets/Unsets the title' }, async function(tagData) { setFrameString(tagData, 'TIT2', this.args[0]); }); parser.defineTask('set-track', { max_args: 1, type: 'write', arg_display: '[trackNr]', help_text: 'Sets/Unsets the track number' }, async function(tagData) { setFrameString(tagData, 'TRCK', this.args[0]); }); parser.defineTask('set-publisher', { max_args: 1, type: 'write', arg_display: '[publisher]', help_text: 'Sets/Unsets the publisher' }, async function(tagData) { setFrameString(tagData, 'TPUB', this.args[0]); }); parser.defineTask('set-comment', { max_args: 1, type: 'write', arg_display: '["lang;short;long"]', help_text: 'Set/Clear comment (a semicolon separated string)' }, async function(tagData) { if (!this.args[0]) { tagData.removeFrame('COMM'); } else { const parts = this.args[0].split(';'); let comment; if (parts.length === 1) { // special case only long comment comment = {language:'eng', short:'', long:parts[0]}; } else if (parts.length === 2) { // only language and long comment = { language: parts[0], short: '', long: parts[1]}; } else { // regular comment comment = {language: parts[0], short:parts[1], long:parts[2]}; } tagData.setFrameBuffer('COMM', tagData.decoder.encodeComment(comment)); } }); parser.defineTask('delete-frame', { args: 1, type: 'write', arg_display: '[frame id]', help_text: 'delete the frame(s) with the given frame id' }, async function(tagData) { const frameId = this.args[0]; tagData.removeFrame(frameId); }); ///---------------------- //TODO define other tasks ///---------------------- //Sinks parser.defineTask('write', { max_args:1, type:'sink', arg_display:'[filename]', help_text:'Specifies that the changes, made to the tags should be written into filename. If filename is left out, the input file is used.' + 'The filename may not be left out if no filename was specified with --in' }, async function(tagData) { const target = this.args[0] || sourceFile; if (!target) { throw new Error("Failed to write changes into file. No file passed to --in or --write task."); } out.debug(`Writing mp3 to '${target}'`); await tagData.writeToFile(target); // TODO: translate error message? out.info(`Successfully written '${target}'`); }); // Parse the tasks parser.run(process.argv.slice(2)).catch((err) => { // Parsing might have failed out.error("ERROR: " + err.message); console.error(err); process.exit(1); }); /** Simply prints all known data about the audio */ function showData(tagData) { const decoder = tagData.decoder; console.dir(tagData); console.log("\n"); function printOut(id, asName, decodefn) { const decfn = decodefn || decoder.decodeString; const buffer = tagData.getFrameBuffer(id); let result = buffer ? decfn.call(decoder, tagData.getFrameBuffer(id)) : ""; if (typeof result !== 'string') { result = JSON.stringify(result, null, 2); } console.log(`${asName} : ${result}`); } //TODO define methods to read/write these properties printOut('TIT2', "Title"); printOut('TRCK', "Track"); printOut('TALB', "Album"); printOut('COMM', "Comment", decoder.decodeComment); printOut('TYER', "Year"); printOut('TPE1', "Lead performer"); printOut('TPE2', "Band"); printOut('POPM', "Popularimeter", decoder.decodePopularity); printOut('APIC', "Picture", (buffer) => { const res = decoder.decodePicture(buffer); res.pictureData = res.pictureData.inspect(); return res; }); } /** This function returns an mp3tag header based on the source. * If the passed source is not a string, then an empty header will be returned. * * @param {string} source the mp3 file path to read the header from * * @return {Promise<TagData>} resolves to the loaded tag data or empty tag data if * no source has been provided. */ async function getHeader(source) { if (typeof(source) === "string") { out.debug(`Loading audio file form '${source}'`); return await mp3tag.readHeader(source); } else { throw new Error('No source file passed to load the audio from'); } } /** This function is used to export the cover picture from the mp3 file. * * @param {TagData} tagData the TagData object which contains the picture * @param {string} destination a filepath or undefined, if default filepath should be used * * @return {Promise<{bytes:number,filename:string}>} resolves to an info object of * where the cover picture has been written to. */ async function exportCover(tagData, destination) { const frameBuffer = tagData.getFrameBuffer('APIC'); if (!frameBuffer) { throw new Error("File has no picture frame"); } const pic = tagData.decoder.decodePicture(frameBuffer); const filename = destination || ("cover." + pic.mimeType.split('/')[1]); const file = await File.open(filename, "w"); const bytesWritten = await pic.pictureData.writeInto(file); file.close(); return {bytes:bytesWritten, filename:filename}; } const KNOWN_MIME_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp' }; /** Writes the given cover image into the mp3 cover picture frame * * @param {TagData} tagData * @param {string} path * @param {string} mimeType * * @returns {Promise} */ async function writeCover(tagData, path, mimeType) { if (!path) { // simply remove any picture frame return tagData.removeFrame('APIC'); } if (!mimeType) { // Check whether we can derive the mime type from the file name const lowerPath = path.toLowerCase(); mimeType = _.find(KNOWN_MIME_TYPES, (mime,extension) => lowerPath.endsWith(extension)); if (!mimeType) { throw new Error("Cannot determine mime type from file name, please specify it explicitly"); } out.debug(`detected mime type for cover image as: ${mimeType}`); } out.debug(`loading cover picture into memory: ${path}`); const imageBuffer = await File.readIntoBuffer(path); const frameBuffer = tagData.decoder.encodePicture({ mimeType: mimeType, pictureData: new Data(imageBuffer), description: 'cover', pictureType: 3 // type 3 is the cover front picture }); tagData.setFrameBuffer('APIC', frameBuffer); } /** Generic string writing utiltiy for string properties */ function setFrameString(tagData, frameID, value) { if (typeof(value) !== 'string') { tagData.removeFrame(frameID); // Just remove the frame } else { tagData.setFrameBuffer(frameID, tagData.decoder.encodeString(value)); } }