From 66a376d22c5b6ee209a22519d8e8cf66d43c2f4d Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:42:28 -0400 Subject: [PATCH] Podcast: search for new shows and add them Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- podcast.js | 10 ++++++++++ public/podcast-view.js | 25 +++++++++++++++++++------ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4164d44..0f6a0b2 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke - **Recovery codes and activity log**: Settings -> Recovery codes makes 8 one-time codes; if you lose every device, the sign-in page's "Use an invite or recovery code" adds a new passkey with one (codes are stored only as hashes). Admins see an **Activity** list in Settings (sign-ins, failed sign-ins, passkeys added or removed, invites, recovery code use, people changes, backups); it is `/data/audit.log` and never contains codes or passwords. - **File version history**: on any Seafile file, "Version history" lists earlier versions with who and when, and can download or restore one (restoring keeps the current version in the history). This uses Seafile's history API and has not been tried against a live Seafile server yet. - **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`. -- **Podcast** is a built-in screen for [Podgrab](https://github.com/akhilrex/podgrab) at `PODCAST_URL`: your shows, their episodes (played state, download all, pause, remove), adding a show by feed address, and playback in the same player as Music. The hub signs in to Podgrab for you with `PODGRAB_PASSWORD` (Podgrab's `PASSWORD` setting; the user name is `podgrab`), so that password never reaches the browser. Search finds your shows too. Not here: resume position, and searching for new shows (add by feed address). +- **Podcast** is a built-in screen for [Podgrab](https://github.com/akhilrex/podgrab) at `PODCAST_URL`: your shows, their episodes (played state, download all, pause, remove), adding a show by feed address, and playback in the same player as Music. The hub signs in to Podgrab for you with `PODGRAB_PASSWORD` (Podgrab's `PASSWORD` setting; the user name is `podgrab`), so that password never reaches the browser. Search finds your shows too. Add podcast also searches Apple's public podcast directory for new shows (the words you type go to Apple). Not here: resume position. - **Podcast Admin** is no longer a menu entry: it is a built-in link on the **Links** page (opens `VERSE_URL` in a new tab, cannot be edited or deleted) for people who have the Podcast Admin permission in Settings -> People; the old `#/verse` address goes to Links. - **Office**: make, find and open Word, Excel and PowerPoint files kept in Seafile (they open in OnlyOffice inside the app). New document / spreadsheet / presentation buttons, search, a filter by kind, sorting (newest, name, size), **Pinned** and **Recently opened** at the top, an Upload button and drop-files-on-the-page (with the same Replace / Keep both / Skip choice as Files), a three-dots menu on every document (share link, download, version history, rename, move, delete), and a saved copy of the list that shows at once while the real one loads (and stays if Seafile cannot be reached). Pins are saved with your settings and follow you across devices. The Ctrl/Cmd-K palette can create a new document, spreadsheet or presentation. - **Mail**: several mail accounts per person in one place: **Gmail**, **iCloud Mail** and (optionally) **Outlook.com** (personal). Folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB) and a From choice, reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab (all accounts added up) and a Home card with the latest unread across accounts. Each person adds their own accounts (Mail tab, or Settings -> Mail -> Add an account); everything is private to them and stored encrypted in their own folder. diff --git a/podcast.js b/podcast.js index 6fdafcc..51f341e 100644 --- a/podcast.js +++ b/podcast.js @@ -17,6 +17,16 @@ 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'; diff --git a/public/podcast-view.js b/public/podcast-view.js index 53ccc06..427a2cc 100644 --- a/public/podcast-view.js +++ b/public/podcast-view.js @@ -34,14 +34,27 @@ async function pdHome() { ${esc(pdF(s, 'title'))}${esc([pdF(s, 'allEpisodesCount') ? pdF(s, 'allEpisodesCount') + ' episodes' : '', pdF(s, 'isPaused') ? 'paused' : ''].filter(Boolean).join(' · '))}`).join('')}` : '

No podcasts yet. Add one with its feed address.

'}`; $('#pdadd').onclick = () => { - const el = openSheet('Add podcast', 'Paste the podcast\'s feed (RSS) address', `
`); - const go = async () => { - const url = el.querySelector('#pdurl').value.trim(); if (!/^https?:\/\//i.test(url)) { el.querySelector('#pde').textContent = 'Enter a feed address starting with http'; return; } - el.querySelector('#pdgo').disabled = true; + const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', ` +
+

Searching uses Apple's public podcast directory, so the words you type are sent to Apple.

`); + const q = el.querySelector('#pdq'), res = el.querySelector('#pdres'), err = el.querySelector('#pde'); let timer, seq = 0, found = []; + const add = async (url, btn) => { + err.textContent = ''; if (btn) btn.disabled = true; try { await pdApi('/podcasts', pdOpt('POST', { url })); closeSheet(); toast('Added; episodes will appear in a moment'); route(); } - catch (e) { el.querySelector('#pde').textContent = e.status === 502 || e.status === 409 || e.status === 400 ? 'Podgrab could not add that (it may already be there)' : e.message; el.querySelector('#pdgo').disabled = false; } + catch (e) { err.textContent = e.status === 502 || e.status === 409 || e.status === 400 ? 'Podgrab could not add that (it may already be there)' : e.message; if (btn) btn.disabled = false; } }; - el.querySelector('#pdgo').onclick = go; el.querySelector('#pdurl').onkeydown = e => { if (e.key === 'Enter') go(); }; el.querySelector('#pdurl').focus(); + const run = async () => { + const term = q.value.trim(), my = ++seq; err.textContent = ''; + if (/^https?:\/\/\S+$/i.test(term)) { res.innerHTML = ``; res.querySelector('#pdfeed').onclick = e => add(term, e.currentTarget); return; } + if (term.length < 2) { res.innerHTML = ''; return; } + res.innerHTML = '

Searching…

'; + try { + found = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return; + res.innerHTML = found.length ? found.map((f, i) => `
${esc(f.title)}${esc(f.author || '')}
`).join('') : '

Nothing found.

'; + res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b)); + } catch (e) { if (my === seq) res.innerHTML = `

${esc(e.message)}

`; } + }; + q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus(); }; }