UNPKG

makemkv-auto-rip

Version:

Automatically rips DVDs & Blu-rays using the MakeMKV console, then saves them to unique folders. It can be used from the command line or via a web interface, and is cross-platform. It is also containerized, so it can be run on any system with Docker insta

599 lines (524 loc) 17.3 kB
/** * MakeMKV Auto Rip - Web API Routes * Handles all API endpoints for the web interface */ import { Router } from "express"; import fs from "fs/promises"; import path from "path"; import { spawn } from "child_process"; import { stringify as yamlStringify, parse as yamlParse } from "yaml"; import { Logger } from "../../utils/logger.js"; import { broadcastStatusUpdate, broadcastLogMessage, } from "../middleware/websocket.middleware.js"; const router = Router(); // Status tracking let currentOperation = null; let operationStatus = "idle"; // idle, loading, ejecting, ripping let currentProcess = null; // Store reference to current running process /** * Execute a CLI command and capture its output * @param {string} command - Command to execute * @param {Array} args - Command arguments * @returns {Promise<{success: boolean, output: string, error?: string}>} */ function executeCliCommand(command, args = []) { return new Promise((resolve) => { const childProcess = spawn(command, args, { cwd: path.resolve(process.cwd()), shell: true, }); // Store reference to current process for potential termination currentProcess = childProcess; let output = ""; let error = ""; childProcess.stdout.on("data", (data) => { const text = data.toString(); output += text; // Broadcast real program output to WebSocket clients broadcastLogMessage("info", text.trim()); }); childProcess.stderr.on("data", (data) => { const text = data.toString(); error += text; // Broadcast errors to WebSocket clients broadcastLogMessage("error", text.trim()); }); childProcess.on("close", (code) => { currentProcess = null; // Clear the process reference resolve({ success: code === 0, output: output.trim(), error: error.trim(), }); }); childProcess.on("error", (err) => { currentProcess = null; // Clear the process reference resolve({ success: false, output: "", error: err.message, }); }); }); } /** * Get current system status */ router.get("/status", async (req, res) => { try { res.json({ operation: currentOperation, status: operationStatus, canStop: currentProcess !== null, timestamp: new Date().toISOString(), }); } catch (error) { Logger.error("Failed to get status", error.message); res.status(500).json({ error: "Failed to get status" }); } }); /** * Get application info (name, version) */ router.get("/info", async (req, res) => { try { const packagePath = path.join(process.cwd(), "package.json"); const packageContent = await fs.readFile(packagePath, "utf8"); const pkg = JSON.parse(packageContent); res.json({ name: pkg.name, version: pkg.version }); } catch (error) { Logger.error("Failed to read package.json for app info", error.message); res.status(500).json({ error: "Failed to get application info" }); } }); /** * Stop current operation */ router.post("/stop", async (req, res) => { try { if (currentProcess) { currentProcess.kill("SIGTERM"); // Wait a moment, then force kill if still running setTimeout(() => { if (currentProcess && !currentProcess.killed) { currentProcess.kill("SIGKILL"); } }, 3000); operationStatus = "idle"; currentOperation = null; currentProcess = null; broadcastStatusUpdate("idle", null); broadcastLogMessage("warn", "Operation stopped by user"); res.json({ success: true, message: "Operation stopped" }); } else { res.status(400).json({ error: "No operation is currently running" }); } } catch (error) { Logger.error("Failed to stop operation", error.message); res .status(500) .json({ error: "Failed to stop operation: " + error.message }); } }); /** * Load all drives using CLI command */ router.post("/drives/load", async (req, res) => { try { if (operationStatus !== "idle") { return res .status(409) .json({ error: "Another operation is in progress" }); } operationStatus = "loading"; currentOperation = "Loading drives..."; broadcastStatusUpdate("loading", "Loading drives..."); const result = await executeCliCommand("npm", [ "run", "load", "--silent", "--", "--quiet", ]); operationStatus = "idle"; currentOperation = null; broadcastStatusUpdate("idle", null); if (result.success) { res.json({ success: true, message: "Drives loaded successfully" }); } else { res.status(500).json({ error: "Failed to load drives: " + result.error }); } } catch (error) { operationStatus = "idle"; currentOperation = null; broadcastStatusUpdate("idle", null); Logger.error("Failed to load drives", error.message); res.status(500).json({ error: "Failed to load drives: " + error.message }); } }); /** * Eject all drives using CLI command */ router.post("/drives/eject", async (req, res) => { try { if (operationStatus !== "idle") { return res .status(409) .json({ error: "Another operation is in progress" }); } operationStatus = "ejecting"; currentOperation = "Ejecting drives..."; broadcastStatusUpdate("ejecting", "Ejecting drives..."); const result = await executeCliCommand("npm", [ "run", "eject", "--silent", "--", "--quiet", ]); operationStatus = "idle"; currentOperation = null; broadcastStatusUpdate("idle", null); if (result.success) { res.json({ success: true, message: "Drives ejected successfully" }); } else { res .status(500) .json({ error: "Failed to eject drives: " + result.error }); } } catch (error) { operationStatus = "idle"; currentOperation = null; broadcastStatusUpdate("idle", null); Logger.error("Failed to eject drives", error.message); res.status(500).json({ error: "Failed to eject drives: " + error.message }); } }); /** * Get current configuration */ router.get("/config", async (req, res) => { try { const configPath = path.join(process.cwd(), "config.yaml"); const configContent = await fs.readFile(configPath, "utf8"); res.json({ config: configContent }); } catch (error) { Logger.error("Failed to read config", error.message); res.status(500).json({ error: "Failed to read configuration file" }); } }); /** * Update configuration */ router.post("/config", async (req, res) => { try { const { config } = req.body; if (!config) { return res .status(400) .json({ error: "Configuration content is required" }); } const configPath = path.join(process.cwd(), "config.yaml"); await fs.writeFile(configPath, config, "utf8"); res.json({ success: true, message: "Configuration updated successfully" }); } catch (error) { Logger.error("Failed to update config", error.message); res .status(500) .json({ error: "Failed to update configuration: " + error.message }); } }); /** * Get current configuration as structured object */ router.get("/config/structured", async (req, res) => { try { const configPath = path.join(process.cwd(), "config.yaml"); const configContent = await fs.readFile(configPath, "utf8"); const config = yamlParse(configContent); res.json({ config }); } catch (error) { Logger.error("Failed to read structured config", error.message); res.status(500).json({ error: "Failed to read configuration file" }); } }); /** * Update configuration with structured object */ router.post("/config/structured", async (req, res) => { try { const { config } = req.body; if (!config || typeof config !== "object") { return res .status(400) .json({ error: "Configuration object is required" }); } // Validate required fields if (!config.paths?.movie_rips_dir) { return res .status(400) .json({ error: "Movie rips directory is required" }); } if (config.paths?.logging?.enabled && !config.paths?.logging?.dir) { return res .status(400) .json({ error: "Log directory is required when logging is enabled" }); } // Track if we need to kill a process const wasRunning = operationStatus !== "idle" && currentProcess; // If not idle, kill the current process before saving config if (wasRunning) { Logger.info("Stopping current operation to save configuration..."); // Kill the current process try { currentProcess.kill("SIGTERM"); // Give it a moment to terminate gracefully, then force kill if needed setTimeout(() => { if (currentProcess && !currentProcess.killed) { currentProcess.kill("SIGKILL"); } }, 3000); // Reset state operationStatus = "idle"; currentOperation = null; currentProcess = null; // Broadcast status update broadcastStatusUpdate("idle", null); } catch (killError) { Logger.error("Failed to stop current process", killError.message); // Continue with config save even if kill failed } } const configPath = path.join(process.cwd(), "config.yaml"); // Read existing YAML content const existingContent = await fs.readFile(configPath, "utf8"); // Update only specific values while preserving all comments and structure const updatedContent = updateYamlValues(existingContent, config); await fs.writeFile(configPath, updatedContent, "utf8"); Logger.info("Configuration updated successfully"); res.json({ success: true, message: "Configuration updated successfully", processKilled: wasRunning, // Let frontend know if we killed a process }); } catch (error) { Logger.error("Failed to update structured config", error.message); res .status(500) .json({ error: "Failed to update configuration: " + error.message }); } }); /** * Update YAML values while preserving all comments and formatting */ function updateYamlValues(yamlContent, config) { let updatedContent = yamlContent; // Helper function to properly format YAML values function formatYamlValue(value) { if (typeof value === "string") { // Always quote strings return `"${value}"`; } else if (typeof value === "boolean") { return value.toString(); } else if (typeof value === "number") { return value.toString(); } return value; } // Helper function to update a specific key-value pair function updateKeyValue(content, keyPath, value) { const keys = keyPath.split("."); let currentContent = content; if (keys.length === 1) { // Top-level key (e.g., "interface:") const regex = new RegExp(`^(\\s*${keys[0]}\\s*:)\\s*(.*)$`, "m"); const match = currentContent.match(regex); if (match) { currentContent = currentContent.replace( regex, `$1 ${formatYamlValue(value)}` ); } } else { // Nested key (e.g., "paths.movie_rips_dir") const parentKey = keys[0]; const childKey = keys[keys.length - 1]; // Find the parent section const parentRegex = new RegExp(`^(\\s*${parentKey}\\s*:)`, "m"); const parentMatch = currentContent.match(parentRegex); if (parentMatch) { // First try to find an active (uncommented) child key const childRegex = new RegExp(`^(\\s+${childKey}\\s*:)\\s*(.*)$`, "m"); const childMatch = currentContent.match(childRegex); if (childMatch) { // Found active key, update it currentContent = currentContent.replace( childRegex, `$1 ${formatYamlValue(value)}` ); } else { // Look for commented version of the key to uncomment and update const commentedRegex = new RegExp( `^(\\s*)#\\s*(${childKey}\\s*:)\\s*(.*)$`, "m" ); const commentedMatch = currentContent.match(commentedRegex); if (commentedMatch) { // Uncomment and update the value currentContent = currentContent.replace( commentedRegex, `$1$2 ${formatYamlValue(value)}` ); } else { // Key doesn't exist, add it after the parent section header const parentIndex = currentContent.search(parentRegex); if (parentIndex !== -1) { const lines = currentContent.split("\n"); let insertIndex = -1; // Find the line with the parent key for (let i = 0; i < lines.length; i++) { if (lines[i].match(parentRegex)) { insertIndex = i + 1; break; } } if (insertIndex !== -1) { // Insert the new key after the parent const indent = " "; // Use 2 spaces for indentation const newLine = `${indent}${childKey}: ${formatYamlValue( value )}`; lines.splice(insertIndex, 0, newLine); currentContent = lines.join("\n"); } } } } } } return currentContent; } // Function to handle deletion of optional keys function deleteKeyValue(content, keyPath) { const keys = keyPath.split("."); if (keys.length === 1) { // Top-level key deletion const regex = new RegExp(`^\\s*${keys[0]}\\s*:.*$`, "m"); return content.replace(regex, ""); } else { // Nested key deletion const childKey = keys[keys.length - 1]; const regex = new RegExp(`^\\s+${childKey}\\s*:.*$`, "m"); return content.replace(regex, ""); } } // Recursively process the config object function processConfigObject(obj, prefix = "") { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if ( value !== null && typeof value === "object" && !Array.isArray(value) ) { // Recursively process nested objects processConfigObject(value, fullKey); } else if (value !== undefined) { // Update the value updatedContent = updateKeyValue(updatedContent, fullKey, value); } } } // Handle makemkv_dir deletion if it was removed from config if (config.paths && !config.paths.hasOwnProperty("makemkv_dir")) { // Comment out the makemkv_dir line if it exists and is not already commented const makemkvRegex = /^(\s+)(makemkv_dir\s*:.*$)/m; const match = updatedContent.match(makemkvRegex); if (match) { updatedContent = updatedContent.replace(makemkvRegex, "$1# $2"); } } // Process all the config updates processConfigObject(config); return updatedContent; } /** * Deep merge utility function for configuration objects */ function deepMerge(target, source) { const result = { ...target }; for (const key in source) { if ( source[key] !== null && typeof source[key] === "object" && !Array.isArray(source[key]) ) { result[key] = deepMerge(result[key] || {}, source[key]); } else if (source[key] !== undefined) { result[key] = source[key]; } } return result; } /** * Start the main ripping process using CLI command */ router.post("/rip/start", async (req, res) => { try { if (operationStatus !== "idle") { return res .status(409) .json({ error: "Another operation is in progress" }); } operationStatus = "ripping"; currentOperation = "Starting rip process..."; broadcastStatusUpdate("ripping", "Starting rip process..."); // Start the ripping process in the background using CLI setImmediate(async () => { try { const result = await executeCliCommand("npm", [ "run", "start", "--silent", "--", "--no-confirm", "--quiet", ]); operationStatus = "idle"; currentOperation = null; broadcastStatusUpdate("idle", null); if (result.success) { broadcastLogMessage( "success", "Ripping process completed successfully" ); } else { broadcastLogMessage( "error", `Ripping process failed: ${result.error}` ); } } catch (error) { Logger.error("Ripping process failed", error.message); operationStatus = "idle"; currentOperation = null; broadcastStatusUpdate("idle", null); broadcastLogMessage( "error", `Ripping process failed: ${error.message}` ); } }); res.json({ success: true, message: "Ripping process started" }); } catch (error) { operationStatus = "idle"; currentOperation = null; Logger.error("Failed to start ripping", error.message); res .status(500) .json({ error: "Failed to start ripping: " + error.message }); } }); export { router as apiRoutes };