Skip to content

Instantly share code, notes, and snippets.

@lijunle
Last active July 7, 2024 07:25
Show Gist options
  • Save lijunle/d714c8f4b2554c3e4acfa866605645e8 to your computer and use it in GitHub Desktop.
Save lijunle/d714c8f4b2554c3e4acfa866605645e8 to your computer and use it in GitHub Desktop.
Rename DJI and iOS video files to normalized format.
.DS_Store
node_modules
#!/usr/bin/env node
// @ts-check
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import url from "node:url";
import { ExifDateTime, exiftool } from "exiftool-vendored";
/**
* Main function.
* @param {string[]} argv Process arguments.
* @returns {Promise<number>} The exit code.
*/
async function main(argv) {
if (!argv[2]) {
console.error(
`Usage: node ${argv[1]} <media-folder> [--run] [--timezone=Asia/Shanghai]`
);
return -1;
}
// Get media folder.
const dirname = path.dirname(url.fileURLToPath(import.meta.url));
const folder = path.resolve(dirname, argv[2]);
console.info("Media folder: " + folder);
// Default to dry run mode.
const dryRun = !argv.includes("--run");
console.info(`Dry run mode: ${dryRun}`);
// Set time zone name.
const timeZone = argv
.find((arg) => arg.startsWith("--timezone="))
?.substring(11);
console.info(`Time zone: ${timeZone}`);
const mediaFolder = new MediaFolder(folder, dryRun);
for (let filename of mediaFolder.getFiles()) {
await processExifMediaFiles(filename, mediaFolder, timeZone);
processDjiVideoFiles(filename, mediaFolder);
}
exiftool.end();
return 0;
}
/**
* Process EXIF based media files.
* It can be Sony photo and video files, or iPhone photo and video files.
* @param {string} filename File name.
* @param {MediaFolder} mediaFolder Media folder.
* @param {string | undefined} timeZone Time zone name.
*/
async function processExifMediaFiles(filename, mediaFolder, timeZone) {
const extname = path.extname(filename).toLowerCase();
if (
(filename.startsWith("_DSC") && extname === ".arw") || // Sony photo
(filename.startsWith("C") && extname === ".mp4") || // Sony video
(filename.startsWith("IMG_") && [".mov", ".heic", ".jpg"].includes(extname)) // iPhone photo and video
) {
const createDate = await mediaFolder.getExifCreateDate(filename);
const targetFilename = formatDate(createDate, timeZone) + extname;
mediaFolder.renameSafely("EXIF", filename, targetFilename);
}
}
/**
* Process DJI video files.
* @param {string} filename File name.
* @param {MediaFolder} mediaFolder Media folder.
*/
function processDjiVideoFiles(filename, mediaFolder) {
if (filename.startsWith("DJI_")) {
const { name, ext } = path.parse(filename);
const targetName = name.replace(
/DJI_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})_\d{3}_video/,
"$1-$2-$3 $4.$5.$6"
);
const targetedFilename = targetName + ext.toLowerCase();
mediaFolder.renameSafely("DJI", filename, targetedFilename);
}
}
/**
* Format the date time to file name format.
* @param {Date} date The date time to format
* @param {string | undefined} timeZone Time zone name.
* @returns The formated date time string
*/
function formatDate(date, timeZone) {
/** @type {Intl.DateTimeFormatOptions} */
const options = {
timeZone,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const str = date.toLocaleString("en-US", options);
const [month, day, year, hour, minute, second] = str.split(/[\s,:/]+/);
const output = `${year}-${month}-${day} ${hour}.${minute}.${second}`;
return output;
}
class MediaFolder {
/**
* Create a new instance of MediaFolder.
* @param {string} folder The media folder.
* @param {boolean} dryRun Dry run mode.
*/
constructor(folder, dryRun) {
this.folder = folder;
this.dryRun = dryRun;
// New file names is only used in dry run mode to detect naming conflicts.
this.newFilenames = new Set(this.dryRun ? this.getFiles() : []);
}
/**
* Read the files in the media folder.
* @returns {string[]} The files in the folder.
*/
getFiles() {
const files = fs.readdirSync(this.folder, "utf-8");
return files;
}
/**
* Get the EXIF create date from the file.
* @param {string} filename The file name
* @returns {Promise<Date>} The create date.
*/
async getExifCreateDate(filename) {
const filePath = path.resolve(this.folder, filename);
const tags = await exiftool.read(filePath, ["-api", "LargeFileSupport=1"]);
const date = tags.CreationDate || tags.CreateDate;
if (!date) {
console.warn(`Create date not found: ${filename}`);
throw new Error("Create date not found");
} else if (typeof date === "string") {
const offsetTime = tags.OffsetTime || "Z";
const createDate = new Date(date + offsetTime);
return createDate;
} else {
const createDate = this.#extractExifDateTime(date).toDate();
return createDate;
}
}
/**
* Extract the EXIF date time.
* @param {ExifDateTime | { Value: ExifDateTime }} date EXIF date time or its wrapper.
* @returns {ExifDateTime} The EXIF date time.
*/
#extractExifDateTime(date) {
// It seems a bug in some platforms that the ExifDateTime is wrapped in an object.
if (date instanceof ExifDateTime) {
return date;
} else {
return date.Value;
}
}
/**
* Resolve the file name with suffix.
* @param {string} filename File name.
* @param {string | number} suffix File name suffix.
* @returns {string} The resolved file name.
*/
#resolveName(filename, suffix = "") {
if (suffix) {
const extname = path.extname(filename);
const basename = path.basename(filename, extname);
const name = `${basename} ${suffix}${extname}`;
return name;
} else {
return filename;
}
}
/**
* Check if the file exists.
* @param {string} filename The file name.
* @returns {boolean} True if the file exists.
*/
#fileExists(filename) {
if (this.dryRun) {
return this.newFilenames.has(filename);
} else {
const filePath = path.resolve(this.folder, filename);
return fs.existsSync(filePath);
}
}
/**
* Rename the old file name to new one safely.
* This method will detect naming conflict and resolve it with counter suffix.
* This method will detect darktable XMP and Sony Video XML files and rename them as well.
* @param {string} category Category of the file.
* @param {string} oldFilename Old file name.
* @param {string} newFilename New file name.
* @param {number} suffix Counter suffix to resolve file name conflict issue.
*/
renameSafely(category, oldFilename, newFilename, suffix = 0) {
const oldName = this.#resolveName(oldFilename);
const newName = this.#resolveName(newFilename, suffix);
if (this.#fileExists(newName)) {
this.renameSafely(category, oldFilename, newFilename, suffix + 1);
} else {
this.#renameFile(category, oldName, newName);
// If darktable XMP file exists, rename it as well.
const xmpOldName = `${oldName}.xmp`;
if (this.#fileExists(xmpOldName)) {
const xmpNewName = `${newName}.xmp`;
this.#renameFile("Darktable XMP", xmpOldName, xmpNewName);
}
// If Sony Video XML file exists, rename it as well.
const xmlOldName = oldName.replace(/\.MP4$/, "M01.XML");
if (oldName.endsWith(".MP4") && this.#fileExists(xmlOldName)) {
const xmlNewName = newName.replace(/\.mp4$/, ".xml");
this.#renameFile("Sony Video XML", xmlOldName, xmlNewName);
}
}
}
/**
* Rename the file. It must ensure the new file name does not conflict.
* @param {string} category The category of the file.
* @param {string} oldFilename The old file name.
* @param {string} newFilename The new file name.
*/
#renameFile(category, oldFilename, newFilename) {
process.stdout.write(
`${category.padEnd(16)}${newFilename.padEnd(27)}${oldFilename}`
);
if (this.dryRun) {
this.newFilenames.add(newFilename);
process.stdout.write("\t\u{1F7E1}\n");
} else {
const oldPath = path.resolve(this.folder, oldFilename);
const newPath = path.resolve(this.folder, newFilename);
fs.renameSync(oldPath, newPath);
process.stdout.write("\t\u{1F7E2}\n");
}
}
}
process.exit(await main(process.argv));
{
"name": "media-renames",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "media-renames",
"version": "0.0.1",
"dependencies": {
"exiftool-vendored": "^26.2.0"
}
},
"node_modules/@photostructure/tz-lookup": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-10.0.0.tgz",
"integrity": "sha512-8ZAjoj/irCuvUlyEinQ/HB6A8hP3bD1dgTOZvfl1b9nAwqniutFDHOQRcGM6Crea68bOwPj010f0Z4KkmuLHEA=="
},
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA=="
},
"node_modules/batch-cluster": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz",
"integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==",
"engines": {
"node": ">=14"
}
},
"node_modules/exiftool-vendored": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-26.2.0.tgz",
"integrity": "sha512-7P6jQ944or7ic2SJzW+uaWK4TLDXlaCppHrBayl4MpIrVcEeQjiQTez4/oOH0wULIRu4j4H6Xruz4SLrDaafUg==",
"dependencies": {
"@photostructure/tz-lookup": "^10.0.0",
"@types/luxon": "^3.4.2",
"batch-cluster": "^13.0.0",
"he": "^1.2.0",
"luxon": "^3.4.4"
},
"optionalDependencies": {
"exiftool-vendored.exe": "12.85.0",
"exiftool-vendored.pl": "12.85.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "12.85.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.85.0.tgz",
"integrity": "sha512-rWsKVp9oXsS79S3bfCNXKeEo4av0xcd7slk/TfPpCa5pojg8ZVXSVfPZMAAlhOuK63YXrKN/e3jRNReeGP+2Gw==",
"optional": true,
"os": [
"win32"
]
},
"node_modules/exiftool-vendored.pl": {
"version": "12.85.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.85.0.tgz",
"integrity": "sha512-AelZQCCfl0a0g7PYx90TqbNGlSu2zDbRfCTjGw6bBBYnJF0NUfUWVhTpa8XGe2lHx1KYikH8AkJaey3esAxMAg==",
"optional": true,
"os": [
"!win32"
]
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"bin": {
"he": "bin/he"
}
},
"node_modules/luxon": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==",
"engines": {
"node": ">=12"
}
}
}
}
{
"name": "media-renames",
"version": "0.0.1",
"private": true,
"dependencies": {
"exiftool-vendored": "^26.2.0"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment