Improve indexing updates and thumbnail streaming

This commit is contained in:
2026-04-05 16:49:10 +01:00
parent c5e9c83ac9
commit c299c7d452
7 changed files with 382 additions and 41 deletions
+1 -1
View File
@@ -15,7 +15,6 @@ dist-ssr
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
@@ -23,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
*.local.*
+97
View File
@@ -65,6 +65,13 @@ pub struct EmbeddingJob {
pub updated_at: String,
}
#[derive(Debug, Clone)]
pub struct ThumbnailJob {
pub image_id: i64,
pub path: String,
pub media_kind: String,
}
pub fn create_pool(db_path: &Path) -> Result<DbPool> {
vector::register_sqlite_vec();
let manager = SqliteConnectionManager::file(db_path);
@@ -111,9 +118,19 @@ pub fn migrate(conn: &Connection) -> Result<()> {
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS thumbnail_jobs (
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_images_folder_id ON images(folder_id);
CREATE INDEX IF NOT EXISTS idx_images_modified_at ON images(modified_at);
CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status);
CREATE INDEX IF NOT EXISTS idx_thumbnail_jobs_status ON thumbnail_jobs(status);
",
)?;
@@ -209,6 +226,19 @@ pub fn enqueue_embedding_job(conn: &Connection, image_id: i64) -> Result<()> {
Ok(())
}
pub fn enqueue_thumbnail_job(conn: &Connection, image_id: i64) -> Result<()> {
conn.execute(
"INSERT INTO thumbnail_jobs (image_id, status, attempts, last_error, created_at, updated_at)
VALUES (?1, 'pending', 0, NULL, datetime('now'), datetime('now'))
ON CONFLICT(image_id) DO UPDATE SET
status = 'pending',
last_error = NULL,
updated_at = datetime('now')",
[image_id],
)?;
Ok(())
}
#[allow(dead_code)]
pub fn get_next_embedding_job(conn: &Connection) -> Result<Option<EmbeddingJob>> {
let mut stmt = conn.prepare(
@@ -282,6 +312,61 @@ pub fn update_folder_count(conn: &Connection, folder_id: i64) -> Result<()> {
Ok(())
}
pub fn get_next_thumbnail_job(conn: &Connection) -> Result<Option<ThumbnailJob>> {
let mut stmt = conn.prepare(
"SELECT j.image_id, i.path, i.media_kind
FROM thumbnail_jobs j
JOIN images i ON i.id = j.image_id
WHERE j.status = 'pending'
ORDER BY j.updated_at, j.image_id
LIMIT 1",
)?;
let mut rows = stmt.query([])?;
let Some(row) = rows.next()? else {
return Ok(None);
};
Ok(Some(ThumbnailJob {
image_id: row.get(0)?,
path: row.get(1)?,
media_kind: row.get(2)?,
}))
}
pub fn mark_thumbnail_job_processing(conn: &Connection, image_id: i64) -> Result<()> {
conn.execute(
"UPDATE thumbnail_jobs
SET status = 'processing', attempts = attempts + 1, updated_at = datetime('now')
WHERE image_id = ?1",
[image_id],
)?;
Ok(())
}
pub fn mark_thumbnail_ready(
conn: &Connection,
image_id: i64,
thumbnail_path: Option<&str>,
) -> Result<ImageRecord> {
conn.execute(
"UPDATE images SET thumbnail_path = ?2 WHERE id = ?1",
params![image_id, thumbnail_path],
)?;
conn.execute("DELETE FROM thumbnail_jobs WHERE image_id = ?1", [image_id])?;
get_image_by_id(conn, image_id)
}
pub fn mark_thumbnail_failed(conn: &Connection, image_id: i64, error: &str) -> Result<()> {
conn.execute(
"UPDATE thumbnail_jobs
SET status = 'failed', last_error = ?2, updated_at = datetime('now')
WHERE image_id = ?1",
params![image_id, error],
)?;
Ok(())
}
pub fn update_image_details(
conn: &Connection,
image_id: i64,
@@ -307,6 +392,18 @@ pub fn update_image_details(
.map_err(Into::into)
}
pub fn get_image_by_id(conn: &Connection, image_id: i64) -> Result<ImageRecord> {
conn.query_row(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
media_kind, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
FROM images
WHERE id = ?1",
[image_id],
map_image_row,
)
.map_err(Into::into)
}
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
let mut stmt =
conn.prepare("SELECT id, path, name, image_count, indexed_at FROM folders ORDER BY name")?;
+100 -8
View File
@@ -63,7 +63,13 @@ pub struct IndexedImagesBatch {
pub images: Vec<ImageRecord>,
}
#[derive(Clone, Serialize)]
pub struct ThumbnailBatch {
pub images: Vec<ImageRecord>,
}
const INDEX_BATCH_SIZE: usize = 25;
const THUMBNAIL_BATCH_SIZE: usize = 12;
pub fn index_folder(
app: AppHandle,
@@ -101,25 +107,40 @@ fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf)
},
);
// Parallel: read file metadata and dimensions across all CPU cores
let records: Vec<ImageRecord> = image_paths
let mut indexed = 0usize;
for path_chunk in image_paths.chunks(INDEX_BATCH_SIZE) {
let records: Vec<ImageRecord> = path_chunk
.par_iter()
.filter_map(|path| build_record(path, folder_id))
.collect();
// Sequential: commit in batches and stream results to the frontend
let mut indexed = 0usize;
for chunk in records.chunks(INDEX_BATCH_SIZE) {
let committed = commit_batch(&pool, chunk)?;
if records.is_empty() {
continue;
}
let committed = commit_batch(&pool, &records)?;
indexed += committed.len();
emit_images(&app, &IndexedImagesBatch { folder_id, images: committed });
emit_images(
&app,
&IndexedImagesBatch {
folder_id,
images: committed,
},
);
let current_file = path_chunk
.last()
.and_then(|path| path.file_name())
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
emit_progress(
&app,
&IndexProgress {
folder_id,
total,
indexed,
current_file: String::new(),
current_file,
done: false,
},
);
@@ -191,6 +212,10 @@ fn emit_images(app: &AppHandle, batch: &IndexedImagesBatch) {
let _ = app.emit("indexed-images", batch);
}
fn emit_thumbnails(app: &AppHandle, batch: &ThumbnailBatch) {
let _ = app.emit("thumbnail-updated", batch);
}
fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecord>> {
let mut conn = pool.get()?;
let tx = conn.transaction()?;
@@ -200,9 +225,76 @@ fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecor
let mut committed_record = record.clone();
committed_record.id = db::upsert_image(&tx, record)?;
db::enqueue_embedding_job(&tx, committed_record.id)?;
db::enqueue_thumbnail_job(&tx, committed_record.id)?;
committed.push(committed_record);
}
tx.commit()?;
Ok(committed)
}
pub fn start_thumbnail_worker(app: AppHandle, pool: DbPool, cache_dir: PathBuf) {
std::thread::spawn(move || loop {
if let Err(error) = process_thumbnail_batch(&app, &pool, &cache_dir) {
eprintln!("Thumbnail worker error: {}", error);
}
std::thread::sleep(std::time::Duration::from_millis(250));
});
}
fn process_thumbnail_batch(app: &AppHandle, pool: &DbPool, cache_dir: &Path) -> Result<()> {
let mut updated_images = Vec::new();
for _ in 0..THUMBNAIL_BATCH_SIZE {
let job = {
let conn = pool.get()?;
db::get_next_thumbnail_job(&conn)?
};
let Some(job) = job else {
break;
};
{
let conn = pool.get()?;
db::mark_thumbnail_job_processing(&conn, job.image_id)?;
}
let thumbnail_result = match job.media_kind.as_str() {
"image" => thumbnail::generate_thumbnail(Path::new(&job.path), cache_dir),
"video" => thumbnail::generate_video_poster(Path::new(&job.path), cache_dir),
_ => {
let conn = pool.get()?;
updated_images.push(db::mark_thumbnail_ready(&conn, job.image_id, None)?);
continue;
}
};
let thumbnail_path = match thumbnail_result {
Ok(path) => Some(path.to_string_lossy().to_string()),
Err(error) => {
let conn = pool.get()?;
db::mark_thumbnail_failed(&conn, job.image_id, &error.to_string())?;
continue;
}
};
let updated_image = {
let conn = pool.get()?;
db::mark_thumbnail_ready(&conn, job.image_id, thumbnail_path.as_deref())?
};
updated_images.push(updated_image);
}
if !updated_images.is_empty() {
emit_thumbnails(
app,
&ThumbnailBatch {
images: updated_images,
},
);
}
Ok(())
}
+5
View File
@@ -28,6 +28,11 @@ pub fn run() {
db::migrate(&conn).expect("Failed to run migrations");
}
let thumb_dir = app_dir.join("thumbnails");
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir");
indexer::start_thumbnail_worker(app.handle().clone(), pool.clone(), thumb_dir);
app.manage(pool);
Ok(())
+83 -1
View File
@@ -1,4 +1,86 @@
use std::path::Path;
use anyhow::{anyhow, Result};
use image::ImageFormat;
use std::path::{Path, PathBuf};
use std::process::Command;
pub const THUMB_SIZE: u32 = 320;
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
thumb_path_with_ext(cache_dir, image_path, "webp")
}
pub fn video_poster_path(cache_dir: &Path, video_path: &str) -> PathBuf {
thumb_path_with_ext(cache_dir, video_path, "jpg")
}
fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> PathBuf {
let hash = hash_path(input_path);
cache_dir.join(format!("{}.{}", hash, extension))
}
fn hash_path(s: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn generate_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<PathBuf> {
let path_str = image_path.to_string_lossy();
let out_path = thumb_path(cache_dir, &path_str);
if out_path.exists() {
return Ok(out_path);
}
let img = image::open(image_path)?;
let thumb = img.thumbnail(THUMB_SIZE, THUMB_SIZE);
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
thumb.save_with_format(&out_path, ImageFormat::WebP)?;
Ok(out_path)
}
pub fn generate_video_poster(video_path: &Path, cache_dir: &Path) -> Result<PathBuf> {
let path_str = video_path.to_string_lossy();
let out_path = video_poster_path(cache_dir, &path_str);
if out_path.exists() {
return Ok(out_path);
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let status = Command::new("ffmpeg")
.args([
"-y",
"-ss",
"00:00:01.000",
"-i",
&path_str,
"-frames:v",
"1",
"-vf",
"scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v",
"4",
out_path.to_string_lossy().as_ref(),
])
.status()?;
if !status.success() {
return Err(anyhow!("ffmpeg failed generating poster for {}", path_str));
}
Ok(out_path)
}
/// Gets image dimensions without fully decoding.
pub fn get_dimensions(image_path: &Path) -> Option<(u32, u32)> {
+27 -12
View File
@@ -79,15 +79,20 @@ function ContextMenu({
</button>
);
})}
{image.rating > 0 ? (
<button
className="ml-auto rounded-lg px-2 py-1 text-sm text-gray-400 hover:bg-white/5 hover:text-white"
className="ml-auto rounded-lg p-1.5 text-gray-400 hover:bg-white/5 hover:text-white"
onClick={async () => {
await updateImageDetails(image.id, { rating: 0 });
onClose();
}}
title="Remove rating"
>
Clear
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
) : null}
</div>
</div>
);
@@ -119,15 +124,7 @@ function ImageTile({
onContextMenu={onContextMenu}
title={image.filename}
>
{image.media_kind === "video" ? (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-fuchsia-500/10 via-white/5 to-cyan-500/10">
<div className="rounded-full border border-white/10 bg-black/30 p-4 text-white shadow-lg">
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
) : src && !errored ? (
{src && !errored ? (
<>
{!loaded ? <div className="absolute inset-0 animate-pulse bg-white/5" /> : null}
<img
@@ -140,7 +137,14 @@ function ImageTile({
/>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-white/5 text-gray-600">
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-fuchsia-500/10 via-white/5 to-cyan-500/10 text-gray-300">
{image.media_kind === "video" ? (
<div className="rounded-full border border-white/10 bg-black/30 p-4 text-white shadow-lg">
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
) : (
<svg className="h-9 w-9" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
@@ -149,9 +153,20 @@ function ImageTile({
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
)}
</div>
)}
{image.media_kind === "video" ? (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="rounded-full border border-white/10 bg-black/35 p-3 text-white shadow-lg backdrop-blur-sm">
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
) : null}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/20 to-transparent opacity-80 transition-opacity group-hover:opacity-100" />
<div className="absolute left-3 right-3 top-3 flex items-start justify-between gap-2">
+51 -1
View File
@@ -49,6 +49,10 @@ export interface IndexedImagesBatch {
images: ImageRecord[];
}
export interface ThumbnailBatch {
images: ImageRecord[];
}
export type SortOrder =
| "date_desc"
| "date_asc"
@@ -153,6 +157,20 @@ function mergeImages(currentImages: ImageRecord[], newImages: ImageRecord[], sor
return Array.from(merged.values()).sort((a, b) => compareImages(a, b, sort));
}
function countNewImages(currentImages: ImageRecord[], newImages: ImageRecord[]): number {
const existingPaths = new Set(currentImages.map((image) => image.path));
let count = 0;
for (const image of newImages) {
if (!existingPaths.has(image.path)) {
existingPaths.add(image.path);
count += 1;
}
}
return count;
}
function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: SortOrder): ImageRecord[] {
return mergeImages(images, [updatedImage], sort);
}
@@ -340,11 +358,42 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return state;
}
const newVisibleCount = countNewImages(state.images, visibleImages);
const images = mergeImages(state.images, visibleImages, state.sort);
return {
images,
loadedCount: images.length,
totalImages: Math.max(state.totalImages, images.length),
totalImages: Math.max(state.totalImages + newVisibleCount, images.length),
};
});
});
const unlistenThumbnails = await listen<ThumbnailBatch>("thumbnail-updated", (event) => {
const batch = event.payload;
set((state) => {
const visibleImages = batch.images.filter((image) =>
matchesFilters(
image,
state.selectedFolderId,
state.mediaFilter,
state.favoritesOnly,
state.search,
),
);
const selectedImage =
state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id)
? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage
: state.selectedImage;
if (visibleImages.length === 0) {
return { selectedImage };
}
return {
images: mergeImages(state.images, visibleImages, state.sort),
selectedImage,
};
});
});
@@ -352,6 +401,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return () => {
unlistenProgress();
unlistenImages();
unlistenThumbnails();
};
},
}));