const fs = require('fs'); const { EventEmitter } = require('events'); const { execFile } = require('child_process'); const { promisify } = require('util'); const settingsService = require('./settingsService'); const logger = require('./logger').child('DISK'); const { parseToc } = require('./cdRipService'); const { errorToMeta } = require('../utils/errorMeta'); const execFileAsync = promisify(execFile); const DEFAULT_POLL_INTERVAL_MS = 4000; const MIN_POLL_INTERVAL_MS = 1000; const MAX_POLL_INTERVAL_MS = 60000; function toBoolean(value, fallback = false) { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value !== 0; } const normalized = String(value || '').trim().toLowerCase(); if (!normalized) { return fallback; } if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') { return true; } if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') { return false; } return fallback; } function clampPollIntervalMs(rawValue) { const parsed = Number(rawValue); if (!Number.isFinite(parsed)) { return DEFAULT_POLL_INTERVAL_MS; } const clamped = Math.max(MIN_POLL_INTERVAL_MS, Math.min(MAX_POLL_INTERVAL_MS, Math.trunc(parsed))); return clamped || DEFAULT_POLL_INTERVAL_MS; } function flattenDevices(nodes, acc = []) { for (const node of nodes || []) { acc.push(node); if (Array.isArray(node.children)) { flattenDevices(node.children, acc); } } return acc; } function buildSignature(info) { return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}|${info.mediaProfile || ''}`; } function normalizeMediaProfile(rawValue) { const value = String(rawValue || '').trim().toLowerCase(); if (!value) { return null; } if ( value === 'bluray' || value === 'blu-ray' || value === 'blu_ray' || value === 'bd' || value === 'bdmv' || value === 'bdrom' || value === 'bd-rom' || value === 'bd-r' || value === 'bd-re' ) { return 'bluray'; } if ( value === 'dvd' || value === 'dvdvideo' || value === 'dvd-video' || value === 'dvdrom' || value === 'dvd-rom' || value === 'video_ts' || value === 'iso9660' ) { return 'dvd'; } if (value === 'cd' || value === 'audio_cd') { return 'cd'; } return null; } function isSpecificMediaProfile(value) { return value === 'bluray' || value === 'dvd' || value === 'cd'; } function inferMediaProfileFromTextParts(parts) { const markerText = (parts || []) .map((value) => String(value || '').trim().toLowerCase()) .filter(Boolean) .join(' '); if (!markerText) { return null; } if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re/.test(markerText)) { return 'bluray'; } if (/(^|[\s_-])video_ts($|[\s_-])|dvd|iso9660/.test(markerText)) { return 'dvd'; } return null; } function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { const fstype = String(rawFsType || '').trim().toLowerCase(); if (fstype === 'audio_cd') { return 'cd'; } const model = String(rawModel || '').trim().toLowerCase(); const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model); const hasDvdModelMarker = /dvd/.test(model); const hasCdOnlyModelMarker = /(^|[\s_-])cd([\s_-]|$)|cd-?rom/.test(model) && !hasBlurayModelMarker && !hasDvdModelMarker; if (!fstype) { if (hasBlurayModelMarker) { return 'bluray'; } if (hasDvdModelMarker) { return 'dvd'; } return null; } if (fstype.includes('udf')) { // UDF is used by both DVDs (UDF 1.x) and Blu-rays (UDF 2.x). // Drive model alone (hasBlurayModelMarker) is not reliable: a BD-ROM drive // with a DVD inside would incorrectly be detected as Blu-ray. // Return null so UDF version detection via blkid can decide. if (hasBlurayModelMarker) { return null; } if (hasDvdModelMarker) { return 'dvd'; } return 'dvd'; } if (fstype.includes('iso9660') || fstype.includes('cdfs')) { // iso9660/cdfs is never used by Blu-ray discs (they use UDF 2.x). // Ignore hasBlurayModelMarker – it only reflects drive capability. if (hasCdOnlyModelMarker) { return 'other'; } return 'dvd'; } return null; } function inferMediaProfileFromUdevProperties(properties = {}) { const flags = Object.entries(properties).reduce((acc, [key, rawValue]) => { const normalizedKey = String(key || '').trim().toUpperCase(); if (!normalizedKey) { return acc; } acc[normalizedKey] = String(rawValue || '').trim(); return acc; }, {}); const hasFlag = (prefix) => Object.entries(flags).some(([key, value]) => key.startsWith(prefix) && value === '1'); if (hasFlag('ID_CDROM_MEDIA_BD')) { return 'bluray'; } if (hasFlag('ID_CDROM_MEDIA_DVD')) { return 'dvd'; } if (hasFlag('ID_CDROM_MEDIA_CD')) { return 'cd'; } return null; } class DiskDetectionService extends EventEmitter { constructor() { super(); this.running = false; this.timer = null; this.lastDetected = null; this.lastPresent = false; this.detectedDiscs = new Map(); // devicePath → device object (multi-drive tracking) this.deviceLocks = new Map(); this.pollingSuspended = false; } start() { if (this.running) { return; } this.running = true; logger.info('start'); this.scheduleNext(1000); } stop() { this.running = false; if (this.timer) { clearTimeout(this.timer); this.timer = null; } logger.info('stop'); } suspendPolling() { if (!this.pollingSuspended) { this.pollingSuspended = true; logger.info('polling:suspended'); } } resumePolling() { if (this.pollingSuspended) { this.pollingSuspended = false; logger.info('polling:resumed'); } } scheduleNext(delayMs) { if (!this.running) { return; } this.timer = setTimeout(async () => { let nextDelay = DEFAULT_POLL_INTERVAL_MS; try { const map = await settingsService.getSettingsMap(); nextDelay = clampPollIntervalMs(map.disc_poll_interval_ms); const autoDetectionEnabled = toBoolean(map.disc_auto_detection_enabled, true); logger.debug('poll:tick', { driveMode: map.drive_mode, driveDevice: map.drive_device, nextDelay, autoDetectionEnabled, suspended: this.pollingSuspended }); if (this.pollingSuspended) { logger.debug('poll:skip:suspended', { nextDelay }); } else if (autoDetectionEnabled) { const detectedList = await this.detectAllDiscs(map); this.applyMultiDetectionResults(detectedList, { forceInsertEvent: false }); } else { logger.debug('poll:skip:auto-detection-disabled', { nextDelay }); } } catch (error) { logger.error('poll:error', { error: errorToMeta(error) }); this.emit('error', error); } this.scheduleNext(nextDelay); }, delayMs); } async rescanAndEmit() { try { const map = await settingsService.getSettingsMap(); logger.info('rescan:requested', { driveMode: map.drive_mode, driveDevice: map.drive_device }); const detectedList = await this.detectAllDiscs(map); const results = this.applyMultiDetectionResults(detectedList, { forceInsertEvent: true }); const insertedResults = results.filter((r) => r.emitted === 'discInserted'); const removedResults = results.filter((r) => r.emitted === 'discRemoved'); const present = detectedList.length > 0; const firstInserted = insertedResults[0] || null; logger.info('rescan:done', { driveCount: detectedList.length, inserted: insertedResults.length, removed: removedResults.length }); // Backward-compat return: report on first inserted device (or removed if nothing inserted) return { present, changed: insertedResults.length > 0 || removedResults.length > 0, emitted: insertedResults.length > 0 ? 'discInserted' : (removedResults.length > 0 ? 'discRemoved' : 'none'), device: firstInserted?.device || null, allDetected: detectedList }; } catch (error) { logger.error('rescan:error', { error: errorToMeta(error) }); throw error; } } normalizeDevicePath(devicePath) { return String(devicePath || '').trim(); } lockDevice(devicePath, owner = null) { const normalized = this.normalizeDevicePath(devicePath); if (!normalized) { return null; } const entry = this.deviceLocks.get(normalized) || { count: 0, owners: [] }; entry.count += 1; if (owner) { entry.owners.push(owner); } this.deviceLocks.set(normalized, entry); logger.info('lock:add', { devicePath: normalized, count: entry.count, owner }); return { devicePath: normalized, owner }; } unlockDevice(devicePath, owner = null) { const normalized = this.normalizeDevicePath(devicePath); if (!normalized) { return; } const entry = this.deviceLocks.get(normalized); if (!entry) { return; } entry.count = Math.max(0, entry.count - 1); if (entry.count === 0) { this.deviceLocks.delete(normalized); logger.info('lock:remove', { devicePath: normalized, owner }); return; } this.deviceLocks.set(normalized, entry); logger.info('lock:decrement', { devicePath: normalized, count: entry.count, owner }); } isDeviceLocked(devicePath) { const normalized = this.normalizeDevicePath(devicePath); if (!normalized) { return false; } return this.deviceLocks.has(normalized); } getActiveLocks() { return Array.from(this.deviceLocks.entries()).map(([path, info]) => ({ path, count: info.count, owners: info.owners })); } applyDetectionResult(detected, { forceInsertEvent = false } = {}) { const isPresent = Boolean(detected); const changed = isPresent && (!this.lastDetected || buildSignature(this.lastDetected) !== buildSignature(detected)); if (isPresent) { const shouldEmitInserted = forceInsertEvent || !this.lastPresent || changed; this.lastDetected = detected; this.lastPresent = true; if (shouldEmitInserted) { logger.info('disc:inserted', { detected, forceInsertEvent, changed }); this.emit('discInserted', detected); return { present: true, changed, emitted: 'discInserted', device: detected }; } return { present: true, changed, emitted: 'none', device: detected }; } if (!isPresent && this.lastPresent) { const removed = this.lastDetected; this.lastDetected = null; this.lastPresent = false; logger.info('disc:removed', { removed }); this.emit('discRemoved', removed); return { present: false, changed: true, emitted: 'discRemoved', device: null }; } return { present: false, changed: false, emitted: 'none', device: null }; } async detectDisc(settingsMap) { if (settingsMap.drive_mode === 'explicit') { return this.detectExplicit(settingsMap.drive_device); } return this.detectAuto(); } // Returns ALL discs with media (not just the first one) async detectAllDiscs(settingsMap) { if (settingsMap.drive_mode === 'explicit') { // drive_devices is a JSON array of paths; fall back to legacy drive_device let explicitPaths = []; try { const parsed = JSON.parse(settingsMap.drive_devices || '[]'); if (Array.isArray(parsed)) { explicitPaths = parsed .map((e) => (typeof e === 'string' ? e.trim() : String(e?.path || '').trim())) .filter(Boolean); } } catch (_error) { // malformed JSON — ignore, fall through to legacy } if (explicitPaths.length === 0) { const legacy = String(settingsMap.drive_device || '').trim(); if (legacy) { explicitPaths = [legacy]; } } const results = await Promise.all(explicitPaths.map((p) => this.detectExplicit(p))); return results.filter(Boolean); } // Auto mode: scan all ROM drives via lsblk const autoResults = await this.detectAllAuto(); // Fallback: if lsblk found no ROM drives but drive_device is configured, try it directly if (autoResults.length === 0) { const legacy = String(settingsMap.drive_device || '').trim(); if (legacy) { logger.debug('detect:auto:lsblk-empty-fallback-explicit', { legacy }); const legacyResult = await this.detectExplicit(legacy); if (legacyResult) { return [legacyResult]; } } } return autoResults; } // Rescan a single specific drive and emit the appropriate event async rescanDriveAndEmit(devicePath) { const normalized = this.normalizeDevicePath(devicePath); if (!normalized) { return { present: false, emitted: 'none', device: null }; } try { logger.info('rescan-drive:requested', { devicePath: normalized }); const detected = await this.detectExplicit(normalized); const previouslyTracked = this.detectedDiscs.has(normalized); if (detected) { const previous = this.detectedDiscs.get(normalized); const changed = previous ? buildSignature(previous) !== buildSignature(detected) : false; this.detectedDiscs.set(normalized, detected); this.lastDetected = detected; this.lastPresent = true; logger.info('rescan-drive:inserted', { devicePath: normalized, changed }); this.emit('discInserted', detected); return { present: true, emitted: 'discInserted', device: detected }; } // No disc found if (previouslyTracked) { const old = this.detectedDiscs.get(normalized); this.detectedDiscs.delete(normalized); if (this.detectedDiscs.size === 0) { this.lastDetected = null; this.lastPresent = false; } logger.info('rescan-drive:removed', { devicePath: normalized }); this.emit('discRemoved', old || { path: normalized }); return { present: false, emitted: 'discRemoved', device: null }; } logger.info('rescan-drive:empty', { devicePath: normalized }); return { present: false, emitted: 'none', device: null }; } catch (error) { logger.error('rescan-drive:error', { devicePath: normalized, error: errorToMeta(error) }); throw error; } } // Multi-drive: tracks per-device state and emits insert/remove events for each applyMultiDetectionResults(detectedList, { forceInsertEvent = false } = {}) { const detected = Array.isArray(detectedList) ? detectedList : []; const newMap = new Map(); for (const device of detected) { if (device?.path) { newMap.set(device.path, device); } } const results = []; // Check for new or changed devices for (const [devicePath, device] of newMap) { const previous = this.detectedDiscs.get(devicePath); const changed = previous ? buildSignature(previous) !== buildSignature(device) : false; const shouldEmitInserted = forceInsertEvent || !previous || changed; if (shouldEmitInserted) { logger.info('disc:inserted', { device, forceInsertEvent, changed }); this.emit('discInserted', device); results.push({ path: devicePath, emitted: 'discInserted', device }); } else { results.push({ path: devicePath, emitted: 'none', device }); } } // Check for removed devices for (const [devicePath, device] of this.detectedDiscs) { if (!newMap.has(devicePath)) { logger.info('disc:removed', { device }); this.emit('discRemoved', device); results.push({ path: devicePath, emitted: 'discRemoved', device: null }); } } this.detectedDiscs = newMap; // Update legacy single-disc tracking for backward compat if (newMap.size > 0) { this.lastDetected = Array.from(newMap.values())[0]; this.lastPresent = true; } else { if (this.lastPresent) { this.lastDetected = null; this.lastPresent = false; } } return results; } async detectExplicit(devicePath) { if (this.isDeviceLocked(devicePath)) { logger.debug('detect:explicit:locked', { devicePath, activeLocks: this.getActiveLocks() }); return null; } if (!devicePath || !fs.existsSync(devicePath)) { logger.debug('detect:explicit:not-found', { devicePath }); return null; } const mediaState = await this.checkMediaPresent(devicePath); if (!mediaState.hasMedia) { logger.debug('detect:explicit:no-media', { devicePath }); return null; } const discLabel = await this.getDiscLabel(devicePath); const details = await this.getBlockDeviceInfo(); const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {}; const detectedFsType = String(match.fstype || mediaState.type || '').trim() || null; const mediaProfile = await this.inferMediaProfile(devicePath, { discLabel, label: match.label, model: match.model, fstype: detectedFsType, mountpoint: match.mountpoint }); const detected = { mode: 'explicit', path: devicePath, name: match.name || devicePath.split('/').pop(), model: match.model || 'Unknown', label: match.label || null, discLabel: discLabel || null, mountpoint: match.mountpoint || null, fstype: detectedFsType, mediaProfile: mediaProfile || null, index: this.guessDiscIndex(match.name || devicePath) }; logger.debug('detect:explicit:success', { detected }); return detected; } async detectAuto() { const details = await this.getBlockDeviceInfo(); const romCandidates = details.filter((entry) => entry.type === 'rom'); for (const item of romCandidates) { const path = item.path || (item.name ? `/dev/${item.name}` : null); if (!path) { continue; } if (this.isDeviceLocked(path)) { logger.debug('detect:auto:skip-locked', { path, activeLocks: this.getActiveLocks() }); continue; } const mediaState = await this.checkMediaPresent(path); if (!mediaState.hasMedia) { continue; } const discLabel = await this.getDiscLabel(path); const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null; const mediaProfile = await this.inferMediaProfile(path, { discLabel, label: item.label, model: item.model, fstype: detectedFsType, mountpoint: item.mountpoint }); const detected = { mode: 'auto', path, name: item.name, model: item.model || 'Optical Drive', label: item.label || null, discLabel: discLabel || null, mountpoint: item.mountpoint || null, fstype: detectedFsType, mediaProfile: mediaProfile || null, index: this.guessDiscIndex(item.name) }; logger.debug('detect:auto:success', { detected }); return detected; } logger.debug('detect:auto:none'); return null; } async detectAllAuto() { const details = await this.getBlockDeviceInfo(); const romCandidates = details.filter((entry) => entry.type === 'rom'); const results = []; for (const item of romCandidates) { const path = item.path || (item.name ? `/dev/${item.name}` : null); if (!path) { continue; } if (this.isDeviceLocked(path)) { logger.debug('detect:all-auto:skip-locked', { path, activeLocks: this.getActiveLocks() }); continue; } const mediaState = await this.checkMediaPresent(path); if (!mediaState.hasMedia) { continue; } const discLabel = await this.getDiscLabel(path); const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null; const mediaProfile = await this.inferMediaProfile(path, { discLabel, label: item.label, model: item.model, fstype: detectedFsType, mountpoint: item.mountpoint }); const detected = { mode: 'auto', path, name: item.name, model: item.model || 'Optical Drive', label: item.label || null, discLabel: discLabel || null, mountpoint: item.mountpoint || null, fstype: detectedFsType, mediaProfile: mediaProfile || null, index: this.guessDiscIndex(item.name) }; logger.debug('detect:all-auto:found', { detected }); results.push(detected); } logger.debug('detect:all-auto:done', { count: results.length }); return results; } async getBlockDeviceInfo() { try { const { stdout } = await execFileAsync('lsblk', [ '-J', '-o', 'NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL' ]); const parsed = JSON.parse(stdout); const devices = flattenDevices(parsed.blockdevices || []).map((entry) => ({ name: entry.name, path: entry.path, type: entry.type, mountpoint: entry.mountpoint, fstype: entry.fstype, label: entry.label, model: entry.model })); logger.debug('lsblk:ok', { deviceCount: devices.length }); return devices; } catch (error) { logger.warn('lsblk:failed', { error: errorToMeta(error) }); return []; } } async checkMediaPresent(devicePath) { let blkidType = null; let blkidError = null; try { const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'TYPE', devicePath]); blkidType = String(stdout || '').trim().toLowerCase() || null; } catch (_error) { blkidError = String(_error?.message || _error || 'unknown'); // blkid failed – could mean no disc, or an audio CD (no filesystem type) } logger.info('check-media:blkid', { devicePath, blkidType, blkidError }); if (blkidType) { return { hasMedia: true, type: blkidType }; } // blkid found nothing – audio CDs have no filesystem, so fall back to udevadm try { const { stdout } = await execFileAsync('udevadm', [ 'info', '--query=property', '--name', devicePath ]); const props = {}; for (const line of String(stdout || '').split(/\r?\n/)) { const idx = line.indexOf('='); if (idx <= 0) { continue; } props[line.slice(0, idx).trim().toUpperCase()] = line.slice(idx + 1).trim(); } const hasBD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_BD') && props[k] === '1'); const hasDVD = Object.keys(props).some((k) => k.startsWith('ID_CDROM_MEDIA_DVD') && props[k] === '1'); const hasCD = props['ID_CDROM_MEDIA_CD'] === '1'; logger.info('check-media:udevadm', { devicePath, hasBD, hasDVD, hasCD }); if (hasCD && !hasDVD && !hasBD) { logger.debug('udevadm:audio-cd', { devicePath }); return { hasMedia: true, type: 'audio_cd' }; } if (hasBD) { logger.debug('udevadm:bluray', { devicePath }); return { hasMedia: true, type: 'udf' }; } if (hasDVD) { logger.debug('udevadm:dvd', { devicePath }); return { hasMedia: true, type: 'udf' }; } } catch (_udevError) { // udevadm not available or failed – ignore } // Last resort: cdparanoia can read the TOC of audio CDs directly. // Useful when udev media flags are not propagated (e.g. VM passthrough). // Some builds return non-zero even when TOC output exists, so parse both // stdout/stderr and treat valid TOC lines as "audio CD present". // Keep compatibility with previous behavior: exit 0 counts as media even // when TOC output format cannot be parsed. try { const { stdout, stderr } = await execFileAsync('cdparanoia', ['-Q', '-d', devicePath], { timeout: 10000 }); const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`); if (tracks.length > 0) { logger.debug('cdparanoia:audio-cd', { devicePath, trackCount: tracks.length }); return { hasMedia: true, type: 'audio_cd' }; } logger.debug('cdparanoia:audio-cd-exit-0-no-parse', { devicePath }); return { hasMedia: true, type: 'audio_cd' }; } catch (cdError) { const stderr = String(cdError?.stderr || ''); const stdout = String(cdError?.stdout || ''); const tracks = parseToc(`${stderr}\n${stdout}`); if (tracks.length > 0) { logger.debug('cdparanoia:audio-cd-from-error-streams', { devicePath, trackCount: tracks.length }); return { hasMedia: true, type: 'audio_cd' }; } // cdparanoia failed and no TOC output could be parsed. } logger.debug('blkid:no-media-or-fail', { devicePath }); return { hasMedia: false, type: null }; } async getDiscLabel(devicePath) { try { const { stdout } = await execFileAsync('blkid', ['-o', 'value', '-s', 'LABEL', devicePath]); const label = stdout.trim(); logger.debug('blkid:label', { devicePath, discLabel: label || null }); return label || null; } catch (error) { logger.debug('blkid:no-label', { devicePath, error: errorToMeta(error) }); return null; } } async inferMediaProfileFromUdev(devicePath) { const normalizedPath = String(devicePath || '').trim(); if (!normalizedPath) { return null; } try { const { stdout } = await execFileAsync('udevadm', ['info', '--query=property', '--name', normalizedPath]); const properties = {}; for (const line of String(stdout || '').split(/\r?\n/)) { const idx = line.indexOf('='); if (idx <= 0) { continue; } const key = String(line.slice(0, idx)).trim(); const value = String(line.slice(idx + 1)).trim(); if (!key) { continue; } properties[key] = value; } const inferred = inferMediaProfileFromUdevProperties(properties); if (inferred) { logger.debug('udev:media-profile', { devicePath: normalizedPath, inferred }); } return inferred; } catch (error) { logger.debug('udev:media-profile:failed', { devicePath: normalizedPath, error: errorToMeta(error) }); return null; } } async inferMediaProfile(devicePath, hints = {}) { // Audio CDs have no filesystem – short-circuit immediately if (String(hints?.fstype || '').trim().toLowerCase() === 'audio_cd') { return 'cd'; } const explicit = normalizeMediaProfile(hints?.mediaProfile); if (isSpecificMediaProfile(explicit)) { return explicit; } // Only pass disc-specific fields – NOT hints?.model (drive model). // Drive model (e.g. "BD-ROM") reflects drive capability, not disc type. // A BD-ROM drive with a DVD would otherwise be detected as Blu-ray here. const hinted = inferMediaProfileFromTextParts([ hints?.discLabel, hints?.label, hints?.fstype, ]); if (hinted) { return hinted; } const mountpoint = String(hints?.mountpoint || '').trim(); if (mountpoint) { try { if (fs.existsSync(`${mountpoint}/BDMV`)) { return 'bluray'; } } catch (_error) { // ignore fs errors } try { if (fs.existsSync(`${mountpoint}/VIDEO_TS`)) { return 'dvd'; } } catch (_error) { // ignore fs errors } } const byUdev = await this.inferMediaProfileFromUdev(devicePath); if (byUdev) { return byUdev; } const hintFstype = String(hints?.fstype || '').trim().toLowerCase(); const byFsTypeHint = inferMediaProfileFromFsTypeAndModel(hints?.fstype, hints?.model); const udfHintFallback = hintFstype.includes('udf') ? inferMediaProfileFromFsTypeAndModel(hints?.fstype, null) : null; // UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model // marker identifying it as Blu-ray, a 'dvd' result from UDF is ambiguous. Skip the // early return and fall through to the blkid check which uses the UDF version number. if (byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) { return byFsTypeHint; } try { const { stdout } = await execFileAsync('blkid', ['-p', '-o', 'export', devicePath]); const payload = {}; for (const line of String(stdout || '').split(/\r?\n/)) { const idx = line.indexOf('='); if (idx <= 0) { continue; } const key = String(line.slice(0, idx)).trim().toUpperCase(); const value = String(line.slice(idx + 1)).trim(); if (!key) { continue; } payload[key] = value; } // APPLICATION_ID contains disc-specific strings (e.g. "BDAV"/"BDMV" for Blu-ray, // "DVD_VIDEO" for DVD). Drive model is excluded – see reasoning above. const byBlkidMarker = inferMediaProfileFromTextParts([ payload.LABEL, payload.TYPE, payload.VERSION, payload.APPLICATION_ID, ]); if (byBlkidMarker) { return byBlkidMarker; } const type = String(payload.TYPE || '').trim().toLowerCase(); // For UDF, VERSION is the most reliable discriminator: 1.x → DVD, 2.x → Blu-ray. // This check must run independently of inferMediaProfileFromFsTypeAndModel so it // is not skipped when the drive model returns null (BD-ROM drive with DVD inside). if (type.includes('udf')) { const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.')); if (Number.isFinite(version)) { return version >= 2 ? 'bluray' : 'dvd'; } } const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model); if (byBlkidFsType) { return byBlkidFsType; } // Last resort for drives that only expose TYPE=udf without VERSION/APPLICATION_ID: // prefer DVD over "other" so DVDs in BD-capable drives do not fall back to Misc. const byBlkidFsTypeWithoutModel = inferMediaProfileFromFsTypeAndModel(type, null); if (byBlkidFsTypeWithoutModel) { return byBlkidFsTypeWithoutModel; } } catch (error) { logger.debug('infer-media-profile:blkid-failed', { devicePath, error: errorToMeta(error) }); } if (udfHintFallback) { return udfHintFallback; } return 'other'; } guessDiscIndex(name) { if (!name) { return 0; } const match = String(name).match(/(\d+)$/); return match ? Number(match[1]) : 0; } } module.exports = new DiskDetectionService();