/* * SysDeck - Jellyfin Panel (v0.0.35) * Author: Jeremy Anderson (https://dcos.net) * * v0.0.35 directive: "next we will integrate a jellyfin management * module where it starts, stops, and loads the admin panel in the * module." Jellyfin ships a single systemd unit (jellyfin.service) * and serves a full admin web UI on http://127.0.0.1:8096. The * bridge starts/stops/restarts the service via systemctl; the panel * iframes the running admin UI — same pattern as the v0.0.34 Glances * integration. * * The panel surfaces: * - Service summary card: status badge, version, port, uptime * - Service controls: Start / Stop / Restart (polkit prompts) * - Admin UI iframe: loads http://127.0.0.1:8096 when running * - Library list: best-effort GET /Library/VirtualFolders * - Install hint when jellyfin is not installed * * Jellyfin is GPL-2.0 licensed by the Jellyfin contributors. The * bridge helper invokes it as a separate process via subprocess — * the suite (MIT) and Jellyfin (GPL-2.0) remain independent * programs. No Jellyfin code is bundled. * * Bridge surface (see shared/bridge.js → bridge.jellyfin): * summary() → {available, status, version, port, url, ...} * status() → service state dict * start() → {action, rc, success, output, stderr} * stop() → same shape * restart() → same shape * webStatus() → {running, url, port, ...} * libraries() → {libraries: [...], count: N} */ export async function mount(panel, { bridge, EventBus }) { panel.innerHTML = renderSkeleton(); const [summary, webStatus, libraries] = await Promise.all([ safe(bridge.jellyfin.summary(), {}), safe(bridge.jellyfin.webStatus(), {}), safe(bridge.jellyfin.libraries(), { libraries: [], count: 0 }), ]); const running = webStatus?.running === true || summary?.status === 'running'; const available = webStatus?.available !== false && summary?.available !== false; const webUrl = webStatus?.url || summary?.url || 'http://127.0.0.1:8096'; panel.innerHTML = `

Jellyfin Media Server

Self-hosted media streaming · movies · TV · music · GPL-2.0 · Jellyfin contributors ${available ? (running ? ` · running` : ` · stopped`) : ` · not installed`}

${renderServiceCard(summary, available, running, webUrl)} ${available && running ? renderWebIframe(webUrl) : ''} ${available ? renderLibrariesCard(libraries) : ''} `; wireEvents(panel, { bridge, EventBus }); EventBus.emit('jellyfin.loaded', { running, available }); } // ── Service card ──────────────────────────────────────────────────── function renderServiceCard(summary, available, running, webUrl) { if (!available) { return `

Jellyfin Service

${escapeHtml(summary?.reason || 'Jellyfin is not installed.')}

${summary?.install ? `

${escapeHtml(summary.install)}

` : ''}

Jellyfin is GPL-2.0 licensed by the Jellyfin contributors — https://jellyfin.org/

`; } const uptime = formatUptime(summary?.uptime_seconds || 0); return `

Service — ${escapeHtml(summary?.service || 'jellyfin.service')}

Status${statusBadge(summary?.status)}
ActiveState${escapeHtml(summary?.active || '—')}
SubState${escapeHtml(summary?.sub || '—')}
Uptime${running ? escapeHtml(uptime) : '—'}
Version${escapeHtml(summary?.version || '—')}
Port${summary?.port || 8096}
URL${escapeHtml(webUrl)}

The bridge runs systemctl start/stop/restart jellyfin.service via the cockpit superuser channel (polkit org.sysdeck.jellyfin.modify).

`; } function renderWebIframe(webUrl) { return `

Admin Panel — ${escapeHtml(webUrl)}

↗ Open in new tab
`; } function renderLibrariesCard(libraries) { const libs = libraries?.libraries || []; if (libraries?.error) { return `

Libraries

${escapeHtml(libraries.error)}

`; } return `

Libraries (${libraries?.count || 0})

${libs.map((lib) => ` `).join('') || ''}
NameTypePaths
${escapeHtml(lib.name)} ${escapeHtml(lib.type || 'mixed')} ${escapeHtml((lib.paths || []).join(' · ') || '—')}
No libraries configured. Open the admin panel above to add media folders.
`; } // ── Event wiring ──────────────────────────────────────────────────── function wireEvents(panel, { bridge, EventBus }) { const action = async (btn, method, label) => { if (!btn) return; btn.disabled = true; const original = btn.textContent; btn.textContent = `${label} ...`; try { const r = await bridge.jellyfin[method](); if (!r?.success && method !== 'restart') { alert(`${label} failed:\n${r?.stderr || r?.output || 'unknown'}`); } setTimeout(() => mount(panel, { bridge, EventBus }), 1000); } catch (err) { btn.disabled = false; btn.textContent = original; alert(`${label} error: ${err.message || err}`); } }; panel.querySelector('#btn-jellyfin-start')?.addEventListener('click', (ev) => action(ev.currentTarget, 'start', 'Start')); panel.querySelector('#btn-jellyfin-stop')?.addEventListener('click', (ev) => action(ev.currentTarget, 'stop', 'Stop')); panel.querySelector('#btn-jellyfin-restart')?.addEventListener('click', (ev) => action(ev.currentTarget, 'restart', 'Restart')); panel.querySelector('#btn-jellyfin-refresh')?.addEventListener('click', () => { mount(panel, { bridge, EventBus }); }); } // ── Utilities ─────────────────────────────────────────────────────── async function safe(p, fallback) { try { const v = await p; return v ?? fallback; } catch { return fallback; } } function statusBadge(status) { const map = { 'running': 'running', 'starting': 'starting', 'stopped': 'stopped', 'error': 'error', 'unknown': 'unknown', }; return map[status] || `${escapeHtml(status || 'unknown')}`; } function formatUptime(seconds) { if (!seconds || seconds <= 0) return '—'; const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const mins = Math.floor((seconds % 3600) / 60); if (days > 0) return `${days}d ${hours}h ${mins}m`; if (hours > 0) return `${hours}h ${mins}m`; return `${mins}m`; } function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function renderSkeleton() { return `
`; }