Compare commits
6
Commits
book-store
...
radio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b16b6432 | ||
|
|
9436ac8d4d | ||
|
|
27df8031ca | ||
|
|
6a4edd442f | ||
|
|
c59ff51361 | ||
|
|
207f8dcc2a |
No files matched your search
@@ -61,6 +61,8 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
|||||||
- **Synced**: where you got to, bookmarks, highlights (four colours) and notes are kept on the server, so a book opens where you left off on any device; two devices adding notes at once keep both (the newest edit of the same note wins). Continue reading on the library page, sorting and search, and *Copy as text* exports a book's notes.
|
- **Synced**: where you got to, bookmarks, highlights (four colours) and notes are kept on the server, so a book opens where you left off on any device; two devices adding notes at once keep both (the newest edit of the same note wins). Continue reading on the library page, sorting and search, and *Copy as text* exports a book's notes.
|
||||||
- **Book store** (Books -> Book store): search free, DRM-free books from **Project Gutenberg** (through Gutendex), **Standard Ebooks**, the **Internet Archive** (public-domain EPUBs only; borrow-only items are refused) and any **OPDS catalogue** an admin adds under *Catalogues* (Calibre-Web, Kavita, Komga and so on; an optional username and password are kept encrypted on the server and never sent to the browser). One search covers every source, results show their cover and source, and *Add to library* downloads the book on the server straight onto your shelf. The server only ever contacts a source's own addresses (redirects to other sites are refused), covers come through the hub, and a catalogue book must live on that catalogue's own address. Settings: `GUTENDEX_URL`, `STANDARD_EBOOKS_URL` and `ARCHIVE_URL` can point at mirrors. `node tools/store-test.js` tests it against fake catalogues; it has not been run against the live sites from the build environment, so if one of them changes its feed format, the line under the search box names the source that failed.
|
- **Book store** (Books -> Book store): search free, DRM-free books from **Project Gutenberg** (through Gutendex), **Standard Ebooks**, the **Internet Archive** (public-domain EPUBs only; borrow-only items are refused) and any **OPDS catalogue** an admin adds under *Catalogues* (Calibre-Web, Kavita, Komga and so on; an optional username and password are kept encrypted on the server and never sent to the browser). One search covers every source, results show their cover and source, and *Add to library* downloads the book on the server straight onto your shelf. The server only ever contacts a source's own addresses (redirects to other sites are refused), covers come through the hub, and a catalogue book must live on that catalogue's own address. Settings: `GUTENDEX_URL`, `STANDARD_EBOOKS_URL` and `ARCHIVE_URL` can point at mirrors. `node tools/store-test.js` tests it against fake catalogues; it has not been run against the live sites from the build environment, so if one of them changes its feed format, the line under the search box names the source that failed.
|
||||||
- The reader scripts are in `public/vendor/reader/` (epub.js, JSZip, pdf.js, mammoth, the MOBI parser) and load only when a book is opened or added. `node tools/books-test.js` tests the server; `python3 tools/mkbooks.py <dir>` makes a small EPUB, PDF and MOBI to try. **DRM-protected books are refused** (a Kindle file with copy protection is detected on upload and the reason is shown); only DRM-free books work. The MOBI reader was tried on a generated MOBI 6 file, not on real Kindle files, so tell me about any book that opens oddly. Not included: text-to-speech, offline reading.
|
- The reader scripts are in `public/vendor/reader/` (epub.js, JSZip, pdf.js, mammoth, the MOBI parser) and load only when a book is opened or added. `node tools/books-test.js` tests the server; `python3 tools/mkbooks.py <dir>` makes a small EPUB, PDF and MOBI to try. **DRM-protected books are refused** (a Kindle file with copy protection is detected on upload and the reason is shown); only DRM-free books work. The MOBI reader was tried on a generated MOBI 6 file, not on real Kindle files, so tell me about any book that opens oddly. Not included: text-to-speech, offline reading.
|
||||||
|
**Free audiobooks** (Audiobooks -> Free audiobooks; works without any audiobook sign-in): search **LibriVox**, thousands of public-domain books read aloud by volunteers, by title or author. A book shows its length, language, description and chapters; *Play* (or *Resume*) queues every chapter in the same player as Music so the next one follows by itself, and starting a book puts it on your shelf (*My free audiobooks*, with a progress bar; *Remove from shelf* in the book's sheet). Where you got to is kept on the server per person, so it follows you between devices. The hub searches LibriVox and hands the browser each chapter's address, but only addresses on LibriVox or the Internet Archive, which hosts the recordings; covers come through the hub. `LIBRIVOX_URL` points at a mirror. `node tools/librivox-test.js` tests it against a fake LibriVox; it has not been run against the live site from the build environment.
|
||||||
|
- **Radio**: internet radio, with **The Journey FM** (Liberty University, Lynchburg, Virginia; MP3 192 kbps, `http://ic.liberty.edu:8000/WRVL`) always on top, showing what is playing now and the last songs played from the station's own feed. Below it, search thousands of stations from the community [Radio Browser](https://www.radio-browser.info/) directory (free, no key) by name or with quick topic chips (Christian, Worship, Gospel, Talk, News, Classical, Jazz...). The heart saves a station to *My stations* (your own list). Stations play in the same player as Music, so they keep playing as you move between tabs. The hub fetches each stream itself and passes it on, because many stations only offer plain http, which a browser refuses to play inside an https page; it also reads the song title carried in the stream (the player shows it) and fetches station logos. The hub never connects to private, local or link-local addresses for a station. Give it to people in Settings -> People (Radio). `JOURNEYFM_STREAM_URL`, `JOURNEYFM_NOW_URL` and `RADIO_BROWSER_URL` can override the addresses. `node tools/radio-test.js` tests it against fake stations; the real Journey stream could not be reached from the build environment, so if it will not play, open the station's own *Website* button and tell me what changed. Not included: HLS-only stations, recording, alarms.
|
||||||
- **Signal**: your Signal chats inside the app, **on desktop-class screens only** (a window at least 900 px wide with a mouse or trackpad; phones and touch tablets don't
|
- **Signal**: your Signal chats inside the app, **on desktop-class screens only** (a window at least 900 px wide with a mouse or trackpad; phones and touch tablets don't
|
||||||
see it). Besides the Signal tab there is a chat button on **every** screen that opens a panel over whatever you are doing (close it with the x; the
|
see it). Besides the Signal tab there is a chat button on **every** screen that opens a panel over whatever you are doing (close it with the x; the
|
||||||
expand button opens the full page). New messages get your attention with a toast, a chime, the unread count in the browser tab title and on the installed app's icon, and
|
expand button opens the full page). New messages get your attention with a toast, a chime, the unread count in the browser tab title and on the installed app's icon, and
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
// Free audiobooks from LibriVox (public-domain books read by volunteers). The hub searches LibriVox's own catalogue, hands the browser each chapter's audio
|
||||||
|
// address (the recordings are hosted by the Internet Archive), keeps each person's shelf of saved books and where they got to in each one.
|
||||||
|
const fs = require('fs'), path = require('path');
|
||||||
|
const users = require('./users');
|
||||||
|
const prog = require('./progress')('librivox-progress.json'); // keyed lv:<book id>: seconds into the whole book
|
||||||
|
|
||||||
|
const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, '');
|
||||||
|
const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname];
|
||||||
|
const allowed = u => { try { const x = new URL(u); return /^https?:$/.test(x.protocol) && HOSTS().some(h => x.hostname === h || x.hostname.endsWith('.' + h)); } catch { return false; } };
|
||||||
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
const plain = s => String(s || '').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/�?39;/g, "'").replace(/\s+/g, ' ').trim();
|
||||||
|
const shelfFile = () => path.join(users.dir(), 'librivox.json');
|
||||||
|
const shelf = () => { try { const l = JSON.parse(fs.readFileSync(shelfFile(), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; } };
|
||||||
|
const saveShelf = l => { const f = shelfFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(l), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||||
|
|
||||||
|
async function api(query) { // LibriVox answers 404 with a message when nothing matches
|
||||||
|
const url = `${BASE()}/api/feed/audiobooks/?format=json&coverart=1&${query}`;
|
||||||
|
let r; try { r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0', accept: 'application/json' }, signal: AbortSignal.timeout(12000) }); } catch (e) { throw fail(502, e.name === 'TimeoutError' ? 'LibriVox did not answer in time' : 'Could not reach LibriVox'); }
|
||||||
|
const text = await r.text(); let j; try { j = JSON.parse(text); } catch { if (r.status === 404) return []; throw fail(502, 'LibriVox sent something unexpected'); }
|
||||||
|
if (!r.ok || !Array.isArray(j.books)) { if (r.status === 404 || (j && j.error)) return []; throw fail(502, 'LibriVox answered ' + r.status); }
|
||||||
|
return j.books;
|
||||||
|
}
|
||||||
|
const author = b => (b.authors || []).map(a => [a.first_name, a.last_name].filter(Boolean).join(' ')).join(', ');
|
||||||
|
const summary = b => ({ id: String(b.id), title: plain(b.title), author: author(b), secs: Math.round(+b.totaltimesecs || 0), language: b.language || '', year: b.copyright_year || '', description: plain(b.description).slice(0, 800), cover: b.coverart_jpg || b.coverart_thumbnail || '', link: b.url_librivox || '' });
|
||||||
|
const cache = new Map(); // book id -> { t, book } (chapters included)
|
||||||
|
async function book(id) {
|
||||||
|
if (!/^\d{1,9}$/.test(id)) throw fail(400, 'Bad book');
|
||||||
|
const c = cache.get(id); if (c && Date.now() - c.t < 3600e3) return c.book;
|
||||||
|
const [b] = await api(`id=${id}&extended=1`); if (!b) throw fail(404, 'Book not found');
|
||||||
|
const chapters = (b.sections || []).filter(s => allowed(s.listen_url)).map((s, i) => ({ n: i, title: plain(s.title) || 'Chapter ' + (i + 1), secs: Math.round(+s.playtime || 0), url: s.listen_url, reader: (s.readers || []).map(r => r.display_name).filter(Boolean).join(', ') }));
|
||||||
|
const full = { ...summary(b), chapters }; if (cache.size > 100) cache.clear(); cache.set(id, { t: Date.now(), book: full }); return full;
|
||||||
|
}
|
||||||
|
const publicBook = b => ({ ...b, chapters: b.chapters.map(({ url, ...c }) => c), cover: b.cover ? `/api/ab/free/cover/${b.id}` : '' });
|
||||||
|
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } };
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab/free'.length) || '/';
|
||||||
|
try {
|
||||||
|
let m;
|
||||||
|
if (p === '/search' && req.method === 'GET') {
|
||||||
|
const q = (u.searchParams.get('q') || '').trim().slice(0, 100), page = Math.min(50, Math.max(1, +u.searchParams.get('page') || 1)), off = (page - 1) * 20;
|
||||||
|
const lists = q ? await Promise.all([api(`title=${encodeURIComponent(q)}&limit=20&offset=${off}`), q.split(/\s+/).length <= 2 ? api(`author=${encodeURIComponent(q.split(/\s+/).pop())}&limit=20&offset=${off}`) : []]) : [await api(`limit=20&offset=${off}`)];
|
||||||
|
const seen = new Set(), items = []; for (const b of lists.flat()) if (!seen.has(String(b.id))) { seen.add(String(b.id)); items.push(summary(b)); }
|
||||||
|
return json(res, 200, { items: items.map(b => ({ ...b, cover: b.cover ? `/api/ab/free/cover/${b.id}?u=${encodeURIComponent(b.cover)}` : '' })), more: lists.flat().length >= 20 });
|
||||||
|
}
|
||||||
|
if ((m = /^\/book\/(\d+)$/.exec(p)) && req.method === 'GET') return json(res, 200, publicBook(await book(m[1])));
|
||||||
|
if ((m = /^\/audio\/(\d+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording itself is on the Internet Archive; the browser is sent straight there (it supports seeking)
|
||||||
|
const b = await book(m[1]), c = b.chapters[+m[2]]; if (!c) throw fail(404, 'No such chapter');
|
||||||
|
res.writeHead(302, { location: c.url, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||||
|
}
|
||||||
|
if ((m = /^\/cover\/(\d+)$/.exec(p)) && req.method === 'GET') {
|
||||||
|
let cu = u.searchParams.get('u'); if (!cu || !allowed(cu)) { const b = await book(m[1]); cu = b.cover; } if (!cu || !allowed(cu)) throw fail(404, 'No cover');
|
||||||
|
let r = await fetch(cu, { headers: { 'user-agent': 'NE-Hub/1.0' }, redirect: 'manual', signal: AbortSignal.timeout(10000) });
|
||||||
|
for (let i = 0; i < 4 && [301, 302, 303, 307, 308].includes(r.status); i++) { const to = new URL(r.headers.get('location') || '', cu).href; if (!allowed(to)) throw fail(502, 'Bad cover address'); cu = to; r = await fetch(cu, { headers: { 'user-agent': 'NE-Hub/1.0' }, redirect: 'manual', signal: AbortSignal.timeout(10000) }); }
|
||||||
|
const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (!r.ok || !/^image\/(jpeg|png|gif|webp)$/.test(type)) throw fail(404, 'No cover');
|
||||||
|
const buf = Buffer.from(await r.arrayBuffer()); if (buf.length > 3e6) throw fail(502, 'Cover too large'); res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800' }); return res.end(buf);
|
||||||
|
}
|
||||||
|
if (p === '/shelf' && req.method === 'GET') return json(res, 200, shelf().map(b => ({ ...b, cover: b.cover ? `/api/ab/free/cover/${b.id}` : '' })));
|
||||||
|
if (p === '/shelf' && req.method === 'POST') {
|
||||||
|
const b = await book(String((await readBody(req)).id || '')), l = shelf().filter(x => x.id !== b.id);
|
||||||
|
l.push({ id: b.id, title: b.title, author: b.author, secs: b.secs, language: b.language, cover: b.cover, added: Date.now() }); saveShelf(l); return json(res, 200, { saved: true });
|
||||||
|
}
|
||||||
|
if ((m = /^\/shelf\/(\d+)$/.exec(p)) && req.method === 'DELETE') { saveShelf(shelf().filter(x => x.id !== m[1])); return json(res, 200, { removed: true }); }
|
||||||
|
if (p === '/progress') {
|
||||||
|
if (req.method === 'GET') return json(res, 200, prog.load());
|
||||||
|
if (req.method === 'POST') return json(res, 200, prog.merge(await readBody(req)));
|
||||||
|
}
|
||||||
|
return json(res, 404, { error: 'Not found' });
|
||||||
|
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle };
|
||||||
+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 podcast abapi abdirect books store; 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 abdirect books store librivox radio; 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": [],
|
||||||
|
|||||||
+7
-4
@@ -99,6 +99,8 @@ const ICONS = {
|
|||||||
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
|
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
|
||||||
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
||||||
bookmark: '<path d="M7 4h10v16l-5-4-5 4z"/>',
|
bookmark: '<path d="M7 4h10v16l-5-4-5 4z"/>',
|
||||||
|
radio: '<rect x="3" y="8" width="18" height="12" rx="2"/><path d="M7 8l10-4M8 14h5M16 14h.01M16 17h.01"/>',
|
||||||
|
heart: '<path d="M12 20s-7-4.4-7-10a4 4 0 017-2.5A4 4 0 0119 10c0 5.6-7 10-7 10z"/>',
|
||||||
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
|
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
|
||||||
font: '<path d="M3 19l5-13 5 13M5 14h6M15 19l3.5-9 3.5 9M16.2 16h4.6"/>',
|
font: '<path d="M3 19l5-13 5 13M5 14h6M15 19l3.5-9 3.5 9M16.2 16h4.6"/>',
|
||||||
};
|
};
|
||||||
@@ -155,12 +157,12 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
|||||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
|
||||||
// ---- Navigation
|
// ---- Navigation
|
||||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||||
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry (each part still needs its own permission). */
|
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry (each part still needs its own permission). */
|
||||||
const navTabs = () => { const out = []; let done = false; for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; if (t[0] === 'tasks' || t[0] === 'calendar') { if (!done) { out.push(['planner', 'Planner']); done = true; } continue; } out.push(t); } return out; };
|
const navTabs = () => { const out = []; let done = false; for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; if (t[0] === 'tasks' || t[0] === 'calendar') { if (!done) { out.push(['planner', 'Planner']); done = true; } continue; } out.push(t); } return out; };
|
||||||
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
|
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
|
||||||
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['music', 'video', 'audio', 'podcast', 'read']], ['', ['settings']]];
|
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['music', 'video', 'audio', 'podcast', 'radio', 'read']], ['', ['settings']]];
|
||||||
const navGroups = (tabs = navTabs()) => {
|
const navGroups = (tabs = navTabs()) => {
|
||||||
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
|
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
|
||||||
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
|
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
|
||||||
@@ -198,7 +200,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: args => args[0] === 'classic' ? viewAudio() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => viewPodcast(args), read: args => viewReader(args), 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() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => viewPodcast(args), radio: viewRadio, read: args => viewReader(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 {}; } };
|
||||||
@@ -265,6 +267,7 @@ function homeApps() {
|
|||||||
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
|
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
|
||||||
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
|
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
|
||||||
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
|
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
|
||||||
|
radio: () => tile('#/radio', '#7b4fd6', 'radio', 'Radio', 'The Journey FM & more'),
|
||||||
read: () => tile('#/read', '#2b8a5e', 'book', 'Books', 'EPUB, PDF, DOCX'),
|
read: () => tile('#/read', '#2b8a5e', 'book', 'Books', 'EPUB, PDF, DOCX'),
|
||||||
signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'),
|
signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'),
|
||||||
links: () => tile('#/links', '#0f9d9a', 'link', 'Links', allLinks().length + ' saved'),
|
links: () => tile('#/links', '#0f9d9a', 'link', 'Links', allLinks().length + ' saved'),
|
||||||
|
|||||||
@@ -63,12 +63,13 @@ async function viewBooks(args = []) {
|
|||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty">Loading…</p></div>';
|
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty">Loading…</p></div>';
|
||||||
try {
|
try {
|
||||||
const [data] = await Promise.all([abApi('/books'), abLoadProg()]);
|
const [data] = await Promise.all([abApi('/books'), abLoadProg(), lvPrime()]);
|
||||||
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
|
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!location.hash.startsWith('#/audio')) return;
|
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>`;
|
await lvPrime(); if (!location.hash.startsWith('#/audio')) return;
|
||||||
$('#abretry').onclick = () => route(); 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="#/audio/free">Free audiobooks</a><a class="btn ghost" href="#/settings">Settings</a><a class="btn ghost" href="#/audio/classic">Open the classic player</a></div>${lvShelf.length ? `<h2>My free audiobooks</h2><div class="vgrid">${lvRecent().map(b => lvCard(b)).join('')}</div>` : ''}</div>`;
|
||||||
|
$('#abretry').onclick = () => route(); lvBind(v, () => route()); return;
|
||||||
}
|
}
|
||||||
if (!location.hash.startsWith('#/audio')) 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 pct = b => { const p = abProg[abId(b)]; return p && p.dur ? Math.min(100, p.pos / p.dur * 100) : 0; };
|
||||||
@@ -76,8 +77,10 @@ async function viewBooks(args = []) {
|
|||||||
<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>
|
<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>`;
|
<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);
|
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>
|
const contFree = lvShelf.filter(b => lvLeft(b.id)).sort((a, b) => lvProg['lv:' + b.id].t - lvProg['lv:' + a.id].t).slice(0, 10);
|
||||||
${cont.length ? `<h2>Continue listening</h2><div class="vrow">${cont.map(b => card(b, true)).join('')}</div>` : ''}
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Audiobooks</h1><span class="pspace grow"></span><a class="btn ghost" href="#/audio/free">Free audiobooks</a><a class="btn ghost" href="#/audio/classic" title="The audiobook player's own page">Classic player</a></div>
|
||||||
|
${cont.length || contFree.length ? `<h2>Continue listening</h2><div class="vrow">${[...cont.map(b => ({ t: abProg[abId(b)].t, h: card(b, true) })), ...contFree.map(b => ({ t: lvProg['lv:' + b.id].t, h: lvCard(b, true) }))].sort((a, b) => b.t - a.t).map(x => x.h).join('')}</div>` : ''}
|
||||||
|
${lvShelf.length ? `<h2>My free audiobooks</h2><div class="vgrid">${lvRecent().map(b => lvCard(b)).join('')}</div>` : ''}
|
||||||
<input type="search" id="abq" placeholder="Search your books" autocomplete="off" autocapitalize="off" style="margin-top:14px">
|
<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>`;
|
<h2>Library</h2><div class="vgrid" id="abgrid"></div></div>`;
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
@@ -88,6 +91,7 @@ async function viewBooks(args = []) {
|
|||||||
draw(); $('#abq').oninput = draw;
|
draw(); $('#abq').oninput = draw;
|
||||||
const byId = new Map(abBooks.map(b => [abId(b), b]));
|
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)); };
|
v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
|
||||||
|
lvBind(v, () => route());
|
||||||
}
|
}
|
||||||
|
|
||||||
function abSheet(b) {
|
function abSheet(b) {
|
||||||
|
|||||||
@@ -73,6 +73,8 @@
|
|||||||
<script src="/podcast-view.js"></script>
|
<script src="/podcast-view.js"></script>
|
||||||
<script src="/reader.js"></script>
|
<script src="/reader.js"></script>
|
||||||
<script src="/audiobooks-view.js"></script>
|
<script src="/audiobooks-view.js"></script>
|
||||||
|
<script src="/librivox-view.js"></script>
|
||||||
|
<script src="/radio-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>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'use strict';
|
||||||
|
// ---- Free audiobooks (LibriVox): search public-domain books read by volunteers, keep a shelf, and listen in the same player as Music. Where you got to in each
|
||||||
|
// book is kept on the server (per person), so it follows you between devices.
|
||||||
|
const lvApi = (p, opts) => jfetch('/api/ab/free' + p, opts);
|
||||||
|
const lvOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
|
||||||
|
const lvDur = 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 lvImg = (b, sq) => `<span class="vimg" style="aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>`;
|
||||||
|
let lvProg = {}, lvQueued = {}, lvLast = 0, lvBook = null, lvLoading = '';
|
||||||
|
const lvPlaying = () => { const t = cur(), m = t && /^lv:(\d+):(\d+)$/.exec(String(t.key)); return m ? { id: m[1], n: +m[2] } : null; };
|
||||||
|
const lvOffsets = b => { let t = 0; return b.chapters.map(c => { const o = t; t += c.secs; return o; }); };
|
||||||
|
const lvTotal = b => b.chapters.reduce((a, c) => a + c.secs, 0) || b.secs;
|
||||||
|
const lvLoadProg = async () => { try { for (const [id, p] of Object.entries(await lvApi('/progress') || {})) if (!lvProg[id] || p.t > lvProg[id].t) lvProg[id] = p; } catch {} };
|
||||||
|
const lvPush = keepalive => {
|
||||||
|
if (!Object.keys(lvQueued).length) return;
|
||||||
|
const body = JSON.stringify(lvQueued); lvQueued = {};
|
||||||
|
fetch('/api/ab/free/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive }).then(r => { if (!r.ok) throw 0; }).catch(() => { lvQueued = { ...JSON.parse(body), ...lvQueued }; });
|
||||||
|
};
|
||||||
|
const lvSave = keepalive => {
|
||||||
|
const now = lvPlaying(), d = pl.a.duration; if (!now || !isFinite(d) || d <= 0) return;
|
||||||
|
if (!lvBook || lvBook.id !== now.id) { // the page was reloaded while a book was playing: fetch its chapter list once, then carry on saving
|
||||||
|
if (lvLoading !== now.id) { lvLoading = now.id; lvApi('/book/' + now.id).then(b => { lvBook = { id: b.id, offsets: lvOffsets(b), total: lvTotal(b) }; }).catch(() => {}).finally(() => { lvLoading = ''; }); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = 'lv:' + now.id; lvProg[key] = lvQueued[key] = { pos: (lvBook.offsets[now.n] || 0) + pl.a.currentTime, dur: lvBook.total, t: Date.now() }; lvPush(keepalive);
|
||||||
|
};
|
||||||
|
pl.a.addEventListener('timeupdate', () => { if (Date.now() - lvLast > 8000) { lvLast = Date.now(); lvSave(); } });
|
||||||
|
pl.a.addEventListener('pause', () => lvSave());
|
||||||
|
pl.a.addEventListener('ended', () => lvSave());
|
||||||
|
addEventListener('pagehide', () => lvSave(true));
|
||||||
|
let lvShelf = [];
|
||||||
|
/** Load the saved free books and where you got to in each (for this page and for the main Audiobooks list). Never throws. */
|
||||||
|
const lvPrime = async () => { try { [lvShelf] = await Promise.all([lvApi('/shelf'), lvLoadProg()]); } catch { lvShelf = []; } return lvShelf; };
|
||||||
|
const lvCard = (b, wide) => { const p = lvLeft(b.id), pct = p ? Math.min(100, p.pos / p.dur * 100) : 0; return `<a class="vcard${wide ? ' wide' : ''}" href="#" data-lv="${esc(b.id)}" ${wide ? 'style="width:140px;flex:none"' : ''}>${lvImg(b).replace('</span>', pct > 1 ? `<i class="vprog"><b style="width:${pct}%"></b></i></span>` : '</span>')}<b>${esc(b.title)}</b><small>${esc(b.author || '')}${b.secs ? (b.author ? ' · ' : '') + esc(lvDur(b.secs)) : ''}</small></a>`; };
|
||||||
|
const lvBind = (root, after, known) => root.querySelectorAll('[data-lv]').forEach(a => a.onclick = e => { e.preventDefault(); lvSheet(a.dataset.lv, after, known); });
|
||||||
|
const lvRecent = () => lvShelf.slice().sort((a, b) => ((lvProg['lv:' + b.id] || {}).t || b.added) - ((lvProg['lv:' + a.id] || {}).t || a.added));
|
||||||
|
const lvLeft = id => { const p = lvProg['lv:' + id]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; };
|
||||||
|
|
||||||
|
/** Play a book from `at` seconds into it (default: where you left off), as one queue of chapters so the next chapter follows by itself. */
|
||||||
|
function lvPlay(b, at) {
|
||||||
|
const offs = lvOffsets(b), p = lvLeft(b.id), pos = at != null ? at : p ? p.pos : 0;
|
||||||
|
let n = 0; for (let i = 0; i < offs.length; i++) if (offs[i] <= pos) n = i;
|
||||||
|
const now = lvPlaying(); lvBook = { id: b.id, offsets: offs, total: lvTotal(b) };
|
||||||
|
if (now && now.id === b.id && now.n === n && at == null) { if (pl.a.paused) pl.a.play().catch(() => {}); return; }
|
||||||
|
playQueue(b.chapters.map(c => ({ key: `lv:${b.id}:${c.n}`, title: c.title, artist: b.author, album: b.title, thumb: b.cover, src: `/api/ab/free/audio/${b.id}/${c.n}`, dur: c.secs * 1000, nocache: true })), n);
|
||||||
|
const within = Math.max(0, pos - offs[n]); if (within > 1) pl.a.addEventListener('loadedmetadata', () => { const t = lvPlaying(); if (t && t.id === b.id && t.n === n) pl.a.currentTime = within; }, { once: true });
|
||||||
|
lvApi('/shelf', lvOpt('POST', { id: b.id })).catch(() => {}); // a book you start is kept on your shelf
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewFreeBooks() {
|
||||||
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty">Loading…</p></div>';
|
||||||
|
try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
|
if (!location.hash.startsWith('#/audio/free')) return;
|
||||||
|
const S = { q: '', page: 1, items: [], more: false, busy: false, seq: 0 };
|
||||||
|
const card = b => lvCard(b);
|
||||||
|
v.innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><h1 style="margin:0">Free audiobooks</h1></div>
|
||||||
|
<p class="sub">Public-domain books read aloud by volunteers (LibriVox). Free, with no sign-in. A book you start is kept on your shelf.</p>
|
||||||
|
<input type="search" id="lvq" placeholder="Search by title or author" autocomplete="off" autocapitalize="off" style="width:100%"><div id="lvnote"></div><div id="lvbody"></div><div id="lvmore" class="bar" style="justify-content:center"></div></div>`;
|
||||||
|
const known = new Map(lvShelf.map(b => [b.id, b]));
|
||||||
|
const draw = () => {
|
||||||
|
const mine = lvRecent();
|
||||||
|
$('#lvbody').innerHTML = (!S.q && mine.length ? `<h2>My free audiobooks</h2><div class="vgrid">${mine.map(card).join('')}</div>` : '') +
|
||||||
|
(S.q ? (S.items.length ? `<h2>Results</h2><div class="vgrid">${S.items.map(card).join('')}</div>` : S.busy ? '' : '<p class="empty">Nothing found.</p>') : `<h2>${S.items.length ? 'Browse' : ''}</h2>${S.items.length ? `<div class="vgrid">${S.items.map(card).join('')}</div>` : S.busy ? '' : ''}`);
|
||||||
|
lvBind($('#lvbody'), draw, known);
|
||||||
|
$('#lvmore').innerHTML = S.busy ? '<span class="sub">Searching…</span>' : S.more ? '<button class="btn ghost" id="lvmb">Show more</button>' : '';
|
||||||
|
if ($('#lvmb')) $('#lvmb').onclick = () => run(false);
|
||||||
|
};
|
||||||
|
const run = async reset => {
|
||||||
|
const my = ++S.seq; if (reset) { S.page = 1; S.items = []; } else S.page++; S.busy = true; $('#lvnote').innerHTML = ''; draw();
|
||||||
|
try { const r = await lvApi(`/search?q=${encodeURIComponent(S.q)}&page=${S.page}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; r.items.forEach(b => known.set(b.id, known.get(b.id) || b)); }
|
||||||
|
catch (e) { if (my === S.seq) $('#lvnote').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||||
|
if (my === S.seq) { S.busy = false; draw(); }
|
||||||
|
};
|
||||||
|
let t; $('#lvq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); run(true); }, 450); };
|
||||||
|
$('#lvq').onkeydown = e => { if (e.key === 'Enter') { clearTimeout(t); S.q = e.target.value.trim(); run(true); } };
|
||||||
|
draw(); run(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lvSheet(id, after, known = new Map()) {
|
||||||
|
const sum = known.get(id) || lvShelf.find(x => x.id === id) || { id, title: 'Audiobook', author: '' };
|
||||||
|
const el = openSheet(sum.title, sum.author || '', '<p class="empty">Loading…</p>');
|
||||||
|
let b; try { b = await lvApi('/book/' + id); } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
||||||
|
if (!document.body.contains(el)) return;
|
||||||
|
const p = lvLeft(id), total = lvTotal(b), onShelf = () => lvShelf.some(x => x.id === id);
|
||||||
|
el.querySelector('.empty').outerHTML = `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>
|
||||||
|
<div style="min-width:0"><p class="sub" style="margin:0">${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' · '))}</p><p class="sub" style="margin:4px 0 0">${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}</p></div></div>
|
||||||
|
${b.description ? `<p style="margin:10px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden">${esc(b.description)}</p>` : ''}
|
||||||
|
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button><button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button></div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||||
|
const go = at => { if (!b.chapters.length) return toast('This book has no audio'); lvPlay(b, at); closeSheet(); if (!onShelf()) lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); if (after) after(); };
|
||||||
|
el.querySelector('#lvp').onclick = () => go();
|
||||||
|
if (el.querySelector('#lvs')) el.querySelector('#lvs').onclick = () => go(0);
|
||||||
|
el.querySelector('#lvc').onclick = () => { const offs = lvOffsets(b); el.querySelector('#lvch').innerHTML = b.chapters.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${esc([abFmt(c.secs), c.reader].filter(Boolean).join(' · '))}</small></span></button>`).join(''); el.querySelectorAll('#lvch [data-i]').forEach(x => x.onclick = () => go(offs[x.dataset.i])); };
|
||||||
|
el.querySelector('#lvk').onclick = async e => { const btn = e.currentTarget; try { if (onShelf()) { await lvApi('/shelf/' + id, lvOpt('DELETE')); lvShelf = lvShelf.filter(x => x.id !== id); } else { await lvApi('/shelf', lvOpt('POST', { id })); lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); } btn.textContent = onShelf() ? 'Remove from shelf' : 'Save to shelf'; if (after) after(); } catch (x) { toast(x.message); } };
|
||||||
|
}
|
||||||
+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 && /^\/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==',
|
art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio)\//.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;
|
||||||
|
|||||||
+1
-1
@@ -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) => 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(() => {});
|
const tlSend = (state, key, pos, dur) => /^(pod|radio|lv):/.test(String(key)) ? 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
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use strict';
|
||||||
|
// ---- Radio: The Journey FM on top, your saved stations, and a search of thousands more (Radio Browser). The hub passes the stream on (and reads the song title
|
||||||
|
// out of it), so stations that only offer plain http still play, and everything plays in the same player as Music.
|
||||||
|
const rdoApi = (p, opts) => jfetch('/api/radio' + p, opts);
|
||||||
|
const rdoOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
|
||||||
|
const rdoIcon = s => s.favicon ? '/api/radio/icon?u=' + encodeURIComponent(s.favicon) : '';
|
||||||
|
const rdoTrack = s => ({ key: 'radio:' + s.id, title: s.name, artist: 'Live radio', album: [s.state, s.country].filter(Boolean).join(', '), thumb: rdoIcon(s), src: '/api/radio/play/' + encodeURIComponent(s.id), nocache: true, dur: 0, station: s.name });
|
||||||
|
const rdoPlaying = () => { const t = cur(); return t && String(t.key).startsWith('radio:') ? t : null; };
|
||||||
|
const rdoMeta = s => [s.tags && s.tags.split(',').slice(0, 2).join(', '), s.state || s.country, s.codec && (s.codec + (s.bitrate ? ' ' + s.bitrate + 'k' : ''))].filter(Boolean).join(' · ');
|
||||||
|
const rdoChips = [['Christian', 'christian'], ['Worship', 'worship'], ['Gospel', 'gospel'], ['Talk', 'talk'], ['News', 'news'], ['Classical', 'classical'], ['Jazz', 'jazz'], ['Country', 'country'], ['Oldies', 'oldies']];
|
||||||
|
let rdoSaved = [], rdoNow = {};
|
||||||
|
|
||||||
|
/** While a station plays, ask what song is on every 20 seconds and show it in the player. */
|
||||||
|
async function rdoPoll() {
|
||||||
|
const t = rdoPlaying(); if (!t) return;
|
||||||
|
const id = t.key.slice(6);
|
||||||
|
try {
|
||||||
|
const n = await rdoApi('/now/' + encodeURIComponent(id)); if (!rdoPlaying() || rdoPlaying().key !== t.key) return;
|
||||||
|
rdoNow[id] = n; t.title = n.title || t.station; t.artist = n.title ? (n.artist || t.station) : 'Live radio'; t.album = n.title ? t.station : t.album;
|
||||||
|
if (typeof updateNow === 'function') updateNow(); const el = document.getElementById('rdonow'); if (el && rdoPlaying()) el.dataset.id === id && rdoDrawNow(el, n);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
setInterval(rdoPoll, 20000); pl.a.addEventListener('playing', () => { if (rdoPlaying()) setTimeout(rdoPoll, 1500); });
|
||||||
|
function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `<small>Now playing</small><b>${esc(n.title)}</b>${n.artist ? '<span> · ' + esc(n.artist) + '</span>' : ''}` : '<small>Live</small>'; }
|
||||||
|
|
||||||
|
async function viewRadio() {
|
||||||
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Radio</h1><p class="empty">Loading…</p></div>';
|
||||||
|
let feat, mine;
|
||||||
|
try { ({ featured: feat, mine } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Radio</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
|
if (!location.hash.startsWith('#/radio')) return;
|
||||||
|
rdoSaved = mine; const S = { q: '', tag: 'christian', items: [], more: false, busy: false, seq: 0, off: 0 };
|
||||||
|
const saved = id => rdoSaved.some(x => x.id === id);
|
||||||
|
const row = (s, fav) => `<div class="row rdorow${rdoPlaying() && rdoPlaying().key === 'radio:' + s.id ? ' playing' : ''}" data-id="${esc(s.id)}"><button class="rdoplay" data-play aria-label="Play ${esc(s.name)}"><span class="art">${s.favicon ? `<img alt="" loading="lazy" src="${esc(rdoIcon(s))}" onerror="this.remove()">` : ''}</span>
|
||||||
|
<span class="grow"><b>${esc(s.name)}</b><small>${esc(rdoMeta(s))}</small></span>${icon('play')}</button>${s.featured ? '' : `<button class="ibtn" data-fav aria-label="${saved(s.id) ? 'Remove from my stations' : 'Save to my stations'}" title="${saved(s.id) ? 'Remove from my stations' : 'Save to my stations'}" style="${saved(s.id) ? 'color:var(--danger)' : ''}">${icon('heart')}</button>`}</div>`;
|
||||||
|
const j = feat[0];
|
||||||
|
v.innerHTML = `<div class="wrap"><h1>Radio</h1>
|
||||||
|
<div class="card rdofeat" style="max-width:none"><div class="sub" style="margin:0">Featured</div><h2 style="margin:2px 0 0;font-size:22px;text-transform:none;letter-spacing:0;color:var(--text)">${esc(j.name)}</h2>
|
||||||
|
<p class="sub" style="margin:2px 0 10px">${esc(rdoMeta(j))}</p><div id="rdonow" data-id="${esc(j.id)}" class="rdonow"><small>Live</small></div>
|
||||||
|
<div class="bar"><button class="btn" id="rdojp">${icon('play')}Listen live</button><a class="btn ghost" href="${esc(j.homepage)}" target="_blank" rel="noopener">${icon('ext')}Website</a><button class="btn ghost" id="rdojr">Recently played</button></div></div>
|
||||||
|
<div id="rdomine"></div><h2>Find stations</h2><input type="search" id="rdoq" placeholder="Search stations by name" autocomplete="off" autocapitalize="off" style="width:100%">
|
||||||
|
<div class="seg" id="rdochips">${rdoChips.map(([l, t]) => `<a href="#" data-t="${t}" class="${t === S.tag ? 'on' : ''}">${l}</a>`).join('')}</div><div id="rdores" class="list"></div><div id="rdomore" class="bar" style="justify-content:center"></div></div>`;
|
||||||
|
const playStation = s => { playQueue([rdoTrack(s)], 0); toast('Tuning in to ' + s.name); drawAll(); setTimeout(rdoPoll, 2500); };
|
||||||
|
const bindRows = (root, byId) => { root.querySelectorAll('.rdorow').forEach(r => { const s = byId(r.dataset.id); r.querySelector('[data-play]').onclick = () => playStation(s);
|
||||||
|
const f = r.querySelector('[data-fav]'); if (f) f.onclick = async () => { try { if (saved(s.id)) { await rdoApi('/stations/' + encodeURIComponent(s.id), rdoOpt('DELETE')); rdoSaved = rdoSaved.filter(x => x.id !== s.id); } else { await rdoApi('/stations', rdoOpt('POST', { id: s.id })); rdoSaved.push(s); } drawAll(); } catch (e) { toast(e.message); } }; }); };
|
||||||
|
const drawMine = () => { $('#rdomine').innerHTML = rdoSaved.length ? `<h2>My stations</h2><div class="list">${rdoSaved.map(s => row(s)).join('')}</div>` : ''; bindRows($('#rdomine'), id => rdoSaved.find(x => x.id === id)); };
|
||||||
|
const drawRes = () => { $('#rdores').innerHTML = S.items.length ? S.items.map(s => row(s)).join('') : S.busy ? '<p class="empty">Searching…</p>' : '<p class="empty">No stations found.</p>'; bindRows($('#rdores'), id => S.items.find(x => x.id === id));
|
||||||
|
$('#rdomore').innerHTML = S.more && !S.busy ? '<button class="btn ghost" id="rdomb">Show more</button>' : ''; if ($('#rdomb')) $('#rdomb').onclick = () => run(false); };
|
||||||
|
const drawAll = () => { drawMine(); drawRes(); };
|
||||||
|
const run = async reset => {
|
||||||
|
const my = ++S.seq; if (reset) { S.off = 0; S.items = []; } S.busy = true; if (reset) drawRes();
|
||||||
|
try { const r = await rdoApi(`/search?q=${encodeURIComponent(S.q)}&tag=${encodeURIComponent(S.q ? '' : S.tag)}&offset=${S.off}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; S.off += 30; }
|
||||||
|
catch (e) { if (my === S.seq) { $('#rdores').innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; S.busy = false; return; } }
|
||||||
|
if (my === S.seq) { S.busy = false; drawRes(); }
|
||||||
|
};
|
||||||
|
$('#rdojp').onclick = () => playStation(j);
|
||||||
|
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty">Loading…</p>'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `<div class="list">${n.recent.map(x => `<div class="row"><span class="grow"><b>${esc(x.title)}</b><small>${esc(x.artist)}</small></span></div>`).join('')}</div>` : '<p class="empty">Nothing to show right now.</p>'; } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; } };
|
||||||
|
$('#rdochips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.tag = a.dataset.t; S.q = ''; $('#rdoq').value = ''; $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); run(true); });
|
||||||
|
let t; $('#rdoq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', !S.q && x.dataset.t === S.tag)); run(true); }, 450); };
|
||||||
|
drawMine(); run(true);
|
||||||
|
rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {});
|
||||||
|
}
|
||||||
@@ -894,3 +894,11 @@ kbd { display: inline-block; padding: 1px 7px; border: 1px solid var(--line); bo
|
|||||||
body.mselecting .mrow { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
|
body.mselecting .mrow { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
|
||||||
.mflag { color: var(--danger); } .mflag svg { fill: currentColor; }
|
.mflag { color: var(--danger); } .mflag svg { fill: currentColor; }
|
||||||
.rdhave { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1; background: rgba(47,158,110,.92); color: #fff; font: 600 11px/1 system-ui; text-align: center; padding: 5px 0; }
|
.rdhave { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1; background: rgba(47,158,110,.92); color: #fff; font: 600 11px/1 system-ui; text-align: center; padding: 5px 0; }
|
||||||
|
|
||||||
|
/* Radio */
|
||||||
|
.rdorow { padding: 0; } .rdorow.playing { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); }
|
||||||
|
.rdoplay { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 10px 12px 10px 16px; background: none; border: 0; text-align: left; color: inherit; min-height: 56px; }
|
||||||
|
.rdoplay > svg { width: 20px; height: 20px; color: var(--accent); flex: none; }
|
||||||
|
.rdoplay .art { width: 44px; height: 44px; border-radius: 10px; background: var(--panel2); flex: none; overflow: hidden; display: block; } .rdoplay .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.rdorow .ibtn { margin-right: 6px; }
|
||||||
|
.rdonow { min-height: 40px; } .rdonow small { display: block; color: var(--muted); } .rdonow b { font-size: 17px; }
|
||||||
+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-v37';
|
const CACHE = 'hub-v40';
|
||||||
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'];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Internet radio. The station list comes from the community Radio Browser directory (free, no key), with The Journey FM (Liberty University, Lynchburg VA)
|
||||||
|
// always on top. The hub fetches each stream itself and passes it on: many stations only offer plain http, which a browser refuses to play inside an https page,
|
||||||
|
// and the hub also reads the "now playing" text out of the stream so the player can show the song. Each person keeps their own list of saved stations.
|
||||||
|
const fs = require('fs'), path = require('path'), http = require('http'), https = require('https'), dns = require('dns'), net = require('net');
|
||||||
|
const users = require('./users');
|
||||||
|
|
||||||
|
const FEATURED = [{
|
||||||
|
id: 'journeyfm', name: 'The Journey FM', url: process.env.JOURNEYFM_STREAM_URL || 'http://ic.liberty.edu:8000/WRVL', homepage: 'https://www.myjourneyfm.com/', favicon: '', tags: 'christian,contemporary christian,worship',
|
||||||
|
country: 'United States', state: 'Virginia', codec: 'MP3', bitrate: 192, featured: true,
|
||||||
|
}];
|
||||||
|
const JOURNEY_NOW = () => process.env.JOURNEYFM_NOW_URL || 'https://www.myjourneyfm.com/wp-json/custom/v2/recentlyPlayed';
|
||||||
|
const DIRECTORY = () => process.env.RADIO_BROWSER_URL ? [process.env.RADIO_BROWSER_URL.replace(/\/+$/, '')] : ['https://de1.api.radio-browser.info', 'https://at1.api.radio-browser.info', 'https://nl1.api.radio-browser.info'];
|
||||||
|
const UA = 'NE-Hub/1.0 (self-hosted)';
|
||||||
|
const PLAYABLE = /^(MP3|AAC|AAC\+|OGG|VORBIS|OPUS)$/i;
|
||||||
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
|
||||||
|
// ---- the hub never connects to a private or local address on behalf of a station (the address is checked after it is looked up, so a name that points inside cannot slip through)
|
||||||
|
const isPrivate = a => {
|
||||||
|
if (process.env.RADIO_ALLOW_PRIVATE === '1') return false;
|
||||||
|
if (net.isIPv4(a)) { const [x, y] = a.split('.').map(Number); return x === 0 || x === 10 || x === 127 || (x === 169 && y === 254) || (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168) || (x === 100 && y >= 64 && y <= 127) || x >= 224; }
|
||||||
|
if (net.isIPv6(a)) { const l = a.toLowerCase(); return l === '::1' || l === '::' || l.startsWith('fe80') || l.startsWith('fc') || l.startsWith('fd') || (l.startsWith('::ffff:') && isPrivate(l.slice(7))); }
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const safeLookup = (host, opts, cb) => { if (typeof opts === 'function') { cb = opts; opts = {}; }
|
||||||
|
dns.lookup(host, { ...opts, all: true }, (err, list) => { if (err) return cb(err); const ok = list.filter(x => !isPrivate(x.address)); if (!ok.length) return cb(new Error('That address is not allowed'));
|
||||||
|
if (opts.all) return cb(null, ok); cb(null, ok[0].address, ok[0].family); }); };
|
||||||
|
function open(url, { headers = {}, timeout = 12000, hops = 4 } = {}) { // GET with redirects; resolves with the response (not yet read)
|
||||||
|
return new Promise((ok, no) => {
|
||||||
|
let u; try { u = new URL(url); } catch { return no(fail(400, 'Bad address')); }
|
||||||
|
if (!/^https?:$/.test(u.protocol)) return no(fail(400, 'Bad address'));
|
||||||
|
const lit = u.hostname.replace(/^\[|\]$/g, ''); if (net.isIP(lit) && isPrivate(lit)) return no(fail(502, 'That address is not allowed')); // (an address written as numbers never goes through the lookup below)
|
||||||
|
const req = (u.protocol === 'https:' ? https : http).get(u, { headers: { 'user-agent': UA, ...headers }, lookup: safeLookup, timeout }, res => {
|
||||||
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { res.resume(); if (!hops) return no(fail(502, 'Too many redirects')); return open(new URL(res.headers.location, u).href, { headers, timeout, hops: hops - 1 }).then(ok, no); }
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
req.on('timeout', () => req.destroy(fail(504, 'The station did not answer'))); req.on('error', e => no(e.code && e.code >= 400 ? e : fail(502, 'Could not reach that address')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function readText(url, max = 2e6, opts) { const res = await open(url, opts); if (res.statusCode >= 400) { res.resume(); throw fail(502, 'Answered ' + res.statusCode); } const c = []; let n = 0; for await (const d of res) { n += d.length; if (n > max) { res.destroy(); throw fail(502, 'Too large'); } c.push(d); } return Buffer.concat(c).toString('utf8'); }
|
||||||
|
|
||||||
|
// ---- Radio Browser
|
||||||
|
async function directory(p) { let last; for (const base of DIRECTORY()) { try { return JSON.parse(await readText(base + p, 2e6, { timeout: 8000 })); } catch (e) { last = e; } } throw fail(502, 'The radio directory is not answering' + (last && last.message ? ' (' + last.message + ')' : '')); }
|
||||||
|
const tidy = s => ({
|
||||||
|
id: s.stationuuid, name: String(s.name || '').replace(/\s+/g, ' ').trim().slice(0, 80), url: s.url_resolved || s.url, homepage: /^https?:/.test(s.homepage || '') ? s.homepage : '', favicon: /^https?:/.test(s.favicon || '') ? s.favicon : '',
|
||||||
|
tags: String(s.tags || '').split(',').map(x => x.trim()).filter(Boolean).slice(0, 4).join(','), country: s.country || '', state: s.state || '', codec: s.codec || '', bitrate: +s.bitrate || 0, votes: +s.votes || 0,
|
||||||
|
});
|
||||||
|
const usable = s => s && s.stationuuid && /^https?:\/\//.test(s.url_resolved || s.url || '') && !s.hls && PLAYABLE.test(s.codec || '') && String(s.name || '').trim();
|
||||||
|
const known = new Map(); // station id -> { t, st }
|
||||||
|
async function station(id) {
|
||||||
|
const f = FEATURED.find(x => x.id === id) || mine().find(x => x.id === id); if (f) return f;
|
||||||
|
if (!/^[\w-]{8,64}$/.test(id)) throw fail(400, 'Bad station');
|
||||||
|
const c = known.get(id); if (c && Date.now() - c.t < 3600e3) return c.st;
|
||||||
|
const [s] = await directory('/json/stations/byuuid/' + id); if (!usable(s)) throw fail(404, 'Station not found');
|
||||||
|
const st = tidy(s); if (known.size > 500) known.clear(); known.set(id, { t: Date.now(), st }); return st;
|
||||||
|
}
|
||||||
|
const favFile = () => path.join(users.dir(), 'radio.json');
|
||||||
|
const mine = () => { try { const l = JSON.parse(fs.readFileSync(favFile(), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; } };
|
||||||
|
const saveMine = l => { const f = favFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(l), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||||
|
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } };
|
||||||
|
|
||||||
|
// ---- now playing: the Journey's own feed, or the song title carried inside the stream
|
||||||
|
const nowMap = new Map(); // person:station -> { title, t }
|
||||||
|
const clean = s => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 160);
|
||||||
|
const titleCase = s => s && s === s.toUpperCase() ? s.toLowerCase().replace(/(^|[\s(&\/-])([a-z])/g, (m, a, b) => a + b.toUpperCase()) : s;
|
||||||
|
async function journeyNow(recent) {
|
||||||
|
const rows = JSON.parse(await readText(JOURNEY_NOW() + (recent ? '/getRecentlyPlayed' : '/getNowPlaying'), 1e6, { timeout: 8000 }));
|
||||||
|
return (Array.isArray(rows) ? rows : []).filter(r => r && r.TITLE && r.ARTIST).map(r => ({ title: titleCase(clean(r.TITLE)), artist: titleCase(clean(r.ARTIST)), at: r.ACTIVITY_DATE || '' })); // rows with no artist are the station's own sound bites
|
||||||
|
}
|
||||||
|
/** Pull the song title out of a stream: every `metaint` bytes of audio are followed by a small text block (StreamTitle='...';), which must not reach the player. */
|
||||||
|
function icyFilter(metaint, onTitle) {
|
||||||
|
let toMeta = metaint, need = -1, buf = Buffer.alloc(0);
|
||||||
|
return chunk => {
|
||||||
|
const out = []; let i = 0;
|
||||||
|
while (i < chunk.length) {
|
||||||
|
if (need >= 0) { const take = Math.min(need - buf.length, chunk.length - i); buf = Buffer.concat([buf, chunk.subarray(i, i + take)]); i += take; if (buf.length === need) { const m = /StreamTitle='([^]*?)';/.exec(buf.toString('utf8')); if (m) onTitle(clean(m[1])); need = -1; buf = Buffer.alloc(0); toMeta = metaint; } }
|
||||||
|
else if (toMeta === 0) { need = chunk[i] * 16; i++; if (need === 0) { need = -1; toMeta = metaint; } }
|
||||||
|
else { const take = Math.min(toMeta, chunk.length - i); out.push(chunk.subarray(i, i + take)); i += take; toMeta -= take; }
|
||||||
|
}
|
||||||
|
return Buffer.concat(out);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/radio'.length) || '/', me = users.current();
|
||||||
|
try {
|
||||||
|
let m;
|
||||||
|
if (p === '/search' && req.method === 'GET') {
|
||||||
|
const q = clean(u.searchParams.get('q')).slice(0, 60), tag = clean(u.searchParams.get('tag')).slice(0, 30), off = Math.min(500, Math.max(0, +u.searchParams.get('offset') || 0));
|
||||||
|
const qs = new URLSearchParams({ hidebroken: 'true', order: 'clickcount', reverse: 'true', limit: '30', offset: String(off), is_https: u.searchParams.get('https') === '1' ? 'true' : '' }); if (!qs.get('is_https')) qs.delete('is_https');
|
||||||
|
if (q) qs.set('name', q); if (tag) qs.set('tag', tag); if (!q && !tag) qs.set('tag', 'christian');
|
||||||
|
const rows = await directory('/json/stations/search?' + qs), items = rows.filter(usable).map(tidy);
|
||||||
|
for (const st of items) known.set(st.id, { t: Date.now(), st }); if (known.size > 500) known.clear(); // saving or playing one of these needs no second look-up
|
||||||
|
return json(res, 200, { items, more: rows.length >= 30 });
|
||||||
|
}
|
||||||
|
if (p === '/stations' && req.method === 'GET') return json(res, 200, { featured: FEATURED, mine: mine() });
|
||||||
|
if (p === '/stations' && req.method === 'POST') {
|
||||||
|
const b = await readBody(req), s = await station(String(b.id || '')); if (s.featured) return json(res, 200, { saved: true });
|
||||||
|
const l = mine().filter(x => x.id !== s.id); l.push(s); saveMine(l.slice(-200)); return json(res, 200, { saved: true });
|
||||||
|
}
|
||||||
|
if ((m = /^\/stations\/([\w-]+)$/.exec(p)) && req.method === 'DELETE') { saveMine(mine().filter(x => x.id !== m[1])); return json(res, 200, { removed: true }); }
|
||||||
|
if ((m = /^\/now\/([\w-]+)$/.exec(p)) && req.method === 'GET') {
|
||||||
|
if (m[1] === 'journeyfm') { let now = null, recent = []; try { recent = await journeyNow(true); now = recent[0] || null; } catch {} return json(res, 200, { title: now ? now.title : '', artist: now ? now.artist : '', recent: recent.slice(0, 12) }); }
|
||||||
|
const n = nowMap.get(me.id + ':' + m[1]); return json(res, 200, { title: n && Date.now() - n.t < 600e3 ? n.title : '', artist: '', recent: [] });
|
||||||
|
}
|
||||||
|
if (p === '/icon' && req.method === 'GET') { // station logos come through the hub too (they are often plain http, and fetching them directly would tell their servers who is listening)
|
||||||
|
const res2 = await open(u.searchParams.get('u') || '', { timeout: 8000 }), type = String(res2.headers['content-type'] || '').split(';')[0].toLowerCase();
|
||||||
|
if (res2.statusCode !== 200 || !/^image\/(png|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon|svg\+xml)$/.test(type)) { res2.resume(); throw fail(404, 'No icon'); }
|
||||||
|
const c = []; let n = 0; for await (const d of res2) { n += d.length; if (n > 5e5) { res2.destroy(); throw fail(404, 'No icon'); } c.push(d); }
|
||||||
|
res.writeHead(200, { 'content-type': type === 'image/svg+xml' ? 'image/svg+xml' : type, 'cache-control': 'private, max-age=604800', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'x-content-type-options': 'nosniff' }); return res.end(Buffer.concat(c));
|
||||||
|
}
|
||||||
|
if ((m = /^\/play\/([\w-]+)$/.exec(p)) && req.method === 'GET') {
|
||||||
|
const s = await station(m[1]), up = await open(s.url, { headers: { 'icy-metadata': '1' }, timeout: 15000 });
|
||||||
|
if (up.statusCode >= 400) { up.resume(); throw fail(502, s.name + ' answered ' + up.statusCode); }
|
||||||
|
const type = String(up.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
||||||
|
if (/mpegurl|x-scpls|text\/|html/.test(type)) { up.resume(); throw fail(502, s.name + ' sent a playlist, not a stream'); }
|
||||||
|
const metaint = +up.headers['icy-metaint'] || 0, key = me.id + ':' + s.id, filter = metaint ? icyFilter(metaint, t => nowMap.set(key, { title: t, t: Date.now() })) : null;
|
||||||
|
res.writeHead(200, { 'content-type': type.startsWith('audio/') || type === 'application/ogg' ? type : 'audio/mpeg', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'accept-ranges': 'none' });
|
||||||
|
const close = () => { try { up.destroy(); } catch {} }; res.on('close', close); up.on('error', () => res.destroy());
|
||||||
|
up.on('data', d => { if (!res.write(filter ? filter(d) : d)) { up.pause(); res.once('drain', () => up.resume()); } }); up.on('end', () => res.end());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return json(res, 404, { error: 'Not found' });
|
||||||
|
} catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle, icyFilter, isPrivate };
|
||||||
@@ -38,6 +38,8 @@ const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (M
|
|||||||
const transcripts = require('./transcripts'); // words for podcasts and audiobooks: the show's own transcript, or speech-to-text (TRANSCRIBE_API_URL)
|
const transcripts = require('./transcripts'); // words for podcasts and audiobooks: the show's own transcript, or speech-to-text (TRANSCRIBE_API_URL)
|
||||||
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 abapi = require('./abapi'); // audiobook player library, audio and progress (AUDIOBOOKS_URL)
|
||||||
|
const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM)
|
||||||
|
const librivox = require('./librivox'); // free audiobooks from LibriVox
|
||||||
const books = require('./books'); // e-book reader: EPUB, PDF and DOCX library with synced progress and notes
|
const books = require('./books'); // e-book reader: EPUB, PDF and DOCX library with synced progress and notes
|
||||||
const podcast = require('./podcast'); // Podgrab podcasts (PODCAST_URL, PODGRAB_PASSWORD)
|
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)
|
||||||
@@ -542,6 +544,7 @@ const ACCESS = [
|
|||||||
[/^\/api\/(audiobooks|ab)\//, ['audio']],
|
[/^\/api\/(audiobooks|ab)\//, ['audio']],
|
||||||
[/^\/api\/podcast\//, ['podcast']],
|
[/^\/api\/podcast\//, ['podcast']],
|
||||||
[/^\/api\/read\//, ['read']],
|
[/^\/api\/read\//, ['read']],
|
||||||
|
[/^\/api\/radio\//, ['radio']],
|
||||||
[/^\/api\/signal\//, ['signal']],
|
[/^\/api\/signal\//, ['signal']],
|
||||||
[/^\/api\/lyrics$/, ['music']],
|
[/^\/api\/lyrics$/, ['music']],
|
||||||
[/^\/api\/transcript$/, ['podcast', 'audio']],
|
[/^\/api\/transcript$/, ['podcast', 'audio']],
|
||||||
@@ -631,9 +634,11 @@ async function handleRequest(req, res) {
|
|||||||
}
|
}
|
||||||
if (pathname === '/api/transcript') return transcripts.handle(req, res);
|
if (pathname === '/api/transcript') return transcripts.handle(req, res);
|
||||||
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/free/')) return librivox.handle(req, res);
|
||||||
if (pathname.startsWith('/api/ab/')) return abapi.handle(req, res);
|
if (pathname.startsWith('/api/ab/')) return abapi.handle(req, res);
|
||||||
if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res);
|
if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res);
|
||||||
if (pathname.startsWith('/api/read/')) return books.handle(req, res);
|
if (pathname.startsWith('/api/read/')) return books.handle(req, res);
|
||||||
|
if (pathname.startsWith('/api/radio/')) return radio.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,43 @@
|
|||||||
|
// Free audiobooks: LibriVox search and book details, chapter audio is only ever sent to the recording hosts, each person's shelf and listening position.
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-lv-'));
|
||||||
|
const gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'), seen = []; let port;
|
||||||
|
const fake = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(p + u.search);
|
||||||
|
const J = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
||||||
|
if (p === '/api/feed/audiobooks/') {
|
||||||
|
const book = { id: '55', title: 'Pride & Prejudice', description: '<p>A novel</p>', url_librivox: 'https://librivox.org/pp', language: 'English', copyright_year: '1813', totaltimesecs: 600, authors: [{ first_name: 'Jane', last_name: 'Austen' }], coverart_jpg: `http://127.0.0.1:${port}/cover.gif` };
|
||||||
|
const full = { ...book, sections: [{ section_number: '1', title: 'Chapter 1', listen_url: `http://127.0.0.1:${port}/dl/1.mp3`, playtime: '300', readers: [{ display_name: 'Ann' }] }, { section_number: '2', title: 'Chapter 2', listen_url: 'https://evil.example.com/2.mp3', playtime: '300' }, { section_number: '3', title: 'Chapter 3', listen_url: `http://127.0.0.1:${port}/dl/3.mp3`, playtime: '300' }] };
|
||||||
|
if (u.searchParams.get('id') === '55') return J({ books: [u.searchParams.get('extended') ? full : book] });
|
||||||
|
if (u.searchParams.get('title') === 'prejudice' || u.searchParams.get('author') === 'austen') return J({ books: [book] });
|
||||||
|
return J({ error: 'Audiobooks could not be found' }, 404);
|
||||||
|
}
|
||||||
|
if (p === '/cover.gif') { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(gif); }
|
||||||
|
res.writeHead(404); res.end();
|
||||||
|
}).listen(0, () => {
|
||||||
|
port = fake.address().port; process.env.LIBRIVOX_URL = 'http://127.0.0.1:' + port;
|
||||||
|
const lv = require('../librivox'), users = require('../users');
|
||||||
|
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['audio'], handle: 'Yg' }) };
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => lv.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/ab/free', call = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o }), j = r => r.json();
|
||||||
|
try {
|
||||||
|
let r = await call('/search?q=prejudice'); let s = await j(r); assert.equal(s.items.length, 1, 'found by title and by author, listed once'); assert.equal(s.items[0].title, 'Pride & Prejudice'); assert.equal(s.items[0].author, 'Jane Austen'); assert.match(s.items[0].cover, /^\/api\/ab\/free\/cover\/55/);
|
||||||
|
assert.deepEqual((await j(await call('/search?q=zzz'))).items, [], 'nothing found is an empty list, not an error');
|
||||||
|
assert.ok((await j(await call('/search'))).items.length >= 0, 'no search words lists the catalogue');
|
||||||
|
r = await call('/book/55'); const b = await j(r); assert.equal(b.chapters.length, 2, 'chapters on other sites are dropped'); assert.ok(!JSON.stringify(b).includes('127.0.0.1:' + port + '/dl'), 'audio addresses are not sent to the browser'); assert.equal(b.chapters[1].title, 'Chapter 3'); assert.equal(b.description, 'A novel');
|
||||||
|
r = await call('/audio/55/0'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), `http://127.0.0.1:${port}/dl/1.mp3`);
|
||||||
|
assert.equal((await call('/audio/55/9')).status, 404); assert.equal((await call('/book/abc')).status, 404);
|
||||||
|
r = await call('/cover/55'); assert.equal(r.headers.get('content-type'), 'image/gif');
|
||||||
|
assert.equal((await call('/cover/55?u=' + encodeURIComponent('http://169.254.169.254/x'))).status, 200, 'an address off the allowed sites is ignored (the book\'s own cover is used)');
|
||||||
|
assert.ok(!seen.some(x => x.includes('169.254')));
|
||||||
|
assert.deepEqual(await j(await call('/shelf')), []); await call('/shelf', { method: 'POST', body: JSON.stringify({ id: '55' }) });
|
||||||
|
s = await j(await call('/shelf')); assert.equal(s[0].title, 'Pride & Prejudice'); assert.deepEqual(await j(await call('/shelf', { headers: { 'x-user': 'b' } })), [], "each person's shelf is their own");
|
||||||
|
const post = o => call('/progress', { method: 'POST', body: JSON.stringify(o) }).then(j);
|
||||||
|
await post({ 'lv:55': { pos: 400, dur: 600, t: 10 } }); assert.equal((await post({ 'lv:55': { pos: 5, dur: 600, t: 3 } }))['lv:55'].pos, 400, 'the newest position wins');
|
||||||
|
assert.deepEqual(await j(await call('/progress', { headers: { 'x-user': 'b' } })), {});
|
||||||
|
await call('/shelf/55', { method: 'DELETE' }); assert.deepEqual(await j(await call('/shelf')), []);
|
||||||
|
console.log('librivox test ok');
|
||||||
|
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||||
|
hub.close(); fake.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Internet radio: directory search, saved stations per person, the stream passed on with its song titles taken out, The Journey FM's now-playing feed, and no private addresses.
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-radio-'));
|
||||||
|
process.env.RADIO_ALLOW_PRIVATE = '1'; // the fake servers below are on this machine
|
||||||
|
const AUDIO = Buffer.alloc(4096, 0x55), META = Buffer.from("StreamTitle='Great Song - Some Band';"); let port;
|
||||||
|
const fake = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname, J = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
||||||
|
if (p === '/json/stations/search') return J([
|
||||||
|
{ stationuuid: 'aaaaaaaa-1111', name: 'Good FM', url_resolved: `http://127.0.0.1:${port}/stream`, codec: 'MP3', bitrate: 128, tags: 'christian,worship,pop,extra,more', country: 'US', state: 'Ohio', hls: 0, homepage: 'https://good.fm', favicon: `http://127.0.0.1:${port}/logo.png` },
|
||||||
|
{ stationuuid: 'bbbbbbbb-2222', name: 'HLS only', url_resolved: `http://127.0.0.1:${port}/x.m3u8`, codec: 'AAC', hls: 1 }, { stationuuid: 'cccccccc-3333', name: 'Odd codec', url_resolved: 'http://a/b', codec: 'FLAC', hls: 0 }, { stationuuid: 'dddddddd-4444', name: ' ', url_resolved: 'http://a/b', codec: 'MP3', hls: 0 }]);
|
||||||
|
if (p === '/json/stations/byuuid/aaaaaaaa-1111') return J([{ stationuuid: 'aaaaaaaa-1111', name: 'Good FM', url_resolved: `http://127.0.0.1:${port}/stream`, codec: 'MP3', bitrate: 128, hls: 0 }]);
|
||||||
|
if (p === '/json/stations/byuuid/eeeeeeee-5555') return J([{ stationuuid: 'eeeeeeee-5555', name: 'Playlist FM', url_resolved: `http://127.0.0.1:${port}/pls`, codec: 'MP3', hls: 0 }]);
|
||||||
|
if (p === '/stream') { // 1 KB of audio, then a metadata block, then more audio
|
||||||
|
res.writeHead(200, { 'content-type': 'audio/mpeg', 'icy-metaint': '1024' }); const blk = Buffer.alloc(Math.ceil(META.length / 16) * 16); META.copy(blk);
|
||||||
|
return res.end(Buffer.concat([AUDIO.subarray(0, 1024), Buffer.from([blk.length / 16]), blk, AUDIO.subarray(1024, 2048), Buffer.from([0]), AUDIO.subarray(2048, 3072)]));
|
||||||
|
}
|
||||||
|
if (p === '/pls') { res.writeHead(200, { 'content-type': 'audio/x-scpls' }); return res.end('[playlist]'); }
|
||||||
|
if (p === '/logo.png') { res.writeHead(200, { 'content-type': 'image/png' }); return res.end(Buffer.from('89504e470d0a1a0a', 'hex')); }
|
||||||
|
if (p === '/journey/getRecentlyPlayed' || p === '/journey/getNowPlaying') return J([{ TITLE: 'THE JOURNEY', ARTIST: null, ACTIVITY_DATE: '10/24/2026 22:01:21' }, { TITLE: "A LIL' CHURCH (NOBODY'S TOO LOST)", ARTIST: 'TOBYMAC', ACTIVITY_DATE: '10/24/2026 22:01:37' }, { TITLE: 'RESCUE', ARTIST: 'JORDAN ST. CYR', ACTIVITY_DATE: '10/24/2026 21:57:43' }]);
|
||||||
|
res.writeHead(404); res.end();
|
||||||
|
}).listen(0, () => {
|
||||||
|
port = fake.address().port; Object.assign(process.env, { RADIO_BROWSER_URL: `http://127.0.0.1:${port}`, JOURNEYFM_NOW_URL: `http://127.0.0.1:${port}/journey`, JOURNEYFM_STREAM_URL: `http://127.0.0.1:${port}/stream` });
|
||||||
|
const radio = require('../radio'), users = require('../users');
|
||||||
|
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['radio'], handle: 'Yg' }) };
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => radio.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/radio', call = (p, o = {}) => fetch(base + p, o), j = r => r.json();
|
||||||
|
try {
|
||||||
|
let r = await j(await call('/stations')); assert.equal(r.featured[0].name, 'The Journey FM'); assert.equal(r.featured[0].id, 'journeyfm'); assert.deepEqual(r.mine, []);
|
||||||
|
r = await j(await call('/search?q=good')); assert.deepEqual(r.items.map(s => s.name), ['Good FM'], 'streams a browser cannot play (HLS, odd codecs) and nameless ones are left out'); assert.equal(r.items[0].tags, 'christian,worship,pop,extra'); assert.equal(r.items[0].id, 'aaaaaaaa-1111');
|
||||||
|
assert.equal((await call('/search')).status, 200, 'no words: Christian stations');
|
||||||
|
// the stream
|
||||||
|
r = await call('/play/journeyfm'); assert.equal(r.status, 200); assert.equal(r.headers.get('content-type'), 'audio/mpeg'); let body = Buffer.from(await r.arrayBuffer());
|
||||||
|
assert.equal(body.length, 3072, 'the song-title blocks are taken out of the audio'); assert.ok(body.equals(AUDIO.subarray(0, 3072)), 'and the audio is untouched');
|
||||||
|
await new Promise(ok => setTimeout(ok, 100)); r = await j(await call('/now/aaaaaaaa-1111')); assert.equal(r.title, '', 'nothing yet for a station not played');
|
||||||
|
r = await call('/play/aaaaaaaa-1111'); assert.equal(r.status, 200); await r.arrayBuffer(); r = await j(await call('/now/aaaaaaaa-1111')); assert.equal(r.title, 'Great Song - Some Band', 'the title carried in the stream is reported');
|
||||||
|
r = await call('/play/eeeeeeee-5555'); assert.equal(r.status, 502, 'a playlist file is not a stream');
|
||||||
|
assert.equal((await call('/play/..%2Fx')).status, 404); assert.equal((await call('/play/zzzzzzzz-9999')).status, 502, 'an unknown station is an error, not a crash');
|
||||||
|
// The Journey's own feed
|
||||||
|
r = await j(await call('/now/journeyfm')); assert.equal(r.title, "A Lil' Church (Nobody's Too Lost)"); assert.equal(r.artist, 'Tobymac'); assert.equal(r.recent.length, 2, "the station's own sound bites (no artist) are not listed songs");
|
||||||
|
// saved stations
|
||||||
|
assert.equal((await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'aaaaaaaa-1111' }) })).status, 200); r = await j(await call('/stations')); assert.deepEqual(r.mine.map(s => s.name), ['Good FM']);
|
||||||
|
await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'aaaaaaaa-1111' }) }); assert.equal((await j(await call('/stations'))).mine.length, 1, 'saving twice keeps one');
|
||||||
|
assert.deepEqual((await j(await call('/stations', { headers: { 'x-user': 'b' } }))).mine, [], "each person's list is their own");
|
||||||
|
await call('/stations/aaaaaaaa-1111', { method: 'DELETE' }); assert.deepEqual((await j(await call('/stations'))).mine, []);
|
||||||
|
r = await call('/icon?u=' + encodeURIComponent(`http://127.0.0.1:${port}/logo.png`)); assert.equal(r.headers.get('content-type'), 'image/png'); assert.equal((await call('/icon?u=' + encodeURIComponent(`http://127.0.0.1:${port}/stream`))).status, 404, 'only pictures come through as icons');
|
||||||
|
// private addresses
|
||||||
|
assert.ok(radio.isPrivate('127.0.0.1') === false, '(allowed only because this test says so)'); process.env.RADIO_ALLOW_PRIVATE = '0';
|
||||||
|
for (const a of ['127.0.0.1', '10.1.2.3', '192.168.0.9', '172.20.1.1', '169.254.169.254', '::1', 'fd00::1', '::ffff:10.0.0.1', '0.0.0.0', '100.64.0.1']) assert.ok(radio.isPrivate(a), a + ' is private');
|
||||||
|
for (const a of ['8.8.8.8', '151.101.1.1', '2606:4700::1111']) assert.ok(!radio.isPrivate(a), a + ' is public');
|
||||||
|
r = await call('/play/journeyfm'); assert.equal(r.status, 502, 'a station on a private address is refused'); await r.text();
|
||||||
|
r = await call('/icon?u=' + encodeURIComponent(`http://localhost:${port}/logo.png`)); assert.ok(r.status >= 400, 'a name that points at this machine is refused too'); await r.text();
|
||||||
|
console.log('radio test ok');
|
||||||
|
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||||
|
hub.close(); fake.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
|
|||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
|
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
|
||||||
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
|
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
|
||||||
const ALWAYS = ['home', 'settings'];
|
const ALWAYS = ['home', 'settings'];
|
||||||
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
|
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
|
||||||
|
|
||||||
let cache = null;
|
let cache = null;
|
||||||
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };
|
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };
|
||||||
|
|||||||
Reference in new issue
Block a user