Podcast: resume where you left off, synced between devices (shared progress store with audiobooks)
build-container / build (push) Successful in 9m26s
build-container / build (push) Successful in 9m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa2133ed9a
commit
b3494b9720
5 files changed
+61
-16
No files matched your search
@@ -5,7 +5,6 @@
|
|||||||
// (the account in Settings), or with the shared secret link (AUDIOBOOKS_LOGIN_LINK). The player keeps the positions.
|
// (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.
|
// No password or link ever reaches the browser.
|
||||||
const { Readable } = require('stream');
|
const { Readable } = require('stream');
|
||||||
const fs = require('fs'), path = require('path');
|
|
||||||
const settings = require('./settings');
|
const settings = require('./settings');
|
||||||
const users = require('./users');
|
const users = require('./users');
|
||||||
|
|
||||||
@@ -46,14 +45,8 @@ async function direct(path, init = {}) {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listening positions kept by the hub (direct mode): one file per person, the newest time wins for each book.
|
// Listening positions kept by the hub (direct mode)
|
||||||
const progFile = () => path.join(users.dir(), 'audiobook-progress.json');
|
const prog = require('./progress')('audiobook-progress.json'), progLoad = prog.load, progMerge = prog.merge;
|
||||||
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) {
|
async function upstream(me, path, init = {}, retry = true) {
|
||||||
if (OA) return direct(path, init);
|
if (OA) return direct(path, init);
|
||||||
|
|||||||
+9
-1
@@ -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
|
// 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.
|
// 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 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') : '';
|
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;
|
const enabled = () => !!BASE;
|
||||||
|
|
||||||
async function handle(req, res) {
|
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) || '/';
|
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)
|
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);
|
const q = (u.searchParams.get('q') || '').trim().slice(0, 100);
|
||||||
@@ -27,6 +27,14 @@ async function handle(req, res) {
|
|||||||
return json(res, 200, out);
|
return json(res, 200, out);
|
||||||
} catch { return json(res, 502, { error: 'The podcast directory did not answer' }); }
|
} 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 (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' });
|
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';
|
const hasBody = req.method === 'POST' || req.method === 'PATCH';
|
||||||
|
|||||||
+15
@@ -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 };
|
||||||
|
};
|
||||||
+28
-3
@@ -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 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 });
|
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 = []) {
|
async function viewPodcast(args = []) {
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = '<div class="wrap"><h1>Podcast</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Podcast</h1><p class="empty">Loading…</p></div>';
|
||||||
@@ -59,11 +79,11 @@ async function pdHome() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pdShow(id) {
|
async function pdShow(id) {
|
||||||
const [show, items0] = await Promise.all([pdApi('/podcasts/' + encodeURIComponent(id)), pdApi('/podcasts/' + encodeURIComponent(id) + '/items')]);
|
const [, show, items0] = await Promise.all([pdLoadProg(), pdApi('/podcasts/' + encodeURIComponent(id)), pdApi('/podcasts/' + encodeURIComponent(id) + '/items')]);
|
||||||
if (!location.hash.startsWith('#/podcast')) return;
|
if (!location.hash.startsWith('#/podcast')) return;
|
||||||
const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate')));
|
const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate')));
|
||||||
const paused = !!pdF(show, 'isPaused'), ep = e => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
|
const paused = !!pdF(show, 'isPaused'), ep = e => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
|
||||||
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : ''].filter(Boolean).join(' · '))}</small></span>
|
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : pdLeft(e) ? pdDur(pdLeft(e).dur - pdLeft(e).pos) + ' left' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||||
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button></div>`;
|
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button></div>`;
|
||||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Back</a></div>
|
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Back</a></div>
|
||||||
<div style="display:flex;gap:16px;align-items:flex-start"><span class="vimg" style="width:120px;flex:none;aspect-ratio:1/1"><img alt="" src="${pdImg('podcasts', show)}" onerror="this.remove()"></span>
|
<div style="display:flex;gap:16px;align-items:flex-start"><span class="vimg" style="width:120px;flex:none;aspect-ratio:1/1"><img alt="" src="${pdImg('podcasts', show)}" onerror="this.remove()"></span>
|
||||||
@@ -72,7 +92,12 @@ async function pdShow(id) {
|
|||||||
<div class="bar">${items.length ? `<button class="btn" id="pdlatest">${icon('play')}Play latest</button>` : ''}<button class="btn ghost" id="pdmore">${icon('more')}More</button></div>
|
<div class="bar">${items.length ? `<button class="btn" id="pdlatest">${icon('play')}Play latest</button>` : ''}<button class="btn ghost" id="pdmore">${icon('more')}More</button></div>
|
||||||
<h2>Episodes</h2><div class="list">${items.map(ep).join('') || '<p class="empty">No episodes yet. Podgrab may still be fetching the feed.</p>'}</div></div>`;
|
<h2>Episodes</h2><div class="list">${items.map(ep).join('') || '<p class="empty">No episodes yet. Podgrab may still be fetching the feed.</p>'}</div></div>`;
|
||||||
const byId = new Map(items.map(e => [String(pdId(e)), e]));
|
const byId = new Map(items.map(e => [String(pdId(e)), e]));
|
||||||
const playFrom = e => { const i = items.indexOf(e); playQueue(items.slice(0, i + 1).reverse().map(x => pdTrack(x, show)), 0); }; // this one, then the newer ones after it
|
const playFrom = e => { // this one (from where you left off), then the newer ones after it
|
||||||
|
const i = items.indexOf(e), id = String(pdId(e)), p = pdLeft(e);
|
||||||
|
if (pdPlaying() === id) { if (pl.a.paused) pl.a.play().catch(() => {}); return; }
|
||||||
|
playQueue(items.slice(0, i + 1).reverse().map(x => pdTrack(x, show)), 0);
|
||||||
|
if (p) pl.a.addEventListener('loadedmetadata', () => { if (pdPlaying() === id) pl.a.currentTime = p.pos; }, { once: true });
|
||||||
|
};
|
||||||
document.querySelectorAll('.pdep').forEach(r => {
|
document.querySelectorAll('.pdep').forEach(r => {
|
||||||
const e = byId.get(r.dataset.id);
|
const e = byId.get(r.dataset.id);
|
||||||
r.querySelector('[data-play]').onclick = () => playFrom(e);
|
r.querySelector('[data-play]').onclick = () => playFrom(e);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Podcast proxy: only the allowed Podgrab calls get through, the Podgrab password is added server-side, audio ranges and redirects work.
|
// Podcast proxy: only the allowed Podgrab calls get through, the Podgrab password is added server-side, audio ranges and redirects work.
|
||||||
const http = require('http'), assert = require('assert');
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-pod-'));
|
||||||
const seen = [];
|
const seen = [];
|
||||||
const fake = http.createServer((req, res) => {
|
const fake = http.createServer((req, res) => {
|
||||||
let b = ''; req.on('data', c => b += c); req.on('end', () => {
|
let b = ''; req.on('data', c => b += c); req.on('end', () => {
|
||||||
@@ -11,8 +12,8 @@ const fake = http.createServer((req, res) => {
|
|||||||
});
|
});
|
||||||
}).listen(0, async () => {
|
}).listen(0, async () => {
|
||||||
process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret';
|
process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret';
|
||||||
const podcast = require('../podcast');
|
const podcast = require('../podcast'), users = require('../users');
|
||||||
const hub = http.createServer((req, res) => podcast.handle(req, res)).listen(0, async () => {
|
const hub = http.createServer((req, res) => users.runAs({ id: 'pod-user', name: 'P' }, () => podcast.handle(req, res))).listen(0, async () => {
|
||||||
const base = 'http://127.0.0.1:' + hub.address().port, get = (p, o = {}) => fetch(base + '/api/podcast' + p, { redirect: 'manual', ...o });
|
const base = 'http://127.0.0.1:' + hub.address().port, get = (p, o = {}) => fetch(base + '/api/podcast' + p, { redirect: 'manual', ...o });
|
||||||
try {
|
try {
|
||||||
let r = await get('/podcasts'); assert.equal(r.status, 200); assert.equal((await r.json())[0].Title, 'Show');
|
let r = await get('/podcasts'); assert.equal(r.status, 200); assert.equal((await r.json())[0].Title, 'Show');
|
||||||
@@ -20,6 +21,9 @@ const fake = http.createServer((req, res) => {
|
|||||||
r = await get('/podcastitems/e1/file', { 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('/podcastitems/e1/file', { 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('/podcastitems/e2/file'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), 'https://cdn.example.com/a.mp3');
|
r = await get('/podcastitems/e2/file'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), 'https://cdn.example.com/a.mp3');
|
||||||
r = await get('/podcasts', { method: 'POST', body: '{"url":"https://x.test/feed"}' }); assert.equal(r.status, 200); assert.equal(seen.at(-1).b, '{"url":"https://x.test/feed"}');
|
r = await get('/podcasts', { method: 'POST', body: '{"url":"https://x.test/feed"}' }); assert.equal(r.status, 200); assert.equal(seen.at(-1).b, '{"url":"https://x.test/feed"}');
|
||||||
|
const post = b => get('/progress', { method: 'POST', body: JSON.stringify(b) }).then(x => x.json());
|
||||||
|
await post({ e1: { pos: 90, dur: 600, t: 10 } }); const pr = await post({ e1: { pos: 5, dur: 600, t: 3 }, e2: { pos: 7, dur: 300, t: 4 } });
|
||||||
|
assert.equal(pr.e1.pos, 90, 'the newest position wins'); assert.equal((await (await get('/progress')).json()).e2.pos, 7);
|
||||||
const n = seen.length;
|
const n = seen.length;
|
||||||
for (const [m, p] of [['GET', '/settings'], ['POST', '/settings'], ['GET', '/opml'], ['DELETE', '/tags/1'], ['GET', '/backups'], ['GET', '/podcasts/../settings'], ['DELETE', '/podcastitems/e1']]) {
|
for (const [m, p] of [['GET', '/settings'], ['POST', '/settings'], ['GET', '/opml'], ['DELETE', '/tags/1'], ['GET', '/backups'], ['GET', '/podcasts/../settings'], ['DELETE', '/podcastitems/e1']]) {
|
||||||
r = await get(p, { method: m }); assert.equal(r.status, 404, m + ' ' + p);
|
r = await get(p, { method: m }); assert.equal(r.status, 404, m + ' ' + p);
|
||||||
|
|||||||
Reference in new issue
Block a user