Files
hub/podcast.js
T
2026-10-22 14:42:28 -04:00

61 lines
4.4 KiB
JavaScript

// 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 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') : '';
// Only these Podgrab calls are passed through: [method, path pattern]. Podgrab's page routes and its settings, backup and OPML routes are not.
const ALLOWED = [
['GET', /^\/podcasts$/], ['POST', /^\/podcasts$/],
['GET', /^\/podcasts\/[\w-]+$/], ['DELETE', /^\/podcasts\/[\w-]+$/],
['GET', /^\/podcasts\/[\w-]+\/(items|image|download|pause|unpause)$/], ['DELETE', /^\/podcasts\/[\w-]+\/items$/],
['GET', /^\/podcastitems$/], ['GET', /^\/podcastitems\/[\w-]+$/], ['PATCH', /^\/podcastitems\/[\w-]+$/],
['GET', /^\/podcastitems\/[\w-]+\/(image|file|markPlayed|markUnplayed|bookmark|unbookmark|download|delete)$/],
];
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
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);
if (q.length < 2) return json(res, 200, []);
try {
const r = await fetch('https://itunes.apple.com/search?' + new URLSearchParams({ media: 'podcast', limit: '15', term: q }), { signal: AbortSignal.timeout(10000) });
if (!r.ok) throw new Error('status ' + r.status);
const out = ((await r.json()).results || []).filter(x => /^https?:\/\//i.test(x.feedUrl || '')).map(x => ({ title: x.collectionName, author: x.artistName, feedUrl: x.feedUrl, image: x.artworkUrl100 }));
return json(res, 200, out);
} catch { return json(res, 502, { error: 'The podcast directory did not answer' }); }
}
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';
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); }
const h = { accept: req.headers.accept || '*/*', 'accept-encoding': 'identity' }; // so the length Podgrab reports is the length we pass on
if (AUTH) h.authorization = AUTH;
if (req.headers.range) h.range = req.headers.range;
if (hasBody) h['content-type'] = 'application/json';
const ctl = new AbortController(), streaming = p.endsWith('/file'), t = streaming ? null : setTimeout(() => ctl.abort(), 20000); // audio may take as long as it takes
res.on('close', () => { clearTimeout(t); ctl.abort(); });
try {
const up = await fetch(BASE + p + u.search, { method: req.method, headers: h, body, signal: ctl.signal, redirect: 'manual' });
const out = { 'cache-control': p.endsWith('/image') ? 'private, max-age=86400' : 'no-store' };
for (const k of ['content-type', 'content-length', 'content-range', 'accept-ranges']) if (up.headers.get(k)) out[k] = up.headers.get(k);
if (up.status >= 300 && up.status < 400) { // Podgrab sends you to the original file or picture when it has no copy of its own
const loc = up.headers.get('location');
if (loc && /^https?:\/\//i.test(loc)) { res.writeHead(302, { location: loc, 'referrer-policy': 'no-referrer' }); return res.end(); }
return json(res, 502, { error: 'Podgrab sent an unexpected redirect' });
}
res.writeHead(up.status, out);
if (!up.body) return res.end();
const { Readable } = require('stream');
Readable.fromWeb(up.body).on('error', () => res.destroy()).pipe(res);
} catch (e) {
if (res.headersSent) return res.destroy();
json(res, 502, { error: 'Podgrab did not answer' + (e.name === 'AbortError' ? ' in time' : '') });
}
}
module.exports = { handle, enabled };