Release 2.21.0: non-destructive media and scoped player sync
This commit is contained in:
1 parent
24df245ea1
commit
7cff39dc76
35 files changed
+2009
-949
No files matched your search
+238
-2
@@ -8,6 +8,49 @@ const express = require('express');
|
||||
require('../src/common');
|
||||
|
||||
const { createUploadSyncService } = require('../src/web/lib/media');
|
||||
const { buildPlayerAssetPlan } = require('../src/web/lib/media/player-assets');
|
||||
const { getMediaStorageId } = require('../src/media-storage');
|
||||
|
||||
test('media storage IDs distinguish separate player volumes from the shared web volume', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-storage-identity-'));
|
||||
try {
|
||||
const web = path.join(root, 'web');
|
||||
const local = path.join(root, 'player-local');
|
||||
const remote = path.join(root, 'player-remote');
|
||||
const webId = await getMediaStorageId(web);
|
||||
assert.equal(await getMediaStorageId(web), webId);
|
||||
assert.notEqual(await getMediaStorageId(local), webId);
|
||||
assert.notEqual(await getMediaStorageId(remote), webId);
|
||||
const moved = path.join(root, 'web-moved');
|
||||
fs.renameSync(web, moved);
|
||||
assert.equal(await getMediaStorageId(moved), webId);
|
||||
const copied = path.join(root, 'web-independent-copy');
|
||||
fs.cpSync(moved, copied, { recursive: true });
|
||||
assert.equal(await getMediaStorageId(copied), webId);
|
||||
fs.unlinkSync(path.join(copied, '.storage-id'));
|
||||
assert.notEqual(await getMediaStorageId(copied), webId);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('unused generated copies are removed before media sync even with no connected players', async () => {
|
||||
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-no-player-copy-cleanup-'));
|
||||
const filename = 'image-crop-' + 'c'.repeat(64) + '.png';
|
||||
fs.writeFileSync(path.join(uploadDir, filename), Buffer.from('copy'));
|
||||
fs.writeFileSync(path.join(uploadDir, 'original.png'), Buffer.from('original'));
|
||||
const pool = { async query(sql) {
|
||||
if (sql.startsWith('SELECT id, original_name')) return [[{ id: 1, original_name: filename, is_published: 1 }]];
|
||||
if (sql.includes('COUNT(DISTINCT owner_type, owner_id)')) return [[{ owner_count: 0 }]];
|
||||
return [[{ ref_count: 0 }]];
|
||||
} };
|
||||
const service = createUploadSyncService({ pool, common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {}, fetchPlayerRegistrations: async () => [], backgroundTaskQueue: { async enqueueTaskAndWait() {} } });
|
||||
try {
|
||||
await service.syncPlaylistUploadsOnChange({ key: 'slide:update:1', pool, localUploadDir: uploadDir, previousUploadRefs: ['/media/uploads/' + filename, '/media/uploads/original.png'], nextUploadRefs: [] });
|
||||
assert.equal(fs.existsSync(path.join(uploadDir, filename)), false);
|
||||
assert.equal(fs.existsSync(path.join(uploadDir, 'original.png')), true);
|
||||
} finally {
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('upload reference collection includes WYSIWYG images across slide regions', () => {
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
@@ -307,6 +350,9 @@ test('slide update sync removes uploads only after the web-managed file is gone'
|
||||
fs.mkdirSync(path.join(uploadDir, 'uploads'), { recursive: true });
|
||||
fs.writeFileSync(path.join(uploadDir, 'uploads', 'keep.bin'), Buffer.from('keep'));
|
||||
fs.writeFileSync(path.join(uploadDir, 'uploads', 'remove.bin'), Buffer.from('remove'));
|
||||
const manifestDir = path.join(uploadDir, 'player-cache', 'media-sync');
|
||||
fs.mkdirSync(manifestDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(manifestDir, require('node:crypto').createHash('sha256').update('player-one').digest('hex') + '.json'), JSON.stringify({ playerIdentifier: 'player-one', files: ['/media/uploads/remove.bin'], clients: [{ id: 'client-one', deviceId: 'client-one', playerIdentifier: 'player-one', screenSlug: 'screen-one', seenAt: Date.now() }] }));
|
||||
const liveLastSeenAt = new Date(Date.now() - 10_000).toISOString();
|
||||
|
||||
const fetchCalls = [];
|
||||
@@ -332,8 +378,11 @@ test('slide update sync removes uploads only after the web-managed file is gone'
|
||||
common: {
|
||||
async fetchAdminData() {
|
||||
return {
|
||||
screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }],
|
||||
playlistSlides: [{ playlist_id: 1, slide_id: 1 }],
|
||||
slides: [
|
||||
{
|
||||
id: 1,
|
||||
content_json: JSON.stringify({
|
||||
imageRegion: {
|
||||
type: 'image',
|
||||
@@ -441,7 +490,11 @@ test('slide update sync queues one media task per live player and targets each p
|
||||
|
||||
let uploadSyncService;
|
||||
uploadSyncService = createUploadSyncService({
|
||||
common: {},
|
||||
common: { async fetchAdminData() { return {
|
||||
screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }, { id: 2, slug: 'screen-two', playlist_id: 1 }],
|
||||
playlistSlides: [{ playlist_id: 1, slide_id: 1 }],
|
||||
slides: [{ id: 1, content_json: { photo: { value: '/media/uploads/sample.bin' } } }], templates: []
|
||||
}; } },
|
||||
pool: {
|
||||
async query() {
|
||||
return [[
|
||||
@@ -463,7 +516,10 @@ test('slide update sync queues one media task per live player and targets each p
|
||||
]];
|
||||
}
|
||||
},
|
||||
playerSnapshotCache: new Map(),
|
||||
playerSnapshotCache: new Map([
|
||||
['screen-one', { connections: [{ deviceId: 'client-one', playerDeviceId: 'player-one' }] }],
|
||||
['screen-two', { connections: [{ deviceId: 'client-two', playerDeviceId: 'player-two' }] }]
|
||||
]),
|
||||
notifyPlayerScreens: async () => {},
|
||||
backgroundTaskQueue: {
|
||||
async enqueueTaskAndWait(definition) {
|
||||
@@ -569,4 +625,184 @@ test('remote media deletion uses the bridge device route', async () => {
|
||||
global.fetch = originalFetch;
|
||||
fs.rmSync(uploadDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('playback reference collection excludes editing originals without weakening retention references', () => {
|
||||
const service = createUploadSyncService({ common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {} });
|
||||
const content = {
|
||||
photo: { type: 'image', value: '/media/uploads/playback.png', original_value: '/media/uploads/original.png' },
|
||||
text: { value: '<img src="/media/uploads/inline.png">' },
|
||||
small: { type: 'image', value: '/media/uploads/small.png', original_value: '/media/uploads/small.png' }
|
||||
};
|
||||
assert.deepEqual([...service.collectPlaybackUploadReferences(content)].sort(), ['/media/uploads/inline.png', '/media/uploads/playback.png', '/media/uploads/small.png']);
|
||||
const retained = new Set();
|
||||
service.collectUploadReferencesFromValue(content, retained);
|
||||
assert.ok(retained.has('/media/uploads/original.png'));
|
||||
});
|
||||
|
||||
test('player asset plans separate direct and bridged players and retain offline client assignments', () => {
|
||||
const service = createUploadSyncService({ common: {}, playerSnapshotCache: new Map(), notifyPlayerScreens: async () => {} });
|
||||
const players = [{ identifier: 'local', public_base_url: 'http://player:8081' }, { identifier: 'remote', public_base_url: '' }];
|
||||
const data = {
|
||||
screens: [{ id: 1, slug: 'one', playlist_id: 10 }, { id: 2, slug: 'two', playlist_id: 20 }],
|
||||
playlistSlides: [{ playlist_id: 10, slide_id: 1 }, { playlist_id: 20, slide_id: 2 }],
|
||||
slides: [{ id: 1, template_id: 1, content_json: { photo: { value: '/media/uploads/local-copy.png', original_value: '/media/uploads/local-original.png' }, brand: { value: '/media/uploads/ignored.png' } } }, { id: 2, template_id: 2, content_json: { photo: { value: '/media/uploads/remote-copy.png', original_value: '/media/uploads/remote-original.png' } } }],
|
||||
templates: [{ id: 1, background_image_path: '/media/uploads/background.png' }, { id: 2 }],
|
||||
templateRegions: [{ template_id: 1, region_key: 'photo', region_type: 'image' }, { template_id: 1, region_key: 'brand', region_type: 'image', read_only: 1, default_content: { value: '/media/uploads/brand.png' } }, { template_id: 2, region_key: 'photo', region_type: 'image' }]
|
||||
};
|
||||
const snapshots = new Map([['one', { connections: [{ deviceId: 'client-one', playerPublicBaseUrl: 'http://player:8081' }] }], ['two', { connections: [{ deviceId: 'client-two', playerDeviceId: 'remote' }] }]]);
|
||||
const plan = buildPlayerAssetPlan(data, snapshots, players, [], service.collectPlaybackUploadReferences);
|
||||
assert.deepEqual([...plan.assets.get('local')].sort(), ['/media/uploads/background.png', '/media/uploads/brand.png', '/media/uploads/local-copy.png']);
|
||||
assert.deepEqual([...plan.assets.get('remote')], ['/media/uploads/remote-copy.png']);
|
||||
const offline = buildPlayerAssetPlan(data, new Map(), players, plan.clients, service.collectPlaybackUploadReferences);
|
||||
assert.deepEqual([...offline.assets.get('remote')], ['/media/uploads/remote-copy.png']);
|
||||
const moved = buildPlayerAssetPlan({ ...data, onboardingDevices: [{ device_id: 'client-two', screen_id: 1 }] }, new Map(), players, plan.clients, service.collectPlaybackUploadReferences);
|
||||
assert.ok(moved.assets.get('remote').has('/media/uploads/local-copy.png'));
|
||||
assert.ok(!moved.assets.get('remote').has('/media/uploads/remote-copy.png'));
|
||||
});
|
||||
|
||||
test('scoped sync sends only assigned playback files through direct and bridge transports and persists offline assignments', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-scoped-players-'));
|
||||
const uploads = path.join(root, 'uploads');
|
||||
fs.mkdirSync(uploads);
|
||||
for (const name of ['local.png', 'remote.png', 'original.png', 'unassigned.png', 'remote.variant-2048.png']) fs.writeFileSync(path.join(uploads, name), name);
|
||||
const players = [
|
||||
{ identifier: 'local', internal_base_url: 'http://player:8081', public_base_url: 'http://local.test', last_seen_at: new Date() },
|
||||
{ identifier: 'remote', internal_base_url: '', public_base_url: '', last_seen_at: new Date() }
|
||||
];
|
||||
const data = {
|
||||
screens: [{ id: 1, slug: 'one', playlist_id: 1 }, { id: 2, slug: 'two', playlist_id: 2 }],
|
||||
playlistSlides: [{ playlist_id: 1, slide_id: 1 }, { playlist_id: 2, slide_id: 2 }],
|
||||
slides: [{ id: 1, content_json: { photo: { value: '/media/uploads/local.png', original_value: '/media/uploads/original.png' } } }, { id: 2, content_json: { photo: { value: '/media/uploads/remote.png', original_value: '/media/uploads/original.png' } } }], templates: []
|
||||
};
|
||||
const pool = { async query(sql) {
|
||||
if (sql.includes('FROM d_players')) return [players];
|
||||
if (sql.includes('FROM d_onboarding_devices')) return [[{ device_id: 'a', screen_id: 1 }, { device_id: 'b', screen_id: 2 }]];
|
||||
return [[]];
|
||||
} };
|
||||
const calls = [];
|
||||
const originalFetch = global.fetch;
|
||||
let failTransfer = false;
|
||||
global.fetch = async function (url, init) { calls.push({ url, method: init.method, player: init.headers['x-pulse-player-device-id'] }); return { ok: !(failTransfer && init.method === 'PUT'), status: failTransfer ? 503 : 200 }; };
|
||||
const snapshots = new Map([['one', { connections: [{ deviceId: 'a', playerDeviceId: 'local' }] }], ['two', { connections: [{ deviceId: 'b', playerDeviceId: 'remote' }] }]]);
|
||||
let service;
|
||||
const options = { pool, common: { async fetchAdminData() { return data; } }, bridgeInternalBaseUrl: 'http://player-bridge:8090', playerSnapshotCache: snapshots, notifyPlayerScreens: async () => {}, backgroundTaskQueue: { async enqueueTaskAndWait(task) { await service.runMediaSyncTask(task.payload); } } };
|
||||
try {
|
||||
service = createUploadSyncService(options);
|
||||
const savedSnapshots = [...snapshots];
|
||||
snapshots.clear();
|
||||
await service.runMediaSyncTask({ mode: 'playlist', playerIdentifier: 'remote', operation: { localUploadDir: uploads } });
|
||||
assert.equal(calls.length, 0);
|
||||
for (const [slug, snapshot] of savedSnapshots) snapshots.set(slug, snapshot);
|
||||
failTransfer = true;
|
||||
await assert.rejects(service.syncPlaylistUploadsOnChange({ key: 'slide:update:2', localUploadDir: uploads, nextUploadRefs: ['/media/uploads/remote.png'] }), /required playback asset/);
|
||||
snapshots.delete('two');
|
||||
failTransfer = false;
|
||||
calls.length = 0;
|
||||
service = createUploadSyncService(options);
|
||||
await service.syncPlaylistUploadsOnChange({ key: 'slide:update:2', localUploadDir: uploads, nextUploadRefs: ['/media/uploads/remote.png'] });
|
||||
assert.deepEqual(calls.filter(call => call.method === 'PUT'), [{ url: 'http://player-bridge:8090/api/media/uploads%2Fremote.png', method: 'PUT', player: 'remote' }]);
|
||||
assert.ok(calls.some(call => call.method === 'DELETE' && call.url.endsWith('original.png')));
|
||||
calls.length = 0;
|
||||
await service.runMediaSyncTask({ mode: 'initial', uploadDir: root, playerIdentifier: 'local' });
|
||||
assert.deepEqual(calls.filter(call => call.method === 'PUT'), [{ url: 'http://player:8081/api/media/uploads%2Flocal.png', method: 'PUT', player: 'local' }]);
|
||||
snapshots.clear();
|
||||
service = createUploadSyncService(options);
|
||||
calls.length = 0;
|
||||
await service.runMediaSyncTask({ mode: 'playlist', playerIdentifier: 'remote', operation: { localUploadDir: uploads } });
|
||||
assert.deepEqual(calls.filter(call => call.method === 'PUT').map(call => call.url), ['http://player-bridge:8090/api/media/uploads%2Fremote.png']);
|
||||
assert.equal(fs.existsSync(path.join(uploads, 'original.png')), true);
|
||||
assert.equal(fs.existsSync(path.join(uploads, 'local.png')), true);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('client-assignment sync refreshes its display after required media transfer', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-assignment-refresh-'));
|
||||
const originalTimer = global.setTimeout;
|
||||
let scheduled;
|
||||
let queuedResolve;
|
||||
const queued = new Promise(resolve => { queuedResolve = resolve; });
|
||||
const service = createUploadSyncService({
|
||||
pool: { async query(sql) { return sql.includes('FROM d_players') ? [[{ identifier: 'one', internal_base_url: 'http://player:8081', last_seen_at: new Date() }]] : [[]]; } },
|
||||
common: { async fetchAdminData() { return { screens: [{ id: 1, slug: 'screen-one', playlist_id: 1 }], playlistSlides: [], slides: [], templates: [] }; } },
|
||||
playerSnapshotCache: new Map([['screen-one', { connections: [{ deviceId: 'client-one', playerDeviceId: 'one' }] }]]),
|
||||
notifyPlayerScreens: async () => {},
|
||||
backgroundTaskQueue: { async enqueueTaskAndWait(task) { queuedResolve(task); } }
|
||||
});
|
||||
try {
|
||||
global.setTimeout = callback => { scheduled = callback; return { unref() {} }; };
|
||||
service.scheduleAssignmentSync(root);
|
||||
global.setTimeout = originalTimer;
|
||||
scheduled();
|
||||
const task = await queued;
|
||||
assert.deepEqual(task.payload.operation.refreshScreenSlugs, ['screen-one']);
|
||||
} finally {
|
||||
global.setTimeout = originalTimer;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit player refresh retries failed local media even when playlist refresh is already in flight', () => {
|
||||
const vm = require('node:vm');
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-playback.js'), 'utf8');
|
||||
const start = source.indexOf('function refresh(applyImmediately, bypassDebounce)');
|
||||
const end = source.indexOf(' playlistRefreshInFlight = true;', start);
|
||||
const failed = { complete: true, naturalWidth: 0, src: 'http://player.test/media/uploads/crop.png' };
|
||||
const loaded = { complete: true, naturalWidth: 1280, src: 'http://player.test/media/uploads/loaded.png' };
|
||||
const remote = { complete: true, naturalWidth: 0, src: 'https://other.test/image.png' };
|
||||
const pending = { complete: false, naturalWidth: 0, src: 'http://player.test/media/uploads/pending.png' };
|
||||
const refresh = vm.runInNewContext('(' + source.slice(start, end) + '\n})', { document: { querySelectorAll() { return [failed, loaded, remote, pending]; } }, window: { location: { href: 'http://player.test/screen/test', origin: 'http://player.test' } }, URL, Date, playlistRefreshInFlight: true });
|
||||
refresh(true);
|
||||
assert.match(failed.src, /crop\.png\?_mediaRefresh=\d+/);
|
||||
assert.equal(loaded.src, 'http://player.test/media/uploads/loaded.png');
|
||||
assert.equal(remote.src, 'https://other.test/image.png');
|
||||
assert.equal(pending.src, 'http://player.test/media/uploads/pending.png');
|
||||
});
|
||||
|
||||
test('player keeps an initial loading screen until slide images decode and cancels stale readiness waits', async () => {
|
||||
const vm = require('node:vm');
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-media.js'), 'utf8');
|
||||
const start = source.indexOf('async function waitForSlideImages(');
|
||||
const images = [];
|
||||
const loaders = [];
|
||||
const context = vm.createContext({
|
||||
Image: class { constructor() { images.push(this); } decode() { return Promise.resolve(); } },
|
||||
document: { createElement(type) { return type === 'template' ? { content: { querySelectorAll() { return [{ getAttribute() { return '/media/uploads/crop.png'; } }]; } } } : { setAttribute() {} }; } },
|
||||
app: { querySelector() { return loaders[0] || null; }, appendChild(element) { loaders.push(element); } },
|
||||
window: { setTimeout() { return 1; }, clearTimeout() {}, location: { href: 'http://player.test/screen/test', origin: 'http://player.test' } },
|
||||
lastRenderedViewKey: '', slideRenderRequestId: 1, URL, Date
|
||||
});
|
||||
vm.runInContext(source.slice(start), context);
|
||||
const waiting = context.waitForSlideImages('<img src="/media/uploads/crop.png">', 1);
|
||||
assert.equal(loaders.length, 1);
|
||||
assert.match(loaders[0].innerHTML, /Loading media/);
|
||||
assert.equal(images.length, 1);
|
||||
images[0].naturalWidth = 1280;
|
||||
await images[0].onload();
|
||||
assert.equal(await waiting, '<img src="/media/uploads/crop.png">');
|
||||
context.slideRenderRequestId = 2;
|
||||
const stale = context.waitForSlideImages('<img src="/media/uploads/crop.png">', 2);
|
||||
context.slideRenderRequestId = 3;
|
||||
images[1].naturalWidth = 1280;
|
||||
await images[1].onload();
|
||||
assert.equal(await stale, false);
|
||||
});
|
||||
|
||||
test('player watchdog does not reload while initial media is still loading', () => {
|
||||
const vm = require('node:vm');
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/player/public/js/player-page-transition.js'), 'utf8');
|
||||
const start = source.indexOf('function startPlayerWatchdog()');
|
||||
const end = source.indexOf('// Cancel the current slide-advance timer.', start);
|
||||
let tick;
|
||||
let reloaded = false;
|
||||
const context = vm.createContext({ playerWatchdogTimer: null, playerLastProgressAt: 0, playerWatchdogRecoveryKey: 'check', isPaused: false, slides: [{}], lastRenderedSlide: {}, document: { visibilityState: 'visible', querySelector() { return {}; } }, window: { setInterval(callback) { tick = callback; return 1; }, sessionStorage: { getItem() { return '0'; }, setItem() {} }, location: { reload() { reloaded = true; } } }, Date, getSlideAdvanceDelay() { return 1000; } });
|
||||
vm.runInContext(source.slice(start, end) + '\nstartPlayerWatchdog();', context);
|
||||
tick();
|
||||
assert.equal(reloaded, false);
|
||||
context.document.querySelector = () => null;
|
||||
tick();
|
||||
assert.equal(reloaded, true);
|
||||
});
|
||||
Reference in new issue
Block a user