feat(gallery): duplicate scan runs as a background job with progress; infinite scrolling

The duplicate scan was one blocking request with a spinner: no progress,
a modal that could not be put away, and a result lost if the tab closed.

- POST /prompt_manager/scan_duplicates/stream starts the scan as a
  background job (same ScanJob fan-out as the output scan) or attaches to
  the running one; GET /prompt_manager/scan_duplicates/status reports it.
  The job holds the duplicates lock, so deletes are refused while it runs.
- hashing runs on the configured worker threads and reports progress
  about every percent
- the gallery modal shows a progress bar, closing it minimizes to a pill
  that reopens it, results wait in the pill if the modal was closed, and a
  reload re-attaches to a running scan

Infinite scrolling is a new Display Setting (off by default): the next page
is appended when the viewport nears the bottom, pagination controls hide,
and the load-more rule lives in list-state.js where it is unit tested.
Thumb-size buttons and the column setting no longer override each other.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:35 -07:00
parent 672eb6a18c
commit 6e3fa65914
9 changed files with 494 additions and 53 deletions
+2
View File
@@ -278,6 +278,8 @@ class PromptManagerAPI(
self._long_jobs: dict = {}
# The current output scan (py/api/scan_job.ScanJob), if one was started.
self._scan_job = None
# The current duplicate scan (also a ScanJob), if one was started.
self._duplicate_job = None
# Run cleanup on initialization to remove any existing duplicates
try:
+124 -11
View File
@@ -8,6 +8,7 @@ import json
import os
import sqlite3
import tempfile
import threading
from pathlib import Path
from aiohttp import web
@@ -298,6 +299,14 @@ class AdminRoutesMixin:
async def scan_duplicates_route(request):
return await self.scan_duplicates_endpoint(request)
@routes.post("/prompt_manager/scan_duplicates/stream")
async def scan_duplicates_stream_route(request):
return await self.scan_duplicates_stream(request)
@routes.get("/prompt_manager/scan_duplicates/status")
async def scan_duplicates_status_route(request):
return await self.scan_duplicates_status(request)
@routes.post("/prompt_manager/delete_duplicate_images")
async def delete_duplicate_images_route(request):
return await self.delete_duplicate_images_endpoint(request)
@@ -375,6 +384,89 @@ class AdminRoutesMixin:
status=500,
)
async def scan_duplicates_stream(self, request):
"""Run the duplicate scan as a background job and stream its progress.
A request while a scan is running attaches to it. The job holds the
duplicates lock, so a delete is refused (409) until it finishes.
"""
job = self._duplicate_job
if job is None or job.finished:
guard = self._acquire_long_job(self.LONG_JOB_DUPLICATES)
if guard is None:
return self._long_job_busy_response("Duplicate scan or delete")
job = ScanJob()
self._duplicate_job = job
job.task = asyncio.ensure_future(self._run_duplicate_job(job, guard))
return await self._stream_scan_job(request, job)
async def scan_duplicates_status(self, request):
"""Whether a duplicate scan is running, and its latest event."""
job = self._duplicate_job
return web.json_response(
{
"success": True,
"running": bool(job and job.running),
"last_event": job.last_event if job else None,
}
)
async def _run_duplicate_job(self, job, guard):
loop = asyncio.get_running_loop()
counts = {"processed": 0}
def report(done, total):
# Called from worker threads; hand the event to the loop.
counts["processed"] = done
pct = int(done / total * 100) if total else 100
loop.call_soon_threadsafe(
job.publish,
{
"type": "progress",
"progress": pct,
"status": f"Hashing file {done}/{total}...",
"processed": done,
"found": 0,
},
)
try:
async with guard:
job.publish(
{
"type": "progress",
"progress": 0,
"status": "Collecting media files...",
"processed": 0,
"found": 0,
}
)
output_dir = await self._run_in_executor(self._find_comfyui_output_dir)
groups = await self._run_in_executor(
self._find_duplicate_images_sync, output_dir, report
)
job.publish(
{
"type": "complete",
"processed": counts["processed"],
"found": len(groups),
"duplicates": groups,
"message": f"Found {len(groups)} groups of duplicate images",
}
)
except Exception:
self.logger.exception("Duplicate scan error")
job.publish(
{
"type": "error",
"message": (
"An internal error occurred. Check server logs for details."
),
}
)
finally:
job.finish()
async def cleanup_duplicates_endpoint(self, request):
"""Cleanup duplicate prompts endpoint."""
try:
@@ -400,8 +492,15 @@ class AdminRoutesMixin:
output_dir = await self._run_in_executor(self._find_comfyui_output_dir)
return await self._run_in_executor(self._find_duplicate_images_sync, output_dir)
def _find_duplicate_images_sync(self, output_dir):
"""Group media files under ``output_dir`` by content hash (blocking)."""
def _find_duplicate_images_sync(self, output_dir, progress=None):
"""Group media files under ``output_dir`` by content hash (blocking).
Files are hashed on ``PromptManagerConfig.WORKER_THREADS`` threads.
``progress(done, total)`` is called from those threads about every
percent and once at the end.
"""
from ..config import PromptManagerConfig
if not output_dir or not os.path.isdir(output_dir):
self.logger.warning("ComfyUI output directory not found")
return []
@@ -409,21 +508,35 @@ class AdminRoutesMixin:
output_path = Path(output_dir)
extensions = IMAGE_EXTENSIONS + VIDEO_EXTENSIONS
media_files = _collect_media_files([output_path], extensions)
self.logger.info(f"Found {len(media_files)} media files to analyze")
total = len(media_files)
self.logger.info(f"Found {total} media files to analyze")
step = max(1, total // 100)
counter = {"done": 0}
lock = threading.Lock()
file_hashes = {}
for index, media_path in enumerate(media_files, start=1):
def describe(media_path):
try:
info = self._describe_media_file(media_path, output_path)
except Exception as e:
self.logger.error(f"Error processing file {media_path}: {e}")
continue
file_hashes.setdefault(info["hash"], []).append(info)
if index % 100 == 0:
self.logger.error(f"Error processing file {media_path.name}: {e}")
info = None
with lock:
counter["done"] += 1
done = counter["done"]
if done % 100 == 0:
self.logger.info(
f"Processed {index}/{len(media_files)} files "
"for duplicate detection"
f"Processed {done}/{total} files " "for duplicate detection"
)
if progress and (done % step == 0 or done == total):
progress(done, total)
return info
infos = map_parallel(describe, media_files, PromptManagerConfig.WORKER_THREADS)
file_hashes = {}
for info in infos:
if info is not None:
file_hashes.setdefault(info["hash"], []).append(info)
duplicates = []
for file_hash, images in file_hashes.items():
+6
View File
@@ -55,3 +55,9 @@ test("gridColumnsStyle produces an explicit grid template", () => {
assert.equal(gridColumnsStyle("6"), "repeat(6, minmax(0, 1fr))");
assert.equal(gridColumnsStyle("x"), "repeat(8, minmax(0, 1fr))");
});
test("infinite scrolling is off by default and must be a boolean", () => {
assert.equal(DEFAULT_SETTINGS.infiniteScroll, false);
assert.equal(normalizeGallerySettings({ infiniteScroll: true }).infiniteScroll, true);
assert.equal(normalizeGallerySettings({ infiniteScroll: "yes" }).infiniteScroll, false);
});
+15 -1
View File
@@ -1,7 +1,7 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const { nextPageState, shouldSaveEdit, hasMorePages } = require("../../web/js/list-state.js");
const { nextPageState, shouldSaveEdit, hasMorePages, shouldLoadMore } = require("../../web/js/list-state.js");
test("hasMorePages follows pagination.has_more only while pages keep arriving", () => {
assert.equal(hasMorePages({ pagination: { has_more: true } }, 5000), true);
@@ -49,3 +49,17 @@ test("shouldSaveEdit is true only for a non-empty text that changed", () => {
assert.equal(shouldSaveEdit("old", " "), false, "an emptied prompt is not saved");
assert.equal(shouldSaveEdit("old", null), false);
});
test("shouldLoadMore asks for the next page only near the bottom with pages left", () => {
const base = {
infiniteScroll: true, loading: false, page: 1, limit: 100, total: 1000,
scrollTop: 0, clientHeight: 800, scrollHeight: 4000, threshold: 600,
};
assert.equal(shouldLoadMore(base), false, "far from the bottom");
assert.equal(shouldLoadMore({ ...base, scrollTop: 2700 }), true, "within the threshold");
assert.equal(shouldLoadMore({ ...base, scrollTop: 2700, loading: true }), false, "a load is in flight");
assert.equal(shouldLoadMore({ ...base, scrollTop: 2700, infiniteScroll: false }), false, "setting off");
assert.equal(shouldLoadMore({ ...base, scrollTop: 2700, page: 10 }), false, "last page reached");
assert.equal(shouldLoadMore({ ...base, scrollHeight: 700 }), true, "content shorter than the viewport");
assert.equal(shouldLoadMore({ ...base, total: 0, scrollHeight: 700 }), false, "nothing to load");
});
+129
View File
@@ -1788,5 +1788,134 @@ class TestScanReadsVideos(AdminAPITestCase):
self.assertEqual([line for line in cm.output if line.startswith("ERROR")], [])
def _sse_payloads(body):
return [
json.loads(line[6:]) for line in body.splitlines() if line.startswith("data: ")
]
class TestDuplicateScanIsABackgroundJob(AdminAPITestCase):
"""The duplicate scan streams progress, survives the client, and is single-flight
with the duplicate delete."""
STREAM = "/prompt_manager/scan_duplicates/stream"
STATUS = "/prompt_manager/scan_duplicates/status"
def _two_dups_one_single(self):
(self.output_dir / "a.png").write_bytes(b"same bytes")
(self.output_dir / "b.png").write_bytes(b"same bytes")
(self.output_dir / "c.png").write_bytes(b"other bytes")
GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)]
def _gate_hashing(self):
import threading
gate = threading.Event()
calls = []
original = self.api._calculate_file_hash
def gated(path):
calls.append(path)
gate.wait(5)
return original(path)
self.api._calculate_file_hash = gated
return gate, calls
async def test_stream_reports_progress_then_the_groups(self):
self._two_dups_one_single()
body = await (await self.client.request("POST", self.STREAM)).text()
events = _sse_payloads(body)
types = [e["type"] for e in events]
self.assertIn("progress", types)
self.assertEqual(types[-1], "complete")
done = events[-1]
self.assertEqual(done["processed"], 3)
self.assertEqual(done["found"], 1)
self.assertEqual(done["duplicates"][0]["count"], 2)
self.assertEqual(
sorted(i["filename"] for i in done["duplicates"][0]["images"]),
["a.png", "b.png"],
)
self.assertNotIn(self.tmpdir, body)
async def test_second_request_attaches_and_delete_is_refused_meanwhile(self):
self._two_dups_one_single()
gate, calls = self._gate_hashing()
first = asyncio.ensure_future(self.client.request("POST", self.STREAM))
await asyncio.sleep(0.1)
second = await asyncio.wait_for(self.client.request("POST", self.STREAM), 5)
self.assertEqual(second.status, 200)
delete = await self.client.request(
"POST",
"/prompt_manager/delete_duplicate_images",
json={"image_paths": ["a.png"]},
)
self.assertEqual(delete.status, 409)
running = await (await self.client.request("GET", self.STATUS)).json()
self.assertTrue(running["running"])
gate.set()
first_body = await (await first).text()
second_body = await second.text()
finished = await (await self.client.request("GET", self.STATUS)).json()
self.assertIn('"type": "complete"', first_body)
self.assertIn('"type": "complete"', second_body)
self.assertEqual(len(calls), 3) # one scan, each file hashed once
self.assertFalse(finished["running"])
self.assertEqual(finished["last_event"]["found"], 1)
async def test_status_is_idle_before_any_scan(self):
data = await (await self.client.request("GET", self.STATUS)).json()
self.assertEqual(data, {"success": True, "running": False, "last_event": None})
async def test_failed_scan_streams_an_error_and_releases_the_lock(self):
GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)]
self.api._find_comfyui_output_dir = _raise
body = await (await self.client.request("POST", self.STREAM)).text()
self.api._find_comfyui_output_dir = lambda: str(self.output_dir)
legacy = await self.client.request("GET", "/prompt_manager/scan_duplicates")
self.assertEqual(_sse_payloads(body)[-1]["type"], "error")
self.assertNotIn("boom", body)
self.assertEqual(legacy.status, 200)
def test_hashing_runs_on_worker_threads_and_reports_progress(self):
import threading
import time
for i in range(8):
(self.output_dir / f"f{i}.png").write_bytes(f"bytes {i % 4}".encode())
PromptManagerConfig.WORKER_THREADS = 4
seen = set()
lock = threading.Lock()
original = self.api._calculate_file_hash
def recording(path):
with lock:
seen.add(threading.current_thread().name)
time.sleep(0.05)
return original(path)
self.api._calculate_file_hash = recording
reports = []
groups = self.api._find_duplicate_images_sync(
str(self.output_dir),
progress=lambda done, total: reports.append((done, total)),
)
self.assertEqual(len(groups), 4)
self.assertGreaterEqual(len(seen), 2)
self.assertEqual(reports[-1], (8, 8))
self.assertEqual([t for _, t in reports], [8] * len(reports))
self.assertEqual(sorted(d for d, _ in reports), [d for d, _ in reports])
if __name__ == "__main__":
unittest.main()
+25 -8
View File
@@ -8,6 +8,7 @@
<script src="/prompt_manager/js/html-escape.js"></script>
<script src="/prompt_manager/js/image-helpers.js"></script>
<script src="/prompt_manager/js/gallery-settings.js"></script>
<script src="/prompt_manager/js/scan-progress.js"></script>
<script src="/prompt_manager/js/api-client.js"></script>
<script src="/prompt_manager/js/png-metadata.js"></script>
<script src="/prompt_manager/js/list-state.js"></script>
@@ -120,7 +121,7 @@
<div id="folderResizeHandle" class="hidden w-1 cursor-col-resize bg-transparent hover:bg-pm-accent/30 flex-shrink-0"></div>
<!-- Main Gallery Area — scrolls independently -->
<div class="flex-1 min-w-0 overflow-y-auto pl-3">
<div id="galleryScroll" class="flex-1 min-w-0 overflow-y-auto pl-3">
<!-- Loading State -->
<div id="loadingState" class="flex items-center justify-center py-12">
<div class="text-center">
@@ -295,6 +296,13 @@
<label class="text-xs text-pm-secondary">Collapse Sidebar by Default</label>
<input type="checkbox" id="sidebarCollapsedByDefaultToggle" class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
<div class="flex items-center justify-between">
<div>
<label class="text-xs text-pm-secondary">Infinite Scrolling</label>
<p class="text-[11px] text-pm-muted">Load the next page as you scroll instead of showing page controls</p>
</div>
<input type="checkbox" id="infiniteScrollToggle" class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
</div>
</div>
<!-- Video Settings -->
@@ -398,6 +406,12 @@
</div>
</div>
<!-- Minimized duplicate scan: shown while it runs with its modal hidden, or when results are waiting -->
<button id="dupScanPill" type="button" title="Show duplicate scan"
class="hidden fixed bottom-4 right-4 z-40 px-3 py-1.5 bg-pm-surface border border-pm shadow-pm rounded-pm-md text-xs text-pm hover:bg-pm-hover transition-colors">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-pm-accent mr-1.5 align-middle animate-pulse"></span><span id="dupScanPillText" class="align-middle">Scanning...</span>
</button>
<!-- Duplicates Modal -->
<div id="duplicatesModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-pm-surface rounded-pm-md max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4 border border-pm">
@@ -412,13 +426,16 @@
</div>
</div>
<div class="p-3 overflow-y-auto max-h-[calc(90vh-200px)]">
<div id="duplicatesScanStatus" class="text-center py-6">
<div class="inline-flex items-center px-3 py-2 bg-pm-warning rounded-pm-sm text-xs text-pm">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Scanning for duplicate images...
<div id="duplicatesScanStatus" class="py-6">
<div class="max-w-md mx-auto bg-pm-input rounded-pm-md p-3">
<div class="flex items-center justify-between mb-1.5">
<span class="text-pm text-xs font-medium">Scanning for duplicate images</span>
<span id="dupProgressText" class="text-pm-secondary text-xs">Starting...</span>
</div>
<div class="w-full bg-pm-primary rounded-full h-1.5">
<div id="dupProgressBar" class="bg-pm-accent h-1.5 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-[11px] text-pm-muted mt-2">Every file is hashed, so this takes a while on a large library. You can close this window; the scan keeps running and the results will be waiting.</p>
</div>
</div>
<div id="duplicatesContent" class="hidden">
+2
View File
@@ -26,6 +26,7 @@
cacheMetadata: true,
showFilePaths: true,
sidebarCollapsedByDefault: false,
infiniteScroll: false,
debugMode: false,
apiTimeout: 30,
thumbnailsGenerated: false,
@@ -64,6 +65,7 @@
cacheMetadata: bool(merged.cacheMetadata, DEFAULT_SETTINGS.cacheMetadata),
showFilePaths: bool(merged.showFilePaths, DEFAULT_SETTINGS.showFilePaths),
sidebarCollapsedByDefault: bool(merged.sidebarCollapsedByDefault, DEFAULT_SETTINGS.sidebarCollapsedByDefault),
infiniteScroll: bool(merged.infiniteScroll, DEFAULT_SETTINGS.infiniteScroll),
debugMode: bool(merged.debugMode, DEFAULT_SETTINGS.debugMode),
thumbnailsGenerated: bool(merged.thumbnailsGenerated, DEFAULT_SETTINGS.thumbnailsGenerated),
checkThumbnailsAtStartup: bool(merged.checkThumbnailsAtStartup, DEFAULT_SETTINGS.checkThumbnailsAtStartup),
+176 -32
View File
@@ -9,6 +9,12 @@
this.currentSubfolder = '';
this.expandedFolders = new Set();
this.thumbSize = 'md'; // sm, md, lg
this.infiniteScroll = false;
this.loadingMore = false;
// Duplicate scan: a server-side job the modal follows (see scanDuplicates)
this.dupScan = ScanProgress.initialState();
this.dupSource = null;
this.dupResults = null;
this.api = ApiClient.createApiClient();
// Only the newest page request may update the grid
@@ -28,6 +34,7 @@
this.applySettings(this.getSettings());
this.loadFolderTree();
this.loadImages();
this.resumeDuplicateScan();
// Check thumbnails at startup if enabled
this.checkThumbnailsAtStartup();
@@ -90,6 +97,8 @@
document.getElementById('clearCacheBtn').addEventListener('click', () => this.clearCache());
document.getElementById('rescanFolderBtn').addEventListener('click', () => this.rescanFolder());
document.getElementById('scanDuplicatesBtn').addEventListener('click', () => this.scanDuplicates());
document.getElementById('dupScanPill').addEventListener('click', () => this.openDuplicatesModal());
document.getElementById('galleryScroll').addEventListener('scroll', () => this.maybeLoadMore(), { passive: true });
// Duplicates modal event listeners
document.getElementById('closeDuplicatesBtn').addEventListener('click', () => this.hideDuplicatesModal());
@@ -303,6 +312,7 @@
lg: { cols: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '12px' },
};
const s = sizes[size] || sizes.md;
grid.classList.remove('gallery-columns-custom'); // an explicit size wins over the column setting
grid.style.gridTemplateColumns = s.cols;
grid.style.gap = s.gap;
// Update button states
@@ -322,8 +332,9 @@
* Load one page of images. `page` is committed to this.currentPage only after the
* server answered, and a newer request supersedes an older one still in flight.
*/
async loadImages(page = this.currentPage) {
this.showLoading();
async loadImages(page = this.currentPage, { append = false } = {}) {
if (!append) this.showLoading();
else document.getElementById('loadingStatus').textContent = 'Loading more...';
const offset = (page - 1) * this.limit;
let url = `/prompt_manager/images/output?limit=${this.limit}&offset=${offset}`;
@@ -341,17 +352,44 @@
);
this.currentPage = next.page;
this.total = next.total;
this.images = data.images;
this.images = append ? this.images.concat(data.images) : data.images;
this.updateStats();
this.renderGallery();
this.updatePagination();
document.getElementById('loadingStatus').textContent = 'Loaded';
this.maybeLoadMore(); // a short page may not fill the viewport
} catch (error) {
console.error('Error loading images:', error);
this.showError(error.message);
}
}
maybeLoadMore() {
const el = document.getElementById('galleryScroll');
if (!el) return;
const wanted = ListState.shouldLoadMore({
infiniteScroll: this.infiniteScroll,
loading: this.loadingMore,
page: this.currentPage,
limit: this.limit,
total: this.total,
scrollTop: el.scrollTop,
clientHeight: el.clientHeight,
scrollHeight: el.scrollHeight,
});
if (wanted) this.loadMoreImages();
}
async loadMoreImages() {
if (this.loadingMore) return;
this.loadingMore = true;
try {
await this.loadImages(this.currentPage + 1, { append: true });
} finally {
this.loadingMore = false;
}
}
showLoading() {
document.getElementById('loadingState').classList.remove('hidden');
document.getElementById('errorState').classList.add('hidden');
@@ -372,8 +410,8 @@
}
updateStats() {
const start = (this.currentPage - 1) * this.limit + 1;
const end = Math.min(this.currentPage * this.limit, this.total);
const start = this.infiniteScroll ? 1 : (this.currentPage - 1) * this.limit + 1;
const end = this.infiniteScroll ? this.images.length : Math.min(this.currentPage * this.limit, this.total);
document.getElementById('showingStart').textContent = this.images.length > 0 ? start : 0;
document.getElementById('showingEnd').textContent = end;
@@ -1654,6 +1692,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
document.getElementById('cacheMetadataToggle').checked = settings.cacheMetadata;
document.getElementById('showFilePathsToggle').checked = settings.showFilePaths;
document.getElementById('sidebarCollapsedByDefaultToggle').checked = settings.sidebarCollapsedByDefault;
document.getElementById('infiniteScrollToggle').checked = settings.infiniteScroll;
document.getElementById('showImageInfoToggle').checked = settings.showImageInfo;
document.getElementById('debugModeToggle').checked = settings.debugMode;
document.getElementById('checkThumbnailsAtStartup').checked = settings.checkThumbnailsAtStartup;
@@ -1699,6 +1738,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
cacheMetadata: document.getElementById('cacheMetadataToggle').checked,
showFilePaths: document.getElementById('showFilePathsToggle').checked,
sidebarCollapsedByDefault: document.getElementById('sidebarCollapsedByDefaultToggle').checked,
infiniteScroll: document.getElementById('infiniteScrollToggle').checked,
debugMode: document.getElementById('debugModeToggle').checked,
apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value),
thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this
@@ -1738,6 +1778,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
const limitChanged = this.limit !== settings.defaultLimit;
this.limit = settings.defaultLimit;
const scrollChanged = this.infiniteScroll !== settings.infiniteScroll;
this.infiniteScroll = settings.infiniteScroll;
const limitSelector = document.getElementById('limitSelector');
if (limitSelector) limitSelector.value = String(settings.defaultLimit);
@@ -1746,7 +1788,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
} else if (loaded && !limitChanged) {
this.renderGallery(); // e.g. "Show Media Info" toggled
}
if (loaded && limitChanged) this.loadImages(1);
if (loaded && (limitChanged || scrollChanged)) this.loadImages(1);
}
updateGridColumns(columns) {
@@ -1754,6 +1796,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
if (!gridContainer) return;
// The Tailwind column classes are compiled for a fixed set of values and a
// wider breakpoint overrides them; an explicit template wins on desktop widths.
gridContainer.style.removeProperty('grid-template-columns'); // undo a thumb-size override
gridContainer.style.setProperty('--gallery-columns', GallerySettings.gridColumnsStyle(columns));
gridContainer.classList.add('gallery-columns-custom');
}
@@ -2092,48 +2135,149 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
}
}
async scanDuplicates() {
this.showDuplicatesModal();
const btn = document.getElementById('scanDuplicatesBtn');
btn.disabled = true;
btn.textContent = 'Scanning...';
try {
const response = await fetch('/prompt_manager/scan_duplicates');
const data = await response.json();
if (data.success) {
this.displayDuplicates(data.duplicates || []);
this.showNotification(`Found ${data.duplicates?.length || 0} duplicate image groups`, 'info');
} else {
throw new Error(data.error || 'Failed to scan for duplicate images');
// ── Duplicate scan ──────────────────────────────────────────────
// The scan runs on the server (POST /scan_duplicates/stream starts or attaches).
// Closing the modal only hides it: a pill shows progress and reopens it, and a
// reload re-attaches to a scan that is still running.
scanDuplicates() {
if (this.dupScan.status === ScanProgress.RUNNING) {
this.openDuplicatesModal();
return;
}
this.dupResults = null;
this.dupScan = ScanProgress.reduceScanEvent(ScanProgress.initialState(), {
type: 'progress', progress: 0, status: 'Starting...', processed: 0, found: 0,
});
this.setDuplicateButton(true);
this.openDuplicatesModal();
this.attachDuplicateStream();
}
attachDuplicateStream() {
if (this.dupSource) this.dupSource.close();
const source = SseStream.connect('/prompt_manager/scan_duplicates/stream', { method: 'POST', body: '{}' });
this.dupSource = source;
source.onmessage = (e) => {
let data;
try {
data = JSON.parse(e.data);
} catch (_) {
return;
}
} catch (error) {
console.error('Duplicate scan error:', error);
this.showNotification('Failed to scan for duplicate images', 'error');
this.applyDuplicateEvent(data);
};
source.onerror = () => {
if (this.dupSource !== source || this.dupScan.status !== ScanProgress.RUNNING) return;
setTimeout(() => this.resumeDuplicateScan(), 2000);
};
source.onclose = () => {
if (this.dupSource === source && this.dupScan.status === ScanProgress.RUNNING) this.resumeDuplicateScan();
};
}
async resumeDuplicateScan() {
let status;
try {
const response = await fetch('/prompt_manager/scan_duplicates/status');
if (!response.ok) return;
status = await response.json();
} catch (_) {
return;
}
const state = ScanProgress.fromStatus(status);
if (state.status !== ScanProgress.RUNNING) {
if (this.dupScan.status === ScanProgress.RUNNING) {
// The scan we were following ended while we were away
const last = status.last_event;
const terminal = last && (last.type === 'complete' || last.type === 'error');
this.applyDuplicateEvent(terminal ? last : { type: 'error', message: 'the server no longer reports a running scan' });
}
return;
}
this.dupScan = state;
this.setDuplicateButton(true);
this.renderDuplicateProgress();
if (document.getElementById('duplicatesModal').classList.contains('hidden')) this.showDuplicatePill();
this.attachDuplicateStream();
}
applyDuplicateEvent(data) {
const next = ScanProgress.reduceScanEvent(this.dupScan, data);
if (next === this.dupScan) return;
this.dupScan = next;
this.renderDuplicateProgress();
if (next.status === ScanProgress.DONE) {
this.dupResults = Array.isArray(data.duplicates) ? data.duplicates : [];
this.setDuplicateButton(false);
const modalOpen = !document.getElementById('duplicatesModal').classList.contains('hidden');
if (modalOpen) {
this.hideDuplicatePill();
this.displayDuplicates(this.dupResults);
} else {
this.showDuplicatePill(`Duplicate scan done: ${this.dupResults.length} groups · click to view`);
}
this.showNotification(`Found ${this.dupResults.length} duplicate image groups`, 'info');
} else if (next.status === ScanProgress.FAILED) {
this.setDuplicateButton(false);
this.hideDuplicatePill();
this.hideDuplicatesModal();
} finally {
btn.disabled = false;
btn.textContent = 'Scan';
this.showNotification(`Duplicate scan failed: ${next.message}`, 'error');
}
}
showDuplicatesModal() {
renderDuplicateProgress() {
const state = this.dupScan;
const bar = document.getElementById('dupProgressBar');
const text = document.getElementById('dupProgressText');
if (bar) bar.style.width = `${state.progress}%`;
if (text) text.textContent = state.status === ScanProgress.RUNNING ? `${state.progress}% · ${state.processed} files hashed` : state.statusText;
const pill = document.getElementById('dupScanPillText');
if (pill && state.status === ScanProgress.RUNNING) pill.textContent = `Finding duplicates ${state.progress}% · ${state.processed} files`;
}
setDuplicateButton(running) {
const btn = document.getElementById('scanDuplicatesBtn');
if (!btn) return;
btn.disabled = running;
btn.textContent = running ? 'Scanning...' : 'Scan';
}
showDuplicatePill(label) {
const pill = document.getElementById('dupScanPill');
if (label) document.getElementById('dupScanPillText').textContent = label;
pill.classList.remove('hidden');
}
hideDuplicatePill() {
document.getElementById('dupScanPill').classList.add('hidden');
}
/** Show the modal in whatever state the scan is in: progress, or results. */
openDuplicatesModal() {
this.hideDuplicatePill();
document.getElementById('duplicatesModal').classList.remove('hidden');
document.getElementById('duplicatesModal').classList.add('flex');
document.body.style.overflow = 'hidden';
// Reset modal state
if (this.dupScan.status === ScanProgress.DONE && this.dupResults) {
this.displayDuplicates(this.dupResults);
return;
}
document.getElementById('duplicatesScanStatus').classList.remove('hidden');
document.getElementById('duplicatesContent').classList.add('hidden');
document.getElementById('duplicatesFooter').classList.add('hidden');
this.renderDuplicateProgress();
}
showDuplicatesModal() {
this.openDuplicatesModal();
}
hideDuplicatesModal() {
document.getElementById('duplicatesModal').classList.add('hidden');
document.getElementById('duplicatesModal').classList.remove('flex');
document.body.style.overflow = '';
// Minimize rather than abandon: the scan keeps running on the server
if (this.dupScan.status === ScanProgress.RUNNING) this.showDuplicatePill();
}
displayDuplicates(duplicates) {
@@ -2312,7 +2456,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
pageInput.value = this.currentPage;
pageInput.max = totalPages;
if (totalPages > 1) {
if (totalPages > 1 && !this.infiniteScroll) {
document.getElementById('paginationControls').classList.remove('hidden');
} else {
document.getElementById('paginationControls').classList.add('hidden');
+15 -1
View File
@@ -44,7 +44,21 @@
return Boolean(pagination && pagination.has_more) && receivedCount > 0;
}
const api = { nextPageState, shouldSaveEdit, hasMorePages };
/**
* Infinite scrolling: fetch the next page when the user is within `threshold`
* pixels of the bottom (or the content does not fill the viewport yet), no
* load is in flight and pages remain.
*/
function shouldLoadMore({ infiniteScroll, loading, page, limit, total, scrollTop, clientHeight, scrollHeight, threshold = 600 }) {
if (!infiniteScroll || loading) return false;
const pageSize = nonNegativeInt(limit) || 1;
const totalPages = Math.ceil(nonNegativeInt(total) / pageSize);
if (nonNegativeInt(page) >= totalPages) return false;
const remaining = scrollHeight - clientHeight - scrollTop;
return remaining < threshold;
}
const api = { nextPageState, shouldSaveEdit, hasMorePages, shouldLoadMore };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ListState = api;
})(typeof window !== "undefined" ? window : globalThis);