Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions photomap/backend/embeddings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1497,9 +1497,12 @@ async def update_index_async(
)

try:
existing = self._load_existing_index_arrays()

# Register before loading: reading a large existing .npz can take
# seconds, and progress polls during it must not report "idle".
progress_tracker.start_operation(album_key, 0, "scanning")
progress_tracker.update_progress(album_key, 0, "Loading existing index...")

existing = self._load_existing_index_arrays()

def traversal_callback(count, message):
# Update the total as we discover more files.
Expand Down
21 changes: 17 additions & 4 deletions photomap/backend/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,23 @@ def begin_indexing(self, album_key: str, total_images: int) -> None:
def set_error(self, album_key: str, error_message: str):
"""Set error status for an album."""
with self._lock:
if album_key in self._progress:
progress = self._progress[album_key]
progress.status = IndexStatus.ERROR
progress.error_message = error_message
progress = self._progress.get(album_key)
if progress is None:
# A failure can hit before the run's first start_operation
# (e.g. the InvokeAI board fetch). Create the entry rather
# than dropping the error, or the frontend polls "idle"
# forever and the failure is invisible outside the log.
progress = ProgressInfo(
album_key=album_key,
status=IndexStatus.ERROR,
current_step="",
images_processed=0,
total_images=0,
start_time=time.time(),
)
self._progress[album_key] = progress
progress.status = IndexStatus.ERROR
progress.error_message = error_message

def set_completion_warning(self, album_key: str, message: str | None) -> None:
"""Record (or clear) a non-fatal notice to attach when the run completes.
Expand Down
12 changes: 12 additions & 0 deletions photomap/backend/routers/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ async def update_index_async(
)

album_config = validate_album_exists(album_key)
# Register the run before the background task is even scheduled, so
# the very first progress poll sees a live operation instead of
# "idle": board resolution and loading a large existing index both
# happen before the first in-task start_operation, and a poller that
# reads "idle" during that window paints "No operation in progress".
# It also guarantees set_error() has an entry to land on if the task
# dies before scanning starts (e.g. InvokeAI unreachable).
progress_tracker.start_operation(album_key, 0, "scanning")
progress_tracker.update_progress(album_key, 0, "Preparing index update...")
background_tasks.add_task(
_update_index_background_async, album_key, album_config
)
Expand Down Expand Up @@ -551,6 +560,9 @@ async def _update_index_background_async(album_key: str, album_config):
"""Background task for updating index with async support."""
try:
if getattr(album_config, "source_type", "directory") == "invokeai_board":
progress_tracker.update_progress(
album_key, 0, "Fetching board contents from InvokeAI..."
)
try:
image_paths, missing = await _resolve_board_album_files(album_config)
except HTTPException as e:
Expand Down
12 changes: 7 additions & 5 deletions photomap/frontend/static/javascript/album-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -1823,11 +1823,6 @@ export class AlbumManager {
await loadAvailableAlbums();
this.showIndexingCompletedUI(cardElement);

if (albumKey === state.album) {
console.log(`Refreshing slideshow for completed indexing of current album: ${albumKey}`);
this.single_swiper.resetAllSlides();
}

// After a delay set the status
if (cardElement) {
setTimeout(async () => {
Expand Down Expand Up @@ -1877,11 +1872,18 @@ export class AlbumManager {
}
}
if (albumKey === state.album) {
// Reload the semantic map (or mark it stale if it's hidden).
window.dispatchEvent(new CustomEvent("albumIndexUpdated", { detail: { albumKey } }));
// Rebuild the slideshow/grid against the new image count; the
// "refresh" type keeps the current position and any active search
// instead of resetting to the first slide (see slide-state.js).
const album = await getIndexMetadata(albumKey);
window.dispatchEvent(
new CustomEvent("albumChanged", {
detail: {
album: albumKey,
totalImages: album ? album.filename_count : 0,
changeType: "refresh",
},
})
);
Expand Down
25 changes: 25 additions & 0 deletions photomap/frontend/static/javascript/back-stack.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,31 @@ class BackStack {
this._notifyChanged();
return;
}
if (detail && detail.changeType === "refresh") {
// Same album re-indexed in place. Index updates append, so existing
// entries stay valid unless images were removed — drop only the ones
// that now point past the end rather than wiping the whole stack.
const total = typeof detail.totalImages === "number" ? detail.totalImages : Infinity;
let newCursor = this._cursor;
const kept = [];
for (let i = 0; i < this._entries.length; i++) {
if (this._entries[i].globalIndex >= total) {
if (i <= this._cursor) {
newCursor--;
}
continue;
}
kept.push(this._entries[i]);
}
if (kept.length !== this._entries.length) {
this._entries = kept;
this._cursor = kept.length === 0 ? -1 : Math.max(0, Math.min(newCursor, kept.length - 1));
this._historyStates = [this._historyStates[0]];
this._currentHistoryIdx = 0;
this._notifyChanged();
}
return;
}
// Album switch / move / index change: cross-album back doesn't make sense.
// The next slideChanged is recorded as a plain step (NOT a jump), so the
// album load itself doesn't create a browser-history entry. If we did
Expand Down
7 changes: 7 additions & 0 deletions photomap/frontend/static/javascript/bookmarks.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ class BookmarkManager {
this.updateAllBookmarkIcons();
return;
}
if (detail.changeType === "refresh") {
// Same album re-indexed in place: the bookmark set is unchanged, and
// resetting isShowingBookmarks/previousSearchResults would drop the
// user's bookmark-browsing context mid-session.
this.updateAllBookmarkIcons();
return;
}
this.loadBookmarks();
this.isShowingBookmarks = false;
this.previousSearchResults = null;
Expand Down
5 changes: 3 additions & 2 deletions photomap/frontend/static/javascript/grid-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,9 @@ class GridViewManager {
});

this.addEventListener(window, "albumChanged", async (e) => {
// Update cache breaker to force thumbnail reload, especially for deletions
if (e.detail?.changeType === "deletion") {
// Update cache breaker to force thumbnail reload whenever the image
// set changed underneath existing indices (deletion, in-place reindex)
if (e.detail?.changeType === "deletion" || e.detail?.changeType === "refresh") {
this.cacheBreaker = Date.now();
}
await this.resetAllSlides();
Expand Down
20 changes: 20 additions & 0 deletions photomap/frontend/static/javascript/slide-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,26 @@ class SlideStateManager {
}

handleAlbumChanged(detail) {
// A "refresh" is the same album whose index was rebuilt in place (e.g.
// the semantic map's reindex button): keep the current position and any
// active search instead of jumping back to the first slide. Index
// updates append new images, so existing global indices only shift when
// images were removed — clamp against the new total for that case.
if (detail.changeType === "refresh") {
this.totalAlbumImages = detail.totalImages;
if (this.isSearchMode) {
this.searchResults = this.searchResults.filter((r) => r.index < detail.totalImages);
if (this.searchResults.length > 0) {
this.currentSearchIndex = Math.min(this.currentSearchIndex, this.searchResults.length - 1);
this.currentGlobalIndex = this.searchResults[this.currentSearchIndex].index;
return;
}
this.exitSearchMode();
}
this.currentGlobalIndex = detail.totalImages > 0 ? Math.min(this.currentGlobalIndex, detail.totalImages - 1) : 0;
return;
}

// For deletions, try to preserve position by calculating how many images before current were deleted
if (detail.changeType === "deletion" && detail.deletedIndices && !this.isSearchMode) {
const deletedIndices = detail.deletedIndices;
Expand Down
40 changes: 36 additions & 4 deletions photomap/frontend/static/javascript/umap-reindex.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
// Album Manager's status colours), and whose hover title carries the live
// status text. Clicking the ring does nothing — cancellation stays in the
// Album Manager. On completion an "albumIndexUpdated" event is dispatched
// so the map can reload itself.
// so the map can reload itself, and an albumChanged/"refresh" event so the
// slideshow picks up the new image set without losing its place.

import { updateIndex } from "./index.js";
import { getIndexMetadata, updateIndex } from "./index.js";
import { state } from "./state.js";
import { fetchJson } from "./utils.js";

Expand Down Expand Up @@ -118,6 +119,7 @@ function beginProgress(albumKey, initialProgress = null) {
stopPolling();
if (progress.status === "completed") {
window.dispatchEvent(new CustomEvent("albumIndexUpdated", { detail: { albumKey } }));
await refreshAlbumImageData(albumKey);
} else if (progress.status === "error") {
const { btn } = elements();
if (btn) {
Expand All @@ -134,6 +136,32 @@ function beginProgress(albumKey, initialProgress = null) {
}, reindexConfig.pollInterval);
}

// The index the swiper and grid are browsing changed underneath them:
// fetch the fresh image count and fire an albumChanged with
// changeType "refresh", which rebuilds the slides while slideState keeps
// the current position and any active search results.
async function refreshAlbumImageData(albumKey) {
if (albumKey !== state.album) {
return;
}
let metadata = null;
try {
metadata = await getIndexMetadata(albumKey);
} catch (error) {
console.error(`Failed to refresh index metadata for album ${albumKey}:`, error);
return;
}
window.dispatchEvent(
new CustomEvent("albumChanged", {
detail: {
album: albumKey,
totalImages: metadata?.filename_count || 0,
changeType: "refresh",
},
})
);
}

export async function startUmapReindex() {
const albumKey = state.album;
if (!albumKey || pollTimer) {
Expand Down Expand Up @@ -204,7 +232,11 @@ export function initUmapReindexButton() {
startUmapReindex();
});

window.addEventListener("albumChanged", () => {
checkUmapReindexOngoing();
window.addEventListener("albumChanged", (e) => {
// "refresh" is dispatched by this module when a run completes — the
// ring is already down and the album hasn't changed, so nothing to do.
if (e.detail?.changeType !== "refresh") {
checkUmapReindexOngoing();
}
});
}
20 changes: 16 additions & 4 deletions photomap/frontend/static/javascript/umap.js
Original file line number Diff line number Diff line change
Expand Up @@ -2030,15 +2030,27 @@ document.addEventListener("DOMContentLoaded", () => {
initializeUmapWindow();
});

// The titlebar reindex button finished an update of the current album: the
// map on screen is stale, so reload it.
// An index update of the current album finished (titlebar reindex button or
// Album Management): the map data is stale. Reload right away if the window
// is showing; otherwise the dataChanged flag makes toggleUmapWindow's show
// path refetch, without rendering Plotly into a hidden container here.
window.addEventListener("albumIndexUpdated", (e) => {
if (e.detail?.albumKey === state.album) {
state.dataChanged = true;
fetchUmapData();
const umapWindow = document.getElementById("umapFloatingWindow");
if (umapWindow?.style.display === "block") {
fetchUmapData();
}
}
});
window.addEventListener("albumChanged", (e) => {
// A "refresh" is the same album re-indexed in place: the map reload is
// handled by the albumIndexUpdated listener above, and re-initializing
// here would refetch a second time and force the window fullscreen.
if (e.detail?.changeType !== "refresh") {
initializeUmapWindow();
}
});
window.addEventListener("albumChanged", initializeUmapWindow);

// ========================================================
// Toggle Curation Mode (Grey out all points)
Expand Down
32 changes: 32 additions & 0 deletions tests/backend/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -741,3 +741,35 @@ def test_index_metadata_reflects_last_update_operation(client, new_album):

assert meta2["filename_count"] == meta1["filename_count"]
assert meta2["last_modified"] > meta1["last_modified"]


def test_update_endpoint_registers_progress_before_task_runs(
client, new_album, monkeypatch
):
"""The update endpoint must create the progress entry itself: the first
poll otherwise races the background task (board resolution and loading a
big existing .npz happen before the task's own start_operation) and
paints "No operation in progress" — and if the task dies inside that
window, the run looks idle forever."""
from photomap.backend.progress import progress_tracker
from photomap.backend.routers import index as index_router

key = new_album["key"]
progress_tracker.remove_progress(key)

async def frozen_task(album_key, album_config):
return None # never reaches its own start_operation

monkeypatch.setattr(index_router, "_update_index_background_async", frozen_task)

try:
response = client.post("/update_index_async", json={"album_key": key})
assert response.status_code == 202

progress = client.get(f"/index_progress/{key}").json()
assert progress["status"] == "scanning"
assert progress["current_step"] == "Preparing index update..."
finally:
# The frozen task never completes; drop the entry so the shared
# tracker can't 409 later tests that reuse this album key.
progress_tracker.remove_progress(key)
24 changes: 24 additions & 0 deletions tests/backend/test_invokeai_board_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,27 @@ def test_describe_image_source_keeps_logs_compact():
description = describe_image_source(many)
assert description == f"3666 explicit paths under {Path('/root/outputs/images')}"
assert len(description) < 120


def test_first_update_failure_surfaces_error_without_prior_run(
client, board_album, monkeypatch
):
"""A failure before scanning starts (InvokeAI unreachable) must produce a
visible ERROR even when the tracker has no entry from an earlier run —
e.g. the first update after a server restart. set_error used to drop the
message then, leaving the UI stuck on "No operation in progress"."""
from photomap.backend.progress import progress_tracker

_build_index(client)
progress_tracker.remove_progress(ALBUM_KEY) # simulate a fresh server

async def broken_fetch(base_url, board_ids, username, password):
raise HTTPException(status_code=502, detail="connection refused")

monkeypatch.setattr(invokeai_client, "fetch_board_image_names", broken_fetch)

response = client.post("/update_index_async", json={"album_key": ALBUM_KEY})
assert response.status_code == 202
progress = _poll_until(client, ALBUM_KEY, {"completed", "error"}, timeout=10)
assert progress["status"] == "error"
assert "InvokeAI" in progress["error_message"]
15 changes: 15 additions & 0 deletions tests/backend/test_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,18 @@ def test_set_completion_warning_none_clears_pending():
tracker.complete_operation("alb")

assert tracker.get_progress("alb").warning_message is None


def test_set_error_creates_entry_when_none_exists():
"""Failures before a run's first start_operation (e.g. the InvokeAI board
fetch) must surface as a visible ERROR entry, not vanish — a dropped
error leaves the frontend polling "No operation in progress" forever."""
tracker = ProgressTracker()

tracker.set_error("alb", "boom")

progress = tracker.get_progress("alb")
assert progress is not None
assert progress.status is IndexStatus.ERROR
assert progress.error_message == "boom"
assert tracker.is_running("alb") is False
Loading
Loading