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}