mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
Two gaps that both end the same way — a panel nobody can fix without a van. OFFLINE. Content bytes were never persistently cached. The service worker skipped /uploads/content/ and leaned on the browser's HTTP cache, which is reasonable on a desktop and is not a documented-persistent store here: BrightSign guarantees survival across reboots for IndexedDB, localStorage and SQLite, and their own answer for offline video is to cache the bytes explicitly. A panel could come back from a power cut with its playlist intact — that lives in localStorage — and no media to play it with. The reason content was skipped is real, and player-cache-policy.js is what makes intercepting it safe. Seeking video issues range requests, and naive caching is worse than none: storing a 206 as the whole file means every later full request gets a fragment, and answering a range request with a 200 makes some media stacks fail outright. So only complete 200s are stored, and ranges are served by slicing the stored body into a correct 206. The content cache survives shell re-versioning, or every deploy would re-download the playlist over a link that may be exactly what is broken. SELF-UPDATE. The package can replace autorun.brs, so a truncated file is a dark panel with no app underneath. The safety is the ordering: download to .part, verify sha256 AND size, then delete the .done marker, rename, reboot. Marker first is not stylistic — leaving it makes the next boot skip the archive and the update silently never happens. A failed extract parks the zip as .bad instead of retrying every boot, which would be a loop indistinguishable from a hardware fault. sha256 because that is what roMessageDigest can compute; a checksum the player cannot verify is an unverifiable package. The decision lives on the server and is unit-tested, and the host only executes it — re-implementing the version comparison in BrightScript would put the prerelease trap somewhere untestable. That trap is honoured directly: a player on 1.9.29-rc1 is running something semver-OLDER than 1.9.29, so an opted-in player HOLDS a prerelease of its own core rather than being pulled off the build it was given to test. Narrowly — a newer core still lands, so opting in never means never updating again. Both loop conditions are closed by construction. The manifest and the download come from one buffer hashed once, so a checksum cannot describe bytes we are not serving. And the version is stamped into autorun.brs at build time by both builders, so the script reports the version it actually is — otherwise the player applies the update, still reports the old version, and is offered the same package forever. Failure always degrades to "keep running the old version": an unreachable manifest, a missing checksum, a failed verification, a full attempt counter and an unbuildable package all resolve to skip. 998 tests pass (was 954).
150 lines
7.3 KiB
JavaScript
150 lines
7.3 KiB
JavaScript
'use strict';
|
|
|
|
// Content bytes were never persistently cached. The service worker skipped `/uploads/content/` and
|
|
// leaned on the browser's HTTP cache, which is fine on a desktop and is NOT a documented-persistent
|
|
// store on BrightSign — they guarantee persistence across reboots for IndexedDB, localStorage and
|
|
// SQLite, and their own answer for offline video is to cache the bytes explicitly. A panel that lost
|
|
// its uplink could therefore come back with a playlist (which survives in localStorage) and no media
|
|
// to play it with.
|
|
//
|
|
// Intercepting content is only safe if range requests keep working, which is exactly why it was
|
|
// avoided before. Two failure modes make video WORSE than not caching at all:
|
|
//
|
|
// * storing a 206 as though it were the whole file — every later full request gets a fragment,
|
|
// and it stays broken until something evicts it
|
|
// * answering a Range request with a 200 — some media stacks treat the mismatch as fatal and the
|
|
// video never starts
|
|
//
|
|
// These tests pin both, plus the range arithmetic that a seeking player depends on.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const P = require('../lib/player-cache-policy');
|
|
|
|
// ---------------------------------------------------------------- what gets cached
|
|
|
|
test('content under /uploads/content is cacheable — that is the media a dark player needs', () => {
|
|
assert.equal(P.isCacheableContent('https://s.example/uploads/content/abc.mp4', 'GET'), true);
|
|
assert.equal(P.isCacheableContent('https://s.example/uploads/content/nested/x.jpg', 'GET'), true);
|
|
});
|
|
|
|
test('everything else is left alone, so API calls and sockets are never served stale', () => {
|
|
assert.equal(P.isCacheableContent('https://s.example/api/status', 'GET'), false);
|
|
assert.equal(P.isCacheableContent('https://s.example/uploads/thumbs/x.jpg', 'GET'), false);
|
|
assert.equal(P.isCacheableContent('https://s.example/player', 'GET'), false);
|
|
});
|
|
|
|
test('a non-GET is never cached — you cannot replay a POST', () => {
|
|
assert.equal(P.isCacheableContent('https://s.example/uploads/content/a.mp4', 'POST'), false);
|
|
});
|
|
|
|
test('a malformed URL is declined rather than throwing inside a fetch handler', () => {
|
|
// A throw here takes down the fetch handler and the page loses every request, not just this one.
|
|
assert.doesNotThrow(() => P.isCacheableContent('::::not a url::::', 'GET'));
|
|
assert.equal(P.isCacheableContent('::::not a url::::', 'GET'), false);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- what gets STORED
|
|
|
|
test('THE CORRUPTION BUG: a 206 is never stored as if it were the whole file', () => {
|
|
// Storing a fragment under the full-file key means every later full request is answered with part
|
|
// of a video, and it stays broken until eviction. This is the single most damaging mistake here.
|
|
assert.equal(P.isStorable({ status: 206, type: 'basic' }), false);
|
|
});
|
|
|
|
test('an opaque response is never stored — status 0, unreadable body, unsliceable', () => {
|
|
assert.equal(P.isStorable({ status: 0, type: 'opaque' }), false);
|
|
assert.equal(P.isStorable({ status: 200, type: 'opaque' }), false);
|
|
});
|
|
|
|
test('errors and redirects are not stored, so an outage cannot poison the cache', () => {
|
|
assert.equal(P.isStorable({ status: 404, type: 'basic' }), false);
|
|
assert.equal(P.isStorable({ status: 503, type: 'basic' }), false);
|
|
assert.equal(P.isStorable({ status: 200, type: 'opaqueredirect' }), false);
|
|
assert.equal(P.isStorable(null), false);
|
|
});
|
|
|
|
test('a complete 200 IS stored — otherwise nothing is ever available offline', () => {
|
|
assert.equal(P.isStorable({ status: 200, type: 'basic' }), true);
|
|
assert.equal(P.isStorable({ status: 200, type: 'cors' }), true);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- range arithmetic
|
|
|
|
test('an open range "bytes=0-" spans the whole body — the common video start', () => {
|
|
assert.deepEqual(P.parseRange('bytes=0-', 1000), { start: 0, end: 999 });
|
|
});
|
|
|
|
test('a closed range is honoured exactly', () => {
|
|
assert.deepEqual(P.parseRange('bytes=100-200', 1000), { start: 100, end: 200 });
|
|
});
|
|
|
|
test('THE SEEK BUG: a suffix range is the LAST n bytes, not the first n', () => {
|
|
// "bytes=-500" means the final 500 bytes. Reading it as 0-500 hands a seeking player the start of
|
|
// the file when it asked for the end — MP4 moov-atom probing does exactly this.
|
|
assert.deepEqual(P.parseRange('bytes=-500', 1000), { start: 500, end: 999 });
|
|
});
|
|
|
|
test('an end past EOF is clamped, because that is what open-ended seeks rely on', () => {
|
|
assert.deepEqual(P.parseRange('bytes=900-99999', 1000), { start: 900, end: 999 });
|
|
});
|
|
|
|
test('a start past EOF is UNSATISFIABLE, not clamped — clamping loops a seeking player forever', () => {
|
|
assert.equal(P.parseRange('bytes=1000-', 1000), 'unsatisfiable');
|
|
assert.equal(P.parseRange('bytes=5000-6000', 1000), 'unsatisfiable');
|
|
});
|
|
|
|
test('a reversed range is unsatisfiable rather than silently swapped', () => {
|
|
assert.equal(P.parseRange('bytes=800-100', 1000), 'unsatisfiable');
|
|
});
|
|
|
|
test('no header, junk, or multipart falls back to the full body instead of guessing', () => {
|
|
assert.equal(P.parseRange(null, 1000), null);
|
|
assert.equal(P.parseRange('', 1000), null);
|
|
assert.equal(P.parseRange('bytes=abc-def', 1000), null);
|
|
assert.equal(P.parseRange('bytes=0-99,200-299', 1000), null, 'multipart: serving one part would be wrong');
|
|
assert.equal(P.parseRange('items=0-10', 1000), null);
|
|
assert.equal(P.parseRange('bytes=-', 1000), null);
|
|
});
|
|
|
|
test('a zero-length body has no satisfiable range', () => {
|
|
assert.equal(P.parseRange('bytes=0-', 0), null);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- 206 headers
|
|
|
|
test('THE DURATION BUG: Content-Range reports the ORIGINAL size, not the slice length', () => {
|
|
// The player learns how long the media is from this. Reporting the slice size makes a 90-minute
|
|
// video look a few seconds long and kills seeking entirely.
|
|
const h = P.partialHeaders(100, 199, 5000, 'video/mp4');
|
|
assert.equal(h['Content-Range'], 'bytes 100-199/5000');
|
|
assert.equal(h['Content-Length'], '100', 'length is the slice, inclusive of both ends');
|
|
assert.equal(h['Accept-Ranges'], 'bytes');
|
|
assert.equal(h['Content-Type'], 'video/mp4');
|
|
});
|
|
|
|
test('a single-byte range still reports length 1', () => {
|
|
assert.equal(P.partialHeaders(0, 0, 10)['Content-Length'], '1');
|
|
});
|
|
|
|
// ---------------------------------------------------------------- quota
|
|
|
|
test('eviction is decided BEFORE the write, not after a QuotaExceededError', () => {
|
|
// A full cache throws on write, and the throw lands on whatever item came next rather than the
|
|
// largest — so the player loses an arbitrary item mid-playlist. Deciding in advance keeps it ours.
|
|
const GB = 1024 * 1024 * 1024;
|
|
assert.equal(P.needsEviction(0, 10 * 1024 * 1024, GB), false);
|
|
assert.equal(P.needsEviction(GB * 0.85, 100 * 1024 * 1024, GB), true);
|
|
});
|
|
|
|
test('headroom leaves room to breathe rather than filling to the brim', () => {
|
|
const GB = 1024 * 1024 * 1024;
|
|
assert.equal(P.needsEviction(GB * 0.89, 1, GB), false);
|
|
assert.equal(P.needsEviction(GB * 0.91, 1, GB), true);
|
|
});
|
|
|
|
test('an unknown quota never triggers eviction — absence of a number is not a full disk', () => {
|
|
assert.equal(P.needsEviction(999, 999, 0), false);
|
|
assert.equal(P.needsEviction(999, 999, undefined), false);
|
|
});
|