Skip to content

Instantly share code, notes, and snippets.

@SeanCassiere
Created August 11, 2025 12:33
Show Gist options
  • Select an option

  • Save SeanCassiere/5db888c95836af9b5c67a29aad78a856 to your computer and use it in GitHub Desktop.

Select an option

Save SeanCassiere/5db888c95836af9b5c67a29aad78a856 to your computer and use it in GitHub Desktop.
// Command to run:
// `node index.js --dry --debug --username un --password pw --host hostname/media`
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
import process from "node:process";
/**
* This function takes the video filename and determines the new name for it.
*
* @param {string} fileNameWithoutExtension - The video filename without its extension.
* @returns {string} The inferred new name with the pattern replaced.
*/
function inferNameFromVideoFilename(fileNameWithoutExtension) {
// Old name: Jurassic World Rebirth (2025) - WEBRip-1080p x265
// New name: Jurassic World Rebirth (2025) WEBRip-1080p x265
const newName = fileNameWithoutExtension.replace(/(\)) - /, "$1 ");
return newName;
}
// --- CLI CONFIGURATION ---
let CACHED_CLI_ARGS = null;
const CLI_CONFIG = {
isDebug: false,
isDry: false,
username: "username",
password: "password",
host: "hostname",
};
optionalBooleanCliArg("--dry", (val) => {
CLI_CONFIG.isDry = val;
});
optionalBooleanCliArg("--debug", (val) => {
CLI_CONFIG.isDebug = val;
});
requiredCliArg("--username", (val) => {
CLI_CONFIG.username = val;
});
requiredCliArg("--password", (val) => {
// Encode the password to handle special characters
CLI_CONFIG.password = encodeURIComponent(val);
});
requiredCliArg("--host", (val) => {
CLI_CONFIG.host = val;
});
// --- SMB CONFIGURATION ---
const SMB_SERVER = `smb://${CLI_CONFIG.username}:${CLI_CONFIG.password}@${CLI_CONFIG.host}`;
const MOUNT_POINT = "/Volumes/MyMediaShare"; // Change as needed
const MOVIES_DIR = path.join(MOUNT_POINT, "movies");
// Common video file extensions
const VIDEO_EXTS = [".mp4", ".mkv", ".avi", ".mov", ".flv", ".wmv", ".webm"];
// --- STEP 1: Mount SMB Share if not mounted ---
function mountSMBShare() {
if (!fs.existsSync(MOUNT_POINT)) {
fs.mkdirSync(MOUNT_POINT, { recursive: true });
}
try {
execSync(`mount | grep "${MOUNT_POINT}"`, { stdio: "ignore" });
console.info(`βœ… SMB share already mounted at ${MOUNT_POINT}`);
} catch {
console.info(`πŸ”Œ Mounting SMB share...`);
try {
execSync(`mount_smbfs ${SMB_SERVER} ${MOUNT_POINT}`);
console.info(`πŸ›₯️ Mounted SMB share at "${MOUNT_POINT}"`);
if (CLI_CONFIG.isDebug)
console.info(
`πŸ” Debug: Mounted SMB share using connection "${SMB_SERVER}"`
);
} catch (err) {
console.error(`❌ Failed to mount SMB share: ${err.message}`);
process.exit(1);
}
}
}
// --- STEP 2: Unmount SMB Share ---
function unmountSMBShare() {
try {
execSync(`umount ${MOUNT_POINT}`);
console.info(`πŸ”Œ Unmounted SMB share from ${MOUNT_POINT}`);
} catch (err) {
console.error(`⚠️ Failed to unmount SMB share: ${err.message}`);
}
}
// --- STEP 3: Rename files and directories ---
function renameMatchingFiles(dir, oldName, newName, filesOrDirsMatchingNames) {
console.info(`πŸ”„ Renaming files from "${oldName}" to "${newName}"...`);
let successCount = 0;
let errorCount = 0;
for (const fileOrDir of filesOrDirsMatchingNames) {
const oldPath = path.join(dir, fileOrDir.name);
// Replace the old name prefix with the new name prefix
const newFileName = fileOrDir.name.replace(oldName, newName);
const newPath = path.join(dir, newFileName);
// Skip if the name wouldn't actually change
if (oldPath === newPath) {
console.info(` ⏭️ Skipping "${fileOrDir.name}" (no change needed)`);
continue;
}
try {
// Check if target already exists
if (fs.existsSync(newPath)) {
console.warn(` ⚠️ Target already exists: "${newFileName}"`);
errorCount++;
continue;
}
// Perform the rename
console.info(` πŸ“‚ Old path: "${oldPath}"`);
console.info(` πŸ“‚ New path: "${newPath}"`);
// Uncomment the next line to actually rename files/directories
if (!CLI_CONFIG.isDry) {
fs.renameSync(oldPath, newPath);
console.info(` βœ… Renamed: "${fileOrDir.name}" β†’ "${newFileName}"`);
} else {
console.info(
` πŸ₯Š DRY RUN: Would rename "${fileOrDir.name}" to "${newFileName}"`
);
}
successCount++;
} catch (err) {
console.error(
` ❌ Failed to rename "${fileOrDir.name}": ${err.message}`
);
errorCount++;
}
}
console.info(
`πŸ“Š Rename summary: ${successCount} successful, ${errorCount} failed`
);
// Print prominent error message if any failures occurred
if (errorCount > 0) {
console.error(
`🚨 RENAME FAILURES DETECTED: ${errorCount} file(s) failed to rename in "${path.basename(
dir
)}"`
);
}
return { successCount, errorCount };
}
// --- STEP 4: Scan only immediate subdirectories ---
function scanVideosOneLevel() {
let subDirs;
try {
subDirs = fs
.readdirSync(MOVIES_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(MOVIES_DIR, entry.name));
} catch (err) {
console.error(`⚠️ Cannot read directory: ${MOVIES_DIR} - ${err.message}`);
return;
}
for (const dir of subDirs) {
let files;
try {
files = fs.readdirSync(dir, { withFileTypes: true });
} catch (err) {
console.error(`⚠️ Cannot read directory: ${dir} - ${err.message}`);
continue;
}
const videoFiles = files
.filter(
(file) =>
!file.isDirectory() &&
VIDEO_EXTS.includes(path.extname(file.name).toLowerCase())
)
.map((file) => file.name);
let videoFile;
if (videoFiles.length === 0) {
console.warn(`πŸ’₯ No video file found in "${path.basename(dir)}"`);
} else if (videoFiles.length > 1) {
console.warn(
`πŸ’₯ Multiple video files found in "${path.basename(
dir
)}": ${videoFiles.join(", ")}`
);
} else {
// console.info(videoFiles[0]); // Exactly one file
videoFile = videoFiles[0];
}
if (!videoFile) {
console.warn(`πŸ’₯ No valid video file found in "${path.basename(dir)}"`);
continue;
}
const withoutExt = path.basename(videoFile, path.extname(videoFile));
console.info(`πŸŽ₯ Found video file in "${path.basename(dir)}":`);
// List all files and directories in the current subdirectory
// that match the video file name without extension
const filesOrDirsMatchingNames = files.filter((file) =>
file.name.startsWith(withoutExt)
);
// For debugging purposes, you can uncomment the following line
if (CLI_CONFIG.isDebug) {
console.info(` - Files/dirs matching "${withoutExt}":`);
console.table(
filesOrDirsMatchingNames.map((file) => ({
name: file.name,
isDirectory: file.isDirectory(),
}))
);
}
const newName = inferNameFromVideoFilename(withoutExt);
console.info(` - Old name pattern: ${withoutExt}`);
console.info(` - New name pattern: ${newName}`);
// Only proceed with rename if there's actually a change needed
if (!CLI_CONFIG.isDebug && withoutExt !== newName) {
// Perform the rename operation
renameMatchingFiles(dir, withoutExt, newName, filesOrDirsMatchingNames);
} else {
console.info(
` ⏭️ No rename needed for files in "${path.basename(dir)}"`
);
}
console.info(""); // Add spacing between directories
}
}
// --- RUN SCRIPT ---
try {
if (CLI_CONFIG.isDebug) {
console.info("πŸ” Debug mode enabled! Running in dry-run mode.");
CLI_CONFIG.isDry = true; // Force dry-run in debug mode
}
if (CLI_CONFIG.isDry) {
console.info("πŸ₯Š Running in DRY mode (no changes will be made).");
} else {
console.info("πŸ₯‘ Running in LIVE mode (changes will be made).");
}
console.info("");
mountSMBShare();
console.info("");
console.info(`πŸ“‚ Scanning immediate subdirectories in ${MOVIES_DIR}...`);
console.info("");
scanVideosOneLevel();
} finally {
unmountSMBShare();
}
// --- CLI HELPERS ---
function getCliArgs() {
if (CACHED_CLI_ARGS) {
return CACHED_CLI_ARGS;
}
CACHED_CLI_ARGS = process.argv.slice(2);
console.info(`πŸ” CLI arguments: ${JSON.stringify(CACHED_CLI_ARGS)}`);
return CACHED_CLI_ARGS;
}
/**
* Handle optional boolean CLI arguments
* @param {string} argName - The argument name (e.g., '--dry')
* @param {function} callback - Function called with boolean indicating if argument is present
*/
function optionalBooleanCliArg(argName, callback) {
const args = getCliArgs();
const isPresent = args.includes(argName);
callback(isPresent);
}
/**
* Handle required CLI arguments with string values
* @param {string} argName - The argument name (e.g., '--username')
* @param {function} callback - Function called with the argument value
*/
function requiredCliArg(argName, callback) {
const args = getCliArgs();
const index = args.indexOf(argName);
if (index === -1) {
console.error(`Error: ${argName} is required`);
process.exit(1);
}
if (index + 1 >= args.length) {
console.error(`Error: ${argName} option requires a value`);
process.exit(1);
}
const value = args[index + 1];
callback(value);
}
/**
* Handle optional CLI arguments with string values
* @param {string} argName - The argument name (e.g., '--config')
* @param {function} callback - Function called with the argument value (or null if not present)
*/
function optionalCliArg(argName, callback) {
const args = getCliArgs();
const index = args.indexOf(argName);
if (index === -1) {
callback(null);
return;
}
if (index + 1 >= args.length) {
console.error(`Error: ${argName} option requires a value`);
process.exit(1);
}
const value = args[index + 1];
callback(value);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment