1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt, ' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety; zakresy 0–5 i A–Z na dywiz. Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach, czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu, tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana. Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie). Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika to zero zmiany. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
5.6 KiB
TypeScript
162 lines
5.6 KiB
TypeScript
/**
|
|
* Mobile-side voe.sx resolver.
|
|
*
|
|
* VOE CDN URL (cloudwindow-route.com) ma `i=<2-oktetowy prefix IP>`, token bound do /16
|
|
* IP które POBRAŁO embed (audit 2026-06-11: z VPS `i=46.62`, master.m3u8 z innego /16 →
|
|
* 403). Backend resolvuje z VPS → telefon dostaje URL bound do /16 VPS → direct 403 →
|
|
* pełny proxy → CAŁE wideo przez Hetzner (131 hitów/48h).
|
|
*
|
|
* Fix: telefon sam pobiera embed (phone IP) → token bound do jego /16 → gra direct,
|
|
* zero VPS. Port 7-stopniowego dekodera z app/extractors/hosters/voe.py (rot13 → replace
|
|
* 7 magic-sepów → strip _ → atob → shift -3 → reverse → atob → JSON.parse).
|
|
*/
|
|
import type { StreamLink } from '../types';
|
|
|
|
const UA =
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
|
|
|
const _REDIRECT_RE = /window\.location\.href\s*=\s*['"]([^'"]+)['"]/;
|
|
const _PAYLOAD_RE = /<script\s+type=["']application\/json["']>(\[[\s\S]+?\])<\/script>/;
|
|
const _MAGIC_SEPS = ['@$', '^^', '~@', '%?', '*~', '!!', '#&'];
|
|
const _B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
|
|
/** True dla embed/page voe.sx (origin *:voe). */
|
|
export function isVoeUrl(url: string | null | undefined): boolean {
|
|
return !!url && /(?:^|\/\/|\.)voe\.sx\//i.test(url);
|
|
}
|
|
|
|
function rot13(s: string): string {
|
|
let out = '';
|
|
for (let i = 0; i < s.length; i++) {
|
|
const c = s.charCodeAt(i);
|
|
if (c >= 0x41 && c <= 0x5a) out += String.fromCharCode(((c - 0x41 + 13) % 26) + 0x41);
|
|
else if (c >= 0x61 && c <= 0x7a) out += String.fromCharCode(((c - 0x61 + 13) % 26) + 0x61);
|
|
else out += s[i];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Standard base64 → bytes (self-contained, bez atob/Buffer). */
|
|
function b64ToBytes(b64: string): Uint8Array {
|
|
const lookup = new Int16Array(128).fill(-1);
|
|
for (let i = 0; i < _B64.length; i++) lookup[_B64.charCodeAt(i)] = i;
|
|
const clean = b64.replace(/=+$/, '');
|
|
const out = new Uint8Array(Math.floor((clean.length * 6) / 8));
|
|
let bits = 0;
|
|
let acc = 0;
|
|
let oi = 0;
|
|
for (let i = 0; i < clean.length; i++) {
|
|
const v = lookup[clean.charCodeAt(i) & 0x7f];
|
|
if (v < 0) continue; // pomiń whitespace/nieznane
|
|
acc = (acc << 6) | v;
|
|
bits += 6;
|
|
if (bits >= 8) {
|
|
bits -= 8;
|
|
out[oi++] = (acc >>> bits) & 0xff;
|
|
}
|
|
}
|
|
return out.subarray(0, oi);
|
|
}
|
|
|
|
/** bytes → UTF-8 string (minimalny dekoder, bez TextDecoder). */
|
|
function utf8Decode(bytes: Uint8Array): string {
|
|
let out = '';
|
|
let i = 0;
|
|
while (i < bytes.length) {
|
|
const b = bytes[i++];
|
|
if (b < 0x80) out += String.fromCharCode(b);
|
|
else if (b >= 0xc0 && b < 0xe0) out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f));
|
|
else if (b >= 0xe0 && b < 0xf0)
|
|
out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f));
|
|
else {
|
|
const cp = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f);
|
|
const c = cp - 0x10000;
|
|
out += String.fromCharCode(0xd800 + (c >> 10), 0xdc00 + (c & 0x3ff));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 7-stopniowy dekoder payloadu (port _decode_payload z voe.py). */
|
|
function decodePayload(payload: string): any | null {
|
|
try {
|
|
let s = rot13(payload);
|
|
for (const sep of _MAGIC_SEPS) s = s.split(sep).join('_');
|
|
s = s.split('_').join('');
|
|
const bytes1 = b64ToBytes(s); // 1st atob (latin-1)
|
|
let shifted = '';
|
|
for (let i = 0; i < bytes1.length; i++) shifted += String.fromCharCode(bytes1[i] - 3);
|
|
const reversed = shifted.split('').reverse().join('');
|
|
const bytes2 = b64ToBytes(reversed); // 2nd atob
|
|
return JSON.parse(utf8Decode(bytes2));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pobiera voe.sx embed NA TELEFONIE → m3u8 (+ mp4 fallback) StreamLink-i (phone-IP-bound,
|
|
* grają direct). Zwraca [] gdy brak/dead (caller spada na backend). Mirror voe.py.
|
|
*/
|
|
export async function resolveVoePage(embedUrl: string): Promise<StreamLink[]> {
|
|
const referer = 'https://voe.sx/';
|
|
let html: string;
|
|
try {
|
|
const r = await fetch(embedUrl, { headers: { 'User-Agent': UA, Accept: 'text/html' } });
|
|
if (!r.ok) return [];
|
|
html = await r.text();
|
|
} catch {
|
|
return [];
|
|
}
|
|
// Stage 1: JS redirect do losowego mirroru.
|
|
if (html.includes('window.location.href')) {
|
|
const m = _REDIRECT_RE.exec(html);
|
|
if (!m) return [];
|
|
try {
|
|
const r2 = await fetch(m[1], { headers: { 'User-Agent': UA, Accept: 'text/html' } });
|
|
if (!r2.ok) return [];
|
|
html = await r2.text();
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
// Stage 2: extract + decode JSON payload.
|
|
const pm = _PAYLOAD_RE.exec(html);
|
|
if (!pm) return [];
|
|
let payload: string | null = null;
|
|
try {
|
|
const arr = JSON.parse(pm[1]);
|
|
payload = Array.isArray(arr) && arr.length ? arr[0] : null;
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (typeof payload !== 'string') return [];
|
|
const config = decodePayload(payload);
|
|
if (!config) return [];
|
|
|
|
const links: StreamLink[] = [];
|
|
const source = (config.source || '').trim();
|
|
if (source) {
|
|
links.push({
|
|
stream_url: source,
|
|
direct_url: source,
|
|
headers: { Referer: referer, 'User-Agent': UA },
|
|
quality: 'auto',
|
|
type: 'm3u8',
|
|
});
|
|
}
|
|
let fallback = config.fallback || [];
|
|
if (fallback && !Array.isArray(fallback)) fallback = [fallback];
|
|
for (const fb of fallback) {
|
|
if (fb && typeof fb === 'object' && fb.file) {
|
|
links.push({
|
|
stream_url: fb.file,
|
|
direct_url: fb.file,
|
|
headers: { Referer: referer, 'User-Agent': UA },
|
|
quality: fb.label || 'auto',
|
|
type: 'mp4',
|
|
});
|
|
}
|
|
}
|
|
return links;
|
|
}
|