// 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 }; });