Add coordinated continuous browser reception trial

This commit is contained in:
2026-10-12 23:30:19 +02:00
parent a5a1df21a4
commit 5c10d8b9b8
2 changed files with 191 additions and 1 deletions
+8
View File
@@ -99,3 +99,11 @@ Korekta raportu panelu administratora: wcześniejszy FAIL był fałszywym wyniki
Mechanizm pasujący do wyników: natywny [playbackmanager](https://github.com/jellyfin/jellyfin-web/blob/master/src/components/playback/playbackmanager.js) wywołuje `alert()` w showPlaybackInfoErrorMessage bez obsługi zwróconej obietnicy. [Dialog](https://github.com/jellyfin/jellyfin-web/blob/master/src/components/dialog/dialog.js) kończy się `Promise.reject()` bez argumentu, gdy zamknięto go bez wyniku przycisku. [dialogHelper](https://github.com/jellyfin/jellyfin-web/blob/master/src/components/dialogHelper/dialogHelper.js) zamyka dialogi przy zmianie historii. To tłumaczy undefined po opuszczeniu komunikatu; nie jest dowodem błędu zatrzymania strumienia ani diagnozą OSCam. W tym zadaniu analizy nie modyfikowano natywnego klienta lub produkcyjnego modułu web. Ewentualne obejście w module wtyczki powinno skoordynować domknięcie komunikatu i nawigację; nie należy globalnie tłumić unhandledrejection.
Brak obrazu na TVP1/TVP2/TVN nadal ma odrębne dowody: produkcyjne logi końcowego E2E zawierają POST PlaybackInfo z InvalidOperationException po HTTP502 z relay, a wcześniejsze odczyty bezpośrednie i diagnostyka odbiornika wykazały brak lub niewłaściwy program/transponder. Nie jest to błąd logowania do konfiguracji ani sam komunikat undefined. Odtwarzanie 4Fun, native OSD, współdzielenie i zwolnienie źródła pozostają potwierdzone wynikami E2E. Aktualne próby sterowania SCR są prowadzone wyłącznie przez zadanie odbiornika; ich wynik nie jest zakładany z góry.
## Skoordynowana próba ciągłego odbioru
`test-reception-trial.cjs` uruchamia się dopiero po wyraźnej wiadomości zadania odbiornika, że zaczyna się uzgodnione okno. Wymaga flagi `--coordinated`; bez niej kończy się przed logowaniem i otwarciem TV. Domyślnie TVP 1 HD: pierwszy klient od pierwszej rzeczywistej klatki przez 900 s, drugi dołącza po 300 s i kończy po 600 s. Parametry `--duration`, `--join`, `--leave` podaje się w sekundach i zmienia wyłącznie zgodnie z ustalonym oknem. Przykład po uzgodnieniu: `node test-reception-trial.cjs --coordinated --report=reception-trial.json`.
Co 5 s zapisuje postęp klatek i zegara, bufor, stan odtwarzacza, zdarzenia przerw i dostępny licznik zdekodowanego audio. Co około 30 s odczytuje administracyjny status wtyczki i listę istniejących klientów OpenWebif. Nie otwiera dodatkowej transmisji diagnostycznej. Wyniki JSON/JSONL rozróżniają jednoczesną liczbę fizycznych połączeń od globalnego licznika otwarć. `Consumers` nie jest utożsamiany z liczbą przeglądarek, ponieważ Jellyfin może współdzielić również własny pipeline. Brak licznika audio jest oznaczony UNVERIFIED; same metadane nie potwierdzają słyszalnego dźwięku.
Próba używa dwóch własnych kont, zamyka ich sesje i usuwa konta oraz preferencje w cleanup; po zakończeniu czeka 20 s na zwolnienie relay. Nie wymaga globalnego zera źródeł, jeżeli inni użytkownicy oglądają TV. Nie przełącza kanałów, nie zapisuje konfiguracji, nie tworzy nagrań i nie restartuje usług. Przy pierwszym nieudanym starcie kończy się z FAIL bez samodzielnego ponawiania odbioru.
+182
View File
@@ -0,0 +1,182 @@
// 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');
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, 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, 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();
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'] });
const primary = await makeViewer(1), secondary = await makeViewer(2);
ssh(`c=next(x for x in api('/LiveTv/Channels?Limit=1000')['Items'] if x['Name']==${JSON.stringify(channel)});print('{}')`);
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);
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;
}
})();