Release 2.21.4: media library thumbnails and scrolling
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m20s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s

This commit is contained in:
lzstealth committed 2026-10-27 00:56:43 +00:00
1 parent 1b5a32cb05
commit b80d4aa9c7
11 files changed
+354 -42

No files matched your search

+116 -5
View File
@@ -16,7 +16,9 @@ const { removeUnusedImageCopies, removePendingMediaAssets } = require('../src/we
const {
normalizeMediaPath,
createImageVariants,
createImageThumbnail,
removeImageVariants,
ensureImageThumbnail,
optimizeUploadedImage,
registerMediaAssets,
syncMediaAssetsFromDirectory,
@@ -566,14 +568,19 @@ test('slide images retain original references and re-editable crops across saves
test('media library sorts Date-valued creation timestamps chronologically', async () => {
const originalSyncMediaAssets = mediaLibrary.syncMediaAssetsFromDirectory;
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
const originalEnsureImageThumbnail = mediaLibrary.ensureImageThumbnail;
const originalEnsureVideoThumbnail = mediaLibrary.ensureVideoThumbnail;
const assets = [
{ id: 1, original_name: 'October 14', media_type: 'image', file_size: 1, created_at: new Date('2026-10-14T12:00:00Z') },
{ id: 2, original_name: 'October 15', media_type: 'image', file_size: 1, created_at: new Date('2026-10-15T12:00:00Z') },
{ id: 3, original_name: 'October 17', media_type: 'image', file_size: 1, created_at: new Date('2026-10-17T12:00:00Z') }
{ id: 1, original_name: 'October 14', media_type: 'image', media_path: '/media/uploads/14.png', file_size: 1, created_at: new Date('2026-10-14T12:00:00Z') },
{ id: 2, original_name: 'October 15', media_type: 'image', media_path: '/media/uploads/15.png', file_size: 1, created_at: new Date('2026-10-15T12:00:00Z') },
{ id: 3, original_name: 'October 17', media_type: 'image', media_path: '/media/uploads/17.png', file_size: 1, created_at: new Date('2026-10-17T12:00:00Z') },
{ id: 4, original_name: 'October 13', media_type: 'video', media_path: '/media/uploads/13.mp4', file_size: 1, created_at: new Date('2026-10-13T12:00:00Z') }
];
const handlers = {};
mediaLibrary.syncMediaAssetsFromDirectory = async function () { return 0; };
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
mediaLibrary.ensureImageThumbnail = async function (mediaPath) { return mediaPath.replace(/\.png$/, '.thumb.webp'); };
mediaLibrary.ensureVideoThumbnail = async function (mediaPath) { return mediaPath.replace(/\.mp4$/, '.poster.webp'); };
try {
registerMediaLibraryRoutes({
@@ -591,14 +598,20 @@ test('media library sorts Date-valued creation timestamps chronologically', asyn
const next = function (error) { if (error) throw error; };
const request = { currentUser: { permissions: [] }, query: { offset: '0', limit: '24', sort: 'date-desc' } };
await handlers['/media-library/assets'](request, response, next);
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 17', 'October 15', 'October 14']);
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 17', 'October 15', 'October 14', 'October 13']);
assert.equal(response.data.assets[0].thumbnail_path, '/media/uploads/17.thumb.webp');
assert.equal(response.data.assets[0].media_path, '/media/uploads/17.png');
assert.equal(response.data.assets[3].thumbnail_path, '/media/uploads/13.poster.webp');
assert.equal(response.data.assets[3].media_path, '/media/uploads/13.mp4');
request.query.sort = 'date-asc';
await handlers['/media-library/assets'](request, response, next);
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 14', 'October 15', 'October 17']);
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 13', 'October 14', 'October 15', 'October 17']);
} finally {
mediaLibrary.syncMediaAssetsFromDirectory = originalSyncMediaAssets;
mediaLibrary.countMediaAssetReferences = originalCountReferences;
mediaLibrary.ensureImageThumbnail = originalEnsureImageThumbnail;
mediaLibrary.ensureVideoThumbnail = originalEnsureVideoThumbnail;
}
});
@@ -649,6 +662,48 @@ test('media library creates only smaller image variants and preserves the origin
}
});
test('media library creates compact WebP thumbnails and removes them with image derivatives', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-thumbnails-'));
const filePath = path.join(uploadDir, 'large.png');
const sourceBuffer = await sharp(crypto.randomBytes(1600 * 900 * 3), {
raw: { width: 1600, height: 900, channels: 3 }
}).png({ compressionLevel: 0 }).toBuffer();
await fs.promises.writeFile(filePath, sourceBuffer);
try {
const thumbnailUrl = await createImageThumbnail({ path: filePath });
const thumbnailPath = path.join(uploadDir, 'large.thumb.webp');
const thumbnailBuffer = await fs.promises.readFile(thumbnailPath);
const metadata = await sharp(thumbnailBuffer).metadata();
assert.equal(thumbnailUrl, '/media/uploads/large.thumb.webp');
assert.equal(metadata.format, 'webp');
assert.ok(metadata.width <= 480 && metadata.height <= 480);
assert.ok((await fs.promises.stat(thumbnailPath)).size < sourceBuffer.length);
assert.deepEqual(await fs.promises.readFile(filePath), sourceBuffer);
await removeImageVariants(filePath);
await assert.rejects(fs.promises.access(thumbnailPath), { code: 'ENOENT' });
} finally {
await fs.promises.rm(uploadDir, { recursive: true, force: true });
}
});
test('media library creates thumbnails for older assets when a gallery page requests them', async () => {
const mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-backfill-'));
const uploadDir = path.join(mediaRoot, 'uploads');
await fs.promises.mkdir(uploadDir);
const filePath = path.join(uploadDir, 'legacy.jpg');
await sharp({ create: { width: 900, height: 600, channels: 3, background: '#16835d' } }).jpeg().toFile(filePath);
try {
assert.equal(await ensureImageThumbnail('/media/uploads/legacy.jpg', uploadDir), '/media/uploads/legacy.thumb.webp');
assert.equal(await ensureImageThumbnail('/media/uploads/missing.jpg', uploadDir), null);
assert.equal(await ensureImageThumbnail('/media/../outside.jpg', uploadDir), null);
assert.ok((await fs.promises.stat(path.join(uploadDir, 'legacy.thumb.webp'))).size > 0);
} finally {
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
}
});
test('media library registers uploaded files with their metadata', async () => {
const queries = [];
const pool = {
@@ -681,6 +736,7 @@ test('media library registers uploaded files with their metadata', async () => {
test('media library backfills existing image and video uploads', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-library-'));
await fs.promises.writeFile(path.join(uploadDir, 'legacy.png'), Buffer.from('image'));
await fs.promises.writeFile(path.join(uploadDir, 'legacy.thumb.webp'), Buffer.from('thumbnail'));
await fs.promises.writeFile(path.join(uploadDir, 'legacy.mp4'), Buffer.from('video'));
await fs.promises.writeFile(path.join(uploadDir, 'ignore.txt'), Buffer.from('other'));
const queries = [];
@@ -746,6 +802,7 @@ test('full-size slide previews share the small thumbnail reference', async () =>
test('media library upload and delete routes audit successful asset changes', async () => {
const originalCreateVariants = mediaLibrary.createImageVariants;
const originalCreateThumbnail = mediaLibrary.createImageThumbnail;
const originalRegisterAsset = mediaLibrary.registerMediaAsset;
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
const originalRemoveVariants = mediaLibrary.removeImageVariants;
@@ -771,6 +828,7 @@ test('media library upload and delete routes audit successful asset changes', as
};
mediaLibrary.createImageVariants = async function () {};
mediaLibrary.createImageThumbnail = async function () {};
mediaLibrary.registerMediaAsset = async function () {};
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
mediaLibrary.removeImageVariants = async function () {};
@@ -820,6 +878,7 @@ test('media library upload and delete routes audit successful asset changes', as
assert.match(jsonResponse.data.error, /disabled/);
} finally {
mediaLibrary.createImageVariants = originalCreateVariants;
mediaLibrary.createImageThumbnail = originalCreateThumbnail;
mediaLibrary.registerMediaAsset = originalRegisterAsset;
mediaLibrary.countMediaAssetReferences = originalCountReferences;
mediaLibrary.removeImageVariants = originalRemoveVariants;
@@ -1019,9 +1078,61 @@ test('empty media gallery retains filters and its refreshable results container'
assert.match(html, /data-media-gallery-usage-filter/);
assert.match(html, /data-media-gallery-sort/);
assert.match(html, /class="media-gallery-scroll"/);
assert.match(html, /data-media-gallery-loading[^>]*role="status" aria-live="polite"/);
assert.doesNotMatch(html, /data-media-gallery-load-more>/);
assert.match(html, /data-media-gallery-no-results>/);
});
test('media gallery automatically loads the next page when its load-more sentinel approaches the scroll viewport', async () => {
const controls = {};
const mediaScroll = {};
const loadMoreWrap = { hidden: false };
const loading = { hidden: true };
let observedTarget;
let observerCallback;
let observerOptions;
let requestedUrl = '';
const gallery = {
dataset: { mediaGalleryOffset: '24', mediaGalleryHasMore: 'true' },
querySelector(selector) {
if (selector === '.media-gallery-scroll') return mediaScroll;
return controls[selector] || null;
},
querySelectorAll() { return []; },
addEventListener() {}
};
controls['[data-media-gallery-load-more-wrap]'] = loadMoreWrap;
controls['[data-media-gallery-loading]'] = loading;
class TestIntersectionObserver {
constructor(callback, options) { observerCallback = callback; observerOptions = options; }
observe(target) { observedTarget = target; }
}
const context = vm.createContext({
URLSearchParams,
window: { location: { search: '' }, innerWidth: 1200, IntersectionObserver: TestIntersectionObserver },
document: {
getElementById() { return null; },
querySelector(selector) { return selector === '.media-gallery' ? gallery : null; }
},
fetch: async function (url) {
requestedUrl = url;
return { ok: true, async json() { return { assets: [], total: 24, nextOffset: 24, hasMore: false }; } };
}
});
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'), context);
assert.equal(observedTarget, loadMoreWrap);
assert.equal(observerOptions.root, mediaScroll);
assert.equal(observerOptions.rootMargin, '0px');
observerCallback([{ isIntersecting: true }]);
assert.equal(loading.hidden, false);
await new Promise(resolve => setImmediate(resolve));
assert.match(requestedUrl, /^\/media-library\/assets\?/);
assert.equal(new URL(requestedUrl, 'http://localhost').searchParams.get('offset'), '24');
assert.equal(loadMoreWrap.hidden, true);
assert.equal(loading.hidden, true);
});
test('media rename validates filenames and changes only metadata with update permission and auditing', async () => {
const handlers = {};
const queries = [];