1.0.0-rc3 rc3
This commit is contained in:
@@ -24,6 +24,7 @@ const diskDetectionService = require('./services/diskDetectionService');
|
|||||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||||
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
|
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
|
||||||
const tempCleanupService = require('./services/tempCleanupService');
|
const tempCleanupService = require('./services/tempCleanupService');
|
||||||
|
const { ensureCurrentBetaKeyCacheOnStartup } = require('./services/makemkvKeyService');
|
||||||
const logger = require('./services/logger').child('BOOT');
|
const logger = require('./services/logger').child('BOOT');
|
||||||
const { errorToMeta } = require('./utils/errorMeta');
|
const { errorToMeta } = require('./utils/errorMeta');
|
||||||
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
|
||||||
@@ -37,6 +38,7 @@ async function start() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) });
|
logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) });
|
||||||
}
|
}
|
||||||
|
await ensureCurrentBetaKeyCacheOnStartup();
|
||||||
await tempCleanupService.init();
|
await tempCleanupService.init();
|
||||||
await pipelineService.init();
|
await pipelineService.init();
|
||||||
await converterScanService.startPolling();
|
await converterScanService.startPolling();
|
||||||
|
|||||||
@@ -58,11 +58,39 @@ router.get(
|
|||||||
'/makemkv/beta-key',
|
'/makemkv/beta-key',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId });
|
logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId });
|
||||||
|
try {
|
||||||
const result = await fetchCurrentBetaKey();
|
const result = await fetchCurrentBetaKey();
|
||||||
res.json({
|
res.json({
|
||||||
betaKey: result.key,
|
betaKey: result.key,
|
||||||
sourceUrl: result.sourceUrl
|
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;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,318 @@ const fs = require('fs');
|
|||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execFile } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
|
const { getDb } = require('../db/database');
|
||||||
const logger = require('./logger').child('MAKEMKV_KEY');
|
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_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
|
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
|
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(
|
const MAKEMKV_BETA_KEY_API_TIMEOUT_MS = Math.max(
|
||||||
1000,
|
1000,
|
||||||
Number(process.env.MAKEMKV_BETA_KEY_API_TIMEOUT_MS || 15000)
|
Number(process.env.MAKEMKV_BETA_KEY_API_TIMEOUT_MS || 15000)
|
||||||
);
|
);
|
||||||
|
const MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS = Math.max(
|
||||||
|
MAKEMKV_BETA_KEY_API_TIMEOUT_MS,
|
||||||
|
Number(process.env.MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS || 30000)
|
||||||
|
);
|
||||||
|
const MAKEMKV_BETA_KEY_BACKOFF_DEFAULT_MS = Math.max(
|
||||||
|
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) {
|
function normalizeRegistrationKey(rawValue) {
|
||||||
return String(rawValue || '').trim();
|
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 = [];
|
const errors = [];
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
errors.push(error?.message || String(error));
|
errors.push(error?.message || String(error));
|
||||||
logger.warn('beta-key:curl-failed', {
|
logger.warn('beta-key:curl-failed', {
|
||||||
@@ -105,18 +495,28 @@ async function fetchCurrentBetaKey() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const finalError = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`);
|
||||||
return await fetchCurrentBetaKeyViaFetch();
|
finalError.statusCode = 502;
|
||||||
} catch (error) {
|
throw finalError;
|
||||||
errors.push(error?.message || String(error));
|
}
|
||||||
logger.warn('beta-key:fetch-failed', {
|
|
||||||
error: error?.message || String(error)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`);
|
async function ensureCurrentBetaKeyCacheOnStartup() {
|
||||||
error.statusCode = 502;
|
try {
|
||||||
throw error;
|
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) {
|
function parseBetaKeyResponse(rawBody) {
|
||||||
@@ -129,35 +529,62 @@ function parseBetaKeyResponse(rawBody) {
|
|||||||
throw error;
|
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)) {
|
let validUntil = null;
|
||||||
const error = new Error('Betakey-Antwort ist ungültig.');
|
const unixExpiry = Number(payload?.keydate || payload?.expires || payload?.valid_until_unix || 0);
|
||||||
error.statusCode = 502;
|
if (Number.isFinite(unixExpiry) && unixExpiry > 0) {
|
||||||
throw error;
|
validUntil = new Date(unixExpiry * 1000).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedValidUntil = resolveValidityOrFallback(validUntil, MAKEMKV_BETA_KEY_API_URL);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: betaKey,
|
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}?<pre><code>\s*([A-Za-z0-9-]{16,})\s*<\/code>/i
|
||||||
|
);
|
||||||
|
const firstCodeMatch = html.match(/<pre><code>\s*([A-Za-z0-9-]{16,})\s*<\/code>/i);
|
||||||
|
|
||||||
|
const keyRaw = contextualMatch?.[1] || firstCodeMatch?.[1] || '';
|
||||||
|
const key = parseBetaKeyCandidate(keyRaw, 'Betakey konnte im Forum nicht gefunden werden.');
|
||||||
|
const parsedValidity = parseForumValidUntilIsoFromHtml(html);
|
||||||
|
const validUntil = resolveValidityOrFallback(parsedValidity, sourceUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
sourceUrl,
|
||||||
|
validUntil
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchCurrentBetaKeyViaCurl() {
|
function fetchCurrentBetaKeyViaCurl() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
execFile(
|
const curlArgs = [
|
||||||
'curl',
|
|
||||||
[
|
|
||||||
'-fsSL',
|
'-fsSL',
|
||||||
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
|
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
|
||||||
'-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
|
'-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
|
||||||
'-H', `From: ${MAKEMKV_BETA_KEY_API_FROM}`,
|
|
||||||
'-H', 'Accept: text/plain, text/html;q=0.9, */*;q=0.8',
|
'-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', 'Accept-Language: de,en-US;q=0.7,en;q=0.3',
|
||||||
'-H', 'Cache-Control: no-cache',
|
'-H', 'Cache-Control: no-cache',
|
||||||
'-H', 'Pragma: 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',
|
||||||
|
curlArgs,
|
||||||
{
|
{
|
||||||
timeout: MAKEMKV_BETA_KEY_API_TIMEOUT_MS,
|
timeout: MAKEMKV_BETA_KEY_API_TIMEOUT_MS,
|
||||||
maxBuffer: 1024 * 1024
|
maxBuffer: 1024 * 1024
|
||||||
@@ -168,6 +595,12 @@ function fetchCurrentBetaKeyViaCurl() {
|
|||||||
`curl fehlgeschlagen (${error.code || 'unknown'}): ${String(stderr || error.message || '').trim() || 'keine Details'}`
|
`curl fehlgeschlagen (${error.code || 'unknown'}): ${String(stderr || error.message || '').trim() || 'keine Details'}`
|
||||||
);
|
);
|
||||||
resolvedError.statusCode = 502;
|
resolvedError.statusCode = 502;
|
||||||
|
if (/\b429\b/.test(String(stderr || ''))) {
|
||||||
|
resolvedError.httpStatus = 429;
|
||||||
|
}
|
||||||
|
if (/\b403\b/.test(String(stderr || ''))) {
|
||||||
|
resolvedError.httpStatus = 403;
|
||||||
|
}
|
||||||
reject(resolvedError);
|
reject(resolvedError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -183,21 +616,28 @@ function fetchCurrentBetaKeyViaCurl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCurrentBetaKeyViaFetch() {
|
async function fetchCurrentBetaKeyViaFetch() {
|
||||||
const response = await fetch(MAKEMKV_BETA_KEY_API_URL, {
|
const requestHeaders = {
|
||||||
headers: {
|
|
||||||
'User-Agent': MAKEMKV_BETA_KEY_API_USER_AGENT,
|
'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': 'text/plain, text/html;q=0.9, */*;q=0.8',
|
||||||
'Accept-Language': 'de,en-US;q=0.7,en;q=0.3',
|
'Accept-Language': 'de,en-US;q=0.7,en;q=0.3',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
'Pragma': '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)
|
signal: AbortSignal.timeout(MAKEMKV_BETA_KEY_API_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(`HTTP ${response.status}`);
|
const error = new Error(`HTTP ${response.status}`);
|
||||||
error.statusCode = 502;
|
error.statusCode = 502;
|
||||||
|
error.httpStatus = response.status;
|
||||||
|
error.retryAfterMs = parseRetryAfterToMs(response.headers.get('retry-after'));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,9 +645,96 @@ async function fetchCurrentBetaKeyViaFetch() {
|
|||||||
return parseBetaKeyResponse(rawBody);
|
return parseBetaKeyResponse(rawBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchCurrentBetaKeyViaForum() {
|
||||||
|
try {
|
||||||
|
return await fetchCurrentBetaKeyViaForumCurl();
|
||||||
|
} catch (error) {
|
||||||
|
const resolved = new Error(`Forum-Betakey konnte nicht geladen werden. Details: ${error?.message || String(error)}`);
|
||||||
|
resolved.statusCode = 502;
|
||||||
|
throw resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchCurrentBetaKeyViaForumCurl() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const errors = [];
|
||||||
|
const urls = [...MAKEMKV_BETA_KEY_FORUM_URLS];
|
||||||
|
|
||||||
|
const runAttempt = (index) => {
|
||||||
|
if (index >= urls.length) {
|
||||||
|
const finalError = new Error(errors.join(' | ') || 'Keine Forum-URL verfügbar');
|
||||||
|
finalError.statusCode = 502;
|
||||||
|
reject(finalError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forumUrl = urls[index];
|
||||||
|
let referer = 'https://www.makemkv.com/';
|
||||||
|
try {
|
||||||
|
referer = `${new URL(forumUrl).origin}/`;
|
||||||
|
} catch (_error) {
|
||||||
|
referer = 'https://www.makemkv.com/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const curlArgs = [
|
||||||
|
'-fsSL',
|
||||||
|
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS / 1000)),
|
||||||
|
'-A', MAKEMKV_BETA_KEY_FORUM_USER_AGENT,
|
||||||
|
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'-H', 'Accept-Language: de,en-US;q=0.7,en;q=0.3',
|
||||||
|
'-H', 'Cache-Control: no-cache',
|
||||||
|
'-H', 'Pragma: no-cache',
|
||||||
|
'-H', `Referer: ${referer}`
|
||||||
|
];
|
||||||
|
const fromHeader = String(MAKEMKV_BETA_KEY_API_FROM || '').trim();
|
||||||
|
if (fromHeader) {
|
||||||
|
curlArgs.push('-H', `From: ${fromHeader}`);
|
||||||
|
}
|
||||||
|
curlArgs.push(forumUrl);
|
||||||
|
|
||||||
|
execFile(
|
||||||
|
'curl',
|
||||||
|
curlArgs,
|
||||||
|
{
|
||||||
|
timeout: MAKEMKV_BETA_KEY_FORUM_TIMEOUT_MS,
|
||||||
|
maxBuffer: 4 * 1024 * 1024
|
||||||
|
},
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
const resolvedError = new Error(
|
||||||
|
`Forum-curl ${forumUrl} fehlgeschlagen (${error.code || 'unknown'}): ${String(stderr || error.message || '').trim() || 'keine Details'}`
|
||||||
|
);
|
||||||
|
resolvedError.statusCode = 502;
|
||||||
|
if (/\b429\b/.test(String(stderr || ''))) {
|
||||||
|
resolvedError.httpStatus = 429;
|
||||||
|
}
|
||||||
|
if (/\b403\b/.test(String(stderr || ''))) {
|
||||||
|
resolvedError.httpStatus = 403;
|
||||||
|
}
|
||||||
|
errors.push(resolvedError.message);
|
||||||
|
runAttempt(index + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolve(parseBetaKeyFromForumHtml(stdout, forumUrl));
|
||||||
|
} catch (parseError) {
|
||||||
|
errors.push(`Forum-Parse ${forumUrl} fehlgeschlagen: ${parseError?.message || String(parseError)}`);
|
||||||
|
runAttempt(index + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
runAttempt(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
MAKEMKV_BETA_KEY_API_URL,
|
MAKEMKV_BETA_KEY_API_URL,
|
||||||
|
MAKEMKV_BETA_KEY_FORUM_URL,
|
||||||
fetchCurrentBetaKey,
|
fetchCurrentBetaKey,
|
||||||
|
ensureCurrentBetaKeyCacheOnStartup,
|
||||||
getMakeMKVConfigDir,
|
getMakeMKVConfigDir,
|
||||||
getMakeMKVSettingsFilePath,
|
getMakeMKVSettingsFilePath,
|
||||||
normalizeRegistrationKey,
|
normalizeRegistrationKey,
|
||||||
|
|||||||
@@ -668,25 +668,32 @@ function isReadonlySetting(setting) {
|
|||||||
function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
|
function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [betaKey, setBetaKey] = useState('');
|
const [betaKey, setBetaKey] = useState('');
|
||||||
|
const [validUntil, setValidUntil] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [warning, setWarning] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
setWarning('');
|
||||||
|
setValidUntil('');
|
||||||
|
|
||||||
api.getMakeMKVBetaKey({ forceRefresh: true })
|
api.getMakeMKVBetaKey()
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBetaKey(String(response?.betaKey || '').trim());
|
setBetaKey(String(response?.betaKey || '').trim());
|
||||||
|
setValidUntil(String(response?.validUntil || '').trim());
|
||||||
|
setWarning(String(response?.warning || '').trim());
|
||||||
})
|
})
|
||||||
.catch((loadError) => {
|
.catch((loadError) => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBetaKey('');
|
setBetaKey('');
|
||||||
|
setValidUntil('');
|
||||||
setError(loadError?.message || 'Betakey konnte nicht geladen werden.');
|
setError(loadError?.message || 'Betakey konnte nicht geladen werden.');
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -719,6 +726,12 @@ function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
|
|||||||
{!loading && error ? (
|
{!loading && error ? (
|
||||||
<small className="error-text">{error}</small>
|
<small className="error-text">{error}</small>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!loading && !error && warning ? (
|
||||||
|
<small className="setting-description">{warning}</small>
|
||||||
|
) : null}
|
||||||
|
{!loading && !error && validUntil ? (
|
||||||
|
<small className="setting-description">Gültig bis: {new Date(validUntil).toLocaleString('de-DE')}</small>
|
||||||
|
) : null}
|
||||||
{!loading && !error && betaKey ? (
|
{!loading && !error && betaKey ? (
|
||||||
<code className="makemkv-beta-key-value">{betaKey}</code>
|
<code className="makemkv-beta-key-value">{betaKey}</code>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user