gba server, jellyfin

This commit is contained in:
Aner Zakobar
2026-08-01 14:59:43 +03:00
parent fc457ddf7c
commit dd30a5935c
4 changed files with 463 additions and 273 deletions
+3 -3
View File
@@ -84,9 +84,9 @@
homey.nextcloud.enable = true; homey.nextcloud.enable = true;
homey.phpldapadmin.enable = true; homey.phpldapadmin.enable = true;
# Media (enable when ready) # Media
homey.jellyfin.enable = false; homey.jellyfin.enable = true;
homey.transmission.enable = false; homey.transmission.enable = true;
# Documents and recipes # Documents and recipes
homey.paperless.enable = true; homey.paperless.enable = true;
+352 -256
View File
@@ -4,15 +4,34 @@
Saves and favourites are keyed by the Remote-User header (set by Authelia Saves and favourites are keyed by the Remote-User header (set by Authelia
via Caddy forward_auth), so each authenticated user gets their own data. via Caddy forward_auth), so each authenticated user gets their own data.
Offline mode: a Service Worker caches static assets and ROMs in the browser Offline mode (PWA): a Service Worker makes the app installable and playable
after first play. Saves made while offline are queued in IndexedDB and synced offline. The design is deliberately *network-first* so online behaviour is
to the server automatically when connectivity is restored. identical to having no Service Worker at all — the cache is only ever read
when the network is genuinely unreachable, so offline support cannot break
online play.
- App shell + EmulatorJS static assets (/static/...) are cached in a
build-scoped cache (`emujs-shell-<BUILD_ID>`). BUILD_ID changes whenever
this file or the EmulatorJS asset path changes, so a redeploy transparently
invalidates stale assets — no manual cache-version bump needed.
- ROMs are large, so they are NOT cached automatically. The launcher has a
per-game "download for offline" button that persists the ROM (and its
thumbnail) in a separate, build-independent cache (`emujs-roms`).
- Saves made while offline are queued in IndexedDB and synced to the server
automatically when connectivity returns.
Note: a game can only be *played* offline if (a) its ROM was downloaded, and
(b) its emulator core was cached — cores are cached automatically the first
time you play any game of that system while online.
""" """
import hashlib
import json import json
import os import os
import re import re
import struct
import urllib.parse import urllib.parse
import zipfile import zipfile
import zlib
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from string import Template from string import Template
@@ -24,6 +43,23 @@ PORT = int(os.environ.get("PORT", "8085"))
SAFE_RE = re.compile(r"^[A-Za-z0-9 _\-\.\(\)\[\]',!?:&+#]+$") SAFE_RE = re.compile(r"^[A-Za-z0-9 _\-\.\(\)\[\]',!?:&+#]+$")
def _compute_build_id() -> str:
"""A short id that changes whenever this server file or the static asset
path changes. Used to name the Service Worker's shell cache so redeploys
invalidate stale cached assets automatically."""
h = hashlib.sha256()
try:
with open(__file__, "rb") as f:
h.update(f.read())
except Exception:
pass
h.update(STATIC_DIR.encode())
return h.hexdigest()[:12]
BUILD_ID = _compute_build_id()
ROM_SYSTEMS = { ROM_SYSTEMS = {
".gb": "gameboy", ".gb": "gameboy",
".gbc": "gameboy_color", ".gbc": "gameboy_color",
@@ -73,14 +109,75 @@ MIME_TYPES = {
".ico": "image/x-icon", ".ico": "image/x-icon",
} }
def _png_icon(size: int, bg: tuple, fg: tuple) -> bytes:
"""Generate a solid PWA icon (dark square + centred accent circle) as PNG,
using only the standard library. Computed once at startup."""
cx = cy = size / 2.0
r2 = (size * 0.32) ** 2
bg_px = bytes(bg)
fg_px = bytes(fg)
raw = bytearray()
for y in range(size):
raw.append(0) # PNG filter type 0 (none) per scanline
dy = y + 0.5 - cy
dy2 = dy * dy
for x in range(size):
dx = x + 0.5 - cx
raw += fg_px if (dx * dx + dy2) <= r2 else bg_px
def chunk(typ: bytes, data: bytes) -> bytes:
crc = zlib.crc32(typ + data) & 0xffffffff
return struct.pack(">I", len(data)) + typ + data + struct.pack(">I", crc)
sig = b"\x89PNG\r\n\x1a\n"
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8-bit truecolor RGB
idat = zlib.compress(bytes(raw), 9)
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
ICON_PNG = _png_icon(256, (26, 26, 46), (233, 69, 96))
MANIFEST_JSON = json.dumps({
"name": "Game Library",
"short_name": "Games",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e",
"icons": [{
"src": "/icon-256.png",
"sizes": "256x256",
"type": "image/png",
"purpose": "any maskable",
}],
})
# HTML <head> fragment that makes a page installable as a PWA.
PWA_HEAD = """\
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1a2e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Games">
<link rel="apple-touch-icon" href="/icon-256.png">"""
# ── Service Worker ───────────────────────────────────────────────────────────── # ── Service Worker ─────────────────────────────────────────────────────────────
SW_JS = """\ # Network-first everywhere so online play behaves exactly as it would with no
const CACHE_NAME = 'emulatorjs-v4'; # Service Worker; the cache is only read when the network is unreachable.
SW_JS = ("""\
const SHELL_CACHE = 'emujs-shell-""" + BUILD_ID + """';
const ROM_CACHE = 'emujs-roms'; // build-independent, holds downloaded ROMs
const APP_SHELL = ['/', '/manifest.json', '/icon-256.png', '/static/loader.js'];
self.addEventListener('install', function(event) { self.addEventListener('install', function(event) {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then(function(cache) { caches.open(SHELL_CACHE).then(function(cache) {
return cache.addAll(['/static/loader.js', '/']); // allSettled: a single missing asset must not abort the whole install.
return Promise.allSettled(APP_SHELL.map(function(u) { return cache.add(u); }));
}) })
); );
self.skipWaiting(); self.skipWaiting();
@@ -89,117 +186,102 @@ self.addEventListener('install', function(event) {
self.addEventListener('activate', function(event) { self.addEventListener('activate', function(event) {
event.waitUntil( event.waitUntil(
caches.keys().then(function(keys) { caches.keys().then(function(keys) {
return Promise.all( return Promise.all(keys.map(function(k) {
keys.filter(function(k) { return k !== CACHE_NAME; }) if (k !== SHELL_CACHE && k !== ROM_CACHE) return caches.delete(k);
.map(function(k) { return caches.delete(k); }) }));
); }).then(function() { return self.clients.claim(); })
})
); );
self.clients.claim();
}); });
// Cache a response only if it is a same-origin 200. This avoids poisoning the
// cache with Authelia auth redirects or error pages.
function cacheable(res) {
return res && res.status === 200 && res.type === 'basic';
}
function networkFirst(req, cacheName) {
return fetch(req).then(function(res) {
if (cacheable(res)) {
var copy = res.clone();
caches.open(cacheName).then(function(c) { c.put(req, copy).catch(function(){}); });
}
return res;
}).catch(function() {
return caches.open(cacheName).then(function(c) {
return c.match(req).then(function(hit) {
return hit || new Response('', { status: 503 });
});
});
});
}
function cacheFirst(req, cacheName) {
return caches.open(cacheName).then(function(c) {
return c.match(req).then(function(hit) {
if (hit) return hit;
return fetch(req).then(function(res) {
if (cacheable(res)) c.put(req, res.clone()).catch(function(){});
return res;
}).catch(function() { return new Response('', { status: 503 }); });
});
});
}
self.addEventListener('fetch', function(event) { self.addEventListener('fetch', function(event) {
var url = new URL(event.request.url); var req = event.request;
if (req.method !== 'GET') return; // saves/favourites writes: untouched
var url = new URL(req.url);
if (url.origin !== self.location.origin) return;
var path = url.pathname; var path = url.pathname;
if (path === '/sw.js' || path === '/manifest.json') return; // The SW itself and dynamic save endpoints: pure passthrough.
if (path === '/sw.js' || path.startsWith('/saves/')) return;
if (path.startsWith('/saves/')) { // Individual ROM file: served from the download cache when present (offline
// play), otherwise straight to network with NO implicit caching (ROMs are
// large — caching is opt-in via the launcher's download button).
if (path.startsWith('/roms/') && path !== '/roms/') {
event.respondWith( event.respondWith(
fetch(event.request).catch(function() { caches.open(ROM_CACHE).then(function(c) {
return new Response('', { status: 503 }); return c.match(req).then(function(hit) { return hit || fetch(req); });
}) })
); );
return; return;
} }
// Static assets and individual ROM files: cache-first. // EmulatorJS static assets are immutable within a build, and SHELL_CACHE is
// Use arrayBuffer() instead of clone() to avoid an iOS Safari bug where // build-scoped, so cache-first is safe and fast (no staleness across builds).
// a cloned response body can be empty when the original stream is consumed. if (path.startsWith('/static/')) {
if (path.startsWith('/static/') || (path.startsWith('/roms/') && path !== '/roms/')) { event.respondWith(cacheFirst(req, SHELL_CACHE));
event.respondWith(
caches.match(event.request).then(function(cached) {
if (cached) return cached;
return fetch(event.request).then(function(response) {
if (!response.ok) return response;
var status = response.status;
var headers = new Headers(response.headers);
return response.arrayBuffer().then(function(body) {
caches.open(CACHE_NAME).then(function(cache) {
cache.put(event.request,
new Response(body, { status: status, headers: headers })
).catch(function(e) {
console.warn('[SW] cache.put failed (quota?):', event.request.url, e);
});
});
return new Response(body, { status: status, headers: headers });
});
}).catch(function() {
return new Response('', { status: 503 });
});
})
);
return; return;
} }
// Player pages: network-first so new deployments always take effect. // Thumbnails: small and stable — cache-first in the persistent ROM cache so
// Cache is used only as offline fallback. // downloaded games still show artwork offline.
if (path.startsWith('/play/')) {
event.respondWith(
fetch(event.request).then(function(response) {
var status = response.status, headers = new Headers(response.headers);
return response.arrayBuffer().then(function(body) {
caches.open(CACHE_NAME).then(function(cache) {
cache.put(event.request, new Response(body, { status: status, headers: headers }));
});
return new Response(body, { status: status, headers: headers });
});
}).catch(function() {
return caches.match(event.request).then(function(cached) {
return cached || new Response('Offline', { status: 503 });
});
})
);
return;
}
// Thumbnails: cache on first successful fetch
if (path.startsWith('/thumbnails/')) { if (path.startsWith('/thumbnails/')) {
event.respondWith( event.respondWith(cacheFirst(req, ROM_CACHE));
caches.match(event.request).then(function(cached) {
if (cached) return cached;
return fetch(event.request).then(function(response) {
if (!response.ok) return response;
var status = response.status, headers = new Headers(response.headers);
return response.arrayBuffer().then(function(body) {
caches.open(CACHE_NAME).then(function(cache) {
cache.put(event.request, new Response(body, { status: status, headers: headers }));
});
return new Response(body, { status: status, headers: headers });
});
}).catch(function() { return new Response('', { status: 404 }); });
})
);
return; return;
} }
// ROM listing, favorites, launcher: network-first, cache fallback (small text responses) // App shell, ROM listing, favourites, player pages, icons/manifest:
event.respondWith( // network-first with a cache fallback so the library is browsable offline.
fetch(event.request).then(function(response) { if (path === '/' || path === '/roms/' || path === '/favorites' ||
var status = response.status, headers = new Headers(response.headers); path.startsWith('/play/') || path === '/manifest.json' ||
return response.arrayBuffer().then(function(body) { path.startsWith('/icon')) {
caches.open(CACHE_NAME).then(function(cache) { event.respondWith(networkFirst(req, SHELL_CACHE));
cache.put(event.request, new Response(body, { status: status, headers: headers })); return;
}); }
return new Response(body, { status: status, headers: headers }); // Everything else: default network handling.
});
}).catch(function() {
return caches.match(event.request).then(function(cached) {
return cached || new Response('', { status: 503 });
});
})
);
}); });
""")
# ── Shared JS: Service Worker registration ─────────────────────────────────────
_SW_REG = """\
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(function(e) {
console.warn('SW registration failed', e);
});
}
""" """
# ── Shared JS: IndexedDB offline save queue ──────────────────────────────────── # ── Shared JS: IndexedDB offline save queue ────────────────────────────────────
@@ -277,12 +359,39 @@ async function syncPendingSaves() {
} }
""" """
# ── Shared JS: Service Worker registration ───────────────────────────────────── # ── Shared JS: name of the build-scoped shell cache (mirrors the Service Worker)
_SW_REG = """\ # so pages can populate it directly, bypassing SW-interception timing gaps.
if ('serviceWorker' in navigator) { _SHELL_CACHE_JS = "var SHELL_CACHE = 'emujs-shell-" + BUILD_ID + "';\n"
navigator.serviceWorker.register('/sw.js').catch(function(e) {
console.warn('SW registration failed', e); # ── Shared JS: persist the EmulatorJS assets a game actually loaded ─────────────
}); # Relying on the Service Worker to cache the core (mgba-*.data etc.) during play
# is unreliable — on the first visit the SW is not yet controlling the page, so
# those fetches are never seen. Instead we read the real URLs the browser loaded
# from Resource Timing and write them into the shell cache directly from the page.
_CACHE_STATIC_JS = """\
async function cacheLoadedStatic() {
if (!('caches' in window) || !navigator.onLine) return;
try {
var cache = await caches.open(SHELL_CACHE);
var urls = performance.getEntriesByType('resource')
.map(function(e) { return e.name; })
.filter(function(u) {
var x; try { x = new URL(u); } catch(_) { return false; }
return x.origin === location.origin && x.pathname.indexOf('/static/') === 0;
});
urls.push(location.pathname); // the /play/<name> document itself
var seen = {};
for (var i = 0; i < urls.length; i++) {
var u = urls[i];
if (seen[u]) continue;
seen[u] = 1;
try {
if (await cache.match(u)) continue;
var r = await fetch(u);
if (r && r.status === 200) await cache.put(u, r.clone());
} catch(_) {}
}
} catch(_) {}
} }
""" """
@@ -294,6 +403,7 @@ LAUNCHER_HTML = ("""\
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Game Library</title> <title>Game Library</title>
""" + PWA_HEAD + """
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee;
@@ -303,10 +413,7 @@ LAUNCHER_HTML = ("""\
display: none; background: #c0392b; color: #fff; text-align: center; display: none; background: #c0392b; color: #fff; text-align: center;
padding: .5rem; font-size: .9rem; margin: -1.5rem -1rem 1rem; padding: .5rem; font-size: .9rem; margin: -1.5rem -1rem 1rem;
} }
#dbg-bar { body.offline #offline-banner { display: block; }
font-size: .7rem; color: #9ab; margin-bottom: .75rem; font-family: monospace;
background: #0d1527; border-radius: 6px; padding: .4rem .75rem;
}
#search { #search {
width: 100%; padding: .7rem 1rem; font-size: 1rem; width: 100%; padding: .7rem 1rem; font-size: 1rem;
background: #16213e; border: 1px solid #0f3460; border-radius: 8px; background: #16213e; border: 1px solid #0f3460; border-radius: 8px;
@@ -323,9 +430,11 @@ LAUNCHER_HTML = ("""\
.card { .card {
background: #16213e; border-radius: 10px; overflow: hidden; position: relative; background: #16213e; border-radius: 10px; overflow: hidden; position: relative;
text-decoration: none; color: #eee; display: block; text-decoration: none; color: #eee; display: block;
transition: transform .15s, background .15s; transition: transform .15s, background .15s, opacity .15s;
} }
.card:hover { background: #0f3460; transform: translateY(-2px); } .card:hover { background: #0f3460; transform: translateY(-2px); }
/* When offline, dim games that are not downloaded (cannot be played). */
body.offline .card:not(.avail) { opacity: .35; pointer-events: none; }
.thumb-wrap { width: 100%; aspect-ratio: 3/4; background: #0d1527; .thumb-wrap { width: 100%; aspect-ratio: 3/4; background: #0d1527;
display: flex; align-items: center; justify-content: center; overflow: hidden; } display: flex; align-items: center; justify-content: center; overflow: hidden; }
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; } .thumb-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
@@ -335,31 +444,28 @@ LAUNCHER_HTML = ("""\
display: -webkit-box; -webkit-line-clamp: 2; display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden; } -webkit-box-orient: vertical; overflow: hidden; }
.card-sys { font-size: .65rem; color: #9ab; margin-top: .2rem; text-transform: uppercase; } .card-sys { font-size: .65rem; color: #9ab; margin-top: .2rem; text-transform: uppercase; }
.star { .star, .dl {
position: absolute; top: .4rem; right: .4rem; z-index: 1; position: absolute; top: .4rem; z-index: 1;
background: rgba(0,0,0,.55); border: none; border-radius: 50%; background: rgba(0,0,0,.55); border: none; border-radius: 50%;
width: 2rem; height: 2rem; font-size: 1rem; line-height: 1; cursor: pointer; width: 2rem; height: 2rem; font-size: 1rem; line-height: 1; cursor: pointer;
color: #888; display: flex; align-items: center; justify-content: center; padding: 0; color: #888; display: flex; align-items: center; justify-content: center; padding: 0;
transition: background .15s, color .15s; transition: background .15s, color .15s;
} }
.star:hover { background: rgba(0,0,0,.85); } .star { right: .4rem; }
.dl { left: .4rem; }
.star:hover, .dl:hover { background: rgba(0,0,0,.85); }
.star.fav { color: #f5c518; } .star.fav { color: #f5c518; }
.cache-dot { .dl.done { color: #2ecc71; }
display: none; position: absolute; bottom: .4rem; left: .4rem;
width: .55rem; height: .55rem; border-radius: 50%; background: #2ecc71;
title: "Cached for offline";
}
.empty { color: #555; grid-column: 1 / -1; } .empty { color: #555; grid-column: 1 / -1; }
</style> </style>
</head> </head>
<body> <body>
<div id="offline-banner">Offline — saves will sync when reconnected</div> <div id="offline-banner">Offline — showing downloaded games; saves will sync when reconnected</div>
<h1>Game Library</h1> <h1>Game Library</h1>
<div id="dbg-bar">SW: checking… | Online: checking…</div>
<input id="search" type="search" placeholder="Search…" autocomplete="off" spellcheck="false"> <input id="search" type="search" placeholder="Search…" autocomplete="off" spellcheck="false">
<div class="grid" id="grid"><p class="empty">Loading…</p></div> <div class="grid" id="grid"><p class="empty">Loading…</p></div>
<script> <script>
""" + _IDB_JS + """ """ + _IDB_JS + _SHELL_CACHE_JS + """
var allGames = []; var allGames = [];
var favSet = new Set(); var favSet = new Set();
@@ -383,13 +489,14 @@ function card(g) {
+ '<div class="card-title">' + esc(g.title) + '</div>' + '<div class="card-title">' + esc(g.title) + '</div>'
+ '<div class="card-sys">' + esc(g.system) + '</div>' + '<div class="card-sys">' + esc(g.system) + '</div>'
+ '</div>' + '</div>'
+ '<button class="dl" data-name="' + esc(g.name) + '" data-title="' + esc(g.title) + '"'
+ ' onclick="toggleDownload(event,this)" title="Download for offline">⬇</button>'
+ '<button class="star' + (isFav ? ' fav' : '') + '"' + '<button class="star' + (isFav ? ' fav' : '') + '"'
+ ' data-name="' + esc(g.name) + '"' + ' data-name="' + esc(g.name) + '"'
+ ' onclick="toggleFav(event,this)"' + ' onclick="toggleFav(event,this)"'
+ ' title="' + (isFav ? 'Remove from favourites' : 'Add to favourites') + '">' + ' title="' + (isFav ? 'Remove from favourites' : 'Add to favourites') + '">'
+ (isFav ? '' : '') + (isFav ? '' : '')
+ '</button>' + '</button>'
+ '<span class="cache-dot" title="Cached for offline"></span>'
+ '</a>'; + '</a>';
} }
@@ -414,7 +521,7 @@ function render(q) {
others.forEach(function(g) { html += card(g); }); others.forEach(function(g) { html += card(g); });
document.getElementById('grid').innerHTML = html; document.getElementById('grid').innerHTML = html;
updateCacheStatus(); refreshOffline();
} }
async function toggleFav(e, btn) { async function toggleFav(e, btn) {
@@ -427,46 +534,73 @@ async function toggleFav(e, btn) {
await fetch('/favorites/' + encodeURIComponent(name), { method: adding ? 'POST' : 'DELETE' }); await fetch('/favorites/' + encodeURIComponent(name), { method: adding ? 'POST' : 'DELETE' });
} }
function setDownloaded(btn, done) {
btn.textContent = done ? '' : '';
btn.classList.toggle('done', done);
btn.title = done ? 'Downloaded — tap to remove' : 'Download for offline';
var cardEl = btn.closest('.card');
if (cardEl) cardEl.classList.toggle('avail', done);
}
async function toggleDownload(e, btn) {
e.preventDefault();
e.stopPropagation();
if (!('caches' in window)) { alert('Offline caching is not supported in this browser.'); return; }
var name = btn.dataset.name;
var title = btn.dataset.title;
var romReq = '/roms/' + encodeURIComponent(name);
var cache = await caches.open('emujs-roms');
var have = await cache.match(romReq);
var playReq = '/play/' + encodeURIComponent(name);
if (have) {
await cache.delete(romReq);
await cache.delete('/thumbnails/' + encodeURIComponent(title));
try { var sh = await caches.open(SHELL_CACHE); await sh.delete(playReq); } catch(_) {}
setDownloaded(btn, false);
return;
}
btn.disabled = true;
btn.textContent = '';
try {
await cache.add(romReq);
try { await cache.add('/thumbnails/' + encodeURIComponent(title)); } catch(_) {}
// Cache the player document too so the page renders offline. The emulator
// core itself is cached separately, when the game is first played online.
try { var shell = await caches.open(SHELL_CACHE); await shell.add(playReq); } catch(_) {}
setDownloaded(btn, true);
} catch(err) {
alert('Download failed: ' + err);
setDownloaded(btn, false);
}
btn.disabled = false;
}
async function refreshOffline() {
if (!('caches' in window)) return;
try {
var cache = await caches.open('emujs-roms');
var keys = await cache.keys();
var have = new Set(
keys.map(function(r) { return decodeURIComponent(new URL(r.url).pathname); })
.filter(function(p) { return p.indexOf('/roms/') === 0 && p !== '/roms/'; })
.map(function(p) { return p.slice('/roms/'.length); })
);
document.querySelectorAll('.dl[data-name]').forEach(function(btn) {
setDownloaded(btn, have.has(btn.dataset.name));
});
} catch(e) {}
}
function updateOnline() {
document.body.classList.toggle('offline', !navigator.onLine);
if (navigator.onLine) syncPendingSaves();
}
document.getElementById('search').addEventListener('input', function() { document.getElementById('search').addEventListener('input', function() {
render(this.value); render(this.value);
}); });
window.addEventListener('online', updateOnline);
async function updateCacheStatus() { window.addEventListener('offline', updateOnline);
if (!('caches' in window)) return;
try {
const cache = await caches.open('emulatorjs-v4');
const keys = await cache.keys();
const cached = new Set(
keys.map(function(r) {
return decodeURIComponent(new URL(r.url).pathname);
}).filter(function(p) {
return p.startsWith('/roms/');
}).map(function(p) {
return p.slice('/roms/'.length);
})
);
document.querySelectorAll('.card[data-rom]').forEach(function(card) {
if (cached.has(card.dataset.rom)) {
card.querySelector('.cache-dot').style.display = 'block';
}
});
return cached;
} catch(e) { return new Set(); }
}
async function updateDbgBar() {
var swReg = 'none', swCtrl = 'NO';
if ('serviceWorker' in navigator) {
var reg = await navigator.serviceWorker.getRegistration('/');
if (reg) swReg = reg.active ? 'active' : (reg.installing ? 'installing' : 'waiting');
if (navigator.serviceWorker.controller) swCtrl = 'YES';
}
var cached = await updateCacheStatus();
var dbg = document.getElementById('dbg-bar');
dbg.textContent = 'SW reg: ' + swReg + ' ctrl: ' + swCtrl
+ ' | Online: ' + (navigator.onLine ? 'yes' : 'NO')
+ ' | Cached ROMs: ' + (cached ? cached.size : '?');
}
Promise.all([ Promise.all([
fetch('/roms/').then(function(r) { return r.json(); }), fetch('/roms/').then(function(r) { return r.json(); }),
@@ -475,19 +609,8 @@ Promise.all([
allGames = results[0]; allGames = results[0];
favSet = new Set(results[1]); favSet = new Set(results[1]);
render(document.getElementById('search').value); render(document.getElementById('search').value);
updateDbgBar();
}); });
updateOnline();
window.addEventListener('online', function() {
document.getElementById('offline-banner').style.display = 'none';
syncPendingSaves();
updateDbgBar();
});
window.addEventListener('offline', function() {
document.getElementById('offline-banner').style.display = '';
updateDbgBar();
});
if (!navigator.onLine) { document.getElementById('offline-banner').style.display = ''; }
""" + _SW_REG + """\ """ + _SW_REG + """\
</script> </script>
</body> </body>
@@ -501,6 +624,7 @@ PLAYER_TMPL = Template("""\
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$title</title> <title>$title</title>
""" + PWA_HEAD + """
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #000; } body { background: #000; }
@@ -532,7 +656,7 @@ PLAYER_TMPL = Template("""\
<script> <script>
const GAME = $game_json; const GAME = $game_json;
const SAVE_URL = '/saves/' + encodeURIComponent(GAME.name); const SAVE_URL = '/saves/' + encodeURIComponent(GAME.name);
""" + _IDB_JS + """ """ + _IDB_JS + _SHELL_CACHE_JS + _CACHE_STATIC_JS + """
function showOfflineBanner(msg) { function showOfflineBanner(msg) {
var b = document.getElementById('offline-banner'); var b = document.getElementById('offline-banner');
b.textContent = msg || 'Offline — saves will sync when reconnected'; b.textContent = msg || 'Offline — saves will sync when reconnected';
@@ -540,48 +664,8 @@ function showOfflineBanner(msg) {
} }
function hideOfflineBanner() { document.getElementById('offline-banner').style.display = 'none'; } function hideOfflineBanner() { document.getElementById('offline-banner').style.display = 'none'; }
async function preflightCheck() {
void('1/4 start');
try {
var swReg = 'none', swCtrl = 'NO';
if ('serviceWorker' in navigator) {
void('2/4 SW reg…');
const reg = await navigator.serviceWorker.getRegistration('/');
if (reg) swReg = reg.active ? 'active' : (reg.installing ? 'installing' : 'waiting');
if (navigator.serviceWorker.controller) swCtrl = 'YES';
}
void('3/4 cache…');
var romCached = false, romInfo = 'NOT in cache';
if ('caches' in window) {
const cache = await caches.open('emulatorjs-v4');
const hit = await cache.match('/roms/' + encodeURIComponent(GAME.name));
if (hit) {
const cl = hit.headers.get('content-length');
const sz = cl ? parseInt(cl) : -1;
romCached = sz > 0;
romInfo = sz > 0 ? 'cached ' + sz + 'B' : 'in cache but 0B (EMPTY!)';
}
}
void('4/4 done — SW: ' + swReg + '/' + swCtrl
+ ' | ROM: ' + romInfo
+ ' | Online: ' + (navigator.onLine ? 'yes' : 'NO')
+ '\n' + GAME.name);
if (!navigator.onLine && !romCached) {
showOfflineBanner('Not available offline — open this game while connected to download it.');
return false;
}
return true;
} catch(e) {
void('preflight error: ' + e + '\n' + GAME.name);
return true; // fail open — try to load EmulatorJS anyway
}
}
async function manualSave() { async function manualSave() {
const btn = document.getElementById('save-btn'); var btn = document.getElementById('save-btn');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Saving...'; btn.textContent = 'Saving...';
await pushSave(); await pushSave();
@@ -591,31 +675,36 @@ async function manualSave() {
async function pushSave() { async function pushSave() {
try { try {
const data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile(); var data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile();
if (!data || data.byteLength === 0) return; if (!data || data.byteLength === 0) return;
if (navigator.onLine) { if (navigator.onLine) {
const r = await fetch(SAVE_URL, { method: 'PUT', body: data, var r = await fetch(SAVE_URL, { method: 'PUT', body: data,
headers: { 'Content-Type': 'application/octet-stream' } }); headers: { 'Content-Type': 'application/octet-stream' } });
if (r.ok) return; if (r.ok) { await idbDelete(GAME.name); return; }
} }
// Offline or server rejected — queue locally to sync later.
await idbPut(GAME.name, new Uint8Array(data)); await idbPut(GAME.name, new Uint8Array(data));
} catch(e) { console.warn('save failed, stored locally', e); } } catch(e) { console.warn('save failed, stored locally', e); }
} }
async function pullSave() { async function pullSave() {
try { try {
let sav; var sav;
if (navigator.onLine) { if (navigator.onLine) {
const r = await fetch(SAVE_URL); var r = await fetch(SAVE_URL);
if (r.ok) sav = new Uint8Array(await r.arrayBuffer()); if (r.ok) sav = new Uint8Array(await r.arrayBuffer());
} }
if (!sav) sav = await idbGet(GAME.name); // Fall back to an unsynced local save (offline, or newer than the server's).
if (!sav || sav.byteLength === 0) return; if (!sav || !sav.byteLength) {
const gm = window.EJS_emulator.gameManager; var local = await idbGet(GAME.name);
const path = gm.getSaveFilePath(); if (local && local.byteLength) sav = local;
const parts = path.split('/'); }
let cp = ''; if (!sav || !sav.byteLength) return;
for (let i = 0; i < parts.length - 1; i++) { var gm = window.EJS_emulator.gameManager;
var path = gm.getSaveFilePath();
var parts = path.split('/');
var cp = '';
for (var i = 0; i < parts.length - 1; i++) {
if (!parts[i]) continue; if (!parts[i]) continue;
cp += '/' + parts[i]; cp += '/' + parts[i];
if (!gm.FS.analyzePath(cp).exists) gm.FS.mkdir(cp); if (!gm.FS.analyzePath(cp).exists) gm.FS.mkdir(cp);
@@ -631,44 +720,44 @@ window.EJS_gameUrl = '/roms/' + encodeURIComponent(GAME.name);
window.EJS_core = GAME.system; window.EJS_core = GAME.system;
window.EJS_pathtodata = '/static/'; window.EJS_pathtodata = '/static/';
window.EJS_onGameStart = function() { window.EJS_onGameStart = function() {
// Sync offline-queued saves first so server has latest, then pull. // Push any offline-queued saves first so the server has the latest, then pull.
// Runs after EmulatorJS is ready so IDB access never blocks startup. // Runs after EmulatorJS is ready so IndexedDB access never blocks startup.
(navigator.onLine ? syncPendingSaves().catch(function(){}) : Promise.resolve()) (navigator.onLine ? syncPendingSaves().catch(function(){}) : Promise.resolve())
.then(function() { return pullSave(); }) .then(function() { return pullSave(); })
.then(function() { .then(function() {
void('running | Online: ' + (navigator.onLine ? 'yes' : 'NO') + '\n' + GAME.name);
setInterval(pushSave, 30000); setInterval(pushSave, 30000);
// Persist the core/assets this game just loaded so it plays offline next
// time. Delayed pass catches anything fetched slightly after game start.
cacheLoadedStatic();
setTimeout(cacheLoadedStatic, 5000);
window.addEventListener('pagehide', function() { window.addEventListener('pagehide', function() {
const data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile(); var data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile();
if (data && data.byteLength > 0) if (!data || data.byteLength === 0) return;
if (navigator.onLine) {
navigator.sendBeacon(SAVE_URL, new Blob([data], { type: 'application/octet-stream' })); navigator.sendBeacon(SAVE_URL, new Blob([data], { type: 'application/octet-stream' }));
} else {
idbPut(GAME.name, new Uint8Array(data)); // best-effort
}
}); });
window.addEventListener('online', function() { window.addEventListener('online', function() {
hideOfflineBanner(); hideOfflineBanner();
cacheLoadedStatic();
syncPendingSaves().catch(function(){}).then(function() { pullSave(); }); syncPendingSaves().catch(function(){}).then(function() { pullSave(); });
preflightCheck();
}); });
window.addEventListener('offline', function() { showOfflineBanner(); preflightCheck(); }); window.addEventListener('offline', function() { showOfflineBanner(); });
if (!navigator.onLine) showOfflineBanner(); if (!navigator.onLine) showOfflineBanner();
}); });
}; };
// Preflight: check cache and block if offline + ROM not cached. if (!navigator.onLine) showOfflineBanner();
// loader.js loads immediately after — syncPendingSaves runs inside EJS_onGameStart
// so IDB access never holds up the startup path. // Load EmulatorJS directly — no preflight gate. If the ROM or core is not
preflightCheck().then(function(ok) { // cached while offline, EmulatorJS surfaces its own error.
if (!ok) return; var s = document.createElement('script');
var s = document.createElement('script'); s.src = '/static/loader.js';
s.src = '/static/loader.js'; document.body.appendChild(s);
document.body.appendChild(s);
}).catch(function(e) {
void('startup error: ' + e + '\n' + GAME.name);
var s = document.createElement('script');
s.src = '/static/loader.js';
document.body.appendChild(s);
});
</script>
""" + _SW_REG + """\ """ + _SW_REG + """\
</script>
</body> </body>
</html>""") </html>""")
@@ -677,10 +766,12 @@ class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
pass pass
def send_body(self, data: bytes, ct: str, status: int = 200): def send_body(self, data: bytes, ct: str, status: int = 200, extra_headers: dict = None):
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", ct) self.send_header("Content-Type", ct)
self.send_header("Content-Length", str(len(data))) self.send_header("Content-Length", str(len(data)))
for k, v in (extra_headers or {}).items():
self.send_header(k, v)
self.end_headers() self.end_headers()
self.wfile.write(data) self.wfile.write(data)
@@ -762,14 +853,19 @@ class Handler(BaseHTTPRequestHandler):
p = self.decoded_path() p = self.decoded_path()
if p == "/sw.js": if p == "/sw.js":
body = SW_JS.encode() self.send_body(
self.send_response(200) SW_JS.encode(), "application/javascript",
self.send_header("Content-Type", "application/javascript") extra_headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
self.send_header("Content-Length", str(len(body))) )
self.send_header("Service-Worker-Allowed", "/") return
self.send_header("Cache-Control", "no-cache")
self.end_headers() if p == "/manifest.json":
self.wfile.write(body) self.send_body(MANIFEST_JSON.encode(), "application/manifest+json")
return
if p == "/icon-256.png":
self.send_body(ICON_PNG, "image/png",
extra_headers={"Cache-Control": "max-age=604800"})
return return
if p in ("/", ""): if p in ("/", ""):
+89 -8
View File
@@ -1,20 +1,38 @@
{ config, lib, pkgs, homeyConfig, ... }: { config, lib, pkgs, homeyConfig, ... }:
# Jellyfin — media server. (Deferred — enable when ready.) # Jellyfin — media server.
#
# Auth model: Jellyfin-native (no Authelia SSO — Jellyfin intercepts the
# forward_auth flow in ways that break SSO). LDAP plugin can be installed
# manually via the UI; the XML config is pre-seeded on first start so the
# plugin settings are pre-populated when the user opens the config page.
#
# Manual step after first deploy:
# 1. Dashboard → Plugins → Catalog → search "LDAP Authentication" → Install
# 2. Restart Jellyfin (Dashboard → Administration → Restart)
# 3. Dashboard → Plugins → LDAP Authentication → verify fields → Save
# #
# Volume layout: # Volume layout:
# <dataDir>/jellyfin/config/ → /config # <dataDir>/jellyfin/config/ → /config
# <dataDir>/media/movies/ → /data/movies # <dataDir>/media/movies/ → /data/movies
# <dataDir>/media/tvshows/ → /data/tvshows # <dataDir>/media/tvshows/ → /data/tvshows
# <dataDir>/media/general/ → /data/general
# <dataDir>/media/complete/ → /data/complete
let let
cfg = config.homey.jellyfin; cfg = config.homey.jellyfin;
dataDir = config.homey.storage.mountPoint; dataDir = config.homey.storage.mountPoint;
domain = homeyConfig.domain; domain = homeyConfig.domain;
ldapBaseDn = lib.concatStringsSep ","
(map (p: "dc=${p}") (lib.splitString "." domain));
ldapConfigDir = "${dataDir}/jellyfin/config/plugins/configurations";
ldapConfigXml = "${ldapConfigDir}/LDAP-Auth.xml";
in in
{ {
options.homey.jellyfin = { options.homey.jellyfin = {
enable = lib.mkEnableOption "Jellyfin media server" // { default = true; }; enable = lib.mkEnableOption "Jellyfin media server";
image = lib.mkOption { image = lib.mkOption {
type = lib.types.str; type = lib.types.str;
@@ -28,12 +46,20 @@ in
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
# -----------------------------------------------------------------------
# Secrets
# -----------------------------------------------------------------------
sops.secrets."openldap/ro_password" = { owner = "root"; };
# -----------------------------------------------------------------------
# Container
# -----------------------------------------------------------------------
virtualisation.oci-containers.containers.jellyfin = { virtualisation.oci-containers.containers.jellyfin = {
image = cfg.image; image = cfg.image;
ports = [ "127.0.0.1:${toString cfg.port}:8096" ]; ports = [ "127.0.0.1:${toString cfg.port}:8096" ];
environment = { environment = {
JELLYFIN_PublishedServerUrl = "https://jellyfin.${domain}"; JELLYFIN_PublishedServerUrl = "https://media.${domain}";
PUID = "1000"; PUID = "1000";
PGID = "1000"; PGID = "1000";
}; };
@@ -42,30 +68,76 @@ in
"${dataDir}/jellyfin/config:/config" "${dataDir}/jellyfin/config:/config"
"${dataDir}/media/movies:/data/movies:ro" "${dataDir}/media/movies:/data/movies:ro"
"${dataDir}/media/tvshows:/data/tvshows:ro" "${dataDir}/media/tvshows:/data/tvshows:ro"
"${dataDir}/media/general:/data/general:ro"
"${dataDir}/media/complete:/data/complete:ro"
]; ];
extraOptions = [ "--network=homey" ]; extraOptions = [ "--network=homey" ];
}; };
# -----------------------------------------------------------------------
# ExecStartPre: pre-seed LDAP plugin config on first start only
# -----------------------------------------------------------------------
systemd.services."podman-jellyfin" = { systemd.services."podman-jellyfin" = {
serviceConfig.ExecStartPre = [
(pkgs.writeShellScript "jellyfin-seed-ldap-config" ''
set -euo pipefail
# Only write on first start; preserve any UI edits on subsequent starts.
if [ -f "${ldapConfigXml}" ]; then
exit 0
fi
mkdir -p "${ldapConfigDir}"
RO_PASSWORD=$(cat ${config.sops.secrets."openldap/ro_password".path})
cat > "${ldapConfigXml}" <<EOF
<?xml version="1.0" encoding="utf-8"?>
<PluginConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<LdapServer>openldap</LdapServer>
<LdapPort>389</LdapPort>
<UseSsl>false</UseSsl>
<UseStartTls>false</UseStartTls>
<SkipSslVerify>false</SkipSslVerify>
<LdapBindUser>cn=readonly,${ldapBaseDn}</LdapBindUser>
<LdapBindPassword>$RO_PASSWORD</LdapBindPassword>
<LdapBaseDn>ou=users,${ldapBaseDn}</LdapBaseDn>
<LdapSearchFilter></LdapSearchFilter>
<LdapSearchAttributes>uid,cn,mail,displayName</LdapSearchAttributes>
<LdapUsernameAttribute>uid</LdapUsernameAttribute>
<LdapPasswordAttribute>userPassword</LdapPasswordAttribute>
<EnableAllFolders>true</EnableAllFolders>
<EnabledFolders />
<AdminBaseDn>cn=admins,ou=groups,${ldapBaseDn}</AdminBaseDn>
<AdminFilter></AdminFilter>
<AllowPassChange>false</AllowPassChange>
<CreateUsersFromLdap>true</CreateUsersFromLdap>
<EnableCaseSensitiveUsername>false</EnableCaseSensitiveUsername>
<LdapClientCertPath></LdapClientCertPath>
<LdapClientKeyPath></LdapClientKeyPath>
<LdapRootCaPath></LdapRootCaPath>
<PasswordResetUrl></PasswordResetUrl>
</PluginConfiguration>
EOF
chmod 600 "${ldapConfigXml}"
'')
];
after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ];
}; };
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Authelia access control — one_factor; Jellyfin has its own login UI. # Authelia access control — bypass; Jellyfin has its own login UI and
# does not work correctly behind SSO forward_auth.
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
homey.authelia.accessControlRules = [{ homey.authelia.accessControlRules = [{
priority = 60; priority = 60;
domain = [ "jellyfin.${domain}" ]; domain = [ "media.${domain}" ];
policy = "one_factor"; policy = "bypass";
}]; }];
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Caddy virtual host — no forward_auth; Jellyfin has its own login UI # Caddy virtual host — no forward_auth; Jellyfin manages its own auth
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{ homey.caddy.virtualHosts = [{
subdomain = "jellyfin"; subdomain = "media";
port = cfg.port; port = cfg.port;
auth = false; auth = false;
}]; }];
@@ -82,5 +154,14 @@ in
# Backup # Backup
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/jellyfin" ]; homey.backup.extraPaths = [ "${dataDir}/jellyfin" ];
# -----------------------------------------------------------------------
# Uptime Kuma monitor
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Jellyfin";
url = "https://media.${domain}";
interval = 60;
}];
}; };
} }
+19 -6
View File
@@ -1,16 +1,21 @@
{ config, lib, pkgs, homeyConfig, ... }: { config, lib, pkgs, homeyConfig, ... }:
# Transmission — BitTorrent client. (Deferred — enable when ready.) # Transmission — BitTorrent client.
# #
# NOTE: Transmission's web UI also runs on port 9091. To avoid clashing # NOTE: Transmission's web UI also runs on port 9091. To avoid clashing
# with Authelia (also 9091), this module binds Transmission to 9092. # with Authelia (also 9091), this module binds Transmission to 9092.
# #
# Auth: Authelia two_factor, admins only (via forward_auth from Caddy).
#
# Volume layout: # Volume layout:
# <dataDir>/transmission/config/ → /config # <dataDir>/transmission/config/ → /config
# <dataDir>/media/movies/ → /downloads/movies # <dataDir>/media/movies/ → /downloads/movies
# <dataDir>/media/tvshows/ → /downloads/tvshows # <dataDir>/media/tvshows/ → /downloads/tvshows
# <dataDir>/media/general/ → /downloads/general # <dataDir>/media/general/ → /downloads/general
# <dataDir>/media/complete/ → /downloads/complete # <dataDir>/media/complete/ → /downloads/complete
#
# The /downloads/* paths are the same dirs Jellyfin reads from (/data/*),
# so downloaded media is immediately visible to Jellyfin without moving files.
let let
cfg = config.homey.transmission; cfg = config.homey.transmission;
@@ -19,7 +24,7 @@ let
in in
{ {
options.homey.transmission = { options.homey.transmission = {
enable = lib.mkEnableOption "Transmission torrent client" // { default = true; }; enable = lib.mkEnableOption "Transmission torrent client";
image = lib.mkOption { image = lib.mkOption {
type = lib.types.str; type = lib.types.str;
@@ -43,7 +48,6 @@ in
environment = { environment = {
PUID = "1000"; PUID = "1000";
PGID = "1000"; PGID = "1000";
TRANSMISSION_WEB_HOME = "/usr/share/transmission/web";
}; };
volumes = [ volumes = [
@@ -66,15 +70,15 @@ in
# Authelia access control — admins only, two_factor; all others denied. # Authelia access control — admins only, two_factor; all others denied.
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
homey.authelia.accessControlRules = [ homey.authelia.accessControlRules = [
{ priority = 30; domain = [ "torrent.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; } { priority = 30; domain = [ "download.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; }
{ priority = 31; domain = [ "torrent.${domain}" ]; policy = "deny"; } { priority = 31; domain = [ "download.${domain}" ]; policy = "deny"; }
]; ];
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Caddy virtual host — forward_auth, admins only # Caddy virtual host — forward_auth, admins only
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{ homey.caddy.virtualHosts = [{
subdomain = "torrent"; subdomain = "download";
port = cfg.port; port = cfg.port;
auth = true; auth = true;
}]; }];
@@ -91,5 +95,14 @@ in
# Backup # Backup
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/transmission" ]; homey.backup.extraPaths = [ "${dataDir}/transmission" ];
# -----------------------------------------------------------------------
# Uptime Kuma monitor
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Transmission";
url = "https://download.${domain}";
interval = 60;
}];
}; };
} }