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

383 lines (331 loc) 12.3 kB
import { exec } from "child_process"; import { AppConfig } from "../config/index.js"; import { Logger } from "../utils/logger.js"; import { ValidationUtils } from "../utils/validation.js"; import { FileSystemUtils } from "../utils/filesystem.js"; import { VALIDATION_CONSTANTS, MEDIA_TYPES } from "../constants/index.js"; import { DriveService } from "./drive.service.js"; import { MakeMKVMessages } from "../utils/makemkv-messages.js"; /** * Service for handling disc detection and information gathering */ export class DiscService { static isFirstMakeMKVCall = true; /** * Main method: Get all available discs with mount detection and complete title processing * @returns {Promise<Array>} - Array of complete drive information objects */ static async getAvailableDiscs() { return new Promise(async (resolve, reject) => { Logger.info("Getting info for all discs..."); try { // First: Detect any immediately available discs (without processing) let detectedDiscs = await this.detectAvailableDiscs(); // Check for additional drives if mount detection is enabled if (AppConfig.mountWaitTimeout > 0) { const mountStatus = await DriveService.getDriveMountStatus(); if (detectedDiscs.length > 0 && mountStatus.unmounted === 0) { Logger.info( `Found ${detectedDiscs.length} disc(s) immediately. All drives are ready, proceeding with ripping.` ); } else if (mountStatus.unmounted > 0) { Logger.info( `Found ${detectedDiscs.length} disc(s) immediately and ${mountStatus.unmounted} drive(s) still mounting. Waiting for additional drives...` ); const additionalDiscs = await this.waitForDriveMount(); // Merge any newly detected discs with existing ones if (additionalDiscs.length > 0) { detectedDiscs = [...detectedDiscs, ...additionalDiscs]; Logger.info( `Total discs found after waiting: ${detectedDiscs.length}` ); } } else if (detectedDiscs.length === 0 && mountStatus.total === 0) { Logger.info("No discs detected and no optical drives found."); } else if (detectedDiscs.length === 0 && mountStatus.total > 0) { Logger.info( `Found ${mountStatus.total} optical drive(s) but no media is currently mounted.` ); } } // Finally: Process complete disc information for ALL discovered discs if (detectedDiscs.length > 0) { Logger.separator(); Logger.info( `Processing complete disc information for ${detectedDiscs.length} disc(s)...` ); const completeDiscInfo = await this.getCompleteDiscInfo( detectedDiscs ); resolve(completeDiscInfo); } else { resolve([]); } } catch (error) { reject(error); } }); } /** * Detect available discs without processing file information (fast) * @returns {Promise<Array>} - Array of basic disc information objects */ static async detectAvailableDiscs() { return new Promise(async (resolve, reject) => { // Get MakeMKV executable path with cross-platform detection const makeMKVExecutable = await AppConfig.getMakeMKVExecutable(); if (!makeMKVExecutable) { reject( new Error( "MakeMKV executable not found. Please ensure MakeMKV is installed." ) ); return; } const command = `${makeMKVExecutable} -r info disc:index`; exec(command, (err, stdout, stderr) => { // Check for critical MakeMKV messages first const isFirstCall = DiscService.isFirstMakeMKVCall; const shouldContinue = MakeMKVMessages.checkOutput( stdout + (stderr || ""), isFirstCall ); if (!shouldContinue) { reject( new Error( "MakeMKV version is too old, please update to the latest version" ) ); return; } // Mark that we've made our first call if (isFirstCall) { DiscService.isFirstMakeMKVCall = false; } // Only fail if we have no stdout data if (!stdout || stdout.trim() === "") { Logger.error("No output from MakeMKV command"); reject(new Error("No output from MakeMKV command")); return; } try { const driveInfo = this.parseDriveInfo(stdout); resolve(driveInfo); } catch (error) { reject(error); } }); }); } /** * Get complete disc information including file numbers for all discs * @param {Array} detectedDiscs - Array of basic disc information * @returns {Promise<Array>} - Array of complete disc information with file numbers */ static async getCompleteDiscInfo(detectedDiscs) { // Get file numbers for each detected disc const drivePromises = detectedDiscs.map((drive) => this.getDiscFileInfo(drive) ); return Promise.all(drivePromises); } /** * Wait for drives to mount media and retry detection * @returns {Promise<Array>} - Array of basic disc information for newly found discs */ static async waitForDriveMount() { const waitTimeout = AppConfig.mountWaitTimeout; const pollInterval = AppConfig.mountPollInterval; const maxAttempts = Math.ceil(waitTimeout / pollInterval); Logger.info( `Waiting up to ${waitTimeout} seconds for additional drives to mount media...` ); let initialDiscCount = 0; // Poll for mounted drives for (let attempt = 1; attempt <= maxAttempts; attempt++) { Logger.info( `Polling attempt ${attempt}/${maxAttempts} for newly mounted drives...` ); try { // Use fast detection during polling (no file processing) const allCurrentDiscs = await this.detectAvailableDiscs(); const mountStatus = await DriveService.getDriveMountStatus(); // Set initial count on first attempt if (attempt === 1) { initialDiscCount = allCurrentDiscs.length; } const newDiscsFound = allCurrentDiscs.length - initialDiscCount; if (newDiscsFound > 0) { Logger.info( `Found ${newDiscsFound} additional disc(s) during polling.` ); } Logger.info( `Current status: ${allCurrentDiscs.length} discs ready, ${mountStatus.unmounted} drives still mounting` ); // Only exit when there are no more unmounted drives to wait for if (mountStatus.unmounted === 0) { Logger.info( "All optical drives have been checked. No more drives to wait for." ); // Return just the newly found basic disc info (processing happens later) const newDiscs = allCurrentDiscs.slice(initialDiscCount); return newDiscs; } // Wait before next attempt (unless this is the last attempt) if (attempt < maxAttempts) { Logger.separator(); await this.sleep(pollInterval * 1000); } } catch (error) { Logger.warning( `Error during polling attempt ${attempt}: ${error.message}` ); } } Logger.info( `Finished waiting ${waitTimeout} seconds for additional drives.` ); // Return any newly found discs after timeout try { const finalDiscs = await this.detectAvailableDiscs(); const newDiscs = finalDiscs.slice(initialDiscCount); return newDiscs; } catch (error) { return []; } } /** * Sleep for the specified number of milliseconds * @param {number} ms - Milliseconds to sleep * @returns {Promise<void>} */ static sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Parse drive information from MakeMKV output * @param {string} data - Raw MakeMKV output * @returns {Array} - Array of drive information objects */ static parseDriveInfo(data) { const validationMessage = ValidationUtils.validateDriveData(data); if (validationMessage) { throw new Error(validationMessage); } const lines = data.split("\n"); return lines .filter((line) => { const lineArray = line.split(","); const driveState = parseInt(lineArray[1]); const mediaTitle = lineArray[5] || ""; return ( lineArray[0].startsWith(VALIDATION_CONSTANTS.DRIVE_FILTER) && driveState == VALIDATION_CONSTANTS.MEDIA_PRESENT && mediaTitle.trim() !== "" ); }) .map((line) => { const lineArray = line.split(","); const mediaType = lineArray[4].includes("BD-ROM") ? MEDIA_TYPES.BLU_RAY : MEDIA_TYPES.DVD; return { driveNumber: lineArray[0].substring(4), title: FileSystemUtils.makeTitleValidFolderPath(lineArray[5]), mediaType: mediaType, }; }); } /** * Get file information for a specific disc * @param {Object} driveInfo - Drive information object * @returns {Promise<Object>} - Enhanced drive info with file number */ static async getDiscFileInfo(driveInfo) { return new Promise(async (resolve, reject) => { Logger.info( `Getting file number for drive title ${driveInfo.driveNumber}-${driveInfo.title}.` ); // Get MakeMKV executable path with cross-platform detection const makeMKVExecutable = await AppConfig.getMakeMKVExecutable(); if (!makeMKVExecutable) { reject( new Error( "MakeMKV executable not found. Please ensure MakeMKV is installed." ) ); return; } const command = `${makeMKVExecutable} -r info disc:${driveInfo.driveNumber}`; exec(command, (err, stdout, stderr) => { // Check for critical MakeMKV messages (not first call, so only check for errors) const shouldContinue = MakeMKVMessages.checkOutput( stdout + (stderr || ""), false ); if (!shouldContinue) { reject( new Error( "MakeMKV version is too old, please update to the latest version" ) ); return; } // Only fail if we have no stdout data if (!stdout || stdout.trim() === "") { Logger.error("No output from MakeMKV command"); reject(new Error("No output from MakeMKV command")); return; } try { const fileNumber = AppConfig.isRipAllEnabled ? "all" : this.getFileNumber(stdout); Logger.info( `Got file info for ${driveInfo.driveNumber}-${driveInfo.title}.` ); resolve({ driveNumber: driveInfo.driveNumber, title: driveInfo.title, fileNumber: fileNumber, mediaType: driveInfo.mediaType, }); } catch (error) { reject(error); } }); }); } /** * Get the file number for the longest title on the disc * @param {string} data - Raw MakeMKV output * @returns {string} - File number of the longest title */ static getFileNumber(data) { const validationMessage = ValidationUtils.validateFileData(data); if (validationMessage) { throw new Error(validationMessage); } let myTitleSectionValue = null; let maxValue = 0; const lines = data.split("\n"); const validLines = lines.filter((line) => { const lineArray = line.split(","); return ( lineArray[0].startsWith("TINFO:") && lineArray[1] == VALIDATION_CONSTANTS.TITLE_LENGTH_CODE ); }); validLines.forEach((line) => { const videoTimeString = line.split(",")[3].replace(/['"]+/g, ""); const videoTimeArray = videoTimeString.split(":"); const videoTimeSeconds = ValidationUtils.getTimeInSeconds(videoTimeArray); if (videoTimeSeconds >= maxValue) { maxValue = videoTimeSeconds; myTitleSectionValue = line.split(",")[0].replace("TINFO:", ""); } }); return myTitleSectionValue || "0"; } }