mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-09 17:07:35 +00:00
- corrige l’extraction Fsvid/Vidzy dans proxiesembed, les extensions et le userscript avec un décodage XOR/P.A.C.K.E.R plus robuste - rejette les URL « troll » et supprime les résultats Fsvid/Vidzy contaminés des caches - ajoute KissKH pour les films et séries avec matching TMDB, catalogue, cache distribué, verrouillage, rotation des proxys et fallback sécurisé - ajoute le relais KissKH HLS/MP4 dans proxiesembed, avec retry SOCKS5 sur les erreurs 403 et streaming des segments BYTERANGE - prend en charge les sous-titres KissKH SRT/TXT/TXT2, leur déchiffrement AES et leur conversion en WebVTT - intègre KissKH au lecteur, aux priorités de sources, aux extensions Chrome/Firefox et au userscript - améliore la sélection, le formatage sécurisé, la traduction et l’affichage Picture-in-Picture des sous-titres - corrige le Cast web et Android pour transmettre les sous-titres internes, externes, KissKH et traduits - ajoute le support des pistes WebVTT inline via le relais LAN Android et améliore le suivi des sessions Cast - protège les liens jouables des caches Cpasmal et Coflix contre les rafraîchissements vides ou concurrents - fiabilise la sélection des saisons FStream et améliore le matching DarkiWorld par TMDB, titre et année - simplifie les nouvelles URL TMDB tout en conservant le décodage des anciens identifiants encodés - corrige l’opacité des icônes SVG dans toute l’interface en séparant la couleur de leur niveau d’opacité - met à jour le Dockerfile vers Node.js 22 et installe uniquement les dépendances runtime du workspace Hono - ajoute le workspace server avec ses dépendances dédiées et un arrêt gracieux sur SIGTERM/SIGINT - accélère le healthcheck Docker et évite de recopier tous les node_modules du builder dans l’image finale - retire les migrations et créations de tables automatiques au démarrage de la Main API au profit de db:init - attribue un port CycleTLS distinct à chaque worker pour éviter les conflits en mode cluster - ajoute des tests de non-régression pour KissKH, les caches de sources, CycleTLS et le Cast Android - publie Android 2.5.8 build 17 avec le nouvel APK - met à jour les extensions Chrome 1.3.13 et Firefox 1.5.10 Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
641 lines
25 KiB
JavaScript
641 lines
25 KiB
JavaScript
const express = require('express');
|
|
const path = require('node:path');
|
|
const { createBundleRegistry } = require('../services/kisskh/bundleRegistry');
|
|
const {
|
|
createFallbackCapabilityStore,
|
|
createFallbackDescriptorValidator,
|
|
createKisskhCache,
|
|
} = require('../services/kisskh/kisskhCache');
|
|
const { createKisskhClient } = require('../services/kisskh/kisskhClient');
|
|
const { fromEnv } = require('../services/kisskh/config');
|
|
const { KisskhError } = require('../services/kisskh/errors');
|
|
const { createKisskhProxyPolicy } = require('../services/kisskh/proxyPolicy');
|
|
const { createKisskhResolver, validatePublicProxyOrigin } = require('../services/kisskh/kisskhResolver');
|
|
|
|
const STATUS_BY_CODE = Object.freeze({
|
|
episode_missing: 404,
|
|
invalid_input: 400,
|
|
not_found: 404,
|
|
provider_changed: 502,
|
|
provider_rate_limited: 429,
|
|
provider_security: 502,
|
|
provider_unavailable: 502,
|
|
proxy_unavailable: 503,
|
|
upstream_unavailable: 502,
|
|
});
|
|
const GENERIC_NOT_FOUND = Object.freeze({ code: 'not_found', message: 'Ressource introuvable' });
|
|
const STARTING_RETRIEVAL_PROGRESS = Object.freeze({
|
|
phase: 'starting', completed: 0, total: null, percent: null,
|
|
});
|
|
const RETRIEVAL_FAILURE_TTL_MS = 15_000;
|
|
const RETRIEVAL_FAILURE_MAX_ENTRIES = 256;
|
|
const RETRIEVAL_LOG_MAX_ENTRIES = 256;
|
|
const RETRIEVAL_LOG_MAX_EVENTS = 64;
|
|
|
|
function invalidInput() {
|
|
return new KisskhError('invalid_input', 'Parametre KissKH invalide');
|
|
}
|
|
|
|
function upstreamError() {
|
|
return new KisskhError('upstream_unavailable', 'Lecture KissKH indisponible');
|
|
}
|
|
|
|
function parseCanonicalInteger(value, minimum) {
|
|
if (typeof value !== 'string') throw invalidInput();
|
|
const pattern = minimum === 0 ? /^(?:0|[1-9]\d*)$/ : /^[1-9]\d*$/;
|
|
if (!pattern.test(value)) throw invalidInput();
|
|
const parsed = Number(value);
|
|
if (!Number.isSafeInteger(parsed) || parsed < minimum) throw invalidInput();
|
|
return parsed;
|
|
}
|
|
|
|
function parseTvRequest(params, query) {
|
|
if (!params || typeof params !== 'object' || !query || typeof query !== 'object') throw invalidInput();
|
|
return {
|
|
tmdbId: parseCanonicalInteger(params.tmdbId, 1),
|
|
season: parseCanonicalInteger(query.season, 0),
|
|
episode: parseCanonicalInteger(query.episode, 1),
|
|
};
|
|
}
|
|
|
|
function parseMovieRequest(params) {
|
|
if (!params || typeof params !== 'object') throw invalidInput();
|
|
return {
|
|
tmdbId: parseCanonicalInteger(params.tmdbId, 1),
|
|
season: 0,
|
|
episode: 1,
|
|
};
|
|
}
|
|
|
|
function exactKeys(value, keys) {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
&& Object.keys(value).sort().join(',') === [...keys].sort().join(',');
|
|
}
|
|
|
|
function publicRetrievalProgress(value) {
|
|
if (!exactKeys(value, ['phase', 'completed', 'total', 'percent'])
|
|
|| !['starting', 'catalog', 'finalizing'].includes(value.phase)
|
|
|| !Number.isSafeInteger(value.completed) || value.completed < 0
|
|
|| (value.total !== null && (!Number.isSafeInteger(value.total) || value.total <= 0))
|
|
|| (value.percent !== null && (!Number.isSafeInteger(value.percent)
|
|
|| value.percent < 0 || value.percent > 100))) return null;
|
|
if (value.total === null) {
|
|
if (value.completed !== 0 || value.percent !== null) return null;
|
|
} else if (value.completed > value.total
|
|
|| value.percent !== Math.floor((value.completed / value.total) * 100)) {
|
|
return null;
|
|
}
|
|
return {
|
|
phase: value.phase,
|
|
completed: value.completed,
|
|
total: value.total,
|
|
percent: value.percent,
|
|
};
|
|
}
|
|
|
|
async function currentRetrievalProgress(resolver) {
|
|
if (typeof resolver?.getRetrievalProgress !== 'function') return null;
|
|
try {
|
|
return publicRetrievalProgress(await resolver.getRetrievalProgress());
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function warmCatalogInBackground(resolver) {
|
|
if (typeof resolver?.warmCatalog !== 'function') return;
|
|
void resolver.warmCatalog().catch(() => {});
|
|
}
|
|
|
|
function safeString(value, maximum = 256) {
|
|
if (typeof value !== 'string' || !value || value.length > maximum || /[\r\n\0]/.test(value)) throw upstreamError();
|
|
return value;
|
|
}
|
|
|
|
function proxyDescendant(value, origin) {
|
|
const raw = safeString(value, 4_096);
|
|
let url;
|
|
try { url = new URL(raw); } catch { throw upstreamError(); }
|
|
const sourceUrl = url.searchParams.get('url');
|
|
let source;
|
|
try { source = new URL(sourceUrl); } catch { throw upstreamError(); }
|
|
if (url.origin !== origin || url.pathname !== '/kisskh-proxy' || url.searchParams.size !== 1
|
|
|| url.username || url.password || url.hash || !['http:', 'https:'].includes(source.protocol)
|
|
|| source.username || source.password || source.hash) {
|
|
throw upstreamError();
|
|
}
|
|
return url.href;
|
|
}
|
|
|
|
function subtitleSource(value) {
|
|
const raw = safeString(value, 8_192);
|
|
let url;
|
|
try { url = new URL(raw); } catch { throw upstreamError(); }
|
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
|
|
throw upstreamError();
|
|
}
|
|
return url.href;
|
|
}
|
|
|
|
function validateCipher(value, format) {
|
|
if (format === 'srt' && exactKeys(value, ['mode']) && value.mode === 'none') return { mode: 'none' };
|
|
if (format === 'txt1' && exactKeys(value, ['mode', 'scheme'])
|
|
&& value.mode === 'unsupported' && value.scheme === 'a2') return { mode: 'unsupported', scheme: 'a2' };
|
|
if (['txt', 'txt2'].includes(format) && exactKeys(value, [
|
|
'ivBase64', 'keyBase64', 'mode', 'padding', 'payloadEncoding',
|
|
]) && value.mode === 'aes-128-cbc' && value.payloadEncoding === 'base64-per-cue' && value.padding === 'pkcs7') {
|
|
for (const field of ['keyBase64', 'ivBase64']) {
|
|
const material = safeString(value[field], 64);
|
|
const decoded = Buffer.from(material, 'base64');
|
|
if (decoded.length !== 16 || decoded.toString('base64') !== material) throw upstreamError();
|
|
}
|
|
return {
|
|
mode: 'aes-128-cbc',
|
|
keyBase64: value.keyBase64,
|
|
ivBase64: value.ivBase64,
|
|
payloadEncoding: 'base64-per-cue',
|
|
padding: 'pkcs7',
|
|
};
|
|
}
|
|
throw upstreamError();
|
|
}
|
|
|
|
function validatePublicResolution(value, expected, publicOrigin, proxyMedia) {
|
|
if (!exactKeys(value, ['match', 'sources', 'subtitles'])
|
|
|| !exactKeys(value.match, ['episode', 'episodeId', 'kisskhDramaId', 'season', 'tmdbId'])
|
|
|| value.match.tmdbId !== expected.tmdbId || value.match.season !== expected.season
|
|
|| value.match.episode !== expected.episode || !Number.isSafeInteger(value.match.kisskhDramaId)
|
|
|| value.match.kisskhDramaId <= 0 || !Number.isSafeInteger(value.match.episodeId) || value.match.episodeId <= 0
|
|
|| !Array.isArray(value.sources) || value.sources.length !== 1
|
|
|| !Array.isArray(value.subtitles) || value.subtitles.length > 128) throw upstreamError();
|
|
const source = value.sources[0];
|
|
if (!exactKeys(source, ['fallbackToken', 'id', 'label', 'type', 'url'])
|
|
|| source.id !== 'kisskh-primary' || source.label !== 'KissKH'
|
|
|| !['hls', 'mp4'].includes(source.type)
|
|
|| typeof source.fallbackToken !== 'string' || !/^[A-Za-z0-9_-]{22,128}$/.test(source.fallbackToken)) {
|
|
throw upstreamError();
|
|
}
|
|
const subtitles = value.subtitles.map((track) => {
|
|
if (!exactKeys(track, ['cipher', 'format', 'id', 'label', 'lang', 'proxyUrl', 'sourceUrl'])
|
|
|| !['srt', 'txt', 'txt1', 'txt2'].includes(track.format)) throw upstreamError();
|
|
return {
|
|
id: safeString(track.id, 128),
|
|
lang: safeString(track.lang, 64),
|
|
label: safeString(track.label, 256),
|
|
format: track.format,
|
|
sourceUrl: subtitleSource(track.sourceUrl),
|
|
proxyUrl: proxyMedia
|
|
? proxyDescendant(track.proxyUrl, publicOrigin)
|
|
: subtitleSource(track.proxyUrl),
|
|
cipher: validateCipher(track.cipher, track.format),
|
|
};
|
|
});
|
|
return {
|
|
match: { ...value.match },
|
|
sources: [{
|
|
id: 'kisskh-primary',
|
|
label: 'KissKH',
|
|
type: source.type,
|
|
url: proxyMedia
|
|
? proxyDescendant(source.url, publicOrigin)
|
|
: subtitleSource(source.url),
|
|
fallbackToken: source.fallbackToken,
|
|
}],
|
|
subtitles,
|
|
};
|
|
}
|
|
|
|
function validateFallbackRecord(value, validateDescriptor) {
|
|
try {
|
|
return validateDescriptor(value);
|
|
} catch {
|
|
throw upstreamError();
|
|
}
|
|
}
|
|
|
|
function sendError(res, error, logs) {
|
|
const code = Object.hasOwn(STATUS_BY_CODE, error?.code) ? error.code : 'provider_unavailable';
|
|
const safe = error instanceof KisskhError
|
|
? { code, message: error.safeMessage }
|
|
: { code, message: 'KissKH indisponible' };
|
|
if (Array.isArray(logs) && logs.length) safe.logs = logs;
|
|
return res.status(STATUS_BY_CODE[code]).json(safe);
|
|
}
|
|
|
|
function createKisskhRouter(deps = {}) {
|
|
const router = express.Router();
|
|
const enabled = deps.enabled === true;
|
|
const publicOrigin = validatePublicProxyOrigin(deps.publicProxyUrl);
|
|
const resolver = deps.resolver;
|
|
const capabilityStore = deps.capabilityStore;
|
|
const proxyPolicy = deps.proxyPolicy;
|
|
const verifyAccessKey = deps.verifyAccessKey;
|
|
const now = deps.now || Date.now;
|
|
const activeRetrievals = new Map();
|
|
const retrievalFailures = new Map();
|
|
const retrievalLogs = new Map();
|
|
const validateFallbackDescriptor = enabled ? createFallbackDescriptorValidator({
|
|
providerBaseUrl: deps.providerBaseUrl,
|
|
now: deps.now,
|
|
}) : null;
|
|
if (enabled && (typeof resolver?.resolveTv !== 'function' || typeof capabilityStore?.consume !== 'function'
|
|
|| typeof proxyPolicy?.assertCircuitClosed !== 'function')) throw new TypeError('route KissKH invalide');
|
|
|
|
function retrievalKey(mediaType, request) {
|
|
return `${mediaType}:${request.tmdbId}:${request.season}:${request.episode}`;
|
|
}
|
|
|
|
function appendRetrievalLog(key, event) {
|
|
if (!event || typeof event !== 'object' || Array.isArray(event)) return;
|
|
let events = retrievalLogs.get(key);
|
|
if (!events) {
|
|
events = [];
|
|
} else {
|
|
retrievalLogs.delete(key);
|
|
}
|
|
retrievalLogs.set(key, events);
|
|
events.push({ ...event, resolutionId: key });
|
|
if (events.length > RETRIEVAL_LOG_MAX_EVENTS) {
|
|
events.splice(0, events.length - RETRIEVAL_LOG_MAX_EVENTS);
|
|
}
|
|
while (retrievalLogs.size > RETRIEVAL_LOG_MAX_ENTRIES) {
|
|
retrievalLogs.delete(retrievalLogs.keys().next().value);
|
|
}
|
|
}
|
|
|
|
function currentRetrievalLogs(key) {
|
|
return (retrievalLogs.get(key) || []).map((event) => ({ ...event }));
|
|
}
|
|
|
|
function currentRetrievalFailure(key) {
|
|
const failure = retrievalFailures.get(key);
|
|
if (!failure) return null;
|
|
if (now() >= failure.expiresAt) {
|
|
retrievalFailures.delete(key);
|
|
return null;
|
|
}
|
|
return failure.error;
|
|
}
|
|
|
|
function rememberRetrievalFailure(key, error) {
|
|
retrievalFailures.delete(key);
|
|
retrievalFailures.set(key, { error, expiresAt: now() + RETRIEVAL_FAILURE_TTL_MS });
|
|
while (retrievalFailures.size > RETRIEVAL_FAILURE_MAX_ENTRIES) {
|
|
retrievalFailures.delete(retrievalFailures.keys().next().value);
|
|
}
|
|
}
|
|
|
|
function startBackgroundRetrieval(key, operation, _logMessage, context) {
|
|
const startedAt = now();
|
|
if (activeRetrievals.has(key)) return activeRetrievals.get(key);
|
|
retrievalFailures.delete(key);
|
|
retrievalLogs.delete(key);
|
|
appendRetrievalLog(key, {
|
|
...context,
|
|
phase: 'background_started',
|
|
elapsedMs: 0,
|
|
});
|
|
const promise = Promise.resolve()
|
|
.then(async () => {
|
|
try {
|
|
await operation((event) => appendRetrievalLog(key, event));
|
|
return { delegated: false };
|
|
} catch (error) {
|
|
if (error?.details?.reason !== 'lock_contended') throw error;
|
|
appendRetrievalLog(key, {
|
|
...context,
|
|
phase: 'delegated_to_active_worker',
|
|
elapsedMs: Math.max(0, now() - startedAt),
|
|
});
|
|
return { delegated: true };
|
|
}
|
|
})
|
|
.then(({ delegated }) => {
|
|
if (delegated) return;
|
|
retrievalFailures.delete(key);
|
|
appendRetrievalLog(key, {
|
|
...context,
|
|
phase: 'completed',
|
|
elapsedMs: Math.max(0, now() - startedAt),
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
rememberRetrievalFailure(key, error);
|
|
appendRetrievalLog(key, {
|
|
...context,
|
|
phase: 'failed',
|
|
elapsedMs: Math.max(0, now() - startedAt),
|
|
code: error?.code || 'provider_unavailable',
|
|
reason: error?.safeMessage || 'KissKH indisponible',
|
|
});
|
|
})
|
|
.finally(() => {
|
|
if (activeRetrievals.get(key) === promise) activeRetrievals.delete(key);
|
|
});
|
|
activeRetrievals.set(key, promise);
|
|
return promise;
|
|
}
|
|
|
|
router.use((_req, res, next) => {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
next();
|
|
});
|
|
|
|
router.get('/tv/:tmdbId', async (req, res) => {
|
|
if (!enabled) return sendError(res, new KisskhError('proxy_unavailable', 'KissKH desactive'));
|
|
let parsed;
|
|
try {
|
|
parsed = parseTvRequest(req.params, req.query);
|
|
const accessKey = typeof req.headers['x-access-key'] === 'string'
|
|
? req.headers['x-access-key']
|
|
: null;
|
|
const vipStatus = typeof verifyAccessKey === 'function'
|
|
? await verifyAccessKey(accessKey)
|
|
: { vip: false };
|
|
const proxyMedia = vipStatus?.vip === true;
|
|
if (typeof resolver.getCachedTv === 'function' && typeof resolver.warmTv === 'function') {
|
|
const key = retrievalKey('tv', parsed);
|
|
const logContext = { mediaType: 'tv', ...parsed };
|
|
const cached = await resolver.getCachedTv(parsed, { proxyMedia });
|
|
if (!cached) {
|
|
const failure = currentRetrievalFailure(key);
|
|
if (failure) return sendError(res, failure, currentRetrievalLogs(key));
|
|
res.setHeader('Retry-After', '1');
|
|
startBackgroundRetrieval(
|
|
key,
|
|
(onProgress) => resolver.warmTv(parsed, { onProgress }),
|
|
'[KISSKH] background resolution failed',
|
|
logContext,
|
|
);
|
|
return res.status(202).json({
|
|
code: 'retrieval_in_progress',
|
|
message: 'Récupération KissKH en cours',
|
|
progress: await currentRetrievalProgress(resolver) || STARTING_RETRIEVAL_PROGRESS,
|
|
logs: currentRetrievalLogs(key),
|
|
});
|
|
}
|
|
if (!currentRetrievalFailure(key)) {
|
|
startBackgroundRetrieval(
|
|
key,
|
|
(onProgress) => resolver.warmTv(parsed, { onProgress }),
|
|
'[KISSKH] background resolution refresh failed',
|
|
logContext,
|
|
);
|
|
}
|
|
warmCatalogInBackground(resolver);
|
|
return res.json(validatePublicResolution(cached, parsed, publicOrigin, proxyMedia));
|
|
}
|
|
warmCatalogInBackground(resolver);
|
|
const result = await resolver.resolveTv(parsed, { proxyMedia });
|
|
return res.json(validatePublicResolution(result, parsed, publicOrigin, proxyMedia));
|
|
} catch (error) {
|
|
return sendError(res, error);
|
|
}
|
|
});
|
|
|
|
router.get('/movie/:tmdbId', async (req, res) => {
|
|
if (!enabled) return sendError(res, new KisskhError('proxy_unavailable', 'KissKH desactive'));
|
|
let parsed;
|
|
try {
|
|
parsed = parseMovieRequest(req.params);
|
|
const accessKey = typeof req.headers['x-access-key'] === 'string'
|
|
? req.headers['x-access-key']
|
|
: null;
|
|
const vipStatus = typeof verifyAccessKey === 'function'
|
|
? await verifyAccessKey(accessKey)
|
|
: { vip: false };
|
|
const proxyMedia = vipStatus?.vip === true;
|
|
if (typeof resolver.getCachedMovie === 'function' && typeof resolver.warmMovie === 'function') {
|
|
const key = retrievalKey('movie', parsed);
|
|
const logContext = { mediaType: 'movie', ...parsed };
|
|
const cached = await resolver.getCachedMovie(parsed, { proxyMedia });
|
|
if (!cached) {
|
|
const failure = currentRetrievalFailure(key);
|
|
if (failure) return sendError(res, failure, currentRetrievalLogs(key));
|
|
res.setHeader('Retry-After', '1');
|
|
startBackgroundRetrieval(
|
|
key,
|
|
(onProgress) => resolver.warmMovie(parsed, { onProgress }),
|
|
'[KISSKH] background movie resolution failed',
|
|
logContext,
|
|
);
|
|
return res.status(202).json({
|
|
code: 'retrieval_in_progress',
|
|
message: 'Récupération KissKH en cours',
|
|
progress: await currentRetrievalProgress(resolver) || STARTING_RETRIEVAL_PROGRESS,
|
|
logs: currentRetrievalLogs(key),
|
|
});
|
|
}
|
|
if (!currentRetrievalFailure(key)) {
|
|
startBackgroundRetrieval(
|
|
key,
|
|
(onProgress) => resolver.warmMovie(parsed, { onProgress }),
|
|
'[KISSKH] background movie resolution refresh failed',
|
|
logContext,
|
|
);
|
|
}
|
|
warmCatalogInBackground(resolver);
|
|
return res.json(validatePublicResolution(cached, parsed, publicOrigin, proxyMedia));
|
|
}
|
|
warmCatalogInBackground(resolver);
|
|
const result = await resolver.resolveMovie(parsed, { proxyMedia });
|
|
return res.json(validatePublicResolution(result, parsed, publicOrigin, proxyMedia));
|
|
} catch (error) {
|
|
return sendError(res, error);
|
|
}
|
|
});
|
|
|
|
router.post('/fallback/:token', async (req, res) => {
|
|
if (!enabled) return sendError(res, new KisskhError('proxy_unavailable', 'KissKH desactive'));
|
|
try {
|
|
const record = await capabilityStore.consume(req.params.token);
|
|
if (!record) return res.status(404).json(GENERIC_NOT_FOUND);
|
|
await proxyPolicy.assertCircuitClosed();
|
|
return res.json(validateFallbackRecord(record, validateFallbackDescriptor));
|
|
} catch (error) {
|
|
return sendError(res, error);
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
function runtimeEnvironment(overrides) {
|
|
if (overrides) return overrides;
|
|
return {
|
|
KISSKH_ENABLED: process.env.KISSKH_ENABLED,
|
|
KISSKH_BASE_URL: process.env.KISSKH_BASE_URL,
|
|
KISSKH_ALLOWED_HOSTS: process.env.KISSKH_ALLOWED_HOSTS,
|
|
KISSKH_MEDIA_ALLOWED_HOSTS: process.env.KISSKH_MEDIA_ALLOWED_HOSTS,
|
|
KISSKH_SUBTITLE_ALLOWED_HOSTS: process.env.KISSKH_SUBTITLE_ALLOWED_HOSTS,
|
|
KISSKH_METADATA_TIMEOUT_MS: process.env.KISSKH_METADATA_TIMEOUT_MS,
|
|
KISSKH_METADATA_MAX_ATTEMPTS: process.env.KISSKH_METADATA_MAX_ATTEMPTS,
|
|
KISSKH_PROXY_QUARANTINE_BASE_MS: process.env.KISSKH_PROXY_QUARANTINE_BASE_MS,
|
|
KISSKH_PROXY_QUARANTINE_MAX_MS: process.env.KISSKH_PROXY_QUARANTINE_MAX_MS,
|
|
KISSKH_CIRCUIT_DEFAULT_MS: process.env.KISSKH_CIRCUIT_DEFAULT_MS,
|
|
KISSKH_BUNDLE_CHECK_TTL_SECONDS: process.env.KISSKH_BUNDLE_CHECK_TTL_SECONDS,
|
|
KISSKH_BUNDLE_STALE_MAX_SECONDS: process.env.KISSKH_BUNDLE_STALE_MAX_SECONDS,
|
|
KISSKH_MATCH_TTL_SECONDS: process.env.KISSKH_MATCH_TTL_SECONDS,
|
|
KISSKH_EPISODES_TTL_SECONDS: process.env.KISSKH_EPISODES_TTL_SECONDS,
|
|
KISSKH_EPISODE_SUB_TTL_SECONDS: process.env.KISSKH_EPISODE_SUB_TTL_SECONDS,
|
|
KISSKH_NOT_FOUND_TTL_SECONDS: process.env.KISSKH_NOT_FOUND_TTL_SECONDS,
|
|
KISSKH_RESOLVE_LOCK_MS: process.env.KISSKH_RESOLVE_LOCK_MS,
|
|
KISSKH_FALLBACK_TOKEN_TTL_SECONDS: process.env.KISSKH_FALLBACK_TOKEN_TTL_SECONDS,
|
|
PROXIESEMBED_PUBLIC_URL: process.env.PROXIESEMBED_PUBLIC_URL,
|
|
PROXY_SERVER_URL: process.env.PROXY_SERVER_URL,
|
|
};
|
|
}
|
|
|
|
function resolvePublicProxyUrl(environ) {
|
|
if (environ.PROXIESEMBED_PUBLIC_URL) {
|
|
return validatePublicProxyOrigin(environ.PROXIESEMBED_PUBLIC_URL);
|
|
}
|
|
if (environ.PROXY_SERVER_URL) {
|
|
let sharedUrl;
|
|
try {
|
|
sharedUrl = new URL(environ.PROXY_SERVER_URL);
|
|
} catch {
|
|
throw new TypeError('PROXY_SERVER_URL invalide');
|
|
}
|
|
if (sharedUrl.username || sharedUrl.password || sharedUrl.search || sharedUrl.hash) {
|
|
throw new TypeError('PROXY_SERVER_URL invalide');
|
|
}
|
|
return validatePublicProxyOrigin(sharedUrl.origin);
|
|
}
|
|
return 'http://127.0.0.1:25569';
|
|
}
|
|
|
|
function positiveInteger(environ, name, fallback, maximum = Number.MAX_SAFE_INTEGER) {
|
|
const raw = environ[name];
|
|
if (raw === undefined || raw === '') return fallback;
|
|
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw)
|
|
|| !Number.isSafeInteger(Number(raw)) || Number(raw) > maximum) {
|
|
throw new TypeError(`${name} invalide`);
|
|
}
|
|
return Number(raw);
|
|
}
|
|
|
|
function boundedConfigInteger(value, name, maximum) {
|
|
if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) {
|
|
throw new TypeError(`${name} invalide`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseRuntimeTtls(environ, config) {
|
|
if (!environ || typeof environ !== 'object' || !config || typeof config !== 'object') {
|
|
throw new TypeError('configuration TTL KissKH invalide');
|
|
}
|
|
return Object.freeze({
|
|
bundleCheckTtlSeconds: boundedConfigInteger(
|
|
config.bundleCheckTtlSeconds, 'KISSKH_BUNDLE_CHECK_TTL_SECONDS', 900,
|
|
),
|
|
bundleStaleMaxSeconds: boundedConfigInteger(
|
|
config.bundleStaleMaxSeconds, 'KISSKH_BUNDLE_STALE_MAX_SECONDS', 86_400,
|
|
),
|
|
matchTtlSeconds: positiveInteger(environ, 'KISSKH_MATCH_TTL_SECONDS', 86_400, 86_400),
|
|
episodesTtlSeconds: positiveInteger(environ, 'KISSKH_EPISODES_TTL_SECONDS', 21_600, 21_600),
|
|
sensitiveTtlSeconds: positiveInteger(environ, 'KISSKH_EPISODE_SUB_TTL_SECONDS', 600, 600),
|
|
notFoundTtlSeconds: positiveInteger(environ, 'KISSKH_NOT_FOUND_TTL_SECONDS', 900, 900),
|
|
resolveLockMs: positiveInteger(environ, 'KISSKH_RESOLVE_LOCK_MS', 30_000, 30_000),
|
|
fallbackTokenTtlSeconds: positiveInteger(
|
|
environ, 'KISSKH_FALLBACK_TOKEN_TTL_SECONDS', 120, 120,
|
|
),
|
|
});
|
|
}
|
|
|
|
function hostList(raw, fallback = []) {
|
|
if (raw === undefined || raw === '') return fallback;
|
|
if (typeof raw !== 'string') throw new TypeError('allowlist KissKH invalide');
|
|
const hosts = raw.split(',');
|
|
if (hosts.some((host) => host !== host.trim() || !host)) throw new TypeError('allowlist KissKH invalide');
|
|
return hosts;
|
|
}
|
|
|
|
function createRuntimeDependencies(deps = {}) {
|
|
const environ = runtimeEnvironment(deps.env);
|
|
const config = fromEnv(environ);
|
|
const ttlOptions = parseRuntimeTtls(environ, config);
|
|
const publicProxyUrl = resolvePublicProxyUrl(environ);
|
|
if (!config.enabled) return { enabled: false, publicProxyUrl };
|
|
const cache = createKisskhCache({
|
|
redis: deps.redis,
|
|
cacheDir: deps.cacheDir || path.join(__dirname, '..', 'cache', 'kisskh'),
|
|
matchTtlSeconds: ttlOptions.matchTtlSeconds,
|
|
episodesTtlSeconds: ttlOptions.episodesTtlSeconds,
|
|
sensitiveTtlSeconds: ttlOptions.sensitiveTtlSeconds,
|
|
notFoundTtlSeconds: ttlOptions.notFoundTtlSeconds,
|
|
resolveLockMs: ttlOptions.resolveLockMs,
|
|
bundleCheckTtlSeconds: ttlOptions.bundleCheckTtlSeconds,
|
|
bundleStaleMaxSeconds: ttlOptions.bundleStaleMaxSeconds,
|
|
});
|
|
const providerBaseUrl = environ.KISSKH_BASE_URL || 'https://kisskh.nl';
|
|
const capabilityStore = createFallbackCapabilityStore({
|
|
redis: deps.redis,
|
|
providerBaseUrl,
|
|
fallbackTokenTtlSeconds: ttlOptions.fallbackTokenTtlSeconds,
|
|
});
|
|
const proxyPolicy = createKisskhProxyPolicy({
|
|
redis: deps.redis,
|
|
pickProxy: deps.pickProxy,
|
|
quarantineBaseMs: positiveInteger(environ, 'KISSKH_PROXY_QUARANTINE_BASE_MS', 30_000),
|
|
quarantineMaxMs: positiveInteger(environ, 'KISSKH_PROXY_QUARANTINE_MAX_MS', 900_000),
|
|
circuitDefaultMs: positiveInteger(environ, 'KISSKH_CIRCUIT_DEFAULT_MS', 60_000),
|
|
});
|
|
const bundleRegistry = createBundleRegistry({
|
|
checkTtlSeconds: ttlOptions.bundleCheckTtlSeconds,
|
|
staleMaxSeconds: ttlOptions.bundleStaleMaxSeconds,
|
|
loadCurrentMetadata: () => cache.getCurrentBundleMetadata(),
|
|
});
|
|
const kisskhClient = createKisskhClient({
|
|
baseUrl: providerBaseUrl,
|
|
allowedHosts: hostList(environ.KISSKH_ALLOWED_HOSTS, ['kisskh.nl']),
|
|
proxyPolicy,
|
|
bundleRegistry,
|
|
request: deps.kisskhRequest,
|
|
timeout: positiveInteger(environ, 'KISSKH_METADATA_TIMEOUT_MS', 10_000),
|
|
maxAttempts: positiveInteger(environ, 'KISSKH_METADATA_MAX_ATTEMPTS', 3),
|
|
});
|
|
const resolver = createKisskhResolver({
|
|
cache,
|
|
capabilityStore,
|
|
bundleRegistry,
|
|
kisskhClient,
|
|
fetchTmdbDetails: deps.fetchTmdbDetails,
|
|
fetchTmdbAlternativeTitles: deps.fetchTmdbAlternativeTitles,
|
|
tmdbApiUrl: deps.tmdbApiUrl,
|
|
tmdbApiKey: deps.tmdbApiKey,
|
|
publicProxyUrl,
|
|
});
|
|
return {
|
|
enabled: true,
|
|
publicProxyUrl,
|
|
providerBaseUrl,
|
|
resolver,
|
|
capabilityStore,
|
|
proxyPolicy,
|
|
verifyAccessKey: deps.verifyAccessKey,
|
|
};
|
|
}
|
|
|
|
let configuredRouter = null;
|
|
const exportedRouter = express.Router();
|
|
exportedRouter.use((req, res, next) => {
|
|
if (configuredRouter) return configuredRouter(req, res, next);
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.status(503).json({ code: 'proxy_unavailable', message: 'KissKH indisponible' });
|
|
});
|
|
|
|
function configure(deps) {
|
|
configuredRouter = createKisskhRouter(createRuntimeDependencies(deps));
|
|
return exportedRouter;
|
|
}
|
|
|
|
module.exports = exportedRouter;
|
|
module.exports.configure = configure;
|
|
module.exports.createFallbackCapabilityStore = createFallbackCapabilityStore;
|
|
module.exports.createKisskhRouter = createKisskhRouter;
|
|
module.exports.createRuntimeDependencies = createRuntimeDependencies;
|
|
module.exports.parseTvRequest = parseTvRequest;
|
|
module.exports.parseMovieRequest = parseMovieRequest;
|
|
module.exports.parseRuntimeTtls = parseRuntimeTtls;
|