diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index 7b31c86..98e4399 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -237,7 +237,7 @@ class MainActivity : AppCompatActivity() { // a widget, a remote stream, or a fully-downloaded local file. A not-yet/failed download is // skipped (kept in the background) instead of blanking the screen on a loading state. playlistController.setContentReadyCheck { item -> - item.isWidget || item.isRemote || contentCache.isContentCached(item.contentId) + item.isWidget || item.isRemote || contentCache.isContentCached(item.contentId, item.contentRev) } // feat/transition-engine: full-screen GLES2 overlay that plays image/video wipes. Inserted just @@ -668,6 +668,10 @@ class MainActivity : AppCompatActivity() { val contentId = if (item.isNull("content_id")) "" else item.optString("content_id", "") if (contentId.isEmpty()) continue val filename = item.optString("filename", "content") + // Bumped when the bytes behind this id change. A cached copy at a different revision is + // a MISS: the id, the filename and the URL are all identical after a replace, so this + // is the only thing that can tell a panel its copy is out of date. + val contentRev = item.optLong("content_rev", 0L) // org.json's optString(key, null) returns the STRING "null" when the value is JSON // null (not the fallback) — so a local item with "remote_url": null was being // misclassified as a remote stream, ack'd "ready", and NEVER downloaded, stranding @@ -687,7 +691,7 @@ class MainActivity : AppCompatActivity() { // defers when the socket is down (watchdog owns recovery), respects backoff, and // downloads at most once. It acks ready/failed itself (deduped via onAck). if (contentChanged) downloadCoordinator.resetBackoff(contentId) // #170: retry now, don't wait out a stale backoff - downloadCoordinator.ensure(contentId, filename) + downloadCoordinator.ensure(contentId, filename, contentRev) } // Start/resume playback immediately — do NOT wait on downloads (they're async now). @@ -962,7 +966,7 @@ class MainActivity : AppCompatActivity() { val file = contentCache.getCachedFile(item.contentId) if (file == null) { Log.i("MainActivity", "Content not ready at play time (${item.filename}) — keeping screen, advancing (bg download continues)") - downloadCoordinator.ensure(item.contentId, item.filename) // ensure it's being fetched (single-flight) + downloadCoordinator.ensure(item.contentId, item.filename, item.contentRev) // ensure it's being fetched (single-flight) handler.post { playlistController.next() } return } diff --git a/android/app/src/main/java/com/remotedisplay/player/data/ContentCache.kt b/android/app/src/main/java/com/remotedisplay/player/data/ContentCache.kt index d101d45..0668bdd 100644 --- a/android/app/src/main/java/com/remotedisplay/player/data/ContentCache.kt +++ b/android/app/src/main/java/com/remotedisplay/player/data/ContentCache.kt @@ -66,7 +66,7 @@ class ContentCache internal constructor( // cross-matching. `contains` rather than `endsWith` for the temp check because the resume // validator sidecar is "..part.tag" — it does not END with ".part", and returning // THAT as the cached asset would hand the player a short ETag file to play. - val files = cacheDir.listFiles { _, name -> name.startsWith("$contentId.") && !name.contains(PART_SUFFIX) } + val files = cacheDir.listFiles { _, name -> name.startsWith("$contentId.") && !name.contains(PART_SUFFIX) && !name.endsWith(REV_SUFFIX) } val hit = files?.firstOrNull()?.takeIf { it.exists() && it.length() > 0 } com.remotedisplay.player.util.DebugLog.v("ContentCache", "getCachedFile($contentId): dir=${cacheDir.absolutePath} listFiles=${files?.size ?: -1} hit=${hit?.name}") return hit @@ -76,11 +76,35 @@ class ContentCache internal constructor( return getCachedFile(contentId) != null } + /** + * Cached AND holding the revision the playlist is asking for. + * + * The dashboard can replace an asset's bytes under a stable content id, which is the one way a + * cached copy can be permanently wrong: the id does not change, the filename does not change, + * and nothing about a plain existence check can tell. A panel would keep playing last month's + * video until somebody deleted and re-added the item. Comparing the revision is what makes + * "cached for offline" compatible with "and it still updates". + * + * A revision of 0 means the server never sent one (an older build): fall back to existence, so + * an upgrade does not re-download the entire playlist over the link least able to afford it. + */ + fun isContentCached(contentId: String, rev: Long): Boolean { + val file = getCachedFile(contentId) ?: return false + if (rev <= 0L) return true + return readRev(file) == rev + } + + private fun revFile(file: File) = File(file.absolutePath + REV_SUFFIX) + + private fun readRev(file: File): Long = + try { revFile(file).takeIf { it.exists() }?.readText()?.trim()?.toLongOrNull() ?: 0L } + catch (e: Exception) { 0L } + /** * Fetch (or continue fetching) [contentId]. Safe to call repeatedly: each call transfers what * the link allows and leaves the rest for the next one. */ - fun fetch(serverUrl: String, contentId: String, filename: String): Result { + fun fetch(serverUrl: String, contentId: String, filename: String, rev: Long = 0L): Result { val ext = filename.substringAfterLast('.', "mp4") val finalFile = File(cacheDir, "$contentId.$ext") val partFile = File(cacheDir, "$contentId.$ext$PART_SUFFIX") @@ -94,7 +118,11 @@ class ContentCache internal constructor( if (resumeFrom == 0L) { partFile.delete(); tagFile.delete() } try { - val builder = Request.Builder().url("$serverUrl/api/content/$contentId/file") + // The revision rides in the URL as well as in the sidecar: an intermediary caching + // /api/content//file would otherwise happily serve the superseded bytes to every + // panel behind it, and no amount of client-side bookkeeping could tell. + val url = "$serverUrl/api/content/$contentId/file" + (if (rev > 0L) "?rev=$rev" else "") + val builder = Request.Builder().url(url) if (resumeFrom > 0) { builder.header("Range", "bytes=$resumeFrom-") builder.header("If-Range", validator!!) @@ -186,6 +214,12 @@ class ContentCache internal constructor( return Result.Failed } tagFile.delete() + // Record WHICH revision these bytes are, so a later replace is detectable. Written + // after the rename: a revision marker next to a file that is not there yet would + // claim a cached asset that does not exist. + try { + if (rev > 0L) revFile(finalFile).writeText(rev.toString()) else revFile(finalFile).delete() + } catch (e: Exception) { /* the file is still usable; worst case it re-downloads */ } Log.i("ContentCache", "Downloaded: $filename -> ${finalFile.absolutePath} ($onDisk bytes)") return Result.Done(finalFile) } @@ -202,8 +236,8 @@ class ContentCache internal constructor( } /** Complete-or-nothing wrapper: returns the cached file only when the asset is whole. */ - fun downloadContent(serverUrl: String, contentId: String, filename: String): File? = - (fetch(serverUrl, contentId, filename) as? Result.Done)?.file + fun downloadContent(serverUrl: String, contentId: String, filename: String, rev: Long = 0L): File? = + (fetch(serverUrl, contentId, filename, rev) as? Result.Done)?.file fun deleteContent(contentId: String) { // Exact-prefix (with the dot) so we don't delete a different id's file — and this also @@ -248,6 +282,7 @@ class ContentCache internal constructor( companion object { private const val PART_SUFFIX = ".part" private const val TAG_SUFFIX = ".tag" + private const val REV_SUFFIX = ".rev" /** * "bytes -/" -> (start, total). Null for anything else, including the diff --git a/android/app/src/main/java/com/remotedisplay/player/data/DownloadCoordinator.kt b/android/app/src/main/java/com/remotedisplay/player/data/DownloadCoordinator.kt index b457731..2864368 100644 --- a/android/app/src/main/java/com/remotedisplay/player/data/DownloadCoordinator.kt +++ b/android/app/src/main/java/com/remotedisplay/player/data/DownloadCoordinator.kt @@ -49,16 +49,16 @@ class DownloadCoordinator( * sweep — the 60s refresh and every post-reconnect re-register included. Idempotent and * non-blocking: it enqueues at most ONE download per contentId and returns immediately. */ - fun ensure(contentId: String, filename: String) { + fun ensure(contentId: String, filename: String, rev: Long = 0L) { if (contentId.isEmpty()) return - if (cache.isContentCached(contentId)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): SEED-A cached -> ack ready"); onAck(contentId, "ready"); return } // already have it — re-ack (SEED-A) + if (cache.isContentCached(contentId, rev)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): SEED-A cached -> ack ready"); onAck(contentId, "ready"); return } // already have it — re-ack (SEED-A) // Socket down => the WATCHDOG owns recovery; don't hammer downloads over a dead connection. if (!socketAlive()) { DebugLog.v("DownloadCoordinator", "ensure($contentId): socket not alive -> skip"); return } if (now() < (nextAttemptAt[contentId] ?: 0L)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): in backoff until ${nextAttemptAt[contentId]} -> skip"); return } // in failure backoff — don't storm if (!inFlight.add(contentId)) { DebugLog.v("DownloadCoordinator", "ensure($contentId): already inFlight -> skip"); return } // single-flight: already downloading DebugLog.v("DownloadCoordinator", "ensure($contentId): dispatching download '$filename'") try { - executor.execute { runDownload(contentId, filename) } + executor.execute { runDownload(contentId, filename, rev) } } catch (e: Throwable) { inFlight.remove(contentId) // executor rejected (shut down) — don't leak the guard } @@ -76,12 +76,12 @@ class DownloadCoordinator( * inFlight is held across the whole chain, so this is still exactly one writer per `.part` and * a concurrent sweep still finds the item busy rather than starting a duplicate. */ - private fun runDownload(contentId: String, filename: String) { + private fun runDownload(contentId: String, filename: String, rev: Long) { try { var link = 0 while (link < MAX_RESUME_CHAIN) { link++ - val result = cache.fetch(serverUrl(), contentId, filename) + val result = cache.fetch(serverUrl(), contentId, filename, rev) when (result) { is ContentCache.Result.Done -> { attempts.remove(contentId); nextAttemptAt.remove(contentId) diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt index a940d65..85f9885 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt @@ -22,6 +22,10 @@ data class PlaylistItem( // Changes whenever the widget is edited. Carried into the render URL so an edited widget gets // a URL the player has not seen, which is what defeats the deliberate same-URL WebView reuse. val widgetRev: Long = 0L, + // Bumped by the server when an asset's BYTES change under a stable content id (the dashboard's + // "replace file"). The cache is keyed on it: without one, a replaced asset would keep playing + // the copy already on disk forever, because nothing about the id or the URL would differ. + val contentRev: Long = 0L, val widgetType: String? = null, val schedules: List = emptyList(), // feat/transition-engine: the resolved GL transition this item plays INTO (null = hard cut). @@ -173,6 +177,7 @@ class PlaylistController( muted = obj.optInt("muted", 0) == 1, widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null }, widgetRev = obj.optLong("widget_rev", 0L), + contentRev = obj.optLong("content_rev", 0L), widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null }, schedules = parseSchedules(obj.optJSONArray("schedules")), transition = Transitions.parse(obj.optJSONObject("transition")) diff --git a/android/app/src/test/java/com/remotedisplay/player/data/ContentDownloadTest.kt b/android/app/src/test/java/com/remotedisplay/player/data/ContentDownloadTest.kt index d2375f2..3e26a9b 100644 --- a/android/app/src/test/java/com/remotedisplay/player/data/ContentDownloadTest.kt +++ b/android/app/src/test/java/com/remotedisplay/player/data/ContentDownloadTest.kt @@ -291,6 +291,45 @@ class ContentDownloadTest { assertNull("and nothing incomplete is ever served as cached", cache.getCachedFile("noval")) } + + // ---- the UPDATE half: caching for offline must not make a screen permanently wrong ---- + @Test fun `an asset replaced under a stable id is a cache MISS at the new revision`() { + // The trap that offline caching creates. PUT /api/content/:id/replace changes the bytes and + // nothing else — same id, same filename, same URL path — so a plain "do I have this file?" + // check says yes forever and the panel keeps playing last month's video. + val v1 = ByteArray(50) { 'a'.code.toByte() } + val url = serveFlaky({ v1 }, bytesPerCall = 500, etagOf = { "\"v1\"" }) + + assertTrue(cache.fetch(url, "swap", "clip.bin", rev = 100L) is ContentCache.Result.Done) + assertTrue("cached at the revision we asked for", cache.isContentCached("swap", 100L)) + assertTrue("...and NOT at a newer one", !cache.isContentCached("swap", 200L)) + } + + @Test fun `a player with no revision from the server still uses whatever it has`() { + // Older servers send no content_rev. Treating that as a permanent miss would re-download the + // entire playlist on every sweep, over the link least able to afford it. + val url = serveOnce { it.writeHttp(4, "abcd".toByteArray()) } + assertNotNull(cache.downloadContent(url, "norev", "clip.bin")) + assertTrue(cache.isContentCached("norev", 0L)) + } + + @Test fun `the revision marker is never served as the cached asset`() { + // "..rev" starts with the id, so a prefix match would hand the player a few bytes + // of ASCII digits to decode as a video. + java.io.File(dir, "marker.bin.rev").writeText("12345") + assertNull(cache.getCachedFile("marker")) + } + + @Test fun `the request carries the revision, so an intermediary cannot serve the old bytes`() { + seen.clear() + val url = serveFlaky({ ByteArray(20) }, bytesPerCall = 500, etagOf = { "\"v1\"" }) + cache.fetch(url, "cdn", "clip.bin", rev = 777L) + // The request line is not captured by the header sniffer, so assert via the effect: a + // revisioned fetch completes and records that revision. + assertTrue(cache.isContentCached("cdn", 777L)) + assertTrue(!cache.isContentCached("cdn", 778L)) + } + // ---- prefix cross-match guard: an id that prefixes another must not match ---- @Test fun `getCachedFile does not cross-match an id that is a prefix of another`() { serveOnce { it.writeHttp(3, "abc".toByteArray()) }.let { url -> diff --git a/docs/player-parity.md b/docs/player-parity.md index a36fbd5..8df58fa 100644 --- a/docs/player-parity.md +++ b/docs/player-parity.md @@ -75,7 +75,7 @@ privilege model exists on those platforms — so the column is collapsed. |---|---|---|---|---| | `sync.clock` | ✅ | ✅ | ✅ | ✅ | | `sync.native` | ❌ no native protocol | ❌ | ❌ | ⚠️ SyncManager, BOS 8.2.10+; multicast so all members must share one L2 network | -| `offline.cache` | ✅ content downloaded to disk | ✅ service worker | ⚠️ **playlist payload only** — `st_payload_cache` replays the last renderable payload, but there is no service worker, so media still needs the network | ✅ service worker + 1GB storage quota | +| `offline.cache` | ✅ content downloaded to disk, **resumable** (Range + If-Range), revision-keyed | ✅ service worker, **resumable chunked prefetch**, revision-keyed | ✅ **media cached to `wgt-private`** (`js/media-cache.js`), resumable, revision-keyed — declared at runtime, since a build with no writable private storage must not claim it | ✅ inherits the web player's service worker | --- @@ -86,9 +86,11 @@ Ordered by how visible the failure is to an operator. 1. **Tizen `audio.volume` — dead control.** `set_volume` has no handler in `tizen/js/app.js`; the only volume path is the on-device `KEYCODE_VOLUME_*` keys. The dashboard slider silently does nothing. Either implement the handler or let the capability hide the control. -2. **Tizen `offline.cache` is partial.** The playlist survives a reboot; the media does not. A - Tizen panel that loses its uplink keeps its schedule and cannot play it. This is the largest - functional gap in the table. +2. ~~**Tizen `offline.cache` is partial.**~~ **Closed.** `tizen/js/media-cache.js` caches the + media itself to `wgt-private` — resumable, so a panel on a bad link accumulates an asset + across attempts instead of restarting from zero, and revision-keyed, so a replaced asset is + still a miss. The capability is declared at runtime rather than assumed: a build that cannot + write to private storage keeps quiet about it. 3. **BrightSign `remote.screenshot` needs primary storage.** Reachable today only via the canvas fallback, which cannot read the video plane, so screenshots show everything except the video. Resolves itself when a card or SSD is fitted. @@ -114,5 +116,6 @@ these are wrong for the existing fleet until each player ships its declaration: - **`tizen` claims `audio.volume`** — no handler exists (gap 1 above). Should be removed. - **`tizen` omits `remote.screenshot` and `remote.stream`** — both are implemented (`captureAndSend`, `startStreaming`). Should be added. -- **`tizen` claims `offline.cache`** — true only for the playlist payload, not media. Either keep - it with the partial meaning documented, or split the capability. +- **`tizen` declares `offline.cache` itself now** — the server baseline still omits it, which is + correct: a fielded panel that has not been updated genuinely cannot hold media, and the + baseline describes what an un-updated one can do. diff --git a/server/db/database.js b/server/db/database.js index 7da00e4..da1be8a 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -92,6 +92,11 @@ const migrations = [ // pre-v4 clients that send no identity block). No logic is built on these yet. 'ALTER TABLE devices ADD COLUMN client_type TEXT', 'ALTER TABLE devices ADD COLUMN client_version TEXT', + // Content revision. SQLite cannot ADD COLUMN with a non-constant default, so this lands as 0 and + // is backfilled from created_at below — a row that has never been replaced is at its birth + // revision, which is exactly right. + 'ALTER TABLE content ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0', + 'UPDATE content SET updated_at = created_at WHERE updated_at = 0', 'ALTER TABLE devices ADD COLUMN platform TEXT', 'ALTER TABLE devices ADD COLUMN contract_version TEXT', // Exit-signal contract v1 — manner-of-death annotation on Offline (additive; NEVER alters offline diff --git a/server/db/schema.sql b/server/db/schema.sql index ab886bb..1fb239b 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -107,7 +107,12 @@ CREATE TABLE IF NOT EXISTS content ( width INTEGER, height INTEGER, remote_url TEXT, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + -- Bumped whenever the BYTES change (PUT /:id/replace). Players cache media by id, and an id + -- whose bytes changed underneath them is the one way a cached asset can be stale forever: the + -- URL is identical, so every offline cache we have would keep serving the old file. This is + -- what makes the URL differ exactly when the content differs. + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); CREATE TABLE IF NOT EXISTS assignments ( diff --git a/server/lib/player-cache-policy.js b/server/lib/player-cache-policy.js index ae247e3..ade4d70 100644 --- a/server/lib/player-cache-policy.js +++ b/server/lib/player-cache-policy.js @@ -138,11 +138,128 @@ return (usedBytes + incomingBytes) > (quotaBytes * headroom); } + /* + * --------------------------------------------------------------------------------------------- + * RESUMABLE TRANSFER + * + * Storing a complete 200 is only safe once you can GET a complete 200. A single fetch() of a + * 200MB asset over a one-bar link does not finish, and every retry starts again from nothing — + * which is how a site ends up with an empty cache and a screen showing the waiting state. The + * Android player hit exactly this and was fixed by resuming; these helpers are the same idea for + * the browser-based players, where there is no file handle to append to and progress has to be + * accumulated as separate cache entries instead. + * --------------------------------------------------------------------------------------------- + */ + + // 4MB. Small enough that a marginal link finishes one inside a stall timeout, large enough that a + // 200MB asset is 50 requests rather than 1600 — per-request overhead on a slow uplink is not free. + var CHUNK_BYTES = 4 * 1024 * 1024; + + /* + * The byte ranges an asset of [total] bytes decomposes into. Inclusive ends, because that is what + * both the Range header and Content-Range use, and converting between the two conventions is + * where off-by-one corruption comes from. + */ + function chunkRanges(total, chunkSize) { + var size = chunkSize > 0 ? chunkSize : CHUNK_BYTES; + var out = []; + if (!(total > 0)) return out; + for (var start = 0; start < total; start += size) { + out.push({ start: start, end: Math.min(start + size, total) - 1 }); + } + return out; + } + + /* + * "bytes -/" -> {start, end, total}. Null for anything we cannot verify a + * total from, including the "*" form: without the total there is nothing to decide completeness + * against, and guessing produces a cache entry that is confidently short. + */ + function parseContentRange(header) { + if (!header || typeof header !== 'string') return null; + var m = /^\s*bytes\s+(\d+)-(\d+)\/(\d+)\s*$/.exec(header); + if (!m) return null; + return { start: Number(m[1]), end: Number(m[2]), total: Number(m[3]) }; + } + + /* + * The validator to send back as If-Range on the next chunk. + * + * Without one, a resume splices the tail of a NEW asset onto the head of an old one and produces + * a file of exactly the right length that is nonetheless corrupt — it would pass every + * completeness check and be played. ETag first (it changes on any byte change); Last-Modified is + * the weaker fallback, and no validator at all means the transfer is not resumable. + */ + function validatorOf(headers) { + if (!headers || typeof headers.get !== 'function') return null; + return headers.get('ETag') || headers.get('Last-Modified') || null; + } + + /* + * Is a stored partial still usable, given what the server just said? + * + * A 206 at the offset we asked for, with a matching validator and the same total, continues. A + * 200 means the server declined the range (changed asset, or no range support) and the transfer + * restarts. Anything else is discarded rather than reasoned about. + */ + function resumeVerdict(status, contentRange, expectStart, expectTotal, validatorSent, validatorNow) { + if (status === 200) return 'restart'; + if (status === 416) return 'discard'; + if (status !== 206) return 'discard'; + var cr = parseContentRange(contentRange); + if (!cr) return 'discard'; + if (cr.start !== expectStart) return 'discard'; + if (expectTotal > 0 && cr.total !== expectTotal) return 'discard'; + // A server that honours If-Range should not answer 206 with a different validator, but the + // check costs nothing and the failure it prevents is a silent splice. + if (validatorSent && validatorNow && validatorSent !== validatorNow) return 'discard'; + return 'continue'; + } + + /* + * The asset a URL refers to, ignoring the revision. + * + * Cache entries are keyed with the revision in the query string so that replacing an asset is a + * MISS everywhere rather than a copy nobody can invalidate. The flip side is that the superseded + * entries would sit there until the quota evicted them — on a 1GB panel quota, a few replaced + * videos is the whole budget. This is what lets a store sweep its own predecessors. + */ + function assetKey(url) { + try { + var u = typeof url === 'string' ? new URL(url, 'http://x') : url; + return u.origin + u.pathname; + } catch (e) { + return String(url).split('?')[0]; + } + } + + /* + * Is [url] a chunk/bookkeeping entry rather than a playable asset? Those must never be returned + * to a media element, and must never be mistaken for "the asset is cached". + */ + function isInternalKey(url) { + return String(url).indexOf(INTERNAL_MARK) !== -1; + } + + var INTERNAL_MARK = '__st_part'; + + function chunkKey(url, start) { + return url + (url.indexOf('?') === -1 ? '?' : '&') + INTERNAL_MARK + '=' + start; + } + return { isCacheableContent: isCacheableContent, parseRange: parseRange, partialHeaders: partialHeaders, isStorable: isStorable, - needsEviction: needsEviction + needsEviction: needsEviction, + CHUNK_BYTES: CHUNK_BYTES, + chunkRanges: chunkRanges, + parseContentRange: parseContentRange, + validatorOf: validatorOf, + resumeVerdict: resumeVerdict, + assetKey: assetKey, + isInternalKey: isInternalKey, + chunkKey: chunkKey }; }); diff --git a/server/player/index.html b/server/player/index.html index 251edf2..b4c5503 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -403,6 +403,45 @@ // Cache the layout alongside the playlist so a cold start renders the correct // zone layout on the FIRST pass, instead of rendering fullscreen and only // switching to zones once the server payload arrives. + /* + * Where an item's bytes live. + * + * The revision is in the URL on purpose. Media is cached for offline playback keyed by URL, and + * PUT /api/content/:id/replace changes an asset's BYTES without changing its id — so without a + * revision a replaced asset would keep playing the old copy on every panel that already held + * it, with no way for the player to find out. The revision changes exactly when the bytes do, + * which makes a replace a cache miss and an untouched asset a hit. + */ + function mediaUrl(it) { + if (!it) return ''; + if (it.remote_url) return it.remote_url; + const base = `${config.serverUrl}/uploads/content/${it.filepath}`; + return it.content_rev ? `${base}?rev=${encodeURIComponent(it.content_rev)}` : base; + } + + /* + * Ask the service worker to hold this playlist's media for offline use. + * + * The worker stores whatever a playback fetch happens to complete, which on a healthy link is + * everything and on a marginal one is nothing: a large asset never finishes in one go, so the + * panel is left with an empty cache exactly where it needs a full one. Handing it the list lets + * it fetch in resumable chunks, on its own schedule, instead of racing the video that is + * currently playing for the same scarce bandwidth. + * + * Best-effort by construction: no service worker (an insecure origin, an older BrightSign + * build) simply means no prefetch, and playback is unchanged. + */ + function requestOfflineCache(items) { + try { + const sw = navigator.serviceWorker && navigator.serviceWorker.controller; + if (!sw) return; + const urls = (items || []) + .filter((it) => it && it.filepath && !it.remote_url) + .map((it) => mediaUrl(it)); + if (urls.length) sw.postMessage({ type: 'st-cache-playlist', urls }); + } catch (e) { /* never let caching break playback */ } + } + const LAYOUT_CACHE_KEY = 'rd_layout_cache' + SCREEN_SUFFIX; function saveLayoutCache(l) { try { localStorage.setItem(LAYOUT_CACHE_KEY, JSON.stringify(l || null)); } catch {} @@ -2063,7 +2102,7 @@ if (!item) return; const isVid = item.mime_type && item.mime_type.indexOf('video/') === 0 && item.mime_type !== 'video/youtube'; if (!isVid) { groupPreloadIdx = idx; groupPreloadEl = null; return; } // mark handled, nothing to warm - const url = item.remote_url || (config.serverUrl + '/uploads/content/' + item.filepath); + const url = mediaUrl(item); try { if (groupPreloadEl) { try { groupPreloadEl.remove(); } catch (e) {} } const v = document.createElement('video'); @@ -2365,6 +2404,7 @@ playlist = newItems; imgPreloadCache = {}; // playlist changed — drop stale one-ahead preloads (feat/player-image-preload) savePlaylistCache(playlist); + requestOfflineCache(playlist); // #157: a fresh structural update supersedes any pending deferred rotation; the branches // below re-arm it only if the current item was removed while live in solo playback. deferredRotation = false; @@ -2875,7 +2915,7 @@ // feat/player-image-preload: warm the NEXT scheduled image (decoded) during the current dwell, // and swap decode-gated so a slow panel never shows a blank/half-painted frame mid-decode. function imgSrcFor(it) { - return it.remote_url || `${config.serverUrl}/uploads/content/${it.filepath}`; + return mediaUrl(it); } // A remote image gets a proxy fallback (/media/proxy/:contentId) so it can be textured for a // transition when its origin sends no CORS header. Local /uploads content is already CORS-enabled, @@ -3101,7 +3141,7 @@ // + the wipe), and a playlist push mid-window must not let a stale clip tear down the newer content that // already took over. function renderVideoBuffered(item) { - const src = item.remote_url || `${config.serverUrl}/uploads/content/${item.filepath}`; + const src = mediaUrl(item); const from = currentTexturableFrame(); // capture the outgoing frame NOW, before any teardown const t = item.transition; const mySeq = renderSeq; @@ -3301,7 +3341,7 @@ const isImage = item.mime_type?.startsWith('image/'); const remoteUrl = item.remote_url; const serverUrl = config.serverUrl; - const src = remoteUrl || `${serverUrl}/uploads/content/${item.filepath}`; + const src = mediaUrl(item); if (layout && layout.zones && layout.zones.length > 1 && !wallConfig) { renderZones(container, item); @@ -3489,7 +3529,7 @@ const isYoutube = a.mime_type === 'video/youtube'; const isVideo = !isYoutube && a.mime_type?.startsWith('video/'); - const src = a.remote_url || `${config.serverUrl}/uploads/content/${a.filepath}`; + const src = mediaUrl(a); const dur = (a.duration_sec || 10) * 1000; // Render based on what the ASSIGNMENT is (widget_id), not the zone's type: diff --git a/server/player/sw.js b/server/player/sw.js index 188f61f..080f5dc 100644 --- a/server/player/sw.js +++ b/server/player/sw.js @@ -1,9 +1,12 @@ +// v21: chunked resumable content prefetch + revision-keyed media URLs — index.html gained +// mediaUrl()/requestOfflineCache() and this worker gained the message handler, so an old shell +// cache would pair a new worker with a player that never posts it a playlist. // v20: rc3 changed the fetch strategy AND the shipped player assets. The activate handler deletes // every cache whose name does not match, so leaving this at v19 kept the previous shell cache alive // — a player then ran a new index.html against a stale st-bridge.js and threw on every heartbeat. // Bump whenever a shipped /player asset changes shape; content lives in its own cache, so this // costs a small re-download and never re-fetches the playlist. -const CACHE_NAME = 'rd-player-v20'; +const CACHE_NAME = 'rd-player-v21'; // Content lives in its own cache so the shell can be re-versioned (the activate handler deletes // every cache that is not CACHE_NAME) WITHOUT throwing away megabytes of media that are still // perfectly valid. Rolling the shell used to mean a player re-downloaded its entire playlist. @@ -108,6 +111,159 @@ self.addEventListener('fetch', (event) => { // Returning without event.respondWith lets the browser handle it natively. }); +/* + * PREFETCH — the half that makes the cache fill on a link that cannot carry a whole asset. + * + * handleContent below stores an asset when a single fetch() of it happens to succeed. On a good + * link that is everything. On a marginal one (the one-bar 5G site this came from) a 200MB fetch + * never completes, every retry starts from nothing, and the cache stays empty — so the panel has + * nothing to fall back on the moment the uplink drops. The Android player had the identical bug and + * was fixed by resuming; a service worker has no file handle to append to, so progress accumulates + * as separate cache entries and is assembled once every piece is present. + * + * Driven by the player rather than by playback: it posts its current media URLs after each playlist + * update, and this works through them ONE AT A TIME. Deliberately not started from the fetch + * handler — that would put the accumulator in competition with the playing video for the same + * scarce bandwidth, which is worse than either alone. + */ +const prefetching = new Set(); +let prefetchChain = Promise.resolve(); + +self.addEventListener('message', (event) => { + const data = event.data; + if (!data || data.type !== 'st-cache-playlist' || !Array.isArray(data.urls)) return; + for (const url of data.urls) { + if (typeof url !== 'string' || !POLICY || !POLICY.isCacheableContent(url, 'GET')) continue; + if (prefetching.has(url)) continue; // single-flight: a 60s playlist sweep must not restart it + prefetching.add(url); + // Serialised. Three concurrent chunk streams on a link that cannot finish one is how you get + // three unfinished downloads instead of one finished one. + prefetchChain = prefetchChain + .then(() => ensureCached(url)) + .catch(() => {}) + .then(() => { prefetching.delete(url); }); + } +}); + +/* + * Fetch [url] into the content cache, one chunk at a time, resuming across calls. + * + * Returns when the asset is whole OR when a chunk fails — the caller does not retry, because the + * player will ask again on its next playlist sweep and whatever landed is still on disk. That is + * the entire point: attempts accumulate instead of restarting. + */ +async function ensureCached(url) { + const cache = await caches.open(CONTENT_CACHE); + if (await cache.match(url, { ignoreVary: true })) { await sweepOldRevisions(cache, url); return; } + + const metaKey = POLICY.chunkKey(url, 'meta'); + let meta = null; + const metaHit = await cache.match(metaKey); + if (metaHit) { try { meta = await metaHit.json(); } catch (e) { meta = null; } } + + // Learn the size and validator from the first ranged request, or trust what a previous call + // already learned. A server that answers 200 here has no range support: fall back to storing it + // whole, which is the pre-existing behaviour and is correct, just not resumable. + if (!meta) { + const probe = await fetch(new Request(url, { headers: { Range: 'bytes=0-' + (POLICY.CHUNK_BYTES - 1) } })); + if (probe.status === 200) { + if (POLICY.isStorable(probe)) await storeContent(cache, new Request(url), probe); + return; + } + const cr = POLICY.parseContentRange(probe.headers.get('Content-Range')); + if (probe.status !== 206 || !cr || !(cr.total > 0)) return; + meta = { total: cr.total, validator: POLICY.validatorOf(probe.headers), type: probe.headers.get('Content-Type') || '' }; + if (!meta.validator) { + // Nothing to detect a changed asset with, so a resume would be a guess. Store this one whole + // if it happens to fit in a chunk; otherwise leave it to the fetch path. + if (cr.total <= POLICY.CHUNK_BYTES) { + await cache.put(new Request(url), new Response(await probe.blob(), { + status: 200, headers: { 'Content-Type': meta.type, 'Content-Length': String(cr.total) } + })); + await sweepOldRevisions(cache, url); + } + return; + } + await cache.put(metaKey, new Response(JSON.stringify(meta), { headers: { 'Content-Type': 'application/json' } })); + await cache.put(POLICY.chunkKey(url, 0), new Response(await probe.blob())); + } + + const ranges = POLICY.chunkRanges(meta.total, POLICY.CHUNK_BYTES); + for (const r of ranges) { + const key = POLICY.chunkKey(url, r.start); + if (await cache.match(key)) continue; // already have it — this is the resume + + let response; + try { + response = await fetch(new Request(url, { + headers: { Range: 'bytes=' + r.start + '-' + r.end, 'If-Range': meta.validator } + })); + } catch (e) { + return; // link died. Everything stored so far stays; the next sweep continues from here. + } + + const verdict = POLICY.resumeVerdict( + response.status, response.headers.get('Content-Range'), + r.start, meta.total, meta.validator, POLICY.validatorOf(response.headers) + ); + if (verdict !== 'continue') { + // 'restart' means the asset changed under us (If-Range declined) and 'discard' means we + // cannot trust what came back. Either way the accumulated chunks describe a file that no + // longer exists, and appending to them is the corruption this check exists to prevent. + await dropChunks(cache, url); + return; + } + await cache.put(key, new Response(await response.blob())); + } + + // Every piece present: assemble once, atomically as far as the player is concerned — the full + // entry appears only when it is genuinely whole, so a cache hit can never be a fragment. + const parts = []; + for (const r of ranges) { + const hit = await cache.match(POLICY.chunkKey(url, r.start)); + if (!hit) return; // evicted mid-assembly; try again later + parts.push(await hit.blob()); + } + const whole = new Blob(parts, { type: meta.type || 'application/octet-stream' }); + if (whole.size !== meta.total) { await dropChunks(cache, url); return; } + await cache.put(new Request(url), new Response(whole, { + status: 200, + headers: { 'Content-Type': meta.type || 'application/octet-stream', 'Content-Length': String(meta.total) } + })); + await dropChunks(cache, url); + await sweepOldRevisions(cache, url); +} + +async function dropChunks(cache, url) { + for (const key of await cache.keys()) { + if (POLICY.isInternalKey(key.url) && POLICY.assetKey(key.url) === POLICY.assetKey(url) && + key.url.indexOf(revOf(url)) !== -1) { + await cache.delete(key); + } + } +} + +/* + * Delete entries for the SAME asset at a DIFFERENT revision. + * + * Replacing an asset changes the revision in its URL, which is what makes the new bytes a cache + * miss everywhere — but it also means the superseded copy would sit there until the quota evicted + * it. On a panel with a 1GB widget quota, a handful of replaced videos is the entire budget. + */ +async function sweepOldRevisions(cache, url) { + const asset = POLICY.assetKey(url); + const rev = revOf(url); + for (const key of await cache.keys()) { + if (POLICY.assetKey(key.url) !== asset) continue; + if (revOf(key.url) === rev) continue; + await cache.delete(key); + } +} + +function revOf(url) { + try { return new URL(url, self.location.href).searchParams.get('rev') || ''; } catch (e) { return ''; } +} + async function handleContent(request) { const range = request.headers.get('range'); const cache = await caches.open(CONTENT_CACHE); diff --git a/server/routes/content.js b/server/routes/content.js index 7834182..1b4e95c 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -530,9 +530,30 @@ router.put('/:id/replace', upload.single('file'), async (req, res) => { console.warn('Thumbnail generation failed:', e.message); } - db.prepare(`UPDATE content SET filepath = ?, mime_type = ?, file_size = ?, thumbnail_path = ?, width = ?, height = ? WHERE id = ?`) + // Bump the revision: this is the ONLY operation in the product that changes an asset's bytes + // without changing its id, so it is the only thing that can make a player's cached copy wrong. + // Players key their media cache on the revision, so this is what evicts it. + // + // strftime seconds can collide with the previous value if a replace lands inside the same second + // as the upload (a small file, a scripted replace) — and a revision that does not change is a + // cache that never updates. MAX(now, previous + 1) guarantees it moves. + db.prepare(`UPDATE content + SET filepath = ?, mime_type = ?, file_size = ?, thumbnail_path = ?, width = ?, height = ?, + updated_at = MAX(CAST(strftime('%s','now') AS INTEGER), COALESCE(NULLIF(updated_at, 0), created_at) + 1) + WHERE id = ?`) .run(filepath, mime, req.file.size, thumbnailPath, width, height, req.params.id); + // ...and tell the panels, which the old code did not. Without this the new bytes reached a screen + // only when something else happened to trigger a playlist refresh — an operator replacing a video + // watched the dashboard update and the screen keep playing the old one. + const affected = db.prepare(` + SELECT DISTINCT d.id as device_id FROM devices d + JOIN playlists p ON d.playlist_id = p.id + JOIN playlist_items pi ON pi.playlist_id = p.id + WHERE pi.content_id = ? + `).all(req.params.id).map((r) => r.device_id); + pushContentUpdates(req, affected); + res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id)); }); diff --git a/server/test/content-revision.test.js b/server/test/content-revision.test.js new file mode 100644 index 0000000..2adb850 --- /dev/null +++ b/server/test/content-revision.test.js @@ -0,0 +1,130 @@ +'use strict'; + +// Caching media for offline playback creates a way for a screen to be permanently WRONG. +// +// PUT /api/content/:id/replace is the only operation that changes an asset's bytes without changing +// its id. Every player caches media keyed 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. And because a +// replace writes a NEW randomly-named file and unlinks the old one, the filepath baked into the +// published playlist snapshot pointed at a deleted file, so web and BrightSign panels 404'd on the +// item until somebody republished the playlist. +// +// The fix is one idea: the playlist snapshot captures the ARRANGEMENT, not the bytes, so the byte +// facts are refreshed at send time — exactly as widget revs already were. + +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +process.env.DATA_DIR = path.join(os.tmpdir(), 'st-rev-' + crypto.randomBytes(4).toString('hex')); +process.env.SELF_HOSTED = 'true'; +process.env.NODE_ENV = 'test'; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const { Server } = require('socket.io'); +const { db } = require('../db/database'); +const setupDeviceSocket = require('../ws/deviceSocket'); + +// buildPlaylistPayload is only attached to the module once the socket layer is set up, so the +// payload is exercised exactly as the real send path builds it rather than through a copy. +let httpServer, io, buildPlaylistPayload; + +const DEV = 'dev-rev'; +const PL = 'pl-rev'; +const CID = 'content-rev'; + +function snapshot(items) { + db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?').run(JSON.stringify(items), PL); +} +function itemFor(deviceId) { + return buildPlaylistPayload(deviceId).assignments[0]; +} + +before(() => { + httpServer = http.createServer(); io = new Server(httpServer); setupDeviceSocket(io); + buildPlaylistPayload = setupDeviceSocket.buildPlaylistPayload; + + db.prepare('INSERT INTO users (id, email) VALUES (?,?)').run('u', 'rev@example.test'); + db.prepare('INSERT INTO content (id, filename, mime_type, file_size, filepath, created_at, updated_at) VALUES (?,?,?,?,?,?,?)') + .run(CID, 'clip.mp4', 'video/mp4', 100, 'aaaa.mp4', 1000, 1000); + db.prepare('INSERT INTO playlists (id, user_id, name) VALUES (?,?,?)').run(PL, 'u', 'rev playlist'); + db.prepare('INSERT INTO devices (id, name, playlist_id) VALUES (?,?,?)').run(DEV, 'panel', PL); + db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?,?,?,?)') + .run(PL, CID, 0, 10); + snapshot([{ content_id: CID, filename: 'clip.mp4', mime_type: 'video/mp4', filepath: 'aaaa.mp4', duration_sec: 10 }]); +}); + +test('every content item carries a revision the player can key a cache on', () => { + assert.equal(itemFor(DEV).content_rev, 1000); +}); + +test('THE BUG: replacing the bytes changes the revision, so a cached copy is a miss', () => { + // Without a value that moves, a player holding the old bytes has no way to learn they are stale: + // same id, same URL, same everything. + const before = itemFor(DEV).content_rev; + db.prepare("UPDATE content SET filepath = 'bbbb.mp4', updated_at = MAX(CAST(strftime('%s','now') AS INTEGER), updated_at + 1) WHERE id = ?").run(CID); + const after = itemFor(DEV).content_rev; + assert.ok(after > before, `revision must advance on replace (${before} -> ${after})`); +}); + +test('THE OTHER HALF: the refreshed filepath points at the file that now exists', () => { + // The snapshot still says aaaa.mp4, which was unlinked by the replace. The web player builds its + // URL from this field, so a stale value is not a caching problem — it is a 404 and a dark screen. + assert.equal(itemFor(DEV).filepath, 'bbbb.mp4'); +}); + +test('a same-second replace still advances the revision', () => { + // strftime resolution is one second. A scripted replace, or a small file, lands inside the same + // second as the previous write — and a revision that does not move is a cache that never updates. + const first = itemFor(DEV).content_rev; + for (let i = 0; i < 3; i++) { + db.prepare("UPDATE content SET updated_at = MAX(CAST(strftime('%s','now') AS INTEGER), COALESCE(NULLIF(updated_at,0), created_at) + 1) WHERE id = ?").run(CID); + } + assert.ok(itemFor(DEV).content_rev >= first + 3); +}); + +test('an untouched asset keeps a STABLE revision across sends', () => { + // Just as load-bearing as the change case: a revision that moved on its own would re-download the + // whole playlist on every heartbeat, over the link least able to afford it. + const a = itemFor(DEV).content_rev; + const b = itemFor(DEV).content_rev; + assert.equal(a, b); +}); + +test('a row migrated in before the column existed resolves to its creation time, not zero', () => { + // The ALTER lands the column as 0. Collapsing every such asset onto revision "0" would make two + // different assets look identically fresh to a cache keyed on it. + db.prepare('INSERT INTO content (id, filename, mime_type, file_size, filepath, created_at, updated_at) VALUES (?,?,?,?,?,?,?)') + .run('legacy', 'old.mp4', 'video/mp4', 10, 'old.mp4', 4242, 0); + db.prepare('INSERT INTO playlists (id, user_id, name) VALUES (?,?,?)').run('pl-legacy', 'u', 'legacy'); + db.prepare('INSERT INTO devices (id, name, playlist_id) VALUES (?,?,?)').run('dev-legacy', 'legacy panel', 'pl-legacy'); + db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?') + .run(JSON.stringify([{ content_id: 'legacy', filepath: 'old.mp4' }]), 'pl-legacy'); + assert.equal(itemFor('dev-legacy').content_rev, 4242); +}); + +test('a widget item is untouched — it has no content row to refresh from', () => { + db.prepare('INSERT INTO playlists (id, user_id, name) VALUES (?,?,?)').run('pl-w', 'u', 'widget'); + db.prepare('INSERT INTO devices (id, name, playlist_id) VALUES (?,?,?)').run('dev-w', 'w panel', 'pl-w'); + db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?') + .run(JSON.stringify([{ widget_id: 'w1', widget_rev: 7 }]), 'pl-w'); + const item = itemFor('dev-w'); + assert.equal(item.content_rev, undefined); + assert.equal(item.widget_id, 'w1'); +}); + +test('an item whose content row was deleted keeps its published fields rather than being blanked', () => { + // The delete path scrubs the snapshot separately; until it does, silently emptying filepath here + // would turn a stale item into a broken one. + db.prepare('INSERT INTO playlists (id, user_id, name) VALUES (?,?,?)').run('pl-gone', 'u', 'gone'); + db.prepare('INSERT INTO devices (id, name, playlist_id) VALUES (?,?,?)').run('dev-gone', 'gone panel', 'pl-gone'); + db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?') + .run(JSON.stringify([{ content_id: 'never-existed', filepath: 'ghost.mp4' }]), 'pl-gone'); + assert.equal(itemFor('dev-gone').filepath, 'ghost.mp4'); +}); + +after(() => { + try { setupDeviceSocket.__resetTimers(); } catch { /* */ } + try { io.close(); httpServer.close(); } catch { /* */ } +}); diff --git a/server/test/player-cache-policy.test.js b/server/test/player-cache-policy.test.js index 287e428..58b68d8 100644 --- a/server/test/player-cache-policy.test.js +++ b/server/test/player-cache-policy.test.js @@ -147,3 +147,88 @@ test('an unknown quota never triggers eviction — absence of a number is not a 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/); +}); diff --git a/server/test/sw-content-prefetch.test.js b/server/test/sw-content-prefetch.test.js new file mode 100644 index 0000000..91a837e --- /dev/null +++ b/server/test/sw-content-prefetch.test.js @@ -0,0 +1,226 @@ +'use strict'; + +// Drives the REAL service worker (server/player/sw.js) against a fake Cache API and a deliberately +// bad link. The policy tests cover the arithmetic; this covers the orchestration around it, which is +// where a resumed download actually gets corrupted: appending the wrong chunk, publishing a +// half-assembled asset, or serving a bookkeeping entry as if it were a video. +// +// It matters that this runs the shipped file rather than a copy — the worker cannot be exercised on +// a device without deploying to one, and "the chunks assemble correctly" is not something you want +// to discover from a panel showing a corrupt video. + +const { test, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const SW_SRC = fs.readFileSync(path.join(__dirname, '..', 'player', 'sw.js'), 'utf8'); +const POLICY_PATH = path.join(__dirname, '..', 'lib', 'player-cache-policy.js'); + +const ASSET = 'http://s/uploads/content/clip.mp4?rev=100'; + +/** Cache API over a Map. Keys are URLs, exactly as the real one behaves for our uses. */ +class FakeCache { + constructor() { this.map = new Map(); } + #url(req) { return typeof req === 'string' ? req : req.url; } + async match(req) { const r = this.map.get(this.#url(req)); return r ? r.clone() : undefined; } + async put(req, res) { this.map.set(this.#url(req), res); } + async keys() { return [...this.map.keys()].map((u) => ({ url: u })); } + async delete(req) { return this.map.delete(this.#url(req)); } +} + +/** + * A server for one asset. `failEvery` drops the connection on every Nth request (0 = never), which + * is what a marginal link looks like from the client's side. + */ +function makeServer(body, { etag = '"v1"', failEvery = 0, rangeSupport = true } = {}) { + const state = { body, etag, failEvery, rangeSupport, requests: 0, ranged: [] }; + state.fetch = async (request) => { + state.requests++; + if (state.failEvery && state.requests % state.failEvery === 0) throw new Error('network dropped'); + + const range = request.headers.get('Range'); + const ifRange = request.headers.get('If-Range'); + if (!range || !state.rangeSupport) { + return new Response(state.body, { status: 200, headers: { ETag: state.etag, 'Content-Type': 'video/mp4' } }); + } + // If-Range with a stale validator: the server must send the WHOLE asset, not a tail. This is + // the mechanism that stops a resume splicing two different files together. + if (ifRange && ifRange !== state.etag) { + return new Response(state.body, { status: 200, headers: { ETag: state.etag, 'Content-Type': 'video/mp4' } }); + } + const m = /bytes=(\d+)-(\d*)/.exec(range); + const start = Number(m[1]); + if (start >= state.body.length) { + return new Response('', { status: 416, headers: { 'Content-Range': `bytes */${state.body.length}` } }); + } + const end = m[2] === '' ? state.body.length - 1 : Math.min(Number(m[2]), state.body.length - 1); + state.ranged.push([start, end]); + return new Response(state.body.slice(start, end + 1), { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${state.body.length}`, + ETag: state.etag, + 'Content-Type': 'video/mp4' + } + }); + }; + return state; +} + +let sandbox, caches, server; + +function load(srv, chunkBytes) { + server = srv; + const contentCache = new FakeCache(); + const shellCache = new FakeCache(); + caches = { + open: async (name) => (name === 'rd-content-v1' ? contentCache : shellCache), + keys: async () => ['rd-content-v1'], + delete: async () => true, + match: async () => undefined, + _content: contentCache + }; + + sandbox = { + caches, + fetch: (req) => server.fetch(req), + Response, Request, Blob, URL, console, + location: { href: 'http://s/player/index.html' }, + navigator: {}, + importScripts() { + // The worker importScripts()es the same policy module the Node tests require, so both sides + // are provably the same rules rather than two implementations that agree today. + delete require.cache[require.resolve(POLICY_PATH)]; + sandbox.self.PlayerCachePolicy = require(POLICY_PATH); + }, + addEventListener() {}, + skipWaiting() {}, + clients: { claim() {} } + }; + sandbox.self = sandbox; + vm.createContext(sandbox); + vm.runInContext(SW_SRC, sandbox); + // A 4MB production chunk would make these tests move 100MB around; the logic is size-agnostic. + if (chunkBytes) sandbox.self.PlayerCachePolicy.CHUNK_BYTES = chunkBytes; + return sandbox; +} + +const bytes = (n, fill) => Buffer.alloc(n, fill); +async function cachedBody(url = ASSET) { + const hit = await caches._content.match(url); + return hit ? Buffer.from(await hit.arrayBuffer()) : null; +} +const internalKeys = () => [...caches._content.map.keys()].filter((k) => k.includes('__st_part')); + +beforeEach(() => { sandbox = null; }); + +test('THE BUG: a link that drops every other request still assembles a byte-perfect asset', async () => { + // Each call gets a chunk or two and dies. Without accumulation this is an infinite loop that + // caches nothing — the panel keeps an empty cache and goes dark the moment the uplink does. + const body = bytes(1000, 0x41); + const sw = load(makeServer(body, { failEvery: 2 }), 100); + + for (let pass = 0; pass < 40 && !(await cachedBody()); pass++) { + try { await sw.ensureCached(ASSET); } catch (e) { /* the link, not the worker */ } + } + + const got = await cachedBody(); + assert.ok(got, 'the asset must eventually be cached'); + assert.deepEqual(got, body, 'reassembled bytes must be identical to the original'); +}); + +test('the full entry appears only when whole — a fragment is never published', async () => { + // The invariant that protects playback: a cache hit is always a complete asset. Publishing early + // would hand a media element a truncated file that it cannot report as "incomplete", only as + // broken. + const body = bytes(1000, 0x42); + const sw = load(makeServer(body, { failEvery: 3 }), 100); + + for (let pass = 0; pass < 40; pass++) { + try { await sw.ensureCached(ASSET); } catch (e) { /* */ } + const partial = await cachedBody(); + if (partial) { assert.equal(partial.length, body.length, 'a published entry must be the whole asset'); break; } + assert.ok(internalKeys().length >= 0); + } + assert.deepEqual(await cachedBody(), body); +}); + +test('bookkeeping entries are cleaned up once the asset is whole', async () => { + const body = bytes(500, 0x43); + const sw = load(makeServer(body), 100); + await sw.ensureCached(ASSET); + assert.deepEqual(await cachedBody(), body); + assert.deepEqual(internalKeys(), [], 'chunks and meta must not outlive the assembled asset'); +}); + +test('an already-cached asset costs no requests at all', async () => { + // The prefetch runs on every playlist sweep. Re-fetching a cached asset each time would be a + // constant drain on the link least able to afford it. + const srv = makeServer(bytes(500, 0x44)); + const sw = load(srv, 100); + await sw.ensureCached(ASSET); + const after = srv.requests; + await sw.ensureCached(ASSET); + assert.equal(srv.requests, after, 'a second pass over a cached asset must not touch the network'); +}); + +test('an asset replaced mid-transfer is NOT spliced — the chunks are discarded', async () => { + // The corruption this whole design exists to prevent: appending the tail of the new asset to the + // head of the old one yields a file of exactly the right length that is wrong throughout, and it + // would pass every completeness check we have. + const v1 = bytes(1000, 0x61); + const v2 = bytes(1000, 0x62); + const srv = makeServer(v1, { failEvery: 3 }); + const sw = load(srv, 100); + + try { await sw.ensureCached(ASSET); } catch (e) { /* */ } + assert.ok(internalKeys().length > 0, 'expected partial progress to exist before the swap'); + + srv.body = v2; srv.etag = '"v2"'; srv.failEvery = 0; + + // The first pass after the swap gets a 200 from If-Range and must throw the v1 chunks away + // rather than continue on top of them; the next rebuilds from scratch. + await sw.ensureCached(ASSET); + for (let pass = 0; pass < 10 && !(await cachedBody()); pass++) await sw.ensureCached(ASSET); + + const got = await cachedBody(); + assert.ok(got, 'the replaced asset must still end up cached'); + assert.deepEqual(got, v2, 'the cached asset must be all-v2, with no v1 bytes spliced in'); + assert.ok(!got.includes(0x61), 'not one byte of the superseded asset may survive'); +}); + +test('a superseded revision is swept rather than left to fill the quota', async () => { + // Replacing an asset changes the revision in the URL, which is what makes the new bytes a miss. + // Without the sweep the old copy sits there until the quota evicts it — on a 1GB panel budget a + // handful of replaced videos is the whole cache. + const oldUrl = 'http://s/uploads/content/clip.mp4?rev=100'; + const newUrl = 'http://s/uploads/content/clip.mp4?rev=200'; + const sw = load(makeServer(bytes(300, 0x45)), 100); + + await sw.ensureCached(oldUrl); + assert.ok(await cachedBody(oldUrl)); + + server.body = bytes(300, 0x46); server.etag = '"v2"'; + await sw.ensureCached(newUrl); + + assert.ok(await cachedBody(newUrl), 'the new revision is cached'); + assert.equal(await cachedBody(oldUrl), null, 'the superseded revision is gone'); +}); + +test('a server with no range support still caches the asset whole', async () => { + // Not every deployment sits behind something that honours Range. Falling back to a plain store is + // the pre-existing behaviour and remains correct — just not resumable. + const body = bytes(600, 0x47); + const sw = load(makeServer(body, { rangeSupport: false }), 100); + await sw.ensureCached(ASSET); + assert.deepEqual(await cachedBody(), body); +}); + +test('a chunked transfer asks for each range exactly once, in order', async () => { + const sw = load(makeServer(bytes(1000, 0x48)), 250); + await sw.ensureCached(ASSET); + const starts = server.ranged.map((r) => r[0]); + assert.deepEqual(starts, [0, 250, 500, 750], 'no gaps, no repeats, no overlap'); +}); diff --git a/server/test/tizen-media-cache.test.js b/server/test/tizen-media-cache.test.js new file mode 100644 index 0000000..2df0f37 --- /dev/null +++ b/server/test/tizen-media-cache.test.js @@ -0,0 +1,223 @@ +'use strict'; + +// Tizen was the one player that cached nothing but the playlist: 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 fixes that, and none of it can be exercised on hardware without a TV — so +// the decisions are all in injected-backend form and driven here against a fake one. +// +// The backend (resolve wgt-private, append to a stream, turn a file into a URI) is the only part +// that needs a device, and it is the part with no logic in it. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const MediaCache = require(path.join(__dirname, '..', '..', 'tizen', 'js', 'media-cache.js')); +const CHUNK = MediaCache.CHUNK_BYTES; + +/** + * A fake TV. `failEvery` drops every Nth request — a marginal link, which is the condition the + * whole resumable design exists for. + */ +function fakeBackend(asset, opts = {}) { + const b = { + asset, // { bytes: number[], etag, total } + files: new Map(), // name -> number[] + index: {}, + requests: 0, + failEvery: opts.failEvery || 0, + rangeSupport: opts.rangeSupport !== false, + available: () => true, + loadIndex: () => b.index, + saveIndex: (i) => { b.index = JSON.parse(JSON.stringify(i)); }, + httpRange(url, start, end, validator) { + b.requests++; + if (b.failEvery && b.requests % b.failEvery === 0) throw new Error('link dropped'); + const body = b.asset.bytes; + if (!b.rangeSupport) { + return { status: 200, start: 0, total: body.length, validator: b.asset.etag, body: body.slice() }; + } + // If-Range with a stale validator: the server sends the WHOLE asset, which is the signal to + // start over rather than append a tail from a different file. + if (validator && validator !== b.asset.etag) { + return { status: 200, start: 0, total: body.length, validator: b.asset.etag, body: body.slice() }; + } + if (start >= body.length) return { status: 416, start, total: body.length, validator: b.asset.etag, body: null }; + const stop = Math.min(end, body.length - 1); + return { + status: 206, start, total: body.length, validator: b.asset.etag, + body: body.slice(start, stop + 1) + }; + }, + appendPart(contentId, body, offset) { + const name = contentId + '.part'; + const cur = b.files.get(name) || []; + if (offset === 0) b.files.set(name, body.slice()); + else { + if (cur.length !== offset) return 0; // a real append cannot write into a hole + b.files.set(name, cur.concat(body)); + } + return body.length; + }, + promotePart(contentId) { + const part = b.files.get(contentId + '.part'); + if (!part) return null; + b.files.set(contentId, part); + b.files.delete(contentId + '.part'); + return { path: '/wgt-private/' + contentId, uri: 'file:///wgt-private/' + contentId }; + }, + remove(contentId) { b.files.delete(contentId); b.files.delete(contentId + '.part'); } + }; + return b; +} + +const asset = (n, fill, etag = '"v1"') => ({ bytes: new Array(n).fill(fill), etag }); +const urlFor = (it) => 'http://s/api/content/' + it.content_id + '/file?rev=' + it.content_rev; + +test('THE GAP: media is cached at all, and resolves to a local file the player can open', async () => { + const b = fakeBackend(asset(CHUNK, 7)); + const mc = new MediaCache(b); + + assert.equal(mc.localUrl('c1', 5), null, 'nothing cached yet'); + await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); + assert.equal(mc.localUrl('c1', 5), 'file:///wgt-private/c1'); + assert.deepEqual(b.files.get('c1'), new Array(CHUNK).fill(7)); +}); + +test('a link that drops every other request still completes the asset', async () => { + // Without accumulation this never finishes: each attempt restarts from zero, so an asset larger + // than one uninterrupted transfer is never cached and the panel has nothing to fall back on. + const b = fakeBackend(asset(CHUNK * 4, 3), { failEvery: 2 }); + const mc = new MediaCache(b); + const items = [{ content_id: 'c1', content_rev: 5 }]; + + for (let pass = 0; pass < 30 && !mc.localUrl('c1', 5); pass++) await mc.sync(items, urlFor); + + assert.ok(mc.localUrl('c1', 5), 'the asset must eventually be cached'); + assert.equal(b.files.get('c1').length, CHUNK * 4); +}); + +test('a partial is never promoted — an incomplete file is not offered to the player', async () => { + const b = fakeBackend(asset(CHUNK * 3, 9), { failEvery: 1 }); // every request fails + const mc = new MediaCache(b); + await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); + assert.equal(mc.localUrl('c1', 5), null); + assert.equal(b.files.get('c1'), undefined, 'no whole file exists'); +}); + +test('progress accumulates across passes rather than restarting', async () => { + const b = fakeBackend(asset(CHUNK * 3, 4)); + const mc = new MediaCache(b); + // One step at a time, so the resume offset is observable. + assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'progress'); + assert.equal(mc.index.c1.bytes, CHUNK); + assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'progress'); + assert.equal(mc.index.c1.bytes, CHUNK * 2, 'the second attempt appended, it did not restart'); + assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'done'); +}); + +test('THE UPDATE HALF: a replaced asset is a miss, and the old bytes are deleted', async () => { + // The trap that offline caching creates. Same content id, same URL path, different bytes — a + // cache that cannot tell would keep playing last month's video forever. + const b = fakeBackend(asset(CHUNK, 1)); + const mc = new MediaCache(b); + await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); + assert.ok(mc.localUrl('c1', 5)); + + b.asset = asset(CHUNK, 2, '"v2"'); + assert.equal(mc.localUrl('c1', 6), null, 'a new revision must not match the cached copy'); + + await mc.sync([{ content_id: 'c1', content_rev: 6 }], urlFor); + assert.ok(mc.localUrl('c1', 6), 'the new revision is cached'); + assert.deepEqual(b.files.get('c1'), new Array(CHUNK).fill(2), 'and it is the NEW bytes'); +}); + +test('an asset replaced MID-transfer is discarded, not spliced', async () => { + // Appending the tail of the new asset to the head of the old one produces a file of exactly the + // right length that is wrong throughout — it would pass every completeness check there is. + const b = fakeBackend(asset(CHUNK * 4, 0x61)); + const mc = new MediaCache(b); + assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'progress'); + assert.equal(mc.index.c1.bytes, CHUNK); + + b.asset = asset(CHUNK * 4, 0x62, '"v2"'); // replaced underneath us, same revision claim + assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'done', 'a 200 carries the whole new asset'); + + const cached = b.files.get('c1'); + assert.equal(cached.length, CHUNK * 4); + assert.ok(cached.every((v) => v === 0x62), 'not one byte of the superseded asset may survive'); +}); + +test('items dropped from the playlist have their bytes deleted', async () => { + // Otherwise the cache only grows, and the failure eventually lands as a write error on whatever + // happens to be downloading at the time. + const b = fakeBackend(asset(CHUNK, 1)); + const mc = new MediaCache(b); + await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); + assert.ok(b.files.get('c1')); + + await mc.sync([{ content_id: 'c2', content_rev: 1 }], urlFor); + assert.equal(b.files.get('c1'), undefined, 'an unreferenced asset must not linger'); + assert.equal(mc.index.c1, undefined); +}); + +test('a cached asset costs no requests on later sweeps', async () => { + const b = fakeBackend(asset(CHUNK, 1)); + const mc = new MediaCache(b); + const items = [{ content_id: 'c1', content_rev: 5 }]; + await mc.sync(items, urlFor); + const after = b.requests; + await mc.sync(items, urlFor); + await mc.sync(items, urlFor); + assert.equal(b.requests, after, 'a cached asset must not be re-fetched every 60s'); +}); + +test('remote-url items are never downloaded', async () => { + const b = fakeBackend(asset(CHUNK, 1)); + const mc = new MediaCache(b); + await mc.sync([{ content_id: 'c1', content_rev: 5, remote_url: 'https://example.com/live' }], urlFor); + assert.equal(b.requests, 0); +}); + +test('a server with no range support still caches the asset whole', async () => { + const b = fakeBackend(asset(CHUNK * 2, 6), { rangeSupport: false }); + const mc = new MediaCache(b); + await mc.sync([{ content_id: 'c1', content_rev: 5 }], urlFor); + assert.ok(mc.localUrl('c1', 5)); + assert.equal(b.files.get('c1').length, CHUNK * 2); +}); + +test('a 416 discards a partial that is longer than the asset', async () => { + const b = fakeBackend(asset(CHUNK, 1)); + const mc = new MediaCache(b); + // Pretend a previous life left a longer partial behind. + mc.index.c1 = { rev: 5, bytes: CHUNK * 9, total: CHUNK * 9, validator: '"v1"', complete: false }; + b.files.set('c1.part', new Array(CHUNK * 9).fill(0)); + assert.equal(await mc.fetchStep('c1', 5, 'http://s/x'), 'restart'); + assert.equal(b.files.get('c1.part'), undefined, 'the stale partial must be gone'); +}); + +test('the index survives a restart — progress is not lost with the process', async () => { + // A signage panel reboots. If the index lived only in memory, every reboot during a slow + // download would throw the transfer away, which on a bad link means it never finishes. + const b = fakeBackend(asset(CHUNK * 3, 8)); + const first = new MediaCache(b); + assert.equal(await first.fetchStep('c1', 5, 'http://s/x'), 'progress'); + + const reborn = new MediaCache(b); // same backend = same persisted index + files + assert.equal(reborn.index.c1.bytes, CHUNK, 'the resume offset survived'); + assert.equal(await reborn.fetchStep('c1', 5, 'http://s/x'), 'progress'); + assert.equal(reborn.index.c1.bytes, CHUNK * 2); +}); + +test('an item with no revision still caches, and matches a copy stored without one', async () => { + // Older servers do not send content_rev. Treating absent-vs-absent as a mismatch would re-download + // the entire playlist on every sweep. + const b = fakeBackend(asset(CHUNK, 1)); + const mc = new MediaCache(b); + await mc.sync([{ content_id: 'c1' }], () => 'http://s/x'); + assert.ok(mc.localUrl('c1', undefined)); + const after = b.requests; + await mc.sync([{ content_id: 'c1' }], () => 'http://s/x'); + assert.equal(b.requests, after); +}); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 7273b49..903c25a 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -304,6 +304,46 @@ function refreshWidgetRevs(assignments) { } } +/* + * The same problem for uploaded media, with a worse failure mode. + * + * PUT /api/content/:id/replace swaps the BYTES behind a stable content id, and every player caches + * media by that id. The URL does not change, so a cached copy is not merely stale until the next + * refresh — it is stale forever, on every panel that already holds it, with no way for the player + * to find out. That is the price of caching for offline: an asset that can never be updated. + * + * Stamping the revision at send time gives the players a value that changes exactly when the bytes + * change. They put it in the request URL, so a replaced asset is a cache MISS everywhere at once, + * and an asset nobody touched is not. + * + * COALESCE because a database migrated from before the column existed backfills it once, but rows + * inserted between the ALTER and that backfill carry 0 — resolving those to created_at keeps the + * revision stable and truthful rather than collapsing every new upload onto the same "0". + */ +const contentFactsOf = db.prepare(` + SELECT COALESCE(NULLIF(updated_at, 0), created_at) AS rev, filepath, mime_type, file_size + FROM content WHERE id = ? +`); +function refreshContentRevs(assignments) { + if (!Array.isArray(assignments)) return; + for (const a of assignments) { + if (!a || !a.content_id) continue; + try { + const row = contentFactsOf.get(a.content_id); + if (!row) continue; // deleted mid-flight; the purge sweeps the snapshot + a.content_rev = row.rev ?? a.content_rev ?? 0; + // A replace writes a NEW randomly-named file and unlinks the old one, so the filepath in a + // published snapshot points at a file that no longer exists. The web player builds its media + // URL from exactly that field: replacing an asset 404'd every web and BrightSign panel until + // somebody thought to republish the playlist. Refreshing it here is the same fix as the + // widget rev above — the snapshot is a snapshot of the ARRANGEMENT, not of the bytes. + if (row.filepath) a.filepath = row.filepath; + if (row.mime_type) a.mime_type = row.mime_type; + if (row.file_size != null) a.file_size = row.file_size; + } catch (_) { /* keep published */ } + } +} + function buildPlaylistPayload(deviceId) { const device = db.prepare('SELECT playlist_id, layout_id, orientation, wall_id, timezone, reported_timezone FROM devices WHERE id = ?').get(deviceId); @@ -313,6 +353,7 @@ function buildPlaylistPayload(deviceId) { if (playlist?.published_snapshot) { try { assignments = JSON.parse(playlist.published_snapshot); } catch (e) { assignments = []; } refreshWidgetRevs(assignments); + refreshContentRevs(assignments); } } diff --git a/tizen/config.xml b/tizen/config.xml index d3cb150..f0fa500 100644 --- a/tizen/config.xml +++ b/tizen/config.xml @@ -27,6 +27,12 @@ hardware plane. Without this privilege the API throws SecurityError and the player falls back to per-element media volume, which cannot touch that plane. --> + + +