MovixOpenSource/API/Mainapi/services/kisskh/kisskhResolver.js
Movix eae5a24c51 feat: ajoute KissKH et fiabilise les sources, les sous-titres et le Cast — Android 2.5.8
- 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>
2026-08-03 21:37:41 +02:00

915 lines
40 KiB
JavaScript

const { KisskhError } = require('./errors');
const {
analyzeSeasonTitle,
buildSeasonAwareQueries,
buildTmdbTitleCandidates,
regularEpisodeCount,
rankKisskhCandidates,
selectEpisodeSegment,
selectConfirmedDrama,
} = require('./kisskhMatcher');
const { assertMediaType } = require('./kisskhCache');
const SAFE_CODES = new Set([
'episode_missing', 'invalid_input', 'not_found', 'provider_changed',
'provider_rate_limited', 'provider_security', 'provider_unavailable',
'proxy_unavailable', 'upstream_unavailable',
]);
const KISSKH_ORIGIN = 'https://kisskh.nl';
const KISSKH_REQUIRED_HEADERS = Object.freeze({
Referer: `${KISSKH_ORIGIN}/`,
Origin: KISSKH_ORIGIN,
});
// Retour instantane au parcours Search historique : passer cette constante a false.
const USE_ENHANCED_CATALOG_MATCHING = true;
const CATALOG_PAGE_SIZE = 100;
const CATALOG_MAX_ENTRIES = 20_000;
const CATALOG_PAGE_CONCURRENCY = 6;
const MAX_ENRICHED_CANDIDATES = 4;
const EPISODE_ASSET_TIMEOUT_MS = 45_000;
function safeError(code) {
const messages = {
episode_missing: 'Episode KissKH introuvable',
invalid_input: 'Parametre KissKH invalide',
not_found: 'Correspondance KissKH introuvable',
provider_changed: 'Version KissKH non approuvee',
provider_rate_limited: 'KissKH temporairement limite',
provider_security: 'Donnees KissKH non autorisees',
provider_unavailable: 'KissKH indisponible',
proxy_unavailable: 'Service de lecture indisponible',
upstream_unavailable: 'Lecture KissKH indisponible',
};
return new KisskhError(SAFE_CODES.has(code) ? code : 'provider_unavailable',
messages[code] || messages.provider_unavailable);
}
function assertRequest(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)
|| !Number.isSafeInteger(value.tmdbId) || value.tmdbId <= 0
|| !Number.isSafeInteger(value.season) || value.season < 0
|| !Number.isSafeInteger(value.episode) || value.episode <= 0) throw safeError('invalid_input');
}
function assertMovieRequest(value) {
assertRequest(value);
if (value.season !== 0 || value.episode !== 1) throw safeError('invalid_input');
}
function validatePublicProxyOrigin(value) {
if (typeof value !== 'string' || /[\r\n]/.test(value)) throw new TypeError('PROXIESEMBED_PUBLIC_URL invalide');
let url;
try { url = new URL(value); } catch { throw new TypeError('PROXIESEMBED_PUBLIC_URL invalide'); }
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
if (!['http:', 'https:'].includes(url.protocol) || (url.protocol === 'http:' && !loopback)
|| url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new TypeError('PROXIESEMBED_PUBLIC_URL invalide');
}
return url.origin;
}
function validateUpstreamUrl(value) {
if (typeof value !== 'string' || !value || value.length > 8_192 || /[\r\n]/.test(value)) {
throw safeError('provider_security');
}
let url;
try { url = new URL(value); } catch { throw safeError('provider_security'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
throw safeError('provider_security');
}
return url.href;
}
function subtitleFormat(urlValue) {
const pathname = new URL(urlValue).pathname.toLowerCase();
const match = pathname.match(/\.([a-z0-9]+)$/);
if (!match || !['srt', 'txt', 'txt1', 'txt2'].includes(match[1])) throw safeError('provider_security');
return match[1];
}
function validateAesMaterial(value) {
if (typeof value !== 'string' || value.length > 64) throw safeError('provider_changed');
const decoded = Buffer.from(value, 'base64');
if (decoded.length !== 16 || decoded.toString('base64') !== value) throw safeError('provider_changed');
return value;
}
function cipherFor(format, algorithm) {
if (format === 'srt') return { mode: 'none' };
if (format === 'txt1') return { mode: 'unsupported', scheme: 'a2' };
const scheme = format === 'txt2' ? 'a3' : 'a1';
return {
mode: 'aes-128-cbc',
keyBase64: validateAesMaterial(algorithm?.subtitleCiphers?.[scheme]?.keyBase64),
ivBase64: validateAesMaterial(algorithm?.subtitleCiphers?.[scheme]?.ivBase64),
payloadEncoding: 'base64-per-cue',
padding: 'pkcs7',
};
}
function normalizedMatch(value, request) {
if (!value || typeof value !== 'object' || Array.isArray(value)
|| value.tmdbId !== request.tmdbId || value.season !== request.season || value.episode !== request.episode
|| !Number.isSafeInteger(value.kisskhDramaId) || value.kisskhDramaId <= 0
|| !Number.isSafeInteger(value.episodeId) || value.episodeId <= 0) throw safeError('provider_security');
return {
tmdbId: request.tmdbId,
kisskhDramaId: value.kisskhDramaId,
episodeId: value.episodeId,
season: request.season,
episode: request.episode,
evidence: {
score: Number.isFinite(value.evidence?.score) ? value.evidence.score : 100,
titleSource: ['localized', 'original', 'alternative'].includes(value.evidence?.titleSource)
? value.evidence.titleSource : 'localized',
},
};
}
function proxyUrl(origin, sourceUrl) {
const url = new URL('/kisskh-proxy', origin);
url.searchParams.set('url', sourceUrl);
return url.href;
}
function mediaType(sourceUrl) {
return new URL(sourceUrl).pathname.toLowerCase().endsWith('.mp4') ? 'mp4' : 'hls';
}
function normalizeCatalogPage(value, expectedPage) {
if (!value || typeof value !== 'object' || Array.isArray(value)
|| value.page !== expectedPage || value.pageSize !== CATALOG_PAGE_SIZE
|| !Number.isSafeInteger(value.totalCount) || value.totalCount <= 0
|| value.totalCount > CATALOG_MAX_ENTRIES
|| !Array.isArray(value.data) || value.data.length > CATALOG_PAGE_SIZE) {
throw safeError('provider_unavailable');
}
return value;
}
function tmdbSeasonEpisodeCount(seasons, seasonNumber) {
if (!Array.isArray(seasons)) return null;
const season = seasons.find((entry) => entry?.season_number === seasonNumber);
return Number.isSafeInteger(season?.episode_count) && season.episode_count > 0
? season.episode_count : null;
}
function compatibleDetailedType(mediaNamespace, value) {
const type = String(value || '').trim().toLowerCase();
if (mediaNamespace === 'movie') return type !== 'tvseries';
return type !== 'movie';
}
function distinctSegments(ranked) {
const seen = new Set();
return ranked.filter((entry) => {
const analyzed = analyzeSeasonTitle(entry?.candidate?.title);
const key = `${analyzed.base}\u0000${analyzed.markers.join(',')}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function createKisskhResolver(deps = {}) {
const cache = deps.cache;
const capabilityStore = deps.capabilityStore;
const bundleRegistry = deps.bundleRegistry;
const now = deps.now || Date.now;
const assetTimeoutMs = deps.assetTimeoutMs === undefined
? EPISODE_ASSET_TIMEOUT_MS : deps.assetTimeoutMs;
const publicOrigin = validatePublicProxyOrigin(deps.publicProxyUrl);
const useEnhancedCatalogMatching = deps.useEnhancedCatalogMatching === undefined
? USE_ENHANCED_CATALOG_MATCHING : deps.useEnhancedCatalogMatching;
if (!cache || typeof cache.singleFlight !== 'function' || typeof capabilityStore?.create !== 'function'
|| typeof bundleRegistry?.resolveApprovedAlgorithm !== 'function'
|| typeof useEnhancedCatalogMatching !== 'boolean' || typeof now !== 'function'
|| !Number.isSafeInteger(assetTimeoutMs) || assetTimeoutMs <= 0
|| assetTimeoutMs > EPISODE_ASSET_TIMEOUT_MS) {
throw new TypeError('resolver KissKH invalide');
}
function resolutionTrace(mediaNamespace, request, startedAt, onProgress) {
const resolutionId = `${mediaNamespace}:${request.tmdbId}:${request.season}:${request.episode}`;
return (phase, details = {}) => {
if (typeof onProgress !== 'function') return;
try {
onProgress({
resolutionId,
mediaType: mediaNamespace,
tmdbId: request.tmdbId,
season: request.season,
episode: request.episode,
phase,
elapsedMs: Math.max(0, now() - startedAt),
...details,
});
} catch {
// Les diagnostics ne doivent jamais interrompre une resolution.
}
};
}
function providerLogDetails(provider) {
return {
kisskhDramaId: provider?.match?.kisskhDramaId,
episodeId: provider?.match?.episodeId,
subtitleCount: Array.isArray(provider?.subtitles) ? provider.subtitles.length : 0,
};
}
function logCatalogPhase(trace, phase, details = {}) {
trace?.(phase, details);
}
async function normalizeSensitive(provider, request, mediaNamespace) {
const match = normalizedMatch(provider.match, request);
await cache.setMatch(mediaNamespace, request.tmdbId, request.season, {
tmdbId: request.tmdbId,
kisskhDramaId: match.kisskhDramaId,
season: request.season,
episodeOffset: Number.isSafeInteger(provider.episodeOffset) ? provider.episodeOffset : 0,
evidence: match.evidence,
});
// Ne jamais remplacer la liste complète mise en cache par le seul épisode
// courant : sinon E1 fonctionne puis E2 devient artificiellement introuvable.
const cachedEpisodes = await cache.getEpisodes(match.kisskhDramaId);
if (!cachedEpisodes) {
await cache.setEpisodes(match.kisskhDramaId, [{ id: match.episodeId, number: request.episode }]);
}
const mediaUrl = validateUpstreamUrl(provider.mediaUrl);
const requiredHeaders = provider.requiredHeaders || KISSKH_REQUIRED_HEADERS;
const algorithm = await bundleRegistry.resolveApprovedAlgorithm();
await cache.recordBundleMetadata(algorithm);
if (!Array.isArray(provider.subtitles) || provider.subtitles.length > 128) throw safeError('provider_security');
const subtitles = [];
for (let index = 0; index < provider.subtitles.length; index += 1) {
const track = provider.subtitles[index];
if (!track || typeof track !== 'object' || Array.isArray(track)) throw safeError('provider_security');
const sourceUrl = validateUpstreamUrl(track.src ?? track.url);
const format = subtitleFormat(sourceUrl);
const rawLang = typeof (track.land ?? track.lang) === 'string' ? String(track.land ?? track.lang).toLowerCase() : 'und';
const lang = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/.test(rawLang) ? rawLang : 'und';
const label = typeof track.label === 'string' && track.label.trim() && track.label.length <= 256
? track.label.trim() : lang;
const id = `kisskh-${lang}-${index + 1}`;
subtitles.push({ id, lang, label, format, sourceUrl, cipher: cipherFor(format, algorithm) });
}
const normalized = { match, mediaUrl, requiredHeaders, subtitles };
await cache.setSensitive('episode', match.episodeId, { mediaUrl, requiredHeaders });
await cache.setSensitive('sub', match.episodeId, { subtitles });
return normalized;
}
async function discoverWithProductionClients(request) {
const { fetchTmdbDetails, fetchTmdbAlternativeTitles, kisskhClient } = deps;
if (typeof fetchTmdbDetails !== 'function' || typeof fetchTmdbAlternativeTitles !== 'function'
|| !kisskhClient || ['search', 'getDrama', 'getEpisode', 'getSubtitles']
.some((name) => typeof kisskhClient[name] !== 'function')) throw safeError('provider_unavailable');
let cachedMatch = await cache.getMatch('tv', request.tmdbId, request.season);
let episodes = cachedMatch ? await cache.getEpisodes(cachedMatch.kisskhDramaId) : null;
let evidence = cachedMatch?.evidence;
if (!cachedMatch || !episodes) {
const localized = await fetchTmdbDetails(deps.tmdbApiUrl, deps.tmdbApiKey, request.tmdbId, 'tv', 'fr-FR');
const original = await fetchTmdbDetails(deps.tmdbApiUrl, deps.tmdbApiKey, request.tmdbId, 'tv', 'en-US');
const alternatives = await fetchTmdbAlternativeTitles(deps.tmdbApiUrl, deps.tmdbApiKey, request.tmdbId);
if (!localized || !original || !alternatives) throw safeError('provider_unavailable');
const titles = buildTmdbTitleCandidates({ localized, original, alternatives });
const queries = buildSeasonAwareQueries(titles, request.season);
const candidates = [];
const seen = new Set();
for (const query of queries) {
const results = await kisskhClient.search(query, 0);
if (!Array.isArray(results)) throw safeError('provider_unavailable');
for (const candidate of results) {
if (Number.isSafeInteger(candidate?.id) && candidate.id > 0 && !seen.has(candidate.id)) {
seen.add(candidate.id);
candidates.push(candidate);
}
}
}
const ranked = rankKisskhCandidates({
titles,
year: localized.first_air_date,
countries: localized.origin_country,
seasonNumber: request.season,
seasonCount: localized.number_of_seasons,
}, candidates);
if (!ranked.length) throw safeError('not_found');
const top = ranked[0];
const dramaId = top.candidate.id;
const drama = await kisskhClient.getDrama(dramaId);
if (!drama || typeof drama !== 'object') throw safeError('provider_unavailable');
const selected = selectConfirmedDrama([{ ...top, candidate: { ...drama, id: dramaId } }], request.season, request.episode);
episodes = selected.drama.episodes.filter((entry) => Number.isSafeInteger(entry?.number) && entry.number > 0);
evidence = { score: top.score, titleSource: top.titleSource };
cachedMatch = await cache.setMatch('tv', request.tmdbId, request.season, {
tmdbId: request.tmdbId,
kisskhDramaId: dramaId,
season: request.season,
evidence,
});
episodes = await cache.setEpisodes(dramaId, episodes);
}
let episodeEntry = episodes.find((entry) => entry.number === request.episode);
if (!episodeEntry && cachedMatch) {
// Une ancienne version écrasait ce cache avec le seul épisode consulté.
// Rafraîchir une fois depuis KissKH évite un faux episode_missing.
const refreshedDrama = await kisskhClient.getDrama(cachedMatch.kisskhDramaId);
if (Array.isArray(refreshedDrama?.episodes)) {
episodes = refreshedDrama.episodes.filter(
(entry) => Number.isSafeInteger(entry?.number) && entry.number > 0,
);
episodes = await cache.setEpisodes(cachedMatch.kisskhDramaId, episodes);
episodeEntry = episodes.find((entry) => entry.number === request.episode);
}
}
if (!episodeEntry) {
throw safeError('episode_missing');
}
const match = {
tmdbId: request.tmdbId,
kisskhDramaId: cachedMatch.kisskhDramaId,
episodeId: episodeEntry.id,
season: request.season,
episode: request.episode,
evidence,
};
const cachedEpisode = await cache.getSensitive('episode', episodeEntry.id);
const cachedSub = await cache.getSensitive('sub', episodeEntry.id);
if (cachedEpisode && cachedSub) return { match, ...cachedEpisode, ...cachedSub };
const episodePayload = await kisskhClient.getEpisode(episodeEntry.id);
const subtitles = await kisskhClient.getSubtitles(episodeEntry.id);
return normalizeSensitive({
match,
episodeOffset: request.episode - episodeEntry.number,
mediaUrl: episodePayload?.Video,
requiredHeaders: KISSKH_REQUIRED_HEADERS,
subtitles,
}, request, 'tv');
}
async function discoverMediaWithProductionClientsLegacy(mediaNamespace, request, options = {}) {
assertMediaType(mediaNamespace);
const trace = options.trace;
const { fetchTmdbDetails, fetchTmdbAlternativeTitles, kisskhClient } = deps;
if (typeof fetchTmdbDetails !== 'function' || typeof fetchTmdbAlternativeTitles !== 'function'
|| !kisskhClient || ['search', 'getDrama', 'getEpisode', 'getSubtitles']
.some((name) => typeof kisskhClient[name] !== 'function')) throw safeError('provider_unavailable');
let cachedMatch = await cache.getMatch(mediaNamespace, request.tmdbId, request.season);
let episodes = cachedMatch ? await cache.getEpisodes(cachedMatch.kisskhDramaId) : null;
let evidence = cachedMatch?.evidence;
const cachedEpisodeNumber = cachedMatch ? request.episode - cachedMatch.episodeOffset : request.episode;
let episodeEntry = episodes?.find((entry) => entry.number === cachedEpisodeNumber) || null;
trace?.('match_cache_checked', {
matchCacheHit: Boolean(cachedMatch),
episodesCacheHit: Boolean(episodes),
episodeCacheHit: Boolean(episodeEntry),
});
if (!cachedMatch || !episodes || !episodeEntry) {
trace?.('tmdb_metadata_started');
const [localized, original, alternativePayload] = await Promise.all([
fetchTmdbDetails(
deps.tmdbApiUrl, deps.tmdbApiKey, request.tmdbId, mediaNamespace, 'fr-FR',
),
fetchTmdbDetails(
deps.tmdbApiUrl, deps.tmdbApiKey, request.tmdbId, mediaNamespace, 'en-US',
),
fetchTmdbAlternativeTitles(
deps.tmdbApiUrl, deps.tmdbApiKey, request.tmdbId, mediaNamespace,
),
]);
const alternatives = mediaNamespace === 'movie'
? { ...alternativePayload, results: alternativePayload?.titles }
: alternativePayload;
if (!localized || !original || !alternatives) throw safeError('provider_unavailable');
const titles = buildTmdbTitleCandidates({ localized, original, alternatives });
const queries = mediaNamespace === 'movie'
? titles.map((entry) => entry.value)
: buildSeasonAwareQueries(titles, request.season);
trace?.('tmdb_metadata_resolved', { titleCount: titles.length, queryCount: queries.length });
const seasonCount = mediaNamespace === 'movie' ? 1 : localized.number_of_seasons;
const tmdbSeasons = mediaNamespace === 'movie' ? [] : localized.seasons;
const countries = mediaNamespace === 'movie'
? (Array.isArray(localized.production_countries) ? localized.production_countries : [])
.map((entry) => entry?.iso_3166_1 || entry?.name).filter(Boolean)
: localized.origin_country;
let selectedResult = null;
let lastCompatibilityError = null;
for (let category = 0; category <= 4 && !selectedResult; category += 1) {
const candidates = [];
const seen = new Set();
trace?.('kisskh_search_started', { category, queryCount: queries.length });
const searchResults = await Promise.all(
queries.map((query) => kisskhClient.search(query, category)),
);
for (const results of searchResults) {
if (!Array.isArray(results)) throw safeError('provider_unavailable');
for (const candidate of results) {
if (Number.isSafeInteger(candidate?.id) && candidate.id > 0 && !seen.has(candidate.id)) {
seen.add(candidate.id);
candidates.push(candidate);
}
}
}
trace?.('kisskh_search_resolved', { category, candidateCount: candidates.length });
const rankingCriteria = {
titles,
year: mediaNamespace === 'movie' ? localized.release_date : localized.first_air_date,
countries,
seasonNumber: mediaNamespace === 'movie' ? undefined : request.season,
seasonCount,
};
let ranked = rankKisskhCandidates(rankingCriteria, candidates);
if (!ranked.length) continue;
const detailedDramaById = new Map();
if (options.regularizeAbsoluteSegments === true
&& Number(seasonCount) <= 1 && ranked.length > 1) {
const detailedCandidates = await Promise.all(
ranked.slice(0, MAX_ENRICHED_CANDIDATES).map(async (entry) => {
const drama = await kisskhClient.getDrama(entry.candidate.id);
if (!drama || typeof drama !== 'object') throw safeError('provider_unavailable');
detailedDramaById.set(entry.candidate.id, drama);
return {
...entry.candidate,
...drama,
id: entry.candidate.id,
episodesCount: regularEpisodeCount(drama.episodes) || entry.candidate.episodesCount,
};
}),
);
ranked = rankKisskhCandidates(rankingCriteria, detailedCandidates);
if (!ranked.length) continue;
}
const segment = selectEpisodeSegment(ranked, {
seasonNumber: request.season,
episodeNumber: request.episode,
seasonCount: mediaNamespace === 'tv' && request.season === 1 ? 1 : seasonCount,
tmdbSeasons,
});
if (!segment) continue;
const top = segment.ranked;
const dramaId = top.candidate.id;
trace?.('drama_details_started', { dramaId });
const drama = detailedDramaById.get(dramaId) || await kisskhClient.getDrama(dramaId);
if (!drama || typeof drama !== 'object') throw safeError('provider_unavailable');
trace?.('drama_details_resolved', {
dramaId,
episodeCount: Array.isArray(drama.episodes) ? drama.episodes.length : 0,
});
const markers = analyzeSeasonTitle(drama.title || top.candidate.title).markers;
const confirmationSeason = Number(seasonCount) <= 1 && markers.length > 0
? markers[0]
: request.season;
try {
const selected = selectConfirmedDrama(
[{ ...top, candidate: { ...drama, id: dramaId } }],
confirmationSeason,
segment.localEpisodeNumber,
);
selectedResult = { selected, top, dramaId, localEpisodeNumber: segment.localEpisodeNumber };
} catch (error) {
if (!['not_found', 'episode_missing'].includes(error?.code)) throw error;
lastCompatibilityError = error.code;
}
}
if (!selectedResult) throw safeError(lastCompatibilityError || 'not_found');
const { selected, top, dramaId, localEpisodeNumber } = selectedResult;
episodes = selected.drama.episodes.filter(
(entry) => Number.isSafeInteger(entry?.number) && entry.number > 0,
);
evidence = { score: top.score, titleSource: top.titleSource };
cachedMatch = await cache.setMatch(mediaNamespace, request.tmdbId, request.season, {
tmdbId: request.tmdbId,
kisskhDramaId: dramaId,
season: request.season,
episodeOffset: request.episode - localEpisodeNumber,
evidence,
});
episodes = await cache.setEpisodes(dramaId, episodes);
episodeEntry = selected.episode;
trace?.('match_resolved', { dramaId, episodeCount: episodes.length });
}
if (!episodeEntry) throw safeError('episode_missing');
const match = {
tmdbId: request.tmdbId,
kisskhDramaId: cachedMatch.kisskhDramaId,
episodeId: episodeEntry.id,
season: request.season,
episode: request.episode,
evidence,
};
const cachedEpisode = await cache.getSensitive('episode', episodeEntry.id);
const cachedSub = await cache.getSensitive('sub', episodeEntry.id);
if (cachedEpisode && cachedSub) {
trace?.('episode_assets_cache_hit', {
episodeId: episodeEntry.id,
subtitleCount: Array.isArray(cachedSub.subtitles) ? cachedSub.subtitles.length : 0,
});
return { match, ...cachedEpisode, ...cachedSub };
}
trace?.('episode_assets_started', { episodeId: episodeEntry.id });
const resolveEpisodeAsset = async (asset, operation) => {
trace?.(`episode_${asset}_started`, { episodeId: episodeEntry.id });
try {
const value = await operation();
trace?.(`episode_${asset}_resolved`, { episodeId: episodeEntry.id });
return value;
} catch (error) {
trace?.(`episode_${asset}_failed`, {
episodeId: episodeEntry.id,
code: SAFE_CODES.has(error?.code) ? error.code : 'provider_unavailable',
reason: error?.safeMessage || 'KissKH indisponible',
});
throw error;
}
};
let timeout;
const assetDeadline = new Promise((_, reject) => {
timeout = setTimeout(() => {
trace?.('episode_assets_timeout', {
episodeId: episodeEntry.id,
timeoutMs: assetTimeoutMs,
});
reject(safeError('provider_unavailable'));
}, assetTimeoutMs);
timeout.unref?.();
});
let episodePayload;
let subtitles;
try {
[episodePayload, subtitles] = await Promise.race([
Promise.all([
resolveEpisodeAsset('video', () => kisskhClient.getEpisode(episodeEntry.id)),
resolveEpisodeAsset('subtitles', () => kisskhClient.getSubtitles(episodeEntry.id)),
]),
assetDeadline,
]);
} finally {
clearTimeout(timeout);
}
trace?.('episode_assets_resolved', {
episodeId: episodeEntry.id,
hasVideo: typeof episodePayload?.Video === 'string' && Boolean(episodePayload.Video),
subtitleCount: Array.isArray(subtitles) ? subtitles.length : 0,
});
return normalizeSensitive({
match,
episodeOffset: request.episode - episodeEntry.number,
mediaUrl: episodePayload?.Video,
requiredHeaders: KISSKH_REQUIRED_HEADERS,
subtitles,
}, request, mediaNamespace);
}
async function refreshCatalog(kisskhClient, lease, trace) {
if (typeof cache.setCatalogProgress === 'function') {
await cache.setCatalogProgress({ phase: 'starting', completed: 0, total: null, percent: null });
}
logCatalogPhase(trace, 'catalog_page_started', { page: 1, pageSize: CATALOG_PAGE_SIZE });
const first = normalizeCatalogPage(
await kisskhClient.list(1, CATALOG_PAGE_SIZE, 0),
1,
);
const pages = Math.ceil(first.totalCount / CATALOG_PAGE_SIZE);
logCatalogPhase(trace, 'catalog_page_resolved', {
page: 1, pages, itemCount: first.data.length, totalCount: first.totalCount,
});
const items = [...first.data];
let completed = 1;
const reportProgress = async (phase = 'catalog') => {
const progress = {
phase,
completed,
total: pages,
percent: Math.floor((completed / pages) * 100),
};
if (typeof cache.setCatalogProgress === 'function') await cache.setCatalogProgress(progress);
logCatalogPhase(trace, 'catalog_progress', {
catalogPhase: phase,
completed,
total: pages,
percent: progress.percent,
});
};
await reportProgress();
for (let start = 2; start <= pages; start += CATALOG_PAGE_CONCURRENCY) {
const pageNumbers = Array.from(
{ length: Math.min(CATALOG_PAGE_CONCURRENCY, pages - start + 1) },
(_unused, index) => start + index,
);
const payloads = await Promise.all(pageNumbers.map(async (page) => {
logCatalogPhase(trace, 'catalog_page_started', { page, pageSize: CATALOG_PAGE_SIZE, pages });
const payload = normalizeCatalogPage(
await kisskhClient.list(page, CATALOG_PAGE_SIZE, 0),
page,
);
logCatalogPhase(trace, 'catalog_page_resolved', {
page, pages, itemCount: payload.data.length, totalCount: payload.totalCount,
});
return payload;
}));
for (const payload of payloads) {
if (payload.totalCount !== first.totalCount) throw safeError('provider_unavailable');
items.push(...payload.data);
completed += 1;
await reportProgress();
}
}
if (items.length !== first.totalCount) throw safeError('provider_unavailable');
await reportProgress('finalizing');
await lease.assertOwned();
const snapshot = await cache.setCatalogSnapshot(items);
if (typeof cache.clearCatalogProgress === 'function') await cache.clearCatalogProgress();
logCatalogPhase(trace, 'catalog_refresh_completed', { itemCount: items.length, pages });
return snapshot;
}
async function catalogItems(kisskhClient, trace) {
const fresh = await cache.getCatalogSnapshot();
if (fresh) {
logCatalogPhase(trace, 'catalog_cache_hit', { itemCount: fresh.items.length, stale: false });
return fresh.items;
}
const stale = await cache.getCatalogSnapshot({ allowStale: true });
logCatalogPhase(trace, 'catalog_refresh_waiting', {
staleAvailable: Boolean(stale),
staleItemCount: stale?.items?.length || 0,
});
try {
const refreshed = await cache.singleFlight(
'kisskh:lock:catalog-refresh:v1',
(lease) => refreshCatalog(kisskhClient, lease, trace),
{ lockMs: 60_000, renewEveryMs: 20_000 },
);
return refreshed.items;
} catch (error) {
logCatalogPhase(trace, 'catalog_refresh_failed', {
code: SAFE_CODES.has(error?.code) ? error.code : 'provider_unavailable',
staleAvailable: Boolean(stale),
});
if (error?.code === 'provider_rate_limited' || error?.code === 'provider_security') throw error;
if (stale) {
return stale.items;
}
return null;
}
}
async function discoverCatalogMatch(mediaNamespace, request, trace) {
const { fetchTmdbDetails, fetchTmdbAlternativeTitles, kisskhClient } = deps;
if (typeof kisskhClient?.list !== 'function'
|| typeof fetchTmdbDetails !== 'function' || typeof fetchTmdbAlternativeTitles !== 'function') return null;
trace?.('tmdb_and_catalog_started');
const [localized, original, alternativePayload, items] = await Promise.all([
fetchTmdbDetails(deps.tmdbApiUrl, deps.tmdbApiKey, request.tmdbId, mediaNamespace, 'fr-FR'),
fetchTmdbDetails(deps.tmdbApiUrl, deps.tmdbApiKey, request.tmdbId, mediaNamespace, 'en-US'),
fetchTmdbAlternativeTitles(deps.tmdbApiUrl, deps.tmdbApiKey, request.tmdbId, mediaNamespace),
catalogItems(kisskhClient, trace),
]);
if (!localized || !original || !alternativePayload || !items) return null;
const alternatives = mediaNamespace === 'movie'
? { ...alternativePayload, results: alternativePayload?.titles }
: alternativePayload;
const titles = buildTmdbTitleCandidates({ localized, original, alternatives });
trace?.('tmdb_and_catalog_resolved', { titleCount: titles.length, catalogItemCount: items.length });
const seasonCount = mediaNamespace === 'movie' ? 1 : localized.number_of_seasons;
const tmdbSeasons = mediaNamespace === 'movie' ? [] : localized.seasons;
const countries = mediaNamespace === 'movie'
? (Array.isArray(localized.production_countries) ? localized.production_countries : [])
.map((entry) => entry?.iso_3166_1 || entry?.name).filter(Boolean)
: localized.origin_country;
const expectedEpisodeCount = mediaNamespace === 'movie'
? 1 : tmdbSeasonEpisodeCount(tmdbSeasons, request.season);
const criteria = {
titles,
year: mediaNamespace === 'movie' ? localized.release_date : localized.first_air_date,
countries,
seasonNumber: mediaNamespace === 'movie' ? undefined : request.season,
seasonCount,
expectedEpisodeCount,
};
const preliminary = rankKisskhCandidates(
{ ...criteria, retainAmbiguous: true },
items,
);
if (!preliminary.length) return null;
const enriched = await Promise.all(preliminary.slice(0, MAX_ENRICHED_CANDIDATES).map(async (entry) => {
const drama = await kisskhClient.getDrama(entry.candidate.id);
if (!drama || typeof drama !== 'object' || !compatibleDetailedType(mediaNamespace, drama.type)) return null;
const count = regularEpisodeCount(drama.episodes);
return {
...entry.candidate,
...drama,
id: entry.candidate.id,
episodesCount: count || entry.candidate.episodesCount,
};
}));
const ranked = distinctSegments(rankKisskhCandidates(criteria, enriched.filter(Boolean)));
if (!ranked.length) return null;
const segment = selectEpisodeSegment(ranked, {
seasonNumber: request.season,
episodeNumber: request.episode,
seasonCount: mediaNamespace === 'tv' && request.season === 1 ? 1 : seasonCount,
tmdbSeasons,
});
if (!segment) return null;
const top = segment.ranked;
const drama = top.candidate;
const markers = analyzeSeasonTitle(drama.title).markers;
const confirmationSeason = Number(seasonCount) <= 1 && markers.length > 0
? markers[0] : request.season;
let selected;
try {
selected = selectConfirmedDrama([top], confirmationSeason, segment.localEpisodeNumber);
} catch (error) {
if (['not_found', 'episode_missing'].includes(error?.code)) return null;
throw error;
}
const episodes = selected.drama.episodes.filter(
(entry) => Number.isSafeInteger(entry?.number) && entry.number > 0,
);
const evidence = { score: top.score, titleSource: top.titleSource };
await cache.setMatch(mediaNamespace, request.tmdbId, request.season, {
tmdbId: request.tmdbId,
kisskhDramaId: drama.id,
season: request.season,
episodeOffset: request.episode - segment.localEpisodeNumber,
evidence,
});
await cache.setEpisodes(drama.id, episodes);
trace?.('catalog_match_resolved', {
dramaId: drama.id,
episodeCount: episodes.length,
localEpisodeNumber: segment.localEpisodeNumber,
});
return true;
}
async function discoverMediaWithProductionClients(mediaNamespace, request, trace) {
if (!useEnhancedCatalogMatching) {
return discoverMediaWithProductionClientsLegacy(mediaNamespace, request, { trace });
}
const cachedMatch = await cache.getMatch(mediaNamespace, request.tmdbId, request.season);
const cachedEpisodes = cachedMatch ? await cache.getEpisodes(cachedMatch.kisskhDramaId) : null;
const cachedNumber = cachedMatch ? request.episode - cachedMatch.episodeOffset : request.episode;
const alreadyCached = cachedEpisodes?.some((entry) => entry.number === cachedNumber);
if (!alreadyCached) {
let matched = false;
try {
matched = await discoverCatalogMatch(mediaNamespace, request, trace) === true;
} catch (error) {
if (error?.code === 'provider_rate_limited' || error?.code === 'provider_security') throw error;
trace?.('enhanced_catalog_fallback', {
code: SAFE_CODES.has(error?.code) ? error.code : 'provider_unavailable',
});
}
if (!matched) {
return discoverMediaWithProductionClientsLegacy(mediaNamespace, request, {
regularizeAbsoluteSegments: true,
trace,
});
}
}
return discoverMediaWithProductionClientsLegacy(mediaNamespace, request, { trace });
}
async function prepare(mediaNamespace, request, trace) {
trace('prepare_started');
const cachedCode = await cache.getNotFound(mediaNamespace, request.tmdbId, request.season, request.episode);
if (cachedCode) throw safeError(cachedCode);
try {
if (typeof deps.resolveProvider === 'function') {
trace('provider_started', { providerMode: 'injected' });
const provider = await normalizeSensitive(
await deps.resolveProvider(request, { mediaType: mediaNamespace }), request, mediaNamespace,
);
trace('provider_resolved', { providerMode: 'injected', ...providerLogDetails(provider) });
return provider;
}
trace('provider_started', { providerMode: 'kisskh' });
const provider = await discoverMediaWithProductionClients(mediaNamespace, request, trace);
trace('provider_resolved', { providerMode: 'kisskh', ...providerLogDetails(provider) });
return provider;
} catch (error) {
const code = SAFE_CODES.has(error?.code) ? error.code : 'provider_unavailable';
trace('provider_failed', {
code,
reason: error?.safeMessage || 'KissKH indisponible',
});
if (['not_found', 'episode_missing'].includes(code)) {
await cache.setNotFound(mediaNamespace, request.tmdbId, request.season, request.episode, code);
}
throw safeError(code);
}
}
async function providerFor(mediaNamespace, request, options = {}) {
const cachedCode = await cache.getNotFound(mediaNamespace, request.tmdbId, request.season, request.episode);
if (cachedCode) throw safeError(cachedCode);
const startedAt = now();
const trace = resolutionTrace(mediaNamespace, request, startedAt, options.onProgress);
const key = `kisskh:lock:resolve:${mediaNamespace}:${request.tmdbId}:${request.season}:${request.episode}`;
const provider = await cache.singleFlight(key, () => prepare(mediaNamespace, request, trace));
await cache.setResolution(mediaNamespace, request.tmdbId, request.season, request.episode, provider);
trace('resolution_cached', providerLogDetails(provider));
return provider;
}
async function makePublicResolution(provider, request, options = {}) {
const fallbackToken = await capabilityStore.create({
url: provider.mediaUrl,
requiredHeaders: provider.requiredHeaders,
});
const proxyMedia = options.proxyMedia === true;
const mediaUrl = proxyMedia ? proxyUrl(publicOrigin, provider.mediaUrl) : provider.mediaUrl;
let subtitles = provider.subtitles;
if (subtitles.some((track) => track.format === 'txt2' && track.cipher?.mode !== 'aes-128-cbc')) {
const algorithm = await bundleRegistry.resolveApprovedAlgorithm();
subtitles = subtitles.map((track) => track.format === 'txt2' && track.cipher?.mode !== 'aes-128-cbc'
? { ...track, cipher: cipherFor('txt2', algorithm) }
: track);
}
return {
match: {
tmdbId: request.tmdbId,
kisskhDramaId: provider.match.kisskhDramaId,
episodeId: provider.match.episodeId,
season: request.season,
episode: request.episode,
},
sources: [{
id: 'kisskh-primary',
label: 'KissKH',
type: mediaType(provider.mediaUrl),
url: mediaUrl,
fallbackToken,
}],
subtitles: subtitles.map((track) => ({
...track,
proxyUrl: proxyMedia ? proxyUrl(publicOrigin, track.sourceUrl) : track.sourceUrl,
})),
};
}
return Object.freeze({
async warmCatalog() {
if (!useEnhancedCatalogMatching || typeof deps.kisskhClient?.list !== 'function') return;
await catalogItems(deps.kisskhClient);
},
async getRetrievalProgress() {
return typeof cache.getCatalogProgress === 'function' ? cache.getCatalogProgress() : null;
},
async getCachedTv(request, options = {}) {
assertRequest(request);
const cachedCode = await cache.getNotFound('tv', request.tmdbId, request.season, request.episode);
if (cachedCode) throw safeError(cachedCode);
const provider = await cache.getResolution(
'tv', request.tmdbId, request.season, request.episode, { allowStale: true },
);
return provider ? makePublicResolution(provider, request, options) : null;
},
async warmTv(request, options = {}) {
assertRequest(request);
if (await cache.getResolution('tv', request.tmdbId, request.season, request.episode)) return;
await providerFor('tv', request, options);
},
async resolveTv(request, options = {}) {
assertRequest(request);
const cached = await cache.getResolution('tv', request.tmdbId, request.season, request.episode);
const provider = cached || await providerFor('tv', request, options);
return makePublicResolution(provider, request, options);
},
async getCachedMovie(request, options = {}) {
assertMovieRequest(request);
const cachedCode = await cache.getNotFound('movie', request.tmdbId, request.season, request.episode);
if (cachedCode) throw safeError(cachedCode);
const provider = await cache.getResolution(
'movie', request.tmdbId, request.season, request.episode, { allowStale: true },
);
return provider ? makePublicResolution(provider, request, options) : null;
},
async warmMovie(request, options = {}) {
assertMovieRequest(request);
if (await cache.getResolution('movie', request.tmdbId, request.season, request.episode)) return;
await providerFor('movie', request, options);
},
async resolveMovie(request, options = {}) {
assertMovieRequest(request);
const cached = await cache.getResolution('movie', request.tmdbId, request.season, request.episode);
const provider = cached || await providerFor('movie', request, options);
return makePublicResolution(provider, request, options);
},
});
}
module.exports = { createKisskhResolver, validatePublicProxyOrigin };