9496 lines
329 KiB
JavaScript
9496 lines
329 KiB
JavaScript
const fs = require('fs');
|
||
const path = require('path');
|
||
const { EventEmitter } = require('events');
|
||
const { getDb } = require('../db/database');
|
||
const settingsService = require('./settingsService');
|
||
const historyService = require('./historyService');
|
||
const omdbService = require('./omdbService');
|
||
const scriptService = require('./scriptService');
|
||
const scriptChainService = require('./scriptChainService');
|
||
const runtimeActivityService = require('./runtimeActivityService');
|
||
const wsService = require('./websocketService');
|
||
const diskDetectionService = require('./diskDetectionService');
|
||
const notificationService = require('./notificationService');
|
||
const logger = require('./logger').child('PIPELINE');
|
||
const { spawnTrackedProcess } = require('./processRunner');
|
||
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
|
||
const { ensureDir, sanitizeFileName, renderTemplate, findMediaFiles } = require('../utils/files');
|
||
const { buildMediainfoReview } = require('../utils/encodePlan');
|
||
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
|
||
const { errorToMeta } = require('../utils/errorMeta');
|
||
const userPresetService = require('./userPresetService');
|
||
|
||
const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK']);
|
||
const REVIEW_REFRESH_SETTING_PREFIXES = [
|
||
'handbrake_',
|
||
'mediainfo_',
|
||
'makemkv_rip_',
|
||
'makemkv_analyze_',
|
||
'output_extension_',
|
||
'filename_template_',
|
||
'output_folder_template_'
|
||
];
|
||
const REVIEW_REFRESH_SETTING_KEYS = new Set([
|
||
'makemkv_min_length_minutes',
|
||
'handbrake_preset',
|
||
'handbrake_extra_args',
|
||
'mediainfo_extra_args',
|
||
'makemkv_rip_mode',
|
||
'makemkv_analyze_extra_args',
|
||
'makemkv_rip_extra_args',
|
||
'output_extension',
|
||
'filename_template',
|
||
'output_folder_template'
|
||
]);
|
||
const QUEUE_ACTIONS = {
|
||
START_PREPARED: 'START_PREPARED',
|
||
RETRY: 'RETRY',
|
||
REENCODE: 'REENCODE',
|
||
RESTART_ENCODE: 'RESTART_ENCODE',
|
||
RESTART_REVIEW: 'RESTART_REVIEW'
|
||
};
|
||
const QUEUE_ACTION_LABELS = {
|
||
[QUEUE_ACTIONS.START_PREPARED]: 'Start',
|
||
[QUEUE_ACTIONS.RETRY]: 'Retry Rippen',
|
||
[QUEUE_ACTIONS.REENCODE]: 'RAW neu encodieren',
|
||
[QUEUE_ACTIONS.RESTART_ENCODE]: 'Encode neu starten',
|
||
[QUEUE_ACTIONS.RESTART_REVIEW]: 'Review neu berechnen'
|
||
};
|
||
const PRE_ENCODE_PROGRESS_RESERVE = 10;
|
||
const POST_ENCODE_PROGRESS_RESERVE = 10;
|
||
const POST_ENCODE_FINISH_BUFFER = 1;
|
||
const MIN_EXTENSIONLESS_DISC_IMAGE_BYTES = 256 * 1024 * 1024;
|
||
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
|
||
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
||
const RAW_FOLDER_STATES = Object.freeze({
|
||
INCOMPLETE: 'incomplete',
|
||
RIP_COMPLETE: 'rip_complete',
|
||
COMPLETE: 'complete'
|
||
});
|
||
|
||
function nowIso() {
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
function normalizeMediaProfile(value) {
|
||
const raw = String(value || '').trim().toLowerCase();
|
||
if (!raw) {
|
||
return null;
|
||
}
|
||
if (
|
||
raw === 'bluray'
|
||
|| raw === 'blu-ray'
|
||
|| raw === 'blu_ray'
|
||
|| raw === 'bd'
|
||
|| raw === 'bdmv'
|
||
|| raw === 'bdrom'
|
||
|| raw === 'bd-rom'
|
||
|| raw === 'bd-r'
|
||
|| raw === 'bd-re'
|
||
) {
|
||
return 'bluray';
|
||
}
|
||
if (
|
||
raw === 'dvd'
|
||
|| raw === 'dvdvideo'
|
||
|| raw === 'dvd-video'
|
||
|| raw === 'dvdrom'
|
||
|| raw === 'dvd-rom'
|
||
|| raw === 'video_ts'
|
||
|| raw === 'iso9660'
|
||
) {
|
||
return 'dvd';
|
||
}
|
||
if (raw === 'disc' || raw === 'other' || raw === 'sonstiges' || raw === 'cd') {
|
||
return 'other';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isSpecificMediaProfile(value) {
|
||
return value === 'bluray' || value === 'dvd';
|
||
}
|
||
|
||
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
|
||
const fstype = String(rawFsType || '').trim().toLowerCase();
|
||
const model = String(rawModel || '').trim().toLowerCase();
|
||
const hasBlurayModelMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r|bd-re)/.test(model);
|
||
const hasDvdModelMarker = /dvd/.test(model);
|
||
const hasCdOnlyModelMarker = /(^|[\s_-])cd([\s_-]|$)|cd-?rom/.test(model) && !hasBlurayModelMarker && !hasDvdModelMarker;
|
||
|
||
if (!fstype) {
|
||
if (hasBlurayModelMarker) {
|
||
return 'bluray';
|
||
}
|
||
if (hasDvdModelMarker) {
|
||
return 'dvd';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (fstype.includes('udf')) {
|
||
// UDF is used by both DVDs (UDF 1.02) and Blu-rays (UDF 2.5/2.6).
|
||
// Drive model alone (hasBlurayModelMarker) is not reliable: a BD-ROM drive
|
||
// with a DVD inside would incorrectly be detected as Blu-ray.
|
||
// Return null so the mountpoint BDMV/VIDEO_TS check can decide.
|
||
if (hasBlurayModelMarker) {
|
||
return null;
|
||
}
|
||
if (hasDvdModelMarker) {
|
||
return 'dvd';
|
||
}
|
||
return 'dvd';
|
||
}
|
||
|
||
if (fstype.includes('iso9660') || fstype.includes('cdfs')) {
|
||
// iso9660/cdfs is never used by Blu-ray discs (they use UDF 2.5/2.6).
|
||
// Ignore hasBlurayModelMarker here – it only reflects drive capability.
|
||
if (hasCdOnlyModelMarker) {
|
||
return 'other';
|
||
}
|
||
return 'dvd';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function isLikelyExtensionlessDvdImageFile(filePath, knownSize = null) {
|
||
if (path.extname(String(filePath || '')).toLowerCase() !== '') {
|
||
return false;
|
||
}
|
||
|
||
let size = Number(knownSize);
|
||
if (!Number.isFinite(size) || size < 0) {
|
||
try {
|
||
size = Number(fs.statSync(filePath).size || 0);
|
||
} catch (_error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return size >= MIN_EXTENSIONLESS_DISC_IMAGE_BYTES;
|
||
}
|
||
|
||
function listTopLevelExtensionlessDvdImages(dirPath) {
|
||
const sourceDir = String(dirPath || '').trim();
|
||
if (!sourceDir) {
|
||
return [];
|
||
}
|
||
|
||
let entries;
|
||
try {
|
||
entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
||
} catch (_error) {
|
||
return [];
|
||
}
|
||
|
||
const results = [];
|
||
for (const entry of entries) {
|
||
if (!entry.isFile()) {
|
||
continue;
|
||
}
|
||
|
||
const absPath = path.join(sourceDir, entry.name);
|
||
let stat;
|
||
try {
|
||
stat = fs.statSync(absPath);
|
||
} catch (_error) {
|
||
continue;
|
||
}
|
||
|
||
if (!isLikelyExtensionlessDvdImageFile(absPath, stat.size)) {
|
||
continue;
|
||
}
|
||
|
||
results.push({
|
||
path: absPath,
|
||
size: Number(stat.size || 0)
|
||
});
|
||
}
|
||
|
||
results.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
|
||
return results;
|
||
}
|
||
|
||
function inferMediaProfileFromRawPath(rawPath) {
|
||
const source = String(rawPath || '').trim();
|
||
if (!source) {
|
||
return null;
|
||
}
|
||
try {
|
||
const sourceStat = fs.statSync(source);
|
||
if (sourceStat.isFile()) {
|
||
if (isLikelyExtensionlessDvdImageFile(source, sourceStat.size)) {
|
||
return 'dvd';
|
||
}
|
||
return null;
|
||
}
|
||
} catch (_error) {
|
||
// ignore fs errors
|
||
}
|
||
|
||
const bdmvPath = path.join(source, 'BDMV');
|
||
const bdmvStreamPath = path.join(bdmvPath, 'STREAM');
|
||
try {
|
||
if (fs.existsSync(bdmvStreamPath) || fs.existsSync(bdmvPath)) {
|
||
return 'bluray';
|
||
}
|
||
} catch (_error) {
|
||
// ignore fs errors
|
||
}
|
||
|
||
const videoTsPath = path.join(source, 'VIDEO_TS');
|
||
try {
|
||
if (fs.existsSync(videoTsPath)) {
|
||
return 'dvd';
|
||
}
|
||
} catch (_error) {
|
||
// ignore fs errors
|
||
}
|
||
|
||
if (listTopLevelExtensionlessDvdImages(source).length > 0) {
|
||
return 'dvd';
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function inferMediaProfileFromDeviceInfo(deviceInfo = null) {
|
||
const device = deviceInfo && typeof deviceInfo === 'object'
|
||
? deviceInfo
|
||
: null;
|
||
if (!device) {
|
||
return null;
|
||
}
|
||
|
||
const explicit = normalizeMediaProfile(
|
||
device.mediaProfile || device.profile || device.type || null
|
||
);
|
||
if (explicit) {
|
||
return explicit;
|
||
}
|
||
|
||
// Only use disc-specific fields for keyword detection, NOT device.model.
|
||
// The drive model describes drive capability (e.g. "BD-ROM"), not disc type.
|
||
// A BD-ROM drive with a DVD inserted would otherwise be misdetected as Blu-ray.
|
||
const discMarkerText = [
|
||
device.discLabel,
|
||
device.label,
|
||
device.fstype,
|
||
]
|
||
.map((value) => String(value || '').trim().toLowerCase())
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
|
||
if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(discMarkerText)) {
|
||
return 'bluray';
|
||
}
|
||
if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(discMarkerText)) {
|
||
return 'dvd';
|
||
}
|
||
|
||
const byFsTypeAndModel = inferMediaProfileFromFsTypeAndModel(device.fstype, device.model);
|
||
if (byFsTypeAndModel) {
|
||
return byFsTypeAndModel;
|
||
}
|
||
|
||
const mountpoint = String(device.mountpoint || '').trim();
|
||
if (mountpoint) {
|
||
try {
|
||
if (fs.existsSync(path.join(mountpoint, 'BDMV'))) {
|
||
return 'bluray';
|
||
}
|
||
} catch (_error) {
|
||
// ignore fs errors
|
||
}
|
||
try {
|
||
if (fs.existsSync(path.join(mountpoint, 'VIDEO_TS'))) {
|
||
return 'dvd';
|
||
}
|
||
} catch (_error) {
|
||
// ignore fs errors
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function fileTimestamp() {
|
||
const d = new Date();
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
const h = String(d.getHours()).padStart(2, '0');
|
||
const min = String(d.getMinutes()).padStart(2, '0');
|
||
const s = String(d.getSeconds()).padStart(2, '0');
|
||
return `${y}${m}${day}-${h}${min}${s}`;
|
||
}
|
||
|
||
function withTimestampBeforeExtension(targetPath, suffix) {
|
||
const dir = path.dirname(targetPath);
|
||
const ext = path.extname(targetPath);
|
||
const base = path.basename(targetPath, ext);
|
||
return path.join(dir, `${base}_${suffix}${ext}`);
|
||
}
|
||
|
||
function resolveOutputTemplateValues(job, fallbackJobId = null) {
|
||
return {
|
||
title: job.title || job.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'job'),
|
||
year: job.year || new Date().getFullYear(),
|
||
imdbId: job.imdb_id || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb')
|
||
};
|
||
}
|
||
|
||
function resolveOutputFileName(settings, values) {
|
||
const fileTemplate = settings.filename_template || '${title} (${year})';
|
||
return sanitizeFileName(renderTemplate(fileTemplate, values));
|
||
}
|
||
|
||
function resolveFinalOutputFolderName(settings, values) {
|
||
const folderTemplateRaw = String(settings.output_folder_template || '').trim();
|
||
const fallbackTemplate = settings.filename_template || '${title} (${year})';
|
||
const folderTemplate = folderTemplateRaw || fallbackTemplate;
|
||
return sanitizeFileName(renderTemplate(folderTemplate, values));
|
||
}
|
||
|
||
function buildFinalOutputPathFromJob(settings, job, fallbackJobId = null) {
|
||
const movieDir = settings.movie_dir;
|
||
const values = resolveOutputTemplateValues(job, fallbackJobId);
|
||
const folderName = resolveFinalOutputFolderName(settings, values);
|
||
const baseName = resolveOutputFileName(settings, values);
|
||
const ext = String(settings.output_extension || 'mkv').trim() || 'mkv';
|
||
return path.join(movieDir, folderName, `${baseName}.${ext}`);
|
||
}
|
||
|
||
function buildIncompleteOutputPathFromJob(settings, job, fallbackJobId = null) {
|
||
const movieDir = settings.movie_dir;
|
||
const values = resolveOutputTemplateValues(job, fallbackJobId);
|
||
const baseName = resolveOutputFileName(settings, values);
|
||
const ext = String(settings.output_extension || 'mkv').trim() || 'mkv';
|
||
const numericJobId = Number(fallbackJobId || job?.id || 0);
|
||
const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0
|
||
? `Incomplete_job-${numericJobId}`
|
||
: 'Incomplete_job-unknown';
|
||
return path.join(movieDir, incompleteFolder, `${baseName}.${ext}`);
|
||
}
|
||
|
||
function ensureUniqueOutputPath(outputPath) {
|
||
if (!fs.existsSync(outputPath)) {
|
||
return outputPath;
|
||
}
|
||
|
||
const ts = fileTimestamp();
|
||
let attempt = withTimestampBeforeExtension(outputPath, ts);
|
||
let i = 1;
|
||
while (fs.existsSync(attempt)) {
|
||
attempt = withTimestampBeforeExtension(outputPath, `${ts}-${i}`);
|
||
i += 1;
|
||
}
|
||
return attempt;
|
||
}
|
||
|
||
function chownRecursive(targetPath, ownerSpec) {
|
||
const spec = String(ownerSpec || '').trim();
|
||
if (!spec || !targetPath) {
|
||
return;
|
||
}
|
||
try {
|
||
const { spawnSync } = require('child_process');
|
||
const result = spawnSync('chown', ['-R', spec, targetPath], { timeout: 15000 });
|
||
if (result.status !== 0) {
|
||
logger.warn('chown:failed', { targetPath, spec, stderr: String(result.stderr || '') });
|
||
}
|
||
} catch (error) {
|
||
logger.warn('chown:error', { targetPath, spec, error: error?.message });
|
||
}
|
||
}
|
||
|
||
function moveFileWithFallback(sourcePath, targetPath) {
|
||
try {
|
||
fs.renameSync(sourcePath, targetPath);
|
||
} catch (error) {
|
||
if (error?.code !== 'EXDEV') {
|
||
throw error;
|
||
}
|
||
fs.copyFileSync(sourcePath, targetPath);
|
||
fs.unlinkSync(sourcePath);
|
||
}
|
||
}
|
||
|
||
function removeDirectoryIfEmpty(directoryPath) {
|
||
try {
|
||
const entries = fs.readdirSync(directoryPath);
|
||
if (entries.length === 0) {
|
||
fs.rmdirSync(directoryPath);
|
||
}
|
||
} catch (_error) {
|
||
// Best effort cleanup.
|
||
}
|
||
}
|
||
|
||
function finalizeOutputPathForCompletedEncode(incompleteOutputPath, preferredFinalOutputPath) {
|
||
const sourcePath = String(incompleteOutputPath || '').trim();
|
||
if (!sourcePath) {
|
||
throw new Error('Encode-Finalisierung fehlgeschlagen: temporärer Output-Pfad fehlt.');
|
||
}
|
||
if (!fs.existsSync(sourcePath)) {
|
||
throw new Error(`Encode-Finalisierung fehlgeschlagen: temporäre Datei fehlt (${sourcePath}).`);
|
||
}
|
||
|
||
const plannedTargetPath = String(preferredFinalOutputPath || '').trim();
|
||
if (!plannedTargetPath) {
|
||
throw new Error('Encode-Finalisierung fehlgeschlagen: finaler Output-Pfad fehlt.');
|
||
}
|
||
|
||
const sourceResolved = path.resolve(sourcePath);
|
||
const targetPath = ensureUniqueOutputPath(plannedTargetPath);
|
||
const targetResolved = path.resolve(targetPath);
|
||
const outputPathWithTimestamp = targetPath !== plannedTargetPath;
|
||
|
||
if (sourceResolved === targetResolved) {
|
||
return {
|
||
outputPath: targetPath,
|
||
outputPathWithTimestamp
|
||
};
|
||
}
|
||
|
||
ensureDir(path.dirname(targetPath));
|
||
moveFileWithFallback(sourcePath, targetPath);
|
||
removeDirectoryIfEmpty(path.dirname(sourcePath));
|
||
|
||
return {
|
||
outputPath: targetPath,
|
||
outputPathWithTimestamp
|
||
};
|
||
}
|
||
|
||
function truncateLine(value, max = 180) {
|
||
const raw = String(value || '').replace(/\s+/g, ' ').trim();
|
||
if (raw.length <= max) {
|
||
return raw;
|
||
}
|
||
return `${raw.slice(0, max)}...`;
|
||
}
|
||
|
||
function extractProgressDetail(source, line) {
|
||
const text = truncateLine(line, 220);
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
|
||
if (source.startsWith('MAKEMKV')) {
|
||
const prgc = text.match(/^PRGC:\d+,\d+,\"([^\"]+)\"/i);
|
||
if (prgc) {
|
||
return truncateLine(prgc[1], 160);
|
||
}
|
||
if (/Title\s+#?\d+/i.test(text)) {
|
||
return text;
|
||
}
|
||
if (/copying|saving|writing|decrypt/i.test(text)) {
|
||
return text;
|
||
}
|
||
if (/operation|progress|processing/i.test(text)) {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
if (source === 'HANDBRAKE') {
|
||
if (/Encoding:\s*task/i.test(text)) {
|
||
return text;
|
||
}
|
||
if (/Muxing|work result|subtitle scan|frame/i.test(text)) {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function composeStatusText(stage, percent, detail) {
|
||
const base = percent !== null && percent !== undefined
|
||
? `${stage} ${percent.toFixed(2)}%`
|
||
: stage;
|
||
|
||
if (detail) {
|
||
return `${base} - ${detail}`;
|
||
}
|
||
|
||
return base;
|
||
}
|
||
|
||
function clampProgressPercent(value) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) {
|
||
return null;
|
||
}
|
||
return Math.max(0, Math.min(100, parsed));
|
||
}
|
||
|
||
function composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, statusWord = null) {
|
||
const phaseLabel = phase === 'pre' ? 'Pre-Encode' : 'Post-Encode';
|
||
const itemLabel = itemType === 'chain' ? 'Kette' : 'Skript';
|
||
const position = Number.isFinite(index) && Number.isFinite(total) && total > 0
|
||
? ` ${index}/${total}`
|
||
: '';
|
||
const status = statusWord ? ` ${statusWord}` : '';
|
||
const detail = String(label || '').trim();
|
||
return `ENCODING ${percent.toFixed(2)}% - ${phaseLabel} ${itemLabel}${position}${status}${detail ? `: ${detail}` : ''}`;
|
||
}
|
||
|
||
function createEncodeScriptProgressTracker({
|
||
jobId,
|
||
preSteps = 0,
|
||
postSteps = 0,
|
||
updateProgress
|
||
}) {
|
||
const preTotal = Math.max(0, Math.trunc(Number(preSteps) || 0));
|
||
const postTotal = Math.max(0, Math.trunc(Number(postSteps) || 0));
|
||
const hasPre = preTotal > 0;
|
||
const hasPost = postTotal > 0;
|
||
const preReserve = hasPre ? PRE_ENCODE_PROGRESS_RESERVE : 0;
|
||
const postReserve = hasPost ? POST_ENCODE_PROGRESS_RESERVE : 0;
|
||
const finalPercentBeforeFinish = hasPost ? (100 - POST_ENCODE_FINISH_BUFFER) : 100;
|
||
const handBrakeStart = preReserve;
|
||
const handBrakeEnd = Math.max(handBrakeStart, finalPercentBeforeFinish - postReserve);
|
||
|
||
let preCompleted = 0;
|
||
let postCompleted = 0;
|
||
|
||
const clampPhasePercent = (value) => {
|
||
const clamped = clampProgressPercent(value);
|
||
if (clamped === null) {
|
||
return 0;
|
||
}
|
||
return Number(clamped.toFixed(2));
|
||
};
|
||
|
||
const calculatePrePercent = () => {
|
||
if (preTotal <= 0) {
|
||
return clampPhasePercent(handBrakeStart);
|
||
}
|
||
return clampPhasePercent((preCompleted / preTotal) * preReserve);
|
||
};
|
||
|
||
const calculatePostPercent = () => {
|
||
if (postTotal <= 0) {
|
||
return clampPhasePercent(handBrakeEnd);
|
||
}
|
||
return clampPhasePercent(handBrakeEnd + ((postCompleted / postTotal) * postReserve));
|
||
};
|
||
|
||
const callProgress = async (percent, statusText) => {
|
||
if (typeof updateProgress !== 'function') {
|
||
return;
|
||
}
|
||
await updateProgress('ENCODING', percent, null, statusText, jobId);
|
||
};
|
||
|
||
return {
|
||
hasScriptSteps: hasPre || hasPost,
|
||
handBrakeStart,
|
||
handBrakeEnd,
|
||
|
||
mapHandBrakePercent(percent) {
|
||
if (!this.hasScriptSteps) {
|
||
return percent;
|
||
}
|
||
const normalized = clampProgressPercent(percent);
|
||
if (normalized === null) {
|
||
return percent;
|
||
}
|
||
const ratio = normalized / 100;
|
||
return clampPhasePercent(handBrakeStart + ((handBrakeEnd - handBrakeStart) * ratio));
|
||
},
|
||
|
||
async onStepStart(phase, itemType, index, total, label) {
|
||
if (phase === 'pre' && preTotal <= 0) {
|
||
return;
|
||
}
|
||
if (phase === 'post' && postTotal <= 0) {
|
||
return;
|
||
}
|
||
const percent = phase === 'pre'
|
||
? calculatePrePercent()
|
||
: calculatePostPercent();
|
||
await callProgress(percent, composeEncodeScriptStatusText(percent, phase, itemType, index, total, label, 'startet'));
|
||
},
|
||
|
||
async onStepComplete(phase, itemType, index, total, label, success = true) {
|
||
if (phase === 'pre' && preTotal <= 0) {
|
||
return;
|
||
}
|
||
if (phase === 'post' && postTotal <= 0) {
|
||
return;
|
||
}
|
||
|
||
if (phase === 'pre') {
|
||
preCompleted = Math.min(preTotal, preCompleted + 1);
|
||
} else {
|
||
postCompleted = Math.min(postTotal, postCompleted + 1);
|
||
}
|
||
|
||
const percent = phase === 'pre'
|
||
? calculatePrePercent()
|
||
: calculatePostPercent();
|
||
await callProgress(
|
||
percent,
|
||
composeEncodeScriptStatusText(
|
||
percent,
|
||
phase,
|
||
itemType,
|
||
index,
|
||
total,
|
||
label,
|
||
success ? 'OK' : 'Fehler'
|
||
)
|
||
);
|
||
}
|
||
};
|
||
}
|
||
|
||
function shouldKeepHighlight(line) {
|
||
return /error|fail|warn|title\s+#|saving|encoding:|muxing|copying|decrypt/i.test(line);
|
||
}
|
||
|
||
function normalizeNonNegativeInteger(rawValue) {
|
||
if (rawValue === null || rawValue === undefined) {
|
||
return null;
|
||
}
|
||
if (typeof rawValue === 'string' && rawValue.trim() === '') {
|
||
return null;
|
||
}
|
||
const parsed = Number(rawValue);
|
||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||
return null;
|
||
}
|
||
return Math.trunc(parsed);
|
||
}
|
||
|
||
function parseDetectedTitle(lines) {
|
||
const candidates = [];
|
||
const blockedPatterns = [
|
||
/evaluierungsversion/i,
|
||
/evaluation version/i,
|
||
/es verbleiben noch/i,
|
||
/days remaining/i,
|
||
/makemkv/i,
|
||
/www\./i,
|
||
/beta/i
|
||
];
|
||
|
||
const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||
|
||
for (const line of lines) {
|
||
const cinfoMatch = line.match(/CINFO:2,0,"([^"]+)"/i);
|
||
if (cinfoMatch) {
|
||
candidates.push(cinfoMatch[1]);
|
||
}
|
||
|
||
const tinfoMatch = line.match(/TINFO:\d+,2,\d+,"([^"]+)"/i);
|
||
if (tinfoMatch) {
|
||
candidates.push(tinfoMatch[1]);
|
||
}
|
||
}
|
||
|
||
const clean = candidates
|
||
.map(normalize)
|
||
.filter((value) => value.length > 2 && !value.startsWith('/'))
|
||
.filter((value) => !blockedPatterns.some((pattern) => pattern.test(value)))
|
||
.filter((value) => !/^disc\s*\d*$/i.test(value))
|
||
.filter((value) => !/^unknown/i.test(value));
|
||
|
||
if (clean.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
clean.sort((a, b) => b.length - a.length);
|
||
return clean[0];
|
||
}
|
||
|
||
function parseMediainfoJsonOutput(rawOutput) {
|
||
const text = String(rawOutput || '').trim();
|
||
if (!text) {
|
||
return null;
|
||
}
|
||
|
||
const extractJsonObjects = (value) => {
|
||
const source = String(value || '');
|
||
const objects = [];
|
||
let start = -1;
|
||
let depth = 0;
|
||
let inString = false;
|
||
let escaped = false;
|
||
for (let i = 0; i < source.length; i += 1) {
|
||
const ch = source[i];
|
||
if (inString) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
} else if (ch === '\\') {
|
||
escaped = true;
|
||
} else if (ch === '"') {
|
||
inString = false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (ch === '"') {
|
||
inString = true;
|
||
continue;
|
||
}
|
||
if (ch === '{') {
|
||
if (depth === 0) {
|
||
start = i;
|
||
}
|
||
depth += 1;
|
||
continue;
|
||
}
|
||
if (ch === '}' && depth > 0) {
|
||
depth -= 1;
|
||
if (depth === 0 && start >= 0) {
|
||
objects.push(source.slice(start, i + 1));
|
||
start = -1;
|
||
}
|
||
}
|
||
}
|
||
return objects;
|
||
};
|
||
|
||
const parsedObjects = [];
|
||
const rawObjects = extractJsonObjects(text);
|
||
for (const candidate of rawObjects) {
|
||
try {
|
||
parsedObjects.push(JSON.parse(candidate));
|
||
} catch (_error) {
|
||
// ignore malformed blocks and continue
|
||
}
|
||
}
|
||
|
||
if (parsedObjects.length === 0) {
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch (_error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const hasTitleList = (entry) =>
|
||
Array.isArray(entry?.TitleList)
|
||
|| Array.isArray(entry?.Scan?.TitleList)
|
||
|| Array.isArray(entry?.title_list);
|
||
|
||
const hasMediaTrack = (entry) =>
|
||
Array.isArray(entry?.media?.track)
|
||
|| Array.isArray(entry?.Media?.track);
|
||
|
||
const getTitleList = (entry) => {
|
||
if (Array.isArray(entry?.TitleList)) {
|
||
return entry.TitleList;
|
||
}
|
||
if (Array.isArray(entry?.Scan?.TitleList)) {
|
||
return entry.Scan.TitleList;
|
||
}
|
||
if (Array.isArray(entry?.title_list)) {
|
||
return entry.title_list;
|
||
}
|
||
return [];
|
||
};
|
||
|
||
const titleSets = parsedObjects
|
||
.map((entry, index) => ({ entry, index }))
|
||
.filter(({ entry }) => hasTitleList(entry))
|
||
.map(({ entry, index }) => {
|
||
const titles = getTitleList(entry);
|
||
let audioTracks = 0;
|
||
let subtitleTracks = 0;
|
||
let validAudioTracks = 0;
|
||
let validSubtitleTracks = 0;
|
||
|
||
for (const title of titles) {
|
||
const audioList = Array.isArray(title?.AudioList) ? title.AudioList : [];
|
||
const subtitleList = Array.isArray(title?.SubtitleList) ? title.SubtitleList : [];
|
||
audioTracks += audioList.length;
|
||
subtitleTracks += subtitleList.length;
|
||
validAudioTracks += audioList.filter((track) => Number.isFinite(Number(track?.TrackNumber)) && Number(track.TrackNumber) > 0).length;
|
||
validSubtitleTracks += subtitleList.filter((track) => Number.isFinite(Number(track?.TrackNumber)) && Number(track.TrackNumber) > 0).length;
|
||
}
|
||
|
||
return {
|
||
entry,
|
||
index,
|
||
titleCount: titles.length,
|
||
audioTracks,
|
||
subtitleTracks,
|
||
validAudioTracks,
|
||
validSubtitleTracks
|
||
};
|
||
});
|
||
|
||
if (titleSets.length > 0) {
|
||
titleSets.sort((a, b) =>
|
||
b.validAudioTracks - a.validAudioTracks
|
||
|| b.validSubtitleTracks - a.validSubtitleTracks
|
||
|| b.audioTracks - a.audioTracks
|
||
|| b.subtitleTracks - a.subtitleTracks
|
||
|| b.titleCount - a.titleCount
|
||
|| b.index - a.index
|
||
);
|
||
return titleSets[0].entry;
|
||
}
|
||
|
||
const mediaSets = parsedObjects
|
||
.map((entry, index) => ({ entry, index }))
|
||
.filter(({ entry }) => hasMediaTrack(entry))
|
||
.map(({ entry, index }) => {
|
||
const tracks = Array.isArray(entry?.media?.track)
|
||
? entry.media.track
|
||
: (Array.isArray(entry?.Media?.track) ? entry.Media.track : []);
|
||
return {
|
||
entry,
|
||
index,
|
||
trackCount: tracks.length
|
||
};
|
||
});
|
||
|
||
if (mediaSets.length > 0) {
|
||
mediaSets.sort((a, b) => b.trackCount - a.trackCount || b.index - a.index);
|
||
return mediaSets[0].entry;
|
||
}
|
||
|
||
return parsedObjects[parsedObjects.length - 1] || null;
|
||
}
|
||
|
||
function getMediaInfoTrackList(mediaInfoJson) {
|
||
if (Array.isArray(mediaInfoJson?.media?.track)) {
|
||
return mediaInfoJson.media.track;
|
||
}
|
||
if (Array.isArray(mediaInfoJson?.Media?.track)) {
|
||
return mediaInfoJson.Media.track;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function countMediaInfoTrackTypes(mediaInfoJson) {
|
||
const tracks = getMediaInfoTrackList(mediaInfoJson);
|
||
let audioCount = 0;
|
||
let subtitleCount = 0;
|
||
for (const track of tracks) {
|
||
const type = String(track?.['@type'] || '').trim().toLowerCase();
|
||
if (type === 'audio') {
|
||
audioCount += 1;
|
||
continue;
|
||
}
|
||
if (type === 'text' || type === 'subtitle') {
|
||
subtitleCount += 1;
|
||
}
|
||
}
|
||
return {
|
||
audioCount,
|
||
subtitleCount
|
||
};
|
||
}
|
||
|
||
function shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, inputPath) {
|
||
if (normalizeMediaProfile(mediaProfile) !== 'dvd') {
|
||
return false;
|
||
}
|
||
if (path.extname(String(inputPath || '')).toLowerCase() !== '') {
|
||
return false;
|
||
}
|
||
const counts = countMediaInfoTrackTypes(parsedMediaInfo);
|
||
return counts.audioCount === 0 && counts.subtitleCount === 0;
|
||
}
|
||
|
||
function parseHmsDurationToSeconds(raw) {
|
||
const value = String(raw || '').trim();
|
||
if (!value) {
|
||
return 0;
|
||
}
|
||
const match = value.match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/);
|
||
if (!match) {
|
||
return 0;
|
||
}
|
||
const hours = Number(match[1]);
|
||
const minutes = Number(match[2]);
|
||
const seconds = Number(match[3]);
|
||
if (!Number.isFinite(hours) || !Number.isFinite(minutes) || !Number.isFinite(seconds)) {
|
||
return 0;
|
||
}
|
||
return (hours * 3600) + (minutes * 60) + seconds;
|
||
}
|
||
|
||
function parseHandBrakeDurationSeconds(rawDuration) {
|
||
if (rawDuration && typeof rawDuration === 'object') {
|
||
const hours = Number(rawDuration.Hours ?? rawDuration.hours ?? 0);
|
||
const minutes = Number(rawDuration.Minutes ?? rawDuration.minutes ?? 0);
|
||
const seconds = Number(rawDuration.Seconds ?? rawDuration.seconds ?? 0);
|
||
if (Number.isFinite(hours) && Number.isFinite(minutes) && Number.isFinite(seconds)) {
|
||
return Math.max(0, Math.trunc((hours * 3600) + (minutes * 60) + seconds));
|
||
}
|
||
}
|
||
|
||
const parsedHms = parseHmsDurationToSeconds(rawDuration);
|
||
if (parsedHms > 0) {
|
||
return parsedHms;
|
||
}
|
||
|
||
const asNumber = Number(rawDuration);
|
||
if (Number.isFinite(asNumber) && asNumber > 0) {
|
||
return Math.max(0, Math.trunc(asNumber));
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
function formatDurationClock(seconds) {
|
||
const total = Number(seconds || 0);
|
||
if (!Number.isFinite(total) || total <= 0) {
|
||
return null;
|
||
}
|
||
const rounded = Math.max(0, Math.trunc(total));
|
||
const h = Math.floor(rounded / 3600);
|
||
const m = Math.floor((rounded % 3600) / 60);
|
||
const s = rounded % 60;
|
||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||
}
|
||
|
||
function normalizeTrackLanguage(raw) {
|
||
const value = String(raw || '').trim();
|
||
if (!value) {
|
||
return 'und';
|
||
}
|
||
return value.toLowerCase().slice(0, 3);
|
||
}
|
||
|
||
function normalizePositiveTrackId(rawValue) {
|
||
const parsed = Number(rawValue);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
return null;
|
||
}
|
||
return Math.trunc(parsed);
|
||
}
|
||
|
||
function isLikelyForcedSubtitleTrack(track) {
|
||
const text = [
|
||
track?.title,
|
||
track?.description,
|
||
track?.name,
|
||
track?.format,
|
||
track?.label
|
||
]
|
||
.map((value) => String(value || '').trim().toLowerCase())
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
if (!text) {
|
||
return false;
|
||
}
|
||
if (/\bnot forced\b/.test(text)) {
|
||
return false;
|
||
}
|
||
return (
|
||
/\bforced(?:\s+only)?\b/.test(text)
|
||
|| /nur\s+erzwungen/.test(text)
|
||
|| /\berzwungen\b/.test(text)
|
||
);
|
||
}
|
||
|
||
function annotateSubtitleForcedAvailability(handBrakeSubtitleTracks, makeMkvSubtitleTracks) {
|
||
const hbTracks = Array.isArray(handBrakeSubtitleTracks) ? handBrakeSubtitleTracks : [];
|
||
if (hbTracks.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const mkTracks = Array.isArray(makeMkvSubtitleTracks) ? makeMkvSubtitleTracks : [];
|
||
const forcedSourceIdsByLanguage = new Map();
|
||
|
||
for (const track of mkTracks) {
|
||
if (!isLikelyForcedSubtitleTrack(track)) {
|
||
continue;
|
||
}
|
||
const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
|
||
const sourceTrackId = normalizePositiveTrackId(track?.sourceTrackId ?? track?.id);
|
||
if (!sourceTrackId) {
|
||
continue;
|
||
}
|
||
if (!forcedSourceIdsByLanguage.has(language)) {
|
||
forcedSourceIdsByLanguage.set(language, []);
|
||
}
|
||
const list = forcedSourceIdsByLanguage.get(language);
|
||
if (!list.includes(sourceTrackId)) {
|
||
list.push(sourceTrackId);
|
||
}
|
||
}
|
||
|
||
return hbTracks.map((track) => {
|
||
const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
|
||
const forcedSourceTrackIds = normalizeTrackIdList(forcedSourceIdsByLanguage.get(language) || []);
|
||
const forcedTrack = isLikelyForcedSubtitleTrack(track);
|
||
return {
|
||
...track,
|
||
forcedTrack,
|
||
forcedAvailable: forcedTrack || forcedSourceTrackIds.length > 0,
|
||
forcedSourceTrackIds
|
||
};
|
||
});
|
||
}
|
||
|
||
function enrichTitleInfoWithForcedSubtitleAvailability(titleInfo, makeMkvSubtitleTracks) {
|
||
if (!titleInfo || typeof titleInfo !== 'object') {
|
||
return titleInfo;
|
||
}
|
||
return {
|
||
...titleInfo,
|
||
subtitleTracks: annotateSubtitleForcedAvailability(
|
||
Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : [],
|
||
makeMkvSubtitleTracks
|
||
)
|
||
};
|
||
}
|
||
|
||
function pickScanTitleList(scanJson) {
|
||
if (!scanJson || typeof scanJson !== 'object') {
|
||
return [];
|
||
}
|
||
|
||
const direct = Array.isArray(scanJson.TitleList) ? scanJson.TitleList : null;
|
||
if (direct) {
|
||
return direct;
|
||
}
|
||
|
||
const scanNode = scanJson.Scan && typeof scanJson.Scan === 'object' ? scanJson.Scan : null;
|
||
if (scanNode) {
|
||
const scanTitles = Array.isArray(scanNode.TitleList) ? scanNode.TitleList : null;
|
||
if (scanTitles) {
|
||
return scanTitles;
|
||
}
|
||
}
|
||
|
||
const alt = Array.isArray(scanJson.title_list) ? scanJson.title_list : null;
|
||
return alt || [];
|
||
}
|
||
|
||
function resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistIdRaw) {
|
||
const playlistId = normalizePlaylistId(playlistIdRaw);
|
||
if (!playlistId || !playlistAnalysis) {
|
||
return {
|
||
playlistId: playlistId || null,
|
||
playlistFile: playlistId ? `${playlistId}.mpls` : null,
|
||
recommended: false,
|
||
evaluationLabel: null,
|
||
segmentCommand: playlistId ? `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts` : null,
|
||
segmentFiles: []
|
||
};
|
||
}
|
||
|
||
const recommended = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId) === playlistId;
|
||
const evaluated = (Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : [])
|
||
.find((item) => normalizePlaylistId(item?.playlistId) === playlistId) || null;
|
||
const segmentMap = playlistAnalysis.playlistSegments && typeof playlistAnalysis.playlistSegments === 'object'
|
||
? playlistAnalysis.playlistSegments
|
||
: {};
|
||
const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null;
|
||
const segmentFiles = Array.isArray(segmentEntry?.segmentFiles)
|
||
? segmentEntry.segmentFiles.filter((item) => String(item || '').trim().length > 0)
|
||
: [];
|
||
|
||
return {
|
||
playlistId,
|
||
playlistFile: `${playlistId}.mpls`,
|
||
recommended,
|
||
evaluationLabel: evaluated?.evaluationLabel || (recommended ? 'wahrscheinlich korrekt (Heuristik)' : null),
|
||
segmentCommand: segmentEntry?.segmentCommand || `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
|
||
segmentFiles
|
||
};
|
||
}
|
||
|
||
function normalizeScanTrackId(rawValue, fallbackIndex) {
|
||
const parsed = Number(rawValue);
|
||
if (Number.isFinite(parsed) && parsed > 0) {
|
||
return Math.trunc(parsed);
|
||
}
|
||
return Math.max(1, Math.trunc(fallbackIndex) + 1);
|
||
}
|
||
|
||
function parseSizeToBytes(rawValue) {
|
||
const text = String(rawValue || '').trim();
|
||
if (!text) {
|
||
return 0;
|
||
}
|
||
|
||
if (/^\d+$/.test(text)) {
|
||
const numeric = Number(text);
|
||
if (Number.isFinite(numeric) && numeric > 0) {
|
||
return Math.trunc(numeric);
|
||
}
|
||
}
|
||
|
||
const match = text.match(/([0-9]+(?:[.,][0-9]+)?)\s*([kmgt]?b)/i);
|
||
if (!match) {
|
||
return 0;
|
||
}
|
||
|
||
const value = Number(String(match[1]).replace(',', '.'));
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
const unit = String(match[2] || 'b').toLowerCase();
|
||
const factorMap = {
|
||
b: 1,
|
||
kb: 1024,
|
||
mb: 1024 ** 2,
|
||
gb: 1024 ** 3,
|
||
tb: 1024 ** 4
|
||
};
|
||
const factor = factorMap[unit] || 1;
|
||
return Math.max(0, Math.trunc(value * factor));
|
||
}
|
||
|
||
function parseMakeMkvDurationSeconds(rawValue) {
|
||
const hms = parseHmsDurationToSeconds(rawValue);
|
||
if (hms > 0) {
|
||
return hms;
|
||
}
|
||
|
||
const text = String(rawValue || '').trim();
|
||
if (!text) {
|
||
return 0;
|
||
}
|
||
|
||
const hours = Number((text.match(/(\d+)\s*h/i) || [])[1] || 0);
|
||
const minutes = Number((text.match(/(\d+)\s*m/i) || [])[1] || 0);
|
||
const seconds = Number((text.match(/(\d+)\s*s/i) || [])[1] || 0);
|
||
if (hours || minutes || seconds) {
|
||
return (hours * 3600) + (minutes * 60) + seconds;
|
||
}
|
||
|
||
const numeric = Number(text);
|
||
if (Number.isFinite(numeric) && numeric > 0) {
|
||
return Math.trunc(numeric);
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
function buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo) {
|
||
const durationSeconds = Math.max(0, Math.trunc(Number(titleInfo?.durationSeconds || 0)));
|
||
const tracks = [];
|
||
tracks.push({
|
||
'@type': 'General',
|
||
// MediaInfo reports numeric Duration as milliseconds. Keep this format so
|
||
// parseDurationSeconds() does not misinterpret long titles.
|
||
Duration: String(durationSeconds * 1000),
|
||
Duration_String3: formatDurationClock(durationSeconds) || null
|
||
});
|
||
|
||
const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : [];
|
||
const subtitleTracks = Array.isArray(titleInfo?.subtitleTracks) ? titleInfo.subtitleTracks : [];
|
||
|
||
for (const track of audioTracks) {
|
||
tracks.push({
|
||
'@type': 'Audio',
|
||
ID: String(track?.sourceTrackId ?? track?.id ?? ''),
|
||
Language: track?.language || 'und',
|
||
Language_String3: track?.language || 'und',
|
||
Title: track?.title || null,
|
||
Format: track?.codecName || track?.format || null,
|
||
Channels: track?.channels || null
|
||
});
|
||
}
|
||
|
||
for (const track of subtitleTracks) {
|
||
tracks.push({
|
||
'@type': 'Text',
|
||
ID: String(track?.sourceTrackId ?? track?.id ?? ''),
|
||
Language: track?.language || 'und',
|
||
Language_String3: track?.language || 'und',
|
||
Title: track?.title || null,
|
||
Format: track?.format || null
|
||
});
|
||
}
|
||
|
||
return {
|
||
media: {
|
||
track: tracks
|
||
}
|
||
};
|
||
}
|
||
|
||
function remapReviewTrackIdsToSourceIds(review) {
|
||
if (!review || !Array.isArray(review.titles)) {
|
||
return review;
|
||
}
|
||
|
||
const normalizeSourceId = (track) => {
|
||
const normalized = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
|
||
return normalized;
|
||
};
|
||
|
||
const titles = review.titles.map((title) => ({
|
||
...title,
|
||
audioTracks: (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => {
|
||
const sourceTrackId = normalizeSourceId(track);
|
||
return {
|
||
...track,
|
||
id: sourceTrackId || track?.id || null,
|
||
sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null
|
||
};
|
||
}),
|
||
subtitleTracks: (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
|
||
const sourceTrackId = normalizeSourceId(track);
|
||
return {
|
||
...track,
|
||
id: sourceTrackId || track?.id || null,
|
||
sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null
|
||
};
|
||
})
|
||
}));
|
||
|
||
return {
|
||
...review,
|
||
titles
|
||
};
|
||
}
|
||
|
||
function extractPlaylistIdFromHandBrakeTitle(title) {
|
||
const directCandidates = [
|
||
title?.Playlist,
|
||
title?.playlist,
|
||
title?.PlaylistName,
|
||
title?.playlistName,
|
||
title?.SourcePlaylist,
|
||
title?.sourcePlaylist
|
||
];
|
||
for (const candidate of directCandidates) {
|
||
const normalized = normalizePlaylistId(candidate);
|
||
if (normalized) {
|
||
return normalized;
|
||
}
|
||
}
|
||
|
||
const textCandidates = [
|
||
title?.Path,
|
||
title?.path,
|
||
title?.Name,
|
||
title?.name,
|
||
title?.File,
|
||
title?.file,
|
||
title?.TitleName,
|
||
title?.titleName,
|
||
title?.SourceName,
|
||
title?.sourceName
|
||
];
|
||
for (const candidate of textCandidates) {
|
||
const text = String(candidate || '').trim();
|
||
if (!text) {
|
||
continue;
|
||
}
|
||
const match = text.match(/(\d{1,5})\.mpls\b/i);
|
||
if (!match) {
|
||
continue;
|
||
}
|
||
const normalized = normalizePlaylistId(match[1]);
|
||
if (normalized) {
|
||
return normalized;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function parseHandBrakeScanSizeBytes(title) {
|
||
const numeric = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0);
|
||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||
return 0;
|
||
}
|
||
return Math.trunc(numeric);
|
||
}
|
||
|
||
function buildHandBrakeScanTitleRows(scanJson) {
|
||
const titleList = pickScanTitleList(scanJson);
|
||
return titleList
|
||
.map((title, idx) => {
|
||
const handBrakeTitleId = normalizeScanTrackId(
|
||
title?.Index ?? title?.index ?? title?.Title ?? title?.title,
|
||
idx
|
||
);
|
||
const playlist = extractPlaylistIdFromHandBrakeTitle(title);
|
||
const durationSeconds = parseHandBrakeDurationSeconds(
|
||
title?.Duration ?? title?.duration ?? title?.Length ?? title?.length
|
||
);
|
||
const sizeBytes = parseHandBrakeScanSizeBytes(title);
|
||
const audioTrackCount = Array.isArray(title?.AudioList) ? title.AudioList.length : 0;
|
||
const subtitleTrackCount = Array.isArray(title?.SubtitleList) ? title.SubtitleList.length : 0;
|
||
return {
|
||
handBrakeTitleId,
|
||
playlist,
|
||
durationSeconds,
|
||
sizeBytes,
|
||
audioTrackCount,
|
||
subtitleTrackCount
|
||
};
|
||
})
|
||
.filter((item) => Number.isFinite(item.handBrakeTitleId) && item.handBrakeTitleId > 0);
|
||
}
|
||
|
||
function listAvailableHandBrakePlaylists(scanJson) {
|
||
const rows = buildHandBrakeScanTitleRows(scanJson);
|
||
return Array.from(new Set(
|
||
rows
|
||
.map((item) => normalizePlaylistId(item?.playlist))
|
||
.filter(Boolean)
|
||
)).sort();
|
||
}
|
||
|
||
function resolveHandBrakeTitleIdForPlaylist(scanJson, playlistIdRaw, options = {}) {
|
||
const playlistId = normalizePlaylistId(playlistIdRaw);
|
||
if (!playlistId) {
|
||
return null;
|
||
}
|
||
|
||
const expectedMakemkvTitleIdRaw = Number(options?.expectedMakemkvTitleId);
|
||
const expectedMakemkvTitleId = Number.isFinite(expectedMakemkvTitleIdRaw) && expectedMakemkvTitleIdRaw >= 0
|
||
? Math.trunc(expectedMakemkvTitleIdRaw)
|
||
: null;
|
||
const expectedDurationRaw = Number(options?.expectedDurationSeconds);
|
||
const expectedDurationSeconds = Number.isFinite(expectedDurationRaw) && expectedDurationRaw > 0
|
||
? Math.trunc(expectedDurationRaw)
|
||
: null;
|
||
const expectedSizeRaw = Number(options?.expectedSizeBytes);
|
||
const expectedSizeBytes = Number.isFinite(expectedSizeRaw) && expectedSizeRaw > 0
|
||
? Math.trunc(expectedSizeRaw)
|
||
: null;
|
||
const durationToleranceRaw = Number(options?.durationToleranceSeconds);
|
||
const durationToleranceSeconds = Number.isFinite(durationToleranceRaw) && durationToleranceRaw >= 0
|
||
? Math.trunc(durationToleranceRaw)
|
||
: 5;
|
||
|
||
const rows = buildHandBrakeScanTitleRows(scanJson);
|
||
const matches = rows.filter((item) => item.playlist === playlistId);
|
||
|
||
const scoreForExpected = (row) => {
|
||
const durationDelta = expectedDurationSeconds !== null
|
||
? Math.abs(Number(row?.durationSeconds || 0) - expectedDurationSeconds)
|
||
: Number.MAX_SAFE_INTEGER;
|
||
const sizeDelta = expectedSizeBytes !== null
|
||
? Math.abs(Number(row?.sizeBytes || 0) - expectedSizeBytes)
|
||
: Number.MAX_SAFE_INTEGER;
|
||
const trackRichness = Number(row?.audioTrackCount || 0) + Number(row?.subtitleTrackCount || 0);
|
||
return {
|
||
row,
|
||
durationDelta,
|
||
sizeDelta,
|
||
trackRichness
|
||
};
|
||
};
|
||
|
||
const sortByExpectedScore = (a, b) =>
|
||
a.durationDelta - b.durationDelta
|
||
|| a.sizeDelta - b.sizeDelta
|
||
|| b.trackRichness - a.trackRichness
|
||
|| b.row.durationSeconds - a.row.durationSeconds
|
||
|| b.row.sizeBytes - a.row.sizeBytes
|
||
|| a.row.handBrakeTitleId - b.row.handBrakeTitleId;
|
||
|
||
if (matches.length > 0) {
|
||
if (expectedDurationSeconds !== null || expectedSizeBytes !== null) {
|
||
const scored = matches.map(scoreForExpected).sort(sortByExpectedScore);
|
||
if (expectedDurationSeconds !== null) {
|
||
const withinTolerance = scored.filter((item) => item.durationDelta <= durationToleranceSeconds);
|
||
if (withinTolerance.length > 0) {
|
||
return withinTolerance[0].row.handBrakeTitleId;
|
||
}
|
||
}
|
||
return scored[0].row.handBrakeTitleId;
|
||
}
|
||
const best = matches.sort((a, b) =>
|
||
b.durationSeconds - a.durationSeconds
|
||
|| b.sizeBytes - a.sizeBytes
|
||
|| a.handBrakeTitleId - b.handBrakeTitleId
|
||
)[0];
|
||
return best?.handBrakeTitleId || null;
|
||
}
|
||
|
||
// Fallback 1: choose closest duration/size if playlist metadata is absent in scan JSON.
|
||
if ((expectedDurationSeconds !== null || expectedSizeBytes !== null) && rows.length > 0) {
|
||
const scored = rows.map(scoreForExpected).sort(sortByExpectedScore);
|
||
if (expectedDurationSeconds !== null) {
|
||
const withinTolerance = scored.filter((item) => item.durationDelta <= durationToleranceSeconds);
|
||
if (withinTolerance.length > 0) {
|
||
return withinTolerance[0].row.handBrakeTitleId;
|
||
}
|
||
}
|
||
return scored[0].row.handBrakeTitleId;
|
||
}
|
||
|
||
// Fallback 2: map MakeMKV title-id to HandBrake title-id if ordering matches.
|
||
if (expectedMakemkvTitleId !== null) {
|
||
const byPlusOne = rows.find((item) => item.handBrakeTitleId === (expectedMakemkvTitleId + 1));
|
||
if (byPlusOne) {
|
||
return byPlusOne.handBrakeTitleId;
|
||
}
|
||
const byEqual = rows.find((item) => item.handBrakeTitleId === expectedMakemkvTitleId);
|
||
if (byEqual) {
|
||
return byEqual.handBrakeTitleId;
|
||
}
|
||
}
|
||
|
||
if (rows.length === 1) {
|
||
return rows[0].handBrakeTitleId;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function isHandBrakePlaylistCacheEntryCompatible(entry, playlistIdRaw, options = {}) {
|
||
const playlistId = normalizePlaylistId(playlistIdRaw);
|
||
if (!playlistId) {
|
||
return false;
|
||
}
|
||
if (!entry || typeof entry !== 'object') {
|
||
return false;
|
||
}
|
||
const handBrakeTitleId = Number(entry?.handBrakeTitleId);
|
||
if (!Number.isFinite(handBrakeTitleId) || handBrakeTitleId <= 0) {
|
||
return false;
|
||
}
|
||
const titleInfo = entry?.titleInfo && typeof entry.titleInfo === 'object' ? entry.titleInfo : null;
|
||
if (!titleInfo) {
|
||
return false;
|
||
}
|
||
|
||
const cachedPlaylistId = normalizePlaylistId(titleInfo?.playlistId || null);
|
||
if (cachedPlaylistId && cachedPlaylistId !== playlistId) {
|
||
return false;
|
||
}
|
||
|
||
const expectedDurationRaw = Number(options?.expectedDurationSeconds);
|
||
const expectedDurationSeconds = Number.isFinite(expectedDurationRaw) && expectedDurationRaw > 0
|
||
? Math.trunc(expectedDurationRaw)
|
||
: null;
|
||
const cachedDurationRaw = Number(titleInfo?.durationSeconds);
|
||
const cachedDurationSeconds = Number.isFinite(cachedDurationRaw) && cachedDurationRaw > 0
|
||
? Math.trunc(cachedDurationRaw)
|
||
: null;
|
||
if (expectedDurationSeconds !== null && cachedDurationSeconds !== null) {
|
||
// Reject clearly wrong cache mappings (e.g. 30s instead of 6681s movie title).
|
||
if (Math.abs(expectedDurationSeconds - cachedDurationSeconds) > 120) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const expectedSizeRaw = Number(options?.expectedSizeBytes);
|
||
const expectedSizeBytes = Number.isFinite(expectedSizeRaw) && expectedSizeRaw > 0
|
||
? Math.trunc(expectedSizeRaw)
|
||
: null;
|
||
const cachedSizeRaw = Number(titleInfo?.sizeBytes);
|
||
const cachedSizeBytes = Number.isFinite(cachedSizeRaw) && cachedSizeRaw > 0
|
||
? Math.trunc(cachedSizeRaw)
|
||
: null;
|
||
if (expectedSizeBytes !== null && cachedSizeBytes !== null) {
|
||
const delta = Math.abs(expectedSizeBytes - cachedSizeBytes);
|
||
if (delta > (512 * 1024 * 1024)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function normalizeCodecNumber(value) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) {
|
||
return null;
|
||
}
|
||
return Math.trunc(numeric);
|
||
}
|
||
|
||
function hasDtsHdMarker(track) {
|
||
const text = `${track?.description || ''} ${track?.title || ''} ${track?.format || ''} ${track?.codecName || ''}`
|
||
.toLowerCase();
|
||
const codec = normalizeCodecNumber(track?.codec);
|
||
return text.includes('dts-hd') || text.includes('dts hd') || codec === 262144;
|
||
}
|
||
|
||
function isLikelyDtsCoreTrack(track) {
|
||
const text = `${track?.description || ''} ${track?.title || ''} ${track?.format || ''} ${track?.codecName || ''}`
|
||
.toLowerCase();
|
||
const codec = normalizeCodecNumber(track?.codec);
|
||
const looksDts = text.includes('dts') || text.includes('dca');
|
||
const looksHd = text.includes('dts-hd') || text.includes('dts hd') || codec === 262144;
|
||
if (!looksDts || looksHd) {
|
||
return false;
|
||
}
|
||
|
||
// HandBrake uses 8192 for DTS core in scan JSON.
|
||
if (codec !== null && codec !== 8192) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function filterDtsCoreFallbackTracks(audioTracks) {
|
||
const tracks = Array.isArray(audioTracks) ? audioTracks : [];
|
||
if (tracks.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const hdLanguages = new Set(
|
||
tracks
|
||
.filter((track) => hasDtsHdMarker(track))
|
||
.map((track) => String(track?.language || 'und'))
|
||
);
|
||
|
||
if (hdLanguages.size === 0) {
|
||
return tracks;
|
||
}
|
||
|
||
return tracks.filter((track) => {
|
||
const language = String(track?.language || 'und');
|
||
if (!hdLanguages.has(language)) {
|
||
return true;
|
||
}
|
||
return !isLikelyDtsCoreTrack(track);
|
||
});
|
||
}
|
||
|
||
function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) {
|
||
const titleList = pickScanTitleList(scanJson);
|
||
if (!Array.isArray(titleList) || titleList.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const preferredPlaylistId = normalizePlaylistId(options?.playlistId || null);
|
||
const rawPreferredHandBrakeTitleId = Number(options?.handBrakeTitleId);
|
||
const preferredHandBrakeTitleId = Number.isFinite(rawPreferredHandBrakeTitleId) && rawPreferredHandBrakeTitleId > 0
|
||
? Math.trunc(rawPreferredHandBrakeTitleId)
|
||
: null;
|
||
const makeMkvSubtitleTracks = Array.isArray(options?.makeMkvSubtitleTracks)
|
||
? options.makeMkvSubtitleTracks
|
||
: [];
|
||
|
||
const parsedTitles = titleList.map((title, idx) => {
|
||
const handBrakeTitleId = normalizeScanTrackId(
|
||
title?.Index ?? title?.index ?? title?.Title ?? title?.title,
|
||
idx
|
||
);
|
||
const playlistId = normalizePlaylistId(
|
||
title?.Playlist
|
||
|| title?.playlist
|
||
|| title?.PlaylistName
|
||
|| title?.playlistName
|
||
|| null
|
||
);
|
||
const durationSeconds = parseHandBrakeDurationSeconds(
|
||
title?.Duration ?? title?.duration ?? title?.Length ?? title?.length
|
||
);
|
||
const sizeBytes = Number(title?.Size?.Bytes ?? title?.Bytes ?? 0) || 0;
|
||
const rawFileName = String(
|
||
title?.Name
|
||
|| title?.TitleName
|
||
|| title?.File
|
||
|| title?.SourceName
|
||
|| ''
|
||
).trim();
|
||
const fileName = rawFileName || `Title #${handBrakeTitleId}`;
|
||
|
||
const audioTracksRaw = (Array.isArray(title?.AudioList) ? title.AudioList : [])
|
||
.map((track, trackIndex) => {
|
||
const sourceTrackId = normalizeScanTrackId(
|
||
// Prefer source numbering from HandBrake JSON so UI/CLI IDs stay stable
|
||
// (e.g. audio 2..10, subtitle 11..21 on some Blu-rays).
|
||
track?.TrackNumber
|
||
?? track?.Track
|
||
?? track?.id
|
||
?? track?.ID
|
||
?? track?.Index,
|
||
trackIndex
|
||
);
|
||
const languageCode = normalizeTrackLanguage(
|
||
track?.LanguageCode
|
||
|| track?.ISO639_2
|
||
|| track?.Language
|
||
|| track?.language
|
||
|| 'und'
|
||
);
|
||
const languageLabel = String(
|
||
track?.Language
|
||
|| track?.LanguageCode
|
||
|| track?.language
|
||
|| languageCode
|
||
).trim() || languageCode;
|
||
|
||
return {
|
||
id: sourceTrackId,
|
||
sourceTrackId,
|
||
language: languageCode,
|
||
languageLabel,
|
||
title: track?.Name || track?.Description || null,
|
||
description: track?.Description || null,
|
||
codec: track?.Codec ?? null,
|
||
codecName: track?.CodecName || null,
|
||
format: track?.Codec || track?.CodecName || track?.CodecParam || null,
|
||
channels: track?.ChannelLayoutName || track?.ChannelLayout || track?.Channels || null
|
||
};
|
||
})
|
||
.filter((track) => Number.isFinite(Number(track?.sourceTrackId)) && Number(track.sourceTrackId) > 0);
|
||
const audioTracks = filterDtsCoreFallbackTracks(audioTracksRaw);
|
||
|
||
const subtitleTracksRaw = (Array.isArray(title?.SubtitleList) ? title.SubtitleList : [])
|
||
.map((track, trackIndex) => {
|
||
const sourceTrackId = normalizeScanTrackId(
|
||
track?.TrackNumber
|
||
?? track?.Track
|
||
?? track?.id
|
||
?? track?.ID
|
||
?? track?.Index,
|
||
trackIndex
|
||
);
|
||
const languageCode = normalizeTrackLanguage(
|
||
track?.LanguageCode
|
||
|| track?.ISO639_2
|
||
|| track?.Language
|
||
|| track?.language
|
||
|| 'und'
|
||
);
|
||
const languageLabel = String(
|
||
track?.Language
|
||
|| track?.LanguageCode
|
||
|| track?.language
|
||
|| languageCode
|
||
).trim() || languageCode;
|
||
|
||
return {
|
||
id: sourceTrackId,
|
||
sourceTrackId,
|
||
language: languageCode,
|
||
languageLabel,
|
||
title: track?.Name || track?.Description || null,
|
||
format: track?.SourceName || track?.Format || track?.Codec || null,
|
||
channels: null
|
||
};
|
||
})
|
||
.filter((track) => Number.isFinite(Number(track?.sourceTrackId)) && Number(track.sourceTrackId) > 0);
|
||
const subtitleTracks = annotateSubtitleForcedAvailability(subtitleTracksRaw, makeMkvSubtitleTracks);
|
||
|
||
return {
|
||
handBrakeTitleId,
|
||
playlistId,
|
||
durationSeconds,
|
||
sizeBytes,
|
||
fileName,
|
||
audioTracks,
|
||
subtitleTracks
|
||
};
|
||
});
|
||
|
||
let selected = null;
|
||
if (preferredHandBrakeTitleId) {
|
||
selected = parsedTitles.find((title) => title.handBrakeTitleId === preferredHandBrakeTitleId) || null;
|
||
}
|
||
if (!selected && preferredPlaylistId) {
|
||
const playlistMatches = parsedTitles
|
||
.filter((title) => normalizePlaylistId(title?.playlistId) === preferredPlaylistId)
|
||
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.handBrakeTitleId - b.handBrakeTitleId);
|
||
selected = playlistMatches[0] || null;
|
||
}
|
||
if (!selected) {
|
||
selected = parsedTitles
|
||
.slice()
|
||
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.handBrakeTitleId - b.handBrakeTitleId)[0] || null;
|
||
}
|
||
if (!selected) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
source: 'handbrake_scan',
|
||
titleId: selected.handBrakeTitleId,
|
||
handBrakeTitleId: selected.handBrakeTitleId,
|
||
fileName: selected.fileName,
|
||
durationSeconds: selected.durationSeconds,
|
||
sizeBytes: selected.sizeBytes,
|
||
playlistId: selected.playlistId || preferredPlaylistId || null,
|
||
audioTracks: selected.audioTracks,
|
||
subtitleTracks: selected.subtitleTracks
|
||
};
|
||
}
|
||
|
||
function pickTitleIdForTrackReview(playlistAnalysis, selectedTitleId = null) {
|
||
const explicit = normalizeNonNegativeInteger(selectedTitleId);
|
||
if (explicit !== null) {
|
||
return explicit;
|
||
}
|
||
|
||
const recommendationTitleId = Number(playlistAnalysis?.recommendation?.titleId);
|
||
if (Number.isFinite(recommendationTitleId) && recommendationTitleId >= 0) {
|
||
return Math.trunc(recommendationTitleId);
|
||
}
|
||
|
||
const candidates = Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : [];
|
||
if (candidates.length > 0) {
|
||
const candidatesWithPlaylist = candidates.filter((item) => normalizePlaylistId(item?.playlistId));
|
||
const sortPool = candidatesWithPlaylist.length > 0 ? candidatesWithPlaylist : candidates;
|
||
const sortedCandidates = [...sortPool].sort((a, b) =>
|
||
Number(b?.durationSeconds || 0) - Number(a?.durationSeconds || 0)
|
||
|| Number(b?.sizeBytes || 0) - Number(a?.sizeBytes || 0)
|
||
|| Number(a?.titleId || 0) - Number(b?.titleId || 0)
|
||
);
|
||
const candidateTitleId = Number(sortedCandidates[0]?.titleId);
|
||
if (Number.isFinite(candidateTitleId) && candidateTitleId >= 0) {
|
||
return Math.trunc(candidateTitleId);
|
||
}
|
||
}
|
||
|
||
const titles = Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : [];
|
||
if (titles.length > 0) {
|
||
const sortedTitles = [...titles].sort((a, b) =>
|
||
Number(b?.durationSeconds || 0) - Number(a?.durationSeconds || 0)
|
||
|| Number(b?.sizeBytes || 0) - Number(a?.sizeBytes || 0)
|
||
|| Number(a?.titleId || 0) - Number(b?.titleId || 0)
|
||
);
|
||
const titleId = Number(sortedTitles[0]?.titleId);
|
||
if (Number.isFinite(titleId) && titleId >= 0) {
|
||
return Math.trunc(titleId);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function isCandidateTitleId(playlistAnalysis, titleId) {
|
||
const normalizedTitleId = normalizeNonNegativeInteger(titleId);
|
||
if (normalizedTitleId === null) {
|
||
return false;
|
||
}
|
||
const candidates = Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : [];
|
||
return candidates.some((item) => Number(item?.titleId) === normalizedTitleId);
|
||
}
|
||
|
||
function buildDiscScanReview({
|
||
scanJson,
|
||
settings,
|
||
playlistAnalysis = null,
|
||
selectedPlaylistId = null,
|
||
selectedMakemkvTitleId = null,
|
||
mediaProfile = null,
|
||
sourceArg = null,
|
||
mode = 'pre_rip',
|
||
preRip = true,
|
||
encodeInputPath = null
|
||
}) {
|
||
const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0);
|
||
const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60));
|
||
const selectedPlaylist = normalizePlaylistId(selectedPlaylistId);
|
||
const selectedMakemkvId = Number(selectedMakemkvTitleId);
|
||
|
||
const titleList = pickScanTitleList(scanJson);
|
||
const parsedTitles = titleList.map((title, idx) => {
|
||
const reviewTitleId = normalizeScanTrackId(
|
||
title?.Index ?? title?.index ?? title?.Title ?? title?.title,
|
||
idx
|
||
);
|
||
const durationSeconds = parseHandBrakeDurationSeconds(
|
||
title?.Duration ?? title?.duration ?? title?.Length ?? title?.length
|
||
);
|
||
const durationMinutes = Number((durationSeconds / 60).toFixed(2));
|
||
const rawPlaylist = title?.Playlist
|
||
|| title?.playlist
|
||
|| title?.PlaylistName
|
||
|| title?.playlistName
|
||
|| null;
|
||
const playlistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, rawPlaylist);
|
||
const mappedMakemkvTitle = Array.isArray(playlistAnalysis?.titles)
|
||
? (playlistAnalysis.titles.find((item) =>
|
||
normalizePlaylistId(item?.playlistId) === normalizePlaylistId(playlistInfo.playlistId)
|
||
) || null)
|
||
: null;
|
||
const makemkvTitleId = Number.isFinite(Number(mappedMakemkvTitle?.titleId))
|
||
? Math.trunc(Number(mappedMakemkvTitle.titleId))
|
||
: null;
|
||
|
||
const audioList = Array.isArray(title?.AudioList) ? title.AudioList : [];
|
||
const subtitleList = Array.isArray(title?.SubtitleList) ? title.SubtitleList : [];
|
||
|
||
const audioTracksRaw = audioList.map((item, trackIndex) => {
|
||
const trackId = normalizeScanTrackId(item?.TrackNumber ?? item?.Track ?? item?.id, trackIndex);
|
||
const languageLabel = String(item?.Language || item?.LanguageCode || item?.language || 'und');
|
||
const format = item?.Codec || item?.CodecName || item?.CodecParam || item?.Name || null;
|
||
return {
|
||
id: trackId,
|
||
sourceTrackId: trackId,
|
||
language: normalizeTrackLanguage(item?.LanguageCode || item?.ISO639_2 || languageLabel),
|
||
languageLabel,
|
||
title: item?.Name || item?.Description || null,
|
||
description: item?.Description || null,
|
||
codec: item?.Codec ?? null,
|
||
codecName: item?.CodecName || null,
|
||
format,
|
||
channels: item?.ChannelLayoutName || item?.ChannelLayout || item?.Channels || null,
|
||
selectedByRule: true,
|
||
encodePreviewActions: [],
|
||
encodePreviewSummary: 'Übernehmen'
|
||
};
|
||
});
|
||
const audioTracks = filterDtsCoreFallbackTracks(audioTracksRaw);
|
||
|
||
const subtitleTracksRaw = subtitleList.map((item, trackIndex) => {
|
||
const trackId = normalizeScanTrackId(item?.TrackNumber ?? item?.Track ?? item?.id, trackIndex);
|
||
const languageLabel = String(item?.Language || item?.LanguageCode || item?.language || 'und');
|
||
return {
|
||
id: trackId,
|
||
sourceTrackId: trackId,
|
||
language: normalizeTrackLanguage(item?.LanguageCode || item?.ISO639_2 || languageLabel),
|
||
languageLabel,
|
||
title: item?.Name || item?.Description || null,
|
||
format: item?.SourceName || item?.Format || null,
|
||
selectedByRule: true,
|
||
subtitlePreviewSummary: 'Übernehmen',
|
||
subtitlePreviewFlags: [],
|
||
subtitlePreviewBurnIn: false,
|
||
subtitlePreviewForced: false,
|
||
subtitlePreviewForcedOnly: false,
|
||
subtitlePreviewDefaultTrack: false
|
||
};
|
||
});
|
||
const subtitleTracks = annotateSubtitleForcedAvailability(
|
||
subtitleTracksRaw,
|
||
Array.isArray(mappedMakemkvTitle?.subtitleTracks) ? mappedMakemkvTitle.subtitleTracks : []
|
||
);
|
||
|
||
return {
|
||
id: reviewTitleId,
|
||
filePath: encodeInputPath || `disc-track-scan://title-${reviewTitleId}`,
|
||
fileName: `Disc Title ${reviewTitleId}`,
|
||
makemkvTitleId,
|
||
sizeBytes: Number(title?.Size?.Bytes ?? title?.Bytes ?? 0) || 0,
|
||
durationSeconds,
|
||
durationMinutes,
|
||
selectedByMinLength: durationSeconds >= minLengthSeconds,
|
||
playlistMatch: playlistInfo,
|
||
audioTracks,
|
||
subtitleTracks
|
||
};
|
||
});
|
||
|
||
const encodeCandidates = parsedTitles.filter((item) => item.selectedByMinLength);
|
||
const selectedPlaylistCandidate = selectedPlaylist
|
||
? encodeCandidates.filter((item) => normalizePlaylistId(item?.playlistMatch?.playlistId) === selectedPlaylist)
|
||
: [];
|
||
const selectedMakemkvCandidate = Number.isFinite(selectedMakemkvId) && selectedMakemkvId >= 0
|
||
? encodeCandidates.find((item) => Number(item?.makemkvTitleId) === Math.trunc(selectedMakemkvId)) || null
|
||
: null;
|
||
const preferredByIndex = Number.isFinite(selectedMakemkvId) && selectedMakemkvId >= 0
|
||
? encodeCandidates.find((item) => Number(item?.id) === Math.trunc(selectedMakemkvId)) || null
|
||
: null;
|
||
|
||
let encodeInputTitle = null;
|
||
if (selectedPlaylistCandidate.length > 0) {
|
||
encodeInputTitle = selectedPlaylistCandidate.reduce((best, current) => (
|
||
!best || current.durationSeconds > best.durationSeconds ? current : best
|
||
), null);
|
||
} else if (selectedMakemkvCandidate) {
|
||
encodeInputTitle = selectedMakemkvCandidate;
|
||
} else if (preferredByIndex) {
|
||
encodeInputTitle = preferredByIndex;
|
||
} else {
|
||
encodeInputTitle = encodeCandidates.reduce((best, current) => (
|
||
!best || current.durationSeconds > best.durationSeconds ? current : best
|
||
), null);
|
||
}
|
||
|
||
const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired && !selectedPlaylist);
|
||
const normalizedTitles = parsedTitles.map((title) => {
|
||
const isEncodeInput = Boolean(encodeInputTitle && Number(encodeInputTitle.id) === Number(title.id));
|
||
return {
|
||
...title,
|
||
selectedForEncode: isEncodeInput,
|
||
encodeInput: isEncodeInput,
|
||
eligibleForEncode: title.selectedByMinLength,
|
||
playlistId: title.playlistMatch?.playlistId || null,
|
||
playlistFile: title.playlistMatch?.playlistFile || null,
|
||
playlistRecommended: Boolean(title.playlistMatch?.recommended),
|
||
playlistEvaluationLabel: title.playlistMatch?.evaluationLabel || null,
|
||
playlistSegmentCommand: title.playlistMatch?.segmentCommand || null,
|
||
playlistSegmentFiles: Array.isArray(title.playlistMatch?.segmentFiles) ? title.playlistMatch.segmentFiles : [],
|
||
audioTracks: title.audioTracks.map((track) => {
|
||
const selectedForEncode = isEncodeInput && Boolean(track.selectedByRule);
|
||
return {
|
||
...track,
|
||
selectedForEncode,
|
||
encodeActions: [],
|
||
encodeActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen'
|
||
};
|
||
}),
|
||
subtitleTracks: title.subtitleTracks.map((track) => {
|
||
const selectedForEncode = isEncodeInput && Boolean(track.selectedByRule);
|
||
return {
|
||
...track,
|
||
selectedForEncode,
|
||
burnIn: false,
|
||
forced: false,
|
||
forcedOnly: false,
|
||
defaultTrack: false,
|
||
flags: [],
|
||
subtitleActionSummary: selectedForEncode ? 'Übernehmen' : 'Nicht übernommen'
|
||
};
|
||
})
|
||
};
|
||
});
|
||
|
||
const selectedTitleIds = normalizedTitles.filter((item) => item.selectedByMinLength).map((item) => item.id);
|
||
const recommendedPlaylistId = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId || null);
|
||
const recommendedReviewTitle = normalizedTitles.find((item) => item.playlistId === recommendedPlaylistId) || null;
|
||
|
||
return {
|
||
generatedAt: nowIso(),
|
||
mode,
|
||
mediaProfile: normalizeMediaProfile(mediaProfile) || null,
|
||
preRip: Boolean(preRip),
|
||
reviewConfirmed: false,
|
||
minLengthMinutes,
|
||
minLengthSeconds,
|
||
selectedTitleIds,
|
||
selectors: {
|
||
preset: settings?.handbrake_preset || '-',
|
||
extraArgs: settings?.handbrake_extra_args || '',
|
||
presetProfileSource: 'disc-scan',
|
||
audio: {
|
||
mode: 'manual',
|
||
encoders: [],
|
||
copyMask: [],
|
||
fallbackEncoder: '-'
|
||
},
|
||
subtitle: {
|
||
mode: 'manual',
|
||
forcedOnly: false,
|
||
burnBehavior: 'none'
|
||
}
|
||
},
|
||
notes: [
|
||
preRip
|
||
? `Vorab-Spurprüfung von Disc-Quelle ${sourceArg || '-'}.`
|
||
: `Titel-/Spurprüfung aus RAW-Quelle ${sourceArg || '-'}.`,
|
||
preRip
|
||
? 'Backup/Rip startet erst nach manueller Bestätigung und CTA.'
|
||
: 'Encode startet erst nach manueller Bestätigung und CTA.'
|
||
],
|
||
titles: normalizedTitles,
|
||
encodeInputPath: encodeInputTitle ? (encodeInputPath || `disc-track-scan://title-${encodeInputTitle.id}`) : null,
|
||
encodeInputTitleId: encodeInputTitle ? encodeInputTitle.id : null,
|
||
playlistDecisionRequired,
|
||
playlistRecommendation: recommendedPlaylistId
|
||
? {
|
||
playlistId: recommendedPlaylistId,
|
||
playlistFile: `${recommendedPlaylistId}.mpls`,
|
||
reviewTitleId: recommendedReviewTitle?.id || null,
|
||
reason: playlistAnalysis?.recommendation?.reason || null
|
||
}
|
||
: null,
|
||
titleSelectionRequired: Boolean(playlistDecisionRequired && !encodeInputTitle)
|
||
};
|
||
}
|
||
|
||
function findExistingRawDirectory(rawBaseDir, metadataBase) {
|
||
if (!rawBaseDir || !metadataBase) {
|
||
return null;
|
||
}
|
||
|
||
if (!fs.existsSync(rawBaseDir)) {
|
||
return null;
|
||
}
|
||
|
||
let entries;
|
||
try {
|
||
entries = fs.readdirSync(rawBaseDir, { withFileTypes: true });
|
||
} catch (_error) {
|
||
return null;
|
||
}
|
||
|
||
const normalizedBase = sanitizeFileName(metadataBase);
|
||
const escapedBase = normalizedBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const escapedIncompletePrefix = RAW_INCOMPLETE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const escapedRipCompletePrefix = RAW_RIP_COMPLETE_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const folderPattern = new RegExp(
|
||
`^(?:(?:${escapedIncompletePrefix}|${escapedRipCompletePrefix}))?${escapedBase}(?:\\s\\[tt\\d{6,12}\\])?\\s-\\sRAW\\s-\\sjob-\\d+\\s*$`,
|
||
'i'
|
||
);
|
||
const candidates = entries
|
||
.filter((entry) => entry.isDirectory() && folderPattern.test(entry.name))
|
||
.map((entry) => {
|
||
const absPath = path.join(rawBaseDir, entry.name);
|
||
try {
|
||
const dirEntries = fs.readdirSync(absPath);
|
||
const stat = fs.statSync(absPath);
|
||
return {
|
||
path: absPath,
|
||
entryCount: dirEntries.length,
|
||
mtimeMs: Number(stat.mtimeMs || 0)
|
||
};
|
||
} catch (_error) {
|
||
return null;
|
||
}
|
||
})
|
||
.filter((item) => item && item.entryCount > 0)
|
||
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||
|
||
return candidates.length > 0 ? candidates[0].path : null;
|
||
}
|
||
|
||
function buildRawMetadataBase(jobLike = {}, fallbackJobId = null) {
|
||
const normalizedJobId = Number(fallbackJobId || jobLike?.id || 0);
|
||
const fallbackTitle = Number.isFinite(normalizedJobId) && normalizedJobId > 0
|
||
? `job-${Math.trunc(normalizedJobId)}`
|
||
: 'job-unknown';
|
||
const rawYear = Number(jobLike?.year ?? jobLike?.fallbackYear ?? null);
|
||
const yearValue = Number.isFinite(rawYear) && rawYear > 0
|
||
? Math.trunc(rawYear)
|
||
: new Date().getFullYear();
|
||
return sanitizeFileName(
|
||
renderTemplate('${title} (${year})', {
|
||
title: jobLike?.title || jobLike?.detected_title || jobLike?.detectedTitle || fallbackTitle,
|
||
year: yearValue
|
||
})
|
||
);
|
||
}
|
||
|
||
function normalizeRawFolderState(rawState, fallback = RAW_FOLDER_STATES.INCOMPLETE) {
|
||
const state = String(rawState || '').trim().toLowerCase();
|
||
if (!state) {
|
||
return fallback;
|
||
}
|
||
if (state === RAW_FOLDER_STATES.INCOMPLETE) {
|
||
return RAW_FOLDER_STATES.INCOMPLETE;
|
||
}
|
||
if (state === RAW_FOLDER_STATES.RIP_COMPLETE || state === 'ripcomplete' || state === 'rip-complete') {
|
||
return RAW_FOLDER_STATES.RIP_COMPLETE;
|
||
}
|
||
if (state === RAW_FOLDER_STATES.COMPLETE || state === 'none' || state === 'final') {
|
||
return RAW_FOLDER_STATES.COMPLETE;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function stripRawStatePrefix(folderName) {
|
||
const rawName = String(folderName || '').trim();
|
||
if (!rawName) {
|
||
return '';
|
||
}
|
||
return rawName
|
||
.replace(/^Incomplete_/i, '')
|
||
.replace(/^Rip_Complete_/i, '')
|
||
.trim();
|
||
}
|
||
|
||
function applyRawFolderStateToName(folderName, state) {
|
||
const baseName = stripRawStatePrefix(folderName);
|
||
if (!baseName) {
|
||
return baseName;
|
||
}
|
||
const normalizedState = normalizeRawFolderState(state, RAW_FOLDER_STATES.COMPLETE);
|
||
if (normalizedState === RAW_FOLDER_STATES.INCOMPLETE) {
|
||
return `${RAW_INCOMPLETE_PREFIX}${baseName}`;
|
||
}
|
||
if (normalizedState === RAW_FOLDER_STATES.RIP_COMPLETE) {
|
||
return `${RAW_RIP_COMPLETE_PREFIX}${baseName}`;
|
||
}
|
||
return baseName;
|
||
}
|
||
|
||
function resolveRawFolderStateFromPath(rawPath) {
|
||
const sourcePath = String(rawPath || '').trim();
|
||
if (!sourcePath) {
|
||
return RAW_FOLDER_STATES.COMPLETE;
|
||
}
|
||
const folderName = path.basename(sourcePath);
|
||
if (/^Incomplete_/i.test(folderName)) {
|
||
return RAW_FOLDER_STATES.INCOMPLETE;
|
||
}
|
||
if (/^Rip_Complete_/i.test(folderName)) {
|
||
return RAW_FOLDER_STATES.RIP_COMPLETE;
|
||
}
|
||
return RAW_FOLDER_STATES.COMPLETE;
|
||
}
|
||
|
||
function resolveRawFolderStateFromOptions(options = {}) {
|
||
if (options && Object.prototype.hasOwnProperty.call(options, 'state')) {
|
||
return normalizeRawFolderState(options.state, RAW_FOLDER_STATES.INCOMPLETE);
|
||
}
|
||
if (options && options.ripComplete) {
|
||
return RAW_FOLDER_STATES.RIP_COMPLETE;
|
||
}
|
||
if (options && Object.prototype.hasOwnProperty.call(options, 'incomplete')) {
|
||
return options.incomplete ? RAW_FOLDER_STATES.INCOMPLETE : RAW_FOLDER_STATES.COMPLETE;
|
||
}
|
||
return RAW_FOLDER_STATES.INCOMPLETE;
|
||
}
|
||
|
||
function buildRawDirName(metadataBase, jobId, options = {}) {
|
||
const state = resolveRawFolderStateFromOptions(options);
|
||
const baseName = sanitizeFileName(`${metadataBase} - RAW - job-${jobId}`);
|
||
return sanitizeFileName(applyRawFolderStateToName(baseName, state));
|
||
}
|
||
|
||
function buildRawPathForState(rawPath, state) {
|
||
const sourcePath = String(rawPath || '').trim();
|
||
if (!sourcePath) {
|
||
return null;
|
||
}
|
||
const folderName = path.basename(sourcePath);
|
||
const nextFolderName = applyRawFolderStateToName(folderName, state);
|
||
if (!nextFolderName) {
|
||
return sourcePath;
|
||
}
|
||
return path.join(path.dirname(sourcePath), nextFolderName);
|
||
}
|
||
|
||
function buildRipCompleteRawPath(rawPath) {
|
||
return buildRawPathForState(rawPath, RAW_FOLDER_STATES.RIP_COMPLETE);
|
||
}
|
||
|
||
function buildCompletedRawPath(rawPath) {
|
||
return buildRawPathForState(rawPath, RAW_FOLDER_STATES.COMPLETE);
|
||
}
|
||
|
||
function normalizeComparablePath(inputPath) {
|
||
const source = String(inputPath || '').trim();
|
||
if (!source) {
|
||
return '';
|
||
}
|
||
return path.resolve(source).replace(/[\\/]+$/, '');
|
||
}
|
||
|
||
function isJobFinished(jobLike = null) {
|
||
const status = String(jobLike?.status || '').trim().toUpperCase();
|
||
const lastState = String(jobLike?.last_state || '').trim().toUpperCase();
|
||
return status === 'FINISHED' || lastState === 'FINISHED';
|
||
}
|
||
|
||
function toPlaylistFile(playlistId) {
|
||
const normalized = normalizePlaylistId(playlistId);
|
||
return normalized ? `${normalized}.mpls` : null;
|
||
}
|
||
|
||
function describePlaylistManualDecision(playlistAnalysis) {
|
||
const obfuscationDetected = Boolean(playlistAnalysis?.obfuscationDetected);
|
||
const candidateCount = Array.isArray(playlistAnalysis?.candidates)
|
||
? playlistAnalysis.candidates.length
|
||
: 0;
|
||
const reasonCodeRaw = String(playlistAnalysis?.manualDecisionReason || '').trim();
|
||
const reasonCode = reasonCodeRaw || (
|
||
obfuscationDetected
|
||
? 'multiple_similar_candidates'
|
||
: (candidateCount > 1 ? 'multiple_candidates_after_min_length' : 'manual_selection_required')
|
||
);
|
||
const detailText = obfuscationDetected
|
||
? 'Blu-ray verwendet Playlist-Obfuscation (mehrere gleichlange Kandidaten).'
|
||
: (candidateCount > 1
|
||
? `Mehrere Playlists erfüllen MIN_LENGTH_MINUTES (${candidateCount} Kandidaten).`
|
||
: 'Manuelle Playlist-Auswahl erforderlich.');
|
||
|
||
return {
|
||
obfuscationDetected,
|
||
candidateCount,
|
||
reasonCode,
|
||
detailText
|
||
};
|
||
}
|
||
|
||
function buildPlaylistCandidates(playlistAnalysis) {
|
||
const rawList = Array.isArray(playlistAnalysis?.candidatePlaylists)
|
||
? playlistAnalysis.candidatePlaylists
|
||
: [];
|
||
const sourceRows = [
|
||
...(Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []),
|
||
...(Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []),
|
||
...(Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : [])
|
||
];
|
||
const segmentMap = playlistAnalysis?.playlistSegments && typeof playlistAnalysis.playlistSegments === 'object'
|
||
? playlistAnalysis.playlistSegments
|
||
: {};
|
||
|
||
return rawList
|
||
.map((playlistId) => normalizePlaylistId(playlistId))
|
||
.filter(Boolean)
|
||
.map((playlistId) => {
|
||
const source = sourceRows.find((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile) === playlistId) || null;
|
||
const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null;
|
||
const score = Number(source?.score);
|
||
const sequenceCoherence = Number(source?.structuralMetrics?.sequenceCoherence);
|
||
const titleId = Number(source?.titleId ?? source?.id);
|
||
const handBrakeTitleId = Number(source?.handBrakeTitleId);
|
||
const durationSecondsRaw = Number(source?.durationSeconds ?? source?.duration ?? 0);
|
||
const durationSeconds = Number.isFinite(durationSecondsRaw) && durationSecondsRaw > 0
|
||
? Math.trunc(durationSecondsRaw)
|
||
: 0;
|
||
const sizeBytesRaw = Number(source?.sizeBytes ?? source?.size ?? 0);
|
||
const sizeBytes = Number.isFinite(sizeBytesRaw) && sizeBytesRaw > 0
|
||
? Math.trunc(sizeBytesRaw)
|
||
: 0;
|
||
const durationLabelRaw = String(source?.durationLabel || '').trim();
|
||
const durationLabel = durationLabelRaw || formatDurationClock(durationSeconds);
|
||
const sourceAudioTracks = Array.isArray(source?.audioTracks) ? source.audioTracks : [];
|
||
const fallbackAudioTrackPreview = sourceAudioTracks
|
||
.slice(0, 8)
|
||
.map((track) => {
|
||
const rawTrackId = Number(track?.sourceTrackId ?? track?.id);
|
||
const trackId = Number.isFinite(rawTrackId) && rawTrackId > 0 ? Math.trunc(rawTrackId) : null;
|
||
const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
|
||
const languageLabel = String(track?.languageLabel || track?.language || language).trim() || language;
|
||
const format = String(track?.format || '').trim();
|
||
const channels = String(track?.channels || '').trim();
|
||
const parts = [];
|
||
if (trackId !== null) {
|
||
parts.push(`#${trackId}`);
|
||
}
|
||
parts.push(language);
|
||
parts.push(languageLabel);
|
||
if (format) {
|
||
parts.push(format);
|
||
}
|
||
if (channels) {
|
||
parts.push(channels);
|
||
}
|
||
return parts.join(' | ');
|
||
})
|
||
.filter((line) => line.length > 0);
|
||
const sourceAudioTrackPreview = Array.isArray(source?.audioTrackPreview)
|
||
? source.audioTrackPreview.map((line) => String(line || '').trim()).filter((line) => line.length > 0)
|
||
: [];
|
||
const audioTrackPreview = sourceAudioTrackPreview.length > 0 ? sourceAudioTrackPreview : fallbackAudioTrackPreview;
|
||
const audioSummary = String(source?.audioSummary || '').trim() || buildHandBrakeAudioSummary(audioTrackPreview);
|
||
|
||
return {
|
||
playlistId,
|
||
playlistFile: toPlaylistFile(playlistId),
|
||
titleId: Number.isFinite(titleId) ? Math.trunc(titleId) : null,
|
||
durationSeconds,
|
||
durationLabel: durationLabel || null,
|
||
sizeBytes,
|
||
score: Number.isFinite(score) ? score : null,
|
||
recommended: Boolean(source?.recommended),
|
||
evaluationLabel: source?.evaluationLabel || null,
|
||
sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
|
||
segmentCommand: source?.segmentCommand
|
||
|| segmentEntry?.segmentCommand
|
||
|| `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
|
||
segmentFiles: Array.isArray(source?.segmentFiles) && source.segmentFiles.length > 0
|
||
? source.segmentFiles
|
||
: (Array.isArray(segmentEntry?.segmentFiles) ? segmentEntry.segmentFiles : []),
|
||
handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0
|
||
? Math.trunc(handBrakeTitleId)
|
||
: null,
|
||
audioSummary: audioSummary || null,
|
||
audioTrackPreview
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildHandBrakeAudioTrackPreview(titleInfo) {
|
||
const tracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : [];
|
||
return tracks
|
||
.map((track) => {
|
||
const rawTrackId = Number(track?.sourceTrackId ?? track?.id);
|
||
const trackId = Number.isFinite(rawTrackId) && rawTrackId > 0 ? Math.trunc(rawTrackId) : null;
|
||
const language = normalizeTrackLanguage(track?.language || track?.languageLabel || 'und');
|
||
const description = String(track?.description || track?.title || '').trim();
|
||
const codec = String(track?.codecName || track?.format || '').trim();
|
||
const channels = String(track?.channels || '').trim();
|
||
|
||
const parts = [];
|
||
if (trackId !== null) {
|
||
parts.push(`#${trackId}`);
|
||
}
|
||
parts.push(language);
|
||
if (description) {
|
||
parts.push(description);
|
||
} else {
|
||
if (codec) {
|
||
parts.push(codec);
|
||
}
|
||
if (channels) {
|
||
parts.push(channels);
|
||
}
|
||
}
|
||
return parts.join(' | ').trim();
|
||
})
|
||
.filter((line) => line.length > 0);
|
||
}
|
||
|
||
function buildHandBrakeAudioSummary(previewLines) {
|
||
const lines = Array.isArray(previewLines)
|
||
? previewLines.filter((line) => String(line || '').trim().length > 0)
|
||
: [];
|
||
if (lines.length === 0) {
|
||
return null;
|
||
}
|
||
return lines.slice(0, 3).join(' || ');
|
||
}
|
||
|
||
function normalizeHandBrakePlaylistScanCache(rawCache) {
|
||
if (!rawCache || typeof rawCache !== 'object') {
|
||
return null;
|
||
}
|
||
|
||
const inputPath = String(rawCache?.inputPath || '').trim() || null;
|
||
const source = String(rawCache?.source || '').trim() || 'HANDBRAKE_SCAN_PLAYLIST_MAP';
|
||
const generatedAt = String(rawCache?.generatedAt || '').trim() || null;
|
||
|
||
const rawEntries = [];
|
||
if (rawCache?.byPlaylist && typeof rawCache.byPlaylist === 'object') {
|
||
for (const [key, value] of Object.entries(rawCache.byPlaylist)) {
|
||
rawEntries.push({ key, value });
|
||
}
|
||
} else if (Array.isArray(rawCache?.playlists)) {
|
||
for (const item of rawCache.playlists) {
|
||
rawEntries.push({ key: item?.playlistId || item?.playlistFile || null, value: item });
|
||
}
|
||
}
|
||
|
||
const byPlaylist = {};
|
||
for (const entry of rawEntries) {
|
||
const row = entry?.value && typeof entry.value === 'object' ? entry.value : null;
|
||
const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || entry?.key || null);
|
||
if (!playlistId) {
|
||
continue;
|
||
}
|
||
const rawHandBrakeTitleId = Number(row?.handBrakeTitleId ?? row?.titleId);
|
||
const handBrakeTitleId = Number.isFinite(rawHandBrakeTitleId) && rawHandBrakeTitleId > 0
|
||
? Math.trunc(rawHandBrakeTitleId)
|
||
: null;
|
||
const titleInfo = row?.titleInfo && typeof row.titleInfo === 'object' ? row.titleInfo : null;
|
||
const audioTrackPreview = Array.isArray(row?.audioTrackPreview)
|
||
? row.audioTrackPreview.map((line) => String(line || '').trim()).filter((line) => line.length > 0)
|
||
: buildHandBrakeAudioTrackPreview(titleInfo);
|
||
const audioSummary = String(row?.audioSummary || '').trim() || buildHandBrakeAudioSummary(audioTrackPreview);
|
||
|
||
byPlaylist[playlistId] = {
|
||
playlistId,
|
||
handBrakeTitleId,
|
||
titleInfo,
|
||
audioTrackPreview,
|
||
audioSummary: audioSummary || null
|
||
};
|
||
}
|
||
|
||
if (Object.keys(byPlaylist).length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
generatedAt,
|
||
source,
|
||
inputPath,
|
||
byPlaylist
|
||
};
|
||
}
|
||
|
||
function getCachedHandBrakePlaylistEntry(scanCache, playlistIdRaw) {
|
||
const playlistId = normalizePlaylistId(playlistIdRaw);
|
||
if (!playlistId) {
|
||
return null;
|
||
}
|
||
const normalized = normalizeHandBrakePlaylistScanCache(scanCache);
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
return normalized.byPlaylist[playlistId] || null;
|
||
}
|
||
|
||
function hasCachedHandBrakeDataForPlaylistCandidates(scanCache, playlistCandidates = []) {
|
||
const normalized = normalizeHandBrakePlaylistScanCache(scanCache);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
|
||
const candidateIds = (Array.isArray(playlistCandidates) ? playlistCandidates : [])
|
||
.map((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile || item))
|
||
.filter(Boolean);
|
||
if (candidateIds.length === 0) {
|
||
return false;
|
||
}
|
||
|
||
return candidateIds.every((playlistId) => {
|
||
const row = normalized.byPlaylist[playlistId];
|
||
return Boolean(row && row.handBrakeTitleId && row.titleInfo);
|
||
});
|
||
}
|
||
|
||
function buildHandBrakePlaylistScanCache(scanJson, playlistCandidates = [], rawPath = null) {
|
||
const candidateMetaByPlaylist = new Map();
|
||
for (const row of (Array.isArray(playlistCandidates) ? playlistCandidates : [])) {
|
||
const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || row);
|
||
if (!playlistId || candidateMetaByPlaylist.has(playlistId)) {
|
||
continue;
|
||
}
|
||
candidateMetaByPlaylist.set(playlistId, {
|
||
expectedMakemkvTitleId: normalizeNonNegativeInteger(row?.titleId),
|
||
expectedDurationSeconds: Number(row?.durationSeconds || 0) || null,
|
||
expectedSizeBytes: Number(row?.sizeBytes || 0) || null
|
||
});
|
||
}
|
||
|
||
const candidateIds = Array.from(new Set(
|
||
(Array.isArray(playlistCandidates) ? playlistCandidates : [])
|
||
.map((item) => normalizePlaylistId(item?.playlistId || item?.playlistFile || item))
|
||
.filter(Boolean)
|
||
));
|
||
|
||
const byPlaylist = {};
|
||
for (const playlistId of candidateIds) {
|
||
const expected = candidateMetaByPlaylist.get(playlistId) || {};
|
||
const handBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(scanJson, playlistId, expected);
|
||
if (!handBrakeTitleId) {
|
||
continue;
|
||
}
|
||
const titleInfo = parseHandBrakeSelectedTitleInfo(scanJson, {
|
||
playlistId,
|
||
handBrakeTitleId
|
||
});
|
||
if (!titleInfo) {
|
||
continue;
|
||
}
|
||
if (!isHandBrakePlaylistCacheEntryCompatible({
|
||
playlistId,
|
||
handBrakeTitleId,
|
||
titleInfo
|
||
}, playlistId, expected)) {
|
||
continue;
|
||
}
|
||
const audioTrackPreview = buildHandBrakeAudioTrackPreview(titleInfo);
|
||
byPlaylist[playlistId] = {
|
||
playlistId,
|
||
handBrakeTitleId,
|
||
titleInfo,
|
||
audioTrackPreview,
|
||
audioSummary: buildHandBrakeAudioSummary(audioTrackPreview)
|
||
};
|
||
}
|
||
|
||
return normalizeHandBrakePlaylistScanCache({
|
||
generatedAt: nowIso(),
|
||
source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
|
||
inputPath: rawPath || null,
|
||
byPlaylist
|
||
});
|
||
}
|
||
|
||
function enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, scanCache) {
|
||
const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' ? playlistAnalysis : null;
|
||
const normalizedCache = normalizeHandBrakePlaylistScanCache(scanCache);
|
||
if (!analysis || !normalizedCache) {
|
||
return analysis;
|
||
}
|
||
|
||
const enrichRow = (row) => {
|
||
const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null);
|
||
if (!playlistId) {
|
||
return row;
|
||
}
|
||
const cached = normalizedCache.byPlaylist[playlistId];
|
||
if (!cached) {
|
||
return row;
|
||
}
|
||
return {
|
||
...row,
|
||
handBrakeTitleId: cached.handBrakeTitleId || null,
|
||
audioSummary: cached.audioSummary || null,
|
||
audioTrackPreview: Array.isArray(cached.audioTrackPreview) ? cached.audioTrackPreview : []
|
||
};
|
||
};
|
||
|
||
const recommendationPlaylistId = normalizePlaylistId(analysis?.recommendation?.playlistId);
|
||
const recommendationCached = recommendationPlaylistId
|
||
? normalizedCache.byPlaylist[recommendationPlaylistId] || null
|
||
: null;
|
||
|
||
return {
|
||
...analysis,
|
||
evaluatedCandidates: Array.isArray(analysis?.evaluatedCandidates)
|
||
? analysis.evaluatedCandidates.map((row) => enrichRow(row))
|
||
: [],
|
||
candidates: Array.isArray(analysis?.candidates)
|
||
? analysis.candidates.map((row) => enrichRow(row))
|
||
: [],
|
||
titles: Array.isArray(analysis?.titles)
|
||
? analysis.titles.map((row) => enrichRow(row))
|
||
: [],
|
||
recommendation: analysis?.recommendation && typeof analysis.recommendation === 'object'
|
||
? {
|
||
...analysis.recommendation,
|
||
handBrakeTitleId: recommendationCached?.handBrakeTitleId || null,
|
||
audioSummary: recommendationCached?.audioSummary || null,
|
||
audioTrackPreview: Array.isArray(recommendationCached?.audioTrackPreview)
|
||
? recommendationCached.audioTrackPreview
|
||
: []
|
||
}
|
||
: analysis?.recommendation || null
|
||
};
|
||
}
|
||
|
||
function pickTitleIdForPlaylist(playlistAnalysis, playlistId) {
|
||
const normalized = normalizePlaylistId(playlistId);
|
||
if (!normalized || !playlistAnalysis) {
|
||
return null;
|
||
}
|
||
|
||
const playlistMap = playlistAnalysis?.playlistToTitleId
|
||
&& typeof playlistAnalysis.playlistToTitleId === 'object'
|
||
? playlistAnalysis.playlistToTitleId
|
||
: null;
|
||
if (playlistMap) {
|
||
const byFile = Number(playlistMap[`${normalized}.mpls`]);
|
||
if (Number.isFinite(byFile) && byFile >= 0) {
|
||
return Math.trunc(byFile);
|
||
}
|
||
const byId = Number(playlistMap[normalized]);
|
||
if (Number.isFinite(byId) && byId >= 0) {
|
||
return Math.trunc(byId);
|
||
}
|
||
}
|
||
|
||
const sources = [
|
||
...(Array.isArray(playlistAnalysis?.evaluatedCandidates) ? playlistAnalysis.evaluatedCandidates : []),
|
||
...(Array.isArray(playlistAnalysis?.candidates) ? playlistAnalysis.candidates : []),
|
||
...(Array.isArray(playlistAnalysis?.titles) ? playlistAnalysis.titles : [])
|
||
];
|
||
|
||
const matches = sources
|
||
.filter((item) => normalizePlaylistId(item?.playlistId) === normalized)
|
||
.map((item) => ({
|
||
titleId: Number(item?.titleId ?? item?.id),
|
||
durationSeconds: Number(item?.durationSeconds || 0),
|
||
sizeBytes: Number(item?.sizeBytes || 0)
|
||
}))
|
||
.filter((item) => Number.isFinite(item.titleId) && item.titleId >= 0)
|
||
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
|
||
|
||
return matches.length > 0 ? matches[0].titleId : null;
|
||
}
|
||
|
||
function normalizeReviewTitleId(rawValue) {
|
||
const value = Number(rawValue);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
return null;
|
||
}
|
||
return Math.trunc(value);
|
||
}
|
||
|
||
function applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId) {
|
||
const normalizedTitleId = normalizeReviewTitleId(selectedEncodeTitleId);
|
||
if (!normalizedTitleId) {
|
||
return {
|
||
plan: encodePlan,
|
||
selectedTitle: null
|
||
};
|
||
}
|
||
|
||
const titles = Array.isArray(encodePlan?.titles) ? encodePlan.titles : [];
|
||
const selectedTitle = titles.find((item) => Number(item?.id) === normalizedTitleId) || null;
|
||
if (!selectedTitle) {
|
||
const error = new Error(`Gewählter Titel #${normalizedTitleId} ist nicht vorhanden.`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const eligible = selectedTitle?.eligibleForEncode !== undefined
|
||
? Boolean(selectedTitle.eligibleForEncode)
|
||
: Boolean(selectedTitle?.selectedByMinLength);
|
||
if (!eligible) {
|
||
const error = new Error(`Titel #${normalizedTitleId} ist laut MIN_LENGTH_MINUTES nicht encodierbar.`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const remappedTitles = titles.map((title) => {
|
||
const isEncodeInput = Number(title?.id) === normalizedTitleId;
|
||
|
||
const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => {
|
||
const selectedByRule = Boolean(track?.selectedByRule);
|
||
const selectedForEncode = isEncodeInput && selectedByRule;
|
||
const previewActions = Array.isArray(track?.encodePreviewActions) ? track.encodePreviewActions : [];
|
||
const previewSummary = track?.encodePreviewSummary || 'Nicht übernommen';
|
||
|
||
return {
|
||
...track,
|
||
selectedForEncode,
|
||
encodeActions: selectedForEncode ? previewActions : [],
|
||
encodeActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen'
|
||
};
|
||
});
|
||
|
||
const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
|
||
const selectedByRule = Boolean(track?.selectedByRule);
|
||
const selectedForEncode = isEncodeInput && selectedByRule;
|
||
const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : [];
|
||
const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen';
|
||
|
||
return {
|
||
...track,
|
||
selectedForEncode,
|
||
burnIn: selectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false,
|
||
forced: selectedForEncode ? Boolean(track?.subtitlePreviewForced) : false,
|
||
forcedOnly: selectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false,
|
||
defaultTrack: selectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false,
|
||
flags: selectedForEncode ? previewFlags : [],
|
||
subtitleActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen'
|
||
};
|
||
});
|
||
|
||
return {
|
||
...title,
|
||
encodeInput: isEncodeInput,
|
||
selectedForEncode: isEncodeInput,
|
||
audioTracks,
|
||
subtitleTracks
|
||
};
|
||
});
|
||
|
||
return {
|
||
plan: {
|
||
...encodePlan,
|
||
titles: remappedTitles,
|
||
encodeInputTitleId: normalizedTitleId,
|
||
encodeInputPath: selectedTitle?.filePath || null,
|
||
titleSelectionRequired: false
|
||
},
|
||
selectedTitle
|
||
};
|
||
}
|
||
|
||
function normalizeTrackIdList(rawList) {
|
||
const list = Array.isArray(rawList) ? rawList : [];
|
||
const seen = new Set();
|
||
const output = [];
|
||
for (const item of list) {
|
||
const value = Number(item);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
continue;
|
||
}
|
||
const normalized = Math.trunc(value);
|
||
const key = String(normalized);
|
||
if (seen.has(key)) {
|
||
continue;
|
||
}
|
||
seen.add(key);
|
||
output.push(normalized);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function isBurnedSubtitleTrack(track) {
|
||
const previewFlags = Array.isArray(track?.subtitlePreviewFlags)
|
||
? track.subtitlePreviewFlags
|
||
: (Array.isArray(track?.flags) ? track.flags : []);
|
||
const hasBurnedFlag = previewFlags.some((flag) => String(flag || '').trim().toLowerCase() === 'burned');
|
||
const summary = `${track?.subtitlePreviewSummary || ''} ${track?.subtitleActionSummary || ''}`;
|
||
return Boolean(
|
||
track?.subtitlePreviewBurnIn
|
||
|| track?.burnIn
|
||
|| hasBurnedFlag
|
||
|| /burned/i.test(summary)
|
||
);
|
||
}
|
||
|
||
function normalizeScriptIdList(rawList) {
|
||
const list = Array.isArray(rawList) ? rawList : [];
|
||
const seen = new Set();
|
||
const output = [];
|
||
for (const item of list) {
|
||
const value = Number(item);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
continue;
|
||
}
|
||
const normalized = Math.trunc(value);
|
||
const key = String(normalized);
|
||
if (seen.has(key)) {
|
||
continue;
|
||
}
|
||
seen.add(key);
|
||
output.push(normalized);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function applyManualTrackSelectionToPlan(encodePlan, selectedTrackSelection) {
|
||
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null;
|
||
if (!plan || !Array.isArray(plan.titles)) {
|
||
return {
|
||
plan: encodePlan,
|
||
selectionApplied: false,
|
||
audioTrackIds: [],
|
||
subtitleTrackIds: []
|
||
};
|
||
}
|
||
|
||
const encodeInputTitleId = normalizeReviewTitleId(plan.encodeInputTitleId);
|
||
if (!encodeInputTitleId) {
|
||
return {
|
||
plan,
|
||
selectionApplied: false,
|
||
audioTrackIds: [],
|
||
subtitleTrackIds: []
|
||
};
|
||
}
|
||
|
||
const selectionPayload = selectedTrackSelection && typeof selectedTrackSelection === 'object'
|
||
? selectedTrackSelection
|
||
: null;
|
||
if (!selectionPayload) {
|
||
return {
|
||
plan,
|
||
selectionApplied: false,
|
||
audioTrackIds: [],
|
||
subtitleTrackIds: []
|
||
};
|
||
}
|
||
|
||
const rawSelection = selectionPayload[encodeInputTitleId]
|
||
|| selectionPayload[String(encodeInputTitleId)]
|
||
|| selectionPayload;
|
||
if (!rawSelection || typeof rawSelection !== 'object') {
|
||
return {
|
||
plan,
|
||
selectionApplied: false,
|
||
audioTrackIds: [],
|
||
subtitleTrackIds: []
|
||
};
|
||
}
|
||
|
||
const encodeTitle = plan.titles.find((title) => Number(title?.id) === encodeInputTitleId) || null;
|
||
if (!encodeTitle) {
|
||
return {
|
||
plan,
|
||
selectionApplied: false,
|
||
audioTrackIds: [],
|
||
subtitleTrackIds: []
|
||
};
|
||
}
|
||
|
||
const validAudioTrackIds = new Set(
|
||
(Array.isArray(encodeTitle.audioTracks) ? encodeTitle.audioTracks : [])
|
||
.map((track) => Number(track?.id))
|
||
.filter((id) => Number.isFinite(id))
|
||
.map((id) => Math.trunc(id))
|
||
);
|
||
const validSubtitleTrackIds = new Set(
|
||
(Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : [])
|
||
.filter((track) => !isBurnedSubtitleTrack(track))
|
||
.map((track) => Number(track?.id))
|
||
.filter((id) => Number.isFinite(id))
|
||
.map((id) => Math.trunc(id))
|
||
);
|
||
|
||
const requestedAudioTrackIds = normalizeTrackIdList(rawSelection.audioTrackIds)
|
||
.filter((id) => validAudioTrackIds.has(id));
|
||
const requestedSubtitleTrackIds = normalizeTrackIdList(rawSelection.subtitleTrackIds)
|
||
.filter((id) => validSubtitleTrackIds.has(id));
|
||
|
||
const audioSelectionSet = new Set(requestedAudioTrackIds.map((id) => String(id)));
|
||
const subtitleSelectionSet = new Set(requestedSubtitleTrackIds.map((id) => String(id)));
|
||
|
||
const remappedTitles = plan.titles.map((title) => {
|
||
const isEncodeInput = Number(title?.id) === encodeInputTitleId;
|
||
|
||
const audioTracks = (Array.isArray(title?.audioTracks) ? title.audioTracks : []).map((track) => {
|
||
const trackId = Number(track?.id);
|
||
const selectedForEncode = isEncodeInput && audioSelectionSet.has(String(Math.trunc(trackId)));
|
||
const previewActions = Array.isArray(track?.encodePreviewActions) ? track.encodePreviewActions : [];
|
||
const previewSummary = track?.encodePreviewSummary || 'Nicht übernommen';
|
||
return {
|
||
...track,
|
||
selectedForEncode,
|
||
encodeActions: selectedForEncode ? previewActions : [],
|
||
encodeActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen'
|
||
};
|
||
});
|
||
|
||
const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
|
||
const trackId = Number(track?.id);
|
||
const selectedForEncode = isEncodeInput && subtitleSelectionSet.has(String(Math.trunc(trackId)));
|
||
const previewFlags = Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : [];
|
||
const previewSummary = track?.subtitlePreviewSummary || 'Nicht übernommen';
|
||
return {
|
||
...track,
|
||
selectedForEncode,
|
||
burnIn: selectedForEncode ? Boolean(track?.subtitlePreviewBurnIn) : false,
|
||
forced: selectedForEncode ? Boolean(track?.subtitlePreviewForced) : false,
|
||
forcedOnly: selectedForEncode ? Boolean(track?.subtitlePreviewForcedOnly) : false,
|
||
defaultTrack: selectedForEncode ? Boolean(track?.subtitlePreviewDefaultTrack) : false,
|
||
flags: selectedForEncode ? previewFlags : [],
|
||
subtitleActionSummary: selectedForEncode ? previewSummary : 'Nicht übernommen'
|
||
};
|
||
});
|
||
|
||
return {
|
||
...title,
|
||
encodeInput: isEncodeInput,
|
||
selectedForEncode: isEncodeInput,
|
||
audioTracks,
|
||
subtitleTracks
|
||
};
|
||
});
|
||
|
||
return {
|
||
plan: {
|
||
...plan,
|
||
titles: remappedTitles,
|
||
manualTrackSelection: {
|
||
titleId: encodeInputTitleId,
|
||
audioTrackIds: requestedAudioTrackIds,
|
||
subtitleTrackIds: requestedSubtitleTrackIds,
|
||
updatedAt: nowIso()
|
||
}
|
||
},
|
||
selectionApplied: true,
|
||
audioTrackIds: requestedAudioTrackIds,
|
||
subtitleTrackIds: requestedSubtitleTrackIds
|
||
};
|
||
}
|
||
|
||
function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) {
|
||
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : null;
|
||
if (!plan || !Array.isArray(plan.titles)) {
|
||
return null;
|
||
}
|
||
|
||
const encodeInputTitleId = normalizeReviewTitleId(plan.encodeInputTitleId);
|
||
let encodeTitle = null;
|
||
|
||
if (encodeInputTitleId) {
|
||
encodeTitle = plan.titles.find((title) => Number(title?.id) === encodeInputTitleId) || null;
|
||
}
|
||
if (!encodeTitle && inputPath) {
|
||
encodeTitle = plan.titles.find((title) => String(title?.filePath || '') === String(inputPath || '')) || null;
|
||
}
|
||
|
||
if (!encodeTitle) {
|
||
return null;
|
||
}
|
||
|
||
const audioTrackIds = normalizeTrackIdList(
|
||
(Array.isArray(encodeTitle.audioTracks) ? encodeTitle.audioTracks : [])
|
||
.filter((track) => Boolean(track?.selectedForEncode))
|
||
.map((track) => track?.sourceTrackId ?? track?.id)
|
||
);
|
||
const subtitleTrackIds = normalizeTrackIdList(
|
||
(Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : [])
|
||
.filter((track) => Boolean(track?.selectedForEncode))
|
||
.map((track) => track?.sourceTrackId ?? track?.id)
|
||
);
|
||
const selectedSubtitleTracks = (Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : [])
|
||
.filter((track) => Boolean(track?.selectedForEncode));
|
||
const subtitleBurnTrackId = normalizeTrackIdList(
|
||
selectedSubtitleTracks.filter((track) => Boolean(track?.burnIn)).map((track) => track?.sourceTrackId ?? track?.id)
|
||
)[0] || null;
|
||
const subtitleDefaultTrackId = normalizeTrackIdList(
|
||
selectedSubtitleTracks.filter((track) => Boolean(track?.defaultTrack)).map((track) => track?.sourceTrackId ?? track?.id)
|
||
)[0] || null;
|
||
const subtitleForcedTrackId = normalizeTrackIdList(
|
||
selectedSubtitleTracks.filter((track) => Boolean(track?.forced)).map((track) => track?.sourceTrackId ?? track?.id)
|
||
)[0] || null;
|
||
const subtitleForcedOnly = selectedSubtitleTracks.some((track) => Boolean(track?.forcedOnly));
|
||
|
||
return {
|
||
titleId: Number(encodeTitle?.id) || null,
|
||
audioTrackIds,
|
||
subtitleTrackIds,
|
||
subtitleBurnTrackId,
|
||
subtitleDefaultTrackId,
|
||
subtitleForcedTrackId,
|
||
subtitleForcedOnly
|
||
};
|
||
}
|
||
|
||
function buildPlaylistSegmentFileSet(playlistAnalysis, selectedPlaylistId = null) {
|
||
const analysis = playlistAnalysis && typeof playlistAnalysis === 'object' ? playlistAnalysis : null;
|
||
if (!analysis) {
|
||
return new Set();
|
||
}
|
||
|
||
const segmentMap = analysis.playlistSegments && typeof analysis.playlistSegments === 'object'
|
||
? analysis.playlistSegments
|
||
: {};
|
||
|
||
const set = new Set();
|
||
const appendSegments = (playlistIdRaw) => {
|
||
const playlistId = normalizePlaylistId(playlistIdRaw);
|
||
if (!playlistId) {
|
||
return;
|
||
}
|
||
const segmentEntry = segmentMap[playlistId] || segmentMap[`${playlistId}.mpls`] || null;
|
||
const segmentFiles = Array.isArray(segmentEntry?.segmentFiles) ? segmentEntry.segmentFiles : [];
|
||
for (const file of segmentFiles) {
|
||
const name = path.basename(String(file || '').trim()).toLowerCase();
|
||
if (!name) {
|
||
continue;
|
||
}
|
||
set.add(name);
|
||
}
|
||
};
|
||
|
||
if (selectedPlaylistId) {
|
||
appendSegments(selectedPlaylistId);
|
||
return set;
|
||
}
|
||
|
||
appendSegments(analysis?.recommendation?.playlistId || null);
|
||
if (set.size > 0) {
|
||
return set;
|
||
}
|
||
|
||
const candidates = Array.isArray(analysis.evaluatedCandidates) ? analysis.evaluatedCandidates : [];
|
||
for (const candidate of candidates) {
|
||
appendSegments(candidate?.playlistId || null);
|
||
}
|
||
return set;
|
||
}
|
||
|
||
|
||
function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedPlaylistId = null } = {}) {
|
||
const sourcePath = String(rawPath || '').trim();
|
||
if (!sourcePath) {
|
||
return {
|
||
mediaFiles: [],
|
||
source: 'none'
|
||
};
|
||
}
|
||
|
||
try {
|
||
const sourceStat = fs.statSync(sourcePath);
|
||
if (sourceStat.isFile()) {
|
||
const ext = path.extname(sourcePath).toLowerCase();
|
||
if (
|
||
ext === '.mkv'
|
||
|| ext === '.mp4'
|
||
|| isLikelyExtensionlessDvdImageFile(sourcePath, sourceStat.size)
|
||
) {
|
||
return {
|
||
mediaFiles: [{ path: sourcePath, size: Number(sourceStat.size || 0) }],
|
||
source: ext === '' ? 'single_extensionless' : 'single_file'
|
||
};
|
||
}
|
||
return {
|
||
mediaFiles: [],
|
||
source: 'none'
|
||
};
|
||
}
|
||
} catch (_error) {
|
||
return {
|
||
mediaFiles: [],
|
||
source: 'none'
|
||
};
|
||
}
|
||
|
||
const topLevelExtensionlessImages = listTopLevelExtensionlessDvdImages(sourcePath);
|
||
if (topLevelExtensionlessImages.length > 0) {
|
||
return {
|
||
mediaFiles: topLevelExtensionlessImages,
|
||
source: 'dvd_image'
|
||
};
|
||
}
|
||
|
||
const primary = findMediaFiles(sourcePath, ['.mkv', '.mp4']);
|
||
if (primary.length > 0) {
|
||
return {
|
||
mediaFiles: primary,
|
||
source: 'mkv'
|
||
};
|
||
}
|
||
|
||
const streamDir = path.join(sourcePath, 'BDMV', 'STREAM');
|
||
const backupRoot = fs.existsSync(streamDir) ? streamDir : sourcePath;
|
||
let backupFiles = findMediaFiles(backupRoot, ['.m2ts']);
|
||
if (backupFiles.length === 0) {
|
||
const vobFiles = findMediaFiles(sourcePath, ['.vob']);
|
||
if (vobFiles.length > 0) {
|
||
return {
|
||
mediaFiles: vobFiles,
|
||
source: 'dvd'
|
||
};
|
||
}
|
||
return {
|
||
mediaFiles: [],
|
||
source: 'none'
|
||
};
|
||
}
|
||
|
||
const allowedSegments = buildPlaylistSegmentFileSet(playlistAnalysis, selectedPlaylistId);
|
||
if (allowedSegments.size > 0) {
|
||
const filtered = backupFiles.filter((file) => allowedSegments.has(path.basename(file.path).toLowerCase()));
|
||
if (filtered.length > 0) {
|
||
backupFiles = filtered;
|
||
}
|
||
}
|
||
|
||
return {
|
||
mediaFiles: backupFiles,
|
||
source: 'backup'
|
||
};
|
||
}
|
||
|
||
function hasBluRayBackupStructure(rawPath) {
|
||
if (!rawPath) {
|
||
return false;
|
||
}
|
||
|
||
const bdmvDir = path.join(rawPath, 'BDMV');
|
||
const streamDir = path.join(bdmvDir, 'STREAM');
|
||
|
||
try {
|
||
return fs.existsSync(bdmvDir) && fs.existsSync(streamDir);
|
||
} catch (_error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function findPreferredRawInput(rawPath, options = {}) {
|
||
const { mediaFiles } = collectRawMediaCandidates(rawPath, options);
|
||
if (!Array.isArray(mediaFiles) || mediaFiles.length === 0) {
|
||
return null;
|
||
}
|
||
return mediaFiles[0];
|
||
}
|
||
|
||
function extractManualSelectionPayloadFromPlan(encodePlan) {
|
||
const selection = extractHandBrakeTrackSelectionFromPlan(encodePlan);
|
||
if (!selection) {
|
||
return null;
|
||
}
|
||
return {
|
||
audioTrackIds: normalizeTrackIdList(selection.audioTrackIds),
|
||
subtitleTrackIds: normalizeTrackIdList(selection.subtitleTrackIds)
|
||
};
|
||
}
|
||
|
||
function normalizeChainIdList(rawList) {
|
||
const list = Array.isArray(rawList) ? rawList : [];
|
||
const seen = new Set();
|
||
const output = [];
|
||
for (const item of list) {
|
||
const value = Number(item);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
continue;
|
||
}
|
||
const normalized = Math.trunc(value);
|
||
const key = String(normalized);
|
||
if (seen.has(key)) {
|
||
continue;
|
||
}
|
||
seen.add(key);
|
||
output.push(normalized);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function normalizeUserPresetForPlan(rawPreset) {
|
||
if (!rawPreset || typeof rawPreset !== 'object') {
|
||
return null;
|
||
}
|
||
const rawId = Number(rawPreset.id);
|
||
const presetId = Number.isFinite(rawId) && rawId > 0 ? Math.trunc(rawId) : null;
|
||
const name = String(rawPreset.name || '').trim();
|
||
const handbrakePreset = String(rawPreset.handbrakePreset || '').trim();
|
||
const extraArgs = String(rawPreset.extraArgs || '').trim();
|
||
if (!presetId && !name && !handbrakePreset && !extraArgs) {
|
||
return null;
|
||
}
|
||
return {
|
||
id: presetId,
|
||
name: name || (presetId ? `Preset #${presetId}` : 'User-Preset'),
|
||
handbrakePreset: handbrakePreset || null,
|
||
extraArgs: extraArgs || null
|
||
};
|
||
}
|
||
|
||
function buildScriptDescriptorList(scriptIds, sourceScripts = []) {
|
||
const normalizedIds = normalizeScriptIdList(scriptIds);
|
||
if (normalizedIds.length === 0) {
|
||
return [];
|
||
}
|
||
const source = Array.isArray(sourceScripts) ? sourceScripts : [];
|
||
const namesById = new Map(
|
||
source
|
||
.map((item) => {
|
||
const id = Number(item?.id ?? item?.scriptId);
|
||
const normalizedId = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null;
|
||
const name = String(item?.name || '').trim();
|
||
if (!normalizedId || !name) {
|
||
return null;
|
||
}
|
||
return [normalizedId, name];
|
||
})
|
||
.filter(Boolean)
|
||
);
|
||
return normalizedIds.map((id) => ({
|
||
id,
|
||
name: namesById.get(id) || `Skript #${id}`
|
||
}));
|
||
}
|
||
|
||
function findSelectedTitleInPlan(encodePlan) {
|
||
if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) {
|
||
return null;
|
||
}
|
||
const preferredTitleId = normalizeReviewTitleId(encodePlan.encodeInputTitleId);
|
||
if (preferredTitleId) {
|
||
const byId = encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === preferredTitleId) || null;
|
||
if (byId) {
|
||
return byId;
|
||
}
|
||
}
|
||
return encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) || null;
|
||
}
|
||
|
||
function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) {
|
||
const reviewTitles = Array.isArray(reviewPlan?.titles) ? reviewPlan.titles : [];
|
||
if (reviewTitles.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
|
||
if (!previousSelectedTitle) {
|
||
return null;
|
||
}
|
||
|
||
const previousPlaylistId = normalizePlaylistId(
|
||
previousSelectedTitle?.playlistId
|
||
|| previousPlan?.selectedPlaylistId
|
||
|| null
|
||
);
|
||
if (previousPlaylistId) {
|
||
const byPlaylist = reviewTitles.find((title) => normalizePlaylistId(title?.playlistId) === previousPlaylistId) || null;
|
||
const id = normalizeReviewTitleId(byPlaylist?.id);
|
||
if (id) {
|
||
return id;
|
||
}
|
||
}
|
||
|
||
const previousMakemkvTitleId = normalizeNonNegativeInteger(
|
||
previousSelectedTitle?.makemkvTitleId
|
||
?? previousPlan?.selectedMakemkvTitleId
|
||
?? null
|
||
);
|
||
if (previousMakemkvTitleId !== null) {
|
||
const byMakemkvTitleId = reviewTitles.find((title) => (
|
||
normalizeNonNegativeInteger(title?.makemkvTitleId) === previousMakemkvTitleId
|
||
)) || null;
|
||
const id = normalizeReviewTitleId(byMakemkvTitleId?.id);
|
||
if (id) {
|
||
return id;
|
||
}
|
||
}
|
||
|
||
const previousFileName = path.basename(
|
||
String(previousSelectedTitle?.filePath || previousSelectedTitle?.fileName || '').trim()
|
||
).toLowerCase();
|
||
if (previousFileName) {
|
||
const byFileName = reviewTitles.find((title) => {
|
||
const candidate = path.basename(
|
||
String(title?.filePath || title?.fileName || '').trim()
|
||
).toLowerCase();
|
||
return candidate && candidate === previousFileName;
|
||
}) || null;
|
||
const id = normalizeReviewTitleId(byFileName?.id);
|
||
if (id) {
|
||
return id;
|
||
}
|
||
}
|
||
|
||
const previousTitleId = normalizeReviewTitleId(previousPlan?.encodeInputTitleId);
|
||
if (!previousTitleId) {
|
||
return null;
|
||
}
|
||
const fallback = reviewTitles.find((title) => normalizeReviewTitleId(title?.id) === previousTitleId) || null;
|
||
return normalizeReviewTitleId(fallback?.id);
|
||
}
|
||
|
||
function mapSelectedSourceTrackIdsToTargetTrackIds(targetTracks, sourceTrackIds, { excludeBurned = false } = {}) {
|
||
const tracks = Array.isArray(targetTracks) ? targetTracks : [];
|
||
const allowedTracks = excludeBurned
|
||
? tracks.filter((track) => !isBurnedSubtitleTrack(track))
|
||
: tracks;
|
||
const requested = normalizeTrackIdList(sourceTrackIds);
|
||
if (requested.length === 0 || allowedTracks.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const mapped = [];
|
||
const seen = new Set();
|
||
for (const sourceTrackId of requested) {
|
||
const match = allowedTracks.find((track) => {
|
||
const sourceId = normalizeTrackIdList([track?.sourceTrackId])[0] || null;
|
||
const reviewId = normalizeTrackIdList([track?.id])[0] || null;
|
||
return sourceId === sourceTrackId || reviewId === sourceTrackId;
|
||
}) || null;
|
||
const targetId = normalizeTrackIdList([match?.id])[0] || null;
|
||
if (targetId === null) {
|
||
continue;
|
||
}
|
||
const key = String(targetId);
|
||
if (seen.has(key)) {
|
||
continue;
|
||
}
|
||
seen.add(key);
|
||
mapped.push(targetId);
|
||
}
|
||
return mapped;
|
||
}
|
||
|
||
function applyPreviousSelectionDefaultsToReviewPlan(reviewPlan, previousPlan = null) {
|
||
const hasReviewTitles = reviewPlan && Array.isArray(reviewPlan?.titles) && reviewPlan.titles.length > 0;
|
||
const hasPreviousTitles = previousPlan && Array.isArray(previousPlan?.titles) && previousPlan.titles.length > 0;
|
||
if (!hasReviewTitles || !hasPreviousTitles) {
|
||
return {
|
||
plan: reviewPlan,
|
||
applied: false,
|
||
selectedEncodeTitleId: normalizeReviewTitleId(reviewPlan?.encodeInputTitleId),
|
||
preEncodeScriptCount: 0,
|
||
postEncodeScriptCount: 0,
|
||
preEncodeChainCount: 0,
|
||
postEncodeChainCount: 0,
|
||
userPresetApplied: false
|
||
};
|
||
}
|
||
|
||
let nextPlan = reviewPlan;
|
||
const prefillTitleId = resolvePrefillEncodeTitleId(nextPlan, previousPlan);
|
||
let selectedTitleApplied = false;
|
||
if (prefillTitleId) {
|
||
try {
|
||
const remapped = applyEncodeTitleSelectionToPlan(nextPlan, prefillTitleId);
|
||
nextPlan = remapped.plan;
|
||
selectedTitleApplied = true;
|
||
} catch (_error) {
|
||
// Keep calculated review defaults when title from previous run is no longer available.
|
||
}
|
||
}
|
||
|
||
const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
|
||
const nextSelectedTitle = findSelectedTitleInPlan(nextPlan);
|
||
let trackSelectionApplied = false;
|
||
if (previousSelectedTitle && nextSelectedTitle) {
|
||
const previousAudioSourceIds = normalizeTrackIdList(
|
||
(Array.isArray(previousSelectedTitle?.audioTracks) ? previousSelectedTitle.audioTracks : [])
|
||
.filter((track) => Boolean(track?.selectedForEncode))
|
||
.map((track) => track?.sourceTrackId ?? track?.id)
|
||
);
|
||
const previousSubtitleSourceIds = normalizeTrackIdList(
|
||
(Array.isArray(previousSelectedTitle?.subtitleTracks) ? previousSelectedTitle.subtitleTracks : [])
|
||
.filter((track) => Boolean(track?.selectedForEncode))
|
||
.map((track) => track?.sourceTrackId ?? track?.id)
|
||
);
|
||
|
||
const mappedAudioTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds(
|
||
nextSelectedTitle?.audioTracks,
|
||
previousAudioSourceIds
|
||
);
|
||
const mappedSubtitleTrackIds = mapSelectedSourceTrackIdsToTargetTrackIds(
|
||
nextSelectedTitle?.subtitleTracks,
|
||
previousSubtitleSourceIds,
|
||
{ excludeBurned: true }
|
||
);
|
||
const fallbackAudioTrackIds = normalizeTrackIdList(
|
||
(Array.isArray(nextSelectedTitle?.audioTracks) ? nextSelectedTitle.audioTracks : [])
|
||
.filter((track) => Boolean(track?.selectedByRule))
|
||
.map((track) => track?.id)
|
||
);
|
||
const fallbackSubtitleTrackIds = normalizeTrackIdList(
|
||
(Array.isArray(nextSelectedTitle?.subtitleTracks) ? nextSelectedTitle.subtitleTracks : [])
|
||
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track))
|
||
.map((track) => track?.id)
|
||
);
|
||
const effectiveAudioTrackIds = previousAudioSourceIds.length > 0 && mappedAudioTrackIds.length === 0
|
||
? fallbackAudioTrackIds
|
||
: mappedAudioTrackIds;
|
||
const effectiveSubtitleTrackIds = previousSubtitleSourceIds.length > 0 && mappedSubtitleTrackIds.length === 0
|
||
? fallbackSubtitleTrackIds
|
||
: mappedSubtitleTrackIds;
|
||
|
||
const targetTitleId = normalizeReviewTitleId(nextSelectedTitle?.id || nextPlan?.encodeInputTitleId);
|
||
if (targetTitleId) {
|
||
const trackSelectionResult = applyManualTrackSelectionToPlan(nextPlan, {
|
||
[targetTitleId]: {
|
||
audioTrackIds: effectiveAudioTrackIds,
|
||
subtitleTrackIds: effectiveSubtitleTrackIds
|
||
}
|
||
});
|
||
nextPlan = trackSelectionResult.plan;
|
||
trackSelectionApplied = Boolean(trackSelectionResult.selectionApplied);
|
||
}
|
||
}
|
||
|
||
const preEncodeScriptIds = normalizeScriptIdList(previousPlan?.preEncodeScriptIds || []);
|
||
const postEncodeScriptIds = normalizeScriptIdList(previousPlan?.postEncodeScriptIds || []);
|
||
const preEncodeChainIds = normalizeChainIdList(previousPlan?.preEncodeChainIds || []);
|
||
const postEncodeChainIds = normalizeChainIdList(previousPlan?.postEncodeChainIds || []);
|
||
const userPreset = normalizeUserPresetForPlan(previousPlan?.userPreset || null);
|
||
|
||
nextPlan = {
|
||
...nextPlan,
|
||
preEncodeScriptIds,
|
||
postEncodeScriptIds,
|
||
preEncodeScripts: buildScriptDescriptorList(preEncodeScriptIds, previousPlan?.preEncodeScripts || []),
|
||
postEncodeScripts: buildScriptDescriptorList(postEncodeScriptIds, previousPlan?.postEncodeScripts || []),
|
||
preEncodeChainIds,
|
||
postEncodeChainIds,
|
||
userPreset,
|
||
reviewConfirmed: false,
|
||
reviewConfirmedAt: null,
|
||
prefilledFromPreviousRun: true,
|
||
prefilledFromPreviousRunAt: nowIso()
|
||
};
|
||
|
||
const applied = selectedTitleApplied
|
||
|| trackSelectionApplied
|
||
|| preEncodeScriptIds.length > 0
|
||
|| postEncodeScriptIds.length > 0
|
||
|| preEncodeChainIds.length > 0
|
||
|| postEncodeChainIds.length > 0
|
||
|| Boolean(userPreset);
|
||
|
||
return {
|
||
plan: nextPlan,
|
||
applied,
|
||
selectedEncodeTitleId: normalizeReviewTitleId(nextPlan?.encodeInputTitleId),
|
||
preEncodeScriptCount: preEncodeScriptIds.length,
|
||
postEncodeScriptCount: postEncodeScriptIds.length,
|
||
preEncodeChainCount: preEncodeChainIds.length,
|
||
postEncodeChainCount: postEncodeChainIds.length,
|
||
userPresetApplied: Boolean(userPreset)
|
||
};
|
||
}
|
||
|
||
class PipelineService extends EventEmitter {
|
||
constructor() {
|
||
super();
|
||
this.snapshot = {
|
||
state: 'IDLE',
|
||
activeJobId: null,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: null,
|
||
context: {}
|
||
};
|
||
this.detectedDisc = null;
|
||
this.activeProcess = null;
|
||
this.activeProcesses = new Map();
|
||
this.cancelRequestedByJob = new Set();
|
||
this.jobProgress = new Map();
|
||
this.lastPersistAt = 0;
|
||
this.lastProgressKey = null;
|
||
this.queueEntries = [];
|
||
this.queuePumpRunning = false;
|
||
this.queueEntrySeq = 1;
|
||
this.lastQueueSnapshot = {
|
||
maxParallelJobs: 1,
|
||
runningCount: 0,
|
||
runningJobs: [],
|
||
queuedJobs: [],
|
||
queuedCount: 0,
|
||
updatedAt: nowIso()
|
||
};
|
||
}
|
||
|
||
isRipSuccessful(job = null) {
|
||
if (Number(job?.rip_successful || 0) === 1) {
|
||
return true;
|
||
}
|
||
if (isJobFinished(job)) {
|
||
return true;
|
||
}
|
||
const mkInfo = this.safeParseJson(job?.makemkv_info_json);
|
||
return String(mkInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
||
}
|
||
|
||
isEncodeSuccessful(job = null) {
|
||
const handBrakeInfo = this.safeParseJson(job?.handbrake_info_json);
|
||
return String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
||
}
|
||
|
||
resolveDesiredRawFolderState(job = null) {
|
||
if (!this.isRipSuccessful(job)) {
|
||
return RAW_FOLDER_STATES.INCOMPLETE;
|
||
}
|
||
if (this.isEncodeSuccessful(job)) {
|
||
return RAW_FOLDER_STATES.COMPLETE;
|
||
}
|
||
return RAW_FOLDER_STATES.RIP_COMPLETE;
|
||
}
|
||
|
||
resolveCurrentRawPath(rawBaseDir, storedRawPath, extraBaseDirs = []) {
|
||
const stored = String(storedRawPath || '').trim();
|
||
if (!stored) {
|
||
return null;
|
||
}
|
||
const folderName = path.basename(stored);
|
||
const candidates = [stored];
|
||
const allBaseDirs = [rawBaseDir, ...extraBaseDirs].filter(Boolean);
|
||
for (const baseDir of allBaseDirs) {
|
||
const byFolder = path.join(baseDir, folderName);
|
||
if (!candidates.includes(byFolder)) {
|
||
candidates.push(byFolder);
|
||
}
|
||
}
|
||
for (const candidate of candidates) {
|
||
try {
|
||
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
||
return candidate;
|
||
}
|
||
} catch (_error) {
|
||
// ignore fs errors
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async migrateRawFolderNamingOnStartup(db) {
|
||
const settings = await settingsService.getSettingsMap();
|
||
const rawBaseDir = String(settings?.raw_dir || '').trim();
|
||
const rawExtraDirs = [
|
||
settings?.raw_dir_bluray,
|
||
settings?.raw_dir_dvd,
|
||
settings?.raw_dir_other
|
||
].map((d) => String(d || '').trim()).filter(Boolean);
|
||
const allRawDirs = [rawBaseDir, ...rawExtraDirs].filter((d) => d && fs.existsSync(d));
|
||
if (allRawDirs.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const rows = await db.all(`
|
||
SELECT id, title, year, detected_title, raw_path, status, last_state, rip_successful, makemkv_info_json, handbrake_info_json
|
||
FROM jobs
|
||
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
|
||
`);
|
||
if (!Array.isArray(rows) || rows.length === 0) {
|
||
return;
|
||
}
|
||
|
||
let renamedCount = 0;
|
||
let pathUpdateCount = 0;
|
||
let ripFlagUpdateCount = 0;
|
||
let conflictCount = 0;
|
||
let missingCount = 0;
|
||
const discoveredByJobId = new Map();
|
||
|
||
for (const scanDir of allRawDirs) {
|
||
try {
|
||
const dirEntries = fs.readdirSync(scanDir, { withFileTypes: true });
|
||
for (const entry of dirEntries) {
|
||
if (!entry.isDirectory()) {
|
||
continue;
|
||
}
|
||
const match = String(entry.name || '').match(/-\s*RAW\s*-\s*job-(\d+)\s*$/i);
|
||
if (!match) {
|
||
continue;
|
||
}
|
||
const mappedJobId = Number(match[1]);
|
||
if (!Number.isFinite(mappedJobId) || mappedJobId <= 0) {
|
||
continue;
|
||
}
|
||
const candidatePath = path.join(scanDir, entry.name);
|
||
let mtimeMs = 0;
|
||
try {
|
||
mtimeMs = Number(fs.statSync(candidatePath).mtimeMs || 0);
|
||
} catch (_error) {
|
||
// ignore fs errors and keep zero mtime
|
||
}
|
||
const current = discoveredByJobId.get(mappedJobId);
|
||
if (!current || mtimeMs > current.mtimeMs) {
|
||
discoveredByJobId.set(mappedJobId, {
|
||
path: candidatePath,
|
||
mtimeMs
|
||
});
|
||
}
|
||
}
|
||
} catch (scanError) {
|
||
logger.warn('startup:raw-dir-migrate:scan-failed', {
|
||
scanDir,
|
||
error: errorToMeta(scanError)
|
||
});
|
||
}
|
||
}
|
||
|
||
for (const row of rows) {
|
||
const jobId = Number(row?.id);
|
||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||
continue;
|
||
}
|
||
|
||
const ripSuccessful = this.isRipSuccessful(row);
|
||
if (ripSuccessful && Number(row?.rip_successful || 0) !== 1) {
|
||
await historyService.updateJob(jobId, { rip_successful: 1 });
|
||
ripFlagUpdateCount += 1;
|
||
}
|
||
|
||
const currentRawPath = this.resolveCurrentRawPath(rawBaseDir, row.raw_path, rawExtraDirs)
|
||
|| discoveredByJobId.get(jobId)?.path
|
||
|| null;
|
||
if (!currentRawPath) {
|
||
missingCount += 1;
|
||
continue;
|
||
}
|
||
|
||
// Keep renamed folder in the same base dir as the current path
|
||
const currentBaseDir = path.dirname(currentRawPath);
|
||
const currentFolderName = stripRawStatePrefix(path.basename(currentRawPath));
|
||
const folderYearMatch = currentFolderName.match(/\((19|20)\d{2}\)/);
|
||
const fallbackYear = folderYearMatch
|
||
? Number(String(folderYearMatch[0]).replace(/[()]/g, ''))
|
||
: null;
|
||
const metadataBase = buildRawMetadataBase({
|
||
title: row.title || row.detected_title || null,
|
||
year: row.year || null,
|
||
fallbackYear
|
||
}, jobId);
|
||
const desiredRawFolderState = this.resolveDesiredRawFolderState(row);
|
||
const desiredRawPath = path.join(
|
||
currentBaseDir,
|
||
buildRawDirName(metadataBase, jobId, { state: desiredRawFolderState })
|
||
);
|
||
|
||
let finalRawPath = currentRawPath;
|
||
if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(desiredRawPath)) {
|
||
if (fs.existsSync(desiredRawPath)) {
|
||
conflictCount += 1;
|
||
logger.warn('startup:raw-dir-migrate:target-exists', {
|
||
jobId,
|
||
currentRawPath,
|
||
desiredRawPath
|
||
});
|
||
} else {
|
||
try {
|
||
fs.renameSync(currentRawPath, desiredRawPath);
|
||
finalRawPath = desiredRawPath;
|
||
renamedCount += 1;
|
||
} catch (renameError) {
|
||
logger.warn('startup:raw-dir-migrate:rename-failed', {
|
||
jobId,
|
||
currentRawPath,
|
||
desiredRawPath,
|
||
error: errorToMeta(renameError)
|
||
});
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (normalizeComparablePath(row.raw_path) !== normalizeComparablePath(finalRawPath)) {
|
||
await historyService.updateRawPathByOldPath(row.raw_path, finalRawPath);
|
||
pathUpdateCount += 1;
|
||
}
|
||
}
|
||
|
||
if (renamedCount > 0 || pathUpdateCount > 0 || ripFlagUpdateCount > 0 || conflictCount > 0 || missingCount > 0) {
|
||
logger.info('startup:raw-dir-migrate:done', {
|
||
renamedCount,
|
||
pathUpdateCount,
|
||
ripFlagUpdateCount,
|
||
conflictCount,
|
||
missingCount,
|
||
scannedDirs: allRawDirs
|
||
});
|
||
}
|
||
}
|
||
|
||
async init() {
|
||
const db = await getDb();
|
||
try {
|
||
await this.migrateRawFolderNamingOnStartup(db);
|
||
} catch (migrationError) {
|
||
logger.warn('init:raw-dir-migrate-failed', {
|
||
error: errorToMeta(migrationError)
|
||
});
|
||
}
|
||
const row = await db.get('SELECT * FROM pipeline_state WHERE id = 1');
|
||
|
||
if (row) {
|
||
this.snapshot = {
|
||
state: row.state,
|
||
activeJobId: row.active_job_id,
|
||
progress: Number(row.progress || 0),
|
||
eta: row.eta,
|
||
statusText: row.status_text,
|
||
context: this.safeParseJson(row.context_json)
|
||
};
|
||
logger.info('init:loaded-snapshot', { snapshot: this.snapshot });
|
||
}
|
||
|
||
try {
|
||
await this.recoverStaleRunningJobsOnStartup(db);
|
||
} catch (recoveryError) {
|
||
logger.warn('init:stale-running-recovery-failed', {
|
||
error: errorToMeta(recoveryError)
|
||
});
|
||
}
|
||
|
||
// Always start with a clean dashboard/session snapshot after server restart.
|
||
const hasContextKeys = this.snapshot.context
|
||
&& typeof this.snapshot.context === 'object'
|
||
&& Object.keys(this.snapshot.context).length > 0;
|
||
if (this.snapshot.state !== 'IDLE' || this.snapshot.activeJobId || hasContextKeys) {
|
||
await this.resetFrontendState('server_restart', {
|
||
force: true,
|
||
keepDetectedDevice: false
|
||
});
|
||
}
|
||
await this.emitQueueChanged();
|
||
void this.pumpQueue();
|
||
}
|
||
|
||
async recoverStaleRunningJobsOnStartup(db) {
|
||
const staleRows = await db.all(`
|
||
SELECT id, status, last_state
|
||
FROM jobs
|
||
WHERE status IN ('ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING')
|
||
ORDER BY updated_at ASC, id ASC
|
||
`);
|
||
const rows = Array.isArray(staleRows) ? staleRows : [];
|
||
if (rows.length === 0) {
|
||
return {
|
||
scanned: 0,
|
||
preparedReadyToEncode: 0,
|
||
markedError: 0,
|
||
skipped: 0
|
||
};
|
||
}
|
||
|
||
let preparedReadyToEncode = 0;
|
||
let markedError = 0;
|
||
let skipped = 0;
|
||
|
||
for (const row of rows) {
|
||
const jobId = this.normalizeQueueJobId(row?.id);
|
||
if (!jobId) {
|
||
skipped += 1;
|
||
continue;
|
||
}
|
||
const rawStage = String(row?.status || row?.last_state || '').trim().toUpperCase();
|
||
const stage = RUNNING_STATES.has(rawStage) ? rawStage : 'ENCODING';
|
||
const message = `Server-Neustart erkannt während ${stage}. Laufender Prozess wurde beendet.`;
|
||
|
||
if (stage === 'ENCODING') {
|
||
try {
|
||
await historyService.appendLog(jobId, 'SYSTEM', message);
|
||
} catch (_error) {
|
||
// keep recovery path even if log append fails
|
||
}
|
||
try {
|
||
await this.restartEncodeWithLastSettings(jobId, {
|
||
immediate: true,
|
||
triggerReason: 'server_restart'
|
||
});
|
||
preparedReadyToEncode += 1;
|
||
continue;
|
||
} catch (error) {
|
||
logger.warn('startup:recover-stale-encoding:restart-failed', {
|
||
jobId,
|
||
error: errorToMeta(error)
|
||
});
|
||
try {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Startup-Recovery Encode fehlgeschlagen, setze Job auf ERROR: ${error?.message || 'unknown'}`
|
||
);
|
||
} catch (_logError) {
|
||
// ignore logging fallback errors
|
||
}
|
||
}
|
||
}
|
||
|
||
await historyService.updateJobStatus(jobId, 'ERROR', {
|
||
end_time: nowIso(),
|
||
error_message: message
|
||
});
|
||
try {
|
||
await historyService.appendLog(jobId, 'SYSTEM', message);
|
||
} catch (_error) {
|
||
// ignore logging failures during startup recovery
|
||
}
|
||
markedError += 1;
|
||
}
|
||
|
||
logger.warn('startup:recover-stale-running-jobs', {
|
||
scanned: rows.length,
|
||
preparedReadyToEncode,
|
||
markedError,
|
||
skipped
|
||
});
|
||
return {
|
||
scanned: rows.length,
|
||
preparedReadyToEncode,
|
||
markedError,
|
||
skipped
|
||
};
|
||
}
|
||
|
||
safeParseJson(raw) {
|
||
if (!raw) {
|
||
return {};
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch (error) {
|
||
logger.warn('safeParseJson:failed', { raw, error: errorToMeta(error) });
|
||
return {};
|
||
}
|
||
}
|
||
|
||
getSnapshot() {
|
||
const jobProgress = {};
|
||
for (const [id, data] of this.jobProgress) {
|
||
jobProgress[id] = data;
|
||
}
|
||
return {
|
||
...this.snapshot,
|
||
jobProgress,
|
||
queue: this.lastQueueSnapshot
|
||
};
|
||
}
|
||
|
||
normalizeParallelJobsLimit(rawValue) {
|
||
const value = Number(rawValue);
|
||
if (!Number.isFinite(value) || value < 1) {
|
||
return 1;
|
||
}
|
||
return Math.max(1, Math.min(12, Math.trunc(value)));
|
||
}
|
||
|
||
normalizeQueueJobId(rawValue) {
|
||
const value = Number(rawValue);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
return null;
|
||
}
|
||
return Math.trunc(value);
|
||
}
|
||
|
||
isJobRunningStatus(status) {
|
||
return RUNNING_STATES.has(String(status || '').trim().toUpperCase());
|
||
}
|
||
|
||
syncPrimaryActiveProcess() {
|
||
if (this.activeProcesses.size === 0) {
|
||
this.activeProcess = null;
|
||
return;
|
||
}
|
||
const first = Array.from(this.activeProcesses.values())[0] || null;
|
||
this.activeProcess = first;
|
||
}
|
||
|
||
async getMaxParallelJobs() {
|
||
const settings = await settingsService.getSettingsMap();
|
||
return this.normalizeParallelJobsLimit(settings?.pipeline_max_parallel_jobs);
|
||
}
|
||
|
||
findQueueEntryIndexByJobId(jobId) {
|
||
return this.queueEntries.findIndex((entry) => Number(entry?.jobId) === Number(jobId));
|
||
}
|
||
|
||
normalizeQueueChainIdList(rawList) {
|
||
const list = Array.isArray(rawList) ? rawList : [];
|
||
const seen = new Set();
|
||
const output = [];
|
||
for (const item of list) {
|
||
const value = Number(item);
|
||
if (!Number.isFinite(value) || value <= 0) {
|
||
continue;
|
||
}
|
||
const normalized = Math.trunc(value);
|
||
const key = String(normalized);
|
||
if (seen.has(key)) {
|
||
continue;
|
||
}
|
||
seen.add(key);
|
||
output.push(normalized);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
extractQueueJobPlan(row) {
|
||
const source = row && typeof row === 'object' ? row : null;
|
||
if (!source) {
|
||
return null;
|
||
}
|
||
if (source.encodePlan && typeof source.encodePlan === 'object') {
|
||
return source.encodePlan;
|
||
}
|
||
if (source.encode_plan_json) {
|
||
try {
|
||
const parsed = JSON.parse(source.encode_plan_json);
|
||
if (parsed && typeof parsed === 'object') {
|
||
return parsed;
|
||
}
|
||
} catch (_) {
|
||
// ignore parse errors for queue decorations
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async buildQueueJobScriptMeta(rows = []) {
|
||
const list = Array.isArray(rows) ? rows : [];
|
||
const byJobId = new Map();
|
||
const allScriptIds = new Set();
|
||
const allChainIds = new Set();
|
||
const scriptNameHints = new Map();
|
||
const chainNameHints = new Map();
|
||
|
||
const addScriptHints = (items) => {
|
||
for (const item of (Array.isArray(items) ? items : [])) {
|
||
if (!item || typeof item !== 'object') {
|
||
continue;
|
||
}
|
||
const id = normalizeScriptIdList([item.id ?? item.scriptId])[0] || null;
|
||
const name = String(item.name || item.scriptName || '').trim();
|
||
if (!id) {
|
||
continue;
|
||
}
|
||
allScriptIds.add(id);
|
||
if (name) {
|
||
scriptNameHints.set(id, name);
|
||
}
|
||
}
|
||
};
|
||
|
||
const addChainHints = (items) => {
|
||
for (const item of (Array.isArray(items) ? items : [])) {
|
||
if (!item || typeof item !== 'object') {
|
||
continue;
|
||
}
|
||
const id = this.normalizeQueueChainIdList([item.id ?? item.chainId])[0] || null;
|
||
const name = String(item.name || item.chainName || '').trim();
|
||
if (!id) {
|
||
continue;
|
||
}
|
||
allChainIds.add(id);
|
||
if (name) {
|
||
chainNameHints.set(id, name);
|
||
}
|
||
}
|
||
};
|
||
|
||
for (const row of list) {
|
||
const jobId = this.normalizeQueueJobId(row?.id);
|
||
if (!jobId) {
|
||
continue;
|
||
}
|
||
const plan = this.extractQueueJobPlan(row);
|
||
if (!plan) {
|
||
continue;
|
||
}
|
||
|
||
const preScriptIds = normalizeScriptIdList([
|
||
...normalizeScriptIdList(plan?.preEncodeScriptIds || []),
|
||
...normalizeScriptIdList((Array.isArray(plan?.preEncodeScripts) ? plan.preEncodeScripts : []).map((item) => item?.id ?? item?.scriptId))
|
||
]);
|
||
const postScriptIds = normalizeScriptIdList([
|
||
...normalizeScriptIdList(plan?.postEncodeScriptIds || []),
|
||
...normalizeScriptIdList((Array.isArray(plan?.postEncodeScripts) ? plan.postEncodeScripts : []).map((item) => item?.id ?? item?.scriptId))
|
||
]);
|
||
const preChainIds = this.normalizeQueueChainIdList([
|
||
...this.normalizeQueueChainIdList(plan?.preEncodeChainIds || []),
|
||
...this.normalizeQueueChainIdList((Array.isArray(plan?.preEncodeChains) ? plan.preEncodeChains : []).map((item) => item?.id ?? item?.chainId))
|
||
]);
|
||
const postChainIds = this.normalizeQueueChainIdList([
|
||
...this.normalizeQueueChainIdList(plan?.postEncodeChainIds || []),
|
||
...this.normalizeQueueChainIdList((Array.isArray(plan?.postEncodeChains) ? plan.postEncodeChains : []).map((item) => item?.id ?? item?.chainId))
|
||
]);
|
||
|
||
addScriptHints(plan?.preEncodeScripts);
|
||
addScriptHints(plan?.postEncodeScripts);
|
||
addChainHints(plan?.preEncodeChains);
|
||
addChainHints(plan?.postEncodeChains);
|
||
|
||
for (const id of preScriptIds) allScriptIds.add(id);
|
||
for (const id of postScriptIds) allScriptIds.add(id);
|
||
for (const id of preChainIds) allChainIds.add(id);
|
||
for (const id of postChainIds) allChainIds.add(id);
|
||
|
||
byJobId.set(jobId, {
|
||
preScriptIds,
|
||
postScriptIds,
|
||
preChainIds,
|
||
postChainIds
|
||
});
|
||
}
|
||
|
||
if (byJobId.size === 0) {
|
||
return new Map();
|
||
}
|
||
|
||
const scriptNameById = new Map();
|
||
const chainNameById = new Map();
|
||
for (const [id, name] of scriptNameHints.entries()) {
|
||
scriptNameById.set(id, name);
|
||
}
|
||
for (const [id, name] of chainNameHints.entries()) {
|
||
chainNameById.set(id, name);
|
||
}
|
||
|
||
if (allScriptIds.size > 0) {
|
||
const scriptService = require('./scriptService');
|
||
try {
|
||
const scripts = await scriptService.resolveScriptsByIds(Array.from(allScriptIds), { strict: false });
|
||
for (const script of scripts) {
|
||
const id = Number(script?.id);
|
||
const name = String(script?.name || '').trim();
|
||
if (Number.isFinite(id) && id > 0 && name) {
|
||
scriptNameById.set(id, name);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.warn('queue:script-summary:resolve-failed', { error: errorToMeta(error) });
|
||
}
|
||
}
|
||
|
||
if (allChainIds.size > 0) {
|
||
const scriptChainService = require('./scriptChainService');
|
||
try {
|
||
const chains = await scriptChainService.getChainsByIds(Array.from(allChainIds));
|
||
for (const chain of chains) {
|
||
const id = Number(chain?.id);
|
||
const name = String(chain?.name || '').trim();
|
||
if (Number.isFinite(id) && id > 0 && name) {
|
||
chainNameById.set(id, name);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.warn('queue:chain-summary:resolve-failed', { error: errorToMeta(error) });
|
||
}
|
||
}
|
||
|
||
const output = new Map();
|
||
for (const [jobId, data] of byJobId.entries()) {
|
||
const preScripts = data.preScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`);
|
||
const postScripts = data.postScriptIds.map((id) => scriptNameById.get(id) || `Skript #${id}`);
|
||
const preChains = data.preChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`);
|
||
const postChains = data.postChainIds.map((id) => chainNameById.get(id) || `Kette #${id}`);
|
||
const hasScripts = preScripts.length > 0 || postScripts.length > 0;
|
||
const hasChains = preChains.length > 0 || postChains.length > 0;
|
||
output.set(jobId, {
|
||
hasScripts,
|
||
hasChains,
|
||
summary: {
|
||
preScripts,
|
||
postScripts,
|
||
preChains,
|
||
postChains
|
||
}
|
||
});
|
||
}
|
||
return output;
|
||
}
|
||
|
||
async getQueueSnapshot() {
|
||
const maxParallelJobs = await this.getMaxParallelJobs();
|
||
const runningJobs = await historyService.getRunningJobs();
|
||
const runningEncodeCount = runningJobs.filter((job) => job.status === 'ENCODING').length;
|
||
const queuedJobIds = this.queueEntries
|
||
.filter((entry) => !entry.type || entry.type === 'job')
|
||
.map((entry) => Number(entry.jobId))
|
||
.filter((id) => Number.isFinite(id) && id > 0);
|
||
const queuedRows = queuedJobIds.length > 0
|
||
? await historyService.getJobsByIds(queuedJobIds)
|
||
: [];
|
||
const queuedById = new Map(queuedRows.map((row) => [Number(row.id), row]));
|
||
const scriptMetaByJobId = await this.buildQueueJobScriptMeta(
|
||
Array.from(
|
||
new Map(
|
||
[...runningJobs, ...queuedRows].map((row) => [Number(row?.id), row])
|
||
).values()
|
||
)
|
||
);
|
||
|
||
const queue = {
|
||
maxParallelJobs,
|
||
runningCount: runningEncodeCount,
|
||
runningJobs: runningJobs.map((job) => ({
|
||
jobId: Number(job.id),
|
||
title: job.title || job.detected_title || `Job #${job.id}`,
|
||
status: job.status,
|
||
lastState: job.last_state || null,
|
||
hasScripts: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasScripts),
|
||
hasChains: Boolean(scriptMetaByJobId.get(Number(job.id))?.hasChains),
|
||
scriptSummary: scriptMetaByJobId.get(Number(job.id))?.summary || null
|
||
})),
|
||
queuedJobs: this.queueEntries.map((entry, index) => {
|
||
const entryType = entry.type || 'job';
|
||
const base = {
|
||
entryId: entry.id,
|
||
position: index + 1,
|
||
type: entryType,
|
||
enqueuedAt: entry.enqueuedAt
|
||
};
|
||
|
||
if (entryType === 'script') {
|
||
return { ...base, scriptId: entry.scriptId, title: entry.scriptName || `Skript #${entry.scriptId}`, status: 'QUEUED' };
|
||
}
|
||
if (entryType === 'chain') {
|
||
return { ...base, chainId: entry.chainId, title: entry.chainName || `Kette #${entry.chainId}`, status: 'QUEUED' };
|
||
}
|
||
if (entryType === 'wait') {
|
||
return { ...base, waitSeconds: entry.waitSeconds, title: `Warten ${entry.waitSeconds}s`, status: 'QUEUED' };
|
||
}
|
||
|
||
// type === 'job'
|
||
const row = queuedById.get(Number(entry.jobId));
|
||
const scriptMeta = scriptMetaByJobId.get(Number(entry.jobId)) || null;
|
||
return {
|
||
...base,
|
||
jobId: Number(entry.jobId),
|
||
action: entry.action,
|
||
actionLabel: QUEUE_ACTION_LABELS[entry.action] || entry.action,
|
||
title: row?.title || row?.detected_title || `Job #${entry.jobId}`,
|
||
status: row?.status || null,
|
||
lastState: row?.last_state || null,
|
||
hasScripts: Boolean(scriptMeta?.hasScripts),
|
||
hasChains: Boolean(scriptMeta?.hasChains),
|
||
scriptSummary: scriptMeta?.summary || null
|
||
};
|
||
}),
|
||
queuedCount: this.queueEntries.length,
|
||
updatedAt: nowIso()
|
||
};
|
||
|
||
return queue;
|
||
}
|
||
|
||
async emitQueueChanged() {
|
||
try {
|
||
this.lastQueueSnapshot = await this.getQueueSnapshot();
|
||
wsService.broadcast('PIPELINE_QUEUE_CHANGED', this.lastQueueSnapshot);
|
||
} catch (error) {
|
||
logger.warn('queue:emit:failed', { error: errorToMeta(error) });
|
||
}
|
||
}
|
||
|
||
async reorderQueue(orderedEntryIds = []) {
|
||
const incoming = Array.isArray(orderedEntryIds)
|
||
? orderedEntryIds.map((value) => Number(value)).filter((v) => Number.isFinite(v) && v > 0)
|
||
: [];
|
||
if (incoming.length !== this.queueEntries.length) {
|
||
const error = new Error('Queue-Reihenfolge ungültig: Anzahl passt nicht.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const currentIdSet = new Set(this.queueEntries.map((entry) => entry.id));
|
||
const incomingSet = new Set(incoming);
|
||
if (incomingSet.size !== incoming.length || incoming.some((id) => !currentIdSet.has(id))) {
|
||
const error = new Error('Queue-Reihenfolge ungültig: IDs passen nicht zur aktuellen Queue.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const byEntryId = new Map(this.queueEntries.map((entry) => [entry.id, entry]));
|
||
this.queueEntries = incoming.map((id) => byEntryId.get(id)).filter(Boolean);
|
||
await this.emitQueueChanged();
|
||
return this.lastQueueSnapshot;
|
||
}
|
||
|
||
async enqueueNonJobEntry(type, params = {}, insertAfterEntryId = null) {
|
||
const validTypes = new Set(['script', 'chain', 'wait']);
|
||
if (!validTypes.has(type)) {
|
||
const error = new Error(`Unbekannter Queue-Eintragstyp: ${type}`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
let entry;
|
||
if (type === 'script') {
|
||
const scriptId = Number(params.scriptId);
|
||
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||
const error = new Error('scriptId fehlt oder ist ungültig.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
const scriptService = require('./scriptService');
|
||
let script;
|
||
try { script = await scriptService.getScriptById(scriptId); } catch (_) { /* ignore */ }
|
||
entry = { id: this.queueEntrySeq++, type: 'script', scriptId, scriptName: script?.name || null, enqueuedAt: nowIso() };
|
||
} else if (type === 'chain') {
|
||
const chainId = Number(params.chainId);
|
||
if (!Number.isFinite(chainId) || chainId <= 0) {
|
||
const error = new Error('chainId fehlt oder ist ungültig.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
const scriptChainService = require('./scriptChainService');
|
||
let chain;
|
||
try { chain = await scriptChainService.getChainById(chainId); } catch (_) { /* ignore */ }
|
||
entry = { id: this.queueEntrySeq++, type: 'chain', chainId, chainName: chain?.name || null, enqueuedAt: nowIso() };
|
||
} else {
|
||
const waitSeconds = Math.round(Number(params.waitSeconds));
|
||
if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) {
|
||
const error = new Error('waitSeconds muss zwischen 1 und 3600 liegen.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
entry = { id: this.queueEntrySeq++, type: 'wait', waitSeconds, enqueuedAt: nowIso() };
|
||
}
|
||
|
||
if (insertAfterEntryId != null) {
|
||
const idx = this.queueEntries.findIndex((e) => e.id === Number(insertAfterEntryId));
|
||
if (idx >= 0) {
|
||
this.queueEntries.splice(idx + 1, 0, entry);
|
||
} else {
|
||
this.queueEntries.push(entry);
|
||
}
|
||
} else {
|
||
this.queueEntries.push(entry);
|
||
}
|
||
|
||
await this.emitQueueChanged();
|
||
void this.pumpQueue();
|
||
return { entryId: entry.id, type, position: this.queueEntries.indexOf(entry) + 1 };
|
||
}
|
||
|
||
async removeQueueEntry(entryId) {
|
||
const normalizedId = Number(entryId);
|
||
if (!Number.isFinite(normalizedId) || normalizedId <= 0) {
|
||
const error = new Error('Ungültige entryId.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
const idx = this.queueEntries.findIndex((e) => e.id === normalizedId);
|
||
if (idx < 0) {
|
||
const error = new Error(`Queue-Eintrag #${normalizedId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
this.queueEntries.splice(idx, 1);
|
||
await this.emitQueueChanged();
|
||
return this.lastQueueSnapshot;
|
||
}
|
||
|
||
async enqueueOrStartAction(action, jobId, startNow) {
|
||
const normalizedJobId = this.normalizeQueueJobId(jobId);
|
||
if (!normalizedJobId) {
|
||
const error = new Error('Ungültige Job-ID für Queue-Aktion.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
if (!Object.values(QUEUE_ACTIONS).includes(action)) {
|
||
const error = new Error(`Unbekannte Queue-Aktion '${action}'.`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
if (typeof startNow !== 'function') {
|
||
const error = new Error('Queue-Aktion kann nicht gestartet werden (startNow fehlt).');
|
||
error.statusCode = 500;
|
||
throw error;
|
||
}
|
||
|
||
const existingQueueIndex = this.findQueueEntryIndexByJobId(normalizedJobId);
|
||
if (existingQueueIndex >= 0) {
|
||
return {
|
||
queued: true,
|
||
started: false,
|
||
queuePosition: existingQueueIndex + 1,
|
||
action
|
||
};
|
||
}
|
||
|
||
const maxParallelJobs = await this.getMaxParallelJobs();
|
||
const runningEncodeJobs = await historyService.getRunningEncodeJobs();
|
||
const shouldQueue = this.queueEntries.length > 0 || runningEncodeJobs.length >= maxParallelJobs;
|
||
if (!shouldQueue) {
|
||
const result = await startNow();
|
||
await this.emitQueueChanged();
|
||
return {
|
||
queued: false,
|
||
started: true,
|
||
action,
|
||
...(result && typeof result === 'object' ? result : {})
|
||
};
|
||
}
|
||
|
||
this.queueEntries.push({
|
||
id: this.queueEntrySeq++,
|
||
jobId: normalizedJobId,
|
||
action,
|
||
enqueuedAt: nowIso()
|
||
});
|
||
await historyService.appendLog(
|
||
normalizedJobId,
|
||
'USER_ACTION',
|
||
`In Queue aufgenommen: ${QUEUE_ACTION_LABELS[action] || action}`
|
||
);
|
||
await this.emitQueueChanged();
|
||
void this.pumpQueue();
|
||
|
||
return {
|
||
queued: true,
|
||
started: false,
|
||
queuePosition: this.queueEntries.length,
|
||
action
|
||
};
|
||
}
|
||
|
||
async dispatchNonJobEntry(entry) {
|
||
const type = entry?.type;
|
||
logger.info('queue:non-job:dispatch', { type, entryId: entry?.id });
|
||
|
||
if (type === 'wait') {
|
||
const seconds = Math.max(1, Number(entry.waitSeconds || 1));
|
||
logger.info('queue:wait:start', { seconds });
|
||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||
logger.info('queue:wait:done', { seconds });
|
||
return;
|
||
}
|
||
|
||
if (type === 'script') {
|
||
const scriptService = require('./scriptService');
|
||
let script;
|
||
try { script = await scriptService.getScriptById(entry.scriptId); } catch (_) { /* ignore */ }
|
||
if (!script) {
|
||
logger.warn('queue:script:not-found', { scriptId: entry.scriptId });
|
||
return;
|
||
}
|
||
const activityId = runtimeActivityService.startActivity('script', {
|
||
name: script.name,
|
||
source: 'queue',
|
||
scriptId: script.id,
|
||
currentStep: 'Queue-Ausfuehrung'
|
||
});
|
||
let prepared = null;
|
||
try {
|
||
prepared = await scriptService.createExecutableScriptFile(script, { source: 'queue', scriptId: script.id, scriptName: script.name });
|
||
const { spawn } = require('child_process');
|
||
await new Promise((resolve, reject) => {
|
||
const child = spawn(prepared.cmd, prepared.args, { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout?.on('data', (chunk) => {
|
||
stdout += String(chunk);
|
||
if (stdout.length > 12000) {
|
||
stdout = `${stdout.slice(0, 12000)}\n...[truncated]`;
|
||
}
|
||
});
|
||
child.stderr?.on('data', (chunk) => {
|
||
stderr += String(chunk);
|
||
if (stderr.length > 12000) {
|
||
stderr = `${stderr.slice(0, 12000)}\n...[truncated]`;
|
||
}
|
||
});
|
||
child.on('error', reject);
|
||
child.on('close', (code) => {
|
||
logger.info('queue:script:done', { scriptId: script.id, exitCode: code });
|
||
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
||
const success = Number(code) === 0;
|
||
runtimeActivityService.completeActivity(activityId, {
|
||
status: success ? 'success' : 'error',
|
||
success,
|
||
outcome: success ? 'success' : 'error',
|
||
exitCode: Number.isFinite(Number(code)) ? Number(code) : null,
|
||
message: success ? 'Queue-Skript abgeschlossen' : `Queue-Skript fehlgeschlagen (Exit ${code})`,
|
||
output: output || null,
|
||
stdout: stdout || null,
|
||
stderr: stderr || null,
|
||
errorMessage: success ? null : `Queue-Skript fehlgeschlagen (Exit ${code})`
|
||
});
|
||
resolve();
|
||
});
|
||
});
|
||
} catch (err) {
|
||
runtimeActivityService.completeActivity(activityId, {
|
||
status: 'error',
|
||
success: false,
|
||
outcome: 'error',
|
||
message: err?.message || 'Queue-Skript Fehler',
|
||
errorMessage: err?.message || 'Queue-Skript Fehler'
|
||
});
|
||
logger.error('queue:script:error', { scriptId: entry.scriptId, error: errorToMeta(err) });
|
||
} finally {
|
||
if (prepared?.cleanup) await prepared.cleanup();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (type === 'chain') {
|
||
const scriptChainService = require('./scriptChainService');
|
||
try {
|
||
await scriptChainService.executeChain(entry.chainId, { source: 'queue' });
|
||
} catch (err) {
|
||
logger.error('queue:chain:error', { chainId: entry.chainId, error: errorToMeta(err) });
|
||
}
|
||
}
|
||
}
|
||
|
||
async dispatchQueuedEntry(entry) {
|
||
const action = entry?.action;
|
||
const jobId = Number(entry?.jobId);
|
||
if (!Number.isFinite(jobId) || jobId <= 0) {
|
||
return;
|
||
}
|
||
switch (action) {
|
||
case QUEUE_ACTIONS.START_PREPARED:
|
||
await this.startPreparedJob(jobId, { immediate: true });
|
||
break;
|
||
case QUEUE_ACTIONS.RETRY:
|
||
await this.retry(jobId, { immediate: true });
|
||
break;
|
||
case QUEUE_ACTIONS.REENCODE:
|
||
await this.reencodeFromRaw(jobId, { immediate: true });
|
||
break;
|
||
case QUEUE_ACTIONS.RESTART_ENCODE:
|
||
await this.restartEncodeWithLastSettings(jobId, { immediate: true });
|
||
break;
|
||
case QUEUE_ACTIONS.RESTART_REVIEW:
|
||
await this.restartReviewFromRaw(jobId, { immediate: true });
|
||
break;
|
||
default: {
|
||
const error = new Error(`Unbekannte Queue-Aktion: ${String(action || '-')}`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
|
||
async pumpQueue() {
|
||
if (this.queuePumpRunning) {
|
||
return;
|
||
}
|
||
this.queuePumpRunning = true;
|
||
try {
|
||
while (this.queueEntries.length > 0) {
|
||
const firstEntry = this.queueEntries[0];
|
||
const isNonJob = firstEntry?.type && firstEntry.type !== 'job';
|
||
|
||
if (!isNonJob) {
|
||
// Job entries: respect the parallel encode limit.
|
||
const maxParallelJobs = await this.getMaxParallelJobs();
|
||
const runningEncodeJobs = await historyService.getRunningEncodeJobs();
|
||
if (runningEncodeJobs.length >= maxParallelJobs) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
const entry = this.queueEntries.shift();
|
||
if (!entry) {
|
||
break;
|
||
}
|
||
|
||
await this.emitQueueChanged();
|
||
try {
|
||
if (isNonJob) {
|
||
await this.dispatchNonJobEntry(entry);
|
||
continue;
|
||
}
|
||
await historyService.appendLog(
|
||
entry.jobId,
|
||
'SYSTEM',
|
||
`Queue-Start: ${QUEUE_ACTION_LABELS[entry.action] || entry.action}`
|
||
);
|
||
await this.dispatchQueuedEntry(entry);
|
||
} catch (error) {
|
||
if (Number(error?.statusCode || 0) === 409) {
|
||
this.queueEntries.unshift(entry);
|
||
await this.emitQueueChanged();
|
||
break;
|
||
}
|
||
logger.error('queue:entry:failed', {
|
||
type: entry.type || 'job',
|
||
action: entry.action,
|
||
jobId: entry.jobId,
|
||
error: errorToMeta(error)
|
||
});
|
||
if (entry.jobId) {
|
||
await historyService.appendLog(
|
||
entry.jobId,
|
||
'SYSTEM',
|
||
`Queue-Start fehlgeschlagen (${QUEUE_ACTION_LABELS[entry.action] || entry.action}): ${error.message}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
} finally {
|
||
this.queuePumpRunning = false;
|
||
await this.emitQueueChanged();
|
||
}
|
||
}
|
||
|
||
async resetFrontendState(reason = 'manual', options = {}) {
|
||
const force = Boolean(options?.force);
|
||
const keepDetectedDevice = options?.keepDetectedDevice !== false;
|
||
|
||
if (!force && (this.activeProcesses.size > 0 || RUNNING_STATES.has(this.snapshot.state))) {
|
||
logger.warn('ui:reset:skipped-busy', {
|
||
reason,
|
||
state: this.snapshot.state,
|
||
activeJobId: this.snapshot.activeJobId
|
||
});
|
||
return {
|
||
reset: false,
|
||
skipped: 'busy'
|
||
};
|
||
}
|
||
|
||
const device = keepDetectedDevice ? (this.detectedDisc || null) : null;
|
||
const nextState = device ? 'DISC_DETECTED' : 'IDLE';
|
||
const statusText = device ? 'Neue Disk erkannt' : 'Bereit';
|
||
|
||
logger.warn('ui:reset', {
|
||
reason,
|
||
previousState: this.snapshot.state,
|
||
previousActiveJobId: this.snapshot.activeJobId,
|
||
nextState,
|
||
keepDetectedDevice
|
||
});
|
||
|
||
await this.setState(nextState, {
|
||
activeJobId: null,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText,
|
||
context: device ? { device } : {}
|
||
});
|
||
|
||
return {
|
||
reset: true,
|
||
state: nextState
|
||
};
|
||
}
|
||
|
||
async notifyPushover(eventKey, payload = {}) {
|
||
try {
|
||
const result = await notificationService.notify(eventKey, payload);
|
||
logger.debug('notify:event', {
|
||
eventKey,
|
||
sent: Boolean(result?.sent),
|
||
reason: result?.reason || null
|
||
});
|
||
} catch (error) {
|
||
logger.warn('notify:event:failed', {
|
||
eventKey,
|
||
error: errorToMeta(error)
|
||
});
|
||
}
|
||
}
|
||
|
||
normalizeDiscValue(value) {
|
||
return String(value || '').trim().toLowerCase();
|
||
}
|
||
|
||
isSameDisc(a, b) {
|
||
const aDiscLabel = this.normalizeDiscValue(a?.discLabel);
|
||
const bDiscLabel = this.normalizeDiscValue(b?.discLabel);
|
||
if (aDiscLabel && bDiscLabel) {
|
||
return aDiscLabel === bDiscLabel;
|
||
}
|
||
|
||
const aPath = this.normalizeDiscValue(a?.path);
|
||
const bPath = this.normalizeDiscValue(b?.path);
|
||
if (aPath && bPath) {
|
||
return aPath === bPath;
|
||
}
|
||
|
||
const aLabel = this.normalizeDiscValue(a?.label);
|
||
const bLabel = this.normalizeDiscValue(b?.label);
|
||
if (aLabel && bLabel) {
|
||
return aLabel === bLabel;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async setState(state, patch = {}) {
|
||
const previous = this.snapshot.state;
|
||
const previousActiveJobId = this.snapshot.activeJobId;
|
||
this.snapshot = {
|
||
...this.snapshot,
|
||
state,
|
||
activeJobId: patch.activeJobId !== undefined ? patch.activeJobId : this.snapshot.activeJobId,
|
||
progress: patch.progress !== undefined ? patch.progress : this.snapshot.progress,
|
||
eta: patch.eta !== undefined ? patch.eta : this.snapshot.eta,
|
||
statusText: patch.statusText !== undefined ? patch.statusText : this.snapshot.statusText,
|
||
context: patch.context !== undefined ? patch.context : this.snapshot.context
|
||
};
|
||
|
||
// Keep per-job progress map in sync when a job starts or finishes.
|
||
if (patch.activeJobId != null) {
|
||
this.jobProgress.set(Number(patch.activeJobId), {
|
||
state,
|
||
progress: patch.progress ?? 0,
|
||
eta: patch.eta ?? null,
|
||
statusText: patch.statusText ?? null
|
||
});
|
||
} else if (patch.activeJobId === null) {
|
||
// Job slot cleared – remove the finished job's live entry so it falls
|
||
// back to DB data in the frontend.
|
||
// Use patch.finishingJobId when provided (parallel-safe); fall back to
|
||
// previousActiveJobId only when no parallel job has overwritten the slot.
|
||
const finishingJobId = patch.finishingJobId != null
|
||
? Number(patch.finishingJobId)
|
||
: (previousActiveJobId != null ? Number(previousActiveJobId) : null);
|
||
if (finishingJobId != null) {
|
||
this.jobProgress.delete(finishingJobId);
|
||
}
|
||
}
|
||
logger.info('state:changed', {
|
||
from: previous,
|
||
to: state,
|
||
activeJobId: this.snapshot.activeJobId,
|
||
statusText: this.snapshot.statusText
|
||
});
|
||
|
||
await this.persistSnapshot();
|
||
const snapshotPayload = this.getSnapshot();
|
||
wsService.broadcast('PIPELINE_STATE_CHANGED', snapshotPayload);
|
||
this.emit('stateChanged', snapshotPayload);
|
||
void this.emitQueueChanged();
|
||
void this.pumpQueue();
|
||
}
|
||
|
||
async persistSnapshot(force = true) {
|
||
if (!force) {
|
||
const now = Date.now();
|
||
if (now - this.lastPersistAt < 300) {
|
||
return;
|
||
}
|
||
this.lastPersistAt = now;
|
||
}
|
||
|
||
const db = await getDb();
|
||
await db.run(
|
||
`
|
||
UPDATE pipeline_state
|
||
SET
|
||
state = ?,
|
||
active_job_id = ?,
|
||
progress = ?,
|
||
eta = ?,
|
||
status_text = ?,
|
||
context_json = ?,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = 1
|
||
`,
|
||
[
|
||
this.snapshot.state,
|
||
this.snapshot.activeJobId,
|
||
this.snapshot.progress,
|
||
this.snapshot.eta,
|
||
this.snapshot.statusText,
|
||
JSON.stringify(this.snapshot.context || {})
|
||
]
|
||
);
|
||
}
|
||
|
||
async updateProgress(stage, percent, eta, statusText, jobIdOverride = null) {
|
||
const effectiveJobId = jobIdOverride != null ? Number(jobIdOverride) : this.snapshot.activeJobId;
|
||
const effectiveProgress = percent ?? this.snapshot.progress;
|
||
const effectiveEta = eta ?? this.snapshot.eta;
|
||
const effectiveStatusText = statusText ?? this.snapshot.statusText;
|
||
|
||
// Update per-job progress so concurrent jobs don't overwrite each other.
|
||
if (effectiveJobId != null) {
|
||
this.jobProgress.set(effectiveJobId, {
|
||
state: stage,
|
||
progress: effectiveProgress,
|
||
eta: effectiveEta,
|
||
statusText: effectiveStatusText
|
||
});
|
||
}
|
||
|
||
// Only update the global snapshot fields when this update belongs to the
|
||
// currently active job (avoids the snapshot jumping between parallel jobs).
|
||
if (effectiveJobId === this.snapshot.activeJobId || effectiveJobId == null) {
|
||
this.snapshot = {
|
||
...this.snapshot,
|
||
state: stage,
|
||
progress: effectiveProgress,
|
||
eta: effectiveEta,
|
||
statusText: effectiveStatusText
|
||
};
|
||
await this.persistSnapshot(false);
|
||
}
|
||
|
||
const rounded = Number((effectiveProgress || 0).toFixed(2));
|
||
const key = `${effectiveJobId}:${stage}:${rounded}`;
|
||
if (key !== this.lastProgressKey) {
|
||
this.lastProgressKey = key;
|
||
logger.debug('progress:update', {
|
||
stage,
|
||
activeJobId: effectiveJobId,
|
||
progress: rounded,
|
||
eta: effectiveEta,
|
||
statusText: effectiveStatusText
|
||
});
|
||
}
|
||
wsService.broadcast('PIPELINE_PROGRESS', {
|
||
state: stage,
|
||
activeJobId: effectiveJobId,
|
||
progress: effectiveProgress,
|
||
eta: effectiveEta,
|
||
statusText: effectiveStatusText
|
||
});
|
||
}
|
||
|
||
async onDiscInserted(deviceInfo) {
|
||
const rawDevice = deviceInfo && typeof deviceInfo === 'object'
|
||
? deviceInfo
|
||
: {};
|
||
const explicitProfile = normalizeMediaProfile(rawDevice.mediaProfile);
|
||
const inferredProfile = inferMediaProfileFromDeviceInfo(rawDevice);
|
||
const resolvedMediaProfile = isSpecificMediaProfile(explicitProfile)
|
||
? explicitProfile
|
||
: (isSpecificMediaProfile(inferredProfile)
|
||
? inferredProfile
|
||
: (explicitProfile || inferredProfile || 'other'));
|
||
const resolvedDevice = {
|
||
...rawDevice,
|
||
mediaProfile: resolvedMediaProfile
|
||
};
|
||
|
||
const previousDevice = this.snapshot.context?.device || this.detectedDisc;
|
||
const previousState = this.snapshot.state;
|
||
const previousJobId = this.snapshot.context?.jobId || this.snapshot.activeJobId || null;
|
||
const discChanged = previousDevice ? !this.isSameDisc(previousDevice, resolvedDevice) : false;
|
||
|
||
this.detectedDisc = resolvedDevice;
|
||
logger.info('disc:inserted', { deviceInfo: resolvedDevice, mediaProfile: resolvedMediaProfile });
|
||
|
||
wsService.broadcast('DISC_DETECTED', {
|
||
device: resolvedDevice
|
||
});
|
||
|
||
if (discChanged && !RUNNING_STATES.has(previousState) && previousState !== 'DISC_DETECTED' && previousState !== 'READY_TO_ENCODE') {
|
||
const message = `Disk gewechselt (${resolvedDevice.discLabel || resolvedDevice.path || 'unbekannt'}). Bitte neu analysieren.`;
|
||
logger.info('disc:changed:reset', {
|
||
fromState: previousState,
|
||
previousDevice,
|
||
newDevice: resolvedDevice,
|
||
previousJobId
|
||
});
|
||
|
||
if (previousJobId && (previousState === 'METADATA_SELECTION' || previousState === 'READY_TO_START' || previousState === 'WAITING_FOR_USER_DECISION')) {
|
||
await historyService.updateJob(previousJobId, {
|
||
status: 'ERROR',
|
||
last_state: 'ERROR',
|
||
end_time: nowIso(),
|
||
error_message: message
|
||
});
|
||
await historyService.appendLog(previousJobId, 'SYSTEM', message);
|
||
}
|
||
|
||
await this.setState('DISC_DETECTED', {
|
||
activeJobId: null,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Neue Disk erkannt',
|
||
context: {
|
||
device: resolvedDevice
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (this.snapshot.state === 'IDLE' || this.snapshot.state === 'FINISHED' || this.snapshot.state === 'ERROR' || this.snapshot.state === 'DISC_DETECTED') {
|
||
await this.setState('DISC_DETECTED', {
|
||
activeJobId: null,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Neue Disk erkannt',
|
||
context: {
|
||
device: resolvedDevice
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async onDiscRemoved(deviceInfo) {
|
||
logger.info('disc:removed', { deviceInfo });
|
||
wsService.broadcast('DISC_REMOVED', {
|
||
device: deviceInfo
|
||
});
|
||
|
||
this.detectedDisc = null;
|
||
if (this.snapshot.state === 'DISC_DETECTED') {
|
||
await this.setState('IDLE', {
|
||
activeJobId: null,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Keine Disk erkannt',
|
||
context: {}
|
||
});
|
||
}
|
||
}
|
||
|
||
ensureNotBusy(action, jobId = null) {
|
||
const normalizedJobId = this.normalizeQueueJobId(jobId);
|
||
if (!normalizedJobId) {
|
||
return;
|
||
}
|
||
if (this.activeProcesses.has(normalizedJobId)) {
|
||
const error = new Error(`Job #${normalizedJobId} ist bereits aktiv. Aktion '${action}' aktuell nicht möglich.`);
|
||
error.statusCode = 409;
|
||
logger.warn('busy:blocked-action', {
|
||
action,
|
||
jobId: normalizedJobId,
|
||
activeState: this.snapshot.state,
|
||
activeJobId: this.snapshot.activeJobId
|
||
});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
isPrimaryJob(jobId) {
|
||
const activeState = String(this.snapshot.state || '').toUpperCase();
|
||
if (!['ENCODING', 'RIPPING'].includes(activeState)) {
|
||
return true;
|
||
}
|
||
return Number(this.snapshot.activeJobId) === Number(jobId);
|
||
}
|
||
|
||
withAnalyzeContextMediaProfile(makemkvInfo, mediaProfile) {
|
||
const normalizedProfile = normalizeMediaProfile(mediaProfile);
|
||
const base = makemkvInfo && typeof makemkvInfo === 'object'
|
||
? makemkvInfo
|
||
: {};
|
||
return {
|
||
...base,
|
||
analyzeContext: {
|
||
...(base.analyzeContext || {}),
|
||
mediaProfile: normalizedProfile || null
|
||
}
|
||
};
|
||
}
|
||
|
||
resolveMediaProfileForJob(job, options = {}) {
|
||
const pickSpecificProfile = (value) => {
|
||
const normalized = normalizeMediaProfile(value);
|
||
if (!normalized) {
|
||
return null;
|
||
}
|
||
if (isSpecificMediaProfile(normalized)) {
|
||
return normalized;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const explicitProfile = pickSpecificProfile(options?.mediaProfile);
|
||
if (explicitProfile) {
|
||
return explicitProfile;
|
||
}
|
||
|
||
const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object'
|
||
? options.encodePlan
|
||
: null;
|
||
const profileFromPlan = pickSpecificProfile(encodePlan?.mediaProfile);
|
||
if (profileFromPlan) {
|
||
return profileFromPlan;
|
||
}
|
||
|
||
const mkInfo = options?.makemkvInfo && typeof options.makemkvInfo === 'object'
|
||
? options.makemkvInfo
|
||
: this.safeParseJson(job?.makemkv_info_json);
|
||
const analyzeContext = mkInfo?.analyzeContext || {};
|
||
const profileFromAnalyze = pickSpecificProfile(
|
||
analyzeContext.mediaProfile || mkInfo?.mediaProfile
|
||
);
|
||
if (profileFromAnalyze) {
|
||
return profileFromAnalyze;
|
||
}
|
||
|
||
const currentContextProfile = (
|
||
Number(this.snapshot.context?.jobId) === Number(job?.id)
|
||
? pickSpecificProfile(this.snapshot.context?.mediaProfile)
|
||
: null
|
||
);
|
||
if (currentContextProfile) {
|
||
return currentContextProfile;
|
||
}
|
||
|
||
const deviceProfile = inferMediaProfileFromDeviceInfo(
|
||
options?.deviceInfo
|
||
|| this.detectedDisc
|
||
|| this.snapshot.context?.device
|
||
|| null
|
||
);
|
||
if (isSpecificMediaProfile(deviceProfile)) {
|
||
return deviceProfile;
|
||
}
|
||
|
||
const rawPathProfile = inferMediaProfileFromRawPath(options?.rawPath || job?.raw_path || null);
|
||
if (rawPathProfile) {
|
||
return rawPathProfile;
|
||
}
|
||
|
||
return 'other';
|
||
}
|
||
|
||
async getEffectiveSettingsForJob(job, options = {}) {
|
||
const mediaProfile = this.resolveMediaProfileForJob(job, options);
|
||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||
return {
|
||
settings,
|
||
mediaProfile
|
||
};
|
||
}
|
||
|
||
async ensureMakeMKVRegistration(jobId, stage) {
|
||
const registrationConfig = await settingsService.buildMakeMKVRegisterConfig();
|
||
if (!registrationConfig) {
|
||
return { applied: false, reason: 'not_configured' };
|
||
}
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'Setze MakeMKV-Registrierungsschlüssel aus den Settings (makemkvcon reg).'
|
||
);
|
||
|
||
await this.runCommand({
|
||
jobId,
|
||
stage,
|
||
source: 'MAKEMKV_REG',
|
||
cmd: registrationConfig.cmd,
|
||
args: registrationConfig.args,
|
||
argsForLog: registrationConfig.argsForLog
|
||
});
|
||
|
||
return { applied: true };
|
||
}
|
||
|
||
isReviewRefreshSettingKey(key) {
|
||
const normalized = String(key || '').trim().toLowerCase();
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
|
||
if (REVIEW_REFRESH_SETTING_KEYS.has(normalized)) {
|
||
return true;
|
||
}
|
||
|
||
return REVIEW_REFRESH_SETTING_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
||
}
|
||
|
||
async refreshEncodeReviewAfterSettingsSave(changedKeys = []) {
|
||
const keys = Array.isArray(changedKeys)
|
||
? changedKeys.map((item) => String(item || '').trim()).filter(Boolean)
|
||
: [];
|
||
if (keys.includes('pipeline_max_parallel_jobs')) {
|
||
await this.emitQueueChanged();
|
||
void this.pumpQueue();
|
||
}
|
||
const relevantKeys = keys.filter((key) => this.isReviewRefreshSettingKey(key));
|
||
if (relevantKeys.length === 0) {
|
||
return {
|
||
triggered: false,
|
||
reason: 'no_relevant_setting_changes',
|
||
relevantKeys: []
|
||
};
|
||
}
|
||
|
||
if (this.activeProcesses.size > 0 || RUNNING_STATES.has(this.snapshot.state)) {
|
||
return {
|
||
triggered: false,
|
||
reason: 'pipeline_busy',
|
||
relevantKeys
|
||
};
|
||
}
|
||
|
||
const rawJobId = Number(this.snapshot.activeJobId || this.snapshot.context?.jobId || null);
|
||
const activeJobId = Number.isFinite(rawJobId) && rawJobId > 0 ? Math.trunc(rawJobId) : null;
|
||
if (!activeJobId) {
|
||
return {
|
||
triggered: false,
|
||
reason: 'no_active_job',
|
||
relevantKeys
|
||
};
|
||
}
|
||
|
||
const job = await historyService.getJobById(activeJobId);
|
||
if (!job) {
|
||
return {
|
||
triggered: false,
|
||
reason: 'active_job_not_found',
|
||
relevantKeys,
|
||
jobId: activeJobId
|
||
};
|
||
}
|
||
|
||
if (job.status !== 'READY_TO_ENCODE' && job.last_state !== 'READY_TO_ENCODE') {
|
||
return {
|
||
triggered: false,
|
||
reason: 'active_job_not_ready_to_encode',
|
||
relevantKeys,
|
||
jobId: activeJobId,
|
||
status: job.status,
|
||
lastState: job.last_state
|
||
};
|
||
}
|
||
|
||
const refreshSettings = await settingsService.getSettingsMap();
|
||
const refreshRawBaseDir = String(refreshSettings?.raw_dir || '').trim();
|
||
const refreshRawExtraDirs = [
|
||
refreshSettings?.raw_dir_bluray,
|
||
refreshSettings?.raw_dir_dvd,
|
||
refreshSettings?.raw_dir_other
|
||
].map((d) => String(d || '').trim()).filter(Boolean);
|
||
const resolvedRefreshRawPath = job.raw_path
|
||
? this.resolveCurrentRawPath(refreshRawBaseDir, job.raw_path, refreshRawExtraDirs)
|
||
: null;
|
||
|
||
if (!resolvedRefreshRawPath) {
|
||
return {
|
||
triggered: false,
|
||
reason: 'raw_path_missing',
|
||
relevantKeys,
|
||
jobId: activeJobId,
|
||
rawPath: job.raw_path || null
|
||
};
|
||
}
|
||
|
||
if (resolvedRefreshRawPath !== job.raw_path) {
|
||
await historyService.updateJob(activeJobId, { raw_path: resolvedRefreshRawPath });
|
||
}
|
||
|
||
const existingPlan = this.safeParseJson(job.encode_plan_json);
|
||
const mode = existingPlan?.mode || this.snapshot.context?.mode || 'rip';
|
||
const sourceJobId = existingPlan?.sourceJobId || this.snapshot.context?.sourceJobId || null;
|
||
|
||
await historyService.appendLog(
|
||
activeJobId,
|
||
'SYSTEM',
|
||
`Settings gespeichert (${relevantKeys.join(', ')}). Titel-/Spurprüfung wird mit aktueller Konfiguration neu gestartet.`
|
||
);
|
||
|
||
this.runReviewForRawJob(activeJobId, resolvedRefreshRawPath, { mode, sourceJobId }).catch((error) => {
|
||
logger.error('settings:refresh-review:failed', {
|
||
jobId: activeJobId,
|
||
relevantKeys,
|
||
error: errorToMeta(error)
|
||
});
|
||
});
|
||
|
||
return {
|
||
triggered: true,
|
||
reason: 'refresh_started',
|
||
relevantKeys,
|
||
jobId: activeJobId,
|
||
mode
|
||
};
|
||
}
|
||
|
||
resolvePlaylistDecisionForJob(jobId, job, selectionOverride = null) {
|
||
const activeContext = this.snapshot.context?.jobId === jobId
|
||
? (this.snapshot.context || {})
|
||
: {};
|
||
|
||
const mkInfo = this.safeParseJson(job?.makemkv_info_json);
|
||
const analyzeContext = mkInfo?.analyzeContext || {};
|
||
const playlistAnalysis = activeContext.playlistAnalysis || analyzeContext.playlistAnalysis || mkInfo?.playlistAnalysis || null;
|
||
|
||
const playlistDecisionRequired = Boolean(
|
||
activeContext.playlistDecisionRequired !== undefined
|
||
? activeContext.playlistDecisionRequired
|
||
: (analyzeContext.playlistDecisionRequired !== undefined
|
||
? analyzeContext.playlistDecisionRequired
|
||
: playlistAnalysis?.manualDecisionRequired)
|
||
);
|
||
|
||
const rawSelection = selectionOverride
|
||
|| activeContext.selectedPlaylist
|
||
|| analyzeContext.selectedPlaylist
|
||
|| null;
|
||
const selectedPlaylist = normalizePlaylistId(rawSelection);
|
||
|
||
const rawSelectedTitleId = activeContext.selectedTitleId ?? analyzeContext.selectedTitleId ?? null;
|
||
let selectedTitleId = null;
|
||
if (selectedPlaylist) {
|
||
selectedTitleId = pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylist);
|
||
}
|
||
if (selectedTitleId === null) {
|
||
const parsedSelectedTitleId = normalizeNonNegativeInteger(rawSelectedTitleId);
|
||
if (parsedSelectedTitleId !== null) {
|
||
selectedTitleId = parsedSelectedTitleId;
|
||
}
|
||
}
|
||
if (!selectedPlaylist && selectedTitleId !== null && !isCandidateTitleId(playlistAnalysis, selectedTitleId)) {
|
||
selectedTitleId = null;
|
||
}
|
||
|
||
const candidatePlaylists = buildPlaylistCandidates(playlistAnalysis);
|
||
const recommendation = playlistAnalysis?.recommendation || null;
|
||
|
||
return {
|
||
playlistAnalysis,
|
||
playlistDecisionRequired,
|
||
candidatePlaylists,
|
||
selectedPlaylist,
|
||
selectedTitleId,
|
||
recommendation
|
||
};
|
||
}
|
||
|
||
async analyzeDisc() {
|
||
this.ensureNotBusy('analyze');
|
||
logger.info('analyze:start');
|
||
|
||
const device = this.detectedDisc || this.snapshot.context?.device;
|
||
if (!device) {
|
||
const error = new Error('Keine Disk erkannt.');
|
||
error.statusCode = 400;
|
||
logger.warn('analyze:no-disc');
|
||
throw error;
|
||
}
|
||
|
||
const detectedTitle = String(
|
||
device.discLabel
|
||
|| device.label
|
||
|| device.model
|
||
|| 'Unknown Disc'
|
||
).trim();
|
||
const explicitProfile = normalizeMediaProfile(device?.mediaProfile);
|
||
const inferredProfile = inferMediaProfileFromDeviceInfo(device);
|
||
const mediaProfile = isSpecificMediaProfile(explicitProfile)
|
||
? explicitProfile
|
||
: (isSpecificMediaProfile(inferredProfile)
|
||
? inferredProfile
|
||
: (explicitProfile || inferredProfile || 'other'));
|
||
const deviceWithProfile = {
|
||
...device,
|
||
mediaProfile
|
||
};
|
||
|
||
const job = await historyService.createJob({
|
||
discDevice: device.path,
|
||
status: 'METADATA_SELECTION',
|
||
detectedTitle
|
||
});
|
||
|
||
try {
|
||
const omdbCandidates = await omdbService.search(detectedTitle).catch(() => []);
|
||
logger.info('metadata:prepare:result', {
|
||
jobId: job.id,
|
||
detectedTitle,
|
||
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({
|
||
phase: 'PREPARE',
|
||
preparedAt: nowIso(),
|
||
analyzeContext: {
|
||
playlistAnalysis: null,
|
||
playlistDecisionRequired: false,
|
||
selectedPlaylist: null,
|
||
selectedTitleId: null
|
||
}
|
||
}, mediaProfile))
|
||
});
|
||
await historyService.appendLog(
|
||
job.id,
|
||
'SYSTEM',
|
||
`Disk erkannt. Metadaten-Suche vorbereitet mit Query "${detectedTitle}".`
|
||
);
|
||
|
||
const runningJobs = await historyService.getRunningJobs();
|
||
const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(job.id));
|
||
const keepCurrentPipelineSession = foreignRunningJobs.length > 0;
|
||
if (!keepCurrentPipelineSession) {
|
||
await this.setState('METADATA_SELECTION', {
|
||
activeJobId: job.id,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Metadaten auswählen',
|
||
context: {
|
||
jobId: job.id,
|
||
device: deviceWithProfile,
|
||
detectedTitle,
|
||
detectedTitleSource: device.discLabel ? 'discLabel' : 'fallback',
|
||
omdbCandidates,
|
||
mediaProfile,
|
||
playlistAnalysis: null,
|
||
playlistDecisionRequired: false,
|
||
playlistCandidates: [],
|
||
selectedPlaylist: null,
|
||
selectedTitleId: null
|
||
}
|
||
});
|
||
} else {
|
||
await historyService.appendLog(
|
||
job.id,
|
||
'SYSTEM',
|
||
`Metadaten-Auswahl im Hintergrund vorbereitet. Aktive Session bleibt bei laufendem Job #${foreignRunningJobs.map((item) => item.id).join(',')}.`
|
||
);
|
||
}
|
||
|
||
void this.notifyPushover('metadata_ready', {
|
||
title: 'Ripster - Metadaten bereit',
|
||
message: `Job #${job.id}: ${detectedTitle} (${omdbCandidates.length} Treffer)`
|
||
});
|
||
|
||
return {
|
||
jobId: job.id,
|
||
detectedTitle,
|
||
omdbCandidates
|
||
};
|
||
} catch (error) {
|
||
logger.error('metadata:prepare:failed', { jobId: job.id, error: errorToMeta(error) });
|
||
await this.failJob(job.id, 'METADATA_SELECTION', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async searchOmdb(query) {
|
||
logger.info('omdb:search', { query });
|
||
const results = await omdbService.search(query);
|
||
logger.info('omdb:search:done', { query, count: results.length });
|
||
return results;
|
||
}
|
||
|
||
async runDiscTrackReviewForJob(jobId, deviceInfo = null, options = {}) {
|
||
this.ensureNotBusy('runDiscTrackReviewForJob', jobId);
|
||
logger.info('disc-track-review:start', { jobId, deviceInfo, options });
|
||
|
||
const job = await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
const mkInfo = this.safeParseJson(job.makemkv_info_json);
|
||
const mediaProfile = this.resolveMediaProfileForJob(job, {
|
||
mediaProfile: options?.mediaProfile,
|
||
deviceInfo,
|
||
makemkvInfo: mkInfo
|
||
});
|
||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||
const analyzeContext = mkInfo?.analyzeContext || {};
|
||
const playlistAnalysis = analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null;
|
||
const selectedPlaylistId = normalizePlaylistId(
|
||
options?.selectedPlaylist
|
||
|| analyzeContext.selectedPlaylist
|
||
|| this.snapshot.context?.selectedPlaylist
|
||
|| null
|
||
);
|
||
const selectedMakemkvTitleIdRaw =
|
||
options?.selectedTitleId
|
||
?? analyzeContext.selectedTitleId
|
||
?? this.snapshot.context?.selectedTitleId
|
||
?? null;
|
||
const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedMakemkvTitleIdRaw);
|
||
const selectedMetadata = {
|
||
title: job.title || job.detected_title || null,
|
||
year: job.year || null,
|
||
imdbId: job.imdb_id || null,
|
||
poster: job.poster_url || null
|
||
};
|
||
|
||
await this.setState('MEDIAINFO_CHECK', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Vorab-Spurprüfung (Disc) läuft',
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
reviewConfirmed: false,
|
||
mode: 'pre_rip',
|
||
mediaProfile,
|
||
selectedMetadata
|
||
}
|
||
});
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'MEDIAINFO_CHECK',
|
||
last_state: 'MEDIAINFO_CHECK',
|
||
error_message: null,
|
||
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)),
|
||
mediainfo_info_json: null,
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0
|
||
});
|
||
|
||
const lines = [];
|
||
const scanConfig = await settingsService.buildHandBrakeScanConfig(deviceInfo, { mediaProfile });
|
||
logger.info('disc-track-review:command', {
|
||
jobId,
|
||
cmd: scanConfig.cmd,
|
||
args: scanConfig.args,
|
||
sourceArg: scanConfig.sourceArg,
|
||
selectedTitleId: selectedMakemkvTitleId
|
||
});
|
||
|
||
const runInfo = await this.runCommand({
|
||
jobId,
|
||
stage: 'MEDIAINFO_CHECK',
|
||
source: 'HANDBRAKE_SCAN',
|
||
cmd: scanConfig.cmd,
|
||
args: scanConfig.args,
|
||
collectLines: lines,
|
||
collectStderrLines: false
|
||
});
|
||
|
||
const parsed = parseMediainfoJsonOutput(lines.join('\n'));
|
||
if (!parsed) {
|
||
const error = new Error('HandBrake Scan-Ausgabe konnte nicht als JSON gelesen werden.');
|
||
error.runInfo = runInfo;
|
||
throw error;
|
||
}
|
||
|
||
const review = buildDiscScanReview({
|
||
scanJson: parsed,
|
||
settings,
|
||
playlistAnalysis,
|
||
selectedPlaylistId,
|
||
selectedMakemkvTitleId,
|
||
mediaProfile,
|
||
sourceArg: scanConfig.sourceArg
|
||
});
|
||
|
||
if (!Array.isArray(review.titles) || review.titles.length === 0) {
|
||
const error = new Error('Vorab-Spurprüfung lieferte keine Titel.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'READY_TO_ENCODE',
|
||
last_state: 'READY_TO_ENCODE',
|
||
error_message: null,
|
||
mediainfo_info_json: JSON.stringify({
|
||
generatedAt: nowIso(),
|
||
source: 'disc_scan',
|
||
runInfo
|
||
}),
|
||
encode_plan_json: JSON.stringify(review),
|
||
encode_input_path: review.encodeInputPath || null,
|
||
encode_review_confirmed: 0
|
||
});
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Vorab-Spurprüfung abgeschlossen: ${review.titles.length} Titel, Auswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.`
|
||
);
|
||
|
||
await this.setState('READY_TO_ENCODE', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: review.titleSelectionRequired
|
||
? 'Vorab-Spurprüfung fertig - Titel per Checkbox wählen'
|
||
: 'Vorab-Spurprüfung fertig - Auswahl bestätigen, dann Backup/Encode starten',
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
inputPath: review.encodeInputPath || null,
|
||
hasEncodableTitle: Boolean(review.encodeInputTitleId),
|
||
reviewConfirmed: false,
|
||
mode: 'pre_rip',
|
||
mediaProfile,
|
||
mediaInfoReview: review,
|
||
selectedMetadata
|
||
}
|
||
});
|
||
|
||
return review;
|
||
}
|
||
|
||
async handleDiscTrackReviewFailure(jobId, error, context = {}) {
|
||
const message = error?.message || String(error);
|
||
const runInfo = error?.runInfo && typeof error.runInfo === 'object'
|
||
? error.runInfo
|
||
: null;
|
||
const isDiscScanFailure = String(runInfo?.source || '').toUpperCase() === 'HANDBRAKE_SCAN'
|
||
|| /no title found/i.test(message);
|
||
|
||
if (!isDiscScanFailure) {
|
||
await this.failJob(jobId, 'MEDIAINFO_CHECK', error);
|
||
return;
|
||
}
|
||
|
||
logger.warn('disc-track-review:fallback-to-manual-rip', {
|
||
jobId,
|
||
message,
|
||
runInfo: runInfo || null
|
||
});
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'READY_TO_START',
|
||
last_state: 'READY_TO_START',
|
||
error_message: null,
|
||
mediainfo_info_json: JSON.stringify({
|
||
source: 'disc_scan',
|
||
failedAt: nowIso(),
|
||
error: message,
|
||
runInfo
|
||
}),
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0
|
||
});
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Vorab-Spurprüfung fehlgeschlagen (${message}). Fallback: Backup/Rip kann manuell gestartet werden; Spurauswahl erfolgt danach.`
|
||
);
|
||
|
||
await this.setState('READY_TO_START', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Vorab-Spurprüfung fehlgeschlagen - Backup manuell starten',
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
selectedMetadata: context.selectedMetadata || this.snapshot.context?.selectedMetadata || null,
|
||
playlistAnalysis: context.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null,
|
||
playlistDecisionRequired: Boolean(context.playlistDecisionRequired ?? this.snapshot.context?.playlistDecisionRequired),
|
||
playlistCandidates: context.playlistCandidates || this.snapshot.context?.playlistCandidates || [],
|
||
selectedPlaylist: context.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null,
|
||
selectedTitleId: context.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null,
|
||
preRipScanFailed: true,
|
||
preRipScanError: message
|
||
}
|
||
});
|
||
}
|
||
|
||
async runBackupTrackReviewForJob(jobId, rawPath, options = {}) {
|
||
this.ensureNotBusy('runBackupTrackReviewForJob', jobId);
|
||
logger.info('backup-track-review:start', { jobId, rawPath, options });
|
||
|
||
const job = await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
if (!rawPath || !fs.existsSync(rawPath)) {
|
||
const error = new Error(`RAW-Pfad nicht gefunden (${rawPath || '-'})`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const mode = String(options?.mode || 'rip').trim().toLowerCase() || 'rip';
|
||
const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection);
|
||
const forceFreshAnalyze = Boolean(options?.forceFreshAnalyze);
|
||
const mkInfo = this.safeParseJson(job.makemkv_info_json);
|
||
const mediaProfile = this.resolveMediaProfileForJob(job, {
|
||
mediaProfile: options?.mediaProfile,
|
||
rawPath,
|
||
makemkvInfo: mkInfo
|
||
});
|
||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||
const analyzeContext = mkInfo?.analyzeContext || {};
|
||
let playlistAnalysis = forceFreshAnalyze
|
||
? null
|
||
: (analyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null);
|
||
let handBrakePlaylistScan = forceFreshAnalyze
|
||
? null
|
||
: normalizeHandBrakePlaylistScanCache(analyzeContext.handBrakePlaylistScan || null);
|
||
if (playlistAnalysis && handBrakePlaylistScan) {
|
||
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
|
||
}
|
||
const selectedPlaylistSource = (forcePlaylistReselection || forceFreshAnalyze)
|
||
? (options?.selectedPlaylist || null)
|
||
: (options?.selectedPlaylist || analyzeContext.selectedPlaylist || this.snapshot.context?.selectedPlaylist || null);
|
||
const selectedPlaylistId = normalizePlaylistId(
|
||
selectedPlaylistSource
|
||
);
|
||
const selectedTitleSource = (forcePlaylistReselection || forceFreshAnalyze)
|
||
? (options?.selectedTitleId ?? null)
|
||
: (options?.selectedTitleId ?? analyzeContext.selectedTitleId ?? this.snapshot.context?.selectedTitleId ?? null);
|
||
const selectedMakemkvTitleId = normalizeNonNegativeInteger(selectedTitleSource);
|
||
const selectedMetadata = {
|
||
title: job.title || job.detected_title || null,
|
||
year: job.year || null,
|
||
imdbId: job.imdb_id || null,
|
||
poster: job.poster_url || null
|
||
};
|
||
|
||
if (this.isPrimaryJob(jobId)) {
|
||
await this.setState('MEDIAINFO_CHECK', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Titel-/Spurprüfung aus RAW-Backup läuft',
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
rawPath,
|
||
inputPath: null,
|
||
hasEncodableTitle: false,
|
||
reviewConfirmed: false,
|
||
mode,
|
||
mediaProfile,
|
||
sourceJobId: options.sourceJobId || null,
|
||
selectedMetadata
|
||
}
|
||
});
|
||
}
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'MEDIAINFO_CHECK',
|
||
last_state: 'MEDIAINFO_CHECK',
|
||
error_message: null,
|
||
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile)),
|
||
mediainfo_info_json: null,
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0
|
||
});
|
||
|
||
if (forcePlaylistReselection && !selectedPlaylistId) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'Re-Encode: gespeicherte Playlist-Auswahl wird ignoriert. Bitte Playlist manuell neu auswählen.'
|
||
);
|
||
}
|
||
if (forceFreshAnalyze) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'Review-Neustart erzwingt frische MakeMKV Full-Analyse (kein Reuse von Playlist-/HandBrake-Cache).'
|
||
);
|
||
}
|
||
|
||
// Build playlist->TITLE_ID mapping once from MakeMKV full robot scan on RAW backup.
|
||
let makeMkvAnalyzeRunInfo = null;
|
||
let analyzedFromFreshRun = false;
|
||
const existingPostBackupAnalyze = mkInfo?.postBackupAnalyze && typeof mkInfo.postBackupAnalyze === 'object'
|
||
? mkInfo.postBackupAnalyze
|
||
: null;
|
||
|
||
await this.ensureMakeMKVRegistration(jobId, 'MEDIAINFO_CHECK');
|
||
if (selectedPlaylistId) {
|
||
if (!playlistAnalysis || !Array.isArray(playlistAnalysis?.titles) || playlistAnalysis.titles.length === 0) {
|
||
const error = new Error(
|
||
'Playlist-Auswahl kann nicht fortgesetzt werden: MakeMKV-Mapping fehlt. Bitte zuerst die RAW-Analyse erneut starten.'
|
||
);
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'Verwende vorhandenes MakeMKV-Playlist-Mapping aus dem letzten Full-Scan (kein erneuter Full-Scan).'
|
||
);
|
||
} else {
|
||
const analyzeLines = [];
|
||
const analyzeConfig = await settingsService.buildMakeMKVAnalyzePathConfig(rawPath, { mediaProfile });
|
||
logger.info('backup-track-review:makemkv-analyze-command', {
|
||
jobId,
|
||
cmd: analyzeConfig.cmd,
|
||
args: analyzeConfig.args,
|
||
sourceArg: analyzeConfig.sourceArg
|
||
});
|
||
|
||
makeMkvAnalyzeRunInfo = await this.runCommand({
|
||
jobId,
|
||
stage: 'MEDIAINFO_CHECK',
|
||
source: 'MAKEMKV_ANALYZE_BACKUP',
|
||
cmd: analyzeConfig.cmd,
|
||
args: analyzeConfig.args,
|
||
parser: parseMakeMkvProgress,
|
||
collectLines: analyzeLines,
|
||
silent: !this.isPrimaryJob(jobId)
|
||
});
|
||
|
||
const analyzed = analyzePlaylistObfuscation(
|
||
analyzeLines,
|
||
Number(settings.makemkv_min_length_minutes || 60),
|
||
{}
|
||
);
|
||
playlistAnalysis = analyzed || null;
|
||
analyzedFromFreshRun = true;
|
||
}
|
||
|
||
const playlistDecisionRequired = Boolean(playlistAnalysis?.manualDecisionRequired);
|
||
let playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
|
||
const selectedTitleFromPlaylist = selectedPlaylistId
|
||
? pickTitleIdForPlaylist(playlistAnalysis, selectedPlaylistId)
|
||
: null;
|
||
const selectedTitleFromContext = (!selectedPlaylistId && !isCandidateTitleId(playlistAnalysis, selectedMakemkvTitleId))
|
||
? null
|
||
: selectedMakemkvTitleId;
|
||
const selectedTitleForContext = selectedTitleFromPlaylist ?? selectedTitleFromContext ?? null;
|
||
if (selectedPlaylistId && playlistCandidates.length > 0) {
|
||
const isKnownPlaylist = playlistCandidates.some((item) => item.playlistId === selectedPlaylistId);
|
||
if (!isKnownPlaylist) {
|
||
const error = new Error(`Playlist ${selectedPlaylistId}.mpls ist nicht in den erkannten Kandidaten enthalten.`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
const shouldPrepareHandBrakeDecisionData = Boolean(
|
||
playlistDecisionRequired
|
||
&& !selectedPlaylistId
|
||
&& playlistCandidates.length > 0
|
||
);
|
||
if (shouldPrepareHandBrakeDecisionData) {
|
||
const hasCompleteCache = hasCachedHandBrakeDataForPlaylistCandidates(handBrakePlaylistScan, playlistCandidates);
|
||
if (!hasCompleteCache) {
|
||
await this.updateProgress(
|
||
'MEDIAINFO_CHECK',
|
||
25,
|
||
null,
|
||
'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet',
|
||
jobId
|
||
);
|
||
try {
|
||
const resolveScanLines = [];
|
||
const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath, { mediaProfile });
|
||
logger.info('backup-track-review:handbrake-predecision-command', {
|
||
jobId,
|
||
cmd: resolveScanConfig.cmd,
|
||
args: resolveScanConfig.args,
|
||
sourceArg: resolveScanConfig.sourceArg,
|
||
candidatePlaylists: playlistCandidates.map((item) => item.playlistFile || item.playlistId)
|
||
});
|
||
|
||
await this.runCommand({
|
||
jobId,
|
||
stage: 'MEDIAINFO_CHECK',
|
||
source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
|
||
cmd: resolveScanConfig.cmd,
|
||
args: resolveScanConfig.args,
|
||
collectLines: resolveScanLines,
|
||
collectStderrLines: false
|
||
});
|
||
|
||
const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n'));
|
||
if (resolveScanJson) {
|
||
const preparedCache = buildHandBrakePlaylistScanCache(resolveScanJson, playlistCandidates, rawPath);
|
||
if (preparedCache) {
|
||
handBrakePlaylistScan = preparedCache;
|
||
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
|
||
playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`HandBrake Playlist-Trackdaten vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.`
|
||
);
|
||
} else {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'HandBrake Playlist-Trackdaten konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.'
|
||
);
|
||
}
|
||
} else {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'HandBrake Playlist-Trackdaten konnten nicht geparst werden (Warteansicht ohne Audiodetails).'
|
||
);
|
||
}
|
||
} catch (error) {
|
||
logger.warn('backup-track-review:handbrake-predecision-failed', {
|
||
jobId,
|
||
error: errorToMeta(error)
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}`
|
||
);
|
||
}
|
||
} else {
|
||
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
|
||
playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'HandBrake Playlist-Trackdaten aus Cache übernommen (kein erneuter --scan -t 0).'
|
||
);
|
||
}
|
||
}
|
||
|
||
const updatedMakemkvInfo = {
|
||
...mkInfo,
|
||
analyzeContext: {
|
||
...(mkInfo?.analyzeContext || {}),
|
||
mediaProfile,
|
||
playlistAnalysis: playlistAnalysis || null,
|
||
playlistDecisionRequired,
|
||
selectedPlaylist: selectedPlaylistId || null,
|
||
selectedTitleId: selectedTitleForContext,
|
||
handBrakePlaylistScan: handBrakePlaylistScan || null
|
||
},
|
||
postBackupAnalyze: analyzedFromFreshRun
|
||
? {
|
||
analyzedAt: nowIso(),
|
||
source: 'MAKEMKV_ANALYZE_BACKUP',
|
||
runInfo: makeMkvAnalyzeRunInfo,
|
||
error: null
|
||
}
|
||
: {
|
||
analyzedAt: existingPostBackupAnalyze?.analyzedAt || nowIso(),
|
||
source: 'MAKEMKV_ANALYZE_BACKUP',
|
||
runInfo: existingPostBackupAnalyze?.runInfo || null,
|
||
reused: true,
|
||
error: null
|
||
}
|
||
};
|
||
|
||
if (playlistDecisionRequired && !selectedPlaylistId) {
|
||
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
|
||
? playlistAnalysis.evaluatedCandidates
|
||
: [];
|
||
const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId);
|
||
const decisionContext = describePlaylistManualDecision(playlistAnalysis);
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'WAITING_FOR_USER_DECISION',
|
||
last_state: 'WAITING_FOR_USER_DECISION',
|
||
error_message: null,
|
||
makemkv_info_json: JSON.stringify(updatedMakemkvInfo),
|
||
mediainfo_info_json: JSON.stringify({
|
||
generatedAt: nowIso(),
|
||
source: 'makemkv_backup_robot',
|
||
runInfo: makeMkvAnalyzeRunInfo
|
||
}),
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0
|
||
});
|
||
|
||
await historyService.appendLog(jobId, 'SYSTEM', 'Mehrere mögliche Haupttitel erkannt!');
|
||
await historyService.appendLog(jobId, 'SYSTEM', decisionContext.detailText);
|
||
for (const candidate of evaluated) {
|
||
const playlistFile = toPlaylistFile(candidate?.playlistId) || `Titel #${candidate?.titleId || '-'}`;
|
||
const score = Number(candidate?.score);
|
||
const scoreLabel = Number.isFinite(score) ? score.toFixed(0) : '-';
|
||
const durationLabel = String(candidate?.durationLabel || '').trim() || formatDurationClock(candidate?.durationSeconds) || '-';
|
||
const recommendedLabel = candidate?.recommended ? ' (empfohlen)' : '';
|
||
const evaluationLabel = candidate?.evaluationLabel ? ` | ${candidate.evaluationLabel}` : '';
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`${playlistFile} -> Dauer ${durationLabel} | Score ${scoreLabel}${recommendedLabel}${evaluationLabel}`
|
||
);
|
||
}
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Status=awaiting_playlist_selection${recommendationFile ? ` | Empfehlung=${recommendationFile}` : ''}`
|
||
);
|
||
|
||
await this.setState('WAITING_FOR_USER_DECISION', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'awaiting_playlist_selection',
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
rawPath,
|
||
inputPath: null,
|
||
hasEncodableTitle: false,
|
||
reviewConfirmed: false,
|
||
mode,
|
||
mediaProfile,
|
||
sourceJobId: options.sourceJobId || null,
|
||
selectedMetadata,
|
||
playlistAnalysis: playlistAnalysis || null,
|
||
playlistDecisionRequired: true,
|
||
playlistCandidates,
|
||
selectedPlaylist: null,
|
||
selectedTitleId: null,
|
||
waitingForManualPlaylistSelection: true,
|
||
manualDecisionState: 'awaiting_playlist_selection',
|
||
mediaInfoReview: null
|
||
}
|
||
});
|
||
|
||
const notificationMessage = [
|
||
'⚠️ Manuelle Prüfung erforderlich!',
|
||
decisionContext.obfuscationDetected
|
||
? 'Mehrere gleichlange Playlists erkannt.'
|
||
: 'Mehrere Playlists erfüllen MIN_LENGTH_MINUTES.',
|
||
'',
|
||
'Empfehlung:',
|
||
recommendationFile || '(keine eindeutige Empfehlung)',
|
||
'',
|
||
'Bitte Titel manuell bestätigen,',
|
||
'bevor Encoding gestartet wird.'
|
||
].join('\n');
|
||
void this.notifyPushover('metadata_ready', {
|
||
title: 'Ripster - Playlist-Auswahl erforderlich',
|
||
message: notificationMessage,
|
||
priority: 1
|
||
});
|
||
|
||
return {
|
||
awaitingPlaylistSelection: true,
|
||
playlistAnalysis,
|
||
playlistCandidates,
|
||
recommendation: playlistAnalysis?.recommendation || null
|
||
};
|
||
}
|
||
|
||
if (selectedPlaylistId) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'USER_ACTION',
|
||
`Playlist-Auswahl übernommen: ${toPlaylistFile(selectedPlaylistId) || selectedPlaylistId}.`
|
||
);
|
||
}
|
||
|
||
const selectedTitleForReview = pickTitleIdForTrackReview(playlistAnalysis, selectedTitleForContext);
|
||
if (selectedTitleForReview === null) {
|
||
const error = new Error('Titel-/Spurprüfung aus RAW nicht möglich: keine auflösbare Titel-ID vorhanden.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const selectedTitleFromAnalysis = Array.isArray(playlistAnalysis?.titles)
|
||
? (playlistAnalysis.titles.find((item) => Number(item?.titleId) === Number(selectedTitleForReview)) || null)
|
||
: null;
|
||
const resolvedPlaylistId = normalizePlaylistId(
|
||
selectedPlaylistId
|
||
|| selectedTitleFromAnalysis?.playlistId
|
||
|| playlistAnalysis?.recommendation?.playlistId
|
||
|| null
|
||
);
|
||
if (!resolvedPlaylistId) {
|
||
const error = new Error(
|
||
`Playlist konnte für MakeMKV Titel #${selectedTitleForReview} nicht aufgelöst werden.`
|
||
);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
if (updatedMakemkvInfo && updatedMakemkvInfo.analyzeContext) {
|
||
updatedMakemkvInfo.analyzeContext.selectedTitleId = selectedTitleForReview;
|
||
updatedMakemkvInfo.analyzeContext.selectedPlaylist = resolvedPlaylistId;
|
||
}
|
||
|
||
const cachedHandBrakePlaylistEntry = getCachedHandBrakePlaylistEntry(handBrakePlaylistScan, resolvedPlaylistId);
|
||
const expectedDurationForCache = Number(selectedTitleFromAnalysis?.durationSeconds || 0) || null;
|
||
const expectedSizeForCache = Number(selectedTitleFromAnalysis?.sizeBytes || 0) || null;
|
||
const hasCachedHandBrakeEntry = Boolean(
|
||
isHandBrakePlaylistCacheEntryCompatible(
|
||
cachedHandBrakePlaylistEntry,
|
||
resolvedPlaylistId,
|
||
{
|
||
expectedDurationSeconds: expectedDurationForCache,
|
||
expectedSizeBytes: expectedSizeForCache
|
||
}
|
||
)
|
||
);
|
||
if (cachedHandBrakePlaylistEntry && !hasCachedHandBrakeEntry) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`HandBrake Cache für ${toPlaylistFile(resolvedPlaylistId)} verworfen (inkompatible Playlist-/Dauerdaten).`
|
||
);
|
||
}
|
||
|
||
await this.updateProgress(
|
||
'MEDIAINFO_CHECK',
|
||
30,
|
||
null,
|
||
hasCachedHandBrakeEntry
|
||
? `HandBrake Trackdaten aus Cache (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`
|
||
: `HandBrake Titel-/Spurscan läuft (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`,
|
||
jobId
|
||
);
|
||
|
||
let handBrakeResolveRunInfo = null;
|
||
let handBrakeTitleRunInfo = null;
|
||
let resolvedHandBrakeTitleId = null;
|
||
const reviewTitleSource = 'handbrake';
|
||
const makeMkvSubtitleTracksForSelection = Array.isArray(selectedTitleFromAnalysis?.subtitleTracks)
|
||
? selectedTitleFromAnalysis.subtitleTracks
|
||
: [];
|
||
let reviewTitleInfo = null;
|
||
if (hasCachedHandBrakeEntry) {
|
||
resolvedHandBrakeTitleId = Math.trunc(Number(cachedHandBrakePlaylistEntry.handBrakeTitleId));
|
||
reviewTitleInfo = enrichTitleInfoWithForcedSubtitleAvailability(
|
||
cachedHandBrakePlaylistEntry.titleInfo,
|
||
makeMkvSubtitleTracksForSelection
|
||
);
|
||
handBrakeResolveRunInfo = {
|
||
source: 'HANDBRAKE_SCAN_PLAYLIST_MAP_CACHE',
|
||
stage: 'MEDIAINFO_CHECK',
|
||
status: 'CACHED',
|
||
exitCode: 0,
|
||
startedAt: handBrakePlaylistScan?.generatedAt || null,
|
||
endedAt: nowIso(),
|
||
durationMs: 0,
|
||
cmd: 'cache',
|
||
args: [`playlist=${resolvedPlaylistId}`, `title=${resolvedHandBrakeTitleId}`],
|
||
highlights: [
|
||
`Cache verwendet: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId}`
|
||
]
|
||
};
|
||
handBrakeTitleRunInfo = handBrakeResolveRunInfo;
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`HandBrake Track-Analyse aus Cache: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (kein erneuter --scan).`
|
||
);
|
||
} else {
|
||
try {
|
||
const resolveScanLines = [];
|
||
const resolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(rawPath, { mediaProfile });
|
||
logger.info('backup-track-review:handbrake-resolve-command', {
|
||
jobId,
|
||
cmd: resolveScanConfig.cmd,
|
||
args: resolveScanConfig.args,
|
||
sourceArg: resolveScanConfig.sourceArg,
|
||
selectedPlaylistId: resolvedPlaylistId,
|
||
selectedMakemkvTitleId: selectedTitleForReview
|
||
});
|
||
|
||
handBrakeResolveRunInfo = await this.runCommand({
|
||
jobId,
|
||
stage: 'MEDIAINFO_CHECK',
|
||
source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
|
||
cmd: resolveScanConfig.cmd,
|
||
args: resolveScanConfig.args,
|
||
collectLines: resolveScanLines,
|
||
collectStderrLines: false,
|
||
silent: !this.isPrimaryJob(jobId)
|
||
});
|
||
|
||
const resolveScanJson = parseMediainfoJsonOutput(resolveScanLines.join('\n'));
|
||
if (!resolveScanJson) {
|
||
const error = new Error('HandBrake Playlist-Mapping lieferte kein parsebares JSON.');
|
||
error.runInfo = handBrakeResolveRunInfo;
|
||
throw error;
|
||
}
|
||
|
||
resolvedHandBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(resolveScanJson, resolvedPlaylistId, {
|
||
expectedMakemkvTitleId: selectedTitleForReview,
|
||
expectedDurationSeconds: expectedDurationForCache,
|
||
expectedSizeBytes: expectedSizeForCache
|
||
});
|
||
if (!resolvedHandBrakeTitleId) {
|
||
const knownPlaylists = listAvailableHandBrakePlaylists(resolveScanJson);
|
||
const error = new Error(
|
||
`Kein HandBrake-Titel für ${toPlaylistFile(resolvedPlaylistId)} gefunden.`
|
||
+ ` ${knownPlaylists.length > 0 ? `Scan-Playlists: ${knownPlaylists.map((id) => `${id}.mpls`).join(', ')}` : 'Scan enthält keine erkennbaren Playlist-IDs.'}`
|
||
);
|
||
error.statusCode = 400;
|
||
error.runInfo = handBrakeResolveRunInfo;
|
||
throw error;
|
||
}
|
||
|
||
reviewTitleInfo = parseHandBrakeSelectedTitleInfo(resolveScanJson, {
|
||
playlistId: resolvedPlaylistId,
|
||
handBrakeTitleId: resolvedHandBrakeTitleId,
|
||
makeMkvSubtitleTracks: makeMkvSubtitleTracksForSelection
|
||
});
|
||
if (!reviewTitleInfo) {
|
||
const error = new Error(
|
||
`HandBrake lieferte keine verwertbaren Trackdaten für ${toPlaylistFile(resolvedPlaylistId)} (-t ${resolvedHandBrakeTitleId}).`
|
||
);
|
||
error.statusCode = 400;
|
||
error.runInfo = handBrakeResolveRunInfo;
|
||
throw error;
|
||
}
|
||
if (!isHandBrakePlaylistCacheEntryCompatible({
|
||
playlistId: resolvedPlaylistId,
|
||
handBrakeTitleId: resolvedHandBrakeTitleId,
|
||
titleInfo: reviewTitleInfo
|
||
}, resolvedPlaylistId, {
|
||
expectedDurationSeconds: expectedDurationForCache,
|
||
expectedSizeBytes: expectedSizeForCache
|
||
})) {
|
||
const error = new Error(
|
||
`HandBrake Titel-Mapping inkonsistent für ${toPlaylistFile(resolvedPlaylistId)} (-t ${resolvedHandBrakeTitleId}).`
|
||
);
|
||
error.statusCode = 400;
|
||
error.runInfo = handBrakeResolveRunInfo;
|
||
throw error;
|
||
}
|
||
|
||
handBrakeTitleRunInfo = handBrakeResolveRunInfo;
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).`
|
||
);
|
||
|
||
const audioTrackPreview = buildHandBrakeAudioTrackPreview(reviewTitleInfo);
|
||
const fallbackCache = normalizeHandBrakePlaylistScanCache(handBrakePlaylistScan) || {
|
||
generatedAt: nowIso(),
|
||
source: 'HANDBRAKE_SCAN_PLAYLIST_MAP',
|
||
inputPath: rawPath,
|
||
byPlaylist: {}
|
||
};
|
||
fallbackCache.byPlaylist[resolvedPlaylistId] = {
|
||
playlistId: resolvedPlaylistId,
|
||
handBrakeTitleId: Math.trunc(Number(resolvedHandBrakeTitleId)),
|
||
titleInfo: reviewTitleInfo,
|
||
audioTrackPreview,
|
||
audioSummary: buildHandBrakeAudioSummary(audioTrackPreview)
|
||
};
|
||
handBrakePlaylistScan = normalizeHandBrakePlaylistScanCache(fallbackCache);
|
||
playlistAnalysis = enrichPlaylistAnalysisWithHandBrakeCache(playlistAnalysis, handBrakePlaylistScan);
|
||
} catch (error) {
|
||
logger.warn('backup-track-review:handbrake-scan-failed', {
|
||
jobId,
|
||
selectedPlaylistId: resolvedPlaylistId,
|
||
selectedTitleForReview,
|
||
error: errorToMeta(error)
|
||
});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
if (updatedMakemkvInfo && updatedMakemkvInfo.analyzeContext) {
|
||
updatedMakemkvInfo.analyzeContext.handBrakePlaylistScan = handBrakePlaylistScan || null;
|
||
}
|
||
playlistCandidates = buildPlaylistCandidates(playlistAnalysis);
|
||
|
||
let presetProfile = null;
|
||
try {
|
||
presetProfile = await settingsService.buildHandBrakePresetProfile(rawPath, {
|
||
titleId: resolvedHandBrakeTitleId,
|
||
mediaProfile
|
||
});
|
||
} catch (error) {
|
||
logger.warn('backup-track-review:preset-profile-failed', {
|
||
jobId,
|
||
error: errorToMeta(error)
|
||
});
|
||
presetProfile = {
|
||
source: 'fallback',
|
||
message: `Preset-Profil konnte nicht geladen werden: ${error.message}`
|
||
};
|
||
}
|
||
|
||
const syntheticFilePath = path.join(
|
||
rawPath,
|
||
reviewTitleSource === 'handbrake' && Number.isFinite(Number(resolvedHandBrakeTitleId)) && Number(resolvedHandBrakeTitleId) > 0
|
||
? `handbrake_t${String(Math.trunc(Number(resolvedHandBrakeTitleId))).padStart(2, '0')}.mkv`
|
||
: `makemkv_t${String(selectedTitleForReview).padStart(2, '0')}.mkv`
|
||
);
|
||
const syntheticMediaInfoByPath = {
|
||
[syntheticFilePath]: buildSyntheticMediaInfoFromMakeMkvTitle(reviewTitleInfo)
|
||
};
|
||
let review = buildMediainfoReview({
|
||
mediaFiles: [{
|
||
path: syntheticFilePath,
|
||
size: Number(reviewTitleInfo?.sizeBytes || 0)
|
||
}],
|
||
mediaInfoByPath: syntheticMediaInfoByPath,
|
||
settings,
|
||
presetProfile,
|
||
playlistAnalysis,
|
||
preferredEncodeTitleId: selectedTitleForReview,
|
||
selectedPlaylistId: resolvedPlaylistId || reviewTitleInfo?.playlistId || null,
|
||
selectedMakemkvTitleId: selectedTitleForReview
|
||
});
|
||
review = remapReviewTrackIdsToSourceIds(review);
|
||
|
||
const resolvedPlaylistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId);
|
||
const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0);
|
||
const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60));
|
||
const subtitleTrackMetaBySourceId = new Map(
|
||
(Array.isArray(reviewTitleInfo?.subtitleTracks) ? reviewTitleInfo.subtitleTracks : [])
|
||
.map((track) => {
|
||
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
|
||
return sourceTrackId ? [sourceTrackId, track] : null;
|
||
})
|
||
.filter(Boolean)
|
||
);
|
||
const normalizedTitles = (Array.isArray(review.titles) ? review.titles : [])
|
||
.slice(0, 1)
|
||
.map((title) => {
|
||
const durationSeconds = Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0);
|
||
const eligibleForEncode = durationSeconds >= minLengthSecondsForReview;
|
||
const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
|
||
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
|
||
const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
|
||
return {
|
||
...track,
|
||
id: sourceTrackId || track?.id || null,
|
||
sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null,
|
||
language: sourceMeta?.language || track?.language || 'und',
|
||
languageLabel: sourceMeta?.languageLabel || track?.languageLabel || track?.language || 'und',
|
||
title: sourceMeta?.title ?? track?.title ?? null,
|
||
format: sourceMeta?.format || track?.format || null,
|
||
forcedTrack: Boolean(sourceMeta?.forcedTrack),
|
||
forcedAvailable: Boolean(sourceMeta?.forcedAvailable),
|
||
forcedSourceTrackIds: normalizeTrackIdList(sourceMeta?.forcedSourceTrackIds || [])
|
||
};
|
||
});
|
||
|
||
return {
|
||
...title,
|
||
filePath: rawPath,
|
||
fileName: reviewTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`,
|
||
durationSeconds,
|
||
durationMinutes: Number(((durationSeconds / 60)).toFixed(2)),
|
||
selectedByMinLength: eligibleForEncode,
|
||
eligibleForEncode,
|
||
sizeBytes: Number(reviewTitleInfo?.sizeBytes || title?.sizeBytes || 0),
|
||
playlistId: resolvedPlaylistInfo.playlistId || title?.playlistId || null,
|
||
playlistFile: resolvedPlaylistInfo.playlistFile || title?.playlistFile || null,
|
||
playlistRecommended: Boolean(resolvedPlaylistInfo.recommended || title?.playlistRecommended),
|
||
playlistEvaluationLabel: resolvedPlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null,
|
||
playlistSegmentCommand: resolvedPlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null,
|
||
playlistSegmentFiles: Array.isArray(resolvedPlaylistInfo.segmentFiles) && resolvedPlaylistInfo.segmentFiles.length > 0
|
||
? resolvedPlaylistInfo.segmentFiles
|
||
: (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []),
|
||
subtitleTracks
|
||
};
|
||
});
|
||
|
||
const encodeInputTitleId = Number(normalizedTitles[0]?.id || review.encodeInputTitleId || null) || null;
|
||
review = {
|
||
...review,
|
||
mode,
|
||
mediaProfile,
|
||
sourceJobId: options.sourceJobId || null,
|
||
reviewConfirmed: false,
|
||
partial: false,
|
||
processedFiles: 1,
|
||
totalFiles: 1,
|
||
handBrakeTitleId: resolvedHandBrakeTitleId || null,
|
||
selectedPlaylistId: resolvedPlaylistId || null,
|
||
selectedMakemkvTitleId: selectedTitleForReview,
|
||
titleSelectionRequired: false,
|
||
titles: normalizedTitles,
|
||
selectedTitleIds: encodeInputTitleId ? [encodeInputTitleId] : [],
|
||
encodeInputTitleId,
|
||
encodeInputPath: rawPath,
|
||
notes: [
|
||
...(Array.isArray(review.notes) ? review.notes : []),
|
||
'MakeMKV Full-Analyse wurde einmal für Playlist-/Titel-Mapping verwendet.',
|
||
`HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).`
|
||
]
|
||
};
|
||
|
||
const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan(
|
||
review,
|
||
options?.previousEncodePlan && typeof options.previousEncodePlan === 'object'
|
||
? options.previousEncodePlan
|
||
: null
|
||
);
|
||
review = reviewPrefillResult.plan;
|
||
|
||
if (!Array.isArray(review.titles) || review.titles.length === 0) {
|
||
const error = new Error('Titel-/Spurprüfung aus RAW lieferte keine Titel.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'READY_TO_ENCODE',
|
||
last_state: 'READY_TO_ENCODE',
|
||
error_message: null,
|
||
makemkv_info_json: JSON.stringify(updatedMakemkvInfo),
|
||
mediainfo_info_json: JSON.stringify({
|
||
generatedAt: nowIso(),
|
||
source: 'raw_backup_handbrake_playlist_scan',
|
||
makemkvAnalyzeRunInfo: makeMkvAnalyzeRunInfo,
|
||
makemkvTitleAnalyzeRunInfo: null,
|
||
handbrakePlaylistResolveRunInfo: handBrakeResolveRunInfo,
|
||
handbrakeTitleRunInfo: handBrakeTitleRunInfo,
|
||
handbrakeTitleId: resolvedHandBrakeTitleId || null
|
||
}),
|
||
encode_plan_json: JSON.stringify(review),
|
||
encode_input_path: review.encodeInputPath || null,
|
||
encode_review_confirmed: 0
|
||
});
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Titel-/Spurprüfung aus RAW abgeschlossen (MakeMKV Titel #${selectedTitleForReview}): ${review.titles.length} Titel, Vorauswahl=${review.encodeInputTitleId ? `Titel #${review.encodeInputTitleId}` : 'keine'}.`
|
||
);
|
||
if (reviewPrefillResult.applied) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, `
|
||
+ `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, `
|
||
+ `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, `
|
||
+ `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.`
|
||
);
|
||
}
|
||
if (playlistDecisionRequired) {
|
||
const playlistFiles = playlistCandidates.map((item) => item.playlistFile).filter(Boolean);
|
||
const recommendationFile = toPlaylistFile(playlistAnalysis?.recommendation?.playlistId);
|
||
const decisionContext = describePlaylistManualDecision(playlistAnalysis);
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`${decisionContext.detailText} (RAW). Kandidaten: ${playlistFiles.join(', ') || 'keine'}.`
|
||
);
|
||
if (recommendationFile) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Playlist-Empfehlung: ${recommendationFile}`
|
||
);
|
||
}
|
||
}
|
||
|
||
const hasEncodableTitle = Boolean(review.encodeInputPath && review.encodeInputTitleId);
|
||
if (this.isPrimaryJob(jobId)) {
|
||
await this.setState('READY_TO_ENCODE', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: review.titleSelectionRequired
|
||
? 'Titel-/Spurprüfung fertig - Titel per Checkbox wählen'
|
||
: (hasEncodableTitle
|
||
? 'Titel-/Spurprüfung fertig - Auswahl bestätigen, dann Encode manuell starten'
|
||
: 'Titel-/Spurprüfung fertig - kein Titel erfüllt MIN_LENGTH_MINUTES'),
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
rawPath,
|
||
inputPath: review.encodeInputPath || null,
|
||
hasEncodableTitle,
|
||
reviewConfirmed: false,
|
||
mode,
|
||
mediaProfile,
|
||
sourceJobId: options.sourceJobId || null,
|
||
mediaInfoReview: review,
|
||
selectedMetadata,
|
||
playlistAnalysis: playlistAnalysis || null,
|
||
playlistDecisionRequired,
|
||
playlistCandidates,
|
||
selectedPlaylist: resolvedPlaylistId || null,
|
||
selectedTitleId: selectedTitleForReview
|
||
}
|
||
});
|
||
}
|
||
|
||
void this.notifyPushover('metadata_ready', {
|
||
title: 'Ripster - RAW geprüft',
|
||
message: `Job #${jobId}: bereit zum manuellen Encode-Start`
|
||
});
|
||
|
||
return review;
|
||
}
|
||
|
||
async runReviewForRawJob(jobId, rawPath, options = {}) {
|
||
const useBackupReview = hasBluRayBackupStructure(rawPath);
|
||
logger.info('review:dispatch', {
|
||
jobId,
|
||
rawPath,
|
||
mode: options?.mode || 'rip',
|
||
useBackupReview
|
||
});
|
||
|
||
if (useBackupReview) {
|
||
return this.runBackupTrackReviewForJob(jobId, rawPath, options);
|
||
}
|
||
return this.runMediainfoReviewForJob(jobId, rawPath, options);
|
||
}
|
||
|
||
async selectMetadata({ jobId, title, year, imdbId, poster, fromOmdb = null, selectedPlaylist = null }) {
|
||
this.ensureNotBusy('selectMetadata', jobId);
|
||
logger.info('metadata:selected', { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist });
|
||
|
||
const job = await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
const mkInfo = this.safeParseJson(job.makemkv_info_json);
|
||
const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo });
|
||
|
||
const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist);
|
||
const waitingForPlaylistSelection = (
|
||
job.status === 'WAITING_FOR_USER_DECISION'
|
||
|| job.last_state === 'WAITING_FOR_USER_DECISION'
|
||
);
|
||
const hasExplicitMetadataPayload = (
|
||
title !== undefined
|
||
|| year !== undefined
|
||
|| imdbId !== undefined
|
||
|| poster !== undefined
|
||
|| (fromOmdb !== null && fromOmdb !== undefined)
|
||
);
|
||
if (normalizedSelectedPlaylist && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload) {
|
||
const currentMkInfo = mkInfo;
|
||
const currentAnalyzeContext = currentMkInfo?.analyzeContext || {};
|
||
const currentPlaylistAnalysis = currentAnalyzeContext.playlistAnalysis || this.snapshot.context?.playlistAnalysis || null;
|
||
const selectedTitleId = pickTitleIdForPlaylist(currentPlaylistAnalysis, normalizedSelectedPlaylist);
|
||
const updatedMkInfo = this.withAnalyzeContextMediaProfile({
|
||
...currentMkInfo,
|
||
analyzeContext: {
|
||
...currentAnalyzeContext,
|
||
playlistAnalysis: currentPlaylistAnalysis || null,
|
||
playlistDecisionRequired: Boolean(currentPlaylistAnalysis?.manualDecisionRequired),
|
||
selectedPlaylist: normalizedSelectedPlaylist,
|
||
selectedTitleId: selectedTitleId ?? null
|
||
}
|
||
}, mediaProfile);
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'MEDIAINFO_CHECK',
|
||
last_state: 'MEDIAINFO_CHECK',
|
||
error_message: null,
|
||
makemkv_info_json: JSON.stringify(updatedMkInfo),
|
||
mediainfo_info_json: null,
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'USER_ACTION',
|
||
`Playlist-Auswahl gesetzt: ${toPlaylistFile(normalizedSelectedPlaylist) || normalizedSelectedPlaylist}.`
|
||
);
|
||
|
||
try {
|
||
await this.runBackupTrackReviewForJob(jobId, job.raw_path, {
|
||
mode: 'rip',
|
||
selectedPlaylist: normalizedSelectedPlaylist,
|
||
selectedTitleId: selectedTitleId ?? null,
|
||
mediaProfile
|
||
});
|
||
} catch (error) {
|
||
logger.error('metadata:playlist-selection:review-failed', {
|
||
jobId,
|
||
selectedPlaylist: normalizedSelectedPlaylist,
|
||
selectedTitleId: selectedTitleId ?? null,
|
||
error: errorToMeta(error)
|
||
});
|
||
await this.failJob(jobId, 'MEDIAINFO_CHECK', error);
|
||
throw error;
|
||
}
|
||
return historyService.getJobById(jobId);
|
||
}
|
||
|
||
const hasTitleInput = title !== undefined && title !== null && String(title).trim().length > 0;
|
||
const effectiveTitle = hasTitleInput
|
||
? String(title).trim()
|
||
: (job.title || job.detected_title || 'Unknown Title');
|
||
const hasYearInput = year !== undefined && year !== null && String(year).trim() !== '';
|
||
let effectiveYear = job.year ?? null;
|
||
if (hasYearInput) {
|
||
const parsedYear = Number(year);
|
||
effectiveYear = Number.isNaN(parsedYear) ? null : parsedYear;
|
||
}
|
||
const effectiveImdbId = imdbId === undefined
|
||
? (job.imdb_id || null)
|
||
: (imdbId || null);
|
||
const selectedFromOmdb = fromOmdb === null || fromOmdb === undefined
|
||
? Number(job.selected_from_omdb || 0)
|
||
: (fromOmdb ? 1 : 0);
|
||
const posterValue = poster === undefined
|
||
? (job.poster_url || null)
|
||
: (poster || null);
|
||
|
||
// Fetch full OMDb details when selecting from OMDb with a valid IMDb ID.
|
||
let omdbJsonValue = job.omdb_json || null;
|
||
if (fromOmdb && effectiveImdbId) {
|
||
try {
|
||
const omdbFull = await omdbService.fetchByImdbId(effectiveImdbId);
|
||
if (omdbFull?.raw) {
|
||
omdbJsonValue = JSON.stringify(omdbFull.raw);
|
||
}
|
||
} catch (omdbErr) {
|
||
logger.warn('metadata:omdb-fetch-failed', { jobId, imdbId: effectiveImdbId, error: errorToMeta(omdbErr) });
|
||
}
|
||
}
|
||
|
||
const selectedMetadata = {
|
||
title: effectiveTitle,
|
||
year: effectiveYear,
|
||
imdbId: effectiveImdbId,
|
||
poster: posterValue
|
||
};
|
||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||
const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
|
||
? 'backup'
|
||
: 'mkv';
|
||
const isBackupMode = ripMode === 'backup';
|
||
const metadataBase = buildRawMetadataBase({
|
||
title: selectedMetadata.title || job.detected_title || null,
|
||
year: selectedMetadata.year || null
|
||
}, jobId);
|
||
const existingRawPath = findExistingRawDirectory(settings.raw_dir, metadataBase);
|
||
let updatedRawPath = existingRawPath || null;
|
||
if (existingRawPath) {
|
||
const existingRawState = resolveRawFolderStateFromPath(existingRawPath);
|
||
const renameState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE
|
||
? RAW_FOLDER_STATES.INCOMPLETE
|
||
: RAW_FOLDER_STATES.RIP_COMPLETE;
|
||
const renamedDirName = buildRawDirName(metadataBase, jobId, { state: renameState });
|
||
const renamedRawPath = path.join(settings.raw_dir, renamedDirName);
|
||
if (existingRawPath !== renamedRawPath && !fs.existsSync(renamedRawPath)) {
|
||
try {
|
||
fs.renameSync(existingRawPath, renamedRawPath);
|
||
updatedRawPath = renamedRawPath;
|
||
await historyService.updateRawPathByOldPath(existingRawPath, renamedRawPath);
|
||
logger.info('metadata:raw-dir-renamed', { from: existingRawPath, to: renamedRawPath, jobId, state: renameState });
|
||
} catch (renameError) {
|
||
logger.warn('metadata:raw-dir-rename-failed', { existingRawPath, renamedRawPath, error: errorToMeta(renameError) });
|
||
}
|
||
}
|
||
}
|
||
const basePlaylistDecision = this.resolvePlaylistDecisionForJob(jobId, job, selectedPlaylist);
|
||
const playlistDecision = isBackupMode
|
||
? {
|
||
...basePlaylistDecision,
|
||
playlistAnalysis: null,
|
||
playlistDecisionRequired: false,
|
||
candidatePlaylists: [],
|
||
selectedPlaylist: null,
|
||
selectedTitleId: null,
|
||
recommendation: null
|
||
}
|
||
: basePlaylistDecision;
|
||
const requiresManualPlaylistSelection = Boolean(
|
||
playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null
|
||
);
|
||
const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START';
|
||
|
||
const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({
|
||
...mkInfo,
|
||
analyzeContext: {
|
||
...(mkInfo?.analyzeContext || {}),
|
||
playlistAnalysis: playlistDecision.playlistAnalysis || mkInfo?.analyzeContext?.playlistAnalysis || null,
|
||
playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired),
|
||
selectedPlaylist: playlistDecision.selectedPlaylist || null,
|
||
selectedTitleId: playlistDecision.selectedTitleId ?? null
|
||
}
|
||
}, mediaProfile);
|
||
|
||
await historyService.updateJob(jobId, {
|
||
title: effectiveTitle,
|
||
year: effectiveYear,
|
||
imdb_id: effectiveImdbId,
|
||
poster_url: posterValue,
|
||
selected_from_omdb: selectedFromOmdb,
|
||
omdb_json: omdbJsonValue,
|
||
status: nextStatus,
|
||
last_state: nextStatus,
|
||
raw_path: updatedRawPath,
|
||
makemkv_info_json: JSON.stringify(updatedMakemkvInfo)
|
||
});
|
||
|
||
const runningJobs = await historyService.getRunningJobs();
|
||
const foreignRunningJobs = runningJobs.filter((item) => Number(item?.id) !== Number(jobId));
|
||
const keepCurrentPipelineSession = foreignRunningJobs.length > 0;
|
||
|
||
if (existingRawPath) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Vorhandenes RAW-Verzeichnis erkannt: ${existingRawPath}`
|
||
);
|
||
} else {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Kein bestehendes RAW-Verzeichnis zu den Metadaten gefunden (${metadataBase}).`
|
||
);
|
||
}
|
||
|
||
if (!keepCurrentPipelineSession) {
|
||
await this.setState(nextStatus, {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: requiresManualPlaylistSelection
|
||
? 'waiting_for_manual_playlist_selection'
|
||
: (existingRawPath
|
||
? 'Metadaten übernommen - vorhandenes RAW erkannt'
|
||
: 'Metadaten übernommen - bereit zum Start'),
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
rawPath: updatedRawPath,
|
||
mediaProfile,
|
||
selectedMetadata,
|
||
playlistAnalysis: playlistDecision.playlistAnalysis || null,
|
||
playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired),
|
||
playlistCandidates: playlistDecision.candidatePlaylists,
|
||
selectedPlaylist: playlistDecision.selectedPlaylist || null,
|
||
selectedTitleId: playlistDecision.selectedTitleId ?? null,
|
||
waitingForManualPlaylistSelection: requiresManualPlaylistSelection,
|
||
manualDecisionState: requiresManualPlaylistSelection
|
||
? 'waiting_for_manual_playlist_selection'
|
||
: null
|
||
}
|
||
});
|
||
} else {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Metadaten übernommen. Aktive Session bleibt bei laufendem Job #${foreignRunningJobs.map((item) => item.id).join(',')}.`
|
||
);
|
||
}
|
||
|
||
if (requiresManualPlaylistSelection) {
|
||
const playlistFiles = playlistDecision.candidatePlaylists
|
||
.map((item) => item.playlistFile)
|
||
.filter(Boolean);
|
||
const recommendationFile = toPlaylistFile(playlistDecision.recommendation?.playlistId);
|
||
const decisionContext = describePlaylistManualDecision(playlistDecision.playlistAnalysis);
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`${decisionContext.detailText} Status=waiting_for_manual_playlist_selection. Kandidaten: ${playlistFiles.join(', ') || 'keine'}.`
|
||
);
|
||
if (recommendationFile) {
|
||
await historyService.appendLog(jobId, 'SYSTEM', `Empfehlung laut MakeMKV-TINFO-Analyse: ${recommendationFile}`);
|
||
}
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'Bitte selected_playlist setzen (z.B. 00800 oder 00800.mpls), bevor Backup/Encoding gestartet wird.'
|
||
);
|
||
return historyService.getJobById(jobId);
|
||
}
|
||
|
||
if (playlistDecision.playlistDecisionRequired && playlistDecision.selectedPlaylist) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Manuelle Playlist-Auswahl übernommen: ${toPlaylistFile(playlistDecision.selectedPlaylist) || playlistDecision.selectedPlaylist}`
|
||
);
|
||
}
|
||
|
||
if (existingRawPath) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'Metadaten übernommen. Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW.'
|
||
);
|
||
const startResult = await this.startPreparedJob(jobId);
|
||
logger.info('metadata:auto-track-review-started', {
|
||
jobId,
|
||
stage: startResult?.stage || null,
|
||
reusedRaw: Boolean(startResult?.reusedRaw),
|
||
selectedPlaylist: playlistDecision.selectedPlaylist || null,
|
||
selectedTitleId: playlistDecision.selectedTitleId ?? null
|
||
});
|
||
return historyService.getJobById(jobId);
|
||
}
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'Metadaten übernommen. Starte Backup/Rip automatisch.'
|
||
);
|
||
const startResult = await this.startPreparedJob(jobId);
|
||
logger.info('metadata:auto-start', {
|
||
jobId,
|
||
stage: startResult?.stage || null,
|
||
reusedRaw: Boolean(startResult?.reusedRaw),
|
||
selectedPlaylist: playlistDecision.selectedPlaylist || null,
|
||
selectedTitleId: playlistDecision.selectedTitleId ?? null
|
||
});
|
||
|
||
return historyService.getJobById(jobId);
|
||
}
|
||
|
||
async startPreparedJob(jobId, options = {}) {
|
||
const immediate = Boolean(options?.immediate);
|
||
if (!immediate) {
|
||
const preloadedJob = await historyService.getJobById(jobId);
|
||
if (!preloadedJob) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
if (!preloadedJob.title && !preloadedJob.detected_title) {
|
||
const error = new Error('Start nicht möglich: keine Metadaten vorhanden.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE';
|
||
if (isReadyToEncode) {
|
||
// Check whether this confirmed job will rip first (pre_rip mode) or encode directly.
|
||
// Pre-rip jobs bypass the encode queue because the next step is a rip, not an encode.
|
||
const jobEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json);
|
||
const jobMode = String(jobEncodePlan?.mode || '').trim().toLowerCase();
|
||
const willRipFirst = jobMode === 'pre_rip' || Boolean(jobEncodePlan?.preRip);
|
||
if (willRipFirst) {
|
||
return this.startPreparedJob(jobId, { ...options, immediate: true });
|
||
}
|
||
return this.enqueueOrStartAction(
|
||
QUEUE_ACTIONS.START_PREPARED,
|
||
jobId,
|
||
() => this.startPreparedJob(jobId, { ...options, immediate: true })
|
||
);
|
||
}
|
||
|
||
let hasUsableRawInput = false;
|
||
if (preloadedJob.raw_path) {
|
||
try {
|
||
if (fs.existsSync(preloadedJob.raw_path)) {
|
||
const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, preloadedJob);
|
||
hasUsableRawInput = Boolean(findPreferredRawInput(preloadedJob.raw_path, {
|
||
playlistAnalysis: playlistDecision.playlistAnalysis,
|
||
selectedPlaylistId: playlistDecision.selectedPlaylist
|
||
}));
|
||
}
|
||
} catch (_error) {
|
||
hasUsableRawInput = false;
|
||
}
|
||
}
|
||
|
||
if (!hasUsableRawInput) {
|
||
// No raw input yet → will rip from disc. Bypass the encode queue entirely.
|
||
return this.startPreparedJob(jobId, { ...options, immediate: true });
|
||
}
|
||
|
||
return this.startPreparedJob(jobId, { ...options, immediate: true, preloadedJob });
|
||
}
|
||
|
||
this.ensureNotBusy('startPreparedJob', jobId);
|
||
logger.info('startPreparedJob:requested', { jobId });
|
||
this.cancelRequestedByJob.delete(Number(jobId));
|
||
|
||
const job = options?.preloadedJob || await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
if (!job.title && !job.detected_title) {
|
||
const error = new Error('Start nicht möglich: keine Metadaten vorhanden.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
await historyService.resetProcessLog(jobId);
|
||
|
||
const encodePlanForReadyState = this.safeParseJson(job.encode_plan_json);
|
||
const readyMode = String(encodePlanForReadyState?.mode || '').trim().toLowerCase();
|
||
const isPreRipReadyState = readyMode === 'pre_rip' || Boolean(encodePlanForReadyState?.preRip);
|
||
const isReadyToEncode = job.status === 'READY_TO_ENCODE' || job.last_state === 'READY_TO_ENCODE';
|
||
if (isReadyToEncode) {
|
||
if (!Number(job.encode_review_confirmed || 0)) {
|
||
const error = new Error('Encode-Start nicht erlaubt: Mediainfo-Prüfung muss zuerst bestätigt werden.');
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
if (isPreRipReadyState) {
|
||
await historyService.updateJob(jobId, {
|
||
status: 'RIPPING',
|
||
last_state: 'RIPPING',
|
||
error_message: null,
|
||
end_time: null
|
||
});
|
||
|
||
this.startRipEncode(jobId).catch((error) => {
|
||
logger.error('startPreparedJob:rip-background-failed', { jobId, error: errorToMeta(error) });
|
||
});
|
||
|
||
return { started: true, stage: 'RIPPING' };
|
||
}
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'ENCODING',
|
||
last_state: 'ENCODING',
|
||
error_message: null,
|
||
end_time: null
|
||
});
|
||
|
||
this.startEncodingFromPrepared(jobId).catch((error) => {
|
||
logger.error('startPreparedJob:encode-background-failed', { jobId, error: errorToMeta(error) });
|
||
});
|
||
|
||
return { started: true, stage: 'ENCODING' };
|
||
}
|
||
|
||
const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job);
|
||
const mediaProfile = this.resolveMediaProfileForJob(job, {
|
||
encodePlan: encodePlanForReadyState
|
||
});
|
||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||
const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
|
||
? 'backup'
|
||
: 'mkv';
|
||
const enforcePlaylistBeforeStart = ripMode !== 'backup';
|
||
if (enforcePlaylistBeforeStart && playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null) {
|
||
const error = new Error(
|
||
'Start nicht möglich: waiting_for_manual_playlist_selection aktiv. Bitte zuerst selected_playlist setzen.'
|
||
);
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
let existingRawInput = null;
|
||
if (job.raw_path) {
|
||
try {
|
||
if (fs.existsSync(job.raw_path)) {
|
||
existingRawInput = findPreferredRawInput(job.raw_path, {
|
||
playlistAnalysis: playlistDecision.playlistAnalysis,
|
||
selectedPlaylistId: playlistDecision.selectedPlaylist
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.warn('startPreparedJob:existing-raw-check-failed', {
|
||
jobId,
|
||
rawPath: job.raw_path,
|
||
error: errorToMeta(error)
|
||
});
|
||
}
|
||
}
|
||
|
||
if (existingRawInput) {
|
||
await historyService.updateJob(jobId, {
|
||
status: 'MEDIAINFO_CHECK',
|
||
last_state: 'MEDIAINFO_CHECK',
|
||
start_time: nowIso(),
|
||
end_time: null,
|
||
error_message: null,
|
||
output_path: null,
|
||
handbrake_info_json: null,
|
||
mediainfo_info_json: null,
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0
|
||
});
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Vorhandenes RAW wird verwendet. Starte Titel-/Spurprüfung: ${job.raw_path}`
|
||
);
|
||
|
||
this.runReviewForRawJob(jobId, job.raw_path, {
|
||
mode: 'rip',
|
||
mediaProfile
|
||
}).catch((error) => {
|
||
logger.error('startPreparedJob:review-background-failed', { jobId, error: errorToMeta(error) });
|
||
this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => {
|
||
logger.error('startPreparedJob:review-background-failJob-failed', {
|
||
jobId,
|
||
error: errorToMeta(failError)
|
||
});
|
||
});
|
||
});
|
||
|
||
return {
|
||
started: true,
|
||
stage: 'MEDIAINFO_CHECK',
|
||
reusedRaw: true,
|
||
rawPath: job.raw_path
|
||
};
|
||
}
|
||
|
||
if (job.raw_path) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Kein verwertbares RAW unter ${job.raw_path} gefunden. Starte neuen Rip.`
|
||
);
|
||
}
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'RIPPING',
|
||
last_state: 'RIPPING',
|
||
error_message: null,
|
||
end_time: null,
|
||
handbrake_info_json: null,
|
||
mediainfo_info_json: null,
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0,
|
||
output_path: null
|
||
});
|
||
|
||
this.startRipEncode(jobId).catch((error) => {
|
||
logger.error('startPreparedJob:background-failed', { jobId, error: errorToMeta(error) });
|
||
});
|
||
|
||
return { started: true, stage: 'RIPPING' };
|
||
}
|
||
|
||
async confirmEncodeReview(jobId, options = {}) {
|
||
this.ensureNotBusy('confirmEncodeReview', jobId);
|
||
const skipPipelineStateUpdate = Boolean(options?.skipPipelineStateUpdate);
|
||
logger.info('confirmEncodeReview:requested', {
|
||
jobId,
|
||
selectedEncodeTitleId: options?.selectedEncodeTitleId ?? null,
|
||
selectedTrackSelectionProvided: Boolean(options?.selectedTrackSelection),
|
||
skipPipelineStateUpdate,
|
||
selectedPostEncodeScriptIdsCount: Array.isArray(options?.selectedPostEncodeScriptIds)
|
||
? options.selectedPostEncodeScriptIds.length
|
||
: 0
|
||
});
|
||
|
||
let job = await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
if (job.status !== 'READY_TO_ENCODE' && job.last_state !== 'READY_TO_ENCODE') {
|
||
const currentStatus = String(job.status || job.last_state || '').trim().toUpperCase();
|
||
const recoverableStatus = currentStatus === 'ERROR' || currentStatus === 'CANCELLED';
|
||
const recoveryPlan = this.safeParseJson(job.encode_plan_json);
|
||
const recoveryMode = String(recoveryPlan?.mode || '').trim().toLowerCase();
|
||
const recoveryPreRip = recoveryMode === 'pre_rip' || Boolean(recoveryPlan?.preRip);
|
||
const recoveryHasInput = recoveryPreRip
|
||
? Boolean(recoveryPlan?.encodeInputTitleId)
|
||
: Boolean(job?.encode_input_path || recoveryPlan?.encodeInputPath || job?.raw_path);
|
||
const recoveryHasConfirmedPlan = Boolean(
|
||
recoveryPlan
|
||
&& Array.isArray(recoveryPlan?.titles)
|
||
&& recoveryPlan.titles.length > 0
|
||
&& (Number(job?.encode_review_confirmed || 0) === 1 || Boolean(recoveryPlan?.reviewConfirmed))
|
||
&& recoveryHasInput
|
||
);
|
||
if (recoverableStatus && recoveryHasConfirmedPlan) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Bestätigung angefordert obwohl Status ${currentStatus}. Letzte Encode-Auswahl wird automatisch geladen.`
|
||
);
|
||
await this.restartEncodeWithLastSettings(jobId, {
|
||
immediate: true,
|
||
triggerReason: 'confirm_auto_prepare'
|
||
});
|
||
job = await historyService.getJobById(jobId);
|
||
}
|
||
}
|
||
|
||
if (!job || (job.status !== 'READY_TO_ENCODE' && job.last_state !== 'READY_TO_ENCODE')) {
|
||
const error = new Error('Bestätigung nicht möglich: Job ist nicht im Status READY_TO_ENCODE.');
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
const encodePlan = this.safeParseJson(job.encode_plan_json);
|
||
if (!encodePlan || !Array.isArray(encodePlan.titles)) {
|
||
const error = new Error('Bestätigung nicht möglich: keine Mediainfo-Auswertung vorhanden.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const selectedEncodeTitleId = options?.selectedEncodeTitleId ?? null;
|
||
const planWithSelectionResult = applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId);
|
||
let planForConfirm = planWithSelectionResult.plan;
|
||
const trackSelectionResult = applyManualTrackSelectionToPlan(
|
||
planForConfirm,
|
||
options?.selectedTrackSelection || null
|
||
);
|
||
planForConfirm = trackSelectionResult.plan;
|
||
const hasExplicitPostScriptSelection = options?.selectedPostEncodeScriptIds !== undefined;
|
||
const selectedPostEncodeScriptIds = hasExplicitPostScriptSelection
|
||
? normalizeScriptIdList(options?.selectedPostEncodeScriptIds || [])
|
||
: normalizeScriptIdList(planForConfirm?.postEncodeScriptIds || encodePlan?.postEncodeScriptIds || []);
|
||
const selectedPostEncodeScripts = await scriptService.resolveScriptsByIds(selectedPostEncodeScriptIds, {
|
||
strict: true
|
||
});
|
||
|
||
const hasExplicitPreScriptSelection = options?.selectedPreEncodeScriptIds !== undefined;
|
||
const selectedPreEncodeScriptIds = hasExplicitPreScriptSelection
|
||
? normalizeScriptIdList(options?.selectedPreEncodeScriptIds || [])
|
||
: normalizeScriptIdList(planForConfirm?.preEncodeScriptIds || encodePlan?.preEncodeScriptIds || []);
|
||
const selectedPreEncodeScripts = await scriptService.resolveScriptsByIds(selectedPreEncodeScriptIds, { strict: true });
|
||
|
||
const hasExplicitPostChainSelection = options?.selectedPostEncodeChainIds !== undefined;
|
||
const selectedPostEncodeChainIds = hasExplicitPostChainSelection
|
||
? normalizeChainIdList(options?.selectedPostEncodeChainIds || [])
|
||
: normalizeChainIdList(planForConfirm?.postEncodeChainIds || encodePlan?.postEncodeChainIds || []);
|
||
|
||
const hasExplicitPreChainSelection = options?.selectedPreEncodeChainIds !== undefined;
|
||
const selectedPreEncodeChainIds = hasExplicitPreChainSelection
|
||
? normalizeChainIdList(options?.selectedPreEncodeChainIds || [])
|
||
: normalizeChainIdList(planForConfirm?.preEncodeChainIds || encodePlan?.preEncodeChainIds || []);
|
||
|
||
const confirmedMode = String(planForConfirm?.mode || encodePlan?.mode || 'rip').trim().toLowerCase();
|
||
const isPreRipMode = confirmedMode === 'pre_rip' || Boolean(planForConfirm?.preRip);
|
||
|
||
if (planForConfirm?.playlistDecisionRequired && !planForConfirm?.encodeInputPath && !planForConfirm?.encodeInputTitleId) {
|
||
const error = new Error('Bestätigung nicht möglich: Bitte zuerst einen Titel per Checkbox auswählen.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
// Resolve user preset: explicit payload wins, otherwise preserve currently selected preset from encode plan.
|
||
const hasExplicitUserPresetSelection = Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId');
|
||
let resolvedUserPreset = null;
|
||
if (hasExplicitUserPresetSelection) {
|
||
const rawUserPresetId = options?.selectedUserPresetId;
|
||
const userPresetId = rawUserPresetId !== null && rawUserPresetId !== undefined && String(rawUserPresetId).trim() !== ''
|
||
? Number(rawUserPresetId)
|
||
: null;
|
||
if (Number.isFinite(userPresetId) && userPresetId > 0) {
|
||
resolvedUserPreset = await userPresetService.getPresetById(userPresetId);
|
||
if (!resolvedUserPreset) {
|
||
const error = new Error(`User-Preset ${userPresetId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
}
|
||
} else {
|
||
resolvedUserPreset = normalizeUserPresetForPlan(encodePlan?.userPreset || null);
|
||
}
|
||
|
||
const confirmedPlan = {
|
||
...planForConfirm,
|
||
postEncodeScriptIds: selectedPostEncodeScripts.map((item) => Number(item.id)),
|
||
postEncodeScripts: selectedPostEncodeScripts.map((item) => ({
|
||
id: Number(item.id),
|
||
name: item.name
|
||
})),
|
||
preEncodeScriptIds: selectedPreEncodeScripts.map((item) => Number(item.id)),
|
||
preEncodeScripts: selectedPreEncodeScripts.map((item) => ({
|
||
id: Number(item.id),
|
||
name: item.name
|
||
})),
|
||
postEncodeChainIds: selectedPostEncodeChainIds,
|
||
preEncodeChainIds: selectedPreEncodeChainIds,
|
||
reviewConfirmed: true,
|
||
reviewConfirmedAt: nowIso(),
|
||
userPreset: normalizeUserPresetForPlan(resolvedUserPreset)
|
||
};
|
||
const readyMediaProfile = this.resolveMediaProfileForJob(job, {
|
||
encodePlan: confirmedPlan
|
||
});
|
||
const inputPath = isPreRipMode
|
||
? null
|
||
: (job.encode_input_path || confirmedPlan.encodeInputPath || this.snapshot.context?.inputPath || null);
|
||
const hasEncodableTitle = isPreRipMode
|
||
? Boolean(confirmedPlan?.encodeInputTitleId)
|
||
: Boolean(inputPath);
|
||
|
||
await historyService.updateJob(jobId, {
|
||
encode_review_confirmed: 1,
|
||
encode_plan_json: JSON.stringify(confirmedPlan),
|
||
encode_input_path: inputPath
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'USER_ACTION',
|
||
`Mediainfo-Prüfung bestätigt.${isPreRipMode ? ' Backup/Rip darf gestartet werden.' : ' Encode darf gestartet werden.'}${confirmedPlan.encodeInputTitleId ? ` Gewählter Titel #${confirmedPlan.encodeInputTitleId}.` : ''}`
|
||
+ ` Audio-Spuren: ${trackSelectionResult.audioTrackIds.length > 0 ? trackSelectionResult.audioTrackIds.join(',') : 'none'}.`
|
||
+ ` Subtitle-Spuren: ${trackSelectionResult.subtitleTrackIds.length > 0 ? trackSelectionResult.subtitleTrackIds.join(',') : 'none'}.`
|
||
+ ` Pre-Encode-Scripte: ${selectedPreEncodeScripts.length > 0 ? selectedPreEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.`
|
||
+ ` Pre-Encode-Ketten: ${selectedPreEncodeChainIds.length > 0 ? selectedPreEncodeChainIds.join(',') : 'none'}.`
|
||
+ ` Post-Encode-Scripte: ${selectedPostEncodeScripts.length > 0 ? selectedPostEncodeScripts.map((item) => item.name).join(' -> ') : 'none'}.`
|
||
+ ` Post-Encode-Ketten: ${selectedPostEncodeChainIds.length > 0 ? selectedPostEncodeChainIds.join(',') : 'none'}.`
|
||
+ (resolvedUserPreset
|
||
? ` User-Preset: "${resolvedUserPreset.name}"${resolvedUserPreset.id ? ` (ID ${resolvedUserPreset.id})` : ''}.`
|
||
: '')
|
||
);
|
||
|
||
if (!skipPipelineStateUpdate) {
|
||
await this.setState('READY_TO_ENCODE', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: hasEncodableTitle
|
||
? (isPreRipMode
|
||
? 'Spurauswahl bestätigt - Backup/Rip + Encode manuell starten'
|
||
: 'Mediainfo bestätigt - Encode manuell starten')
|
||
: (isPreRipMode
|
||
? 'Spurauswahl bestätigt - kein passender Titel gewählt'
|
||
: 'Mediainfo bestätigt - kein Titel erfüllt MIN_LENGTH_MINUTES'),
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
inputPath,
|
||
hasEncodableTitle,
|
||
mediaProfile: readyMediaProfile,
|
||
mediaInfoReview: confirmedPlan,
|
||
reviewConfirmed: true
|
||
}
|
||
});
|
||
}
|
||
|
||
return historyService.getJobById(jobId);
|
||
}
|
||
|
||
async reencodeFromRaw(sourceJobId, options = {}) {
|
||
this.ensureNotBusy('reencodeFromRaw', sourceJobId);
|
||
logger.info('reencodeFromRaw:requested', { sourceJobId });
|
||
this.cancelRequestedByJob.delete(Number(sourceJobId));
|
||
|
||
const sourceJob = await historyService.getJobById(sourceJobId);
|
||
if (!sourceJob) {
|
||
const error = new Error(`Quelle-Job ${sourceJobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
if (!sourceJob.raw_path) {
|
||
const error = new Error('Re-Encode nicht möglich: raw_path fehlt.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(sourceJob.status)) {
|
||
const error = new Error('Re-Encode nicht möglich: Quelljob ist noch aktiv.');
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json);
|
||
const ripSuccessful = this.isRipSuccessful(sourceJob);
|
||
if (!ripSuccessful) {
|
||
const error = new Error(
|
||
`Re-Encode nicht möglich: RAW-Rip ist nicht abgeschlossen (MakeMKV Status ${mkInfo?.status || 'unknown'}).`
|
||
);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const reencodeSettings = await settingsService.getSettingsMap();
|
||
const reencodeRawBaseDir = String(reencodeSettings?.raw_dir || '').trim();
|
||
const reencodeRawExtraDirs = [
|
||
reencodeSettings?.raw_dir_bluray,
|
||
reencodeSettings?.raw_dir_dvd,
|
||
reencodeSettings?.raw_dir_other
|
||
].map((d) => String(d || '').trim()).filter(Boolean);
|
||
const resolvedReencodeRawPath = this.resolveCurrentRawPath(reencodeRawBaseDir, sourceJob.raw_path, reencodeRawExtraDirs);
|
||
if (!resolvedReencodeRawPath) {
|
||
const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
await historyService.resetProcessLog(sourceJobId);
|
||
|
||
const rawInput = findPreferredRawInput(resolvedReencodeRawPath);
|
||
if (!rawInput) {
|
||
const error = new Error('Re-Encode nicht möglich: keine Datei im RAW-Pfad gefunden.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const resetMakemkvInfoJson = (mkInfo && typeof mkInfo === 'object')
|
||
? JSON.stringify({
|
||
...mkInfo,
|
||
analyzeContext: {
|
||
...(mkInfo?.analyzeContext || {}),
|
||
selectedPlaylist: null,
|
||
selectedTitleId: null
|
||
}
|
||
})
|
||
: (sourceJob.makemkv_info_json || null);
|
||
|
||
const reencodeJobUpdate = {
|
||
status: 'MEDIAINFO_CHECK',
|
||
last_state: 'MEDIAINFO_CHECK',
|
||
start_time: nowIso(),
|
||
end_time: null,
|
||
error_message: null,
|
||
output_path: null,
|
||
handbrake_info_json: null,
|
||
mediainfo_info_json: null,
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0,
|
||
makemkv_info_json: resetMakemkvInfoJson
|
||
};
|
||
if (resolvedReencodeRawPath !== sourceJob.raw_path) {
|
||
reencodeJobUpdate.raw_path = resolvedReencodeRawPath;
|
||
}
|
||
await historyService.updateJob(sourceJobId, reencodeJobUpdate);
|
||
await historyService.appendLog(
|
||
sourceJobId,
|
||
'USER_ACTION',
|
||
`Re-Encode angefordert. Bestehender Job wird wiederverwendet. Input-Kandidat: ${rawInput.path}`
|
||
);
|
||
|
||
this.runReviewForRawJob(sourceJobId, resolvedReencodeRawPath, {
|
||
mode: 'reencode',
|
||
sourceJobId,
|
||
forcePlaylistReselection: true,
|
||
mediaProfile: this.resolveMediaProfileForJob(sourceJob, { makemkvInfo: mkInfo, rawPath: resolvedReencodeRawPath })
|
||
}).catch((error) => {
|
||
logger.error('reencodeFromRaw:background-failed', { jobId: sourceJobId, sourceJobId, error: errorToMeta(error) });
|
||
this.failJob(sourceJobId, 'MEDIAINFO_CHECK', error).catch((failError) => {
|
||
logger.error('reencodeFromRaw:background-failJob-failed', {
|
||
jobId: sourceJobId,
|
||
sourceJobId,
|
||
error: errorToMeta(failError)
|
||
});
|
||
});
|
||
});
|
||
|
||
return {
|
||
started: true,
|
||
stage: 'MEDIAINFO_CHECK',
|
||
sourceJobId,
|
||
jobId: sourceJobId
|
||
};
|
||
}
|
||
|
||
async runMediainfoForFile(jobId, inputPath, options = {}) {
|
||
const lines = [];
|
||
const config = await settingsService.buildMediaInfoConfig(inputPath, {
|
||
mediaProfile: options?.mediaProfile || null,
|
||
settingsMap: options?.settingsMap || null
|
||
});
|
||
logger.info('mediainfo:command', { jobId, inputPath, cmd: config.cmd, args: config.args });
|
||
|
||
const runInfo = await this.runCommand({
|
||
jobId,
|
||
stage: 'MEDIAINFO_CHECK',
|
||
source: 'MEDIAINFO',
|
||
cmd: config.cmd,
|
||
args: config.args,
|
||
collectLines: lines,
|
||
collectStderrLines: false
|
||
});
|
||
|
||
const parsed = parseMediainfoJsonOutput(lines.join('\n'));
|
||
if (!parsed) {
|
||
const error = new Error(`Mediainfo-Ausgabe konnte nicht als JSON gelesen werden (${path.basename(inputPath)}).`);
|
||
error.runInfo = runInfo;
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
runInfo,
|
||
parsed
|
||
};
|
||
}
|
||
|
||
async runDvdTrackFallbackForFile(jobId, inputPath, options = {}) {
|
||
const lines = [];
|
||
const scanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, {
|
||
mediaProfile: options?.mediaProfile || null,
|
||
settingsMap: options?.settingsMap || null
|
||
});
|
||
logger.info('mediainfo:track-fallback:handbrake-scan:command', {
|
||
jobId,
|
||
inputPath,
|
||
cmd: scanConfig.cmd,
|
||
args: scanConfig.args
|
||
});
|
||
|
||
const runInfo = await this.runCommand({
|
||
jobId,
|
||
stage: 'MEDIAINFO_CHECK',
|
||
source: 'HANDBRAKE_SCAN_DVD_TRACK_FALLBACK',
|
||
cmd: scanConfig.cmd,
|
||
args: scanConfig.args,
|
||
collectLines: lines,
|
||
collectStderrLines: false
|
||
});
|
||
|
||
const parsedScan = parseMediainfoJsonOutput(lines.join('\n'));
|
||
if (!parsedScan) {
|
||
return {
|
||
runInfo,
|
||
parsedMediaInfo: null,
|
||
titleInfo: null
|
||
};
|
||
}
|
||
|
||
const titleInfo = parseHandBrakeSelectedTitleInfo(parsedScan);
|
||
if (!titleInfo) {
|
||
return {
|
||
runInfo,
|
||
parsedMediaInfo: null,
|
||
titleInfo: null
|
||
};
|
||
}
|
||
|
||
return {
|
||
runInfo,
|
||
parsedMediaInfo: buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo),
|
||
titleInfo
|
||
};
|
||
}
|
||
|
||
async runMediainfoReviewForJob(jobId, rawPath, options = {}) {
|
||
this.ensureNotBusy('runMediainfoReviewForJob', jobId);
|
||
logger.info('mediainfo:review:start', { jobId, rawPath, options });
|
||
|
||
const job = await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
const mkInfo = this.safeParseJson(job.makemkv_info_json);
|
||
const mediaProfile = this.resolveMediaProfileForJob(job, {
|
||
mediaProfile: options?.mediaProfile,
|
||
makemkvInfo: mkInfo,
|
||
rawPath
|
||
});
|
||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||
const analyzeContext = mkInfo?.analyzeContext || {};
|
||
const selectedPlaylistId = normalizePlaylistId(
|
||
analyzeContext.selectedPlaylist
|
||
|| this.snapshot.context?.selectedPlaylist
|
||
|| null
|
||
);
|
||
const playlistAnalysis = analyzeContext.playlistAnalysis
|
||
|| this.snapshot.context?.playlistAnalysis
|
||
|| null;
|
||
const preferredEncodeTitleId = normalizeNonNegativeInteger(analyzeContext.selectedTitleId);
|
||
const rawMedia = collectRawMediaCandidates(rawPath, {
|
||
playlistAnalysis,
|
||
selectedPlaylistId
|
||
});
|
||
const mediaFiles = rawMedia.mediaFiles;
|
||
if (mediaFiles.length === 0) {
|
||
const error = new Error('Mediainfo-Prüfung nicht möglich: keine Datei im RAW-Pfad gefunden.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Mediainfo-Quelle: ${rawMedia.source} (${mediaFiles.length} Datei(en))`
|
||
);
|
||
let presetProfile = null;
|
||
try {
|
||
presetProfile = await settingsService.buildHandBrakePresetProfile(mediaFiles[0].path, {
|
||
mediaProfile,
|
||
settingsMap: settings
|
||
});
|
||
} catch (error) {
|
||
logger.warn('mediainfo:review:preset-profile-failed', {
|
||
jobId,
|
||
error: errorToMeta(error)
|
||
});
|
||
presetProfile = {
|
||
source: 'fallback',
|
||
message: `Preset-Profil konnte nicht geladen werden: ${error.message}`
|
||
};
|
||
}
|
||
|
||
const selectedMetadata = {
|
||
title: job.title || job.detected_title || null,
|
||
year: job.year || null,
|
||
imdbId: job.imdb_id || null,
|
||
poster: job.poster_url || null
|
||
};
|
||
|
||
if (this.isPrimaryJob(jobId)) {
|
||
await this.setState('MEDIAINFO_CHECK', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Mediainfo-Prüfung läuft',
|
||
context: {
|
||
jobId,
|
||
rawPath,
|
||
reviewConfirmed: false,
|
||
mode: options.mode || 'rip',
|
||
mediaProfile,
|
||
sourceJobId: options.sourceJobId || null,
|
||
selectedMetadata
|
||
}
|
||
});
|
||
}
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'MEDIAINFO_CHECK',
|
||
last_state: 'MEDIAINFO_CHECK',
|
||
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile(mkInfo, mediaProfile))
|
||
});
|
||
|
||
const mediaInfoByPath = {};
|
||
const mediaInfoRuns = [];
|
||
const buildReviewSnapshot = (processedCount) => {
|
||
const processedFiles = mediaFiles
|
||
.slice(0, processedCount)
|
||
.filter((item) => Boolean(mediaInfoByPath[item.path]));
|
||
|
||
if (processedFiles.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
...buildMediainfoReview({
|
||
mediaFiles: processedFiles,
|
||
mediaInfoByPath,
|
||
settings,
|
||
presetProfile,
|
||
playlistAnalysis,
|
||
preferredEncodeTitleId,
|
||
selectedPlaylistId,
|
||
selectedMakemkvTitleId: preferredEncodeTitleId
|
||
}),
|
||
mode: options.mode || 'rip',
|
||
mediaProfile,
|
||
sourceJobId: options.sourceJobId || null,
|
||
reviewConfirmed: false,
|
||
partial: processedFiles.length < mediaFiles.length,
|
||
processedFiles: processedFiles.length,
|
||
totalFiles: mediaFiles.length
|
||
};
|
||
};
|
||
|
||
for (let i = 0; i < mediaFiles.length; i += 1) {
|
||
const file = mediaFiles[i];
|
||
const percent = Number((((i + 1) / mediaFiles.length) * 100).toFixed(2));
|
||
await this.updateProgress('MEDIAINFO_CHECK', percent, null, `Mediainfo ${i + 1}/${mediaFiles.length}: ${path.basename(file.path)}`, jobId);
|
||
|
||
const result = await this.runMediainfoForFile(jobId, file.path, {
|
||
mediaProfile,
|
||
settingsMap: settings
|
||
});
|
||
let parsedMediaInfo = result.parsed;
|
||
let fallbackRunInfo = null;
|
||
if (shouldRunDvdTrackFallback(parsedMediaInfo, mediaProfile, file.path)) {
|
||
try {
|
||
const fallback = await this.runDvdTrackFallbackForFile(jobId, file.path, {
|
||
mediaProfile,
|
||
settingsMap: settings
|
||
});
|
||
if (fallback?.parsedMediaInfo) {
|
||
parsedMediaInfo = fallback.parsedMediaInfo;
|
||
fallbackRunInfo = fallback.runInfo || null;
|
||
const audioCount = Array.isArray(fallback?.titleInfo?.audioTracks)
|
||
? fallback.titleInfo.audioTracks.length
|
||
: 0;
|
||
const subtitleCount = Array.isArray(fallback?.titleInfo?.subtitleTracks)
|
||
? fallback.titleInfo.subtitleTracks.length
|
||
: 0;
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`DVD Track-Fallback aktiv (${path.basename(file.path)}): Audio=${audioCount}, Subtitle=${subtitleCount}.`
|
||
);
|
||
} else {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`DVD Track-Fallback ohne Ergebnis (${path.basename(file.path)}).`
|
||
);
|
||
}
|
||
} catch (error) {
|
||
logger.warn('mediainfo:track-fallback:failed', {
|
||
jobId,
|
||
inputPath: file.path,
|
||
error: errorToMeta(error)
|
||
});
|
||
}
|
||
}
|
||
|
||
mediaInfoByPath[file.path] = parsedMediaInfo;
|
||
mediaInfoRuns.push({
|
||
filePath: file.path,
|
||
runInfo: result.runInfo,
|
||
fallbackRunInfo
|
||
});
|
||
|
||
const partialReview = buildReviewSnapshot(i + 1);
|
||
if (this.isPrimaryJob(jobId)) {
|
||
await this.setState('MEDIAINFO_CHECK', {
|
||
activeJobId: jobId,
|
||
progress: percent,
|
||
eta: null,
|
||
statusText: `Mediainfo ${i + 1}/${mediaFiles.length} analysiert: ${path.basename(file.path)}`,
|
||
context: {
|
||
jobId,
|
||
rawPath,
|
||
inputPath: partialReview?.encodeInputPath || null,
|
||
hasEncodableTitle: Boolean(partialReview?.encodeInputPath),
|
||
reviewConfirmed: false,
|
||
mode: options.mode || 'rip',
|
||
mediaProfile,
|
||
sourceJobId: options.sourceJobId || null,
|
||
mediaInfoReview: partialReview,
|
||
selectedMetadata
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
const review = buildMediainfoReview({
|
||
mediaFiles,
|
||
mediaInfoByPath,
|
||
settings,
|
||
presetProfile,
|
||
playlistAnalysis,
|
||
preferredEncodeTitleId,
|
||
selectedPlaylistId,
|
||
selectedMakemkvTitleId: preferredEncodeTitleId
|
||
});
|
||
|
||
let enrichedReview = {
|
||
...review,
|
||
mode: options.mode || 'rip',
|
||
mediaProfile,
|
||
sourceJobId: options.sourceJobId || null,
|
||
reviewConfirmed: false,
|
||
partial: false,
|
||
processedFiles: mediaFiles.length,
|
||
totalFiles: mediaFiles.length
|
||
};
|
||
const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan(
|
||
enrichedReview,
|
||
options?.previousEncodePlan && typeof options.previousEncodePlan === 'object'
|
||
? options.previousEncodePlan
|
||
: null
|
||
);
|
||
enrichedReview = reviewPrefillResult.plan;
|
||
const hasEncodableTitle = Boolean(enrichedReview.encodeInputPath);
|
||
const titleSelectionRequired = Boolean(enrichedReview.titleSelectionRequired);
|
||
if (!hasEncodableTitle && !titleSelectionRequired) {
|
||
enrichedReview.notes = [
|
||
...(Array.isArray(enrichedReview.notes) ? enrichedReview.notes : []),
|
||
'Kein Titel erfüllt aktuell MIN_LENGTH_MINUTES. Bitte Konfiguration prüfen.'
|
||
];
|
||
}
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'READY_TO_ENCODE',
|
||
last_state: 'READY_TO_ENCODE',
|
||
error_message: null,
|
||
mediainfo_info_json: JSON.stringify({
|
||
generatedAt: nowIso(),
|
||
files: mediaInfoRuns
|
||
}),
|
||
encode_plan_json: JSON.stringify(enrichedReview),
|
||
encode_input_path: enrichedReview.encodeInputPath || null,
|
||
encode_review_confirmed: 0
|
||
});
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Mediainfo-Prüfung abgeschlossen: ${enrichedReview.titles.length} Titel, Input=${enrichedReview.encodeInputPath || (titleSelectionRequired ? 'Titelauswahl erforderlich' : 'kein passender Titel')}`
|
||
);
|
||
if (reviewPrefillResult.applied) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Vorherige Encode-Auswahl als Standard übernommen: Titel #${reviewPrefillResult.selectedEncodeTitleId || '-'}, `
|
||
+ `Pre-Skripte=${reviewPrefillResult.preEncodeScriptCount}, Pre-Ketten=${reviewPrefillResult.preEncodeChainCount}, `
|
||
+ `Post-Skripte=${reviewPrefillResult.postEncodeScriptCount}, Post-Ketten=${reviewPrefillResult.postEncodeChainCount}, `
|
||
+ `User-Preset=${reviewPrefillResult.userPresetApplied ? 'ja' : 'nein'}.`
|
||
);
|
||
}
|
||
|
||
if (this.isPrimaryJob(jobId)) {
|
||
await this.setState('READY_TO_ENCODE', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: titleSelectionRequired
|
||
? 'Mediainfo geprüft - Titelauswahl per Checkbox erforderlich'
|
||
: (hasEncodableTitle
|
||
? 'Mediainfo geprüft - Encode manuell starten'
|
||
: 'Mediainfo geprüft - kein Titel erfüllt MIN_LENGTH_MINUTES'),
|
||
context: {
|
||
jobId,
|
||
rawPath,
|
||
inputPath: enrichedReview.encodeInputPath || null,
|
||
hasEncodableTitle,
|
||
reviewConfirmed: false,
|
||
mode: options.mode || 'rip',
|
||
mediaProfile,
|
||
sourceJobId: options.sourceJobId || null,
|
||
mediaInfoReview: enrichedReview,
|
||
selectedMetadata
|
||
}
|
||
});
|
||
}
|
||
|
||
void this.notifyPushover('metadata_ready', {
|
||
title: 'Ripster - Mediainfo geprüft',
|
||
message: `Job #${jobId}: bereit zum manuellen Encode-Start`
|
||
});
|
||
|
||
return enrichedReview;
|
||
}
|
||
|
||
async runEncodeChains(jobId, chainIds, context = {}, phase = 'post', progressTracker = null) {
|
||
const ids = Array.isArray(chainIds) ? chainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0) : [];
|
||
if (ids.length === 0) {
|
||
return { configured: 0, succeeded: 0, failed: 0, results: [] };
|
||
}
|
||
const results = [];
|
||
let succeeded = 0;
|
||
let failed = 0;
|
||
for (let index = 0; index < ids.length; index += 1) {
|
||
const chainId = ids[index];
|
||
const chainLabel = `#${chainId}`;
|
||
if (progressTracker?.onStepStart) {
|
||
await progressTracker.onStepStart(phase, 'chain', index + 1, ids.length, chainLabel);
|
||
}
|
||
await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette startet (ID ${chainId})...`);
|
||
try {
|
||
const chainResult = await scriptChainService.executeChain(chainId, {
|
||
...context,
|
||
jobId,
|
||
source: phase === 'pre' ? 'pre_encode_chain' : 'post_encode_chain'
|
||
}, {
|
||
appendLog: (src, msg) => historyService.appendLog(jobId, src, msg)
|
||
});
|
||
if (chainResult.aborted || chainResult.failed > 0) {
|
||
failed += 1;
|
||
await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" fehlgeschlagen.`);
|
||
} else {
|
||
succeeded += 1;
|
||
await historyService.appendLog(jobId, 'SYSTEM', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette "${chainResult.chainName}" erfolgreich.`);
|
||
}
|
||
if (progressTracker?.onStepComplete) {
|
||
await progressTracker.onStepComplete(
|
||
phase,
|
||
'chain',
|
||
index + 1,
|
||
ids.length,
|
||
chainResult.chainName || chainLabel,
|
||
!(chainResult.aborted || chainResult.failed > 0)
|
||
);
|
||
}
|
||
results.push({ chainId, ...chainResult });
|
||
} catch (error) {
|
||
failed += 1;
|
||
results.push({ chainId, success: false, error: error.message });
|
||
await historyService.appendLog(jobId, 'ERROR', `${phase === 'pre' ? 'Pre' : 'Post'}-Encode Kette ${chainId} Fehler: ${error.message}`);
|
||
logger.warn(`encode:${phase}-chain:failed`, { jobId, chainId, error: errorToMeta(error) });
|
||
if (progressTracker?.onStepComplete) {
|
||
await progressTracker.onStepComplete(phase, 'chain', index + 1, ids.length, chainLabel, false);
|
||
}
|
||
}
|
||
}
|
||
return { configured: ids.length, succeeded, failed, results };
|
||
}
|
||
|
||
async runPreEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) {
|
||
const scriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []);
|
||
const chainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [];
|
||
if (scriptIds.length === 0 && chainIds.length === 0) {
|
||
return { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] };
|
||
}
|
||
|
||
const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false });
|
||
const scriptById = new Map(scripts.map((item) => [Number(item.id), item]));
|
||
const results = [];
|
||
let succeeded = 0;
|
||
let failed = 0;
|
||
let skipped = 0;
|
||
let aborted = false;
|
||
|
||
for (let index = 0; index < scriptIds.length; index += 1) {
|
||
const scriptId = scriptIds[index];
|
||
const script = scriptById.get(Number(scriptId));
|
||
const scriptLabel = script?.name || `#${scriptId}`;
|
||
if (progressTracker?.onStepStart) {
|
||
await progressTracker.onStepStart('pre', 'script', index + 1, scriptIds.length, scriptLabel);
|
||
}
|
||
if (!script) {
|
||
failed += 1;
|
||
aborted = true;
|
||
results.push({ scriptId, scriptName: null, status: 'ERROR', error: 'missing' });
|
||
await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript #${scriptId} nicht gefunden. Kette abgebrochen.`);
|
||
if (progressTracker?.onStepComplete) {
|
||
await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, scriptLabel, false);
|
||
}
|
||
break;
|
||
}
|
||
await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`);
|
||
const activityId = runtimeActivityService.startActivity('script', {
|
||
name: script.name,
|
||
source: 'pre_encode',
|
||
scriptId: script.id,
|
||
jobId,
|
||
currentStep: `Pre-Encode ${index + 1}/${scriptIds.length}`
|
||
});
|
||
let prepared = null;
|
||
try {
|
||
prepared = await scriptService.createExecutableScriptFile(script, {
|
||
source: 'pre_encode',
|
||
mode: context?.mode || null,
|
||
jobId,
|
||
jobTitle: context?.jobTitle || null,
|
||
inputPath: context?.inputPath || null,
|
||
outputPath: context?.outputPath || null,
|
||
rawPath: context?.rawPath || null
|
||
});
|
||
const runInfo = await this.runCommand({
|
||
jobId,
|
||
stage: 'ENCODING',
|
||
source: 'PRE_ENCODE_SCRIPT',
|
||
cmd: prepared.cmd,
|
||
args: prepared.args,
|
||
argsForLog: prepared.argsForLog
|
||
});
|
||
succeeded += 1;
|
||
results.push({ scriptId: script.id, scriptName: script.name, status: 'SUCCESS', runInfo });
|
||
const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : '';
|
||
runtimeActivityService.completeActivity(activityId, {
|
||
status: 'success',
|
||
success: true,
|
||
outcome: 'success',
|
||
exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null,
|
||
message: 'Pre-Encode Skript erfolgreich',
|
||
output: runOutput || null
|
||
});
|
||
await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript erfolgreich: ${script.name}`);
|
||
if (progressTracker?.onStepComplete) {
|
||
await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, true);
|
||
}
|
||
} catch (error) {
|
||
const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null;
|
||
const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : '';
|
||
const runStatus = String(runInfo?.status || '').trim().toUpperCase();
|
||
const cancelled = runStatus === 'CANCELLED';
|
||
runtimeActivityService.completeActivity(activityId, {
|
||
status: 'error',
|
||
success: false,
|
||
outcome: cancelled ? 'cancelled' : 'error',
|
||
cancelled,
|
||
message: error?.message || 'Pre-Encode Skriptfehler',
|
||
errorMessage: error?.message || 'Pre-Encode Skriptfehler',
|
||
output: runOutput || null
|
||
});
|
||
failed += 1;
|
||
aborted = true;
|
||
results.push({ scriptId: script.id, scriptName: script.name, status: 'ERROR', error: error?.message || 'unknown' });
|
||
await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript fehlgeschlagen: ${script.name} (${error?.message || 'unknown'})`);
|
||
logger.warn('encode:pre-script:failed', { jobId, scriptId: script.id, error: errorToMeta(error) });
|
||
if (progressTracker?.onStepComplete) {
|
||
await progressTracker.onStepComplete('pre', 'script', index + 1, scriptIds.length, script.name, false);
|
||
}
|
||
break;
|
||
} finally {
|
||
if (prepared?.cleanup) {
|
||
await prepared.cleanup();
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!aborted && chainIds.length > 0) {
|
||
const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'pre', progressTracker);
|
||
if (chainResult.failed > 0) {
|
||
aborted = true;
|
||
failed += chainResult.failed;
|
||
}
|
||
succeeded += chainResult.succeeded;
|
||
results.push(...chainResult.results);
|
||
}
|
||
|
||
if (aborted) {
|
||
const pendingScripts = scriptIds.slice(results.filter((r) => r.scriptId != null).length);
|
||
for (const pendingId of pendingScripts) {
|
||
const s = scriptById.get(Number(pendingId));
|
||
skipped += 1;
|
||
results.push({ scriptId: Number(pendingId), scriptName: s?.name || null, status: 'SKIPPED_ABORTED' });
|
||
}
|
||
throw Object.assign(new Error('Pre-Encode Skripte fehlgeschlagen - Encode wird nicht gestartet.'), { statusCode: 500, preEncodeFailed: true });
|
||
}
|
||
|
||
return {
|
||
configured: scriptIds.length + chainIds.length,
|
||
attempted: scriptIds.length - skipped + chainIds.length,
|
||
succeeded,
|
||
failed,
|
||
skipped,
|
||
aborted,
|
||
results
|
||
};
|
||
}
|
||
|
||
async runPostEncodeScripts(jobId, encodePlan, context = {}, progressTracker = null) {
|
||
const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
|
||
const chainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [];
|
||
if (scriptIds.length === 0 && chainIds.length === 0) {
|
||
return {
|
||
configured: 0,
|
||
attempted: 0,
|
||
succeeded: 0,
|
||
failed: 0,
|
||
skipped: 0,
|
||
results: []
|
||
};
|
||
}
|
||
|
||
const scripts = await scriptService.resolveScriptsByIds(scriptIds, { strict: false });
|
||
const scriptById = new Map(scripts.map((item) => [Number(item.id), item]));
|
||
const results = [];
|
||
let succeeded = 0;
|
||
let failed = 0;
|
||
let skipped = 0;
|
||
let aborted = false;
|
||
let abortReason = null;
|
||
let failedScriptName = null;
|
||
let failedScriptId = null;
|
||
const titleForPush = context?.jobTitle || `Job #${jobId}`;
|
||
|
||
for (let index = 0; index < scriptIds.length; index += 1) {
|
||
const scriptId = scriptIds[index];
|
||
const script = scriptById.get(Number(scriptId));
|
||
const scriptLabel = script?.name || `#${scriptId}`;
|
||
if (progressTracker?.onStepStart) {
|
||
await progressTracker.onStepStart('post', 'script', index + 1, scriptIds.length, scriptLabel);
|
||
}
|
||
if (!script) {
|
||
failed += 1;
|
||
aborted = true;
|
||
failedScriptId = Number(scriptId);
|
||
failedScriptName = `Script #${scriptId}`;
|
||
abortReason = `Post-Encode Skript #${scriptId} wurde nicht gefunden (${index + 1}/${scriptIds.length}).`;
|
||
await historyService.appendLog(jobId, 'SYSTEM', abortReason);
|
||
results.push({
|
||
scriptId,
|
||
scriptName: null,
|
||
status: 'ERROR',
|
||
error: 'missing'
|
||
});
|
||
if (progressTracker?.onStepComplete) {
|
||
await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, scriptLabel, false);
|
||
}
|
||
break;
|
||
}
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Post-Encode Skript startet (${index + 1}/${scriptIds.length}): ${script.name}`
|
||
);
|
||
const activityId = runtimeActivityService.startActivity('script', {
|
||
name: script.name,
|
||
source: 'post_encode',
|
||
scriptId: script.id,
|
||
jobId,
|
||
currentStep: `Post-Encode ${index + 1}/${scriptIds.length}`
|
||
});
|
||
|
||
let prepared = null;
|
||
try {
|
||
prepared = await scriptService.createExecutableScriptFile(script, {
|
||
source: 'post_encode',
|
||
mode: context?.mode || null,
|
||
jobId,
|
||
jobTitle: context?.jobTitle || null,
|
||
inputPath: context?.inputPath || null,
|
||
outputPath: context?.outputPath || null,
|
||
rawPath: context?.rawPath || null
|
||
});
|
||
const runInfo = await this.runCommand({
|
||
jobId,
|
||
stage: 'ENCODING',
|
||
source: 'POST_ENCODE_SCRIPT',
|
||
cmd: prepared.cmd,
|
||
args: prepared.args,
|
||
argsForLog: prepared.argsForLog
|
||
});
|
||
|
||
succeeded += 1;
|
||
results.push({
|
||
scriptId: script.id,
|
||
scriptName: script.name,
|
||
status: 'SUCCESS',
|
||
runInfo
|
||
});
|
||
const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : '';
|
||
runtimeActivityService.completeActivity(activityId, {
|
||
status: 'success',
|
||
success: true,
|
||
outcome: 'success',
|
||
exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null,
|
||
message: 'Post-Encode Skript erfolgreich',
|
||
output: runOutput || null
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Post-Encode Skript erfolgreich: ${script.name}`
|
||
);
|
||
if (progressTracker?.onStepComplete) {
|
||
await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, true);
|
||
}
|
||
} catch (error) {
|
||
const runInfo = error?.runInfo && typeof error.runInfo === 'object' ? error.runInfo : null;
|
||
const runOutput = Array.isArray(runInfo?.highlights) ? runInfo.highlights.join('\n').trim() : '';
|
||
const runStatus = String(runInfo?.status || '').trim().toUpperCase();
|
||
const cancelled = runStatus === 'CANCELLED';
|
||
runtimeActivityService.completeActivity(activityId, {
|
||
status: 'error',
|
||
success: false,
|
||
outcome: cancelled ? 'cancelled' : 'error',
|
||
cancelled,
|
||
message: error?.message || 'Post-Encode Skriptfehler',
|
||
errorMessage: error?.message || 'Post-Encode Skriptfehler',
|
||
output: runOutput || null
|
||
});
|
||
failed += 1;
|
||
aborted = true;
|
||
failedScriptId = Number(script.id);
|
||
failedScriptName = script.name;
|
||
abortReason = error?.message || 'unknown';
|
||
results.push({
|
||
scriptId: script.id,
|
||
scriptName: script.name,
|
||
status: 'ERROR',
|
||
error: abortReason
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Post-Encode Skript fehlgeschlagen: ${script.name} (${abortReason})`
|
||
);
|
||
logger.warn('encode:post-script:failed', {
|
||
jobId,
|
||
scriptId: script.id,
|
||
scriptName: script.name,
|
||
error: errorToMeta(error)
|
||
});
|
||
if (progressTracker?.onStepComplete) {
|
||
await progressTracker.onStepComplete('post', 'script', index + 1, scriptIds.length, script.name, false);
|
||
}
|
||
break;
|
||
} finally {
|
||
if (prepared?.cleanup) {
|
||
await prepared.cleanup();
|
||
}
|
||
}
|
||
}
|
||
|
||
if (aborted) {
|
||
const executedScriptIds = new Set(results.map((item) => Number(item?.scriptId)));
|
||
for (const pendingScriptId of scriptIds) {
|
||
const numericId = Number(pendingScriptId);
|
||
if (executedScriptIds.has(numericId)) {
|
||
continue;
|
||
}
|
||
const pendingScript = scriptById.get(numericId);
|
||
skipped += 1;
|
||
results.push({
|
||
scriptId: numericId,
|
||
scriptName: pendingScript?.name || null,
|
||
status: 'SKIPPED_ABORTED'
|
||
});
|
||
}
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Post-Encode Skriptkette abgebrochen nach Fehler in ${failedScriptName || `Script #${failedScriptId || 'unknown'}`}.`
|
||
);
|
||
void this.notifyPushover('job_error', {
|
||
title: 'Ripster - Post-Encode Skriptfehler',
|
||
message: `${titleForPush}: ${failedScriptName || `Script #${failedScriptId || 'unknown'}`} fehlgeschlagen (${abortReason || 'unknown'}). Skriptkette abgebrochen.`
|
||
});
|
||
}
|
||
|
||
if (!aborted && chainIds.length > 0) {
|
||
const chainResult = await this.runEncodeChains(jobId, chainIds, context, 'post', progressTracker);
|
||
if (chainResult.failed > 0) {
|
||
aborted = true;
|
||
failed += chainResult.failed;
|
||
abortReason = `Post-Encode Kette fehlgeschlagen`;
|
||
void this.notifyPushover('job_error', {
|
||
title: 'Ripster - Post-Encode Kettenfehler',
|
||
message: `${context?.jobTitle || `Job #${jobId}`}: Eine Post-Encode Kette ist fehlgeschlagen.`
|
||
});
|
||
}
|
||
succeeded += chainResult.succeeded;
|
||
results.push(...chainResult.results);
|
||
}
|
||
|
||
return {
|
||
configured: scriptIds.length + chainIds.length,
|
||
attempted: scriptIds.length - skipped + chainIds.length,
|
||
succeeded,
|
||
failed,
|
||
skipped,
|
||
aborted,
|
||
abortReason,
|
||
failedScriptId,
|
||
failedScriptName,
|
||
results
|
||
};
|
||
}
|
||
|
||
async startEncodingFromPrepared(jobId) {
|
||
this.ensureNotBusy('startEncodingFromPrepared', jobId);
|
||
logger.info('encode:start-from-prepared', { jobId });
|
||
|
||
const job = await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
const encodePlan = this.safeParseJson(job.encode_plan_json);
|
||
const mediaProfile = this.resolveMediaProfileForJob(job, {
|
||
encodePlan,
|
||
rawPath: job.raw_path
|
||
});
|
||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||
const movieDir = settings.movie_dir;
|
||
ensureDir(movieDir);
|
||
const mode = encodePlan?.mode || this.snapshot.context?.mode || 'rip';
|
||
let inputPath = job.encode_input_path || encodePlan?.encodeInputPath || this.snapshot.context?.inputPath || null;
|
||
|
||
if (!inputPath && job.raw_path) {
|
||
const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job);
|
||
inputPath = findPreferredRawInput(job.raw_path, {
|
||
playlistAnalysis: playlistDecision.playlistAnalysis,
|
||
selectedPlaylistId: playlistDecision.selectedPlaylist
|
||
})?.path || null;
|
||
}
|
||
|
||
if (!inputPath) {
|
||
const error = new Error('Encode-Start nicht möglich: kein Input-Pfad vorhanden.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
if (!fs.existsSync(inputPath)) {
|
||
const error = new Error(`Encode-Start nicht möglich: Input-Datei fehlt (${inputPath}).`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const incompleteOutputPath = buildIncompleteOutputPathFromJob(settings, job, jobId);
|
||
const preferredFinalOutputPath = buildFinalOutputPathFromJob(settings, job, jobId);
|
||
ensureDir(path.dirname(incompleteOutputPath));
|
||
|
||
await this.setState('ENCODING', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: mode === 'reencode' ? 'Re-Encoding mit HandBrake' : 'Encoding mit HandBrake',
|
||
context: {
|
||
jobId,
|
||
mode,
|
||
inputPath,
|
||
outputPath: incompleteOutputPath,
|
||
reviewConfirmed: true,
|
||
mediaProfile,
|
||
mediaInfoReview: encodePlan || null,
|
||
selectedMetadata: {
|
||
title: job.title || job.detected_title || null,
|
||
year: job.year || null,
|
||
imdbId: job.imdb_id || null,
|
||
poster: job.poster_url || null
|
||
}
|
||
}
|
||
});
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'ENCODING',
|
||
last_state: 'ENCODING',
|
||
output_path: incompleteOutputPath,
|
||
encode_input_path: inputPath
|
||
});
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Temporärer Encode-Output: ${incompleteOutputPath} (wird nach erfolgreichem Encode in den finalen Zielordner verschoben).`
|
||
);
|
||
|
||
if (mode === 'reencode') {
|
||
void this.notifyPushover('reencode_started', {
|
||
title: 'Ripster - Re-Encode gestartet',
|
||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
|
||
});
|
||
} else {
|
||
void this.notifyPushover('encoding_started', {
|
||
title: 'Ripster - Encoding gestartet',
|
||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${preferredFinalOutputPath}`
|
||
});
|
||
}
|
||
|
||
const preEncodeContext = {
|
||
mode,
|
||
jobId,
|
||
jobTitle: job.title || job.detected_title || null,
|
||
inputPath,
|
||
rawPath: job.raw_path || null,
|
||
mediaProfile
|
||
};
|
||
const preScriptIds = normalizeScriptIdList(encodePlan?.preEncodeScriptIds || []);
|
||
const preChainIds = Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : [];
|
||
const postScriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
|
||
const postChainIds = Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : [];
|
||
const normalizedPreChainIds = Array.isArray(preChainIds)
|
||
? preChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
||
: [];
|
||
const normalizedPostChainIds = Array.isArray(postChainIds)
|
||
? postChainIds.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
||
: [];
|
||
const encodeScriptProgressTracker = createEncodeScriptProgressTracker({
|
||
jobId,
|
||
preSteps: preScriptIds.length + normalizedPreChainIds.length,
|
||
postSteps: postScriptIds.length + normalizedPostChainIds.length,
|
||
updateProgress: this.updateProgress.bind(this)
|
||
});
|
||
let preEncodeScriptsSummary = { configured: 0, attempted: 0, succeeded: 0, failed: 0, skipped: 0, results: [] };
|
||
if (preScriptIds.length > 0 || preChainIds.length > 0) {
|
||
await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten werden ausgeführt...');
|
||
try {
|
||
preEncodeScriptsSummary = await this.runPreEncodeScripts(
|
||
jobId,
|
||
encodePlan,
|
||
preEncodeContext,
|
||
encodeScriptProgressTracker
|
||
);
|
||
} catch (preError) {
|
||
if (preError.preEncodeFailed) {
|
||
await this.failJob(jobId, 'ENCODING', preError);
|
||
throw preError;
|
||
}
|
||
throw preError;
|
||
}
|
||
await historyService.appendLog(jobId, 'SYSTEM', 'Pre-Encode Skripte/Ketten abgeschlossen.');
|
||
}
|
||
|
||
try {
|
||
const trackSelection = extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath);
|
||
let handBrakeTitleId = null;
|
||
let directoryInput = false;
|
||
try {
|
||
if (fs.existsSync(inputPath) && fs.statSync(inputPath).isDirectory()) {
|
||
directoryInput = true;
|
||
}
|
||
} catch (_error) {
|
||
directoryInput = false;
|
||
handBrakeTitleId = null;
|
||
}
|
||
if (directoryInput) {
|
||
const reviewMappedTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId);
|
||
if (reviewMappedTitleId) {
|
||
handBrakeTitleId = reviewMappedTitleId;
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`HandBrake Titel-Mapping aus Vorbereitung übernommen: -t ${handBrakeTitleId}`
|
||
);
|
||
}
|
||
const selectedPlaylistId = normalizePlaylistId(
|
||
encodePlan?.selectedPlaylistId
|
||
|| (Array.isArray(encodePlan?.titles)
|
||
? (encodePlan.titles.find((title) => Boolean(title?.selectedForEncode))?.playlistId || null)
|
||
: null)
|
||
|| this.snapshot.context?.selectedPlaylist
|
||
|| null
|
||
);
|
||
const selectedEncodeTitle = Array.isArray(encodePlan?.titles)
|
||
? (
|
||
encodePlan.titles.find((title) =>
|
||
Boolean(title?.selectedForEncode) && normalizePlaylistId(title?.playlistId) === selectedPlaylistId
|
||
)
|
||
|| encodePlan.titles.find((title) => Boolean(title?.selectedForEncode))
|
||
|| null
|
||
)
|
||
: null;
|
||
const expectedMakemkvTitleIdForResolve = normalizeNonNegativeInteger(
|
||
selectedEncodeTitle?.makemkvTitleId
|
||
?? encodePlan?.playlistRecommendation?.makemkvTitleId
|
||
?? this.snapshot.context?.selectedTitleId
|
||
?? null
|
||
);
|
||
const expectedDurationSecondsForResolve = Number(selectedEncodeTitle?.durationSeconds || 0) || null;
|
||
const expectedSizeBytesForResolve = Number(selectedEncodeTitle?.sizeBytes || 0) || null;
|
||
if (!handBrakeTitleId && selectedPlaylistId) {
|
||
const titleResolveScanLines = [];
|
||
const titleResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, {
|
||
mediaProfile,
|
||
settingsMap: settings
|
||
});
|
||
logger.info('encoding:title-resolve-scan:command', {
|
||
jobId,
|
||
cmd: titleResolveScanConfig.cmd,
|
||
args: titleResolveScanConfig.args,
|
||
sourceArg: titleResolveScanConfig.sourceArg,
|
||
selectedPlaylistId
|
||
});
|
||
const titleResolveRunInfo = await this.runCommand({
|
||
jobId,
|
||
stage: 'ENCODING',
|
||
source: 'HANDBRAKE_SCAN_TITLE_RESOLVE',
|
||
cmd: titleResolveScanConfig.cmd,
|
||
args: titleResolveScanConfig.args,
|
||
collectLines: titleResolveScanLines,
|
||
collectStderrLines: false
|
||
});
|
||
const titleResolveParsed = parseMediainfoJsonOutput(titleResolveScanLines.join('\n'));
|
||
if (!titleResolveParsed) {
|
||
const error = new Error('HandBrake Scan-Ausgabe für Titel-Mapping konnte nicht als JSON gelesen werden.');
|
||
error.runInfo = titleResolveRunInfo;
|
||
throw error;
|
||
}
|
||
handBrakeTitleId = resolveHandBrakeTitleIdForPlaylist(titleResolveParsed, selectedPlaylistId, {
|
||
expectedMakemkvTitleId: expectedMakemkvTitleIdForResolve,
|
||
expectedDurationSeconds: expectedDurationSecondsForResolve,
|
||
expectedSizeBytes: expectedSizeBytesForResolve
|
||
});
|
||
if (!handBrakeTitleId) {
|
||
const knownPlaylists = listAvailableHandBrakePlaylists(titleResolveParsed);
|
||
const error = new Error(
|
||
`Kein HandBrake-Titel für Playlist ${selectedPlaylistId}.mpls gefunden.`
|
||
+ ` ${knownPlaylists.length > 0 ? `Scan-Playlists: ${knownPlaylists.map((id) => `${id}.mpls`).join(', ')}` : 'Scan enthält keine erkennbaren Playlist-IDs.'}`
|
||
);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`HandBrake Titel-Mapping: ${selectedPlaylistId}.mpls -> -t ${handBrakeTitleId}`
|
||
);
|
||
} else if (!handBrakeTitleId) {
|
||
handBrakeTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId ?? encodePlan?.encodeInputTitleId);
|
||
}
|
||
}
|
||
const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, incompleteOutputPath, {
|
||
trackSelection,
|
||
titleId: handBrakeTitleId,
|
||
mediaProfile,
|
||
settingsMap: settings,
|
||
userPreset: encodePlan?.userPreset || null
|
||
});
|
||
if (trackSelection) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`HandBrake Track-Override: audio=${trackSelection.audioTrackIds.length > 0 ? trackSelection.audioTrackIds.join(',') : 'none'}, subtitles=${trackSelection.subtitleTrackIds.length > 0 ? trackSelection.subtitleTrackIds.join(',') : 'none'}, subtitle-burned=${trackSelection.subtitleBurnTrackId ?? 'none'}, subtitle-default=${trackSelection.subtitleDefaultTrackId ?? 'none'}, subtitle-forced=${trackSelection.subtitleForcedTrackId ?? (trackSelection.subtitleForcedOnly ? 'forced-only' : 'none')}`
|
||
);
|
||
}
|
||
if (handBrakeTitleId) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`HandBrake Titel-Selektion aktiv: -t ${handBrakeTitleId}`
|
||
);
|
||
}
|
||
logger.info('encoding:command', { jobId, cmd: handBrakeConfig.cmd, args: handBrakeConfig.args });
|
||
const handBrakeProgressParser = encodeScriptProgressTracker.hasScriptSteps
|
||
? (line) => {
|
||
const parsed = parseHandBrakeProgress(line);
|
||
if (!parsed || parsed.percent === null || parsed.percent === undefined) {
|
||
return parsed;
|
||
}
|
||
return {
|
||
...parsed,
|
||
percent: encodeScriptProgressTracker.mapHandBrakePercent(parsed.percent)
|
||
};
|
||
}
|
||
: parseHandBrakeProgress;
|
||
const handbrakeInfo = await this.runCommand({
|
||
jobId,
|
||
stage: 'ENCODING',
|
||
source: 'HANDBRAKE',
|
||
cmd: handBrakeConfig.cmd,
|
||
args: handBrakeConfig.args,
|
||
parser: handBrakeProgressParser
|
||
});
|
||
const outputFinalization = finalizeOutputPathForCompletedEncode(
|
||
incompleteOutputPath,
|
||
preferredFinalOutputPath
|
||
);
|
||
const finalizedOutputPath = outputFinalization.outputPath;
|
||
chownRecursive(path.dirname(finalizedOutputPath), settings.movie_dir_owner);
|
||
if (outputFinalization.outputPathWithTimestamp) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Finaler Output existierte bereits. Neuer Zielpfad mit Timestamp: ${finalizedOutputPath}`
|
||
);
|
||
}
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Encode-Output finalisiert: ${finalizedOutputPath}`
|
||
);
|
||
let postEncodeScriptsSummary = {
|
||
configured: 0,
|
||
attempted: 0,
|
||
succeeded: 0,
|
||
failed: 0,
|
||
skipped: 0,
|
||
results: []
|
||
};
|
||
try {
|
||
postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, {
|
||
mode,
|
||
jobTitle: job.title || job.detected_title || null,
|
||
inputPath,
|
||
outputPath: finalizedOutputPath,
|
||
rawPath: job.raw_path || null
|
||
}, encodeScriptProgressTracker);
|
||
} catch (error) {
|
||
logger.warn('encode:post-script:summary-failed', {
|
||
jobId,
|
||
error: errorToMeta(error)
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
|
||
);
|
||
}
|
||
if (postEncodeScriptsSummary.configured > 0) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}`
|
||
);
|
||
}
|
||
let finalizedRawPath = job.raw_path || null;
|
||
if (job.raw_path) {
|
||
const currentRawPath = String(job.raw_path || '').trim();
|
||
const completedRawPath = buildCompletedRawPath(currentRawPath);
|
||
if (completedRawPath && completedRawPath !== currentRawPath) {
|
||
if (fs.existsSync(completedRawPath)) {
|
||
logger.warn('encoding:raw-dir-finalize:target-exists', {
|
||
jobId,
|
||
sourceRawPath: currentRawPath,
|
||
targetRawPath: completedRawPath
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`RAW-Ordner konnte nicht finalisiert werden (Ziel existiert bereits): ${completedRawPath}`
|
||
);
|
||
} else {
|
||
try {
|
||
fs.renameSync(currentRawPath, completedRawPath);
|
||
await historyService.updateRawPathByOldPath(currentRawPath, completedRawPath);
|
||
finalizedRawPath = completedRawPath;
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`RAW-Ordner nach erfolgreichem Encode finalisiert (Prefix entfernt): ${currentRawPath} -> ${completedRawPath}`
|
||
);
|
||
} catch (rawRenameError) {
|
||
logger.warn('encoding:raw-dir-finalize:rename-failed', {
|
||
jobId,
|
||
sourceRawPath: currentRawPath,
|
||
targetRawPath: completedRawPath,
|
||
error: errorToMeta(rawRenameError)
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`RAW-Ordner konnte nach Encode nicht finalisiert werden: ${rawRenameError.message}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const handbrakeInfoWithPostScripts = {
|
||
...handbrakeInfo,
|
||
preEncodeScripts: preEncodeScriptsSummary,
|
||
postEncodeScripts: postEncodeScriptsSummary
|
||
};
|
||
|
||
await historyService.updateJob(jobId, {
|
||
handbrake_info_json: JSON.stringify(handbrakeInfoWithPostScripts),
|
||
status: 'FINISHED',
|
||
last_state: 'FINISHED',
|
||
end_time: nowIso(),
|
||
raw_path: finalizedRawPath,
|
||
rip_successful: 1,
|
||
output_path: finalizedOutputPath,
|
||
error_message: null
|
||
});
|
||
|
||
logger.info('encoding:finished', { jobId, mode, outputPath: finalizedOutputPath });
|
||
const finishedStatusTextBase = mode === 'reencode' ? 'Re-Encode abgeschlossen' : 'Job abgeschlossen';
|
||
const finishedStatusText = postEncodeScriptsSummary.failed > 0
|
||
? `${finishedStatusTextBase} (${postEncodeScriptsSummary.failed} Skript(e) fehlgeschlagen)`
|
||
: finishedStatusTextBase;
|
||
|
||
await this.setState('FINISHED', {
|
||
activeJobId: jobId,
|
||
progress: 100,
|
||
eta: null,
|
||
statusText: finishedStatusText,
|
||
context: {
|
||
jobId,
|
||
mode,
|
||
outputPath: finalizedOutputPath
|
||
}
|
||
});
|
||
|
||
if (mode === 'reencode') {
|
||
void this.notifyPushover('reencode_finished', {
|
||
title: 'Ripster - Re-Encode abgeschlossen',
|
||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}`
|
||
});
|
||
} else {
|
||
void this.notifyPushover('job_finished', {
|
||
title: 'Ripster - Job abgeschlossen',
|
||
message: `${job.title || job.detected_title || `Job #${jobId}`} -> ${finalizedOutputPath}`
|
||
});
|
||
}
|
||
|
||
setTimeout(async () => {
|
||
if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) {
|
||
await this.setState('IDLE', {
|
||
finishingJobId: jobId,
|
||
activeJobId: null,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Bereit',
|
||
context: {}
|
||
});
|
||
}
|
||
}, 3000);
|
||
} catch (error) {
|
||
if (error.runInfo && error.runInfo.source === 'HANDBRAKE') {
|
||
await historyService.updateJob(jobId, {
|
||
handbrake_info_json: JSON.stringify(error.runInfo)
|
||
});
|
||
}
|
||
logger.error('encode:start-from-prepared:failed', { jobId, mode, error: errorToMeta(error) });
|
||
await this.failJob(jobId, 'ENCODING', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async startRipEncode(jobId) {
|
||
this.ensureNotBusy('startRipEncode', jobId);
|
||
logger.info('ripEncode:start', { jobId });
|
||
|
||
let job = await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
const preRipPlanBeforeRip = this.safeParseJson(job.encode_plan_json);
|
||
const preRipModeBeforeRip = String(preRipPlanBeforeRip?.mode || '').trim().toLowerCase();
|
||
const hasPreRipConfirmedSelection = (preRipModeBeforeRip === 'pre_rip' || Boolean(preRipPlanBeforeRip?.preRip))
|
||
&& Number(job.encode_review_confirmed || 0) === 1;
|
||
const preRipTrackSelectionPayload = hasPreRipConfirmedSelection
|
||
? extractManualSelectionPayloadFromPlan(preRipPlanBeforeRip)
|
||
: null;
|
||
const preRipPostEncodeScriptIds = hasPreRipConfirmedSelection
|
||
? normalizeScriptIdList(preRipPlanBeforeRip?.postEncodeScriptIds || [])
|
||
: [];
|
||
const preRipPreEncodeScriptIds = hasPreRipConfirmedSelection
|
||
? normalizeScriptIdList(preRipPlanBeforeRip?.preEncodeScriptIds || [])
|
||
: [];
|
||
const preRipPostEncodeChainIds = hasPreRipConfirmedSelection
|
||
? (Array.isArray(preRipPlanBeforeRip?.postEncodeChainIds) ? preRipPlanBeforeRip.postEncodeChainIds : [])
|
||
.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
||
: [];
|
||
const preRipPreEncodeChainIds = hasPreRipConfirmedSelection
|
||
? (Array.isArray(preRipPlanBeforeRip?.preEncodeChainIds) ? preRipPlanBeforeRip.preEncodeChainIds : [])
|
||
.map(Number).filter((id) => Number.isFinite(id) && id > 0)
|
||
: [];
|
||
const mkInfo = this.safeParseJson(job.makemkv_info_json);
|
||
const mediaProfile = this.resolveMediaProfileForJob(job, {
|
||
encodePlan: preRipPlanBeforeRip,
|
||
makemkvInfo: mkInfo,
|
||
deviceInfo: this.detectedDisc || this.snapshot.context?.device || null
|
||
});
|
||
const playlistDecision = this.resolvePlaylistDecisionForJob(jobId, job);
|
||
const selectedTitleId = playlistDecision.selectedTitleId;
|
||
const selectedPlaylist = playlistDecision.selectedPlaylist;
|
||
const selectedPlaylistFile = toPlaylistFile(selectedPlaylist);
|
||
|
||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||
const rawBaseDir = settings.raw_dir;
|
||
const ripMode = String(settings.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
|
||
? 'backup'
|
||
: 'mkv';
|
||
const effectiveSelectedTitleId = ripMode === 'mkv' ? (selectedTitleId ?? null) : null;
|
||
const effectiveSelectedPlaylist = ripMode === 'mkv' ? (selectedPlaylist || null) : null;
|
||
const effectiveSelectedPlaylistFile = ripMode === 'mkv' ? selectedPlaylistFile : null;
|
||
const selectedPlaylistTitleInfo = ripMode === 'mkv' && Array.isArray(playlistDecision.playlistAnalysis?.titles)
|
||
? (playlistDecision.playlistAnalysis.titles.find((item) =>
|
||
Number(item?.titleId) === Number(selectedTitleId)
|
||
) || null)
|
||
: null;
|
||
logger.info('rip:playlist-resolution', {
|
||
jobId,
|
||
ripMode,
|
||
selectedPlaylist: effectiveSelectedPlaylistFile,
|
||
selectedTitleId: effectiveSelectedTitleId,
|
||
selectedTitleDurationSeconds: Number(selectedPlaylistTitleInfo?.durationSeconds || 0),
|
||
selectedTitleDurationLabel: selectedPlaylistTitleInfo?.durationLabel || null
|
||
});
|
||
logger.debug('ripEncode:paths', { jobId, rawBaseDir });
|
||
|
||
ensureDir(rawBaseDir);
|
||
|
||
const metadataBase = buildRawMetadataBase({
|
||
title: job.title || job.detected_title || null,
|
||
year: job.year || null
|
||
}, jobId);
|
||
const rawDirName = buildRawDirName(metadataBase, jobId, { state: RAW_FOLDER_STATES.INCOMPLETE });
|
||
const rawJobDir = path.join(rawBaseDir, rawDirName);
|
||
ensureDir(rawJobDir);
|
||
chownRecursive(rawJobDir, settings.raw_dir_owner);
|
||
logger.info('rip:raw-dir-created', { jobId, rawJobDir });
|
||
|
||
const deviceCandidate = this.detectedDisc || this.snapshot.context?.device || {
|
||
path: job.disc_device,
|
||
index: Number(settings.makemkv_source_index || 0)
|
||
};
|
||
const deviceProfile = normalizeMediaProfile(deviceCandidate?.mediaProfile)
|
||
|| inferMediaProfileFromDeviceInfo(deviceCandidate)
|
||
|| mediaProfile;
|
||
const device = {
|
||
...deviceCandidate,
|
||
mediaProfile: deviceProfile
|
||
};
|
||
const devicePath = device.path || null;
|
||
|
||
await this.setState('RIPPING', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: ripMode === 'backup' ? 'Backup mit MakeMKV' : 'Ripping mit MakeMKV',
|
||
context: {
|
||
jobId,
|
||
device,
|
||
mediaProfile,
|
||
ripMode,
|
||
playlistDecisionRequired: Boolean(playlistDecision.playlistDecisionRequired),
|
||
playlistCandidates: playlistDecision.candidatePlaylists,
|
||
selectedPlaylist: effectiveSelectedPlaylist,
|
||
selectedTitleId: effectiveSelectedTitleId,
|
||
preRipSelectionLocked: hasPreRipConfirmedSelection,
|
||
selectedMetadata: {
|
||
title: job.title || job.detected_title || null,
|
||
year: job.year || null,
|
||
imdbId: job.imdb_id || null,
|
||
poster: job.poster_url || null
|
||
}
|
||
}
|
||
});
|
||
|
||
void this.notifyPushover('rip_started', {
|
||
title: ripMode === 'backup' ? 'Ripster - Backup gestartet' : 'Ripster - Rip gestartet',
|
||
message: `${job.title || job.detected_title || `Job #${jobId}`} (${device.path || 'disc'})`
|
||
});
|
||
|
||
const backupOutputBase = ripMode === 'backup' && mediaProfile === 'dvd'
|
||
? sanitizeFileName(job.title || job.detected_title || `disc-${jobId}`)
|
||
: null;
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'RIPPING',
|
||
last_state: 'RIPPING',
|
||
raw_path: rawJobDir,
|
||
handbrake_info_json: null,
|
||
mediainfo_info_json: null,
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0,
|
||
output_path: null,
|
||
error_message: null,
|
||
end_time: null
|
||
});
|
||
job = await historyService.getJobById(jobId);
|
||
|
||
let makemkvInfo = null;
|
||
try {
|
||
await this.ensureMakeMKVRegistration(jobId, 'RIPPING');
|
||
|
||
const ripConfig = await settingsService.buildMakeMKVRipConfig(rawJobDir, device, {
|
||
selectedTitleId: effectiveSelectedTitleId,
|
||
mediaProfile,
|
||
settingsMap: settings,
|
||
backupOutputBase
|
||
});
|
||
logger.info('rip:command', {
|
||
jobId,
|
||
cmd: ripConfig.cmd,
|
||
args: ripConfig.args,
|
||
ripMode,
|
||
selectedPlaylist: effectiveSelectedPlaylistFile,
|
||
selectedTitleId: effectiveSelectedTitleId
|
||
});
|
||
if (ripMode === 'backup') {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'Backup-Modus aktiv: MakeMKV erstellt 1:1 Backup ohne Titel-/Playlist-Einschränkungen.'
|
||
);
|
||
} else if (effectiveSelectedPlaylistFile) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Manuelle Playlist-Auswahl aktiv: ${effectiveSelectedPlaylistFile} (Titel ${effectiveSelectedTitleId}).`
|
||
);
|
||
if (selectedPlaylistTitleInfo) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Playlist-Auflösung: Titel ${effectiveSelectedTitleId} Dauer ${selectedPlaylistTitleInfo.durationLabel || `${selectedPlaylistTitleInfo.durationSeconds || 0}s`}.`
|
||
);
|
||
}
|
||
} else if (playlistDecision.playlistDecisionRequired) {
|
||
const decisionContext = describePlaylistManualDecision(playlistDecision.playlistAnalysis);
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`${decisionContext.detailText} Rip läuft ohne Vorauswahl. Finale Titelwahl erfolgt in der Mediainfo-Prüfung per Checkbox.`
|
||
);
|
||
}
|
||
if (devicePath) {
|
||
diskDetectionService.lockDevice(devicePath, {
|
||
jobId,
|
||
stage: 'RIPPING',
|
||
source: 'MAKEMKV_RIP'
|
||
});
|
||
}
|
||
try {
|
||
makemkvInfo = await this.runCommand({
|
||
jobId,
|
||
stage: 'RIPPING',
|
||
source: 'MAKEMKV_RIP',
|
||
cmd: ripConfig.cmd,
|
||
args: ripConfig.args,
|
||
parser: parseMakeMkvProgress
|
||
});
|
||
} finally {
|
||
if (devicePath) {
|
||
diskDetectionService.unlockDevice(devicePath, {
|
||
jobId,
|
||
stage: 'RIPPING',
|
||
source: 'MAKEMKV_RIP'
|
||
});
|
||
}
|
||
}
|
||
|
||
// Check for MakeMKV backup failure even when exit code is 0.
|
||
// MakeMKV can exit 0 but still output "Backup failed" in stdout.
|
||
const backupFailed = Array.isArray(makemkvInfo?.highlights) &&
|
||
makemkvInfo.highlights.some(line => /backup failed/i.test(line));
|
||
if (backupFailed) {
|
||
const failMsg = makemkvInfo.highlights.find(line => /backup failed/i.test(line)) || 'Backup failed';
|
||
throw Object.assign(
|
||
new Error(`MakeMKV Backup fehlgeschlagen (Exit Code 0): ${failMsg}`),
|
||
{ runInfo: makemkvInfo }
|
||
);
|
||
}
|
||
|
||
const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json);
|
||
await historyService.updateJob(jobId, {
|
||
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
|
||
...makemkvInfo,
|
||
analyzeContext: mkInfoBeforeRip?.analyzeContext || null
|
||
}, mediaProfile)),
|
||
rip_successful: 1
|
||
});
|
||
|
||
// Mark RAW as rip-complete until encode succeeds.
|
||
let activeRawJobDir = rawJobDir;
|
||
const ripCompleteRawJobDir = buildRipCompleteRawPath(rawJobDir);
|
||
if (ripCompleteRawJobDir && ripCompleteRawJobDir !== rawJobDir) {
|
||
if (fs.existsSync(ripCompleteRawJobDir)) {
|
||
logger.warn('rip:raw-complete:rename-skip', { jobId, rawJobDir, ripCompleteRawJobDir });
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`RAW-Ordner konnte nach Rip nicht als Rip_Complete markiert werden (Zielordner existiert): ${ripCompleteRawJobDir}`
|
||
);
|
||
} else {
|
||
try {
|
||
fs.renameSync(rawJobDir, ripCompleteRawJobDir);
|
||
activeRawJobDir = ripCompleteRawJobDir;
|
||
chownRecursive(activeRawJobDir, settings.raw_dir_owner);
|
||
await historyService.updateRawPathByOldPath(rawJobDir, ripCompleteRawJobDir);
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`RAW-Ordner nach erfolgreichem Rip als Rip_Complete markiert: ${rawJobDir} → ${ripCompleteRawJobDir}`
|
||
);
|
||
} catch (renameError) {
|
||
logger.warn('rip:raw-complete:rename-failed', {
|
||
jobId,
|
||
rawJobDir,
|
||
ripCompleteRawJobDir,
|
||
error: errorToMeta(renameError)
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`RAW-Ordner konnte nach Rip nicht als Rip_Complete markiert werden: ${renameError.message}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const review = await this.runReviewForRawJob(jobId, activeRawJobDir, {
|
||
mode: 'rip',
|
||
mediaProfile
|
||
});
|
||
logger.info('rip:review-ready', {
|
||
jobId,
|
||
encodeInputPath: review.encodeInputPath,
|
||
selectedTitleCount: Array.isArray(review.selectedTitleIds)
|
||
? review.selectedTitleIds.length
|
||
: (Array.isArray(review.titles)
|
||
? review.titles.filter((item) => Boolean(item?.selectedForEncode)).length
|
||
: 0)
|
||
});
|
||
if (hasPreRipConfirmedSelection && !review?.awaitingPlaylistSelection) {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
'Vorab bestätigte Spurauswahl erkannt. Übernehme Auswahl automatisch und starte Encode.'
|
||
);
|
||
await this.confirmEncodeReview(jobId, {
|
||
selectedEncodeTitleId: review?.encodeInputTitleId || null,
|
||
selectedTrackSelection: preRipTrackSelectionPayload || null,
|
||
selectedPostEncodeScriptIds: preRipPostEncodeScriptIds,
|
||
selectedPreEncodeScriptIds: preRipPreEncodeScriptIds,
|
||
selectedPostEncodeChainIds: preRipPostEncodeChainIds,
|
||
selectedPreEncodeChainIds: preRipPreEncodeChainIds
|
||
});
|
||
const autoStartResult = await this.startPreparedJob(jobId);
|
||
logger.info('rip:auto-encode-started', {
|
||
jobId,
|
||
stage: autoStartResult?.stage || null
|
||
});
|
||
}
|
||
} catch (error) {
|
||
if (error.runInfo && error.runInfo.source === 'MAKEMKV_RIP') {
|
||
const mkInfoBeforeRip = this.safeParseJson(job.makemkv_info_json);
|
||
await historyService.updateJob(jobId, {
|
||
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
|
||
...error.runInfo,
|
||
analyzeContext: mkInfoBeforeRip?.analyzeContext || null
|
||
}, mediaProfile))
|
||
});
|
||
}
|
||
if (
|
||
error.runInfo
|
||
&& [
|
||
'MEDIAINFO',
|
||
'HANDBRAKE_SCAN',
|
||
'HANDBRAKE_SCAN_PLAYLIST_MAP',
|
||
'HANDBRAKE_SCAN_SELECTED_TITLE',
|
||
'MAKEMKV_ANALYZE_BACKUP'
|
||
].includes(error.runInfo.source)
|
||
) {
|
||
await historyService.updateJob(jobId, {
|
||
mediainfo_info_json: JSON.stringify({
|
||
failedAt: nowIso(),
|
||
runInfo: error.runInfo
|
||
})
|
||
});
|
||
}
|
||
logger.error('ripEncode:failed', { jobId, stage: this.snapshot.state, error: errorToMeta(error) });
|
||
await this.failJob(jobId, this.snapshot.state, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async retry(jobId, options = {}) {
|
||
const immediate = Boolean(options?.immediate);
|
||
if (!immediate) {
|
||
// Retry always starts a rip → bypass the encode queue entirely.
|
||
return this.retry(jobId, { ...options, immediate: true });
|
||
}
|
||
|
||
this.ensureNotBusy('retry', jobId);
|
||
logger.info('retry:start', { jobId });
|
||
this.cancelRequestedByJob.delete(Number(jobId));
|
||
|
||
const job = await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
if (!job.title && !job.detected_title) {
|
||
const error = new Error('Retry nicht möglich: keine Metadaten vorhanden.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
await historyService.resetProcessLog(jobId);
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'RIPPING',
|
||
last_state: 'RIPPING',
|
||
error_message: null,
|
||
end_time: null,
|
||
handbrake_info_json: null,
|
||
mediainfo_info_json: null,
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0,
|
||
output_path: null
|
||
});
|
||
|
||
this.startRipEncode(jobId).catch((error) => {
|
||
logger.error('retry:background-failed', { jobId, error: errorToMeta(error) });
|
||
});
|
||
|
||
return { started: true };
|
||
}
|
||
|
||
async resumeReadyToEncodeJob(jobId) {
|
||
this.ensureNotBusy('resumeReadyToEncodeJob', jobId);
|
||
logger.info('resumeReadyToEncodeJob:requested', { jobId });
|
||
|
||
const job = await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
const isReadyToEncode = job.status === 'READY_TO_ENCODE' || job.last_state === 'READY_TO_ENCODE';
|
||
if (!isReadyToEncode) {
|
||
const error = new Error(`Job ${jobId} ist nicht im Status READY_TO_ENCODE.`);
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
const encodePlan = this.safeParseJson(job.encode_plan_json);
|
||
if (!encodePlan || !Array.isArray(encodePlan.titles)) {
|
||
const error = new Error('READY_TO_ENCODE Job kann nicht geladen werden: encode_plan fehlt.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||
const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||
const reviewConfirmed = Boolean(Number(job.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed);
|
||
const inputPath = isPreRipMode
|
||
? null
|
||
: (job.encode_input_path || encodePlan?.encodeInputPath || null);
|
||
const hasEncodableTitle = isPreRipMode
|
||
? Boolean(encodePlan?.encodeInputTitleId)
|
||
: Boolean(inputPath);
|
||
const selectedMetadata = {
|
||
title: job.title || job.detected_title || null,
|
||
year: job.year || null,
|
||
imdbId: job.imdb_id || null,
|
||
poster: job.poster_url || null
|
||
};
|
||
const readyMediaProfile = this.resolveMediaProfileForJob(job, {
|
||
encodePlan
|
||
});
|
||
|
||
await this.setState('READY_TO_ENCODE', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: hasEncodableTitle
|
||
? (reviewConfirmed
|
||
? (isPreRipMode
|
||
? 'Spurauswahl geladen - Backup/Rip + Encode startbereit'
|
||
: 'Mediainfo geladen - Encode startbereit')
|
||
: (isPreRipMode
|
||
? 'Spurauswahl geladen - bitte bestätigen'
|
||
: 'Mediainfo geladen - bitte bestätigen'))
|
||
: (isPreRipMode
|
||
? 'Spurauswahl geladen - kein passender Titel gewählt'
|
||
: 'Mediainfo geladen - kein Titel erfüllt MIN_LENGTH_MINUTES'),
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
inputPath,
|
||
hasEncodableTitle,
|
||
reviewConfirmed,
|
||
mode,
|
||
mediaProfile: readyMediaProfile,
|
||
sourceJobId: encodePlan?.sourceJobId || null,
|
||
selectedMetadata,
|
||
mediaInfoReview: encodePlan
|
||
}
|
||
});
|
||
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'USER_ACTION',
|
||
'READY_TO_ENCODE Job nach Neustart ins Dashboard geladen.'
|
||
);
|
||
|
||
return historyService.getJobById(jobId);
|
||
}
|
||
|
||
async restartEncodeWithLastSettings(jobId, options = {}) {
|
||
const immediate = Boolean(options?.immediate);
|
||
if (!immediate) {
|
||
// Restart-Encode now prepares an editable READY_TO_ENCODE state first.
|
||
// No queue slot is needed because encoding is not started automatically here.
|
||
return this.restartEncodeWithLastSettings(jobId, { ...options, immediate: true });
|
||
}
|
||
|
||
this.ensureNotBusy('restartEncodeWithLastSettings', jobId);
|
||
logger.info('restartEncodeWithLastSettings:requested', { jobId });
|
||
this.cancelRequestedByJob.delete(Number(jobId));
|
||
const triggerReason = String(options?.triggerReason || 'manual').trim().toLowerCase();
|
||
|
||
const job = await historyService.getJobById(jobId);
|
||
if (!job) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
const currentStatus = String(job.status || '').trim().toUpperCase();
|
||
if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK'].includes(currentStatus)) {
|
||
const error = new Error(`Encode-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`);
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
const encodePlan = this.safeParseJson(job.encode_plan_json);
|
||
if (!encodePlan || !Array.isArray(encodePlan.titles) || encodePlan.titles.length === 0) {
|
||
const error = new Error('Encode-Neustart nicht möglich: encode_plan fehlt.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||
const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||
const reviewConfirmed = Boolean(Number(job.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed);
|
||
if (!reviewConfirmed) {
|
||
const error = new Error('Encode-Neustart nicht möglich: Spurauswahl wurde noch nicht bestätigt.');
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
const hasEncodableInput = isPreRipMode
|
||
? Boolean(encodePlan?.encodeInputTitleId)
|
||
: Boolean(job.encode_input_path || encodePlan?.encodeInputPath || job.raw_path);
|
||
if (!hasEncodableInput) {
|
||
const error = new Error('Encode-Neustart nicht möglich: kein verwertbarer Encode-Input vorhanden.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const settings = await settingsService.getSettingsMap();
|
||
const restartDeleteIncompleteOutput = settings?.handbrake_restart_delete_incomplete_output !== undefined
|
||
? Boolean(settings.handbrake_restart_delete_incomplete_output)
|
||
: true;
|
||
const handBrakeInfo = this.safeParseJson(job.handbrake_info_json);
|
||
const encodePreviouslySuccessful = String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
||
const previousOutputPath = String(job.output_path || '').trim() || null;
|
||
|
||
if (previousOutputPath && restartDeleteIncompleteOutput && !encodePreviouslySuccessful) {
|
||
try {
|
||
const deleteResult = await historyService.deleteJobFiles(jobId, 'movie');
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'USER_ACTION',
|
||
`Encode-Neustart: unvollständigen Output vor Start entfernt (movie files=${deleteResult?.summary?.movie?.filesDeleted ?? 0}, dirs=${deleteResult?.summary?.movie?.dirsRemoved ?? 0}).`
|
||
);
|
||
} catch (error) {
|
||
logger.warn('restartEncodeWithLastSettings:delete-incomplete-output-failed', {
|
||
jobId,
|
||
outputPath: previousOutputPath,
|
||
error: errorToMeta(error)
|
||
});
|
||
}
|
||
}
|
||
|
||
const restartPlan = {
|
||
...encodePlan,
|
||
reviewConfirmed: false,
|
||
reviewConfirmedAt: null,
|
||
prefilledFromPreviousRun: true,
|
||
prefilledFromPreviousRunAt: nowIso()
|
||
};
|
||
const selectedMetadata = {
|
||
title: job.title || job.detected_title || null,
|
||
year: job.year || null,
|
||
imdbId: job.imdb_id || null,
|
||
poster: job.poster_url || null
|
||
};
|
||
const readyMediaProfile = this.resolveMediaProfileForJob(job, {
|
||
encodePlan: restartPlan
|
||
});
|
||
const inputPath = isPreRipMode
|
||
? null
|
||
: (job.encode_input_path || restartPlan.encodeInputPath || null);
|
||
const hasEncodableTitle = isPreRipMode
|
||
? Boolean(restartPlan?.encodeInputTitleId)
|
||
: Boolean(inputPath);
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: 'READY_TO_ENCODE',
|
||
last_state: 'READY_TO_ENCODE',
|
||
error_message: null,
|
||
end_time: null,
|
||
output_path: null,
|
||
handbrake_info_json: null,
|
||
encode_plan_json: JSON.stringify(restartPlan),
|
||
encode_input_path: inputPath,
|
||
encode_review_confirmed: 0
|
||
});
|
||
const loadedSelectionText = (
|
||
previousOutputPath
|
||
? `Letzte bestätigte Auswahl wurde geladen und kann angepasst werden. Vorheriger Output-Pfad: ${previousOutputPath}. autoDeleteIncomplete=${restartDeleteIncompleteOutput ? 'on' : 'off'}`
|
||
: 'Letzte bestätigte Auswahl wurde geladen und kann angepasst werden.'
|
||
);
|
||
let restartLogMessage;
|
||
if (triggerReason === 'cancelled_encode') {
|
||
restartLogMessage = `Encode wurde abgebrochen. ${loadedSelectionText}`;
|
||
} else if (triggerReason === 'failed_encode') {
|
||
restartLogMessage = `Encode ist fehlgeschlagen. ${loadedSelectionText}`;
|
||
} else if (triggerReason === 'server_restart') {
|
||
restartLogMessage = `Server-Neustart während Encode erkannt. ${loadedSelectionText}`;
|
||
} else if (triggerReason === 'confirm_auto_prepare') {
|
||
restartLogMessage = `Status war nicht READY_TO_ENCODE. ${loadedSelectionText}`;
|
||
} else {
|
||
restartLogMessage = `Encode-Neustart angefordert. ${loadedSelectionText}`;
|
||
}
|
||
await historyService.appendLog(jobId, 'USER_ACTION', restartLogMessage);
|
||
|
||
await this.setState('READY_TO_ENCODE', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: hasEncodableTitle
|
||
? (isPreRipMode
|
||
? 'Vorherige Spurauswahl geladen - anpassen und Backup/Rip + Encode starten'
|
||
: 'Vorherige Encode-Auswahl geladen - anpassen und Encoding starten')
|
||
: (isPreRipMode
|
||
? 'Vorherige Spurauswahl geladen - kein passender Titel gewählt'
|
||
: 'Vorherige Encode-Auswahl geladen - kein Titel erfüllt MIN_LENGTH_MINUTES'),
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
inputPath,
|
||
hasEncodableTitle,
|
||
reviewConfirmed: false,
|
||
mode,
|
||
mediaProfile: readyMediaProfile,
|
||
sourceJobId: restartPlan?.sourceJobId || null,
|
||
selectedMetadata,
|
||
mediaInfoReview: restartPlan
|
||
}
|
||
});
|
||
|
||
return {
|
||
restarted: true,
|
||
started: false,
|
||
stage: 'READY_TO_ENCODE',
|
||
reviewConfirmed: false
|
||
};
|
||
}
|
||
|
||
async restartReviewFromRaw(jobId, options = {}) {
|
||
this.ensureNotBusy('restartReviewFromRaw', jobId);
|
||
logger.info('restartReviewFromRaw:requested', { jobId, options });
|
||
this.cancelRequestedByJob.delete(Number(jobId));
|
||
|
||
const sourceJob = await historyService.getJobById(jobId);
|
||
if (!sourceJob) {
|
||
const error = new Error(`Job ${jobId} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
|
||
if (!sourceJob.raw_path) {
|
||
const error = new Error('Review-Neustart nicht möglich: raw_path fehlt.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const reviewSettings = await settingsService.getSettingsMap();
|
||
const reviewRawBaseDir = String(reviewSettings?.raw_dir || '').trim();
|
||
const reviewRawExtraDirs = [
|
||
reviewSettings?.raw_dir_bluray,
|
||
reviewSettings?.raw_dir_dvd,
|
||
reviewSettings?.raw_dir_other
|
||
].map((d) => String(d || '').trim()).filter(Boolean);
|
||
const resolvedReviewRawPath = this.resolveCurrentRawPath(reviewRawBaseDir, sourceJob.raw_path, reviewRawExtraDirs);
|
||
if (!resolvedReviewRawPath) {
|
||
const error = new Error(`Review-Neustart nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const hasRawInput = Boolean(
|
||
hasBluRayBackupStructure(resolvedReviewRawPath)
|
||
|| findPreferredRawInput(resolvedReviewRawPath)
|
||
);
|
||
if (!hasRawInput) {
|
||
const error = new Error('Review-Neustart nicht möglich: keine Mediendateien im RAW-Pfad gefunden. Disc muss zuerst gerippt werden.');
|
||
error.statusCode = 400;
|
||
throw error;
|
||
}
|
||
|
||
const currentStatus = String(sourceJob.status || '').trim().toUpperCase();
|
||
if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(currentStatus)) {
|
||
const error = new Error(`Review-Neustart nicht möglich: Job ${jobId} ist noch aktiv (${currentStatus}).`);
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
const staleQueueIndex = this.findQueueEntryIndexByJobId(Number(jobId));
|
||
if (staleQueueIndex >= 0) {
|
||
const [removed] = this.queueEntries.splice(staleQueueIndex, 1);
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'USER_ACTION',
|
||
`Queue-Eintrag entfernt (Review-Neustart): ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}`
|
||
);
|
||
await this.emitQueueChanged();
|
||
}
|
||
|
||
await historyService.resetProcessLog(jobId);
|
||
|
||
const forcePlaylistReselection = Boolean(options?.forcePlaylistReselection);
|
||
const previousEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
|
||
const mkInfo = this.safeParseJson(sourceJob.makemkv_info_json);
|
||
const nextMakemkvInfoJson = mkInfo && typeof mkInfo === 'object'
|
||
? JSON.stringify({
|
||
...mkInfo,
|
||
analyzeContext: {
|
||
...(mkInfo?.analyzeContext || {}),
|
||
playlistAnalysis: null,
|
||
playlistDecisionRequired: false,
|
||
selectedPlaylist: null,
|
||
selectedTitleId: null,
|
||
handBrakePlaylistScan: null
|
||
},
|
||
postBackupAnalyze: null
|
||
})
|
||
: sourceJob.makemkv_info_json;
|
||
|
||
const jobUpdatePayload = {
|
||
status: 'MEDIAINFO_CHECK',
|
||
last_state: 'MEDIAINFO_CHECK',
|
||
start_time: nowIso(),
|
||
end_time: null,
|
||
error_message: null,
|
||
output_path: null,
|
||
handbrake_info_json: null,
|
||
mediainfo_info_json: null,
|
||
encode_plan_json: null,
|
||
encode_input_path: null,
|
||
encode_review_confirmed: 0,
|
||
makemkv_info_json: nextMakemkvInfoJson
|
||
};
|
||
if (resolvedReviewRawPath !== sourceJob.raw_path) {
|
||
jobUpdatePayload.raw_path = resolvedReviewRawPath;
|
||
}
|
||
await historyService.updateJob(jobId, jobUpdatePayload);
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'USER_ACTION',
|
||
`Review-Neustart aus RAW angefordert.${forcePlaylistReselection ? ' Playlist-Auswahl wird zurückgesetzt.' : ''} MakeMKV Full-Analyse wird vollständig neu ausgeführt.`
|
||
);
|
||
|
||
await this.setState('MEDIAINFO_CHECK', {
|
||
activeJobId: jobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Titel-/Spurprüfung wird neu gestartet...',
|
||
context: {
|
||
...(this.snapshot.context || {}),
|
||
jobId,
|
||
reviewConfirmed: false,
|
||
mediaInfoReview: null
|
||
}
|
||
});
|
||
|
||
this.runReviewForRawJob(jobId, resolvedReviewRawPath, {
|
||
mode: options?.mode || 'reencode',
|
||
sourceJobId: jobId,
|
||
forcePlaylistReselection,
|
||
forceFreshAnalyze: true,
|
||
previousEncodePlan
|
||
}).catch((error) => {
|
||
logger.error('restartReviewFromRaw:background-failed', { jobId, error: errorToMeta(error) });
|
||
this.failJob(jobId, 'MEDIAINFO_CHECK', error).catch((failError) => {
|
||
logger.error('restartReviewFromRaw:background-failJob-failed', {
|
||
jobId,
|
||
error: errorToMeta(failError)
|
||
});
|
||
});
|
||
});
|
||
|
||
return {
|
||
restarted: true,
|
||
started: true,
|
||
stage: 'MEDIAINFO_CHECK',
|
||
jobId
|
||
};
|
||
}
|
||
|
||
async cancel(jobId = null) {
|
||
const normalizedJobId = this.normalizeQueueJobId(jobId)
|
||
|| this.normalizeQueueJobId(this.snapshot.activeJobId)
|
||
|| this.normalizeQueueJobId(this.snapshot.context?.jobId)
|
||
|| this.normalizeQueueJobId(Array.from(this.activeProcesses.keys())[0]);
|
||
|
||
if (!normalizedJobId) {
|
||
const error = new Error('Kein laufender Prozess zum Abbrechen.');
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
const queuedIndex = this.findQueueEntryIndexByJobId(normalizedJobId);
|
||
if (queuedIndex >= 0) {
|
||
const [removed] = this.queueEntries.splice(queuedIndex, 1);
|
||
await historyService.appendLog(
|
||
normalizedJobId,
|
||
'USER_ACTION',
|
||
`Aus Queue entfernt: ${QUEUE_ACTION_LABELS[removed?.action] || removed?.action || 'Aktion'}`
|
||
);
|
||
await this.emitQueueChanged();
|
||
return {
|
||
cancelled: true,
|
||
queuedOnly: true,
|
||
jobId: normalizedJobId
|
||
};
|
||
}
|
||
|
||
const buildForcedCancelError = (message) => {
|
||
const reason = String(message || 'Vom Benutzer hart abgebrochen.').trim() || 'Vom Benutzer hart abgebrochen.';
|
||
const endedAt = nowIso();
|
||
const error = new Error(reason);
|
||
error.statusCode = 409;
|
||
error.runInfo = {
|
||
source: 'USER_CANCEL',
|
||
stage: this.snapshot.state || null,
|
||
cmd: null,
|
||
args: [],
|
||
startedAt: endedAt,
|
||
endedAt,
|
||
durationMs: 0,
|
||
status: 'CANCELLED',
|
||
exitCode: null,
|
||
stdoutLines: 0,
|
||
stderrLines: 0,
|
||
lastProgress: 0,
|
||
eta: null,
|
||
lastDetail: null,
|
||
highlights: []
|
||
};
|
||
return error;
|
||
};
|
||
|
||
const forceFinalizeCancelledJob = async (reason, stageHint = null) => {
|
||
const rawStage = String(stageHint || this.snapshot.state || '').trim().toUpperCase();
|
||
const effectiveStage = RUNNING_STATES.has(rawStage)
|
||
? rawStage
|
||
: (
|
||
RUNNING_STATES.has(String(this.snapshot.state || '').trim().toUpperCase())
|
||
? String(this.snapshot.state || '').trim().toUpperCase()
|
||
: 'ENCODING'
|
||
);
|
||
try {
|
||
await historyService.appendLog(normalizedJobId, 'USER_ACTION', reason);
|
||
} catch (_error) {
|
||
// continue with force-cancel even if logging failed
|
||
}
|
||
try {
|
||
await this.failJob(normalizedJobId, effectiveStage, buildForcedCancelError(reason));
|
||
} catch (forceError) {
|
||
logger.error('cancel:force-finalize:failed', {
|
||
jobId: normalizedJobId,
|
||
stage: effectiveStage,
|
||
reason,
|
||
error: errorToMeta(forceError)
|
||
});
|
||
const fallbackJob = await historyService.getJobById(normalizedJobId);
|
||
await historyService.updateJob(normalizedJobId, {
|
||
status: 'CANCELLED',
|
||
last_state: 'CANCELLED',
|
||
end_time: nowIso(),
|
||
error_message: reason
|
||
});
|
||
await this.setState('CANCELLED', {
|
||
activeJobId: normalizedJobId,
|
||
progress: this.snapshot.progress,
|
||
eta: null,
|
||
statusText: reason,
|
||
context: {
|
||
jobId: normalizedJobId,
|
||
rawPath: fallbackJob?.raw_path || null,
|
||
error: reason,
|
||
canRestartReviewFromRaw: Boolean(fallbackJob?.raw_path)
|
||
}
|
||
});
|
||
} finally {
|
||
this.cancelRequestedByJob.delete(normalizedJobId);
|
||
this.activeProcesses.delete(normalizedJobId);
|
||
this.syncPrimaryActiveProcess();
|
||
}
|
||
return {
|
||
cancelled: true,
|
||
queuedOnly: false,
|
||
forced: true,
|
||
jobId: normalizedJobId
|
||
};
|
||
};
|
||
|
||
const runningJob = await historyService.getJobById(normalizedJobId);
|
||
const runningStatus = String(
|
||
runningJob?.status
|
||
|| runningJob?.last_state
|
||
|| this.snapshot.state
|
||
|| ''
|
||
).trim().toUpperCase();
|
||
|
||
const processHandle = this.activeProcesses.get(normalizedJobId) || null;
|
||
if (!processHandle) {
|
||
if (runningStatus === 'READY_TO_ENCODE') {
|
||
// Kein laufender Prozess – Job direkt abbrechen
|
||
await historyService.updateJob(normalizedJobId, {
|
||
status: 'CANCELLED',
|
||
last_state: 'CANCELLED',
|
||
end_time: nowIso(),
|
||
error_message: 'Vom Benutzer abgebrochen.'
|
||
});
|
||
await historyService.appendLog(normalizedJobId, 'USER_ACTION', 'Abbruch im Status READY_TO_ENCODE.');
|
||
await this.setState('CANCELLED', {
|
||
activeJobId: normalizedJobId,
|
||
progress: 0,
|
||
eta: null,
|
||
statusText: 'Vom Benutzer abgebrochen.',
|
||
context: {
|
||
jobId: normalizedJobId,
|
||
rawPath: runningJob?.raw_path || null,
|
||
error: 'Vom Benutzer abgebrochen.',
|
||
canRestartReviewFromRaw: Boolean(runningJob?.raw_path)
|
||
}
|
||
});
|
||
return { cancelled: true, queuedOnly: false, jobId: normalizedJobId };
|
||
}
|
||
|
||
if (RUNNING_STATES.has(runningStatus)) {
|
||
return forceFinalizeCancelledJob(
|
||
`Abbruch erzwungen: kein aktiver Prozess-Handle gefunden (Status ${runningStatus}).`,
|
||
runningStatus
|
||
);
|
||
}
|
||
|
||
const error = new Error(`Kein laufender Prozess für Job #${normalizedJobId} zum Abbrechen.`);
|
||
error.statusCode = 409;
|
||
throw error;
|
||
}
|
||
|
||
logger.warn('cancel:requested', {
|
||
state: this.snapshot.state,
|
||
activeJobId: this.snapshot.activeJobId,
|
||
requestedJobId: normalizedJobId,
|
||
pid: processHandle?.child?.pid || null
|
||
});
|
||
this.cancelRequestedByJob.add(normalizedJobId);
|
||
processHandle.cancel();
|
||
try {
|
||
await historyService.appendLog(
|
||
normalizedJobId,
|
||
'USER_ACTION',
|
||
`Abbruch angefordert (hard-cancel). Status=${runningStatus || '-'}.`
|
||
);
|
||
} catch (_error) {
|
||
// keep hard-cancel flow even if logging fails
|
||
}
|
||
|
||
const settleResult = await Promise.race([
|
||
Promise.resolve(processHandle.promise)
|
||
.then(() => 'settled')
|
||
.catch(() => 'settled'),
|
||
new Promise((resolve) => setTimeout(() => resolve('timeout'), 2200))
|
||
]);
|
||
const stillActive = this.activeProcesses.has(normalizedJobId);
|
||
if (settleResult === 'settled' && !stillActive) {
|
||
return {
|
||
cancelled: true,
|
||
queuedOnly: false,
|
||
jobId: normalizedJobId
|
||
};
|
||
}
|
||
|
||
logger.error('cancel:hard-timeout', {
|
||
jobId: normalizedJobId,
|
||
runningStatus,
|
||
settleResult,
|
||
stillActive,
|
||
pid: processHandle?.child?.pid || null
|
||
});
|
||
try {
|
||
processHandle.cancel();
|
||
} catch (_error) {
|
||
// ignore second cancel errors
|
||
}
|
||
const childPid = Number(processHandle?.child?.pid);
|
||
if (Number.isFinite(childPid) && childPid > 0) {
|
||
try { process.kill(-childPid, 'SIGKILL'); } catch (_error) { /* noop */ }
|
||
try { process.kill(childPid, 'SIGKILL'); } catch (_error) { /* noop */ }
|
||
}
|
||
try {
|
||
processHandle?.child?.kill?.('SIGKILL');
|
||
} catch (_error) {
|
||
// noop
|
||
}
|
||
this.activeProcesses.delete(normalizedJobId);
|
||
this.syncPrimaryActiveProcess();
|
||
return forceFinalizeCancelledJob(
|
||
`Abbruch erzwungen: Prozess reagierte nicht rechtzeitig auf Kill-Signal (Status ${runningStatus || '-'}).`,
|
||
runningStatus
|
||
);
|
||
}
|
||
|
||
async runCommand({
|
||
jobId,
|
||
stage,
|
||
source,
|
||
cmd,
|
||
args,
|
||
parser,
|
||
collectLines = null,
|
||
collectStdoutLines = true,
|
||
collectStderrLines = true,
|
||
argsForLog = null,
|
||
silent = false
|
||
}) {
|
||
const normalizedJobId = this.normalizeQueueJobId(jobId) || Number(jobId) || jobId;
|
||
const loggableArgs = Array.isArray(argsForLog) ? argsForLog : args;
|
||
if (this.cancelRequestedByJob.has(Number(normalizedJobId))) {
|
||
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
||
cancelError.statusCode = 409;
|
||
const endedAt = nowIso();
|
||
cancelError.runInfo = {
|
||
source,
|
||
stage,
|
||
cmd,
|
||
args: loggableArgs,
|
||
startedAt: endedAt,
|
||
endedAt,
|
||
durationMs: 0,
|
||
status: 'CANCELLED',
|
||
exitCode: null,
|
||
stdoutLines: 0,
|
||
stderrLines: 0,
|
||
lastProgress: 0,
|
||
eta: null,
|
||
lastDetail: null,
|
||
highlights: []
|
||
};
|
||
logger.warn('command:cancelled-before-spawn', { jobId: normalizedJobId, stage, source });
|
||
throw cancelError;
|
||
}
|
||
|
||
await historyService.appendLog(jobId, 'SYSTEM', `Spawn ${cmd} ${loggableArgs.join(' ')}`);
|
||
logger.info('command:spawn', { jobId, stage, source, cmd, args: loggableArgs });
|
||
|
||
const runInfo = {
|
||
source,
|
||
stage,
|
||
cmd,
|
||
args: loggableArgs,
|
||
startedAt: nowIso(),
|
||
endedAt: null,
|
||
durationMs: null,
|
||
status: 'RUNNING',
|
||
exitCode: null,
|
||
stdoutLines: 0,
|
||
stderrLines: 0,
|
||
lastProgress: 0,
|
||
eta: null,
|
||
lastDetail: null,
|
||
highlights: []
|
||
};
|
||
|
||
const applyLine = (line, isStderr) => {
|
||
const text = truncateLine(line, 400);
|
||
if (isStderr) {
|
||
runInfo.stderrLines += 1;
|
||
} else {
|
||
runInfo.stdoutLines += 1;
|
||
}
|
||
|
||
const detail = extractProgressDetail(source, text);
|
||
if (detail) {
|
||
runInfo.lastDetail = detail;
|
||
}
|
||
|
||
if (runInfo.highlights.length < 120 && shouldKeepHighlight(text)) {
|
||
runInfo.highlights.push(text);
|
||
}
|
||
|
||
if (parser && !silent) {
|
||
const progress = parser(text);
|
||
if (progress && progress.percent !== null) {
|
||
runInfo.lastProgress = progress.percent;
|
||
runInfo.eta = progress.eta || runInfo.eta;
|
||
const statusText = composeStatusText(stage, progress.percent, runInfo.lastDetail);
|
||
void this.updateProgress(stage, progress.percent, progress.eta, statusText, normalizedJobId);
|
||
} else if (detail) {
|
||
const jobEntry = this.jobProgress.get(Number(normalizedJobId));
|
||
const currentProgress = jobEntry?.progress ?? Number(this.snapshot.progress || 0);
|
||
const currentEta = jobEntry?.eta ?? this.snapshot.eta;
|
||
const statusText = composeStatusText(stage, currentProgress, runInfo.lastDetail);
|
||
void this.updateProgress(stage, currentProgress, currentEta, statusText, normalizedJobId);
|
||
}
|
||
}
|
||
};
|
||
|
||
const processHandle = spawnTrackedProcess({
|
||
cmd,
|
||
args,
|
||
context: { jobId, stage, source },
|
||
onStdoutLine: (line) => {
|
||
if (collectLines && collectStdoutLines) {
|
||
collectLines.push(line);
|
||
}
|
||
void historyService.appendProcessLog(jobId, source, line);
|
||
applyLine(line, false);
|
||
},
|
||
onStderrLine: (line) => {
|
||
if (collectLines && collectStderrLines) {
|
||
collectLines.push(line);
|
||
}
|
||
void historyService.appendProcessLog(jobId, `${source}_ERR`, line);
|
||
applyLine(line, true);
|
||
}
|
||
});
|
||
|
||
this.activeProcesses.set(Number(normalizedJobId), processHandle);
|
||
this.syncPrimaryActiveProcess();
|
||
|
||
try {
|
||
const procResult = await processHandle.promise;
|
||
runInfo.status = 'SUCCESS';
|
||
runInfo.exitCode = procResult.code;
|
||
runInfo.endedAt = nowIso();
|
||
runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime();
|
||
await historyService.appendLog(jobId, 'SYSTEM', `${source} abgeschlossen.`);
|
||
logger.info('command:completed', { jobId, stage, source });
|
||
return runInfo;
|
||
} catch (error) {
|
||
if (this.cancelRequestedByJob.has(Number(normalizedJobId))) {
|
||
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
|
||
cancelError.statusCode = 409;
|
||
runInfo.status = 'CANCELLED';
|
||
runInfo.exitCode = null;
|
||
runInfo.endedAt = nowIso();
|
||
runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime();
|
||
cancelError.runInfo = runInfo;
|
||
logger.warn('command:cancelled', { jobId, stage, source });
|
||
throw cancelError;
|
||
}
|
||
runInfo.status = 'ERROR';
|
||
runInfo.exitCode = error.code ?? null;
|
||
runInfo.endedAt = nowIso();
|
||
runInfo.durationMs = new Date(runInfo.endedAt).getTime() - new Date(runInfo.startedAt).getTime();
|
||
runInfo.errorMessage = error.message;
|
||
error.runInfo = runInfo;
|
||
logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) });
|
||
throw error;
|
||
} finally {
|
||
await historyService.closeProcessLog(jobId);
|
||
this.activeProcesses.delete(Number(normalizedJobId));
|
||
this.syncPrimaryActiveProcess();
|
||
this.cancelRequestedByJob.delete(Number(normalizedJobId));
|
||
await this.emitQueueChanged();
|
||
void this.pumpQueue();
|
||
}
|
||
}
|
||
|
||
async failJob(jobId, stage, error) {
|
||
const message = error?.message || String(error);
|
||
const isCancelled = /abgebrochen|cancelled/i.test(message)
|
||
|| String(error?.runInfo?.status || '').trim().toUpperCase() === 'CANCELLED';
|
||
const normalizedStage = String(stage || '').trim().toUpperCase();
|
||
const job = await historyService.getJobById(jobId);
|
||
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
||
const finalState = isCancelled ? 'CANCELLED' : 'ERROR';
|
||
logger[isCancelled ? 'warn' : 'error']('job:failed', { jobId, stage, error: errorToMeta(error) });
|
||
const encodePlan = this.safeParseJson(job?.encode_plan_json);
|
||
const mode = String(encodePlan?.mode || '').trim().toLowerCase();
|
||
const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||
const hasEncodableInput = isPreRipMode
|
||
? Boolean(encodePlan?.encodeInputTitleId)
|
||
: Boolean(job?.encode_input_path || encodePlan?.encodeInputPath || job?.raw_path);
|
||
const hasConfirmedPlan = Boolean(
|
||
encodePlan
|
||
&& Array.isArray(encodePlan?.titles)
|
||
&& encodePlan.titles.length > 0
|
||
&& (Number(job?.encode_review_confirmed || 0) === 1 || Boolean(encodePlan?.reviewConfirmed))
|
||
&& hasEncodableInput
|
||
);
|
||
let hasRawPath = false;
|
||
try {
|
||
hasRawPath = Boolean(
|
||
job?.raw_path
|
||
&& fs.existsSync(job.raw_path)
|
||
&& (hasBluRayBackupStructure(job.raw_path) || findPreferredRawInput(job.raw_path))
|
||
);
|
||
} catch (_error) {
|
||
hasRawPath = false;
|
||
}
|
||
|
||
if (normalizedStage === 'ENCODING' && hasConfirmedPlan) {
|
||
try {
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.`
|
||
);
|
||
await this.restartEncodeWithLastSettings(jobId, {
|
||
immediate: true,
|
||
triggerReason: isCancelled ? 'cancelled_encode' : 'failed_encode'
|
||
});
|
||
this.cancelRequestedByJob.delete(Number(jobId));
|
||
return;
|
||
} catch (recoveryError) {
|
||
logger.error('job:encoding:auto-recover-failed', {
|
||
jobId,
|
||
stage,
|
||
error: errorToMeta(recoveryError)
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`Auto-Recovery nach Encode-Abbruch fehlgeschlagen: ${recoveryError?.message || 'unknown'}`
|
||
);
|
||
}
|
||
}
|
||
|
||
await historyService.updateJob(jobId, {
|
||
status: finalState,
|
||
last_state: finalState,
|
||
end_time: nowIso(),
|
||
error_message: message
|
||
});
|
||
await historyService.appendLog(
|
||
jobId,
|
||
'SYSTEM',
|
||
`${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}`
|
||
);
|
||
|
||
await this.setState(finalState, {
|
||
activeJobId: jobId,
|
||
progress: this.snapshot.progress,
|
||
eta: null,
|
||
statusText: message,
|
||
context: {
|
||
jobId,
|
||
stage,
|
||
error: message,
|
||
rawPath: job?.raw_path || null,
|
||
inputPath: job?.encode_input_path || encodePlan?.encodeInputPath || null,
|
||
selectedMetadata: {
|
||
title: job?.title || job?.detected_title || null,
|
||
year: job?.year || null,
|
||
imdbId: job?.imdb_id || null,
|
||
poster: job?.poster_url || null
|
||
},
|
||
canRestartEncodeFromLastSettings: hasConfirmedPlan,
|
||
canRestartReviewFromRaw: hasRawPath
|
||
}
|
||
});
|
||
this.cancelRequestedByJob.delete(Number(jobId));
|
||
|
||
void this.notifyPushover(isCancelled ? 'job_cancelled' : 'job_error', {
|
||
title: isCancelled ? 'Ripster - Job abgebrochen' : 'Ripster - Job Fehler',
|
||
message: `${title} (${stage}): ${message}`
|
||
});
|
||
}
|
||
|
||
}
|
||
|
||
module.exports = new PipelineService();
|