gba server, jellyfin
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 ("/", ""):
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user