Audiobooks: each person's listening positions are their own (the whole library stays shared)
build-container / build (push) Successful in 9m2s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 16:52:38 -04:00
1 parent 2635e4615d
commit 3f57e4bde1
3 files changed
+12 -13

No files matched your search

+1 -1
View File
@@ -50,7 +50,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks. - **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 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 Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as <name>" with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal. - **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 Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as <name>" with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal.
- **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. **Bottom bar and mini picture**: the video's controls live in the same bottom bar Music uses, on every tab: poster, title, time, a seek line you can tap or drag, back and forward 10 s, play/pause, and on wider screens previous and next episode and close. Tap the title to open the full player. Leave the player page and the picture keeps playing in a small window at the bottom right (click it to go back). On the player page, volume, speed, Quality & tracks, picture in picture, full screen and close are along the top; the on-video controls only appear in full screen. Music and video end each other (starting one stops the other). Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting. - **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. **Bottom bar and mini picture**: the video's controls live in the same bottom bar Music uses, on every tab: poster, title, time, a seek line you can tap or drag, back and forward 10 s, play/pause, and on wider screens previous and next episode and close. Tap the title to open the full player. Leave the player page and the picture keeps playing in a small window at the bottom right (click it to go back). On the player page, volume, speed, Quality & tracks, picture in picture, full screen and close are along the top; the on-video controls only appear in full screen. Music and video end each other (starting one stops the other). Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting.
- **Audiobooks** is a built-in screen for the [audiobook player](https://git.necloud.us/nate/audiobook-player) at `AUDIOBOOKS_URL`: your library with covers, Continue listening, search, chapters, and playback in the same player as Music. The hub signs in to the player as you with the audiobook sign-in saved in Settings (or, if you have none, the shared `AUDIOBOOKS_LOGIN_LINK`), so no password reaches the browser. Where you got to is kept by the audiobook player, so it matches its own page and your other devices. Search finds your books too. Set `OPENAUDIBLE_URL` to talk to OpenAudible directly instead: then the username and password in Settings -> Audiobooks sign-in are OpenAudible's own login (or `OPENAUDIBLE_USER` / `OPENAUDIBLE_PASSWORD` on the server), and the hub keeps each person's listening position itself. The old frame is still there as **Classic player** (`#/audio/classic`). - **Audiobooks** is a built-in screen for the [audiobook player](https://git.necloud.us/nate/audiobook-player) at `AUDIOBOOKS_URL`: your library with covers, Continue listening, search, chapters, and playback in the same player as Music. The hub signs in to the player as you with the audiobook sign-in saved in Settings (or, if you have none, the shared `AUDIOBOOKS_LOGIN_LINK`), so no password reaches the browser. Everyone sees the whole library, but where you got to in each book is your own: the hub keeps each person's positions on the server (so they follow you between devices), and with a personal audiobook-player sign-in they are also kept in step with the player's own page. Search finds your books too. Set `OPENAUDIBLE_URL` to talk to OpenAudible directly instead: then the username and password in Settings -> Audiobooks sign-in are OpenAudible's own login (or `OPENAUDIBLE_USER` / `OPENAUDIBLE_PASSWORD` on the server), and the hub keeps each person's listening position itself (as it does in both modes). The old frame is still there as **Classic player** (`#/audio/classic`).
(Classic player) 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 (Classic player) 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 Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page, the person may use the Audiobooks 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` 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`
+8 -12
View File
@@ -61,22 +61,18 @@ async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab'.length) || '/'; const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab'.length) || '/';
const ctl = new AbortController(); res.on('close', () => ctl.abort()); const ctl = new AbortController(); res.on('close', () => ctl.abort());
try { try {
if (p === '/progress') { if (p === '/progress') { // each person's own positions are kept by the hub; with a personal sign-in on the audiobook player they are also kept in step with it
if (OA) { // positions live in the hub const personal = !OA && (c => !!(c && c.u && c.p))(settings.get('ab_creds'));
if (req.method === 'GET') return json(res, 200, progLoad());
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e6) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
return json(res, 200, progMerge(body));
}
if (req.method === 'POST') { if (req.method === 'POST') {
const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e6) return json(res, 413, { error: 'Too large' }); chunks.push(c); } const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e6) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
const r = await upstream(me, '/sync/progress', { method: 'POST', body: Buffer.concat(chunks), headers: { 'content-type': 'application/json' }, signal: ctl.signal }); let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
res.writeHead(r.status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(Buffer.from(await r.arrayBuffer())); const merged = progMerge(body);
if (personal) upstream(me, '/sync/progress', { method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }).then(r => r.body?.cancel()).catch(() => {});
return json(res, 200, merged);
} }
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' }); if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
const r = await upstream(me, '/sync/progress', { signal: ctl.signal }); if (personal) { try { const r = await upstream(me, '/sync/progress', { signal: ctl.signal }); if (r.ok) progMerge(await r.json()); } catch { /* the hub's own copy is enough */ } }
res.writeHead(r.status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(Buffer.from(await r.arrayBuffer())); return json(res, 200, progLoad());
} }
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' }); if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
let to = null; for (const [re, f] of ROUTES) { const m = re.exec(p); if (m) { to = f(m); break; } } let to = null; for (const [re, f] of ROUTES) { const m = re.exec(p); if (m) { to = f(m); break; } }
+3
View File
@@ -31,6 +31,9 @@ const player = http.createServer((req, res) => {
r = await get('/books/b1/files/b1.mp3', { headers: { range: 'bytes=0-3' } }); assert.equal(r.status, 206); assert.equal(await r.text(), 'abcd'); assert.equal(seen.at(-1).range, 'bytes=0-3'); r = await get('/books/b1/files/b1.mp3', { headers: { range: 'bytes=0-3' } }); assert.equal(r.status, 206); assert.equal(await r.text(), 'abcd'); assert.equal(seen.at(-1).range, 'bytes=0-3');
r = await get('/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ b1: { pos: 12, dur: 100, t: 5 } }) }); assert.equal(r.status, 200); r = await get('/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ b1: { pos: 12, dur: 100, t: 5 } }) }); assert.equal(r.status, 200);
r = await get('/progress'); assert.equal((await r.json()).b1.pos, 12); r = await get('/progress'); assert.equal((await r.json()).b1.pos, 12);
await new Promise(ok => setTimeout(ok, 100)); assert.equal(progress.b1.pos, 12, 'a personal sign-in on the player is kept in step');
progress.b7 = { pos: 3, dur: 9, t: 99 }; r = await get('/progress'); assert.equal((await r.json()).b7.pos, 3, 'what the player knows is merged in');
r = await get('/progress', { headers: { 'x-nobody': '1' } }); assert.deepEqual(await r.json(), {}, 'another person starts with their own empty positions');
const logins = seen.filter(s => s.u === '/login').length; assert.equal(logins, 1, 'one sign-in is reused'); const logins = seen.filter(s => s.u === '/login').length; assert.equal(logins, 1, 'one sign-in is reused');
const n = seen.length; const n = seen.length;
for (const [m, p] of [['POST', '/books'], ['GET', '/admin/users'], ['GET', '/books/b1'], ['GET', '/books/b1/files'], ['DELETE', '/progress'], ['GET', '/books/../admin']]) { for (const [m, p] of [['POST', '/books'], ['GET', '/admin/users'], ['GET', '/books/b1'], ['GET', '/books/b1/files'], ['DELETE', '/progress'], ['GET', '/books/../admin']]) {