Audiobooks: talk to OpenAudible directly with its own username and password (OPENAUDIBLE_URL)
build-container / build (push) Successful in 9m46s
build-container / build (push) Successful in 9m46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9231315186
commit
aa2133ed9a
7 files changed
+86
-6
No files matched your search
@@ -32,6 +32,7 @@ jobs:
|
||||
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
||||
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
|
||||
- run: node tools/abdirect-test.js # audiobooks straight from OpenAudible: its login, byte ranges, positions kept by the hub
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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. 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. 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`).
|
||||
(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,
|
||||
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`
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// Audiobooks from the audiobook player (https://git.necloud.us/nate/audiobook-player) at AUDIOBOOKS_URL: the library, covers, chapters, the audio itself
|
||||
// and each person's listening position. The hub signs in to the player as the person (their audiobook sign-in from Settings), or, when they have
|
||||
// none saved, with the site's shared secret link (AUDIOBOOKS_LOGIN_LINK), so no password or link ever reaches the browser.
|
||||
// Audiobooks: the library, covers, chapters, the audio itself and each person's listening position. Two ways to reach them:
|
||||
// - OPENAUDIBLE_URL set: the hub talks to OpenAudible directly, signing in with the username and password saved in Settings (OpenAudible's own login;
|
||||
// OPENAUDIBLE_USER / OPENAUDIBLE_PASSWORD are used when none are saved). The hub keeps each person's listening position itself.
|
||||
// - otherwise: through the audiobook player (https://git.necloud.us/nate/audiobook-player) at AUDIOBOOKS_URL, signing in to it as the person
|
||||
// (the account in Settings), or with the shared secret link (AUDIOBOOKS_LOGIN_LINK). The player keeps the positions.
|
||||
// No password or link ever reaches the browser.
|
||||
const { Readable } = require('stream');
|
||||
const fs = require('fs'), path = require('path');
|
||||
const settings = require('./settings');
|
||||
const users = require('./users');
|
||||
|
||||
const OA = (process.env.OPENAUDIBLE_URL || '').replace(/\/$/, ''); // direct mode
|
||||
const BASE = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
|
||||
const LINK = process.env.AUDIOBOOKS_LOGIN_LINK || '';
|
||||
const COOKIE = 'oa_session';
|
||||
@@ -31,7 +36,27 @@ async function signIn(me) {
|
||||
sessions.set(me.id, ck); return ck;
|
||||
}
|
||||
|
||||
// Direct to OpenAudible: Basic auth with the saved login (or the server's), and a clear message when it is refused.
|
||||
async function direct(path, init = {}) {
|
||||
const c = settings.get('ab_creds') || {}, u = c.u || process.env.OPENAUDIBLE_USER || '', p = c.u ? c.p : process.env.OPENAUDIBLE_PASSWORD || '';
|
||||
const headers = { ...init.headers }; if (u) headers.authorization = 'Basic ' + Buffer.from(u + ':' + p).toString('base64');
|
||||
else throw Object.assign(new Error('Add your OpenAudible username and password in Settings first'), { code: 503 });
|
||||
const r = await fetch(OA + path, { ...init, headers, redirect: 'manual' });
|
||||
if (r.status === 401) { await r.body?.cancel().catch(() => {}); throw Object.assign(new Error('OpenAudible did not accept the username and password saved in Settings'), { code: 502 }); }
|
||||
return r;
|
||||
}
|
||||
|
||||
// Listening positions kept by the hub (direct mode): one file per person, the newest time wins for each book.
|
||||
const progFile = () => path.join(users.dir(), 'audiobook-progress.json');
|
||||
const progLoad = () => { try { return JSON.parse(fs.readFileSync(progFile(), 'utf8')); } catch { return {}; } };
|
||||
function progMerge(body) {
|
||||
const mine = progLoad();
|
||||
for (const [k, v] of Object.entries(body || {})) if (v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) mine[k] = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
|
||||
const f = progFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine;
|
||||
}
|
||||
|
||||
async function upstream(me, path, init = {}, retry = true) {
|
||||
if (OA) return direct(path, init);
|
||||
const ck = sessions.get(me.id) || await signIn(me);
|
||||
const r = await fetch(BASE + path, { ...init, headers: { ...init.headers, cookie: ck }, redirect: 'manual' });
|
||||
if ((r.status === 401 || r.status === 302) && retry) { sessions.delete(me.id); await r.body?.cancel().catch(() => {}); return upstream(me, path, init, false); }
|
||||
@@ -44,6 +69,13 @@ async function handle(req, res) {
|
||||
const ctl = new AbortController(); res.on('close', () => ctl.abort());
|
||||
try {
|
||||
if (p === '/progress') {
|
||||
if (OA) { // positions live in the hub
|
||||
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') {
|
||||
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 });
|
||||
@@ -60,6 +92,16 @@ async function handle(req, res) {
|
||||
const r = await upstream(me, to, { headers, signal: ctl.signal });
|
||||
const out = { 'cache-control': p.endsWith('/cover') ? 'private, max-age=86400' : 'no-store' };
|
||||
for (const k of ['content-type', 'content-length', 'content-range', 'accept-ranges']) if (r.headers.get(k)) out[k] = r.headers.get(k);
|
||||
const want = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || ''), total = +r.headers.get('content-length') || 0;
|
||||
if (OA && want && r.status === 200 && total && (want[1] || want[2])) { // OpenAudible sent the whole file: phones need byte ranges, so cut the piece out here
|
||||
const a = want[1] === '' ? Math.max(0, total - +want[2]) : +want[1], b = want[1] === '' || want[2] === '' ? total - 1 : Math.min(+want[2], total - 1);
|
||||
if (a > b || a >= total) { await r.body.cancel().catch(() => {}); res.writeHead(416, { 'content-range': 'bytes */' + total }); return res.end(); }
|
||||
res.writeHead(206, { ...out, 'content-range': `bytes ${a}-${b}/${total}`, 'content-length': String(b - a + 1), 'accept-ranges': 'bytes' });
|
||||
let at = 0; const rd = r.body.getReader();
|
||||
try { for (;;) { const { done, value } = await rd.read(); if (done) break; const end = at + value.length; if (end > a && at <= b) { if (!res.write(value.subarray(Math.max(0, a - at), Math.min(value.length, b + 1 - at)))) await new Promise(ok => res.once('drain', ok)); } at = end; if (at > b) break; } }
|
||||
finally { rd.cancel().catch(() => {}); res.end(); }
|
||||
return;
|
||||
}
|
||||
res.writeHead(r.status, out);
|
||||
if (!r.body) return res.end();
|
||||
Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res);
|
||||
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-}
|
||||
LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it
|
||||
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
|
||||
OPENAUDIBLE_URL: ${OPENAUDIBLE_URL:-} # audiobooks straight from OpenAudible (e.g. http://172.16.16.106:8000); the username and password in Settings -> Audiobooks sign-in are OpenAudible's. Leave empty to go through the audiobook player instead
|
||||
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
|
||||
PODGRAB_PASSWORD: ${PODGRAB_PASSWORD:-} # Podgrab's PASSWORD setting, if it has one (the sign-in name is always podgrab); stays on the server
|
||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi; do node tools/$t-test.js || exit 1; done",
|
||||
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect; do node tools/$t-test.js || exit 1; done",
|
||||
"audit": "npm audit --omit=dev --audit-level=high"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ function viewSettings() {
|
||||
</div>
|
||||
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
|
||||
<div id="sec-mail"><h2>Mail</h2><div class="list" id="mailst"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>
|
||||
<div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy button on the Audiobooks tab to paste them into the site's sign-in form.</p>
|
||||
<div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Your OpenAudible username and password (the server's audiobook library login). Saved on your server (encrypted) and shared with all your devices. The Audiobooks tab uses them to load your library and play books.</p>
|
||||
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
|
||||
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
|
||||
<label for="ab-p">Password</label>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Audiobooks straight from OpenAudible (OPENAUDIBLE_URL): its own username and password, byte ranges cut by the hub, positions kept by the hub per person.
|
||||
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-abd-'));
|
||||
process.env.DATA_DIR = dir; process.env.COOKIE_SECRET = 'abd-'.repeat(8);
|
||||
const seen = [], AUDIO = Buffer.from('0123456789');
|
||||
const oa = http.createServer((req, res) => {
|
||||
seen.push({ u: req.url, auth: req.headers.authorization, range: req.headers.range });
|
||||
if (req.headers.authorization !== 'Basic ' + Buffer.from('nate:oapw').toString('base64')) { res.writeHead(401); return res.end(); }
|
||||
if (req.url === '/api/books') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('[{"id":"b1","title":"One"}]'); }
|
||||
if (req.url === '/api/books/b1/files/b1.mp3') { res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': AUDIO.length }); return res.end(AUDIO); } // ignores Range, like some servers
|
||||
res.writeHead(404); res.end();
|
||||
}).listen(0, async () => {
|
||||
process.env.OPENAUDIBLE_URL = 'http://127.0.0.1:' + oa.address().port;
|
||||
const users = require('../users'), abapi = require('../abapi'), me = { id: 'abd', name: 'N' };
|
||||
const put = c => { fs.mkdirSync(path.join(dir, 'users', me.id), { recursive: true }); fs.writeFileSync(path.join(dir, 'users', me.id, 'settings.json'), JSON.stringify({ ab_creds: c })); };
|
||||
put({ u: 'nate', p: 'oapw' });
|
||||
const hub = http.createServer((req, res) => users.runAs(me, () => abapi.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/ab', get = (p, o) => fetch(base + p, o);
|
||||
try {
|
||||
let r = await get('/books'); assert.equal(r.status, 200); assert.equal((await r.json())[0].title, 'One');
|
||||
r = await get('/books/b1/files/b1.mp3', { headers: { range: 'bytes=2-5' } }); assert.equal(r.status, 206); assert.equal(await r.text(), '2345'); assert.equal(r.headers.get('content-range'), 'bytes 2-5/10');
|
||||
r = await get('/books/b1/files/b1.mp3', { headers: { range: 'bytes=7-' } }); assert.equal(await r.text(), '789');
|
||||
r = await get('/books/b1/files/b1.mp3'); assert.equal(r.status, 200); assert.equal((await r.text()).length, 10);
|
||||
const post = b => get('/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }).then(x => x.json());
|
||||
await post({ b1: { pos: 50, dur: 100, t: 10 } }); let p = await post({ b1: { pos: 5, dur: 100, t: 5 }, b2: { pos: 1, dur: 9, t: 1 } });
|
||||
assert.equal(p.b1.pos, 50, 'the newest position wins'); assert.equal(p.b2.pos, 1);
|
||||
assert.equal((await (await get('/progress')).json()).b1.pos, 50);
|
||||
put({ u: 'nate', p: 'wrong' });
|
||||
r = await get('/books'); assert.equal(r.status, 502); assert.ok(/did not accept the username and password/.test((await r.json()).error));
|
||||
put({});
|
||||
r = await get('/books'); assert.equal(r.status, 503); assert.ok(/OpenAudible username and password in Settings/.test((await r.json()).error));
|
||||
console.log('audiobooks direct ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); oa.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user