mp3tag
Version:
A library for reading/writing mp3 tag data
460 lines (459 loc) • 17.4 kB
JavaScript
#!/usr/bin/env node
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const _ = __importStar(require("lodash"));
require("./mp3tag");
const file_1 = require("./file");
const data_1 = require("./data");
const out = __importStar(require("./cli/output"));
const parser = __importStar(require("./cli/taskParser"));
const taskParser_1 = require("./cli/taskParser");
const interpolator_1 = require("./cli/interpolator");
const mp3tag_1 = require("./mp3tag");
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
*/
let resolver;
let sourceFile;
// Options
parser.defineTask('v', {
type: taskParser_1.TaskType.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: taskParser_1.TaskType.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_1.Interpolator(sourceFile, tagData);
return tagData;
});
const exportProperties = {}; // The collected properties to export
parser.defineTask('export-album', {
max_args: 1,
type: taskParser_1.TaskType.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.getDecoder().decodeString(buffer) : '';
});
parser.defineTask('export-artist', {
max_args: 1,
type: taskParser_1.TaskType.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.getDecoder().decodeString(buffer) : '';
});
parser.defineTask('export-band', {
max_args: 1,
type: taskParser_1.TaskType.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.getDecoder().decodeString(buffer) : '';
});
parser.defineTask('export-cover', {
max_args: 1,
type: taskParser_1.TaskType.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: taskParser_1.TaskType.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.getDecoder().decodeString(buffer) : '';
});
parser.defineTask('export-track', {
max_args: 1,
type: taskParser_1.TaskType.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.getDecoder().decodeString(buffer) : '';
});
parser.defineTask('export-publisher', {
max_args: 1,
type: taskParser_1.TaskType.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.getDecoder().decodeString(buffer) : '';
});
parser.defineTask('export-comment-lang', {
max_args: 1,
type: taskParser_1.TaskType.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.getDecoder().decodeComment(buffer).language : '';
});
parser.defineTask('export-comment-short', {
max_args: 1,
type: taskParser_1.TaskType.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.getDecoder().decodeComment(buffer).short : '';
});
parser.defineTask('export-comment-long', {
max_args: 1,
type: taskParser_1.TaskType.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.getDecoder().decodeComment(buffer).long : '';
});
parser.defineTask('export-comment', {
max_args: 1,
type: taskParser_1.TaskType.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.getDecoder().decodeComment(buffer) : '';
});
parser.defineTask('export-text-frame', {
min_args: 1,
max_args: 2,
type: taskParser_1.TaskType.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), buffer => tagData.getDecoder().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: taskParser_1.TaskType.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: taskParser_1.TaskType.Read,
help_text: 'Prints out the contained tag data'
}, async function (tagData) {
showData(tagData);
});
// Write operations
parser.defineTask('set-album', {
max_args: 1,
type: taskParser_1.TaskType.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: taskParser_1.TaskType.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: taskParser_1.TaskType.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: taskParser_1.TaskType.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: taskParser_1.TaskType.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: taskParser_1.TaskType.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: taskParser_1.TaskType.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: taskParser_1.TaskType.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.getDecoder().encodeComment(comment));
}
});
parser.defineTask('delete-frame', {
args: 1,
type: taskParser_1.TaskType.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: taskParser_1.TaskType.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.getDecoder();
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, buffer) : "";
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 picture = decoder.decodePicture(buffer);
return {
...picture,
pictureData: picture.pictureData.inspect()
};
});
}
/** 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 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 (0, mp3tag_1.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 the TagData object which contains the picture
* @param 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.getDecoder().decodePicture(frameBuffer);
const filename = destination || ("cover." + pic.mimeType.split('/')[1]);
const file = await file_1.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
*/
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_1.File.readIntoBuffer(path);
const frameBuffer = tagData.getDecoder().encodePicture({
mimeType: mimeType,
pictureData: new data_1.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.getDecoder().encodeString(value));
}
}