diff --git a/py/api/__init__.py b/py/api/__init__.py
index cf2fa251..1a7d5b60 100644
--- a/py/api/__init__.py
+++ b/py/api/__init__.py
@@ -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:
diff --git a/py/api/admin.py b/py/api/admin.py
index 6fbf9241..1af21360 100644
--- a/py/api/admin.py
+++ b/py/api/admin.py
@@ -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():
diff --git a/tests/js/gallery-settings.test.js b/tests/js/gallery-settings.test.js
index 44098a53..c3681a29 100644
--- a/tests/js/gallery-settings.test.js
+++ b/tests/js/gallery-settings.test.js
@@ -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);
+});
diff --git a/tests/js/list-state.test.js b/tests/js/list-state.test.js
index 0c2186ed..3aa11d20 100644
--- a/tests/js/list-state.test.js
+++ b/tests/js/list-state.test.js
@@ -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");
+});
diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py
index 06bfcc35..180d09c2 100644
--- a/tests/test_api_admin.py
+++ b/tests/test_api_admin.py
@@ -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()
diff --git a/web/gallery.html b/web/gallery.html
index be411101..d494247e 100644
--- a/web/gallery.html
+++ b/web/gallery.html
@@ -8,6 +8,7 @@
+
@@ -120,7 +121,7 @@
-
+
+
+
-
-
-
- Scanning for duplicate images...
+
diff --git a/web/js/gallery-settings.js b/web/js/gallery-settings.js
index d2fe3e25..4c21dd62 100644
--- a/web/js/gallery-settings.js
+++ b/web/js/gallery-settings.js
@@ -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),
diff --git a/web/js/gallery.js b/web/js/gallery.js
index 2b70ed66..0bb2831e 100644
--- a/web/js/gallery.js
+++ b/web/js/gallery.js
@@ -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');
diff --git a/web/js/list-state.js b/web/js/list-state.js
index 1ec32e06..a6cf7533 100644
--- a/web/js/list-state.js
+++ b/web/js/list-state.js
@@ -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);