diff --git a/README.md b/README.md index 0f6a0b2..2a907fc 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ 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 " 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**: 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 +- **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`). + (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` (and optionally `AUDIOBOOKS_SSO_URL`, default `/sso`) for short-lived signed links: `?token=.`, where `body` is base64url JSON diff --git a/abapi.js b/abapi.js new file mode 100644 index 0000000..30da471 --- /dev/null +++ b/abapi.js @@ -0,0 +1,72 @@ +// 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. +const { Readable } = require('stream'); +const settings = require('./settings'); +const users = require('./users'); + +const BASE = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, ''); +const LINK = process.env.AUDIOBOOKS_LOGIN_LINK || ''; +const COOKIE = 'oa_session'; +const sessions = new Map(); // person id -> the player's session cookie +const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const cookieOf = r => { const c = (r.headers.getSetCookie ? r.headers.getSetCookie() : []).map(x => x.split(';')[0]).find(x => x.startsWith(COOKIE + '=')); return c || ''; }; + +// Hub path -> player path (the player itself only serves read-only calls for these) +const ROUTES = [ + [/^\/books$/, () => '/api/books'], + [/^\/books\/([^/]+)\/(chapters\/flat|cover)$/, m => `/api/books/${m[1]}/${m[2]}`], + [/^\/books\/([^/]+)\/filelist$/, m => `/admin/api/books/${m[1]}/files`], + [/^\/books\/([^/]+)\/files\/([^/]+)$/, m => `/api/books/${m[1]}/files/${m[2]}`], +]; + +async function signIn(me) { + const c = settings.get('ab_creds') || {}; + let r; + if (c.u && c.p) r = await fetch(BASE + '/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: c.u, password: c.p }), redirect: 'manual', signal: AbortSignal.timeout(15000) }); + else if (LINK) r = await fetch(LINK, { redirect: 'manual', signal: AbortSignal.timeout(15000) }); + else throw Object.assign(new Error('Add your audiobook sign-in in Settings first'), { code: 503 }); + const ck = cookieOf(r); + if (!ck) throw Object.assign(new Error(c.u ? 'The audiobook site did not accept your saved sign-in' : 'The audiobook site did not accept the shared sign-in'), { code: 502 }); + sessions.set(me.id, ck); return ck; +} + +async function upstream(me, path, init = {}, retry = true) { + 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); } + return r; +} + +async function handle(req, res) { + const me = users.current(); + const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab'.length) || '/'; + const ctl = new AbortController(); res.on('close', () => ctl.abort()); + try { + if (p === '/progress') { + 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 }); + res.writeHead(r.status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(Buffer.from(await r.arrayBuffer())); + } + if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' }); + const r = await upstream(me, '/sync/progress', { signal: ctl.signal }); + res.writeHead(r.status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(Buffer.from(await r.arrayBuffer())); + } + 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; } } + if (!to) return json(res, 404, { error: 'Not found' }); + const headers = req.headers.range ? { range: req.headers.range } : {}; + 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); + res.writeHead(r.status, out); + if (!r.body) return res.end(); + Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res); + } catch (e) { + if (res.headersSent) return res.destroy(); + json(res, e.code || 502, { error: e.code ? e.message : 'The audiobook site did not answer' }); + } +} + +module.exports = { handle }; diff --git a/package.json b/package.json index 33a730e..c6ec433 100644 --- a/package.json +++ b/package.json @@ -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; 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; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/app.js b/public/app.js index 11ff1bd..94ded20 100644 --- a/public/app.js +++ b/public/app.js @@ -195,7 +195,7 @@ function openMore() { el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet)); } -const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: () => location.replace('#/links'), podcast: args => viewPodcast(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); +const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => viewPodcast(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); let editor; // Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device. const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } }; diff --git a/public/audiobooks-view.js b/public/audiobooks-view.js new file mode 100644 index 0000000..58ee784 --- /dev/null +++ b/public/audiobooks-view.js @@ -0,0 +1,107 @@ +'use strict'; +// ---- Audiobooks: your library from the audiobook player, through the hub (the hub signs in for you). Books play in the same player as Music, +// and where you got to is kept by the audiobook player, so it follows you between this app, the player's own page and your other devices. +const abApi = (p, opts) => jfetch('/api/ab' + p, opts); +const abPick = (o, ...ks) => { for (const k of ks) if (o && o[k] != null && o[k] !== '') return o[k]; }; +const abId = b => String(abPick(b, 'id', 'asin', 'ASIN', 'book_id')); +const abTitle = b => abPick(b, 'title', 'title_short', 'name') || '(untitled)'; +const abAuthor = b => { const a = abPick(b, 'author', 'author_name', 'authors', 'author_display') || ''; return Array.isArray(a) ? a.join(', ') : a; }; +const abSeries = b => abPick(b, 'series_name', 'series') || ''; +const abCover = b => `/api/ab/books/${encodeURIComponent(abId(b))}/cover`; +const abFmt = s => { s = Math.max(0, Math.floor(s || 0)); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); }; +const abHue = t => { let h = 0; for (const c of t) h = (h * 31 + c.charCodeAt(0)) % 360; return h; }; +let abBooks = null, abProg = {}; +const abIsPlaying = () => { const t = cur(); return t && String(t.key).startsWith('ab:') ? t.key.slice(3) : ''; }; + +async function abFile(id) { + const data = await abApi(`/books/${encodeURIComponent(id)}/filelist`); + const arr = Array.isArray(data) ? data : (data.files || Object.values(data)); + const names = arr.map(f => typeof f === 'string' ? f : abPick(f, 'name', 'filename', 'file', 'path')).filter(Boolean).map(n => n.split('/').pop()); + return names.find(n => /\.mp3$/i.test(n)) || names.find(n => /\.(mp3|m4b|m4a|aac|ogg|opus|flac|wav)$/i.test(n)); +} +async function abChapters(id) { + try { + const data = await abApi(`/books/${encodeURIComponent(id)}/chapters/flat`), arr = Array.isArray(data) ? data : (data.chapters || []); + return arr.map(c => { let s = abPick(c, 'start', 'start_sec', 'start_seconds', 'startOffsetSec', 'start_offset_sec'); + if (s == null) { const ms = abPick(c, 'start_ms', 'start_offset_ms', 'startOffsetMs', 'offset_ms'); if (ms != null) s = ms / 1000; } + return { title: abPick(c, 'title', 'name') || 'Chapter', start: Number(s) }; }).filter(c => !isNaN(c.start)); + } catch { return []; } +} + +/** Start (or carry on with) a book: from where you got to, unless that was the very end. */ +async function abPlay(b, at) { + const id = abId(b); + if (abIsPlaying() === id) { if (at != null) pl.a.currentTime = at; else if (pl.a.paused) pl.a.play().catch(() => {}); return; } + const file = await abFile(id); + if (!file) throw new Error('No playable MP3 or M4B for this book. Convert it in OpenAudible first.'); + const pr = abProg[id], start = at != null ? at : pr && pr.pos > 10 && (!pr.dur || pr.pos < pr.dur - 30) ? pr.pos : 0; + playQueue([{ key: 'ab:' + id, title: abTitle(b), artist: abAuthor(b), album: abSeries(b), thumb: abCover(b), src: `/api/ab/books/${encodeURIComponent(id)}/files/${encodeURIComponent(file)}`, dur: 0, nocache: true }], 0); + if (start) pl.a.addEventListener('loadedmetadata', () => { if (abIsPlaying() === id) pl.a.currentTime = start; }, { once: true }); +} + +// Where you got to goes back to the audiobook player every few seconds while listening, and when you pause or leave. +let abLast = 0, abQueued = {}; +const abPush = keepalive => { + if (!Object.keys(abQueued).length) return; + const body = JSON.stringify(abQueued); abQueued = {}; + fetch('/api/ab/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive }) + .then(r => { if (!r.ok) throw 0; }).catch(() => { abQueued = { ...JSON.parse(body), ...abQueued }; }); +}; +const abSave = keepalive => { + const id = abIsPlaying(), d = pl.a.duration; if (!id || !isFinite(d) || d <= 0) return; + abProg[id] = abQueued[id] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; abPush(keepalive); +}; +pl.a.addEventListener('timeupdate', () => { if (Date.now() - abLast > 8000) { abLast = Date.now(); abSave(); } }); +pl.a.addEventListener('pause', () => abSave()); +pl.a.addEventListener('ended', () => abSave()); +addEventListener('pagehide', () => abSave(true)); + +/** Fetch where you got to in each book (from the audiobook player) and fold it into what this page already knows. */ +const abLoadProg = async () => { try { for (const [id, p] of Object.entries(await abApi('/progress') || {})) if (!abProg[id] || p.t > abProg[id].t) abProg[id] = p; } catch {} }; + +async function viewBooks(args = []) { + const v = $('#view'); + v.innerHTML = '

Audiobooks

Loading…

'; + try { + const [data] = await Promise.all([abApi('/books'), abLoadProg()]); + abBooks = Array.isArray(data) ? data : (data.books || Object.values(data)); + } catch (e) { + if (!location.hash.startsWith('#/audio')) return; + v.innerHTML = `

Audiobooks

${esc(e.message)}

`; + $('#abretry').onclick = () => route(); return; + } + if (!location.hash.startsWith('#/audio')) return; + const pct = b => { const p = abProg[abId(b)]; return p && p.dur ? Math.min(100, p.pos / p.dur * 100) : 0; }; + const card = (b, wide) => ``; + const cont = abBooks.filter(b => { const p = abProg[abId(b)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; }).sort((a, b) => abProg[abId(b)].t - abProg[abId(a)].t).slice(0, 10); + v.innerHTML = `

Audiobooks

Classic player
+ ${cont.length ? `

Continue listening

${cont.map(b => card(b, true)).join('')}
` : ''} + +

Library

`; + const draw = () => { + const q = $('#abq').value.trim().toLowerCase(), list = abBooks.filter(b => !q || (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(q)) + .sort((a, b) => abTitle(a).localeCompare(abTitle(b))); + $('#abgrid').innerHTML = list.map(b => card(b)).join('') || `

${abBooks.length ? 'No books match.' : 'No books in the library yet.'}

`; + }; + draw(); $('#abq').oninput = draw; + const byId = new Map(abBooks.map(b => [abId(b), b])); + v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); }; +} + +function abSheet(b) { + const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30; + const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `
+

${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}

+
${resumable ? '' : ''}
`); + const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } }; + el.querySelector('#abp').onclick = () => go(); + if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0); + el.querySelector('#abc').onclick = async () => { + const box = el.querySelector('#abch'); box.innerHTML = '

Loading…

'; + const ch = await abChapters(id); + box.innerHTML = ch.length ? ch.map((c, i) => ``).join('') : '

This book has no chapter list.

'; + box.querySelectorAll('button[data-i]').forEach(x => x.onclick = () => go(ch[x.dataset.i].start)); + }; +} diff --git a/public/index.html b/public/index.html index 959f904..c83a1cc 100644 --- a/public/index.html +++ b/public/index.html @@ -70,6 +70,7 @@ + diff --git a/public/music.js b/public/music.js index 5b6341f..ac5dd91 100644 --- a/public/music.js +++ b/public/music.js @@ -13,7 +13,7 @@ const px = { if (!r.ok) throw new Error('Plex error ' + r.status); return (await r.json()).MediaContainer || {}; }, - art: (thumb, w = 300) => thumb && thumb.startsWith('/api/podcast/') ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', + art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab)\//.test(thumb) ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', // All music libraries on the server; the chosen one is remembered per server. async sections() { if (px.secs) return px.secs; diff --git a/public/player.js b/public/player.js index f39ca55..80dd050 100644 --- a/public/player.js +++ b/public/player.js @@ -22,7 +22,7 @@ const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSear // so changing songs is instant and a slow or lost connection does not stop the music. const sleep = ms => new Promise(ok => setTimeout(ok, ms)); const tc = { m: new Map(), ctl: null, busy: false }; -const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key)).map(t => t.src); +const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); // (long audio, such as audiobooks, is streamed, not saved whole) function tcTrim() { const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src)); for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); } diff --git a/public/podcast-view.js b/public/podcast-view.js index 427a2cc..0ba9974 100644 --- a/public/podcast-view.js +++ b/public/podcast-view.js @@ -10,7 +10,7 @@ const pdDur = s => { s = Math.round(+s || 0); if (!s) return ''; const h = Math. const pdDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; }; const pdTime = d => Math.max(0, Date.parse(d) || 0); const pdPlain = h => { const d = document.createElement('div'); d.innerHTML = String(h || ''); return (d.textContent || '').trim(); }; -const pdTrack = (e, show) => ({ key: 'pod:' + pdId(e), title: pdF(e, 'title') || 'Episode', artist: pdF(show, 'title') || '', album: '', thumb: pdImg('podcasts', show), src: '/api/podcast/podcastitems/' + encodeURIComponent(pdId(e)) + '/file', dur: (+pdF(e, 'duration') || 0) * 1000 }); +const pdTrack = (e, show) => ({ key: 'pod:' + pdId(e), title: pdF(e, 'title') || 'Episode', artist: pdF(show, 'title') || '', album: '', thumb: pdImg('podcasts', show), src: '/api/podcast/podcastitems/' + encodeURIComponent(pdId(e)) + '/file', nocache: true, dur: (+pdF(e, 'duration') || 0) * 1000 }); async function viewPodcast(args = []) { const v = $('#view'); diff --git a/public/search-view.js b/public/search-view.js index 90b6561..16e769f 100644 --- a/public/search-view.js +++ b/public/search-view.js @@ -38,8 +38,14 @@ async function searchPodcasts(term) { return (Array.isArray(list) ? list : []).filter(s => ((pdF(s, 'title') || '') + ' ' + (pdF(s, 'author') || '')).toLowerCase().includes(lower)).slice(0, 12); } +// The audiobook library, matched on title, author and series. +async function searchBooks(term) { + const [data] = await Promise.all([jfetch('/api/ab/books'), abLoadProg()]), all = Array.isArray(data) ? data : (data.books || Object.values(data)), lower = term.toLowerCase(); + return all.filter(b => (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(lower)).slice(0, 10); +} + function viewSearch() { - $('#view').innerHTML = `

Search

+ $('#view').innerHTML = `

Search

@@ -54,7 +60,7 @@ function viewSearch() { const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`; const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓'; const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓'; - el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)} · Movies & TV ${part(r.video, musicOff)} · Podcasts ${part(r.pods, '✓')}`; + el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)} · Movies & TV ${part(r.video, musicOff)} · Podcasts ${part(r.pods, '✓')} · Audiobooks ${part(r.books, '✓')}`; }; jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources()); showSources(); @@ -68,12 +74,13 @@ function viewSearch() { const enc = encodeURIComponent, lower = term.toLowerCase(); const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server); const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out')); - const [libs, files, music, video, pods, ddg, hub] = await Promise.allSettled([ + const [libs, files, music, video, pods, books, ddg, hub] = await Promise.allSettled([ libsP, libsP.then(l => searchFiles(term, l)), plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')), plexReady ? searchVideo(term) : Promise.reject(new Error('not connected')), allowed('podcast') ? searchPodcasts(term) : Promise.reject(new Error('not allowed')), + allowed('audio') ? searchBooks(term) : Promise.reject(new Error('not allowed')), jfetch('/api/ddg?q=' + enc(term)), jfetch('/api/search?q=' + enc(term)), ]); @@ -95,6 +102,7 @@ function viewSearch() { } const videoHtml = (video.status === 'fulfilled' ? video.value : []).map(m => vdCard(m)).join(''); const podHtml = (pods.status === 'fulfilled' ? pods.value : []).map(s => `${esc(pdF(s, 'title'))}Podcast${pdF(s, 'author') ? ' · ' + esc(pdF(s, 'author')) : ''}`).join(''); + const bookHtml = (books.status === 'fulfilled' ? books.value : []).map(b => `${esc(abTitle(b))}Audiobook${abAuthor(b) ? ' · ' + esc(abAuthor(b)) : ''}`).join(''); const d = ddg.status === 'fulfilled' ? ddg.value : {}; const card = (d.text || d.answer) ? `
${d.image ? `` : ''} @@ -114,13 +122,15 @@ function viewSearch() { files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' }, music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message }, pods: !allowed('podcast') ? undefined : pods.status === 'fulfilled' ? { n: pods.value.length } : { err: pods.reason && pods.reason.message }, + books: !allowed('audio') ? undefined : books.status === 'fulfilled' ? { n: books.value.length } : { err: books.reason && books.reason.message }, video: !plexReady ? undefined : video.status === 'fulfilled' ? { n: video.value.length } : { err: video.reason && video.reason.message }, }); const notes = [...(h.errors || [])].map(e => `

${esc(e)}

`).join(''); const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `${icon('link')}${esc(l.title || l.url)}${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}`).join('') : ''; - res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (videoHtml ? `

Movies & TV

${videoHtml}
` : '') + notes; - if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !card) res.innerHTML = '

No results. The line above shows what was searched.

' + notes; + res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `

Movies & TV

${videoHtml}
` : '') + notes; + if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !card) res.innerHTML = '

No results. The line above shows what was searched.

' + notes; res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); }); + res.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (books.value || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); }); res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0)); res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; }); } diff --git a/public/sw.js b/public/sw.js index 2e34b56..775602a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v11'; +const CACHE = 'hub-v12'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/server.js b/server.js index 69d6dab..46971df 100644 --- a/server.js +++ b/server.js @@ -36,6 +36,7 @@ const backup = require('./backup'); const push = require('./push'); const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (MS_CLIENT_ID) const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL) +const abapi = require('./abapi'); // audiobook player library, audio and progress (AUDIOBOOKS_URL) const podcast = require('./podcast'); // Podgrab podcasts (PODCAST_URL, PODGRAB_PASSWORD) const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL) const vault = require('./vault')('nehub-plex'); @@ -532,7 +533,7 @@ const ACCESS = [ [/^\/sf\//, ['files', 'office']], [/^\/api\/oo\/config$/, ['office', 'files']], [/^\/(api\/plex\/|plex\/)/, ['music', 'video']], - [/^\/api\/audiobooks\//, ['audio']], + [/^\/api\/(audiobooks|ab)\//, ['audio']], [/^\/api\/podcast\//, ['podcast']], [/^\/api\/signal\//, ['signal']], [/^\/api\/lyrics$/, ['music']], @@ -610,6 +611,7 @@ async function handleRequest(req, res) { res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end(); } if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server'); + if (pathname.startsWith('/api/ab/')) return abapi.handle(req, res); if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res); if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server'); if (pathname.startsWith('/api/mail/')) return mail.handle(req, res); diff --git a/settings.js b/settings.js index bce52b5..264ebcd 100644 --- a/settings.js +++ b/settings.js @@ -47,4 +47,7 @@ async function handle(req, res) { } catch (e) { json(res, 500, { error: e.message }); } } -module.exports = { handle, KEYS }; +// One saved value for the current person, read on the server (secret ones are decrypted). +const get = k => { const v = load()[k]; return SECRET.has(k) && v && v._sealed ? unseal(v) : v; }; + +module.exports = { handle, KEYS, get }; diff --git a/tools/abapi-test.js b/tools/abapi-test.js new file mode 100644 index 0000000..775b8e3 --- /dev/null +++ b/tools/abapi-test.js @@ -0,0 +1,45 @@ +// Audiobooks through the hub: the hub signs in to the player as the person, streams audio with ranges, keeps progress per person, and blocks everything else. +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-ab-')); +process.env.DATA_DIR = dir; process.env.COOKIE_SECRET = 'ab-'.repeat(12); +const seen = []; let progress = {}; +const player = http.createServer((req, res) => { + let b = ''; req.on('data', c => b += c); req.on('end', () => { + seen.push({ m: req.method, u: req.url, cookie: req.headers.cookie, range: req.headers.range }); + const j = (c, o, h = {}) => { res.writeHead(c, { 'content-type': 'application/json', ...h }); res.end(JSON.stringify(o)); }; + if (req.url === '/login') { const o = JSON.parse(b); return o.username === 'nate' && o.password === 'pw' ? j(200, { user: 'nate' }, { 'set-cookie': 'oa_session=tok1; Path=/; HttpOnly' }) : j(401, { error: 'wrong' }); } + if (!/oa_session=tok1/.test(req.headers.cookie || '')) return j(401, { error: 'not signed in' }); + if (req.url === '/api/books') return j(200, [{ id: 'b1', title: 'Book One', author: 'A. Writer' }]); + if (req.url === '/admin/api/books/b1/files') return j(200, ['b1.m4b', 'b1.mp3']); + if (req.url === '/api/books/b1/files/b1.mp3') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); } + if (req.url === '/sync/progress' && req.method === 'POST') { progress = { ...progress, ...JSON.parse(b) }; return j(200, progress); } + if (req.url === '/sync/progress') return j(200, progress); + j(404, { error: 'no' }); + }); +}).listen(0, async () => { + process.env.AUDIOBOOKS_URL = 'http://127.0.0.1:' + player.address().port; + const users = require('../users'), abapi = require('../abapi'); + const me = { id: 'ab-user', name: 'Nate' }; + fs.mkdirSync(path.join(dir, 'users', me.id), { recursive: true }); + fs.writeFileSync(path.join(dir, 'users', me.id, 'settings.json'), JSON.stringify({ ab_creds: { u: 'nate', p: 'pw' } })); + const hub = http.createServer((req, res) => users.runAs(req.headers['x-nobody'] ? { id: 'nobody', name: 'N' } : me, () => abapi.handle(req, res))).listen(0, async () => { + const base = 'http://127.0.0.1:' + hub.address().port, get = (p, o = {}) => fetch(base + '/api/ab' + p, { redirect: 'manual', ...o }); + try { + let r = await get('/books'); assert.equal(r.status, 200); assert.equal((await r.json())[0].title, 'Book One'); + assert.ok(/oa_session=tok1/.test(seen.at(-1).cookie), 'signed in as the person'); + r = await get('/books/b1/filelist'); assert.deepEqual(await r.json(), ['b1.m4b', 'b1.mp3']); + 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'); assert.equal((await r.json()).b1.pos, 12); + const logins = seen.filter(s => s.u === '/login').length; assert.equal(logins, 1, 'one sign-in is reused'); + 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']]) { + r = await get(p, { method: m }); assert.ok([404, 405].includes(r.status), m + ' ' + p + ' -> ' + r.status); + } + assert.equal(seen.length, n, 'blocked calls never reach the player'); + r = await get('/books', { headers: { 'x-nobody': '1' } }); assert.equal(r.status, 503); assert.ok(/sign-in in Settings/.test((await r.json()).error), 'someone with no saved sign-in is told what to do'); + console.log('audiobooks ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); player.close(); + }); +});