Start decoder TV channels with one click in Jellyfin Web

This commit is contained in:
2026-10-11 13:39:25 +02:00
parent a049a63d7a
commit 92052d588f
9 changed files with 140 additions and 10 deletions
@@ -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 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 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 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'; 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 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(){ function render(){
@@ -36,7 +37,7 @@
q('channelRows').replaceChildren();q('channelMessage').textContent=visible.length+' / '+channels.length+' kanałów'; 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'; 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);} 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();}})); 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); 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); events.forEach(p=>{const card=document.createElement('div');card.style.cssText='max-width:22em;cursor:pointer';card.onclick=()=>details(p);
@@ -2,8 +2,8 @@
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks> <TargetFrameworks>net10.0</TargetFrameworks>
<AssemblyVersion>7.0.0.0</AssemblyVersion> <AssemblyVersion>7.0.1.0</AssemblyVersion>
<FileVersion>7.0.0.0</FileVersion> <FileVersion>7.0.1.0</FileVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net6.0|AnyCPU'">
+10 -1
View File
@@ -1,6 +1,6 @@
# Enigma2 dla Jellyfin 12.2 # 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 ## 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`. 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. 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.
+3 -3
View File
@@ -15,12 +15,12 @@ if config.exists():shutil.copy2(config,backup/config.name)
web_config=pathlib.Path('/usr/share/jellyfin/web/config.json') web_config=pathlib.Path('/usr/share/jellyfin/web/config.json')
shutil.copy2(web_config,backup/'web-config.json') shutil.copy2(web_config,backup/'web-config.json')
old=[p for p in plugins.glob('Enigma2_*') if p.is_dir()] 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) subprocess.run(['systemctl','stop','jellyfin'],check=True)
try: try:
for folder in old:shutil.move(str(folder),str(backup/folder.name)) 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') 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)) (new/'meta.json').write_text(json.dumps(metadata))
subprocess.run(['chown','-R','jellyfin:jellyfin',str(new)],check=True) subprocess.run(['chown','-R','jellyfin:jellyfin',str(new)],check=True)
settings=json.loads(web_config.read_text());links=settings.setdefault('menuLinks',[]) 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)) if (backup/folder.name).exists():shutil.move(str(backup/folder.name),str(folder))
raise raise
finally:subprocess.run(['systemctl','start','jellyfin'],check=True) 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)}))
+27
View File
@@ -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 = '<script defer="defer" src="decoder-channel-playback.js"></script>'
if tag not in html:
html = html.replace('<head>', '<head>' + 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)}))
+9 -2
View File
@@ -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; const user=JSON.parse(ssh(setup));let browser;
try{ try{
browser=await chromium.launch({headless:true,channel:'msedge'});const page=await browser.newPage({viewport:{width:1280,height:900}}); 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'; 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.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.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('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.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.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('; ')); 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{ }finally{
if(browser)await browser.close(); 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')`); 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')`);
+33
View File
@@ -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});
+1 -1
View File
@@ -9,7 +9,7 @@ def api(path,data=None,method=None,token=admin):
body=response.read();return json.loads(body) if body else None body=response.read();return json.loads(body) if body else None
report={'checked_utc':datetime.datetime.now(datetime.timezone.utc).isoformat()} report={'checked_utc':datetime.datetime.now(datetime.timezone.utc).isoformat()}
report['plugin']=next(x for x in api('/Plugins') if x['Name']=='Enigma2') 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['epg']=api('/DecoderIntegration/Status')
report['channel_count']=api('/LiveTv/Channels?Limit=0')['TotalRecordCount'] report['channel_count']=api('/LiveTv/Channels?Limit=0')['TotalRecordCount']
report['timer_count']=api('/LiveTv/Timers?Limit=0')['TotalRecordCount'] report['timer_count']=api('/LiveTv/Timers?Limit=0')['TotalRecordCount']
+53
View File
@@ -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);
}
};