Open Library in the book store; podcast topic search and transcript search; Recipes (Mealie) with a shopping list synced to Tasks; Contacts linked to Mail and the Calendar
build-container / build (push) Successful in 7m21s
build-container / build (push) Successful in 7m21s
- Store: Open Library search, books fetched from the Internet Archive (free editions only) - Podcast: Podcast Index discovery by name and topic (optional key); search what is said across transcripts and play from that moment - Recipes: browse, import from a link, add ingredients to Mealie's shopping list, mirrored two ways with a Shopping list in Tasks - Contacts: per-person address book, vCard import/export, recipient suggestions in Mail, birthdays in the Calendar Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0617664d97
commit
176b1b8169
28 files changed
+799
-46
No files matched your search
+41
-10
@@ -20,6 +20,25 @@ const ALLOWED = [
|
||||
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); };
|
||||
|
||||
// ---- Podcast Index (https://podcastindex.org): a free, open directory searched by topic as well as name. Needs a free key and secret (PODCASTINDEX_KEY, PODCASTINDEX_SECRET).
|
||||
const PI_URL = (process.env.PODCASTINDEX_URL || 'https://api.podcastindex.org/api/1.0').replace(/\/$/, '');
|
||||
const PI_TOPICS = ['News', 'Technology', 'Business', 'Science', 'History', 'Health', 'Religion', 'Education', 'Comedy', 'Sports', 'Music', 'Society'];
|
||||
const piOn = () => !!(process.env.PODCASTINDEX_KEY && process.env.PODCASTINDEX_SECRET);
|
||||
async function piGet(path) {
|
||||
const date = String(Math.floor(Date.now() / 1000)), sig = require('crypto').createHash('sha1').update(process.env.PODCASTINDEX_KEY + process.env.PODCASTINDEX_SECRET + date).digest('hex');
|
||||
const r = await fetch(PI_URL + path, { headers: { 'user-agent': 'NE-Hub/1.0', 'x-auth-key': process.env.PODCASTINDEX_KEY, 'x-auth-date': date, authorization: sig }, signal: AbortSignal.timeout(10000) });
|
||||
if (!r.ok) throw new Error('status ' + r.status); return r.json();
|
||||
}
|
||||
const piShape = f => ({ title: f.title, author: f.author || f.ownerName || '', feedUrl: f.url, image: f.image || f.artwork || '' });
|
||||
const piKeep = f => f && /^https?:\/\//i.test(f.url || '') && !f.dead;
|
||||
const piSearch = async q => ((await piGet('/search/byterm?max=20&q=' + encodeURIComponent(q))).feeds || []).filter(piKeep).map(piShape);
|
||||
const piTrending = async cat => ((await piGet('/podcasts/trending?max=20&lang=en&cat=' + encodeURIComponent(cat))).feeds || []).filter(piKeep).map(piShape);
|
||||
async function appleSearch(q) {
|
||||
const r = await fetch((process.env.APPLE_PODCASTS_URL || '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);
|
||||
return ((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 }));
|
||||
}
|
||||
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) });
|
||||
@@ -36,16 +55,17 @@ const enabled = () => !!BASE;
|
||||
|
||||
async function handle(req, res) {
|
||||
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 === '/discover') { // find new shows: Podcast Index (free, with a key) and Apple's free directory (the search words go to them; nothing else does)
|
||||
const q = (u.searchParams.get('q') || '').trim().slice(0, 100), topic = (u.searchParams.get('topic') || '').trim().slice(0, 40);
|
||||
if (topic && !piOn()) return json(res, 200, []);
|
||||
if (!topic && q.length < 2) return json(res, 200, []);
|
||||
const [pi, ap] = await Promise.allSettled([piOn() ? (topic ? piTrending(topic) : piSearch(q)) : Promise.resolve([]), topic ? Promise.resolve([]) : appleSearch(q)]);
|
||||
if (pi.status === 'rejected' && ap.status === 'rejected') return json(res, 502, { error: 'The podcast directory did not answer' });
|
||||
const seen = new Set(), out = [];
|
||||
for (const x of [...(pi.value || []), ...(ap.value || [])]) { const k = x.feedUrl.replace(/\/+$/, '').toLowerCase(); if (!seen.has(k)) { seen.add(k); out.push(x); } }
|
||||
return json(res, 200, out.slice(0, 30));
|
||||
}
|
||||
if (p === '/discover/topics') return json(res, 200, piOn() ? PI_TOPICS : []);
|
||||
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' });
|
||||
@@ -142,4 +162,15 @@ async function episodeAudio(itemId) {
|
||||
if (r.status >= 300 && r.status < 400 && loc && /^https?:\/\//i.test(loc)) return fetch(loc, { signal: AbortSignal.timeout(2 * 3600e3) }); // (Podgrab has no copy of its own: the original)
|
||||
return r;
|
||||
}
|
||||
module.exports = { handle, enabled, feedInfo, recentEpisodes, episodeAudio };
|
||||
/** Every episode of the shows on this person's list: [{ id, title, show }], remembered for a few minutes (the transcript search uses it to know what they may see). */
|
||||
const epsCache = new Map();
|
||||
async function myEpisodes(me) {
|
||||
const c = epsCache.get(me.id); if (c && Date.now() - c.t < 300000) return c.v;
|
||||
const f = (o, k) => { const hit = Object.keys(o || {}).find(x => x.toLowerCase() === k.toLowerCase()); return hit === undefined ? undefined : o[hit]; }, v = [];
|
||||
if (BASE) for (const sid of await mySubs(me)) {
|
||||
const sh = await (await podgrab('/podcasts/' + sid)).json().catch(() => null), items = await (await podgrab('/podcasts/' + sid + '/items')).json().catch(() => []);
|
||||
for (const it of Array.isArray(items) ? items : []) v.push({ id: String(idOf(it)), title: f(it, 'Title') || '', show: (sh && f(sh, 'Title')) || '', thumb: '/api/podcast/podcasts/' + sid + '/image' });
|
||||
}
|
||||
epsCache.set(me.id, { t: Date.now(), v }); return v;
|
||||
}
|
||||
module.exports = { handle, myEpisodes, enabled, feedInfo, recentEpisodes, episodeAudio };
|
||||
Reference in new issue
Block a user