aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/cast-relay.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client/src/cast-relay.js')
-rw-r--r--packages/meshbay-client/src/cast-relay.js154
1 files changed, 125 insertions, 29 deletions
diff --git a/packages/meshbay-client/src/cast-relay.js b/packages/meshbay-client/src/cast-relay.js
index c5eadc7..ed20b33 100644
--- a/packages/meshbay-client/src/cast-relay.js
+++ b/packages/meshbay-client/src/cast-relay.js
@@ -15,9 +15,11 @@
* a media element, so unlike the stream it needs CORS headers to be readable
* at all.
*
- * A photo is served at `/image`, one at a time: the page decrypts it, scales
- * it to the television and hands it here, and the receiver loads it as a
- * picture. The relay starts for it when it is not already running.
+ * A whole file — a photo, or a music track with its cover at `/cover` — is
+ * served at `/file`, one at a time: the page decrypts it and hands it over in
+ * pieces (begin, writes, end), and the receiver loads it as a picture or as
+ * music. Ranges are honoured there, which is what a receiver seeks with. The
+ * relay starts for a file when it is not already running.
*
* Mitigations:
* · Bind to the LAN interface, never 0.0.0.0
@@ -56,7 +58,14 @@ const debug = util.debuglog('cast-relay');
const RING_CAP = 64;
// What the receiver is ever told a photo is. The page re-encodes every photo
// to JPEG, so this list is a guard, not a menu.
-const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
+const FILE_TYPES = new Set([
+ 'image/jpeg', 'image/png', 'image/webp',
+ 'audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/flac', 'audio/ogg', 'audio/opus',
+ 'audio/wav', 'audio/webm',
+]);
+// A track is a few megabytes, a long lossless one a few hundred; this only
+// stops a page from filling the machine's memory.
+const FILE_MAX_BYTES = 1024 * 1024 * 1024;
const BACKPRESSURE_HIGH = 8 * 1024 * 1024;
const MOOF = 0x6d6f6f66;
const PORT_BASE = 19550;
@@ -170,8 +179,9 @@ class CastRelay {
this._bytesSent = 0;
this._subtitle = null;
this._subtitleVersion = 0;
- this._image = null;
- this._imageVersion = 0;
+ this._file = null;
+ this._fileVersion = 0;
+ this._incoming = null;
}
get active() { return this._server !== null; }
@@ -195,26 +205,67 @@ class CastRelay {
}
/**
- * Where the current photo can be fetched, or null when there is none.
+ * Where the current file can be fetched, or null when there is none.
* Versioned for the same reason as the subtitle: a receiver handed the same
* address twice shows what it already has.
*/
- get imageUrl() {
- if (!this._server || !this._image) return null;
- return `http://${this._lanIP}:${this._port}/image`
- + `?t=${this._token}&v=${this._imageVersion}`;
+ get fileUrl() {
+ if (!this._server || !this._file) return null;
+ return `http://${this._lanIP}:${this._port}/file`
+ + `?t=${this._token}&v=${this._fileVersion}`;
}
- get imageType() { return this._image ? this._image.type : null; }
+ get fileType() { return this._file ? this._file.type : null; }
- /** Serve one photo, starting the relay if nothing else has. */
- async showImage({ bytes, type }) {
- if (!IMAGE_TYPES.has(type)) throw new Error(`Not a picture a receiver shows: ${type}`);
+ /** What the receiver is told the file is: title, artist, album, cover. */
+ get fileMeta() {
+ if (!this._file) return null;
+ const { title, artist, album } = this._file.meta;
+ const cover = this._file.cover
+ ? `http://${this._lanIP}:${this._port}/cover?t=${this._token}&v=${this._fileVersion}`
+ : null;
+ return { title, artist, album, cover };
+ }
+
+ /**
+ * A file arrives in three steps, so that a large one never has to cross in
+ * a single message: `beginFile` names it, `writeFile` appends, `endFile`
+ * puts it up and answers its address. The relay starts if nothing has.
+ */
+ async beginFile({ type, size, cover, title, artist, album }) {
+ if (!FILE_TYPES.has(type)) throw new Error(`Not something a receiver shows: ${type}`);
+ if (!(size > 0 && size <= FILE_MAX_BYTES)) throw new Error(`Bad file size: ${size}`);
+ if (cover && !FILE_TYPES.has(cover.type)) throw new Error(`Bad cover type: ${cover.type}`);
if (!this._server) await this.start({});
- this._image = { bytes: Buffer.from(bytes), type };
- this._imageVersion++;
- debug(`[cast-relay] image set: ${this._image.bytes.length} bytes, ${type}, v${this._imageVersion}`);
- return { url: this.imageUrl };
+ const text = (v) => (typeof v === 'string' ? v.slice(0, 500) : '');
+ this._incoming = {
+ type, size, parts: [], received: 0,
+ cover: cover && cover.type.startsWith('image/')
+ ? { bytes: Buffer.from(cover.bytes), type: cover.type } : null,
+ meta: { title: text(title), artist: text(artist), album: text(album) },
+ };
+ return true;
+ }
+
+ writeFile(bytes) {
+ const f = this._incoming;
+ if (!f) throw new Error('No file is being sent');
+ const buf = Buffer.from(bytes);
+ if (f.received + buf.length > f.size) throw new Error('More bytes than announced');
+ f.parts.push(buf);
+ f.received += buf.length;
+ return true;
+ }
+
+ endFile() {
+ const f = this._incoming;
+ this._incoming = null;
+ if (!f) throw new Error('No file is being sent');
+ if (f.received !== f.size) throw new Error(`File cut short: ${f.received} of ${f.size} bytes`);
+ this._file = { bytes: Buffer.concat(f.parts), type: f.type, cover: f.cover, meta: f.meta };
+ this._fileVersion++;
+ debug(`[cast-relay] file set: ${f.size} bytes, ${f.type}, v${this._fileVersion}`);
+ return { url: this.fileUrl };
}
get subtitle() {
@@ -264,7 +315,8 @@ class CastRelay {
this._chunkCount = 0;
this._bytesSent = 0;
this._subtitle = null;
- this._image = null;
+ this._file = null;
+ this._incoming = null;
this.setSubtitle(subtitle);
const server = http.createServer((req, res) => this._handle(req, res));
@@ -356,7 +408,8 @@ class CastRelay {
this._token = null;
this._initSegment = null;
this._subtitle = null;
- this._image = null;
+ this._file = null;
+ this._incoming = null;
this._ring = [];
this._accum.reset();
this._fragCount = 0;
@@ -400,8 +453,13 @@ class CastRelay {
return;
}
- if (url.pathname === '/image') {
- this._handleImage(res);
+ if (url.pathname === '/file') {
+ this._handleFile(req, res);
+ return;
+ }
+
+ if (url.pathname === '/cover') {
+ this._handleCover(res);
return;
}
@@ -459,20 +517,58 @@ class CastRelay {
debug(`[cast-relay] subtitle served: ${this._subtitle.vtt.length} bytes`);
}
- _handleImage(res) {
- if (!this._image) {
+ /**
+ * The whole file, or the one range asked for: a receiver seeks in a track
+ * by asking for the bytes from there on.
+ */
+ _handleFile(req, res) {
+ const f = this._file;
+ if (!f) {
+ res.writeHead(404, CORS_HEADERS);
+ res.end();
+ return;
+ }
+ const total = f.bytes.length;
+ const base = {
+ ...CORS_HEADERS,
+ 'Content-Type': f.type,
+ 'Cache-Control': 'no-store',
+ 'Accept-Ranges': 'bytes',
+ };
+ const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
+ if (m && (m[1] !== '' || m[2] !== '')) {
+ let from = m[1] === '' ? total - Number(m[2]) : Number(m[1]);
+ let to = m[1] === '' || m[2] === '' ? total - 1 : Math.min(Number(m[2]), total - 1);
+ from = Math.max(0, from);
+ if (from > to || from >= total) {
+ res.writeHead(416, { ...base, 'Content-Range': `bytes */${total}` });
+ res.end();
+ return;
+ }
+ res.writeHead(206, { ...base, 'Content-Range': `bytes ${from}-${to}/${total}`,
+ 'Content-Length': to - from + 1 });
+ res.end(f.bytes.subarray(from, to + 1));
+ return;
+ }
+ res.writeHead(200, { ...base, 'Content-Length': total });
+ res.end(f.bytes);
+ debug(`[cast-relay] file served: ${total} bytes`);
+ }
+
+ _handleCover(res) {
+ const c = this._file && this._file.cover;
+ if (!c) {
res.writeHead(404, CORS_HEADERS);
res.end();
return;
}
res.writeHead(200, {
...CORS_HEADERS,
- 'Content-Type': this._image.type,
- 'Content-Length': this._image.bytes.length,
+ 'Content-Type': c.type,
+ 'Content-Length': c.bytes.length,
'Cache-Control': 'no-store',
});
- res.end(this._image.bytes);
- debug(`[cast-relay] image served: ${this._image.bytes.length} bytes`);
+ res.end(c.bytes);
}
}