From 92052d588f01ac15db63f93d202d99713c3cf1f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Kacprzak?= Date: Sun, 11 Oct 2026 13:39:25 +0200 Subject: [PATCH] Start decoder TV channels with one click in Jellyfin Web --- .../Configuration/channels.html | 3 +- .../Jellyfin.Plugin.Enigma2.csproj | 4 +- README.integration.md | 11 +++- deploy.py | 6 +-- install-web-playback.py | 27 ++++++++++ test-browser.cjs | 11 +++- test-native-playback.cjs | 33 ++++++++++++ verify.py | 2 +- web/decoder-channel-playback.js | 53 +++++++++++++++++++ 9 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 install-web-playback.py create mode 100644 test-native-playback.cjs create mode 100644 web/decoder-channel-playback.js diff --git a/Jellyfin.Plugin.Enigma2/Configuration/channels.html b/Jellyfin.Plugin.Enigma2/Configuration/channels.html index 499e74c..1454be6 100644 --- a/Jellyfin.Plugin.Enigma2/Configuration/channels.html +++ b/Jellyfin.Plugin.Enigma2/Configuration/channels.html @@ -26,6 +26,7 @@ function save(){const snapshot=JSON.parse(JSON.stringify(preferences));saving=saving.catch(()=>{}).then(()=>request('DecoderIntegration/Preferences','PUT',snapshot));saving.catch(()=>q('channelMessage').textContent='Nie udało się zapisać ustawień.');} function button(text,action){const b=document.createElement('button');b.textContent=text;b.className='raised emby-button';b.onclick=action;return b;} function details(item){location.href=base+'/web/index.html#/details?id='+encodeURIComponent(item.Id)+'&serverId='+encodeURIComponent(ApiClient.serverId());} + function playChannel(item){location.href=base+'/web/index.html#/livetv?decoderPlay='+encodeURIComponent(item.Id)+'&serverId='+encodeURIComponent(ApiClient.serverId());} q('nativeGuide').href=base+'/web/index.html#/livetv'; function toggle(field,id){const a=preferences[field],i=a.indexOf(id);if(i<0)a.push(id);else a.splice(i,1);if(field==='FavoriteIds')request('Users/'+ApiClient.getCurrentUserId()+'/FavoriteItems/'+id,i<0?'POST':'DELETE').catch(()=>q('channelMessage').textContent='Nie udało się zsynchronizować ulubionych z Jellyfinem.');save();render();} function render(){ @@ -36,7 +37,7 @@ q('channelRows').replaceChildren();q('channelMessage').textContent=visible.length+' / '+channels.length+' kanałów'; visible.forEach(c=>{const id=key(c.Id),row=document.createElement('section');row.style.cssText='padding:1em 0;border-bottom:1px solid #666;display:flex;gap:1em;flex-wrap:wrap;align-items:center'; if(c.ImageTags?.Primary){const image=document.createElement('img');image.src=ApiClient.getImageUrl(c.Id,{type:'Primary',tag:c.ImageTags.Primary,maxWidth:100});image.alt='';image.width=80;image.loading='lazy';row.append(image);} - row.append(button((c.Number?c.Number+'. ':'')+c.Name,()=>details(c)),button(fav.has(id)?'★ Ulubiony':'☆ Ulubiony',()=>toggle('FavoriteIds',id)),button(hidden.has(id)?'Pokaż':'Ukryj',()=>toggle('HiddenIds',id))); + row.append(button((c.Number?c.Number+'. ':'')+c.Name,()=>playChannel(c)),button('Szczegóły',()=>details(c)),button(fav.has(id)?'★ Ulubiony':'☆ Ulubiony',()=>toggle('FavoriteIds',id)),button(hidden.has(id)?'Pokaż':'Ukryj',()=>toggle('HiddenIds',id))); row.append(button('↑',()=>{const list=sorted.map(x=>key(x.Id)),i=list.indexOf(id);if(i>0){[list[i-1],list[i]]=[list[i],list[i-1]];preferences.OrderedIds=list;save();render();}})); const events=view==='guide'?(programs[c.Id]||[]):[c.CurrentProgram].filter(Boolean); events.forEach(p=>{const card=document.createElement('div');card.style.cssText='max-width:22em;cursor:pointer';card.onclick=()=>details(p); diff --git a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj index 1b5a670..26c62f9 100644 --- a/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj +++ b/Jellyfin.Plugin.Enigma2/Jellyfin.Plugin.Enigma2.csproj @@ -2,8 +2,8 @@ net10.0 - 7.0.0.0 - 7.0.0.0 + 7.0.1.0 + 7.0.1.0 diff --git a/README.integration.md b/README.integration.md index 1955735..159fcb6 100644 --- a/README.integration.md +++ b/README.integration.md @@ -1,6 +1,6 @@ # Enigma2 dla Jellyfin 12.2 -Lokalne rozszerzenie wtyczki Enigma2 6.0.0.0, wersja 7.0.0.0. Zachowuje identyfikator wtyczki, nazwę usługi, konfigurację połączenia, natywne strumienie, nagrania i timery dekodera. Profile transkodowania pozostają na dekoderze. +Lokalne rozszerzenie wtyczki Enigma2 6.0.0.0, wersja 7.0.1.0. Zachowuje identyfikator wtyczki, nazwę usługi, konfigurację połączenia, natywne strumienie, nagrania i timery dekodera. Profile transkodowania pozostają na dekoderze. ## Funkcje @@ -52,3 +52,12 @@ Jellyfin w CT100191 zaktualizowano z 10.11.6 do **12.2.0**, FFmpeg do **8.1.3**. SSO autora nie ma jeszcze paczki ABI 12. Przygotowano lokalny build 4.0.0.4 z upstream `f1e157bac29e6d27a0844378f3e1d4a4fdaa577a`, .NET10 i zależnościami Jellyfin12.2. Usunięto starą referencję XML cryptography6, przypięto Pkcs10; build bez ostrzeżeń i błędów. Reprodukcja bez instalacji: `python3 compat/build-sso12.py` na Linuxie z Dockerem. Oficjalne dodatki: DLNA14, OpenSubtitles25, TVmaze14, Webhook22, SessionCleaner6. Kopia starych paczek: `/var/lib/jellyfin/decoder-plugin-backups/upgrade12-20261011T110701Z`. Enigma7 aktywna. Obsługa nagrań Vu wyłączona w konfiguracji, lista dostawców `/Channels` pusta. Testy konsolowe obejmują także rejestrację przed utworzeniem Plugin.Instance i odpowiedzi przy Accept-Encoding gzip/br. Test API: preferencje dwóch kont, natywna lista/paginacja, uprawnienia administracyjne. Test Edge headless: 216 kanałów, ulubione, ukrywanie/przywracanie, EPG, brak błędów JS. Po aktualizacji uruchomiono pełny skan bibliotek i odświeżanie EPG. + + +## Odtwarzanie jednym kliknięciem + +Wersja 7.0.1.0: kliknięcie nazwy kanału na stronie dodatku uruchamia natywny odtwarzacz Jellyfin Web. Osobny przycisk „Szczegóły” zachowuje dostęp do informacji i EPG. `web/decoder-channel-playback.js` korzysta ze wspieranego API dodatków window w Jellyfin Web (PluginManager przekazuje playbackManager). Kanał jest sprawdzany przez uwierzytelnione API użytkownika i musi mieć typ TvChannel. Grafiki przyszłych audycji nadal otwierają szczegóły. + +Po skopiowaniu skryptu do `/tmp/decoder-channel-playback.js` uruchomić `install-web-playback.py` w CT100191; instalator zachowuje kopie index/config i wpisuje dodatek do istniejącej listy plugins. Po aktualizacji pakietu jellyfin-web trzeba ponownie uruchomić instalator. W otwartej przeglądarce wykonać Ctrl+Shift+R. Działanie dotyczy Jellyfin Web; natywne aplikacje Android TV mają własne sterowanie. Test Edge: wybór TVP INFO HD jednym kliknięciem, odpowiedź PlaybackInfo 200 i odtwarzanie video z rosnącym currentTime, bez przycisku Play. + +Test `test-native-playback.cjs`: kliknięcie narożnika grafiki kanału TVP INFO HD w natywnym `/livetv?tab=2`, bez trafienia w przycisk Play, uruchamia wideo (PASS). Stanowisko: izolowany Edge headless, konto testowe usuwane w finally. diff --git a/deploy.py b/deploy.py index 8163d58..fe946d0 100644 --- a/deploy.py +++ b/deploy.py @@ -15,12 +15,12 @@ if config.exists():shutil.copy2(config,backup/config.name) web_config=pathlib.Path('/usr/share/jellyfin/web/config.json') shutil.copy2(web_config,backup/'web-config.json') old=[p for p in plugins.glob('Enigma2_*') if p.is_dir()] -new=plugins/'Enigma2_7.0.0.0' +new=plugins/'Enigma2_7.0.1.0' subprocess.run(['systemctl','stop','jellyfin'],check=True) try: for folder in old:shutil.move(str(folder),str(backup/folder.name)) new.mkdir();shutil.copy2('/tmp/Jellyfin.Plugin.Enigma2.dll',new/'Jellyfin.Plugin.Enigma2.dll') - metadata={'category':'Live TV','description':'Enigma2 with internet EPG artwork and per-user channel preferences','guid':'193f29f9-ea6c-4595-a6f6-55e79d7c590a','name':'Enigma2','overview':'Enigma2 decoder integration','owner':'local','status':'Active','targetAbi':'12.2.0.0','version':'7.0.0.0'} + metadata={'category':'Live TV','description':'Enigma2 with internet EPG artwork and per-user channel preferences','guid':'193f29f9-ea6c-4595-a6f6-55e79d7c590a','name':'Enigma2','overview':'Enigma2 decoder integration','owner':'local','status':'Active','targetAbi':'12.2.0.0','version':'7.0.1.0'} (new/'meta.json').write_text(json.dumps(metadata)) subprocess.run(['chown','-R','jellyfin:jellyfin',str(new)],check=True) settings=json.loads(web_config.read_text());links=settings.setdefault('menuLinks',[]) @@ -33,4 +33,4 @@ except Exception: if (backup/folder.name).exists():shutil.move(str(backup/folder.name),str(folder)) raise finally:subprocess.run(['systemctl','start','jellyfin'],check=True) -print(json.dumps({'installed':'7.0.0.0','backup':str(backup)})) +print(json.dumps({'installed':'7.0.1.0','backup':str(backup)})) diff --git a/install-web-playback.py b/install-web-playback.py new file mode 100644 index 0000000..f38d77c --- /dev/null +++ b/install-web-playback.py @@ -0,0 +1,27 @@ +"""Run in CT100191 after staging decoder-channel-playback.js in /tmp. No restart needed.""" +import datetime +import json +import pathlib +import shutil + +web = pathlib.Path('/usr/share/jellyfin/web') +backup = pathlib.Path('/var/lib/jellyfin/decoder-plugin-backups') / ('web-playback-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')) +backup.mkdir(mode=0o700) +for name in ['index.html', 'config.json']: + shutil.copy2(web / name, backup / name) +script = web / 'decoder-channel-playback.js' +if script.exists(): + shutil.copy2(script, backup / script.name) +shutil.copy2('/tmp/decoder-channel-playback.js', script) +html = (web / 'index.html').read_text() +tag = '' +if tag not in html: + html = html.replace('', '' + tag, 1) + assert tag in html + (web / 'index.html').write_text(html) +config = json.loads((web / 'config.json').read_text()) +plugins = config.setdefault('plugins', []) +if 'DecoderChannelPlayback' not in plugins: + plugins.append('DecoderChannelPlayback') +(web / 'config.json').write_text(json.dumps(config, indent=2) + '\n') +print(json.dumps({'web_playback_installed': True, 'backup': str(backup)})) diff --git a/test-browser.cjs b/test-browser.cjs index 8e2b008..4d6f0fa 100644 --- a/test-browser.cjs +++ b/test-browser.cjs @@ -12,7 +12,7 @@ n='decoder-browser-check-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=ap const user=JSON.parse(ssh(setup));let browser; try{ browser=await chromium.launch({headless:true,channel:'msedge'});const page=await browser.newPage({viewport:{width:1280,height:900}}); - const errors=[];page.on('pageerror',e=>errors.push(e.message)); + const errors=[];page.on('console',m=>{if(m.type()==='error'&&m.text().includes('Decoder'))console.log(m.text());});page.on('pageerror',e=>errors.push(e.message)); const base='http://192.168.100.191:8096/web/index.html'; await page.goto(base);await page.locator('#txtManualName').fill(user.name);await page.locator('#txtManualPassword').fill(user.password);await page.getByRole('checkbox').check();await page.getByRole('button',{name:'Sign In',exact:true}).click(); await page.waitForURL(/home/,{timeout:20000}); @@ -28,8 +28,15 @@ n='decoder-browser-check-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=ap await page.locator('#channelView').selectOption('all');await page.getByRole('button',{name:'Ustaw ten widok jako domyślny'}).click(); await page.locator('#channelView').selectOption('guide');await page.locator('#channelRows section div p').first().waitFor({timeout:60000}); await page.screenshot({path:process.argv[2]||'decoder-channels.png'}); + await page.locator('#channelView').selectOption('all'); + const row=page.locator('#channelRows section').filter({has:page.getByRole('button',{name:/^\d+\. TVP INFO HD$/})}); + const playback=page.waitForResponse(r=>r.url().includes('/PlaybackInfo')&&r.request().method()==='POST',{timeout:60000}); + await row.locator('button').first().click(); + const response=await playback;if(!response.ok())throw Error('Native playback request failed: '+response.status()); + await page.waitForFunction(()=>[...document.querySelectorAll('video')].some(v=>v.currentTime>0&&v.readyState>=2),{},{timeout:60000}); + await page.screenshot({path:(process.argv[2]||'decoder-channels.png').replace('.png','-playing.png')}); if(errors.length)throw Error(errors.join('; ')); - console.log(JSON.stringify({channel_rows:total,favorites:'PASS',hidden:'PASS',user_page:'PASS',browser_errors:errors})); + console.log(JSON.stringify({channel_rows:total,favorites:'PASS',hidden:'PASS',user_page:'PASS',one_click_live_playback:'PASS',browser_errors:errors})); }finally{ if(browser)await browser.close(); ssh(`import sqlite3,urllib.request,pathlib\nc=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()\nurllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Users/${user.id}',headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'},method='DELETE')).read()\npathlib.Path('/var/lib/jellyfin/decoder-integration/users/${user.id.replace(/-/g,'')}.json').unlink(missing_ok=True)\nprint('clean')`); diff --git a/test-native-playback.cjs b/test-native-playback.cjs new file mode 100644 index 0000000..202df65 --- /dev/null +++ b/test-native-playback.cjs @@ -0,0 +1,33 @@ +// Isolated headless browser; temporary server account is always removed. +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=py=>execFileSync('ssh',['-o','BatchMode=yes','root@192.168.100.25','pct exec 100191 -- python3 -'],{input:py,encoding:'utf8'}).trim().split('\n').at(-1); +const setup=`import sqlite3,json,secrets,urllib.request +c=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close() +def api(p,d): + r=urllib.request.Request('http://127.0.0.1:8096'+p,headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"','Content-Type':'application/json'},data=json.dumps(d).encode());b=urllib.request.urlopen(r).read();return json.loads(b) if b else None +n='decoder-browser-check-'+secrets.token_hex(4);p=secrets.token_urlsafe(24);u=api('/Users/New',{'Name':n});api('/Users/'+u['Id']+'/Password',{'CurrentPw':'','NewPw':p});print(json.dumps({'id':u['Id'],'name':n,'password':p})) +`; +(async()=>{ + const user=JSON.parse(ssh(setup));let browser; + try{ + browser=await chromium.launch({headless:true,channel:'msedge'});const page=await browser.newPage({viewport:{width:1280,height:900}}); + const errors=[];page.on('pageerror',e=>errors.push(e.message)); + const base='http://192.168.100.191:8096/web/index.html'; + await page.goto(base);await page.locator('#txtManualName').fill(user.name);await page.locator('#txtManualPassword').fill(user.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.goto('http://192.168.100.191:8096/DecoderIntegration/Channels'); + try { await page.locator('#channelRows section').first().waitFor({timeout:30000}); } + catch(error) { console.log(JSON.stringify({url:page.url(),text:await page.locator('body').innerText(),errors})); await page.screenshot({path:process.argv[2]||'decoder-channels.png'});throw error; } + await page.goto(base+'#/livetv?tab=2'); + const image=page.locator('.card[data-type="TvChannel"]').filter({has:page.locator('a.textActionButton',{hasText:'TVP INFO HD'})}).locator('a.cardImageContainer'); + const playback=page.waitForResponse(r=>r.url().includes('/PlaybackInfo')&&r.request().method()==='POST',{timeout:60000});playback.catch(()=>{}); + await image.click({position:{x:20,y:20}}).catch(async e=>{console.log(JSON.stringify({image_count:await image.count(),error:e.message.slice(0,600)}));throw e;});const r=await playback;if(!r.ok())throw Error('Native channel playback failed'); + await page.waitForFunction(()=>[...document.querySelectorAll('video')].some(v=>v.currentTime>0&&v.readyState>=2),{},{timeout:60000}); + console.log(JSON.stringify({native_channel_image_one_click_playback:'PASS'})); + }finally{ + if(browser)await browser.close(); + ssh(`import sqlite3,urllib.request,pathlib\nc=sqlite3.connect('file:/var/lib/jellyfin/data/jellyfin.db?mode=ro',uri=True);k=c.execute('SELECT AccessToken FROM ApiKeys ORDER BY Id LIMIT 1').fetchone()[0];c.close()\nurllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8096/Users/${user.id}',headers={'Authorization':'MediaBrowser Token="'+k+'", Client="DecoderIntegration", Device="Maintenance", DeviceId="decoder-maintenance", Version="7"'},method='DELETE')).read()\npathlib.Path('/var/lib/jellyfin/decoder-integration/users/${user.id.replace(/-/g,'')}.json').unlink(missing_ok=True)\nprint('clean')`); + } +})().catch(e=>{console.error(e.message);process.exitCode=1}); diff --git a/verify.py b/verify.py index 6fa5716..b434bc0 100644 --- a/verify.py +++ b/verify.py @@ -9,7 +9,7 @@ def api(path,data=None,method=None,token=admin): body=response.read();return json.loads(body) if body else None report={'checked_utc':datetime.datetime.now(datetime.timezone.utc).isoformat()} report['plugin']=next(x for x in api('/Plugins') if x['Name']=='Enigma2') -assert report['plugin']['Version']=='7.0.0.0' and report['plugin']['Status']=='Active' +assert report['plugin']['Version']=='7.0.1.0' and report['plugin']['Status']=='Active' report['epg']=api('/DecoderIntegration/Status') report['channel_count']=api('/LiveTv/Channels?Limit=0')['TotalRecordCount'] report['timer_count']=api('/LiveTv/Timers?Limit=0')['TotalRecordCount'] diff --git a/web/decoder-channel-playback.js b/web/decoder-channel-playback.js new file mode 100644 index 0000000..15b27b6 --- /dev/null +++ b/web/decoder-channel-playback.js @@ -0,0 +1,53 @@ +// Jellyfin Web's supported window plugin API supplies the native playback manager. +window.DecoderChannelPlayback = async () => class DecoderChannelPlayback { + constructor({ playbackManager, toast }) { + this.id = 'decoder-channel-playback'; + this.name = 'Decoder channel playback'; + this.type = 'integration'; + const play = async (id, serverId) => { + const api = window.ApiClient; + if (!api?.getCurrentUserId() || (serverId && api.serverId() !== serverId)) return false; + const item = await api.getItem(api.getCurrentUserId(), id); + if (item.Type !== 'TvChannel') throw Error('Expected a TV channel'); + await playbackManager.play({ items: [item] }); + return true; + }; + let pending; + const handleRoute = () => { + clearTimeout(pending); + const route = location.hash; + const query = new URLSearchParams(route.split('?')[1] || ''); + const id = query.get('decoderPlay'); + if (!id || !/^[a-f\d]{32}$|^[a-f\d-]{36}$/i.test(id)) return; + const deadline = Date.now() + 30000; + const attempt = async () => { + if (location.hash !== route) return; + if (!window.ApiClient?.getCurrentUserId()) { + if (Date.now() < deadline) pending = setTimeout(attempt, 250); + return; + } + query.delete('decoderPlay'); + history.replaceState(null, '', location.pathname + location.search + route.split('?')[0] + (query.size ? '?' + query : '')); + try { await play(id, query.get('serverId')); } + catch (error) { console.error('Decoder channel playback failed', error); toast('Nie udało się uruchomić kanału.'); } + }; + void attempt(); + }; + window.addEventListener('hashchange', handleRoute); + handleRoute(); + // Channel image/name clicks in native channel cards should also play immediately. + document.addEventListener('click', event => { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; + const link = event.target.closest('a.cardImageContainer, a.itemAction.textActionButton'); + const card = link?.closest('[data-type="TvChannel"]'); + if (!card) return; + const id = card.dataset.id; + if (!id) return; + event.preventDefault(); event.stopImmediatePropagation(); + void play(id, card.dataset.serverid).catch(error => { + console.error('Decoder channel playback failed', error); + toast('Nie udało się uruchomić kanału.'); + }); + }, true); + } +};