From d7ddc953b94f4a0e80702173b5112b45f4df9d01 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Wed, 26 Nov 2025 22:13:58 -0800 Subject: [PATCH 01/22] Switch to folder-based database cleanup --- javascript/gallery.js | 95 ++++++++++++++++++++++++++++++------------- javascript/indexdb.js | 80 ++++++++++++++++++++++++++---------- 2 files changed, 125 insertions(+), 50 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index d3d8825dc..028f622c1 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -22,7 +22,19 @@ const el = { const SUPPORTED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'tiff', 'jp2', 'jxl', 'gif', 'mp4', 'mkv', 'avi', 'mjpeg', 'mpg', 'avr']; async function awaitForIDB(num = 0) { - while (outstanding > num || idbIsCleaning) await new Promise((resolve) => setTimeout(resolve, 50)); // eslint-disable-line no-promise-executor-return + while (outstanding > num || idbIsCleaning) await new Promise((resolve) => { setTimeout(resolve, 50); }); +} + +async function awaitForGallery(folderName, num = 0) { + let timeout = 0; + const timeoutThreshold = 60; // 30 seconds (60*0.5) + while (galleryHashes.size < num && activeGalleryFolder === folderName && !idbIsCleaning && timeout++ < timeoutThreshold) await new Promise((resolve) => { setTimeout(resolve, 500); }); // longer interval because it's a low priority check + if (timeout >= timeoutThreshold) { + throw new Error('Timed out waiting for gallery to populate'); + } + if (idbIsCleaning) { + throw new Error('Another thread has already started cleaning the database'); + } } // HTML Elements @@ -554,9 +566,21 @@ async function gallerySort(btn) { updateStatusWithSort(`${arr.length.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); } +/** + * Function for updating the cleaning overlay message + * @callback updateMsgCallback + * @param {number} progressPercent - Value for completion progress percentage + * @returns {void} + */ +/** + * Function for removing the cleaning overlay + * @callback clearMsgCallback + * @returns {void} + */ + /** * Generate and display the overlay to announce cleanup is in progress. - * @returns {() => void} Function for clearing the overlay + * @returns {[updateMsgCallback, clearMsgCallback]} */ function showCleaningMsg() { const parent = el.folders.parentElement; @@ -567,36 +591,54 @@ function showCleaningMsg() { parent.style.position = 'relative'; cleaningOverlay.style.cssText = 'position: absolute; height: 100%; width: 100%; background-color: hsl(210 50 20 / 0.8); display: flex; align-items: center; justify-content: center;'; msg.style.cssText = 'display: block; background-color: hsl(0 0 10); color: white; padding: 12px; border-radius: 8px; margin-right: 16px;'; - msg.innerText = 'Running thumbnail cleanup'; + msg.innerText = 'Thumbnail cleanup (0%)'; anim.classList.add('idbBusyAnim'); cleaningOverlay.append(msg, anim); parent.append(cleaningOverlay); - return () => { - parent.style.position = ''; - cleaningOverlay.remove(); - }; + return [ + (pct) => { + msg.innerText = `Thumbnail cleanup (${pct}%)`; + }, + () => { + parent.style.position = ''; + cleaningOverlay.remove(); + }, + ]; } -async function thumbCacheCleanup() { +/** + * IndexedDB thumbnail cache cleanup function + * @param {string} folder - Folder to clean + * @param {number} imgCount - Expected number of images in gallery + */ +async function thumbCacheCleanup(folder, imgCount) { if (idbIsCleaning) return; await awaitForIDB(); - idbIsCleaning = true; - - const t0 = performance.now(); - const cachedHashesCount = await idbCount() - .catch(() => 0); - if (cachedHashesCount < galleryHashes.size + 500) { - // Don't run when there aren't many excess entries - idbIsCleaning = false; + try { + await awaitForGallery(folder, imgCount); + } catch (err) { + log('Thumbnail DB cleanup:', err.message); return; } + if (activeGalleryFolder !== folder || idbIsCleaning) return; // First check for other thread activity - const removeOverlayFunc = showCleaningMsg(); - idbClean(galleryHashes, activeGalleryFolder) - .then((delcount, folder) => { + const t0 = performance.now(); + const staticGalleryHashes = new Set(galleryHashes); + const cachedHashesCount = await idbCount(folder) + .catch(() => 0); + if (cachedHashesCount < staticGalleryHashes.size + 500) { + // Don't run when there aren't many excess entries + return; + } + if (activeGalleryFolder !== folder || idbIsCleaning) return; // Second check for other thread activity + + idbIsCleaning = true; + const [updateCleaningMsg, removeOverlayFunc] = showCleaningMsg(); + idbClean(staticGalleryHashes, folder, updateCleaningMsg) + .then((delcount) => { const t1 = performance.now(); - log(`Thumbnail DB cleanup: folder=${folder} kept=${galleryHashes.size} deleted=${delcount} time=${Math.floor(t1 - t0)}ms`); + log(`Thumbnail DB cleanup: folder=${folder} kept=${staticGalleryHashes.size} deleted=${delcount} time=${Math.floor(t1 - t0)}ms`); }) .catch((err) => { error('Thumbnail DB cleanup: Cleanup failed.', err.message); @@ -633,27 +675,25 @@ async function fetchFilesHT(evt) { el.files.appendChild(fragment); const t1 = performance.now(); - activeGalleryFolder = evt.target.name; log(`gallery: folder=${evt.target.name} num=${numFiles} time=${Math.floor(t1 - t0)}ms`); updateStatusWithSort(`Folder: ${evt.target.name} | ${numFiles.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); - addSeparators(); - thumbCacheCleanup(); + thumbCacheCleanup(evt.target.name, numFiles); } async function fetchFilesWS(evt) { // fetch file-by-file list over websockets - if (idbIsCleaning) return; + if (idbIsCleaning || !url) return; galleryHashes.clear(); // Only called here because fetchFilesHT isn't called directly el.files.innerHTML = ''; - if (!url) return; if (ws && ws.readyState === WebSocket.OPEN) ws.close(); // abort previous request let wsConnected = false; try { ws = new WebSocket(`${url}/sdapi/v1/browser/files`); - wsConnected = await wsConnect(ws); + wsConnected = await wsConnect(ws); // Warning. This changes "evt". } catch (err) { log('gallery: ws connect error', err); return; } + activeGalleryFolder = evt.target.name; log(`gallery: connected=${wsConnected} state=${ws?.readyState} url=${ws?.url}`); if (!wsConnected) { await fetchFilesHT(evt); // fallback to http @@ -688,11 +728,10 @@ async function fetchFilesWS(evt) { // fetch file-by-file list over websockets ws.onclose = (event) => { el.files.appendChild(fragment); // gallerySort(); - activeGalleryFolder = evt.target.name; log(`gallery: folder=${evt.target.name} num=${numFiles} time=${Math.floor(t1 - t0)}ms`); updateStatusWithSort(`Folder: ${evt.target.name} | ${numFiles.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); addSeparators(); - thumbCacheCleanup(); + thumbCacheCleanup(evt.target.name, numFiles); }; ws.onerror = (event) => { log('gallery ws error', event); diff --git a/javascript/indexdb.js b/javascript/indexdb.js index ee84cde12..490eb46f6 100644 --- a/javascript/indexdb.js +++ b/javascript/indexdb.js @@ -1,9 +1,12 @@ -let db; +/** + * @type {?IDBDatabase} + */ +let db = null; async function initIndexDB() { async function createDB() { return new Promise((resolve, reject) => { - const request = indexedDB.open('SDNext'); + const request = indexedDB.open('SDNext', 2); request.onerror = (evt) => reject(evt); request.onsuccess = (evt) => { db = evt.target.result; @@ -16,9 +19,15 @@ async function initIndexDB() { }; request.onupgradeneeded = (evt) => { db = evt.target.result; - const store = db.createObjectStore('thumbs', { keyPath: 'hash' }); - store.createIndex('hash', 'hash', { unique: true }); - const index = store.index('hash'); + const oldver = evt.oldVersion; + if (oldver < 1) { + const store = db.createObjectStore('thumbs', { keyPath: 'hash' }); + store.createIndex('hash', 'hash', { unique: true }); + } + if (oldver < 2) { + const existingStore = request.transaction.objectStore('thumbs'); + existingStore.createIndex('folder', 'folder', { unique: false }); + } resolve(); }; }); @@ -75,54 +84,81 @@ async function put(record) { }); } -async function idbGetAllKeys() { +async function idbGetAllKeys(index = null, query = null) { if (!db) return null; return new Promise((resolve, reject) => { - const request = db + let request; + const store = db .transaction('thumbs', 'readonly') - .objectStore('thumbs') - .getAllKeys(); + .objectStore('thumbs'); + if (index) { + request = store.index(index).getAllKeys(query); + } else { + request = store.getAllKeys(query); + } request.onsuccess = () => resolve(request.result); request.onerror = (evt) => reject(evt); }); } -async function idbCount() { +async function idbCount(folder = null) { if (!db) return null; return new Promise((resolve, reject) => { - const request = db + let request; + const store = db .transaction('thumbs', 'readonly') - .objectStore('thumbs') - .count(); + .objectStore('thumbs'); + if (folder) { + request = store.index('folder').count(folder); + } else { + request = store.count(); + } request.onsuccess = () => resolve(request.result); request.onerror = (evt) => reject(evt); }); } -async function idbClean(keepSet, folder = null) { +/** + * @param {Set} keepSet - Set containing the hashes of the current files in the folder. + * @param {string} folder - Folder name/path + * @param {updateMsgCallback} msgCallback - Callback for updating progress display + */ +async function idbClean(keepSet, folder, msgCallback) { if (!db) return null; if (!(keepSet instanceof Set)) { throw new TypeError('IndexedDB cleaning function must be given a Set() of hashes to keep'); } - if (folder === null) { + if (!folder) { throw new Error('IndexedDB cleaning function must be told the current active folder'); } + const folderCached = new Set(await idbGetAllKeys('folder', folder)); + const removals = folderCached.difference(keepSet); + const totalRemovals = removals.size; + let counter = 0; return new Promise((resolve, reject) => { - let counter = 0; - const request = db + const folderIndex = db .transaction('thumbs', 'readwrite') .objectStore('thumbs') - .openCursor(); + .index('folder'); + const request = folderIndex.openCursor(folder); + request.onsuccess = (evt) => { const cursor = evt.target.result; if (cursor) { - if (folder === cursor.value.folder && !keepSet.has(cursor.key)) { - cursor.delete(); + if (removals.has(cursor.primaryKey)) { counter++; + cursor.delete(); + } + if (counter === totalRemovals) { + resolve(counter); // Got lucky with element order and can stop early + } else { + if (counter % 100 === 0 && counter !== 0) { + msgCallback(Math.floor((counter / totalRemovals) * 100)); + } + cursor.continue(); } - cursor.continue(); } else { - resolve(counter, folder); + resolve(counter); } }; request.onerror = (evt) => reject(evt); From 8197cbefaa3545cc8512df732df1bb88b29312b4 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Thu, 27 Nov 2025 18:34:11 -0800 Subject: [PATCH 02/22] Update names/info and ensure folder is string --- .eslintrc.json | 2 +- javascript/gallery.js | 2 +- javascript/indexdb.js | 9 ++++----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index a957e1335..b5ae04c04 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -117,7 +117,7 @@ "idbDel": "readonly", "idbAdd": "readonly", "idbCount": "readonly", - "idbClean": "readonly", + "idbFolderCleanup": "readonly", "initChangelog": "readonly", "sendNotification": "readonly", "monitorConnection": "readonly" diff --git a/javascript/gallery.js b/javascript/gallery.js index 028f622c1..ca1e524d5 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -635,7 +635,7 @@ async function thumbCacheCleanup(folder, imgCount) { idbIsCleaning = true; const [updateCleaningMsg, removeOverlayFunc] = showCleaningMsg(); - idbClean(staticGalleryHashes, folder, updateCleaningMsg) + idbFolderCleanup(staticGalleryHashes, folder, updateCleaningMsg) .then((delcount) => { const t1 = performance.now(); log(`Thumbnail DB cleanup: folder=${folder} kept=${staticGalleryHashes.size} deleted=${delcount} time=${Math.floor(t1 - t0)}ms`); diff --git a/javascript/indexdb.js b/javascript/indexdb.js index 490eb46f6..0adb76b57 100644 --- a/javascript/indexdb.js +++ b/javascript/indexdb.js @@ -123,16 +123,15 @@ async function idbCount(folder = null) { * @param {string} folder - Folder name/path * @param {updateMsgCallback} msgCallback - Callback for updating progress display */ -async function idbClean(keepSet, folder, msgCallback) { +async function idbFolderCleanup(keepSet, folder, msgCallback) { if (!db) return null; if (!(keepSet instanceof Set)) { - throw new TypeError('IndexedDB cleaning function must be given a Set() of hashes to keep'); + throw new TypeError('IndexedDB cleaning function must be given a Set() of the current gallery hashes'); } - if (!folder) { + if (!folder || typeof folder !== 'string') { throw new Error('IndexedDB cleaning function must be told the current active folder'); } - const folderCached = new Set(await idbGetAllKeys('folder', folder)); - const removals = folderCached.difference(keepSet); + const removals = (new Set(await idbGetAllKeys('folder', folder))).difference(keepSet); const totalRemovals = removals.size; let counter = 0; return new Promise((resolve, reject) => { From 7e2bf5eb139621dd7195aa3ab802c84f0b6cc140 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Thu, 27 Nov 2025 19:28:40 -0800 Subject: [PATCH 03/22] Update JSDoc --- javascript/indexdb.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/javascript/indexdb.js b/javascript/indexdb.js index 0adb76b57..e823b88dd 100644 --- a/javascript/indexdb.js +++ b/javascript/indexdb.js @@ -101,6 +101,12 @@ async function idbGetAllKeys(index = null, query = null) { }); } +/** + * Get the number of entries in the IndexedDB thumbnail cache. + * @global + * @param {?string} folder - If specified, get the count for this gallery folder. Otherwise get the total count. + * @returns {Promise} + */ async function idbCount(folder = null) { if (!db) return null; return new Promise((resolve, reject) => { @@ -119,9 +125,11 @@ async function idbCount(folder = null) { } /** + * Cleanup function for IndexedDB thumbnail cache. + * @global * @param {Set} keepSet - Set containing the hashes of the current files in the folder. - * @param {string} folder - Folder name/path - * @param {updateMsgCallback} msgCallback - Callback for updating progress display + * @param {string} folder - Folder name/path. + * @param {updateMsgCallback} msgCallback - Callback for updating the overlay message progress. */ async function idbFolderCleanup(keepSet, folder, msgCallback) { if (!db) return null; From 8bf7fb50edad9403f86eb7c4a46d62ae1496b30b Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Thu, 27 Nov 2025 20:27:47 -0800 Subject: [PATCH 04/22] Restore accidental line deletion --- javascript/gallery.js | 1 + 1 file changed, 1 insertion(+) diff --git a/javascript/gallery.js b/javascript/gallery.js index ca1e524d5..d20e82284 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -677,6 +677,7 @@ async function fetchFilesHT(evt) { const t1 = performance.now(); log(`gallery: folder=${evt.target.name} num=${numFiles} time=${Math.floor(t1 - t0)}ms`); updateStatusWithSort(`Folder: ${evt.target.name} | ${numFiles.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); + addSeparators(); thumbCacheCleanup(evt.target.name, numFiles); } From 9ceed935ceca820f670c2edbaa7c5a6a7f151e8c Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:21:24 -0800 Subject: [PATCH 05/22] Naming changes and additional check --- javascript/gallery.js | 19 +++++++++++-------- javascript/indexdb.js | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index d20e82284..d69b09e3c 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -568,19 +568,19 @@ async function gallerySort(btn) { /** * Function for updating the cleaning overlay message - * @callback updateMsgCallback + * @callback UpdateMsgCallback * @param {number} progressPercent - Value for completion progress percentage * @returns {void} */ /** * Function for removing the cleaning overlay - * @callback clearMsgCallback + * @callback ClearMsgCallback * @returns {void} */ /** * Generate and display the overlay to announce cleanup is in progress. - * @returns {[updateMsgCallback, clearMsgCallback]} + * @returns {[UpdateMsgCallback, ClearMsgCallback]} */ function showCleaningMsg() { const parent = el.folders.parentElement; @@ -608,14 +608,17 @@ function showCleaningMsg() { } /** - * IndexedDB thumbnail cache cleanup function + * Handles calling the cleanup function for the thumbnail cache * @param {string} folder - Folder to clean * @param {number} imgCount - Expected number of images in gallery */ async function thumbCacheCleanup(folder, imgCount) { if (idbIsCleaning) return; - await awaitForIDB(); try { + if (typeof folder !== 'string' || typeof imgCount !== 'number') { + throw new Error('Function called with invalid arguments'); + } + await awaitForIDB(); await awaitForGallery(folder, imgCount); } catch (err) { log('Thumbnail DB cleanup:', err.message); @@ -634,8 +637,8 @@ async function thumbCacheCleanup(folder, imgCount) { if (activeGalleryFolder !== folder || idbIsCleaning) return; // Second check for other thread activity idbIsCleaning = true; - const [updateCleaningMsg, removeOverlayFunc] = showCleaningMsg(); - idbFolderCleanup(staticGalleryHashes, folder, updateCleaningMsg) + const [cb_updateMsg, cb_clearMsg] = showCleaningMsg(); + idbFolderCleanup(staticGalleryHashes, folder, cb_updateMsg) .then((delcount) => { const t1 = performance.now(); log(`Thumbnail DB cleanup: folder=${folder} kept=${staticGalleryHashes.size} deleted=${delcount} time=${Math.floor(t1 - t0)}ms`); @@ -644,7 +647,7 @@ async function thumbCacheCleanup(folder, imgCount) { error('Thumbnail DB cleanup: Cleanup failed.', err.message); }) .finally(() => { - removeOverlayFunc(); + cb_clearMsg(); idbIsCleaning = false; }); } diff --git a/javascript/indexdb.js b/javascript/indexdb.js index e823b88dd..cba86e8eb 100644 --- a/javascript/indexdb.js +++ b/javascript/indexdb.js @@ -129,7 +129,7 @@ async function idbCount(folder = null) { * @global * @param {Set} keepSet - Set containing the hashes of the current files in the folder. * @param {string} folder - Folder name/path. - * @param {updateMsgCallback} msgCallback - Callback for updating the overlay message progress. + * @param {UpdateMsgCallback} msgCallback - Callback for updating the overlay message progress. */ async function idbFolderCleanup(keepSet, folder, msgCallback) { if (!db) return null; From 1b5295dd5e5ec2dc4be80709277cbf0e54fa4228 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:37:48 -0800 Subject: [PATCH 06/22] Remove redundant check --- javascript/indexdb.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/javascript/indexdb.js b/javascript/indexdb.js index cba86e8eb..b632b358f 100644 --- a/javascript/indexdb.js +++ b/javascript/indexdb.js @@ -136,7 +136,7 @@ async function idbFolderCleanup(keepSet, folder, msgCallback) { if (!(keepSet instanceof Set)) { throw new TypeError('IndexedDB cleaning function must be given a Set() of the current gallery hashes'); } - if (!folder || typeof folder !== 'string') { + if (typeof folder !== 'string') { throw new Error('IndexedDB cleaning function must be told the current active folder'); } const removals = (new Set(await idbGetAllKeys('folder', folder))).difference(keepSet); From eb4f4c683c2f157c6d9cbcaed4ca5aa755b3f106 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:26:03 -0800 Subject: [PATCH 07/22] Improve error handling --- javascript/gallery.js | 8 +++- javascript/indexdb.js | 97 ++++++++++++++++++++++++------------------- 2 files changed, 60 insertions(+), 45 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index d69b09e3c..05c8e15b4 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -643,8 +643,12 @@ async function thumbCacheCleanup(folder, imgCount) { const t1 = performance.now(); log(`Thumbnail DB cleanup: folder=${folder} kept=${staticGalleryHashes.size} deleted=${delcount} time=${Math.floor(t1 - t0)}ms`); }) - .catch((err) => { - error('Thumbnail DB cleanup: Cleanup failed.', err.message); + .catch((reason) => { + if (reason instanceof Error) { + error('Thumbnail DB cleanup: Cleanup failed.', reason.message); + } else { + log('Thumbnail DB cleanup:', reason); + } }) .finally(() => { cb_clearMsg(); diff --git a/javascript/indexdb.js b/javascript/indexdb.js index b632b358f..1b391d9c0 100644 --- a/javascript/indexdb.js +++ b/javascript/indexdb.js @@ -87,17 +87,21 @@ async function put(record) { async function idbGetAllKeys(index = null, query = null) { if (!db) return null; return new Promise((resolve, reject) => { - let request; - const store = db - .transaction('thumbs', 'readonly') - .objectStore('thumbs'); - if (index) { - request = store.index(index).getAllKeys(query); - } else { - request = store.getAllKeys(query); + try { + let request; + const transaction = db.transaction('thumbs', 'readonly'); + const store = transaction.objectStore('thumbs'); + if (index) { + request = store.index(index).getAllKeys(query); + } else { + request = store.getAllKeys(query); + } + request.onsuccess = () => resolve(request.result); + request.onerror = (e) => reject(e); + transaction.onabort = (e) => reject(e); + } catch (err) { + reject(err); } - request.onsuccess = () => resolve(request.result); - request.onerror = (evt) => reject(evt); }); } @@ -110,17 +114,21 @@ async function idbGetAllKeys(index = null, query = null) { async function idbCount(folder = null) { if (!db) return null; return new Promise((resolve, reject) => { - let request; - const store = db - .transaction('thumbs', 'readonly') - .objectStore('thumbs'); - if (folder) { - request = store.index('folder').count(folder); - } else { - request = store.count(); + try { + let request; + const transaction = db.transaction('thumbs', 'readonly'); + const store = transaction.objectStore('thumbs'); + if (folder) { + request = store.index('folder').count(folder); + } else { + request = store.count(); + } + request.onsuccess = () => resolve(request.result); + request.onerror = (e) => reject(e); + transaction.onabort = (e) => reject(e); + } catch (err) { + reject(err); } - request.onsuccess = () => resolve(request.result); - request.onerror = (evt) => reject(evt); }); } @@ -143,32 +151,35 @@ async function idbFolderCleanup(keepSet, folder, msgCallback) { const totalRemovals = removals.size; let counter = 0; return new Promise((resolve, reject) => { - const folderIndex = db - .transaction('thumbs', 'readwrite') - .objectStore('thumbs') - .index('folder'); - const request = folderIndex.openCursor(folder); + try { + const transaction = db.transaction('thumbs', 'readwrite'); + const folderIndex = transaction.objectStore('thumbs').index('folder'); + const request = folderIndex.openCursor(folder); - request.onsuccess = (evt) => { - const cursor = evt.target.result; - if (cursor) { - if (removals.has(cursor.primaryKey)) { - counter++; - cursor.delete(); - } - if (counter === totalRemovals) { - resolve(counter); // Got lucky with element order and can stop early - } else { - if (counter % 100 === 0 && counter !== 0) { - msgCallback(Math.floor((counter / totalRemovals) * 100)); + request.onsuccess = (evt) => { + const cursor = evt.target.result; + if (cursor) { + if (removals.has(cursor.primaryKey)) { + counter++; + cursor.delete(); } - cursor.continue(); + if (counter === totalRemovals) { + resolve(counter); // Got lucky with element order and can stop early + } else { + if (counter % 100 === 0 && counter !== 0) { + msgCallback(Math.floor((counter / totalRemovals) * 100)); + } + cursor.continue(); + } + } else { + resolve(counter); } - } else { - resolve(counter); - } - }; - request.onerror = (evt) => reject(evt); + }; + request.onerror = (e) => reject(e); + transaction.onabort = (e) => reject(e); + } catch (err) { + reject(err); + } }); } From c3986172d5e39bbd61761ca0cb2fad694c8ea9b0 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Sat, 29 Nov 2025 01:09:43 -0800 Subject: [PATCH 08/22] Don't run on error --- javascript/gallery.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index 05c8e15b4..fcad81a74 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -629,7 +629,7 @@ async function thumbCacheCleanup(folder, imgCount) { const t0 = performance.now(); const staticGalleryHashes = new Set(galleryHashes); const cachedHashesCount = await idbCount(folder) - .catch(() => 0); + .catch(() => Infinity); // Forces next check to fail if something went wrong if (cachedHashesCount < staticGalleryHashes.size + 500) { // Don't run when there aren't many excess entries return; From 29a47933021c6ca2ab8613dc3d28af9eb0432343 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Sat, 29 Nov 2025 03:19:33 -0800 Subject: [PATCH 09/22] Much faster performance for normal runs - May result in the initial run being slower in some circumstances. --- javascript/indexdb.js | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/javascript/indexdb.js b/javascript/indexdb.js index 1b391d9c0..88da54835 100644 --- a/javascript/indexdb.js +++ b/javascript/indexdb.js @@ -147,36 +147,25 @@ async function idbFolderCleanup(keepSet, folder, msgCallback) { if (typeof folder !== 'string') { throw new Error('IndexedDB cleaning function must be told the current active folder'); } - const removals = (new Set(await idbGetAllKeys('folder', folder))).difference(keepSet); + + let removals = new Set(await idbGetAllKeys('folder', folder)); + removals = removals.difference(keepSet); // Don't need to keep full set in memory const totalRemovals = removals.size; - let counter = 0; + return new Promise((resolve, reject) => { try { const transaction = db.transaction('thumbs', 'readwrite'); - const folderIndex = transaction.objectStore('thumbs').index('folder'); - const request = folderIndex.openCursor(folder); + const store = transaction.objectStore('thumbs'); - request.onsuccess = (evt) => { - const cursor = evt.target.result; - if (cursor) { - if (removals.has(cursor.primaryKey)) { - counter++; - cursor.delete(); - } - if (counter === totalRemovals) { - resolve(counter); // Got lucky with element order and can stop early - } else { - if (counter % 100 === 0 && counter !== 0) { - msgCallback(Math.floor((counter / totalRemovals) * 100)); - } - cursor.continue(); - } - } else { - resolve(counter); + removals = Array.from(removals); + for (let index = 0; index < totalRemovals; index++) { + const entry = removals[index]; + store.delete(entry); + if (index % 100 === 0 && index !== 0) { + msgCallback(Math.floor((index / totalRemovals) * 100)); } - }; - request.onerror = (e) => reject(e); - transaction.onabort = (e) => reject(e); + } + resolve(totalRemovals); } catch (err) { reject(err); } From b4e5db478f2fcd5ad47bb32294c88fcf9be1b544 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:27:56 -0800 Subject: [PATCH 10/22] Merge updated gallery maintenance system (#3) * Add eslint rule for class members * Finalize gallery maintenance system - Adds a simple function queue in lieu of the Web Locks API in order to work in the (probably unlikely) event that SD.Next is accessed over regular HTTP but not from localhost. - Removes overlay message progress update since it's incompatible with the new method. --- javascript/gallery.js | 210 +++++++++++++++++++++++++++--------------- javascript/indexdb.js | 53 +++++++---- 2 files changed, 170 insertions(+), 93 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index fcad81a74..00a3c1a2f 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -1,4 +1,5 @@ /* eslint-disable max-classes-per-file */ +/* eslint lines-between-class-members: ["error", "always", { "exceptAfterSingleLine": true }] */ let ws; let url; let currentImage; @@ -6,9 +7,8 @@ let pruneImagesTimer; let outstanding = 0; let lastSort = 0; let lastSortName = 'None'; -let idbIsCleaning = false; -let activeGalleryFolder = ''; const galleryHashes = new Set(); +let maintenanceController = new AbortController(); // Store separator states for the session const separatorStates = new Map(); const el = { @@ -21,19 +21,74 @@ const el = { const SUPPORTED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'tiff', 'jp2', 'jxl', 'gif', 'mp4', 'mkv', 'avi', 'mjpeg', 'mpg', 'avr']; -async function awaitForIDB(num = 0) { - while (outstanding > num || idbIsCleaning) await new Promise((resolve) => { setTimeout(resolve, 50); }); +async function awaitForIDB(num = 0, signal = null) { + const timeout = AbortSignal.timeout(30000); + const combinedSignals = signal ? AbortSignal.any([timeout, signal]) : timeout; + while (outstanding > num && !combinedSignals.aborted) await new Promise((resolve) => { setTimeout(resolve, 50); }); } -async function awaitForGallery(folderName, num = 0) { - let timeout = 0; - const timeoutThreshold = 60; // 30 seconds (60*0.5) - while (galleryHashes.size < num && activeGalleryFolder === folderName && !idbIsCleaning && timeout++ < timeoutThreshold) await new Promise((resolve) => { setTimeout(resolve, 500); }); // longer interval because it's a low priority check - if (timeout >= timeoutThreshold) { - throw new Error('Timed out waiting for gallery to populate'); +/** + * Wait for gallery to finish populating + * @param {number} expectedSize - Expected gallery size + * @param {AbortSignal} signal - AbortController signal + */ +async function awaitForGallery(expectedSize, signal) { + const timeout = AbortSignal.timeout(60000); + const combinedSignals = AbortSignal.any([timeout, signal]); + while (galleryHashes.size < expectedSize && !combinedSignals.aborted) await new Promise((resolve) => { setTimeout(resolve, 500); }); // longer interval because it's a low priority check + if (timeout.aborted) { + throw 'Timed out waiting for gallery to populate'; // eslint-disable-line no-throw-literal } - if (idbIsCleaning) { - throw new Error('Another thread has already started cleaning the database'); +} + +// Classes + +class SimpleFunctionQueue { + /* This isn't as robust as the Web Locks API, but it will at least work if accessing a remote machine without HTTPS */ + #id; + #running; + #queue; + + constructor(id) { + this.#id = id; + this.#running = false; + this.#queue = []; + } + + /** + * @param {{ + * signal: AbortSignal, + * callback: Function + * }} config + */ + enqueue(config) { + if (!(config.signal instanceof AbortSignal) || typeof config.callback !== 'function') { + throw new Error('Invalid configuration. Object must contain an AbortSignal and a function'); + } + if (config.signal.aborted) { + debug(`${this.#id} Queue: Skipping addition to queue due to "${config.signal.reason}"`); + } + this.#queue.push(config); + if (!this.busy) { + this.#runNext(); + } + } + + async #runNext() { + if (this.#running || !this.#queue.length) return; + try { + const { signal, callback } = this.#queue.shift(); + if (signal.aborted) { + return; + } + this.#running = true; + await callback(); + } catch (err) { + error(`${this.#id} Queue:`, err); + } finally { + this.#running = false; + this.#runNext(); + } } } @@ -220,10 +275,13 @@ async function delayFetchThumb(fn) { } class GalleryFile extends HTMLElement { - constructor(folder, file) { + #gallerySignal; + + constructor(folder, file, signal = undefined) { super(); this.folder = folder; this.name = file; + this.#gallerySignal = signal; this.size = 0; this.mtime = 0; this.hash = undefined; @@ -250,7 +308,6 @@ class GalleryFile extends HTMLElement { } this.hash = await getHash(`${this.folder}/${this.name}/${this.size}/${this.mtime}`); // eslint-disable-line no-use-before-define - galleryHashes.add(this.hash); const style = document.createElement('style'); const width = opts.browser_fixed_width ? `${opts.extra_networks_card_size}px` : 'unset'; style.textContent = ` @@ -318,6 +375,11 @@ class GalleryFile extends HTMLElement { img.src = `file=${this.src}`; } } + if (!this.#gallerySignal?.aborted) { + // Guard against accessing external context from a stale initialization + galleryHashes.add(this.hash); // Add to hashes Set *after* any database operations + this.#gallerySignal = null; // Clean up reference to AbortSignal + } if (!ok) { return; } @@ -566,12 +628,6 @@ async function gallerySort(btn) { updateStatusWithSort(`${arr.length.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); } -/** - * Function for updating the cleaning overlay message - * @callback UpdateMsgCallback - * @param {number} progressPercent - Value for completion progress percentage - * @returns {void} - */ /** * Function for removing the cleaning overlay * @callback ClearMsgCallback @@ -580,7 +636,7 @@ async function gallerySort(btn) { /** * Generate and display the overlay to announce cleanup is in progress. - * @returns {[UpdateMsgCallback, ClearMsgCallback]} + * @returns {ClearMsgCallback} */ function showCleaningMsg() { const parent = el.folders.parentElement; @@ -591,72 +647,77 @@ function showCleaningMsg() { parent.style.position = 'relative'; cleaningOverlay.style.cssText = 'position: absolute; height: 100%; width: 100%; background-color: hsl(210 50 20 / 0.8); display: flex; align-items: center; justify-content: center;'; msg.style.cssText = 'display: block; background-color: hsl(0 0 10); color: white; padding: 12px; border-radius: 8px; margin-right: 16px;'; - msg.innerText = 'Thumbnail cleanup (0%)'; + msg.innerText = 'Thumbnail cleanup...'; anim.classList.add('idbBusyAnim'); cleaningOverlay.append(msg, anim); parent.append(cleaningOverlay); - return [ - (pct) => { - msg.innerText = `Thumbnail cleanup (${pct}%)`; - }, - () => { - parent.style.position = ''; - cleaningOverlay.remove(); - }, - ]; + return () => { cleaningOverlay.remove(); }; } +const maintenanceQueue = new SimpleFunctionQueue('Maintenance'); + /** * Handles calling the cleanup function for the thumbnail cache * @param {string} folder - Folder to clean * @param {number} imgCount - Expected number of images in gallery + * @param {AbortController} controller - AbortController that's handling this task */ -async function thumbCacheCleanup(folder, imgCount) { - if (idbIsCleaning) return; +async function thumbCacheCleanup(folder, imgCount, controller) { try { if (typeof folder !== 'string' || typeof imgCount !== 'number') { throw new Error('Function called with invalid arguments'); } - await awaitForIDB(); - await awaitForGallery(folder, imgCount); + debug('Thumbnail DB cleanup: Waiting for gallery data to settle'); + await awaitForGallery(imgCount, controller.signal); } catch (err) { - log('Thumbnail DB cleanup:', err.message); + if (err instanceof Error) { + error('Thumbnail DB cleanup:', err.message); + } else { + log('Thumbnail DB cleanup:', err); + } return; } - if (activeGalleryFolder !== folder || idbIsCleaning) return; // First check for other thread activity - const t0 = performance.now(); - const staticGalleryHashes = new Set(galleryHashes); - const cachedHashesCount = await idbCount(folder) - .catch(() => Infinity); // Forces next check to fail if something went wrong - if (cachedHashesCount < staticGalleryHashes.size + 500) { - // Don't run when there aren't many excess entries - return; - } - if (activeGalleryFolder !== folder || idbIsCleaning) return; // Second check for other thread activity - - idbIsCleaning = true; - const [cb_updateMsg, cb_clearMsg] = showCleaningMsg(); - idbFolderCleanup(staticGalleryHashes, folder, cb_updateMsg) - .then((delcount) => { - const t1 = performance.now(); - log(`Thumbnail DB cleanup: folder=${folder} kept=${staticGalleryHashes.size} deleted=${delcount} time=${Math.floor(t1 - t0)}ms`); - }) - .catch((reason) => { - if (reason instanceof Error) { - error('Thumbnail DB cleanup: Cleanup failed.', reason.message); - } else { - log('Thumbnail DB cleanup:', reason); + maintenanceQueue.enqueue({ + signal: controller.signal, + callback: async () => { + debug(`Thumbnail DB cleanup: Checking if "${folder}" neads cleaning`); + const t0 = performance.now(); + const staticGalleryHashes = new Set(galleryHashes); // External context should be safe since this function run is guarded by AbortController/AbortSignal in the SimpleFunctionQueue + const cachedHashesCount = await idbCount(folder) + .catch(() => Infinity); // Forces next check to fail if something went wrong + if (cachedHashesCount < staticGalleryHashes.size + 500) { + // Don't run when there aren't many excess entries + debug('Thumbnail DB cleanup: Maintenance is not needed yet'); + return; } - }) - .finally(() => { - cb_clearMsg(); - idbIsCleaning = false; - }); + + if (controller.signal.aborted) { + debug(`Thumbnail DB cleanup: Cancelling "${folder}" cleanup due to "${controller.signal.reason}"`); + return; + } + const cb_clearMsg = showCleaningMsg(); + await idbFolderCleanup(staticGalleryHashes, folder, controller.signal) + .then((delcount) => { + const t1 = performance.now(); + log(`Thumbnail DB cleanup: folder=${folder} kept=${staticGalleryHashes.size} deleted=${delcount} time=${Math.floor(t1 - t0)}ms`); + }) + .catch((reason) => { + if (typeof reason === 'string' || (reason instanceof DOMException && reason.name === 'AbortError')) { + log('Thumbnail DB cleanup:', reason?.message || reason); + } else { + error('Thumbnail DB cleanup:', reason.message); + } + }) + .finally(() => { + cb_clearMsg(); + }); + }, + }); } -async function fetchFilesHT(evt) { +async function fetchFilesHT(evt, controller) { const t0 = performance.now(); const fragment = document.createDocumentFragment(); updateStatusWithSort(`Folder: ${evt.target.name} | in-progress`); @@ -674,7 +735,7 @@ async function fetchFilesHT(evt) { const ext = fileName.split('.').pop().toLowerCase(); if (SUPPORTED_EXTENSIONS.includes(ext)) { numFiles++; - const f = new GalleryFile(data[0], fileName); + const f = new GalleryFile(data[0], fileName, controller.signal); fragment.appendChild(f); } } @@ -685,12 +746,16 @@ async function fetchFilesHT(evt) { log(`gallery: folder=${evt.target.name} num=${numFiles} time=${Math.floor(t1 - t0)}ms`); updateStatusWithSort(`Folder: ${evt.target.name} | ${numFiles.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); addSeparators(); - thumbCacheCleanup(evt.target.name, numFiles); + thumbCacheCleanup(evt.target.name, numFiles, controller); } async function fetchFilesWS(evt) { // fetch file-by-file list over websockets - if (idbIsCleaning || !url) return; - galleryHashes.clear(); // Only called here because fetchFilesHT isn't called directly + if (!url) return; + const controller = new AbortController(); // Only called here because fetchFilesHT isn't called directly + maintenanceController.abort('Gallery update'); // Abort previous controller + maintenanceController = controller; // Point to new controller for next time + galleryHashes.clear(); // Must happen AFTER the AbortController steps + el.files.innerHTML = ''; if (ws && ws.readyState === WebSocket.OPEN) ws.close(); // abort previous request let wsConnected = false; @@ -701,10 +766,9 @@ async function fetchFilesWS(evt) { // fetch file-by-file list over websockets log('gallery: ws connect error', err); return; } - activeGalleryFolder = evt.target.name; log(`gallery: connected=${wsConnected} state=${ws?.readyState} url=${ws?.url}`); if (!wsConnected) { - await fetchFilesHT(evt); // fallback to http + await fetchFilesHT(evt, controller); // fallback to http return; } updateStatusWithSort(`Folder: ${evt.target.name}`); @@ -722,7 +786,7 @@ async function fetchFilesWS(evt) { // fetch file-by-file list over websockets const fileName = data[1]; const ext = fileName.split('.').pop().toLowerCase(); if (SUPPORTED_EXTENSIONS.includes(ext)) { - const file = new GalleryFile(data[0], fileName); + const file = new GalleryFile(data[0], fileName, controller.signal); numFiles++; fragment.appendChild(file); if (numFiles % 100 === 0) { @@ -739,7 +803,7 @@ async function fetchFilesWS(evt) { // fetch file-by-file list over websockets log(`gallery: folder=${evt.target.name} num=${numFiles} time=${Math.floor(t1 - t0)}ms`); updateStatusWithSort(`Folder: ${evt.target.name} | ${numFiles.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); addSeparators(); - thumbCacheCleanup(evt.target.name, numFiles); + thumbCacheCleanup(evt.target.name, numFiles, controller); }; ws.onerror = (event) => { log('gallery ws error', event); diff --git a/javascript/indexdb.js b/javascript/indexdb.js index 88da54835..24386eaa5 100644 --- a/javascript/indexdb.js +++ b/javascript/indexdb.js @@ -135,11 +135,11 @@ async function idbCount(folder = null) { /** * Cleanup function for IndexedDB thumbnail cache. * @global - * @param {Set} keepSet - Set containing the hashes of the current files in the folder. - * @param {string} folder - Folder name/path. - * @param {UpdateMsgCallback} msgCallback - Callback for updating the overlay message progress. + * @param {Set} keepSet - Set containing the hashes of the current files in the folder + * @param {string} folder - Folder name/path + * @param {AbortSignal} signal - Signal from the AbortController for thumbCacheCleanup() */ -async function idbFolderCleanup(keepSet, folder, msgCallback) { +async function idbFolderCleanup(keepSet, folder, signal) { if (!db) return null; if (!(keepSet instanceof Set)) { throw new TypeError('IndexedDB cleaning function must be given a Set() of the current gallery hashes'); @@ -151,24 +151,37 @@ async function idbFolderCleanup(keepSet, folder, msgCallback) { let removals = new Set(await idbGetAllKeys('folder', folder)); removals = removals.difference(keepSet); // Don't need to keep full set in memory const totalRemovals = removals.size; - + if (signal.aborted) { + throw `Aborting. ${signal.reason}`; // eslint-disable-line no-throw-literal + } return new Promise((resolve, reject) => { - try { - const transaction = db.transaction('thumbs', 'readwrite'); - const store = transaction.objectStore('thumbs'); - - removals = Array.from(removals); - for (let index = 0; index < totalRemovals; index++) { - const entry = removals[index]; - store.delete(entry); - if (index % 100 === 0 && index !== 0) { - msgCallback(Math.floor((index / totalRemovals) * 100)); - } - } - resolve(totalRemovals); - } catch (err) { - reject(err); + const transaction = db.transaction('thumbs', 'readwrite'); + function abortTransaction() { + signal.removeEventListener('abort', abortTransaction); + transaction.abort(); } + signal.addEventListener('abort', abortTransaction); + + try { + const store = transaction.objectStore('thumbs'); + removals.forEach((entry) => { store.delete(entry); }); + } catch (err) { + error(err); + abortTransaction(); + } + + transaction.onabort = () => { + signal.removeEventListener('abort', abortTransaction); + reject(`Aborting. ${signal.reason}`); // eslint-disable-line prefer-promise-reject-errors + }; + transaction.onerror = () => { + signal.removeEventListener('abort', abortTransaction); + reject(new Error('Database transaction error')); + }; + transaction.oncomplete = async () => { + signal.removeEventListener('abort', abortTransaction); + resolve(totalRemovals); + }; }); } From d458ff1073c95e31e622f9e80694a4450f15abe9 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:57:45 -0800 Subject: [PATCH 11/22] Change timeout handling to act as just a failsafe --- javascript/gallery.js | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index 00a3c1a2f..800b3028b 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -22,7 +22,7 @@ const el = { const SUPPORTED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'tiff', 'jp2', 'jxl', 'gif', 'mp4', 'mkv', 'avi', 'mjpeg', 'mpg', 'avr']; async function awaitForIDB(num = 0, signal = null) { - const timeout = AbortSignal.timeout(30000); + const timeout = AbortSignal.timeout(180000); // Failsafe to ensure no memory leaks const combinedSignals = signal ? AbortSignal.any([timeout, signal]) : timeout; while (outstanding > num && !combinedSignals.aborted) await new Promise((resolve) => { setTimeout(resolve, 50); }); } @@ -33,12 +33,9 @@ async function awaitForIDB(num = 0, signal = null) { * @param {AbortSignal} signal - AbortController signal */ async function awaitForGallery(expectedSize, signal) { - const timeout = AbortSignal.timeout(60000); + const timeout = AbortSignal.timeout(180000); // Failsafe to ensure no memory leaks const combinedSignals = AbortSignal.any([timeout, signal]); while (galleryHashes.size < expectedSize && !combinedSignals.aborted) await new Promise((resolve) => { setTimeout(resolve, 500); }); // longer interval because it's a low priority check - if (timeout.aborted) { - throw 'Timed out waiting for gallery to populate'; // eslint-disable-line no-throw-literal - } } // Classes @@ -671,11 +668,7 @@ async function thumbCacheCleanup(folder, imgCount, controller) { debug('Thumbnail DB cleanup: Waiting for gallery data to settle'); await awaitForGallery(imgCount, controller.signal); } catch (err) { - if (err instanceof Error) { - error('Thumbnail DB cleanup:', err.message); - } else { - log('Thumbnail DB cleanup:', err); - } + debug(`Thumbnail DB cleanup: Skipping cleanup for "${folder}" due to "${controller.signal.aborted ? controller.signal.reason : 'timeout'}"`); return; } From a1f6611da28c3b94bbce8e137c92ff9d350d7d7c Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:57:59 -0800 Subject: [PATCH 12/22] Fix typo and change log level --- javascript/gallery.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index 800b3028b..45d38b4c3 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -675,7 +675,7 @@ async function thumbCacheCleanup(folder, imgCount, controller) { maintenanceQueue.enqueue({ signal: controller.signal, callback: async () => { - debug(`Thumbnail DB cleanup: Checking if "${folder}" neads cleaning`); + log(`Thumbnail DB cleanup: Checking if "${folder}" needs cleaning`); const t0 = performance.now(); const staticGalleryHashes = new Set(galleryHashes); // External context should be safe since this function run is guarded by AbortController/AbortSignal in the SimpleFunctionQueue const cachedHashesCount = await idbCount(folder) From 143558df4bc6e9aba1068386175b17a15ca59063 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:02:03 -0800 Subject: [PATCH 13/22] Fix typo and missing return --- javascript/gallery.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index 45d38b4c3..e2fde6291 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -64,9 +64,10 @@ class SimpleFunctionQueue { } if (config.signal.aborted) { debug(`${this.#id} Queue: Skipping addition to queue due to "${config.signal.reason}"`); + return; } this.#queue.push(config); - if (!this.busy) { + if (!this.#running) { this.#runNext(); } } From b25cf5618157fe1d191b846f5279ddfeb68fad9d Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:05:14 -0800 Subject: [PATCH 14/22] Rename and remove redundant logic --- javascript/gallery.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index e2fde6291..ed3bd9fdb 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -67,12 +67,10 @@ class SimpleFunctionQueue { return; } this.#queue.push(config); - if (!this.#running) { - this.#runNext(); - } + this.#tryRunNext(); } - async #runNext() { + async #tryRunNext() { if (this.#running || !this.#queue.length) return; try { const { signal, callback } = this.#queue.shift(); @@ -85,7 +83,7 @@ class SimpleFunctionQueue { error(`${this.#id} Queue:`, err); } finally { this.#running = false; - this.#runNext(); + this.#tryRunNext(); } } } From 35cd563c7df9c431400cd63f7e7fe3864918d776 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Sun, 30 Nov 2025 22:59:26 -0800 Subject: [PATCH 15/22] Minor rename and logic adjustment Only add to the hashes Set when guard is in place. --- javascript/gallery.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index ed3bd9fdb..b679e1fd3 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -271,13 +271,13 @@ async function delayFetchThumb(fn) { } class GalleryFile extends HTMLElement { - #gallerySignal; + #signal; constructor(folder, file, signal = undefined) { super(); this.folder = folder; this.name = file; - this.#gallerySignal = signal; + this.#signal = signal; this.size = 0; this.mtime = 0; this.hash = undefined; @@ -371,10 +371,10 @@ class GalleryFile extends HTMLElement { img.src = `file=${this.src}`; } } - if (!this.#gallerySignal?.aborted) { + if (this.#signal && !this.#signal.aborted) { // Guard against accessing external context from a stale initialization galleryHashes.add(this.hash); // Add to hashes Set *after* any database operations - this.#gallerySignal = null; // Clean up reference to AbortSignal + this.#signal = null; // Clean up reference to AbortSignal } if (!ok) { return; From 6cab50e58447f664828f3299e15f469fc674f4bc Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Mon, 1 Dec 2025 05:18:23 -0800 Subject: [PATCH 16/22] Overhaul custom element styling Large performance improvement due to only needing a single style source instead of creating and parsing thousands of style elements. --- javascript/gallery.js | 67 ++++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index b679e1fd3..649e16583 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -9,6 +9,8 @@ let lastSort = 0; let lastSortName = 'None'; const galleryHashes = new Set(); let maintenanceController = new AbortController(); +const folderStylesheet = new CSSStyleSheet(); +const fileStylesheet = new CSSStyleSheet(); // Store separator states for the session const separatorStates = new Map(); const el = { @@ -38,6 +40,32 @@ async function awaitForGallery(expectedSize, signal) { while (galleryHashes.size < expectedSize && !combinedSignals.aborted) await new Promise((resolve) => { setTimeout(resolve, 500); }); // longer interval because it's a low priority check } +function updateGalleryStyles() { + folderStylesheet.replaceSync((window.opts.theme_type + === 'Modern' + ? `.gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; background-color: var(--sd-button-normal-color); border-radius: var(--sd-border-radius); text-align: left; min-width: 12em;} + .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover); } + .gallery-folder-selected { background-color: var(--sd-button-selected-color); color: var(--sd-button-selected-text-color); } + .gallery-folder-icon { font-size: 1.2em; color: var(--sd-button-icon-color); margin-right: 1em; filter: drop-shadow(1px 1px 2px black); float: left; } + ` + : ` + .gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; } + .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover); } + .gallery-folder-selected { background-color: var(--button-primary-background-fill); } + `)); + fileStylesheet.replaceSync(` + .gallery-file { + object-fit: contain; + cursor: pointer; + height: ${opts.extra_networks_card_size}px; + width: ${opts.browser_fixed_width ? `${opts.extra_networks_card_size}px` : 'unset'}; + } + .gallery-file:hover { + filter: grayscale(100%); + } + `); +} + // Classes class SimpleFunctionQueue { @@ -95,32 +123,17 @@ class GalleryFolder extends HTMLElement { super(); this.name = decodeURI(name); this.shadow = this.attachShadow({ mode: 'open' }); + this.shadow.adoptedStyleSheets = [folderStylesheet]; } connectedCallback() { - const style = document.createElement('style'); // silly but necessasry since we're inside shadowdom - if (window.opts.theme_type === 'Modern') { - style.textContent = ` - .gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; background-color: var(--sd-button-normal-color); border-radius: var(--sd-border-radius); text-align: left; min-width: 12em;} - .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover); } - .gallery-folder-selected { background-color: var(--sd-button-selected-color); color: var(--sd-button-selected-text-color); } - .gallery-folder-icon { font-size: 1.2em; color: var(--sd-button-icon-color); margin-right: 1em; filter: drop-shadow(1px 1px 2px black); float: left; } - `; - } else { - style.textContent = ` - .gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; } - .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover); } - .gallery-folder-selected { background-color: var(--button-primary-background-fill); } - `; - } - this.shadow.appendChild(style); const div = document.createElement('div'); div.className = 'gallery-folder'; div.innerHTML = `\uf03e ${this.name}`; div.addEventListener('click', () => { for (const folder of el.folders.children) { - if (folder.name === this.name) folder.shadow.children[1].classList.add('gallery-folder-selected'); - else folder.shadow.children[1].classList.remove('gallery-folder-selected'); + if (folder.name === this.name) folder.shadow.firstElementChild.classList.add('gallery-folder-selected'); + else folder.shadow.firstElementChild.classList.remove('gallery-folder-selected'); } }); div.addEventListener('click', fetchFilesWS); // eslint-disable-line no-use-before-define @@ -286,6 +299,7 @@ class GalleryFile extends HTMLElement { this.height = 0; this.src = `${this.folder}/${this.name}`; this.shadow = this.attachShadow({ mode: 'open' }); + this.shadow.adoptedStyleSheets = [fileStylesheet]; } async connectedCallback() { @@ -304,20 +318,6 @@ class GalleryFile extends HTMLElement { } this.hash = await getHash(`${this.folder}/${this.name}/${this.size}/${this.mtime}`); // eslint-disable-line no-use-before-define - const style = document.createElement('style'); - const width = opts.browser_fixed_width ? `${opts.extra_networks_card_size}px` : 'unset'; - style.textContent = ` - .gallery-file { - object-fit: contain; - cursor: pointer; - height: ${opts.extra_networks_card_size}px; - width: ${width}; - } - .gallery-file:hover { - filter: grayscale(100%); - } - `; - const cache = (this.hash && opts.browser_cache) ? await idbGet(this.hash) : undefined; const img = document.createElement('img'); img.className = 'gallery-file'; @@ -395,7 +395,6 @@ class GalleryFile extends HTMLElement { this.style.display = shouldDisplayBasedOnSearch ? 'unset' : 'none'; } - this.shadow.appendChild(style); this.shadow.appendChild(img); } } @@ -749,6 +748,7 @@ async function fetchFilesWS(evt) { // fetch file-by-file list over websockets galleryHashes.clear(); // Must happen AFTER the AbortController steps el.files.innerHTML = ''; + updateGalleryStyles(); if (ws && ws.readyState === WebSocket.OPEN) ws.close(); // abort previous request let wsConnected = false; try { @@ -862,6 +862,7 @@ async function initGallery() { // triggered on gradio change to monitor when ui error('initGallery', 'Missing gallery elements'); return; } + updateGalleryStyles(); setOverlayAnimation(); el.search.addEventListener('input', gallerySearch); el.btnSend = gradioApp().getElementById('tab-gallery-send-image'); From 8fa791a22fd6e3aa18d9917832562947c025b370 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Mon, 1 Dec 2025 05:26:06 -0800 Subject: [PATCH 17/22] Remove extra parentheses Looks like they snuck in when refactoring to inline declaration. --- javascript/gallery.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index 649e16583..598b8f38f 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -41,7 +41,7 @@ async function awaitForGallery(expectedSize, signal) { } function updateGalleryStyles() { - folderStylesheet.replaceSync((window.opts.theme_type + folderStylesheet.replaceSync(window.opts.theme_type === 'Modern' ? `.gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; background-color: var(--sd-button-normal-color); border-radius: var(--sd-border-radius); text-align: left; min-width: 12em;} .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover); } @@ -52,7 +52,7 @@ function updateGalleryStyles() { .gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; } .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover); } .gallery-folder-selected { background-color: var(--button-primary-background-fill); } - `)); + `); fileStylesheet.replaceSync(` .gallery-file { object-fit: contain; From 766aa6c72801d9b87ce020322bc36760bc5a8d5a Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:55:40 -0800 Subject: [PATCH 18/22] Change from ternary to make things easier to read --- javascript/gallery.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index 598b8f38f..2f7cbf21a 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -41,18 +41,20 @@ async function awaitForGallery(expectedSize, signal) { } function updateGalleryStyles() { - folderStylesheet.replaceSync(window.opts.theme_type - === 'Modern' - ? `.gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; background-color: var(--sd-button-normal-color); border-radius: var(--sd-border-radius); text-align: left; min-width: 12em;} + if (opts.theme_type?.lower() === 'modern') { + folderStylesheet.replaceSync(` + .gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; background-color: var(--sd-button-normal-color); border-radius: var(--sd-border-radius); text-align: left; min-width: 12em;} .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover); } .gallery-folder-selected { background-color: var(--sd-button-selected-color); color: var(--sd-button-selected-text-color); } .gallery-folder-icon { font-size: 1.2em; color: var(--sd-button-icon-color); margin-right: 1em; filter: drop-shadow(1px 1px 2px black); float: left; } - ` - : ` + `); + } else { + folderStylesheet.replaceSync(` .gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; } .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover); } .gallery-folder-selected { background-color: var(--button-primary-background-fill); } `); + } fileStylesheet.replaceSync(` .gallery-file { object-fit: contain; From 9ae7c1a7c7454aa90e26f6376f463a84b49467c1 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:20:23 -0800 Subject: [PATCH 19/22] Generalize the SimpleFunctionQueue class --- javascript/gallery.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index 2f7cbf21a..c4f483b5e 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -70,8 +70,8 @@ function updateGalleryStyles() { // Classes +/* This isn't as robust as the Web Locks API, but it will at least work if accessing a remote machine without HTTPS */ class SimpleFunctionQueue { - /* This isn't as robust as the Web Locks API, but it will at least work if accessing a remote machine without HTTPS */ #id; #running; #queue; @@ -108,7 +108,11 @@ class SimpleFunctionQueue { return; } this.#running = true; - await callback(); + if (callback.constructor.name.lower() === 'asyncfunction') { + await callback(); + } else { + callback(); + } } catch (err) { error(`${this.#id} Queue:`, err); } finally { From e1febfd92cb377402b32d0c2790c96f30a37a351 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:45:23 -0800 Subject: [PATCH 20/22] Logging adjustment --- javascript/gallery.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index c4f483b5e..ed17499c0 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -683,10 +683,12 @@ async function thumbCacheCleanup(folder, imgCount, controller) { const t0 = performance.now(); const staticGalleryHashes = new Set(galleryHashes); // External context should be safe since this function run is guarded by AbortController/AbortSignal in the SimpleFunctionQueue const cachedHashesCount = await idbCount(folder) - .catch(() => Infinity); // Forces next check to fail if something went wrong + .catch((e) => { + error(`Thumbnail DB cleanup: Error when getting entry count for "${folder}".`, e); + return Infinity; // Forces next check to fail if something went wrong + }); if (cachedHashesCount < staticGalleryHashes.size + 500) { // Don't run when there aren't many excess entries - debug('Thumbnail DB cleanup: Maintenance is not needed yet'); return; } From 75dc851d1cee7728f273c432847cde7332279105 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:49:00 -0800 Subject: [PATCH 21/22] Fix incorrect syntax --- javascript/gallery.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/javascript/gallery.js b/javascript/gallery.js index ed17499c0..dd1adfd57 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -41,7 +41,7 @@ async function awaitForGallery(expectedSize, signal) { } function updateGalleryStyles() { - if (opts.theme_type?.lower() === 'modern') { + if (opts.theme_type?.toLowerCase() === 'modern') { folderStylesheet.replaceSync(` .gallery-folder { cursor: pointer; padding: 8px 6px 8px 6px; background-color: var(--sd-button-normal-color); border-radius: var(--sd-border-radius); text-align: left; min-width: 12em;} .gallery-folder:hover { background-color: var(--button-primary-background-fill-hover); } @@ -108,7 +108,7 @@ class SimpleFunctionQueue { return; } this.#running = true; - if (callback.constructor.name.lower() === 'asyncfunction') { + if (callback.constructor.name.toLowerCase() === 'asyncfunction') { await callback(); } else { callback(); From e986f5638e541d9da20bdd3458ad128930a3f9f4 Mon Sep 17 00:00:00 2001 From: awsr <43862868+awsr@users.noreply.github.com> Date: Mon, 1 Dec 2025 20:19:06 -0800 Subject: [PATCH 22/22] Update initGallery position to wait for opts --- javascript/startup.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/javascript/startup.js b/javascript/startup.js index 82194d942..5a1af8f5e 100644 --- a/javascript/startup.js +++ b/javascript/startup.js @@ -38,7 +38,6 @@ async function initStartup() { await initAccordions(); await initSettings(); await initImageViewer(); - await initGallery(); await initiGenerationParams(); await initChangelog(); await setupControlUI(); @@ -47,6 +46,8 @@ async function initStartup() { await reconnectUI(); await waitForOpts(); + await initGallery(); + log('mountURL', window.opts.subpath); if (window.opts.subpath?.length > 0) { window.subpath = window.opts.subpath;