0.12.0 Begin neu Architecture
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user