Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b3494b9720
commit
4468c2e025
4 files changed
+69
-8
No files matched your search
+48
@@ -1,5 +1,7 @@
|
||||
// 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 fs = require('fs'), path = require('path');
|
||||
const users = require('./users');
|
||||
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') : '';
|
||||
@@ -12,6 +14,23 @@ const ALLOWED = [
|
||||
['GET', /^\/podcastitems$/], ['GET', /^\/podcastitems\/[\w-]+$/], ['PATCH', /^\/podcastitems\/[\w-]+$/],
|
||||
['GET', /^\/podcastitems\/[\w-]+\/(image|file|markPlayed|markUnplayed|bookmark|unbookmark|download|delete)$/],
|
||||
];
|
||||
|
||||
// Podgrab holds every show in one shared library; which of them are on a person's own list is kept here, per person. A new person starts with an empty
|
||||
// list (the admin starts with everything already in Podgrab). Taking a show off your list leaves Podgrab alone unless nobody else follows it.
|
||||
const subsFile = uid => path.join(users.dirOf(uid), 'podcast-subs.json');
|
||||
const subsRead = uid => { try { const j = JSON.parse(fs.readFileSync(subsFile(uid), 'utf8')); return Array.isArray(j) ? j : null; } catch { return null; } };
|
||||
const subsWrite = (uid, ids) => { const f = subsFile(uid), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify([...new Set(ids)]), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||
const idOf = o => o && (o.ID !== undefined ? o.ID : o.id);
|
||||
async function podgrab(p, init = {}) {
|
||||
const r = await fetch(BASE + p, { ...init, headers: { accept: 'application/json', ...(AUTH ? { authorization: AUTH } : {}), ...init.headers }, redirect: 'manual', signal: AbortSignal.timeout(20000) });
|
||||
return r;
|
||||
}
|
||||
async function mySubs(me) {
|
||||
const have = subsRead(me.id); if (have) return have;
|
||||
let ids = [];
|
||||
if (me.role === 'admin') { try { const l = await (await podgrab('/podcasts')).json(); ids = (Array.isArray(l) ? l : []).map(idOf).filter(Boolean); } catch { return []; } }
|
||||
subsWrite(me.id, ids); return ids;
|
||||
}
|
||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
const enabled = () => !!BASE;
|
||||
|
||||
@@ -37,6 +56,35 @@ async function handle(req, res) {
|
||||
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 me = users.current(), mine = await mySubs(me);
|
||||
let m;
|
||||
try {
|
||||
if (req.method === 'GET' && p === '/podcasts') { // only the shows on this person's list
|
||||
const r = await podgrab('/podcasts' + u.search); const l = await r.json();
|
||||
return json(res, r.status, Array.isArray(l) ? l.filter(x => mine.includes(idOf(x))) : l);
|
||||
}
|
||||
if (req.method === 'POST' && p === '/podcasts') { // add to Podgrab (if it is not there yet) and to this person's list
|
||||
const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e5) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
|
||||
let url = ''; try { url = String(JSON.parse(Buffer.concat(chunks).toString('utf8')).url || '').trim(); } catch {}
|
||||
if (!/^https?:\/\//i.test(url)) return json(res, 400, { error: 'Enter a feed address starting with http' });
|
||||
const r = await podgrab('/podcasts', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url }) });
|
||||
let added = null; if (r.status === 200) { try { added = await r.json(); } catch {} }
|
||||
let id = idOf(added);
|
||||
if (!id) { // Podgrab already had it (409), or did not say which: find it by its feed address
|
||||
const l = await (await podgrab('/podcasts')).json(); const norm = x => String(x || '').replace(/\/+$/, '').toLowerCase();
|
||||
const hit = (Array.isArray(l) ? l : []).find(x => norm(x.URL || x.url) === norm(url)); id = idOf(hit); added = hit;
|
||||
if (!id) return json(res, r.status === 200 ? 502 : r.status, { error: 'Podgrab could not add that feed' });
|
||||
}
|
||||
subsWrite(me.id, [...mine, id]); return json(res, 200, added || { ID: id });
|
||||
}
|
||||
if (req.method === 'DELETE' && (m = /^\/podcasts\/([\w-]+)$/.exec(p))) { // off this person's list; out of Podgrab only when nobody else follows it
|
||||
subsWrite(me.id, mine.filter(x => x !== m[1]));
|
||||
const others = users.all().some(x => x.id !== me.id && (subsRead(x.id) || []).includes(m[1]));
|
||||
if (!others) await podgrab(p, { method: 'DELETE' }).catch(() => {});
|
||||
return json(res, 200, { ok: true, removed: !others });
|
||||
}
|
||||
if ((m = /^\/podcasts\/([\w-]+)(\/|$)/.exec(p)) && !mine.includes(m[1])) return json(res, 404, { error: 'That podcast is not on your list' });
|
||||
} catch (e) { return json(res, 502, { error: 'Podgrab did not answer' + (e.name === 'TimeoutError' ? ' in time' : '') }); }
|
||||
const hasBody = req.method === 'POST' || req.method === 'PATCH';
|
||||
let body;
|
||||
if (hasBody) { const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e5) return json(res, 413, { error: 'Too large' }); chunks.push(c); } body = Buffer.concat(chunks); }
|
||||
|
||||
Reference in new issue
Block a user