Podcast: each person has their own list of shows
build-container / build (push) Successful in 7m29s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 16:12:04 -04:00
1 parent b3494b9720
commit 4468c2e025
4 files changed
+69 -8

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Recovery codes and activity log**: Settings -> Recovery codes makes 8 one-time codes; if you lose every device, the sign-in page's "Use an invite or recovery code" adds a new passkey with one (codes are stored only as hashes). Admins see an **Activity** list in Settings (sign-ins, failed sign-ins, passkeys added or removed, invites, recovery code use, people changes, backups); it is `/data/audit.log` and never contains codes or passwords. - **Recovery codes and activity log**: Settings -> Recovery codes makes 8 one-time codes; if you lose every device, the sign-in page's "Use an invite or recovery code" adds a new passkey with one (codes are stored only as hashes). Admins see an **Activity** list in Settings (sign-ins, failed sign-ins, passkeys added or removed, invites, recovery code use, people changes, backups); it is `/data/audit.log` and never contains codes or passwords.
- **File version history**: on any Seafile file, "Version history" lists earlier versions with who and when, and can download or restore one (restoring keeps the current version in the history). This uses Seafile's history API and has not been tried against a live Seafile server yet. - **File version history**: on any Seafile file, "Version history" lists earlier versions with who and when, and can download or restore one (restoring keeps the current version in the history). This uses Seafile's history API and has not been tried against a live Seafile server yet.
- **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`. - **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`.
- **Podcast** is a built-in screen for [Podgrab](https://github.com/akhilrex/podgrab) at `PODCAST_URL`: your shows, their episodes (played state, download all, pause, remove), adding a show by feed address, and playback in the same player as Music. The hub signs in to Podgrab for you with `PODGRAB_PASSWORD` (Podgrab's `PASSWORD` setting; the user name is `podgrab`), so that password never reaches the browser. Search finds your shows too. Add podcast also searches Apple's public podcast directory for new shows (the words you type go to Apple). Not here: resume position. - **Podcast** is a built-in screen for [Podgrab](https://github.com/akhilrex/podgrab) at `PODCAST_URL`: your shows, their episodes (played state, download all, pause, remove), adding a show by feed address, and playback in the same player as Music. The hub signs in to Podgrab for you with `PODGRAB_PASSWORD` (Podgrab's `PASSWORD` setting; the user name is `podgrab`), so that password never reaches the browser. Each person has their own list of shows (the admin starts with everything already in Podgrab, everyone else starts empty); adding a show someone already follows just puts it on your list, and removing one only takes it off yours (Podgrab drops it when nobody follows it). Where you got to in each episode is kept per person on the server, so it follows you between devices. Search finds your shows too. Add podcast also searches Apple's public podcast directory for new shows (the words you type go to Apple). Not here: resume position.
- **Podcast Admin** is no longer a menu entry: it is a built-in link on the **Links** page (opens `VERSE_URL` in a new tab, cannot be edited or deleted) for people who have the Podcast Admin permission in Settings -> People; the old `#/verse` address goes to Links. - **Podcast Admin** is no longer a menu entry: it is a built-in link on the **Links** page (opens `VERSE_URL` in a new tab, cannot be edited or deleted) for people who have the Podcast Admin permission in Settings -> People; the old `#/verse` address goes to Links.
- **Office**: make, find and open Word, Excel and PowerPoint files kept in Seafile (they open in OnlyOffice inside the app). New document / spreadsheet / presentation buttons, search, a filter by kind, sorting (newest, name, size), **Pinned** and **Recently opened** at the top, an Upload button and drop-files-on-the-page (with the same Replace / Keep both / Skip choice as Files), a three-dots menu on every document (share link, download, version history, rename, move, delete), and a saved copy of the list that shows at once while the real one loads (and stays if Seafile cannot be reached). Pins are saved with your settings and follow you across devices. The Ctrl/Cmd-K palette can create a new document, spreadsheet or presentation. - **Office**: make, find and open Word, Excel and PowerPoint files kept in Seafile (they open in OnlyOffice inside the app). New document / spreadsheet / presentation buttons, search, a filter by kind, sorting (newest, name, size), **Pinned** and **Recently opened** at the top, an Upload button and drop-files-on-the-page (with the same Replace / Keep both / Skip choice as Files), a three-dots menu on every document (share link, download, version history, rename, move, delete), and a saved copy of the list that shows at once while the real one loads (and stays if Seafile cannot be reached). Pins are saved with your settings and follow you across devices. The Ctrl/Cmd-K palette can create a new document, spreadsheet or presentation.
- **Mail**: several mail accounts per person in one place: **Gmail**, **iCloud Mail** and (optionally) **Outlook.com** (personal). Folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB) and a From choice, reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab (all accounts added up) and a Home card with the latest unread across accounts. Each person adds their own accounts (Mail tab, or Settings -> Mail -> Add an account); everything is private to them and stored encrypted in their own folder. - **Mail**: several mail accounts per person in one place: **Gmail**, **iCloud Mail** and (optionally) **Outlook.com** (personal). Folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB) and a From choice, reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab (all accounts added up) and a Home card with the latest unread across accounts. Each person adds their own accounts (Mail tab, or Settings -> Mail -> Add an account); everything is private to them and stored encrypted in their own folder.
+48
View File
@@ -1,5 +1,7 @@
// Podcasts from Podgrab (https://github.com/akhilrex/podgrab). The hub talks to Podgrab's own web API on the person's behalf, so the // Podcasts from Podgrab (https://github.com/akhilrex/podgrab). The hub talks to Podgrab's own web API on the person's behalf, so the
// Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub. // Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub.
const fs = require('fs'), path = require('path');
const users = require('./users');
const prog = require('./progress')('podcast-progress.json'); // where each person got to in each episode const prog = require('./progress')('podcast-progress.json'); // where each person got to in each episode
const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, ''); const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, '');
const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : ''; const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : '';
@@ -12,6 +14,23 @@ const ALLOWED = [
['GET', /^\/podcastitems$/], ['GET', /^\/podcastitems\/[\w-]+$/], ['PATCH', /^\/podcastitems\/[\w-]+$/], ['GET', /^\/podcastitems$/], ['GET', /^\/podcastitems\/[\w-]+$/], ['PATCH', /^\/podcastitems\/[\w-]+$/],
['GET', /^\/podcastitems\/[\w-]+\/(image|file|markPlayed|markUnplayed|bookmark|unbookmark|download|delete)$/], ['GET', /^\/podcastitems\/[\w-]+\/(image|file|markPlayed|markUnplayed|bookmark|unbookmark|download|delete)$/],
]; ];
// Podgrab holds every show in one shared library; which of them are on a person's own list is kept here, per person. A new person starts with an empty
// list (the admin starts with everything already in Podgrab). Taking a show off your list leaves Podgrab alone unless nobody else follows it.
const subsFile = uid => path.join(users.dirOf(uid), 'podcast-subs.json');
const subsRead = uid => { try { const j = JSON.parse(fs.readFileSync(subsFile(uid), 'utf8')); return Array.isArray(j) ? j : null; } catch { return null; } };
const subsWrite = (uid, ids) => { const f = subsFile(uid), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify([...new Set(ids)]), { mode: 0o600 }); fs.renameSync(tmp, f); };
const idOf = o => o && (o.ID !== undefined ? o.ID : o.id);
async function podgrab(p, init = {}) {
const r = await fetch(BASE + p, { ...init, headers: { accept: 'application/json', ...(AUTH ? { authorization: AUTH } : {}), ...init.headers }, redirect: 'manual', signal: AbortSignal.timeout(20000) });
return r;
}
async function mySubs(me) {
const have = subsRead(me.id); if (have) return have;
let ids = [];
if (me.role === 'admin') { try { const l = await (await podgrab('/podcasts')).json(); ids = (Array.isArray(l) ? l : []).map(idOf).filter(Boolean); } catch { return []; } }
subsWrite(me.id, ids); return ids;
}
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const enabled = () => !!BASE; const enabled = () => !!BASE;
@@ -37,6 +56,35 @@ async function handle(req, res) {
if (!BASE) return json(res, 503, { error: 'Podcasts are not set up on this server' }); if (!BASE) return json(res, 503, { error: 'Podcasts are not set up on this server' });
if (p === '/status') return json(res, 200, { ok: true, host: new URL(BASE).host }); if (p === '/status') return json(res, 200, { ok: true, host: new URL(BASE).host });
if (!ALLOWED.some(([m, re]) => m === req.method && re.test(p))) return json(res, 404, { error: 'Not found' }); if (!ALLOWED.some(([m, re]) => m === req.method && re.test(p))) return json(res, 404, { error: 'Not found' });
const me = users.current(), mine = await mySubs(me);
let m;
try {
if (req.method === 'GET' && p === '/podcasts') { // only the shows on this person's list
const r = await podgrab('/podcasts' + u.search); const l = await r.json();
return json(res, r.status, Array.isArray(l) ? l.filter(x => mine.includes(idOf(x))) : l);
}
if (req.method === 'POST' && p === '/podcasts') { // add to Podgrab (if it is not there yet) and to this person's list
const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e5) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
let url = ''; try { url = String(JSON.parse(Buffer.concat(chunks).toString('utf8')).url || '').trim(); } catch {}
if (!/^https?:\/\//i.test(url)) return json(res, 400, { error: 'Enter a feed address starting with http' });
const r = await podgrab('/podcasts', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url }) });
let added = null; if (r.status === 200) { try { added = await r.json(); } catch {} }
let id = idOf(added);
if (!id) { // Podgrab already had it (409), or did not say which: find it by its feed address
const l = await (await podgrab('/podcasts')).json(); const norm = x => String(x || '').replace(/\/+$/, '').toLowerCase();
const hit = (Array.isArray(l) ? l : []).find(x => norm(x.URL || x.url) === norm(url)); id = idOf(hit); added = hit;
if (!id) return json(res, r.status === 200 ? 502 : r.status, { error: 'Podgrab could not add that feed' });
}
subsWrite(me.id, [...mine, id]); return json(res, 200, added || { ID: id });
}
if (req.method === 'DELETE' && (m = /^\/podcasts\/([\w-]+)$/.exec(p))) { // off this person's list; out of Podgrab only when nobody else follows it
subsWrite(me.id, mine.filter(x => x !== m[1]));
const others = users.all().some(x => x.id !== me.id && (subsRead(x.id) || []).includes(m[1]));
if (!others) await podgrab(p, { method: 'DELETE' }).catch(() => {});
return json(res, 200, { ok: true, removed: !others });
}
if ((m = /^\/podcasts\/([\w-]+)(\/|$)/.exec(p)) && !mine.includes(m[1])) return json(res, 404, { error: 'That podcast is not on your list' });
} catch (e) { return json(res, 502, { error: 'Podgrab did not answer' + (e.name === 'TimeoutError' ? ' in time' : '') }); }
const hasBody = req.method === 'POST' || req.method === 'PATCH'; const hasBody = req.method === 'POST' || req.method === 'PATCH';
let body; let body;
if (hasBody) { const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e5) return json(res, 413, { error: 'Too large' }); chunks.push(c); } body = Buffer.concat(chunks); } if (hasBody) { const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e5) return json(res, 413, { error: 'Too large' }); chunks.push(c); } body = Buffer.concat(chunks); }
+3 -3
View File
@@ -52,7 +52,7 @@ async function pdHome() {
${shows.length ? `<div class="vgrid">${shows.map(s => `<a class="vcard" href="#/podcast/show/${encodeURIComponent(pdId(s))}"> ${shows.length ? `<div class="vgrid">${shows.map(s => `<a class="vcard" href="#/podcast/show/${encodeURIComponent(pdId(s))}">
<span class="vimg" style="aspect-ratio:1/1"><img loading="lazy" alt="" src="${pdImg('podcasts', s)}" onerror="this.remove()"></span> <span class="vimg" style="aspect-ratio:1/1"><img loading="lazy" alt="" src="${pdImg('podcasts', s)}" onerror="this.remove()"></span>
<b>${esc(pdF(s, 'title'))}</b><small>${esc([pdF(s, 'allEpisodesCount') ? pdF(s, 'allEpisodesCount') + ' episodes' : '', pdF(s, 'isPaused') ? 'paused' : ''].filter(Boolean).join(' · '))}</small></a>`).join('')}</div>` <b>${esc(pdF(s, 'title'))}</b><small>${esc([pdF(s, 'allEpisodesCount') ? pdF(s, 'allEpisodesCount') + ' episodes' : '', pdF(s, 'isPaused') ? 'paused' : ''].filter(Boolean).join(' · '))}</small></a>`).join('')}</div>`
: '<p class="empty">No podcasts yet. Add one with its feed address.</p>'}</div>`; : '<p class="empty">No podcasts on your list yet. Search for one, or paste its feed address.</p>'}</div>`;
$('#pdadd').onclick = () => { $('#pdadd').onclick = () => {
const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', `<input type="search" id="pdq" placeholder="Search podcasts, or paste https://…/feed.xml" autocomplete="off" autocapitalize="off"> const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', `<input type="search" id="pdq" placeholder="Search podcasts, or paste https://…/feed.xml" autocomplete="off" autocapitalize="off">
<div class="err" id="pde"></div><div id="pdres" class="list" style="margin-top:8px;max-height:50vh;overflow:auto"></div> <div class="err" id="pde"></div><div id="pdres" class="list" style="margin-top:8px;max-height:50vh;overflow:auto"></div>
@@ -109,11 +109,11 @@ async function pdShow(id) {
$('#pdmore').onclick = () => { $('#pdmore').onclick = () => {
const el = openSheet(pdF(show, 'title'), '', `<div class="list"><button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button> const el = openSheet(pdF(show, 'title'), '', `<div class="list"><button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
<button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button> <button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button>
<button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove this podcast</b><small>Also deletes its downloaded episodes</small></span></button></div>`); <button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove from my podcasts</b><small>Only from your list; it is deleted from Podgrab when nobody else follows it</small></span></button></div>`);
const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } }; const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } };
act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background'); act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background');
act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused'); act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused');
el.querySelector('#pdd').onclick = () => { closeSheet(); confirmSheet('Remove this podcast?', 'Its episodes and downloaded files are deleted from Podgrab.', 'Remove', async () => { el.querySelector('#pdd').onclick = () => { closeSheet(); confirmSheet('Remove from your podcasts?', 'It leaves your list. Its downloaded episodes are only deleted when nobody else follows it.', 'Remove', async () => {
try { await pdApi('/podcasts/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Removed'); location.hash = '#/podcast'; } catch (e) { toast(e.message); } }); }; try { await pdApi('/podcasts/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Removed'); location.hash = '#/podcast'; } catch (e) { toast(e.message); } }); };
}; };
} }
+17 -4
View File
@@ -5,7 +5,8 @@ const seen = [];
const fake = http.createServer((req, res) => { const fake = http.createServer((req, res) => {
let b = ''; req.on('data', c => b += c); req.on('end', () => { let b = ''; req.on('data', c => b += c); req.on('end', () => {
seen.push({ m: req.method, u: req.url, auth: req.headers.authorization, range: req.headers.range, b }); seen.push({ m: req.method, u: req.url, auth: req.headers.authorization, range: req.headers.range, b });
if (req.url === '/podcasts') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('[{"ID":"p1","Title":"Show"}]'); } if (req.url === '/podcasts' && req.method === 'GET') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify([{ ID: 'p1', Title: 'Show', URL: 'https://a.test/feed' }, { ID: 'p9', Title: 'Other', URL: 'https://b.test/feed' }])); }
if (req.url === '/podcasts' && req.method === 'POST') { const u = JSON.parse(b).url; if (u === 'https://a.test/feed') { res.writeHead(409, { 'content-type': 'application/json' }); return res.end('{"message":"exists"}'); } res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"ID":"p2","Title":"New","URL":"' + u + '"}'); }
if (req.url === '/podcastitems/e1/file') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); } if (req.url === '/podcastitems/e1/file') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); }
if (req.url === '/podcastitems/e2/file') { res.writeHead(302, { location: 'https://cdn.example.com/a.mp3' }); return res.end(); } if (req.url === '/podcastitems/e2/file') { res.writeHead(302, { location: 'https://cdn.example.com/a.mp3' }); return res.end(); }
res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}'); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}');
@@ -13,14 +14,26 @@ const fake = http.createServer((req, res) => {
}).listen(0, async () => { }).listen(0, async () => {
process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret'; process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret';
const podcast = require('../podcast'), users = require('../users'); const podcast = require('../podcast'), users = require('../users');
const hub = http.createServer((req, res) => users.runAs({ id: 'pod-user', name: 'P' }, () => podcast.handle(req, res))).listen(0, async () => { const people = { admin: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), other: users.create({ name: 'Bob', role: 'user', tabs: ['podcast'], handle: 'Yg' }) };
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'other' ? people.other : people.admin, () => podcast.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port, get = (p, o = {}) => fetch(base + '/api/podcast' + p, { redirect: 'manual', ...o }); const base = 'http://127.0.0.1:' + hub.address().port, get = (p, o = {}) => fetch(base + '/api/podcast' + p, { redirect: 'manual', ...o });
try { try {
let r = await get('/podcasts'); assert.equal(r.status, 200); assert.equal((await r.json())[0].Title, 'Show'); let r = await get('/podcasts'); assert.equal(r.status, 200); assert.deepEqual((await r.json()).map(x => x.ID), ['p1', 'p9'], 'the admin starts with everything already in Podgrab');
assert.equal(seen[0].auth, 'Basic ' + Buffer.from('podgrab:secret').toString('base64'), 'password added by the server'); assert.equal(seen[0].auth, 'Basic ' + Buffer.from('podgrab:secret').toString('base64'), 'password added by the server');
r = await get('/podcastitems/e1/file', { headers: { range: 'bytes=0-3' } }); assert.equal(r.status, 206); assert.equal(await r.text(), 'abcd'); assert.equal(seen.at(-1).range, 'bytes=0-3'); r = await get('/podcastitems/e1/file', { headers: { range: 'bytes=0-3' } }); assert.equal(r.status, 206); assert.equal(await r.text(), 'abcd'); assert.equal(seen.at(-1).range, 'bytes=0-3');
r = await get('/podcastitems/e2/file'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), 'https://cdn.example.com/a.mp3'); r = await get('/podcastitems/e2/file'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), 'https://cdn.example.com/a.mp3');
r = await get('/podcasts', { method: 'POST', body: '{"url":"https://x.test/feed"}' }); assert.equal(r.status, 200); assert.equal(seen.at(-1).b, '{"url":"https://x.test/feed"}'); const other = { 'x-user': 'other' };
r = await get('/podcasts', { headers: other }); assert.deepEqual(await r.json(), [], 'a new person starts with their own empty list');
r = await get('/podcasts/p1/items', { headers: other }); assert.equal(r.status, 404, 'a show not on your list is not reachable');
r = await get('/podcasts', { method: 'POST', headers: other, body: '{"url":"https://a.test/feed"}' }); assert.equal(r.status, 200); // Podgrab already has it (409): found by its address and put on their list
assert.deepEqual((await (await get('/podcasts', { headers: other })).json()).map(x => x.ID), ['p1']);
r = await get('/podcasts', { method: 'POST', body: '{"url":"https://x.test/feed"}' }); assert.equal(r.status, 200); assert.equal((await r.json()).ID, 'p2');
assert.deepEqual((await (await get('/podcasts')).json()).map(x => x.ID), ['p1', 'p9'], 'the other list did not change (fake Podgrab lists only its own two)');
let dels = seen.filter(x => x.m === 'DELETE').length;
r = await get('/podcasts/p1', { method: 'DELETE', headers: other }); assert.equal((await r.json()).removed, false, 'the admin still follows p1, so Podgrab keeps it');
assert.equal(seen.filter(x => x.m === 'DELETE').length, dels);
r = await get('/podcasts/p9', { method: 'DELETE' }); assert.equal((await r.json()).removed, true, 'nobody else follows p9, so Podgrab drops it');
assert.equal(seen.filter(x => x.m === 'DELETE').length, dels + 1);
const post = b => get('/progress', { method: 'POST', body: JSON.stringify(b) }).then(x => x.json()); const post = b => get('/progress', { method: 'POST', body: JSON.stringify(b) }).then(x => x.json());
await post({ e1: { pos: 90, dur: 600, t: 10 } }); const pr = await post({ e1: { pos: 5, dur: 600, t: 3 }, e2: { pos: 7, dur: 300, t: 4 } }); await post({ e1: { pos: 90, dur: 600, t: 10 } }); const pr = await post({ e1: { pos: 5, dur: 600, t: 3 }, e2: { pos: 7, dur: 300, t: 4 } });
assert.equal(pr.e1.pos, 90, 'the newest position wins'); assert.equal((await (await get('/progress')).json()).e2.pos, 7); assert.equal(pr.e1.pos, 90, 'the newest position wins'); assert.equal((await (await get('/progress')).json()).e2.pos, 7);