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);
+ }
+};