diff --git a/README.md b/README.md index 37f1ea4..9d1d865 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ playing an episode or pressing the button still works. Settings -> Health shows ## Store -One **Store** tab (turn it on per person) with sections for **Books** (the free book sources, Open Library and any OPDS catalogue), **Audiobooks** (LibriVox) and **Radio** (the station finder), each shown only to people who have that tab, plus **My Store**. The old Book store and Free audiobooks buttons now open the Store. +One **Store** tab (turn it on per person) with sections for **Books** (the free book sources, Open Library and any OPDS catalogue), **Audiobooks** (LibriVox) and **Radio** (the station finder), each shown only to people who have that tab, plus **My Store**. The old Book store and Free audiobooks buttons now open the Store. **Radio** under Listen starts empty: find stations in the Store's Radio section (The Journey FM is listed there too) and tap *Add to Radio*; only what you add appears under Listen, and you can remove it again. Someone without the Store tab keeps the station finder in Radio itself. **My Store** is your own catalogue, for everyone who has the Store tab. The admin adds items with **Add**: upload files, or copy a file from their Seafile (**From Files**), with an optional cover picture. - **Books** (EPUB, PDF, MOBI, AZW3, DOCX): *Add to my library* puts a copy on that person's shelf, or download it. diff --git a/caldav.js b/caldav.js index fcb8bc1..14dc0c8 100644 --- a/caldav.js +++ b/caldav.js @@ -87,11 +87,12 @@ async function connect(appleId, password) { const principalHref = hrefOf(tag(a.text, 'current-user-principal') || ''); if (!principalHref) throw new Error('iCloud did not return an account (is calendar turned on for this Apple ID?)'); const b = await dav('PROPFIND', abs(principalHref, a.url), { ...o, depth: 0, body: PROPFIND_HOME }); - const homeHref = hrefOf(tag(b.text, 'calendar-home-set') || ''); - if (!homeHref) throw new Error('Could not find the calendar home for this account'); - const home = abs(homeHref, b.url); - const c = await dav('PROPFIND', home, { ...o, depth: 1, body: PROPFIND_CALS }); - if (c.status >= 400) throw new Error(`iCloud answered ${c.status} when listing the calendars`); + let homeHref = hrefOf(tag(b.text, 'calendar-home-set') || ''); + const guess = abs(principalHref, a.url).replace(/\/principal\/?$/, '/calendars/'); + const home0 = homeHref ? abs(homeHref, b.url) : guess.endsWith('/calendars/') ? guess : null; // (some accounts do not report the home at the principal; Apple keeps it beside it, as //calendars/) + if (!home0) throw new Error(`Could not find the calendar home for this account (iCloud answered ${b.status}: ${b.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160) || 'nothing'})`); + const c = await dav('PROPFIND', home0, { ...o, depth: 1, body: PROPFIND_CALS }); + if (c.status >= 400) throw new Error(homeHref ? `iCloud answered ${c.status} when listing the calendars` : `Could not find the calendar home for this account (the principal gave none, and ${new URL(home0).pathname} answered ${c.status})`); const calendars = parseCollections(c.text, c.url); if (!calendars.found.length) throw new Error(`Signed in, but no calendars were found (iCloud listed ${calendars.seen.length} item${calendars.seen.length === 1 ? '' : 's'}: ${calendars.seen.slice(0, 8).join(', ') || 'none'}). Make sure Calendar is turned on for this Apple ID in iCloud settings.`); save({ user: o.user, pass: o.pass, calendars: calendars.found }); diff --git a/package.json b/package.json index 0a2bd4f..072d304 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore; 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 vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/app.js b/public/app.js index b668673..fdce390 100644 --- a/public/app.js +++ b/public/app.js @@ -252,7 +252,7 @@ function drawSubnav(name) { const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name; let curRoute = ''; -const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, recipes: args => viewRecipes(args), store: args => viewStore(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), 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' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); let editor; // Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device. const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } }; diff --git a/public/radio-view.js b/public/radio-view.js index 1913adf..34a6c24 100644 --- a/public/radio-view.js +++ b/public/radio-view.js @@ -38,41 +38,59 @@ function rdoApplyNow(t, n) { setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPlaying()) setTimeout(rdoPoll, 1500); }); function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `Now playing${esc(n.title)}${n.artist ? ' · ' + esc(n.artist) + '' : ''}` : 'Live'; } -async function viewRadio(host) { // host: { el, hash } when shown inside the Store tab +async function viewRadio(host) { // host: { el, hash } when shown inside the Store (the station finder); on the Radio tab itself only what was added from the Store is listed const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '

Radio

Loading…

'; - let feat, mine; - try { ({ featured: feat, mine } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `

Radio

${esc(e.message)}

`; return; } + let feat, mine, added; + try { ({ featured: feat, mine, added } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `

Radio

${esc(e.message)}

`; return; } if (!here()) 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) => `
${s.featured ? '' : ``}
`; - const j = feat[0]; + rdoSaved = mine; const inStore = !!host, finder = inStore || !allowed('store'), S = { q: '', tag: 'christian', items: [], more: false, busy: false, seq: 0, off: 0 }; // (someone without the Store tab can still find stations here) + const isFeat = id => feat.some(f => f.id === id), isAdded = id => isFeat(id) ? added.includes(id) : rdoSaved.some(x => x.id === id); + const j = feat[0], showJ = !inStore && added.includes(j.id); + const row = s => { const on = isAdded(s.id), label = on ? 'Remove from my stations' : 'Add to Radio'; + const act = inStore ? `` + : isFeat(s.id) ? '' : ``; + return `
${act}
`; }; + const empty = !inStore && !showJ && !rdoSaved.length; + const findHtml = finder ? `

Find stations

+
${rdoChips.map(([l, t]) => `${l}`).join('')}
` + : empty ? '' : `
${icon('search')}Find more stations in the Store
`; v.innerHTML = `
${host ? '' : '

Radio

'} -
Featured

${esc(j.name)}

+ ${inStore ? `

Add a station and it shows up in Radio under Listen.

Featured

` : showJ ? `
Featured

${esc(j.name)}

${esc(rdoMeta(j))}

Live
-
${icon('ext')}Website
-

Find stations

-
${rdoChips.map(([l, t]) => `${l}`).join('')}
`; +
${icon('ext')}Website
` : ''} + ${inStore ? '' : '
'}${empty ? `

Nothing here yet. ${allowed('store') ? 'Add stations from the Store and they will show up here.' : 'Find a station below and tap the heart.'}

${allowed('store') ? `
${icon('search')}Open the Store
` : ''}` : ''} + ${findHtml}`; const playStation = s => { const t = rdoTrack(s); playQueue([t], 0); toast('Tuning in to ' + s.name); drawAll(); rdoApi('/now/' + encodeURIComponent(s.id)).then(n => { if (rdoPlaying() && rdoPlaying().key === t.key) { rdoNow[s.id] = n; rdoApplyNow(rdoPlaying(), n); const el = document.getElementById('rdonow'); if (el && el.dataset.id === s.id) rdoDrawNow(el, n); } }).catch(() => {}); 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 ? `

My stations

${rdoSaved.map(s => row(s)).join('')}
` : ''; 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 ? '

Searching…

' : '

No stations found.

'; bindRows($('#rdores'), id => S.items.find(x => x.id === id)); + const toggle = async s => { + if (isAdded(s.id)) { await rdoApi('/stations/' + encodeURIComponent(s.id), rdoOpt('DELETE')); if (isFeat(s.id)) added = added.filter(x => x !== s.id); else rdoSaved = rdoSaved.filter(x => x.id !== s.id); } + else { await rdoApi('/stations', rdoOpt('POST', { id: s.id })); if (isFeat(s.id)) added.push(s.id); else rdoSaved.push(s); } + }; + const bindRows = (root, byId) => { if (!root) return; 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 { await toggle(s); drawAll(); } catch (e) { toast(e.message); } }; }); }; + const drawFeat = () => { const el = $('#rdofeat'); if (!el) return; el.innerHTML = feat.map(row).join(''); bindRows(el, id => feat.find(x => x.id === id)); }; + const drawMine = () => { const el = $('#rdomine'); if (!el) return; el.innerHTML = rdoSaved.length ? `

My stations

${rdoSaved.map(row).join('')}
` : ''; bindRows(el, id => rdoSaved.find(x => x.id === id)); }; + const drawRes = () => { const res = $('#rdores'); if (!res) return; res.innerHTML = S.items.length ? S.items.map(row).join('') : S.busy ? '

Searching…

' : '

No stations found.

'; bindRows(res, id => S.items.find(x => x.id === id)); $('#rdomore').innerHTML = S.more && !S.busy ? '' : ''; if ($('#rdomb')) $('#rdomb').onclick = () => run(false); }; - const drawAll = () => { drawMine(); drawRes(); }; + const drawAll = () => { drawFeat(); 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 = `

${esc(e.message)}

`; S.busy = false; return; } } if (my === S.seq) { S.busy = false; drawRes(); } }; - $('#rdojp').onclick = () => playStation(j); - $('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); }; - $('#rdojv').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof vizOpen === 'function') vizOpen(); }, 400); }; - $('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '

Loading…

'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `
${n.recent.map(x => `
${esc(x.title)}${esc(x.artist)}
`).join('')}
` : '

Nothing to show right now.

'; } catch (e) { el.querySelector('.empty').outerHTML = `

${esc(e.message)}

`; } }; - $('#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(() => {}); + if (showJ) { + $('#rdojp').onclick = () => playStation(j); + $('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); }; + $('#rdojv').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof vizOpen === 'function') vizOpen(); }, 400); }; + $('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '

Loading…

'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `
${n.recent.map(x => `
${esc(x.title)}${esc(x.artist)}
`).join('')}
` : '

Nothing to show right now.

'; } catch (e) { el.querySelector('.empty').outerHTML = `

${esc(e.message)}

`; } }; + $('#rdojx').onclick = async () => { try { await rdoApi('/stations/' + encodeURIComponent(j.id), rdoOpt('DELETE')); toast('Removed from Radio'); viewRadio(host); } catch (e) { toast(e.message); } }; + rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {}); + } + if (finder) { + $('#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); }; + run(true); + } + drawFeat(); drawMine(); } diff --git a/public/sw.js b/public/sw.js index b449a91..bc93182 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-v52'; +const CACHE = 'hub-v53'; 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 ed1240a..e53567a 100644 --- a/radio.js +++ b/radio.js @@ -57,6 +57,10 @@ async function station(id) { 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); }; +// The featured stations (The Journey FM) are not on anyone's Radio until they add them from the Store, so each person has a list of the ones they added. +const addedFile = () => path.join(users.dir(), 'radio-featured.json'); +const added = () => { try { const l = JSON.parse(fs.readFileSync(addedFile(), 'utf8')); return Array.isArray(l) ? l.filter(id => FEATURED.some(f => f.id === id)) : []; } catch { return []; } }; +const saveAdded = l => { const f = addedFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify([...new Set(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 @@ -124,12 +128,12 @@ async function handle(req, res) { 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 === 'GET') return json(res, 200, { featured: FEATURED, added: added(), 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 b = await readBody(req), s = await station(String(b.id || '')); if (s.featured) { saveAdded([...added(), s.id]); 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 = /^\/stations\/([\w-]+)$/.exec(p)) && req.method === 'DELETE') { saveMine(mine().filter(x => x.id !== m[1])); saveAdded(added().filter(x => x !== 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 : '', startedAt: now ? now.startedAt : 0, recent: recent.slice(0, 12) }); } 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" diff --git a/tools/caldav-test.js b/tools/caldav-test.js new file mode 100644 index 0000000..23d2974 --- /dev/null +++ b/tools/caldav-test.js @@ -0,0 +1,13 @@ +// iCloud connect: when the account does not report its calendar home at the principal, the home beside it (//calendars/) is tried. +const http = require('http'), fs = require('fs'), os = require('os'), path = require('path'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cd-')); process.env.CALDAV_TEST = '1'; +const root = path.join(__dirname, '..'); +const srv = http.createServer((rq, rs) => { const p = new URL(rq.url, 'http://x').pathname, x = b => { rs.writeHead(207, { 'content-type': 'application/xml' }); rs.end('' + b + ''); }; + if (p === '/') return x('//9/principal/HTTP/1.1 200 OK'); + if (p === '/9/principal/') return x('/9/principal/HTTP/1.1 404 Not Found'); + if (p === '/9/calendars/') return x('/9/calendars/home/HomeHTTP/1.1 200 OK'); + rs.writeHead(404); rs.end(); }).listen(0, async () => { + process.env.CALDAV_BASE = 'http://127.0.0.1:' + srv.address().port; + const users = require(root + '/users'), caldav = require(root + '/caldav'), me = users.create({ name: 'A', role: 'admin', handle: 'YQ' }); + await users.runAs(me, async () => { try { const s = await caldav.connect('a@icloud.com', 'abcd-efgh'); require('assert').deepEqual(s.calendars.map(c => c.name), ['Home']); console.log('caldav fallback ok'); } catch (e) { console.error(e); process.exitCode = 1; } }); + srv.close(); process.exit(process.exitCode || 0); }); diff --git a/tools/radio-test.js b/tools/radio-test.js index 7208941..081a2fb 100644 --- a/tools/radio-test.js +++ b/tools/radio-test.js @@ -27,7 +27,7 @@ const fake = http.createServer((req, res) => { 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, []); + 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, []); assert.deepEqual(r.added, [], 'nothing is on Radio until it is added from the Store'); 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 @@ -48,6 +48,8 @@ const fake = http.createServer((req, res) => { 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', { method: 'POST', body: JSON.stringify({ id: 'journeyfm' }) }); assert.deepEqual((await j(await call('/stations'))).added, ['journeyfm'], 'the Journey is added like any other'); assert.deepEqual((await j(await call('/stations'))).mine.map(s => s.name), ['Good FM'], 'and is not mixed into the saved list'); assert.deepEqual((await j(await call('/stations', { headers: { 'x-user': 'b' } }))).added, []); + await call('/stations/journeyfm', { method: 'DELETE' }); assert.deepEqual((await j(await call('/stations'))).added, []); 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