0.12.0 Begin neu Architecture

This commit is contained in:
2026-03-18 15:35:10 +00:00
parent d00191324b
commit 0a9cf6969f
30 changed files with 4570 additions and 183 deletions
+622 -47
View File
@@ -3857,6 +3857,8 @@ class PipelineService extends EventEmitter {
this.queueEntries = [];
this.queuePumpRunning = false;
this.queueEntrySeq = 1;
this.pluginRegistryInitialized = false;
this.sourcePluginRegistry = null;
this.lastQueueSnapshot = {
maxParallelJobs: 1,
runningCount: 0,
@@ -3867,6 +3869,318 @@ class PipelineService extends EventEmitter {
};
}
normalizeBooleanSetting(value) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
ensureSourcePluginRegistry() {
if (this.pluginRegistryInitialized) {
return this.sourcePluginRegistry;
}
try {
const { registry } = require('../plugins/PluginRegistry');
const { BluRayPlugin } = require('../plugins/BluRayPlugin');
const { DVDPlugin } = require('../plugins/DVDPlugin');
const { CdPlugin } = require('../plugins/CdPlugin');
const { AudiobookPlugin } = require('../plugins/AudiobookPlugin');
const plugins = [
new BluRayPlugin(),
new DVDPlugin(),
new CdPlugin(),
new AudiobookPlugin()
];
for (const plugin of plugins) {
if (!registry.getPlugin(plugin.id)) {
registry.register(plugin);
}
}
this.sourcePluginRegistry = registry;
this.pluginRegistryInitialized = true;
return this.sourcePluginRegistry;
} catch (error) {
logger.warn('plugin-architecture:registry-init-failed', {
error: errorToMeta(error)
});
return null;
}
}
async isPluginArchitectureEnabled() {
return this.isPluginArchitectureEnabledForPlugin(null);
}
getPluginArchitectureSettingKey(pluginId = null) {
const normalizedPluginId = String(pluginId || '').trim().toLowerCase();
if (!normalizedPluginId) {
return null;
}
const byPluginId = {
bluray: 'use_plugin_architecture_bluray',
dvd: 'use_plugin_architecture_dvd',
cd: 'use_plugin_architecture_cd',
audiobook: 'use_plugin_architecture_audiobook'
};
return byPluginId[normalizedPluginId] || null;
}
isPluginArchitectureEnabledForSettings(settingsMap = null, pluginId = null) {
const settings = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
const globalEnabled = this.normalizeBooleanSetting(settings.use_plugin_architecture);
if (!globalEnabled) {
return false;
}
const pluginSettingKey = this.getPluginArchitectureSettingKey(pluginId);
if (!pluginSettingKey) {
return globalEnabled;
}
if (!(pluginSettingKey in settings)) {
return true;
}
return this.normalizeBooleanSetting(settings[pluginSettingKey]);
}
async isPluginArchitectureEnabledForPlugin(pluginId = null) {
try {
const settingsMap = await settingsService.getSettingsMap();
return this.isPluginArchitectureEnabledForSettings(settingsMap, pluginId);
} catch (error) {
logger.warn('plugin-architecture:settings-read-failed', {
error: errorToMeta(error),
pluginId: pluginId || null
});
return false;
}
}
async resolveAnalyzePlugin(discInfo = null) {
const enabled = await this.isPluginArchitectureEnabledForPlugin(null);
if (!enabled) {
return null;
}
const registry = this.ensureSourcePluginRegistry();
if (!registry) {
return null;
}
const plugin = registry.findPlugin(discInfo);
if (!plugin?.id) {
return null;
}
const pluginEnabled = await this.isPluginArchitectureEnabledForPlugin(plugin.id);
if (!pluginEnabled) {
logger.info('plugin-architecture:plugin-disabled', {
pluginId: plugin.id,
mediaProfile: discInfo?.mediaProfile || null
});
return null;
}
return plugin;
}
sanitizePluginExecutionState(rawState = null) {
if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) {
return null;
}
const normalizeStage = (value) => {
const stage = String(value || '').trim().toLowerCase();
return stage || 'unknown';
};
const pluginId = String(rawState.pluginId || '').trim().toLowerCase() || 'unknown';
const pluginName = String(rawState.pluginName || '').trim() || pluginId;
const pluginFile = String(rawState.pluginFile || '').trim() || null;
const markerSource = String(rawState.markerSource || '').trim().toLowerCase() || 'plugin-file';
const byStageRaw = rawState.byStage && typeof rawState.byStage === 'object' && !Array.isArray(rawState.byStage)
? rawState.byStage
: {};
const byStage = {};
for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) {
const normalizedStage = normalizeStage(stageKey);
const count = Number(stageMeta?.count);
byStage[normalizedStage] = {
count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : 1,
lastMarkedAt: String(stageMeta?.lastMarkedAt || '').trim() || null
};
}
const explicitStages = Array.isArray(rawState.stages)
? rawState.stages.map((stage) => normalizeStage(stage)).filter(Boolean)
: [];
const rawLastStage = String(rawState.lastStage || '').trim();
const lastStage = rawLastStage ? normalizeStage(rawLastStage) : null;
const stages = Array.from(new Set([
...explicitStages,
...Object.keys(byStage),
...(lastStage ? [lastStage] : [])
]));
return {
markerSource,
pluginId,
pluginName,
pluginFile,
jobId: this.normalizeQueueJobId(rawState.jobId),
firstMarkedAt: String(rawState.firstMarkedAt || '').trim() || null,
lastMarkedAt: String(rawState.lastMarkedAt || '').trim() || null,
lastStage,
stages,
byStage
};
}
mergePluginExecutionState(existingState = null, nextState = null) {
const existing = this.sanitizePluginExecutionState(existingState);
const incoming = this.sanitizePluginExecutionState(nextState);
if (!existing) {
return incoming;
}
if (!incoming) {
return existing;
}
const byStage = {};
const stageKeys = new Set([
...Object.keys(existing.byStage || {}),
...Object.keys(incoming.byStage || {})
]);
for (const stage of stageKeys) {
const previousMeta = existing.byStage?.[stage] || null;
const nextMeta = incoming.byStage?.[stage] || null;
const count = Number(previousMeta?.count || 0) + Number(nextMeta?.count || 0);
byStage[stage] = {
count: count > 0 ? Math.trunc(count) : 1,
lastMarkedAt: nextMeta?.lastMarkedAt || previousMeta?.lastMarkedAt || null
};
}
const stages = Array.from(new Set([
...(Array.isArray(existing.stages) ? existing.stages : []),
...(Array.isArray(incoming.stages) ? incoming.stages : [])
]));
return {
markerSource: incoming.markerSource || existing.markerSource || 'plugin-file',
pluginId: incoming.pluginId || existing.pluginId || 'unknown',
pluginName: incoming.pluginName || existing.pluginName || incoming.pluginId || existing.pluginId || 'unknown',
pluginFile: incoming.pluginFile || existing.pluginFile || null,
jobId: this.normalizeQueueJobId(incoming.jobId || existing.jobId),
firstMarkedAt: existing.firstMarkedAt || incoming.firstMarkedAt || incoming.lastMarkedAt || existing.lastMarkedAt || null,
lastMarkedAt: incoming.lastMarkedAt || existing.lastMarkedAt || null,
lastStage: incoming.lastStage || existing.lastStage || (stages.length > 0 ? stages[stages.length - 1] : null),
stages,
byStage
};
}
withPluginExecutionMeta(info = null, executionState = null) {
const baseInfo = info && typeof info === 'object' && !Array.isArray(info)
? { ...info }
: {};
const mergedExecution = this.mergePluginExecutionState(baseInfo.pluginExecution, executionState);
if (!mergedExecution) {
return baseInfo;
}
baseInfo.pluginExecution = mergedExecution;
return baseInfo;
}
async applyPluginExecutionMarker(marker = null, executionState = null) {
const mergedExecution = this.mergePluginExecutionState(null, executionState || marker);
const jobId = this.normalizeQueueJobId(mergedExecution?.jobId || marker?.jobId);
if (!mergedExecution || !jobId) {
return;
}
const previousJobProgress = this.jobProgress.get(jobId) || {};
const nextJobProgress = {
...previousJobProgress,
state: previousJobProgress.state || this.snapshot.state,
progress: previousJobProgress.progress ?? this.snapshot.progress ?? 0,
eta: previousJobProgress.eta ?? this.snapshot.eta ?? null,
statusText: previousJobProgress.statusText ?? this.snapshot.statusText ?? null,
context: {
...(previousJobProgress.context && typeof previousJobProgress.context === 'object'
? previousJobProgress.context
: {}),
pluginExecution: this.mergePluginExecutionState(previousJobProgress.context?.pluginExecution, mergedExecution)
}
};
this.jobProgress.set(jobId, nextJobProgress);
const snapshotJobId = this.normalizeQueueJobId(this.snapshot.activeJobId || this.snapshot.context?.jobId);
if (snapshotJobId === jobId) {
this.snapshot = {
...this.snapshot,
context: {
...(this.snapshot.context && typeof this.snapshot.context === 'object'
? this.snapshot.context
: {}),
pluginExecution: this.mergePluginExecutionState(this.snapshot.context?.pluginExecution, mergedExecution)
}
};
await this.persistSnapshot(false);
}
const cdDriveEntry = this._getCdDriveByJobId(jobId);
if (cdDriveEntry?.devicePath) {
const existingDrive = this.cdDrives.get(cdDriveEntry.devicePath);
if (existingDrive) {
this.cdDrives.set(cdDriveEntry.devicePath, {
...existingDrive,
context: {
...(existingDrive.context && typeof existingDrive.context === 'object'
? existingDrive.context
: {}),
pluginExecution: this.mergePluginExecutionState(existingDrive.context?.pluginExecution, mergedExecution)
}
});
}
}
wsService.broadcast('PIPELINE_PROGRESS', {
state: nextJobProgress.state || this.snapshot.state,
activeJobId: jobId,
progress: nextJobProgress.progress ?? 0,
eta: nextJobProgress.eta ?? null,
statusText: nextJobProgress.statusText ?? null,
contextPatch: {
pluginExecution: nextJobProgress.context.pluginExecution
}
});
}
async buildPluginContext(pluginId, extra = {}) {
const { PluginContext } = require('../plugins/PluginContext');
const normalizedJobId = this.normalizeQueueJobId(extra?.jobId ?? extra?.job?.id);
return new PluginContext({
settings: settingsService,
db: await getDb(),
logger: require('./logger').child(`PLUGIN:${String(pluginId || 'unknown').trim().toUpperCase() || 'UNKNOWN'}`),
websocket: wsService,
processRunner: { spawnTrackedProcess },
emitProgress: () => {},
emitState: () => {},
onPluginExecution: (marker, aggregateState) => {
void this.applyPluginExecutionMarker(marker, aggregateState);
},
extra: {
...extra,
jobId: normalizedJobId,
pluginId
}
});
}
isRipSuccessful(job = null) {
if (Number(job?.rip_successful || 0) === 1) {
return true;
@@ -4402,6 +4716,34 @@ class PipelineService extends EventEmitter {
return null;
}
_releaseCdDrive(devicePath, options = {}) {
const normalizedPath = String(devicePath || '').trim();
if (!normalizedPath) {
return;
}
const existing = this.cdDrives.get(normalizedPath) || null;
const fallbackDevice = options?.device && typeof options.device === 'object'
? options.device
: (existing?.device && typeof existing.device === 'object'
? existing.device
: { path: normalizedPath, mediaProfile: 'cd' });
this._setCdDriveState(normalizedPath, {
state: 'DISC_DETECTED',
jobId: null,
device: fallbackDevice,
progress: 0,
eta: null,
statusText: null,
context: {
device: fallbackDevice,
devicePath: normalizedPath,
mediaProfile: 'cd'
}
});
}
normalizeParallelJobsLimit(rawValue) {
const value = Number(rawValue);
if (!Number.isFinite(value) || value < 1) {
@@ -6051,10 +6393,13 @@ class PipelineService extends EventEmitter {
...device,
mediaProfile
};
const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile);
// Route audio CDs to the dedicated CD pipeline
if (mediaProfile === 'cd') {
return this.analyzeCd(deviceWithProfile);
return this.analyzeCd(deviceWithProfile, {
plugin: analyzePlugin?.id === 'cd' ? analyzePlugin : null
});
}
const job = await historyService.createJob({
@@ -6064,18 +6409,50 @@ class PipelineService extends EventEmitter {
});
try {
const omdbCandidates = await omdbService.search(detectedTitle).catch(() => []);
let effectiveDetectedTitle = detectedTitle;
let omdbCandidates = null;
let pluginExecution = null;
if (analyzePlugin) {
const pluginContext = await this.buildPluginContext(analyzePlugin.id, {
discInfo: deviceWithProfile,
jobId: job.id
});
try {
const pluginResult = await analyzePlugin.analyze(device.path, job, pluginContext);
pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution());
const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim();
if (pluginDetectedTitle) {
effectiveDetectedTitle = pluginDetectedTitle;
}
if (Array.isArray(pluginResult?.omdbCandidates)) {
omdbCandidates = pluginResult.omdbCandidates;
}
logger.info('plugin:analyze:used', {
jobId: job.id,
pluginId: analyzePlugin.id,
mediaProfile
});
} catch (error) {
pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution());
logger.warn('plugin:analyze:fallback-legacy', {
jobId: job.id,
pluginId: analyzePlugin.id,
mediaProfile,
error: errorToMeta(error)
});
}
}
if (!Array.isArray(omdbCandidates)) {
omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []);
}
logger.info('metadata:prepare:result', {
jobId: job.id,
detectedTitle,
detectedTitle: effectiveDetectedTitle,
omdbCandidateCount: omdbCandidates.length
});
await historyService.updateJob(job.id, {
status: 'METADATA_SELECTION',
last_state: 'METADATA_SELECTION',
detected_title: detectedTitle,
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
const prepareInfo = this.withPluginExecutionMeta(
this.withAnalyzeContextMediaProfile({
phase: 'PREPARE',
preparedAt: nowIso(),
analyzeContext: {
@@ -6084,12 +6461,20 @@ class PipelineService extends EventEmitter {
selectedPlaylist: null,
selectedTitleId: null
}
}, mediaProfile))
}, mediaProfile),
pluginExecution
);
await historyService.updateJob(job.id, {
status: 'METADATA_SELECTION',
last_state: 'METADATA_SELECTION',
detected_title: effectiveDetectedTitle,
makemkv_info_json: JSON.stringify(prepareInfo)
});
await historyService.appendLog(
job.id,
'SYSTEM',
`Disk erkannt. Metadaten-Suche vorbereitet mit Query "${detectedTitle}".`
`Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".`
);
const runningJobs = await historyService.getRunningJobs();
@@ -6104,15 +6489,18 @@ class PipelineService extends EventEmitter {
context: {
jobId: job.id,
device: deviceWithProfile,
detectedTitle,
detectedTitleSource: device.discLabel ? 'discLabel' : 'fallback',
detectedTitle: effectiveDetectedTitle,
detectedTitleSource: effectiveDetectedTitle !== detectedTitle
? 'plugin'
: (device.discLabel ? 'discLabel' : 'fallback'),
omdbCandidates,
mediaProfile,
playlistAnalysis: null,
playlistDecisionRequired: false,
playlistCandidates: [],
selectedPlaylist: null,
selectedTitleId: null
selectedTitleId: null,
...(pluginExecution ? { pluginExecution } : {})
}
});
} else {
@@ -6125,12 +6513,12 @@ class PipelineService extends EventEmitter {
void this.notifyPushover('metadata_ready', {
title: 'Ripster - Metadaten bereit',
message: `Job #${job.id}: ${detectedTitle} (${omdbCandidates.length} Treffer)`
message: `Job #${job.id}: ${effectiveDetectedTitle} (${omdbCandidates.length} Treffer)`
});
return {
jobId: job.id,
detectedTitle,
detectedTitle: effectiveDetectedTitle,
omdbCandidates
};
} catch (error) {
@@ -8107,6 +8495,98 @@ class PipelineService extends EventEmitter {
return this.startPreparedJob(sourceJobId);
}
if (reencodeMediaProfile === 'cd') {
const cdReencodeSettings = await settingsService.getEffectiveSettingsMap('cd');
const resolvedCdRawPath = this.resolveCurrentRawPathForSettings(
cdReencodeSettings,
'cd',
sourceJob.raw_path
);
if (!resolvedCdRawPath) {
const error = new Error(`CD-Encode nicht möglich: RAW-Verzeichnis nicht gefunden (${sourceJob.raw_path}).`);
error.statusCode = 400;
throw error;
}
// WAV-Dateien im RAW-Verzeichnis suchen
const wavFiles = fs.existsSync(resolvedCdRawPath)
? fs.readdirSync(resolvedCdRawPath).filter((f) => f.endsWith('.cdda.wav'))
: [];
if (wavFiles.length === 0) {
const error = new Error('CD-Encode nicht möglich: keine WAV-Dateien im RAW-Verzeichnis gefunden.');
error.statusCode = 400;
throw error;
}
const cdEncodePlan = sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {};
const cdMkInfo = mkInfo && typeof mkInfo === 'object' ? mkInfo : {};
const selectedMeta = cdMkInfo?.selectedMetadata && typeof cdMkInfo.selectedMetadata === 'object'
? cdMkInfo.selectedMetadata
: {};
const tocTracks = Array.isArray(cdMkInfo?.tracks) && cdMkInfo.tracks.length > 0
? cdMkInfo.tracks
: (Array.isArray(cdEncodePlan?.tracks) ? cdEncodePlan.tracks : []);
const selectedTrackPositions = normalizeCdTrackPositionList(
Array.isArray(cdEncodePlan?.selectedTracks) && cdEncodePlan.selectedTracks.length > 0
? cdEncodePlan.selectedTracks
: tocTracks.map((t) => Number(t?.position)).filter((p) => Number.isFinite(p) && p > 0)
);
const format = String(cdEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac';
const formatOptions = cdEncodePlan?.formatOptions && typeof cdEncodePlan.formatOptions === 'object'
? cdEncodePlan.formatOptions
: {};
const cdOutputTemplate = String(
cdReencodeSettings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE
).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
const cdOutputBaseDir = String(cdReencodeSettings.movie_dir || '').trim()
|| String(cdReencodeSettings.raw_dir || '').trim()
|| settingsService.DEFAULT_CD_DIR;
const cdOutputOwner = String(cdReencodeSettings.movie_dir_owner || cdReencodeSettings.raw_dir_owner || '').trim();
const outputDir = cdRipService.buildOutputDir(selectedMeta, cdOutputBaseDir, cdOutputTemplate);
await historyService.resetProcessLog(sourceJobId);
await historyService.updateJob(sourceJobId, {
status: 'CD_RIPPING',
last_state: 'CD_RIPPING',
start_time: new Date().toISOString(),
end_time: null,
error_message: null,
output_path: outputDir
});
await historyService.appendLog(
sourceJobId,
'USER_ACTION',
`CD-Encode aus RAW angefordert (skipRip). Format=${format}, Tracks=${selectedTrackPositions.join(',') || 'alle'}, RAW=${resolvedCdRawPath}`
);
this._runCdRip({
jobId: sourceJobId,
devicePath: null,
cdparanoiaCmd: 'cdparanoia',
rawWavDir: resolvedCdRawPath,
rawBaseDir: null,
cdMetadataBase: null,
outputDir,
format,
formatOptions,
outputTemplate: cdOutputTemplate,
rawOwner: null,
outputOwner: cdOutputOwner,
selectedTrackPositions,
tocTracks,
selectedMeta,
encodePlan: cdEncodePlan,
skipRip: true
}).catch((error) => {
logger.error('reencodeFromRaw:cd:background-failed', { jobId: sourceJobId, error: errorToMeta(error) });
this.failJob(sourceJobId, 'CD_ENCODING', error).catch((failError) => {
logger.error('reencodeFromRaw:cd:failJob-failed', { jobId: sourceJobId, error: errorToMeta(failError) });
});
});
return { jobId: sourceJobId, started: true, queued: false };
}
const ripSuccessful = this.isRipSuccessful(sourceJob);
if (!ripSuccessful) {
const error = new Error(
@@ -9999,6 +10479,8 @@ class PipelineService extends EventEmitter {
`${decisionContext.detailText} Rip läuft ohne Vorauswahl. Finale Titelwahl erfolgt in der Mediainfo-Prüfung per Checkbox.`
);
}
const ripPlugin = await this.resolveAnalyzePlugin({ mediaProfile }).catch(() => null);
if (devicePath) {
diskDetectionService.lockDevice(devicePath, {
jobId,
@@ -10007,14 +10489,28 @@ class PipelineService extends EventEmitter {
});
}
try {
makemkvInfo = await this.runCommand({
jobId,
stage: 'RIPPING',
source: 'MAKEMKV_RIP',
cmd: ripConfig.cmd,
args: ripConfig.args,
parser: parseMakeMkvProgress
});
if (ripPlugin) {
// Plugin-Pfad: VideoDiscPlugin.rip() baut eigene ripConfig + ruft ctx.extra.runCommand()
const ripCtx = await this.buildPluginContext(ripPlugin.id, {
jobId,
rawJobDir,
deviceInfo: device,
selectedTitleId: effectiveSelectedTitleId,
backupOutputBase,
runCommand: this.runCommand.bind(this)
});
makemkvInfo = await ripPlugin.rip(job, ripCtx);
} else {
// Legacy-Pfad
makemkvInfo = await this.runCommand({
jobId,
stage: 'RIPPING',
source: 'MAKEMKV_RIP',
cmd: ripConfig.cmd,
args: ripConfig.args,
parser: parseMakeMkvProgress
});
}
} finally {
if (devicePath) {
diskDetectionService.unlockDevice(devicePath, {
@@ -10039,10 +10535,18 @@ class PipelineService extends EventEmitter {
}
const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json);
// Nach dem Plugin-Rip hat jobProgress die aktuelle pluginExecution (analyze + rip).
// Nach Legacy-Rip nehmen wir die analyze-Phase aus der DB.
const postRipPluginExecution = this.sanitizePluginExecutionState(
this.jobProgress.get(Number(jobId))?.context?.pluginExecution
?? mkInfoBeforeRip?.pluginExecution
?? null
);
await historyService.updateJob(jobId, {
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
...makemkvInfo,
analyzeContext: mkInfoBeforeRip?.analyzeContext || null
analyzeContext: mkInfoBeforeRip?.analyzeContext || null,
pluginExecution: postRipPluginExecution || null
}, mediaProfile)),
rip_successful: 1
});
@@ -10177,15 +10681,6 @@ class PipelineService extends EventEmitter {
const sourceStatus = String(sourceJob.status || '').trim().toUpperCase();
const sourceLastState = String(sourceJob.last_state || '').trim().toUpperCase();
const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
|| ['ERROR', 'CANCELLED'].includes(sourceLastState);
if (!retryable) {
const error = new Error(
`Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).`
);
error.statusCode = 409;
throw error;
}
const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json);
const sourceEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
@@ -10196,6 +10691,18 @@ class PipelineService extends EventEmitter {
const isCdRetry = mediaProfile === 'cd';
const isAudiobookRetry = mediaProfile === 'audiobook';
// CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden (z.B. importierte Jobs)
const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
|| ['ERROR', 'CANCELLED'].includes(sourceLastState)
|| (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus));
if (!retryable) {
const error = new Error(
`Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).`
);
error.statusCode = 409;
throw error;
}
let cdRetryConfig = null;
if (isCdRetry) {
const normalizeTrackPosition = (value) => {
@@ -10208,11 +10715,28 @@ class PipelineService extends EventEmitter {
const sourceTracks = Array.isArray(sourceMakemkvInfo?.tracks)
? sourceMakemkvInfo.tracks
: (Array.isArray(sourceEncodePlan?.tracks) ? sourceEncodePlan.tracks : []);
// Keine Trackdaten → Neustart ohne Vorkonfiguration (TOC wird von der CD gelesen)
if (sourceTracks.length === 0) {
const error = new Error('Retry nicht möglich: keine CD-Trackdaten im Quelljob vorhanden.');
error.statusCode = 400;
throw error;
}
cdRetryConfig = {
format: String(sourceEncodePlan?.format || 'flac').trim().toLowerCase() || 'flac',
formatOptions: sourceEncodePlan?.formatOptions && typeof sourceEncodePlan.formatOptions === 'object'
? sourceEncodePlan.formatOptions
: {},
selectedTracks: [],
tracks: [],
metadata: {
title: sourceMakemkvInfo?.selectedMetadata?.title || sourceJob.title || sourceJob.detected_title || 'Audio CD',
artist: sourceMakemkvInfo?.selectedMetadata?.artist || null,
year: sourceMakemkvInfo?.selectedMetadata?.year ?? sourceJob.year ?? null,
mbId: sourceMakemkvInfo?.selectedMetadata?.mbId || null,
coverUrl: sourceMakemkvInfo?.selectedMetadata?.coverUrl || sourceJob.poster_url || null
},
selectedPreEncodeScriptIds: [],
selectedPostEncodeScriptIds: [],
selectedPreEncodeChainIds: [],
selectedPostEncodeChainIds: []
};
} else {
const selectedTracks = normalizeCdTrackPositionList(
Array.isArray(sourceEncodePlan?.selectedTracks)
? sourceEncodePlan.selectedTracks
@@ -10252,6 +10776,7 @@ class PipelineService extends EventEmitter {
selectedPreEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.preEncodeChainIds || []),
selectedPostEncodeChainIds: normalizeChainIdList(sourceEncodePlan?.postEncodeChainIds || [])
};
} // end else (sourceTracks.length > 0)
} else if (!isAudiobookRetry) {
const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig(
@@ -11570,6 +12095,9 @@ class PipelineService extends EventEmitter {
statusText: message,
context: failContext
});
if (isCancelled) {
this._releaseCdDrive(cdDevicePath);
}
}
} else {
await this.setState(finalState, {
@@ -12483,7 +13011,7 @@ class PipelineService extends EventEmitter {
// ── CD Pipeline ─────────────────────────────────────────────────────────────
async analyzeCd(device) {
async analyzeCd(device, options = {}) {
const devicePath = String(device?.path || '').trim();
const detectedTitle = String(
device?.discLabel || device?.label || device?.model || 'Audio CD'
@@ -12499,7 +13027,8 @@ class PipelineService extends EventEmitter {
try {
const settings = await settingsService.getSettingsMap();
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
let effectiveDetectedTitle = detectedTitle;
let cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
// Read TOC
this._setCdDriveState(devicePath, {
@@ -12512,7 +13041,48 @@ class PipelineService extends EventEmitter {
context: { jobId: job.id, device, mediaProfile: 'cd' }
});
const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
let tracks = null;
let pluginExecution = null;
const analyzePlugin = options?.plugin && typeof options.plugin === 'object'
? options.plugin
: null;
if (analyzePlugin) {
const pluginContext = await this.buildPluginContext(analyzePlugin.id, {
discInfo: device,
jobId: job.id
});
try {
const pluginResult = await analyzePlugin.analyze(devicePath, job, pluginContext);
pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution());
if (Array.isArray(pluginResult?.tracks) && pluginResult.tracks.length > 0) {
tracks = pluginResult.tracks;
}
const pluginCmd = String(pluginResult?.cdparanoiaCmd || '').trim();
if (pluginCmd) {
cdparanoiaCmd = pluginCmd;
}
const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim();
if (pluginDetectedTitle) {
effectiveDetectedTitle = pluginDetectedTitle;
}
logger.info('plugin:analyze:used', {
jobId: job.id,
pluginId: analyzePlugin.id,
mediaProfile: 'cd'
});
} catch (error) {
pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution());
logger.warn('plugin:analyze:cd:fallback-legacy', {
jobId: job.id,
pluginId: analyzePlugin.id,
error: errorToMeta(error)
});
}
}
if (!Array.isArray(tracks) || tracks.length === 0) {
tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
}
logger.info('cd:analyze:toc', { jobId: job.id, trackCount: tracks.length });
if (!tracks.length) {
const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
@@ -12526,14 +13096,15 @@ class PipelineService extends EventEmitter {
preparedAt: nowIso(),
cdparanoiaCmd,
tracks,
detectedTitle
detectedTitle: effectiveDetectedTitle
};
const persistedCdInfo = this.withPluginExecutionMeta(cdInfo, pluginExecution);
await historyService.updateJob(job.id, {
status: 'CD_METADATA_SELECTION',
last_state: 'CD_METADATA_SELECTION',
detected_title: detectedTitle,
makemkv_info_json: JSON.stringify(cdInfo)
detected_title: effectiveDetectedTitle,
makemkv_info_json: JSON.stringify(persistedCdInfo)
});
await historyService.appendLog(
job.id,
@@ -12557,12 +13128,13 @@ class PipelineService extends EventEmitter {
devicePath,
cdparanoiaCmd,
cdparanoiaCommandPreview,
detectedTitle,
tracks
detectedTitle: effectiveDetectedTitle,
tracks,
...(pluginExecution ? { pluginExecution } : {})
}
});
return { jobId: job.id, detectedTitle, tracks };
return { jobId: job.id, detectedTitle: effectiveDetectedTitle, tracks };
} catch (error) {
logger.error('cd:analyze:failed', { jobId: job.id, error: errorToMeta(error) });
await this.failJob(job.id, 'CD_ANALYZING', error);
@@ -13197,7 +13769,8 @@ class PipelineService extends EventEmitter {
selectedTrackPositions,
tocTracks,
selectedMeta,
encodePlan = null
encodePlan = null,
skipRip = false
}) {
const processKey = Number(jobId);
let currentProcessHandle = null;
@@ -13307,6 +13880,7 @@ class PipelineService extends EventEmitter {
selectedTracks: selectedTrackPositions,
tracks: tocTracks,
meta: selectedMeta,
skipRip,
onProcessHandle: bindProcessHandle,
isCancelled: () => this.cancelRequestedByJob.has(processKey),
onProgress: async ({ phase, percent, trackIndex, trackTotal, trackPosition, trackEvent }) => {
@@ -13490,6 +14064,7 @@ class PipelineService extends EventEmitter {
selectedMetadata: selectedMeta
}
});
this._releaseCdDrive(devicePath);
void this.notifyPushover('job_finished', {
title: 'Ripster - CD Rip erfolgreich',