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:
@@ -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
@@ -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():
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user