6 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 62ff4d815d Audiobooks: built-in library, playback and synced progress through the audiobook player (classic frame kept)
build-container / build (push) Canceled after 2m57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:48:05 -04:00
nmemmertandClaude Sonnet 5.5 66a376d22c Podcast: search for new shows and add them
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:42:28 -04:00
nmemmertandClaude Sonnet 5.5 675df5710b Podcast: built-in screen for Podgrab (shows, episodes, playback, search)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:41:59 -04:00
nmemmertandClaude Sonnet 5.5 7f38ac5366 Home: no sideways scrolling on phones
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:41:56 -04:00
nmemmertandClaude Sonnet 5.5 fa4b562fcc Reminders: Clear all notifications on this device
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:41:56 -04:00
nmemmertandClaude Sonnet 5.5 05e2a2ec7b Search: movies and TV from Plex
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:41:56 -04:00
20 changed files with 484 additions and 32 deletions

No files matched your search

+4 -3
View File
@@ -50,8 +50,8 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks. - **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as <name>" with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal. - **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as <name>" with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal.
- **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. **Bottom bar and mini picture**: the video's controls live in the same bottom bar Music uses, on every tab: poster, title, time, a seek line you can tap or drag, back and forward 10 s, play/pause, and on wider screens previous and next episode and close. Tap the title to open the full player. Leave the player page and the picture keeps playing in a small window at the bottom right (click it to go back). On the player page, volume, speed, Quality & tracks, picture in picture, full screen and close are along the top; the on-video controls only appear in full screen. Music and video end each other (starting one stops the other). Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting. - **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. **Bottom bar and mini picture**: the video's controls live in the same bottom bar Music uses, on every tab: poster, title, time, a seek line you can tap or drag, back and forward 10 s, play/pause, and on wider screens previous and next episode and close. Tap the title to open the full player. Leave the player page and the picture keeps playing in a small window at the bottom right (click it to go back). On the player page, volume, speed, Quality & tracks, picture in picture, full screen and close are along the top; the on-video controls only appear in full screen. Music and video end each other (starting one stops the other). Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting.
- **Audiobooks**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons. - **Audiobooks** is a built-in screen for the [audiobook player](https://git.necloud.us/nate/audiobook-player) at `AUDIOBOOKS_URL`: your library with covers, Continue listening, search, chapters, and playback in the same player as Music. The hub signs in to the player as you with the audiobook sign-in saved in Settings (or, if you have none, the shared `AUDIOBOOKS_LOGIN_LINK`), so no password reaches the browser. Where you got to is kept by the audiobook player, so it matches its own page and your other devices. Search finds your books too. The old frame is still there as **Classic player** (`#/audio/classic`).
Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks (Classic player) Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks
the person may use the Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page, the person may use the Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page,
script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET` script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET`
(and optionally `AUDIOBOOKS_SSO_URL`, default `<AUDIOBOOKS_URL>/sso`) for short-lived signed links: `?token=<body>.<sig>`, where `body` is base64url JSON (and optionally `AUDIOBOOKS_SSO_URL`, default `<AUDIOBOOKS_URL>/sso`) for short-lived signed links: `?token=<body>.<sig>`, where `body` is base64url JSON
@@ -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. 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. - **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).
+72
View File
@@ -0,0 +1,72 @@
// Audiobooks from the audiobook player (https://git.necloud.us/nate/audiobook-player) at AUDIOBOOKS_URL: the library, covers, chapters, the audio itself
// and each person's listening position. The hub signs in to the player as the person (their audiobook sign-in from Settings), or, when they have
// none saved, with the site's shared secret link (AUDIOBOOKS_LOGIN_LINK), so no password or link ever reaches the browser.
const { Readable } = require('stream');
const settings = require('./settings');
const users = require('./users');
const BASE = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
const LINK = process.env.AUDIOBOOKS_LOGIN_LINK || '';
const COOKIE = 'oa_session';
const sessions = new Map(); // person id -> the player's session cookie
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const cookieOf = r => { const c = (r.headers.getSetCookie ? r.headers.getSetCookie() : []).map(x => x.split(';')[0]).find(x => x.startsWith(COOKIE + '=')); return c || ''; };
// Hub path -> player path (the player itself only serves read-only calls for these)
const ROUTES = [
[/^\/books$/, () => '/api/books'],
[/^\/books\/([^/]+)\/(chapters\/flat|cover)$/, m => `/api/books/${m[1]}/${m[2]}`],
[/^\/books\/([^/]+)\/filelist$/, m => `/admin/api/books/${m[1]}/files`],
[/^\/books\/([^/]+)\/files\/([^/]+)$/, m => `/api/books/${m[1]}/files/${m[2]}`],
];
async function signIn(me) {
const c = settings.get('ab_creds') || {};
let r;
if (c.u && c.p) r = await fetch(BASE + '/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: c.u, password: c.p }), redirect: 'manual', signal: AbortSignal.timeout(15000) });
else if (LINK) r = await fetch(LINK, { redirect: 'manual', signal: AbortSignal.timeout(15000) });
else throw Object.assign(new Error('Add your audiobook sign-in in Settings first'), { code: 503 });
const ck = cookieOf(r);
if (!ck) throw Object.assign(new Error(c.u ? 'The audiobook site did not accept your saved sign-in' : 'The audiobook site did not accept the shared sign-in'), { code: 502 });
sessions.set(me.id, ck); return ck;
}
async function upstream(me, path, init = {}, retry = true) {
const ck = sessions.get(me.id) || await signIn(me);
const r = await fetch(BASE + path, { ...init, headers: { ...init.headers, cookie: ck }, redirect: 'manual' });
if ((r.status === 401 || r.status === 302) && retry) { sessions.delete(me.id); await r.body?.cancel().catch(() => {}); return upstream(me, path, init, false); }
return r;
}
async function handle(req, res) {
const me = users.current();
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab'.length) || '/';
const ctl = new AbortController(); res.on('close', () => ctl.abort());
try {
if (p === '/progress') {
if (req.method === 'POST') {
const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e6) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
const r = await upstream(me, '/sync/progress', { method: 'POST', body: Buffer.concat(chunks), headers: { 'content-type': 'application/json' }, signal: ctl.signal });
res.writeHead(r.status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(Buffer.from(await r.arrayBuffer()));
}
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
const r = await upstream(me, '/sync/progress', { signal: ctl.signal });
res.writeHead(r.status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(Buffer.from(await r.arrayBuffer()));
}
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
let to = null; for (const [re, f] of ROUTES) { const m = re.exec(p); if (m) { to = f(m); break; } }
if (!to) return json(res, 404, { error: 'Not found' });
const headers = req.headers.range ? { range: req.headers.range } : {};
const r = await upstream(me, to, { headers, signal: ctl.signal });
const out = { 'cache-control': p.endsWith('/cover') ? 'private, max-age=86400' : 'no-store' };
for (const k of ['content-type', 'content-length', 'content-range', 'accept-ranges']) if (r.headers.get(k)) out[k] = r.headers.get(k);
res.writeHead(r.status, out);
if (!r.body) return res.end();
Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res);
} catch (e) {
if (res.headersSent) return res.destroy();
json(res, e.code || 502, { error: e.code ? e.message : 'The audiobook site did not answer' });
}
}
module.exports = { handle };
+1
View File
@@ -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
View File
@@ -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 abapi; 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": [],
+60
View File
@@ -0,0 +1,60 @@
// 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 };
+1 -1
View File
@@ -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: args => args[0] === 'classic' ? viewAudio() : viewBooks(args), 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 {}; } };
+107
View File
@@ -0,0 +1,107 @@
'use strict';
// ---- Audiobooks: your library from the audiobook player, through the hub (the hub signs in for you). Books play in the same player as Music,
// and where you got to is kept by the audiobook player, so it follows you between this app, the player's own page and your other devices.
const abApi = (p, opts) => jfetch('/api/ab' + p, opts);
const abPick = (o, ...ks) => { for (const k of ks) if (o && o[k] != null && o[k] !== '') return o[k]; };
const abId = b => String(abPick(b, 'id', 'asin', 'ASIN', 'book_id'));
const abTitle = b => abPick(b, 'title', 'title_short', 'name') || '(untitled)';
const abAuthor = b => { const a = abPick(b, 'author', 'author_name', 'authors', 'author_display') || ''; return Array.isArray(a) ? a.join(', ') : a; };
const abSeries = b => abPick(b, 'series_name', 'series') || '';
const abCover = b => `/api/ab/books/${encodeURIComponent(abId(b))}/cover`;
const abFmt = s => { s = Math.max(0, Math.floor(s || 0)); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); };
const abHue = t => { let h = 0; for (const c of t) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
let abBooks = null, abProg = {};
const abIsPlaying = () => { const t = cur(); return t && String(t.key).startsWith('ab:') ? t.key.slice(3) : ''; };
async function abFile(id) {
const data = await abApi(`/books/${encodeURIComponent(id)}/filelist`);
const arr = Array.isArray(data) ? data : (data.files || Object.values(data));
const names = arr.map(f => typeof f === 'string' ? f : abPick(f, 'name', 'filename', 'file', 'path')).filter(Boolean).map(n => n.split('/').pop());
return names.find(n => /\.mp3$/i.test(n)) || names.find(n => /\.(mp3|m4b|m4a|aac|ogg|opus|flac|wav)$/i.test(n));
}
async function abChapters(id) {
try {
const data = await abApi(`/books/${encodeURIComponent(id)}/chapters/flat`), arr = Array.isArray(data) ? data : (data.chapters || []);
return arr.map(c => { let s = abPick(c, 'start', 'start_sec', 'start_seconds', 'startOffsetSec', 'start_offset_sec');
if (s == null) { const ms = abPick(c, 'start_ms', 'start_offset_ms', 'startOffsetMs', 'offset_ms'); if (ms != null) s = ms / 1000; }
return { title: abPick(c, 'title', 'name') || 'Chapter', start: Number(s) }; }).filter(c => !isNaN(c.start));
} catch { return []; }
}
/** Start (or carry on with) a book: from where you got to, unless that was the very end. */
async function abPlay(b, at) {
const id = abId(b);
if (abIsPlaying() === id) { if (at != null) pl.a.currentTime = at; else if (pl.a.paused) pl.a.play().catch(() => {}); return; }
const file = await abFile(id);
if (!file) throw new Error('No playable MP3 or M4B for this book. Convert it in OpenAudible first.');
const pr = abProg[id], start = at != null ? at : pr && pr.pos > 10 && (!pr.dur || pr.pos < pr.dur - 30) ? pr.pos : 0;
playQueue([{ key: 'ab:' + id, title: abTitle(b), artist: abAuthor(b), album: abSeries(b), thumb: abCover(b), src: `/api/ab/books/${encodeURIComponent(id)}/files/${encodeURIComponent(file)}`, dur: 0, nocache: true }], 0);
if (start) pl.a.addEventListener('loadedmetadata', () => { if (abIsPlaying() === id) pl.a.currentTime = start; }, { once: true });
}
// Where you got to goes back to the audiobook player every few seconds while listening, and when you pause or leave.
let abLast = 0, abQueued = {};
const abPush = keepalive => {
if (!Object.keys(abQueued).length) return;
const body = JSON.stringify(abQueued); abQueued = {};
fetch('/api/ab/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive })
.then(r => { if (!r.ok) throw 0; }).catch(() => { abQueued = { ...JSON.parse(body), ...abQueued }; });
};
const abSave = keepalive => {
const id = abIsPlaying(), d = pl.a.duration; if (!id || !isFinite(d) || d <= 0) return;
abProg[id] = abQueued[id] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; abPush(keepalive);
};
pl.a.addEventListener('timeupdate', () => { if (Date.now() - abLast > 8000) { abLast = Date.now(); abSave(); } });
pl.a.addEventListener('pause', () => abSave());
pl.a.addEventListener('ended', () => abSave());
addEventListener('pagehide', () => abSave(true));
/** Fetch where you got to in each book (from the audiobook player) and fold it into what this page already knows. */
const abLoadProg = async () => { try { for (const [id, p] of Object.entries(await abApi('/progress') || {})) if (!abProg[id] || p.t > abProg[id].t) abProg[id] = p; } catch {} };
async function viewBooks(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty">Loading…</p></div>';
try {
const [data] = await Promise.all([abApi('/books'), abLoadProg()]);
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
} catch (e) {
if (!location.hash.startsWith('#/audio')) return;
v.innerHTML = `<div class="wrap"><h1>Audiobooks</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="abretry">Try again</button><a class="btn ghost" href="#/settings">Settings</a><a class="btn ghost" href="#/audio/classic">Open the classic player</a></div></div>`;
$('#abretry').onclick = () => route(); return;
}
if (!location.hash.startsWith('#/audio')) return;
const pct = b => { const p = abProg[abId(b)]; return p && p.dur ? Math.min(100, p.pos / p.dur * 100) : 0; };
const card = (b, wide) => `<button class="vcard${wide ? ' wide' : ''}" data-id="${esc(abId(b))}" style="text-align:left;background:none;border:0;padding:0;color:inherit;font:inherit;cursor:pointer;${wide ? 'width:140px;flex:none' : ''}">
<span class="vimg" style="aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(abTitle(b))} 55% 40%),hsl(${abHue(abTitle(b)) + 40} 55% 28%))"><img loading="lazy" alt="" src="${abCover(b)}" onerror="this.remove()">${pct(b) > 1 ? `<i class="vprog"><b style="width:${pct(b)}%"></b></i>` : ''}</span>
<b>${esc(abTitle(b))}</b><small>${esc(abAuthor(b))}</small></button>`;
const cont = abBooks.filter(b => { const p = abProg[abId(b)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; }).sort((a, b) => abProg[abId(b)].t - abProg[abId(a)].t).slice(0, 10);
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Audiobooks</h1><span class="pspace grow"></span><a class="btn ghost" href="#/audio/classic" title="The audiobook player's own page">Classic player</a></div>
${cont.length ? `<h2>Continue listening</h2><div class="vrow">${cont.map(b => card(b, true)).join('')}</div>` : ''}
<input type="search" id="abq" placeholder="Search your books" autocomplete="off" autocapitalize="off" style="margin-top:14px">
<h2>Library</h2><div class="vgrid" id="abgrid"></div></div>`;
const draw = () => {
const q = $('#abq').value.trim().toLowerCase(), list = abBooks.filter(b => !q || (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(q))
.sort((a, b) => abTitle(a).localeCompare(abTitle(b)));
$('#abgrid').innerHTML = list.map(b => card(b)).join('') || `<p class="empty">${abBooks.length ? 'No books match.' : 'No books in the library yet.'}</p>`;
};
draw(); $('#abq').oninput = draw;
const byId = new Map(abBooks.map(b => [abId(b), b]));
v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
}
function abSheet(b) {
const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30;
const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1"><img alt="" src="${abCover(b)}" onerror="this.remove()"></span>
<div style="min-width:0"><p class="sub" style="margin:0">${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}</p></div></div>
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button></div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
el.querySelector('#abp').onclick = () => go();
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
el.querySelector('#abc').onclick = async () => {
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
const ch = await abChapters(id);
box.innerHTML = ch.length ? ch.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${abFmt(c.start)}</small></span></button>`).join('') : '<p class="empty">This book has no chapter list.</p>';
box.querySelectorAll('button[data-i]').forEach(x => x.onclick = () => go(ch[x.dataset.i].start));
};
}
-12
View File
@@ -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';
+2
View File
@@ -65,10 +65,12 @@
<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>
<script src="/player.js"></script> <script src="/player.js"></script>
<script src="/audiobooks-view.js"></script>
<script src="/lyrics-view.js"></script> <script src="/lyrics-view.js"></script>
<script src="/settings.js"></script> <script src="/settings.js"></script>
<script src="/files.js"></script> <script src="/files.js"></script>
+1 -1
View File
@@ -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 && /^\/api\/(podcast|ab)\//.test(thumb) ? 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;
+4 -3
View File
@@ -22,7 +22,7 @@ const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSear
// so changing songs is instant and a slow or lost connection does not stop the music. // so changing songs is instant and a slow or lost connection does not stop the music.
const sleep = ms => new Promise(ok => setTimeout(ok, ms)); const sleep = ms => new Promise(ok => setTimeout(ok, ms));
const tc = { m: new Map(), ctl: null, busy: false }; const tc = { m: new Map(), ctl: null, busy: false };
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key)).map(t => t.src); const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); // (long audio, such as audiobooks, is streamed, not saved whole)
function tcTrim() { function tcTrim() {
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src)); const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); } for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
@@ -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);
+94
View File
@@ -0,0 +1,94 @@
'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', nocache: true, 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', 'Search for a show, or paste its feed (RSS) address', `<input type="search" id="pdq" placeholder="Search podcasts, or paste https://…/feed.xml" autocomplete="off" autocapitalize="off">
<div class="err" id="pde"></div><div id="pdres" class="list" style="margin-top:8px;max-height:50vh;overflow:auto"></div>
<p class="sub" style="margin:8px 0 0">Searching uses Apple's public podcast directory, so the words you type are sent to Apple.</p>`);
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) { 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; }
};
const run = async () => {
const term = q.value.trim(), my = ++seq; err.textContent = '';
if (/^https?:\/\/\S+$/i.test(term)) { res.innerHTML = `<button class="row" id="pdfeed"><span class="grow"><b>Add this feed</b><small style="white-space:normal">${esc(term)}</small></span></button>`; res.querySelector('#pdfeed').onclick = e => add(term, e.currentTarget); return; }
if (term.length < 2) { res.innerHTML = ''; return; }
res.innerHTML = '<p class="empty">Searching…</p>';
try {
found = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return;
res.innerHTML = found.length ? found.map((f, i) => `<div class="row"><img class="art" alt="" style="width:48px;height:48px;border-radius:8px;flex:none" src="${esc(f.image || '')}" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(f.title)}</b><small>${esc(f.author || '')}</small></span><button class="btn" data-i="${i}" style="min-height:36px">Add</button></div>`).join('') : '<p class="empty">Nothing found.</p>';
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b));
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.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); } }); };
};
}
+9 -1
View File
@@ -23,7 +23,8 @@ async function remindersInit() {
<div class="row"><span class="grow"><b>Morning time</b></span><input type="time" id="remat" value="${esc(info.tasksAt)}" style="width:auto;min-width:130px"></div>` : ''} <div class="row"><span class="grow"><b>Morning time</b></span><input type="time" id="remat" value="${esc(info.tasksAt)}" style="width:auto;min-width:130px"></div>` : ''}
${allowed('calendar') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Upcoming events</b><small>Shortly before they start</small></span><input type="checkbox" id="reme" ${info.events ? 'checked' : ''}></label> ${allowed('calendar') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Upcoming events</b><small>Shortly before they start</small></span><input type="checkbox" id="reme" ${info.events ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''} <div class="row"><span class="grow"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''}
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>`; <div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>
<div class="row"><span class="grow"><small>Dismiss every notification from NE Hub still showing on this device</small></span><button class="btn ghost" id="remclear" style="min-height:36px">Clear all</button></div>`;
const on1 = document.getElementById('remon'); const on1 = document.getElementById('remon');
on1.onchange = async () => { on1.onchange = async () => {
try { try {
@@ -42,5 +43,12 @@ async function remindersInit() {
}; };
const pref = (id, key, get) => { const el = document.getElementById(id); if (el) el.onchange = () => post('prefs', { [key]: get(el) }).catch(e => toast(e.message)); }; const pref = (id, key, get) => { const el = document.getElementById(id); if (el) el.onchange = () => post('prefs', { [key]: get(el) }).catch(e => toast(e.message)); };
pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value); pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value);
document.getElementById('remclear').onclick = async () => {
try {
const list = await reg.getNotifications(); list.forEach(n => n.close());
try { navigator.clearAppBadge && await navigator.clearAppBadge(); } catch {}
toast(list.length ? 'Cleared ' + list.length + (list.length === 1 ? ' notification' : ' notifications') : 'No notifications to clear');
} catch (e) { toast(e.message); }
};
document.getElementById('remtest').onclick = () => post('test', {}).then(() => toast('Test sent')).catch(e => toast(e.message)); document.getElementById('remtest').onclick = () => post('test', {}).then(() => toast('Test sent')).catch(e => toast(e.message));
} }
+34 -5
View File
@@ -25,8 +25,27 @@ async function searchMusic(term) {
return items.slice(0, 16); return items.slice(0, 16);
} }
// Plex movies and shows (and episodes) from the server-wide search.
async function searchVideo(term) {
const mc = await px.get(`/hubs/search?query=${encodeURIComponent(term)}&limit=12`), items = [];
for (const hub of mc.Hub || []) for (const m of hub.Metadata || []) if (['movie', 'show', 'episode'].includes(m.type)) items.push(m);
return items.slice(0, 24);
}
// Podgrab: the podcasts you follow, matched on title and author.
async function searchPodcasts(term) {
const list = await jfetch('/api/podcast/podcasts'), lower = term.toLowerCase();
return (Array.isArray(list) ? list : []).filter(s => ((pdF(s, 'title') || '') + ' ' + (pdF(s, 'author') || '')).toLowerCase().includes(lower)).slice(0, 12);
}
// The audiobook library, matched on title, author and series.
async function searchBooks(term) {
const [data] = await Promise.all([jfetch('/api/ab/books'), abLoadProg()]), all = Array.isArray(data) ? data : (data.books || Object.values(data)), lower = term.toLowerCase();
return all.filter(b => (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(lower)).slice(0, 10);
}
function viewSearch() { function viewSearch() {
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Tasks, events, files, music…" autocomplete="off" autocapitalize="off"> $('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Tasks, events, files, music, movies & TV, podcasts, audiobooks…" autocomplete="off" autocapitalize="off">
<p class="sub" id="src" style="margin:8px 0 0"></p> <p class="sub" id="src" style="margin:8px 0 0"></p>
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a> <div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div> <a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
@@ -41,7 +60,7 @@ function viewSearch() {
const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`; const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓'; const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓';
const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓'; const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)}`; el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)} · Movies & TV ${part(r.video, musicOff)} · Podcasts ${part(r.pods, '✓')} · Audiobooks ${part(r.books, '✓')}`;
}; };
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources()); jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
showSources(); showSources();
@@ -55,10 +74,13 @@ function viewSearch() {
const enc = encodeURIComponent, lower = term.toLowerCase(); const enc = encodeURIComponent, lower = term.toLowerCase();
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server); const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out')); const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
const [libs, files, music, ddg, hub] = await Promise.allSettled([ const [libs, files, music, video, pods, books, ddg, hub] = await Promise.allSettled([
libsP, libsP,
libsP.then(l => searchFiles(term, l)), libsP.then(l => searchFiles(term, l)),
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')), plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
plexReady ? searchVideo(term) : Promise.reject(new Error('not connected')),
allowed('podcast') ? searchPodcasts(term) : Promise.reject(new Error('not allowed')),
allowed('audio') ? searchBooks(term) : Promise.reject(new Error('not allowed')),
jfetch('/api/ddg?q=' + enc(term)), jfetch('/api/ddg?q=' + enc(term)),
jfetch('/api/search?q=' + enc(term)), jfetch('/api/search?q=' + enc(term)),
]); ]);
@@ -78,6 +100,9 @@ function viewSearch() {
else if (m.type === 'album') musicHtml += `<a class="row" href="#/music/album/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Album · ${esc(m.parentTitle || '')}</small></span></a>`; else if (m.type === 'album') musicHtml += `<a class="row" href="#/music/album/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Album · ${esc(m.parentTitle || '')}</small></span></a>`;
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `<button class="row" data-t="${esc(JSON.stringify(tr))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb || m.parentThumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Track · ${esc(m.grandparentTitle || '')}</small></span></button>`; } else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `<button class="row" data-t="${esc(JSON.stringify(tr))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb || m.parentThumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Track · ${esc(m.grandparentTitle || '')}</small></span></button>`; }
} }
const videoHtml = (video.status === 'fulfilled' ? video.value : []).map(m => vdCard(m)).join('');
const podHtml = (pods.status === 'fulfilled' ? pods.value : []).map(s => `<a class="row" href="#/podcast/show/${enc(pdId(s))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${pdImg('podcasts', s)}" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(pdF(s, 'title'))}</b><small>Podcast${pdF(s, 'author') ? ' · ' + esc(pdF(s, 'author')) : ''}</small></span></a>`).join('');
const bookHtml = (books.status === 'fulfilled' ? books.value : []).map(b => `<a class="row" href="#/audio" data-book="${esc(abId(b))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${abCover(b)}" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(abTitle(b))}</b><small>Audiobook${abAuthor(b) ? ' · ' + esc(abAuthor(b)) : ''}</small></span></a>`).join('');
const d = ddg.status === 'fulfilled' ? ddg.value : {}; const d = ddg.status === 'fulfilled' ? ddg.value : {};
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start"> const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''} ${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
@@ -96,12 +121,16 @@ function viewSearch() {
events: hub.status === 'fulfilled' ? { n: (h.events || []).length } : { err: hub.reason && hub.reason.message }, events: hub.status === 'fulfilled' ? { n: (h.events || []).length } : { err: hub.reason && hub.reason.message },
files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' }, files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' },
music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message }, music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message },
pods: !allowed('podcast') ? undefined : pods.status === 'fulfilled' ? { n: pods.value.length } : { err: pods.reason && pods.reason.message },
books: !allowed('audio') ? undefined : books.status === 'fulfilled' ? { n: books.value.length } : { err: books.reason && books.reason.message },
video: !plexReady ? undefined : video.status === 'fulfilled' ? { n: video.value.length } : { err: video.reason && video.reason.message },
}); });
const notes = [...(h.errors || [])].map(e => `<p class="sub">${esc(e)}</p>`).join(''); const notes = [...(h.errors || [])].map(e => `<p class="sub">${esc(e)}</p>`).join('');
const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).join('') : ''; const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).join('') : '';
res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + notes; res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `<h2>Movies & TV</h2><div class="vgrid">${videoHtml}</div>` : '') + notes;
if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes; if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); }); res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
res.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (books.value || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0)); res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; }); res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; });
} }
+6 -2
View File
@@ -32,7 +32,7 @@ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: v
.sub { color: var(--muted); margin: 0 0 8px; } .sub { color: var(--muted); margin: 0 0 8px; }
.wrap { max-width: 1100px; margin: 0 auto; } .wrap { max-width: 1100px; margin: 0 auto; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(2, 1fr); } .grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tile { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px; .tile { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px;
display: flex; flex-direction: column; gap: 10px; min-height: 108px; text-align: left; } display: flex; flex-direction: column; gap: 10px; min-height: 108px; text-align: left; }
.tile:active, .row:active { background: var(--panel2); } .tile:active, .row:active { background: var(--panel2); }
@@ -716,7 +716,7 @@ body.nav-mini .ngrp span { display: none; }
.homeact { display: flex; gap: 8px; flex-wrap: wrap; } .homeact { display: flex; gap: 8px; flex-wrap: wrap; }
.homeact .btn { margin: 0; } .homeact .btn { margin: 0; }
.home .searchbar { margin: 14px 0 4px; } .home .searchbar { margin: 14px 0 4px; }
.homecols { display: grid; grid-template-columns: 1fr; gap: 0 24px; align-items: start; } .homecols { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0 24px; align-items: start; }
.hcol:empty, .hcol > :empty { display: none; } .hcol:empty, .hcol > :empty { display: none; }
.home .homeapps h2:first-child { margin-top: 24px; } .home .homeapps h2:first-child { margin-top: 24px; }
.home .tile { flex-direction: row; align-items: center; gap: 12px; min-height: 0; padding: 12px 14px; border-radius: 14px; } .home .tile { flex-direction: row; align-items: center; gap: 12px; min-height: 0; padding: 12px 14px; border-radius: 14px; }
@@ -746,3 +746,7 @@ body.nav-mini .ngrp span { display: none; }
.home .tile small { align-self: start; } .home .tile small { align-self: start; }
@media (max-width: 560px) { .homehead { flex-wrap: nowrap; align-items: center; } #qpal { display: none; } } /* the command palette is a keyboard thing: Quick add sits beside the greeting on a phone */ @media (max-width: 560px) { .homehead { flex-wrap: nowrap; align-items: center; } #qpal { display: none; } } /* the command palette is a keyboard thing: Quick add sits beside the greeting on a phone */
@media (max-width: 560px) { .homehead h1 { font-size: 24px; } .homeact .btn { white-space: nowrap; } #settabs { display: flex; } #settabs a { flex: 1; justify-content: center; padding: 6px 8px; } } @media (max-width: 560px) { .homehead h1 { font-size: 24px; } .homeact .btn { white-space: nowrap; } #settabs { display: flex; } #settabs a { flex: 1; justify-content: center; padding: 6px 8px; } }
/* Home never scrolls sideways: wide content shrinks or wraps instead */
.home, .home .hcol, .home .homeapps { min-width: 0; max-width: 100%; }
#view { overflow-x: hidden; }
+1 -1
View File
@@ -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-v12';
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'];
+6 -1
View File
@@ -36,6 +36,8 @@ 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 abapi = require('./abapi'); // audiobook player library, audio and progress (AUDIOBOOKS_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';
@@ -531,7 +533,8 @@ const ACCESS = [
[/^\/sf\//, ['files', 'office']], [/^\/sf\//, ['files', 'office']],
[/^\/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|ab)\//, ['audio']],
[/^\/api\/podcast\//, ['podcast']],
[/^\/api\/signal\//, ['signal']], [/^\/api\/signal\//, ['signal']],
[/^\/api\/lyrics$/, ['music']], [/^\/api\/lyrics$/, ['music']],
[/^\/api\/mail\//, ['mail']], [/^\/api\/mail\//, ['mail']],
@@ -608,6 +611,8 @@ 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/ab/')) return abapi.handle(req, res);
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);
+4 -1
View File
@@ -47,4 +47,7 @@ async function handle(req, res) {
} catch (e) { json(res, 500, { error: e.message }); } } catch (e) { json(res, 500, { error: e.message }); }
} }
module.exports = { handle, KEYS }; // One saved value for the current person, read on the server (secret ones are decrypted).
const get = k => { const v = load()[k]; return SECRET.has(k) && v && v._sealed ? unseal(v) : v; };
module.exports = { handle, KEYS, get };
+45
View File
@@ -0,0 +1,45 @@
// Audiobooks through the hub: the hub signs in to the player as the person, streams audio with ranges, keeps progress per person, and blocks everything else.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-ab-'));
process.env.DATA_DIR = dir; process.env.COOKIE_SECRET = 'ab-'.repeat(12);
const seen = []; let progress = {};
const player = http.createServer((req, res) => {
let b = ''; req.on('data', c => b += c); req.on('end', () => {
seen.push({ m: req.method, u: req.url, cookie: req.headers.cookie, range: req.headers.range });
const j = (c, o, h = {}) => { res.writeHead(c, { 'content-type': 'application/json', ...h }); res.end(JSON.stringify(o)); };
if (req.url === '/login') { const o = JSON.parse(b); return o.username === 'nate' && o.password === 'pw' ? j(200, { user: 'nate' }, { 'set-cookie': 'oa_session=tok1; Path=/; HttpOnly' }) : j(401, { error: 'wrong' }); }
if (!/oa_session=tok1/.test(req.headers.cookie || '')) return j(401, { error: 'not signed in' });
if (req.url === '/api/books') return j(200, [{ id: 'b1', title: 'Book One', author: 'A. Writer' }]);
if (req.url === '/admin/api/books/b1/files') return j(200, ['b1.m4b', 'b1.mp3']);
if (req.url === '/api/books/b1/files/b1.mp3') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); }
if (req.url === '/sync/progress' && req.method === 'POST') { progress = { ...progress, ...JSON.parse(b) }; return j(200, progress); }
if (req.url === '/sync/progress') return j(200, progress);
j(404, { error: 'no' });
});
}).listen(0, async () => {
process.env.AUDIOBOOKS_URL = 'http://127.0.0.1:' + player.address().port;
const users = require('../users'), abapi = require('../abapi');
const me = { id: 'ab-user', name: 'Nate' };
fs.mkdirSync(path.join(dir, 'users', me.id), { recursive: true });
fs.writeFileSync(path.join(dir, 'users', me.id, 'settings.json'), JSON.stringify({ ab_creds: { u: 'nate', p: 'pw' } }));
const hub = http.createServer((req, res) => users.runAs(req.headers['x-nobody'] ? { id: 'nobody', name: 'N' } : me, () => abapi.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port, get = (p, o = {}) => fetch(base + '/api/ab' + p, { redirect: 'manual', ...o });
try {
let r = await get('/books'); assert.equal(r.status, 200); assert.equal((await r.json())[0].title, 'Book One');
assert.ok(/oa_session=tok1/.test(seen.at(-1).cookie), 'signed in as the person');
r = await get('/books/b1/filelist'); assert.deepEqual(await r.json(), ['b1.m4b', 'b1.mp3']);
r = await get('/books/b1/files/b1.mp3', { 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('/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ b1: { pos: 12, dur: 100, t: 5 } }) }); assert.equal(r.status, 200);
r = await get('/progress'); assert.equal((await r.json()).b1.pos, 12);
const logins = seen.filter(s => s.u === '/login').length; assert.equal(logins, 1, 'one sign-in is reused');
const n = seen.length;
for (const [m, p] of [['POST', '/books'], ['GET', '/admin/users'], ['GET', '/books/b1'], ['GET', '/books/b1/files'], ['DELETE', '/progress'], ['GET', '/books/../admin']]) {
r = await get(p, { method: m }); assert.ok([404, 405].includes(r.status), m + ' ' + p + ' -> ' + r.status);
}
assert.equal(seen.length, n, 'blocked calls never reach the player');
r = await get('/books', { headers: { 'x-nobody': '1' } }); assert.equal(r.status, 503); assert.ok(/sign-in in Settings/.test((await r.json()).error), 'someone with no saved sign-in is told what to do');
console.log('audiobooks ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); player.close();
});
});
+32
View File
@@ -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();
});
});