0.12.0-2 Checkable Infrastructure
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-1",
|
"version": "0.12.0-2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-1",
|
"version": "0.12.0-2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-1",
|
"version": "0.12.0-2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1003,7 +1003,7 @@ async function migrateSettingsSchemaMetadata(db) {
|
|||||||
// ✅ = plugin-pfad aktiv ⏳ = noch nicht implementiert (readonly, immer aus)
|
// ✅ = plugin-pfad aktiv ⏳ = noch nicht implementiert (readonly, immer aus)
|
||||||
//
|
//
|
||||||
// BluRay / DVD: Analyse ✅ Rip ✅ Review ⏳ Encode ⏳
|
// BluRay / DVD: Analyse ✅ Rip ✅ Review ⏳ Encode ⏳
|
||||||
// CD: Analyse ✅ Rip ⏳
|
// CD: Analyse ✅ Rip ✅
|
||||||
// Audiobook: Analyse ✅ Encode ⏳
|
// Audiobook: Analyse ✅ Encode ⏳
|
||||||
|
|
||||||
const pluginArchitectureSettings = [
|
const pluginArchitectureSettings = [
|
||||||
@@ -1130,12 +1130,12 @@ async function migrateSettingsSchemaMetadata(db) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'use_plugin_architecture_cd_rip',
|
key: 'use_plugin_architecture_cd_rip',
|
||||||
label: 'Rip + Encode (Audio-CD)',
|
label: 'Rip (Audio-CD)',
|
||||||
description: 'Noch nicht implementiert — cdparanoia-Rip läuft noch über Legacy.',
|
description: 'CD-Rip/RAW-Rip/Encode-aus-RAW läuft über das Plugin.',
|
||||||
defaultValue: 'false',
|
defaultValue: 'true',
|
||||||
orderIndex: 10022,
|
orderIndex: 10022,
|
||||||
dependsOn: 'use_plugin_architecture_cd',
|
dependsOn: 'use_plugin_architecture_cd',
|
||||||
validationJson: '{"readonly":true}'
|
validationJson: '{}'
|
||||||
},
|
},
|
||||||
// ── Audiobook ────────────────────────────────────────────────────────────
|
// ── Audiobook ────────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
@@ -1178,6 +1178,13 @@ async function migrateSettingsSchemaMetadata(db) {
|
|||||||
WHERE key = ? AND (depends_on IS NOT ? OR order_index != ? OR validation_json != ?)`,
|
WHERE key = ? AND (depends_on IS NOT ? OR order_index != ? OR validation_json != ?)`,
|
||||||
[setting.dependsOn ?? null, setting.orderIndex, setting.validationJson, setting.key, setting.dependsOn ?? null, setting.orderIndex, setting.validationJson]
|
[setting.dependsOn ?? null, setting.orderIndex, setting.validationJson, setting.key, setting.dependsOn ?? null, setting.orderIndex, setting.validationJson]
|
||||||
);
|
);
|
||||||
|
await db.run(
|
||||||
|
`UPDATE settings_schema
|
||||||
|
SET label = ?, description = ?, default_value = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = ?
|
||||||
|
AND (label != ? OR description != ? OR COALESCE(default_value, '') != COALESCE(?, ''))`,
|
||||||
|
[setting.label, setting.description, setting.defaultValue, setting.key, setting.label, setting.description, setting.defaultValue]
|
||||||
|
);
|
||||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES (?, ?)`, [setting.key, setting.defaultValue]);
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES (?, ?)`, [setting.key, setting.defaultValue]);
|
||||||
// Readonly-Toggles immer auf 'false' zurücksetzen (nie aktivierbar)
|
// Readonly-Toggles immer auf 'false' zurücksetzen (nie aktivierbar)
|
||||||
if (setting.validationJson === '{"readonly":true}') {
|
if (setting.validationJson === '{"readonly":true}') {
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ class CdPlugin extends SourcePlugin {
|
|||||||
* läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf.
|
* läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf.
|
||||||
*
|
*
|
||||||
* Pflicht-Felder in ctx.extra:
|
* Pflicht-Felder in ctx.extra:
|
||||||
* ripConfig - { format, formatOptions, selectedTracks, tracks, metadata }
|
* ripConfig - { format, formatOptions, selectedTracks, tracks, metadata, skipRip, skipEncode }
|
||||||
* rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner)
|
* rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner)
|
||||||
* outputDir - Finaler Ausgabeordner
|
* outputDir - Finaler Ausgabeordner (optional bei skipEncode=true)
|
||||||
* outputTemplate - Template-String für Dateinamen
|
* outputTemplate - Template-String für Dateinamen
|
||||||
* isCancelled - () => boolean
|
* isCancelled - () => boolean
|
||||||
* onProcessHandle - (handle) => void [optional]
|
* onProcessHandle - (handle) => void [optional]
|
||||||
@@ -118,13 +118,18 @@ class CdPlugin extends SourcePlugin {
|
|||||||
outputDir,
|
outputDir,
|
||||||
outputTemplate,
|
outputTemplate,
|
||||||
isCancelled,
|
isCancelled,
|
||||||
onProcessHandle
|
onProcessHandle,
|
||||||
|
onProgress: onRawProgress,
|
||||||
|
onLog: onExternalLog
|
||||||
} = ctx.extra || {};
|
} = ctx.extra || {};
|
||||||
|
|
||||||
if (!rawWavDir) {
|
if (!rawWavDir) {
|
||||||
throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt');
|
throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt');
|
||||||
}
|
}
|
||||||
if (!outputDir) {
|
const skipRip = Boolean(ripConfig?.skipRip);
|
||||||
|
const skipEncode = Boolean(ripConfig?.skipEncode);
|
||||||
|
|
||||||
|
if (!skipEncode && !outputDir) {
|
||||||
throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt');
|
throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,8 +137,7 @@ class CdPlugin extends SourcePlugin {
|
|||||||
const settings = await ctx.settings.getSettingsMap();
|
const settings = await ctx.settings.getSettingsMap();
|
||||||
const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
|
||||||
const devicePath = String(job?.disc_device || '').trim();
|
const devicePath = String(job?.disc_device || '').trim();
|
||||||
|
if (!devicePath && !skipRip) {
|
||||||
if (!devicePath) {
|
|
||||||
throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).');
|
throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,14 +193,16 @@ class CdPlugin extends SourcePlugin {
|
|||||||
|
|
||||||
ctx.logger.info('cd:rip:start', {
|
ctx.logger.info('cd:rip:start', {
|
||||||
jobId: job?.id,
|
jobId: job?.id,
|
||||||
devicePath,
|
devicePath: devicePath || null,
|
||||||
|
skipRip,
|
||||||
|
skipEncode,
|
||||||
format,
|
format,
|
||||||
trackCount: selectedTrackPositions.length
|
trackCount: selectedTrackPositions.length
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await cdRipService.ripAndEncode({
|
const result = await cdRipService.ripAndEncode({
|
||||||
jobId: job?.id,
|
jobId: job?.id,
|
||||||
devicePath,
|
devicePath: devicePath || null,
|
||||||
cdparanoiaCmd,
|
cdparanoiaCmd,
|
||||||
rawWavDir,
|
rawWavDir,
|
||||||
outputDir,
|
outputDir,
|
||||||
@@ -206,7 +212,12 @@ class CdPlugin extends SourcePlugin {
|
|||||||
tracks: mergedTracks,
|
tracks: mergedTracks,
|
||||||
meta,
|
meta,
|
||||||
outputTemplate: effectiveTemplate,
|
outputTemplate: effectiveTemplate,
|
||||||
|
skipRip,
|
||||||
|
skipEncode,
|
||||||
onProgress: (progressEvent) => {
|
onProgress: (progressEvent) => {
|
||||||
|
if (typeof onRawProgress === 'function') {
|
||||||
|
onRawProgress(progressEvent);
|
||||||
|
}
|
||||||
const pct = Number(progressEvent?.percent ?? 0);
|
const pct = Number(progressEvent?.percent ?? 0);
|
||||||
const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe';
|
const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe';
|
||||||
const trackIdx = progressEvent?.trackIndex || 1;
|
const trackIdx = progressEvent?.trackIndex || 1;
|
||||||
@@ -220,6 +231,9 @@ class CdPlugin extends SourcePlugin {
|
|||||||
if (ctx.logger[level]) {
|
if (ctx.logger[level]) {
|
||||||
ctx.logger[level](msg, { jobId: job?.id });
|
ctx.logger[level](msg, { jobId: job?.id });
|
||||||
}
|
}
|
||||||
|
if (typeof onExternalLog === 'function') {
|
||||||
|
onExternalLog(level, msg);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onProcessHandle,
|
onProcessHandle,
|
||||||
isCancelled,
|
isCancelled,
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ router.post(
|
|||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const target = String(req.body?.target || 'none');
|
const target = String(req.body?.target || 'none');
|
||||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||||
|
const resetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
|
||||||
|
const keepDetectedDevice = req.body?.keepDetectedDevice !== undefined
|
||||||
|
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
|
||||||
|
: !resetDriveState;
|
||||||
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
||||||
? req.body.selectedMoviePaths
|
? req.body.selectedMoviePaths
|
||||||
.map((item) => String(item || '').trim())
|
.map((item) => String(item || '').trim())
|
||||||
@@ -159,11 +163,29 @@ router.post(
|
|||||||
id,
|
id,
|
||||||
target,
|
target,
|
||||||
includeRelated,
|
includeRelated,
|
||||||
|
resetDriveState,
|
||||||
|
keepDetectedDevice,
|
||||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const preview = resetDriveState
|
||||||
|
? await historyService.getJobDeletePreview(id, { includeRelated })
|
||||||
|
: null;
|
||||||
|
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
|
||||||
|
? preview.relatedJobs
|
||||||
|
.map((row) => String(row?.discDevice || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths });
|
const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths });
|
||||||
const uiReset = await pipelineService.resetFrontendState('history_delete');
|
await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], {
|
||||||
|
resetDriveState,
|
||||||
|
devicePaths: relatedDevicePaths
|
||||||
|
}).catch((error) => {
|
||||||
|
logger.warn('post:delete-job:cleanup-failed', { id, error: error?.message || String(error) });
|
||||||
|
});
|
||||||
|
const uiReset = await pipelineService.resetFrontendState('history_delete', {
|
||||||
|
keepDetectedDevice
|
||||||
|
});
|
||||||
res.json({ ...result, uiReset });
|
res.json({ ...result, uiReset });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artis
|
|||||||
function parseToc(tocOutput) {
|
function parseToc(tocOutput) {
|
||||||
const lines = String(tocOutput || '').split(/\r?\n/);
|
const lines = String(tocOutput || '').split(/\r?\n/);
|
||||||
const tracks = [];
|
const tracks = [];
|
||||||
|
const tocTimePattern = /[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/g;
|
||||||
|
const tocTablePattern =
|
||||||
|
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/i;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
|
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
|
||||||
if (trackMatch) {
|
if (trackMatch) {
|
||||||
const position = Number(trackMatch[1]);
|
const position = Number(trackMatch[1]);
|
||||||
const payloadWithoutTimes = String(trackMatch[2] || '')
|
const payloadWithoutTimes = String(trackMatch[2] || '')
|
||||||
.replace(/[\(\[]\s*\d+:\d+\.\d+\s*[\)\]]/g, ' ');
|
.replace(tocTimePattern, ' ');
|
||||||
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
|
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
|
||||||
if (sectorValues.length < 2) {
|
if (sectorValues.length < 2) {
|
||||||
continue;
|
continue;
|
||||||
@@ -58,9 +61,7 @@ function parseToc(tocOutput) {
|
|||||||
// Alternative cdparanoia -Q table style:
|
// Alternative cdparanoia -Q table style:
|
||||||
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
|
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
|
||||||
// ^ length sectors ^ start sector
|
// ^ length sectors ^ start sector
|
||||||
const tableMatch = line.match(
|
const tableMatch = line.match(tocTablePattern);
|
||||||
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]\s+(\d+)\s+[\(\[]\d+:\d+\.\d+[\)\]]/i
|
|
||||||
);
|
|
||||||
if (!tableMatch) {
|
if (!tableMatch) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -395,6 +396,7 @@ async function runProcessTracked({
|
|||||||
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
|
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
|
||||||
* @param {object} options.meta - album metadata {title, artist, year}
|
* @param {object} options.meta - album metadata {title, artist, year}
|
||||||
* @param {string} options.outputTemplate - template for relative output path without extension
|
* @param {string} options.outputTemplate - template for relative output path without extension
|
||||||
|
* @param {boolean} options.skipEncode - true => rip only (no encode)
|
||||||
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
|
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
|
||||||
* @param {Function} options.onLog - (level, msg) => void
|
* @param {Function} options.onLog - (level, msg) => void
|
||||||
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
|
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
|
||||||
@@ -419,10 +421,11 @@ async function ripAndEncode(options) {
|
|||||||
onProcessHandle,
|
onProcessHandle,
|
||||||
isCancelled,
|
isCancelled,
|
||||||
context,
|
context,
|
||||||
skipRip = false
|
skipRip = false,
|
||||||
|
skipEncode = false
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (!SUPPORTED_FORMATS.has(format)) {
|
if (!skipEncode && !SUPPORTED_FORMATS.has(format)) {
|
||||||
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
|
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +438,9 @@ async function ripAndEncode(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ensureDir(rawWavDir);
|
await ensureDir(rawWavDir);
|
||||||
await ensureDir(outputDir);
|
if (!skipEncode) {
|
||||||
|
await ensureDir(outputDir);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('rip:start', {
|
logger.info('rip:start', {
|
||||||
jobId,
|
jobId,
|
||||||
@@ -448,6 +453,9 @@ async function ripAndEncode(options) {
|
|||||||
logger[level] && logger[level](msg, { jobId });
|
logger[level] && logger[level](msg, { jobId });
|
||||||
onLog && onLog(level, msg);
|
onLog && onLog(level, msg);
|
||||||
};
|
};
|
||||||
|
const ripPercentSpan = skipEncode ? 100 : 50;
|
||||||
|
const encodePercentStart = ripPercentSpan;
|
||||||
|
const encodePercentSpan = Math.max(0, 100 - encodePercentStart);
|
||||||
|
|
||||||
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
|
||||||
if (skipRip) {
|
if (skipRip) {
|
||||||
@@ -472,7 +480,7 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 0,
|
trackPercent: 0,
|
||||||
percent: (i / tracksToRip.length) * 50
|
percent: (i / tracksToRip.length) * ripPercentSpan
|
||||||
});
|
});
|
||||||
|
|
||||||
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
log('info', `Rippe Track ${track.position} von ${tracksToRip.length} …`);
|
||||||
@@ -486,7 +494,7 @@ async function ripAndEncode(options) {
|
|||||||
onStderrLine(line) {
|
onStderrLine(line) {
|
||||||
const parsed = parseCdParanoiaProgress(line);
|
const parsed = parseCdParanoiaProgress(line);
|
||||||
if (parsed && parsed.percent !== null) {
|
if (parsed && parsed.percent !== null) {
|
||||||
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * 50;
|
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * ripPercentSpan;
|
||||||
onProgress && onProgress({
|
onProgress && onProgress({
|
||||||
phase: 'rip',
|
phase: 'rip',
|
||||||
trackEvent: 'progress',
|
trackEvent: 'progress',
|
||||||
@@ -518,13 +526,22 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 100,
|
trackPercent: 100,
|
||||||
percent: ((i + 1) / tracksToRip.length) * 50
|
percent: ((i + 1) / tracksToRip.length) * ripPercentSpan
|
||||||
});
|
});
|
||||||
|
|
||||||
log('info', `Track ${track.position} gerippt.`);
|
log('info', `Track ${track.position} gerippt.`);
|
||||||
}
|
}
|
||||||
} // end if (!skipRip)
|
} // end if (!skipRip)
|
||||||
|
|
||||||
|
if (skipEncode) {
|
||||||
|
return {
|
||||||
|
outputDir: null,
|
||||||
|
format: 'raw',
|
||||||
|
trackCount: tracksToRip.length,
|
||||||
|
encodeResults: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||||
const encodeResults = [];
|
const encodeResults = [];
|
||||||
|
|
||||||
@@ -542,7 +559,7 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 0,
|
trackPercent: 0,
|
||||||
percent: 50 + ((i / tracksToRip.length) * 50)
|
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||||||
});
|
});
|
||||||
ensureDir(path.dirname(outFile));
|
ensureDir(path.dirname(outFile));
|
||||||
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
||||||
@@ -554,7 +571,7 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 100,
|
trackPercent: 100,
|
||||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||||||
});
|
});
|
||||||
log('info', `WAV für Track ${track.position} gespeichert.`);
|
log('info', `WAV für Track ${track.position} gespeichert.`);
|
||||||
encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
|
encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
|
||||||
@@ -581,7 +598,7 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 0,
|
trackPercent: 0,
|
||||||
percent: 50 + ((i / tracksToRip.length) * 50)
|
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
|
||||||
});
|
});
|
||||||
|
|
||||||
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
log('info', `Encodiere Track ${track.position} → ${outFilename} …`);
|
||||||
@@ -628,7 +645,7 @@ async function ripAndEncode(options) {
|
|||||||
trackTotal: tracksToRip.length,
|
trackTotal: tracksToRip.length,
|
||||||
trackPosition: track.position,
|
trackPosition: track.position,
|
||||||
trackPercent: 100,
|
trackPercent: 100,
|
||||||
percent: 50 + ((i + 1) / tracksToRip.length) * 50
|
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
|
||||||
});
|
});
|
||||||
|
|
||||||
log('info', `Track ${track.position} encodiert.`);
|
log('info', `Track ${track.position} encodiert.`);
|
||||||
|
|||||||
@@ -159,6 +159,11 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isIsoLikeFsType(rawFsType) {
|
||||||
|
const fstype = String(rawFsType || '').trim().toLowerCase();
|
||||||
|
return fstype.includes('iso9660') || fstype.includes('cdfs');
|
||||||
|
}
|
||||||
|
|
||||||
function inferMediaProfileFromUdevProperties(properties = {}) {
|
function inferMediaProfileFromUdevProperties(properties = {}) {
|
||||||
const flags = Object.entries(properties).reduce((acc, [key, rawValue]) => {
|
const flags = Object.entries(properties).reduce((acc, [key, rawValue]) => {
|
||||||
const normalizedKey = String(key || '').trim().toUpperCase();
|
const normalizedKey = String(key || '').trim().toUpperCase();
|
||||||
@@ -535,6 +540,19 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return { present: false, emitted: 'none', device: null };
|
return { present: false, emitted: 'none', device: null };
|
||||||
}
|
}
|
||||||
|
if (this.isDeviceLocked(normalized)) {
|
||||||
|
const existing = this.detectedDiscs.get(normalized) || null;
|
||||||
|
logger.info('rescan-drive:skip-locked', {
|
||||||
|
devicePath: normalized,
|
||||||
|
activeLocks: this.getActiveLocks()
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
present: Boolean(existing),
|
||||||
|
emitted: 'none',
|
||||||
|
device: existing,
|
||||||
|
locked: true
|
||||||
|
};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
logger.info('rescan-drive:requested', { devicePath: normalized });
|
logger.info('rescan-drive:requested', { devicePath: normalized });
|
||||||
const detected = await this.detectExplicit(normalized);
|
const detected = await this.detectExplicit(normalized);
|
||||||
@@ -598,6 +616,16 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve currently tracked locked devices that are intentionally skipped
|
||||||
|
// by detectAll* while a rip lock is active.
|
||||||
|
for (const [devicePath, device] of this.detectedDiscs) {
|
||||||
|
if (newMap.has(devicePath) || !this.isDeviceLocked(devicePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newMap.set(devicePath, device);
|
||||||
|
results.push({ path: devicePath, emitted: 'none', device, locked: true });
|
||||||
|
}
|
||||||
|
|
||||||
// Check for removed devices
|
// Check for removed devices
|
||||||
for (const [devicePath, device] of this.detectedDiscs) {
|
for (const [devicePath, device] of this.detectedDiscs) {
|
||||||
if (!newMap.has(devicePath)) {
|
if (!newMap.has(devicePath)) {
|
||||||
@@ -652,9 +680,15 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
if (!mediaState.hasMedia && hasSizeMedia) {
|
if (!mediaState.hasMedia && hasSizeMedia) {
|
||||||
logger.debug('detect:explicit:media-by-size-fallback', { devicePath, sizeBytes: match.sizeBytes });
|
logger.debug('detect:explicit:media-by-size-fallback', { devicePath, sizeBytes: match.sizeBytes });
|
||||||
}
|
}
|
||||||
const mediaType = mediaState.type;
|
const mediaType = String(mediaState.type || '').trim().toLowerCase() || null;
|
||||||
const discLabel = await this.getDiscLabel(devicePath);
|
const discLabel = await this.getDiscLabel(devicePath);
|
||||||
const detectedFsType = String(match.fstype || mediaType || '').trim() || null;
|
// Preserve explicit audio-CD detection from checkMediaPresent even if lsblk
|
||||||
|
// reports ambiguous optical fs markers like iso9660/cdfs.
|
||||||
|
const detectedFsType = String(
|
||||||
|
mediaType === 'audio_cd'
|
||||||
|
? mediaType
|
||||||
|
: (match.fstype || mediaType || '')
|
||||||
|
).trim() || null;
|
||||||
|
|
||||||
const mediaProfile = await this.inferMediaProfile(devicePath, {
|
const mediaProfile = await this.inferMediaProfile(devicePath, {
|
||||||
discLabel,
|
discLabel,
|
||||||
@@ -702,8 +736,13 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
if (!mediaState.hasMedia) {
|
if (!mediaState.hasMedia) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const mediaType = String(mediaState.type || '').trim().toLowerCase() || null;
|
||||||
const discLabel = await this.getDiscLabel(path);
|
const discLabel = await this.getDiscLabel(path);
|
||||||
const detectedFsType = String(item.fstype || mediaState.type || '').trim() || null;
|
const detectedFsType = String(
|
||||||
|
mediaType === 'audio_cd'
|
||||||
|
? mediaType
|
||||||
|
: (item.fstype || mediaType || '')
|
||||||
|
).trim() || null;
|
||||||
|
|
||||||
const mediaProfile = await this.inferMediaProfile(path, {
|
const mediaProfile = await this.inferMediaProfile(path, {
|
||||||
discLabel,
|
discLabel,
|
||||||
@@ -764,9 +803,13 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
if (!mediaState.hasMedia && hasSizeMedia) {
|
if (!mediaState.hasMedia && hasSizeMedia) {
|
||||||
logger.debug('detect:all-auto:media-by-size-fallback', { path, sizeBytes: item.sizeBytes });
|
logger.debug('detect:all-auto:media-by-size-fallback', { path, sizeBytes: item.sizeBytes });
|
||||||
}
|
}
|
||||||
const mediaType = mediaState.type;
|
const mediaType = String(mediaState.type || '').trim().toLowerCase() || null;
|
||||||
const discLabel = await this.getDiscLabel(path);
|
const discLabel = await this.getDiscLabel(path);
|
||||||
const detectedFsType = String(item.fstype || mediaType || '').trim() || null;
|
const detectedFsType = String(
|
||||||
|
mediaType === 'audio_cd'
|
||||||
|
? mediaType
|
||||||
|
: (item.fstype || mediaType || '')
|
||||||
|
).trim() || null;
|
||||||
|
|
||||||
const mediaProfile = await this.inferMediaProfile(path, {
|
const mediaProfile = await this.inferMediaProfile(path, {
|
||||||
discLabel,
|
discLabel,
|
||||||
@@ -871,6 +914,8 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
return { hasMedia: true, type: blkidType };
|
return { hasMedia: true, type: blkidType };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasOpticalMediaHintFromUdev = false;
|
||||||
|
|
||||||
// blkid found nothing – audio CDs have no filesystem, so fall back to udevadm
|
// blkid found nothing – audio CDs have no filesystem, so fall back to udevadm
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync('udevadm', [
|
const { stdout } = await execFileAsync('udevadm', [
|
||||||
@@ -902,7 +947,10 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
if (inferredByUdev === 'bluray' || inferredByUdev === 'dvd') {
|
if (inferredByUdev === 'bluray' || inferredByUdev === 'dvd') {
|
||||||
logger.debug('udevadm:optical-media', { devicePath, inferredByUdev });
|
logger.debug('udevadm:optical-media', { devicePath, inferredByUdev });
|
||||||
return { hasMedia: true, type: null };
|
// Keep this as a presence hint, but still probe cdparanoia. Some drives
|
||||||
|
// expose mixed DVD/CD flags for audio CDs and would otherwise be
|
||||||
|
// downgraded to "other" before TOC probing.
|
||||||
|
hasOpticalMediaHintFromUdev = true;
|
||||||
}
|
}
|
||||||
} catch (_udevError) {
|
} catch (_udevError) {
|
||||||
// udevadm not available or failed – ignore
|
// udevadm not available or failed – ignore
|
||||||
@@ -919,6 +967,10 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
return { hasMedia: true, type: 'audio_cd' };
|
return { hasMedia: true, type: 'audio_cd' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasOpticalMediaHintFromUdev) {
|
||||||
|
return { hasMedia: true, type: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Final fallback: check block device size via sysfs.
|
// Final fallback: check block device size via sysfs.
|
||||||
// In VM/passthrough environments udev metadata may be absent even though
|
// In VM/passthrough environments udev metadata may be absent even though
|
||||||
// the kernel reports a valid disc size (visible in lsblk). A non-zero
|
// the kernel reports a valid disc size (visible in lsblk). A non-zero
|
||||||
@@ -1046,7 +1098,12 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
// Also guard: when hintFstype is empty (no filesystem info at all), the drive model
|
// Also guard: when hintFstype is empty (no filesystem info at all), the drive model
|
||||||
// alone is not a reliable disc-type indicator — a BD-RE drive can contain a DVD.
|
// alone is not a reliable disc-type indicator — a BD-RE drive can contain a DVD.
|
||||||
// In that case skip this early return and let blkid -p determine the actual disc type.
|
// In that case skip this early return and let blkid -p determine the actual disc type.
|
||||||
if (hintFstype && byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) {
|
if (
|
||||||
|
hintFstype
|
||||||
|
&& byFsTypeHint
|
||||||
|
&& !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')
|
||||||
|
&& !(isIsoLikeFsType(hintFstype) && byFsTypeHint === 'dvd')
|
||||||
|
) {
|
||||||
return byFsTypeHint;
|
return byFsTypeHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1090,14 +1147,14 @@ class DiskDetectionService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model);
|
const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model);
|
||||||
if (byBlkidFsType) {
|
if (byBlkidFsType && !(isIsoLikeFsType(type) && byBlkidFsType === 'dvd')) {
|
||||||
return byBlkidFsType;
|
return byBlkidFsType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort for drives that only expose TYPE=udf without VERSION/APPLICATION_ID:
|
// Last resort for drives that only expose TYPE=udf without VERSION/APPLICATION_ID:
|
||||||
// prefer DVD over "other" so DVDs in BD-capable drives do not fall back to Misc.
|
// prefer DVD over "other" so DVDs in BD-capable drives do not fall back to Misc.
|
||||||
const byBlkidFsTypeWithoutModel = inferMediaProfileFromFsTypeAndModel(type, null);
|
const byBlkidFsTypeWithoutModel = inferMediaProfileFromFsTypeAndModel(type, null);
|
||||||
if (byBlkidFsTypeWithoutModel) {
|
if (byBlkidFsTypeWithoutModel && !(isIsoLikeFsType(type) && byBlkidFsTypeWithoutModel === 'dvd')) {
|
||||||
return byBlkidFsTypeWithoutModel;
|
return byBlkidFsTypeWithoutModel;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4114,6 +4114,7 @@ class HistoryService {
|
|||||||
parentJobId: normalizeJobIdValue(job.parent_job_id),
|
parentJobId: normalizeJobIdValue(job.parent_job_id),
|
||||||
title: buildJobDisplayTitle(job),
|
title: buildJobDisplayTitle(job),
|
||||||
status: String(job.status || '').trim() || null,
|
status: String(job.status || '').trim() || null,
|
||||||
|
discDevice: String(job.disc_device || '').trim() || null,
|
||||||
isPrimary: Number(job.id) === normalizedJobId,
|
isPrimary: Number(job.id) === normalizedJobId,
|
||||||
createdAt: String(job.created_at || '').trim() || null
|
createdAt: String(job.created_at || '').trim() || null
|
||||||
}));
|
}));
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+58
-10
@@ -478,26 +478,74 @@ INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, des
|
|||||||
VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495);
|
VALUES ('ui_expert_mode', 'Benachrichtigungen', 'Expertenmodus', 'boolean', 1, 'Schaltet erweiterte Einstellungen in der UI ein.', 'false', '[]', '{}', 495);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ui_expert_mode', 'false');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ui_expert_mode', 'false');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
VALUES ('use_plugin_architecture', 'Erweitert', 'Neue Plugin-Architektur (Beta)', 'boolean', 1, 'Aktiviert die neue modulare Plugin-Architektur (Phase 2). Legacy-Code bleibt aktiv solange dieser Toggle deaktiviert ist. Nur für Testzwecke aktivieren.', 'false', '[]', '{}', 9999);
|
VALUES ('use_plugin_architecture', 'Erweitert', 'Neue Plugin-Architektur (Beta)', 'boolean', 1, 'Masterschalter: Aktiviert die neue modulare Plugin-Architektur (Phase 2). Alle Medium-Schalter darunter sind nur wirksam wenn dieser aktiv ist.', 'false', '[]', '{}', 9999, NULL);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture', 'false');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture', 'false');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
VALUES ('use_plugin_architecture_bluray', 'Erweitert', 'Beta: Blu-ray Plugin aktiv', 'boolean', 1, 'Aktiviert das Blu-ray Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10000);
|
VALUES ('use_plugin_architecture_bluray', 'Erweitert', 'Blu-ray Plugin', 'boolean', 1, 'Aktiviert das Blu-ray Plugin. Deaktiviert = Legacy-Pfad für alle Blu-ray-Phasen.', 'true', '[]', '{}', 10000, 'use_plugin_architecture');
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray', 'true');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray', 'true');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
VALUES ('use_plugin_architecture_dvd', 'Erweitert', 'Beta: DVD Plugin aktiv', 'boolean', 1, 'Aktiviert das DVD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10001);
|
VALUES ('use_plugin_architecture_bluray_analyze', 'Erweitert', 'Analyse (Blu-ray)', 'boolean', 1, 'Disc-Erkennung und OMDB-Suche laufen über das Plugin.', 'true', '[]', '{}', 10001, 'use_plugin_architecture_bluray');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray_analyze', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_bluray_rip', 'Erweitert', 'Rip (Blu-ray)', 'boolean', 1, 'MakeMKV-Rip läuft über das Plugin.', 'true', '[]', '{}', 10002, 'use_plugin_architecture_bluray');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray_rip', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_bluray_review', 'Erweitert', 'Review / Mediainfo-Prüfung (Blu-ray)', 'boolean', 1, 'Noch nicht implementiert — kommt in einem späteren Sprint.', 'false', '[]', '{"readonly":true}', 10003, 'use_plugin_architecture_bluray');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray_review', 'false');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_bluray_encode', 'Erweitert', 'Encoding (Blu-ray)', 'boolean', 1, 'Noch nicht implementiert — kommt in einem späteren Sprint.', 'false', '[]', '{"readonly":true}', 10004, 'use_plugin_architecture_bluray');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_bluray_encode', 'false');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_dvd', 'Erweitert', 'DVD Plugin', 'boolean', 1, 'Aktiviert das DVD Plugin. Deaktiviert = Legacy-Pfad für alle DVD-Phasen.', 'true', '[]', '{}', 10010, 'use_plugin_architecture');
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd', 'true');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd', 'true');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
VALUES ('use_plugin_architecture_cd', 'Erweitert', 'Beta: Audio-CD Plugin aktiv', 'boolean', 1, 'Aktiviert das Audio-CD Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10002);
|
VALUES ('use_plugin_architecture_dvd_analyze', 'Erweitert', 'Analyse (DVD)', 'boolean', 1, 'Disc-Erkennung und OMDB-Suche laufen über das Plugin.', 'true', '[]', '{}', 10011, 'use_plugin_architecture_dvd');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_analyze', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_dvd_rip', 'Erweitert', 'Rip (DVD)', 'boolean', 1, 'MakeMKV-Rip läuft über das Plugin.', 'true', '[]', '{}', 10012, 'use_plugin_architecture_dvd');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_rip', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_dvd_review', 'Erweitert', 'Review / Mediainfo-Prüfung (DVD)', 'boolean', 1, 'Noch nicht implementiert — kommt in einem späteren Sprint.', 'false', '[]', '{"readonly":true}', 10013, 'use_plugin_architecture_dvd');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_review', 'false');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_dvd_encode', 'Erweitert', 'Encoding (DVD)', 'boolean', 1, 'Noch nicht implementiert — kommt in einem späteren Sprint.', 'false', '[]', '{"readonly":true}', 10014, 'use_plugin_architecture_dvd');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_dvd_encode', 'false');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_cd', 'Erweitert', 'Audio-CD Plugin', 'boolean', 1, 'Aktiviert das Audio-CD Plugin. Deaktiviert = Legacy-Pfad für alle CD-Phasen.', 'true', '[]', '{}', 10020, 'use_plugin_architecture');
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_cd', 'true');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_cd', 'true');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
VALUES ('use_plugin_architecture_audiobook', 'Erweitert', 'Beta: Audiobook Plugin aktiv', 'boolean', 1, 'Aktiviert das Audiobook Plugin der neuen Architektur. Wirksam nur wenn der globale Beta-Schalter aktiviert ist. Deaktiviert = Legacy-Pfad.', 'true', '[]', '{}', 10003);
|
VALUES ('use_plugin_architecture_cd_analyze', 'Erweitert', 'Analyse (Audio-CD)', 'boolean', 1, 'TOC-Auslesung läuft über das Plugin.', 'true', '[]', '{}', 10021, 'use_plugin_architecture_cd');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_cd_analyze', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_cd_rip', 'Erweitert', 'Rip (Audio-CD)', 'boolean', 1, 'CD-Rip/RAW-Rip/Encode-aus-RAW läuft über das Plugin.', 'true', '[]', '{}', 10022, 'use_plugin_architecture_cd');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_cd_rip', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_audiobook', 'Erweitert', 'Audiobook Plugin', 'boolean', 1, 'Aktiviert das Audiobook Plugin. Deaktiviert = Legacy-Pfad für alle Audiobook-Phasen.', 'true', '[]', '{}', 10030, 'use_plugin_architecture');
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_audiobook', 'true');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_audiobook', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_audiobook_analyze', 'Erweitert', 'Analyse (Audiobook)', 'boolean', 1, 'ffprobe-Metadatenanalyse läuft über das Plugin.', 'true', '[]', '{}', 10031, 'use_plugin_architecture_audiobook');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_audiobook_analyze', 'true');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
|
||||||
|
VALUES ('use_plugin_architecture_audiobook_encode', 'Erweitert', 'Encoding (Audiobook)', 'boolean', 1, 'Noch nicht implementiert — Encoding läuft noch über Legacy.', 'false', '[]', '{"readonly":true}', 10032, 'use_plugin_architecture_audiobook');
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('use_plugin_architecture_audiobook_encode', 'false');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500);
|
VALUES ('pushover_enabled', 'Benachrichtigungen', 'PushOver aktiviert', 'boolean', 1, 'Master-Schalter für PushOver Versand.', 'false', '[]', '{}', 500);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false');
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_enabled', 'false');
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-1",
|
"version": "0.12.0-2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-1",
|
"version": "0.12.0-2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-1",
|
"version": "0.12.0-2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -742,16 +742,23 @@ export const api = {
|
|||||||
},
|
},
|
||||||
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
||||||
const includeRelated = Boolean(options?.includeRelated);
|
const includeRelated = Boolean(options?.includeRelated);
|
||||||
|
const resetDriveState = Boolean(options?.resetDriveState);
|
||||||
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
|
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
|
||||||
? options.selectedMoviePaths
|
? options.selectedMoviePaths
|
||||||
.map((item) => String(item || '').trim())
|
.map((item) => String(item || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: null;
|
: null;
|
||||||
|
const hasKeepDetectedDevice = options?.keepDetectedDevice !== undefined;
|
||||||
|
const keepDetectedDevice = hasKeepDetectedDevice
|
||||||
|
? Boolean(options.keepDetectedDevice)
|
||||||
|
: null;
|
||||||
const result = await request(`/history/${jobId}/delete`, {
|
const result = await request(`/history/${jobId}/delete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
target,
|
target,
|
||||||
includeRelated,
|
includeRelated,
|
||||||
|
resetDriveState,
|
||||||
|
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
|
||||||
...(selectedMoviePaths ? { selectedMoviePaths } : {})
|
...(selectedMoviePaths ? { selectedMoviePaths } : {})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export default function AudiobookConfigPanel({
|
|||||||
onStart,
|
onStart,
|
||||||
onCancel,
|
onCancel,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onDeleteJob,
|
||||||
busy
|
busy
|
||||||
}) {
|
}) {
|
||||||
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
||||||
@@ -353,6 +354,17 @@ export default function AudiobookConfigPanel({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{jobId ? (
|
||||||
|
<Button
|
||||||
|
label="Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onDeleteJob?.(jobId)}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
<Button
|
<Button
|
||||||
label="Abbrechen"
|
label="Abbrechen"
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export default function CdRipConfigPanel({
|
|||||||
onRetry,
|
onRetry,
|
||||||
onRestartReview,
|
onRestartReview,
|
||||||
onOpenMetadata,
|
onOpenMetadata,
|
||||||
|
onDeleteJob,
|
||||||
busy
|
busy
|
||||||
}) {
|
}) {
|
||||||
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
||||||
@@ -809,6 +810,16 @@ export default function CdRipConfigPanel({
|
|||||||
onClick={() => onCancel && onCancel()}
|
onClick={() => onCancel && onCancel()}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
|
{jobId ? (
|
||||||
|
<Button
|
||||||
|
label="Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onDeleteJob && onDeleteJob(jobId)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isTerminalFailure ? (
|
{isTerminalFailure ? (
|
||||||
@@ -851,6 +862,16 @@ export default function CdRipConfigPanel({
|
|||||||
loading={busy}
|
loading={busy}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{jobId ? (
|
||||||
|
<Button
|
||||||
|
label="Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onDeleteJob && onDeleteJob(jobId)}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1297,6 +1318,16 @@ export default function CdRipConfigPanel({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{jobId ? (
|
||||||
|
<Button
|
||||||
|
label="Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onDeleteJob && onDeleteJob(jobId)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
label="Abbrechen"
|
label="Abbrechen"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ export default function PipelineStatusCard({
|
|||||||
onSelectHandBrakeTitle,
|
onSelectHandBrakeTitle,
|
||||||
onCancel,
|
onCancel,
|
||||||
onRetry,
|
onRetry,
|
||||||
|
onDeleteJob,
|
||||||
isQueued = false,
|
isQueued = false,
|
||||||
busy,
|
busy,
|
||||||
liveJobLog = ''
|
liveJobLog = ''
|
||||||
@@ -832,6 +833,16 @@ export default function PipelineStatusCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{retryJobId && typeof onDeleteJob === 'function' ? (
|
||||||
|
<Button
|
||||||
|
label="Job löschen"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
onClick={() => onDeleteJob?.(retryJobId)}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{running ? (
|
{running ? (
|
||||||
|
|||||||
@@ -731,6 +731,16 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
&& !processingStates.includes(jobStatus)
|
&& !processingStates.includes(jobStatus)
|
||||||
&& resolvedMediaType !== 'audiobook'
|
&& resolvedMediaType !== 'audiobook'
|
||||||
);
|
);
|
||||||
|
const cdSkipRipReady = Boolean(
|
||||||
|
resolvedMediaType === 'cd'
|
||||||
|
&& (
|
||||||
|
Boolean(encodePlan?.skipRip)
|
||||||
|
|| (
|
||||||
|
Number(job?.rip_successful || 0) === 1
|
||||||
|
&& Boolean(String(job?.raw_path || '').trim())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
const computedContext = {
|
const computedContext = {
|
||||||
jobId,
|
jobId,
|
||||||
rawPath: job?.raw_path || null,
|
rawPath: job?.raw_path || null,
|
||||||
@@ -741,6 +751,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|||||||
devicePath,
|
devicePath,
|
||||||
cdparanoiaCmd,
|
cdparanoiaCmd,
|
||||||
cdparanoiaCommandPreview,
|
cdparanoiaCommandPreview,
|
||||||
|
skipRip: cdSkipRipReady ? true : undefined,
|
||||||
cdRipConfig,
|
cdRipConfig,
|
||||||
tracks: cdTracks,
|
tracks: cdTracks,
|
||||||
inputPath,
|
inputPath,
|
||||||
@@ -1777,6 +1788,58 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteDashboardJob = async (jobId) => {
|
||||||
|
const normalizedJobId = normalizeJobId(jobId);
|
||||||
|
if (!normalizedJobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||||
|
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Job #${normalizedJobId} wirklich löschen?\n` +
|
||||||
|
`${title}\n\n` +
|
||||||
|
'Hinweis: Dateien bleiben erhalten. Das zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJobBusy(normalizedJobId, true);
|
||||||
|
try {
|
||||||
|
const statusBeforeDelete = String(job?.status || '').trim().toUpperCase();
|
||||||
|
if (processingStates.includes(statusBeforeDelete)) {
|
||||||
|
await api.cancelPipeline(normalizedJobId);
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
|
const latest = await api.getJob(normalizedJobId, { lite: true, forceRefresh: true }).catch(() => null);
|
||||||
|
const latestStatus = String(latest?.job?.status || latest?.job?.last_state || '').trim().toUpperCase();
|
||||||
|
if (!processingStates.includes(latestStatus)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await wait(250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await api.deleteJobEntry(normalizedJobId, 'none', {
|
||||||
|
includeRelated: false,
|
||||||
|
resetDriveState: true,
|
||||||
|
keepDetectedDevice: false
|
||||||
|
});
|
||||||
|
await refreshPipeline();
|
||||||
|
await loadDashboardJobs();
|
||||||
|
setExpandedJobId((prev) => (normalizeJobId(prev) === normalizedJobId ? null : prev));
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Job gelöscht',
|
||||||
|
detail: `Job #${normalizedJobId} wurde entfernt.`,
|
||||||
|
life: 3200
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
setJobBusy(normalizedJobId, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRestartEncodeWithLastSettings = async (jobId) => {
|
const handleRestartEncodeWithLastSettings = async (jobId) => {
|
||||||
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
||||||
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||||
@@ -2702,6 +2765,7 @@ export default function DashboardPage({
|
|||||||
onCancel={() => handleCancel(jobId, jobState)}
|
onCancel={() => handleCancel(jobId, jobState)}
|
||||||
onRetry={() => handleRetry(jobId)}
|
onRetry={() => handleRetry(jobId)}
|
||||||
onRestartReview={() => handleRestartCdReviewFromRaw(jobId)}
|
onRestartReview={() => handleRestartCdReviewFromRaw(jobId)}
|
||||||
|
onDeleteJob={() => handleDeleteDashboardJob(jobId)}
|
||||||
onOpenMetadata={() => {
|
onOpenMetadata={() => {
|
||||||
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
||||||
? pipelineForJob.context
|
? pipelineForJob.context
|
||||||
@@ -2733,6 +2797,7 @@ export default function DashboardPage({
|
|||||||
onStart={(config) => handleAudiobookStart(jobId, config)}
|
onStart={(config) => handleAudiobookStart(jobId, config)}
|
||||||
onCancel={() => handleCancel(jobId, jobState)}
|
onCancel={() => handleCancel(jobId, jobState)}
|
||||||
onRetry={() => handleRetry(jobId)}
|
onRetry={() => handleRetry(jobId)}
|
||||||
|
onDeleteJob={() => handleDeleteDashboardJob(jobId)}
|
||||||
busy={busyJobIds.has(jobId) || needsBytes}
|
busy={busyJobIds.has(jobId) || needsBytes}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -2755,6 +2820,7 @@ export default function DashboardPage({
|
|||||||
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
|
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onRetry={handleRetry}
|
onRetry={handleRetry}
|
||||||
|
onDeleteJob={handleDeleteDashboardJob}
|
||||||
onRemoveFromQueue={handleRemoveQueuedJob}
|
onRemoveFromQueue={handleRemoveQueuedJob}
|
||||||
isQueued={isQueued}
|
isQueued={isQueued}
|
||||||
busy={busyJobIds.has(jobId)}
|
busy={busyJobIds.has(jobId)}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const STATUS_LABELS = {
|
|||||||
ERROR: 'Fehler',
|
ERROR: 'Fehler',
|
||||||
CD_ANALYZING: 'CD-Analyse',
|
CD_ANALYZING: 'CD-Analyse',
|
||||||
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
|
CD_METADATA_SELECTION: 'CD-Metadatenauswahl',
|
||||||
CD_READY_TO_RIP: 'CD bereit zum Rippen',
|
CD_READY_TO_RIP: 'CD bereit (Rip/Encode)',
|
||||||
CD_RIPPING: 'CD rippen',
|
CD_RIPPING: 'CD rippen',
|
||||||
CD_ENCODING: 'CD encodieren'
|
CD_ENCODING: 'CD encodieren'
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.12.0-1",
|
"version": "0.12.0-2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.12.0-1",
|
"version": "0.12.0-2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.0-1",
|
"version": "0.12.0-2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user