diff --git a/abapi.js b/abapi.js index 2db1df4..b270e0f 100644 --- a/abapi.js +++ b/abapi.js @@ -5,7 +5,6 @@ // (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'); @@ -46,14 +45,8 @@ async function direct(path, init = {}) { 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; -} +// Listening positions kept by the hub (direct mode) +const prog = require('./progress')('audiobook-progress.json'), progLoad = prog.load, progMerge = prog.merge; async function upstream(me, path, init = {}, retry = true) { if (OA) return direct(path, init); diff --git a/podcast.js b/podcast.js index 51f341e..cb64b5e 100644 --- a/podcast.js +++ b/podcast.js @@ -1,5 +1,6 @@ // Podcasts from Podgrab (https://github.com/akhilrex/podgrab). The hub talks to Podgrab's own web API on the person's behalf, so the // Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub. +const prog = require('./progress')('podcast-progress.json'); // where each person got to in each episode const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, ''); const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : ''; @@ -15,7 +16,6 @@ const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'applicat const enabled = () => !!BASE; async function handle(req, res) { - if (!BASE) return json(res, 503, { error: 'Podcasts are not set up on this server' }); const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/podcast'.length) || '/'; if (p === '/discover') { // find new shows in Apple's free podcast directory (the search words go to Apple; nothing else does) const q = (u.searchParams.get('q') || '').trim().slice(0, 100); @@ -27,6 +27,14 @@ async function handle(req, res) { return json(res, 200, out); } catch { return json(res, 502, { error: 'The podcast directory did not answer' }); } } + if (p === '/progress') { + if (req.method === 'GET') return json(res, 200, prog.load()); + 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, prog.merge(body)); + } + if (!BASE) return json(res, 503, { error: 'Podcasts are not set up on this server' }); if (p === '/status') return json(res, 200, { ok: true, host: new URL(BASE).host }); if (!ALLOWED.some(([m, re]) => m === req.method && re.test(p))) return json(res, 404, { error: 'Not found' }); const hasBody = req.method === 'POST' || req.method === 'PATCH'; diff --git a/progress.js b/progress.js new file mode 100644 index 0000000..d668436 --- /dev/null +++ b/progress.js @@ -0,0 +1,15 @@ +// Where each person has got to in each thing they listen to (audiobooks, podcast episodes), kept on the server so every device agrees. +// One file per person; for each item the newest report wins, so an old device cannot overwrite a newer position. +const fs = require('fs'), path = require('path'); +const users = require('./users'); + +module.exports = name => { + const file = () => path.join(users.dir(), name); + const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } }; + const merge = body => { + const mine = load(); + for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && 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 = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine; + }; + return { load, merge }; +}; diff --git a/public/podcast-view.js b/public/podcast-view.js index 0ba9974..a9327ce 100644 --- a/public/podcast-view.js +++ b/public/podcast-view.js @@ -12,6 +12,26 @@ 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', nocache: true, dur: (+pdF(e, 'duration') || 0) * 1000 }); +// Where you got to in each episode is kept on the server (per person), so it follows you between devices. +let pdProg = {}, pdLast = 0, pdQueued = {}; +const pdPlaying = () => { const t = cur(); return t && String(t.key).startsWith('pod:') ? t.key.slice(4) : ''; }; +const pdLoadProg = async () => { try { for (const [id, p] of Object.entries(await pdApi('/progress') || {})) if (!pdProg[id] || p.t > pdProg[id].t) pdProg[id] = p; } catch {} }; +const pdPush = keepalive => { + if (!Object.keys(pdQueued).length) return; + const body = JSON.stringify(pdQueued); pdQueued = {}; + fetch('/api/podcast/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive }) + .then(r => { if (!r.ok) throw 0; }).catch(() => { pdQueued = { ...JSON.parse(body), ...pdQueued }; }); +}; +const pdSave = keepalive => { + const id = pdPlaying(), d = pl.a.duration; if (!id || !isFinite(d) || d <= 0) return; + pdProg[id] = pdQueued[id] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; pdPush(keepalive); +}; +pl.a.addEventListener('timeupdate', () => { if (Date.now() - pdLast > 8000) { pdLast = Date.now(); pdSave(); } }); +pl.a.addEventListener('pause', () => pdSave()); +pl.a.addEventListener('ended', () => pdSave()); +addEventListener('pagehide', () => pdSave(true)); +const pdLeft = e => { const p = pdProg[pdId(e)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; }; // started but not finished + async function viewPodcast(args = []) { const v = $('#view'); v.innerHTML = '
Loading…
No episodes yet. Podgrab may still be fetching the feed.
'}