diff --git a/backend/package-lock.json b/backend/package-lock.json index 81e2da2..de67d50 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "1.0.0-rc3", + "version": "1.0.0-rc4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "1.0.0-rc3", + "version": "1.0.0-rc4", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index aadd6a9..58b7da3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "1.0.0-rc3", + "version": "1.0.0-rc4", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/index.js b/backend/src/index.js index 32af1b9..a7078df 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -24,11 +24,20 @@ const diskDetectionService = require('./services/diskDetectionService'); const hardwareMonitorService = require('./services/hardwareMonitorService'); const coverArtRecoveryService = require('./services/coverArtRecoveryService'); const tempCleanupService = require('./services/tempCleanupService'); -const { ensureCurrentBetaKeyCacheOnStartup } = require('./services/makemkvKeyService'); +const { fetchCurrentBetaKey } = require('./services/makemkvKeyService'); const logger = require('./services/logger').child('BOOT'); const { errorToMeta } = require('./utils/errorMeta'); const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService'); +function getDelayUntilNextBetaKeyCheck(now = new Date()) { + const next = new Date(now); + next.setHours(0, 1, 0, 0); + if (next.getTime() <= now.getTime()) { + next.setDate(next.getDate() + 1); + } + return Math.max(1000, next.getTime() - now.getTime()); +} + async function start() { logger.info('backend:start:init'); await initDatabase(); @@ -38,7 +47,32 @@ async function start() { } catch (error) { logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) }); } - await ensureCurrentBetaKeyCacheOnStartup(); + let betaKeyRefreshTimer = null; + const scheduleBetaKeyRefresh = () => { + clearTimeout(betaKeyRefreshTimer); + const delayMs = getDelayUntilNextBetaKeyCheck(); + betaKeyRefreshTimer = setTimeout(runBetaKeyRefresh, delayMs); + betaKeyRefreshTimer.unref?.(); + logger.info('beta-key:daily-check:scheduled', { + delayMs, + nextRunAt: new Date(Date.now() + delayMs).toISOString() + }); + }; + const runBetaKeyRefresh = async () => { + try { + const result = await fetchCurrentBetaKey({ forceRefresh: true }); + logger.info('beta-key:daily-check:done', { + sourceUrl: result?.sourceUrl || null, + validUntil: result?.validUntil || null, + fetchedAt: result?.fetchedAt || null + }); + } catch (error) { + logger.warn('beta-key:daily-check:failed', { error: errorToMeta(error) }); + } finally { + scheduleBetaKeyRefresh(); + } + }; + scheduleBetaKeyRefresh(); await tempCleanupService.init(); await pipelineService.init(); await converterScanService.startPolling(); @@ -107,6 +141,7 @@ async function start() { hardwareMonitorService.stop(); cronService.stop(); tempCleanupService.stop(); + clearTimeout(betaKeyRefreshTimer); server.close(() => { logger.warn('backend:shutdown:completed'); process.exit(0); diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index e2eb517..1eec7c2 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -12,7 +12,7 @@ const userPresetDefaultsService = require('../services/userPresetDefaultsService const activationBytesService = require('../services/activationBytesService'); const diskDetectionService = require('../services/diskDetectionService'); const coverArtRecoveryService = require('../services/coverArtRecoveryService'); -const { fetchCurrentBetaKey } = require('../services/makemkvKeyService'); +const { fetchCurrentBetaKey, getCachedBetaKey } = require('../services/makemkvKeyService'); const logger = require('../services/logger').child('SETTINGS_ROUTE'); const { getDb } = require('../db/database'); @@ -58,39 +58,36 @@ router.get( '/makemkv/beta-key', asyncHandler(async (req, res) => { logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId }); - try { - const result = await fetchCurrentBetaKey(); - res.json({ - betaKey: result.key, - sourceUrl: result.sourceUrl, - validUntil: result.validUntil || null, - cached: result.cached === true, - stale: result.stale === true - }); - return; - } catch (error) { - const status = Number(error?.statusCode || 0); - if (status === 429 || status === 403) { - const currentSettings = await settingsService.getSettingsMap({ forceRefresh: false }); - const existingKey = String(currentSettings?.makemkv_registration_key || '').trim(); - logger.warn('get:settings:makemkv:beta-key:fallback-local', { - reqId: req.reqId, - status, - hasExistingKey: Boolean(existingKey), - retryAt: error?.retryAt || null - }); - res.json({ - betaKey: existingKey, - sourceUrl: null, - validUntil: null, - cached: Boolean(existingKey), - stale: true, - warning: error?.message || 'Betakey-API aktuell nicht erreichbar.' - }); - return; - } - throw error; - } + const currentSettings = await settingsService.getSettingsMap({ forceRefresh: false }); + const existingKey = String(currentSettings?.makemkv_registration_key || '').trim(); + const cached = await getCachedBetaKey({ allowExpired: true }); + + res.json({ + betaKey: cached?.key || '', + sourceUrl: cached?.sourceUrl || null, + validUntil: cached?.validUntil || null, + fetchedAt: cached?.fetchedAt || null, + cached: cached?.cached === true, + stale: cached?.stale === true, + appliedKey: existingKey || null, + appliedMatchesCache: Boolean(cached?.key) && existingKey === cached.key + }); + }) +); + +router.post( + '/makemkv/beta-key/check', + asyncHandler(async (req, res) => { + logger.info('post:settings:makemkv:beta-key:check', { reqId: req.reqId }); + const betaKeyResult = await fetchCurrentBetaKey({ forceRefresh: true }); + res.json({ + betaKey: betaKeyResult.key, + sourceUrl: betaKeyResult.sourceUrl, + validUntil: betaKeyResult.validUntil || null, + fetchedAt: betaKeyResult.fetchedAt || null, + cached: betaKeyResult.cached === true, + stale: betaKeyResult.stale === true + }); }) ); @@ -98,35 +95,26 @@ router.post( '/makemkv/beta-key/apply', asyncHandler(async (req, res) => { logger.info('post:settings:makemkv:beta-key:apply', { reqId: req.reqId }); - const force = req.body?.force === true; - const currentSettings = await settingsService.getSettingsMap({ forceRefresh: true }); - const existingKey = String(currentSettings?.makemkv_registration_key || '').trim(); - const betaKeyResult = await fetchCurrentBetaKey(); + const requestedKey = String(req.body?.betaKey || '').trim(); + const cachedResult = requestedKey + ? { key: requestedKey } + : await getCachedBetaKey({ allowExpired: true }); + const betaKey = String(cachedResult?.key || '').trim(); - if (!force && existingKey && existingKey !== betaKeyResult.key) { - const syncResult = await settingsService.syncMakeMKVRegistrationKeyFromSettings({ - settingsMap: currentSettings - }); - res.json({ - applied: false, - preservedExistingKey: true, - reason: 'existing_key', - betaKey: betaKeyResult.key, - sourceUrl: betaKeyResult.sourceUrl, - settingsFilePath: syncResult?.path || null - }); - return; + if (!betaKey) { + const error = new Error('Kein geprüfter Betakey vorhanden. Bitte zuerst prüfen.'); + error.statusCode = 400; + throw error; } - const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKeyResult.key); + const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKey); wsService.broadcast('SETTINGS_UPDATED', updated); res.json({ applied: true, - preservedExistingKey: false, setting: updated, - betaKey: betaKeyResult.key, - sourceUrl: betaKeyResult.sourceUrl + betaKey, + sourceUrl: String(cachedResult?.sourceUrl || '').trim() || null }); }) ); diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index b4bbb6f..d4644a2 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -8,6 +8,7 @@ const tmdbService = require('./tmdbService'); const cdRipService = require('./cdRipService'); const { getJobLogDir } = require('./logPathService'); const thumbnailService = require('./thumbnailService'); +const { getServerTimestamp } = require('../utils/serverTime'); function parseJsonSafe(raw, fallback = null) { if (!raw) { @@ -3542,7 +3543,7 @@ class HistoryService { }); processLogStreams.set(streamKey, stream); } - const line = `[${new Date().toISOString()}] [${source}] ${String(message || '')}\n`; + const line = `[${getServerTimestamp()}] [${source}] ${String(message || '')}\n`; stream.write(line); } catch (error) { logger.warn('job:process-log:append-failed', { @@ -3869,7 +3870,7 @@ class HistoryService { .filter(Boolean) .join(', '); lines.push( - `[${new Date().toISOString()}] [SYSTEM] Importierte vorhandene Job-Logs: ${fileSummary}` + `[${getServerTimestamp()}] [SYSTEM] Importierte vorhandene Job-Logs: ${fileSummary}` ); for (const source of normalizedSources) { lines.push(...source.lines); @@ -3877,7 +3878,7 @@ class HistoryService { } if (importInfo) { lines.push( - `[${new Date().toISOString()}] [SYSTEM] Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(importInfo)}` + `[${getServerTimestamp()}] [SYSTEM] Orphan-RAW-Import Zusatzinfo: ${JSON.stringify(importInfo)}` ); } diff --git a/backend/src/services/logger.js b/backend/src/services/logger.js index c551576..90454b0 100644 --- a/backend/src/services/logger.js +++ b/backend/src/services/logger.js @@ -2,6 +2,7 @@ const fs = require('fs'); const path = require('path'); const { logLevel } = require('../config'); const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService'); +const { getServerTimestamp } = require('../utils/serverTime'); const LEVELS = { debug: 10, @@ -123,7 +124,7 @@ function emit(level, scope, message, meta = null) { return; } - const timestamp = new Date().toISOString(); + const timestamp = getServerTimestamp(); const payload = { timestamp, level: normLevel, diff --git a/backend/src/services/makemkvKeyService.js b/backend/src/services/makemkvKeyService.js index a97c0ba..bbd3ff1 100644 --- a/backend/src/services/makemkvKeyService.js +++ b/backend/src/services/makemkvKeyService.js @@ -7,11 +7,6 @@ const logger = require('./logger').child('MAKEMKV_KEY'); const backendPackage = require('../../package.json'); const MAKEMKV_BETA_KEY_API_URL = 'https://cable.ayra.ch/makemkv/api.php?json'; -const MAKEMKV_BETA_KEY_FORUM_URLS = [ - 'https://www.makemkv.com/forum/viewtopic.php?t=1053', - 'https://forum.makemkv.com/forum/viewtopic.php?t=1053' -]; -const MAKEMKV_BETA_KEY_FORUM_URL = MAKEMKV_BETA_KEY_FORUM_URLS[0]; const MAKEMKV_BETA_KEY_CACHE_PREF_KEY = 'makemkv_beta_key_cache_v1'; const MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS = 1000; const MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS = Math.max( @@ -20,63 +15,24 @@ const MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS = Math.max( ); const RIPSTER_VERSION = String(backendPackage?.version || 'dev').trim() || 'dev'; -const DEFAULT_USER_AGENT = `Ripster/MakeMKVKey-${RIPSTER_VERSION} (${os.platform()}/${os.release()}; Node/${process.versions.node}) +https://github.com/mboehmlaender/ripster`; +const DEFAULT_USER_AGENT = `Ripster/MakeMKVKey-${RIPSTER_VERSION} (linux/manual; Node/manual) +https://github.com/mboehmlaender/ripster`; const MAKEMKV_BETA_KEY_API_USER_AGENT = process.env.MAKEMKV_BETA_KEY_API_USER_AGENT || DEFAULT_USER_AGENT; -const MAKEMKV_BETA_KEY_FORUM_USER_AGENT = process.env.MAKEMKV_BETA_KEY_FORUM_USER_AGENT - || 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'; -const MAKEMKV_BETA_KEY_API_FROM = process.env.MAKEMKV_BETA_KEY_API_FROM - || ''; const MAKEMKV_BETA_KEY_API_TIMEOUT_MS = Math.max( 1000, Number(process.env.MAKEMKV_BETA_KEY_API_TIMEOUT_MS || 15000) ); -const MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS = Math.max( - MAKEMKV_BETA_KEY_API_TIMEOUT_MS, - Number(process.env.MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS || 30000) -); const MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS = Math.max( 30 * 1000, Number(process.env.MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS || (15 * 60 * 1000)) ); -const MONTH_NAME_TO_INDEX = new Map([ - ['january', 0], ['jan', 0], - ['february', 1], ['feb', 1], - ['march', 2], ['mar', 2], - ['april', 3], ['apr', 3], - ['may', 4], - ['june', 5], ['jun', 5], - ['july', 6], ['jul', 6], - ['august', 7], ['aug', 7], - ['september', 8], ['sep', 8], ['sept', 8], - ['october', 9], ['oct', 9], - ['november', 10], ['nov', 10], - ['december', 11], ['dec', 11] -]); - let cachedBetaKeyEntry = null; let blockedUntilMs = 0; -function parseRetryAfterToMs(retryAfterValue) { - const raw = String(retryAfterValue || '').trim(); - if (!raw) { - return 0; - } - if (/^\d+$/.test(raw)) { - const seconds = Number(raw); - return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0; - } - const parsedDate = Date.parse(raw); - if (!Number.isFinite(parsedDate)) { - return 0; - } - return Math.max(0, parsedDate - Date.now()); -} - function parseBetaKeyCandidate(value, invalidMessage = 'Betakey-Antwort ist ungültig.') { const betaKey = String(value || '').trim(); - if (!/^[A-Za-z0-9][A-Za-z0-9-]{15,}$/.test(betaKey)) { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{15,}$/.test(betaKey)) { const error = new Error(invalidMessage); error.statusCode = 502; throw error; @@ -84,83 +40,6 @@ function parseBetaKeyCandidate(value, invalidMessage = 'Betakey-Antwort ist ung return betaKey; } -function resolveMonthIndex(monthName) { - const normalized = String(monthName || '').trim().toLowerCase(); - return MONTH_NAME_TO_INDEX.has(normalized) ? MONTH_NAME_TO_INDEX.get(normalized) : null; -} - -function endOfMonthUtcIso(year, monthIndex) { - const y = Number(year); - const m = Number(monthIndex); - if (!Number.isInteger(y) || !Number.isInteger(m) || m < 0 || m > 11) { - return null; - } - return new Date(Date.UTC(y, m + 1, 0, 23, 59, 59, 999)).toISOString(); -} - -function endOfDayUtcIso(year, monthIndex, day) { - const y = Number(year); - const m = Number(monthIndex); - const d = Number(day); - if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) { - return null; - } - if (m < 0 || m > 11 || d < 1 || d > 31) { - return null; - } - return new Date(Date.UTC(y, m, d, 23, 59, 59, 999)).toISOString(); -} - -function parseValidityPhraseToIso(phrase) { - const raw = String(phrase || '').trim().replace(/\s+/g, ' '); - if (!raw) { - return null; - } - - const endOfMonthMatch = raw.match(/^end of\s+([A-Za-z]+)\s+(\d{4})$/i); - if (endOfMonthMatch) { - const monthIndex = resolveMonthIndex(endOfMonthMatch[1]); - if (monthIndex != null) { - return endOfMonthUtcIso(Number(endOfMonthMatch[2]), monthIndex); - } - } - - const exactDayMatch = raw.match(/^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$/i); - if (exactDayMatch) { - const monthIndex = resolveMonthIndex(exactDayMatch[2]); - if (monthIndex != null) { - return endOfDayUtcIso(Number(exactDayMatch[3]), monthIndex, Number(exactDayMatch[1])); - } - } - - const monthYearMatch = raw.match(/^([A-Za-z]+)\s+(\d{4})$/i); - if (monthYearMatch) { - const monthIndex = resolveMonthIndex(monthYearMatch[1]); - if (monthIndex != null) { - return endOfMonthUtcIso(Number(monthYearMatch[2]), monthIndex); - } - } - - return null; -} - -function parseForumValidUntilIsoFromHtml(rawBody) { - const html = String(rawBody || ''); - const plainText = html - .replace(/<[^>]+>/g, ' ') - .replace(/ /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) { if (!rawEntry || typeof rawEntry !== 'object') { @@ -174,7 +53,7 @@ function normalizeCacheEntry(rawEntry) { return null; } - const sourceUrl = String(rawEntry.sourceUrl || MAKEMKV_BETA_KEY_FORUM_URL).trim() || MAKEMKV_BETA_KEY_FORUM_URL; + const sourceUrl = String(rawEntry.sourceUrl || MAKEMKV_BETA_KEY_API_URL).trim() || MAKEMKV_BETA_KEY_API_URL; const validUntilIsoRaw = String(rawEntry.validUntil || rawEntry.validUntilIso || '').trim(); const validUntilMs = Date.parse(validUntilIsoRaw); @@ -211,6 +90,7 @@ function buildPublicResult(entry, options = {}) { key: entry.key, sourceUrl: entry.sourceUrl, validUntil: entry.validUntil, + fetchedAt: entry.fetchedAt || null, cached: options.cached === true, stale: options.stale === true }; @@ -273,6 +153,31 @@ async function tryUsePersistentCache(nowMs = Date.now()) { return buildPublicResult(persistent, { cached: true, stale: false }); } +async function getCachedBetaKey(options = {}) { + const nowMs = Date.now(); + const allowExpired = options?.allowExpired !== false; + + if (cachedBetaKeyEntry) { + const stale = !isCacheEntryValid(cachedBetaKeyEntry, nowMs); + if (!stale || allowExpired) { + return buildPublicResult(cachedBetaKeyEntry, { cached: true, stale }); + } + } + + const persistent = await readPersistentCacheEntry(); + if (!persistent) { + return null; + } + + setInMemoryCache(persistent); + const stale = !isCacheEntryValid(persistent, nowMs); + if (stale && !allowExpired) { + return null; + } + + return buildPublicResult(persistent, { cached: true, stale }); +} + function createRateLimitError(retryAfterMs = 0) { const parsedRetryMs = Number(retryAfterMs || 0); const waitMs = parsedRetryMs > 0 ? parsedRetryMs : MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS; @@ -296,11 +201,6 @@ function buildExistingBackoffError() { return error; } -function shouldSkipFallback(error) { - const status = Number(error?.httpStatus || error?.status || 0); - return status >= 400 && status < 500; -} - function resolveValidityOrFallback(validUntilIso, sourceUrl) { const parsedMs = Date.parse(String(validUntilIso || '').trim()); if (Number.isFinite(parsedMs)) { @@ -413,71 +313,13 @@ async function fetchCurrentBetaKey(options = {}) { throw buildExistingBackoffError(); } - const errors = []; - try { - const forumResult = await fetchCurrentBetaKeyViaForum(); - const normalized = normalizeCacheEntry({ - ...forumResult, - fetchedAt: new Date().toISOString() - }); - - if (!normalized) { - const error = new Error('Forum-Betakey konnte nicht normalisiert werden.'); - error.statusCode = 502; - throw error; - } - - blockedUntilMs = 0; - setInMemoryCache(normalized); - await persistCacheEntry(normalized); - return buildPublicResult(normalized, { cached: false, stale: false }); - } catch (error) { - errors.push(error?.message || String(error)); - logger.warn('beta-key:forum-failed', { - error: error?.message || String(error) - }); - } - - try { - const apiResult = await fetchCurrentBetaKeyViaFetch(); + const apiResult = await fetchCurrentBetaKeyViaCurl(); const normalized = normalizeCacheEntry({ ...apiResult, fetchedAt: new Date().toISOString() }); - if (!normalized) { - const error = new Error('API-Betakey konnte nicht normalisiert werden.'); - error.statusCode = 502; - throw error; - } - - blockedUntilMs = 0; - setInMemoryCache(normalized); - await persistCacheEntry(normalized); - return buildPublicResult(normalized, { cached: false, stale: false }); - } catch (error) { - errors.push(error?.message || String(error)); - logger.warn('beta-key:fetch-failed', { - error: error?.message || String(error) - }); - if ([403, 429].includes(Number(error?.httpStatus || error?.status || 0))) { - throw createRateLimitError(Number(error?.retryAfterMs || 0)); - } - if (shouldSkipFallback(error)) { - const resolved = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`); - resolved.statusCode = Number(error?.httpStatus || error?.status || 502) || 502; - throw resolved; - } - } - - try { - const apiCurlResult = await fetchCurrentBetaKeyViaCurl(); - const normalized = normalizeCacheEntry({ - ...apiCurlResult, - fetchedAt: new Date().toISOString() - }); - if (!normalized) { const error = new Error('API-curl-Betakey konnte nicht normalisiert werden.'); error.statusCode = 502; @@ -489,33 +331,15 @@ async function fetchCurrentBetaKey(options = {}) { await persistCacheEntry(normalized); return buildPublicResult(normalized, { cached: false, stale: false }); } catch (error) { - errors.push(error?.message || String(error)); - logger.warn('beta-key:curl-failed', { + logger.warn('beta-key:fetch-failed', { error: error?.message || String(error) }); - } - - const finalError = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`); - finalError.statusCode = 502; - throw finalError; -} - -async function ensureCurrentBetaKeyCacheOnStartup() { - try { - const current = await fetchCurrentBetaKey(); - logger.info('beta-key:startup-check:done', { - sourceUrl: current?.sourceUrl || null, - validUntil: current?.validUntil || null, - cached: current?.cached === true - }); - return current; - } catch (error) { - logger.warn('beta-key:startup-check:failed', { - error: error?.message || String(error), - statusCode: Number(error?.statusCode || 0) || null, - retryAt: error?.retryAt || null - }); - return null; + if (Number(error?.httpStatus || error?.statusCode || error?.status || 0) === 429) { + throw createRateLimitError(Number(error?.retryAfterMs || 0)); + } + const resolved = new Error(`Betakey konnte nicht geladen werden. Details: ${error?.message || String(error)}`); + resolved.statusCode = Number(error?.httpStatus || error?.statusCode || error?.status || 502) || 502; + throw resolved; } } @@ -546,41 +370,14 @@ function parseBetaKeyResponse(rawBody) { }; } -function parseBetaKeyFromForumHtml(rawBody, sourceUrl = MAKEMKV_BETA_KEY_FORUM_URL) { - const html = String(rawBody || ''); - const contextualMatch = html.match( - /current\s+beta\s+key\s+is[\s\S]{0,4000}?
\s*([A-Za-z0-9-]{16,})\s*<\/code>/i
-  );
-  const firstCodeMatch = html.match(/
\s*([A-Za-z0-9-]{16,})\s*<\/code>/i);
-
-  const keyRaw = contextualMatch?.[1] || firstCodeMatch?.[1] || '';
-  const key = parseBetaKeyCandidate(keyRaw, 'Betakey konnte im Forum nicht gefunden werden.');
-  const parsedValidity = parseForumValidUntilIsoFromHtml(html);
-  const validUntil = resolveValidityOrFallback(parsedValidity, sourceUrl);
-
-  return {
-    key,
-    sourceUrl,
-    validUntil
-  };
-}
-
 function fetchCurrentBetaKeyViaCurl() {
   return new Promise((resolve, reject) => {
     const curlArgs = [
       '-fsSL',
       '--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
       '-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
-      '-H', 'Accept: text/plain, text/html;q=0.9, */*;q=0.8',
-      '-H', 'Accept-Language: de,en-US;q=0.7,en;q=0.3',
-      '-H', 'Cache-Control: no-cache',
-      '-H', 'Pragma: no-cache'
+      MAKEMKV_BETA_KEY_API_URL
     ];
-    const fromHeader = String(MAKEMKV_BETA_KEY_API_FROM || '').trim();
-    if (fromHeader) {
-      curlArgs.push('-H', `From: ${fromHeader}`);
-    }
-    curlArgs.push(MAKEMKV_BETA_KEY_API_URL);
 
     execFile(
       'curl',
@@ -615,126 +412,10 @@ function fetchCurrentBetaKeyViaCurl() {
   });
 }
 
-async function fetchCurrentBetaKeyViaFetch() {
-  const requestHeaders = {
-    'User-Agent': MAKEMKV_BETA_KEY_API_USER_AGENT,
-    'Accept': 'text/plain, text/html;q=0.9, */*;q=0.8',
-    'Accept-Language': 'de,en-US;q=0.7,en;q=0.3',
-    'Cache-Control': 'no-cache',
-    'Pragma': 'no-cache'
-  };
-  const fromHeader = String(MAKEMKV_BETA_KEY_API_FROM || '').trim();
-  if (fromHeader) {
-    requestHeaders.From = fromHeader;
-  }
-
-  const response = await fetch(MAKEMKV_BETA_KEY_API_URL, {
-    headers: requestHeaders,
-    signal: AbortSignal.timeout(MAKEMKV_BETA_KEY_API_TIMEOUT_MS)
-  });
-
-  if (!response.ok) {
-    const error = new Error(`HTTP ${response.status}`);
-    error.statusCode = 502;
-    error.httpStatus = response.status;
-    error.retryAfterMs = parseRetryAfterToMs(response.headers.get('retry-after'));
-    throw error;
-  }
-
-  const rawBody = await response.text();
-  return parseBetaKeyResponse(rawBody);
-}
-
-async function fetchCurrentBetaKeyViaForum() {
-  try {
-    return await fetchCurrentBetaKeyViaForumCurl();
-  } catch (error) {
-    const resolved = new Error(`Forum-Betakey konnte nicht geladen werden. Details: ${error?.message || String(error)}`);
-    resolved.statusCode = 502;
-    throw resolved;
-  }
-}
-
-function fetchCurrentBetaKeyViaForumCurl() {
-  return new Promise((resolve, reject) => {
-    const errors = [];
-    const urls = [...MAKEMKV_BETA_KEY_FORUM_URLS];
-
-    const runAttempt = (index) => {
-      if (index >= urls.length) {
-        const finalError = new Error(errors.join(' | ') || 'Keine Forum-URL verfügbar');
-        finalError.statusCode = 502;
-        reject(finalError);
-        return;
-      }
-
-      const forumUrl = urls[index];
-      let referer = 'https://www.makemkv.com/';
-      try {
-        referer = `${new URL(forumUrl).origin}/`;
-      } catch (_error) {
-        referer = 'https://www.makemkv.com/';
-      }
-
-      const curlArgs = [
-        '-fsSL',
-        '--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS / 1000)),
-        '-A', MAKEMKV_BETA_KEY_FORUM_USER_AGENT,
-        '-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
-        '-H', 'Accept-Language: de,en-US;q=0.7,en;q=0.3',
-        '-H', 'Cache-Control: no-cache',
-        '-H', 'Pragma: no-cache',
-        '-H', `Referer: ${referer}`
-      ];
-      const fromHeader = String(MAKEMKV_BETA_KEY_API_FROM || '').trim();
-      if (fromHeader) {
-        curlArgs.push('-H', `From: ${fromHeader}`);
-      }
-      curlArgs.push(forumUrl);
-
-      execFile(
-        'curl',
-        curlArgs,
-        {
-          timeout: MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS,
-          maxBuffer: 4 * 1024 * 1024
-        },
-        (error, stdout, stderr) => {
-          if (error) {
-            const resolvedError = new Error(
-              `Forum-curl ${forumUrl} fehlgeschlagen (${error.code || 'unknown'}): ${String(stderr || error.message || '').trim() || 'keine Details'}`
-            );
-            resolvedError.statusCode = 502;
-            if (/\b429\b/.test(String(stderr || ''))) {
-              resolvedError.httpStatus = 429;
-            }
-            if (/\b403\b/.test(String(stderr || ''))) {
-              resolvedError.httpStatus = 403;
-            }
-            errors.push(resolvedError.message);
-            runAttempt(index + 1);
-            return;
-          }
-
-          try {
-            resolve(parseBetaKeyFromForumHtml(stdout, forumUrl));
-          } catch (parseError) {
-            errors.push(`Forum-Parse ${forumUrl} fehlgeschlagen: ${parseError?.message || String(parseError)}`);
-            runAttempt(index + 1);
-          }
-        }
-      );
-    };
-
-    runAttempt(0);
-  });
-}
-
 module.exports = {
   MAKEMKV_BETA_KEY_API_URL,
-  MAKEMKV_BETA_KEY_FORUM_URL,
   fetchCurrentBetaKey,
-  ensureCurrentBetaKeyCacheOnStartup,
+  getCachedBetaKey,
   getMakeMKVConfigDir,
   getMakeMKVSettingsFilePath,
   normalizeRegistrationKey,
diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js
index ed558a6..f08c35f 100644
--- a/backend/src/services/pipelineService.js
+++ b/backend/src/services/pipelineService.js
@@ -6377,6 +6377,133 @@ function buildSelectedHandBrakeReviewTitles(scanJson, selectedHandBrakeTitleIds
   return titles;
 }
 
+function collectAlternativeFeatureReviewCandidates({
+  playlistCandidates = [],
+  handBrakePlaylistScan = null,
+  playlistAnalysis = null,
+  selectedPlaylistId = null,
+  selectedHandBrakeTitleId = null,
+  selectedTitleInfo = null,
+  makeMkvSubtitleTracks = []
+}) {
+  const normalizedSelectedPlaylistId = normalizePlaylistId(selectedPlaylistId);
+  const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId);
+  const normalizedCache = normalizeHandBrakePlaylistScanCache(handBrakePlaylistScan);
+  const baselineDurationSeconds = Number(selectedTitleInfo?.durationSeconds || 0) || 0;
+  const baselineSizeBytes = Number(selectedTitleInfo?.sizeBytes || 0) || 0;
+  const candidateRows = Array.isArray(playlistCandidates) ? playlistCandidates : [];
+  const candidateMap = new Map();
+
+  for (const row of candidateRows) {
+    const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null);
+    if (!playlistId || candidateMap.has(playlistId)) {
+      continue;
+    }
+    candidateMap.set(playlistId, row);
+  }
+
+  const resolved = [];
+  const seenKeys = new Set();
+
+  const pushCandidate = (playlistIdRaw, titleInfoRaw, sourceRow = null) => {
+    const playlistId = normalizePlaylistId(playlistIdRaw || titleInfoRaw?.playlistId || null);
+    const titleInfo = enrichTitleInfoWithForcedSubtitleAvailability(
+      titleInfoRaw,
+      makeMkvSubtitleTracks
+    );
+    const handBrakeTitleId = normalizeReviewTitleId(
+      titleInfo?.handBrakeTitleId
+      || sourceRow?.handBrakeTitleId
+      || null
+    );
+    if (!playlistId || !titleInfo || !handBrakeTitleId) {
+      return;
+    }
+    const durationSeconds = Number(titleInfo?.durationSeconds || sourceRow?.durationSeconds || 0) || 0;
+    const sizeBytes = Number(titleInfo?.sizeBytes || sourceRow?.sizeBytes || 0) || 0;
+    const isSelectedDefault = (
+      (normalizedSelectedPlaylistId && playlistId === normalizedSelectedPlaylistId)
+      || (normalizedSelectedHandBrakeTitleId && handBrakeTitleId === normalizedSelectedHandBrakeTitleId)
+    );
+    if (!isSelectedDefault && durationSeconds < baselineDurationSeconds) {
+      return;
+    }
+    const dedupeKey = `${playlistId}:${handBrakeTitleId}`;
+    if (seenKeys.has(dedupeKey)) {
+      return;
+    }
+    seenKeys.add(dedupeKey);
+    resolved.push({
+      playlistId,
+      handBrakeTitleId,
+      titleInfo,
+      sourceRow,
+      durationSeconds,
+      sizeBytes,
+      playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, playlistId),
+      isSelectedDefault
+    });
+  };
+
+  if (normalizedSelectedPlaylistId && selectedTitleInfo) {
+    pushCandidate(
+      normalizedSelectedPlaylistId,
+      selectedTitleInfo,
+      candidateMap.get(normalizedSelectedPlaylistId) || null
+    );
+  }
+
+  if (normalizedCache?.byPlaylist && typeof normalizedCache.byPlaylist === 'object') {
+    for (const [playlistId, cacheEntry] of Object.entries(normalizedCache.byPlaylist)) {
+      pushCandidate(
+        playlistId,
+        cacheEntry?.titleInfo || null,
+        candidateMap.get(playlistId) || null
+      );
+    }
+  }
+
+  for (const row of candidateRows) {
+    const playlistId = normalizePlaylistId(row?.playlistId || row?.playlistFile || null);
+    if (!playlistId) {
+      continue;
+    }
+    const cacheEntry = normalizedCache?.byPlaylist?.[playlistId] || null;
+    pushCandidate(
+      playlistId,
+      cacheEntry?.titleInfo || null,
+      row
+    );
+  }
+
+  const byPriority = resolved
+    .slice()
+    .sort((left, right) => {
+      if (left.isSelectedDefault !== right.isSelectedDefault) {
+        return left.isSelectedDefault ? -1 : 1;
+      }
+      return left.durationSeconds - right.durationSeconds
+        || left.sizeBytes - right.sizeBytes
+        || String(left.playlistId || '').localeCompare(String(right.playlistId || ''))
+        || left.handBrakeTitleId - right.handBrakeTitleId;
+    });
+
+  if (byPriority.length === 0 && normalizedSelectedPlaylistId && selectedTitleInfo) {
+    return [{
+      playlistId: normalizedSelectedPlaylistId,
+      handBrakeTitleId: normalizedSelectedHandBrakeTitleId || null,
+      titleInfo: enrichTitleInfoWithForcedSubtitleAvailability(selectedTitleInfo, makeMkvSubtitleTracks),
+      sourceRow: candidateMap.get(normalizedSelectedPlaylistId) || null,
+      durationSeconds: baselineDurationSeconds,
+      sizeBytes: baselineSizeBytes,
+      playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, normalizedSelectedPlaylistId),
+      isSelectedDefault: true
+    }];
+  }
+
+  return byPriority;
+}
+
 function pickTitleIdForTrackReview(playlistAnalysis, selectedTitleId = null) {
   const explicit = normalizeNonNegativeInteger(selectedTitleId);
   if (explicit !== null) {
@@ -8116,6 +8243,23 @@ function applyEncodeTitleSelectionToPlan(encodePlan, selectedEncodeTitleId, sele
       selectedTitleIds: normalizedTitleIds,
       encodeInputTitleId: effectivePrimaryTitleId,
       encodeInputPath: selectedTitle?.filePath || null,
+      handBrakeTitleId: normalizeReviewTitleId(
+        selectedTitle?.handBrakeTitleId
+        ?? selectedTitle?.titleIndex
+        ?? encodePlan?.handBrakeTitleId
+        ?? null
+      ),
+      selectedPlaylistId: normalizePlaylistId(
+        selectedTitle?.playlistId
+        || selectedTitle?.playlistFile
+        || encodePlan?.selectedPlaylistId
+        || null
+      ),
+      selectedMakemkvTitleId: normalizeNonNegativeInteger(
+        selectedTitle?.makemkvTitleId
+        ?? encodePlan?.selectedMakemkvTitleId
+        ?? null
+      ),
       titleSelectionRequired: false
     },
     selectedTitle,
@@ -9348,6 +9492,9 @@ function resolvePrefillEncodeTitleId(reviewPlan, previousPlan) {
   if (reviewTitles.length === 0) {
     return null;
   }
+  if (Boolean(reviewPlan?.alternativeTitleSelection?.enabled)) {
+    return null;
+  }
 
   const previousSelectedTitle = findSelectedTitleInPlan(previousPlan);
   if (!previousSelectedTitle) {
@@ -11072,6 +11219,63 @@ class PipelineService extends EventEmitter {
         }
       }
 
+      if (stage === 'RIPPING' || stage === 'MEDIAINFO_CHECK') {
+        let recoveryValidation;
+        try {
+          recoveryValidation = await this.validateStartupEncodeRecoveryReadiness(row);
+        } catch (validationError) {
+          logger.warn('startup:recover-stale-review:validation-failed', {
+            jobId,
+            stage,
+            error: errorToMeta(validationError)
+          });
+          recoveryValidation = {
+            ok: false,
+            code: 'validation_failed',
+            message: `Server-Neustart: Rip-Validierung fehlgeschlagen (${validationError?.message || 'unknown'}). Bitte Rip vom Laufwerk neu starten.`
+          };
+        }
+
+        if (recoveryValidation?.ok) {
+          const reviewRecoveryMessage = stage === 'MEDIAINFO_CHECK'
+            ? 'Server-Neustart während Titel-/Spurprüfung erkannt. Rip ist bereits fertig; Review kann aus dem RAW-Backup neu gestartet werden.'
+            : 'Server-Neustart nach abgeschlossenem Rip erkannt. Review kann aus dem RAW-Backup neu gestartet werden.';
+          await historyService.updateJob(jobId, {
+            status: 'ERROR',
+            last_state: 'MEDIAINFO_CHECK',
+            end_time: nowIso(),
+            error_message: reviewRecoveryMessage,
+            raw_path: recoveryValidation?.resolvedRawPath || row?.raw_path || null,
+            rip_successful: 1
+          });
+          try {
+            await historyService.appendLog(jobId, 'SYSTEM', reviewRecoveryMessage);
+          } catch (_error) {
+            // keep recovery path even if log append fails
+          }
+          markedError += 1;
+          continue;
+        }
+
+        const recoveryMessage = String(recoveryValidation?.message || '').trim()
+          || `${message} Rip ist unvollständig. Bitte Rip vom Laufwerk neu starten.`;
+        await historyService.updateJob(jobId, {
+          status: 'ERROR',
+          last_state: 'RIPPING',
+          end_time: nowIso(),
+          error_message: recoveryMessage,
+          rip_successful: 0
+        });
+        try {
+          await historyService.appendLog(jobId, 'SYSTEM', recoveryMessage);
+        } catch (_error) {
+          // keep recovery path even if log append fails
+        }
+        markedError += 1;
+        blockedIncompleteRip += 1;
+        continue;
+      }
+
       await historyService.updateJob(jobId, {
         status: 'ERROR',
         last_state: stage,
@@ -18502,14 +18706,25 @@ class PipelineService extends EventEmitter {
       && !selectedPlaylistId
       && playlistCandidates.length > 0
     );
-    if (shouldPrepareHandBrakeDecisionData) {
+    const shouldPrepareAlternativeFeatureReviewData = Boolean(
+      selectedPlaylistId
+      && !isSeriesBackupReview
+      && playlistCandidates.length > 1
+    );
+    const shouldPrepareExpandedHandBrakePlaylistData = (
+      shouldPrepareHandBrakeDecisionData
+      || shouldPrepareAlternativeFeatureReviewData
+    );
+    if (shouldPrepareExpandedHandBrakePlaylistData) {
       const hasCompleteCache = hasCachedHandBrakeDataForPlaylistCandidates(handBrakePlaylistScan, playlistCandidates);
       if (!hasCompleteCache) {
         await this.updateProgress(
           'MEDIAINFO_CHECK',
           25,
           null,
-          'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet',
+          shouldPrepareAlternativeFeatureReviewData
+            ? 'HandBrake Trackdaten für alternative Filmfassungen werden vorbereitet'
+            : 'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet',
           jobId
         );
         try {
@@ -18540,13 +18755,17 @@ class PipelineService extends EventEmitter {
               await historyService.appendLog(
                 jobId,
                 'SYSTEM',
-                `HandBrake Playlist-Trackdaten vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.`
+                shouldPrepareAlternativeFeatureReviewData
+                  ? `HandBrake Trackdaten für alternative Filmfassungen vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.`
+                  : `HandBrake Playlist-Trackdaten vorbereitet: ${Object.keys(preparedCache.byPlaylist || {}).length} Kandidaten aus --scan -t 0 analysiert.`
               );
             } else {
               await historyService.appendLog(
                 jobId,
                 'SYSTEM',
-                'HandBrake Playlist-Trackdaten konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.'
+                shouldPrepareAlternativeFeatureReviewData
+                  ? 'HandBrake Trackdaten für alternative Filmfassungen konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.'
+                  : 'HandBrake Playlist-Trackdaten konnten aus --scan -t 0 nicht auf Kandidaten abgebildet werden.'
               );
             }
           } else {
@@ -18562,7 +18781,9 @@ class PipelineService extends EventEmitter {
           await historyService.appendLog(
             jobId,
             'SYSTEM',
-            `HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}`
+            shouldPrepareAlternativeFeatureReviewData
+              ? `HandBrake Vorbereitung für alternative Filmfassungen fehlgeschlagen: ${error.message}`
+              : `HandBrake Voranalyse für Playlist-Auswahl fehlgeschlagen: ${error.message}`
           );
           throw error;
         }
@@ -18572,7 +18793,9 @@ class PipelineService extends EventEmitter {
         await historyService.appendLog(
           jobId,
           'SYSTEM',
-          'HandBrake Playlist-Trackdaten aus Cache übernommen (kein erneuter --scan -t 0).'
+          shouldPrepareAlternativeFeatureReviewData
+            ? 'HandBrake Trackdaten für alternative Filmfassungen aus Cache übernommen (kein erneuter --scan -t 0).'
+            : 'HandBrake Playlist-Trackdaten aus Cache übernommen (kein erneuter --scan -t 0).'
         );
       }
     }
@@ -19455,188 +19678,243 @@ class PipelineService extends EventEmitter {
       };
     }
 
-    const syntheticFilePath = path.join(
-      rawPath,
-      reviewTitleSource === 'handbrake' && Number.isFinite(Number(resolvedHandBrakeTitleId)) && Number(resolvedHandBrakeTitleId) > 0
-        ? `handbrake_t${String(Math.trunc(Number(resolvedHandBrakeTitleId))).padStart(2, '0')}.mkv`
-        : `makemkv_t${String(selectedTitleForReview).padStart(2, '0')}.mkv`
+    const alternativeReviewCandidates = collectAlternativeFeatureReviewCandidates({
+      playlistCandidates,
+      handBrakePlaylistScan,
+      playlistAnalysis,
+      selectedPlaylistId: resolvedPlaylistId,
+      selectedHandBrakeTitleId: resolvedHandBrakeTitleId,
+      selectedTitleInfo: reviewTitleInfo,
+      makeMkvSubtitleTracks: makeMkvSubtitleTracksForSelection
+    });
+    const reviewCandidateRows = alternativeReviewCandidates.length > 0
+      ? alternativeReviewCandidates
+      : [{
+        playlistId: resolvedPlaylistId,
+        handBrakeTitleId: resolvedHandBrakeTitleId,
+        titleInfo: reviewTitleInfo,
+        sourceRow: null,
+        durationSeconds: Number(reviewTitleInfo?.durationSeconds || 0) || 0,
+        sizeBytes: Number(reviewTitleInfo?.sizeBytes || 0) || 0,
+        playlistInfo: resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId),
+        isSelectedDefault: true
+      }];
+
+    const buildSyntheticCandidatePath = (handBrakeTitleId, fallbackTitleId) => (
+      path.join(
+        rawPath,
+        reviewTitleSource === 'handbrake' && Number.isFinite(Number(handBrakeTitleId)) && Number(handBrakeTitleId) > 0
+          ? `handbrake_t${String(Math.trunc(Number(handBrakeTitleId))).padStart(2, '0')}.mkv`
+          : `makemkv_t${String(fallbackTitleId).padStart(2, '0')}.mkv`
+      )
     );
-    const syntheticMediaInfoByPath = {
-      [syntheticFilePath]: buildSyntheticMediaInfoFromMakeMkvTitle(reviewTitleInfo)
-    };
+
+    const reviewCandidatesWithPaths = reviewCandidateRows.map((candidate, index) => {
+      const fallbackTitleId = Number(selectedTitleForReview || index + 1) || (index + 1);
+      return {
+        ...candidate,
+        syntheticFilePath: buildSyntheticCandidatePath(candidate.handBrakeTitleId, fallbackTitleId)
+      };
+    });
+
+    const syntheticMediaInfoByPath = Object.fromEntries(
+      reviewCandidatesWithPaths.map((candidate) => [
+        candidate.syntheticFilePath,
+        buildSyntheticMediaInfoFromMakeMkvTitle(candidate.titleInfo)
+      ])
+    );
+    const allowAlternativeTitleSelection = reviewCandidatesWithPaths.length > 1;
     let review = buildMediainfoReview({
-      mediaFiles: [{
-        path: syntheticFilePath,
-        size: Number(reviewTitleInfo?.sizeBytes || 0)
-      }],
+      mediaFiles: reviewCandidatesWithPaths.map((candidate) => ({
+        path: candidate.syntheticFilePath,
+        size: Number(candidate?.titleInfo?.sizeBytes || 0)
+      })),
       mediaInfoByPath: syntheticMediaInfoByPath,
       settings,
       presetProfile,
       playlistAnalysis,
-      preferredEncodeTitleId: selectedTitleForReview,
-      selectedPlaylistId: resolvedPlaylistId || reviewTitleInfo?.playlistId || null,
-      selectedMakemkvTitleId: selectedTitleForReview
+      preferredEncodeTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview,
+      selectedPlaylistId: allowAlternativeTitleSelection
+        ? null
+        : (resolvedPlaylistId || reviewTitleInfo?.playlistId || null),
+      selectedMakemkvTitleId: allowAlternativeTitleSelection ? null : selectedTitleForReview
     });
     review = remapReviewTrackIdsToSourceIds(review);
 
-    const resolvedPlaylistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId);
     const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0);
     const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60));
-    const subtitleTrackMetaBySourceId = new Map(
-      (Array.isArray(reviewTitleInfo?.subtitleTracks) ? reviewTitleInfo.subtitleTracks : [])
-        .map((track) => {
-          const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
-          return sourceTrackId ? [sourceTrackId, track] : null;
-        })
-        .filter(Boolean)
+    const candidateMetaBySyntheticPath = new Map(
+      reviewCandidatesWithPaths.map((candidate) => [candidate.syntheticFilePath, candidate])
     );
-    const normalizedTitles = (Array.isArray(review.titles) ? review.titles : [])
-      .slice(0, 1)
-      .map((title) => {
-        const normalizedHandBrakeTitleId = normalizeReviewTitleId(
-          resolvedHandBrakeTitleId
-          ?? title?.handBrakeTitleId
-          ?? title?.titleIndex
-          ?? title?.id
-          ?? selectedTitleForReview
-        );
-        const durationSeconds = Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0);
-        const eligibleForEncode = durationSeconds >= minLengthSecondsForReview;
-        const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
-          const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
-          const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
-          const selectedByRule = Boolean(sourceMeta?.selectedByRule ?? track?.selectedByRule);
-          const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate);
-          const subtitleType = String(
-            sourceMeta?.subtitleType
-            || track?.subtitleType
-            || (sourceMeta?.forcedTrack ? 'forced' : (track?.forcedTrack ? 'forced' : 'full'))
-          ).trim().toLowerCase() === 'forced'
-            ? 'forced'
-            : 'full';
-          const rawConfidence = String(sourceMeta?.sourceConfidence || track?.sourceConfidence || '')
-            .trim()
-            .toLowerCase();
-          const confidenceSource = String(sourceMeta?.confidenceSource || track?.confidenceSource || '')
-            .trim()
-            .toLowerCase();
-          let sourceConfidence = null;
-          if (subtitleType === 'forced') {
-            if (rawConfidence === 'high' || rawConfidence === 'medium' || rawConfidence === 'low') {
-              sourceConfidence = rawConfidence;
-            } else if (confidenceSource === 'title') {
-              sourceConfidence = 'medium';
-            } else {
-              sourceConfidence = 'low';
-            }
-          }
-          const subtitlePreviewBurnIn = Boolean(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn);
-          const subtitlePreviewForced = Boolean(
-            sourceMeta?.subtitlePreviewForced
-            ?? track?.subtitlePreviewForced
-            ?? (selectedByRule && subtitleType === 'forced')
-          );
-          const subtitlePreviewForcedOnly = Boolean(
-            sourceMeta?.subtitlePreviewForcedOnly
-            ?? track?.subtitlePreviewForcedOnly
-          );
-          const isForcedOnly = Boolean(
-            sourceMeta?.isForcedOnly
-            ?? track?.isForcedOnly
-            ?? sourceMeta?.forcedOnly
-            ?? track?.forcedOnly
-            ?? subtitlePreviewForcedOnly
-            ?? subtitleType === 'forced'
-          );
-          const fullHasForced = !isForcedOnly && Boolean(
-            sourceMeta?.fullHasForced
-            ?? track?.fullHasForced
-            ?? sourceMeta?.subtitleFullHasForced
-            ?? track?.subtitleFullHasForced
-          );
-          const subtitlePreviewDefaultTrack = Boolean(
-            sourceMeta?.subtitlePreviewDefaultTrack
-            ?? track?.subtitlePreviewDefaultTrack
-            ?? sourceMeta?.defaultFlag
-            ?? track?.defaultFlag
-          );
-          const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags)
-            ? sourceMeta.subtitlePreviewFlags
-            : (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []);
-          const subtitlePreviewSummary = String(
-            sourceMeta?.subtitlePreviewSummary
-            || track?.subtitlePreviewSummary
-            || (selectedByRule
-              ? 'Übernehmen'
-              : (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'))
-          ).trim() || 'Nicht übernommen';
-          const selectedForEncode = selectedByRule;
-          return {
-            ...track,
-            id: sourceTrackId || track?.id || null,
-            sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null,
-            language: sourceMeta?.language || track?.language || 'und',
-            languageLabel: sourceMeta?.languageLabel || track?.languageLabel || track?.language || 'und',
-            title: sourceMeta?.title ?? track?.title ?? null,
-            format: sourceMeta?.format || track?.format || null,
-            defaultFlag: Boolean(sourceMeta?.defaultFlag ?? track?.defaultFlag),
-            eventCount: Number.isFinite(Number(sourceMeta?.eventCount))
-              ? Math.trunc(Number(sourceMeta.eventCount))
-              : (Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null),
-            streamSizeBytes: Number.isFinite(Number(sourceMeta?.streamSizeBytes))
-              ? Math.trunc(Number(sourceMeta.streamSizeBytes))
-              : (Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null),
-            forcedTrack: Boolean(sourceMeta?.forcedTrack ?? subtitlePreviewForced),
-            forcedAvailable: Boolean(sourceMeta?.forcedAvailable),
-            forcedSourceTrackIds: normalizeTrackIdList(sourceMeta?.forcedSourceTrackIds || []),
-            isForcedOnly,
-            fullHasForced,
-            subtitleType,
-            sourceConfidence,
-            confidenceSource,
-            duplicate,
-            selected: selectedByRule,
-            selectedByRule,
-            selectedForEncode,
-            subtitlePreviewSummary,
-            subtitlePreviewFlags,
-            subtitlePreviewBurnIn,
-            subtitlePreviewForced,
-            subtitlePreviewForcedOnly,
-            subtitlePreviewDefaultTrack,
-            burnIn: selectedForEncode ? subtitlePreviewBurnIn : false,
-            forced: selectedForEncode ? subtitlePreviewForced : false,
-            forcedOnly: selectedForEncode ? subtitlePreviewForcedOnly : false,
-            defaultTrack: selectedForEncode ? subtitlePreviewDefaultTrack : false,
-            flags: selectedForEncode ? subtitlePreviewFlags : [],
-            subtitleActionSummary: selectedForEncode ? subtitlePreviewSummary : 'Nicht übernommen'
-          };
-        });
 
+    const buildNormalizedReviewTitle = (title) => {
+      const candidateMeta = candidateMetaBySyntheticPath.get(String(title?.filePath || '').trim()) || null;
+      const candidateTitleInfo = candidateMeta?.titleInfo || reviewTitleInfo;
+      const candidatePlaylistInfo = candidateMeta?.playlistInfo || resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId);
+      const candidateHandBrakeTitleId = normalizeReviewTitleId(
+        candidateMeta?.handBrakeTitleId
+        ?? resolvedHandBrakeTitleId
+        ?? title?.handBrakeTitleId
+        ?? title?.titleIndex
+        ?? title?.id
+        ?? selectedTitleForReview
+      );
+      const durationSeconds = Number(candidateTitleInfo?.durationSeconds || title?.durationSeconds || 0);
+      const eligibleForEncode = durationSeconds >= minLengthSecondsForReview;
+      const subtitleTrackMetaBySourceId = new Map(
+        (Array.isArray(candidateTitleInfo?.subtitleTracks) ? candidateTitleInfo.subtitleTracks : [])
+          .map((track) => {
+            const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
+            return sourceTrackId ? [sourceTrackId, track] : null;
+          })
+          .filter(Boolean)
+      );
+      const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => {
+        const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null;
+        const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null;
+        const selectedByRule = Boolean(sourceMeta?.selectedByRule ?? track?.selectedByRule);
+        const duplicate = Boolean(sourceMeta?.duplicate ?? track?.duplicate);
+        const subtitleType = String(
+          sourceMeta?.subtitleType
+          || track?.subtitleType
+          || (sourceMeta?.forcedTrack ? 'forced' : (track?.forcedTrack ? 'forced' : 'full'))
+        ).trim().toLowerCase() === 'forced'
+          ? 'forced'
+          : 'full';
+        const rawConfidence = String(sourceMeta?.sourceConfidence || track?.sourceConfidence || '')
+          .trim()
+          .toLowerCase();
+        const confidenceSource = String(sourceMeta?.confidenceSource || track?.confidenceSource || '')
+          .trim()
+          .toLowerCase();
+        let sourceConfidence = null;
+        if (subtitleType === 'forced') {
+          if (rawConfidence === 'high' || rawConfidence === 'medium' || rawConfidence === 'low') {
+            sourceConfidence = rawConfidence;
+          } else if (confidenceSource === 'title') {
+            sourceConfidence = 'medium';
+          } else {
+            sourceConfidence = 'low';
+          }
+        }
+        const subtitlePreviewBurnIn = Boolean(sourceMeta?.subtitlePreviewBurnIn ?? track?.subtitlePreviewBurnIn);
+        const subtitlePreviewForced = Boolean(
+          sourceMeta?.subtitlePreviewForced
+          ?? track?.subtitlePreviewForced
+          ?? (selectedByRule && subtitleType === 'forced')
+        );
+        const subtitlePreviewForcedOnly = Boolean(
+          sourceMeta?.subtitlePreviewForcedOnly
+          ?? track?.subtitlePreviewForcedOnly
+        );
+        const isForcedOnly = Boolean(
+          sourceMeta?.isForcedOnly
+          ?? track?.isForcedOnly
+          ?? sourceMeta?.forcedOnly
+          ?? track?.forcedOnly
+          ?? subtitlePreviewForcedOnly
+          ?? subtitleType === 'forced'
+        );
+        const fullHasForced = !isForcedOnly && Boolean(
+          sourceMeta?.fullHasForced
+          ?? track?.fullHasForced
+          ?? sourceMeta?.subtitleFullHasForced
+          ?? track?.subtitleFullHasForced
+        );
+        const subtitlePreviewDefaultTrack = Boolean(
+          sourceMeta?.subtitlePreviewDefaultTrack
+          ?? track?.subtitlePreviewDefaultTrack
+          ?? sourceMeta?.defaultFlag
+          ?? track?.defaultFlag
+        );
+        const subtitlePreviewFlags = Array.isArray(sourceMeta?.subtitlePreviewFlags)
+          ? sourceMeta.subtitlePreviewFlags
+          : (Array.isArray(track?.subtitlePreviewFlags) ? track.subtitlePreviewFlags : []);
+        const subtitlePreviewSummary = String(
+          sourceMeta?.subtitlePreviewSummary
+          || track?.subtitlePreviewSummary
+          || (selectedByRule
+            ? 'Übernehmen'
+            : (duplicate ? 'Nicht übernommen (Duplikat)' : 'Nicht übernommen'))
+        ).trim() || 'Nicht übernommen';
+        const selectedForEncode = selectedByRule;
         return {
-          ...title,
-          id: normalizedHandBrakeTitleId || title?.id || null,
-          handBrakeTitleId: normalizedHandBrakeTitleId || null,
-          filePath: rawPath,
-          fileName: reviewTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`,
-          durationSeconds,
-          durationMinutes: Number(((durationSeconds / 60)).toFixed(2)),
-          selectedByMinLength: eligibleForEncode,
-          eligibleForEncode,
-          sizeBytes: Number(reviewTitleInfo?.sizeBytes || title?.sizeBytes || 0),
-          playlistId: resolvedPlaylistInfo.playlistId || title?.playlistId || null,
-          playlistFile: resolvedPlaylistInfo.playlistFile || title?.playlistFile || null,
-          playlistRecommended: Boolean(resolvedPlaylistInfo.recommended || title?.playlistRecommended),
-          playlistEvaluationLabel: resolvedPlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null,
-          playlistSegmentCommand: resolvedPlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null,
-          playlistSegmentFiles: Array.isArray(resolvedPlaylistInfo.segmentFiles) && resolvedPlaylistInfo.segmentFiles.length > 0
-            ? resolvedPlaylistInfo.segmentFiles
-            : (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []),
-          subtitleTracks
+          ...track,
+          id: sourceTrackId || track?.id || null,
+          sourceTrackId: sourceTrackId || track?.sourceTrackId || track?.id || null,
+          language: sourceMeta?.language || track?.language || 'und',
+          languageLabel: sourceMeta?.languageLabel || track?.languageLabel || track?.language || 'und',
+          title: sourceMeta?.title ?? track?.title ?? null,
+          format: sourceMeta?.format || track?.format || null,
+          defaultFlag: Boolean(sourceMeta?.defaultFlag ?? track?.defaultFlag),
+          eventCount: Number.isFinite(Number(sourceMeta?.eventCount))
+            ? Math.trunc(Number(sourceMeta.eventCount))
+            : (Number.isFinite(Number(track?.eventCount)) ? Math.trunc(Number(track.eventCount)) : null),
+          streamSizeBytes: Number.isFinite(Number(sourceMeta?.streamSizeBytes))
+            ? Math.trunc(Number(sourceMeta.streamSizeBytes))
+            : (Number.isFinite(Number(track?.streamSizeBytes)) ? Math.trunc(Number(track.streamSizeBytes)) : null),
+          forcedTrack: Boolean(sourceMeta?.forcedTrack ?? subtitlePreviewForced),
+          forcedAvailable: Boolean(sourceMeta?.forcedAvailable),
+          forcedSourceTrackIds: normalizeTrackIdList(sourceMeta?.forcedSourceTrackIds || []),
+          isForcedOnly,
+          fullHasForced,
+          subtitleType,
+          sourceConfidence,
+          confidenceSource,
+          duplicate,
+          selected: selectedByRule,
+          selectedByRule,
+          selectedForEncode,
+          subtitlePreviewSummary,
+          subtitlePreviewFlags,
+          subtitlePreviewBurnIn,
+          subtitlePreviewForced,
+          subtitlePreviewForcedOnly,
+          subtitlePreviewDefaultTrack,
+          burnIn: selectedForEncode ? subtitlePreviewBurnIn : false,
+          forced: selectedForEncode ? subtitlePreviewForced : false,
+          forcedOnly: selectedForEncode ? subtitlePreviewForcedOnly : false,
+          defaultTrack: selectedForEncode ? subtitlePreviewDefaultTrack : false,
+          flags: selectedForEncode ? subtitlePreviewFlags : [],
+          subtitleActionSummary: selectedForEncode ? subtitlePreviewSummary : 'Nicht übernommen'
         };
       });
 
-    const encodeInputTitleId = Number(normalizedTitles[0]?.id || review.encodeInputTitleId || null) || null;
+      return {
+        ...title,
+        id: candidateHandBrakeTitleId || title?.id || null,
+        handBrakeTitleId: candidateHandBrakeTitleId || null,
+        filePath: rawPath,
+        fileName: candidateTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`,
+        durationSeconds,
+        durationMinutes: Number((durationSeconds / 60).toFixed(2)),
+        selectedByMinLength: eligibleForEncode,
+        eligibleForEncode,
+        sizeBytes: Number(candidateTitleInfo?.sizeBytes || title?.sizeBytes || 0),
+        playlistId: candidatePlaylistInfo.playlistId || title?.playlistId || null,
+        playlistFile: candidatePlaylistInfo.playlistFile || title?.playlistFile || null,
+        playlistAliases: Array.isArray(candidatePlaylistInfo.playlistAliases) ? candidatePlaylistInfo.playlistAliases : [],
+        playlistRecommended: Boolean(candidatePlaylistInfo.recommended || title?.playlistRecommended),
+        playlistEvaluationLabel: candidatePlaylistInfo.evaluationLabel || title?.playlistEvaluationLabel || null,
+        playlistSegmentCommand: candidatePlaylistInfo.segmentCommand || title?.playlistSegmentCommand || null,
+        playlistSegmentFiles: Array.isArray(candidatePlaylistInfo.segmentFiles) && candidatePlaylistInfo.segmentFiles.length > 0
+          ? candidatePlaylistInfo.segmentFiles
+          : (Array.isArray(title?.playlistSegmentFiles) ? title.playlistSegmentFiles : []),
+        subtitleTracks
+      };
+    };
+
+    const normalizedTitles = (Array.isArray(review.titles) ? review.titles : [])
+      .map((title) => buildNormalizedReviewTitle(title))
+      .filter((title) => normalizeReviewTitleId(title?.id));
+
+    const encodeInputTitleId = normalizeReviewTitleId(
+      normalizedTitles.find((title) => Boolean(title?.playlistId && normalizePlaylistId(title.playlistId) === normalizePlaylistId(resolvedPlaylistId)))?.id
+      || normalizedTitles.find((title) => normalizeReviewTitleId(title?.handBrakeTitleId) === normalizeReviewTitleId(resolvedHandBrakeTitleId))?.id
+      || normalizedTitles[0]?.id
+      || review?.encodeInputTitleId
+      || null
+    );
     review = {
       ...review,
       mode,
@@ -19644,8 +19922,8 @@ class PipelineService extends EventEmitter {
       sourceJobId: options.sourceJobId || null,
       reviewConfirmed: false,
       partial: false,
-      processedFiles: 1,
-      totalFiles: 1,
+      processedFiles: normalizedTitles.length,
+      totalFiles: normalizedTitles.length,
       handBrakeTitleId: resolvedHandBrakeTitleId || null,
       selectedPlaylistId: resolvedPlaylistId || null,
       selectedMakemkvTitleId: selectedTitleForReview,
@@ -19654,12 +19932,26 @@ class PipelineService extends EventEmitter {
       selectedTitleIds: encodeInputTitleId ? [encodeInputTitleId] : [],
       encodeInputTitleId,
       encodeInputPath: rawPath,
+      alternativeTitleSelection: normalizedTitles.length > 1
+        ? {
+          enabled: true,
+          reason: 'same_or_longer_titles',
+          matchedTitleId: encodeInputTitleId || null,
+          matchedPlaylistId: normalizePlaylistId(resolvedPlaylistId) || null,
+          matchedDurationSeconds: Number(reviewTitleInfo?.durationSeconds || 0) || 0,
+          optionCount: normalizedTitles.length
+        }
+        : null,
       notes: [
         ...(Array.isArray(review.notes) ? review.notes : []),
         'MakeMKV Full-Analyse wurde einmal für Playlist-/Titel-Mapping verwendet.',
-        `HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).`
+        `HandBrake Track-Analyse aktiv: ${toPlaylistFile(resolvedPlaylistId)} -> -t ${resolvedHandBrakeTitleId} (aus --scan -t 0).`,
+        ...(normalizedTitles.length > 1
+          ? ['Weitere gleichlange oder längere Titel wurden gefunden. In der Review kann zwischen alternativen Schnittfassungen/Playlist-Varianten umgeschaltet werden.']
+          : [])
       ]
     };
+    review = applyEncodeTitleSelectionToPlan(review, encodeInputTitleId, encodeInputTitleId ? [encodeInputTitleId] : []).plan;
 
     const reviewPrefillResult = applyPreviousSelectionDefaultsToReviewPlan(
       review,
@@ -25796,6 +26088,14 @@ class PipelineService extends EventEmitter {
         error.statusCode = 400;
         throw error;
       }
+      const primaryPlanTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId);
+      const primaryPlanTitle = Array.isArray(encodePlan?.titles)
+        ? (
+          encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === primaryPlanTitleId)
+          || encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput))
+          || null
+        )
+        : null;
       let handBrakeTitleId = null;
       let directoryInput = false;
       try {
@@ -25807,7 +26107,12 @@ class PipelineService extends EventEmitter {
         handBrakeTitleId = null;
       }
       if (directoryInput) {
-        const reviewMappedTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId);
+        const reviewMappedTitleId = normalizeReviewTitleId(
+          primaryPlanTitle?.handBrakeTitleId
+          ?? primaryPlanTitle?.titleIndex
+          ?? encodePlan?.handBrakeTitleId
+          ?? null
+        );
         if (reviewMappedTitleId) {
           handBrakeTitleId = reviewMappedTitleId;
           await historyService.appendLog(
@@ -25967,14 +26272,6 @@ class PipelineService extends EventEmitter {
         }
       }
       if (!handBrakeTitleId) {
-        const primaryPlanTitleId = normalizeReviewTitleId(encodePlan?.encodeInputTitleId);
-        const primaryPlanTitle = Array.isArray(encodePlan?.titles)
-          ? (
-            encodePlan.titles.find((title) => normalizeReviewTitleId(title?.id) === primaryPlanTitleId)
-            || encodePlan.titles.find((title) => Boolean(title?.selectedForEncode || title?.encodeInput))
-            || null
-          )
-          : null;
         const primaryTitleMappedHandBrakeId = normalizeReviewTitleId(
           primaryPlanTitle?.handBrakeTitleId
           ?? primaryPlanTitle?.titleIndex
diff --git a/backend/src/utils/serverTime.js b/backend/src/utils/serverTime.js
new file mode 100644
index 0000000..f2fd97f
--- /dev/null
+++ b/backend/src/utils/serverTime.js
@@ -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
+};
diff --git a/db/database.sqlite b/db/database.sqlite
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c7c71ae..3dbb67b 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "ripster-frontend",
-  "version": "1.0.0-rc3",
+  "version": "1.0.0-rc4",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "ripster-frontend",
-      "version": "1.0.0-rc3",
+      "version": "1.0.0-rc4",
       "dependencies": {
         "chart.js": "^4.5.1",
         "primeicons": "^7.0.0",
diff --git a/frontend/package.json b/frontend/package.json
index 4fd373c..704d3c7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
 {
   "name": "ripster-frontend",
-  "version": "1.0.0-rc3",
+  "version": "1.0.0-rc4",
   "private": true,
   "type": "module",
   "scripts": {
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 6efd5d6..cbbf934 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -65,6 +65,14 @@ function isTerminalStage(value) {
   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) {
   if (!value) {
     return 0;
@@ -209,8 +217,9 @@ function App() {
     prevCdDrivesRef.current = current;
   }, [pipeline?.cdDrives]);
 
-  // Trigger a jobs refresh when a tracked job transitions into a terminal
-  // state through PIPELINE_PROGRESS, so Ripper/History update immediately.
+  // Trigger a jobs refresh when a tracked job transitions into a terminal or
+  // interactive review state through PIPELINE_PROGRESS, so Ripper/History
+  // update immediately without a manual page reload.
   useEffect(() => {
     const current = pipeline?.jobProgress && typeof pipeline.jobProgress === 'object'
       ? pipeline.jobProgress
@@ -233,6 +242,13 @@ function App() {
       ) {
         shouldRefresh = true;
       }
+      if (
+        currentState
+        && isInteractiveReviewStage(currentState)
+        && currentState !== previousState
+      ) {
+        shouldRefresh = true;
+      }
     }
 
     prevJobProgressStatesRef.current = nextStates;
@@ -394,6 +410,11 @@ function App() {
 
       if (message.type === 'PIPELINE_STATE_CHANGED') {
         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') {
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index ce03c11..bc603b6 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -388,6 +388,13 @@ export const api = {
       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 = {}) {
     const result = await request('/settings/makemkv/beta-key/apply', {
       method: 'POST',
diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx
index 123c32b..db78468 100644
--- a/frontend/src/components/DynamicSettingsForm.jsx
+++ b/frontend/src/components/DynamicSettingsForm.jsx
@@ -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 [checking, setChecking] = useState(false);
+  const [applying, setApplying] = useState(false);
   const [betaKey, setBetaKey] = useState('');
   const [validUntil, setValidUntil] = useState('');
+  const [fetchedAt, setFetchedAt] = useState('');
+  const [appliedKey, setAppliedKey] = useState('');
   const [error, setError] = useState('');
   const [warning, setWarning] = useState('');
 
-  useEffect(() => {
-    let active = true;
+  const loadCachedState = async () => {
     setLoading(true);
     setError('');
     setWarning('');
-    setValidUntil('');
+    try {
+      const response = await api.getMakeMKVBetaKey();
+      setBetaKey(String(response?.betaKey || '').trim());
+      setValidUntil(String(response?.validUntil || '').trim());
+      setFetchedAt(String(response?.fetchedAt || '').trim());
+      setAppliedKey(String(response?.appliedKey || '').trim());
+      if (response?.stale) {
+        setWarning('Zwischengespeicherter Betakey ist veraltet. Bitte bei Bedarf neu prüfen.');
+      } 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('');
+      setValidUntil('');
+      setFetchedAt('');
+      setAppliedKey('');
+      setError(loadError?.message || 'Betakey konnte nicht geladen werden.');
+    } finally {
+      setLoading(false);
+    }
+  };
 
-    api.getMakeMKVBetaKey()
-      .then((response) => {
-        if (!active) {
-          return;
-        }
-        setBetaKey(String(response?.betaKey || '').trim());
-        setValidUntil(String(response?.validUntil || '').trim());
-        setWarning(String(response?.warning || '').trim());
-      })
-      .catch((loadError) => {
-        if (!active) {
-          return;
-        }
-        setBetaKey('');
-        setValidUntil('');
-        setError(loadError?.message || 'Betakey konnte nicht geladen werden.');
-      })
-      .finally(() => {
-        if (active) {
-          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 (
     
Betakey -
{loading ? ( - Aktueller Betakey wird geladen… + Zwischengespeicherter Betakey wird geladen… ) : null} {!loading && error ? ( {error} @@ -729,6 +812,9 @@ function MakeMKVBetaKeyHint({ onApply, disabled = false }) { {!loading && !error && warning ? ( {warning} ) : null} + {!loading && !error && fetchedAt ? ( + Zuletzt geprüft: {new Date(fetchedAt).toLocaleString('de-DE')} + ) : null} {!loading && !error && validUntil ? ( Gültig bis: {new Date(validUntil).toLocaleString('de-DE')} ) : null} @@ -786,7 +872,9 @@ function SettingField({ ownerDirty, onChange, variant = 'default', - disabled = false + disabled = false, + onNotify = null, + onSettingApplied = null }) { const ownerKey = ownerSetting?.key; const pathHasValue = Boolean(String(value ?? '').trim()); @@ -905,7 +993,8 @@ function SettingField({ {normalizeSettingKey(setting?.key) === 'makemkv_registration_key' ? ( onChange?.(setting.key, nextKey)} + onNotify={onNotify} + onSettingApplied={onSettingApplied} /> ) : null} {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 settingsByKey = new Map(list.map((s) => [s.key, s])); @@ -1153,6 +1251,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect ownerError={ownerError} ownerDirty={ownerDirty} onChange={onChange} + onNotify={onNotify} + onSettingApplied={onSettingApplied} /> ); })} @@ -1173,7 +1273,8 @@ export default function DynamicSettingsForm({ onChange, effectivePaths, activationBytes = [], - onNotify + onNotify, + onSettingApplied }) { const safeCategories = Array.isArray(categories) ? categories : []; const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]); @@ -1258,6 +1359,8 @@ export default function DynamicSettingsForm({ dirtyKeys={dirtyKeys} onChange={onChange} effectivePaths={effectivePaths} + onNotify={onNotify} + onSettingApplied={onSettingApplied} /> ) : (() => { const sections = buildSectionsForCategory(category?.category, category?.settings || []); @@ -1339,6 +1442,8 @@ export default function DynamicSettingsForm({ onChange={onChangeFn} variant={variant} disabled={disabled} + onNotify={onNotify} + onSettingApplied={onSettingApplied} /> ); }; diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx index ae1f5dc..ae91f28 100644 --- a/frontend/src/components/MediaInfoReviewPanel.jsx +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -1588,6 +1588,7 @@ export default function MediaInfoReviewPanel({ commandOutputPathByTitle = {}, selectedEncodeTitleId = null, selectedEncodeTitleIds = [], + displayOnlySelectedTitle = false, titleSelectionTouched = false, allowTitleSelection = false, compactTitleSelection = false, @@ -1670,6 +1671,9 @@ export default function MediaInfoReviewPanel({ || selectedTitleIds[0] || null; 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 : []) .map((item) => { const reviewCandidate = item?.review && typeof item.review === 'object' @@ -2707,12 +2711,12 @@ export default function MediaInfoReviewPanel({ ) : null}
- {displayTitles.length === 0 ? ( + {visibleDisplayTitles.length === 0 ? (

Keine Titel analysiert.

- ) : displayTitles.map((title) => { + ) : visibleDisplayTitles.map((title) => { const normalizedTitleId = normalizeTitleId(title.id); const displayTitleId = normalizeTitleId(title?.handBrakeTitleId) || normalizedTitleId; - const titleChecked = allowTitleSelection + const titleChecked = (allowTitleSelection || displayOnlySelectedTitle) ? (selectedTitleIdSet.size > 0 ? selectedTitleIdSet.has(String(normalizedTitleId)) : Boolean(title.selectedForEncode)) @@ -2799,8 +2803,8 @@ export default function MediaInfoReviewPanel({ ); const allowTrackSelectionForTitle = Boolean( allowTrackSelection - && allowTitleSelection && titleChecked + && (allowTitleSelection || displayOnlySelectedTitle) ); return ( @@ -2813,6 +2817,15 @@ export default function MediaInfoReviewPanel({ if (!allowTitleSelection) { return; } + if (displayOnlySelectedTitle) { + if (typeof onToggleEncodeTitle === 'function') { + onToggleEncodeTitle(normalizedTitleId, true); + } + if (typeof onSelectEncodeTitle === 'function') { + onSelectEncodeTitle(normalizedTitleId); + } + return; + } const checked = Boolean(event?.target?.checked); if (typeof onToggleEncodeTitle === 'function') { onToggleEncodeTitle(normalizedTitleId, checked); diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index 524ccd4..037d28b 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -1635,17 +1635,29 @@ export default function PipelineStatusCard({ const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase(); const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip); 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( !running && retryJobId && typeof onRetry === 'function' && !queueLocked && !isOrphanCancelled - && !['converter', 'audiobook'].includes(String(jobMediaProfile || '').trim().toLowerCase()) + && !hasReviewRecoverableRaw && ['ERROR', 'CANCELLED'].includes(jobStatusUpper) && ( ['RIPPING', 'CD_RIPPING'].includes(jobLastStateUpper) - || jobErrorMessageLower.includes('server-neustart') + || ( + jobErrorMessageLower.includes('server-neustart') + && !hasReviewRecoverableRaw + ) || jobErrorMessageLower.includes('rip ist unvollständig') || jobErrorMessageLower.includes('rip-validierung fehlgeschlagen') ) @@ -2374,6 +2386,15 @@ export default function PipelineStatusCard({ ); const allowTrackSelectionInReview = 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) const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => { @@ -2550,6 +2571,31 @@ export default function PipelineStatusCard({ } return dedup; }, [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' && Boolean(pipeline?.context?.handBrakeTitleDecisionRequired); @@ -4316,6 +4362,38 @@ export default function PipelineStatusCard({ commandOutputPathByTitle={commandOutputPathByTitle} topContent={( <> + {hasAlternativeTitleSelection ? ( +
+

Alternative Titel gefunden

+ + 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. + +
+ + { + const nextTitleId = normalizeTitleId(e?.value); + if (!nextTitleId) { + return; + } + setSelectedEncodeTitleId(nextTitleId); + setSelectedEncodeTitleIds([nextTitleId]); + setTitleSelectionTouched(true); + }} + style={{ width: '100%' }} + disabled={!allowReviewSelectionEdit || alternativeTitleOptions.length <= 1} + /> +
+
+ ) : null} {multipartSettingsLocked ? (

Multipart-Lock aktiv

@@ -4354,6 +4432,7 @@ export default function PipelineStatusCard({ )} selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)} selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)} + displayOnlySelectedTitle={hasAlternativeTitleSelection} titleSelectionTouched={titleSelectionTouched} allowTitleSelection={allowTitleSelectionInReview} compactTitleSelection={isDiscTitleSelectionRequired} diff --git a/frontend/src/hooks/useWebSocket.js b/frontend/src/hooks/useWebSocket.js index 2603c72..549015c 100644 --- a/frontend/src/hooks/useWebSocket.js +++ b/frontend/src/hooks/useWebSocket.js @@ -9,6 +9,21 @@ function buildWsUrl() { 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 }) { const onMessageRef = useRef(onMessage); onMessageRef.current = onMessage; @@ -69,10 +84,19 @@ export function useWebSocket({ onMessage }) { if (message?.type === 'PIPELINE_PROGRESS') { const progressJobId = message?.payload?.activeJobId; const jobKey = progressJobId == null ? '__global__' : String(progressJobId); - latestProgressMessagesByJob.set(jobKey, message); - scheduleProgressFlush(); + const progressStage = normalizeStage(message?.payload?.state); + if (isRunningStage(progressStage)) { + latestProgressMessagesByJob.set(jobKey, message); + scheduleProgressFlush(); + } else { + latestProgressMessagesByJob.delete(jobKey); + clearProgressFlush(); + flushProgressMessage(); + onMessageRef.current?.(message); + } return; } + flushProgressMessage(); onMessageRef.current?.(message); } catch (error) { // ignore invalid json diff --git a/frontend/src/pages/RipperPage.jsx b/frontend/src/pages/RipperPage.jsx index cd87f1a..b0cf4a9 100644 --- a/frontend/src/pages/RipperPage.jsx +++ b/frontend/src/pages/RipperPage.jsx @@ -1614,7 +1614,7 @@ export default function RipperPage({ // 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), // triggering a ripper jobs reload. - const jobProgressInteractiveKey = useMemo(() => Object.entries(pipeline?.jobProgress || {}) + const jobProgressInteractiveSignature = useMemo(() => Object.entries(pipeline?.jobProgress || {}) .filter(([, v]) => { const s = String(v?.state || '').toUpperCase(); return s === 'METADATA_LOOKUP' @@ -1622,7 +1622,7 @@ export default function RipperPage({ || s === 'READY_TO_ENCODE' || s === 'WAITING_FOR_USER_DECISION'; }) - .map(([id]) => id) + .map(([id, value]) => `${id}:${String(value?.state || '').trim().toUpperCase()}`) .sort() .join(','), [pipeline?.jobProgress]); @@ -1918,7 +1918,17 @@ export default function RipperPage({ useEffect(() => { 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(() => { api.getDetectedDrives() diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 1ce12be..5f4b226 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -1476,6 +1476,15 @@ export default function SettingsPage() { effectivePaths={effectivePaths} activationBytes={activationBytes} 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 })); + }} /> )} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index f7d85ec..7dedab6 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -2228,6 +2228,13 @@ body { gap: 0.5rem; } +.settings-inline-actions { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; +} + .makemkv-beta-key-title { font-size: 0.8rem; font-weight: 700; diff --git a/package-lock.json b/package-lock.json index 3c14f34..9ca090d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "1.0.0-rc3", + "version": "1.0.0-rc4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "1.0.0-rc3", + "version": "1.0.0-rc4", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index 1295b00..f2ed2b8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "1.0.0-rc3", + "version": "1.0.0-rc4", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",