screentinker/tizen/js/media-cache.js
ScreenTinker 4e1de8ec0e Make the Tizen and BrightSign players do what they say they do
Both players carried calls that compile, read correctly, and are documented to
do something else. Verified line by line against docs.brightsign.biz and
Samsung's Smart TV Filesystem reference; every fix below cites the doc that
proves it, and the linter has been extended so each one fails here next time.

TIZEN

The offline media cache could never have worked on a panel. Its adapter used
the deprecated Filesystem API in three ways the IDL rules out:
`tizen.filesystem.resolve()` is declared `void`, so `var dir = resolve(...)`
was always undefined and MediaCache.create() returned null on every panel in
the fleet; `openStream()` is asynchronous, so appendPart read `written` before
any callback could run and returned 0 forever; and `moveTo()` is asynchronous,
belongs on the parent directory, and takes (origin, destination) — it was
called on a file handle with the arguments transposed. Rewritten against the
5.0 synchronous FileSystemManager, which is genuinely synchronous and is what
the decision layer needs. A Tizen 4.0 panel now reports available() false
instead of being handed a cache that silently writes nothing.

Writes are now POSITIONED rather than appended at EOF. Power cut between a
write and the index save — the exact event this feature exists for — replayed
the last chunk, and an append landed it twice: a silently corrupt video that
promoted as complete. A positioned write makes the replay idempotent.

Three decision-layer bugs alongside it: a 206 with no readable Content-Range
fell back to Content-Length, which is the CHUNK length, so the first megabyte
of a 50MB video promoted as a complete 1MB asset; a 200 whose body was short of
its own Content-Length returned 'done'; and a server with no ETag or
Last-Modified was re-fetched from zero on every sweep, forever, on precisely
the marginal link this feature exists to be gentle on.

The volume slider was dead. The dashboard sends `{level: 0..1}`; this handler
read `value`/`volume` as a 0..100 percentage, so it matched nothing and logged
"no usable value in payload" on every slider move while the panel declared
audio.volume as working. Both halves had to move together — taking `level` as a
percentage turns 50% into 0.5%, which is inaudible and looks like a fix.
Verified by driving the real handler in headless Chrome, before and after.

BRIGHTSIGN

FindMemberFunction is documented as available only when
roDeviceInfo.HasFeature("FindMemberFunction") is true. It was called
unguarded from the capability probe and from host telemetry — both on the event
loop — so a player without the feature would have died within a minute of boot
and taken the display with it. The guard needed guarding.

The boot report never arrived. The host flushed its buffer straight after
Show(), before the page had been fetched, while the player correctly waits for
its socket before subscribing. Between two correct decisions every boot line
fell on the floor. The host now waits for the page's `probe`, and the bridge
buffers until a consumer registers.

offline.cache was claimed on `navigator.serviceWorker` being present. It is
present on a BrightSign widget and will not run a worker — our XT245 passes the
check and never fetches sw.js. Now requires a controller, matching the web
player. Removed from the brightsign baseline for the same reason.

display.resolution was claimed on @brightsign/videooutput, which has no
setMode at all; mode setting lives on @brightsign/videomodeconfiguration.

roStorageHotplug.GetStorages() answers "USB1:/" while GetStorageStatus() is
documented as unreliable for "USBn:" — feeding one to the other re-created the
bug the static fallback list exists to avoid, and only on the OS versions that
have the enumerator.

dual/clone output mode put two full-screen widgets on output ONE, on top of
each other, while output two stayed dark: roHtmlWidget has no output selector,
and a second output is addressed by its display_x/display_y within the
SetScreenModes canvas. Now positioned properly, or refused with a reason.

Also: a manifest missing sha256/size passed `invalid` into typed parameters, a
runtime error at the call the comment already described and did not prevent;
storage_quota was a string where the docs say use a double; and the comment
crediting brightsign_js_objects_enabled with gating require("@brightsign/*")
named the wrong flag — it is nodejs_enabled.

TESTS

The two suites that mattered most were the ones that passed while the code was
broken, because they asserted on source text or against a fake more correct
than the platform. The host-diagnostics regexes now execute the bridge; the
media-cache suite now drives the shipped adapter against a fake tizen.filesystem
written from Samsung's IDL. Ten new rules in the BrightScript linter, each
verified to fail against the source it was written to reject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 10:51:15 -05:00

435 lines
20 KiB
JavaScript

/*
* Offline media cache for the Tizen player.
*
* Tizen was the one player that cached NOTHING but the playlist. A panel could come back from a
* reboot knowing exactly what to show and then fetch every frame of it from a server that was not
* there — the playlist survived the outage and the content did not, which from the floor looks the
* same as having nothing at all. `offline.cache` is deliberately absent from the Tizen capability
* baseline for exactly this reason; this is what makes it earnable.
*
* A service worker is not available here: the widget runs from an app:// origin, so the mechanism
* the web player uses does not exist. The platform's own persistent store does: tizen.filesystem
* under wgt-private, which survives reboots and app updates, and whose file:// URI both <video> and
* AVPlay accept as a source.
*
* RESUMABLE, for the same reason as everywhere else in this product: a signage panel is often on
* the worst link in the building, and a transfer that restarts from zero on every interruption
* never finishes at all. Progress is appended to a `.part` file and continued with Range on the
* next pass.
*
* REVISION-KEYED, so caching cannot make a screen permanently wrong. Replacing an asset in the
* dashboard changes its content_rev; a cached copy at a different revision is treated as a miss and
* re-fetched, and its bytes are deleted. Without that, "cached for offline" would mean "can never
* be updated".
*
* The BACKEND is injected so every decision here is testable in Node without a TV (see
* server/test/tizen-media-cache.test.js). The Tizen-specific parts — resolving wgt-private,
* appending to a stream, turning a file into a URI — are the only things that need hardware, and
* they are the parts with no logic in them.
*/
(function (root) {
'use strict';
var INDEX_KEY = 'st_media_index';
// 1MB. Smaller than the web player's 4MB because each chunk crosses the JS/native boundary as a
// byte array here, and a 4MB array is a memory spike on a TV that a slow link does not justify.
var CHUNK_BYTES = 1024 * 1024;
function MediaCache(backend) {
this.backend = backend;
this.index = backend.loadIndex() || {};
this.busy = false;
}
MediaCache.CHUNK_BYTES = CHUNK_BYTES;
MediaCache.INDEX_KEY = INDEX_KEY;
MediaCache.prototype.save = function () {
try { this.backend.saveIndex(this.index); } catch (e) { /* a full store must not break playback */ }
};
/*
* The local URI for an item, or null when the bytes we hold are not the bytes it wants.
*
* The revision comparison is the whole point: `!==` rather than a truthiness check, so an item
* that arrives WITHOUT a revision (an older server) still matches a cached copy recorded without
* one, and a changed revision never matches a stale copy.
*/
MediaCache.prototype.localUrl = function (contentId, rev) {
if (!contentId) return null;
var e = this.index[contentId];
if (!e || !e.complete) return null;
if (String(e.rev || '') !== String(rev || '')) return null;
return e.uri || null;
};
/*
* Bring [contentId] a little closer to being cached. One call = one attempt: it transfers what
* the link allows and returns, leaving the rest for the next sweep. Never throws.
*
* Returns 'done' | 'progress' | 'stalled' | 'restart' — 'progress' being the one that matters,
* because a caller that cannot tell progress from failure will back a slow link off into a dead
* one.
*/
MediaCache.prototype.fetchStep = function (contentId, rev, url) {
var self = this;
var e = this.index[contentId];
// A different revision means the bytes on disk describe an asset that no longer exists.
if (e && String(e.rev || '') !== String(rev || '')) {
this.drop(contentId);
e = null;
}
if (e && e.complete) return Promise.resolve('done');
// A server that offered no validator for this revision cannot be resumed from, and asking again
// only re-downloads a chunk we already know we will throw away. See applyChunk.
if (e && e.unresumable) return Promise.resolve('stalled');
if (!e) {
e = this.index[contentId] = { rev: rev, bytes: 0, total: 0, validator: null, complete: false, path: null, uri: null };
}
var pending;
try {
pending = e.bytes > 0 && e.validator
? this.backend.httpRange(url, e.bytes, e.bytes + CHUNK_BYTES - 1, e.validator)
: this.backend.httpRange(url, 0, CHUNK_BYTES - 1, null);
} catch (err) {
return Promise.resolve('stalled');
}
// The backend hands back a promise (the real one is an ASYNC XHR — a synchronous request on
// this thread would freeze the player for as long as the chunk takes, which on the links this
// exists for is a stalled screen, not a slow download). Tests may hand back a plain value.
return Promise.resolve(pending).then(function (res) {
return self.applyChunk(contentId, rev, res);
}, function () { return 'stalled'; });
};
/* The decision half, given whatever the server said. Pure enough to reason about on its own. */
MediaCache.prototype.applyChunk = function (contentId, rev, res) {
var e = this.index[contentId];
if (!e) return 'stalled';
if (!res) return 'stalled';
if (res.status === 416) {
// Our partial is at or past the end of the asset — it belongs to something else. Keeping it
// would mean asking for a range past the end on every future sweep and never recovering.
this.drop(contentId);
return 'restart';
}
if (res.status === 200) {
// No range support, or If-Range told the server the asset changed. Either way the body is the
// WHOLE asset and anything we already hold is wrong.
this.drop(contentId);
e = this.index[contentId] = { rev: rev, bytes: 0, total: res.total || 0, validator: res.validator || null, complete: false, path: null, uri: null };
if (!this.commit(contentId, e, res.body, 0, res.total || (res.body && res.body.length) || 0)) return 'stalled';
// 'done' is a claim about the ASSET, not about the write. A 200 whose body is shorter than
// its own Content-Length (a truncated proxy response) wrote successfully and is still
// incomplete; reporting 'done' there stopped the sweep on an asset that had more to fetch.
return e.complete ? 'done' : 'progress';
}
if (res.status !== 206) return 'stalled';
// Content-Length on a 206 is the length of the CHUNK. The full size only comes from
// Content-Range, and without it there is nothing to decide completeness against.
if (!(res.total > 0) || res.start !== e.bytes) return 'stalled';
if (e.total && res.total !== e.total) { this.drop(contentId); return 'restart'; }
if (e.validator && res.validator && e.validator !== res.validator) { this.drop(contentId); return 'restart'; }
e.total = res.total;
// No validator means no safe resume: a later attempt could append the tail of a different
// asset. We keep the bytes only when we can prove on the next pass that they still belong.
e.validator = res.validator || null;
var wrote = this.commit(contentId, e, res.body, e.bytes, res.total);
if (!wrote) return 'stalled';
/*
* No validator and more to fetch: there is no safe resume. A later attempt could append the
* tail of a different asset, so the bytes have to go.
*
* What matters is that we then STOP asking. Dropping alone left the entry absent, so the next
* sweep started from zero, pulled the same first megabyte, dropped it again, and did that every
* sweep forever — burning the link this feature exists to be gentle on, permanently, for an
* asset it could never finish. A tombstone at the current revision records "we tried, this
* server will not let us resume" and costs one skipped item instead. Publishing a new revision
* clears it (prune drops anything at a superseded rev), and an asset small enough to arrive in
* one chunk is unaffected — it completes before this branch is reached.
*/
if (!e.validator && !e.complete) {
this.drop(contentId);
this.index[contentId] = { rev: rev, unresumable: true, bytes: 0, total: 0, validator: null, complete: false, path: null, uri: null };
this.save();
return 'stalled';
}
return e.complete ? 'done' : 'progress';
};
/* Append [body] at [offset] and mark the entry complete when it reaches [total]. */
MediaCache.prototype.commit = function (contentId, e, body, offset, total) {
if (!body || !body.length) return false;
var written;
try {
written = this.backend.appendPart(contentId, body, offset);
} catch (err) {
return false;
}
if (!(written > 0)) return false;
e.bytes = offset + written;
e.total = total || e.total;
if (e.total > 0 && e.bytes >= e.total) {
// Promote only when whole. A player handed a partial file has no way to report it as
// incomplete — only as broken.
var promoted;
try { promoted = this.backend.promotePart(contentId); } catch (err) { promoted = null; }
if (!promoted) return false;
e.complete = true;
e.path = promoted.path;
e.uri = promoted.uri;
}
this.save();
return true;
};
/* Forget an entry and delete its bytes, partial or complete. */
MediaCache.prototype.drop = function (contentId) {
try { this.backend.remove(contentId); } catch (e) { /* best effort */ }
delete this.index[contentId];
this.save();
};
/*
* Delete anything the current playlist does not reference, and anything at a superseded revision.
*
* Without this the cache only ever grows: a panel that has cycled through a year of campaigns
* fills its storage with assets nobody will play again, and the failure lands as a write error on
* whatever happens to be downloading at the time.
*/
MediaCache.prototype.prune = function (items) {
var keep = {};
(items || []).forEach(function (it) {
if (it && it.content_id) keep[it.content_id] = String(it.content_rev || '');
});
var self = this;
Object.keys(this.index).forEach(function (id) {
var wanted = keep[id];
if (wanted === undefined || wanted !== String(self.index[id].rev || '')) self.drop(id);
});
};
/*
* One pass over the playlist: prune, then advance each uncached item in turn.
*
* Serialised on purpose. Three concurrent transfers on a link that cannot finish one produce
* three unfinished transfers instead of one finished one — and on a TV they also produce three
* simultaneous native writes competing with video decode.
*/
MediaCache.prototype.sync = function (items, urlFor) {
if (this.busy) return Promise.resolve();
this.busy = true;
var self = this;
try { this.prune(items); } catch (e) { /* pruning must never block fetching */ }
var list = (items || []).filter(function (it) { return it && it.content_id && !it.remote_url; });
/*
* One item at a time, and within an item, keep going while bytes are landing — exactly the
* shape the other players use. Serialised on purpose: three concurrent transfers on a link that
* cannot finish one produce three unfinished transfers instead of one finished one, and on a TV
* they also compete with video decode for the same memory.
*/
function item(i) {
if (i >= list.length) return Promise.resolve();
var it = list[i];
var guard = 0;
function step() {
// Bounded, so one enormous asset cannot monopolise the pass and starve the rest of the
// playlist; whatever is left resumes on the next sweep.
if (guard++ >= 32) return Promise.resolve();
return self.fetchStep(it.content_id, it.content_rev, urlFor(it)).then(function (verdict) {
return verdict === 'progress' ? step() : null;
});
}
return step().catch(function () { /* this item stalled; the rest of the playlist continues */ })
.then(function () { return item(i + 1); });
}
return item(0).catch(function () {}).then(function () { self.busy = false; });
};
/* ------------------------------------------------------------------ *
* The Tizen adapter. No decisions live here — only platform calls.
*
* ⚠️ EVERY CALL HERE IS THE 5.0 SYNCHRONOUS FileSystemManager, deliberately, and the version
* before it used the DEPRECATED callback API in a way that could not work at all:
*
* tizen.filesystem.resolve(...) is declared `void`. It hands the directory to a callback and
* returns undefined — so `var dir = tizen.filesystem.resolve(...)` set dir to undefined,
* available() answered false, MediaCache.create() returned null, and the offline cache did
* not exist on a single panel in the field. It failed CLOSED, which is the only reason this
* never showed up as corruption: the capability was correctly withheld, and the feature was
* simply never there.
* File.openStream(...) is asynchronous. `written` was read on the line after the
* call, before any callback could have run, so appendPart returned 0 every time.
* File.moveTo(...) is asynchronous, belongs on the PARENT DIRECTORY, and takes
* (originFullPath, destinationFullPath). It was called on the FILE handle with the
* destination first and a bare name second — three documented errors in one call, each of
* which alone raises IOError.
*
* The 5.0 API (`openFile` -> FileHandle, `toURI`, `pathExists`) is genuinely synchronous, which
* is what the decision layer above actually needs. Tizen 5.0 is the 2019 model year; a 4.0 panel
* has none of it and is told so by available() rather than being handed a cache that writes
* nothing.
*
* There is no rename step. `moveFile` is callback-based even in the 5.0 API, and promoting a
* finished download by renaming it was only ever belt-and-braces: `localUrl` already refuses to
* hand out an entry that is not `complete`, so a partial file at the final name is unreachable.
* Removing the rename removes the last asynchronous operation from this adapter.
* ------------------------------------------------------------------ */
var DIR = 'wgt-private/st-media';
function tizenBackend() {
var fsm = null;
try { fsm = (typeof tizen !== 'undefined' && tizen.filesystem) ? tizen.filesystem : null; }
catch (e) { fsm = null; }
// The whole synchronous surface has to be present. Probing one method and assuming the rest is
// how a half-supported runtime ends up with a cache that half works.
var usable = !!(fsm &&
typeof fsm.openFile === 'function' &&
typeof fsm.toURI === 'function' &&
typeof fsm.pathExists === 'function');
function pathFor(contentId) { return DIR + '/' + contentId; }
return {
available: function () { return usable; },
loadIndex: function () {
try { return JSON.parse(localStorage.getItem(INDEX_KEY) || '{}'); } catch (e) { return {}; }
},
saveIndex: function (idx) {
try { localStorage.setItem(INDEX_KEY, JSON.stringify(idx)); } catch (e) { /* full */ }
},
httpRange: function (url, start, end, validator) {
return new Promise(function (resolve) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
// Binary over responseText: the widget runtime on the shipped panels predates a reliable
// arraybuffer path through this API, and x-user-defined keeps every byte addressable.
xhr.overrideMimeType('text/plain; charset=x-user-defined');
xhr.setRequestHeader('Range', 'bytes=' + start + '-' + end);
if (validator) xhr.setRequestHeader('If-Range', validator);
// A stalled chunk must give up rather than hold the slot forever — on a bad link a hung
// request is indistinguishable from a dead one, and the next sweep resumes anyway.
xhr.timeout = 60000;
function done(res) { resolve(res); }
xhr.onerror = function () { done(null); };
xhr.ontimeout = function () { done(null); };
xhr.onload = function () {
var cr = xhr.getResponseHeader('Content-Range') || '';
var m = /bytes\s+(\d+)-(\d+)\/(\d+)/.exec(cr);
var body = null;
if (xhr.status === 200 || xhr.status === 206) {
var text = xhr.responseText || '';
body = [];
for (var i = 0; i < text.length; i++) body.push(text.charCodeAt(i) & 0xff);
}
/*
* total comes from Content-Range on a 206 and from Content-Length ONLY on a 200.
*
* Content-Length on a 206 is the length of the CHUNK, so falling back to it would
* report a 1MB first slice of a 50MB video as a 1MB asset — and the decision layer,
* correctly trusting its input, would mark it complete and hand the panel a truncated
* file to play. It is not hypothetical: a proxy that strips Content-Range, or a CORS
* context where the header is simply not readable, produces exactly this. 0 means
* "unknown", which the decision layer already treats as a stall.
*/
var isPartial = xhr.status === 206;
done({
status: xhr.status,
start: m ? Number(m[1]) : 0,
total: m ? Number(m[3]) : (isPartial ? 0 : Number(xhr.getResponseHeader('Content-Length') || 0)),
validator: xhr.getResponseHeader('ETag') || xhr.getResponseHeader('Last-Modified') || null,
body: body
});
};
try { xhr.send(null); } catch (e) { done(null); }
});
},
/*
* A POSITIONED write, not an append.
*
* 'a' appends at EOF, so the moment the index and the file disagreed by so much as one chunk
* — a power cut between the write and the index save, which is precisely the event this whole
* feature exists for — every later chunk landed in the wrong place and the panel promoted a
* silently corrupt video. Seeking to the offset makes a repeated write IDEMPOTENT: replaying
* a chunk overwrites the same bytes with the same bytes, so the crash window stops mattering
* instead of being papered over.
*/
appendPart: function (contentId, body, offset) {
if (!usable || !body || !body.length) return 0;
var fh = null;
try {
// 'w' truncates, which is what a fresh start means; 'rw' keeps what is there to write
// into. makeParents:true creates wgt-private/st-media on first use.
fh = fsm.openFile(pathFor(contentId), offset > 0 ? 'rw' : 'w', true);
if (!fh) return 0;
if (offset > 0) fh.seek(offset, 'BEGIN');
fh.writeData(new Uint8Array(body));
if (typeof fh.flush === 'function') fh.flush();
return body.length;
} catch (e) {
return 0; // no space, no permission, no file — all "did not write"
} finally {
if (fh) { try { fh.close(); } catch (e2) { /* already gone */ } }
}
},
/*
* Nothing to promote — the bytes have been written to their final name all along. This just
* answers with the URI, and only once the file is really there.
*/
promotePart: function (contentId) {
if (!usable) return null;
var p = pathFor(contentId);
try {
if (!fsm.pathExists(p)) return null;
return { path: p, uri: fsm.toURI(p) };
} catch (e) { return null; }
},
remove: function (contentId) {
if (!usable) return;
var p = pathFor(contentId);
try {
if (!fsm.pathExists(p)) return;
// Still callback-based even in the 5.0 API, and best-effort by design: a delete that
// fails costs disk, while blocking on it would cost playback.
fsm.deleteFile(p, function () {}, function () {});
} catch (e) { /* not present, or refused */ }
}
};
}
MediaCache.tizenBackend = tizenBackend;
/*
* The live instance, or null when the platform cannot give us persistent storage. Null is a
* supported state everywhere it is used: the player falls back to streaming from the server,
* which is what it did before this existed.
*/
MediaCache.create = function () {
try {
var backend = tizenBackend();
if (!backend.available()) return null;
return new MediaCache(backend);
} catch (e) {
return null;
}
};
root.MediaCache = MediaCache;
if (typeof module === 'object' && module.exports) module.exports = MediaCache;
})(typeof window !== 'undefined' ? window : this);