1.0.0-rc4 rc4

This commit is contained in:
2026-06-08 12:15:36 +02:00
parent 1502d4d9d0
commit 6d3c6fed0f
23 changed files with 968 additions and 667 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "1.0.0-rc3",
"version": "1.0.0-rc4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "1.0.0-rc3",
"version": "1.0.0-rc4",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "1.0.0-rc3",
"version": "1.0.0-rc4",
"private": true,
"type": "commonjs",
"scripts": {
+37 -2
View File
@@ -24,11 +24,20 @@ const diskDetectionService = require('./services/diskDetectionService');
const hardwareMonitorService = require('./services/hardwareMonitorService');
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
const tempCleanupService = require('./services/tempCleanupService');
const { ensureCurrentBetaKeyCacheOnStartup } = require('./services/makemkvKeyService');
const { fetchCurrentBetaKey } = require('./services/makemkvKeyService');
const logger = require('./services/logger').child('BOOT');
const { errorToMeta } = require('./utils/errorMeta');
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
function getDelayUntilNextBetaKeyCheck(now = new Date()) {
const next = new Date(now);
next.setHours(0, 1, 0, 0);
if (next.getTime() <= now.getTime()) {
next.setDate(next.getDate() + 1);
}
return Math.max(1000, next.getTime() - now.getTime());
}
async function start() {
logger.info('backend:start:init');
await initDatabase();
@@ -38,7 +47,32 @@ async function start() {
} catch (error) {
logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) });
}
await ensureCurrentBetaKeyCacheOnStartup();
let betaKeyRefreshTimer = null;
const scheduleBetaKeyRefresh = () => {
clearTimeout(betaKeyRefreshTimer);
const delayMs = getDelayUntilNextBetaKeyCheck();
betaKeyRefreshTimer = setTimeout(runBetaKeyRefresh, delayMs);
betaKeyRefreshTimer.unref?.();
logger.info('beta-key:daily-check:scheduled', {
delayMs,
nextRunAt: new Date(Date.now() + delayMs).toISOString()
});
};
const runBetaKeyRefresh = async () => {
try {
const result = await fetchCurrentBetaKey({ forceRefresh: true });
logger.info('beta-key:daily-check:done', {
sourceUrl: result?.sourceUrl || null,
validUntil: result?.validUntil || null,
fetchedAt: result?.fetchedAt || null
});
} catch (error) {
logger.warn('beta-key:daily-check:failed', { error: errorToMeta(error) });
} finally {
scheduleBetaKeyRefresh();
}
};
scheduleBetaKeyRefresh();
await tempCleanupService.init();
await pipelineService.init();
await converterScanService.startPolling();
@@ -107,6 +141,7 @@ async function start() {
hardwareMonitorService.stop();
cronService.stop();
tempCleanupService.stop();
clearTimeout(betaKeyRefreshTimer);
server.close(() => {
logger.warn('backend:shutdown:completed');
process.exit(0);
+43 -55
View File
@@ -12,7 +12,7 @@ const userPresetDefaultsService = require('../services/userPresetDefaultsService
const activationBytesService = require('../services/activationBytesService');
const diskDetectionService = require('../services/diskDetectionService');
const coverArtRecoveryService = require('../services/coverArtRecoveryService');
const { fetchCurrentBetaKey } = require('../services/makemkvKeyService');
const { fetchCurrentBetaKey, getCachedBetaKey } = require('../services/makemkvKeyService');
const logger = require('../services/logger').child('SETTINGS_ROUTE');
const { getDb } = require('../db/database');
@@ -58,39 +58,36 @@ router.get(
'/makemkv/beta-key',
asyncHandler(async (req, res) => {
logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId });
try {
const result = await fetchCurrentBetaKey();
res.json({
betaKey: result.key,
sourceUrl: result.sourceUrl,
validUntil: result.validUntil || null,
cached: result.cached === true,
stale: result.stale === true
});
return;
} catch (error) {
const status = Number(error?.statusCode || 0);
if (status === 429 || status === 403) {
const currentSettings = await settingsService.getSettingsMap({ forceRefresh: false });
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
logger.warn('get:settings:makemkv:beta-key:fallback-local', {
reqId: req.reqId,
status,
hasExistingKey: Boolean(existingKey),
retryAt: error?.retryAt || null
});
res.json({
betaKey: existingKey,
sourceUrl: null,
validUntil: null,
cached: Boolean(existingKey),
stale: true,
warning: error?.message || 'Betakey-API aktuell nicht erreichbar.'
});
return;
}
throw error;
}
const currentSettings = await settingsService.getSettingsMap({ forceRefresh: false });
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
const cached = await getCachedBetaKey({ allowExpired: true });
res.json({
betaKey: cached?.key || '',
sourceUrl: cached?.sourceUrl || null,
validUntil: cached?.validUntil || null,
fetchedAt: cached?.fetchedAt || null,
cached: cached?.cached === true,
stale: cached?.stale === true,
appliedKey: existingKey || null,
appliedMatchesCache: Boolean(cached?.key) && existingKey === cached.key
});
})
);
router.post(
'/makemkv/beta-key/check',
asyncHandler(async (req, res) => {
logger.info('post:settings:makemkv:beta-key:check', { reqId: req.reqId });
const betaKeyResult = await fetchCurrentBetaKey({ forceRefresh: true });
res.json({
betaKey: betaKeyResult.key,
sourceUrl: betaKeyResult.sourceUrl,
validUntil: betaKeyResult.validUntil || null,
fetchedAt: betaKeyResult.fetchedAt || null,
cached: betaKeyResult.cached === true,
stale: betaKeyResult.stale === true
});
})
);
@@ -98,35 +95,26 @@ router.post(
'/makemkv/beta-key/apply',
asyncHandler(async (req, res) => {
logger.info('post:settings:makemkv:beta-key:apply', { reqId: req.reqId });
const force = req.body?.force === true;
const currentSettings = await settingsService.getSettingsMap({ forceRefresh: true });
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
const betaKeyResult = await fetchCurrentBetaKey();
const requestedKey = String(req.body?.betaKey || '').trim();
const cachedResult = requestedKey
? { key: requestedKey }
: await getCachedBetaKey({ allowExpired: true });
const betaKey = String(cachedResult?.key || '').trim();
if (!force && existingKey && existingKey !== betaKeyResult.key) {
const syncResult = await settingsService.syncMakeMKVRegistrationKeyFromSettings({
settingsMap: currentSettings
});
res.json({
applied: false,
preservedExistingKey: true,
reason: 'existing_key',
betaKey: betaKeyResult.key,
sourceUrl: betaKeyResult.sourceUrl,
settingsFilePath: syncResult?.path || null
});
return;
if (!betaKey) {
const error = new Error('Kein geprüfter Betakey vorhanden. Bitte zuerst prüfen.');
error.statusCode = 400;
throw error;
}
const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKeyResult.key);
const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKey);
wsService.broadcast('SETTINGS_UPDATED', updated);
res.json({
applied: true,
preservedExistingKey: false,
setting: updated,
betaKey: betaKeyResult.key,
sourceUrl: betaKeyResult.sourceUrl
betaKey,
sourceUrl: String(cachedResult?.sourceUrl || '').trim() || null
});
})
);
+4 -3
View File
@@ -8,6 +8,7 @@ const tmdbService = require('./tmdbService');
const cdRipService = require('./cdRipService');
const { getJobLogDir } = require('./logPathService');
const thumbnailService = require('./thumbnailService');
const { getServerTimestamp } = require('../utils/serverTime');
function parseJsonSafe(raw, fallback = null) {
if (!raw) {
@@ -3542,7 +3543,7 @@ class HistoryService {
});
processLogStreams.set(streamKey, stream);
}
const line = `[${new Date().toISOString()}] [${source}] ${String(message || '')}\n`;
const line = `[${getServerTimestamp()}] [${source}] ${String(message || '')}\n`;
stream.write(line);
} catch (error) {
logger.warn('job:process-log:append-failed', {
@@ -3869,7 +3870,7 @@ class HistoryService {
.filter(Boolean)
.join(', ');
lines.push(
`[${new Date().toISOString()}] [SYSTEM] Importierte vorhandene Job-Logs: ${fileSummary}`
`[${getServerTimestamp()}] [SYSTEM] Importierte vorhandene Job-Logs: ${fileSummary}`
);
for (const source of normalizedSources) {
lines.push(...source.lines);
@@ -3877,7 +3878,7 @@ class HistoryService {
}
if (importInfo) {
lines.push(
`[${new Date().toISOString()}] [SYSTEM] Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(importInfo)}`
`[${getServerTimestamp()}] [SYSTEM] Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(importInfo)}`
);
}
+2 -1
View File
@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path');
const { logLevel } = require('../config');
const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService');
const { getServerTimestamp } = require('../utils/serverTime');
const LEVELS = {
debug: 10,
@@ -123,7 +124,7 @@ function emit(level, scope, message, meta = null) {
return;
}
const timestamp = new Date().toISOString();
const timestamp = getServerTimestamp();
const payload = {
timestamp,
level: normLevel,
+39 -358
View File
@@ -7,11 +7,6 @@ const logger = require('./logger').child('MAKEMKV_KEY');
const backendPackage = require('../../package.json');
const MAKEMKV_BETA_KEY_API_URL = 'https://cable.ayra.ch/makemkv/api.php?json';
const MAKEMKV_BETA_KEY_FORUM_URLS = [
'https://www.makemkv.com/forum/viewtopic.php?t=1053',
'https://forum.makemkv.com/forum/viewtopic.php?t=1053'
];
const MAKEMKV_BETA_KEY_FORUM_URL = MAKEMKV_BETA_KEY_FORUM_URLS[0];
const MAKEMKV_BETA_KEY_CACHE_PREF_KEY = 'makemkv_beta_key_cache_v1';
const MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS = 1000;
const MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS = Math.max(
@@ -20,63 +15,24 @@ const MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS = Math.max(
);
const RIPSTER_VERSION = String(backendPackage?.version || 'dev').trim() || 'dev';
const DEFAULT_USER_AGENT = `Ripster/MakeMKVKey-${RIPSTER_VERSION} (${os.platform()}/${os.release()}; Node/${process.versions.node}) +https://github.com/mboehmlaender/ripster`;
const DEFAULT_USER_AGENT = `Ripster/MakeMKVKey-${RIPSTER_VERSION} (linux/manual; Node/manual) +https://github.com/mboehmlaender/ripster`;
const MAKEMKV_BETA_KEY_API_USER_AGENT = process.env.MAKEMKV_BETA_KEY_API_USER_AGENT
|| DEFAULT_USER_AGENT;
const MAKEMKV_BETA_KEY_FORUM_USER_AGENT = process.env.MAKEMKV_BETA_KEY_FORUM_USER_AGENT
|| 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36';
const MAKEMKV_BETA_KEY_API_FROM = process.env.MAKEMKV_BETA_KEY_API_FROM
|| '';
const MAKEMKV_BETA_KEY_API_TIMEOUT_MS = Math.max(
1000,
Number(process.env.MAKEMKV_BETA_KEY_API_TIMEOUT_MS || 15000)
);
const MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS = Math.max(
MAKEMKV_BETA_KEY_API_TIMEOUT_MS,
Number(process.env.MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS || 30000)
);
const MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS = Math.max(
30 * 1000,
Number(process.env.MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS || (15 * 60 * 1000))
);
const MONTH_NAME_TO_INDEX = new Map([
['january', 0], ['jan', 0],
['february', 1], ['feb', 1],
['march', 2], ['mar', 2],
['april', 3], ['apr', 3],
['may', 4],
['june', 5], ['jun', 5],
['july', 6], ['jul', 6],
['august', 7], ['aug', 7],
['september', 8], ['sep', 8], ['sept', 8],
['october', 9], ['oct', 9],
['november', 10], ['nov', 10],
['december', 11], ['dec', 11]
]);
let cachedBetaKeyEntry = null;
let blockedUntilMs = 0;
function parseRetryAfterToMs(retryAfterValue) {
const raw = String(retryAfterValue || '').trim();
if (!raw) {
return 0;
}
if (/^\d+$/.test(raw)) {
const seconds = Number(raw);
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
}
const parsedDate = Date.parse(raw);
if (!Number.isFinite(parsedDate)) {
return 0;
}
return Math.max(0, parsedDate - Date.now());
}
function parseBetaKeyCandidate(value, invalidMessage = 'Betakey-Antwort ist ungültig.') {
const betaKey = String(value || '').trim();
if (!/^[A-Za-z0-9][A-Za-z0-9-]{15,}$/.test(betaKey)) {
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{15,}$/.test(betaKey)) {
const error = new Error(invalidMessage);
error.statusCode = 502;
throw error;
@@ -84,83 +40,6 @@ function parseBetaKeyCandidate(value, invalidMessage = 'Betakey-Antwort ist ung
return betaKey;
}
function resolveMonthIndex(monthName) {
const normalized = String(monthName || '').trim().toLowerCase();
return MONTH_NAME_TO_INDEX.has(normalized) ? MONTH_NAME_TO_INDEX.get(normalized) : null;
}
function endOfMonthUtcIso(year, monthIndex) {
const y = Number(year);
const m = Number(monthIndex);
if (!Number.isInteger(y) || !Number.isInteger(m) || m < 0 || m > 11) {
return null;
}
return new Date(Date.UTC(y, m + 1, 0, 23, 59, 59, 999)).toISOString();
}
function endOfDayUtcIso(year, monthIndex, day) {
const y = Number(year);
const m = Number(monthIndex);
const d = Number(day);
if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) {
return null;
}
if (m < 0 || m > 11 || d < 1 || d > 31) {
return null;
}
return new Date(Date.UTC(y, m, d, 23, 59, 59, 999)).toISOString();
}
function parseValidityPhraseToIso(phrase) {
const raw = String(phrase || '').trim().replace(/\s+/g, ' ');
if (!raw) {
return null;
}
const endOfMonthMatch = raw.match(/^end of\s+([A-Za-z]+)\s+(\d{4})$/i);
if (endOfMonthMatch) {
const monthIndex = resolveMonthIndex(endOfMonthMatch[1]);
if (monthIndex != null) {
return endOfMonthUtcIso(Number(endOfMonthMatch[2]), monthIndex);
}
}
const exactDayMatch = raw.match(/^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/i);
if (exactDayMatch) {
const monthIndex = resolveMonthIndex(exactDayMatch[2]);
if (monthIndex != null) {
return endOfDayUtcIso(Number(exactDayMatch[3]), monthIndex, Number(exactDayMatch[1]));
}
}
const monthYearMatch = raw.match(/^([A-Za-z]+)\s+(\d{4})$/i);
if (monthYearMatch) {
const monthIndex = resolveMonthIndex(monthYearMatch[1]);
if (monthIndex != null) {
return endOfMonthUtcIso(Number(monthYearMatch[2]), monthIndex);
}
}
return null;
}
function parseForumValidUntilIsoFromHtml(rawBody) {
const html = String(rawBody || '');
const plainText = html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/&#160;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim();
const match = plainText.match(/valid\s+until\s+([^\.]{3,80})/i);
if (!match?.[1]) {
return null;
}
return parseValidityPhraseToIso(match[1]);
}
function normalizeCacheEntry(rawEntry) {
if (!rawEntry || typeof rawEntry !== 'object') {
@@ -174,7 +53,7 @@ function normalizeCacheEntry(rawEntry) {
return null;
}
const sourceUrl = String(rawEntry.sourceUrl || MAKEMKV_BETA_KEY_FORUM_URL).trim() || MAKEMKV_BETA_KEY_FORUM_URL;
const sourceUrl = String(rawEntry.sourceUrl || MAKEMKV_BETA_KEY_API_URL).trim() || MAKEMKV_BETA_KEY_API_URL;
const validUntilIsoRaw = String(rawEntry.validUntil || rawEntry.validUntilIso || '').trim();
const validUntilMs = Date.parse(validUntilIsoRaw);
@@ -211,6 +90,7 @@ function buildPublicResult(entry, options = {}) {
key: entry.key,
sourceUrl: entry.sourceUrl,
validUntil: entry.validUntil,
fetchedAt: entry.fetchedAt || null,
cached: options.cached === true,
stale: options.stale === true
};
@@ -273,6 +153,31 @@ async function tryUsePersistentCache(nowMs = Date.now()) {
return buildPublicResult(persistent, { cached: true, stale: false });
}
async function getCachedBetaKey(options = {}) {
const nowMs = Date.now();
const allowExpired = options?.allowExpired !== false;
if (cachedBetaKeyEntry) {
const stale = !isCacheEntryValid(cachedBetaKeyEntry, nowMs);
if (!stale || allowExpired) {
return buildPublicResult(cachedBetaKeyEntry, { cached: true, stale });
}
}
const persistent = await readPersistentCacheEntry();
if (!persistent) {
return null;
}
setInMemoryCache(persistent);
const stale = !isCacheEntryValid(persistent, nowMs);
if (stale && !allowExpired) {
return null;
}
return buildPublicResult(persistent, { cached: true, stale });
}
function createRateLimitError(retryAfterMs = 0) {
const parsedRetryMs = Number(retryAfterMs || 0);
const waitMs = parsedRetryMs > 0 ? parsedRetryMs : MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS;
@@ -296,11 +201,6 @@ function buildExistingBackoffError() {
return error;
}
function shouldSkipFallback(error) {
const status = Number(error?.httpStatus || error?.status || 0);
return status >= 400 && status < 500;
}
function resolveValidityOrFallback(validUntilIso, sourceUrl) {
const parsedMs = Date.parse(String(validUntilIso || '').trim());
if (Number.isFinite(parsedMs)) {
@@ -413,71 +313,13 @@ async function fetchCurrentBetaKey(options = {}) {
throw buildExistingBackoffError();
}
const errors = [];
try {
const forumResult = await fetchCurrentBetaKeyViaForum();
const normalized = normalizeCacheEntry({
...forumResult,
fetchedAt: new Date().toISOString()
});
if (!normalized) {
const error = new Error('Forum-Betakey konnte nicht normalisiert werden.');
error.statusCode = 502;
throw error;
}
blockedUntilMs = 0;
setInMemoryCache(normalized);
await persistCacheEntry(normalized);
return buildPublicResult(normalized, { cached: false, stale: false });
} catch (error) {
errors.push(error?.message || String(error));
logger.warn('beta-key:forum-failed', {
error: error?.message || String(error)
});
}
try {
const apiResult = await fetchCurrentBetaKeyViaFetch();
const apiResult = await fetchCurrentBetaKeyViaCurl();
const normalized = normalizeCacheEntry({
...apiResult,
fetchedAt: new Date().toISOString()
});
if (!normalized) {
const error = new Error('API-Betakey konnte nicht normalisiert werden.');
error.statusCode = 502;
throw error;
}
blockedUntilMs = 0;
setInMemoryCache(normalized);
await persistCacheEntry(normalized);
return buildPublicResult(normalized, { cached: false, stale: false });
} catch (error) {
errors.push(error?.message || String(error));
logger.warn('beta-key:fetch-failed', {
error: error?.message || String(error)
});
if ([403, 429].includes(Number(error?.httpStatus || error?.status || 0))) {
throw createRateLimitError(Number(error?.retryAfterMs || 0));
}
if (shouldSkipFallback(error)) {
const resolved = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`);
resolved.statusCode = Number(error?.httpStatus || error?.status || 502) || 502;
throw resolved;
}
}
try {
const apiCurlResult = await fetchCurrentBetaKeyViaCurl();
const normalized = normalizeCacheEntry({
...apiCurlResult,
fetchedAt: new Date().toISOString()
});
if (!normalized) {
const error = new Error('API-curl-Betakey konnte nicht normalisiert werden.');
error.statusCode = 502;
@@ -489,33 +331,15 @@ async function fetchCurrentBetaKey(options = {}) {
await persistCacheEntry(normalized);
return buildPublicResult(normalized, { cached: false, stale: false });
} catch (error) {
errors.push(error?.message || String(error));
logger.warn('beta-key:curl-failed', {
logger.warn('beta-key:fetch-failed', {
error: error?.message || String(error)
});
}
const finalError = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`);
finalError.statusCode = 502;
throw finalError;
}
async function ensureCurrentBetaKeyCacheOnStartup() {
try {
const current = await fetchCurrentBetaKey();
logger.info('beta-key:startup-check:done', {
sourceUrl: current?.sourceUrl || null,
validUntil: current?.validUntil || null,
cached: current?.cached === true
});
return current;
} catch (error) {
logger.warn('beta-key:startup-check:failed', {
error: error?.message || String(error),
statusCode: Number(error?.statusCode || 0) || null,
retryAt: error?.retryAt || null
});
return null;
if (Number(error?.httpStatus || error?.statusCode || error?.status || 0) === 429) {
throw createRateLimitError(Number(error?.retryAfterMs || 0));
}
const resolved = new Error(`Betakey konnte nicht geladen werden. Details: ${error?.message || String(error)}`);
resolved.statusCode = Number(error?.httpStatus || error?.statusCode || error?.status || 502) || 502;
throw resolved;
}
}
@@ -546,41 +370,14 @@ function parseBetaKeyResponse(rawBody) {
};
}
function parseBetaKeyFromForumHtml(rawBody, sourceUrl = MAKEMKV_BETA_KEY_FORUM_URL) {
const html = String(rawBody || '');
const contextualMatch = html.match(
/current\s+beta\s+key\s+is[\s\S]{0,4000}?<pre><code>\s*([A-Za-z0-9-]{16,})\s*<\/code>/i
);
const firstCodeMatch = html.match(/<pre><code>\s*([A-Za-z0-9-]{16,})\s*<\/code>/i);
const keyRaw = contextualMatch?.[1] || firstCodeMatch?.[1] || '';
const key = parseBetaKeyCandidate(keyRaw, 'Betakey konnte im Forum nicht gefunden werden.');
const parsedValidity = parseForumValidUntilIsoFromHtml(html);
const validUntil = resolveValidityOrFallback(parsedValidity, sourceUrl);
return {
key,
sourceUrl,
validUntil
};
}
function fetchCurrentBetaKeyViaCurl() {
return new Promise((resolve, reject) => {
const curlArgs = [
'-fsSL',
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
'-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
'-H', 'Accept: text/plain, text/html;q=0.9, */*;q=0.8',
'-H', 'Accept-Language: de,en-US;q=0.7,en;q=0.3',
'-H', 'Cache-Control: no-cache',
'-H', 'Pragma: no-cache'
MAKEMKV_BETA_KEY_API_URL
];
const fromHeader = String(MAKEMKV_BETA_KEY_API_FROM || '').trim();
if (fromHeader) {
curlArgs.push('-H', `From: ${fromHeader}`);
}
curlArgs.push(MAKEMKV_BETA_KEY_API_URL);
execFile(
'curl',
@@ -615,126 +412,10 @@ function fetchCurrentBetaKeyViaCurl() {
});
}
async function fetchCurrentBetaKeyViaFetch() {
const requestHeaders = {
'User-Agent': MAKEMKV_BETA_KEY_API_USER_AGENT,
'Accept': 'text/plain, text/html;q=0.9, */*;q=0.8',
'Accept-Language': 'de,en-US;q=0.7,en;q=0.3',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
};
const fromHeader = String(MAKEMKV_BETA_KEY_API_FROM || '').trim();
if (fromHeader) {
requestHeaders.From = fromHeader;
}
const response = await fetch(MAKEMKV_BETA_KEY_API_URL, {
headers: requestHeaders,
signal: AbortSignal.timeout(MAKEMKV_BETA_KEY_API_TIMEOUT_MS)
});
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.statusCode = 502;
error.httpStatus = response.status;
error.retryAfterMs = parseRetryAfterToMs(response.headers.get('retry-after'));
throw error;
}
const rawBody = await response.text();
return parseBetaKeyResponse(rawBody);
}
async function fetchCurrentBetaKeyViaForum() {
try {
return await fetchCurrentBetaKeyViaForumCurl();
} catch (error) {
const resolved = new Error(`Forum-Betakey konnte nicht geladen werden. Details: ${error?.message || String(error)}`);
resolved.statusCode = 502;
throw resolved;
}
}
function fetchCurrentBetaKeyViaForumCurl() {
return new Promise((resolve, reject) => {
const errors = [];
const urls = [...MAKEMKV_BETA_KEY_FORUM_URLS];
const runAttempt = (index) => {
if (index >= urls.length) {
const finalError = new Error(errors.join(' | ') || 'Keine Forum-URL verfügbar');
finalError.statusCode = 502;
reject(finalError);
return;
}
const forumUrl = urls[index];
let referer = 'https://www.makemkv.com/';
try {
referer = `${new URL(forumUrl).origin}/`;
} catch (_error) {
referer = 'https://www.makemkv.com/';
}
const curlArgs = [
'-fsSL',
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS / 1000)),
'-A', MAKEMKV_BETA_KEY_FORUM_USER_AGENT,
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'-H', 'Accept-Language: de,en-US;q=0.7,en;q=0.3',
'-H', 'Cache-Control: no-cache',
'-H', 'Pragma: no-cache',
'-H', `Referer: ${referer}`
];
const fromHeader = String(MAKEMKV_BETA_KEY_API_FROM || '').trim();
if (fromHeader) {
curlArgs.push('-H', `From: ${fromHeader}`);
}
curlArgs.push(forumUrl);
execFile(
'curl',
curlArgs,
{
timeout: MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS,
maxBuffer: 4 * 1024 * 1024
},
(error, stdout, stderr) => {
if (error) {
const resolvedError = new Error(
`Forum-curl ${forumUrl} fehlgeschlagen (${error.code || 'unknown'}): ${String(stderr || error.message || '').trim() || 'keine Details'}`
);
resolvedError.statusCode = 502;
if (/\b429\b/.test(String(stderr || ''))) {
resolvedError.httpStatus = 429;
}
if (/\b403\b/.test(String(stderr || ''))) {
resolvedError.httpStatus = 403;
}
errors.push(resolvedError.message);
runAttempt(index + 1);
return;
}
try {
resolve(parseBetaKeyFromForumHtml(stdout, forumUrl));
} catch (parseError) {
errors.push(`Forum-Parse ${forumUrl} fehlgeschlagen: ${parseError?.message || String(parseError)}`);
runAttempt(index + 1);
}
}
);
};
runAttempt(0);
});
}
module.exports = {
MAKEMKV_BETA_KEY_API_URL,
MAKEMKV_BETA_KEY_FORUM_URL,
fetchCurrentBetaKey,
ensureCurrentBetaKeyCacheOnStartup,
getCachedBetaKey,
getMakeMKVConfigDir,
getMakeMKVSettingsFilePath,
normalizeRegistrationKey,
+480 -183
View File
@@ -6377,6 +6377,133 @@ function buildSelectedHandBrakeReviewTitles(scanJson, selectedHandBrakeTitleIds
return titles;
}
function collectAlternativeFeatureReviewCandidates({
playlistCandidates = [],
handBrakePlaylistScan = null,
playlistAnalysis = null,
selectedPlaylistId = null,
selectedHandBrakeTitleId = null,
selectedTitleInfo = null,
makeMkvSubtitleTracks = []
}) {
const normalizedSelectedPlaylistId = normalizePlaylistId(selectedPlaylistId);
const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId);
const normalizedCache = normalizeHandBrakePlaylistScanCache(handBrakePlaylistScan);
const baselineDurationSeconds = Number(selectedTitleInfo?.durationSeconds || 0) || 0;
const baselineSizeBytes = Number(selectedTitleInfo?.sizeBytes || 0) || 0;
const candidateRows = Array.isArray(playlistCandidates) ? playlistCandidates : [];
const candidateMap = new Map();
for (const row of candidateRows) {
const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null);
if (!playlistId || candidateMap.has(playlistId)) {
continue;
}
candidateMap.set(playlistId, row);
}
const resolved = [];
const seenKeys = new Set();
const pushCandidate = (playlistIdRaw, titleInfoRaw, sourceRow = null) => {
const playlistId = normalizePlaylistId(playlistIdRaw || titleInfoRaw?.playlistId || null);
const titleInfo = enrichTitleInfoWithForcedSubtitleAvailability(
titleInfoRaw,
makeMkvSubtitleTracks
);
const handBrakeTitleId = normalizeReviewTitleId(
titleInfo?.handBrakeTitleId
|| sourceRow?.handBrakeTitleId
|| null
);
if (!playlistId || !titleInfo || !handBrakeTitleId) {
return;
}
const durationSeconds = Number(titleInfo?.durationSeconds || sourceRow?.durationSeconds || 0) || 0;
const sizeBytes = Number(titleInfo?.sizeBytes || sourceRow?.sizeBytes || 0) || 0;
const isSelectedDefault = (
(normalizedSelectedPlaylistId && playlistId === normalizedSelectedPlaylistId)
|| (normalizedSelectedHandBrakeTitleId && handBrakeTitleId === normalizedSelectedHandBrakeTitleId)
);
if (!isSelectedDefault && durationSeconds < baselineDurationSeconds) {
return;
}
const dedupeKey = `${playlistId}:${handBrakeTitleId}`;
if (seenKeys.has(dedupeKey)) {
return;
}
seenKeys.add(dedupeKey);
resolved.push({
playlistId,
handBrakeTitleId,
titleInfo,
sourceRow,
durationSeconds,
sizeBytes,
playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistId),
isSelectedDefault
});
};
if (normalizedSelectedPlaylistId && selectedTitleInfo) {
pushCandidate(
normalizedSelectedPlaylistId,
selectedTitleInfo,
candidateMap.get(normalizedSelectedPlaylistId) || null
);
}
if (normalizedCache?.byPlaylist && typeof normalizedCache.byPlaylist === 'object') {
for (const [playlistId, cacheEntry] of Object.entries(normalizedCache.byPlaylist)) {
pushCandidate(
playlistId,
cacheEntry?.titleInfo || null,
candidateMap.get(playlistId) || null
);
}
}
for (const row of candidateRows) {
const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null);
if (!playlistId) {
continue;
}
const cacheEntry = normalizedCache?.byPlaylist?.[playlistId] || null;
pushCandidate(
playlistId,
cacheEntry?.titleInfo || null,
row
);
}
const byPriority = resolved
.slice()
.sort((left, right) => {
if (left.isSelectedDefault !== right.isSelectedDefault) {
return left.isSelectedDefault ? -1 : 1;
}
return left.durationSeconds - right.durationSeconds
|| left.sizeBytes - right.sizeBytes
|| String(left.playlistId || '').localeCompare(String(right.playlistId || ''))
|| left.handBrakeTitleId - right.handBrakeTitleId;
});
if (byPriority.length === 0 && normalizedSelectedPlaylistId && selectedTitleInfo) {
return [{
playlistId: normalizedSelectedPlaylistId,
handBrakeTitleId: normalizedSelectedHandBrakeTitleId || null,
titleInfo: enrichTitleInfoWithForcedSubtitleAvailability(selectedTitleInfo, makeMkvSubtitleTracks),
sourceRow: candidateMap.get(normalizedSelectedPlaylistId) || null,
durationSeconds: baselineDurationSeconds,
sizeBytes: baselineSizeBytes,
playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, normalizedSelectedPlaylistId),
isSelectedDefault: true
}];
}
return byPriority;
}
function pickTitleIdForTrackReview(playlistAnalysis, selectedTitleId = null) {
const explicit = normalizeNonNegativeInteger(selectedTitleId);
if (explicit !== null) {
@@ -8116,6 +8243,23 @@ function applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId, sele
selectedTitleIds: normalizedTitleIds,
encodeInputTitleId: effectivePrimaryTitleId,
encodeInputPath: selectedTitle?.filePath || null,
handBrakeTitleId: normalizeReviewTitleId(
selectedTitle?.handBrakeTitleId
?? selectedTitle?.titleIndex
?? encodePlan?.handBrakeTitleId
?? null
),
selectedPlaylistId: normalizePlaylistId(
selectedTitle?.playlistId
|| selectedTitle?.playlistFile
|| encodePlan?.selectedPlaylistId
|| null
),
selectedMakemkvTitleId: normalizeNonNegativeInteger(
selectedTitle?.makemkvTitleId
?? encodePlan?.selectedMakemkvTitleId
?? null
),
titleSelectionRequired: false
},
selectedTitle,
@@ -9348,6 +9492,9 @@ function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) {
if (reviewTitles.length === 0) {
return null;
}
if (Boolean(reviewPlan?.alternativeTitleSelection?.enabled)) {
return null;
}
const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
if (!previousSelectedTitle) {
@@ -11072,6 +11219,63 @@ class PipelineService extends EventEmitter {
}
}
if (stage === 'RIPPING' || stage === 'MEDIAINFO_CHECK') {
let recoveryValidation;
try {
recoveryValidation = await this.validateStartupEncodeRecoveryReadiness(row);
} catch (validationError) {
logger.warn('startup:recover-stale-review:validation-failed', {
jobId,
stage,
error: errorToMeta(validationError)
});
recoveryValidation = {
ok: false,
code: 'validation_failed',
message: `Server-Neustart: Rip-Validierung fehlgeschlagen (${validationError?.message || 'unknown'}). Bitte Rip vom Laufwerk neu starten.`
};
}
if (recoveryValidation?.ok) {
const reviewRecoveryMessage = stage === 'MEDIAINFO_CHECK'
? 'Server-Neustart während Titel-/Spurprüfung erkannt. Rip ist bereits fertig; Review kann aus dem RAW-Backup neu gestartet werden.'
: 'Server-Neustart nach abgeschlossenem Rip erkannt. Review kann aus dem RAW-Backup neu gestartet werden.';
await historyService.updateJob(jobId, {
status: 'ERROR',
last_state: 'MEDIAINFO_CHECK',
end_time: nowIso(),
error_message: reviewRecoveryMessage,
raw_path: recoveryValidation?.resolvedRawPath || row?.raw_path || null,
rip_successful: 1
});
try {
await historyService.appendLog(jobId, 'SYSTEM', reviewRecoveryMessage);
} catch (_error) {
// keep recovery path even if log append fails
}
markedError += 1;
continue;
}
const recoveryMessage = String(recoveryValidation?.message || '').trim()
|| `${message} Rip ist unvollständig. Bitte Rip vom Laufwerk neu starten.`;
await historyService.updateJob(jobId, {
status: 'ERROR',
last_state: 'RIPPING',
end_time: nowIso(),
error_message: recoveryMessage,
rip_successful: 0
});
try {
await historyService.appendLog(jobId, 'SYSTEM', recoveryMessage);
} catch (_error) {
// keep recovery path even if log append fails
}
markedError += 1;
blockedIncompleteRip += 1;
continue;
}
await historyService.updateJob(jobId, {
status: 'ERROR',
last_state: stage,
@@ -18502,14 +18706,25 @@ class PipelineService extends EventEmitter {
&& !selectedPlaylistId
&& playlistCandidates.length > 0
);
if (shouldPrepareHandBrakeDecisionData) {
const shouldPrepareAlternativeFeatureReviewData = Boolean(
selectedPlaylistId
&& !isSeriesBackupReview
&& playlistCandidates.length > 1
);
const shouldPrepareExpandedHandBrakePlaylistData = (
shouldPrepareHandBrakeDecisionData
|| shouldPrepareAlternativeFeatureReviewData
);
if (shouldPrepareExpandedHandBrakePlaylistData) {
const hasCompleteCache = hasCachedHandBrakeDataForPlaylistCandidates(handBrakePlaylistScan, playlistCandidates);
if (!hasCompleteCache) {
await this.updateProgress(
'MEDIAINFO_CHECK',
25,
null,
'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet',
shouldPrepareAlternativeFeatureReviewData
? 'HandBrake Trackdaten für alternative Filmfassungen werden vorbereitet'
: 'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet',
jobId
);
try {
@@ -18540,13 +18755,17 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(
jobId,
'SYSTEM',
`HandBrake Playlist-Trackdaten vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.`
shouldPrepareAlternativeFeatureReviewData
? `HandBrake Trackdaten für alternative Filmfassungen vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.`
: `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.'
shouldPrepareAlternativeFeatureReviewData
? 'HandBrake Trackdaten für alternative Filmfassungen konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.'
: 'HandBrake Playlist-Trackdaten konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.'
);
}
} else {
@@ -18562,7 +18781,9 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(
jobId,
'SYSTEM',
`HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}`
shouldPrepareAlternativeFeatureReviewData
? `HandBrake Vorbereitung für alternative Filmfassungen fehlgeschlagen: ${error.message}`
: `HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}`
);
throw error;
}
@@ -18572,7 +18793,9 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(
jobId,
'SYSTEM',
'HandBrake Playlist-Trackdaten aus Cache übernommen (kein erneuter --scan -t 0).'
shouldPrepareAlternativeFeatureReviewData
? 'HandBrake Trackdaten für alternative Filmfassungen aus Cache übernommen (kein erneuter --scan -t 0).'
: 'HandBrake Playlist-Trackdaten aus Cache übernommen (kein erneuter --scan -t 0).'
);
}
}
@@ -19455,188 +19678,243 @@ class PipelineService extends EventEmitter {
};
}
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 alternativeReviewCandidates = collectAlternativeFeatureReviewCandidates({
playlistCandidates,
handBrakePlaylistScan,
playlistAnalysis,
selectedPlaylistId: resolvedPlaylistId,
selectedHandBrakeTitleId: resolvedHandBrakeTitleId,
selectedTitleInfo: reviewTitleInfo,
makeMkvSubtitleTracks: makeMkvSubtitleTracksForSelection
});
const reviewCandidateRows = alternativeReviewCandidates.length > 0
? alternativeReviewCandidates
: [{
playlistId: resolvedPlaylistId,
handBrakeTitleId: resolvedHandBrakeTitleId,
titleInfo: reviewTitleInfo,
sourceRow: null,
durationSeconds: Number(reviewTitleInfo?.durationSeconds || 0) || 0,
sizeBytes: Number(reviewTitleInfo?.sizeBytes || 0) || 0,
playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId),
isSelectedDefault: true
}];
const buildSyntheticCandidatePath = (handBrakeTitleId, fallbackTitleId) => (
path.join(
rawPath,
reviewTitleSource === 'handbrake' && Number.isFinite(Number(handBrakeTitleId)) && Number(handBrakeTitleId) > 0
? `handbrake_t${String(Math.trunc(Number(handBrakeTitleId))).padStart(2, '0')}.mkv`
: `makemkv_t${String(fallbackTitleId).padStart(2, '0')}.mkv`
)
);
const syntheticMediaInfoByPath = {
[syntheticFilePath]: buildSyntheticMediaInfoFromMakeMkvTitle(reviewTitleInfo)
};
const reviewCandidatesWithPaths = reviewCandidateRows.map((candidate, index) => {
const fallbackTitleId = Number(selectedTitleForReview || index + 1) || (index + 1);
return {
...candidate,
syntheticFilePath: buildSyntheticCandidatePath(candidate.handBrakeTitleId, fallbackTitleId)
};
});
const syntheticMediaInfoByPath = Object.fromEntries(
reviewCandidatesWithPaths.map((candidate) => [
candidate.syntheticFilePath,
buildSyntheticMediaInfoFromMakeMkvTitle(candidate.titleInfo)
])
);
const allowAlternativeTitleSelection = reviewCandidatesWithPaths.length > 1;
let review = buildMediainfoReview({
mediaFiles: [{
path: syntheticFilePath,
size: Number(reviewTitleInfo?.sizeBytes || 0)
}],
mediaFiles: reviewCandidatesWithPaths.map((candidate) => ({
path: candidate.syntheticFilePath,
size: Number(candidate?.titleInfo?.sizeBytes || 0)
})),
mediaInfoByPath: syntheticMediaInfoByPath,
settings,
presetProfile,
playlistAnalysis,
preferredEncodeTitleId: selectedTitleForReview,
selectedPlaylistId: resolvedPlaylistId || reviewTitleInfo?.playlistId || null,
selectedMakemkvTitleId: selectedTitleForReview
preferredEncodeTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview,
selectedPlaylistId: allowAlternativeTitleSelection
? null
: (resolvedPlaylistId || reviewTitleInfo?.playlistId || null),
selectedMakemkvTitleId: allowAlternativeTitleSelection ? null : 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 candidateMetaBySyntheticPath = new Map(
reviewCandidatesWithPaths.map((candidate) => [candidate.syntheticFilePath, candidate])
);
const normalizedTitles = (Array.isArray(review.titles) ? review.titles : [])
.slice(0, 1)
.map((title) => {
const normalizedHandBrakeTitleId = normalizeReviewTitleId(
resolvedHandBrakeTitleId
?? title?.handBrakeTitleId
?? title?.titleIndex
?? title?.id
?? selectedTitleForReview
);
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;
const selectedByRule = Boolean(sourceMeta?.selectedByRule ?? track?.selectedByRule);
const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate);
const subtitleType = String(
sourceMeta?.subtitleType
|| track?.subtitleType
|| (sourceMeta?.forcedTrack ? 'forced' : (track?.forcedTrack ? 'forced' : 'full'))
).trim().toLowerCase() === 'forced'
? 'forced'
: 'full';
const rawConfidence = String(sourceMeta?.sourceConfidence || track?.sourceConfidence || '')
.trim()
.toLowerCase();
const confidenceSource = String(sourceMeta?.confidenceSource || track?.confidenceSource || '')
.trim()
.toLowerCase();
let sourceConfidence = null;
if (subtitleType === 'forced') {
if (rawConfidence === 'high' || rawConfidence === 'medium' || rawConfidence === 'low') {
sourceConfidence = rawConfidence;
} else if (confidenceSource === 'title') {
sourceConfidence = 'medium';
} else {
sourceConfidence = 'low';
}
}
const subtitlePreviewBurnIn = Boolean(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn);
const subtitlePreviewForced = Boolean(
sourceMeta?.subtitlePreviewForced
?? track?.subtitlePreviewForced
?? (selectedByRule && subtitleType === 'forced')
);
const subtitlePreviewForcedOnly = Boolean(
sourceMeta?.subtitlePreviewForcedOnly
?? track?.subtitlePreviewForcedOnly
);
const isForcedOnly = Boolean(
sourceMeta?.isForcedOnly
?? track?.isForcedOnly
?? sourceMeta?.forcedOnly
?? track?.forcedOnly
?? subtitlePreviewForcedOnly
?? subtitleType === 'forced'
);
const fullHasForced = !isForcedOnly && Boolean(
sourceMeta?.fullHasForced
?? track?.fullHasForced
?? sourceMeta?.subtitleFullHasForced
?? track?.subtitleFullHasForced
);
const subtitlePreviewDefaultTrack = Boolean(
sourceMeta?.subtitlePreviewDefaultTrack
?? track?.subtitlePreviewDefaultTrack
?? sourceMeta?.defaultFlag
?? track?.defaultFlag
);
const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags)
? sourceMeta.subtitlePreviewFlags
: (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []);
const subtitlePreviewSummary = String(
sourceMeta?.subtitlePreviewSummary
|| track?.subtitlePreviewSummary
|| (selectedByRule
? 'Übernehmen'
: (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'))
).trim() || 'Nicht übernommen';
const selectedForEncode = selectedByRule;
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,
defaultFlag: Boolean(sourceMeta?.defaultFlag ?? track?.defaultFlag),
eventCount: Number.isFinite(Number(sourceMeta?.eventCount))
? Math.trunc(Number(sourceMeta.eventCount))
: (Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null),
streamSizeBytes: Number.isFinite(Number(sourceMeta?.streamSizeBytes))
? Math.trunc(Number(sourceMeta.streamSizeBytes))
: (Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null),
forcedTrack: Boolean(sourceMeta?.forcedTrack ?? subtitlePreviewForced),
forcedAvailable: Boolean(sourceMeta?.forcedAvailable),
forcedSourceTrackIds: normalizeTrackIdList(sourceMeta?.forcedSourceTrackIds || []),
isForcedOnly,
fullHasForced,
subtitleType,
sourceConfidence,
confidenceSource,
duplicate,
selected: selectedByRule,
selectedByRule,
selectedForEncode,
subtitlePreviewSummary,
subtitlePreviewFlags,
subtitlePreviewBurnIn,
subtitlePreviewForced,
subtitlePreviewForcedOnly,
subtitlePreviewDefaultTrack,
burnIn: selectedForEncode ? subtitlePreviewBurnIn : false,
forced: selectedForEncode ? subtitlePreviewForced : false,
forcedOnly: selectedForEncode ? subtitlePreviewForcedOnly : false,
defaultTrack: selectedForEncode ? subtitlePreviewDefaultTrack : false,
flags: selectedForEncode ? subtitlePreviewFlags : [],
subtitleActionSummary: selectedForEncode ? subtitlePreviewSummary : 'Nicht übernommen'
};
});
const buildNormalizedReviewTitle = (title) => {
const candidateMeta = candidateMetaBySyntheticPath.get(String(title?.filePath || '').trim()) || null;
const candidateTitleInfo = candidateMeta?.titleInfo || reviewTitleInfo;
const candidatePlaylistInfo = candidateMeta?.playlistInfo || resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId);
const candidateHandBrakeTitleId = normalizeReviewTitleId(
candidateMeta?.handBrakeTitleId
?? resolvedHandBrakeTitleId
?? title?.handBrakeTitleId
?? title?.titleIndex
?? title?.id
?? selectedTitleForReview
);
const durationSeconds = Number(candidateTitleInfo?.durationSeconds || title?.durationSeconds || 0);
const eligibleForEncode = durationSeconds >= minLengthSecondsForReview;
const subtitleTrackMetaBySourceId = new Map(
(Array.isArray(candidateTitleInfo?.subtitleTracks) ? candidateTitleInfo.subtitleTracks : [])
.map((track) => {
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
return sourceTrackId ? [sourceTrackId, track] : null;
})
.filter(Boolean)
);
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;
const selectedByRule = Boolean(sourceMeta?.selectedByRule ?? track?.selectedByRule);
const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate);
const subtitleType = String(
sourceMeta?.subtitleType
|| track?.subtitleType
|| (sourceMeta?.forcedTrack ? 'forced' : (track?.forcedTrack ? 'forced' : 'full'))
).trim().toLowerCase() === 'forced'
? 'forced'
: 'full';
const rawConfidence = String(sourceMeta?.sourceConfidence || track?.sourceConfidence || '')
.trim()
.toLowerCase();
const confidenceSource = String(sourceMeta?.confidenceSource || track?.confidenceSource || '')
.trim()
.toLowerCase();
let sourceConfidence = null;
if (subtitleType === 'forced') {
if (rawConfidence === 'high' || rawConfidence === 'medium' || rawConfidence === 'low') {
sourceConfidence = rawConfidence;
} else if (confidenceSource === 'title') {
sourceConfidence = 'medium';
} else {
sourceConfidence = 'low';
}
}
const subtitlePreviewBurnIn = Boolean(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn);
const subtitlePreviewForced = Boolean(
sourceMeta?.subtitlePreviewForced
?? track?.subtitlePreviewForced
?? (selectedByRule && subtitleType === 'forced')
);
const subtitlePreviewForcedOnly = Boolean(
sourceMeta?.subtitlePreviewForcedOnly
?? track?.subtitlePreviewForcedOnly
);
const isForcedOnly = Boolean(
sourceMeta?.isForcedOnly
?? track?.isForcedOnly
?? sourceMeta?.forcedOnly
?? track?.forcedOnly
?? subtitlePreviewForcedOnly
?? subtitleType === 'forced'
);
const fullHasForced = !isForcedOnly && Boolean(
sourceMeta?.fullHasForced
?? track?.fullHasForced
?? sourceMeta?.subtitleFullHasForced
?? track?.subtitleFullHasForced
);
const subtitlePreviewDefaultTrack = Boolean(
sourceMeta?.subtitlePreviewDefaultTrack
?? track?.subtitlePreviewDefaultTrack
?? sourceMeta?.defaultFlag
?? track?.defaultFlag
);
const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags)
? sourceMeta.subtitlePreviewFlags
: (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []);
const subtitlePreviewSummary = String(
sourceMeta?.subtitlePreviewSummary
|| track?.subtitlePreviewSummary
|| (selectedByRule
? 'Übernehmen'
: (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'))
).trim() || 'Nicht übernommen';
const selectedForEncode = selectedByRule;
return {
...title,
id: normalizedHandBrakeTitleId || title?.id || null,
handBrakeTitleId: normalizedHandBrakeTitleId || null,
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
...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,
defaultFlag: Boolean(sourceMeta?.defaultFlag ?? track?.defaultFlag),
eventCount: Number.isFinite(Number(sourceMeta?.eventCount))
? Math.trunc(Number(sourceMeta.eventCount))
: (Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null),
streamSizeBytes: Number.isFinite(Number(sourceMeta?.streamSizeBytes))
? Math.trunc(Number(sourceMeta.streamSizeBytes))
: (Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null),
forcedTrack: Boolean(sourceMeta?.forcedTrack ?? subtitlePreviewForced),
forcedAvailable: Boolean(sourceMeta?.forcedAvailable),
forcedSourceTrackIds: normalizeTrackIdList(sourceMeta?.forcedSourceTrackIds || []),
isForcedOnly,
fullHasForced,
subtitleType,
sourceConfidence,
confidenceSource,
duplicate,
selected: selectedByRule,
selectedByRule,
selectedForEncode,
subtitlePreviewSummary,
subtitlePreviewFlags,
subtitlePreviewBurnIn,
subtitlePreviewForced,
subtitlePreviewForcedOnly,
subtitlePreviewDefaultTrack,
burnIn: selectedForEncode ? subtitlePreviewBurnIn : false,
forced: selectedForEncode ? subtitlePreviewForced : false,
forcedOnly: selectedForEncode ? subtitlePreviewForcedOnly : false,
defaultTrack: selectedForEncode ? subtitlePreviewDefaultTrack : false,
flags: selectedForEncode ? subtitlePreviewFlags : [],
subtitleActionSummary: selectedForEncode ? subtitlePreviewSummary : 'Nicht übernommen'
};
});
const encodeInputTitleId = Number(normalizedTitles[0]?.id || review.encodeInputTitleId || null) || null;
return {
...title,
id: candidateHandBrakeTitleId || title?.id || null,
handBrakeTitleId: candidateHandBrakeTitleId || null,
filePath: rawPath,
fileName: candidateTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`,
durationSeconds,
durationMinutes: Number((durationSeconds / 60).toFixed(2)),
selectedByMinLength: eligibleForEncode,
eligibleForEncode,
sizeBytes: Number(candidateTitleInfo?.sizeBytes || title?.sizeBytes || 0),
playlistId: candidatePlaylistInfo.playlistId || title?.playlistId || null,
playlistFile: candidatePlaylistInfo.playlistFile || title?.playlistFile || null,
playlistAliases: Array.isArray(candidatePlaylistInfo.playlistAliases) ? candidatePlaylistInfo.playlistAliases : [],
playlistRecommended: Boolean(candidatePlaylistInfo.recommended || title?.playlistRecommended),
playlistEvaluationLabel: candidatePlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null,
playlistSegmentCommand: candidatePlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null,
playlistSegmentFiles: Array.isArray(candidatePlaylistInfo.segmentFiles) && candidatePlaylistInfo.segmentFiles.length > 0
? candidatePlaylistInfo.segmentFiles
: (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []),
subtitleTracks
};
};
const normalizedTitles = (Array.isArray(review.titles) ? review.titles : [])
.map((title) => buildNormalizedReviewTitle(title))
.filter((title) => normalizeReviewTitleId(title?.id));
const encodeInputTitleId = normalizeReviewTitleId(
normalizedTitles.find((title) => Boolean(title?.playlistId && normalizePlaylistId(title.playlistId) === normalizePlaylistId(resolvedPlaylistId)))?.id
|| normalizedTitles.find((title) => normalizeReviewTitleId(title?.handBrakeTitleId) === normalizeReviewTitleId(resolvedHandBrakeTitleId))?.id
|| normalizedTitles[0]?.id
|| review?.encodeInputTitleId
|| null
);
review = {
...review,
mode,
@@ -19644,8 +19922,8 @@ class PipelineService extends EventEmitter {
sourceJobId: options.sourceJobId || null,
reviewConfirmed: false,
partial: false,
processedFiles: 1,
totalFiles: 1,
processedFiles: normalizedTitles.length,
totalFiles: normalizedTitles.length,
handBrakeTitleId: resolvedHandBrakeTitleId || null,
selectedPlaylistId: resolvedPlaylistId || null,
selectedMakemkvTitleId: selectedTitleForReview,
@@ -19654,12 +19932,26 @@ class PipelineService extends EventEmitter {
selectedTitleIds: encodeInputTitleId ? [encodeInputTitleId] : [],
encodeInputTitleId,
encodeInputPath: rawPath,
alternativeTitleSelection: normalizedTitles.length > 1
? {
enabled: true,
reason: 'same_or_longer_titles',
matchedTitleId: encodeInputTitleId || null,
matchedPlaylistId: normalizePlaylistId(resolvedPlaylistId) || null,
matchedDurationSeconds: Number(reviewTitleInfo?.durationSeconds || 0) || 0,
optionCount: normalizedTitles.length
}
: null,
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).`
`HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).`,
...(normalizedTitles.length > 1
? ['Weitere gleichlange oder längere Titel wurden gefunden. In der Review kann zwischen alternativen Schnittfassungen/Playlist-Varianten umgeschaltet werden.']
: [])
]
};
review = applyEncodeTitleSelectionToPlan(review, encodeInputTitleId, encodeInputTitleId ? [encodeInputTitleId] : []).plan;
const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan(
review,
@@ -25796,6 +26088,14 @@ class PipelineService extends EventEmitter {
error.statusCode = 400;
throw error;
}
const primaryPlanTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId);
const primaryPlanTitle = Array.isArray(encodePlan?.titles)
? (
encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === primaryPlanTitleId)
|| encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput))
|| null
)
: null;
let handBrakeTitleId = null;
let directoryInput = false;
try {
@@ -25807,7 +26107,12 @@ class PipelineService extends EventEmitter {
handBrakeTitleId = null;
}
if (directoryInput) {
const reviewMappedTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId);
const reviewMappedTitleId = normalizeReviewTitleId(
primaryPlanTitle?.handBrakeTitleId
?? primaryPlanTitle?.titleIndex
?? encodePlan?.handBrakeTitleId
?? null
);
if (reviewMappedTitleId) {
handBrakeTitleId = reviewMappedTitleId;
await historyService.appendLog(
@@ -25967,14 +26272,6 @@ class PipelineService extends EventEmitter {
}
}
if (!handBrakeTitleId) {
const primaryPlanTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId);
const primaryPlanTitle = Array.isArray(encodePlan?.titles)
? (
encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === primaryPlanTitleId)
|| encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput))
|| null
)
: null;
const primaryTitleMappedHandBrakeId = normalizeReviewTitleId(
primaryPlanTitle?.handBrakeTitleId
?? primaryPlanTitle?.titleIndex
+23
View File
@@ -0,0 +1,23 @@
function padNumber(value, length = 2) {
return String(value).padStart(length, '0');
}
function formatTimezoneOffset(offsetMinutes) {
const sign = offsetMinutes >= 0 ? '+' : '-';
const absoluteMinutes = Math.abs(offsetMinutes);
const hours = Math.floor(absoluteMinutes / 60);
const minutes = absoluteMinutes % 60;
return `${sign}${padNumber(hours)}:${padNumber(minutes)}`;
}
function getServerTimestamp(input = new Date()) {
const date = input instanceof Date ? input : new Date(input);
return `${date.getFullYear()}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}`
+ `T${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}`
+ `.${padNumber(date.getMilliseconds(), 3)}${formatTimezoneOffset(-date.getTimezoneOffset())}`;
}
module.exports = {
getServerTimestamp
};