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

+6
View File
@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.21.4 - 2026-10-27
### Added
- The media library generates compact image thumbnails and video posters, and automatically loads more assets while scrolling.
## 2.21.3 - 2026-10-26 ## 2.21.3 - 2026-10-26
### Added ### Added
+1 -1
View File
@@ -2,7 +2,7 @@ FROM node:26.12.1-alpine
WORKDIR /app WORKDIR /app
RUN apk add --no-cache chromium nss freetype harfbuzz ttf-freefont RUN apk add --no-cache chromium ffmpeg nss freetype harfbuzz ttf-freefont
COPY build/package.web.json ./package.json COPY build/package.web.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund RUN npm install --omit=dev --no-audit --no-fund
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.21.3", "version": "2.21.4",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.21.3", "version": "2.21.4",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.21.3", "version": "2.21.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.21.3", "version": "2.21.4",
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.7.13", "@better-auth/passkey": "^1.7.13",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.21.3", "version": "2.21.4",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+165 -5
View File
@@ -2,6 +2,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { execFile } = require('child_process');
const { countContentReferenceOwners } = require('#src/data/content-references'); const { countContentReferenceOwners } = require('#src/data/content-references');
const crypto = require('crypto'); const crypto = require('crypto');
const sharp = require('sharp'); const sharp = require('sharp');
@@ -22,6 +23,8 @@ const MEDIA_MIME_TYPES = {
const OPTIMIZABLE_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']); const OPTIMIZABLE_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384]; const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384];
const IMAGE_THUMBNAIL_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg']);
const IMAGE_THUMBNAIL_MAX_DIMENSION = 480;
function getImageVariantPath(filePath, dimension) { function getImageVariantPath(filePath, dimension) {
const extension = path.extname(filePath); const extension = path.extname(filePath);
@@ -29,14 +32,167 @@ function getImageVariantPath(filePath, dimension) {
return path.join(path.dirname(filePath), baseName + '.variant-' + dimension + extension); return path.join(path.dirname(filePath), baseName + '.variant-' + dimension + extension);
} }
function getImageThumbnailPath(filePath) {
const extension = path.extname(filePath);
return path.join(path.dirname(filePath), path.basename(filePath, extension) + '.thumb.webp');
}
function getVideoPosterPath(filePath) {
const extension = path.extname(filePath);
return path.join(path.dirname(filePath), path.basename(filePath, extension) + '.poster.webp');
}
function runFfmpeg(args) {
return new Promise(function (resolve, reject) {
execFile('ffmpeg', args, { timeout: 12000, maxBuffer: 1024 * 1024, windowsHide: true }, function (error) {
if (error) return reject(error);
resolve();
});
});
}
async function createVideoThumbnail(file) {
const filePath = String(file && file.path || '').trim();
const extension = path.extname(filePath).toLowerCase();
if (!filePath || !MEDIA_MIME_TYPES[extension] || MEDIA_MIME_TYPES[extension].indexOf('video/') !== 0) {
return null;
}
const posterPath = getVideoPosterPath(filePath);
try {
await fs.promises.access(posterPath);
return '/media/uploads/' + path.basename(posterPath);
} catch (error) {
if (!error || error.code !== 'ENOENT') throw error;
}
const originalStats = await fs.promises.stat(filePath);
const temporaryPath = posterPath + '.partial-' + crypto.randomUUID();
try {
for (const seekSeconds of ['1', '0']) {
await fs.promises.unlink(temporaryPath).catch(function () {});
try {
await runFfmpeg([
'-hide_banner', '-loglevel', 'error', '-y', '-ss', seekSeconds, '-i', filePath,
'-frames:v', '1', '-an', '-sn', '-dn',
'-vf', "scale='min(480,iw)':'min(480,ih)':force_original_aspect_ratio=decrease",
'-c:v', 'libwebp', '-quality', '78', '-compression_level', '4', '-f', 'webp', temporaryPath
]);
} catch (error) {
continue;
}
const posterStats = await fs.promises.stat(temporaryPath).catch(function () { return null; });
if (!posterStats || !posterStats.size || posterStats.size >= originalStats.size) continue;
try {
await fs.promises.rename(temporaryPath, posterPath);
} catch (error) {
if (!error || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
await fs.promises.access(posterPath);
}
return '/media/uploads/' + path.basename(posterPath);
}
return null;
} finally {
await fs.promises.unlink(temporaryPath).catch(function () {});
}
}
async function createImageThumbnail(file) {
const filePath = String(file && file.path || '').trim();
const extension = path.extname(filePath).toLowerCase();
if (!filePath || !IMAGE_THUMBNAIL_EXTENSIONS.has(extension)) {
return null;
}
const thumbnailPath = getImageThumbnailPath(filePath);
try {
await fs.promises.access(thumbnailPath);
return '/media/uploads/' + path.basename(thumbnailPath);
} catch (error) {
if (!error || error.code !== 'ENOENT') throw error;
}
const originalStats = await fs.promises.stat(filePath);
const thumbnailBuffer = await sharp(filePath, { failOn: 'none' })
.rotate()
.resize({ width: IMAGE_THUMBNAIL_MAX_DIMENSION, height: IMAGE_THUMBNAIL_MAX_DIMENSION, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 78, effort: 4 })
.toBuffer();
if (!thumbnailBuffer.length || thumbnailBuffer.length >= originalStats.size) {
return null;
}
const temporaryPath = thumbnailPath + '.partial-' + crypto.randomUUID();
try {
await fs.promises.writeFile(temporaryPath, thumbnailBuffer);
try {
await fs.promises.rename(temporaryPath, thumbnailPath);
} catch (error) {
if (!error || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
await fs.promises.access(thumbnailPath);
}
} finally {
await fs.promises.unlink(temporaryPath).catch(function () {});
}
return '/media/uploads/' + path.basename(thumbnailPath);
}
async function ensureImageThumbnail(mediaPath, uploadDir) {
const normalizedPath = normalizeMediaPath(mediaPath);
if (!normalizedPath || !String(uploadDir || '').trim()) return null;
const extension = path.extname(normalizedPath).toLowerCase();
if (!IMAGE_THUMBNAIL_EXTENSIONS.has(extension)) return null;
const mediaRoot = path.resolve(path.dirname(uploadDir));
const filePath = path.resolve(mediaRoot, normalizedPath.slice('/media/'.length));
if (!filePath.startsWith(mediaRoot + path.sep)) return null;
try {
await fs.promises.access(filePath);
} catch (error) {
if (error && error.code === 'ENOENT') return null;
throw error;
}
const thumbnailPath = await createImageThumbnail({ path: filePath });
if (!thumbnailPath) return null;
return normalizedPath.slice(0, normalizedPath.length - extension.length) + '.thumb.webp';
}
async function ensureVideoThumbnail(mediaPath, uploadDir) {
const normalizedPath = normalizeMediaPath(mediaPath);
if (!normalizedPath || !String(uploadDir || '').trim()) return null;
const extension = path.extname(normalizedPath).toLowerCase();
if (!MEDIA_MIME_TYPES[extension] || MEDIA_MIME_TYPES[extension].indexOf('video/') !== 0) return null;
const mediaRoot = path.resolve(path.dirname(uploadDir));
const filePath = path.resolve(mediaRoot, normalizedPath.slice('/media/'.length));
if (!filePath.startsWith(mediaRoot + path.sep)) return null;
try {
await fs.promises.access(filePath);
} catch (error) {
if (error && error.code === 'ENOENT') return null;
throw error;
}
const posterUrl = await createVideoThumbnail({ path: filePath });
if (!posterUrl) return null;
return normalizedPath.slice(0, normalizedPath.length - extension.length) + '.poster.webp';
}
async function removeImageVariants(filePath) { async function removeImageVariants(filePath) {
const normalizedPath = String(filePath || '').trim(); const normalizedPath = String(filePath || '').trim();
const extension = path.extname(normalizedPath).toLowerCase(); const extension = path.extname(normalizedPath).toLowerCase();
if (!normalizedPath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) { if (!normalizedPath) return;
return; const derivativePaths = [getImageThumbnailPath(normalizedPath), getVideoPosterPath(normalizedPath)];
if (OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
derivativePaths.push(...IMAGE_VARIANT_DIMENSIONS.map(function (dimension) {
return getImageVariantPath(normalizedPath, dimension);
}));
} }
await Promise.all(IMAGE_VARIANT_DIMENSIONS.map(function (dimension) { await Promise.all(derivativePaths.map(function (derivativePath) {
return fs.promises.unlink(getImageVariantPath(normalizedPath, dimension)).catch(function (error) { return fs.promises.unlink(derivativePath).catch(function (error) {
if (error && error.code !== 'ENOENT') { if (error && error.code !== 'ENOENT') {
throw error; throw error;
} }
@@ -221,7 +377,7 @@ async function syncMediaAssetsFromDirectory(pool, uploadDir, userId) {
continue; continue;
} }
const originalName = String(entry.name || '').trim(); const originalName = String(entry.name || '').trim();
if (/\.variant-\d+\.(?:jpe?g|png|webp)$/i.test(originalName)) { if (/\.variant-\d+\.(?:jpe?g|png|webp)$/i.test(originalName) || /\.(?:thumb|poster)\.webp$/i.test(originalName)) {
continue; continue;
} }
const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()]; const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()];
@@ -308,6 +464,10 @@ module.exports = {
normalizeMediaPath, normalizeMediaPath,
createImageVariants, createImageVariants,
removeImageVariants, removeImageVariants,
createImageThumbnail,
ensureImageThumbnail,
createVideoThumbnail,
ensureVideoThumbnail,
optimizeUploadedImage, optimizeUploadedImage,
registerMediaAsset, registerMediaAsset,
registerMediaAssets, registerMediaAssets,
+38 -22
View File
@@ -18,7 +18,8 @@ if (search && initialGalleryQuery.has('search')) search.value = initialGalleryQu
const galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]'); const galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]');
const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]'); const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]');
const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]'); const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]');
const loadMoreButton = gallery && gallery.querySelector('[data-media-gallery-load-more]'); const galleryLoading = gallery && gallery.querySelector('[data-media-gallery-loading]');
const galleryScroll = gallery && gallery.querySelector('.media-gallery-scroll');
const selectModeButton = gallery && gallery.querySelector('[data-media-gallery-select-mode]'); const selectModeButton = gallery && gallery.querySelector('[data-media-gallery-select-mode]');
const deleteSelectedButton = gallery && gallery.querySelector('[data-media-gallery-delete-selected]'); const deleteSelectedButton = gallery && gallery.querySelector('[data-media-gallery-delete-selected]');
const addMediaButton = gallery && gallery.querySelector('[data-media-library-add]'); const addMediaButton = gallery && gallery.querySelector('[data-media-library-add]');
@@ -29,6 +30,7 @@ let galleryHasMore = gallery ? gallery.dataset.mediaGalleryHasMore === 'true' :
let galleryTotal = galleryCount ? Number(galleryCount.textContent || galleryItems.length) : galleryItems.length; let galleryTotal = galleryCount ? Number(galleryCount.textContent || galleryItems.length) : galleryItems.length;
let galleryRefreshSequence = 0; let galleryRefreshSequence = 0;
let selectionMode = false; let selectionMode = false;
let galleryLoadInProgress = false;
const selectedAssetIds = new Set(); const selectedAssetIds = new Set();
const uploadForm = document.querySelector('form[action="/media-library"]'); const uploadForm = document.querySelector('form[action="/media-library"]');
const uploadInput = document.querySelector('[data-media-library-upload]'); const uploadInput = document.querySelector('[data-media-library-upload]');
@@ -84,11 +86,12 @@ if (addMediaButton && uploadModal) {
preview.dataset.mediaName = asset.original_name || ''; preview.dataset.mediaName = asset.original_name || '';
preview.setAttribute('aria-label', 'Preview ' + (asset.original_name || 'media')); preview.setAttribute('aria-label', 'Preview ' + (asset.original_name || 'media'));
const media = asset.media_type === 'video' ? document.createElement('video') : document.createElement('img'); const media = asset.media_type === 'video' ? document.createElement('video') : document.createElement('img');
media.src = asset.media_path || ''; media.src = asset.thumbnail_path || asset.media_path || '';
if (asset.media_type === 'video') { if (asset.media_type === 'video') {
media.muted = true; media.muted = true;
media.playsInline = true; media.playsInline = true;
media.preload = 'metadata'; media.preload = 'none';
if (asset.thumbnail_path) media.poster = asset.thumbnail_path;
} else { } else {
media.alt = asset.original_name || ''; media.alt = asset.original_name || '';
media.loading = 'lazy'; media.loading = 'lazy';
@@ -478,25 +481,38 @@ if (gallery) {
} }
if (selectModeButton) selectModeButton.addEventListener('click', function () { setSelectionMode(!selectionMode); }); if (selectModeButton) selectModeButton.addEventListener('click', function () { setSelectionMode(!selectionMode); });
if (deleteSelectedButton) deleteSelectedButton.addEventListener('click', submitBulkDelete); if (deleteSelectedButton) deleteSelectedButton.addEventListener('click', submitBulkDelete);
if (loadMoreButton) { async function loadMoreMedia() {
loadMoreButton.addEventListener('click', async function () { if (!loadMoreWrap || !galleryHasMore || galleryLoadInProgress) return;
loadMoreButton.disabled = true; galleryLoadInProgress = true;
try { if (galleryLoading) galleryLoading.hidden = false;
const response = await fetch('/media-library/assets?' + getGalleryQuery(galleryOffset, 4 * getColumnsPerRow()), { headers: { Accept: 'application/json' } }); try {
if (!response.ok) throw new Error('Unable to load more media.'); const response = await fetch('/media-library/assets?' + getGalleryQuery(galleryOffset, 4 * getColumnsPerRow()), { headers: { Accept: 'application/json' } });
const result = await response.json(); if (!response.ok) throw new Error('Unable to load more media.');
(result.assets || []).forEach(appendGalleryAsset); const result = await response.json();
galleryOffset = Number(result.nextOffset || galleryOffset); (result.assets || []).forEach(appendGalleryAsset);
galleryHasMore = Boolean(result.hasMore); galleryOffset = Number(result.nextOffset || galleryOffset);
galleryTotal = Number(result.total || galleryTotal); galleryHasMore = Boolean(result.hasMore);
visibleRowLimit += 4; galleryTotal = Number(result.total || galleryTotal);
updateSearchResults(); visibleRowLimit += 4;
} catch (error) { updateSearchResults();
window.PulseUpload.showMessage(error.message || 'Unable to load more media.'); } catch (error) {
} finally { window.PulseUpload.showMessage(error.message || 'Unable to load more media.');
loadMoreButton.disabled = false; } finally {
} if (galleryLoading) galleryLoading.hidden = true;
}); galleryLoadInProgress = false;
}
}
if (loadMoreWrap && galleryScroll && window.IntersectionObserver) {
const loadMoreObserver = new window.IntersectionObserver(function (entries) {
if (entries.some(function (entry) { return entry.isIntersecting; })) loadMoreMedia();
}, { root: galleryScroll, rootMargin: '0px' });
loadMoreObserver.observe(loadMoreWrap);
} else if (loadMoreWrap && galleryScroll) {
galleryScroll.addEventListener('scroll', function () {
const remaining = galleryScroll.scrollHeight - galleryScroll.scrollTop - galleryScroll.clientHeight;
if (remaining <= 0) loadMoreMedia();
}, { passive: true });
} }
updateSearchResults(); updateSearchResults();
+17 -1
View File
@@ -152,8 +152,16 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
}); });
const settings = await fetchAppSettings(deps.pool); const settings = await fetchAppSettings(deps.pool);
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query)); const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
const pageAssets = filteredAssets.slice(0, pageSize);
await Promise.all(pageAssets.map(async function (asset) {
asset.thumbnail_path = asset.media_type === 'image'
? await mediaLibrary.ensureImageThumbnail(asset.media_path, deps.uploadDir)
: asset.media_type === 'video'
? await mediaLibrary.ensureVideoThumbnail(asset.media_path, deps.uploadDir)
: null;
}));
res.send(deps.pages.renderMediaLibraryPage({ res.send(deps.pages.renderMediaLibraryPage({
assets: filteredAssets.slice(0, pageSize), assets: pageAssets,
assetTotal: filteredAssets.length, assetTotal: filteredAssets.length,
hasMore: filteredAssets.length > pageSize, hasMore: filteredAssets.length > pageSize,
uploadLimits: getUploadLimits(settings) uploadLimits: getUploadLimits(settings)
@@ -181,6 +189,13 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
const limit = Math.min(pageSize, Math.max(1, Number.parseInt(req.query.limit, 10) || pageSize)); const limit = Math.min(pageSize, Math.max(1, Number.parseInt(req.query.limit, 10) || pageSize));
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query)); const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
const assets = filteredAssets.slice(offset, offset + limit); const assets = filteredAssets.slice(offset, offset + limit);
await Promise.all(assets.map(async function (asset) {
asset.thumbnail_path = asset.media_type === 'image'
? await mediaLibrary.ensureImageThumbnail(asset.media_path, deps.uploadDir)
: asset.media_type === 'video'
? await mediaLibrary.ensureVideoThumbnail(asset.media_path, deps.uploadDir)
: null;
}));
res.json({ assets: assets, total: filteredAssets.length, nextOffset: offset + assets.length, hasMore: offset + assets.length < filteredAssets.length }); res.json({ assets: assets, total: filteredAssets.length, nextOffset: offset + assets.length, hasMore: offset + assets.length < filteredAssets.length });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -214,6 +229,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
return rejectUpload('File must be ' + limitLabel + ' or smaller.'); return rejectUpload('File must be ' + limitLabel + ' or smaller.');
} }
await mediaLibrary.createImageVariants(req.file); await mediaLibrary.createImageVariants(req.file);
await mediaLibrary.createImageThumbnail(req.file);
const mediaPath = '/media/uploads/' + req.file.filename; const mediaPath = '/media/uploads/' + req.file.filename;
await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), mediaPath, deps.getAuditUserId(req)); await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), mediaPath, deps.getAuditUserId(req));
if (typeof recordRequestAuditEvent === 'function') { if (typeof recordRequestAuditEvent === 'function') {
@@ -54,10 +54,10 @@
<article class="media-gallery-card" data-media-created-at="{{created_at}}" data-media-file-size="{{file_size}}"> <article class="media-gallery-card" data-media-created-at="{{created_at}}" data-media-file-size="{{file_size}}">
<button type="button" class="media-gallery-preview" data-media-gallery-preview data-media-path="{{media_path}}" data-media-type="{{media_type}}" data-media-name="{{original_name}}" aria-label="Preview {{original_name}}"> <button type="button" class="media-gallery-preview" data-media-gallery-preview data-media-path="{{media_path}}" data-media-type="{{media_type}}" data-media-name="{{original_name}}" aria-label="Preview {{original_name}}">
{{#if (eq media_type 'video')}} {{#if (eq media_type 'video')}}
<video src="{{media_path}}" muted playsinline preload="metadata"></video> <video src="{{media_path}}" poster="{{thumbnail_path}}" muted playsinline preload="none"></video>
<span class="media-gallery-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span> <span class="media-gallery-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>
{{else}} {{else}}
<img src="{{media_path}}" alt="{{original_name}}" loading="lazy" /> <img src="{{#if thumbnail_path}}{{thumbnail_path}}{{else}}{{media_path}}{{/if}}" alt="{{original_name}}" loading="lazy" />
{{/if}} {{/if}}
<span class="media-gallery-type">{{mediaFormat}}</span> <span class="media-gallery-type">{{mediaFormat}}</span>
</button> </button>
@@ -81,7 +81,10 @@
{{/each}} {{/each}}
</div> </div>
<div class="text-center mt-4" data-media-gallery-load-more-wrap hidden> <div class="text-center mt-4" data-media-gallery-load-more-wrap hidden>
<button type="button" class="btn btn-primary px-4" data-media-gallery-load-more>Load more...</button> <div class="d-inline-flex align-items-center gap-2 text-body-secondary small" data-media-gallery-loading hidden role="status" aria-live="polite">
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span>Loading media...</span>
</div>
</div> </div>
</div> </div>
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results{{#if assets.length}} hidden{{/if}}>No media matches your search.</div> <div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results{{#if assets.length}} hidden{{/if}}>No media matches your search.</div>
+116 -5
View File
@@ -16,7 +16,9 @@ const { removeUnusedImageCopies, removePendingMediaAssets } = require('../src/we
const { const {
normalizeMediaPath, normalizeMediaPath,
createImageVariants, createImageVariants,
createImageThumbnail,
removeImageVariants, removeImageVariants,
ensureImageThumbnail,
optimizeUploadedImage, optimizeUploadedImage,
registerMediaAssets, registerMediaAssets,
syncMediaAssetsFromDirectory, 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 () => { test('media library sorts Date-valued creation timestamps chronologically', async () => {
const originalSyncMediaAssets = mediaLibrary.syncMediaAssetsFromDirectory; const originalSyncMediaAssets = mediaLibrary.syncMediaAssetsFromDirectory;
const originalCountReferences = mediaLibrary.countMediaAssetReferences; const originalCountReferences = mediaLibrary.countMediaAssetReferences;
const originalEnsureImageThumbnail = mediaLibrary.ensureImageThumbnail;
const originalEnsureVideoThumbnail = mediaLibrary.ensureVideoThumbnail;
const assets = [ const assets = [
{ id: 1, original_name: 'October 14', media_type: 'image', file_size: 1, created_at: new Date('2026-10-14T12: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', file_size: 1, created_at: new Date('2026-10-15T12: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', file_size: 1, created_at: new Date('2026-10-17T12: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 = {}; const handlers = {};
mediaLibrary.syncMediaAssetsFromDirectory = async function () { return 0; }; mediaLibrary.syncMediaAssetsFromDirectory = async function () { return 0; };
mediaLibrary.countMediaAssetReferences = 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 { try {
registerMediaLibraryRoutes({ registerMediaLibraryRoutes({
@@ -591,14 +598,20 @@ test('media library sorts Date-valued creation timestamps chronologically', asyn
const next = function (error) { if (error) throw error; }; const next = function (error) { if (error) throw error; };
const request = { currentUser: { permissions: [] }, query: { offset: '0', limit: '24', sort: 'date-desc' } }; const request = { currentUser: { permissions: [] }, query: { offset: '0', limit: '24', sort: 'date-desc' } };
await handlers['/media-library/assets'](request, response, next); 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'; request.query.sort = 'date-asc';
await handlers['/media-library/assets'](request, response, next); 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 { } finally {
mediaLibrary.syncMediaAssetsFromDirectory = originalSyncMediaAssets; mediaLibrary.syncMediaAssetsFromDirectory = originalSyncMediaAssets;
mediaLibrary.countMediaAssetReferences = originalCountReferences; 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 () => { test('media library registers uploaded files with their metadata', async () => {
const queries = []; const queries = [];
const pool = { 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 () => { test('media library backfills existing image and video uploads', async () => {
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-library-')); 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.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, 'legacy.mp4'), Buffer.from('video'));
await fs.promises.writeFile(path.join(uploadDir, 'ignore.txt'), Buffer.from('other')); await fs.promises.writeFile(path.join(uploadDir, 'ignore.txt'), Buffer.from('other'));
const queries = []; 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 () => { test('media library upload and delete routes audit successful asset changes', async () => {
const originalCreateVariants = mediaLibrary.createImageVariants; const originalCreateVariants = mediaLibrary.createImageVariants;
const originalCreateThumbnail = mediaLibrary.createImageThumbnail;
const originalRegisterAsset = mediaLibrary.registerMediaAsset; const originalRegisterAsset = mediaLibrary.registerMediaAsset;
const originalCountReferences = mediaLibrary.countMediaAssetReferences; const originalCountReferences = mediaLibrary.countMediaAssetReferences;
const originalRemoveVariants = mediaLibrary.removeImageVariants; 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.createImageVariants = async function () {};
mediaLibrary.createImageThumbnail = async function () {};
mediaLibrary.registerMediaAsset = async function () {}; mediaLibrary.registerMediaAsset = async function () {};
mediaLibrary.countMediaAssetReferences = async function () { return 0; }; mediaLibrary.countMediaAssetReferences = async function () { return 0; };
mediaLibrary.removeImageVariants = async function () {}; 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/); assert.match(jsonResponse.data.error, /disabled/);
} finally { } finally {
mediaLibrary.createImageVariants = originalCreateVariants; mediaLibrary.createImageVariants = originalCreateVariants;
mediaLibrary.createImageThumbnail = originalCreateThumbnail;
mediaLibrary.registerMediaAsset = originalRegisterAsset; mediaLibrary.registerMediaAsset = originalRegisterAsset;
mediaLibrary.countMediaAssetReferences = originalCountReferences; mediaLibrary.countMediaAssetReferences = originalCountReferences;
mediaLibrary.removeImageVariants = originalRemoveVariants; 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-usage-filter/);
assert.match(html, /data-media-gallery-sort/); assert.match(html, /data-media-gallery-sort/);
assert.match(html, /class="media-gallery-scroll"/); 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>/); 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 () => { test('media rename validates filenames and changes only metadata with update permission and auditing', async () => {
const handlers = {}; const handlers = {};
const queries = []; const queries = [];