206 lines
20 KiB
JavaScript
206 lines
20 KiB
JavaScript
// Coordinated production trial. No tuning, restart, recording or configuration writes.
|
|
// Invoke only AFTER the receiver task explicitly announces its test window.
|
|
const fs = require('node:fs');
|
|
const { execFileSync } = require('node:child_process');
|
|
const { performance } = require('node:perf_hooks');
|
|
const { chromium } = require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
|
const option = (name, fallback) => process.argv.find(a => a.startsWith('--' + name + '='))?.split('=').slice(1).join('=') ?? fallback;
|
|
const duration = Number(option('duration', '900')), joinAt = Number(option('join', '300')), leaveAt = Number(option('leave', '600'));
|
|
const channel = option('channel', 'TVP 1 HD');
|
|
const reportPath = option('report', 'reception-trial.json');
|
|
const bridgeFile = option('bridge-file', null);
|
|
const frameFile = option('frame', null);
|
|
if (!process.argv.includes('--coordinated') || !Number.isFinite(duration) || !Number.isFinite(joinAt) || !Number.isFinite(leaveAt) || !(0 < joinAt && joinAt < leaveAt && leaveAt < duration)) {
|
|
console.error('Requires --coordinated after explicit start signal, and 0 < join < leave < duration (seconds).');
|
|
process.exit(2);
|
|
}
|
|
const origin = 'http://192.168.100.191:8096', web = origin + '/web/index.html';
|
|
const safe = value => String(value).replace(/(api_key|access_token|Token|password)([=:\s]+)[^\s,"&]+/gi, '$1$2<redacted>').replace(/(https?:\/\/[^\s?"']+)\?[^\s"']*/g, '$1?<redacted>').slice(0, 500);
|
|
const report = { started_utc: new Date().toISOString(), channel, relay_id: null, isolated_bridge_override: Boolean(bridgeFile), schedule_seconds: { duration, join: joinAt, leave: leaveAt }, checks: [], events: [], snapshots: [], audio_scope: 'Decoded byte counter when available; no human audible verification.' };
|
|
fs.writeFileSync(reportPath + '.jsonl', '');
|
|
const record = (type, data) => { const row = { utc: new Date().toISOString(), type, ...data }; fs.appendFileSync(reportPath + '.jsonl', JSON.stringify(row) + '\n'); return row; };
|
|
const check = (name, ok, evidence) => { const row = { name, status: ok ? 'PASS' : 'FAIL', evidence }; report.checks.push(row); console.log(name + ': ' + row.status); record('check', row); };
|
|
const admin = `import json,sqlite3,urllib.request,pathlib,secrets\nc=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys LIMIT 1').fetchone()[0];c.close()\ndef api(p,d=None,method=None):\n r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="ReceptionTrial", Device="Maintenance", DeviceId="decoder-reception-trial", Version="1"','Content-Type':'application/json'},data=json.dumps(d).encode() if d is not None else None,method=method)\n b=urllib.request.urlopen(r,timeout=10).read();return json.loads(b) if b else None\n`;
|
|
const ssh = code => JSON.parse(execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@192.168.100.25', 'pct exec 100191 -- python3 -'], { input: admin + code, encoding: 'utf8', timeout: 20000 }).trim().split('\n').at(-1));
|
|
let browser, started, serviceReference, baseline, testedChannelId, stopRequested = false;
|
|
process.on('SIGINT', () => { stopRequested = true; });
|
|
process.on('SIGTERM', () => { stopRequested = true; });
|
|
const accounts = [], viewers = [];
|
|
function elapsed() { return started === undefined ? null : Number(((performance.now() - started) / 1000).toFixed(2)); }
|
|
async function makeViewer(index) {
|
|
const account = ssh(`n='decoder-reception-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});print(json.dumps({'id':u['Id'],'name':n,'password':p}))`);
|
|
accounts.push(account);
|
|
ssh(`api('/Users/${account.id}/Password',{'CurrentPw':'','NewPw':${JSON.stringify(account.password)}});print('{}')`);
|
|
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }), page = await context.newPage();
|
|
if (bridgeFile) {
|
|
// A/B evaluation only: replace the plugin JS in this isolated browser, never on the server.
|
|
await context.route('**/decoder-channel-playback.js*', route => {
|
|
record('isolated_bridge_served', { viewer: index });
|
|
return route.fulfill({ contentType: 'application/javascript', body: fs.readFileSync(bridgeFile, 'utf8') });
|
|
});
|
|
await context.route('**/DecoderIntegration/WebPlaybackPolicy?*', route => {
|
|
const requested = new URL(route.request().url()).searchParams.get('channelId')?.replace(/-/g, '').toLowerCase();
|
|
const matched = requested === testedChannelId.replace(/-/g, '').toLowerCase();
|
|
record('isolated_policy_request', { viewer: index, matched });
|
|
return route.fulfill({ json: { IsEnigmaChannel: matched, PreferH264: true } });
|
|
});
|
|
}
|
|
const viewer = { index, context, page, active: false, closing: false, samples: [], errors: [], hls: { manifests: 0, segments: 0, failed: [], request_failed: [] } }; viewers.push(viewer);
|
|
await page.addInitScript(() => {
|
|
window.__trialEvents = [];
|
|
for (const type of ['waiting','stalled','error','ended','playing','pause']) document.addEventListener(type, event => {
|
|
if (event.target instanceof HTMLVideoElement) window.__trialEvents.push({ type, at: Date.now(), time: event.target.currentTime, error: event.target.error?.code || null });
|
|
}, true);
|
|
});
|
|
page.on('pageerror', error => { const value = { elapsed: elapsed(), message: safe(error.message) }; viewer.errors.push(value); record('browser_error', { viewer: index, ...value }); });
|
|
page.on('response', response => {
|
|
const path = new URL(response.url()).pathname;
|
|
if (!/\.(m3u8|ts|m4s)$/.test(path)) return;
|
|
if (path.endsWith('.m3u8')) viewer.hls.manifests++; else viewer.hls.segments++;
|
|
if (response.status() >= 400) { const value = { elapsed: elapsed(), path, status: response.status() }; viewer.hls.failed.push(value); record('hls_error', { viewer: index, ...value }); }
|
|
});
|
|
page.on('requestfailed', request => {
|
|
const path = new URL(request.url()).pathname;
|
|
if (/\.(m3u8|ts|m4s)$/.test(path)) {
|
|
const value = { elapsed: elapsed(), path, message: safe(request.failure()?.errorText), during_close: viewer.closing };
|
|
if (!viewer.closing) viewer.hls.request_failed.push(value);
|
|
record('hls_request_failed', { viewer: index, ...value });
|
|
}
|
|
});
|
|
await page.goto(web);
|
|
await page.locator('#txtManualName').fill(account.name); await page.locator('#txtManualPassword').fill(account.password);
|
|
await page.getByRole('checkbox').check(); await page.getByRole('button', { name: 'Sign In', exact: true }).click();
|
|
await page.waitForURL(/home/, { timeout: 20000 });
|
|
await page.waitForFunction(() => JSON.parse(localStorage.getItem('jellyfin_credentials') || '{}').Servers?.some(s => s.AccessToken && s.UserId));
|
|
await page.locator('#txtManualPassword:visible').waitFor({ state: 'hidden', timeout: 10000 });
|
|
await page.waitForTimeout(1000);
|
|
return viewer;
|
|
}
|
|
async function open(viewer) {
|
|
const began = performance.now();
|
|
await viewer.page.goto(web + '#/livetv');
|
|
await viewer.page.waitForURL(/tab=2/, { timeout: 15000 });
|
|
const escaped = channel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
await viewer.page.locator('.card[data-type="TvChannel"]').filter({ has: viewer.page.locator('a.textActionButton', { hasText: new RegExp('^\\d+ ' + escaped + '$') }) }).locator('a.cardImageContainer').click({ position: { x: 20, y: 20 } });
|
|
viewer.active = true;
|
|
await viewer.page.waitForFunction(() => [...document.querySelectorAll('video')].some(v => v.currentTime > 0 && v.readyState >= 2 && v.videoWidth > 0 && !v.paused), {}, { timeout: 30000 });
|
|
viewer.startup_seconds = Number(((performance.now() - began) / 1000).toFixed(2));
|
|
check('viewer ' + viewer.index + ' one-click decoded start', true, { seconds: viewer.startup_seconds });
|
|
}
|
|
async function sample(viewer) {
|
|
const data = await viewer.page.evaluate(() => {
|
|
const videos = [...document.querySelectorAll('video')];
|
|
const video = videos.find(v => v.currentTime > 0 && v.videoWidth > 0 && !v.paused) || videos.find(v => v.currentTime > 0 && v.videoWidth > 0);
|
|
const events = window.__trialEvents.splice(0);
|
|
if (!video) return { video: null, events, route: location.hash };
|
|
const quality = video.getVideoPlaybackQuality?.();
|
|
const range = [...Array(video.buffered.length).keys()].find(i => video.buffered.start(i) <= video.currentTime && video.buffered.end(i) >= video.currentTime);
|
|
return { video: { time: video.currentTime, frames: quality?.totalVideoFrames ?? video.webkitDecodedFrameCount ?? null, dropped: quality?.droppedVideoFrames ?? video.webkitDroppedFrameCount ?? null, audio_bytes: video.webkitAudioDecodedByteCount ?? null, width: video.videoWidth, height: video.videoHeight, ready: video.readyState, paused: video.paused, ended: video.ended, error: video.error?.code || null, buffered_ahead: range === undefined ? 0 : video.buffered.end(range) - video.currentTime }, events, route: location.hash };
|
|
});
|
|
const row = record('video', { viewer: viewer.index, elapsed: elapsed(), ...data }); viewer.samples.push(row); return row;
|
|
}
|
|
async function closeViewer(viewer) {
|
|
if (!viewer.active) return;
|
|
viewer.closing = true;
|
|
let normal = false;
|
|
try {
|
|
if (await viewer.page.locator('#decoderPlaybackExit').count()) await viewer.page.locator('#decoderPlaybackExit').click();
|
|
await viewer.page.waitForURL(/livetv.*tab=2/, { timeout: 15000 });
|
|
normal = true;
|
|
} catch (error) { record('exit_error', { viewer: viewer.index, message: safe(error.message) }); }
|
|
try { await viewer.context.close(); } finally { viewer.active = false; }
|
|
check('viewer ' + viewer.index + ' exit to Channels', normal, { elapsed: elapsed() });
|
|
}
|
|
async function serverSnapshot(label) {
|
|
const server = ssh(`s=api('/DecoderIntegration/Status');t=api('/Sessions');print(json.dumps({'plugin':next(x['Version'] for x in api('/Plugins') if x['Name']=='Enigma2'),'streams':s.get('Streams',{}),'probe_error':s.get('ProbeError'),'sessions':[{'user':x.get('UserName'),'channel':(x.get('NowPlayingItem') or {}).get('Name'),'transcoding':{k:(x.get('TranscodingInfo') or {}).get(k) for k in ['VideoCodec','AudioCodec','IsVideoDirect','IsAudioDirect','Framerate','TranscodeReasons']}} for x in t if x.get('UserName','').startswith('decoder-reception-')]}))`);
|
|
server.streams.LastErrors = (server.streams.LastErrors || []).map(safe);
|
|
if (server.probe_error) server.probe_error = safe(server.probe_error);
|
|
// The receiver endpoint only reports existing clients; it does not open a TV source.
|
|
let receiver;
|
|
try {
|
|
const response = await fetch('http://192.168.10.20/api/about', { signal: AbortSignal.timeout(8000) });
|
|
const body = await response.json();
|
|
const rows = body.info?.streams || body.streams || [];
|
|
receiver = { status: response.status, jellyfin_channel_rows: rows.filter(row => row.ip === '192.168.100.191' && row.name === channel).length, rows: rows.map(row => ({ ip: row.ip, name: row.name, ref: row.ref })) };
|
|
// OpenWebif versions differ in field names. Preserve only non-sensitive stream fields.
|
|
receiver.channel_matches = rows.filter(row => JSON.stringify(row).replace(/ /g, '').toLowerCase().includes(channel.replace(/ /g, '').toLowerCase())).length;
|
|
} catch (error) { receiver = { error: safe(error.message) }; }
|
|
const row = record('server', { label, elapsed: elapsed(), ...server, receiver }); report.snapshots.push(row); return row;
|
|
}
|
|
function identifyRelay(snapshot) {
|
|
const reference = snapshot.receiver.rows?.find(row => row.ip === '192.168.100.191' && row.name === channel)?.ref;
|
|
const normalize = text => decodeURIComponent(text || '').replace(/^\//, '').toUpperCase();
|
|
const exact = reference && snapshot.streams.Channels?.find(c => normalize(c.ServiceReference) === normalize(reference));
|
|
const added = snapshot.streams.Channels?.filter(c => !baseline?.streams.Channels?.some(b => b.Id === c.Id)) || [];
|
|
const candidate = exact || (added.length === 1 ? added[0] : null);
|
|
if (candidate) { report.relay_id = candidate.Id; serviceReference = candidate.ServiceReference; report.service_reference = serviceReference; }
|
|
}
|
|
(async () => {
|
|
try {
|
|
browser = await chromium.launch({ headless: true, channel: 'msedge', args: ['--autoplay-policy=no-user-gesture-required','--disable-background-timer-throttling','--disable-backgrounding-occluded-windows','--disable-renderer-backgrounding'] });
|
|
testedChannelId = ssh(`c=next(x for x in api('/LiveTv/Channels?Limit=1000')['Items'] if x['Name']==${JSON.stringify(channel)});print(json.dumps(c['Id']))`);
|
|
const primary = await makeViewer(1), secondary = await makeViewer(2);
|
|
baseline = await serverSnapshot('before');
|
|
await open(primary); started = performance.now(); report.first_video_utc = new Date().toISOString();
|
|
const first = await serverSnapshot('primary-started');
|
|
identifyRelay(first);
|
|
if (frameFile) {
|
|
await primary.page.waitForTimeout(3000);
|
|
const handle = await primary.page.evaluateHandle(() => [...document.querySelectorAll('video')].find(v => v.currentTime > 0 && v.videoWidth > 0 && !v.paused));
|
|
const video = handle.asElement();
|
|
if (!video) throw Error('No active video for visual verification');
|
|
await video.screenshot({ path: frameFile }); await handle.dispose();
|
|
report.frame_artifact = frameFile; record('frame_saved', { path: frameFile, elapsed: elapsed() });
|
|
}
|
|
let joined = false, left = false, nextServer = 30, nextProgress = 60;
|
|
while (!stopRequested && elapsed() < duration) {
|
|
if (!joined && elapsed() >= joinAt) { joined = true; report.secondary_request_seconds = elapsed(); await open(secondary); report.secondary_video_seconds = elapsed(); await serverSnapshot('secondary-joined'); }
|
|
if (joined && !left && elapsed() >= leaveAt) { left = true; report.secondary_stop_seconds = elapsed(); await closeViewer(secondary); await serverSnapshot('secondary-left'); }
|
|
for (const viewer of viewers.filter(v => v.active)) await sample(viewer);
|
|
if (elapsed() >= nextServer) { await serverSnapshot('periodic'); nextServer = elapsed() + 30; }
|
|
if (elapsed() >= nextProgress) { console.log(JSON.stringify({ elapsed: elapsed(), viewers: viewers.filter(v => v.active).map(v => ({ viewer: v.index, ...v.samples.at(-1)?.video })) })); nextProgress = elapsed() + 60; }
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
}
|
|
report.playback_elapsed_seconds = elapsed();
|
|
report.primary_stop_seconds = elapsed();
|
|
await closeViewer(primary);
|
|
check('scheduled duration completed', !stopRequested && report.playback_elapsed_seconds >= duration, { seconds: report.playback_elapsed_seconds, interrupted: stopRequested });
|
|
check('second viewer phase completed', joined && left, { joinAt, leaveAt });
|
|
} catch (error) { check('trial execution', false, { elapsed: elapsed(), error: safe(error.message) }); }
|
|
finally {
|
|
for (const viewer of viewers) try { await closeViewer(viewer); } catch (error) { check('viewer cleanup', false, { viewer: viewer.index, error: safe(error.message) }); }
|
|
// Obtain final metrics before closing the browser API context; never close other users' sessions.
|
|
if (browser && viewers.length) {
|
|
await new Promise(resolve => setTimeout(resolve, 20000));
|
|
try {
|
|
const after = await serverSnapshot('after-cleanup'); if (!report.relay_id) identifyRelay(after);
|
|
const own = after.streams.Channels?.find(c => c.Id === report.relay_id), before = baseline?.streams.Channels?.find(c => c.Id === report.relay_id);
|
|
const released = before?.Active ? own?.Consumers <= before.Consumers : !own || !own.Active && own.Consumers === 0;
|
|
check('own relay consumers released after last viewer', Boolean(report.relay_id) && released, { relay_id: report.relay_id, active: own?.Active ?? false, consumers: own?.Consumers ?? 0, baseline_active: before?.Active ?? false, baseline_consumers: before?.Consumers ?? 0 });
|
|
} catch (error) { check('relay cleanup verification', false, { error: safe(error.message) }); }
|
|
}
|
|
if (browser) try { await browser.close(); } catch (error) { check('browser cleanup', false, { error: safe(error.message) }); }
|
|
for (const account of accounts) try { ssh(`api('/Users/${account.id}',method='DELETE');pathlib.Path('/var/lib/jellyfin/decoder-integration/users/${account.id.replace(/-/g,'')}.json').unlink(missing_ok=True);print('{}')`); } catch (error) { check('test account cleanup', false, { error: safe(error.message) }); }
|
|
for (const viewer of viewers) {
|
|
let stalls = 0, audioIntervals = 0, frameIntervals = 0, previous;
|
|
for (const row of viewer.samples) {
|
|
if (!row.video) { stalls++; previous = null; continue; }
|
|
if (previous) { if (row.video.time <= previous.time || row.video.frames !== null && previous.frames !== null && row.video.frames <= previous.frames) stalls++; else frameIntervals++; if (row.video.audio_bytes !== null && previous.audio_bytes !== null && row.video.audio_bytes > previous.audio_bytes) audioIntervals++; }
|
|
previous = row.video;
|
|
}
|
|
check('viewer ' + viewer.index + ' continuous decoded progress', stalls === 0 && frameIntervals > 0, { samples: viewer.samples.length, progressing_intervals: frameIntervals, stalled_intervals: stalls, audio_progress_intervals: audioIntervals });
|
|
check('viewer ' + viewer.index + ' browser and HLS responses', viewer.errors.length === 0 && viewer.hls.failed.length === 0 && viewer.hls.request_failed.length === 0, { browser_errors: viewer.errors, hls: viewer.hls });
|
|
const available = viewer.samples.some(row => row.video?.audio_bytes !== null && row.video?.audio_bytes !== undefined);
|
|
if (available) check('viewer ' + viewer.index + ' audio decoding progresses', audioIntervals > 0, { progressing_intervals: audioIntervals });
|
|
else { const row = { name: 'viewer ' + viewer.index + ' audio decoding progresses', status: 'UNVERIFIED', evidence: 'Browser does not expose decoded audio byte counter.' }; report.checks.push(row); record('check', row); }
|
|
}
|
|
const during = report.snapshots.filter(s => s.label === 'secondary-joined' || s.label === 'periodic' && s.elapsed >= joinAt && s.elapsed <= leaveAt);
|
|
const beforeJoin = report.snapshots.filter(s => s.elapsed !== null && s.elapsed < joinAt).at(-1);
|
|
check('sharing uses one physical receiver stream', during.length > 0 && beforeJoin && beforeJoin.receiver.jellyfin_channel_rows === 1 && during.every(s => s.receiver.jellyfin_channel_rows === 1 && s.streams.Channels?.some(c => c.Id === report.relay_id && c.Active)), { before_opened: beforeJoin?.streams.UpstreamConnectionsOpened, during: during.map(s => ({ elapsed: s.elapsed, opened: s.streams.UpstreamConnectionsOpened, physical_rows: s.receiver.jellyfin_channel_rows, consumers: s.streams.Channels?.find(c => c.Id === report.relay_id)?.Consumers })) });
|
|
check('global receiver upstream counter stable during sharing', during.length > 0 && beforeJoin && during.every(s => s.streams.UpstreamConnectionsOpened === beforeJoin.streams.UpstreamConnectionsOpened), { before: beforeJoin?.streams.UpstreamConnectionsOpened, during: during.map(s => s.streams.UpstreamConnectionsOpened), scope: 'Global plugin counter; correlate a failure with other users and channel snapshots.' });
|
|
report.finished_utc = new Date().toISOString(); report.pass = report.checks.filter(c => c.status === 'PASS').length; report.fail = report.checks.filter(c => c.status === 'FAIL').length; report.unverified = report.checks.filter(c => c.status === 'UNVERIFIED').length;
|
|
report.viewers = viewers.map(v => ({ viewer: v.index, startup_seconds: v.startup_seconds, samples: v.samples, errors: v.errors, hls: v.hls }));
|
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n'); console.log(JSON.stringify({ report: reportPath, pass: report.pass, fail: report.fail, finished_utc: report.finished_utc })); process.exitCode = report.fail ? 1 : 0;
|
|
}
|
|
})();
|