OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / bb452ae

Generic source probe: capture HOW a player gets its stream
vidcore_resolve only captures media URLs, which is enough to play vidcore but not enough to evaluate a new source. vidlink is the case in point: its stream URL carries sign/t params, 403s on every referer and 427s with its own, so the m3u8 alone is worthless -- the request that MINTED it is the artefact. Records media, XHR/fetch calls with request headers and a slice of the response, and cookies. The API pattern is deliberately broad; a probe that only looks where it expects finds only what it expected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bb452ae93ba50c7df81a508635774fcb539c43cd
by plankatron <fleeky@gmail.com> · 2026-08-11T16:10:30.000Z

Changed files (1) +117 -0

node_helper/source_probe.mjs +117 -0
……@@ -0,0 +1,117 @@
1#!/usr/bin/env node
2// Generic source probe: open a player page and record HOW it obtains its stream.
3//
4// vidcore_resolve.mjs only needs the m3u8 URL, so it captures media requests and
5// nothing else. That is not enough to evaluate a NEW source: the interesting
6// question is where the URL is minted and what that mint depends on. vidlink is
7// the case in point -- its stream URL carries `sign` and `t` params, returns 403
8// on every referer tried and 427 with its own, so it is session- or IP-bound and
9// the m3u8 alone is worthless. What matters is the request that produced it.
10//
11// So this records three things per run:
12// media - stream URLs (m3u8/mp4), with the referer actually sent
13// api - XHR/fetch calls, with request headers and a slice of the response
14// cookies - what the origin set, since a session-bound token has to live
15// somewhere
16//
17// Usage: node source_probe.mjs <url> [referer] [seconds]
18// Output (last stdout line): {media:[...], api:[...], cookies:[...], title, bodyText}
19import { chromium } from 'patchright';
20import fs from 'node:fs';
21import os from 'node:os';
22import path from 'node:path';
23
24const URL_ARG = process.argv[2];
25const REFERER = process.argv[3] || '';
26const SECONDS = process.argv[4] ? parseInt(process.argv[4], 10) : 30;
27const log = (...a) => console.error('[probe]', ...a);
28if (!URL_ARG) {
29 console.log(JSON.stringify({ error: 'usage: <url> [referer] [seconds]' }));
30 process.exit(1);
31}
32
33const MEDIA = /\.m3u8(\?|$)|\.mp4(\?|$)|\/playlist|\/master|\.ts(\?|$)|\.m4s(\?|$)/i;
34// Anything that could be minting a URL. Deliberately broad -- a probe that only
35// looks where it expects finds only what it expected.
36const API = /\/api\/|\/source|\/stream|\/play|\/get|sign=|token=|\.json(\?|$)|graphql/i;
37const NOISE = /youtube|googlevideo|ytimg|google-analytics|googletagmanager|doubleclick|facebook|cloudflareinsights|sentry|\.(png|jpg|jpeg|webp|svg|gif|css|woff2?|ico)(\?|$)/i;
38
39const media = new Map(); // url -> referer sent
40const api = []; // {url, method, status, reqHeaders, body}
41const seen = new Set();
42
43const PROFILE = fs.mkdtempSync(path.join(os.tmpdir(), 'probe_'));
44process.on('exit', () => { try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch {} });
45
46const ctx = await chromium.launchPersistentContext(PROFILE, {
47 headless: false, args: ['--no-sandbox'],
48 viewport: { width: 1280, height: 720 }, locale: 'en-US',
49});
50// Popunders: these players open ad tabs that steal focus and can outlive the run.
51ctx.on('page', p => setTimeout(() => {
52 if (!p.isClosed() && p.url() !== URL_ARG) p.close().catch(() => {});
53}, 800));
54
55ctx.on('request', r => {
56 const u = r.url();
57 if (NOISE.test(u)) return;
58 if (MEDIA.test(u)) media.set(u, (r.headers() || {}).referer || null);
59});
60
61ctx.on('response', async resp => {
62 const u = resp.url();
63 if (NOISE.test(u) || !API.test(u) || seen.has(u)) return;
64 seen.add(u);
65 const req = resp.request();
66 if (!['xhr', 'fetch', 'document'].includes(req.resourceType())) return;
67 let body = null;
68 try {
69 const t = await resp.text();
70 body = t.slice(0, 700);
71 } catch { /* opaque or already consumed */ }
72 api.push({
73 url: u.slice(0, 300),
74 method: req.method(),
75 status: resp.status(),
76 reqHeaders: Object.fromEntries(Object.entries(req.headers() || {})
77 .filter(([k]) => /referer|origin|authorization|x-|cookie/i.test(k))),
78 body,
79 });
80});
81
82const page = ctx.pages()[0] || await ctx.newPage();
83if (REFERER) await page.setExtraHTTPHeaders({ Referer: REFERER });
84log('goto', URL_ARG, REFERER ? `(referer ${REFERER})` : '');
85await page.goto(URL_ARG, { waitUntil: 'domcontentloaded', timeout: 60000 })
86 .catch(e => log('warn', e.message));
87
88const rounds = Math.max(1, Math.floor(SECONDS / 3));
89for (let i = 0; i < rounds; i++) {
90 await page.waitForTimeout(3000);
91 // Nudge anything waiting on a gesture. Both a <video> and a generic play
92 // button are tried -- these players differ and a missed click looks exactly
93 // like a source that has nothing.
94 const v = await page.$('video').catch(() => null);
95 if (v) await v.click({ timeout: 1000, force: true }).catch(() => {});
96 for (const sel of ['button[aria-label*="lay" i]', '.play-button', '#player']) {
97 const el = await page.$(sel).catch(() => null);
98 if (el) await el.click({ timeout: 800, force: true }).catch(() => {});
99 }
100 const st = await page.evaluate(() => (document.body?.innerText || '')
101 .replace(/\s+/g, ' ').slice(0, 60)).catch(() => '');
102 log(`t=${(i + 1) * 3}s media=${media.size} api=${api.length} "${st}"`);
103 if (media.size) break;
104}
105
106const cookies = (await ctx.cookies().catch(() => []))
107 .map(c => `${c.domain}${c.path} ${c.name}=${String(c.value).slice(0, 24)}`);
108const title = await page.title().catch(() => '');
109const bodyText = await page.evaluate(() => (document.body?.innerText || '')
110 .replace(/\s+/g, ' ').slice(0, 300)).catch(() => '');
111await ctx.close();
112fs.rmSync(PROFILE, { recursive: true, force: true });
113
114console.log(JSON.stringify({
115 media: [...media.entries()].map(([url, referer]) => ({ url, referer })),
116 api, cookies, title, bodyText,
117}));