Files
ripster/backend/src/plugins/CdPlugin.js
T

347 lines
11 KiB
JavaScript

'use strict';
const { SourcePlugin } = require('./PluginBase');
const cdRipService = require('../services/cdRipService');
/**
* Source-Plugin für Audio-CDs.
*
* Delegiert die gesamte Arbeit an den bereits fertig ausgelagerten
* cdRipService — dieser Plugin ist ein dünner Adapter-Wrapper.
*
* CD-Besonderheit: Rip und Encode sind bei CDs Track-für-Track
* verzahnt (rip track → encode track → nächster Track).
* Deshalb ist encode() ein No-Op; die gesamte Arbeit liegt in rip().
*
* Erwartete ctx.extra-Felder für rip():
* ripConfig - Das ripConfig-Objekt aus dem API-Request (format, formatOptions,
* selectedTracks, tracks, metadata, ...)
* rawWavDir - Absoluter Pfad zum WAV-Temp-/RAW-Ordner
* outputDir - Absoluter Pfad zum finalen Ausgabeordner
* outputTemplate - CD-Output-Template String
* isCancelled - () => boolean — Abbruchsignal vom Orchestrator
* onProcessHandle - Callback mit dem Prozess-Handle (für Abbruch)
*/
class CdPlugin extends SourcePlugin {
get id() {
return 'cd';
}
get name() {
return 'Audio CD';
}
get priority() {
return 10;
}
/**
* Erkennt Audio-CDs anhand des mediaProfile-Feldes, das vom
* DiskDetectionService / diskDetectionService.inferMediaProfile() gesetzt wird.
*
* Akzeptierte Werte: 'cd', 'audio_cd'
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
return profile === 'cd' || profile === 'audio_cd' || fstype === 'audio_cd';
}
/**
* Liest das TOC der CD mit cdparanoia -Q und gibt die Trackliste zurück.
*
* @param {string} devicePath - z.B. '/dev/sr0'
* @param {object} job - Job-Record (id wird für Logging genutzt)
* @param {PluginContext} ctx
* @returns {Promise<{tracks: Array, cdparanoiaCmd: string, detectedTitle: string}>}
*/
async analyze(devicePath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
const settings = await ctx.settings.getSettingsMap();
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
const detectedTitle = String(
ctx.extra?.discInfo?.discLabel || ctx.extra?.discInfo?.label || ctx.extra?.discInfo?.model || 'Audio CD'
).trim();
ctx.logger.info('cd:analyze:start', { devicePath, jobId: job?.id, detectedTitle });
const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
if (!tracks.length) {
const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
error.statusCode = 400;
throw error;
}
ctx.logger.info('cd:analyze:done', { devicePath, jobId: job?.id, trackCount: tracks.length });
return {
tracks,
cdparanoiaCmd,
detectedTitle
};
}
/**
* Rippt und encodiert alle ausgewählten Tracks der CD.
*
* Bei CDs sind Rip und Encode Track-für-Track verzahnt — alles
* läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf.
*
* Pflicht-Felder in ctx.extra:
* ripConfig - { format, formatOptions, selectedTracks, tracks, metadata, skipRip, skipEncode }
* rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner)
* outputDir - Finaler Ausgabeordner (optional bei skipEncode=true)
* outputTemplate - Template-String für Dateinamen
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<{outputDir, format, trackCount, encodeResults}>}
*/
async rip(job, ctx) {
ctx.markExecution('rip', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
const {
ripConfig = {},
rawWavDir,
outputDir,
outputTemplate,
isCancelled,
onProcessHandle,
onProgress: onRawProgress,
onLog: onExternalLog
} = ctx.extra || {};
if (!rawWavDir) {
throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt');
}
const skipRip = Boolean(ripConfig?.skipRip);
const skipEncode = Boolean(ripConfig?.skipEncode);
if (!skipEncode && !outputDir) {
throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt');
}
const cdInfo = _safeParseJson(job?.makemkv_info_json) || {};
const settings = await ctx.settings.getSettingsMap();
const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
const devicePath = String(job?.disc_device || '').trim();
if (!devicePath && !skipRip) {
throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).');
}
const format = String(ripConfig.format || 'flac').trim().toLowerCase();
const formatOptions = ripConfig.formatOptions || {};
const effectiveTemplate = outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
// Trackliste: TOC-Tracks aus DB, optional mit Metadaten aus ripConfig überschrieben
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
const incomingTracks = Array.isArray(ripConfig.tracks) ? ripConfig.tracks : [];
const incomingByPos = new Map(
incomingTracks
.filter((t) => Number.isFinite(Number(t?.position)) && Number(t.position) > 0)
.map((t) => [Math.trunc(Number(t.position)), t])
);
const mergedTracks = tocTracks
.map((track) => {
const pos = Math.trunc(Number(track?.position));
if (!Number.isFinite(pos) || pos <= 0) {
return null;
}
const incoming = incomingByPos.get(pos) || null;
return {
...track,
position: pos,
title: incoming?.title || track.title || `Track ${pos}`,
artist: incoming?.artist || track.artist || null,
selected: incoming ? Boolean(incoming.selected) : (track.selected !== false)
};
})
.filter(Boolean);
const selectedMeta = cdInfo.selectedMetadata || {};
const incomingMeta = (ripConfig.metadata && typeof ripConfig.metadata === 'object')
? ripConfig.metadata
: {};
const meta = {
title: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD',
artist: _normText(incomingMeta.artist) || _normText(selectedMeta.artist) || null,
year: _normYear(incomingMeta.year) ?? _normYear(selectedMeta.year) ?? _normYear(job?.year) ?? null,
album: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD'
};
const rawSelectedPositions = Array.isArray(ripConfig.selectedTracks)
? ripConfig.selectedTracks
.map((v) => Math.trunc(Number(v)))
.filter((v) => Number.isFinite(v) && v > 0)
: [];
const selectedTrackPositions = rawSelectedPositions.length > 0
? rawSelectedPositions
: mergedTracks.filter((t) => t.selected !== false).map((t) => t.position);
ctx.logger.info('cd:rip:start', {
jobId: job?.id,
devicePath: devicePath || null,
skipRip,
skipEncode,
format,
trackCount: selectedTrackPositions.length
});
const result = await cdRipService.ripAndEncode({
jobId: job?.id,
devicePath: devicePath || null,
cdparanoiaCmd,
rawWavDir,
outputDir,
format,
formatOptions,
selectedTracks: selectedTrackPositions,
tracks: mergedTracks,
meta,
outputTemplate: effectiveTemplate,
skipRip,
skipEncode,
onProgress: (progressEvent) => {
if (typeof onRawProgress === 'function') {
onRawProgress(progressEvent);
}
const pct = Number(progressEvent?.percent ?? 0);
const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe';
const trackIdx = progressEvent?.trackIndex || 1;
const trackTotal = progressEvent?.trackTotal || 1;
ctx.emitProgress(
Math.round(pct),
`${phase} Track ${trackIdx}/${trackTotal}`
);
},
onLog: (level, msg) => {
if (ctx.logger[level]) {
ctx.logger[level](msg, { jobId: job?.id });
}
if (typeof onExternalLog === 'function') {
onExternalLog(level, msg);
}
},
onProcessHandle,
isCancelled,
context: { jobId: job?.id, source: 'CdPlugin' }
});
ctx.logger.info('cd:rip:done', {
jobId: job?.id,
format,
trackCount: result.trackCount,
outputDir: result.outputDir
});
// Ergebnis in ctx.extra ablegen, damit der Orchestrator darauf zugreifen kann
ctx.extra.ripResult = result;
return result;
}
/**
* No-Op: Bei CDs ist der Encode-Schritt bereits in rip() integriert.
*/
async encode(_job, _ctx) {
_ctx.markExecution('encode', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
// CD Rip und Encode sind in rip() verzahnt — nichts zu tun
}
/**
* CDs brauchen keine Encode-Review (kein HandBrake).
*/
async review(_job, _ctx) {
_ctx.markExecution('review', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
return null;
}
/**
* Plugin-spezifische Settings für Audio CDs.
*/
getSettingsSchema() {
return [
{
key: 'cdparanoia_command',
category: 'Tools',
label: 'cdparanoia Kommando',
type: 'string',
required: 1,
description: 'Pfad oder Befehl für cdparanoia. Wird für Audio-CD-Ripping genutzt.',
default_value: 'cdparanoia',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 240
},
{
key: 'cd_output_template',
category: 'Pfade',
label: 'CD Output Template',
type: 'string',
required: 1,
description: `Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {trackNr}, {title}. Unterordner über "/".`,
default_value: cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE,
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 730
}
];
}
}
// ── Hilfsfunktionen (privat, nicht exportiert) ────────────────────────────────
function _safeParseJson(value) {
try {
return value ? JSON.parse(value) : null;
} catch {
return null;
}
}
function _normText(value) {
const s = String(value == null ? '' : value)
.normalize('NFC')
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return s || null;
}
function _normYear(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
module.exports = { CdPlugin };