Files
ripster/backend/src/services/diskDetectionService.js
T
2026-03-18 15:35:10 +00:00

1023 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();