Podcast: built-in screen for Podgrab (shows, episodes, playback, search)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f38ac5366
commit
675df5710b
13 files changed
+177
-19
No files matched your search
@@ -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.
|
- **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.
|
- **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/`.
|
- **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.
|
- **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.
|
- **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).
|
- **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).
|
||||||
|
|||||||
@@ -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
|
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
|
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/}
|
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
|
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)
|
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
|
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"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"
|
"audit": "npm audit --omit=dev --audit-level=high"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
+50
@@ -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 };
|
||||||
+1
-1
@@ -195,7 +195,7 @@ function openMore() {
|
|||||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
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;
|
let editor;
|
||||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
// 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 {}; } };
|
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
||||||
|
|||||||
@@ -19,18 +19,6 @@ function showFrame(key, url, title, allow) {
|
|||||||
wrap.className = 'on'; document.body.classList.add('onaudio');
|
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 = `<div class="wrap"><h1>Podcast</h1><p class="sub">${esc(u.host)}</p>
|
|
||||||
<div class="bar" style="margin-bottom:0"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a></div>
|
|
||||||
${canFrame ? '<p class="sub" style="margin:8px 0 0">If the page below stays blank, the app does not allow being shown inside another app. Use Open in new tab.</p>'
|
|
||||||
: '<div class="card" style="max-width:none;margin-top:12px"><p style="margin:0">This is a plain <b>http</b> address, which a secure page cannot show inside itself. It opens in its own tab, and works at home or on your VPN.</p></div>'}</div>`;
|
|
||||||
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.
|
// 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).
|
// 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';
|
const ENTER = '/api/audiobooks/enter';
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
<script src="/calendar.js"></script>
|
<script src="/calendar.js"></script>
|
||||||
<script src="/tasks.js"></script>
|
<script src="/tasks.js"></script>
|
||||||
<script src="/audiobooks.js"></script>
|
<script src="/audiobooks.js"></script>
|
||||||
|
<script src="/podcast-view.js"></script>
|
||||||
<script src="/music.js"></script>
|
<script src="/music.js"></script>
|
||||||
<script src="/video.js"></script>
|
<script src="/video.js"></script>
|
||||||
<script src="/office.js"></script>
|
<script src="/office.js"></script>
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ const px = {
|
|||||||
if (!r.ok) throw new Error('Plex error ' + r.status);
|
if (!r.ok) throw new Error('Plex error ' + r.status);
|
||||||
return (await r.json()).MediaContainer || {};
|
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.
|
// All music libraries on the server; the chosen one is remembered per server.
|
||||||
async sections() {
|
async sections() {
|
||||||
if (px.secs) return px.secs;
|
if (px.secs) return px.secs;
|
||||||
|
|||||||
+3
-2
@@ -143,7 +143,7 @@ const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cu
|
|||||||
pl.a.preload = 'auto';
|
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).
|
// 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;
|
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) {
|
function tlReport(state) {
|
||||||
const t = cur(); if (!t) return;
|
const t = cur(); if (!t) return;
|
||||||
if (tlKey && tlKey !== t.key) tlSend('stopped', tlKey, tlPos, tlDur); // the previous song is over
|
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 (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'); }
|
if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); }
|
||||||
const t = cur(), d = pl.a.duration;
|
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 {} }
|
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);
|
addEventListener('pagehide', saveState);
|
||||||
|
|||||||
@@ -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 = '<div class="wrap"><h1>Podcast</h1><p class="empty">Loading…</p></div>';
|
||||||
|
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 = `<div class="wrap"><h1>Podcast</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="pdretry">Try again</button>${cfg.podcast ? `<a class="btn ghost" href="${esc(cfg.podcast)}" target="_blank" rel="noopener">Open Podgrab</a>` : ''}</div></div>`;
|
||||||
|
$('#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 = `<div class="wrap"><div class="ptop"><h1>Podcast</h1><span class="pspace grow"></span><button class="btn" id="pdadd">${icon('plus')}Add podcast</button></div>
|
||||||
|
${shows.length ? `<div class="vgrid">${shows.map(s => `<a class="vcard" href="#/podcast/show/${encodeURIComponent(pdId(s))}">
|
||||||
|
<span class="vimg" style="aspect-ratio:1/1"><img loading="lazy" alt="" src="${pdImg('podcasts', s)}" onerror="this.remove()"></span>
|
||||||
|
<b>${esc(pdF(s, 'title'))}</b><small>${esc([pdF(s, 'allEpisodesCount') ? pdF(s, 'allEpisodesCount') + ' episodes' : '', pdF(s, 'isPaused') ? 'paused' : ''].filter(Boolean).join(' · '))}</small></a>`).join('')}</div>`
|
||||||
|
: '<p class="empty">No podcasts yet. Add one with its feed address.</p>'}</div>`;
|
||||||
|
$('#pdadd').onclick = () => {
|
||||||
|
const el = openSheet('Add podcast', 'Paste the podcast\'s feed (RSS) address', `<input type="url" id="pdurl" placeholder="https://example.com/feed.xml" autocomplete="off" autocapitalize="off"><div class="err" id="pde"></div><div class="bar"><button class="btn" id="pdgo">Add</button></div>`);
|
||||||
|
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 => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
|
||||||
|
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||||
|
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button></div>`;
|
||||||
|
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Back</a></div>
|
||||||
|
<div style="display:flex;gap:16px;align-items:flex-start"><span class="vimg" style="width:120px;flex:none;aspect-ratio:1/1"><img alt="" src="${pdImg('podcasts', show)}" onerror="this.remove()"></span>
|
||||||
|
<div style="min-width:0"><h1 style="margin:0">${esc(pdF(show, 'title'))}</h1><p class="sub" style="margin:2px 0 0">${esc(pdF(show, 'author') || '')}</p>
|
||||||
|
<p class="sub" style="margin:6px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden">${esc(pdPlain(pdF(show, 'summary')))}</p></div></div>
|
||||||
|
<div class="bar">${items.length ? `<button class="btn" id="pdlatest">${icon('play')}Play latest</button>` : ''}<button class="btn ghost" id="pdmore">${icon('more')}More</button></div>
|
||||||
|
<h2>Episodes</h2><div class="list">${items.map(ep).join('') || '<p class="empty">No episodes yet. Podgrab may still be fetching the feed.</p>'}</div></div>`;
|
||||||
|
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'), '', `<div class="list"><button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
||||||
|
<button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button>
|
||||||
|
<button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove this podcast</b><small>Also deletes its downloaded episodes</small></span></button></div>`);
|
||||||
|
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); } }); };
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// 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 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'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const backup = require('./backup');
|
|||||||
const push = require('./push');
|
const push = require('./push');
|
||||||
const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (MS_CLIENT_ID)
|
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 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 signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL)
|
||||||
const vault = require('./vault')('nehub-plex');
|
const vault = require('./vault')('nehub-plex');
|
||||||
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
||||||
@@ -532,6 +533,7 @@ const ACCESS = [
|
|||||||
[/^\/api\/oo\/config$/, ['office', 'files']],
|
[/^\/api\/oo\/config$/, ['office', 'files']],
|
||||||
[/^\/(api\/plex\/|plex\/)/, ['music', 'video']],
|
[/^\/(api\/plex\/|plex\/)/, ['music', 'video']],
|
||||||
[/^\/api\/audiobooks\//, ['audio']],
|
[/^\/api\/audiobooks\//, ['audio']],
|
||||||
|
[/^\/api\/podcast\//, ['podcast']],
|
||||||
[/^\/api\/signal\//, ['signal']],
|
[/^\/api\/signal\//, ['signal']],
|
||||||
[/^\/api\/lyrics$/, ['music']],
|
[/^\/api\/lyrics$/, ['music']],
|
||||||
[/^\/api\/mail\//, ['mail']],
|
[/^\/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();
|
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 === '/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/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 (pathname.startsWith('/api/mail/')) return mail.handle(req, res);
|
||||||
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user