Surface failed embeddings and add filter for affected files

- Fix root cause: embedding_source_path() returns Result<PathBuf>, returning
  Err for videos without a thumbnail instead of silently falling back to the
  raw .mp4 path that CLIP cannot decode
- indexer: split embedding batch into pre-failed (no source) and embeddable
  jobs; pre-failed are marked immediately without hitting the CLIP model
- db: retry_failed_embedding_jobs skips videos still without a thumbnail so
  they no longer re-fail immediately on retry
- Add get_failed_embedding_images command listing failed files + error per folder
- Gallery: amber warning badge on tiles with embedding_status = 'failed'
- BackgroundTasks: fetch and show failed filenames/errors in expanded panel
- Toolbar: conditional 'Failed Embeddings' amber filter pill shown when any
  folder has embedding_failed > 0; filters at DB level via new
  embedding_failed_only param on get_images / count_images
- TagCloud: replaced vocabulary/dictionary label system with representative
  image thumbnails per cluster; results cached in SQLite by image-id hash
This commit is contained in:
2026-04-06 16:54:03 +01:00
parent 6c3fd449ce
commit d0b41119c6
11 changed files with 560 additions and 343 deletions
+33 -6
View File
@@ -91,16 +91,22 @@ function FilterPill({
label,
active,
onClick,
variant = "default",
}: {
label: string;
active: boolean;
onClick: () => void;
variant?: "default" | "amber";
}) {
const activeClass =
variant === "amber"
? "bg-amber-500/15 text-amber-300 border border-amber-500/30"
: "bg-white/10 text-white";
return (
<button
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
active
? "bg-white/10 text-white"
? activeClass
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={onClick}
@@ -127,12 +133,21 @@ export function Toolbar() {
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
const [searchValue, setSearchValue] = useState(search);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Tracks whether the user has typed in the search box at least once.
// Prevents the debounce effect from dispatching setSearch on initial mount
// when searchValue === search (which would wipe a loadSimilarImages result).
const userHasTyped = useRef(false);
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media");
@@ -153,6 +168,7 @@ export function Toolbar() {
}, [mediaFilter, sort, setSort]);
useEffect(() => {
if (!userHasTyped.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { setSearch(searchValue); }, 200);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
@@ -233,7 +249,10 @@ export function Toolbar() {
ref={searchInputRef}
type="text"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
onChange={(event) => {
userHasTyped.current = true;
setSearchValue(event.target.value);
}}
placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."}
className="w-64 bg-transparent py-1.5 pl-8 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors"
/>
@@ -283,10 +302,18 @@ export function Toolbar() {
{/* Filter row */}
<div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => setFavoritesOnly(!favoritesOnly)} />
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} />
{hasAnyFailedEmbeddings ? (
<FilterPill
label="Failed Embeddings"
active={failedEmbeddingsOnly}
variant="amber"
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
/>
) : null}
</div>
</div>
);