mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Two halves of the same problem. A screen has to keep playing when the link is gone, and it must not keep playing the wrong thing once the link is back. CACHING FOR OFFLINE, on the players that could not: - Tizen cached nothing but the playlist, so a panel came back from a reboot knowing exactly what to show and fetched every frame of it from a server that was not there. tizen/js/media-cache.js caches the media itself to wgt-private (the store Tizen documents as surviving reboots), resumable via Range and If-Range, with the transfer async so a stalled chunk cannot freeze the player. offline.cache moves from "absent" to a runtime claim: a build with no writable private storage still says nothing. - The web player's worker stored only what a single fetch() happened to complete, which on a marginal link is nothing at all — a 200MB asset never finishes in one go and every retry starts from zero. It now accumulates in resumable chunks, driven by the player's playlist rather than by playback, so the prefetch is not competing with the video that is currently on screen for the same scarce bandwidth. BrightSign inherits this. STILL UPDATING, which caching quietly breaks: PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every cache keys on that id, so before this the new bytes could not reach a panel that already held the old ones — not until the next refresh, but never. Content now carries a revision, stamped onto each item at send time like widget revs, and every player keys its cache on it. The same send-time refresh fixes a second bug: a replace writes a new randomly-named file and unlinks the old one, so the filepath in a published snapshot pointed at a deleted file and web panels 404'd on the item until somebody republished the playlist. The route now also pushes to affected devices, which it never did. Bytes are kept only where they can be built upon: no validator means no safe resume, so the partial is discarded and the attempt backs off as the failure it is rather than re-fetching the same prefix forever. Server needed no new transfer support — res.sendFile already does Range, If-Range and 416. The Tizen cache and the service worker are both driven in Node against fakes, because neither can be exercised without hardware and "the chunks assemble correctly" is not something to discover from a panel showing a corrupt video. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
235 lines
12 KiB
JavaScript
235 lines
12 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);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Resumable transfer. The arithmetic below is what stops a resumed download from being corrupt,
|
|
// and it is shared by the service worker and the Tizen media cache — neither of which can be
|
|
// tested without a device, which is exactly why the decisions live here.
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
test('chunkRanges covers every byte exactly once, with an inclusive final range', () => {
|
|
const r = P.chunkRanges(10, 4);
|
|
assert.deepEqual(r, [{ start: 0, end: 3 }, { start: 4, end: 7 }, { start: 8, end: 9 }]);
|
|
// The gap-or-overlap check: a one-byte error here corrupts the middle of a video in a way that
|
|
// only shows up on playback, long after the download "succeeded".
|
|
const big = P.chunkRanges(1000, 256);
|
|
let cursor = 0;
|
|
for (const c of big) { assert.equal(c.start, cursor); cursor = c.end + 1; }
|
|
assert.equal(cursor, 1000);
|
|
});
|
|
|
|
test('chunkRanges on an exact multiple does not emit a trailing empty range', () => {
|
|
assert.deepEqual(P.chunkRanges(8, 4), [{ start: 0, end: 3 }, { start: 4, end: 7 }]);
|
|
});
|
|
|
|
test('chunkRanges of an unknown or empty size is no chunks, not one bad one', () => {
|
|
assert.deepEqual(P.chunkRanges(0, 4), []);
|
|
assert.deepEqual(P.chunkRanges(-1, 4), []);
|
|
});
|
|
|
|
test('parseContentRange refuses anything without a verifiable total', () => {
|
|
assert.deepEqual(P.parseContentRange('bytes 4-7/10'), { start: 4, end: 7, total: 10 });
|
|
assert.equal(P.parseContentRange('bytes 4-7/*'), null, 'no total = nothing to check against');
|
|
assert.equal(P.parseContentRange('items 0-1/2'), null);
|
|
assert.equal(P.parseContentRange(null), null);
|
|
});
|
|
|
|
test('resumeVerdict continues only on a verified 206 at the offset we asked for', () => {
|
|
assert.equal(P.resumeVerdict(206, 'bytes 4-7/10', 4, 10, '"v1"', '"v1"'), 'continue');
|
|
});
|
|
|
|
test('resumeVerdict RESTARTS on a 200 — the server declined our range', () => {
|
|
// If-Range with a stale validator produces exactly this, and it is the mechanism that stops a
|
|
// changed asset being spliced. Treating it as a chunk to append is the corruption.
|
|
assert.equal(P.resumeVerdict(200, null, 4, 10, '"v1"', '"v2"'), 'restart');
|
|
});
|
|
|
|
test('resumeVerdict discards a 206 that starts anywhere other than where we asked', () => {
|
|
assert.equal(P.resumeVerdict(206, 'bytes 0-7/10', 4, 10, '"v1"', '"v1"'), 'discard');
|
|
});
|
|
|
|
test('resumeVerdict discards a 206 whose total disagrees with what we are assembling', () => {
|
|
// Same offset, different asset length: appending would leave a file that is complete by our
|
|
// count and wrong by every other measure.
|
|
assert.equal(P.resumeVerdict(206, 'bytes 4-7/99', 4, 10, '"v1"', '"v1"'), 'discard');
|
|
});
|
|
|
|
test('resumeVerdict discards a 206 whose validator changed underneath us', () => {
|
|
assert.equal(P.resumeVerdict(206, 'bytes 4-7/10', 4, 10, '"v1"', '"v2"'), 'discard');
|
|
});
|
|
|
|
test('resumeVerdict discards 416 and every unexpected status', () => {
|
|
assert.equal(P.resumeVerdict(416, null, 4, 10, '"v1"', null), 'discard');
|
|
assert.equal(P.resumeVerdict(500, null, 4, 10, '"v1"', null), 'discard');
|
|
assert.equal(P.resumeVerdict(0, null, 4, 10, '"v1"', null), 'discard');
|
|
});
|
|
|
|
test('validatorOf prefers ETag and reports nothing when there is nothing to trust', () => {
|
|
const h = (o) => ({ get: (k) => o[k.toLowerCase()] || null });
|
|
assert.equal(P.validatorOf(h({ etag: '"v1"', 'last-modified': 'Mon' })), '"v1"');
|
|
assert.equal(P.validatorOf(h({ 'last-modified': 'Mon' })), 'Mon');
|
|
assert.equal(P.validatorOf(h({})), null, 'no validator means the transfer is not resumable');
|
|
});
|
|
|
|
test('assetKey ignores the revision so a store can sweep its own predecessors', () => {
|
|
const a = 'http://s/uploads/content/clip.mp4?rev=100';
|
|
const b = 'http://s/uploads/content/clip.mp4?rev=200';
|
|
assert.equal(P.assetKey(a), P.assetKey(b));
|
|
assert.notEqual(P.assetKey(a), P.assetKey('http://s/uploads/content/other.mp4?rev=100'));
|
|
});
|
|
|
|
test('chunk keys are recognisable as internal, so one can never be served as the asset', () => {
|
|
const k = P.chunkKey('http://s/uploads/content/clip.mp4?rev=100', 4194304);
|
|
assert.ok(P.isInternalKey(k));
|
|
assert.ok(!P.isInternalKey('http://s/uploads/content/clip.mp4?rev=100'));
|
|
// ...and the revision survives into the chunk key, or two revisions would share chunks.
|
|
assert.match(k, /rev=100/);
|
|
});
|