Files
jellyfin-plugin-enigma2/diagnose-web.cjs

90 lines
11 KiB
JavaScript

// No receiver streams: playback failures are injected into the browser request only.
const fs=require('node:fs');
const {execFileSync}=require('node:child_process');
const {chromium}=require('C:/Users/michal/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const ssh=code=>JSON.parse(execFileSync('ssh',['-o','BatchMode=yes','root@192.168.100.25','pct exec 100191 -- python3 -'],{input:code,encoding:'utf8',timeout:30000}).trim().split('\n').at(-1));
const admin=`import sqlite3,json,urllib.request,secrets,pathlib
c=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()
def api(p,d=None,method=None):
r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="Analysis", Device="Maintenance", DeviceId="decoder-analysis", Version="1"','Content-Type':'application/json'},data=json.dumps(d).encode() if d is not None else None,method=method)
b=urllib.request.urlopen(r,timeout=15).read();return json.loads(b) if b else None
`;
const origin='http://192.168.100.191:8096',web=origin+'/web/index.html';
const report={checked_utc:new Date().toISOString(),receiver_connections_opened_by_test:0,configuration:[],playback:[]};
const users=[];let browser;
const safe=s=>String(s).replace(/([?&](?:api_key|AccessToken)=)[^&\s]+/gi,'$1<redacted>').slice(0,1800);
async function login(page,u){await page.goto(web);await page.locator('#txtManualName').fill(u.name);await page.locator('#txtManualPassword').fill(u.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.waitForTimeout(1000);}
async function traceBridge(page){
const source=fs.readFileSync(__dirname+'/web/decoder-channel-playback.js','utf8')
.replace('await playbackManager.stop();',`await Promise.resolve(playbackManager.stop()).then(()=>{(window.__leaveTrace??=[]).push('stop resolved');},e=>{(window.__leaveTrace??=[]).push('stop rejected: '+String(e));throw e;});`)
.replace("await appRouter.show('livetv?tab=2');",`await Promise.resolve(appRouter.show('livetv?tab=2')).then(()=>{(window.__leaveTrace??=[]).push('route resolved');},e=>{(window.__leaveTrace??=[]).push('route rejected: '+String(e));throw e;});`);
await page.route('**/decoder-channel-playback.js',r=>r.fulfill({status:200,contentType:'application/javascript',body:source}));
}
function create(isAdmin){
const u=ssh(admin+`n='decoder-analysis-'+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}))`);users.push(u);
ssh(admin+`api('/Users/${u.id}/Password',{'CurrentPw':'','NewPw':${JSON.stringify(u.password)}})
u=api('/Users/${u.id}');p=u['Policy'];p['IsAdministrator']=${isAdmin?'True':'False'};api('/Users/${u.id}/Policy',p)
print('{}')`);return u;
}
(async()=>{
try{
browser=await chromium.launch({headless:true,channel:'msedge'});
const elevated=create(true),ordinary=create(false);
for(const bridge of [true,false]){
const context=await browser.newContext(),page=await context.newPage();const responses=[],errors=[];
if(!bridge)await page.route('**/decoder-channel-playback.js',r=>r.fulfill({status:200,contentType:'application/javascript',body:'window.DecoderChannelPlayback = async () => class { constructor({playbackManager,appRouter}) { this.id="decoder-channel-playback";this.type="integration";window.__diagnosticPlayer=playbackManager;window.__diagnosticRouter=appRouter; } };'}));
page.on('pageerror',e=>errors.push(safe(e.stack||e.message)));
page.on('response',r=>{const path=new URL(r.url()).pathname;if(/ConfigurationPage|\/Plugins\//i.test(path))responses.push({path,status:r.status()});});
await login(page,elevated);await page.evaluate(()=>location.hash='#/configurationpage?name=enigma2');
let opened=false;try{await page.locator('#txtHostName').waitFor({timeout:12000});await page.waitForFunction(()=>document.querySelector('#txtHostName')?.value==='192.168.10.20',{},{timeout:12000});opened=true;}catch{}
const state=await page.evaluate(()=>({route:location.hash,login_fields:document.querySelectorAll('#txtManualPassword').length,configuration_fields:document.querySelectorAll('#txtHostName').length}));
let values;if(opened){values={host:await page.locator('#txtHostName').inputValue(),port:await page.locator('#txtStreamingPort').inputValue(),vu_recordings:await page.locator('#chkEnableVuRecordings').isChecked()};}
report.configuration.push({bridge,administrator_before_initial_login:true,opened,state,values,responses,errors});console.log('configuration '+bridge+': '+opened);await context.close();
}
for(const bridge of [true,false]){
const context=await browser.newContext(),page=await context.newPage();const errors=[];let injected=0;
if(bridge)await traceBridge(page);
if(!bridge)await page.route('**/decoder-channel-playback.js',r=>r.fulfill({status:200,contentType:'application/javascript',body:'window.DecoderChannelPlayback = async () => class { constructor({playbackManager,appRouter}) { this.id="decoder-channel-playback";this.type="integration";window.__diagnosticPlayer=playbackManager;window.__diagnosticRouter=appRouter; } };'}));
await page.addInitScript(()=>{window.__rejections=[];window.addEventListener('unhandledrejection',e=>window.__rejections.push({type:typeof e.reason,message:String(e.reason?.message||e.reason),stack:e.reason?.stack||null}));});
page.on('pageerror',e=>errors.push(safe(e.stack||e.message)));
await page.route('**/*PlaybackInfo*',r=>{++injected;return r.fulfill({status:500,contentType:'application/json',body:JSON.stringify({Message:'Diagnostic unavailable source',StatusCode:500})});});
// Defense in depth: an unexpected live-open request is also blocked locally.
await page.route('**/LiveTv/LiveStreams/Open*',r=>r.fulfill({status:503,body:'Diagnostic: blocked live open'}));
await login(page,ordinary);await page.goto(web+'#/livetv?tab=2');await page.locator('.card[data-type="TvChannel"]').first().waitFor({timeout:15000});
const card=page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:/^\d+ TVP 1 HD$/})});
if(bridge)await card.locator('a.cardImageContainer').click({position:{x:20,y:20}});
else {
// Start the same native player directly; the bridge is the only excluded layer.
await page.evaluate(async()=>{const Plugin=await window.DecoderChannelPlayback();window.__nativeStart=Plugin;});
const play=card.locator('[data-action="play"]');
if(await play.count())await play.first().click({force:true});else await card.locator('a.cardImageContainer').click();
if(await page.getByRole('button',{name:'Play',exact:true}).count())await page.getByRole('button',{name:'Play',exact:true}).first().click();
}
await page.waitForTimeout(5000);
if(bridge&&await page.locator('#decoderPlaybackExit').count()) {await page.locator('#decoderPlaybackExit').click();await page.waitForTimeout(1000);}
if(!bridge) {await page.evaluate(async()=>{try{await window.__diagnosticPlayer.stop();}catch{}try{await window.__diagnosticRouter.show('livetv?tab=2');}catch{}});await page.waitForTimeout(1000);}
const rejected=await page.evaluate(()=>window.__rejections);report.playback.push({bridge,injected_failures:injected,errors,rejected:rejected.map(r=>({...r,stack:r.stack?safe(r.stack):null})),leave_trace:await page.evaluate(()=>window.__leaveTrace||[])});console.log('failure '+bridge+': '+errors.length);await context.close();
}
const context=await browser.newContext(),page=await context.newPage();let injected=0;const errors=[];
await page.addInitScript(()=>{window.__rejections=[];window.addEventListener('unhandledrejection',e=>window.__rejections.push({type:typeof e.reason,message:String(e.reason?.message||e.reason),stack:e.reason?.stack||null}));});
page.on('pageerror',e=>errors.push(safe(e.stack||e.message)));
await page.route('**/*PlaybackInfo*',async r=>{++injected;await new Promise(resolve=>setTimeout(resolve,10000));try{await r.fulfill({status:500,body:'Diagnostic unavailable source'});}catch{}});
await page.route('**/LiveTv/LiveStreams/Open*',r=>r.fulfill({status:503,body:'Diagnostic: blocked live open'}));
await login(page,ordinary);await page.goto(web+'#/livetv?tab=2');await page.locator('.card[data-type="TvChannel"]').first().waitFor();
await page.locator('.card[data-type="TvChannel"]').first().locator('a.cardImageContainer').click({position:{x:20,y:20}});await page.locator('#decoderPlaybackExit').waitFor();
await page.waitForTimeout(1000);await page.locator('#decoderPlaybackExit').click();await page.waitForTimeout(12000);
report.playback.push({bridge:true,scenario:'cancel pending playback request',injected_failures:injected,errors,rejected:await page.evaluate(()=>window.__rejections),final_route:new URL(page.url()).hash});await context.close();
const emptyContext=await browser.newContext(),empty=await emptyContext.newPage();const emptyErrors=[];let emptyResponses=0;
empty.on('pageerror',e=>emptyErrors.push(safe(e.stack||e.message)));
await empty.route('**/*PlaybackInfo*',r=>{++emptyResponses;return r.fulfill({status:200,contentType:'application/json',body:JSON.stringify({MediaSources:[],PlaySessionId:'00000000000000000000000000000000',ErrorCode:'NoCompatibleStream'})});});
await empty.route('**/LiveTv/LiveStreams/Open*',r=>r.fulfill({status:503,body:'Diagnostic: blocked live open'}));
await login(empty,ordinary);await empty.goto(web+'#/livetv?tab=2');await empty.locator('.card[data-type="TvChannel"]').first().waitFor();await empty.locator('.card[data-type="TvChannel"]').first().locator('a.cardImageContainer').click({position:{x:20,y:20}});await empty.waitForTimeout(5000);
if(await empty.locator('#decoderPlaybackExit').count())await empty.locator('#decoderPlaybackExit').click();await empty.waitForTimeout(1000);
report.playback.push({bridge:true,scenario:'empty media sources and exit',injected_failures:emptyResponses,errors:emptyErrors,final_route:new URL(empty.url()).hash});await emptyContext.close();
}catch(e){report.error=safe(e.stack||e.message);}
finally{
if(browser)await browser.close();for(const u of users)ssh(admin+`api('/Users/${u.id}',method='DELETE');pathlib.Path('/var/lib/jellyfin/decoder-integration/users/${u.id.replace(/-/g,'')}.json').unlink(missing_ok=True);print('{}')`);
report.finished_utc=new Date().toISOString();fs.writeFileSync(__dirname+'/web-analysis-20261012.json',JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report));
}
})();