Release 2.21.4: media library thumbnails and scrolling
This commit is contained in:
1 parent
1b5a32cb05
commit
b80d4aa9c7
11 files changed
+354
-42
No files matched your search
+116
-5
@@ -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 = [];
|
||||
|
||||
Reference in new issue
Block a user