From 5c10d8b9b86002ed541487e5cdd3d9211c5a2e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Mon, 12 Oct 2026 23:30:19 +0200 Subject: [PATCH] Add coordinated continuous browser reception trial --- README.integration.md | 10 ++- test-reception-trial.cjs | 182 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 test-reception-trial.cjs diff --git a/README.integration.md b/README.integration.md index b4be5bc..92788ed 100644 --- a/README.integration.md +++ b/README.integration.md @@ -98,4 +98,12 @@ 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. \ No newline at end of file +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. diff --git a/test-reception-trial.cjs b/test-reception-trial.cjs new file mode 100644 index 0000000..1533b78 --- /dev/null +++ b/test-reception-trial.cjs @@ -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').replace(/(https?:\/\/[^\s?"']+)\?[^\s"']*/g, '$1?').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; + } +})();