From bc3710807d378969aeb5a0b542eb455a5d147a4b Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:26:18 -0400 Subject: [PATCH] Seafile sign-in follows the person across devices; sturdier Plex player; audiobooks frame with server-side secret sign-in - Seafile token is saved on the server (encrypted) so Settings shows the real signed-in state everywhere - Player: whole-track cache for current and next two, stall recovery, instant track changes, shuffle/repeat, Plex play counts, transcode fallback, Plex request timeout - Audio tab: toolbar, full-height kept-alive frame; /api/audiobooks/enter adds the secret login link on the server Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 6 ++ README.md | 11 ++- docker-compose.yml | 3 + public/app.js | 216 +++++++++++++++++++++++++++++++++++-------- public/style.css | 10 +- server.js | 21 ++++- settings.js | 6 +- tools/player-test.js | 34 +++++++ 8 files changed, 263 insertions(+), 44 deletions(-) create mode 100644 tools/player-test.js diff --git a/.env.example b/.env.example index 9ed6103..b989e1f 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,9 @@ VERSE_URL=https://versebyversewithnate.us/admin PODCAST_URL=https://podcast.necloud.us/ # Days you stay signed in without using the app (it renews every time you do) SESSION_DAYS=180 + +# Audiobooks secret sign-in link (optional; anyone holding it is signed in there, so never commit it) +AUDIOBOOKS_LOGIN_LINK= +# or per-person signed links (optional): shared secret the audiobooks site uses to verify signed links +AUDIOBOOKS_SSO_SECRET= +#AUDIOBOOKS_SSO_URL=https://audiobooks.necloud.us/sso diff --git a/README.md b/README.md index 9dd41ae..0aceef7 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,15 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke - **Tasks**: to-do lists stored in NE Hub (`/data/tasks.json`), with due dates, notes and completion. If the connected iCloud account exposes reminder lists over CalDAV they appear too and sync both ways. Apple hides Reminders lists that were upgraded for iOS 13+, so for most accounts only NE Hub lists are available. -- **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. Per-device items (Seafile sign-in token, music queue) stay in the browser. +- **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. The Seafile sign-in token is saved there too (encrypted), so Settings shows Seafile as signed in on every device. Only the music queue stays in the browser. - **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks. -- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the queue (play next / add to queue) and position are remembered. -- **Audio**: embeds audiobooks.necloud.us; sign-in details saved in Settings can be copied with one tap. +- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the queue (play next / add to queue) and position are remembered. +- **Audio**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons. + Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks + the person may use the Audio tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page, + script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET` + (and optionally `AUDIOBOOKS_SSO_URL`, default `/sso`) for short-lived signed links: `?token=.`, where `body` is base64url JSON + `{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied. - **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs. - **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing. - **Mail**: deep links to Gmail, Outlook.com, Microsoft 365 and iCloud webmail (inbox + compose). diff --git a/docker-compose.yml b/docker-compose.yml index 1fc0b62..ad5e108 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ services: SEAFILE_URL: ${SEAFILE_URL:-https://seafile.necloud.us} ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us} AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us} + AUDIOBOOKS_LOGIN_LINK: ${AUDIOBOOKS_LOGIN_LINK:-} + AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-} + AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-} VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin} PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/} ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret diff --git a/public/app.js b/public/app.js index 8e78d55..8b81d9b 100644 --- a/public/app.js +++ b/public/app.js @@ -9,8 +9,8 @@ window.fetch = async (...a) => { const $ = s => document.querySelector(s); const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different -// device, or the separate storage of the iOS home-screen app. Per-device things (Seafile token, music queue) stay local. -const SYNCED = new Set(['mail_hints', 'ab_creds', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']); +// device, or the separate storage of the iOS home-screen app. Only the music queue stays local. +const SYNCED = new Set(['mail_hints', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']); const outbox = new Map(); let outTimer = null, warned = false; function flushSettings() { outTimer = null; @@ -62,12 +62,16 @@ const ICONS = { file: '', dl: '', audio: '', + refresh: '', + full: '', + ext: '', copy: '', music: '', play: '', pause: '', next: '', prev: '', + repeat: '', shuffle: '', more: '', cal: '', @@ -854,19 +858,30 @@ function viewPodcast() { if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write'); } +// The frame is created once and kept alive, so playback carries on while you use other tabs. +// It loads /api/audiobooks/enter, where the server adds the site's secret sign-in link itself (the key never reaches this page). +const ENTER = '/api/audiobooks/enter'; function viewAudio() { - const url = cfg.audiobooks, c = abCreds(); - $('#view').innerHTML = `

Audio

-

${esc(new URL(url).host)}

-
Open in new tab - ${c.u ? `` : ''} - ${c.p ? `` : ''} - ${c.u || c.p ? '' : 'Save sign-in'}
`; - showFrame('audio', url, 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write'); // created once, so playback carries on across tabs - const copy = async (v, what) => { try { await navigator.clipboard.writeText(v); toast(what + ' copied. Paste it into the sign-in box.'); } catch { toast('Could not copy'); } }; - const cu = $('#cu'), cp = $('#cp'); - if (cu) cu.onclick = () => copy(c.u, 'Username'); - if (cp) cp.onclick = () => copy(c.p, 'Password'); + const c = abCreds(), old = $('#audiowrap iframe[data-k="audio"]'); + $('#view').innerHTML = `
Audiobooks${old ? '' : 'Opening…'} + ${c.u ? `` : ''} + + + ${icon('ext')}
`; + const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; }; + const open = fresh => { + setSt('Opening…'); + let f = $('#audiowrap iframe[data-k="audio"]'); if (f) f.remove(); + showFrame('audio', ENTER + (fresh ? '?t=' + Date.now() : ''), 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media'); + f = $('#audiowrap iframe[data-k="audio"]'); f.referrerPolicy = 'no-referrer'; + let done = false; + f.addEventListener('load', () => { done = true; setSt(''); }); + setTimeout(() => { if (!done) setSt('Still loading… if it stays blank, tap the arrow to open it in its own tab'); }, 10000); + }; + if (!old) open(false); else { showFrame('audio', '', 'Audiobooks', ''); setSt(''); } + $('#ar').onclick = () => open(true); + $('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="audio"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); }; + const cu = $('#cu'); if (cu) cu.onclick = async () => { try { await navigator.clipboard.writeText(c.u); toast('Username copied. Password is in Settings.'); } catch { toast('Could not copy'); } }; } // ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here) @@ -1018,7 +1033,7 @@ async function musicRoute([kind = 'albums', key], st) { v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p)); v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => trackSheet(tracks[+b.dataset.m])); $('#pa').onclick = () => tracks.length && playQueue(tracks, 0); - $('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0); + $('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true); markPlaying(); return; } @@ -1027,14 +1042,99 @@ async function musicRoute([kind = 'albums', key], st) { // ---- Player: one