From 675df5710b4f4105cf7c3a258d36a1d45afc9de4 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:41:59 -0400 Subject: [PATCH] Podcast: built-in screen for Podgrab (shows, episodes, playback, search) Co-Authored-By: Claude Sonnet 5.5 --- README.md | 3 +- docker-compose.yml | 1 + package.json | 2 +- podcast.js | 50 ++++++++++++++++++++++++++ public/app.js | 2 +- public/audiobooks.js | 12 ------- public/index.html | 1 + public/music.js | 2 +- public/player.js | 5 +-- public/podcast-view.js | 81 ++++++++++++++++++++++++++++++++++++++++++ public/sw.js | 2 +- server.js | 3 ++ tools/podcast-test.js | 32 +++++++++++++++++ 13 files changed, 177 insertions(+), 19 deletions(-) create mode 100644 podcast.js create mode 100644 public/podcast-view.js create mode 100644 tools/podcast-test.js diff --git a/README.md b/README.md index 29ba85a..4164d44 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ 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** opens `PODCAST_URL` (a home-network address) in its own tab. **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. +- **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 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. - **Gmail and iCloud** use IMAP/SMTP with an **app password**, so there is nothing to set up on the server. Gmail: turn on 2-Step Verification, then make an app password at myaccount.google.com/apppasswords. iCloud: appleid.apple.com -> Sign-In and Security -> App-Specific Passwords. The password is checked with the provider before it is saved, and wrong ones are explained in plain words. iCloud does not keep a copy of mail sent over SMTP, so NE Hub files one in Sent itself (not yet confirmed against a real iCloud account). diff --git a/docker-compose.yml b/docker-compose.yml index 0be20e3..7920929 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/} + PODGRAB_PASSWORD: ${PODGRAB_PASSWORD:-} # Podgrab's PASSWORD setting, if it has one (the sign-in name is always podgrab); stays on the server ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here) HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log diff --git a/package.json b/package.json index 722caf6..33a730e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files; do node tools/$t-test.js || exit 1; done", + "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/podcast.js b/podcast.js new file mode 100644 index 0000000..6fdafcc --- /dev/null +++ b/podcast.js @@ -0,0 +1,50 @@ +// 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 === '/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 }; diff --git a/public/app.js b/public/app.js index f8a8490..11ff1bd 100644 --- a/public/app.js +++ b/public/app.js @@ -195,7 +195,7 @@ function openMore() { el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet)); } -const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: () => location.replace('#/links'), podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); +const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: () => location.replace('#/links'), podcast: args => viewPodcast(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); let editor; // Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device. const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } }; diff --git a/public/audiobooks.js b/public/audiobooks.js index fb09329..468cf44 100644 --- a/public/audiobooks.js +++ b/public/audiobooks.js @@ -19,18 +19,6 @@ function showFrame(key, url, title, allow) { wrap.className = 'on'; document.body.classList.add('onaudio'); } -// Podcast: opens in a frame when it has a secure (https) address; a plain-http address cannot be framed by a secure page. -// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is -// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN). -function viewPodcast() { - const url = cfg.podcast, u = new URL(url), canFrame = u.protocol === 'https:'; - $('#view').innerHTML = `

Podcast

${esc(u.host)}

- - ${canFrame ? '

If the page below stays blank, the app does not allow being shown inside another app. Use Open in new tab.

' - : '

This is a plain http address, which a secure page cannot show inside itself. It opens in its own tab, and works at home or on your VPN.

'}
`; - if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write'); -} - // The frame is created once and kept alive, so playback carries on while you use other tabs. // It loads /api/audiobooks/enter, where the server adds the site's secret sign-in link itself (the key never reaches this page). const ENTER = '/api/audiobooks/enter'; diff --git a/public/index.html b/public/index.html index e5af507..959f904 100644 --- a/public/index.html +++ b/public/index.html @@ -65,6 +65,7 @@ + diff --git a/public/music.js b/public/music.js index fb55ee3..5b6341f 100644 --- a/public/music.js +++ b/public/music.js @@ -13,7 +13,7 @@ const px = { if (!r.ok) throw new Error('Plex error ' + r.status); return (await r.json()).MediaContainer || {}; }, - art: (thumb, w = 300) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', + art: (thumb, w = 300) => thumb && thumb.startsWith('/api/podcast/') ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', // All music libraries on the server; the chosen one is remembered per server. async sections() { if (px.secs) return px.secs; diff --git a/public/player.js b/public/player.js index 6d66295..f39ca55 100644 --- a/public/player.js +++ b/public/player.js @@ -143,7 +143,7 @@ const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cu pl.a.preload = 'auto'; // Tell Plex what is playing, so it shows under Now Playing / Activity in the Plex dashboard (as "NE Hub", under the signed-in Plex account). let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0; -const tlSend = (state, key, pos, dur) => fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {}); +const tlSend = (state, key, pos, dur) => String(key).startsWith('pod:') ? 0 : fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {}); function tlReport(state) { const t = cur(); if (!t) return; if (tlKey && tlKey !== t.key) tlSend('stopped', tlKey, tlPos, tlDur); // the previous song is over @@ -157,7 +157,8 @@ pl.a.addEventListener('timeupdate', () => { if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); } if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); } const t = cur(), d = pl.a.duration; - if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex + if (t && String(t.key).startsWith('pod:')) { if (!pl.scrobbled && d && pl.a.currentTime > d * 0.9) { pl.scrobbled = true; fetch('/api/podcast/podcastitems/' + encodeURIComponent(t.key.slice(4)) + '/markPlayed').catch(() => {}); } } // a podcast episode counts as heard near its end + else if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex if (Date.now() - lastPos > 1000 && 'mediaSession' in navigator && pl.a.duration) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: pl.a.duration, position: pl.a.currentTime, playbackRate: pl.a.playbackRate }); } catch {} } }); addEventListener('pagehide', saveState); diff --git a/public/podcast-view.js b/public/podcast-view.js new file mode 100644 index 0000000..53ccc06 --- /dev/null +++ b/public/podcast-view.js @@ -0,0 +1,81 @@ +'use strict'; +// ---- Podcast: your shows and episodes from Podgrab, through the hub (the server holds the Podgrab sign-in). Episodes play in the same player as Music. +const pdApi = (p, opts) => jfetch('/api/podcast' + p, opts); +const pdOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); +// Podgrab sends its field names with a capital letter (ID, Title, ...); accept either spelling. +const pdF = (o, k) => o == null ? undefined : o[k] !== undefined ? o[k] : o[k[0].toUpperCase() + k.slice(1)] !== undefined ? o[k[0].toUpperCase() + k.slice(1)] : o[k.toUpperCase()]; +const pdId = o => pdF(o, 'id'); +const pdImg = (kind, o, w) => `/api/podcast/${kind}/${encodeURIComponent(pdId(o))}/image`; +const pdDur = s => { s = Math.round(+s || 0); if (!s) return ''; const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : Math.max(1, m) + ' min'; }; +const pdDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; }; +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 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', dur: (+pdF(e, 'duration') || 0) * 1000 }); + +async function viewPodcast(args = []) { + const v = $('#view'); + v.innerHTML = '

Podcast

Loading…

'; + try { + if (args[0] === 'show' && args[1]) return await pdShow(args[1]); + return await pdHome(); + } catch (e) { + if (!location.hash.startsWith('#/podcast')) return; + v.innerHTML = `

Podcast

${esc(e.message)}

${cfg.podcast ? `Open Podgrab` : ''}
`; + $('#pdretry').onclick = () => route(); + } +} + +async function pdHome() { + const list = await pdApi('/podcasts?sort=lastepisode&order=desc'), shows = Array.isArray(list) ? list : (list.podcasts || []); + if (!location.hash.startsWith('#/podcast')) return; + $('#view').innerHTML = `

Podcast

+ ${shows.length ? `` + : '

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; + 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; } + }; + el.querySelector('#pdgo').onclick = go; el.querySelector('#pdurl').onkeydown = e => { if (e.key === 'Enter') go(); }; el.querySelector('#pdurl').focus(); + }; +} + +async function pdShow(id) { + const [show, items0] = await Promise.all([pdApi('/podcasts/' + encodeURIComponent(id)), pdApi('/podcasts/' + encodeURIComponent(id) + '/items')]); + if (!location.hash.startsWith('#/podcast')) return; + const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate'))); + const paused = !!pdF(show, 'isPaused'), ep = e => `
+ ${esc(pdF(e, 'title'))}${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : ''].filter(Boolean).join(' · '))} +
`; + $('#view').innerHTML = `
+
+

${esc(pdF(show, 'title'))}

${esc(pdF(show, 'author') || '')}

+

${esc(pdPlain(pdF(show, 'summary')))}

+
${items.length ? `` : ''}
+

Episodes

${items.map(ep).join('') || '

No episodes yet. Podgrab may still be fetching the feed.

'}
`; + 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 + document.querySelectorAll('.pdep').forEach(r => { + const e = byId.get(r.dataset.id); + r.querySelector('[data-play]').onclick = () => playFrom(e); + r.querySelector('[data-seen]').onclick = async () => { + try { await pdApi('/podcastitems/' + encodeURIComponent(pdId(e)) + (pdF(e, 'isPlayed') ? '/markUnplayed' : '/markPlayed')); route(); } catch (err) { toast(err.message); } + }; + }); + if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]); + $('#pdmore').onclick = () => { + const el = openSheet(pdF(show, 'title'), '', `
+ +
`); + const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } }; + act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background'); + act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused'); + el.querySelector('#pdd').onclick = () => { closeSheet(); confirmSheet('Remove this podcast?', 'Its episodes and downloaded files are deleted from Podgrab.', 'Remove', async () => { + try { await pdApi('/podcasts/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Removed'); location.hash = '#/podcast'; } catch (e) { toast(e.message); } }); }; + }; +} diff --git a/public/sw.js b/public/sw.js index c503fdd..2e34b56 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v9'; +const CACHE = 'hub-v11'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/server.js b/server.js index 45b5e6f..69d6dab 100644 --- a/server.js +++ b/server.js @@ -36,6 +36,7 @@ const backup = require('./backup'); const push = require('./push'); const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (MS_CLIENT_ID) const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL) +const podcast = require('./podcast'); // Podgrab podcasts (PODCAST_URL, PODGRAB_PASSWORD) const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL) const vault = require('./vault')('nehub-plex'); const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin'; @@ -532,6 +533,7 @@ const ACCESS = [ [/^\/api\/oo\/config$/, ['office', 'files']], [/^\/(api\/plex\/|plex\/)/, ['music', 'video']], [/^\/api\/audiobooks\//, ['audio']], + [/^\/api\/podcast\//, ['podcast']], [/^\/api\/signal\//, ['signal']], [/^\/api\/lyrics$/, ['music']], [/^\/api\/mail\//, ['mail']], @@ -608,6 +610,7 @@ async function handleRequest(req, res) { res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end(); } if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server'); + if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res); if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server'); if (pathname.startsWith('/api/mail/')) return mail.handle(req, res); if (req.url.startsWith('/api/plex/')) return plexApi(req, res); diff --git a/tools/podcast-test.js b/tools/podcast-test.js new file mode 100644 index 0000000..ca7f28f --- /dev/null +++ b/tools/podcast-test.js @@ -0,0 +1,32 @@ +// 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 seen = []; +const fake = http.createServer((req, res) => { + let b = ''; req.on('data', c => b += c); req.on('end', () => { + seen.push({ m: req.method, u: req.url, auth: req.headers.authorization, range: req.headers.range, b }); + if (req.url === '/podcasts') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('[{"ID":"p1","Title":"Show"}]'); } + if (req.url === '/podcastitems/e1/file') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); } + if (req.url === '/podcastitems/e2/file') { res.writeHead(302, { location: 'https://cdn.example.com/a.mp3' }); return res.end(); } + res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}'); + }); +}).listen(0, async () => { + process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret'; + const podcast = require('../podcast'); + const hub = http.createServer((req, res) => 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 }); + try { + let r = await get('/podcasts'); assert.equal(r.status, 200); assert.equal((await r.json())[0].Title, 'Show'); + assert.equal(seen[0].auth, 'Basic ' + Buffer.from('podgrab:secret').toString('base64'), 'password added by the server'); + 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('/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 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']]) { + r = await get(p, { method: m }); assert.equal(r.status, 404, m + ' ' + p); + } + assert.equal(seen.length, n, 'blocked calls never reach Podgrab'); + console.log('podcast ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); fake.close(); + }); +});