diff --git a/README.md b/README.md
index 9c9f22e..222bb83 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Book store** (Books -> Book store): search free, DRM-free books from **Project Gutenberg** (its own catalogue feed), **Standard Ebooks** (its public website: its OPDS feed is for paying members only, so the hub reads the public search pages and downloads the free EPUB), 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: `GUTENBERG_URL`, `STANDARD_EBOOKS_URL` and `ARCHIVE_URL` can point at mirrors. `node tools/store-test.js` tests it against fake catalogues; it was also tried against the live Project Gutenberg, Standard Ebooks and Internet Archive (searches, and real EPUBs downloaded); if one of them changes its pages or feed, 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
` 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. Lyrics come from the Lyrica service if you run one (`LYRICS_API_URL`), and otherwise (or when Lyrica finds nothing) from [LRCLIB](https://lrclib.net), a free lyrics database that needs no key: the artist and song title are sent to it, and set `LYRICS_LRCLIB=0` to never ask it. `node tools/radio-test.js` and `node tools/lrclib-test.js` test it against fakes; 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. **Song and artist**: the player shows the song as the title with the artist under it (and the station as the album), also on the mini player, the Now Playing sheet, the Radio page and the phone's lock screen; it follows the station every 10 seconds. The Journey FM's own feed gives the artist and when each song began; other stations send `Artist - Title` inside the stream, which the hub splits. **Lyrics** (the Lyrics button on the player or the Radio page) are looked up for the song that is on now and change when the song does. A station that says when a song began (The Journey FM) gets synced lyrics that follow along; because what you hear is a few seconds behind the station's clock, it starts 5 seconds early and the - and + buttons move it a second at a time (remembered for that station). Other stations show the words without following along. Not included: HLS-only stations, recording, alarms.
+- **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. Lyrics come from the Lyrica service if you run one (`LYRICS_API_URL`), and otherwise (or when Lyrica finds nothing) from [LRCLIB](https://lrclib.net), a free lyrics database that needs no key: the artist and song title are sent to it, and set `LYRICS_LRCLIB=0` to never ask it. `node tools/radio-test.js` and `node tools/lrclib-test.js` test it against fakes; 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. **Song and artist**: the player shows the song as the title with the artist under it (and the station as the album), also on the mini player, the Now Playing sheet, the Radio page and the phone's lock screen; it follows the station every 10 seconds. The Journey FM's own feed gives the artist and when each song began; other stations send `Artist - Title` inside the stream, which the hub splits. **Cover art**: the cover of the song that is on (from Apple's free iTunes search, no key; the song's artist and title are sent to it), else the station's logo, else a coloured tile with the station's initials, so there is never an empty picture. A live station shows *LIVE* in place of a length, and has no seek bar, queue or skip buttons. **Lyrics** (the Lyrics button on the player or the Radio page) are looked up for the song that is on now and change when the song does. A station that says when a song began (The Journey FM) gets synced lyrics that follow along; because what you hear is a few seconds behind the station's clock, it starts 5 seconds early and the - and + buttons move it a second at a time (remembered for that station). Other stations show the words without following along. Not included: HLS-only stations, recording, alarms.
- **Verse By Verse** (Podcast -> *Verse By Verse*; needs only the Podcast tab, not Podgrab): free verse-by-verse Bible teaching from [Verse By Verse Ministry International](https://versebyverseministry.org). Browse the ministry's studies (about 80: Old and New Testament books, topical series, single teachings, women's and youth), search them, and open a study to see its lessons with dates and summaries. *Start* / *Continue* plays the study lesson after lesson in the same player as Music, a lesson resumes where you left off (kept on the server per person, so it follows you between devices), and each lesson's handout (PDF) opens from *Notes*. The hub reads the study list from the ministry's category pages and the lessons from each study's own podcast feed (`/bible-studies//podcast.xml`), keeps what it read for 30 to 60 minutes so the ministry's site is not asked on every visit, and sends the browser straight to the ministry's media server for the audio. Only the ministry's own addresses are ever used. `VBV_URL` can point at a mirror. `node tools/vbv-test.js` tests it against a fake copy of the site; it was also tried against the live site (80 studies, a study's lessons, audio and handout links). If the ministry redesigns its pages, the study list is the first thing that would break, and the screen would say so.
- **Save to Files**: copy things into your own Seafile from where you find them. **Books** (long-press or right-click a book -> *Save to Files*), **free audiobooks** (a book's sheet -> *Save to Files*, one numbered MP3 per chapter in a folder) and **Verse By Verse** (a study's *Save to Files* button saves every lesson and handout, or just the audio or just the handouts; the download button on a lesson saves that one). You pick the library and folder (it remembers the last one); the hub makes a tidy subfolder (`Books`, `Audiobooks/`, `Verse By Verse/`), names files safely and in order, **skips anything already there** (so running it again fills in what is missing) and counts lessons that have no handout instead of failing. The hub does the downloading and uploading itself with the Seafile sign-in already saved for you, so a whole study (several GB) does not pass through your phone and keeps going if you tap *Hide*; a sheet shows progress, *Stop* cancels, and *Open folder* jumps to the result. It needs the Files tab and the tab the content comes from (Books, Audiobooks or Podcast); each person can have two saves running at once; files up to 500 MB. `node tools/savefiles-test.js` tests it against a fake Seafile.
- **Bible**: the **Berean Standard Bible** (BSB, public domain: https://berean.bible/), all 66 books, with its section headings, poetry layout and footnotes. The whole text is one file (`public/vendor/bible/bsb.json`, about 1.5 MB compressed, made by `python3 tools/make-bsb.py` from the free Hello AO Bible API) that the browser loads once and keeps, so reading is instant and works offline. **Choose** a book and chapter, **search** for words (every word must be in the verse) or type a place (`John 3:16`, `1 cor 13:4-7`, `ps 23`), swipe or use the arrow keys to turn chapters, and tap verses to **highlight** (four colours), add a **note**, or **copy** them with their reference. Tap a small letter for a footnote. *My highlights and notes* lists everything. Your place, highlights and notes are kept on the server per person, so they follow you between devices; text size and font are remembered per device. Give it to people in Settings -> People (Bible). `node tools/bible-test.js` tests the saving and checks all 31,086 verses are in the file.
diff --git a/public/player.js b/public/player.js
index fe30ae2..b82ec48 100644
--- a/public/player.js
+++ b/public/player.js
@@ -214,19 +214,20 @@ function modeUI() {
document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
}
function syncUI() {
- const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
+ const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
const state = pl.buf && !a.paused ? 'buf' : String(a.paused), ic = state === 'buf' ? icon('spin') : icon(a.paused ? 'play' : 'pause');
const mt = $('#mt'); if (mt && mt.dataset.s !== state) { mt.innerHTML = ic; mt.dataset.s = state; }
const bf = $('#mbuf'); if (bf) { let e = 0; try { for (let i = 0; i < a.buffered.length; i++) if (a.buffered.start(i) <= a.currentTime + 1) e = Math.max(e, a.buffered.end(i)); } catch {} bf.style.width = d ? Math.min(100, (e / d) * 100) + '%' : '0'; }
- const tm = $('#mtm'); if (tm) { const v = fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
+ const tm = $('#mtm'); if (tm) { const v = liveT ? 'LIVE' : fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
const t = cur(), sub = t ? (pl.buf ? (navigator.onLine ? 'Buffering…' : 'Waiting for connection…') : t.artist) : '';
const ms = $('#mo small'); if (ms && ms.textContent !== sub) ms.textContent = sub;
const ns = $('#npsub'); if (ns && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (ns.textContent !== v) ns.textContent = v; }
const nt = $('#npt'); if (nt && nt.dataset.s !== state) { nt.innerHTML = ic; nt.dataset.s = state; }
- const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
- const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
- const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
+ const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = liveT ? 0 : a.currentTime; } if (sk) sk.hidden = liveT;
+ const c = $('#npc'); if (c) c.textContent = liveT ? '\u25CF LIVE' : fmtDur(a.currentTime * 1000);
+ const dd = $('#npd'); if (dd) dd.textContent = liveT ? '' : fmtDur(d * 1000);
+ for (const id of ['nps', 'npr', 'npq', 'npp', 'npn', 'mpv', 'mn', 'mseek']) { const e = $('#' + id); if (e) e.hidden = liveT; } // (a live station has no queue, no skipping and no seeking)
const lt = $('#lyrt'); if (lt && lt.dataset.s !== state) { lt.innerHTML = ic; lt.dataset.s = state; }
// the Now Playing card on the Music tab
const ct = $('#cnt'); if (ct && ct.dataset.s !== state) { ct.innerHTML = ic; ct.dataset.s = state; }
diff --git a/public/radio-view.js b/public/radio-view.js
index e1a9b4a..07af18f 100644
--- a/public/radio-view.js
+++ b/public/radio-view.js
@@ -3,8 +3,9 @@
// 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 rdoArt = (s, song) => '/api/radio/art?s=' + encodeURIComponent(s.id) + (song && song.title ? '&artist=' + encodeURIComponent(song.artist || '') + '&title=' + encodeURIComponent(song.title) : '');
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(', '), album0: [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 rdoTrack = s => ({ key: 'radio:' + s.id, title: s.name, artist: 'Live radio', album: [s.state, s.country].filter(Boolean).join(', '), album0: [s.state, s.country].filter(Boolean).join(', '), thumb: rdoArt(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']];
@@ -26,6 +27,7 @@ function rdoApplyNow(t, n) {
const was = t.song ? t.song.title + '|' + t.song.artist : '';
t.song = n.title ? { title: n.title, artist: n.artist || '', startedAt: n.startedAt || 0 } : null;
t.title = n.title || t.station; t.artist = n.title ? (n.artist || t.station) : 'Live radio'; t.album = n.title ? t.station : t.album0;
+ t.thumb = rdoArt({ id: t.key.slice(6) }, t.song); // the cover of the song that is on, else the station's logo
const now = t.song ? t.song.title + '|' + t.song.artist : '';
if (was === now && t.shown) return;
t.shown = true;
diff --git a/public/sw.js b/public/sw.js
index 8c036cb..d5c1ccc 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
-const CACHE = 'hub-v46';
+const CACHE = 'hub-v47';
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
diff --git a/radio.js b/radio.js
index f5165ed..ed1240a 100644
--- a/radio.js
+++ b/radio.js
@@ -5,7 +5,7 @@ const fs = require('fs'), path = require('path'), http = require('http'), https
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',
+ id: 'journeyfm', name: 'The Journey FM', url: process.env.JOURNEYFM_STREAM_URL || 'http://ic.liberty.edu:8000/WRVL', homepage: 'https://www.myjourneyfm.com/', favicon: 'https://www.myjourneyfm.com/wp-content/uploads/sites/2/2025/12/cropped-Version-4-192x192.png', 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';
@@ -91,6 +91,27 @@ function icyFilter(metaint, onTitle) {
};
}
+// ---- cover art for the song that is on: iTunes' free search (no key), else the station's logo, else a plain coloured tile with the station's initials
+const ITUNES = () => (process.env.ITUNES_URL || 'https://itunes.apple.com').replace(/\/+$/, '');
+const artCache = new Map(); // "artist|title" -> { t, url }
+const nrm = s => String(s || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/\(.*?\)|\[.*?\]/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
+async function songArtUrl(artist, title) {
+ const key = nrm(artist) + '|' + nrm(title), c = artCache.get(key); if (c && Date.now() - c.t < 6 * 3600e3) return c.url;
+ let url = '';
+ try {
+ const d = JSON.parse(await readText(ITUNES() + '/search?' + new URLSearchParams({ term: artist + ' ' + title, entity: 'song', media: 'music', limit: '8' }), 1e6, { timeout: 8000 })), na = nrm(artist).split(' ')[0], nt = nrm(title);
+ const hit = (d.results || []).find(x => { const t = nrm(x.trackName), a = nrm(x.artistName); return (t === nt || t.includes(nt) || nt.includes(t)) && (a.includes(na) || nrm(artist).includes(a.split(' ')[0])); });
+ if (hit && hit.artworkUrl100) url = hit.artworkUrl100.replace(/\/\d+x\d+bb\./, '/600x600bb.');
+ } catch {}
+ if (artCache.size > 500) artCache.clear(); artCache.set(key, { t: Date.now(), url }); return url;
+}
+const initials = n => String(n || 'Radio').split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase().replace(/[<>&"']/g, '');
+const tile = n => { const hue = [...String(n)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0); return ``; };
+async function imageFrom(url, max = 1.5e6) { // a picture, or null
+ try { const r = await open(url, { timeout: 8000 }), type = String(r.headers['content-type'] || '').split(';')[0].toLowerCase(); if (r.statusCode !== 200 || !/^image\/(jpeg|png|gif|webp)$/.test(type)) { r.resume(); return null; }
+ const c = []; let n = 0; for await (const d of r) { n += d.length; if (n > max) { r.destroy(); return null; } c.push(d); } return { type, data: Buffer.concat(c) }; } catch { return null; }
+}
+
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/radio'.length) || '/', me = users.current();
try {
@@ -114,6 +135,14 @@ async function handle(req, res) {
const n = nowMap.get(me.id + ':' + m[1]); const fresh = n && Date.now() - n.t < 600e3, sp = fresh ? /^(.+?)\s+-\s+(.+)$/.exec(n.title) : null; // many stations send "Artist - Title"
return json(res, 200, { title: fresh ? (sp ? sp[2] : n.title) : '', artist: sp ? sp[1] : '', startedAt: 0, recent: [] });
}
+ if (p === '/art' && req.method === 'GET') { // never a broken picture: the song's cover, else the station's logo, else a tile
+ const artist = clean(u.searchParams.get('artist')), title = clean(u.searchParams.get('title')); let img = null, name = 'Radio';
+ const sid = u.searchParams.get('s') || ''; let st = null; try { st = sid ? await station(sid) : null; } catch {} if (st) name = st.name;
+ if (artist && title) { const url = await songArtUrl(artist, title); if (url && (/(^|\.)mzstatic\.com$/.test(new URL(url).hostname) || process.env.ITUNES_URL)) img = await imageFrom(url); }
+ if (!img && st && st.favicon) img = await imageFrom(st.favicon);
+ if (img) { res.writeHead(200, { 'content-type': img.type, 'cache-control': 'private, max-age=86400', 'x-content-type-options': 'nosniff' }); return res.end(img.data); }
+ res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'private, max-age=3600', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'x-content-type-options': 'nosniff' }); return res.end(tile(name));
+ }
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'); }
diff --git a/tools/radio-test.js b/tools/radio-test.js
index 529ca1c..7208941 100644
--- a/tools/radio-test.js
+++ b/tools/radio-test.js
@@ -2,9 +2,9 @@
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='Some Band - Great Song';"); let port;
+const seen = []; const AUDIO = Buffer.alloc(4096, 0x55), META = Buffer.from("StreamTitle='Some Band - Great Song';"); 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)); };
+ const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(p); const 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 }]);
@@ -15,11 +15,13 @@ const fake = http.createServer((req, res) => {
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 === '/search') { const term = u.searchParams.get('term') || ''; return J({ results: /Great Song/.test(term) ? [{ trackName: 'Other Thing', artistName: 'Else', artworkUrl100: `http://127.0.0.1:${port}/art/wrong-100x100bb.gif` }, { trackName: 'Great Song (Radio Edit)', artistName: 'Some Band', artworkUrl100: `http://127.0.0.1:${port}/art/100x100bb.gif` }] : [] }); }
+ if (p === '/art/600x600bb.gif') { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(Buffer.from('GIF89a-cover')); }
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` });
+ 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`, ITUNES_URL: `http://127.0.0.1:${port}` });
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 () => {
@@ -38,6 +40,10 @@ const fake = http.createServer((req, res) => {
// 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(new Date(r.startedAt).toISOString(), '2026-10-25T02:01:37.000Z', "the station's clock (US Eastern) becomes a real moment in time"); assert.equal(r.recent.length, 2, "the station's own sound bites (no artist) are not listed songs");
const E = radio.easternEpoch; assert.equal(new Date(E('01/15/2026 08:00:00')).toISOString(), '2026-01-15T13:00:00.000Z', 'winter: Eastern time is 5 hours behind'); assert.equal(new Date(E('07/04/2026 12:00:00')).toISOString(), '2026-07-04T16:00:00.000Z', 'summer: 4 hours behind'); assert.equal(new Date(E('03/08/2026 03:30:00')).toISOString(), '2026-03-08T07:30:00.000Z', 'just after the clocks go forward'); assert.equal(E('nonsense'), 0);
+ r = await call('/art?s=aaaaaaaa-1111&artist=Some%20Band&title=Great%20Song'); assert.equal(r.headers.get('content-type'), 'image/gif'); assert.equal((await r.text()), 'GIF89a-cover', "the cover of the song that is on (the right match, at the larger size)"); assert.ok(seen.includes('/art/600x600bb.gif'));
+ r = await call('/art?s=aaaaaaaa-1111&artist=Nobody&title=Nothing'); assert.equal(r.headers.get('content-type'), 'image/png', 'no cover found: the station logo');
+ r = await call('/art?s=eeeeeeee-5555'); assert.equal(r.headers.get('content-type'), 'image/svg+xml', 'no logo either: a coloured tile'); assert.match(await r.text(), />PF, 'with the station initials');
+ r = await call('/art'); assert.equal(r.status, 200); assert.equal(r.headers.get('content-type'), 'image/svg+xml', 'never a broken picture');
// 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');