From 1502d4d9d0fb6f634730234214342c2f768216bb Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Wed, 27 May 2026 16:54:22 +0200 Subject: [PATCH] 1.0.0-rc3 rc3 --- backend/src/index.js | 2 + backend/src/routes/settingsRoutes.js | 38 +- backend/src/services/makemkvKeyService.js | 607 ++++++++++++++++-- .../src/components/DynamicSettingsForm.jsx | 15 +- 4 files changed, 616 insertions(+), 46 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index 0faae4d..32af1b9 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -24,6 +24,7 @@ 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 logger = require('./services/logger').child('BOOT'); const { errorToMeta } = require('./utils/errorMeta'); const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService'); @@ -37,6 +38,7 @@ async function start() { } catch (error) { logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) }); } + await ensureCurrentBetaKeyCacheOnStartup(); await tempCleanupService.init(); await pipelineService.init(); await converterScanService.startPolling(); diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index 4ae0dda..e2eb517 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -58,11 +58,39 @@ router.get( '/makemkv/beta-key', asyncHandler(async (req, res) => { logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId }); - const result = await fetchCurrentBetaKey(); - res.json({ - betaKey: result.key, - sourceUrl: result.sourceUrl - }); + 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; + } }) ); diff --git a/backend/src/services/makemkvKeyService.js b/backend/src/services/makemkvKeyService.js index 85c3610..a97c0ba 100644 --- a/backend/src/services/makemkvKeyService.js +++ b/backend/src/services/makemkvKeyService.js @@ -2,17 +2,318 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFile } = require('child_process'); +const { getDb } = require('../db/database'); 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( + 1, + Number(process.env.MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS || 24) +); + +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 MAKEMKV_BETA_KEY_API_USER_AGENT = process.env.MAKEMKV_BETA_KEY_API_USER_AGENT - || 'Ripster/1.0 (MakeMKV beta key sync)'; + || 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 - || 'admin@example.invalid'; + || ''; 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)) { + const error = new Error(invalidMessage); + error.statusCode = 502; + throw error; + } + 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') { + return null; + } + + let key = ''; + try { + key = parseBetaKeyCandidate(rawEntry.key || rawEntry.betaKey || '', 'Betakey-Cache ist ungültig.'); + } catch (_error) { + return null; + } + + const sourceUrl = String(rawEntry.sourceUrl || MAKEMKV_BETA_KEY_FORUM_URL).trim() || MAKEMKV_BETA_KEY_FORUM_URL; + const validUntilIsoRaw = String(rawEntry.validUntil || rawEntry.validUntilIso || '').trim(); + const validUntilMs = Date.parse(validUntilIsoRaw); + + if (!Number.isFinite(validUntilMs)) { + return null; + } + + const fetchedAtIsoRaw = String(rawEntry.fetchedAt || rawEntry.fetchedAtIso || '').trim(); + const fetchedAtMs = Date.parse(fetchedAtIsoRaw); + + return { + key, + sourceUrl, + validUntil: new Date(validUntilMs).toISOString(), + validUntilMs, + fetchedAt: Number.isFinite(fetchedAtMs) ? new Date(fetchedAtMs).toISOString() : null, + fetchedAtMs: Number.isFinite(fetchedAtMs) ? fetchedAtMs : null + }; +} + +function isCacheEntryValid(entry, nowMs = Date.now()) { + return Boolean( + entry + && typeof entry === 'object' + && typeof entry.key === 'string' + && entry.key.length >= 16 + && Number.isFinite(entry.validUntilMs) + && entry.validUntilMs > (nowMs + MAKEMKV_BETA_KEY_EXPIRY_GUARD_MS) + ); +} + +function buildPublicResult(entry, options = {}) { + return { + key: entry.key, + sourceUrl: entry.sourceUrl, + validUntil: entry.validUntil, + cached: options.cached === true, + stale: options.stale === true + }; +} + +function setInMemoryCache(entry) { + cachedBetaKeyEntry = normalizeCacheEntry(entry); +} + +async function readPersistentCacheEntry() { + try { + const db = await getDb(); + const row = await db.get('SELECT value FROM user_prefs WHERE key = ? LIMIT 1', [MAKEMKV_BETA_KEY_CACHE_PREF_KEY]); + if (!row?.value) { + return null; + } + const parsed = JSON.parse(String(row.value)); + return normalizeCacheEntry(parsed); + } catch (error) { + logger.warn('beta-key:cache-db-read-failed', { + error: error?.message || String(error) + }); + return null; + } +} + +async function persistCacheEntry(entry) { + const normalized = normalizeCacheEntry(entry); + if (!normalized) { + return; + } + + const payload = JSON.stringify({ + key: normalized.key, + sourceUrl: normalized.sourceUrl, + validUntil: normalized.validUntil, + fetchedAt: normalized.fetchedAt || new Date().toISOString() + }); + + try { + const db = await getDb(); + await db.run( + `INSERT INTO user_prefs (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + [MAKEMKV_BETA_KEY_CACHE_PREF_KEY, payload] + ); + } catch (error) { + logger.warn('beta-key:cache-db-write-failed', { + error: error?.message || String(error) + }); + } +} + +async function tryUsePersistentCache(nowMs = Date.now()) { + const persistent = await readPersistentCacheEntry(); + if (!isCacheEntryValid(persistent, nowMs)) { + return null; + } + setInMemoryCache(persistent); + return buildPublicResult(persistent, { cached: true, stale: false }); +} + +function createRateLimitError(retryAfterMs = 0) { + const parsedRetryMs = Number(retryAfterMs || 0); + const waitMs = parsedRetryMs > 0 ? parsedRetryMs : MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS; + blockedUntilMs = Math.max(blockedUntilMs, Date.now() + waitMs); + + const retryAtIso = new Date(blockedUntilMs).toISOString(); + const error = new Error(`Betakey-API limitiert Anfragen. Neuer Versuch nach ${retryAtIso}.`); + error.statusCode = 429; + error.retryAt = retryAtIso; + error.retryAfterMs = Math.max(1000, blockedUntilMs - Date.now()); + return error; +} + +function buildExistingBackoffError() { + const retryAtMs = Math.max(Date.now() + 1000, Number(blockedUntilMs || 0)); + const retryAtIso = new Date(retryAtMs).toISOString(); + const error = new Error(`Betakey-API limitiert Anfragen. Neuer Versuch nach ${retryAtIso}.`); + error.statusCode = 429; + error.retryAt = retryAtIso; + error.retryAfterMs = Math.max(1000, retryAtMs - Date.now()); + 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)) { + return new Date(parsedMs).toISOString(); + } + + const fallbackIso = new Date(Date.now() + MAKEMKV_BETA_KEY_FALLBACK_VALIDITY_HOURS * 60 * 60 * 1000).toISOString(); + logger.warn('beta-key:validity-parse-fallback', { + sourceUrl: sourceUrl || null, + fallbackIso + }); + return fallbackIso; +} function normalizeRegistrationKey(rawValue) { return String(rawValue || '').trim(); @@ -93,11 +394,100 @@ async function syncRegistrationKeyToConfig(rawValue, options = {}) { }; } -async function fetchCurrentBetaKey() { +async function fetchCurrentBetaKey(options = {}) { + const forceRefresh = options?.forceRefresh === true; + const now = Date.now(); + + if (!forceRefresh && isCacheEntryValid(cachedBetaKeyEntry, now)) { + return buildPublicResult(cachedBetaKeyEntry, { cached: true, stale: false }); + } + + if (!forceRefresh) { + const persistentCacheHit = await tryUsePersistentCache(now); + if (persistentCacheHit) { + return persistentCacheHit; + } + } + + if (!forceRefresh && blockedUntilMs > now) { + throw buildExistingBackoffError(); + } + const errors = []; try { - return await fetchCurrentBetaKeyViaCurl(); + 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 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; + 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:curl-failed', { @@ -105,18 +495,28 @@ async function fetchCurrentBetaKey() { }); } - try { - return await fetchCurrentBetaKeyViaFetch(); - } catch (error) { - errors.push(error?.message || String(error)); - 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; +} - const error = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`); - error.statusCode = 502; - throw error; +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; + } } function parseBetaKeyResponse(rawBody) { @@ -129,35 +529,62 @@ function parseBetaKeyResponse(rawBody) { throw error; } - const betaKey = String(payload?.key || '').trim(); + const betaKey = parseBetaKeyCandidate(String(payload?.key || '').trim(), 'Betakey-Antwort ist ungültig.'); - if (!/^[A-Za-z0-9][A-Za-z0-9-]{15,}$/.test(betaKey)) { - const error = new Error('Betakey-Antwort ist ungültig.'); - error.statusCode = 502; - throw error; + let validUntil = null; + const unixExpiry = Number(payload?.keydate || payload?.expires || payload?.valid_until_unix || 0); + if (Number.isFinite(unixExpiry) && unixExpiry > 0) { + validUntil = new Date(unixExpiry * 1000).toISOString(); } + const resolvedValidUntil = resolveValidityOrFallback(validUntil, MAKEMKV_BETA_KEY_API_URL); + return { key: betaKey, - sourceUrl: MAKEMKV_BETA_KEY_API_URL + sourceUrl: MAKEMKV_BETA_KEY_API_URL, + validUntil: resolvedValidUntil + }; +} + +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'
+    ];
+    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',
-      [
-        '-fsSL',
-        '--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
-        '-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
-        '-H', `From: ${MAKEMKV_BETA_KEY_API_FROM}`,
-        '-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
-      ],
+      curlArgs,
       {
         timeout: MAKEMKV_BETA_KEY_API_TIMEOUT_MS,
         maxBuffer: 1024 * 1024
@@ -168,6 +595,12 @@ function fetchCurrentBetaKeyViaCurl() {
             `curl 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;
+          }
           reject(resolvedError);
           return;
         }
@@ -183,21 +616,28 @@ 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: {
-      'User-Agent': MAKEMKV_BETA_KEY_API_USER_AGENT,
-      'From': MAKEMKV_BETA_KEY_API_FROM,
-      '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'
-    },
+    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;
   }
 
@@ -205,9 +645,96 @@ async function fetchCurrentBetaKeyViaFetch() {
   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,
   getMakeMKVConfigDir,
   getMakeMKVSettingsFilePath,
   normalizeRegistrationKey,
diff --git a/frontend/src/components/DynamicSettingsForm.jsx b/frontend/src/components/DynamicSettingsForm.jsx
index 8ece03a..123c32b 100644
--- a/frontend/src/components/DynamicSettingsForm.jsx
+++ b/frontend/src/components/DynamicSettingsForm.jsx
@@ -668,25 +668,32 @@ function isReadonlySetting(setting) {
 function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
   const [loading, setLoading] = useState(true);
   const [betaKey, setBetaKey] = useState('');
+  const [validUntil, setValidUntil] = useState('');
   const [error, setError] = useState('');
+  const [warning, setWarning] = useState('');
 
   useEffect(() => {
     let active = true;
     setLoading(true);
     setError('');
+    setWarning('');
+    setValidUntil('');
 
-    api.getMakeMKVBetaKey({ forceRefresh: true })
+    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(() => {
@@ -719,6 +726,12 @@ function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
       {!loading && error ? (
         {error}
       ) : null}
+      {!loading && !error && warning ? (
+        {warning}
+      ) : null}
+      {!loading && !error && validUntil ? (
+        Gültig bis: {new Date(validUntil).toLocaleString('de-DE')}
+      ) : null}
       {!loading && !error && betaKey ? (
         {betaKey}
       ) : null}