SteveCelticus commited on
Commit
30f73be
·
verified ·
1 Parent(s): 9fc329b

Update api/stremio.js

Browse files
Files changed (1) hide show
  1. api/stremio.js +523 -523
api/stremio.js CHANGED
@@ -1,523 +1,523 @@
1
- const { addonBuilder } = require('stremio-addon-sdk');
2
- const kisskh = require('./kisskh');
3
- const { getCloudflareCookie } = require('./cloudflare');
4
- const puppeteerExtra = require('puppeteer-extra');
5
- const StealthPlugin = require('puppeteer-extra-plugin-stealth');
6
- puppeteerExtra.use(StealthPlugin());
7
-
8
- const builder = new addonBuilder({
9
- id: 'com.kisskh.addon',
10
- version: '1.1.6',
11
- name: 'KissKH Addon',
12
- description: 'Asian content',
13
- resources: [
14
- { name: 'catalog', types: ['series'] },
15
- { name: 'meta', types: ['series'], idPrefixes: ['kisskh_'] },
16
- { name: 'stream', types: ['series'], idPrefixes: ['kisskh_'], idPattern: 'kisskh_\\d+:\\d+' },
17
- { name: 'subtitles', types: ['series'], idPrefixes: ['kisskh_'] }
18
- ],
19
- types: ['series'],
20
- catalogs: [{
21
- type: 'series',
22
- id: 'kisskh',
23
- name: 'K-Drama',
24
- extra: [
25
- { name: 'search', isRequired: false },
26
- { name: 'skip', isRequired: false },
27
- { name: 'limit', isRequired: false }
28
- ]
29
- }]
30
- });
31
-
32
- const seriesDetailsCache = new Map();
33
- const streamCache = new Map();
34
-
35
- async function getCachedSeriesDetails(seriesId) {
36
- if (seriesDetailsCache.has(seriesId)) {
37
- const cached = seriesDetailsCache.get(seriesId);
38
- if (Date.now() - cached.timestamp < 2 * 60 * 60 * 1000) {
39
- console.log(`[Cache] getSeriesDetails hit per ${seriesId}`);
40
- return cached.data;
41
- } else {
42
- seriesDetailsCache.delete(seriesId);
43
- }
44
- }
45
- const data = await kisskh.getSeriesDetails(seriesId);
46
- seriesDetailsCache.set(seriesId, { data, timestamp: Date.now() });
47
- return data;
48
- }
49
-
50
- async function extractStreamFromIframe(page) {
51
- try {
52
- const iframes = await page.$$('iframe');
53
- if (iframes.length === 0) return null;
54
-
55
- for (const iframe of iframes) {
56
- const src = await iframe.evaluate(el => el.src);
57
- if (src && (src.includes('player') || src.includes('embed'))) {
58
- console.log(`[extractStreamFromIframe] Found iframe with src: ${src}`);
59
-
60
- // Navigate to iframe source
61
- const iframePage = await page.browser().newPage();
62
- await iframePage.goto(src, { waitUntil: 'networkidle2', timeout: 30000 });
63
-
64
- // Look for stream URLs in iframe page
65
- const iframeContent = await iframePage.content();
66
- const streamMatches = iframeContent.match(/(https?:\/\/[^"'\s]+\.m3u8[^"'\s]*|https?:\/\/[^"'\s]+\.mp4[^"'\s]*)/g);
67
-
68
- if (streamMatches && streamMatches.length > 0) {
69
- const streamUrl = streamMatches[0];
70
- console.log(`[extractStreamFromIframe] Found stream in iframe: ${streamUrl}`);
71
- await iframePage.close();
72
- return streamUrl;
73
- }
74
-
75
- // Try to extract from network requests
76
- let iframeStreamUrl = null;
77
- iframePage.on('request', request => {
78
- const url = request.url();
79
- if (url.includes('.m3u8') || url.includes('.mp4')) {
80
- console.log(`[extractStreamFromIframe] Intercepted stream in iframe: ${url}`);
81
- iframeStreamUrl = url;
82
- }
83
- });
84
-
85
- // Try clicking play button in iframe
86
- try {
87
- const playButtons = [
88
- '.jw-icon-playback', '.vjs-big-play-button',
89
- '.play-button', '[aria-label="Play"]',
90
- '.ytp-large-play-button', '.play-icon',
91
- 'button[title="Play"]', '.plyr__control--play'
92
- ];
93
-
94
- for (const selector of playButtons) {
95
- const playButton = await iframePage.$(selector);
96
- if (playButton) {
97
- console.log(`[extractStreamFromIframe] Clicking play button in iframe: ${selector}`);
98
- await playButton.click();
99
- // Replace waitForTimeout with setTimeout wrapped in a Promise
100
- await new Promise(resolve => setTimeout(resolve, 5000));
101
- break;
102
- }
103
- }
104
- } catch (e) {
105
- console.log('[extractStreamFromIframe] Error clicking play in iframe:', e.message);
106
- }
107
-
108
- // Replace waitForTimeout with setTimeout wrapped in a Promise
109
- await new Promise(resolve => setTimeout(resolve, 5000));
110
- await iframePage.close();
111
-
112
- if (iframeStreamUrl) return iframeStreamUrl;
113
- }
114
- }
115
- } catch (e) {
116
- console.error('[extractStreamFromIframe] Error:', e.message);
117
- }
118
- return null;
119
- }
120
-
121
- async function resolveEpisodeStreamUrl(seriesId, episodeId) {
122
- const cacheKey = `${seriesId}_${episodeId}`;
123
- if (streamCache.has(cacheKey)) {
124
- const cached = streamCache.get(cacheKey);
125
- if (Date.now() - cached.timestamp < 2 * 60 * 60 * 1000) {
126
- console.log(`[StreamCache] Hit per ${cacheKey}`);
127
- return cached.url;
128
- }
129
- }
130
-
131
- const browser = await puppeteerExtra.launch({
132
- headless: true,
133
- args: [
134
- '--no-sandbox',
135
- '--disable-setuid-sandbox',
136
- '--disable-web-security',
137
- '--disable-features=IsolateOrigins,site-per-process'
138
- ]
139
- });
140
- let streamUrl = null;
141
-
142
- try {
143
- const page = await browser.newPage();
144
- await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36');
145
-
146
- // Enable request interception
147
- await page.setRequestInterception(true);
148
-
149
- // Set up request handler
150
- page.on('request', request => {
151
- // Block image and font requests to speed up loading
152
- if (['image', 'font', 'stylesheet'].includes(request.resourceType())) {
153
- request.abort();
154
- } else {
155
- request.continue();
156
- }
157
- });
158
-
159
- const cfCookieString = await getCloudflareCookie();
160
- const cfClearanceValue = cfCookieString.split('=')[1];
161
- await page.setCookie({
162
- name: 'cf_clearance',
163
- value: cfClearanceValue,
164
- domain: 'kisskh.co',
165
- path: '/',
166
- httpOnly: true,
167
- secure: true,
168
- sameSite: 'Lax'
169
- });
170
-
171
- // Extract episode ID correctly
172
- let epId;
173
- if (episodeId.includes(':')) {
174
- epId = episodeId.split(':').pop();
175
- } else if (episodeId.startsWith('kisskh_')) {
176
- epId = episodeId.replace(/^kisskh_\d+:/, '');
177
- } else {
178
- epId = episodeId;
179
- }
180
-
181
- const targetUrl = `https://kisskh.co/Drama/Any/Episode-Any?id=${seriesId}&ep=${epId}`;
182
- console.log(`[resolveEpisodeStreamUrl] Navigating to ${targetUrl}`);
183
-
184
- // Track all network requests for stream URLs
185
- page.on('response', async response => {
186
- if (streamUrl) return; // Already found a stream
187
-
188
- const url = response.url();
189
- const contentType = response.headers()['content-type'] || '';
190
-
191
- // Direct stream URLs
192
- if (url.includes('.m3u8') || url.includes('.mp4')) {
193
- console.log(`[resolveEpisodeStreamUrl] Direct stream found: ${url}`);
194
- streamUrl = url;
195
- return;
196
- }
197
-
198
- // API responses that might contain stream info
199
- if ((url.includes('/api/DramaList/') || url.includes('/api/Drama/')) &&
200
- contentType.includes('application/json')) {
201
- try {
202
- const text = await response.text();
203
- const data = JSON.parse(text);
204
-
205
- // Check various possible fields for stream URLs
206
- const possibleFields = ['Video', 'video', 'stream', 'url', 'src', 'source', 'file'];
207
- for (const field of possibleFields) {
208
- if (data && data[field] && typeof data[field] === 'string') {
209
- const possibleUrl = data[field];
210
- if (possibleUrl.includes('http') || possibleUrl.startsWith('//')) {
211
- console.log(`[resolveEpisodeStreamUrl] Found stream in API (${field}): ${possibleUrl}`);
212
- streamUrl = possibleUrl.startsWith('//') ? 'https:' + possibleUrl : possibleUrl;
213
- return;
214
- }
215
- }
216
- }
217
-
218
- // Check for nested sources array
219
- if (data && data.sources && Array.isArray(data.sources)) {
220
- for (const source of data.sources) {
221
- if (source && source.file && typeof source.file === 'string') {
222
- console.log(`[resolveEpisodeStreamUrl] Found stream in sources array: ${source.file}`);
223
- streamUrl = source.file.startsWith('//') ? 'https:' + source.file : source.file;
224
- return;
225
- }
226
- }
227
- }
228
- } catch (e) {
229
- // Ignore parsing errors
230
- }
231
- }
232
- });
233
-
234
- // Navigate to the page
235
- await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 60000 });
236
-
237
- // Wait for content to load - replace waitForTimeout with setTimeout wrapped in a Promise
238
- await new Promise(resolve => setTimeout(resolve, 8000));
239
-
240
- // If no stream found yet, try direct API call
241
- if (!streamUrl) {
242
- try {
243
- // Try to make a direct API call to get the stream
244
- const apiUrl = `https://kisskh.co/api/DramaList/Episode/${epId}.png?err=false&ts=null&time=null`;
245
- console.log(`[resolveEpisodeStreamUrl] Trying direct API call: ${apiUrl}`);
246
-
247
- const apiResponse = await page.evaluate(async (url) => {
248
- const response = await fetch(url);
249
- return await response.text();
250
- }, apiUrl);
251
-
252
- try {
253
- const apiData = JSON.parse(apiResponse);
254
- if (apiData && apiData.Video) {
255
- console.log(`[resolveEpisodeStreamUrl] Found stream in direct API call: ${apiData.Video}`);
256
- streamUrl = apiData.Video;
257
- }
258
- } catch (e) {
259
- console.log('[resolveEpisodeStreamUrl] Error parsing API response:', e.message);
260
- }
261
- } catch (e) {
262
- console.log('[resolveEpisodeStreamUrl] Error with direct API call:', e.message);
263
- }
264
- }
265
-
266
- // If still no stream, try to click play button
267
- if (!streamUrl) {
268
- try {
269
- const playButtonSelectors = [
270
- '.jw-icon-playback', '.vjs-big-play-button',
271
- '.play-button', '[aria-label="Play"]',
272
- '.ytp-large-play-button', '.play-icon',
273
- 'button[title="Play"]', '.plyr__control--play',
274
- '.btn-play', '#play-button'
275
- ];
276
-
277
- for (const selector of playButtonSelectors) {
278
- const playButton = await page.$(selector);
279
- if (playButton) {
280
- console.log(`[resolveEpisodeStreamUrl] Clicking play button: ${selector}`);
281
- await playButton.click();
282
- // Replace waitForTimeout with setTimeout wrapped in a Promise
283
- await new Promise(resolve => setTimeout(resolve, 5000));
284
- break;
285
- }
286
- }
287
- } catch (e) {
288
- console.log('[resolveEpisodeStreamUrl] Error clicking play button:', e.message);
289
- }
290
- }
291
-
292
- // If still no stream, try to extract from iframes
293
- if (!streamUrl) {
294
- streamUrl = await extractStreamFromIframe(page);
295
- }
296
-
297
- // If still no stream, try to extract from page content
298
- if (!streamUrl) {
299
- const pageContent = await page.content();
300
-
301
- // Look for m3u8 or mp4 URLs
302
- const streamMatches = pageContent.match(/(https?:\/\/[^"'\s]+\.m3u8[^"'\s]*|https?:\/\/[^"'\s]+\.mp4[^"'\s]*)/g);
303
- if (streamMatches && streamMatches.length > 0) {
304
- streamUrl = streamMatches[0];
305
- console.log(`[resolveEpisodeStreamUrl] Found stream in page content: ${streamUrl}`);
306
- } else {
307
- // Look for player configuration
308
- const jwPlayerMatch = pageContent.match(/jwplayer\([^)]+\)\.setup\((\{[^}]+\})\)/);
309
- if (jwPlayerMatch && jwPlayerMatch[1]) {
310
- try {
311
- // Extract and clean up the JSON string
312
- let configStr = jwPlayerMatch[1].replace(/'/g, '"');
313
- // Handle trailing commas which are invalid in JSON
314
- configStr = configStr.replace(/,\s*}/g, '}').replace(/,\s*]/g, ']');
315
-
316
- // Try to parse as JSON
317
- const config = JSON.parse(configStr);
318
- if (config.file) {
319
- streamUrl = config.file;
320
- console.log(`[resolveEpisodeStreamUrl] Found stream in JW Player config: ${streamUrl}`);
321
- } else if (config.sources && Array.isArray(config.sources) && config.sources.length > 0) {
322
- streamUrl = config.sources[0].file;
323
- console.log(`[resolveEpisodeStreamUrl] Found stream in JW Player sources: ${streamUrl}`);
324
- }
325
- } catch (e) {
326
- console.log('[resolveEpisodeStreamUrl] Error parsing JW Player config:', e.message);
327
- }
328
- }
329
- }
330
- }
331
-
332
- // Cache the result if found
333
- if (streamUrl) {
334
- streamCache.set(cacheKey, { url: streamUrl, timestamp: Date.now() });
335
- } else {
336
- console.warn(`[resolveEpisodeStreamUrl] No stream found for ${seriesId}:${epId}`);
337
- }
338
-
339
- return streamUrl;
340
- } catch (err) {
341
- console.error('[resolveEpisodeStreamUrl] Error:', err.stack || err.message);
342
- return null;
343
- } finally {
344
- await browser.close();
345
- }
346
- }
347
-
348
- builder.defineCatalogHandler(async ({ type, id, extra = {} }) => {
349
- console.log(`[CatalogHandler] Request catalog: type=${type}, id=${id}, extra=${JSON.stringify(extra)}`);
350
-
351
- if (type !== 'series') return { metas: [] };
352
-
353
- const limit = parseInt(extra.limit) || 30;
354
- const skip = parseInt(extra.skip) || 0;
355
- const page = Math.floor(skip / limit) + 1;
356
- const search = extra.search || '';
357
- const metas = await kisskh.getCatalog({ page, limit, search });
358
- return { metas };
359
- });
360
-
361
- builder.defineMetaHandler(async ({ type, id }) => {
362
- console.log(`[MetaHandler] Request meta for id=${id}`);
363
- if (type !== 'series') return { meta: null };
364
-
365
- const seriesId = id.replace('kisskh_', '');
366
- let details;
367
- try {
368
- details = await getCachedSeriesDetails(seriesId);
369
- console.log('[MetaHandler] Details retrieved:', JSON.stringify(details, null, 2));
370
- } catch (e) {
371
- console.error('[MetaHandler] Error in getSeriesDetails:', e.stack || e.message);
372
- return {
373
- meta: {
374
- id,
375
- type: 'series',
376
- name: 'Loading Error',
377
- description: 'Unable to retrieve series details. Please try again later.',
378
- poster: '',
379
- videos: []
380
- }
381
- };
382
- }
383
-
384
- if (!details || !Array.isArray(details.episodes) || details.episodes.length === 0) {
385
- console.warn('[MetaHandler] Incomplete details or missing episodes for', seriesId);
386
- return {
387
- meta: {
388
- id,
389
- type: 'series',
390
- name: details?.title || 'Title not available',
391
- description: 'Series details incomplete or missing.',
392
- poster: details?.thumbnail || '',
393
- videos: []
394
- }
395
- };
396
- }
397
-
398
- // Map episodes correctly
399
- const videos = details.episodes.map(ep => ({
400
- id: `${ep.id}`,
401
- title: ep.title || `Episode ${ep.number}`,
402
- season: ep.season || 1,
403
- episode: ep.episode || ep.number || 1
404
- }));
405
-
406
- const meta = {
407
- id: `kisskh_${details.id}`,
408
- type: 'series',
409
- name: details.title || '',
410
- poster: details.thumbnail || '',
411
- background: details.thumbnail || '',
412
- posterShape: 'poster',
413
- description: (details.description || '').replace(/\r?\n+/g, ' ').trim(),
414
- releaseInfo: details.releaseDate ? details.releaseDate.slice(0, 4) : '',
415
- videos,
416
- };
417
-
418
- return { meta };
419
- });
420
-
421
- builder.defineStreamHandler(async ({ type, id }) => {
422
- console.log(`[StreamHandler] Request stream for id=${id}`);
423
- if (type !== 'series') return { streams: [] };
424
-
425
- if (!id.includes(':')) {
426
- console.log(`[StreamHandler] Generic request for ${id} (no episode selected)`);
427
- return {
428
- streams: [{
429
- title: '🔍 Select an episode to see the stream',
430
- url: 'https://stremio.com', // Dummy but valid URL
431
- isFree: true,
432
- behaviorHints: {
433
- notWebReady: true,
434
- catalogNotSelectable: true
435
- }
436
- }]
437
- };
438
- }
439
-
440
- // Robust ID parsing
441
- let seriesId, episodeId;
442
- if (id.startsWith('kisskh_')) {
443
- const parts = id.split(':');
444
- if (parts.length === 2) {
445
- // Normal case: "kisskh_123:456"
446
- seriesId = parts[0].replace('kisskh_', '');
447
- episodeId = parts[1];
448
- } else if (parts.length === 3) {
449
- // Anomalous case: "kisskh_123:kisskh_123:456"
450
- seriesId = parts[0].replace('kisskh_', '');
451
- episodeId = parts[2];
452
- } else {
453
- // Fallback
454
- seriesId = id.replace('kisskh_', '').split(':')[0];
455
- episodeId = id.split(':').pop();
456
- }
457
- } else {
458
- // Fallback for ID without prefix
459
- seriesId = id.split(':')[0];
460
- episodeId = id.split(':').pop();
461
- }
462
- console.log(`[StreamHandler] seriesId=${seriesId} episodeId=${episodeId}`);
463
-
464
- try {
465
- const streamUrl = await resolveEpisodeStreamUrl(seriesId, episodeId);
466
-
467
- if (!streamUrl) {
468
- return {
469
- streams: [{
470
- title: '⏳ No stream found. Try again later.',
471
- url: '',
472
- isFree: true,
473
- behaviorHints: { notWebReady: true }
474
- }]
475
- };
476
- }
477
-
478
- const format = streamUrl.includes('.m3u8') ? 'hls' : 'mp4';
479
- return {
480
- streams: [{
481
- title: '▶️ Episode Stream',
482
- url: streamUrl,
483
- isFree: true,
484
- format,
485
- behaviorHints: { notWebReady: false }
486
- }]
487
- };
488
- } catch (e) {
489
- console.error('[STREAM HANDLER ERROR]', e.stack || e.message);
490
- return {
491
- streams: [{
492
- title: '❌ Error during loading',
493
- url: '',
494
- isFree: true,
495
- behaviorHints: { notWebReady: true }
496
- }]
497
- };
498
- }
499
- });
500
-
501
- builder.defineSubtitlesHandler(async ({ type, id }) => {
502
- console.log(`[SubtitlesHandler] Request subtitles for id=${id}`);
503
- if (type !== 'series') return { subtitles: [] };
504
-
505
- const [seriesId, episodeId] = id.replace('kisskh_', '').split(':');
506
- if (!seriesId || !episodeId) return { subtitles: [] };
507
-
508
- try {
509
- const subtitles = await kisskh.getSubtitlesWithPuppeteer(seriesId, episodeId);
510
- return {
511
- subtitles: subtitles.map(sub => ({
512
- id: `${id}:${sub.lang}`,
513
- lang: sub.lang,
514
- url: `data:text/vtt;base64,${Buffer.from(sub.text).toString('base64')}`
515
- }))
516
- };
517
- } catch (e) {
518
- console.error(`[SubtitlesHandler] Subtitle error:`, e.stack || e.message);
519
- return { subtitles: [] };
520
- }
521
- });
522
-
523
- module.exports = builder.getInterface();
 
1
+ const { addonBuilder } = require('stremio-addon-sdk');
2
+ const kisskh = require('./kisskh');
3
+ const { getCloudflareCookie } = require('./cloudflare');
4
+ const puppeteerExtra = require('puppeteer-extra');
5
+ const StealthPlugin = require('puppeteer-extra-plugin-stealth');
6
+ puppeteerExtra.use(StealthPlugin());
7
+
8
+ const builder = new addonBuilder({
9
+ id: 'com.kisskh.addon',
10
+ version: '1.1.7',
11
+ name: 'KissKH Addon',
12
+ description: 'Asian content',
13
+ resources: [
14
+ { name: 'catalog', types: ['series'] },
15
+ { name: 'meta', types: ['series'], idPrefixes: ['kisskh_'] },
16
+ { name: 'stream', types: ['series'], idPrefixes: ['kisskh_'], idPattern: 'kisskh_\\d+:\\d+' },
17
+ { name: 'subtitles', types: ['series'], idPrefixes: ['kisskh_'] }
18
+ ],
19
+ types: ['series'],
20
+ catalogs: [{
21
+ type: 'series',
22
+ id: 'kisskh',
23
+ name: 'K-Drama',
24
+ extra: [
25
+ { name: 'search', isRequired: false },
26
+ { name: 'skip', isRequired: false },
27
+ { name: 'limit', isRequired: false }
28
+ ]
29
+ }]
30
+ });
31
+
32
+ const seriesDetailsCache = new Map();
33
+ const streamCache = new Map();
34
+
35
+ async function getCachedSeriesDetails(seriesId) {
36
+ if (seriesDetailsCache.has(seriesId)) {
37
+ const cached = seriesDetailsCache.get(seriesId);
38
+ if (Date.now() - cached.timestamp < 2 * 60 * 60 * 1000) {
39
+ console.log(`[Cache] getSeriesDetails hit per ${seriesId}`);
40
+ return cached.data;
41
+ } else {
42
+ seriesDetailsCache.delete(seriesId);
43
+ }
44
+ }
45
+ const data = await kisskh.getSeriesDetails(seriesId);
46
+ seriesDetailsCache.set(seriesId, { data, timestamp: Date.now() });
47
+ return data;
48
+ }
49
+
50
+ async function extractStreamFromIframe(page) {
51
+ try {
52
+ const iframes = await page.$$('iframe');
53
+ if (iframes.length === 0) return null;
54
+
55
+ for (const iframe of iframes) {
56
+ const src = await iframe.evaluate(el => el.src);
57
+ if (src && (src.includes('player') || src.includes('embed'))) {
58
+ console.log(`[extractStreamFromIframe] Found iframe with src: ${src}`);
59
+
60
+ // Navigate to iframe source
61
+ const iframePage = await page.browser().newPage();
62
+ await iframePage.goto(src, { waitUntil: 'networkidle2', timeout: 30000 });
63
+
64
+ // Look for stream URLs in iframe page
65
+ const iframeContent = await iframePage.content();
66
+ const streamMatches = iframeContent.match(/(https?:\/\/[^"'\s]+\.m3u8[^"'\s]*|https?:\/\/[^"'\s]+\.mp4[^"'\s]*)/g);
67
+
68
+ if (streamMatches && streamMatches.length > 0) {
69
+ const streamUrl = streamMatches[0];
70
+ console.log(`[extractStreamFromIframe] Found stream in iframe: ${streamUrl}`);
71
+ await iframePage.close();
72
+ return streamUrl;
73
+ }
74
+
75
+ // Try to extract from network requests
76
+ let iframeStreamUrl = null;
77
+ iframePage.on('request', request => {
78
+ const url = request.url();
79
+ if (url.includes('.m3u8') || url.includes('.mp4')) {
80
+ console.log(`[extractStreamFromIframe] Intercepted stream in iframe: ${url}`);
81
+ iframeStreamUrl = url;
82
+ }
83
+ });
84
+
85
+ // Try clicking play button in iframe
86
+ try {
87
+ const playButtons = [
88
+ '.jw-icon-playback', '.vjs-big-play-button',
89
+ '.play-button', '[aria-label="Play"]',
90
+ '.ytp-large-play-button', '.play-icon',
91
+ 'button[title="Play"]', '.plyr__control--play'
92
+ ];
93
+
94
+ for (const selector of playButtons) {
95
+ const playButton = await iframePage.$(selector);
96
+ if (playButton) {
97
+ console.log(`[extractStreamFromIframe] Clicking play button in iframe: ${selector}`);
98
+ await playButton.click();
99
+ // Replace waitForTimeout with setTimeout wrapped in a Promise
100
+ await new Promise(resolve => setTimeout(resolve, 5000));
101
+ break;
102
+ }
103
+ }
104
+ } catch (e) {
105
+ console.log('[extractStreamFromIframe] Error clicking play in iframe:', e.message);
106
+ }
107
+
108
+ // Replace waitForTimeout with setTimeout wrapped in a Promise
109
+ await new Promise(resolve => setTimeout(resolve, 5000));
110
+ await iframePage.close();
111
+
112
+ if (iframeStreamUrl) return iframeStreamUrl;
113
+ }
114
+ }
115
+ } catch (e) {
116
+ console.error('[extractStreamFromIframe] Error:', e.message);
117
+ }
118
+ return null;
119
+ }
120
+
121
+ async function resolveEpisodeStreamUrl(seriesId, episodeId) {
122
+ const cacheKey = `${seriesId}_${episodeId}`;
123
+ if (streamCache.has(cacheKey)) {
124
+ const cached = streamCache.get(cacheKey);
125
+ if (Date.now() - cached.timestamp < 2 * 60 * 60 * 1000) {
126
+ console.log(`[StreamCache] Hit per ${cacheKey}`);
127
+ return cached.url;
128
+ }
129
+ }
130
+
131
+ const browser = await puppeteerExtra.launch({
132
+ headless: true,
133
+ args: [
134
+ '--no-sandbox',
135
+ '--disable-setuid-sandbox',
136
+ '--disable-web-security',
137
+ '--disable-features=IsolateOrigins,site-per-process'
138
+ ]
139
+ });
140
+ let streamUrl = null;
141
+
142
+ try {
143
+ const page = await browser.newPage();
144
+ await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36');
145
+
146
+ // Enable request interception
147
+ await page.setRequestInterception(true);
148
+
149
+ // Set up request handler
150
+ page.on('request', request => {
151
+ // Block image and font requests to speed up loading
152
+ if (['image', 'font', 'stylesheet'].includes(request.resourceType())) {
153
+ request.abort();
154
+ } else {
155
+ request.continue();
156
+ }
157
+ });
158
+
159
+ const cfCookieString = await getCloudflareCookie();
160
+ const cfClearanceValue = cfCookieString.split('=')[1];
161
+ await page.setCookie({
162
+ name: 'cf_clearance',
163
+ value: cfClearanceValue,
164
+ domain: 'kisskh.co',
165
+ path: '/',
166
+ httpOnly: true,
167
+ secure: true,
168
+ sameSite: 'Lax'
169
+ });
170
+
171
+ // Extract episode ID correctly
172
+ let epId;
173
+ if (episodeId.includes(':')) {
174
+ epId = episodeId.split(':').pop();
175
+ } else if (episodeId.startsWith('kisskh_')) {
176
+ epId = episodeId.replace(/^kisskh_\d+:/, '');
177
+ } else {
178
+ epId = episodeId;
179
+ }
180
+
181
+ const targetUrl = `https://kisskh.co/Drama/Any/Episode-Any?id=${seriesId}&ep=${epId}`;
182
+ console.log(`[resolveEpisodeStreamUrl] Navigating to ${targetUrl}`);
183
+
184
+ // Track all network requests for stream URLs
185
+ page.on('response', async response => {
186
+ if (streamUrl) return; // Already found a stream
187
+
188
+ const url = response.url();
189
+ const contentType = response.headers()['content-type'] || '';
190
+
191
+ // Direct stream URLs
192
+ if (url.includes('.m3u8') || url.includes('.mp4')) {
193
+ console.log(`[resolveEpisodeStreamUrl] Direct stream found: ${url}`);
194
+ streamUrl = url;
195
+ return;
196
+ }
197
+
198
+ // API responses that might contain stream info
199
+ if ((url.includes('/api/DramaList/') || url.includes('/api/Drama/')) &&
200
+ contentType.includes('application/json')) {
201
+ try {
202
+ const text = await response.text();
203
+ const data = JSON.parse(text);
204
+
205
+ // Check various possible fields for stream URLs
206
+ const possibleFields = ['Video', 'video', 'stream', 'url', 'src', 'source', 'file'];
207
+ for (const field of possibleFields) {
208
+ if (data && data[field] && typeof data[field] === 'string') {
209
+ const possibleUrl = data[field];
210
+ if (possibleUrl.includes('http') || possibleUrl.startsWith('//')) {
211
+ console.log(`[resolveEpisodeStreamUrl] Found stream in API (${field}): ${possibleUrl}`);
212
+ streamUrl = possibleUrl.startsWith('//') ? 'https:' + possibleUrl : possibleUrl;
213
+ return;
214
+ }
215
+ }
216
+ }
217
+
218
+ // Check for nested sources array
219
+ if (data && data.sources && Array.isArray(data.sources)) {
220
+ for (const source of data.sources) {
221
+ if (source && source.file && typeof source.file === 'string') {
222
+ console.log(`[resolveEpisodeStreamUrl] Found stream in sources array: ${source.file}`);
223
+ streamUrl = source.file.startsWith('//') ? 'https:' + source.file : source.file;
224
+ return;
225
+ }
226
+ }
227
+ }
228
+ } catch (e) {
229
+ // Ignore parsing errors
230
+ }
231
+ }
232
+ });
233
+
234
+ // Navigate to the page
235
+ await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 60000 });
236
+
237
+ // Wait for content to load - replace waitForTimeout with setTimeout wrapped in a Promise
238
+ await new Promise(resolve => setTimeout(resolve, 8000));
239
+
240
+ // If no stream found yet, try direct API call
241
+ if (!streamUrl) {
242
+ try {
243
+ // Try to make a direct API call to get the stream
244
+ const apiUrl = `https://kisskh.co/api/DramaList/Episode/${epId}.png?err=false&ts=null&time=null`;
245
+ console.log(`[resolveEpisodeStreamUrl] Trying direct API call: ${apiUrl}`);
246
+
247
+ const apiResponse = await page.evaluate(async (url) => {
248
+ const response = await fetch(url);
249
+ return await response.text();
250
+ }, apiUrl);
251
+
252
+ try {
253
+ const apiData = JSON.parse(apiResponse);
254
+ if (apiData && apiData.Video) {
255
+ console.log(`[resolveEpisodeStreamUrl] Found stream in direct API call: ${apiData.Video}`);
256
+ streamUrl = apiData.Video;
257
+ }
258
+ } catch (e) {
259
+ console.log('[resolveEpisodeStreamUrl] Error parsing API response:', e.message);
260
+ }
261
+ } catch (e) {
262
+ console.log('[resolveEpisodeStreamUrl] Error with direct API call:', e.message);
263
+ }
264
+ }
265
+
266
+ // If still no stream, try to click play button
267
+ if (!streamUrl) {
268
+ try {
269
+ const playButtonSelectors = [
270
+ '.jw-icon-playback', '.vjs-big-play-button',
271
+ '.play-button', '[aria-label="Play"]',
272
+ '.ytp-large-play-button', '.play-icon',
273
+ 'button[title="Play"]', '.plyr__control--play',
274
+ '.btn-play', '#play-button'
275
+ ];
276
+
277
+ for (const selector of playButtonSelectors) {
278
+ const playButton = await page.$(selector);
279
+ if (playButton) {
280
+ console.log(`[resolveEpisodeStreamUrl] Clicking play button: ${selector}`);
281
+ await playButton.click();
282
+ // Replace waitForTimeout with setTimeout wrapped in a Promise
283
+ await new Promise(resolve => setTimeout(resolve, 5000));
284
+ break;
285
+ }
286
+ }
287
+ } catch (e) {
288
+ console.log('[resolveEpisodeStreamUrl] Error clicking play button:', e.message);
289
+ }
290
+ }
291
+
292
+ // If still no stream, try to extract from iframes
293
+ if (!streamUrl) {
294
+ streamUrl = await extractStreamFromIframe(page);
295
+ }
296
+
297
+ // If still no stream, try to extract from page content
298
+ if (!streamUrl) {
299
+ const pageContent = await page.content();
300
+
301
+ // Look for m3u8 or mp4 URLs
302
+ const streamMatches = pageContent.match(/(https?:\/\/[^"'\s]+\.m3u8[^"'\s]*|https?:\/\/[^"'\s]+\.mp4[^"'\s]*)/g);
303
+ if (streamMatches && streamMatches.length > 0) {
304
+ streamUrl = streamMatches[0];
305
+ console.log(`[resolveEpisodeStreamUrl] Found stream in page content: ${streamUrl}`);
306
+ } else {
307
+ // Look for player configuration
308
+ const jwPlayerMatch = pageContent.match(/jwplayer\([^)]+\)\.setup\((\{[^}]+\})\)/);
309
+ if (jwPlayerMatch && jwPlayerMatch[1]) {
310
+ try {
311
+ // Extract and clean up the JSON string
312
+ let configStr = jwPlayerMatch[1].replace(/'/g, '"');
313
+ // Handle trailing commas which are invalid in JSON
314
+ configStr = configStr.replace(/,\s*}/g, '}').replace(/,\s*]/g, ']');
315
+
316
+ // Try to parse as JSON
317
+ const config = JSON.parse(configStr);
318
+ if (config.file) {
319
+ streamUrl = config.file;
320
+ console.log(`[resolveEpisodeStreamUrl] Found stream in JW Player config: ${streamUrl}`);
321
+ } else if (config.sources && Array.isArray(config.sources) && config.sources.length > 0) {
322
+ streamUrl = config.sources[0].file;
323
+ console.log(`[resolveEpisodeStreamUrl] Found stream in JW Player sources: ${streamUrl}`);
324
+ }
325
+ } catch (e) {
326
+ console.log('[resolveEpisodeStreamUrl] Error parsing JW Player config:', e.message);
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ // Cache the result if found
333
+ if (streamUrl) {
334
+ streamCache.set(cacheKey, { url: streamUrl, timestamp: Date.now() });
335
+ } else {
336
+ console.warn(`[resolveEpisodeStreamUrl] No stream found for ${seriesId}:${epId}`);
337
+ }
338
+
339
+ return streamUrl;
340
+ } catch (err) {
341
+ console.error('[resolveEpisodeStreamUrl] Error:', err.stack || err.message);
342
+ return null;
343
+ } finally {
344
+ await browser.close();
345
+ }
346
+ }
347
+
348
+ builder.defineCatalogHandler(async ({ type, id, extra = {} }) => {
349
+ console.log(`[CatalogHandler] Request catalog: type=${type}, id=${id}, extra=${JSON.stringify(extra)}`);
350
+
351
+ if (type !== 'series') return { metas: [] };
352
+
353
+ const limit = parseInt(extra.limit) || 30;
354
+ const skip = parseInt(extra.skip) || 0;
355
+ const page = Math.floor(skip / limit) + 1;
356
+ const search = extra.search || '';
357
+ const metas = await kisskh.getCatalog({ page, limit, search });
358
+ return { metas };
359
+ });
360
+
361
+ builder.defineMetaHandler(async ({ type, id }) => {
362
+ console.log(`[MetaHandler] Request meta for id=${id}`);
363
+ if (type !== 'series') return { meta: null };
364
+
365
+ const seriesId = id.replace('kisskh_', '');
366
+ let details;
367
+ try {
368
+ details = await getCachedSeriesDetails(seriesId);
369
+ console.log('[MetaHandler] Details retrieved:', JSON.stringify(details, null, 2));
370
+ } catch (e) {
371
+ console.error('[MetaHandler] Error in getSeriesDetails:', e.stack || e.message);
372
+ return {
373
+ meta: {
374
+ id,
375
+ type: 'series',
376
+ name: 'Loading Error',
377
+ description: 'Unable to retrieve series details. Please try again later.',
378
+ poster: '',
379
+ videos: []
380
+ }
381
+ };
382
+ }
383
+
384
+ if (!details || !Array.isArray(details.episodes) || details.episodes.length === 0) {
385
+ console.warn('[MetaHandler] Incomplete details or missing episodes for', seriesId);
386
+ return {
387
+ meta: {
388
+ id,
389
+ type: 'series',
390
+ name: details?.title || 'Title not available',
391
+ description: 'Series details incomplete or missing.',
392
+ poster: details?.thumbnail || '',
393
+ videos: []
394
+ }
395
+ };
396
+ }
397
+
398
+ // Map episodes correctly
399
+ const videos = details.episodes.map(ep => ({
400
+ id: `${ep.id}`,
401
+ title: ep.title || `Episode ${ep.number}`,
402
+ season: ep.season || 1,
403
+ episode: ep.episode || ep.number || 1
404
+ }));
405
+
406
+ const meta = {
407
+ id: `kisskh_${details.id}`,
408
+ type: 'series',
409
+ name: details.title || '',
410
+ poster: details.thumbnail || '',
411
+ background: details.thumbnail || '',
412
+ posterShape: 'poster',
413
+ description: (details.description || '').replace(/\r?\n+/g, ' ').trim(),
414
+ releaseInfo: details.releaseDate ? details.releaseDate.slice(0, 4) : '',
415
+ videos,
416
+ };
417
+
418
+ return { meta };
419
+ });
420
+
421
+ builder.defineStreamHandler(async ({ type, id }) => {
422
+ console.log(`[StreamHandler] Request stream for id=${id}`);
423
+ if (type !== 'series') return { streams: [] };
424
+
425
+ if (!id.includes(':')) {
426
+ console.log(`[StreamHandler] Generic request for ${id} (no episode selected)`);
427
+ return {
428
+ streams: [{
429
+ title: '🔍 Select an episode to see the stream',
430
+ url: 'https://stremio.com', // Dummy but valid URL
431
+ isFree: true,
432
+ behaviorHints: {
433
+ notWebReady: true,
434
+ catalogNotSelectable: true
435
+ }
436
+ }]
437
+ };
438
+ }
439
+
440
+ // Robust ID parsing
441
+ let seriesId, episodeId;
442
+ if (id.startsWith('kisskh_')) {
443
+ const parts = id.split(':');
444
+ if (parts.length === 2) {
445
+ // Normal case: "kisskh_123:456"
446
+ seriesId = parts[0].replace('kisskh_', '');
447
+ episodeId = parts[1];
448
+ } else if (parts.length === 3) {
449
+ // Anomalous case: "kisskh_123:kisskh_123:456"
450
+ seriesId = parts[0].replace('kisskh_', '');
451
+ episodeId = parts[2];
452
+ } else {
453
+ // Fallback
454
+ seriesId = id.replace('kisskh_', '').split(':')[0];
455
+ episodeId = id.split(':').pop();
456
+ }
457
+ } else {
458
+ // Fallback for ID without prefix
459
+ seriesId = id.split(':')[0];
460
+ episodeId = id.split(':').pop();
461
+ }
462
+ console.log(`[StreamHandler] seriesId=${seriesId} episodeId=${episodeId}`);
463
+
464
+ try {
465
+ const streamUrl = await resolveEpisodeStreamUrl(seriesId, episodeId);
466
+
467
+ if (!streamUrl) {
468
+ return {
469
+ streams: [{
470
+ title: '⏳ No stream found. Try again later.',
471
+ url: '',
472
+ isFree: true,
473
+ behaviorHints: { notWebReady: true }
474
+ }]
475
+ };
476
+ }
477
+
478
+ const format = streamUrl.includes('.m3u8') ? 'hls' : 'mp4';
479
+ return {
480
+ streams: [{
481
+ title: '▶️ Episode Stream',
482
+ url: streamUrl,
483
+ isFree: true,
484
+ format,
485
+ behaviorHints: { notWebReady: false }
486
+ }]
487
+ };
488
+ } catch (e) {
489
+ console.error('[STREAM HANDLER ERROR]', e.stack || e.message);
490
+ return {
491
+ streams: [{
492
+ title: '❌ Error during loading',
493
+ url: '',
494
+ isFree: true,
495
+ behaviorHints: { notWebReady: true }
496
+ }]
497
+ };
498
+ }
499
+ });
500
+
501
+ builder.defineSubtitlesHandler(async ({ type, id }) => {
502
+ console.log(`[SubtitlesHandler] Request subtitles for id=${id}`);
503
+ if (type !== 'series') return { subtitles: [] };
504
+
505
+ const [seriesId, episodeId] = id.replace('kisskh_', '').split(':');
506
+ if (!seriesId || !episodeId) return { subtitles: [] };
507
+
508
+ try {
509
+ const subtitles = await kisskh.getSubtitlesWithPuppeteer(seriesId, episodeId);
510
+ return {
511
+ subtitles: subtitles.map(sub => ({
512
+ id: `${id}:${sub.lang}`,
513
+ lang: sub.lang,
514
+ url: `data:text/vtt;base64,${Buffer.from(sub.text).toString('base64')}`
515
+ }))
516
+ };
517
+ } catch (e) {
518
+ console.error(`[SubtitlesHandler] Subtitle error:`, e.stack || e.message);
519
+ return { subtitles: [] };
520
+ }
521
+ });
522
+
523
+ module.exports = builder.getInterface();