recycleactor commited on
Commit
d6078d2
Β·
verified Β·
1 Parent(s): 8247903

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile +42 -0
  2. alloha.js +165 -0
  3. cinemar.js +348 -0
  4. package.json +17 -0
  5. server.js +751 -0
  6. turbo.js +479 -0
Dockerfile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim
2
+
3
+ # Install Chromium for Puppeteer
4
+ RUN apt-get update && apt-get install -y \
5
+ chromium \
6
+ fonts-liberation \
7
+ libatk-bridge2.0-0 \
8
+ libatk1.0-0 \
9
+ libcups2 \
10
+ libdrm2 \
11
+ libgbm1 \
12
+ libgtk-3-0 \
13
+ libnspr4 \
14
+ libnss3 \
15
+ libxcomposite1 \
16
+ libxdamage1 \
17
+ libxfixes3 \
18
+ libxkbcommon0 \
19
+ libxrandr2 \
20
+ xdg-utils \
21
+ --no-install-recommends \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
24
+ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
25
+ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
26
+
27
+ WORKDIR /app
28
+ COPY package*.json ./
29
+ RUN npm install
30
+ COPY . .
31
+
32
+ # Create non-root user (required by Choreo/Back4App security scan)
33
+ RUN useradd -m -u 10014 appuser && chown -R appuser:appuser /app
34
+ USER 10014
35
+
36
+ # Port is configured via PORT environment variable
37
+ # Back4App: set PORT=80 in container settings
38
+ # HuggingFace: set PORT=7860 in container settings
39
+ # Railway/Render: PORT is set automatically
40
+ EXPOSE 3000
41
+
42
+ CMD ["node", "server.js"]
alloha.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Alloha stream extractor using Puppeteer
3
+ * Opens the player page in a real browser, intercepts the /bnsi request
4
+ */
5
+ const puppeteer = require('puppeteer');
6
+
7
+ // Alloha domains config
8
+ const ALLOHA_DOMAINS = [
9
+ {
10
+ domain: 'https://streamalloha.live',
11
+ token: '7fda2b04f6ae5e0e228bda812b0dee',
12
+ referer: 'https://kinokrad.my/',
13
+ },
14
+ {
15
+ domain: 'https://alloha.videozal.club',
16
+ token: '7245bc6ce2604536b78f128f818b06',
17
+ referer: 'https://videozal.club/',
18
+ },
19
+ {
20
+ domain: 'https://no.nextpool.online',
21
+ token: 'a4ae37fcdff5014cc929230cb83da5',
22
+ referer: 'https://kinojump.com/',
23
+ },
24
+ ];
25
+
26
+ let browserInstance = null;
27
+
28
+ async function getBrowser() {
29
+ if (browserInstance && browserInstance.connected) {
30
+ return browserInstance;
31
+ }
32
+ console.log('[puppeteer] Launching browser...');
33
+
34
+ const launchOptions = {
35
+ headless: true,
36
+ args: [
37
+ '--no-sandbox',
38
+ '--disable-setuid-sandbox',
39
+ '--disable-dev-shm-usage',
40
+ '--disable-gpu',
41
+ '--no-first-run',
42
+ '--no-zygote',
43
+ '--single-process',
44
+ '--disable-extensions',
45
+ '--disable-background-networking',
46
+ '--disable-default-apps',
47
+ '--disable-sync',
48
+ '--disable-translate',
49
+ '--hide-scrollbars',
50
+ '--metrics-recording-only',
51
+ '--mute-audio',
52
+ '--safebrowsing-disable-auto-update',
53
+ ],
54
+ };
55
+
56
+ // Use system Chromium if specified (Docker/Railway)
57
+ if (process.env.PUPPETEER_EXECUTABLE_PATH) {
58
+ launchOptions.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
59
+ console.log('[puppeteer] Using system Chromium:', launchOptions.executablePath);
60
+ }
61
+
62
+ browserInstance = await puppeteer.launch(launchOptions);
63
+ console.log('[puppeteer] Browser launched');
64
+ return browserInstance;
65
+ }
66
+
67
+ /**
68
+ * Try one domain β€” returns result or throws
69
+ */
70
+ async function tryDomain(browser, { domain, token, referer }, token_movie, timeout_ms) {
71
+ const page = await browser.newPage();
72
+ try {
73
+ await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36');
74
+ await page.setExtraHTTPHeaders({ 'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8' });
75
+
76
+ const playerUrl = `${domain}/?token_movie=${token_movie}&token=${encodeURIComponent(token)}`;
77
+ console.log(`[puppeteer] Trying: ${domain}`);
78
+
79
+ const bnsiPromise = new Promise((resolve, reject) => {
80
+ const timer = setTimeout(() => reject(new Error(`timeout ${timeout_ms}ms`)), timeout_ms);
81
+
82
+ page.on('response', async (response) => {
83
+ const url = response.url();
84
+ if (url.includes('/bnsi/')) {
85
+ clearTimeout(timer);
86
+ try {
87
+ const status = response.status();
88
+ const text = await response.text();
89
+ console.log(`[puppeteer] bnsi ${status} ${url.substring(0, 80)}`);
90
+ if (status === 200) {
91
+ const json = JSON.parse(text);
92
+ resolve({ ok: true, domain, url, ...json });
93
+ } else {
94
+ const decoded = text.replace(/\\u([0-9a-f]{4})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));
95
+ reject(new Error(`bnsi ${status}: ${decoded.substring(0, 100)}`));
96
+ }
97
+ } catch (e) {
98
+ reject(e);
99
+ }
100
+ }
101
+ });
102
+ });
103
+
104
+ // Navigate with referer
105
+ await page.goto(playerUrl, {
106
+ waitUntil: 'domcontentloaded',
107
+ timeout: timeout_ms,
108
+ referer: referer,
109
+ });
110
+
111
+ return await bnsiPromise;
112
+
113
+ } finally {
114
+ await page.close();
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Get streams for a movie using Puppeteer
120
+ * Tries all domains in order, returns first success
121
+ */
122
+ async function getStreams({ token_movie, partner_token, referer, timeout_ms = 20000 }) {
123
+ const browser = await getBrowser();
124
+
125
+ // Build domain list β€” if partner_token provided, try to match or prepend custom
126
+ let domains = [...ALLOHA_DOMAINS];
127
+ if (partner_token) {
128
+ // Find matching domain or add custom entry at front
129
+ const match = domains.find(d => d.token === partner_token);
130
+ if (!match) {
131
+ domains.unshift({ domain: 'https://streamalloha.live', token: partner_token, referer: referer || 'https://kinokrad.my/' });
132
+ } else {
133
+ // Move matching domain to front
134
+ domains = [match, ...domains.filter(d => d !== match)];
135
+ }
136
+ }
137
+
138
+ let lastError = null;
139
+ for (const domainConfig of domains) {
140
+ try {
141
+ const result = await tryDomain(browser, domainConfig, token_movie, timeout_ms);
142
+ if (result && result.ok) {
143
+ console.log(`[puppeteer] SUCCESS on ${domainConfig.domain}`);
144
+ return result;
145
+ }
146
+ } catch (e) {
147
+ console.log(`[puppeteer] ${domainConfig.domain} failed: ${e.message}`);
148
+ lastError = e;
149
+ }
150
+ }
151
+
152
+ throw lastError || new Error('all domains failed');
153
+ }
154
+
155
+ /**
156
+ * Close the browser (call on shutdown)
157
+ */
158
+ async function closeBrowser() {
159
+ if (browserInstance) {
160
+ await browserInstance.close();
161
+ browserInstance = null;
162
+ }
163
+ }
164
+
165
+ module.exports = { getStreams, closeBrowser, getBrowser };
cinemar.js ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Cinemar (cinemar.cc) stream extractor using Puppeteer
3
+ * cinemar.cc now uses Cloudflare Rocket Loader β€” data only available after JS execution
4
+ */
5
+ const { getBrowser } = require('./alloha');
6
+ const https = require('https');
7
+ const http = require('http');
8
+
9
+ function fetchRaw(reqUrl, headers) {
10
+ return new Promise((resolve, reject) => {
11
+ let u; try { u = new URL(reqUrl); } catch(e) { return reject(e); }
12
+ const mod = u.protocol === 'https:' ? https : http;
13
+ const req = mod.request({
14
+ hostname: u.hostname, path: u.pathname + u.search,
15
+ headers: headers || {}
16
+ }, res => {
17
+ let d = ''; res.setEncoding('utf8');
18
+ res.on('data', c => d += c);
19
+ res.on('end', () => resolve(d));
20
+ });
21
+ req.on('error', reject);
22
+ req.setTimeout(12000, () => { req.destroy(); reject(new Error('timeout')); });
23
+ req.end();
24
+ });
25
+ }
26
+
27
+ // ── Helpers (same as server.js) ───────────────────────────────────────────────
28
+
29
+ function findAllInBuf(buf, pattern) {
30
+ const positions = [];
31
+ let pos = 0;
32
+ while (pos < buf.length) {
33
+ const idx = buf.indexOf(pattern, pos);
34
+ if (idx < 0) break;
35
+ positions.push(idx);
36
+ pos = idx + 1;
37
+ }
38
+ return positions;
39
+ }
40
+
41
+ function readJsonStr(buf, pos) {
42
+ let str = '', i = pos;
43
+ while (i < buf.length) {
44
+ const b = buf[i];
45
+ if (b === 0x22) return { str, end: i };
46
+ if (b === 0x5C) {
47
+ i++;
48
+ if (i >= buf.length) break;
49
+ const esc = buf[i];
50
+ if (esc === 0x75 && i + 4 < buf.length) {
51
+ const hex = buf.slice(i + 1, i + 5).toString('ascii');
52
+ if (/^[0-9a-fA-F]{4}$/.test(hex)) { str += String.fromCharCode(parseInt(hex, 16)); i += 5; continue; }
53
+ }
54
+ str += String.fromCharCode(esc);
55
+ } else if (b >= 0x20 && b <= 0x7E) {
56
+ str += String.fromCharCode(b);
57
+ } else if ((b & 0xE0) === 0xC0 && i + 1 < buf.length) {
58
+ str += String.fromCodePoint(((b & 0x1F) << 6) | (buf[i+1] & 0x3F)); i += 2; continue;
59
+ } else if ((b & 0xF0) === 0xE0 && i + 2 < buf.length) {
60
+ str += String.fromCodePoint(((b & 0x0F) << 12) | ((buf[i+1] & 0x3F) << 6) | (buf[i+2] & 0x3F)); i += 3; continue;
61
+ } else break;
62
+ i++;
63
+ }
64
+ return { str, end: i };
65
+ }
66
+
67
+ function findBufVal(buf, key, fromPos, maxDist) {
68
+ const keyBuf = Buffer.from('"' + key + '":"');
69
+ const idx = buf.indexOf(keyBuf, fromPos);
70
+ if (idx < 0 || idx > fromPos + (maxDist || 400)) return null;
71
+ return readJsonStr(buf, idx + keyBuf.length);
72
+ }
73
+
74
+ const FOLDER_MARKER_BUF = Buffer.from('"folder":[');
75
+
76
+ function decodeCinemarFile(fileValue) {
77
+ const w3sPos = fileValue.indexOf('W3s');
78
+ if (w3sPos < 0) return null;
79
+ const raw = fileValue.substring(w3sPos);
80
+ const chunks = raw.split('&');
81
+ const buffers = [];
82
+ for (let i = 0; i < chunks.length; i++) {
83
+ let chunk = chunks[i];
84
+ if (i < chunks.length - 1) chunk = chunk.slice(0, -4);
85
+ const clean = chunk.replace(/[^A-Za-z0-9+/=]/g, '');
86
+ if (clean.length > 0) {
87
+ const padded = clean + '='.repeat((4 - clean.length % 4) % 4);
88
+ try { buffers.push(Buffer.from(padded, 'base64')); } catch(e) {}
89
+ }
90
+ }
91
+ if (!buffers.length) return null;
92
+ return Buffer.concat(buffers);
93
+ }
94
+
95
+ function normUrl(f) { return f ? (f.startsWith('//') ? 'https:' + f : f) : ''; }
96
+
97
+ function parseSubs(subtitle) {
98
+ const subs = [];
99
+ if (!subtitle) return subs;
100
+ subtitle.split(',').forEach(part => {
101
+ const sm = part.match(/^\[([^\]]+)\](\/\/.+|https?:\/\/.+)/);
102
+ if (sm) subs.push({ label: sm[1], url: normUrl(sm[2]) });
103
+ });
104
+ return subs;
105
+ }
106
+
107
+ // ── Get HTML via Puppeteer (bypasses Cloudflare Rocket Loader) ────────────────
108
+ async function getEmbedHtmlViaPuppeteer(embedUrl) {
109
+ const browser = await getBrowser();
110
+ const page = await browser.newPage();
111
+ try {
112
+ await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36');
113
+ await page.setExtraHTTPHeaders({ 'Accept-Language': 'ru-RU,ru;q=0.9' });
114
+
115
+ // Intercept the file data from window object after JS executes
116
+ let fileData = null;
117
+
118
+ await page.setRequestInterception(true);
119
+ page.on('request', req => req.continue());
120
+
121
+ await page.goto(embedUrl, {
122
+ waitUntil: 'networkidle2',
123
+ timeout: 25000,
124
+ referer: 'https://uakinogo.io/'
125
+ });
126
+
127
+ // Wait for player to initialize and extract file data
128
+ await new Promise(r => setTimeout(r, 2000));
129
+
130
+ fileData = await page.evaluate(() => {
131
+ // Try to find file data in window variables
132
+ for (const k of Object.keys(window)) {
133
+ try {
134
+ const v = window[k];
135
+ if (v && typeof v === 'object' && v.file && typeof v.file === 'string' && v.file.length > 100) {
136
+ return JSON.stringify(v);
137
+ }
138
+ } catch(e) {}
139
+ }
140
+ // Try to get from DOM β€” look for script with file data
141
+ for (const s of document.querySelectorAll('script:not([src])')) {
142
+ const t = s.textContent || '';
143
+ if (t.includes('"file"') && t.includes('W3s')) return t;
144
+ }
145
+ // Get full page HTML
146
+ return document.documentElement.outerHTML;
147
+ });
148
+
149
+ console.log('[cinemar] puppeteer got data, length:', fileData ? fileData.length : 0);
150
+ return fileData || '';
151
+ } finally {
152
+ await page.close();
153
+ }
154
+ }
155
+
156
+ // ── Parse cinemar HTML/data ───────────────────────────────────────────────────
157
+ function parseCinemarData(html) {
158
+ const fileMatch = html.match(/"file":"([^"]+)"/);
159
+ if (!fileMatch) return null;
160
+ const buf = decodeCinemarFile(fileMatch[1]);
161
+ if (!buf || buf.length < 20) return null;
162
+
163
+ const isSerial = buf.indexOf(FOLDER_MARKER_BUF) >= 0;
164
+
165
+ if (!isSerial) {
166
+ // Try JSON parse first (movie format: array of voice objects)
167
+ try {
168
+ const jsonStr = buf.toString('utf8');
169
+ const arr = JSON.parse(jsonStr);
170
+ if (Array.isArray(arr) && arr.length && arr[0].title) {
171
+ const items = arr.map(item => ({
172
+ title: item.title || 'Unknown',
173
+ file: (item.file || '').replace(/\\\//g, '/').replace(/^\/\//, 'https://'),
174
+ subtitle: item.subtitle || ''
175
+ })).filter(i => i.file && i.file.includes('cinemap'));
176
+ if (items.length) return { isSerial: false, items };
177
+ }
178
+ } catch(e) {}
179
+
180
+ // Fallback: binary search for src_id
181
+ const items = [];
182
+ findAllInBuf(buf, Buffer.from('"src_id"')).forEach(pos => {
183
+ const t = findBufVal(buf, 'title', pos, 300);
184
+ const f = findBufVal(buf, 'file', pos, 5000);
185
+ const s = findBufVal(buf, 'subtitle', pos, 6000);
186
+ if (!f) return;
187
+ const url = normUrl(f.str.replace(/\\\//g, '/'));
188
+ if (!url || !url.includes('cinemap')) return;
189
+ const title = t ? t.str : 'Unknown';
190
+ if (!items.find(i => i.title === title)) items.push({ title, file: url, subtitle: s ? s.str.replace(/\\\//g, '/') : '' });
191
+ });
192
+ return { isSerial: false, items };
193
+ }
194
+
195
+ // Serial
196
+ const epIdPrefix = Buffer.from('"id":"s');
197
+ const allEpPositions = [];
198
+ findAllInBuf(buf, epIdPrefix).forEach(pos => {
199
+ let i = pos + epIdPrefix.length;
200
+ while (i < buf.length && buf[i] >= 0x30 && buf[i] <= 0x39) i++;
201
+ if (buf[i] !== 0x65) return;
202
+ i++;
203
+ while (i < buf.length && buf[i] >= 0x30 && buf[i] <= 0x39) i++;
204
+ if (buf[i] !== 0x22) return;
205
+ const fullId = buf.slice(pos + 6, i).toString('ascii');
206
+ const seasonMatch = fullId.match(/^(s\d+)e(\d+)$/);
207
+ if (!seasonMatch) return;
208
+ const t = findBufVal(buf, 'title', pos, 200);
209
+ allEpPositions.push({ id: fullId, seasonId: seasonMatch[1], epNum: parseInt(seasonMatch[2], 10), title: t ? t.str : ('БСрия ' + seasonMatch[2]), pos });
210
+ });
211
+
212
+ const seasonMap = {};
213
+ allEpPositions.forEach(ep => {
214
+ if (!seasonMap[ep.seasonId]) seasonMap[ep.seasonId] = [];
215
+ if (!seasonMap[ep.seasonId].find(e => e.id === ep.id)) seasonMap[ep.seasonId].push(ep);
216
+ });
217
+ const seasonIds = Object.keys(seasonMap).sort((a, b) => parseInt(a.substring(1)) - parseInt(b.substring(1)));
218
+ if (!seasonIds.length) return null;
219
+
220
+ const episodes = {};
221
+ seasonIds.forEach(seasonId => {
222
+ const eps = seasonMap[seasonId].sort((a, b) => a.epNum - b.epNum);
223
+ episodes[seasonId] = eps.map((ep, ei) => {
224
+ const nextEpPos = ei + 1 < eps.length ? eps[ei + 1].pos : buf.length;
225
+ const eBuf = buf.slice(ep.pos, nextEpPos);
226
+ const voices = [];
227
+ findAllInBuf(eBuf, Buffer.from('"src_id"')).forEach(vpos => {
228
+ const t = findBufVal(eBuf, 'title', vpos, 300);
229
+ const f = findBufVal(eBuf, 'file', vpos, 2000);
230
+ const s = findBufVal(eBuf, 'subtitle', vpos, 3000);
231
+ if (!f) return;
232
+ const url = normUrl(f.str.replace(/\\\//g, '/'));
233
+ if (!url || !url.includes('.m3u8')) return;
234
+ const title = t ? t.str : 'Unknown';
235
+ if (!voices.find(v => v.title === title)) voices.push({ title, file: url, subtitle: s ? s.str.replace(/\\\//g, '/') : '' });
236
+ });
237
+ return { id: ep.id, title: ep.title, voices };
238
+ });
239
+ });
240
+
241
+ return { isSerial: true, seasonIds, episodes };
242
+ }
243
+
244
+ // ── Main: get stream for a uakinogo page URL ──────────────────────────────────
245
+ async function getCinemarStream(pageUrl) {
246
+ // Step 1: get embed URL from uakinogo page
247
+ const pageHtml = await fetchRaw(pageUrl, {
248
+ 'user-agent': 'Mozilla/5.0 Chrome/120', 'referer': 'https://uakinogo.io/'
249
+ });
250
+ const em = pageHtml.match(/"embedUrl"\s*:\s*"(https:\/\/cinemar\.cc\/embed\/[^"]+)"/);
251
+ if (!em) throw new Error('embed_url not found');
252
+ const embedUrl = em[1];
253
+ console.log('[cinemar] embed URL:', embedUrl.substring(0, 80));
254
+
255
+ // Step 2: get embed HTML β€” try plain HTTP first, fallback to Puppeteer
256
+ let embedHtml = await fetchRaw(embedUrl, {
257
+ 'user-agent': 'Mozilla/5.0 Chrome/120',
258
+ 'referer': 'https://uakinogo.io/',
259
+ 'origin': 'https://uakinogo.io'
260
+ });
261
+
262
+ if (!embedHtml || !embedHtml.includes('"file"')) {
263
+ console.log('[cinemar] no file in plain HTML, trying Puppeteer...');
264
+ embedHtml = await getEmbedHtmlViaPuppeteer(embedUrl);
265
+ } else {
266
+ console.log('[cinemar] got file data via plain HTTP');
267
+ }
268
+
269
+ if (!embedHtml || !embedHtml.includes('"file"')) {
270
+ throw new Error('no file data in embed (Cloudflare blocked?)');
271
+ }
272
+
273
+ // Step 3: parse data
274
+ const parsed = parseCinemarData(embedHtml);
275
+ if (!parsed) throw new Error('failed to parse cinemar data');
276
+
277
+ if (!parsed.isSerial) {
278
+ const voices = parsed.items.map(item => ({
279
+ label: item.title, url: item.file, subtitles: parseSubs(item.subtitle)
280
+ }));
281
+ return { content_type: 'movie', embed_url: embedUrl, voices, serial: null, stream: voices[0] ? voices[0].url : null };
282
+ }
283
+
284
+ // Serial: fetch multiple times and merge
285
+ const merged = {};
286
+ const addEpisodes = (data) => {
287
+ if (!data || !data.isSerial) return;
288
+ data.seasonIds.forEach(sid => {
289
+ (data.episodes[sid] || []).forEach(ep => {
290
+ if (!merged[ep.id] || ep.voices.length > (merged[ep.id] ? merged[ep.id].voices.length : 0)) {
291
+ merged[ep.id] = { ...ep, seasonId: sid };
292
+ }
293
+ });
294
+ });
295
+ };
296
+
297
+ addEpisodes(parsed);
298
+
299
+ // Fetch more in parallel to get all episodes
300
+ const BATCH = 5, MAX_BATCHES = 4;
301
+ for (let b = 0; b < MAX_BATCHES; b++) {
302
+ const prevCount = Object.keys(merged).length;
303
+ const htmls = await Promise.all(
304
+ Array.from({ length: BATCH }, () =>
305
+ fetchRaw(embedUrl, { 'user-agent': 'Mozilla/5.0 Chrome/120', 'referer': 'https://uakinogo.io/', 'origin': 'https://uakinogo.io' }).catch(() => null)
306
+ )
307
+ );
308
+ htmls.filter(Boolean).forEach(html => addEpisodes(parseCinemarData(html)));
309
+ const newCount = Object.keys(merged).length;
310
+ console.log(`[cinemar] batch ${b+1}: ${newCount} eps (+${newCount - prevCount})`);
311
+ if (newCount === prevCount && b >= 1) break;
312
+ if (b < MAX_BATCHES - 1) await new Promise(r => setTimeout(r, 200));
313
+ }
314
+
315
+ // Build serial structure
316
+ const seasonMap = {};
317
+ Object.values(merged).forEach(ep => {
318
+ if (!seasonMap[ep.seasonId]) seasonMap[ep.seasonId] = [];
319
+ seasonMap[ep.seasonId].push(ep);
320
+ });
321
+ const seasonIds = Object.keys(seasonMap).sort((a, b) => parseInt(a.substring(1)) - parseInt(b.substring(1)));
322
+ const seasons = [], episodes = {}, voicesMap = {};
323
+ seasonIds.forEach(sid => {
324
+ const eps = seasonMap[sid].sort((a, b) => {
325
+ const an = parseInt(a.id.match(/e(\d+)/)[1]);
326
+ const bn = parseInt(b.id.match(/e(\d+)/)[1]);
327
+ return an - bn;
328
+ });
329
+ seasons.push({ id: sid, title: 'Π‘Π΅Π·ΠΎΠ½ ' + parseInt(sid.substring(1)) });
330
+ episodes[sid] = eps.map(ep => ({ id: ep.id, title: ep.title }));
331
+ voicesMap[sid] = {};
332
+ eps.forEach(ep => {
333
+ voicesMap[sid][ep.id] = ep.voices.map(v => ({ label: v.title, url: normUrl(v.file), subtitles: parseSubs(v.subtitle) }));
334
+ });
335
+ });
336
+
337
+ const totalEps = Object.values(episodes).reduce((s, e) => s + e.length, 0);
338
+ console.log(`[cinemar] done: ${seasons.length} seasons, ${totalEps} eps`);
339
+
340
+ const serial = { seasons, episodes, voices: voicesMap };
341
+ const firstVoice = seasons[0] && episodes[seasons[0].id] && episodes[seasons[0].id][0]
342
+ ? (voicesMap[seasons[0].id][episodes[seasons[0].id][0].id] || [])[0]
343
+ : null;
344
+
345
+ return { content_type: 'serial', embed_url: embedUrl, serial, voices: [], stream: firstVoice ? firstVoice.url : null };
346
+ }
347
+
348
+ module.exports = { getCinemarStream };
package.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "alloha-puppeteer",
3
+ "version": "1.0.0",
4
+ "description": "Alloha stream extractor using Puppeteer",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js",
8
+ "test": "node test.js",
9
+ "postinstall": "node -e \"try { const p = require('puppeteer'); const path = p.executablePath ? p.executablePath() : 'unknown'; console.log('[postinstall] Chromium path:', path); } catch(e) { console.log('[postinstall] puppeteer error:', e.message); }\""
10
+ },
11
+ "dependencies": {
12
+ "puppeteer": "24.15.0"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ }
17
+ }
server.js ADDED
@@ -0,0 +1,751 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Alloha + Cinemar + Turbo Proxy Server
3
+ * Endpoints: /alloha/streams, /alloha/player-info, /hls, /srt2vtt,
4
+ * /uakinogo/search, /uakinogo/stream, /uakinogo/embed,
5
+ * /turbo/search, /turbo/stream, /health
6
+ */
7
+ const http = require('http');
8
+ const https = require('https');
9
+
10
+ // Load alloha with error handling β€” Puppeteer/Chromium may not be available on all platforms
11
+ let getStreams, closeBrowser;
12
+ try {
13
+ const alloha = require('./alloha');
14
+ getStreams = alloha.getStreams;
15
+ closeBrowser = alloha.closeBrowser;
16
+ console.log('[server] Alloha/Puppeteer loaded OK');
17
+ } catch(e) {
18
+ console.log('[server] Alloha/Puppeteer NOT available:', e.message);
19
+ getStreams = async () => { throw new Error('Puppeteer not available on this platform'); };
20
+ closeBrowser = async () => {};
21
+ }
22
+
23
+ const { turboSearch, getTurboStream } = require('./turbo');
24
+ const { getCinemarStream } = require('./cinemar');
25
+
26
+ const PORT = process.env.PORT || 3000;
27
+ const ALLOHA_HOST = 'streamalloha.live';
28
+ const ALLOHA_TOKEN = '7fda2b04f6ae5e0e228bda812b0dee';
29
+
30
+ const CORS = {
31
+ 'Access-Control-Allow-Origin': '*',
32
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
33
+ 'Access-Control-Allow-Headers': '*',
34
+ 'Content-Type': 'application/json',
35
+ };
36
+
37
+ function jsonResp(res, data, status) {
38
+ res.writeHead(status || 200, CORS);
39
+ res.end(JSON.stringify(data));
40
+ }
41
+
42
+ function parseQuery(reqUrl) {
43
+ try {
44
+ const u = new URL('http://x' + reqUrl);
45
+ const q = {};
46
+ u.searchParams.forEach((v, k) => { q[k] = v; });
47
+ return { pathname: u.pathname, query: q };
48
+ } catch(e) {
49
+ return { pathname: reqUrl.split('?')[0], query: {} };
50
+ }
51
+ }
52
+
53
+ function readBody(req) {
54
+ return new Promise(resolve => {
55
+ let body = '';
56
+ req.setEncoding('utf8');
57
+ req.on('data', c => { body += c; });
58
+ req.on('end', () => resolve(body));
59
+ });
60
+ }
61
+
62
+ function fetchRaw(reqUrl, headers) {
63
+ return new Promise((resolve, reject) => {
64
+ let u; try { u = new URL(reqUrl); } catch(e) { return reject(e); }
65
+ const mod = u.protocol === 'https:' ? https : http;
66
+ const req = mod.request({
67
+ hostname: u.hostname,
68
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
69
+ path: u.pathname + u.search,
70
+ method: 'GET',
71
+ headers: headers || {},
72
+ }, res => {
73
+ // Handle gzip/deflate encoding
74
+ const encoding = res.headers['content-encoding'];
75
+ let stream = res;
76
+ if (encoding === 'gzip' || encoding === 'deflate') {
77
+ const zlib = require('zlib');
78
+ stream = encoding === 'gzip' ? res.pipe(zlib.createGunzip()) : res.pipe(zlib.createInflate());
79
+ }
80
+ let data = '';
81
+ stream.setEncoding('utf8');
82
+ stream.on('data', c => { data += c; });
83
+ stream.on('end', () => resolve(data));
84
+ stream.on('error', reject);
85
+ });
86
+ req.on('error', reject);
87
+ req.setTimeout(20000, () => { req.destroy(); reject(new Error('timeout')); });
88
+ req.end();
89
+ });
90
+ }
91
+
92
+ // ── Cinemar parser ────────────────────────────────────────────────────────────
93
+
94
+ function findAllInBuf(buf, pattern) {
95
+ const positions = [];
96
+ let pos = 0;
97
+ while (pos < buf.length) {
98
+ const idx = buf.indexOf(pattern, pos);
99
+ if (idx < 0) break;
100
+ positions.push(idx);
101
+ pos = idx + 1;
102
+ }
103
+ return positions;
104
+ }
105
+
106
+ function readJsonStr(buf, pos) {
107
+ let str = '', i = pos;
108
+ while (i < buf.length) {
109
+ const b = buf[i];
110
+ if (b === 0x22) return { str, end: i };
111
+ if (b === 0x5C) {
112
+ i++;
113
+ if (i >= buf.length) break;
114
+ const esc = buf[i];
115
+ if (esc === 0x75 && i + 4 < buf.length) {
116
+ const hex = buf.slice(i + 1, i + 5).toString('ascii');
117
+ if (/^[0-9a-fA-F]{4}$/.test(hex)) { str += String.fromCharCode(parseInt(hex, 16)); i += 5; continue; }
118
+ }
119
+ str += String.fromCharCode(esc);
120
+ } else if (b >= 0x20 && b <= 0x7E) {
121
+ str += String.fromCharCode(b);
122
+ } else if ((b & 0xE0) === 0xC0 && i + 1 < buf.length && (buf[i+1] & 0xC0) === 0x80) {
123
+ str += String.fromCodePoint(((b & 0x1F) << 6) | (buf[i+1] & 0x3F)); i += 2; continue;
124
+ } else if ((b & 0xF0) === 0xE0 && i + 2 < buf.length && (buf[i+1] & 0xC0) === 0x80 && (buf[i+2] & 0xC0) === 0x80) {
125
+ str += String.fromCodePoint(((b & 0x0F) << 12) | ((buf[i+1] & 0x3F) << 6) | (buf[i+2] & 0x3F)); i += 3; continue;
126
+ } else break;
127
+ i++;
128
+ }
129
+ return { str, end: i };
130
+ }
131
+
132
+ function findBufVal(buf, key, fromPos, maxDist) {
133
+ const keyBuf = Buffer.from('"' + key + '":"');
134
+ const idx = buf.indexOf(keyBuf, fromPos);
135
+ if (idx < 0 || idx > fromPos + (maxDist || 400)) return null;
136
+ return readJsonStr(buf, idx + keyBuf.length);
137
+ }
138
+
139
+ const FOLDER_MARKER_BUF = Buffer.from('"folder":[');
140
+
141
+ // Cinemar inserts garbage: 4 hex chars + '&' every ~4000 chars into the base64 string.
142
+ // Must split on '&', strip the 4 trailing hex chars from each chunk, decode separately, concat.
143
+ function decodeCinemarFile(fileValue) {
144
+ const w3sPos = fileValue.indexOf('W3s');
145
+ if (w3sPos < 0) return null;
146
+ const raw = fileValue.substring(w3sPos);
147
+ const chunks = raw.split('&');
148
+ const buffers = [];
149
+ for (let i = 0; i < chunks.length; i++) {
150
+ let chunk = chunks[i];
151
+ if (i < chunks.length - 1) chunk = chunk.slice(0, -4); // strip 4 hex garbage chars
152
+ const clean = chunk.replace(/[^A-Za-z0-9+/=]/g, '');
153
+ if (clean.length > 0) {
154
+ const padded = clean + '='.repeat((4 - clean.length % 4) % 4);
155
+ try { buffers.push(Buffer.from(padded, 'base64')); } catch(e) {}
156
+ }
157
+ }
158
+ if (!buffers.length) return null;
159
+ return Buffer.concat(buffers);
160
+ }
161
+
162
+ function normUrl(f) { return f ? (f.startsWith('//') ? 'https:' + f : f) : ''; }
163
+
164
+ function parseSubs(subtitle) {
165
+ const subs = [];
166
+ if (!subtitle) return subs;
167
+ subtitle.split(',').forEach(part => {
168
+ const sm = part.match(/^\[([^\]]+)\](\/\/.+|https?:\/\/.+)/);
169
+ if (sm) subs.push({ label: sm[1], url: normUrl(sm[2]) });
170
+ });
171
+ return subs;
172
+ }
173
+
174
+ function parseCinemarBase64(html) {
175
+ const fileMatch = html.match(/"file":"([^"]+)"/);
176
+ if (!fileMatch) return null;
177
+ const buf = decodeCinemarFile(fileMatch[1]);
178
+ if (!buf || buf.length < 20) return null;
179
+
180
+ const isSerial = buf.indexOf(FOLDER_MARKER_BUF) >= 0;
181
+
182
+ if (!isSerial) {
183
+ const items = [];
184
+ findAllInBuf(buf, Buffer.from('"src_id"')).forEach(pos => {
185
+ const t = findBufVal(buf, 'title', pos, 300);
186
+ const f = findBufVal(buf, 'file', pos, 5000); // dlink field can be very long
187
+ const s = findBufVal(buf, 'subtitle', pos, 6000);
188
+ if (!f) return;
189
+ const url = normUrl(f.str.replace(/\\\//g, '/'));
190
+ if (!url || !url.includes('cinemap')) return; // must be a real stream URL
191
+ const title = t ? t.str : 'Unknown';
192
+ if (!items.find(i => i.title === title)) items.push({ title, file: url, subtitle: s ? s.str.replace(/\\\//g, '/') : '' });
193
+ });
194
+ return items.length ? items : null;
195
+ }
196
+
197
+ // Serial: find all episode IDs (sXXeYY) and group by season
198
+ const epIdPrefix = Buffer.from('"id":"s');
199
+ const allEpPositions = [];
200
+ findAllInBuf(buf, epIdPrefix).forEach(pos => {
201
+ let i = pos + epIdPrefix.length;
202
+ while (i < buf.length && buf[i] >= 0x30 && buf[i] <= 0x39) i++;
203
+ if (buf[i] !== 0x65) return;
204
+ i++;
205
+ while (i < buf.length && buf[i] >= 0x30 && buf[i] <= 0x39) i++;
206
+ if (buf[i] !== 0x22) return;
207
+ const fullId = buf.slice(pos + 6, i).toString('ascii');
208
+ const seasonMatch = fullId.match(/^(s\d+)e(\d+)$/);
209
+ if (!seasonMatch) return;
210
+ const folderIdx = buf.indexOf(FOLDER_MARKER_BUF, pos);
211
+ if (folderIdx < 0 || folderIdx > pos + 2000) return;
212
+ const t = findBufVal(buf, 'title', pos, 200);
213
+ allEpPositions.push({ id: fullId, seasonId: seasonMatch[1], epNum: parseInt(seasonMatch[2], 10), title: t ? t.str : ('БСрия ' + seasonMatch[2]), pos });
214
+ });
215
+
216
+ const seasonMap = {};
217
+ allEpPositions.forEach(ep => {
218
+ if (!seasonMap[ep.seasonId]) seasonMap[ep.seasonId] = [];
219
+ if (!seasonMap[ep.seasonId].find(e => e.id === ep.id)) seasonMap[ep.seasonId].push(ep);
220
+ });
221
+ const seasonIds = Object.keys(seasonMap).sort((a, b) => parseInt(a.substring(1)) - parseInt(b.substring(1)));
222
+ if (!seasonIds.length) return null;
223
+
224
+ return seasonIds.map(seasonId => {
225
+ const eps = seasonMap[seasonId].sort((a, b) => a.epNum - b.epNum);
226
+ const episodesWithVoices = eps.map((ep, ei) => {
227
+ const nextEpPos = ei + 1 < eps.length ? eps[ei + 1].pos : buf.length;
228
+ const eBuf = buf.slice(ep.pos, nextEpPos);
229
+ const voices = [];
230
+ findAllInBuf(eBuf, Buffer.from('"src_id"')).forEach(vpos => {
231
+ const t = findBufVal(eBuf, 'title', vpos, 300);
232
+ const f = findBufVal(eBuf, 'file', vpos, 2000);
233
+ const s = findBufVal(eBuf, 'subtitle', vpos, 3000);
234
+ if (!f) return;
235
+ const url = normUrl(f.str.replace(/\\\//g, '/'));
236
+ if (!url || !url.includes('.m3u8')) return;
237
+ const title = t ? t.str : 'Unknown';
238
+ if (!voices.find(v => v.title === title)) voices.push({ title, file: url, subtitle: s ? s.str.replace(/\\\//g, '/') : '' });
239
+ });
240
+ return { id: ep.id, title: ep.title, folder: voices };
241
+ });
242
+ return { id: seasonId, title: 'Π‘Π΅Π·ΠΎΠ½ ' + parseInt(seasonId.substring(1)), folder: episodesWithVoices };
243
+ });
244
+ }
245
+
246
+ // ── Multi-fetch helpers ───────────────────────────────────────────────────────
247
+
248
+ function parseEpisodesFromHtml(html) {
249
+ const fileMatch = html.match(/"file":"([^"]+)"/);
250
+ if (!fileMatch) return {};
251
+ const buf = decodeCinemarFile(fileMatch[1]);
252
+ if (!buf || buf.length < 20) return {};
253
+ if (buf.indexOf(FOLDER_MARKER_BUF) < 0) return {};
254
+
255
+ const epIdPrefix = Buffer.from('"id":"s');
256
+ const episodes = {};
257
+ findAllInBuf(buf, epIdPrefix).forEach(pos => {
258
+ let i = pos + epIdPrefix.length;
259
+ let seasonNum = '';
260
+ while (i < buf.length && buf[i] >= 0x30 && buf[i] <= 0x39) { seasonNum += String.fromCharCode(buf[i]); i++; }
261
+ if (buf[i] !== 0x65) return;
262
+ i++;
263
+ let epNum = '';
264
+ while (i < buf.length && buf[i] >= 0x30 && buf[i] <= 0x39) { epNum += String.fromCharCode(buf[i]); i++; }
265
+ if (buf[i] !== 0x22) return;
266
+
267
+ const fullId = 's' + seasonNum + 'e' + epNum;
268
+ const seasonId = 's' + seasonNum;
269
+ const t = findBufVal(buf, 'title', pos, 200);
270
+ const title = t ? t.str : ('БСрия ' + epNum);
271
+
272
+ const nextEpPos = buf.indexOf(epIdPrefix, pos + 1);
273
+ const endPos = nextEpPos > 0 ? Math.min(nextEpPos, pos + 5000) : pos + 5000;
274
+ const eBuf = buf.slice(pos, endPos);
275
+ const voices = [];
276
+ findAllInBuf(eBuf, Buffer.from('"src_id"')).forEach(vpos => {
277
+ const vt = findBufVal(eBuf, 'title', vpos, 300);
278
+ const f = findBufVal(eBuf, 'file', vpos, 2000);
279
+ const s = findBufVal(eBuf, 'subtitle', vpos, 3000);
280
+ if (!f) return;
281
+ const url = normUrl(f.str.replace(/\\\//g, '/'));
282
+ if (!url || !url.includes('.m3u8')) return;
283
+ const vtitle = vt ? vt.str : 'Unknown';
284
+ if (!voices.find(v => v.title === vtitle)) voices.push({ title: vtitle, file: url, subtitle: s ? s.str.replace(/\\\//g, '/') : '' });
285
+ });
286
+
287
+ if (!episodes[fullId] || voices.length > episodes[fullId].voices.length)
288
+ episodes[fullId] = { id: fullId, seasonId, seasonNum: parseInt(seasonNum), epNum: parseInt(epNum), title, voices };
289
+ });
290
+ return episodes;
291
+ }
292
+
293
+ function buildSerialDataFromMerged(mergedEpisodes) {
294
+ const seasonMap = {};
295
+ Object.values(mergedEpisodes).forEach(ep => {
296
+ if (!seasonMap[ep.seasonId]) seasonMap[ep.seasonId] = [];
297
+ seasonMap[ep.seasonId].push(ep);
298
+ });
299
+ const seasonIds = Object.keys(seasonMap).sort((a, b) => parseInt(a.substring(1)) - parseInt(b.substring(1)));
300
+ const seasons = [], episodes = {}, voicesMap = {};
301
+ seasonIds.forEach(sid => {
302
+ const eps = seasonMap[sid].sort((a, b) => a.epNum - b.epNum);
303
+ seasons.push({ id: sid, title: 'Π‘Π΅Π·ΠΎΠ½ ' + parseInt(sid.substring(1)) });
304
+ episodes[sid] = [];
305
+ voicesMap[sid] = {};
306
+ eps.forEach(ep => {
307
+ episodes[sid].push({ id: ep.id, title: ep.title });
308
+ voicesMap[sid][ep.id] = ep.voices.map(v => ({ label: v.title, url: normUrl(v.file), subtitles: parseSubs(v.subtitle) }));
309
+ });
310
+ });
311
+ return { seasons, episodes, voices: voicesMap };
312
+ }
313
+
314
+ function parseCinemar(embedHtml) {
315
+ let voices = [], masterUrl = null, serialData = null, contentType = 'movie';
316
+ const rawItems = parseCinemarBase64(embedHtml);
317
+ if (rawItems && rawItems.length) {
318
+ const first = rawItems[0];
319
+ if (first.folder && first.folder.length && first.folder[0] && first.folder[0].folder) {
320
+ contentType = 'serial';
321
+ const seasons = [], episodes = {}, voicesMap = {};
322
+ rawItems.forEach((season, si) => {
323
+ const sid = season.id || String(si + 1);
324
+ seasons.push({ id: sid, title: season.title || ('Π‘Π΅Π·ΠΎΠ½ ' + (si + 1)) });
325
+ episodes[sid] = [];
326
+ voicesMap[sid] = {};
327
+ (season.folder || []).forEach((ep, ei) => {
328
+ const eid = ep.id || String(ei + 1);
329
+ episodes[sid].push({ id: eid, title: ep.title || ('БСрия ' + (ei + 1)) });
330
+ voicesMap[sid][eid] = (ep.folder || []).map(v => ({ label: v.title || 'Unknown', url: normUrl(v.file || ''), subtitles: parseSubs(v.subtitle) })).filter(v => v.url);
331
+ });
332
+ });
333
+ serialData = { seasons, episodes, voices: voicesMap };
334
+ const fs = seasons[0];
335
+ if (fs && episodes[fs.id] && episodes[fs.id][0]) {
336
+ const fv = voicesMap[fs.id][episodes[fs.id][0].id];
337
+ if (fv && fv[0]) masterUrl = fv[0].url;
338
+ }
339
+ } else if (first.folder) {
340
+ contentType = 'serial';
341
+ const seasons = [], episodes = {}, voicesMap = {};
342
+ rawItems.forEach((season, si) => {
343
+ const sid = season.id || String(si + 1);
344
+ seasons.push({ id: sid, title: season.title || ('Π‘Π΅Π·ΠΎΠ½ ' + (si + 1)) });
345
+ episodes[sid] = [];
346
+ voicesMap[sid] = {};
347
+ (season.folder || []).forEach((ep, ei) => {
348
+ const eid = ep.id || String(ei + 1);
349
+ episodes[sid].push({ id: eid, title: ep.title || ('БСрия ' + (ei + 1)) });
350
+ const u = normUrl(ep.file || '');
351
+ voicesMap[sid][eid] = u ? [{ label: ep.title || 'Unknown', url: u, subtitles: parseSubs(ep.subtitle) }] : [];
352
+ });
353
+ });
354
+ serialData = { seasons, episodes, voices: voicesMap };
355
+ const fs = seasons[0];
356
+ if (fs && episodes[fs.id] && episodes[fs.id][0]) {
357
+ const fv = voicesMap[fs.id][episodes[fs.id][0].id];
358
+ if (fv && fv[0]) masterUrl = fv[0].url;
359
+ }
360
+ } else {
361
+ contentType = 'movie';
362
+ voices = rawItems.map(item => ({ label: item.title || 'Unknown', url: normUrl(item.file || ''), subtitles: parseSubs(item.subtitle) })).filter(v => v.url);
363
+ if (voices.length) masterUrl = voices[0].url;
364
+ }
365
+ }
366
+ if (!masterUrl) {
367
+ const fm = embedHtml.match(/["'](https?:\/\/[^"']*cinemap[^"']*\.m3u8[^"']*)['"]/);
368
+ if (fm) masterUrl = fm[1];
369
+ }
370
+ return { contentType, voices, masterUrl, serialData };
371
+ }
372
+
373
+ // ── HLS proxy ─────────────────────────────────────────────────────────────────
374
+
375
+ function handleHls(req, res) {
376
+ const { query } = parseQuery(req.url);
377
+ const targetUrl = query.url;
378
+ if (!targetUrl) { res.writeHead(400, { 'Access-Control-Allow-Origin': '*' }); res.end('no url'); return; }
379
+
380
+ function doRequest(reqUrl, redirectCount) {
381
+ if (redirectCount > 5) { res.writeHead(500, { 'Access-Control-Allow-Origin': '*' }); res.end('too many redirects'); return; }
382
+ let pu;
383
+ try { pu = new URL(reqUrl); } catch(e) { res.writeHead(400); res.end('bad url'); return; }
384
+ const mod = pu.protocol === 'https:' ? https : http;
385
+ const isObrut = pu.hostname.includes('obrut') || pu.hostname.includes('superdupercdn');
386
+ const isCinemap = pu.hostname.includes('cinemap');
387
+ const isAlloha = pu.hostname.includes('stream-balancer') || pu.hostname.includes('streamalloha') || pu.hostname.includes('allo-');
388
+ const opts = {
389
+ hostname: pu.hostname,
390
+ port: pu.port || (pu.protocol === 'https:' ? 443 : 80),
391
+ path: pu.pathname + pu.search,
392
+ headers: {
393
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
394
+ 'accept': '*/*',
395
+ 'origin': isCinemap ? 'https://uakinogo.io' : isObrut ? 'https://kinojump.com' : isAlloha ? 'https://streamalloha.live' : 'https://streamalloha.live',
396
+ 'referer': isCinemap ? 'https://uakinogo.io/' : isObrut ? 'https://kinojump.com/' : isAlloha ? 'https://streamalloha.live/' : 'https://streamalloha.live/',
397
+ },
398
+ };
399
+ if (req.headers['range']) opts.headers['range'] = req.headers['range'];
400
+ const pr = mod.request(opts, upstream => {
401
+ // Follow redirects
402
+ if (upstream.statusCode >= 300 && upstream.statusCode < 400 && upstream.headers.location) {
403
+ upstream.resume();
404
+ let loc = upstream.headers.location;
405
+ if (!loc.startsWith('http')) loc = pu.origin + loc;
406
+ return doRequest(loc, redirectCount + 1);
407
+ }
408
+ const ct = upstream.headers['content-type'] || '';
409
+ const isM3u8 = ct.includes('mpegurl') || (reqUrl.includes('.m3u8') || reqUrl.includes(':hls:manifest')) && !reqUrl.includes(':hls:seg-');
410
+ const rh = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' };
411
+ if (isM3u8) {
412
+ rh['Content-Type'] = 'application/vnd.apple.mpegurl';
413
+ res.writeHead(upstream.statusCode, rh);
414
+ let body = '';
415
+ upstream.setEncoding('utf8');
416
+ upstream.on('data', c => { body += c; });
417
+ upstream.on('end', () => {
418
+ const base = reqUrl.substring(0, reqUrl.lastIndexOf('/') + 1);
419
+ const serverBase = 'https://' + (req.headers.host || 'localhost:' + PORT);
420
+ function rewriteUrl(rel) {
421
+ let a = rel.trim();
422
+ if (!a) return a;
423
+ if (a.startsWith('//')) a = 'https:' + a;
424
+ else if (!a.startsWith('http')) a = base + a;
425
+ return serverBase + '/hls?url=' + encodeURIComponent(a);
426
+ }
427
+ res.end(body.split('\n').map(line => {
428
+ const tr = line.trim();
429
+ if (!tr) return line;
430
+ if (tr.startsWith('#')) return line.replace(/URI="([^"]+)"/g, (m, uri) => `URI="${rewriteUrl(uri)}"`);
431
+ return rewriteUrl(tr);
432
+ }).join('\n'));
433
+ });
434
+ } else {
435
+ rh['Content-Type'] = ct || 'video/mp2t';
436
+ if (upstream.headers['content-length']) rh['Content-Length'] = upstream.headers['content-length'];
437
+ if (upstream.headers['content-range']) rh['Content-Range'] = upstream.headers['content-range'];
438
+ res.writeHead(upstream.statusCode, rh);
439
+ upstream.pipe(res);
440
+ }
441
+ });
442
+ pr.on('error', e => { res.writeHead(500); res.end(e.message); });
443
+ pr.end();
444
+ }
445
+
446
+ doRequest(targetUrl, 0);
447
+ }
448
+
449
+ // ── Server ────────────────────────────────────────────────────────────────────
450
+
451
+ const server = http.createServer(async (req, res) => {
452
+ const { pathname, query } = parseQuery(req.url);
453
+
454
+ if (req.method === 'OPTIONS') {
455
+ res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': '*' });
456
+ res.end();
457
+ return;
458
+ }
459
+
460
+ if (pathname === '/health') { jsonResp(res, { ok: true, time: new Date().toISOString() }); return; }
461
+
462
+ // Show outbound IP of this server
463
+ if (pathname === '/debug/ip') {
464
+ fetchRaw('https://api.ipify.org?format=json', { 'user-agent': 'curl/7.0' })
465
+ .then(d => { jsonResp(res, { ok: true, ip: JSON.parse(d).ip }); })
466
+ .catch(e => { jsonResp(res, { ok: false, error: e.message }); });
467
+ return;
468
+ }
469
+
470
+ if (pathname === '/debug/platform') {
471
+ const fs = require('fs');
472
+ const chromiumPaths = [
473
+ process.env.PUPPETEER_EXECUTABLE_PATH,
474
+ '/usr/bin/chromium',
475
+ '/usr/bin/chromium-browser',
476
+ '/usr/bin/google-chrome'
477
+ ].filter(Boolean);
478
+ const available = chromiumPaths.filter(p => { try { return fs.existsSync(p); } catch(e) { return false; } });
479
+ jsonResp(res, {
480
+ platform: process.platform,
481
+ node: process.version,
482
+ puppeteer_path: process.env.PUPPETEER_EXECUTABLE_PATH || 'not set',
483
+ chromium_found: available,
484
+ alloha_available: typeof getStreams === 'function' && getStreams.toString().includes('getBrowser') ? 'yes' : 'fallback'
485
+ });
486
+ return;
487
+ }
488
+ if (pathname === '/hls') { handleHls(req, res); return; }
489
+
490
+ if (pathname === '/srt2vtt') {
491
+ const corsH = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Content-Type': 'text/vtt; charset=utf-8' };
492
+ const subUrl = query.url;
493
+ if (!subUrl) { res.writeHead(400, corsH); res.end('no url'); return; }
494
+ fetchRaw(subUrl, { 'user-agent': 'Mozilla/5.0 Chrome/120' }).then(body => {
495
+ const vtt = 'WEBVTT\n\n' + body.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, '$1.$2');
496
+ res.writeHead(200, corsH); res.end(vtt);
497
+ }).catch(e => { res.writeHead(500, corsH); res.end(e.message); });
498
+ return;
499
+ }
500
+
501
+ if (pathname === '/alloha/player-info' && req.method === 'POST') {
502
+ try {
503
+ const params = JSON.parse(await readBody(req));
504
+ const { token_movie, partner_token } = params;
505
+ if (!token_movie) { jsonResp(res, { ok: false, error: 'token_movie required' }, 400); return; }
506
+ const token = partner_token || ALLOHA_TOKEN;
507
+ const playerUrl = `https://${ALLOHA_HOST}/?token_movie=${token_movie}&token=${encodeURIComponent(token)}`;
508
+ console.log(`[server] /alloha/player-info token_movie=${token_movie}`);
509
+ const html = await fetchRaw(playerUrl, { 'user-agent': 'Mozilla/5.0 Chrome/120', 'referer': 'https://kinokrad.my/' });
510
+ const nkMatch = html.match(/name="viewporti"\s+content="([^"]+)"/);
511
+ const nk = nkMatch ? nkMatch[1] : null;
512
+ if (!nk) { jsonResp(res, { ok: false, error: 'nk_not_found' }); return; }
513
+ let fileId = null;
514
+ const flMatch = html.match(/"active"\s*:\s*\{[^}]*?"id"\s*:\s*(\d+)/);
515
+ if (flMatch) fileId = flMatch[1];
516
+ if (!fileId) { const m = html.match(/"id"\s*:\s*(\d+)/); if (m) fileId = m[1]; }
517
+ let fileList = null;
518
+ const flRaw = html.match(/fileList\s*=\s*JSON\.parse\('([\s\S]*?)'\)/);
519
+ if (flRaw) { try { fileList = JSON.parse(flRaw[1]); } catch(e) {} }
520
+ const appBundle = (html.match(/src="(\/build\/app\.[a-f0-9]+\.js)"/) || [])[1] || null;
521
+ const runtimeBundle = (html.match(/src="(\/build\/runtime\.[a-f0-9]+\.js)"/) || [])[1] || null;
522
+ const bundle539 = (html.match(/src="(\/build\/539\.[a-f0-9]+\.js)"/) || [])[1] || null;
523
+ jsonResp(res, {
524
+ ok: true, nk, file_id: fileId, player_url: playerUrl, file_list: fileList,
525
+ bundle_app: appBundle ? `https://${ALLOHA_HOST}${appBundle}` : null,
526
+ bundle_runtime: runtimeBundle ? `https://${ALLOHA_HOST}${runtimeBundle}` : null,
527
+ bundle_539: bundle539 ? `https://${ALLOHA_HOST}${bundle539}` : null,
528
+ });
529
+ } catch(e) {
530
+ console.log('[server] player-info ERROR:', e.message);
531
+ jsonResp(res, { ok: false, error: e.message }, 500);
532
+ }
533
+ return;
534
+ }
535
+
536
+ if (pathname === '/alloha/streams' && req.method === 'POST') {
537
+ try {
538
+ const params = JSON.parse(await readBody(req));
539
+ const { token_movie, partner_token, referer } = params;
540
+ if (!token_movie) { jsonResp(res, { ok: false, error: 'token_movie required' }, 400); return; }
541
+ console.log(`[server] /alloha/streams token_movie=${token_movie}`);
542
+ const result = await getStreams({ token_movie, partner_token, referer });
543
+ const serverBase = 'https://' + (req.headers.host || 'localhost:' + PORT);
544
+ if (result.hlsSource) {
545
+ result.hlsSource = result.hlsSource.map(src => {
546
+ if (src.quality) {
547
+ const newQ = {};
548
+ for (const [q, u] of Object.entries(src.quality)) {
549
+ newQ[q] = u.split(' or ').map(raw => {
550
+ const t = raw.trim();
551
+ const full = t.startsWith('//') ? 'https:' + t : t;
552
+ return serverBase + '/hls?url=' + encodeURIComponent(full);
553
+ }).join(' or ');
554
+ }
555
+ src.quality = newQ;
556
+ }
557
+ return src;
558
+ });
559
+ }
560
+ jsonResp(res, result);
561
+ } catch(e) {
562
+ console.log('[server] streams ERROR:', e.message);
563
+ jsonResp(res, { ok: false, error: e.message }, 500);
564
+ }
565
+ return;
566
+ }
567
+
568
+ if (pathname === '/uakinogo/search') {
569
+ const q = query.q || '';
570
+ if (!q) { jsonResp(res, { ok: false, error: 'no query' }, 400); return; }
571
+ const searchUrl = 'https://uakinogo.io/index.php?do=search&subaction=search&story=' + encodeURIComponent(q);
572
+ fetchRaw(searchUrl, { 'user-agent': 'Mozilla/5.0 Chrome/120', 'referer': 'https://uakinogo.io/' }).then(html => {
573
+ const results = [];
574
+ const re = /href="(https?:\/\/uakinogo\.io\/(?:filmy|serialy|multserialy|multfilmy|zarubezhnye-serialy|russkie-serialy|anime-serialy|anime|novinki)\/(\d+)-([^"]+)\.html)"/g;
575
+ let m;
576
+ while ((m = re.exec(html)) !== null) {
577
+ if (!results.find(r => r.id === m[2])) results.push({ url: m[1], id: m[2], slug: m[3] });
578
+ }
579
+ jsonResp(res, { ok: true, results: results.slice(0, 10) });
580
+ }).catch(e => jsonResp(res, { ok: false, error: e.message }, 500));
581
+ return;
582
+ }
583
+
584
+ if (pathname === '/uakinogo/embed') {
585
+ const pageUrl = query.url;
586
+ if (!pageUrl) { jsonResp(res, { ok: false, error: 'no url' }, 400); return; }
587
+ fetchRaw(pageUrl, { 'user-agent': 'Mozilla/5.0 Chrome/120', 'referer': 'https://uakinogo.io/' }).then(html => {
588
+ const m = html.match(/"embedUrl"\s*:\s*"(https:\/\/cinemar\.cc\/embed\/[^"]+)"/);
589
+ jsonResp(res, { ok: true, embed_url: m ? m[1] : null });
590
+ }).catch(e => jsonResp(res, { ok: false, error: e.message }, 500));
591
+ return;
592
+ }
593
+
594
+ // ── /turbo/search?q=TITLE ─────────────────────────────────────────────────
595
+ if (pathname === '/turbo/search') {
596
+ const q = query.q || '';
597
+ if (!q) { jsonResp(res, { ok: false, error: 'no query' }, 400); return; }
598
+ turboSearch(q)
599
+ .then(results => jsonResp(res, { ok: true, results }))
600
+ .catch(e => jsonResp(res, { ok: false, error: e.message }, 500));
601
+ return;
602
+ }
603
+
604
+ // ── /turbo/search-embed?q=TITLE ──────────────────────────────────────────
605
+ if (pathname === '/turbo/search-embed') {
606
+ const q = query.q || '';
607
+ if (!q) { jsonResp(res, { ok: false, error: 'no query' }, 400); return; }
608
+ const { parseObrutEmbed, buildSerialFromEntries, extractMovieVoices, getKinojumpHtml } = require('./turbo');
609
+ (async () => {
610
+ // Step 1: search kinojump
611
+ const results = await turboSearch(q);
612
+ if (!results.length) throw new Error('not found on kinojump');
613
+ const pageUrl = results[0].url;
614
+ console.log('[turbo/search-embed] page:', pageUrl);
615
+
616
+ // Step 2: get embed URL β€” plain HTTP first, Puppeteer fallback
617
+ const { getBrowser } = require('./alloha');
618
+ const browser = await getBrowser();
619
+ const pageHtml = await getKinojumpHtml(pageUrl, browser).catch(e => {
620
+ console.log('[turbo/search-embed] getKinojumpHtml error:', e.message);
621
+ return '';
622
+ });
623
+ const em = pageHtml.match(/(?:https?:\/\/)?(?:([a-z0-9]+)\.)?obrut\.show\/embed\/([A-Za-z0-9]+)\/content\/([A-Za-z0-9]+)/);
624
+ if (!em) throw new Error('obrut embed URL not found');
625
+ const subdomain = em[1] ? em[1] + '.obrut.show' : '49372504.obrut.show';
626
+ const embedUrl = 'https://' + subdomain + '/embed/' + em[2] + '/content/' + em[3];
627
+ console.log('[turbo/search-embed] embed URL:', embedUrl);
628
+ // Step 3: parse embed (retry until enough entries)
629
+ const result = await parseObrutEmbed(embedUrl);
630
+ if (!result) throw new Error('failed to parse obrut embed');
631
+
632
+ if (result.entries.length > 0) {
633
+ const serial = buildSerialFromEntries(result.entries);
634
+ jsonResp(res, { ok: true, embed_url: embedUrl, content_type: 'serial', serial });
635
+ } else if (result.movieVoices.length > 0) {
636
+ jsonResp(res, { ok: true, embed_url: embedUrl, content_type: 'movie', voices: result.movieVoices });
637
+ } else {
638
+ throw new Error('no data found in obrut embed');
639
+ }
640
+ })().catch(e => { console.log('[turbo/search-embed] error:', e.message); jsonResp(res, { ok: false, error: e.message }, 500); });
641
+ return;
642
+ }
643
+
644
+ // ── /turbo/stream?url=PAGE_URL ────────────────────────────────────────────
645
+ if (pathname === '/turbo/stream') {
646
+ const pageUrl = query.url;
647
+ if (!pageUrl) { jsonResp(res, { ok: false, error: 'no url' }, 400); return; }
648
+ getTurboStream(pageUrl)
649
+ .then(data => jsonResp(res, { ok: true, ...data }))
650
+ .catch(e => { console.log('[turbo] error:', e.message); jsonResp(res, { ok: false, error: e.message }, 500); });
651
+ return;
652
+ }
653
+
654
+ // ── /turbo/proxy-embed?url=OBRUT_EMBED_URL ───────────────────────────────
655
+ // Raw HTTP proxy for obrut embed β€” adds correct Referer header
656
+ // Used by browser clients that can't set Referer (file:/// origin)
657
+ if (pathname === '/turbo/proxy-embed') {
658
+ const embedUrl = query.url;
659
+ if (!embedUrl || !embedUrl.includes('obrut.show')) {
660
+ jsonResp(res, { ok: false, error: 'invalid url' }, 400); return;
661
+ }
662
+ (async () => {
663
+ const html = await fetchRaw(embedUrl, {
664
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
665
+ 'referer': 'https://kinojump.com/',
666
+ 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
667
+ 'accept-language': 'ru-RU,ru;q=0.9,en;q=0.8',
668
+ 'accept-encoding': 'identity',
669
+ });
670
+ console.log('[turbo/proxy-embed] len:', html.length, 'hasPlayer:', html.includes('new Player('));
671
+ res.writeHead(200, {
672
+ 'Access-Control-Allow-Origin': '*',
673
+ 'Access-Control-Allow-Headers': '*',
674
+ 'Content-Type': 'text/html; charset=utf-8',
675
+ });
676
+ res.end(html);
677
+ })().catch(e => {
678
+ console.log('[turbo/proxy-embed] error:', e.message);
679
+ res.writeHead(500, { 'Access-Control-Allow-Origin': '*' });
680
+ res.end('error: ' + e.message);
681
+ });
682
+ return;
683
+ }
684
+
685
+ // ── /turbo/embed?url=OBRUT_EMBED_URL ─────────────────────────────────────
686
+ // Parse obrut embed page directly (no Puppeteer needed)
687
+ if (pathname === '/turbo/embed') {
688
+ const embedUrl = query.url;
689
+ if (!embedUrl) { jsonResp(res, { ok: false, error: 'no url' }, 400); return; }
690
+ const { parseObrutEmbed, buildSerialFromEntries } = require('./turbo');
691
+ parseObrutEmbed(embedUrl)
692
+ .then(result => {
693
+ if (!result || (result.entries.length === 0 && result.movieVoices.length === 0)) {
694
+ jsonResp(res, { ok: false, error: 'failed to parse embed' }); return;
695
+ }
696
+ if (result.entries.length > 0) {
697
+ const serial = buildSerialFromEntries(result.entries);
698
+ jsonResp(res, { ok: true, embed_url: embedUrl, content_type: 'serial', serial, entry_count: result.entries.length });
699
+ } else {
700
+ jsonResp(res, { ok: true, embed_url: embedUrl, content_type: 'movie', voices: result.movieVoices });
701
+ }
702
+ })
703
+ .catch(e => { console.log('[turbo/embed] error:', e.message); jsonResp(res, { ok: false, error: e.message }, 500); });
704
+ return;
705
+ }
706
+
707
+ // ── /uakinogo/stream?url=PAGE_URL ─────────────────────────────────────────
708
+ if (pathname === '/uakinogo/stream') {
709
+ const pageUrl = query.url;
710
+ if (!pageUrl) { jsonResp(res, { ok: false, error: 'no url' }, 400); return; }
711
+ getCinemarStream(pageUrl)
712
+ .then(data => jsonResp(res, { ok: true, ...data }))
713
+ .catch(e => { console.log('[cinemar] error:', e.message); jsonResp(res, { ok: false, error: e.message }, 500); });
714
+ return;
715
+ }
716
+
717
+ // ── /puppeteer/fetch?url=URL β€” fetch URL via Puppeteer (bypasses Cloudflare JS challenge)
718
+ if (pathname === '/puppeteer/fetch') {
719
+ const targetUrl = query.url;
720
+ if (!targetUrl) { jsonResp(res, { ok: false, error: 'no url' }, 400); return; }
721
+ (async () => {
722
+ let browser;
723
+ try {
724
+ const { getBrowser } = require('./alloha');
725
+ browser = await getBrowser();
726
+ const page = await browser.newPage();
727
+ await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
728
+ await page.setExtraHTTPHeaders({ 'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8' });
729
+ // Navigate and wait for JS challenge to complete
730
+ await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 20000 });
731
+ // Extra wait for fingerprint redirect
732
+ await new Promise(r => setTimeout(r, 2500));
733
+ const html = await page.content();
734
+ const finalUrl = page.url();
735
+ await page.close();
736
+ jsonResp(res, { ok: true, html, url: finalUrl });
737
+ } catch(e) {
738
+ console.log('[puppeteer/fetch] error:', e.message);
739
+ jsonResp(res, { ok: false, error: e.message }, 500);
740
+ }
741
+ })();
742
+ return;
743
+ }
744
+
745
+ jsonResp(res, { ok: false, error: 'not found' }, 404);
746
+ });
747
+
748
+ server.listen(PORT, () => console.log(`[server] Alloha+Cinemar proxy on port ${PORT}`));
749
+
750
+ process.on('SIGTERM', async () => { await closeBrowser(); server.close(); });
751
+ process.on('SIGINT', async () => { await closeBrowser(); process.exit(0); });
turbo.js ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Turbo (obrut.show) stream extractor
3
+ * Flow: embed URL β†’ fetch HTML β†’ parse Player() base64 β†’ extract entries via regex
4
+ */
5
+ const { getBrowser } = require('./alloha');
6
+ const https = require('https');
7
+ const http = require('http');
8
+
9
+ const KINOJUMP_BASE = 'https://kinojump.com';
10
+
11
+ // ── HTTP helper ───────────────────────────────────────────────────────────────
12
+ function fetchRaw(reqUrl, headers) {
13
+ return new Promise((resolve, reject) => {
14
+ let u; try { u = new URL(reqUrl); } catch(e) { return reject(e); }
15
+ const mod = u.protocol === 'https:' ? https : http;
16
+ const req = mod.request({
17
+ hostname: u.hostname, path: u.pathname + u.search,
18
+ headers: headers || {}
19
+ }, res => {
20
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
21
+ let loc = res.headers.location.startsWith('http')
22
+ ? res.headers.location
23
+ : new URL(reqUrl).origin + res.headers.location;
24
+ // Don't follow redirect to web.kinojump.com β€” stay on kinojump.com
25
+ loc = loc.replace('web.kinojump.com', 'kinojump.com');
26
+ return fetchRaw(loc, headers).then(resolve).catch(reject);
27
+ }
28
+ let d = '';
29
+ // Handle gzip/deflate (Railway proxy may add it)
30
+ const encoding = res.headers['content-encoding'];
31
+ let stream = res;
32
+ if (encoding === 'gzip' || encoding === 'deflate') {
33
+ const zlib = require('zlib');
34
+ stream = encoding === 'gzip' ? res.pipe(zlib.createGunzip()) : res.pipe(zlib.createInflate());
35
+ }
36
+ stream.setEncoding('utf8');
37
+ stream.on('data', c => d += c);
38
+ stream.on('end', () => resolve(d));
39
+ stream.on('error', reject);
40
+ });
41
+ req.on('error', reject);
42
+ req.setTimeout(15000, () => { req.destroy(); reject(new Error('timeout')); });
43
+ req.end();
44
+ });
45
+ }
46
+
47
+ // ── Search kinojump ───────────────────────────────────────────────────────────
48
+ async function turboSearch(query) {
49
+ // Search on web.kinojump.com (kinojump.com redirects to web. for search)
50
+ const searchUrl = 'https://web.kinojump.com/index.php?do=search&subaction=search&story=' + encodeURIComponent(query);
51
+ const html = await fetchRaw(searchUrl, {
52
+ 'user-agent': 'Mozilla/5.0 Chrome/120',
53
+ 'referer': 'https://web.kinojump.com/'
54
+ });
55
+ const results = [];
56
+ const re = /href="(https?:\/\/(?:web\.)?kinojump\.com\/(\d+)-([^"]+)\.html)"/g;
57
+ let m;
58
+ while ((m = re.exec(html)) !== null) {
59
+ if (!results.find(r => r.id === m[2])) {
60
+ // Always use kinojump.com (not web.) β€” web. has no player
61
+ const url = m[1].replace('web.kinojump.com', 'kinojump.com');
62
+ results.push({ url, id: m[2], slug: m[3] });
63
+ }
64
+ }
65
+ return results.slice(0, 10);
66
+ }
67
+
68
+ // ── Get kinojump page HTML β€” try plain HTTP first, then Puppeteer ────────────
69
+ async function getKinojumpHtml(pageUrl, browser) {
70
+ const normalizedUrl = pageUrl.replace('web.kinojump.com', 'kinojump.com');
71
+ // Try plain HTTP first β€” with PHPSESSID cookie to bypass Cloudflare Rocket Loader
72
+ // Retry multiple times since kinojump may load-balance between servers
73
+ for (let attempt = 0; attempt < 5; attempt++) {
74
+ try {
75
+ const html = await fetchRaw(normalizedUrl, {
76
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36',
77
+ 'referer': KINOJUMP_BASE + '/',
78
+ 'accept': 'text/html',
79
+ 'accept-language': 'ru-RU,ru;q=0.9',
80
+ 'accept-encoding': 'identity',
81
+ 'cookie': 'PHPSESSID=a1b2c3d4e5f6; dle_user_id=0; dle_password=0'
82
+ });
83
+ if (html.includes('obrut')) {
84
+ const refs = html.match(/[a-z0-9]*obrut[^\s"'<>]{0,100}/g);
85
+ console.log('[turbo] got HTML via plain HTTP');
86
+ console.log('[turbo] obrut refs:', refs ? refs.slice(0,3) : 'none');
87
+ const hasEmbed = html.includes('obrut.show/embed/');
88
+ if (hasEmbed) return html;
89
+ if (attempt < 4) {
90
+ console.log('[turbo] no embed URL, retrying... (' + (attempt+1) + '/5)');
91
+ await new Promise(r => setTimeout(r, 500));
92
+ continue;
93
+ }
94
+ console.log('[turbo] obrut found but no embed URL - trying Puppeteer');
95
+ }
96
+ console.log('[turbo] plain HTTP no obrut, len:', html.length, 'has helper:', html.includes('s1obrut'), 'has rocket:', html.includes('rocket-loader'));
97
+ const playerIdx = html.indexOf('pmovie__player');
98
+ if (playerIdx > 0) console.log('[turbo] player area:', html.substring(playerIdx, playerIdx + 500));
99
+ } catch(e) { console.log('[turbo] plain HTTP failed:', e.message); }
100
+ break;
101
+ }
102
+
103
+ // Fallback: Puppeteer β€” intercept network requests to catch obrut embed URL
104
+ console.log('[turbo] using Puppeteer for kinojump page');
105
+ const page = await browser.newPage();
106
+ try {
107
+ await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36');
108
+ await page.setExtraHTTPHeaders({ 'Accept-Language': 'ru-RU,ru;q=0.9' });
109
+ await page.setCookie({ name: 'PHPSESSID', value: 'a1b2c3d4e5f6', domain: 'kinojump.com' });
110
+
111
+ let obrutEmbedUrl = null;
112
+ let generalJsContent = null;
113
+
114
+ await page.setRequestInterception(true);
115
+ page.on('request', req => {
116
+ const u = req.url();
117
+ if (u.includes('obrut.show/embed/') && !obrutEmbedUrl) {
118
+ obrutEmbedUrl = u;
119
+ console.log('[turbo] intercepted obrut embed request:', u.substring(0, 120));
120
+ }
121
+ req.continue();
122
+ });
123
+ page.on('response', async res => {
124
+ try {
125
+ const u = res.url();
126
+ if (u.includes('general') && u.includes('kinojump') && !generalJsContent) {
127
+ generalJsContent = await res.text().catch(() => null);
128
+ if (generalJsContent) console.log('[turbo] captured general.js len:', generalJsContent.length);
129
+ }
130
+ } catch(e) {}
131
+ });
132
+
133
+ await page.goto(normalizedUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
134
+ await new Promise(r => setTimeout(r, 2000));
135
+
136
+ if (obrutEmbedUrl) return `<!-- obrut-embed: ${obrutEmbedUrl} -->`;
137
+
138
+ // Try executing general.js to trigger iframe creation
139
+ if (generalJsContent) {
140
+ try {
141
+ await page.evaluate(generalJsContent);
142
+ console.log('[turbo] executed general.js');
143
+ await new Promise(r => setTimeout(r, 3000));
144
+ if (obrutEmbedUrl) return `<!-- obrut-embed: ${obrutEmbedUrl} -->`;
145
+ } catch(e) { console.log('[turbo] general.js exec error:', e.message); }
146
+ }
147
+
148
+ // Fix Rocket Loader β€” re-execute blocked scripts
149
+ await page.evaluate(() => {
150
+ document.querySelectorAll('script[type]').forEach(s => {
151
+ if (s.type && s.type !== 'text/javascript' && s.type !== 'module' && s.src) {
152
+ const ns = document.createElement('script');
153
+ ns.src = s.src;
154
+ document.head.appendChild(ns);
155
+ }
156
+ });
157
+ });
158
+
159
+ // Wait up to 10s for obrut request
160
+ for (let i = 0; i < 10; i++) {
161
+ await new Promise(r => setTimeout(r, 1000));
162
+ if (obrutEmbedUrl) return `<!-- obrut-embed: ${obrutEmbedUrl} -->`;
163
+ // Also check DOM
164
+ const html = await page.content();
165
+ if (html.includes('obrut.show/embed/')) return html;
166
+ }
167
+
168
+ const finalHtml = await page.content();
169
+ console.log('[turbo] Puppeteer final HTML len:', finalHtml.length, 'has embed:', finalHtml.includes('obrut.show/embed/'));
170
+ return finalHtml;
171
+ } finally {
172
+ await page.close();
173
+ }
174
+ }
175
+
176
+ // ── Get embed URL from kinojump via Puppeteer ─────────────────────────────────
177
+ async function getEmbedUrlViaPuppeteer(browser, pageUrl) {
178
+ // First try plain HTTP β€” embed URL is in raw HTML without https:// prefix
179
+ try {
180
+ // kinojump may redirect to web.kinojump.com β€” force stay on kinojump.com
181
+ const normalizedUrl = pageUrl.replace('web.kinojump.com', 'kinojump.com');
182
+ const html = await fetchRaw(normalizedUrl, {
183
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36',
184
+ 'referer': KINOJUMP_BASE + '/',
185
+ 'accept': 'text/html',
186
+ 'accept-language': 'ru-RU,ru;q=0.9',
187
+ 'accept-encoding': 'identity'
188
+ });
189
+ // Pattern: XXXXXXXX.obrut.show/embed/... OR obrut.show/embed/... (with or without subdomain)
190
+ const m = html.match(/([a-z0-9]*\.?obrut\.show\/embed\/[A-Za-z0-9]+\/content\/[A-Za-z0-9]+)/);
191
+ if (m) {
192
+ // Ensure it has a subdomain β€” if not, use default
193
+ let embedPath = m[1];
194
+ if (!embedPath.includes('.obrut.show')) {
195
+ embedPath = '49372504.' + embedPath; // fallback subdomain
196
+ }
197
+ const embedUrl = 'https://' + embedPath;
198
+ console.log('[turbo] found embed URL in raw HTML:', embedUrl);
199
+ return embedUrl;
200
+ }
201
+ console.log('[turbo] embed not found in raw HTML, falling back to Puppeteer');
202
+ console.log('[turbo] HTML length:', html.length, '| contains obrut:', html.includes('obrut'));
203
+ // Log what URL we actually got (after redirects)
204
+ const titleMatch = html.match(/<title>([^<]{0,80})<\/title>/);
205
+ if (titleMatch) console.log('[turbo] page title from raw HTML:', titleMatch[1]);
206
+ } catch(e) {
207
+ console.log('[turbo] raw HTTP failed:', e.message);
208
+ }
209
+ const page = await browser.newPage();
210
+ try {
211
+ await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36');
212
+ await page.setExtraHTTPHeaders({ 'Accept-Language': 'ru-RU,ru;q=0.9' });
213
+ let foundUrl = null;
214
+ await page.setRequestInterception(true);
215
+ page.on('request', req => {
216
+ const u = req.url();
217
+ if (u.includes('obrut.show') && u.includes('/embed/') && !foundUrl) {
218
+ foundUrl = u;
219
+ console.log('[turbo] caught embed URL:', u.substring(0, 120));
220
+ }
221
+ req.continue();
222
+ });
223
+ await page.goto(pageUrl, { waitUntil: 'networkidle2', timeout: 30000 });
224
+ for (let i = 0; i < 20 && !foundUrl; i++) {
225
+ await new Promise(r => setTimeout(r, 1000));
226
+ const found = await page.evaluate((host) => {
227
+ for (const el of document.querySelectorAll('iframe, [src], [data-src]')) {
228
+ const src = el.src || el.getAttribute('src') || el.getAttribute('data-src') || '';
229
+ if (src.includes(host) && src.includes('/embed/')) return src;
230
+ }
231
+ return null;
232
+ }, 'obrut.show');
233
+ if (found) { foundUrl = found; break; }
234
+ if (i % 5 === 4) console.log('[turbo] waiting for obrut embed... (' + (i+1) + 's)');
235
+ }
236
+ if (!foundUrl) {
237
+ const html = await page.content();
238
+ const m = html.match(/https?:\/\/[^"'\s<>]*obrut\.show\/embed\/[^"'\s<>]*/g);
239
+ if (m && m.length) foundUrl = m[0];
240
+ console.log('[turbo] page title:', await page.title());
241
+ console.log('[turbo] obrut embed in HTML:', m ? m.slice(0,3) : 'none');
242
+ }
243
+ return foundUrl;
244
+ } finally {
245
+ await page.close();
246
+ }
247
+ }
248
+
249
+ // ── Parse obrut embed page ────────────────────────────────────────────────────
250
+ // The Player() data has a random prefix + base64 JSON with garbage bytes injected.
251
+ // We extract entries via regex on the decoded text, retrying until we get enough.
252
+
253
+ function getCleanText(raw) {
254
+ const eyJIdx = raw.indexOf('eyJ');
255
+ const b64 = eyJIdx > 0 ? raw.substring(eyJIdx) : raw;
256
+ const decoded = Buffer.from(b64, 'base64').toString('utf8');
257
+ for (let i = 0; i < decoded.length; i++) {
258
+ const code = decoded.charCodeAt(i);
259
+ if (code > 127 || (code < 32 && code !== 9 && code !== 10 && code !== 13)) {
260
+ return decoded.substring(0, i);
261
+ }
262
+ }
263
+ return decoded;
264
+ }
265
+
266
+ function parseFileStr(fileStr) {
267
+ const streams = [];
268
+ const re = /\[(\w+)\](https?:\/\/[^,\[]+)/g;
269
+ let m;
270
+ while ((m = re.exec(fileStr)) !== null) streams.push({ quality: m[1], url: m[2].trim() });
271
+ return streams;
272
+ }
273
+
274
+ function extractEntries(text) {
275
+ // Serial: t1 is non-empty like "S01E01 - Title"
276
+ const re = /"title":"([^"]+)","t1":"([^"]+)","poster":"[^"]*","file":"((?:\[\w+\]https?:\\\/\\\/[^"]+))"/g;
277
+ const entries = [];
278
+ let m;
279
+ while ((m = re.exec(text)) !== null) {
280
+ if (!m[2]) continue;
281
+ const fileStr = m[3].replace(/\\\//g, '/');
282
+ const streams = parseFileStr(fileStr);
283
+ if (streams.length > 0) entries.push({ voice: m[1], episode: m[2], streams });
284
+ }
285
+ return entries;
286
+ }
287
+
288
+ function extractMovieVoices(text) {
289
+ // Movie: t1 is empty string ""
290
+ const re = /"title":"([^"]+)","t1":"","poster":"[^"]*","file":"((?:\[\w+\]https?:\\\/\\\/[^"]+))"/g;
291
+ const voices = [];
292
+ let m;
293
+ while ((m = re.exec(text)) !== null) {
294
+ const fileStr = m[3].replace(/\\\//g, '/');
295
+ const streams = parseFileStr(fileStr);
296
+ if (streams.length > 0) voices.push({ label: m[1], url: streams[0].url, qualities: streams, subtitles: [] });
297
+ }
298
+ return voices;
299
+ }
300
+
301
+ async function parseObrutEmbed(embedUrl) {
302
+ // Run parallel batches of requests and merge results
303
+ // obrut injects random garbage bytes that truncate data β€” more parallel requests = better coverage
304
+ const BATCH_SIZE = 10;
305
+ const MAX_BATCHES = 8;
306
+
307
+ // Merged data structures
308
+ const mergedEntries = new Map(); // key: voice+episode -> entry
309
+ const mergedVoices = new Map(); // key: label -> voice
310
+
311
+ // First try plain HTTP
312
+ const testHtml = await fetchRaw(embedUrl, {
313
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36',
314
+ 'referer': 'https://web.kinojump.com/',
315
+ 'accept': 'text/html,application/xhtml+xml',
316
+ 'accept-encoding': 'identity'
317
+ }).catch(() => '');
318
+
319
+ const hasPlayer = testHtml.includes('new Player(');
320
+ console.log('[turbo] obrut embed test: len=', testHtml.length, 'hasPlayer=', hasPlayer, 'start:', testHtml.substring(0, 100));
321
+
322
+ if (!hasPlayer) {
323
+ // obrut blocked plain HTTP β€” use Puppeteer to get embed HTML
324
+ console.log('[turbo] obrut blocked plain HTTP, using Puppeteer for embed');
325
+ const browser = await getBrowser();
326
+ const page = await browser.newPage();
327
+ try {
328
+ await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36');
329
+ await page.setExtraHTTPHeaders({ 'referer': 'https://web.kinojump.com/' });
330
+ await page.goto(embedUrl, { waitUntil: 'networkidle2', timeout: 25000 });
331
+ await new Promise(r => setTimeout(r, 2000));
332
+ const html = await page.content();
333
+ console.log('[turbo] Puppeteer embed len:', html.length, 'hasPlayer:', html.includes('new Player('));
334
+
335
+ if (html.includes('new Player(')) {
336
+ const pm = html.match(/new\s+Player\s*\(\s*"([A-Za-z0-9+/=]{20,})"/);
337
+ if (pm) {
338
+ const text = getCleanText(pm[1]);
339
+ const entries = extractEntries(text);
340
+ const movieVoices = extractMovieVoices(text);
341
+ console.log('[turbo] Puppeteer embed: entries=', entries.length, 'voices=', movieVoices.length);
342
+ if (entries.length > 0 || movieVoices.length > 0) {
343
+ return { entries, movieVoices };
344
+ }
345
+ // If Puppeteer got data but not enough, continue with parallel HTTP
346
+ console.log('[turbo] Puppeteer got Player but no entries, trying parallel HTTP');
347
+ }
348
+ }
349
+ } finally {
350
+ await page.close();
351
+ }
352
+ }
353
+
354
+ for (let batch = 0; batch < MAX_BATCHES; batch++) {
355
+ // Fetch BATCH_SIZE pages in parallel
356
+ const promises = Array.from({ length: BATCH_SIZE }, () =>
357
+ fetchRaw(embedUrl, {
358
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36',
359
+ 'referer': 'https://web.kinojump.com/',
360
+ 'accept': 'text/html,application/xhtml+xml',
361
+ 'accept-encoding': 'identity'
362
+ }).then(html => {
363
+ const pm = html.match(/new\s+Player\s*\(\s*"([A-Za-z0-9+/=]{20,})"/);
364
+ if (!pm) {
365
+ if (batch === 0) console.log('[turbo] no Player() in embed, len:', html.length, 'start:', html.substring(0, 150));
366
+ return null;
367
+ }
368
+ const text = getCleanText(pm[1]);
369
+ return { entries: extractEntries(text), movieVoices: extractMovieVoices(text) };
370
+ }).catch(() => null)
371
+ );
372
+
373
+ const results = await Promise.all(promises);
374
+
375
+ for (const r of results) {
376
+ if (!r) continue;
377
+ for (const e of r.entries) {
378
+ const key = e.voice + '|' + e.episode;
379
+ if (!mergedEntries.has(key)) mergedEntries.set(key, e);
380
+ }
381
+ for (const v of r.movieVoices) {
382
+ if (!mergedVoices.has(v.label)) mergedVoices.set(v.label, v);
383
+ }
384
+ }
385
+
386
+ const e = mergedEntries.size, v = mergedVoices.size;
387
+ console.log(`[turbo] batch ${batch + 1}: merged entries=${e} voices=${v}`);
388
+ // For movies stop early, for serials always run all batches
389
+ if (v >= 5) break;
390
+ }
391
+
392
+ const entries = Array.from(mergedEntries.values());
393
+ const movieVoices = Array.from(mergedVoices.values());
394
+
395
+ if (entries.length === 0 && movieVoices.length === 0) return null;
396
+ return { entries, movieVoices };
397
+ }
398
+
399
+ // ── Build serial structure from entries ──────────────────────────────────────
400
+ function buildSerialFromEntries(entries) {
401
+ // entries: [{voice, episode (e.g. "S01E01 - Title"), streams}]
402
+ // Group by season/episode
403
+ const seasonMap = {};
404
+ const voiceSet = new Set();
405
+
406
+ for (const e of entries) {
407
+ voiceSet.add(e.voice);
408
+ // Parse season/episode from t1: "S01E01 - Title" or "S01E01"
409
+ const seMatch = e.episode.match(/S(\d+)E(\d+)/i);
410
+ if (!seMatch) continue;
411
+ const sNum = parseInt(seMatch[1], 10);
412
+ const eNum = parseInt(seMatch[2], 10);
413
+ const sKey = 's' + String(sNum).padStart(2, '0');
414
+ const eKey = 'e' + String(eNum).padStart(2, '0');
415
+ if (!seasonMap[sKey]) seasonMap[sKey] = {};
416
+ if (!seasonMap[sKey][eKey]) seasonMap[sKey][eKey] = [];
417
+ seasonMap[sKey][eKey].push({ label: e.voice, streams: e.streams, url: e.streams[0] ? e.streams[0].url : '' });
418
+ }
419
+
420
+ const seasons = Object.keys(seasonMap).sort().map(sk => ({ id: sk, title: 'Season ' + parseInt(sk.slice(1), 10) }));
421
+ const episodes = {};
422
+ const voices = {};
423
+ for (const sk of Object.keys(seasonMap)) {
424
+ episodes[sk] = Object.keys(seasonMap[sk]).sort().map(ek => ({ id: ek, title: 'Episode ' + parseInt(ek.slice(1), 10) }));
425
+ voices[sk] = {};
426
+ for (const ek of Object.keys(seasonMap[sk])) {
427
+ voices[sk][ek] = seasonMap[sk][ek].map(v => ({
428
+ label: v.label,
429
+ url: v.url,
430
+ qualities: v.streams,
431
+ subtitles: []
432
+ }));
433
+ }
434
+ }
435
+
436
+ return { seasons, episodes, voices };
437
+ }
438
+
439
+ // ── Main: get stream for a kinojump page URL ──────────────────────────────────
440
+ async function getTurboStream(pageUrl) {
441
+ const browser = await getBrowser();
442
+
443
+ // Step 1: try plain HTTP first (fast, no Puppeteer)
444
+ console.log('[turbo] Step 1 - getting embed URL from:', pageUrl);
445
+ let embedUrl = null;
446
+ try {
447
+ const html = await getKinojumpHtml(pageUrl, browser);
448
+ const m = html.match(/(?:([a-z0-9]+)\.)?obrut\.show\/embed\/([A-Za-z0-9]+)\/content\/([A-Za-z0-9]+)/);
449
+ if (m) {
450
+ // m[1] = subdomain (may be undefined), m[2] = content type, m[3] = content id
451
+ const subdomain = m[1] ? m[1] + '.obrut.show' : '49372504.obrut.show';
452
+ embedUrl = 'https://' + subdomain + '/embed/' + m[2] + '/content/' + m[3];
453
+ console.log('[turbo] embed URL:', embedUrl);
454
+ } else { console.log('[turbo] embed not found in HTML'); }
455
+ } catch(e) { console.log('[turbo] getKinojumpHtml failed:', e.message); }
456
+
457
+ if (!embedUrl) throw new Error('obrut embed URL not found on kinojump page');
458
+
459
+ // Step 3: parse embed
460
+ console.log('[turbo] Step 2 - parsing obrut embed:', embedUrl);
461
+ const result = await parseObrutEmbed(embedUrl);
462
+ if (!result) throw new Error('failed to extract data from obrut embed');
463
+
464
+ const hasSerial = result.entries.length > 0;
465
+ const hasMovie = result.movieVoices.length > 0;
466
+
467
+ if (hasSerial) {
468
+ const serial = buildSerialFromEntries(result.entries);
469
+ console.log('[turbo] serial:', serial.seasons.length, 'seasons');
470
+ return { embed_url: embedUrl, content_type: 'serial', serial };
471
+ } else if (hasMovie) {
472
+ console.log('[turbo] movie:', result.movieVoices.length, 'voices');
473
+ return { embed_url: embedUrl, content_type: 'movie', voices: result.movieVoices };
474
+ } else {
475
+ throw new Error('no entries or voices found in obrut embed');
476
+ }
477
+ }
478
+
479
+ module.exports = { turboSearch, getTurboStream, parseObrutEmbed, extractEntries, extractMovieVoices, buildSerialFromEntries, getKinojumpHtml };