Files
ipx/tests/ui/fixtures/serve.ts
rays 2c4d03668f Episodes can be kept on the device to listen offline (#412)
Offline mode (#389) kept lists, text and pictures, but the worker let
every /media/ request pass, so no episode played without a connection.

A downloaded episode's files pane has a keep button beside Play. It
fetches the copy that person plays, the file or, when DeadAir cuts
something for them, the version without its dead air (/media/{id}/deadair,
the save button's), whole into a cache of its own, ipx-episodes, and
asks once for persistent storage. A kept episode plays as
/media/...?offline, online or not, and the worker answers that from the
cache with the byte ranges an audio element asks for (206, 416, 200);
not kept there, it goes to the server as before. Pressing it again takes
it off; signing out clears it with everything else. Only someone's
choice is kept, so there is no budget: a full device is the quota's
refusal, said in words. The button is data-a="offline", not "keep",
which the Directory's show page already uses for subscribing.

Places saved from DeadAir's version were read in its clock only if ipx
had handed out that file's playlist since it started (state.playing). A
kept copy plays without one, so the player now names the file in the
position it saves (deadair: <enclosure>) whenever it plays DeadAir's
clock, and ipx loads that file's plan when it has none, for that item's
file only. Checked the beacon is queued by the worker offline: it is.

Tests: playing a kept episode offline, its place queued and sent once
back, and taking it off (the offline show plays quiet.mp3, the only real
MP3 among the fixtures); the worker's ranges; which copy is kept and
played, in page-unit; the server reading a flagged place in DeadAir's
clock without a playlist, and ignoring another item's file. The files
pane's one-row test counts five buttons and checks a phone's width too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 15:31:04 +00:00

105 lines
5.7 KiB
TypeScript

// Serves the fixture feeds so the daemon under test has something real to scan.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const dir = import.meta.dirname;
const port = Number(process.env.FIXTURE_PORT || 8792);
http.createServer((req, res) => {
// Jev, for Signals (#254): sure of an item whose text has a question's keyword in it. Refuses
// a request in the shape TypeSafe refuses, as it does (#257).
if (req.method === 'POST' && req.url === '/jev') {
let body = '';
req.on('data', c => { body += c; });
req.on('end', () => {
// As TypeSafe answers a key it does not know (#271).
if (req.headers.authorization === 'Bearer bad-key') {
res.writeHead(401, { 'content-type': 'application/json' }).end('{"detail":"invalid api key"}');
return;
}
// As ipx asks TypeSafe's Jev: the item, and a question a Signal.
const { state, questions } = JSON.parse(body) as { state: unknown, questions: Record<string, { criteria: unknown, instructions: string }> };
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
return;
}
const title = String(state).toLowerCase();
const answers: Record<string, { noul: number }> = {};
for (const [id, q] of Object.entries(questions)) {
// A key's check asks about no Signal's words.
const words = /described by these words: ([^.]*)\./.exec(q.instructions)?.[1].split(', ') ?? [];
answers[id] = { noul: words.some(w => title.includes(w.toLowerCase())) ? 0.9 : 0.1 };
}
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers }));
});
return;
}
// Dated today, since a Signal looks at the last 30 days only.
if (req.url === '/signal.xml') {
const now = new Date().toUTCString();
const item = (guid: string, title: string) => `<item><guid>${guid}</guid><title>${title}</title><pubDate>${now}</pubDate></item>`;
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
<title>Signal Show</title>${item('sig-1', 'Monaco qualifying')}${item('sig-2', 'Baking bread')}</channel></rss>`);
return;
}
// A show to download from by hand (#404): one episode sent slowly enough for its progress to be
// seen, and one that always fails. Their files are theirs alone, as enclosure URLs must be.
if (req.url === '/live.xml') {
const item = (guid: string, title: string, file: string) =>
`<item><guid>${guid}</guid><title>${title}</title><enclosure url="http://127.0.0.1:${port}/${file}" type="audio/mpeg" length="40000"/></item>`;
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
<title>Live Show</title>${item('live-1', 'Slow episode', 'live-slow.mp3')}${item('live-2', 'Broken episode', 'live-broken.mp3')}</channel></rss>`);
return;
}
// Four episodes, newest first, for Download latest and a schedule's window (#406).
if (req.url === '/latest.xml') {
const item = (n: number) => `<item><guid>latest-${n}</guid><title>Latest ${n}</title><link>http://127.0.0.1:${port}/latest-${n}.html</link><pubDate>${new Date(Date.UTC(2026, 0, 10 - n)).toUTCString()}</pubDate>`
+ `<enclosure url="http://127.0.0.1:${port}/latest-${n}.mp3" type="audio/mpeg" length="40000"/></item>`;
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
<title>Latest Show</title>${[1, 2, 3, 4].map(item).join('')}</channel></rss>`);
return;
}
// Kept on the device and played offline (#412): real audio, which ep1.mp3 is not, at a URL of
// its own, as enclosure URLs must be.
if (req.url === '/offline-ep.mp3') {
const body = fs.readFileSync(path.join(dir, 'quiet.mp3'));
res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': body.length }).end(body);
return;
}
if (/^\/latest-\d\.mp3$/.test(req.url ?? '')) {
const body = fs.readFileSync(path.join(dir, 'ep1.mp3'));
res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': body.length }).end(body);
return;
}
if (req.url === '/live-broken.mp3') { res.writeHead(500).end('broken'); return; }
if (req.url === '/live-slow.mp3') {
const body = fs.readFileSync(path.join(dir, 'ep1.mp3'));
res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': body.length });
// Ten parts, a fifth of a second apart: two seconds of progress.
let part = 0;
const step = Math.ceil(body.length / 10);
const next = () => {
if (part * step >= body.length) { res.end(); return; }
res.write(body.subarray(part * step, ++part * step));
setTimeout(next, 200);
};
next();
return;
}
const name = decodeURIComponent((req.url ?? '/').split('?')[0]!.replace(/^\//, '')) || 'index';
const file = path.join(dir, path.basename(name));
fs.readFile(file, (err, body) => {
if (err) { res.writeHead(404).end('no'); return; }
const type = file.endsWith('.mp3') ? 'audio/mpeg'
: file.endsWith('.opml') ? 'text/x-opml'
// Artwork as an image, or /api/art refuses it as not one.
: file.endsWith('.jpg') ? 'image/jpeg'
// A picture a browser can draw: the JPEGs are a few bytes that only say they are one.
: file.endsWith('.png') ? 'image/png'
: file.endsWith('.html') ? 'text/html' : 'application/xml';
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
res.end(body);
});
}).listen(port, '127.0.0.1', () => console.log(`fixtures on ${port}`));