0.12.0-3 Plugin Integration
This commit is contained in:
@@ -454,8 +454,10 @@ router.get(
|
||||
.map((d) => {
|
||||
const name = String(d.name || '');
|
||||
const devicePath = d.path || (name ? `/dev/${name}` : null);
|
||||
const match = name.match(/sr(\d+)$/);
|
||||
const discIndex = match ? Number(match[1]) : null;
|
||||
const resolvedIndex = Number(d.makemkvIndex);
|
||||
const discIndex = Number.isFinite(resolvedIndex) && resolvedIndex >= 0
|
||||
? Math.trunc(resolvedIndex)
|
||||
: null;
|
||||
return {
|
||||
path: devicePath,
|
||||
name,
|
||||
|
||||
@@ -52,6 +52,40 @@ function flattenDevices(nodes, acc = []) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
function normalizeOpticalDevicePath(entry) {
|
||||
const directPath = String(entry?.path || '').trim();
|
||||
if (directPath) {
|
||||
return directPath;
|
||||
}
|
||||
const name = String(entry?.name || '').trim();
|
||||
return name ? `/dev/${name}` : '';
|
||||
}
|
||||
|
||||
function compareOpticalDevicePaths(left, right) {
|
||||
return String(left || '').localeCompare(String(right || ''), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
});
|
||||
}
|
||||
|
||||
function buildMakeMkvIndexByDevicePath(entries = []) {
|
||||
const candidates = (Array.isArray(entries) ? entries : [])
|
||||
.filter((entry) => String(entry?.type || '').trim() === 'rom')
|
||||
.map((entry) => normalizeOpticalDevicePath(entry))
|
||||
.filter(Boolean);
|
||||
const sortedUniquePaths = Array.from(new Set(candidates)).sort(compareOpticalDevicePaths);
|
||||
const map = new Map();
|
||||
for (let index = 0; index < sortedUniquePaths.length; index += 1) {
|
||||
const devicePath = sortedUniquePaths[index];
|
||||
map.set(devicePath, index);
|
||||
const devName = devicePath.startsWith('/dev/') ? devicePath.slice(5) : '';
|
||||
if (devName) {
|
||||
map.set(devName, index);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildSignature(info) {
|
||||
return `${info.path || ''}|${info.discLabel || ''}|${info.label || ''}|${info.model || ''}|${info.mountpoint || ''}|${info.fstype || ''}|${info.mediaProfile || ''}`;
|
||||
}
|
||||
@@ -187,10 +221,13 @@ function inferMediaProfileFromUdevProperties(properties = {}) {
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const hasFlag = (prefix) => Object.entries(flags).some(([key, value]) => key.startsWith(prefix) && value === '1');
|
||||
const hasBD = hasFlag('ID_CDROM_MEDIA_BD');
|
||||
const hasDVD = hasFlag('ID_CDROM_MEDIA_DVD');
|
||||
const hasCD = hasFlag('ID_CDROM_MEDIA_CD');
|
||||
const hasExactFlag = (key) => flags[String(key || '').trim().toUpperCase()] === '1';
|
||||
// Only use exact media-presence keys here. Prefix matching would also catch
|
||||
// drive capability flags (e.g. ID_CDROM_MEDIA_BD_R=1) and misclassify DVDs
|
||||
// in BD-capable drives as Blu-ray media.
|
||||
const hasBD = hasExactFlag('ID_CDROM_MEDIA_BD');
|
||||
const hasDVD = hasExactFlag('ID_CDROM_MEDIA_DVD');
|
||||
const hasCD = hasExactFlag('ID_CDROM_MEDIA_CD');
|
||||
const audioTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_AUDIO);
|
||||
const dataTrackCount = parseTrackCount(flags.ID_CDROM_MEDIA_TRACK_COUNT_DATA);
|
||||
const hasAudioTracks = Number.isFinite(audioTrackCount) && audioTrackCount > 0;
|
||||
@@ -395,6 +432,28 @@ class DiskDetectionService extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
forceUnlockDevice(devicePath, options = {}) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const entry = this.deviceLocks.get(normalized);
|
||||
if (!entry) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const previousCount = Math.max(0, Number(entry.count) || 0);
|
||||
this.deviceLocks.delete(normalized);
|
||||
logger.warn('lock:force-remove', {
|
||||
devicePath: normalized,
|
||||
previousCount,
|
||||
reason: String(options?.reason || '').trim() || null,
|
||||
deletedJobIds: Array.isArray(options?.deletedJobIds) ? options.deletedJobIds : []
|
||||
});
|
||||
return previousCount;
|
||||
}
|
||||
|
||||
isDeviceLocked(devicePath) {
|
||||
const normalized = this.normalizeDevicePath(devicePath);
|
||||
if (!normalized) {
|
||||
@@ -666,7 +725,13 @@ class DiskDetectionService extends EventEmitter {
|
||||
}
|
||||
|
||||
const details = await this.getBlockDeviceInfo();
|
||||
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details);
|
||||
const match = details.find((entry) => entry.path === devicePath || `/dev/${entry.name}` === devicePath) || {};
|
||||
const inferredIndex = Number(
|
||||
makemkvIndexByPath.get(devicePath)
|
||||
?? makemkvIndexByPath.get(match.name || '')
|
||||
?? match.makemkvIndex
|
||||
);
|
||||
|
||||
// Always call checkMediaPresent to get the filesystem type (needed for accurate
|
||||
// mediaProfile detection). Use lsblk SIZE as fallback presence indicator for
|
||||
@@ -708,7 +773,9 @@ class DiskDetectionService extends EventEmitter {
|
||||
mountpoint: match.mountpoint || null,
|
||||
fstype: detectedFsType,
|
||||
mediaProfile: mediaProfile || null,
|
||||
index: this.guessDiscIndex(match.name || devicePath)
|
||||
index: Number.isFinite(inferredIndex) && inferredIndex >= 0
|
||||
? Math.trunc(inferredIndex)
|
||||
: this.guessDiscIndex(match.name || devicePath)
|
||||
};
|
||||
logger.debug('detect:explicit:success', { detected });
|
||||
return detected;
|
||||
@@ -716,6 +783,7 @@ class DiskDetectionService extends EventEmitter {
|
||||
|
||||
async detectAuto() {
|
||||
const details = await this.getBlockDeviceInfo();
|
||||
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details);
|
||||
const romCandidates = details.filter((entry) => entry.type === 'rom');
|
||||
|
||||
for (const item of romCandidates) {
|
||||
@@ -751,6 +819,11 @@ class DiskDetectionService extends EventEmitter {
|
||||
fstype: detectedFsType,
|
||||
mountpoint: item.mountpoint
|
||||
});
|
||||
const detectedIndex = Number(
|
||||
makemkvIndexByPath.get(path)
|
||||
?? makemkvIndexByPath.get(item.name || '')
|
||||
?? item.makemkvIndex
|
||||
);
|
||||
|
||||
const detected = {
|
||||
mode: 'auto',
|
||||
@@ -762,7 +835,9 @@ class DiskDetectionService extends EventEmitter {
|
||||
mountpoint: item.mountpoint || null,
|
||||
fstype: detectedFsType,
|
||||
mediaProfile: mediaProfile || null,
|
||||
index: this.guessDiscIndex(item.name)
|
||||
index: Number.isFinite(detectedIndex) && detectedIndex >= 0
|
||||
? Math.trunc(detectedIndex)
|
||||
: this.guessDiscIndex(item.name)
|
||||
};
|
||||
logger.debug('detect:auto:success', { detected });
|
||||
return detected;
|
||||
@@ -774,6 +849,7 @@ class DiskDetectionService extends EventEmitter {
|
||||
|
||||
async detectAllAuto() {
|
||||
const details = await this.getBlockDeviceInfo();
|
||||
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(details);
|
||||
const romCandidates = details.filter((entry) => entry.type === 'rom');
|
||||
const results = [];
|
||||
|
||||
@@ -818,6 +894,11 @@ class DiskDetectionService extends EventEmitter {
|
||||
fstype: detectedFsType,
|
||||
mountpoint: item.mountpoint
|
||||
});
|
||||
const detectedIndex = Number(
|
||||
makemkvIndexByPath.get(path)
|
||||
?? makemkvIndexByPath.get(item.name || '')
|
||||
?? item.makemkvIndex
|
||||
);
|
||||
|
||||
const detected = {
|
||||
mode: 'auto',
|
||||
@@ -829,7 +910,9 @@ class DiskDetectionService extends EventEmitter {
|
||||
mountpoint: item.mountpoint || null,
|
||||
fstype: detectedFsType,
|
||||
mediaProfile: mediaProfile || null,
|
||||
index: this.guessDiscIndex(item.name)
|
||||
index: Number.isFinite(detectedIndex) && detectedIndex >= 0
|
||||
? Math.trunc(detectedIndex)
|
||||
: this.guessDiscIndex(item.name)
|
||||
};
|
||||
logger.debug('detect:all-auto:found', { detected });
|
||||
results.push(detected);
|
||||
@@ -858,8 +941,25 @@ class DiskDetectionService extends EventEmitter {
|
||||
model: entry.model,
|
||||
sizeBytes: Number(entry.size) || 0
|
||||
}));
|
||||
const makemkvIndexByPath = buildMakeMkvIndexByDevicePath(devices);
|
||||
const withMakeMkvIndex = devices.map((entry) => {
|
||||
if (entry.type !== 'rom') {
|
||||
return { ...entry, makemkvIndex: null };
|
||||
}
|
||||
const devicePath = normalizeOpticalDevicePath(entry);
|
||||
const inferredIndex = Number(
|
||||
makemkvIndexByPath.get(devicePath)
|
||||
?? makemkvIndexByPath.get(entry.name || '')
|
||||
);
|
||||
return {
|
||||
...entry,
|
||||
makemkvIndex: Number.isFinite(inferredIndex) && inferredIndex >= 0
|
||||
? Math.trunc(inferredIndex)
|
||||
: null
|
||||
};
|
||||
});
|
||||
logger.debug('lsblk:ok', { deviceCount: devices.length });
|
||||
return devices;
|
||||
return withMakeMkvIndex;
|
||||
} catch (error) {
|
||||
logger.warn('lsblk:failed', { error: errorToMeta(error) });
|
||||
return [];
|
||||
|
||||
@@ -4059,10 +4059,25 @@ class HistoryService {
|
||||
}
|
||||
}
|
||||
|
||||
const hasTrackedOutputSource = (sources = []) => {
|
||||
const normalizedSources = Array.isArray(sources)
|
||||
? sources
|
||||
.map((source) => String(source || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return normalizedSources.includes('output_path') || normalizedSources.includes('lineage_output_path');
|
||||
};
|
||||
|
||||
const buildList = (target) => Array.from(candidateMap.values())
|
||||
.filter((row) => row.target === target)
|
||||
.map((row) => {
|
||||
const inspection = inspectDeletionPath(row.path);
|
||||
const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right));
|
||||
// Do not expose stale output file paths from DB/lineage in delete preview.
|
||||
// They cannot be deleted anyway and show up as "ghost paths" in UI/QA checks.
|
||||
if (target === 'movie' && !inspection.exists && hasTrackedOutputSource(sources)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
target,
|
||||
path: row.path,
|
||||
@@ -4070,9 +4085,10 @@ class HistoryService {
|
||||
isDirectory: Boolean(inspection.isDirectory),
|
||||
isFile: Boolean(inspection.isFile),
|
||||
jobIds: Array.from(row.jobIds).sort((left, right) => left - right),
|
||||
sources: Array.from(row.sources).sort((left, right) => left.localeCompare(right))
|
||||
sources
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de'));
|
||||
|
||||
return {
|
||||
@@ -4289,6 +4305,8 @@ class HistoryService {
|
||||
raw: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null },
|
||||
movie: { attempted: false, deleted: false, filesDeleted: 0, dirsRemoved: 0, reason: null }
|
||||
};
|
||||
let movieDeletedPathForTracking = null;
|
||||
let movieCandidatePathForTracking = null;
|
||||
|
||||
if (target === 'raw' || target === 'both') {
|
||||
summary.raw.attempted = true;
|
||||
@@ -4328,7 +4346,59 @@ class HistoryService {
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
} else if (!fs.existsSync(effectiveOutputPath)) {
|
||||
summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.';
|
||||
const movieRoot = normalizeComparablePath(effectiveMovieDir);
|
||||
const trackedFolders = await this.getJobOutputFolders(jobId);
|
||||
const trackedExistingPaths = Array.from(new Set(
|
||||
trackedFolders
|
||||
.map((row) => normalizeComparablePath(row?.output_path))
|
||||
.filter(Boolean)
|
||||
.filter((candidatePath) => isPathInside(effectiveMovieDir, candidatePath))
|
||||
.filter((candidatePath) => candidatePath !== movieRoot)
|
||||
.filter((candidatePath) => fs.existsSync(candidatePath))
|
||||
));
|
||||
|
||||
if (trackedExistingPaths.length === 1) {
|
||||
const trackedPath = trackedExistingPaths[0];
|
||||
const stat = fs.lstatSync(trackedPath);
|
||||
if (stat.isDirectory()) {
|
||||
const keepRoot = trackedPath === movieRoot;
|
||||
const result = deleteFilesRecursively(trackedPath, keepRoot);
|
||||
summary.movie.deleted = true;
|
||||
summary.movie.filesDeleted = result.filesDeleted;
|
||||
summary.movie.dirsRemoved = result.dirsRemoved;
|
||||
movieDeletedPathForTracking = trackedPath;
|
||||
movieCandidatePathForTracking = trackedPath;
|
||||
summary.movie.reason = null;
|
||||
} else {
|
||||
const parentDir = normalizeComparablePath(path.dirname(trackedPath));
|
||||
const canDeleteParentDir = parentDir
|
||||
&& parentDir !== movieRoot
|
||||
&& isPathInside(movieRoot, parentDir)
|
||||
&& fs.existsSync(parentDir)
|
||||
&& fs.lstatSync(parentDir).isDirectory();
|
||||
|
||||
if (canDeleteParentDir) {
|
||||
const result = deleteFilesRecursively(parentDir, false);
|
||||
summary.movie.deleted = true;
|
||||
summary.movie.filesDeleted = result.filesDeleted;
|
||||
summary.movie.dirsRemoved = result.dirsRemoved;
|
||||
movieDeletedPathForTracking = parentDir;
|
||||
movieCandidatePathForTracking = trackedPath;
|
||||
} else {
|
||||
fs.unlinkSync(trackedPath);
|
||||
summary.movie.deleted = true;
|
||||
summary.movie.filesDeleted = 1;
|
||||
summary.movie.dirsRemoved = 0;
|
||||
movieDeletedPathForTracking = trackedPath;
|
||||
movieCandidatePathForTracking = trackedPath;
|
||||
}
|
||||
summary.movie.reason = null;
|
||||
}
|
||||
} else if (trackedExistingPaths.length > 1) {
|
||||
summary.movie.reason = 'Mehrere bekannte Output-Ordner gefunden. Bitte gezielt über die Ordnerauswahl löschen.';
|
||||
} else {
|
||||
summary.movie.reason = 'Movie-Datei/Pfad existiert nicht.';
|
||||
}
|
||||
} else {
|
||||
const outputPath = normalizeComparablePath(effectiveOutputPath);
|
||||
const movieRoot = normalizeComparablePath(effectiveMovieDir);
|
||||
@@ -4339,6 +4409,8 @@ class HistoryService {
|
||||
summary.movie.deleted = true;
|
||||
summary.movie.filesDeleted = result.filesDeleted;
|
||||
summary.movie.dirsRemoved = result.dirsRemoved;
|
||||
movieDeletedPathForTracking = outputPath;
|
||||
movieCandidatePathForTracking = outputPath;
|
||||
} else {
|
||||
const parentDir = normalizeComparablePath(path.dirname(outputPath));
|
||||
const canDeleteParentDir = parentDir
|
||||
@@ -4352,19 +4424,29 @@ class HistoryService {
|
||||
summary.movie.deleted = true;
|
||||
summary.movie.filesDeleted = result.filesDeleted;
|
||||
summary.movie.dirsRemoved = result.dirsRemoved;
|
||||
movieDeletedPathForTracking = parentDir;
|
||||
movieCandidatePathForTracking = outputPath;
|
||||
} else {
|
||||
fs.unlinkSync(outputPath);
|
||||
summary.movie.deleted = true;
|
||||
summary.movie.filesDeleted = 1;
|
||||
summary.movie.dirsRemoved = 0;
|
||||
movieDeletedPathForTracking = outputPath;
|
||||
movieCandidatePathForTracking = outputPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove deleted movie paths from output folders tracking
|
||||
if (summary.movie?.deleted && effectiveOutputPath) {
|
||||
await this.removeJobOutputFolder(jobId, effectiveOutputPath);
|
||||
if (summary.movie?.deleted) {
|
||||
const trackingCleanupPaths = Array.from(new Set([
|
||||
movieCandidatePathForTracking,
|
||||
movieDeletedPathForTracking
|
||||
].filter(Boolean)));
|
||||
for (const candidatePath of trackingCleanupPaths) {
|
||||
await this.removeJobOutputFolder(jobId, candidatePath);
|
||||
}
|
||||
}
|
||||
|
||||
await this.appendLog(
|
||||
|
||||
@@ -4900,16 +4900,21 @@ class PipelineService extends EventEmitter {
|
||||
: (existing?.device && typeof existing.device === 'object'
|
||||
? existing.device
|
||||
: { path: normalizedPath, mediaProfile: 'cd' });
|
||||
const releasedDevice = {
|
||||
...fallbackDevice,
|
||||
path: normalizedPath,
|
||||
mediaProfile: 'cd'
|
||||
};
|
||||
|
||||
this._setCdDriveState(normalizedPath, {
|
||||
state: 'DISC_DETECTED',
|
||||
jobId: null,
|
||||
device: fallbackDevice,
|
||||
device: releasedDevice,
|
||||
progress: 0,
|
||||
eta: null,
|
||||
statusText: null,
|
||||
context: {
|
||||
device: fallbackDevice,
|
||||
device: releasedDevice,
|
||||
devicePath: normalizedPath,
|
||||
mediaProfile: 'cd'
|
||||
}
|
||||
@@ -5143,6 +5148,149 @@ class PipelineService extends EventEmitter {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
_forceUnlockDriveLocksForDeletedJobs(devicePaths = [], deletedJobIds = []) {
|
||||
const normalizedPaths = Array.from(new Set(
|
||||
(Array.isArray(devicePaths) ? devicePaths : [])
|
||||
.map((value) => this.normalizeDrivePath(value))
|
||||
.filter((value) => Boolean(value) && !value.startsWith('__virtual__'))
|
||||
));
|
||||
if (normalizedPaths.length === 0 || typeof diskDetectionService.forceUnlockDevice !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deletedSet = new Set(
|
||||
(Array.isArray(deletedJobIds) ? deletedJobIds : [])
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value))
|
||||
);
|
||||
const activeLocks = diskDetectionService.getActiveLocks();
|
||||
const forceUnlockedPaths = [];
|
||||
|
||||
for (const devicePath of normalizedPaths) {
|
||||
const lock = activeLocks.find((entry) => this.normalizeDrivePath(entry?.path) === devicePath) || null;
|
||||
if (!lock) {
|
||||
continue;
|
||||
}
|
||||
const owners = Array.isArray(lock?.owners) ? lock.owners : [];
|
||||
const ownerJobIds = owners
|
||||
.map((owner) => Number(owner?.jobId))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value));
|
||||
const hasForeignOwner = ownerJobIds.some((jobId) => !deletedSet.has(jobId));
|
||||
if (hasForeignOwner) {
|
||||
logger.warn('drive-lock:force-unlock:skipped-foreign-owner', {
|
||||
devicePath,
|
||||
ownerJobIds
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const clearedCount = Number(diskDetectionService.forceUnlockDevice(devicePath, {
|
||||
reason: 'job_deleted',
|
||||
deletedJobIds: Array.from(deletedSet)
|
||||
}) || 0);
|
||||
if (clearedCount > 0) {
|
||||
forceUnlockedPaths.push(devicePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (forceUnlockedPaths.length > 0) {
|
||||
logger.warn('drive-lock:force-unlocked-after-delete', {
|
||||
devicePaths: forceUnlockedPaths
|
||||
});
|
||||
}
|
||||
return forceUnlockedPaths;
|
||||
}
|
||||
|
||||
async _rescanDrivesAfterDelete(devicePaths = []) {
|
||||
const normalizedPaths = Array.from(new Set(
|
||||
(Array.isArray(devicePaths) ? devicePaths : [])
|
||||
.map((value) => this.normalizeDrivePath(value))
|
||||
.filter((value) => Boolean(value) && !value.startsWith('__virtual__'))
|
||||
));
|
||||
if (normalizedPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const retryDelaysMs = [0, 450, 1250];
|
||||
for (const devicePath of normalizedPaths) {
|
||||
let lastResult = null;
|
||||
for (let attempt = 0; attempt < retryDelaysMs.length; attempt += 1) {
|
||||
const waitMs = retryDelaysMs[attempt];
|
||||
if (waitMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
try {
|
||||
lastResult = await diskDetectionService.rescanDriveAndEmit(devicePath);
|
||||
} catch (error) {
|
||||
logger.warn('job-delete:drive-rescan:failed', {
|
||||
devicePath,
|
||||
attempt: attempt + 1,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const detectedProfile = String(lastResult?.device?.mediaProfile || '').trim().toLowerCase();
|
||||
const present = Boolean(lastResult?.present);
|
||||
const locked = Boolean(lastResult?.locked);
|
||||
if (locked) {
|
||||
continue;
|
||||
}
|
||||
if (!present) {
|
||||
continue;
|
||||
}
|
||||
if (detectedProfile && detectedProfile !== 'other' && detectedProfile !== 'unknown') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
logger.info('job-delete:drive-rescan:done', {
|
||||
devicePath,
|
||||
emitted: lastResult?.emitted || 'none',
|
||||
present: Boolean(lastResult?.present),
|
||||
mediaProfile: lastResult?.device?.mediaProfile || null,
|
||||
locked: Boolean(lastResult?.locked)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_forceStopActiveProcessForJob(jobId, options = {}) {
|
||||
const normalizedJobId = Number(jobId);
|
||||
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetJobId = Math.trunc(normalizedJobId);
|
||||
const processHandle = this.activeProcesses.get(targetJobId) || null;
|
||||
if (!processHandle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const childPid = Number(processHandle?.child?.pid);
|
||||
try {
|
||||
processHandle?.cancel?.();
|
||||
} catch (_error) {
|
||||
// ignore cancellation race errors
|
||||
}
|
||||
|
||||
if (Number.isFinite(childPid) && childPid > 0) {
|
||||
try { process.kill(-childPid, 'SIGKILL'); } catch (_error) { /* noop */ }
|
||||
try { process.kill(childPid, 'SIGKILL'); } catch (_error) { /* noop */ }
|
||||
}
|
||||
try {
|
||||
processHandle?.child?.kill?.('SIGKILL');
|
||||
} catch (_error) {
|
||||
// noop
|
||||
}
|
||||
|
||||
logger.warn('job-delete:active-process-killed', {
|
||||
jobId: targetJobId,
|
||||
reason: String(options?.reason || '').trim() || null,
|
||||
pid: Number.isFinite(childPid) && childPid > 0 ? childPid : null
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async onJobsDeleted(jobIds = [], options = {}) {
|
||||
const normalizedIds = Array.isArray(jobIds)
|
||||
? jobIds
|
||||
@@ -5167,7 +5315,8 @@ class PipelineService extends EventEmitter {
|
||||
const removedQueueEntries = previousQueueLength - this.queueEntries.length;
|
||||
|
||||
for (const jobId of normalizedIds) {
|
||||
this.cancelRequestedByJob.delete(jobId);
|
||||
this.cancelRequestedByJob.add(jobId);
|
||||
this._forceStopActiveProcessForJob(jobId, { reason: 'job_deleted' });
|
||||
const lockForJob = this._getDriveLockByJobId(jobId);
|
||||
if (lockForJob?.devicePath) {
|
||||
affectedDevicePaths.add(this.normalizeDrivePath(lockForJob.devicePath));
|
||||
@@ -5190,6 +5339,14 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
this.activeProcesses.delete(jobId);
|
||||
}
|
||||
// Keep the cancellation marker briefly so any late async step in the old
|
||||
// pipeline thread aborts before spawning follow-up processes.
|
||||
for (const jobId of normalizedIds) {
|
||||
setTimeout(() => {
|
||||
this.cancelRequestedByJob.delete(jobId);
|
||||
}, 120000);
|
||||
}
|
||||
const forceUnlockedPaths = this._forceUnlockDriveLocksForDeletedJobs(Array.from(affectedDevicePaths), normalizedIds);
|
||||
|
||||
let driveResetChanged = false;
|
||||
if (resetDriveState) {
|
||||
@@ -5222,6 +5379,10 @@ class PipelineService extends EventEmitter {
|
||||
await this.emitQueueChanged();
|
||||
void this.pumpQueue();
|
||||
}
|
||||
|
||||
if (resetDriveState || forceUnlockedPaths.length > 0) {
|
||||
void this._rescanDrivesAfterDelete(Array.from(affectedDevicePaths));
|
||||
}
|
||||
return {
|
||||
cleaned: normalizedIds.length,
|
||||
removedQueueEntries
|
||||
@@ -12780,8 +12941,9 @@ class PipelineService extends EventEmitter {
|
||||
applyLine(line, true);
|
||||
}
|
||||
});
|
||||
|
||||
this.activeProcesses.set(Number(normalizedJobId), processHandle);
|
||||
const normalizedProcessKey = Number(normalizedJobId);
|
||||
const previousProcessHandle = this.activeProcesses.get(normalizedProcessKey) || null;
|
||||
this.activeProcesses.set(normalizedProcessKey, processHandle);
|
||||
this.syncPrimaryActiveProcess();
|
||||
|
||||
try {
|
||||
@@ -12814,8 +12976,18 @@ class PipelineService extends EventEmitter {
|
||||
logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) });
|
||||
throw error;
|
||||
} finally {
|
||||
this.activeProcesses.delete(Number(normalizedJobId));
|
||||
this.cancelRequestedByJob.delete(Number(normalizedJobId));
|
||||
const activeHandleForJob = this.activeProcesses.get(normalizedProcessKey) || null;
|
||||
if (activeHandleForJob === processHandle) {
|
||||
if (previousProcessHandle && previousProcessHandle !== processHandle) {
|
||||
this.activeProcesses.set(normalizedProcessKey, previousProcessHandle);
|
||||
} else {
|
||||
this.activeProcesses.delete(normalizedProcessKey);
|
||||
}
|
||||
}
|
||||
// Preserve cancellation marker for outer/orchestrating handles (e.g. CD shared handle).
|
||||
if (!(previousProcessHandle && previousProcessHandle !== processHandle)) {
|
||||
this.cancelRequestedByJob.delete(normalizedProcessKey);
|
||||
}
|
||||
this.syncPrimaryActiveProcess();
|
||||
await historyService.closeProcessLog(jobId);
|
||||
await this.emitQueueChanged();
|
||||
@@ -14963,6 +15135,8 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
};
|
||||
let currentTrackPosition = null;
|
||||
let buildLiveContext = () => null;
|
||||
this.activeProcesses.set(processKey, sharedHandle);
|
||||
this.syncPrimaryActiveProcess();
|
||||
|
||||
@@ -14995,8 +15169,8 @@ class PipelineService extends EventEmitter {
|
||||
let encodeCompletedCount = 0;
|
||||
let currentPhase = 'rip';
|
||||
let currentTrackIndex = effectiveTrackTotal > 0 ? 1 : 0;
|
||||
let currentTrackPosition = liveTrackRows[0]?.position || null;
|
||||
const buildLiveContext = (failedTrackPosition = null) => buildCdLiveProgressSnapshot({
|
||||
currentTrackPosition = liveTrackRows[0]?.position || null;
|
||||
buildLiveContext = (failedTrackPosition = null) => buildCdLiveProgressSnapshot({
|
||||
trackRows: liveTrackRows,
|
||||
phase: currentPhase,
|
||||
trackIndex: currentTrackIndex,
|
||||
|
||||
@@ -576,7 +576,8 @@ function mapPresetEntriesToOptions(entries) {
|
||||
* Parses the drive_devices JSON setting into a normalized array of {path, makemkvIndex}.
|
||||
* Supports both the legacy string format ["/dev/sr0"] and the object format
|
||||
* [{"path": "/dev/sr0", "makemkvIndex": 0}].
|
||||
* When makemkvIndex is missing, it is auto-derived from the device name (sr0→0, sr1→1).
|
||||
* When makemkvIndex is missing, it is assigned by list order (0,1,2,...) so
|
||||
* sparse kernel names like /dev/sr2 still map to contiguous MakeMKV indices.
|
||||
*/
|
||||
function parseDriveDeviceEntries(raw) {
|
||||
try {
|
||||
@@ -584,28 +585,49 @@ function parseDriveDeviceEntries(raw) {
|
||||
if (!Array.isArray(arr)) {
|
||||
return [];
|
||||
}
|
||||
return arr.map((entry) => {
|
||||
const normalized = arr.map((entry) => {
|
||||
if (typeof entry === 'string') {
|
||||
const p = entry.trim();
|
||||
if (!p) {
|
||||
return null;
|
||||
}
|
||||
const m = p.match(/sr(\d+)$/);
|
||||
return { path: p, makemkvIndex: m ? Number(m[1]) : 0 };
|
||||
return { path: p, makemkvIndex: null };
|
||||
}
|
||||
if (entry && typeof entry === 'object' && entry.path) {
|
||||
const p = String(entry.path || '').trim();
|
||||
if (!p) {
|
||||
return null;
|
||||
}
|
||||
const idxRaw = entry.makemkvIndex != null ? Number(entry.makemkvIndex) : null;
|
||||
const m = p.match(/sr(\d+)$/);
|
||||
const derived = m ? Number(m[1]) : 0;
|
||||
const idx = Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : derived;
|
||||
return { path: p, makemkvIndex: idx };
|
||||
const idxRaw = Number(entry.makemkvIndex);
|
||||
return {
|
||||
path: p,
|
||||
makemkvIndex: Number.isFinite(idxRaw) && idxRaw >= 0 ? Math.trunc(idxRaw) : null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
const used = new Set();
|
||||
let nextAutoIndex = 0;
|
||||
return normalized.map((entry) => {
|
||||
if (entry.makemkvIndex != null) {
|
||||
used.add(entry.makemkvIndex);
|
||||
if (entry.makemkvIndex >= nextAutoIndex) {
|
||||
nextAutoIndex = entry.makemkvIndex + 1;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
while (used.has(nextAutoIndex)) {
|
||||
nextAutoIndex += 1;
|
||||
}
|
||||
const resolved = {
|
||||
path: entry.path,
|
||||
makemkvIndex: nextAutoIndex
|
||||
};
|
||||
used.add(nextAutoIndex);
|
||||
nextAutoIndex += 1;
|
||||
return resolved;
|
||||
});
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -1413,17 +1435,31 @@ class SettingsService {
|
||||
|
||||
resolveSourceArg(map, deviceInfo = null) {
|
||||
const devicePath = String(deviceInfo?.path || '').trim();
|
||||
const deviceIndex = Number(deviceInfo?.makemkvIndex ?? deviceInfo?.index);
|
||||
const configuredEntries = parseDriveDeviceEntries(map.drive_devices);
|
||||
|
||||
// In explicit mode: look up per-drive makemkvIndex from drive_devices
|
||||
if (devicePath && map.drive_mode === 'explicit') {
|
||||
const entries = parseDriveDeviceEntries(map.drive_devices);
|
||||
const entry = entries.find((e) => e.path === devicePath);
|
||||
const entry = configuredEntries.find((e) => e.path === devicePath);
|
||||
if (entry) {
|
||||
return `disc:${entry.makemkvIndex}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-derive from device name (/dev/sr0 → disc:0, /dev/sr1 → disc:1)
|
||||
// Prefer configured per-drive index when a path match exists (also in auto mode).
|
||||
if (devicePath) {
|
||||
const configured = configuredEntries.find((e) => e.path === devicePath);
|
||||
if (configured) {
|
||||
return `disc:${configured.makemkvIndex}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer device-provided MakeMKV index from disk detection service.
|
||||
if (Number.isFinite(deviceIndex) && deviceIndex >= 0) {
|
||||
return `disc:${Math.trunc(deviceIndex)}`;
|
||||
}
|
||||
|
||||
// Last automatic fallback: derive from device name (/dev/sr0 → disc:0).
|
||||
if (devicePath) {
|
||||
const match = devicePath.match(/sr(\d+)$/);
|
||||
if (match) {
|
||||
|
||||
Reference in New Issue
Block a user