Skip to content

Instantly share code, notes, and snippets.

@blowery
Last active September 22, 2020 19:27
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 blowery/15fa739be96be0de38407f51c78e11e2 to your computer and use it in GitHub Desktop.
Save blowery/15fa739be96be0de38407f51c78e11e2 to your computer and use it in GitHub Desktop.
Tesla Media Transformer
const fs = require("fs");
const path = require("path");
const mkdirp = require("mkdirp");
const mp3tag = require("mp3tag");
const _ = require("lodash");
const TagData = require("mp3tag/tagdata");
// from
const FROM_DIR = path.join(__dirname, "Rips");
const TO_DIR = path.join(__dirname, "ForTesla");
console.log(`going from ${FROM_DIR} to ${TO_DIR}`);
async function getDirs(path) {
let dirs = await fs.promises.readdir(path, { withFileTypes: true });
return dirs.filter((dir) => dir.isDirectory());
}
/** @typedef {Object} PathAndName
* @property {string} name - The name of the file
* @property {string} path - The full path to the file
*/
/** @returns {Promise<Array<PathAndName>>} */
async function getMP3s(rootPath) {
const files = await fs.promises.readdir(rootPath, { withFileTypes: true });
return files
.filter((file) => file.isFile() && file.name.endsWith(".mp3"))
.map((file) => ({
name: file.name,
path: path.join(rootPath, file.name),
}));
}
async function* tagDataGenerator(files) {
for (let file of files) {
yield await mp3tag.readHeader(file.path);
}
}
/**
* Get a frame buffer as a string
* @param {TagData} tag
* @param {string} id
*/
function getTagString(tag, id) {
const buf = tag.getFrameBuffer(id);
if (!buf) {
return null;
}
return tag.decoder.decodeString(buf);
}
async function processAlbum(artist, album) {
// make this directory in the target if not there
const source = path.join(FROM_DIR, artist, album);
const target = path.join(TO_DIR, artist, album);
let exists;
try {
const ok = await fs.promises.access(target);
exists = true;
} catch (e) {
exists = false;
}
if (!exists) {
console.log(`making ${target}`);
try {
await mkdirp(target);
} catch (e) {
console.error(e);
}
}
// find files
let sourceFiles = await getMP3s(source);
console.log(" %d songs", sourceFiles.length);
for (let file of sourceFiles) {
await fs.promises.copyFile(
path.join(file.path),
path.join(target, file.name)
);
//console.log('.');
}
// grab the target files
let targetFiles = await getMP3s(target);
// grab the idv3 tag info for everything
/** @type {Array<TagData>} */
let tags = [];
for await (const tag of tagDataGenerator(targetFiles)) {
tags.push(tag);
}
// is this is a multidisc set?
// if so, we have to add (Disc %d) to the album name on each track to diff
const discs = tags.reduce((discs, tag, i) => {
let disc = getTagString(tag, "TPOS");
if (!disc) {
//console.log(" no TPOS for %s", targetFiles[i].name);
return discs;
}
// this can be either a plain X or X/Y
let discNo = Number(disc.split("/")[0]);
discs.add(discNo);
return discs;
}, new Set());
if (discs.size > 1) {
console.log(` MultiDisc: (%d)`, discs.size);
try {
await Promise.all(
tags.map((tag) => {
const albumTitle = getTagString(tag, "TALB");
const discNo = Number(getTagString(tag, "TPOS").split("/")[0]);
const newAlbumTitle = `${albumTitle} (Disc ${discNo})`;
tag.setFrameBuffer("TALB", tag.decoder.encodeString(newAlbumTitle));
return tag.save();
})
);
} catch (e) {
console.error(e);
process.exit(1);
}
}
// is this a compilation?
// if so, we need to consolidate the track artist down to one value
// write the track artist into the title instead, if different from the album artist
// use the album artist as the track artist
const uniqueArtists = new Set(tags.map((tag) => getTagString(tag, "TPE1")));
const uniqueAlbumArtists = new Set(
tags.map((tag) => getTagString(tag, "TPE2"))
);
uniqueArtists.delete(null);
uniqueAlbumArtists.delete(null);
const isComp =
tags.some((tag) => getTagString(tag, "TCMP") === "1") ||
uniqueArtists.size > 1;
isComp && console.log(` Compilation`);
if (isComp) {
// gather all of the album artists
const artists = Array.from(uniqueAlbumArtists);
console.log(
" Album Artist(s): %s",
artists.length ? artists.join(", ") : "<None Found>"
);
const albumArtist =
artists.length === 0 || artists.length > 1
? "Various Artists"
: artists[0];
console.log(' Using "%s"', albumArtist);
try {
await Promise.all(
tags.map((tag) => {
const artist = getTagString(tag, "TPE1");
if (artist && artist !== albumArtist) {
const trackTitle = getTagString(tag, "TIT2").trim();
const newTrackTitle = `${trackTitle} by ${artist}`;
console.log(" title -> ", newTrackTitle);
tag.setFrameBuffer("TIT2", tag.decoder.encodeString(newTrackTitle));
}
console.log(" artists -> ", albumArtist);
tag.setFrameBuffer("TPE1", tag.decoder.encodeString(albumArtist));
tag.setFrameBuffer("TPE2", tag.decoder.encodeString(albumArtist));
return tag.save();
})
);
} catch (e) {
console.error(e);
process.exit(1);
}
}
}
(async function () {
let artists = await getDirs(FROM_DIR);
for (let artist of artists) {
console.log(`${artist.name}`);
let albums = await getDirs(path.join(FROM_DIR, artist.name));
for (let album of albums) {
console.log(` ${album.name}`);
await processAlbum(artist.name, album.name);
console.log();
}
}
})();
{
"name": "tesla-transform",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"lodash": "^4.17.20",
"mkdirp": "^1.0.4",
"mp3tag": "^1.0.3",
"node-dir": "^0.1.17",
"prettier": "^2.1.1"
}
}
@blowery
Copy link
Author

blowery commented Sep 22, 2020

This assumes that it lives in a folder with two subfolders, Rips and TeslaMusic. Run yarn first then run node index.js to run the transform. It doesn't currently clean out TeslaMusic before transforming, I just run rm -rf TeslaMusic/* before each run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment