Improve media indexing and processing flow

This commit is contained in:
2026-04-05 18:58:10 +01:00
parent c299c7d452
commit c4036140e6
18 changed files with 1663 additions and 472 deletions
+5 -1
View File
@@ -1,13 +1,16 @@
import { useEffect } from "react";
import { useGalleryStore } from "./store";
import { Sidebar } from "./components/Sidebar";
import { BackgroundTasks } from "./components/BackgroundTasks";
import { MenuBar } from "./components/MenuBar";
import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox";
export default function App() {
const { loadFolders, loadImages, subscribeToProgress } = useGalleryStore();
const loadFolders = useGalleryStore((state) => state.loadFolders);
const loadImages = useGalleryStore((state) => state.loadImages);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
useEffect(() => {
loadFolders().then(() => loadImages(true));
@@ -26,6 +29,7 @@ export default function App() {
<main className="flex-1 flex flex-col min-w-0">
<MenuBar />
<Toolbar />
<BackgroundTasks />
<Gallery />
</main>
<Lightbox />
+88
View File
@@ -0,0 +1,88 @@
import { useMemo } from "react";
import { useGalleryStore } from "../store";
function ProgressBar({ value }: { value: number }) {
return (
<div className="h-1.5 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-blue-400 transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, value))}%` }}
/>
</div>
);
}
export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const tasks = useMemo(() => {
return folders
.map((folder) => {
const index = indexingProgress[folder.id];
const jobs = mediaJobProgress[folder.id];
const pendingMediaWork = (jobs?.thumbnail_pending ?? 0) + (jobs?.metadata_pending ?? 0);
if (!index && pendingMediaWork === 0) {
return null;
}
const indexPercent = index && index.total > 0 ? (index.indexed / index.total) * 100 : 0;
return {
id: folder.id,
name: folder.name,
index,
jobs,
pendingMediaWork,
indexPercent,
};
})
.filter((task) => task !== null);
}, [folders, indexingProgress, mediaJobProgress]);
if (tasks.length === 0) {
return null;
}
return (
<div className="border-b border-white/5 bg-gray-950/40 px-5 py-2 backdrop-blur-xl">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Background Tasks</h3>
<span className="text-xs text-gray-500">{tasks.length} active</span>
</div>
<div className="grid gap-2 xl:grid-cols-2">
{tasks.map((task) => (
<div key={task.id} className="rounded-xl border border-white/8 bg-white/[0.03] px-3 py-2.5">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{task.name}</p>
<p className="text-[11px] text-gray-500">
{task.index && !task.index.done
? `${task.index.indexed.toLocaleString()} of ${task.index.total.toLocaleString()} scanned`
: `${task.pendingMediaWork.toLocaleString()} media jobs remaining`}
</p>
</div>
<div className="text-right text-[11px] text-gray-400">
{task.jobs?.thumbnail_pending ? <div>{task.jobs.thumbnail_pending.toLocaleString()} thumbnails</div> : null}
{task.jobs?.metadata_pending ? <div>{task.jobs.metadata_pending.toLocaleString()} metadata</div> : null}
</div>
</div>
{task.index && !task.index.done ? (
<div className="mt-2 space-y-1">
<ProgressBar value={task.indexPercent} />
<p className="truncate text-[11px] text-gray-500">{task.index.current_file || "Scanning..."}</p>
</div>
) : task.pendingMediaWork > 0 ? (
<div className="mt-2 space-y-1">
<ProgressBar value={0} />
<p className="text-[11px] text-gray-500">Processing thumbnails and metadata</p>
</div>
) : null}
</div>
))}
</div>
</div>
);
}
+25 -1
View File
@@ -24,6 +24,20 @@ function RatingStars({ rating }: { rating: number }) {
);
}
function formatDuration(durationMs: number | null): string | null {
if (!durationMs || durationMs <= 0) return null;
const totalSeconds = Math.floor(durationMs / 1000);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function ContextMenu({
x,
y,
@@ -174,6 +188,11 @@ function ImageTile({
{image.media_kind}
</div>
<div className="flex items-center gap-1">
{image.media_kind === "video" && image.duration_ms ? (
<div className="rounded-full border border-white/10 bg-black/35 px-2 py-1 text-[10px] font-medium text-white/80">
{formatDuration(image.duration_ms)}
</div>
) : null}
{image.favorite ? (
<div className="rounded-full border border-white/10 bg-black/35 p-1 text-rose-300">
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
@@ -196,7 +215,12 @@ function ImageTile({
}
export function Gallery() {
const { images, loadMoreImages, openImage, totalImages, loadingImages, zoomPreset } = useGalleryStore();
const images = useGalleryStore((state) => state.images);
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages);
const openImage = useGalleryStore((state) => state.openImage);
const totalImages = useGalleryStore((state) => state.totalImages);
const loadingImages = useGalleryStore((state) => state.loadingImages);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const parentRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
+96 -49
View File
@@ -18,8 +18,26 @@ function formatDate(iso: string | null): string {
});
}
function formatDuration(durationMs: number | null): string {
if (!durationMs || durationMs <= 0) return "Pending / unavailable";
const totalSeconds = Math.floor(durationMs / 1000);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export function Lightbox() {
const { selectedImage, closeImage, images, openImage, updateImageDetails } = useGalleryStore();
const selectedImage = useGalleryStore((state) => state.selectedImage);
const closeImage = useGalleryStore((state) => state.closeImage);
const images = useGalleryStore((state) => state.images);
const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const [zoom, setZoom] = useState(1);
const imageViewportRef = useRef<HTMLDivElement>(null);
@@ -93,58 +111,61 @@ export function Lightbox() {
</svg>
</button>
<motion.div
key={selectedImage.id}
className="flex flex-1 flex-col"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex flex-1 flex-col" onClick={(event) => event.stopPropagation()}>
<div className="flex flex-1 overflow-hidden">
<div
ref={imageViewportRef}
className="group relative flex flex-1 items-center justify-center overflow-auto p-10"
>
{selectedImage.media_kind === "video" ? (
<video
src={convertFileSrc(selectedImage.path)}
controls
className="max-h-full max-w-full rounded-2xl shadow-2xl"
style={{ maxHeight: "calc(100vh - 10rem)" }}
/>
) : (
<>
<img
src={convertFileSrc(selectedImage.path)}
alt={selectedImage.filename}
className="max-w-full rounded-2xl shadow-2xl"
style={{
maxHeight: "calc(100vh - 10rem)",
transform: `scale(${zoom})`,
transformOrigin: "center center",
}}
/>
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
<button
className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
>
-
</button>
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
<button
className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
>
+
</button>
</div>
</div>
</>
)}
<AnimatePresence mode="wait">
<motion.div
key={selectedImage.id}
className="flex items-center justify-center"
initial={{ opacity: 0.3, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0.3, scale: 0.985 }}
transition={{ duration: 0.12 }}
>
{selectedImage.media_kind === "video" ? (
<video
src={convertFileSrc(selectedImage.path)}
controls
className="max-h-full max-w-full rounded-2xl shadow-2xl"
style={{ maxHeight: "calc(100vh - 10rem)" }}
/>
) : (
<>
<img
src={convertFileSrc(selectedImage.path)}
alt={selectedImage.filename}
className="max-w-full rounded-2xl shadow-2xl"
style={{
maxHeight: "calc(100vh - 10rem)",
transform: `scale(${zoom})`,
transformOrigin: "center center",
}}
/>
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
<button
className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
>
-
</button>
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
<button
className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
>
+
</button>
</div>
</div>
</>
)}
</motion.div>
</AnimatePresence>
</div>
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 p-5">
@@ -217,6 +238,32 @@ export function Lightbox() {
</p>
</div>
{selectedImage.media_kind === "video" ? (
<>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Duration</p>
<p className="text-white">{formatDuration(selectedImage.duration_ms)}</p>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Video codec</p>
<p className="text-white">{selectedImage.video_codec ?? "Pending / unavailable"}</p>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Audio codec</p>
<p className="text-white">{selectedImage.audio_codec ?? "None / unavailable"}</p>
</div>
{selectedImage.metadata_error ? (
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Metadata</p>
<p className="text-amber-300">{selectedImage.metadata_error}</p>
</div>
) : null}
</>
) : null}
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Type</p>
<p className="text-white">{selectedImage.mime_type}</p>
@@ -248,7 +295,7 @@ export function Lightbox() {
</div>
</div>
</div>
</motion.div>
</div>
<button
className="absolute right-80 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
+9 -11
View File
@@ -72,17 +72,15 @@ const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [
export function MenuBar() {
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const {
addFolder,
reindexFolder,
selectedFolderId,
zoomPreset,
setZoomPreset,
mediaFilter,
setMediaFilter,
favoritesOnly,
setFavoritesOnly,
} = useGalleryStore();
const addFolder = useGalleryStore((state) => state.addFolder);
const reindexFolder = useGalleryStore((state) => state.reindexFolder);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const mediaFilter = useGalleryStore((state) => state.mediaFilter);
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
+6 -2
View File
@@ -95,8 +95,12 @@ function FolderItem({
}
export function Sidebar() {
const { folders, selectedFolderId, addFolder, indexingProgress, totalImages, selectFolder } =
useGalleryStore();
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const addFolder = useGalleryStore((state) => state.addFolder);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const totalImages = useGalleryStore((state) => state.totalImages);
const selectFolder = useGalleryStore((state) => state.selectFolder);
const handleAddFolder = async () => {
const selected = await open({
+14 -16
View File
@@ -34,22 +34,20 @@ function FilterChip({
}
export function Toolbar() {
const {
search,
setSearch,
sort,
setSort,
totalImages,
loadedCount,
selectedFolderId,
folders,
mediaFilter,
setMediaFilter,
favoritesOnly,
setFavoritesOnly,
zoomPreset,
setZoomPreset,
} = useGalleryStore();
const search = useGalleryStore((state) => state.search);
const setSearch = useGalleryStore((state) => state.setSearch);
const sort = useGalleryStore((state) => state.sort);
const setSort = useGalleryStore((state) => state.setSort);
const totalImages = useGalleryStore((state) => state.totalImages);
const loadedCount = useGalleryStore((state) => state.loadedCount);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const folders = useGalleryStore((state) => state.folders);
const mediaFilter = useGalleryStore((state) => state.mediaFilter);
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const [searchValue, setSearchValue] = useState(search);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+58 -8
View File
@@ -28,6 +28,11 @@ export interface ImageRecord {
modified_at: string | null;
mime_type: string;
media_kind: MediaKind;
duration_ms: number | null;
video_codec: string | null;
audio_codec: string | null;
metadata_updated_at: string | null;
metadata_error: string | null;
favorite: boolean;
rating: number;
embedding_status: string;
@@ -44,6 +49,16 @@ export interface IndexProgress {
done: boolean;
}
export interface FolderJobProgress {
folder_id: number;
thumbnail_pending: number;
metadata_pending: number;
}
export interface MediaJobProgressEvent {
progress: FolderJobProgress[];
}
export interface IndexedImagesBatch {
folder_id: number;
images: ImageRecord[];
@@ -75,6 +90,7 @@ interface GalleryState {
zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null;
indexingProgress: Record<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>;
cacheDir: string;
loadFolders: () => Promise<void>;
@@ -98,6 +114,16 @@ interface GalleryState {
const PAGE_SIZE = 200;
function mergeIntoVisibleWindow(
currentImages: ImageRecord[],
newImages: ImageRecord[],
sort: SortOrder,
windowSize: number,
): ImageRecord[] {
const merged = mergeImages(currentImages, newImages, sort);
return merged.slice(0, Math.max(windowSize, 0));
}
function matchesSearch(image: ImageRecord, search: string): boolean {
if (!search) return true;
return image.filename.toLowerCase().includes(search.toLowerCase());
@@ -175,6 +201,16 @@ function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: So
return mergeImages(images, [updatedImage], sort);
}
function replaceExistingImages(
currentImages: ImageRecord[],
updatedImages: ImageRecord[],
sort: SortOrder,
): ImageRecord[] {
const updatesByPath = new Map(updatedImages.map((image) => [image.path, image]));
const nextImages = currentImages.map((image) => updatesByPath.get(image.path) ?? image);
return nextImages.sort((a, b) => compareImages(a, b, sort));
}
export function tileSizeForZoom(zoomPreset: ZoomPreset): number {
switch (zoomPreset) {
case "compact":
@@ -200,6 +236,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
zoomPreset: "comfortable",
selectedImage: null,
indexingProgress: {},
mediaJobProgress: {},
cacheDir: "",
setCacheDir: (cacheDir) => set({ cacheDir }),
@@ -210,8 +247,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
addFolder: async (path) => {
const { cacheDir, loadFolders } = get();
await invoke("add_folder", { path, cacheDir });
const { loadFolders } = get();
await invoke("add_folder", { path });
await loadFolders();
},
@@ -226,8 +263,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
reindexFolder: async (folderId) => {
const { cacheDir, loadFolders } = get();
await invoke("reindex_folder", { folderId, cacheDir });
const { loadFolders } = get();
await invoke("reindex_folder", { folderId });
await loadFolders();
},
@@ -329,6 +366,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
if (progress.done) {
void get().loadFolders();
void get().loadImages(true);
setTimeout(() => {
set((state) => {
@@ -340,6 +378,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
});
const unlistenMediaJobs = await listen<MediaJobProgressEvent>("media-job-progress", (event) => {
set((state) => {
const next = { ...state.mediaJobProgress };
for (const progress of event.payload.progress) {
next[progress.folder_id] = progress;
}
return { mediaJobProgress: next };
});
});
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
const batch = event.payload;
@@ -359,16 +407,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
const newVisibleCount = countNewImages(state.images, visibleImages);
const images = mergeImages(state.images, visibleImages, state.sort);
const visibleWindow = Math.max(state.loadedCount, PAGE_SIZE);
const images = mergeIntoVisibleWindow(state.images, visibleImages, state.sort, visibleWindow);
return {
images,
loadedCount: images.length,
loadedCount: Math.max(state.loadedCount, Math.min(images.length, visibleWindow)),
totalImages: Math.max(state.totalImages + newVisibleCount, images.length),
};
});
});
const unlistenThumbnails = await listen<ThumbnailBatch>("thumbnail-updated", (event) => {
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
const batch = event.payload;
set((state) => {
@@ -392,7 +441,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
return {
images: mergeImages(state.images, visibleImages, state.sort),
images: replaceExistingImages(state.images, visibleImages, state.sort),
selectedImage,
};
});
@@ -400,6 +449,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return () => {
unlistenProgress();
unlistenMediaJobs();
unlistenImages();
unlistenThumbnails();
};