Instantly share code, notes, and snippets.
Created
August 11, 2025 12:33
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save SeanCassiere/5db888c95836af9b5c67a29aad78a856 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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