diff --git a/lumen/Dockerfile b/lumen/Dockerfile index 4f33497..738eae7 100644 --- a/lumen/Dockerfile +++ b/lumen/Dockerfile @@ -22,6 +22,12 @@ COPY deploy/05-lumen-env-aliases.envsh /docker-entrypoint.d/05-lumen-env-aliases COPY deploy/15-lumen-trusted-proxies.sh /docker-entrypoint.d/15-lumen-trusted-proxies.sh RUN chmod 755 /docker-entrypoint.d/05-lumen-env-aliases.envsh /docker-entrypoint.d/15-lumen-trusted-proxies.sh COPY --from=build /app/dist /usr/share/nginx/html +# "Lumen settings for everyone" are saved by a small nginx script (njs, shipped with this +# image) into /lumen-data: a folder the installer mounts, writable by user 101 (nginx). It is +# deliberately not created here, so settings are never saved inside a container that an +# update would replace. Without it the admin page offers lumen-config.json as a download. +COPY deploy/lumen-config.js /etc/nginx/lumen/lumen-config.js +RUN sed -i '1i load_module modules/ngx_http_js_module.so;' /etc/nginx/nginx.conf # The entrypoint renders only proxy configuration. Keep the static app read-only # while allowing the unprivileged master to write its config, cache and PID. RUN sed -i 's@/run/nginx.pid@/tmp/nginx.pid@' /etc/nginx/nginx.conf && \ diff --git a/lumen/compose.lumen-only.yaml b/lumen/compose.lumen-only.yaml index b59c0e2..1841c8e 100644 --- a/lumen/compose.lumen-only.yaml +++ b/lumen/compose.lumen-only.yaml @@ -35,9 +35,14 @@ services: - ALL security_opt: - no-new-privileges:true - # Optional admin defaults for every user (see lumen/public/lumen-config.example.json). - # Create the file first, then remove the # from the two lines below. + # Optional admin defaults for every user (You > Lumen settings for everyone). + # To save them from that page, create a folder Lumen may write to: + # mkdir lumen-data && sudo chown 101:101 lumen-data + # then remove the # from "volumes:" and the lumen-data line below. + # Or write lumen-config.json yourself (see lumen/public/lumen-config.example.json), + # create it first, and uncomment the lumen-config.json line instead. # volumes: + # - ./lumen-data:/lumen-data # - ./lumen-config.json:/usr/share/nginx/html/lumen-config.json:ro # - ./branding:/usr/share/nginx/html/branding:ro # optional logo / tab icon restart: unless-stopped diff --git a/lumen/compose.yaml b/lumen/compose.yaml index 5c24aa7..ffce0f9 100644 --- a/lumen/compose.yaml +++ b/lumen/compose.yaml @@ -50,9 +50,14 @@ services: - ALL security_opt: - no-new-privileges:true - # Optional admin defaults for every user (see lumen/public/lumen-config.example.json). - # Create the file first, then remove the # from the two lines below. + # Optional admin defaults for every user (You > Lumen settings for everyone). + # To save them from that page, create a folder Lumen may write to: + # mkdir lumen-data && sudo chown 101:101 lumen-data + # then remove the # from "volumes:" and the lumen-data line below. + # Or write lumen-config.json yourself (see lumen/public/lumen-config.example.json), + # create it first, and uncomment the lumen-config.json line instead. # volumes: + # - ./lumen-data:/lumen-data # - ./lumen-config.json:/usr/share/nginx/html/lumen-config.json:ro # - ./branding:/usr/share/nginx/html/branding:ro # optional logo / tab icon restart: unless-stopped diff --git a/lumen/deploy/lumen-config.js b/lumen/deploy/lumen-config.js new file mode 100644 index 0000000..b522280 --- /dev/null +++ b/lumen/deploy/lumen-config.js @@ -0,0 +1,206 @@ +// Saves "Lumen settings for everyone" from the admin page, so nobody has to download +// lumen-config.json and copy it onto the server by hand. Runs inside nginx (njs). +// +// The file goes in /lumen-data, a folder the installer mounts and lets the container write. +// Without that folder the admin page keeps offering the download instead. +// +// Only a SeerrNG admin may save: the person's own SeerrNG session is checked through +// /_lumen_session_check (SeerrNG's /auth/me) before anything is written. +// +// Admins can also upload the logo and tab icon (branding below). Each upload gets a new file +// name in /lumen-data/branding, served at /branding/, so no browser keeps an old copy. +import fs from 'fs'; + +const DIR = '/lumen-data'; +const FILE = DIR + '/lumen-config.json'; +const ADMIN = 2; // SeerrNG's Admin permission bit +const MAX_BYTES = 64 * 1024; + +// 'ok', 'missing' (no folder mounted) or 'readonly' (mounted, but this container can't write) +function folder() { + try { + fs.accessSync(DIR, fs.constants.F_OK); + } catch (e) { + return 'missing'; + } + try { + fs.accessSync(DIR, fs.constants.W_OK); + return 'ok'; + } catch (e) { + return 'readonly'; + } +} + +function send(r, status, body) { + r.headersOut['Content-Type'] = 'application/json'; + r.headersOut['Cache-Control'] = 'no-store'; + r.return(status, JSON.stringify(body)); +} + +// A page on another site can't send JSON here without asking first (CORS preflight), which +// nothing here answers, so it can't save with an admin's cookie. (No Origin check: reverse +// proxies often rewrite Host.) +function sameSite(r) { + const type = (r.headersIn['Content-Type'] || '').toLowerCase(); + return type.split(';')[0].trim() === 'application/json'; +} + +// Is the person sending this a SeerrNG admin? Answers the request itself when not. +async function isAdmin(r) { + const me = await r.subrequest('/_lumen_session_check', { method: 'GET' }); + if (me.status !== 200) { + send(r, 401, { message: 'Sign in again.' }); + return false; + } + let user; + try { + user = JSON.parse(me.responseText); + } catch (e) { + send(r, 502, { message: 'SeerrNG didn’t say who you are.' }); + return false; + } + if (!user || ((user.permissions || 0) & ADMIN) === 0) { + send(r, 403, { message: 'Only admins can change settings for everyone.' }); + return false; + } + return true; +} + +async function save(r) { + if (r.method === 'GET') { + const f = folder(); + send(r, 200, { canSave: f === 'ok', folder: f }); + return; + } + if (r.method !== 'PUT') { + send(r, 405, { message: 'Use PUT.' }); + return; + } + if (!sameSite(r)) { + send(r, 403, { message: 'Send the settings as JSON from Lumen.' }); + return; + } + if (!(await isAdmin(r))) return; + const text = r.requestText || ''; + if (!text || text.length > MAX_BYTES) { + send(r, 413, { message: 'The settings are empty or too large.' }); + return; + } + let cfg; + try { + cfg = JSON.parse(text); + } catch (e) { + send(r, 400, { message: 'The settings aren’t valid JSON.' }); + return; + } + if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) { + send(r, 400, { message: 'The settings must be a JSON object.' }); + return; + } + if (folder() !== 'ok') { + send(r, 409, { + message: + 'Lumen has no writable /lumen-data folder. Download the file instead.', + }); + return; + } + try { + // write a copy first, then swap it in, so a reader never sees half a file + const tmp = FILE + '.saving'; + fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n'); + fs.renameSync(tmp, FILE); + } catch (e) { + send(r, 500, { message: 'Couldn’t write lumen-config.json: ' + e.message }); + return; + } + send(r, 200, { saved: true }); +} + +// ---- logo and tab icon uploads: PUT /lumen/branding/logo or /lumen/branding/icon ---- +const BRANDING = DIR + '/branding'; +const MAX_IMAGE = 2 * 1024 * 1024; +// what the file really is, from its first bytes (the browser's Content-Type is only a hint) +function imageType(b) { + if ( + b.length > 8 && + b[0] === 0x89 && + b[1] === 0x50 && + b[2] === 0x4e && + b[3] === 0x47 + ) + return 'png'; + if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) + return 'jpg'; + // (njs has no spread syntax: compare byte by byte) + const at = (i, text) => { + for (let k = 0; k < text.length; k++) + if (b[i + k] !== text.charCodeAt(k)) return false; + return true; + }; + if (b.length > 12 && at(0, 'RIFF') && at(8, 'WEBP')) return 'webp'; + if (b.length > 4 && b[0] === 0 && b[1] === 0 && b[2] === 1 && b[3] === 0) + return 'ico'; + const head = b.toString('utf8', 0, Math.min(b.length, 1024)).trimStart(); + if ( + (head.startsWith(' MAX_IMAGE) { + send(r, 413, { message: 'Choose an image of up to 2 MB.' }); + return; + } + const ext = imageType(body); + if (!ext) { + send(r, 415, { message: 'Use a PNG, JPEG, WebP, SVG or ICO image.' }); + return; + } + const name = kind + '-' + Date.now() + '.' + ext; + try { + try { + fs.mkdirSync(BRANDING); + } catch (e) { + // already there + } + const tmp = BRANDING + '/.' + name + '.saving'; + fs.writeFileSync(tmp, body); + fs.renameSync(tmp, BRANDING + '/' + name); + // earlier uploads of the same kind are no longer used + fs.readdirSync(BRANDING).forEach(function (f) { + if (f !== name && f.startsWith(kind + '-')) + try { + fs.unlinkSync(BRANDING + '/' + f); + } catch (e) { + // still fine: it's only an unused file + } + }); + } catch (e) { + send(r, 500, { message: 'Couldn’t keep the image: ' + e.message }); + return; + } + send(r, 200, { url: '/branding/' + name }); +} + +export default { save, branding }; diff --git a/lumen/deploy/nginx.conf.template b/lumen/deploy/nginx.conf.template index b4d073a..2353234 100644 --- a/lumen/deploy/nginx.conf.template +++ b/lumen/deploy/nginx.conf.template @@ -18,6 +18,9 @@ map "$lumen_via_proxy:$http_x_forwarded_proto" $lumen_forwarded_proto { # The same protections full SeerrNG sends with its pages: no framing (clickjacking), scripts only # from this origin. Outside hosts: Google Fonts, Plex sign-in, and cover art from many CDNs. +# saves "Lumen settings for everyone" from the admin page (see lumen-config.js) +js_import lumen from /etc/nginx/lumen/lumen-config.js; + map "" $lumen_csp { default "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; connect-src 'self' https://plex.tv https://*.plex.tv; manifest-src 'self'; worker-src 'self'; frame-src 'none'"; } @@ -58,6 +61,8 @@ server { internal; proxy_pass ${SEERR_URL}/api/v1/auth/me; proxy_method GET; + # lumen-config.js reads the answer (who is signed in), not just the status + subrequest_output_buffer_size 64k; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header Cookie $http_cookie; @@ -80,6 +85,59 @@ server { proxy_set_header X-Forwarded-Proto $lumen_forwarded_proto; } + # "Lumen settings for everyone": the admin page saves them here (PUT, SeerrNG admins only) + # when /lumen-data is mounted and writable; GET says whether it is + location = /lumen/config { + client_max_body_size 64k; + client_body_buffer_size 64k; + js_content lumen.save; + } + # logo and tab icon uploaded from the admin page (PUT, SeerrNG admins only) + location ^~ /lumen/branding/ { + client_max_body_size 2m; + client_body_buffer_size 2m; + js_content lumen.branding; + } + # /branding/: uploads first, then a branding folder mounted into the app. Images only, so + # nothing here may run scripts (an SVG opened on its own included). + location /branding/ { + add_header Cache-Control "no-cache"; + add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header Referrer-Policy same-origin; + root /lumen-data; + try_files $uri @branding_mounted; + } + location @branding_mounted { + add_header Cache-Control "no-cache"; + add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header Referrer-Policy same-origin; + root /usr/share/nginx/html; + try_files $uri =404; + } + # settings saved from the admin page win over a lumen-config.json mounted into the app + location = /lumen-config.json { + add_header Cache-Control "no-cache"; + add_header Content-Security-Policy $lumen_csp; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header Referrer-Policy same-origin; + root /lumen-data; + try_files /lumen-config.json @lumen_config_mounted; + } + location @lumen_config_mounted { + add_header Cache-Control "no-cache"; + add_header Content-Security-Policy $lumen_csp; + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header Referrer-Policy same-origin; + root /usr/share/nginx/html; + try_files /lumen-config.json =404; + } + # "open full SeerrNG" links when lumen-config.json sets no seerrPublicUrl location = /seerrng { return 302 ${SEERR_FULL_URL}/; diff --git a/lumen/src/api.ts b/lumen/src/api.ts index d915b87..22135eb 100644 --- a/lumen/src/api.ts +++ b/lumen/src/api.ts @@ -1,5 +1,6 @@ // Thin client for the SeerrNG API. Everything goes through the same origin (the Lumen // container or the Vite dev proxy), so SeerrNG's session cookie does the auth. +import { markAllStale } from './apiCache'; export class ApiError extends Error { constructor( @@ -29,6 +30,8 @@ export async function api(path: string, init: RequestInit = {}): Promise { } throw new ApiError(res.status, msg); } + // a request or other change: saved answers may now be out of date + if (init.method && init.method !== 'GET') markAllStale(); return res.status === 204 ? (undefined as T) : res.json(); } @@ -127,6 +130,7 @@ export interface RequestOptions { serverId?: number; profileId?: number; rootFolder?: string; + userId?: number; // request on someone else's behalf (admins) } const post = (body: object) => api<{ id: number }>('/request', { @@ -245,6 +249,7 @@ export async function requestAnotherNarration( const body = await res.json().catch(() => ({})); if (!res.ok) throw new ApiError(res.status, body.error || 'The request failed.'); + markAllStale(); return body as { ok: true; bookId: number; diff --git a/lumen/src/apiCache.ts b/lumen/src/apiCache.ts new file mode 100644 index 0000000..dfa8252 --- /dev/null +++ b/lumen/src/apiCache.ts @@ -0,0 +1,105 @@ +// Answers from SeerrNG, kept so going back to a page or reopening Lumen doesn't ask for +// everything again. Every call counts against SeerrNG's per-account rate limit, and its +// first lookup of a title can take seconds. +// +// - Fresh (under FRESH_MS): used as is, nothing is asked. +// - Older (up to KEEP_MS): shown at once while a new answer loads in the background. +// - Saved per account in localStorage, so a reopened app starts from what it last showed. +// Signing out or switching account drops it. Requests and other changes mark everything +// stale, so nothing keeps showing a title as not requested. +import { onSessionReset, sessionUserId } from './sessionState'; + +export const FRESH_MS = 3 * 60_000; +const KEEP_MS = 24 * 60 * 60_000; +const MAX_SAVED = 150; +// these change as people use the site: always asked again (still shown at once meanwhile) +const ALWAYS_CHECK = /^\/(request|auth|user\/\d+\/quota|settings\/public)/; + +type Entry = { at: number; data: unknown }; +let entries = new Map(); +let loadedFor: number | null | undefined; +const storeKey = (id: number) => `lumen-cache:${id}`; + +function ensureLoaded() { + const id = sessionUserId(); + if (loadedFor === id) return; + loadedFor = id; + entries = new Map(); + if (id == null) return; + try { + const saved = JSON.parse(localStorage.getItem(storeKey(id)) || '[]'); + const now = Date.now(); + if (Array.isArray(saved)) + for (const item of saved) { + if (!Array.isArray(item) || typeof item[0] !== 'string') continue; + const e = item[1] as Entry | undefined; + if (e && typeof e.at === 'number' && now - e.at < KEEP_MS) + entries.set(item[0], e); + } + } catch { + // unreadable or blocked storage: start empty + } +} + +let saveTimer: ReturnType | undefined; +function save() { + const id = sessionUserId(); + if (id == null || loadedFor !== id) return; + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + if (sessionUserId() !== id) return; + try { + const newest = [...entries] + .sort((a, b) => b[1].at - a[1].at) + .slice(0, MAX_SAVED); + localStorage.setItem(storeKey(id), JSON.stringify(newest)); + } catch { + // full or blocked storage: keep it in memory only + } + }, 400); +} + +export function cached(path: string): T | undefined { + ensureLoaded(); + return entries.get(path)?.data as T | undefined; +} + +export function isFresh(path: string) { + ensureLoaded(); + const e = entries.get(path); + return !!e && !ALWAYS_CHECK.test(path) && Date.now() - e.at < FRESH_MS; +} + +export function remember(path: string, data: unknown) { + ensureLoaded(); + entries.set(path, { at: Date.now(), data }); + save(); +} + +export function forget(prefix: string) { + ensureLoaded(); + for (const k of [...entries.keys()]) + if (k.startsWith(prefix)) entries.delete(k); + save(); +} + +// after a request or other change: keep showing what's there, but ask again next time +export function markAllStale() { + ensureLoaded(); + const stale = Date.now() - FRESH_MS - 1; + for (const e of entries.values()) e.at = Math.min(e.at, stale); + save(); +} + +onSessionReset(() => { + const previous = loadedFor; + if (previous != null && previous !== sessionUserId()) + try { + localStorage.removeItem(storeKey(previous)); + } catch { + // blocked storage: nothing was saved + } + clearTimeout(saveTimer); + entries = new Map(); + loadedFor = undefined; +}); diff --git a/lumen/src/components/brand.tsx b/lumen/src/components/brand.tsx index f72f62f..f0df569 100644 --- a/lumen/src/components/brand.tsx +++ b/lumen/src/components/brand.tsx @@ -7,7 +7,11 @@ export function BrandMark() { return ( <> {s.logo ? ( - + ) : ( diff --git a/lumen/src/components/cards.tsx b/lumen/src/components/cards.tsx index 228f03e..42db420 100644 --- a/lumen/src/components/cards.tsx +++ b/lumen/src/components/cards.tsx @@ -61,9 +61,12 @@ export function statusOf( return d ? { k: 'dl', - word: d.size - ? `Downloading ${Math.round((1 - d.sizeLeft / d.size) * 100)}%` - : 'Downloading', + // still all to come: waiting its turn in the download client's queue + word: !d.size + ? 'Downloading' + : d.sizeLeft >= d.size + ? 'Queued' + : `Downloading ${Math.round((1 - d.sizeLeft / d.size) * 100)}%`, ico: 'down', } : { k: 'wait', word: 'Requested', ico: 'clock' }; @@ -548,6 +551,26 @@ export const RailSkeleton = ({ n = 8, sq }: { n?: number; sq?: boolean }) => ( ); +// placeholder grid while a list's first page loads, so a slow answer doesn't look empty +export const GridSkeleton = ({ + n = 12, + sq, + className = 'grid', +}: { + n?: number; + sq?: boolean; + className?: string; +}) => ( +
+ {Array.from({ length: n }, (_, i) => ( +
+
+
+
+ ))} +
+); + // endless scrolling: loads the next page when this marker comes within a screen of view export function MoreOnScroll({ done, diff --git a/lumen/src/components/detail.tsx b/lumen/src/components/detail.tsx index 155dc88..7cac2e4 100644 --- a/lumen/src/components/detail.tsx +++ b/lumen/src/components/detail.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type ReactNode } from 'react'; import { api, type Person, type RequestOptions } from '../api'; import { useAuth } from '../auth'; +import { Glyph, type Stat } from './cards'; import { Icon, type IconName } from '../icons'; const hue = (s: string) => @@ -89,6 +90,7 @@ export function DetailTop({ title, by, line, + status, }: { backdrop?: string; poster?: string; @@ -96,6 +98,8 @@ export function DetailTop({ title: string; by?: ReactNode; line: ReactNode[]; + // in the library, requested or downloading: the posters' corner glyph, on this poster too + status?: Stat; }) { // the floating back button lives in Shell, so it's there while the page loads or if it fails return ( @@ -106,6 +110,7 @@ export function DetailTop({
{poster && } +

{title}

@@ -156,6 +161,16 @@ interface ServerDetail { // who may pick the Radarr/Sonarr server, quality and folder for a request export const canChooseRequestOptions = (permissions?: number) => ((permissions ?? 0) & MANAGE) !== 0; +// who may make a request in someone else's name: SeerrNG wants Manage Users and Manage +// Requests together (or Admin) +export const canRequestAs = (permissions?: number) => { + const p = permissions ?? 0; + return (p & 2) !== 0 || (p & (8 | 16)) === (8 | 16); +}; +interface RequestUser { + id: number; + displayName: string; +} export function AdvancedOptions({ kind, @@ -163,10 +178,13 @@ export function AdvancedOptions({ onChange, inline = false, onReady, + initial, }: { kind: 'movie' | 'tv'; is4k: boolean; onChange: (o: RequestOptions) => void; + // changing an existing request: start from its server, quality, folder and requester + initial?: RequestOptions; inline?: boolean; // always open, no "Advanced" toggle (the quick-request sheet) onReady?: (ready: boolean) => void; }) { @@ -178,6 +196,15 @@ export function AdvancedOptions({ const [detail, setDetail] = useState(null); const [err, setErr] = useState(''); const [o, setO] = useState({}); + const asAllowed = canRequestAs(user?.permissions); + const [people, setPeople] = useState(null); + const [asUser, setAsUser] = useState(initial?.userId); + useEffect(() => { + if (!open || !asAllowed || people) return; + api<{ results: RequestUser[] }>('/user?take=100&skip=0&sort=displayname') + .then((d) => setPeople(d.results)) + .catch(() => setPeople([])); // the picker just stays hidden + }, [open, asAllowed, people]); useEffect(() => { if (!open || servers) return; @@ -185,7 +212,10 @@ export function AdvancedOptions({ .then((s) => { const mine = s.filter((x) => x.is4k === is4k); setServers(mine); - const d = mine.find((x) => x.isDefault) || mine[0]; + const d = + mine.find((x) => x.id === initial?.serverId) || + mine.find((x) => x.isDefault) || + mine[0]; if (d) setO((x) => ({ ...x, serverId: d.id })); }) .catch((e) => setErr(e.message)); @@ -205,8 +235,18 @@ export function AdvancedOptions({ ? x : { ...x, - profileId: x.profileId ?? d.server.activeProfileId, - rootFolder: x.rootFolder ?? d.server.activeDirectory, + profileId: + x.profileId ?? + (serverId === initial?.serverId && + d.profiles.some((p) => p.id === initial?.profileId) + ? initial?.profileId + : d.server.activeProfileId), + rootFolder: + x.rootFolder ?? + (serverId === initial?.serverId && + d.rootFolders.some((r) => r.path === initial?.rootFolder) + ? initial?.rootFolder + : d.server.activeDirectory), } ); }) @@ -221,7 +261,19 @@ export function AdvancedOptions({ live = false; }; }, [o.serverId, svc]); - useEffect(() => onChange(open ? o : {}), [o, open, onChange]); + useEffect( + () => + onChange( + open + ? // a new request is the admin's own unless someone else is picked; a request being + // changed always says whose it is (picking "Me" moves it to the admin) + asUser && (asUser !== user?.id || initial?.userId !== undefined) + ? { ...o, userId: asUser } + : o + : {} + ), + [o, open, asUser, user?.id, initial?.userId, onChange] + ); useEffect(() => { onReady?.( !!detail && @@ -239,6 +291,24 @@ export function AdvancedOptions({ {!servers && !err && open &&

Loading servers…

} {servers && (
+ {asAllowed && !!people?.length && user && ( + + )} + ) : null; const copy = () => navigator.clipboard ?.writeText(json) @@ -161,11 +267,20 @@ export default function LumenSettings() {

- Pick what everyone gets, then download lumen-config.json and - put it next to Lumen (see “Admin defaults” in the Lumen guide). People - see the change the next time they open Lumen. “Always on” and “Always - off” can’t be changed on anyone’s You page; the others are just where - everyone starts. + {saveState?.canSave ? ( + <> + Pick what everyone gets, then select Save for everyone. + + ) : ( + <> + Pick what everyone gets, then download lumen-config.json{' '} + and put it next to Lumen (see “Admin defaults” in the Lumen + guide). + + )}{' '} + People see the change the next time they open Lumen. “Always on” and + “Always off” can’t be changed on anyone’s You page; the others are + just where everyone starts.

@@ -177,7 +292,7 @@ export default function LumenSettings() { Names in every label and the sign-in button. SeerrNG reports{' '} {mediaServer().source === 'seerrng' - ? mediaServer().name + ? mediaServer().product : 'its own server'} . @@ -198,6 +313,21 @@ export default function LumenSettings() { + -