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).
149 lines
6.8 KiB
JavaScript
149 lines
6.8 KiB
JavaScript
// Offline content caching policy for the web player's service worker.
|
|
//
|
|
// WHY THIS EXISTS: content bytes were never persistently cached. The service worker deliberately
|
|
// skipped `/uploads/content/` and leaned on `Cache-Control: public, max-age=2592000, immutable`,
|
|
// letting the browser's own HTTP cache hold the media. On a desktop browser that is reasonable.
|
|
// On BrightSign it is not a guarantee: BrightSign documents persistence across reloads, app
|
|
// restarts and REBOOTS for IndexedDB, localStorage and SQLite only — the HTTP disk cache is not on
|
|
// that list, and their canonical answer for offline video is to cache the bytes explicitly. So a
|
|
// panel that lost its server could come back up with a playlist (that survives in localStorage) and
|
|
// no media to play, which is the exact failure signage cannot have.
|
|
//
|
|
// The service worker's Cache API is the mechanism this repo already trusts for exactly this: widget
|
|
// renders are cache-first precisely so a widget keeps rendering when the uplink is gone. This
|
|
// extends the same treatment to content.
|
|
//
|
|
// THE REASON CONTENT WAS SKIPPED IN THE FIRST PLACE IS REAL, and this module is what makes
|
|
// intercepting it safe: video elements issue RANGE requests when they seek, and naive caching
|
|
// breaks playback in two well-known ways.
|
|
//
|
|
// 1. Caching a 206 partial as if it were the whole file. A later full request then gets a
|
|
// fragment and the video is corrupt — worse than not caching, and it persists until eviction.
|
|
// 2. Answering a Range request with a 200 full body. Some media stacks accept it; others treat
|
|
// the mismatch as a fatal error and the video simply never plays.
|
|
//
|
|
// So: only ever STORE complete 200 responses, and when a Range request arrives, slice the stored
|
|
// body into a correct 206 ourselves. Both halves are pure functions here, tested without a browser.
|
|
//
|
|
// Dependency-free UMD: Node (require) + service worker (importScripts -> self.PlayerCachePolicy).
|
|
|
|
(function (root, factory) {
|
|
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
else root.PlayerCachePolicy = factory();
|
|
})(typeof self !== 'undefined' ? self : this, function () {
|
|
'use strict';
|
|
|
|
/*
|
|
* Is this a request for playable content we should hold for offline use?
|
|
*
|
|
* Deliberately narrow. `/uploads/content/` is the media the playlist points at; everything else
|
|
* on /uploads (thumbnails aside) is dashboard-facing and not needed by a dark player. Non-GET
|
|
* never caches — a POST is not a thing you can replay.
|
|
*/
|
|
function isCacheableContent(url, method) {
|
|
if (method && method !== 'GET') return false;
|
|
var path;
|
|
try {
|
|
path = typeof url === 'string' ? new URL(url, 'http://x').pathname : url.pathname;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
return path.indexOf('/uploads/content/') === 0;
|
|
}
|
|
|
|
/*
|
|
* Parse a Range header against a known body size.
|
|
*
|
|
* Returns {start, end} INCLUSIVE, or null when the header is absent/unparseable (caller then
|
|
* serves the whole thing), or the string 'unsatisfiable' when the range lies outside the body —
|
|
* which must become a 416, not a silent clamp, or a seeking player can loop forever asking for
|
|
* bytes that do not exist.
|
|
*
|
|
* Only single ranges are honoured. Multipart ranges are legal HTTP and essentially never used by
|
|
* media elements; answering one wrongly is worse than declining to, so those return null and fall
|
|
* through to the full body.
|
|
*/
|
|
function parseRange(rangeHeader, size) {
|
|
if (!rangeHeader || typeof rangeHeader !== 'string') return null;
|
|
if (!(size > 0)) return null;
|
|
|
|
var m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
|
if (!m) return null; // multipart, or junk: serve whole
|
|
var startRaw = m[1];
|
|
var endRaw = m[2];
|
|
if (startRaw === '' && endRaw === '') return null;
|
|
|
|
var start, end;
|
|
if (startRaw === '') {
|
|
// Suffix form: "bytes=-500" means the LAST 500 bytes, not "from 0 to 500". Getting this
|
|
// backwards hands the player the beginning of the file when it asked for the end.
|
|
var suffix = parseInt(endRaw, 10);
|
|
if (!(suffix > 0)) return 'unsatisfiable';
|
|
start = Math.max(0, size - suffix);
|
|
end = size - 1;
|
|
} else {
|
|
start = parseInt(startRaw, 10);
|
|
end = endRaw === '' ? size - 1 : parseInt(endRaw, 10);
|
|
if (isNaN(start) || isNaN(end)) return null;
|
|
// A start at or past EOF is unsatisfiable. An END past EOF is not — it is clamped, which is
|
|
// what every media player relies on when it asks for "bytes=0-" style open ranges.
|
|
if (start >= size) return 'unsatisfiable';
|
|
if (end >= size) end = size - 1;
|
|
if (end < start) return 'unsatisfiable';
|
|
}
|
|
return { start: start, end: end };
|
|
}
|
|
|
|
/*
|
|
* Headers for the 206 we build from a cached full body. Content-Range must describe the ORIGINAL
|
|
* size, not the slice length — a player uses it to learn how long the media is, and reporting the
|
|
* slice size makes a long video look like a fragment and stops seeking dead.
|
|
*/
|
|
function partialHeaders(start, end, size, contentType) {
|
|
var h = {
|
|
'Content-Range': 'bytes ' + start + '-' + end + '/' + size,
|
|
'Content-Length': String(end - start + 1),
|
|
'Accept-Ranges': 'bytes'
|
|
};
|
|
if (contentType) h['Content-Type'] = contentType;
|
|
return h;
|
|
}
|
|
|
|
/*
|
|
* Is a network response safe to STORE?
|
|
*
|
|
* A 206 must never be stored: it is a fragment, and storing it means a later full request is
|
|
* answered with part of a file. An opaque response (no-cors) has an unreadable body and a status
|
|
* of 0, so it cannot be validated or sliced. Both were the reason content caching was avoided;
|
|
* refusing them here is what makes it safe.
|
|
*/
|
|
function isStorable(response) {
|
|
if (!response) return false;
|
|
if (response.status !== 200) return false;
|
|
if (response.type === 'opaque' || response.type === 'opaqueredirect') return false;
|
|
return true;
|
|
}
|
|
|
|
/*
|
|
* Should we evict to make room? Callers pass current usage and the incoming size.
|
|
*
|
|
* The player's widget is created with a fixed storage_quota (1GB) and a full cache does not fail
|
|
* gracefully — writes throw QuotaExceededError, and the failure lands on whichever item happened
|
|
* to be next, not on the biggest one. Keeping a headroom margin means eviction happens on our
|
|
* terms, in advance, instead of as a surprise mid-playlist.
|
|
*/
|
|
function needsEviction(usedBytes, incomingBytes, quotaBytes, headroomRatio) {
|
|
if (!(quotaBytes > 0)) return false;
|
|
var headroom = typeof headroomRatio === 'number' ? headroomRatio : 0.9;
|
|
return (usedBytes + incomingBytes) > (quotaBytes * headroom);
|
|
}
|
|
|
|
return {
|
|
isCacheableContent: isCacheableContent,
|
|
parseRange: parseRange,
|
|
partialHeaders: partialHeaders,
|
|
isStorable: isStorable,
|
|
needsEviction: needsEviction
|
|
};
|
|
});
|