mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Two halves of the same problem. A screen has to keep playing when the link is gone, and it must not keep playing the wrong thing once the link is back. CACHING FOR OFFLINE, on the players that could not: - Tizen cached nothing but the playlist, so a panel came back from a reboot knowing exactly what to show and fetched every frame of it from a server that was not there. tizen/js/media-cache.js caches the media itself to wgt-private (the store Tizen documents as surviving reboots), resumable via Range and If-Range, with the transfer async so a stalled chunk cannot freeze the player. offline.cache moves from "absent" to a runtime claim: a build with no writable private storage still says nothing. - The web player's worker stored only what a single fetch() happened to complete, which on a marginal link is nothing at all — a 200MB asset never finishes in one go and every retry starts from zero. It now accumulates in resumable chunks, driven by the player's playlist rather than by playback, so the prefetch is not competing with the video that is currently on screen for the same scarce bandwidth. BrightSign inherits this. STILL UPDATING, which caching quietly breaks: PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every cache keys on that id, so before this the new bytes could not reach a panel that already held the old ones — not until the next refresh, but never. Content now carries a revision, stamped onto each item at send time like widget revs, and every player keys its cache on it. The same send-time refresh fixes a second bug: a replace writes a new randomly-named file and unlinks the old one, so the filepath in a published snapshot pointed at a deleted file and web panels 404'd on the item until somebody republished the playlist. The route now also pushes to affected devices, which it never did. Bytes are kept only where they can be built upon: no validator means no safe resume, so the partial is discarded and the attempt backs off as the failure it is rather than re-fetching the same prefix forever. Server needed no new transfer support — res.sendFile already does Range, If-Range and 416. The Tizen cache and the service worker are both driven in Node against fakes, because neither can be exercised without hardware and "the chunks assemble correctly" is not something to discover from a panel showing a corrupt video. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
131 lines
7 KiB
JavaScript
131 lines
7 KiB
JavaScript
'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 { /* */ }
|
|
});
|