1.0.0-rc4 rc4
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0-rc3",
|
"version": "1.0.0-rc4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0-rc3",
|
"version": "1.0.0-rc4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0-rc3",
|
"version": "1.0.0-rc4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+37
-2
@@ -24,11 +24,20 @@ const diskDetectionService = require('./services/diskDetectionService');
|
|||||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||||
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
|
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
|
||||||
const tempCleanupService = require('./services/tempCleanupService');
|
const tempCleanupService = require('./services/tempCleanupService');
|
||||||
const { ensureCurrentBetaKeyCacheOnStartup } = require('./services/makemkvKeyService');
|
const { fetchCurrentBetaKey } = require('./services/makemkvKeyService');
|
||||||
const logger = require('./services/logger').child('BOOT');
|
const logger = require('./services/logger').child('BOOT');
|
||||||
const { errorToMeta } = require('./utils/errorMeta');
|
const { errorToMeta } = require('./utils/errorMeta');
|
||||||
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
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() {
|
async function start() {
|
||||||
logger.info('backend:start:init');
|
logger.info('backend:start:init');
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
@@ -38,7 +47,32 @@ async function start() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(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 tempCleanupService.init();
|
||||||
await pipelineService.init();
|
await pipelineService.init();
|
||||||
await converterScanService.startPolling();
|
await converterScanService.startPolling();
|
||||||
@@ -107,6 +141,7 @@ async function start() {
|
|||||||
hardwareMonitorService.stop();
|
hardwareMonitorService.stop();
|
||||||
cronService.stop();
|
cronService.stop();
|
||||||
tempCleanupService.stop();
|
tempCleanupService.stop();
|
||||||
|
clearTimeout(betaKeyRefreshTimer);
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.warn('backend:shutdown:completed');
|
logger.warn('backend:shutdown:completed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const userPresetDefaultsService = require('../services/userPresetDefaultsService
|
|||||||
const activationBytesService = require('../services/activationBytesService');
|
const activationBytesService = require('../services/activationBytesService');
|
||||||
const diskDetectionService = require('../services/diskDetectionService');
|
const diskDetectionService = require('../services/diskDetectionService');
|
||||||
const coverArtRecoveryService = require('../services/coverArtRecoveryService');
|
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 logger = require('../services/logger').child('SETTINGS_ROUTE');
|
||||||
|
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
@@ -58,39 +58,36 @@ router.get(
|
|||||||
'/makemkv/beta-key',
|
'/makemkv/beta-key',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId });
|
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 currentSettings = await settingsService.getSettingsMap({ forceRefresh: false });
|
||||||
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
|
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
|
||||||
logger.warn('get:settings:makemkv:beta-key:fallback-local', {
|
const cached = await getCachedBetaKey({ allowExpired: true });
|
||||||
reqId: req.reqId,
|
|
||||||
status,
|
|
||||||
hasExistingKey: Boolean(existingKey),
|
|
||||||
retryAt: error?.retryAt || null
|
|
||||||
});
|
|
||||||
res.json({
|
res.json({
|
||||||
betaKey: existingKey,
|
betaKey: cached?.key || '',
|
||||||
sourceUrl: null,
|
sourceUrl: cached?.sourceUrl || null,
|
||||||
validUntil: null,
|
validUntil: cached?.validUntil || null,
|
||||||
cached: Boolean(existingKey),
|
fetchedAt: cached?.fetchedAt || null,
|
||||||
stale: true,
|
cached: cached?.cached === true,
|
||||||
warning: error?.message || 'Betakey-API aktuell nicht erreichbar.'
|
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
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,35 +95,26 @@ router.post(
|
|||||||
'/makemkv/beta-key/apply',
|
'/makemkv/beta-key/apply',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
logger.info('post:settings:makemkv:beta-key:apply', { reqId: req.reqId });
|
logger.info('post:settings:makemkv:beta-key:apply', { reqId: req.reqId });
|
||||||
const force = req.body?.force === true;
|
const requestedKey = String(req.body?.betaKey || '').trim();
|
||||||
const currentSettings = await settingsService.getSettingsMap({ forceRefresh: true });
|
const cachedResult = requestedKey
|
||||||
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
|
? { key: requestedKey }
|
||||||
const betaKeyResult = await fetchCurrentBetaKey();
|
: await getCachedBetaKey({ allowExpired: true });
|
||||||
|
const betaKey = String(cachedResult?.key || '').trim();
|
||||||
|
|
||||||
if (!force && existingKey && existingKey !== betaKeyResult.key) {
|
if (!betaKey) {
|
||||||
const syncResult = await settingsService.syncMakeMKVRegistrationKeyFromSettings({
|
const error = new Error('Kein geprüfter Betakey vorhanden. Bitte zuerst prüfen.');
|
||||||
settingsMap: currentSettings
|
error.statusCode = 400;
|
||||||
});
|
throw error;
|
||||||
res.json({
|
|
||||||
applied: false,
|
|
||||||
preservedExistingKey: true,
|
|
||||||
reason: 'existing_key',
|
|
||||||
betaKey: betaKeyResult.key,
|
|
||||||
sourceUrl: betaKeyResult.sourceUrl,
|
|
||||||
settingsFilePath: syncResult?.path || null
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKeyResult.key);
|
const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKey);
|
||||||
wsService.broadcast('SETTINGS_UPDATED', updated);
|
wsService.broadcast('SETTINGS_UPDATED', updated);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
applied: true,
|
applied: true,
|
||||||
preservedExistingKey: false,
|
|
||||||
setting: updated,
|
setting: updated,
|
||||||
betaKey: betaKeyResult.key,
|
betaKey,
|
||||||
sourceUrl: betaKeyResult.sourceUrl
|
sourceUrl: String(cachedResult?.sourceUrl || '').trim() || null
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const tmdbService = require('./tmdbService');
|
|||||||
const cdRipService = require('./cdRipService');
|
const cdRipService = require('./cdRipService');
|
||||||
const { getJobLogDir } = require('./logPathService');
|
const { getJobLogDir } = require('./logPathService');
|
||||||
const thumbnailService = require('./thumbnailService');
|
const thumbnailService = require('./thumbnailService');
|
||||||
|
const { getServerTimestamp } = require('../utils/serverTime');
|
||||||
|
|
||||||
function parseJsonSafe(raw, fallback = null) {
|
function parseJsonSafe(raw, fallback = null) {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -3542,7 +3543,7 @@ class HistoryService {
|
|||||||
});
|
});
|
||||||
processLogStreams.set(streamKey, stream);
|
processLogStreams.set(streamKey, stream);
|
||||||
}
|
}
|
||||||
const line = `[${new Date().toISOString()}] [${source}] ${String(message || '')}\n`;
|
const line = `[${getServerTimestamp()}] [${source}] ${String(message || '')}\n`;
|
||||||
stream.write(line);
|
stream.write(line);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('job:process-log:append-failed', {
|
logger.warn('job:process-log:append-failed', {
|
||||||
@@ -3869,7 +3870,7 @@ class HistoryService {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
lines.push(
|
lines.push(
|
||||||
`[${new Date().toISOString()}] [SYSTEM] Importierte vorhandene Job-Logs: ${fileSummary}`
|
`[${getServerTimestamp()}] [SYSTEM] Importierte vorhandene Job-Logs: ${fileSummary}`
|
||||||
);
|
);
|
||||||
for (const source of normalizedSources) {
|
for (const source of normalizedSources) {
|
||||||
lines.push(...source.lines);
|
lines.push(...source.lines);
|
||||||
@@ -3877,7 +3878,7 @@ class HistoryService {
|
|||||||
}
|
}
|
||||||
if (importInfo) {
|
if (importInfo) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`[${new Date().toISOString()}] [SYSTEM] Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(importInfo)}`
|
`[${getServerTimestamp()}] [SYSTEM] Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(importInfo)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { logLevel } = require('../config');
|
const { logLevel } = require('../config');
|
||||||
const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService');
|
const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService');
|
||||||
|
const { getServerTimestamp } = require('../utils/serverTime');
|
||||||
|
|
||||||
const LEVELS = {
|
const LEVELS = {
|
||||||
debug: 10,
|
debug: 10,
|
||||||
@@ -123,7 +124,7 @@ function emit(level, scope, message, meta = null) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = getServerTimestamp();
|
||||||
const payload = {
|
const payload = {
|
||||||
timestamp,
|
timestamp,
|
||||||
level: normLevel,
|
level: normLevel,
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ const logger = require('./logger').child('MAKEMKV_KEY');
|
|||||||
const backendPackage = require('../../package.json');
|
const backendPackage = require('../../package.json');
|
||||||
|
|
||||||
const MAKEMKV_BETA_KEY_API_URL = 'https://cable.ayra.ch/makemkv/api.php?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_CACHE_PREF_KEY = 'makemkv_beta_key_cache_v1';
|
||||||
const MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS = 1000;
|
const MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS = 1000;
|
||||||
const MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS = Math.max(
|
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 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
|
const MAKEMKV_BETA_KEY_API_USER_AGENT = process.env.MAKEMKV_BETA_KEY_API_USER_AGENT
|
||||||
|| DEFAULT_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(
|
const MAKEMKV_BETA_KEY_API_TIMEOUT_MS = Math.max(
|
||||||
1000,
|
1000,
|
||||||
Number(process.env.MAKEMKV_BETA_KEY_API_TIMEOUT_MS || 15000)
|
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(
|
const MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS = Math.max(
|
||||||
30 * 1000,
|
30 * 1000,
|
||||||
Number(process.env.MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS || (15 * 60 * 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 cachedBetaKeyEntry = null;
|
||||||
let blockedUntilMs = 0;
|
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.') {
|
function parseBetaKeyCandidate(value, invalidMessage = 'Betakey-Antwort ist ungültig.') {
|
||||||
const betaKey = String(value || '').trim();
|
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);
|
const error = new Error(invalidMessage);
|
||||||
error.statusCode = 502;
|
error.statusCode = 502;
|
||||||
throw error;
|
throw error;
|
||||||
@@ -84,83 +40,6 @@ function parseBetaKeyCandidate(value, invalidMessage = 'Betakey-Antwort ist ung
|
|||||||
return betaKey;
|
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(/ /gi, ' ')
|
|
||||||
.replace(/ /g, ' ')
|
|
||||||
.replace(/&/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) {
|
function normalizeCacheEntry(rawEntry) {
|
||||||
if (!rawEntry || typeof rawEntry !== 'object') {
|
if (!rawEntry || typeof rawEntry !== 'object') {
|
||||||
@@ -174,7 +53,7 @@ function normalizeCacheEntry(rawEntry) {
|
|||||||
return null;
|
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 validUntilIsoRaw = String(rawEntry.validUntil || rawEntry.validUntilIso || '').trim();
|
||||||
const validUntilMs = Date.parse(validUntilIsoRaw);
|
const validUntilMs = Date.parse(validUntilIsoRaw);
|
||||||
|
|
||||||
@@ -211,6 +90,7 @@ function buildPublicResult(entry, options = {}) {
|
|||||||
key: entry.key,
|
key: entry.key,
|
||||||
sourceUrl: entry.sourceUrl,
|
sourceUrl: entry.sourceUrl,
|
||||||
validUntil: entry.validUntil,
|
validUntil: entry.validUntil,
|
||||||
|
fetchedAt: entry.fetchedAt || null,
|
||||||
cached: options.cached === true,
|
cached: options.cached === true,
|
||||||
stale: options.stale === true
|
stale: options.stale === true
|
||||||
};
|
};
|
||||||
@@ -273,6 +153,31 @@ async function tryUsePersistentCache(nowMs = Date.now()) {
|
|||||||
return buildPublicResult(persistent, { cached: true, stale: false });
|
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) {
|
function createRateLimitError(retryAfterMs = 0) {
|
||||||
const parsedRetryMs = Number(retryAfterMs || 0);
|
const parsedRetryMs = Number(retryAfterMs || 0);
|
||||||
const waitMs = parsedRetryMs > 0 ? parsedRetryMs : MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS;
|
const waitMs = parsedRetryMs > 0 ? parsedRetryMs : MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS;
|
||||||
@@ -296,11 +201,6 @@ function buildExistingBackoffError() {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldSkipFallback(error) {
|
|
||||||
const status = Number(error?.httpStatus || error?.status || 0);
|
|
||||||
return status >= 400 && status < 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveValidityOrFallback(validUntilIso, sourceUrl) {
|
function resolveValidityOrFallback(validUntilIso, sourceUrl) {
|
||||||
const parsedMs = Date.parse(String(validUntilIso || '').trim());
|
const parsedMs = Date.parse(String(validUntilIso || '').trim());
|
||||||
if (Number.isFinite(parsedMs)) {
|
if (Number.isFinite(parsedMs)) {
|
||||||
@@ -413,71 +313,13 @@ async function fetchCurrentBetaKey(options = {}) {
|
|||||||
throw buildExistingBackoffError();
|
throw buildExistingBackoffError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const errors = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const forumResult = await fetchCurrentBetaKeyViaForum();
|
const apiResult = await fetchCurrentBetaKeyViaCurl();
|
||||||
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 normalized = normalizeCacheEntry({
|
const normalized = normalizeCacheEntry({
|
||||||
...apiResult,
|
...apiResult,
|
||||||
fetchedAt: new Date().toISOString()
|
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) {
|
if (!normalized) {
|
||||||
const error = new Error('API-curl-Betakey konnte nicht normalisiert werden.');
|
const error = new Error('API-curl-Betakey konnte nicht normalisiert werden.');
|
||||||
error.statusCode = 502;
|
error.statusCode = 502;
|
||||||
@@ -489,33 +331,15 @@ async function fetchCurrentBetaKey(options = {}) {
|
|||||||
await persistCacheEntry(normalized);
|
await persistCacheEntry(normalized);
|
||||||
return buildPublicResult(normalized, { cached: false, stale: false });
|
return buildPublicResult(normalized, { cached: false, stale: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.push(error?.message || String(error));
|
logger.warn('beta-key:fetch-failed', {
|
||||||
logger.warn('beta-key:curl-failed', {
|
|
||||||
error: error?.message || String(error)
|
error: error?.message || String(error)
|
||||||
});
|
});
|
||||||
|
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)}`);
|
||||||
const finalError = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`);
|
resolved.statusCode = Number(error?.httpStatus || error?.statusCode || error?.status || 502) || 502;
|
||||||
finalError.statusCode = 502;
|
throw resolved;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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() {
|
function fetchCurrentBetaKeyViaCurl() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const curlArgs = [
|
const curlArgs = [
|
||||||
'-fsSL',
|
'-fsSL',
|
||||||
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
|
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
|
||||||
'-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
|
'-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
|
||||||
'-H', 'Accept: text/plain, text/html;q=0.9, */*;q=0.8',
|
MAKEMKV_BETA_KEY_API_URL
|
||||||
'-H', 'Accept-Language: de,en-US;q=0.7,en;q=0.3',
|
|
||||||
'-H', 'Cache-Control: no-cache',
|
|
||||||
'-H', 'Pragma: no-cache'
|
|
||||||
];
|
];
|
||||||
const fromHeader = String(MAKEMKV_BETA_KEY_API_FROM || '').trim();
|
|
||||||
if (fromHeader) {
|
|
||||||
curlArgs.push('-H', `From: ${fromHeader}`);
|
|
||||||
}
|
|
||||||
curlArgs.push(MAKEMKV_BETA_KEY_API_URL);
|
|
||||||
|
|
||||||
execFile(
|
execFile(
|
||||||
'curl',
|
'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 = {
|
module.exports = {
|
||||||
MAKEMKV_BETA_KEY_API_URL,
|
MAKEMKV_BETA_KEY_API_URL,
|
||||||
MAKEMKV_BETA_KEY_FORUM_URL,
|
|
||||||
fetchCurrentBetaKey,
|
fetchCurrentBetaKey,
|
||||||
ensureCurrentBetaKeyCacheOnStartup,
|
getCachedBetaKey,
|
||||||
getMakeMKVConfigDir,
|
getMakeMKVConfigDir,
|
||||||
getMakeMKVSettingsFilePath,
|
getMakeMKVSettingsFilePath,
|
||||||
normalizeRegistrationKey,
|
normalizeRegistrationKey,
|
||||||
|
|||||||
@@ -6377,6 +6377,133 @@ function buildSelectedHandBrakeReviewTitles(scanJson, selectedHandBrakeTitleIds
|
|||||||
return titles;
|
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) {
|
function pickTitleIdForTrackReview(playlistAnalysis, selectedTitleId = null) {
|
||||||
const explicit = normalizeNonNegativeInteger(selectedTitleId);
|
const explicit = normalizeNonNegativeInteger(selectedTitleId);
|
||||||
if (explicit !== null) {
|
if (explicit !== null) {
|
||||||
@@ -8116,6 +8243,23 @@ function applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId, sele
|
|||||||
selectedTitleIds: normalizedTitleIds,
|
selectedTitleIds: normalizedTitleIds,
|
||||||
encodeInputTitleId: effectivePrimaryTitleId,
|
encodeInputTitleId: effectivePrimaryTitleId,
|
||||||
encodeInputPath: selectedTitle?.filePath || null,
|
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
|
titleSelectionRequired: false
|
||||||
},
|
},
|
||||||
selectedTitle,
|
selectedTitle,
|
||||||
@@ -9348,6 +9492,9 @@ function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) {
|
|||||||
if (reviewTitles.length === 0) {
|
if (reviewTitles.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (Boolean(reviewPlan?.alternativeTitleSelection?.enabled)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
|
const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
|
||||||
if (!previousSelectedTitle) {
|
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, {
|
await historyService.updateJob(jobId, {
|
||||||
status: 'ERROR',
|
status: 'ERROR',
|
||||||
last_state: stage,
|
last_state: stage,
|
||||||
@@ -18502,14 +18706,25 @@ class PipelineService extends EventEmitter {
|
|||||||
&& !selectedPlaylistId
|
&& !selectedPlaylistId
|
||||||
&& playlistCandidates.length > 0
|
&& playlistCandidates.length > 0
|
||||||
);
|
);
|
||||||
if (shouldPrepareHandBrakeDecisionData) {
|
const shouldPrepareAlternativeFeatureReviewData = Boolean(
|
||||||
|
selectedPlaylistId
|
||||||
|
&& !isSeriesBackupReview
|
||||||
|
&& playlistCandidates.length > 1
|
||||||
|
);
|
||||||
|
const shouldPrepareExpandedHandBrakePlaylistData = (
|
||||||
|
shouldPrepareHandBrakeDecisionData
|
||||||
|
|| shouldPrepareAlternativeFeatureReviewData
|
||||||
|
);
|
||||||
|
if (shouldPrepareExpandedHandBrakePlaylistData) {
|
||||||
const hasCompleteCache = hasCachedHandBrakeDataForPlaylistCandidates(handBrakePlaylistScan, playlistCandidates);
|
const hasCompleteCache = hasCachedHandBrakeDataForPlaylistCandidates(handBrakePlaylistScan, playlistCandidates);
|
||||||
if (!hasCompleteCache) {
|
if (!hasCompleteCache) {
|
||||||
await this.updateProgress(
|
await this.updateProgress(
|
||||||
'MEDIAINFO_CHECK',
|
'MEDIAINFO_CHECK',
|
||||||
25,
|
25,
|
||||||
null,
|
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
|
jobId
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -18540,13 +18755,17 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
'SYSTEM',
|
'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 {
|
} else {
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
'SYSTEM',
|
'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 {
|
} else {
|
||||||
@@ -18562,7 +18781,9 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
'SYSTEM',
|
'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;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -18572,7 +18793,9 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
'SYSTEM',
|
'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,53 +19678,97 @@ class PipelineService extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const syntheticFilePath = path.join(
|
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,
|
rawPath,
|
||||||
reviewTitleSource === 'handbrake' && Number.isFinite(Number(resolvedHandBrakeTitleId)) && Number(resolvedHandBrakeTitleId) > 0
|
reviewTitleSource === 'handbrake' && Number.isFinite(Number(handBrakeTitleId)) && Number(handBrakeTitleId) > 0
|
||||||
? `handbrake_t${String(Math.trunc(Number(resolvedHandBrakeTitleId))).padStart(2, '0')}.mkv`
|
? `handbrake_t${String(Math.trunc(Number(handBrakeTitleId))).padStart(2, '0')}.mkv`
|
||||||
: `makemkv_t${String(selectedTitleForReview).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({
|
let review = buildMediainfoReview({
|
||||||
mediaFiles: [{
|
mediaFiles: reviewCandidatesWithPaths.map((candidate) => ({
|
||||||
path: syntheticFilePath,
|
path: candidate.syntheticFilePath,
|
||||||
size: Number(reviewTitleInfo?.sizeBytes || 0)
|
size: Number(candidate?.titleInfo?.sizeBytes || 0)
|
||||||
}],
|
})),
|
||||||
mediaInfoByPath: syntheticMediaInfoByPath,
|
mediaInfoByPath: syntheticMediaInfoByPath,
|
||||||
settings,
|
settings,
|
||||||
presetProfile,
|
presetProfile,
|
||||||
playlistAnalysis,
|
playlistAnalysis,
|
||||||
preferredEncodeTitleId: selectedTitleForReview,
|
preferredEncodeTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview,
|
||||||
selectedPlaylistId: resolvedPlaylistId || reviewTitleInfo?.playlistId || null,
|
selectedPlaylistId: allowAlternativeTitleSelection
|
||||||
selectedMakemkvTitleId: selectedTitleForReview
|
? null
|
||||||
|
: (resolvedPlaylistId || reviewTitleInfo?.playlistId || null),
|
||||||
|
selectedMakemkvTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview
|
||||||
});
|
});
|
||||||
review = remapReviewTrackIdsToSourceIds(review);
|
review = remapReviewTrackIdsToSourceIds(review);
|
||||||
|
|
||||||
const resolvedPlaylistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId);
|
|
||||||
const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0);
|
const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0);
|
||||||
const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60));
|
const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60));
|
||||||
|
const candidateMetaBySyntheticPath = new Map(
|
||||||
|
reviewCandidatesWithPaths.map((candidate) => [candidate.syntheticFilePath, candidate])
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
const subtitleTrackMetaBySourceId = new Map(
|
||||||
(Array.isArray(reviewTitleInfo?.subtitleTracks) ? reviewTitleInfo.subtitleTracks : [])
|
(Array.isArray(candidateTitleInfo?.subtitleTracks) ? candidateTitleInfo.subtitleTracks : [])
|
||||||
.map((track) => {
|
.map((track) => {
|
||||||
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
|
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
|
||||||
return sourceTrackId ? [sourceTrackId, track] : null;
|
return sourceTrackId ? [sourceTrackId, track] : null;
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
);
|
);
|
||||||
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 subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
|
||||||
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
|
const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
|
||||||
const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
|
const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
|
||||||
@@ -19615,28 +19882,39 @@ class PipelineService extends EventEmitter {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...title,
|
...title,
|
||||||
id: normalizedHandBrakeTitleId || title?.id || null,
|
id: candidateHandBrakeTitleId || title?.id || null,
|
||||||
handBrakeTitleId: normalizedHandBrakeTitleId || null,
|
handBrakeTitleId: candidateHandBrakeTitleId || null,
|
||||||
filePath: rawPath,
|
filePath: rawPath,
|
||||||
fileName: reviewTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`,
|
fileName: candidateTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
durationMinutes: Number(((durationSeconds / 60)).toFixed(2)),
|
durationMinutes: Number((durationSeconds / 60).toFixed(2)),
|
||||||
selectedByMinLength: eligibleForEncode,
|
selectedByMinLength: eligibleForEncode,
|
||||||
eligibleForEncode,
|
eligibleForEncode,
|
||||||
sizeBytes: Number(reviewTitleInfo?.sizeBytes || title?.sizeBytes || 0),
|
sizeBytes: Number(candidateTitleInfo?.sizeBytes || title?.sizeBytes || 0),
|
||||||
playlistId: resolvedPlaylistInfo.playlistId || title?.playlistId || null,
|
playlistId: candidatePlaylistInfo.playlistId || title?.playlistId || null,
|
||||||
playlistFile: resolvedPlaylistInfo.playlistFile || title?.playlistFile || null,
|
playlistFile: candidatePlaylistInfo.playlistFile || title?.playlistFile || null,
|
||||||
playlistRecommended: Boolean(resolvedPlaylistInfo.recommended || title?.playlistRecommended),
|
playlistAliases: Array.isArray(candidatePlaylistInfo.playlistAliases) ? candidatePlaylistInfo.playlistAliases : [],
|
||||||
playlistEvaluationLabel: resolvedPlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null,
|
playlistRecommended: Boolean(candidatePlaylistInfo.recommended || title?.playlistRecommended),
|
||||||
playlistSegmentCommand: resolvedPlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null,
|
playlistEvaluationLabel: candidatePlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null,
|
||||||
playlistSegmentFiles: Array.isArray(resolvedPlaylistInfo.segmentFiles) && resolvedPlaylistInfo.segmentFiles.length > 0
|
playlistSegmentCommand: candidatePlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null,
|
||||||
? resolvedPlaylistInfo.segmentFiles
|
playlistSegmentFiles: Array.isArray(candidatePlaylistInfo.segmentFiles) && candidatePlaylistInfo.segmentFiles.length > 0
|
||||||
|
? candidatePlaylistInfo.segmentFiles
|
||||||
: (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []),
|
: (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []),
|
||||||
subtitleTracks
|
subtitleTracks
|
||||||
};
|
};
|
||||||
});
|
};
|
||||||
|
|
||||||
const encodeInputTitleId = Number(normalizedTitles[0]?.id || review.encodeInputTitleId || null) || null;
|
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 = {
|
||||||
...review,
|
...review,
|
||||||
mode,
|
mode,
|
||||||
@@ -19644,8 +19922,8 @@ class PipelineService extends EventEmitter {
|
|||||||
sourceJobId: options.sourceJobId || null,
|
sourceJobId: options.sourceJobId || null,
|
||||||
reviewConfirmed: false,
|
reviewConfirmed: false,
|
||||||
partial: false,
|
partial: false,
|
||||||
processedFiles: 1,
|
processedFiles: normalizedTitles.length,
|
||||||
totalFiles: 1,
|
totalFiles: normalizedTitles.length,
|
||||||
handBrakeTitleId: resolvedHandBrakeTitleId || null,
|
handBrakeTitleId: resolvedHandBrakeTitleId || null,
|
||||||
selectedPlaylistId: resolvedPlaylistId || null,
|
selectedPlaylistId: resolvedPlaylistId || null,
|
||||||
selectedMakemkvTitleId: selectedTitleForReview,
|
selectedMakemkvTitleId: selectedTitleForReview,
|
||||||
@@ -19654,12 +19932,26 @@ class PipelineService extends EventEmitter {
|
|||||||
selectedTitleIds: encodeInputTitleId ? [encodeInputTitleId] : [],
|
selectedTitleIds: encodeInputTitleId ? [encodeInputTitleId] : [],
|
||||||
encodeInputTitleId,
|
encodeInputTitleId,
|
||||||
encodeInputPath: rawPath,
|
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: [
|
notes: [
|
||||||
...(Array.isArray(review.notes) ? review.notes : []),
|
...(Array.isArray(review.notes) ? review.notes : []),
|
||||||
'MakeMKV Full-Analyse wurde einmal für Playlist-/Titel-Mapping verwendet.',
|
'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(
|
const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan(
|
||||||
review,
|
review,
|
||||||
@@ -25796,6 +26088,14 @@ class PipelineService extends EventEmitter {
|
|||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
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 handBrakeTitleId = null;
|
||||||
let directoryInput = false;
|
let directoryInput = false;
|
||||||
try {
|
try {
|
||||||
@@ -25807,7 +26107,12 @@ class PipelineService extends EventEmitter {
|
|||||||
handBrakeTitleId = null;
|
handBrakeTitleId = null;
|
||||||
}
|
}
|
||||||
if (directoryInput) {
|
if (directoryInput) {
|
||||||
const reviewMappedTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId);
|
const reviewMappedTitleId = normalizeReviewTitleId(
|
||||||
|
primaryPlanTitle?.handBrakeTitleId
|
||||||
|
?? primaryPlanTitle?.titleIndex
|
||||||
|
?? encodePlan?.handBrakeTitleId
|
||||||
|
?? null
|
||||||
|
);
|
||||||
if (reviewMappedTitleId) {
|
if (reviewMappedTitleId) {
|
||||||
handBrakeTitleId = reviewMappedTitleId;
|
handBrakeTitleId = reviewMappedTitleId;
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
@@ -25967,14 +26272,6 @@ class PipelineService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!handBrakeTitleId) {
|
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(
|
const primaryTitleMappedHandBrakeId = normalizeReviewTitleId(
|
||||||
primaryPlanTitle?.handBrakeTitleId
|
primaryPlanTitle?.handBrakeTitleId
|
||||||
?? primaryPlanTitle?.titleIndex
|
?? primaryPlanTitle?.titleIndex
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0-rc3",
|
"version": "1.0.0-rc4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0-rc3",
|
"version": "1.0.0-rc4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0-rc3",
|
"version": "1.0.0-rc4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+23
-2
@@ -65,6 +65,14 @@ function isTerminalStage(value) {
|
|||||||
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
|
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isInteractiveReviewStage(value) {
|
||||||
|
const normalized = normalizeStage(value);
|
||||||
|
return normalized === 'METADATA_LOOKUP'
|
||||||
|
|| normalized === 'METADATA_SELECTION'
|
||||||
|
|| normalized === 'WAITING_FOR_USER_DECISION'
|
||||||
|
|| normalized === 'READY_TO_ENCODE';
|
||||||
|
}
|
||||||
|
|
||||||
function parseTimestamp(value) {
|
function parseTimestamp(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -209,8 +217,9 @@ function App() {
|
|||||||
prevCdDrivesRef.current = current;
|
prevCdDrivesRef.current = current;
|
||||||
}, [pipeline?.cdDrives]);
|
}, [pipeline?.cdDrives]);
|
||||||
|
|
||||||
// Trigger a jobs refresh when a tracked job transitions into a terminal
|
// Trigger a jobs refresh when a tracked job transitions into a terminal or
|
||||||
// state through PIPELINE_PROGRESS, so Ripper/History update immediately.
|
// interactive review state through PIPELINE_PROGRESS, so Ripper/History
|
||||||
|
// update immediately without a manual page reload.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const current = pipeline?.jobProgress && typeof pipeline.jobProgress === 'object'
|
const current = pipeline?.jobProgress && typeof pipeline.jobProgress === 'object'
|
||||||
? pipeline.jobProgress
|
? pipeline.jobProgress
|
||||||
@@ -233,6 +242,13 @@ function App() {
|
|||||||
) {
|
) {
|
||||||
shouldRefresh = true;
|
shouldRefresh = true;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
currentState
|
||||||
|
&& isInteractiveReviewStage(currentState)
|
||||||
|
&& currentState !== previousState
|
||||||
|
) {
|
||||||
|
shouldRefresh = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prevJobProgressStatesRef.current = nextStates;
|
prevJobProgressStatesRef.current = nextStates;
|
||||||
@@ -394,6 +410,11 @@ function App() {
|
|||||||
|
|
||||||
if (message.type === 'PIPELINE_STATE_CHANGED') {
|
if (message.type === 'PIPELINE_STATE_CHANGED') {
|
||||||
setPipeline((prev) => mergePipelineStatePreservingNewestQueue(prev, message.payload));
|
setPipeline((prev) => mergePipelineStatePreservingNewestQueue(prev, message.payload));
|
||||||
|
const nextState = normalizeStage(message?.payload?.state);
|
||||||
|
if (isTerminalStage(nextState) || isInteractiveReviewStage(nextState)) {
|
||||||
|
setRipperJobsRefreshToken((token) => token + 1);
|
||||||
|
setHistoryJobsRefreshToken((token) => token + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.type === 'PIPELINE_PROGRESS') {
|
if (message.type === 'PIPELINE_PROGRESS') {
|
||||||
|
|||||||
@@ -388,6 +388,13 @@ export const api = {
|
|||||||
forceRefresh: options.forceRefresh
|
forceRefresh: options.forceRefresh
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async checkMakeMKVBetaKey() {
|
||||||
|
const result = await request('/settings/makemkv/beta-key/check', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/settings/makemkv/beta-key']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
async applyMakeMKVBetaKey(payload = {}) {
|
async applyMakeMKVBetaKey(payload = {}) {
|
||||||
const result = await request('/settings/makemkv/beta-key/apply', {
|
const result = await request('/settings/makemkv/beta-key/apply', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -665,63 +665,146 @@ function isReadonlySetting(setting) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
|
function MakeMKVBetaKeyHint({ disabled = false, onNotify = null, onSettingApplied = null }) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
const [betaKey, setBetaKey] = useState('');
|
const [betaKey, setBetaKey] = useState('');
|
||||||
const [validUntil, setValidUntil] = useState('');
|
const [validUntil, setValidUntil] = useState('');
|
||||||
|
const [fetchedAt, setFetchedAt] = useState('');
|
||||||
|
const [appliedKey, setAppliedKey] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [warning, setWarning] = useState('');
|
const [warning, setWarning] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
const loadCachedState = async () => {
|
||||||
let active = true;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
setWarning('');
|
setWarning('');
|
||||||
setValidUntil('');
|
try {
|
||||||
|
const response = await api.getMakeMKVBetaKey();
|
||||||
api.getMakeMKVBetaKey()
|
|
||||||
.then((response) => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBetaKey(String(response?.betaKey || '').trim());
|
setBetaKey(String(response?.betaKey || '').trim());
|
||||||
setValidUntil(String(response?.validUntil || '').trim());
|
setValidUntil(String(response?.validUntil || '').trim());
|
||||||
setWarning(String(response?.warning || '').trim());
|
setFetchedAt(String(response?.fetchedAt || '').trim());
|
||||||
})
|
setAppliedKey(String(response?.appliedKey || '').trim());
|
||||||
.catch((loadError) => {
|
if (response?.stale) {
|
||||||
if (!active) {
|
setWarning('Zwischengespeicherter Betakey ist veraltet. Bitte bei Bedarf neu prüfen.');
|
||||||
return;
|
} else if (!response?.betaKey) {
|
||||||
|
setWarning('Noch kein geprüfter Betakey im Cache. Prüfung startet nur per Button.');
|
||||||
|
} else if (response?.appliedMatchesCache) {
|
||||||
|
setWarning('Geprüfter Betakey ist bereits übernommen.');
|
||||||
|
} else {
|
||||||
|
setWarning('');
|
||||||
}
|
}
|
||||||
|
} catch (loadError) {
|
||||||
setBetaKey('');
|
setBetaKey('');
|
||||||
setValidUntil('');
|
setValidUntil('');
|
||||||
|
setFetchedAt('');
|
||||||
|
setAppliedKey('');
|
||||||
setError(loadError?.message || 'Betakey konnte nicht geladen werden.');
|
setError(loadError?.message || 'Betakey konnte nicht geladen werden.');
|
||||||
})
|
} finally {
|
||||||
.finally(() => {
|
|
||||||
if (active) {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCachedState();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleCheck = async () => {
|
||||||
|
setChecking(true);
|
||||||
|
setError('');
|
||||||
|
setWarning('');
|
||||||
|
try {
|
||||||
|
const response = await api.checkMakeMKVBetaKey();
|
||||||
|
const nextKey = String(response?.betaKey || '').trim();
|
||||||
|
setBetaKey(nextKey);
|
||||||
|
setValidUntil(String(response?.validUntil || '').trim());
|
||||||
|
setFetchedAt(String(response?.fetchedAt || '').trim());
|
||||||
|
setWarning('');
|
||||||
|
if (typeof onNotify === 'function') {
|
||||||
|
onNotify({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'MakeMKV Betakey',
|
||||||
|
detail: nextKey ? 'Betakey erfolgreich geprüft.' : 'Betakey-Prüfung abgeschlossen.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (checkError) {
|
||||||
|
setError(checkError?.message || 'Betakey konnte nicht geprüft werden.');
|
||||||
|
if (typeof onNotify === 'function') {
|
||||||
|
onNotify({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'MakeMKV Betakey',
|
||||||
|
detail: checkError?.message || 'Betakey konnte nicht geprüft werden.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (!betaKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setApplying(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const response = await api.applyMakeMKVBetaKey({ betaKey });
|
||||||
|
const nextAppliedKey = String(response?.betaKey || betaKey).trim();
|
||||||
|
setAppliedKey(nextAppliedKey);
|
||||||
|
setWarning('Geprüfter Betakey ist bereits übernommen.');
|
||||||
|
if (typeof onSettingApplied === 'function') {
|
||||||
|
onSettingApplied({
|
||||||
|
key: 'makemkv_registration_key',
|
||||||
|
value: nextAppliedKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof onNotify === 'function') {
|
||||||
|
onNotify({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'MakeMKV Betakey',
|
||||||
|
detail: 'Betakey wurde übernommen und gespeichert.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (applyError) {
|
||||||
|
setError(applyError?.message || 'Betakey konnte nicht übernommen werden.');
|
||||||
|
if (typeof onNotify === 'function') {
|
||||||
|
onNotify({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'MakeMKV Betakey',
|
||||||
|
detail: applyError?.message || 'Betakey konnte nicht übernommen werden.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="makemkv-beta-key-box">
|
<div className="makemkv-beta-key-box">
|
||||||
<div className="makemkv-beta-key-head">
|
<div className="makemkv-beta-key-head">
|
||||||
<span className="makemkv-beta-key-title">Betakey</span>
|
<span className="makemkv-beta-key-title">Betakey</span>
|
||||||
|
<div className="settings-inline-actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
label="prüfen"
|
||||||
|
disabled={disabled || loading || checking || applying}
|
||||||
|
onClick={() => void handleCheck()}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="small"
|
size="small"
|
||||||
text
|
text
|
||||||
label="übernehmen"
|
label="übernehmen"
|
||||||
disabled={disabled || loading || !betaKey}
|
disabled={disabled || loading || checking || applying || !betaKey || betaKey === appliedKey}
|
||||||
onClick={() => onApply?.(betaKey)}
|
onClick={() => void handleApply()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<small className="setting-description">Aktueller Betakey wird geladen…</small>
|
<small className="setting-description">Zwischengespeicherter Betakey wird geladen…</small>
|
||||||
) : null}
|
) : null}
|
||||||
{!loading && error ? (
|
{!loading && error ? (
|
||||||
<small className="error-text">{error}</small>
|
<small className="error-text">{error}</small>
|
||||||
@@ -729,6 +812,9 @@ function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
|
|||||||
{!loading && !error && warning ? (
|
{!loading && !error && warning ? (
|
||||||
<small className="setting-description">{warning}</small>
|
<small className="setting-description">{warning}</small>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!loading && !error && fetchedAt ? (
|
||||||
|
<small className="setting-description">Zuletzt geprüft: {new Date(fetchedAt).toLocaleString('de-DE')}</small>
|
||||||
|
) : null}
|
||||||
{!loading && !error && validUntil ? (
|
{!loading && !error && validUntil ? (
|
||||||
<small className="setting-description">Gültig bis: {new Date(validUntil).toLocaleString('de-DE')}</small>
|
<small className="setting-description">Gültig bis: {new Date(validUntil).toLocaleString('de-DE')}</small>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -786,7 +872,9 @@ function SettingField({
|
|||||||
ownerDirty,
|
ownerDirty,
|
||||||
onChange,
|
onChange,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
disabled = false
|
disabled = false,
|
||||||
|
onNotify = null,
|
||||||
|
onSettingApplied = null
|
||||||
}) {
|
}) {
|
||||||
const ownerKey = ownerSetting?.key;
|
const ownerKey = ownerSetting?.key;
|
||||||
const pathHasValue = Boolean(String(value ?? '').trim());
|
const pathHasValue = Boolean(String(value ?? '').trim());
|
||||||
@@ -905,7 +993,8 @@ function SettingField({
|
|||||||
{normalizeSettingKey(setting?.key) === 'makemkv_registration_key' ? (
|
{normalizeSettingKey(setting?.key) === 'makemkv_registration_key' ? (
|
||||||
<MakeMKVBetaKeyHint
|
<MakeMKVBetaKeyHint
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onApply={(nextKey) => onChange?.(setting.key, nextKey)}
|
onNotify={onNotify}
|
||||||
|
onSettingApplied={onSettingApplied}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -946,7 +1035,16 @@ function SettingField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effectivePaths }) {
|
function PathCategoryTab({
|
||||||
|
settings,
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
dirtyKeys,
|
||||||
|
onChange,
|
||||||
|
effectivePaths,
|
||||||
|
onNotify,
|
||||||
|
onSettingApplied
|
||||||
|
}) {
|
||||||
const list = Array.isArray(settings) ? settings : [];
|
const list = Array.isArray(settings) ? settings : [];
|
||||||
const settingsByKey = new Map(list.map((s) => [s.key, s]));
|
const settingsByKey = new Map(list.map((s) => [s.key, s]));
|
||||||
|
|
||||||
@@ -1153,6 +1251,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
|||||||
ownerError={ownerError}
|
ownerError={ownerError}
|
||||||
ownerDirty={ownerDirty}
|
ownerDirty={ownerDirty}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onNotify={onNotify}
|
||||||
|
onSettingApplied={onSettingApplied}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1173,7 +1273,8 @@ export default function DynamicSettingsForm({
|
|||||||
onChange,
|
onChange,
|
||||||
effectivePaths,
|
effectivePaths,
|
||||||
activationBytes = [],
|
activationBytes = [],
|
||||||
onNotify
|
onNotify,
|
||||||
|
onSettingApplied
|
||||||
}) {
|
}) {
|
||||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||||
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
|
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
|
||||||
@@ -1258,6 +1359,8 @@ export default function DynamicSettingsForm({
|
|||||||
dirtyKeys={dirtyKeys}
|
dirtyKeys={dirtyKeys}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
effectivePaths={effectivePaths}
|
effectivePaths={effectivePaths}
|
||||||
|
onNotify={onNotify}
|
||||||
|
onSettingApplied={onSettingApplied}
|
||||||
/>
|
/>
|
||||||
) : (() => {
|
) : (() => {
|
||||||
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
|
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
|
||||||
@@ -1339,6 +1442,8 @@ export default function DynamicSettingsForm({
|
|||||||
onChange={onChangeFn}
|
onChange={onChangeFn}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onNotify={onNotify}
|
||||||
|
onSettingApplied={onSettingApplied}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1588,6 +1588,7 @@ export default function MediaInfoReviewPanel({
|
|||||||
commandOutputPathByTitle = {},
|
commandOutputPathByTitle = {},
|
||||||
selectedEncodeTitleId = null,
|
selectedEncodeTitleId = null,
|
||||||
selectedEncodeTitleIds = [],
|
selectedEncodeTitleIds = [],
|
||||||
|
displayOnlySelectedTitle = false,
|
||||||
titleSelectionTouched = false,
|
titleSelectionTouched = false,
|
||||||
allowTitleSelection = false,
|
allowTitleSelection = false,
|
||||||
compactTitleSelection = false,
|
compactTitleSelection = false,
|
||||||
@@ -1670,6 +1671,9 @@ export default function MediaInfoReviewPanel({
|
|||||||
|| selectedTitleIds[0]
|
|| selectedTitleIds[0]
|
||||||
|| null;
|
|| null;
|
||||||
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
|
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
|
||||||
|
const visibleDisplayTitles = displayOnlySelectedTitle && currentSelectedId !== null
|
||||||
|
? displayTitles.filter((title) => normalizeTitleId(title?.id) === currentSelectedId)
|
||||||
|
: displayTitles;
|
||||||
const normalizedComparisonReviews = (Array.isArray(comparisonReviewItems) ? comparisonReviewItems : [])
|
const normalizedComparisonReviews = (Array.isArray(comparisonReviewItems) ? comparisonReviewItems : [])
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const reviewCandidate = item?.review && typeof item.review === 'object'
|
const reviewCandidate = item?.review && typeof item.review === 'object'
|
||||||
@@ -2707,12 +2711,12 @@ export default function MediaInfoReviewPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="media-title-list">
|
<div className="media-title-list">
|
||||||
{displayTitles.length === 0 ? (
|
{visibleDisplayTitles.length === 0 ? (
|
||||||
<p>Keine Titel analysiert.</p>
|
<p>Keine Titel analysiert.</p>
|
||||||
) : displayTitles.map((title) => {
|
) : visibleDisplayTitles.map((title) => {
|
||||||
const normalizedTitleId = normalizeTitleId(title.id);
|
const normalizedTitleId = normalizeTitleId(title.id);
|
||||||
const displayTitleId = normalizeTitleId(title?.handBrakeTitleId) || normalizedTitleId;
|
const displayTitleId = normalizeTitleId(title?.handBrakeTitleId) || normalizedTitleId;
|
||||||
const titleChecked = allowTitleSelection
|
const titleChecked = (allowTitleSelection || displayOnlySelectedTitle)
|
||||||
? (selectedTitleIdSet.size > 0
|
? (selectedTitleIdSet.size > 0
|
||||||
? selectedTitleIdSet.has(String(normalizedTitleId))
|
? selectedTitleIdSet.has(String(normalizedTitleId))
|
||||||
: Boolean(title.selectedForEncode))
|
: Boolean(title.selectedForEncode))
|
||||||
@@ -2799,8 +2803,8 @@ export default function MediaInfoReviewPanel({
|
|||||||
);
|
);
|
||||||
const allowTrackSelectionForTitle = Boolean(
|
const allowTrackSelectionForTitle = Boolean(
|
||||||
allowTrackSelection
|
allowTrackSelection
|
||||||
&& allowTitleSelection
|
|
||||||
&& titleChecked
|
&& titleChecked
|
||||||
|
&& (allowTitleSelection || displayOnlySelectedTitle)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2813,6 +2817,15 @@ export default function MediaInfoReviewPanel({
|
|||||||
if (!allowTitleSelection) {
|
if (!allowTitleSelection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (displayOnlySelectedTitle) {
|
||||||
|
if (typeof onToggleEncodeTitle === 'function') {
|
||||||
|
onToggleEncodeTitle(normalizedTitleId, true);
|
||||||
|
}
|
||||||
|
if (typeof onSelectEncodeTitle === 'function') {
|
||||||
|
onSelectEncodeTitle(normalizedTitleId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const checked = Boolean(event?.target?.checked);
|
const checked = Boolean(event?.target?.checked);
|
||||||
if (typeof onToggleEncodeTitle === 'function') {
|
if (typeof onToggleEncodeTitle === 'function') {
|
||||||
onToggleEncodeTitle(normalizedTitleId, checked);
|
onToggleEncodeTitle(normalizedTitleId, checked);
|
||||||
|
|||||||
@@ -1635,17 +1635,29 @@ export default function PipelineStatusCard({
|
|||||||
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
|
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
|
||||||
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
|
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
|
||||||
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
|
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
|
||||||
|
const hasReviewRecoverableRaw = Boolean(
|
||||||
|
!['converter', 'audiobook'].includes(String(jobMediaProfile || '').trim().toLowerCase())
|
||||||
|
&& ripCompleted
|
||||||
|
&& (
|
||||||
|
jobRow?.raw_path
|
||||||
|
|| pipeline?.context?.rawPath
|
||||||
|
|| jobRow?.makemkvInfo?.rawPath
|
||||||
|
)
|
||||||
|
);
|
||||||
const retryRipRecoveryRequired = Boolean(
|
const retryRipRecoveryRequired = Boolean(
|
||||||
!running
|
!running
|
||||||
&& retryJobId
|
&& retryJobId
|
||||||
&& typeof onRetry === 'function'
|
&& typeof onRetry === 'function'
|
||||||
&& !queueLocked
|
&& !queueLocked
|
||||||
&& !isOrphanCancelled
|
&& !isOrphanCancelled
|
||||||
&& !['converter', 'audiobook'].includes(String(jobMediaProfile || '').trim().toLowerCase())
|
&& !hasReviewRecoverableRaw
|
||||||
&& ['ERROR', 'CANCELLED'].includes(jobStatusUpper)
|
&& ['ERROR', 'CANCELLED'].includes(jobStatusUpper)
|
||||||
&& (
|
&& (
|
||||||
['RIPPING', 'CD_RIPPING'].includes(jobLastStateUpper)
|
['RIPPING', 'CD_RIPPING'].includes(jobLastStateUpper)
|
||||||
|| jobErrorMessageLower.includes('server-neustart')
|
|| (
|
||||||
|
jobErrorMessageLower.includes('server-neustart')
|
||||||
|
&& !hasReviewRecoverableRaw
|
||||||
|
)
|
||||||
|| jobErrorMessageLower.includes('rip ist unvollständig')
|
|| jobErrorMessageLower.includes('rip ist unvollständig')
|
||||||
|| jobErrorMessageLower.includes('rip-validierung fehlgeschlagen')
|
|| jobErrorMessageLower.includes('rip-validierung fehlgeschlagen')
|
||||||
)
|
)
|
||||||
@@ -2374,6 +2386,15 @@ export default function PipelineStatusCard({
|
|||||||
);
|
);
|
||||||
const allowTrackSelectionInReview = state === 'READY_TO_ENCODE' && allowReviewSelectionEdit;
|
const allowTrackSelectionInReview = state === 'READY_TO_ENCODE' && allowReviewSelectionEdit;
|
||||||
const allowEncodeItemSelectionInReview = state === 'READY_TO_ENCODE' && allowReviewSelectionEdit;
|
const allowEncodeItemSelectionInReview = state === 'READY_TO_ENCODE' && allowReviewSelectionEdit;
|
||||||
|
const alternativeTitleSelection = mediaInfoReview?.alternativeTitleSelection
|
||||||
|
&& typeof mediaInfoReview.alternativeTitleSelection === 'object'
|
||||||
|
? mediaInfoReview.alternativeTitleSelection
|
||||||
|
: null;
|
||||||
|
const hasAlternativeTitleSelection = Boolean(
|
||||||
|
alternativeTitleSelection?.enabled
|
||||||
|
&& Array.isArray(mediaInfoReview?.titles)
|
||||||
|
&& mediaInfoReview.titles.length > 1
|
||||||
|
);
|
||||||
|
|
||||||
// Filter user presets by job media profile ('all' presets always shown)
|
// Filter user presets by job media profile ('all' presets always shown)
|
||||||
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
|
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
|
||||||
@@ -2550,6 +2571,31 @@ export default function PipelineStatusCard({
|
|||||||
}
|
}
|
||||||
return dedup;
|
return dedup;
|
||||||
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
|
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
|
||||||
|
const alternativeTitleOptions = useMemo(() => {
|
||||||
|
if (!hasAlternativeTitleSelection) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const titles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
|
||||||
|
return titles
|
||||||
|
.map((title) => {
|
||||||
|
const titleId = normalizeTitleId(title?.id);
|
||||||
|
if (!titleId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const playlistFile = String(title?.playlistFile || '').trim();
|
||||||
|
const durationLabel = formatDurationClock(title?.durationSeconds) || null;
|
||||||
|
const parts = [
|
||||||
|
playlistFile || `Titel #${titleId}`,
|
||||||
|
durationLabel
|
||||||
|
].filter(Boolean);
|
||||||
|
return {
|
||||||
|
value: titleId,
|
||||||
|
label: parts.join(' | ')
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((left, right) => Number(left.value) - Number(right.value));
|
||||||
|
}, [hasAlternativeTitleSelection, mediaInfoReview?.titles]);
|
||||||
|
|
||||||
const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION'
|
const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION'
|
||||||
&& Boolean(pipeline?.context?.handBrakeTitleDecisionRequired);
|
&& Boolean(pipeline?.context?.handBrakeTitleDecisionRequired);
|
||||||
@@ -4316,6 +4362,38 @@ export default function PipelineStatusCard({
|
|||||||
commandOutputPathByTitle={commandOutputPathByTitle}
|
commandOutputPathByTitle={commandOutputPathByTitle}
|
||||||
topContent={(
|
topContent={(
|
||||||
<>
|
<>
|
||||||
|
{hasAlternativeTitleSelection ? (
|
||||||
|
<div className="playlist-decision-block">
|
||||||
|
<h3>Alternative Titel gefunden</h3>
|
||||||
|
<small>
|
||||||
|
Es wurden weitere gleichlange oder längere Titel erkannt. Das kann auf alternative Schnittfassungen
|
||||||
|
oder Playlist-Varianten derselben Disc hindeuten. Bitte waehle hier den Titel aus, der fuer Tracks,
|
||||||
|
Untertitel, Infos und den HandBrakeCLI-Command verwendet werden soll.
|
||||||
|
</small>
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>
|
||||||
|
Zu verwendender Titel
|
||||||
|
</label>
|
||||||
|
<Dropdown
|
||||||
|
value={normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(mediaInfoReview?.encodeInputTitleId) || null}
|
||||||
|
options={alternativeTitleOptions}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextTitleId = normalizeTitleId(e?.value);
|
||||||
|
if (!nextTitleId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedEncodeTitleId(nextTitleId);
|
||||||
|
setSelectedEncodeTitleIds([nextTitleId]);
|
||||||
|
setTitleSelectionTouched(true);
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
disabled={!allowReviewSelectionEdit || alternativeTitleOptions.length <= 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{multipartSettingsLocked ? (
|
{multipartSettingsLocked ? (
|
||||||
<div className="playlist-decision-block">
|
<div className="playlist-decision-block">
|
||||||
<h3>Multipart-Lock aktiv</h3>
|
<h3>Multipart-Lock aktiv</h3>
|
||||||
@@ -4354,6 +4432,7 @@ export default function PipelineStatusCard({
|
|||||||
)}
|
)}
|
||||||
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
||||||
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
|
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
|
||||||
|
displayOnlySelectedTitle={hasAlternativeTitleSelection}
|
||||||
titleSelectionTouched={titleSelectionTouched}
|
titleSelectionTouched={titleSelectionTouched}
|
||||||
allowTitleSelection={allowTitleSelectionInReview}
|
allowTitleSelection={allowTitleSelectionInReview}
|
||||||
compactTitleSelection={isDiscTitleSelectionRequired}
|
compactTitleSelection={isDiscTitleSelectionRequired}
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ function buildWsUrl() {
|
|||||||
return `${protocol}//${window.location.host}/ws`;
|
return `${protocol}//${window.location.host}/ws`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStage(value) {
|
||||||
|
return String(value || '').trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunningStage(value) {
|
||||||
|
const normalized = normalizeStage(value);
|
||||||
|
return normalized === 'ANALYZING'
|
||||||
|
|| normalized === 'RIPPING'
|
||||||
|
|| normalized === 'MEDIAINFO_CHECK'
|
||||||
|
|| normalized === 'ENCODING'
|
||||||
|
|| normalized === 'CD_ANALYZING'
|
||||||
|
|| normalized === 'CD_RIPPING'
|
||||||
|
|| normalized === 'CD_ENCODING';
|
||||||
|
}
|
||||||
|
|
||||||
export function useWebSocket({ onMessage }) {
|
export function useWebSocket({ onMessage }) {
|
||||||
const onMessageRef = useRef(onMessage);
|
const onMessageRef = useRef(onMessage);
|
||||||
onMessageRef.current = onMessage;
|
onMessageRef.current = onMessage;
|
||||||
@@ -69,10 +84,19 @@ export function useWebSocket({ onMessage }) {
|
|||||||
if (message?.type === 'PIPELINE_PROGRESS') {
|
if (message?.type === 'PIPELINE_PROGRESS') {
|
||||||
const progressJobId = message?.payload?.activeJobId;
|
const progressJobId = message?.payload?.activeJobId;
|
||||||
const jobKey = progressJobId == null ? '__global__' : String(progressJobId);
|
const jobKey = progressJobId == null ? '__global__' : String(progressJobId);
|
||||||
|
const progressStage = normalizeStage(message?.payload?.state);
|
||||||
|
if (isRunningStage(progressStage)) {
|
||||||
latestProgressMessagesByJob.set(jobKey, message);
|
latestProgressMessagesByJob.set(jobKey, message);
|
||||||
scheduleProgressFlush();
|
scheduleProgressFlush();
|
||||||
|
} else {
|
||||||
|
latestProgressMessagesByJob.delete(jobKey);
|
||||||
|
clearProgressFlush();
|
||||||
|
flushProgressMessage();
|
||||||
|
onMessageRef.current?.(message);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
flushProgressMessage();
|
||||||
onMessageRef.current?.(message);
|
onMessageRef.current?.(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// ignore invalid json
|
// ignore invalid json
|
||||||
|
|||||||
@@ -1614,7 +1614,7 @@ export default function RipperPage({
|
|||||||
// Detects when a background job reaches an interactive review/selection state via
|
// Detects when a background job reaches an interactive review/selection state via
|
||||||
// PIPELINE_PROGRESS (e.g. METADATA_LOOKUP, METADATA_SELECTION, READY_TO_ENCODE, WAITING_FOR_USER_DECISION),
|
// PIPELINE_PROGRESS (e.g. METADATA_LOOKUP, METADATA_SELECTION, READY_TO_ENCODE, WAITING_FOR_USER_DECISION),
|
||||||
// triggering a ripper jobs reload.
|
// triggering a ripper jobs reload.
|
||||||
const jobProgressInteractiveKey = useMemo(() => Object.entries(pipeline?.jobProgress || {})
|
const jobProgressInteractiveSignature = useMemo(() => Object.entries(pipeline?.jobProgress || {})
|
||||||
.filter(([, v]) => {
|
.filter(([, v]) => {
|
||||||
const s = String(v?.state || '').toUpperCase();
|
const s = String(v?.state || '').toUpperCase();
|
||||||
return s === 'METADATA_LOOKUP'
|
return s === 'METADATA_LOOKUP'
|
||||||
@@ -1622,7 +1622,7 @@ export default function RipperPage({
|
|||||||
|| s === 'READY_TO_ENCODE'
|
|| s === 'READY_TO_ENCODE'
|
||||||
|| s === 'WAITING_FOR_USER_DECISION';
|
|| s === 'WAITING_FOR_USER_DECISION';
|
||||||
})
|
})
|
||||||
.map(([id]) => id)
|
.map(([id, value]) => `${id}:${String(value?.state || '').trim().toUpperCase()}`)
|
||||||
.sort()
|
.sort()
|
||||||
.join(','),
|
.join(','),
|
||||||
[pipeline?.jobProgress]);
|
[pipeline?.jobProgress]);
|
||||||
@@ -1918,7 +1918,17 @@ export default function RipperPage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadRipperJobs();
|
void loadRipperJobs();
|
||||||
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveKey]);
|
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const normalizedPipelineState = String(pipeline?.state || '').trim().toUpperCase();
|
||||||
|
const isReviewState = normalizedPipelineState === 'READY_TO_ENCODE'
|
||||||
|
|| normalizedPipelineState === 'WAITING_FOR_USER_DECISION';
|
||||||
|
if (!isReviewState || !currentPipelineJobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedJobId(currentPipelineJobId);
|
||||||
|
}, [pipeline?.state, currentPipelineJobId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getDetectedDrives()
|
api.getDetectedDrives()
|
||||||
|
|||||||
@@ -1476,6 +1476,15 @@ export default function SettingsPage() {
|
|||||||
effectivePaths={effectivePaths}
|
effectivePaths={effectivePaths}
|
||||||
activationBytes={activationBytes}
|
activationBytes={activationBytes}
|
||||||
onNotify={(msg) => toastRef.current?.show(msg)}
|
onNotify={(msg) => toastRef.current?.show(msg)}
|
||||||
|
onSettingApplied={(setting) => {
|
||||||
|
const key = String(setting?.key || '').trim();
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraftValues((prev) => ({ ...prev, [key]: setting?.value }));
|
||||||
|
setInitialValues((prev) => ({ ...prev, [key]: setting?.value }));
|
||||||
|
setErrors((prev) => ({ ...prev, [key]: null }));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
@@ -2228,6 +2228,13 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-inline-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.makemkv-beta-key-title {
|
.makemkv-beta-key-title {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "1.0.0-rc3",
|
"version": "1.0.0-rc4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "1.0.0-rc3",
|
"version": "1.0.0-rc4",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-rc3",
|
"version": "1.0.0-rc4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user