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
-2
View File
@@ -1,7 +1,5 @@
# Phokus
`Phokus` is the chosen name for this project. It keeps the visual/photo association obvious while feeling sharper and more distinctive than `image-gallery`.
## Overview
Phokus is a Tauri desktop app for building a fast, local media library from folders on disk. It indexes images and videos, stores metadata in SQLite, and gives you a dense browsing workflow with filtering, favorites, ratings, and a lightbox preview.
+321 -27
View File
@@ -59,6 +59,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -739,6 +748,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -850,6 +870,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dom_query"
version = "0.27.0"
@@ -1014,6 +1043,20 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fast_image_resize"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12dd43e5011e8d8411a3215a0d57a2ec5c68282fb90eb5d7221fab0113442174"
dependencies = [
"bytemuck",
"cfg-if",
"document-features",
"image",
"num-traits",
"thiserror 2.0.18",
]
[[package]]
name = "fastrand"
version = "2.4.0"
@@ -1049,6 +1092,19 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "ffmpeg-sidecar"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42bc8b8df567bc8a9dcf9004a22be12c4e0766649b2116b2397dd04f25c8f2fe"
dependencies = [
"anyhow",
"tar",
"ureq",
"xz2",
"zip",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1059,6 +1115,17 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1073,6 +1140,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -1899,31 +1967,6 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "image-gallery"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"image",
"log",
"r2d2",
"r2d2_sqlite",
"rayon",
"rusqlite",
"serde",
"serde_json",
"sqlite-vec",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-opener",
"tokio",
"uuid",
"walkdir",
]
[[package]]
name = "image-webp"
version = "0.2.4"
@@ -2183,7 +2226,10 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [
"bitflags 2.11.0",
"libc",
"plain",
"redox_syscall 0.7.3",
]
[[package]]
@@ -2209,6 +2255,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2224,6 +2276,17 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "mac"
version = "0.1.1"
@@ -2631,7 +2694,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
@@ -2835,6 +2898,34 @@ dependencies = [
"siphasher 1.0.2",
]
[[package]]
name = "phokus"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"fast_image_resize",
"ffmpeg-sidecar",
"image",
"log",
"r2d2",
"r2d2_sqlite",
"rayon",
"rusqlite",
"serde",
"serde_json",
"sqlite-vec",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-opener",
"tokio",
"uuid",
"walkdir",
"xxhash-rust",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2858,6 +2949,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.8.0"
@@ -3216,6 +3313,15 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_syscall"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -3334,6 +3440,20 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@@ -3376,6 +3496,41 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3753,7 +3908,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"redox_syscall",
"redox_syscall 0.5.18",
"tracing",
"wasm-bindgen",
"web-sys",
@@ -3856,6 +4011,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -3971,6 +4132,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4734,6 +4906,41 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64 0.22.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -4765,6 +4972,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5039,6 +5252,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5275,6 +5497,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -5702,6 +5933,31 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xxhash-rust"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yoke"
version = "0.8.2"
@@ -5827,6 +6083,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.4"
@@ -5860,12 +6122,44 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap 2.13.1",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.1"
+6 -3
View File
@@ -1,12 +1,12 @@
[package]
name = "image-gallery"
name = "phokus"
version = "0.1.0"
description = "A performant image gallery application"
authors = ["you"]
authors = ["JezzWTF"]
edition = "2021"
[lib]
name = "image_gallery_lib"
name = "phokus_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
@@ -24,6 +24,7 @@ r2d2 = "0.8"
r2d2_sqlite = "0.25"
sqlite-vec = "=0.1.9"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff"] }
fast_image_resize = { version = "6.0.0", features = ["image"] }
walkdir = "2"
rayon = "1"
tokio = { version = "1", features = ["full"] }
@@ -31,3 +32,5 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
anyhow = "1"
log = "0.4"
ffmpeg-sidecar = "2.5.0"
xxhash-rust = { version = "0.8", features = ["xxh3"] }
+2 -6
View File
@@ -37,7 +37,6 @@ pub async fn add_folder(
app: AppHandle,
db: State<'_, DbState>,
path: String,
cache_dir: String,
) -> Result<Folder, String> {
let folder_path = PathBuf::from(&path);
@@ -65,8 +64,7 @@ pub async fn add_folder(
.find(|f| f.id == folder_id)
.ok_or("Folder not found after insert")?;
let cache_path = PathBuf::from(cache_dir);
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path, cache_path);
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path);
Ok(folder)
}
@@ -135,7 +133,6 @@ pub async fn reindex_folder(
app: AppHandle,
db: State<'_, DbState>,
folder_id: i64,
cache_dir: String,
) -> Result<(), String> {
let folder_path = {
let conn = db.get().map_err(|e| e.to_string())?;
@@ -147,7 +144,6 @@ pub async fn reindex_folder(
.ok_or("Folder not found")?
};
let cache_path = PathBuf::from(cache_dir);
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path, cache_path);
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path);
Ok(())
}
+236 -26
View File
@@ -46,6 +46,11 @@ pub struct ImageRecord {
pub modified_at: Option<String>,
pub mime_type: String,
pub media_kind: String,
pub duration_ms: Option<i64>,
pub video_codec: Option<String>,
pub audio_codec: Option<String>,
pub metadata_updated_at: Option<String>,
pub metadata_error: Option<String>,
pub favorite: bool,
pub rating: i64,
pub embedding_status: String,
@@ -68,10 +73,34 @@ pub struct EmbeddingJob {
#[derive(Debug, Clone)]
pub struct ThumbnailJob {
pub image_id: i64,
pub folder_id: i64,
pub path: String,
pub media_kind: String,
}
#[derive(Debug, Clone)]
pub struct MetadataJob {
pub image_id: i64,
pub folder_id: i64,
pub path: String,
}
#[derive(Debug, Clone)]
pub struct IndexedMediaEntry {
pub id: i64,
pub path: String,
pub modified_at: Option<String>,
pub file_size: i64,
pub media_kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FolderJobProgress {
pub folder_id: i64,
pub thumbnail_pending: i64,
pub metadata_pending: i64,
}
pub fn create_pool(db_path: &Path) -> Result<DbPool> {
vector::register_sqlite_vec();
let manager = SqliteConnectionManager::file(db_path);
@@ -127,10 +156,20 @@ pub fn migrate(conn: &Connection) -> Result<()> {
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS metadata_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);
CREATE INDEX IF NOT EXISTS idx_metadata_jobs_status ON metadata_jobs(status);
",
)?;
@@ -151,6 +190,11 @@ pub fn migrate(conn: &Connection) -> Result<()> {
)?;
ensure_column(conn, "images", "favorite", "INTEGER NOT NULL DEFAULT 0")?;
ensure_column(conn, "images", "rating", "INTEGER NOT NULL DEFAULT 0")?;
ensure_column(conn, "images", "duration_ms", "INTEGER")?;
ensure_column(conn, "images", "video_codec", "TEXT")?;
ensure_column(conn, "images", "audio_codec", "TEXT")?;
ensure_column(conn, "images", "metadata_updated_at", "TEXT")?;
ensure_column(conn, "images", "metadata_error", "TEXT")?;
vector::migrate(conn)?;
Ok(())
@@ -171,8 +215,8 @@ pub fn insert_folder(conn: &Connection, path: &str, name: &str) -> Result<i64> {
pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
let id = conn.query_row(
"INSERT INTO images (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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
"INSERT INTO images (folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)
ON CONFLICT(path) DO UPDATE SET
folder_id = excluded.folder_id,
filename = excluded.filename,
@@ -184,6 +228,11 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
modified_at = excluded.modified_at,
mime_type = excluded.mime_type,
media_kind = excluded.media_kind,
duration_ms = excluded.duration_ms,
video_codec = excluded.video_codec,
audio_codec = excluded.audio_codec,
metadata_updated_at = excluded.metadata_updated_at,
metadata_error = excluded.metadata_error,
embedding_status = excluded.embedding_status,
embedding_model = excluded.embedding_model,
embedding_updated_at = excluded.embedding_updated_at,
@@ -201,6 +250,11 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
img.modified_at,
img.mime_type,
img.media_kind,
img.duration_ms,
img.video_codec,
img.audio_codec,
img.metadata_updated_at,
img.metadata_error,
img.favorite,
img.rating,
img.embedding_status,
@@ -239,6 +293,19 @@ pub fn enqueue_thumbnail_job(conn: &Connection, image_id: i64) -> Result<()> {
Ok(())
}
pub fn enqueue_metadata_job(conn: &Connection, image_id: i64) -> Result<()> {
conn.execute(
"INSERT INTO metadata_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(
@@ -312,26 +379,97 @@ pub fn update_folder_count(conn: &Connection, folder_id: i64) -> Result<()> {
Ok(())
}
pub fn get_next_thumbnail_job(conn: &Connection) -> Result<Option<ThumbnailJob>> {
pub fn get_folder_media_index(conn: &Connection, folder_id: i64) -> Result<Vec<IndexedMediaEntry>> {
let mut stmt = conn.prepare(
"SELECT j.image_id, i.path, i.media_kind
"SELECT id, path, modified_at, file_size, media_kind
FROM images
WHERE folder_id = ?1",
)?;
let rows = stmt.query_map([folder_id], |row| {
Ok(IndexedMediaEntry {
id: row.get(0)?,
path: row.get(1)?,
modified_at: row.get(2)?,
file_size: row.get(3)?,
media_kind: row.get(4)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn delete_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
vector::delete_embedding(&tx, *image_id)?;
tx.execute("DELETE FROM images WHERE id = ?1", [image_id])?;
}
tx.commit()?;
Ok(())
}
pub fn get_folder_job_progress(conn: &Connection, folder_id: i64) -> Result<FolderJobProgress> {
let thumbnail_pending = conn.query_row(
"SELECT COUNT(*)
FROM thumbnail_jobs j
JOIN images i ON i.id = j.image_id
WHERE i.folder_id = ?1 AND j.status IN ('pending', 'processing')",
[folder_id],
|row| row.get(0),
)?;
let metadata_pending = conn.query_row(
"SELECT COUNT(*)
FROM metadata_jobs j
JOIN images i ON i.id = j.image_id
WHERE i.folder_id = ?1 AND j.status IN ('pending', 'processing')",
[folder_id],
|row| row.get(0),
)?;
Ok(FolderJobProgress {
folder_id,
thumbnail_pending,
metadata_pending,
})
}
pub fn get_pending_thumbnail_jobs(conn: &Connection, limit: usize) -> Result<Vec<ThumbnailJob>> {
let mut stmt = conn.prepare(
"SELECT j.image_id, i.folder_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",
LIMIT ?1",
)?;
let mut rows = stmt.query([])?;
let Some(row) = rows.next()? else {
return Ok(None);
};
Ok(Some(ThumbnailJob {
let rows = stmt.query_map([limit as i64], |row| {
Ok(ThumbnailJob {
image_id: row.get(0)?,
path: row.get(1)?,
media_kind: row.get(2)?,
}))
folder_id: row.get(1)?,
path: row.get(2)?,
media_kind: row.get(3)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn get_pending_metadata_jobs(conn: &Connection, limit: usize) -> Result<Vec<MetadataJob>> {
let mut stmt = conn.prepare(
"SELECT j.image_id, i.folder_id, i.path
FROM metadata_jobs j
JOIN images i ON i.id = j.image_id
WHERE j.status = 'pending' AND i.media_kind = 'video'
ORDER BY j.updated_at, j.image_id
LIMIT ?1",
)?;
let rows = stmt.query_map([limit as i64], |row| {
Ok(MetadataJob {
image_id: row.get(0)?,
folder_id: row.get(1)?,
path: row.get(2)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn mark_thumbnail_job_processing(conn: &Connection, image_id: i64) -> Result<()> {
@@ -348,10 +486,16 @@ pub fn mark_thumbnail_ready(
conn: &Connection,
image_id: i64,
thumbnail_path: Option<&str>,
width: Option<i64>,
height: Option<i64>,
) -> Result<ImageRecord> {
conn.execute(
"UPDATE images SET thumbnail_path = ?2 WHERE id = ?1",
params![image_id, thumbnail_path],
"UPDATE images
SET thumbnail_path = ?2,
width = COALESCE(?3, width),
height = COALESCE(?4, height)
WHERE id = ?1",
params![image_id, thumbnail_path, width, height],
)?;
conn.execute("DELETE FROM thumbnail_jobs WHERE image_id = ?1", [image_id])?;
get_image_by_id(conn, image_id)
@@ -367,6 +511,64 @@ pub fn mark_thumbnail_failed(conn: &Connection, image_id: i64, error: &str) -> R
Ok(())
}
pub fn mark_metadata_job_processing(conn: &Connection, image_id: i64) -> Result<()> {
conn.execute(
"UPDATE metadata_jobs
SET status = 'processing', attempts = attempts + 1, updated_at = datetime('now')
WHERE image_id = ?1",
[image_id],
)?;
Ok(())
}
pub fn mark_metadata_ready(
conn: &Connection,
image_id: i64,
duration_ms: Option<i64>,
width: Option<i64>,
height: Option<i64>,
video_codec: Option<&str>,
audio_codec: Option<&str>,
) -> Result<ImageRecord> {
conn.execute(
"UPDATE images
SET duration_ms = ?2,
width = COALESCE(?3, width),
height = COALESCE(?4, height),
video_codec = ?5,
audio_codec = ?6,
metadata_updated_at = datetime('now'),
metadata_error = NULL
WHERE id = ?1",
params![
image_id,
duration_ms,
width,
height,
video_codec,
audio_codec
],
)?;
conn.execute("DELETE FROM metadata_jobs WHERE image_id = ?1", [image_id])?;
get_image_by_id(conn, image_id)
}
pub fn mark_metadata_failed(conn: &Connection, image_id: i64, error: &str) -> Result<()> {
conn.execute(
"UPDATE images
SET metadata_error = ?2
WHERE id = ?1",
params![image_id, error],
)?;
conn.execute(
"UPDATE metadata_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,
@@ -383,7 +585,8 @@ pub fn update_image_details(
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
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
FROM images
WHERE id = ?1",
[image_id],
@@ -395,7 +598,8 @@ pub fn update_image_details(
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
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
FROM images
WHERE id = ?1",
[image_id],
@@ -443,7 +647,8 @@ pub fn get_images(
let favorites_flag = i64::from(favorites_only);
let sql = format!(
"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
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
FROM images
WHERE (?1 IS NULL OR folder_id = ?1)
AND (?2 IS NULL OR filename LIKE ?2)
@@ -510,12 +715,17 @@ fn map_image_row(row: &Row<'_>) -> rusqlite::Result<ImageRecord> {
modified_at: row.get(9)?,
mime_type: row.get(10)?,
media_kind: row.get(11)?,
favorite: row.get::<_, i64>(12)? != 0,
rating: row.get(13)?,
embedding_status: row.get(14)?,
embedding_model: row.get(15)?,
embedding_updated_at: row.get(16)?,
embedding_error: row.get(17)?,
duration_ms: row.get(12)?,
video_codec: row.get(13)?,
audio_codec: row.get(14)?,
metadata_updated_at: row.get(15)?,
metadata_error: row.get(16)?,
favorite: row.get::<_, i64>(17)? != 0,
rating: row.get(18)?,
embedding_status: row.get(19)?,
embedding_model: row.get(20)?,
embedding_updated_at: row.get(21)?,
embedding_error: row.get(22)?,
})
}
+494 -256
View File
@@ -1,10 +1,14 @@
use crate::db::{self, DbPool, ImageRecord};
use crate::db::{self, DbPool, FolderJobProgress, ImageRecord, IndexedMediaEntry};
use crate::media::{probe_video_metadata, MediaTools};
use crate::thumbnail;
use crate::vector;
use anyhow::Result;
use rayon::prelude::*;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
use walkdir::WalkDir;
@@ -14,12 +18,497 @@ const IMAGE_EXTENSIONS: &[&str] = &[
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"];
const INDEX_BATCH_SIZE: usize = 250;
const WORKER_BATCH_SIZE: usize = 12;
const WORKER_FETCH_SIZE: usize = 64;
const JOB_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(750);
static LAST_JOB_PROGRESS_EMIT: OnceLock<Mutex<HashMap<i64, Instant>>> = OnceLock::new();
static ACTIVE_INDEXING_FOLDERS: OnceLock<Mutex<HashSet<i64>>> = OnceLock::new();
#[derive(Clone, Serialize)]
pub struct IndexProgress {
pub folder_id: i64,
pub total: usize,
pub indexed: usize,
pub current_file: String,
pub done: bool,
}
#[derive(Clone, Serialize)]
pub struct IndexedImagesBatch {
pub folder_id: i64,
pub images: Vec<ImageRecord>,
}
#[derive(Clone, Serialize)]
pub struct MediaUpdateBatch {
pub images: Vec<ImageRecord>,
}
#[derive(Clone, Serialize)]
pub struct MediaJobProgressEvent {
pub progress: Vec<FolderJobProgress>,
}
pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) {
std::thread::spawn(move || {
set_folder_indexing_state(folder_id, true);
if let Err(error) = do_index(app, pool, folder_id, folder_path) {
eprintln!("Indexing error: {}", error);
}
set_folder_indexing_state(folder_id, false);
});
}
pub fn start_thumbnail_worker(
app: AppHandle,
pool: DbPool,
media_tools: MediaTools,
cache_dir: PathBuf,
) {
std::thread::spawn(move || loop {
if let Err(error) = process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) {
eprintln!("Thumbnail worker error: {}", error);
}
std::thread::sleep(std::time::Duration::from_millis(250));
});
}
pub fn start_metadata_worker(app: AppHandle, pool: DbPool, media_tools: MediaTools) {
std::thread::spawn(move || loop {
if let Err(error) = process_metadata_batch(&app, &pool, &media_tools) {
eprintln!("Metadata worker error: {}", error);
}
std::thread::sleep(std::time::Duration::from_millis(250));
});
}
fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
let existing_entries = {
let conn = pool.get()?;
db::get_folder_media_index(&conn, folder_id)?
};
let existing_by_path = existing_entries
.into_iter()
.map(|entry| (entry.path.clone(), entry))
.collect::<HashMap<_, _>>();
let media_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
.follow_links(true)
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file() && is_supported_media(entry.path()))
.map(|entry| entry.path().to_path_buf())
.collect();
let total = media_paths.len();
emit_progress(
&app,
&IndexProgress {
folder_id,
total,
indexed: 0,
current_file: String::new(),
done: false,
},
);
let mut seen_paths = HashSet::with_capacity(total);
let mut processed = 0usize;
for path_chunk in media_paths.chunks(INDEX_BATCH_SIZE) {
let records: Vec<ImageRecord> = path_chunk
.par_iter()
.filter_map(|path| {
let path_str = path.to_string_lossy().to_string();
let existing = existing_by_path.get(&path_str);
build_record(path, folder_id, existing)
})
.collect();
for path in path_chunk {
seen_paths.insert(path.to_string_lossy().to_string());
}
if !records.is_empty() {
let committed = commit_batch(&pool, &records)?;
emit_images(
&app,
&IndexedImagesBatch {
folder_id,
images: committed,
},
);
emit_folder_job_progress(&app, &pool, &[folder_id]);
}
processed += path_chunk.len();
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: processed,
current_file,
done: false,
},
);
}
let missing_ids = existing_by_path
.values()
.filter(|entry| !seen_paths.contains(&entry.path))
.map(|entry| entry.id)
.collect::<Vec<_>>();
{
let conn = pool.get()?;
if !missing_ids.is_empty() {
db::delete_images_by_ids(&conn, &missing_ids)?;
}
db::update_folder_count(&conn, folder_id)?;
}
emit_progress(
&app,
&IndexProgress {
folder_id,
total,
indexed: processed,
current_file: String::new(),
done: true,
},
);
emit_folder_job_progress(&app, &pool, &[folder_id]);
Ok(())
}
fn build_record(
path: &Path,
folder_id: i64,
existing: Option<&IndexedMediaEntry>,
) -> Option<ImageRecord> {
let path_str = path.to_string_lossy().to_string();
let filename = path.file_name()?.to_string_lossy().to_string();
let metadata = std::fs::metadata(path).ok()?;
let file_size = metadata.len() as i64;
let modified_at = metadata.modified().ok().map(|time| {
let date_time: chrono::DateTime<chrono::Utc> = time.into();
date_time.to_rfc3339()
});
let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("jpg");
let media_kind = media_kind_for_ext(ext).to_string();
if let Some(existing) = existing {
if existing.file_size == file_size
&& existing.modified_at == modified_at
&& existing.media_kind == media_kind
{
return None;
}
}
Some(ImageRecord {
id: existing.map(|entry| entry.id).unwrap_or(0),
folder_id,
path: path_str,
filename,
thumbnail_path: None,
width: None,
height: None,
file_size,
created_at: None,
modified_at,
mime_type: mime_for_ext(ext).to_string(),
media_kind: media_kind.clone(),
duration_ms: None,
video_codec: None,
audio_codec: None,
metadata_updated_at: None,
metadata_error: None,
favorite: false,
rating: 0,
embedding_status: "pending".to_string(),
embedding_model: Some(vector::CLIP_MODEL_NAME.to_string()),
embedding_updated_at: None,
embedding_error: None,
})
}
fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecord>> {
let mut conn = pool.get()?;
let tx = conn.transaction()?;
let mut committed = Vec::with_capacity(records.len());
for record in records {
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)?;
if committed_record.media_kind == "video" {
db::enqueue_metadata_job(&tx, committed_record.id)?;
}
committed.push(committed_record);
}
tx.commit()?;
Ok(committed)
}
fn process_thumbnail_batch(
app: &AppHandle,
pool: &DbPool,
media_tools: &MediaTools,
cache_dir: &Path,
) -> Result<()> {
let jobs = {
let conn = pool.get()?;
let active_folders = active_indexing_folders();
db::get_pending_thumbnail_jobs(&conn, WORKER_FETCH_SIZE)?
.into_iter()
.filter(|job| !active_folders.contains(&job.folder_id))
.take(WORKER_BATCH_SIZE)
.collect::<Vec<_>>()
};
if jobs.is_empty() {
return Ok(());
}
for job in &jobs {
let conn = pool.get()?;
db::mark_thumbnail_job_processing(&conn, job.image_id)?;
}
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
jobs.into_iter().partition(|job| job.media_kind == "image");
let mut results = image_jobs
.par_iter()
.map(|job| {
(
job.image_id,
if job.media_kind == "image" {
thumbnail::generate_image_thumbnail(Path::new(&job.path), cache_dir).map(Some)
} else {
thumbnail::generate_video_thumbnail(
media_tools,
Path::new(&job.path),
cache_dir,
)
.map(Some)
},
)
})
.collect::<Vec<_>>();
for job in video_jobs {
results.push((
job.image_id,
thumbnail::generate_video_thumbnail(media_tools, Path::new(&job.path), cache_dir)
.map(Some),
));
}
let mut updated_images = Vec::new();
for (image_id, thumbnail_result) in results {
let generated = match thumbnail_result {
Ok(path) => path,
Err(error) => {
let conn = pool.get()?;
db::mark_thumbnail_failed(&conn, image_id, &error.to_string())?;
continue;
}
};
let thumbnail_path = generated
.as_ref()
.map(|thumb| thumb.path.to_string_lossy().to_string());
let width = generated.as_ref().and_then(|thumb| thumb.width);
let height = generated.as_ref().and_then(|thumb| thumb.height);
let updated_image = {
let conn = pool.get()?;
db::mark_thumbnail_ready(&conn, image_id, thumbnail_path.as_deref(), width, height)?
};
updated_images.push(updated_image);
}
if !updated_images.is_empty() {
let folder_ids = updated_images
.iter()
.map(|image| image.folder_id)
.collect::<HashSet<_>>();
emit_media_updates(
app,
&MediaUpdateBatch {
images: updated_images,
},
);
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>());
}
Ok(())
}
fn process_metadata_batch(app: &AppHandle, pool: &DbPool, media_tools: &MediaTools) -> Result<()> {
let jobs = {
let conn = pool.get()?;
let active_folders = active_indexing_folders();
db::get_pending_metadata_jobs(&conn, WORKER_FETCH_SIZE)?
.into_iter()
.filter(|job| !active_folders.contains(&job.folder_id))
.take(WORKER_BATCH_SIZE)
.collect::<Vec<_>>()
};
if jobs.is_empty() {
return Ok(());
}
for job in &jobs {
let conn = pool.get()?;
db::mark_metadata_job_processing(&conn, job.image_id)?;
}
let mut updated_images = Vec::new();
for job in jobs {
let metadata = match probe_video_metadata(media_tools, Path::new(&job.path)) {
Ok(metadata) => metadata,
Err(error) => {
let conn = pool.get()?;
db::mark_metadata_failed(&conn, job.image_id, &error.to_string())?;
continue;
}
};
let updated_image = {
let conn = pool.get()?;
db::mark_metadata_ready(
&conn,
job.image_id,
metadata.duration_ms,
metadata.width,
metadata.height,
metadata.video_codec.as_deref(),
metadata.audio_codec.as_deref(),
)?
};
updated_images.push(updated_image);
}
if !updated_images.is_empty() {
let folder_ids = updated_images
.iter()
.map(|image| image.folder_id)
.collect::<HashSet<_>>();
emit_media_updates(
app,
&MediaUpdateBatch {
images: updated_images,
},
);
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>());
}
Ok(())
}
fn active_indexing_folders() -> HashSet<i64> {
ACTIVE_INDEXING_FOLDERS
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
.map(|folders| folders.clone())
.unwrap_or_default()
}
fn set_folder_indexing_state(folder_id: i64, is_active: bool) {
if let Ok(mut folders) = ACTIVE_INDEXING_FOLDERS
.get_or_init(|| Mutex::new(HashSet::new()))
.lock()
{
if is_active {
folders.insert(folder_id);
} else {
folders.remove(&folder_id);
}
}
}
fn emit_progress(app: &AppHandle, progress: &IndexProgress) {
let _ = app.emit("index-progress", progress);
}
fn emit_images(app: &AppHandle, batch: &IndexedImagesBatch) {
let _ = app.emit("indexed-images", batch);
}
fn emit_media_updates(app: &AppHandle, batch: &MediaUpdateBatch) {
let _ = app.emit("media-updated", batch);
}
fn emit_folder_job_progress(app: &AppHandle, pool: &DbPool, folder_ids: &[i64]) {
let mut unique_folder_ids = folder_ids.iter().copied().collect::<Vec<_>>();
unique_folder_ids.sort_unstable();
unique_folder_ids.dedup();
let now = Instant::now();
let emit_tracker = LAST_JOB_PROGRESS_EMIT.get_or_init(|| Mutex::new(HashMap::new()));
let mut tracker = match emit_tracker.lock() {
Ok(guard) => guard,
Err(_) => return,
};
unique_folder_ids.retain(|folder_id| {
let should_emit = tracker
.get(folder_id)
.map(|last_emit| now.duration_since(*last_emit) >= JOB_PROGRESS_EMIT_INTERVAL)
.unwrap_or(true);
if should_emit {
tracker.insert(*folder_id, now);
}
should_emit
});
drop(tracker);
if unique_folder_ids.is_empty() {
return;
}
let Ok(conn) = pool.get() else {
return;
};
let progress = unique_folder_ids
.into_iter()
.filter_map(|folder_id| db::get_folder_job_progress(&conn, folder_id).ok())
.collect::<Vec<_>>();
if !progress.is_empty() {
let _ = app.emit("media-job-progress", MediaJobProgressEvent { progress });
}
}
fn is_supported_media(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| {
let ext = e.to_lowercase();
IMAGE_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str())
.and_then(|value| value.to_str())
.map(|value| {
let extension = value.to_lowercase();
IMAGE_EXTENSIONS.contains(&extension.as_str())
|| VIDEO_EXTENSIONS.contains(&extension.as_str())
})
.unwrap_or(false)
}
@@ -47,254 +536,3 @@ fn mime_for_ext(ext: &str) -> &'static str {
_ => "image/jpeg",
}
}
#[derive(Clone, Serialize)]
pub struct IndexProgress {
pub folder_id: i64,
pub total: usize,
pub indexed: usize,
pub current_file: String,
pub done: bool,
}
#[derive(Clone, Serialize)]
pub struct IndexedImagesBatch {
pub folder_id: i64,
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,
pool: DbPool,
folder_id: i64,
folder_path: PathBuf,
_cache_dir: PathBuf,
) {
std::thread::spawn(move || {
if let Err(e) = do_index(app, pool, folder_id, folder_path) {
eprintln!("Indexing error: {}", e);
}
});
}
fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
let image_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file() && is_supported_media(e.path()))
.map(|e| e.path().to_path_buf())
.collect();
let total = image_paths.len();
emit_progress(
&app,
&IndexProgress {
folder_id,
total,
indexed: 0,
current_file: String::new(),
done: false,
},
);
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();
if records.is_empty() {
continue;
}
let committed = commit_batch(&pool, &records)?;
indexed += committed.len();
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,
done: false,
},
);
}
{
let conn = pool.get()?;
db::update_folder_count(&conn, folder_id)?;
}
emit_progress(
&app,
&IndexProgress {
folder_id,
total,
indexed,
current_file: String::new(),
done: true,
},
);
Ok(())
}
fn build_record(path: &Path, folder_id: i64) -> Option<ImageRecord> {
let path_str = path.to_string_lossy().to_string();
let filename = path.file_name()?.to_string_lossy().to_string();
let meta = std::fs::metadata(path).ok();
let file_size = meta.as_ref().map(|m| m.len() as i64).unwrap_or(0);
let modified_at = meta.as_ref().and_then(|m| m.modified().ok()).map(|t| {
let dt: chrono::DateTime<chrono::Utc> = t.into();
dt.to_rfc3339()
});
let (width, height) = thumbnail::get_dimensions(path)
.map(|(w, h)| (Some(w as i64), Some(h as i64)))
.unwrap_or((None, None));
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
Some(ImageRecord {
id: 0,
folder_id,
path: path_str,
filename,
thumbnail_path: None,
width,
height,
file_size,
created_at: None,
modified_at,
mime_type: mime_for_ext(ext).to_string(),
media_kind: media_kind_for_ext(ext).to_string(),
favorite: false,
rating: 0,
embedding_status: "pending".to_string(),
embedding_model: Some(vector::CLIP_MODEL_NAME.to_string()),
embedding_updated_at: None,
embedding_error: None,
})
}
fn emit_progress(app: &AppHandle, progress: &IndexProgress) {
let _ = app.emit("index-progress", progress);
}
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()?;
let mut committed = Vec::with_capacity(records.len());
for record in records {
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(())
}
+12 -1
View File
@@ -1,6 +1,7 @@
mod commands;
mod db;
mod indexer;
mod media;
mod thumbnail;
mod vector;
@@ -20,8 +21,11 @@ pub fn run() {
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
media::MediaTools::ensure_installed().expect("Failed to provision FFmpeg sidecar");
let db_path = app_dir.join("gallery.db");
let pool = db::create_pool(&db_path).expect("Failed to create database pool");
let media_tools = media::MediaTools::resolve();
{
let conn = pool.get().expect("Failed to get connection for migration");
@@ -31,9 +35,16 @@ pub fn run() {
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);
indexer::start_thumbnail_worker(
app.handle().clone(),
pool.clone(),
media_tools.clone(),
thumb_dir,
);
indexer::start_metadata_worker(app.handle().clone(), pool.clone(), media_tools.clone());
app.manage(pool);
app.manage(media_tools);
Ok(())
})
+1 -1
View File
@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
image_gallery_lib::run()
phokus_lib::run()
}
+139
View File
@@ -0,0 +1,139 @@
use anyhow::{anyhow, Result};
use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent};
use ffmpeg_sidecar::ffprobe::ffprobe_path;
use ffmpeg_sidecar::paths::ffmpeg_path;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct MediaTools {
ffmpeg_path: PathBuf,
ffprobe_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct VideoMetadata {
pub duration_ms: Option<i64>,
pub width: Option<i64>,
pub height: Option<i64>,
pub video_codec: Option<String>,
pub audio_codec: Option<String>,
}
impl MediaTools {
pub fn resolve() -> Self {
Self {
ffmpeg_path: ffmpeg_path(),
ffprobe_path: ffprobe_path(),
}
}
pub fn ensure_installed() -> Result<()> {
auto_download_with_progress(|event| match event {
FfmpegDownloadProgressEvent::Starting => {
println!("Downloading bundled FFmpeg...");
}
FfmpegDownloadProgressEvent::Downloading {
total_bytes,
downloaded_bytes,
} => {
println!(
"Downloading bundled FFmpeg: {}/{} bytes",
downloaded_bytes, total_bytes
);
}
FfmpegDownloadProgressEvent::UnpackingArchive => {
println!("Unpacking bundled FFmpeg...");
}
FfmpegDownloadProgressEvent::Done => {
println!("Bundled FFmpeg ready.");
}
})
}
pub fn ffmpeg_command(&self) -> Command {
Command::new(&self.ffmpeg_path)
}
pub fn ffprobe_command(&self) -> Command {
Command::new(&self.ffprobe_path)
}
}
pub fn probe_video_metadata(tools: &MediaTools, video_path: &Path) -> Result<VideoMetadata> {
let output = tools
.ffprobe_command()
.args([
"-v",
"error",
"-print_format",
"json",
"-show_format",
"-show_streams",
&video_path.to_string_lossy(),
])
.output()?;
if !output.status.success() {
return Err(anyhow!(
"ffprobe failed for {}: {}",
video_path.display(),
String::from_utf8_lossy(&output.stderr)
));
}
let probe: FfprobeOutput = serde_json::from_slice(&output.stdout)?;
let video_stream = probe
.streams
.iter()
.find(|stream| stream.codec_type.as_deref() == Some("video"));
let audio_stream = probe
.streams
.iter()
.find(|stream| stream.codec_type.as_deref() == Some("audio"));
let duration_ms = probe
.format
.and_then(|format| format.duration)
.and_then(|duration| parse_duration_ms(&duration))
.or_else(|| {
video_stream
.and_then(|stream| stream.duration.as_deref())
.and_then(parse_duration_ms)
});
Ok(VideoMetadata {
duration_ms,
width: video_stream.and_then(|stream| stream.width),
height: video_stream.and_then(|stream| stream.height),
video_codec: video_stream.and_then(|stream| stream.codec_name.clone()),
audio_codec: audio_stream.and_then(|stream| stream.codec_name.clone()),
})
}
fn parse_duration_ms(value: &str) -> Option<i64> {
let seconds = value.parse::<f64>().ok()?;
Some((seconds * 1000.0).round() as i64)
}
#[derive(Deserialize)]
struct FfprobeOutput {
format: Option<FfprobeFormat>,
#[serde(default)]
streams: Vec<FfprobeStream>,
}
#[derive(Deserialize)]
struct FfprobeFormat {
duration: Option<String>,
}
#[derive(Deserialize)]
struct FfprobeStream {
codec_type: Option<String>,
codec_name: Option<String>,
duration: Option<String>,
width: Option<i64>,
height: Option<i64>,
}
+151 -62
View File
@@ -1,10 +1,150 @@
use crate::media::MediaTools;
use anyhow::{anyhow, Result};
use fast_image_resize as fir;
use fast_image_resize::IntoImageView;
use image::ImageDecoder;
use image::ImageFormat;
use std::path::{Path, PathBuf};
use std::process::Command;
pub const THUMB_SIZE: u32 = 320;
pub struct GeneratedThumbnail {
pub path: PathBuf,
pub width: Option<i64>,
pub height: Option<i64>,
}
pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<GeneratedThumbnail> {
let path_str = image_path.to_string_lossy();
let out_path = thumb_path(cache_dir, &path_str);
let original_dimensions = image::image_dimensions(image_path)
.ok()
.map(|(width, height)| (Some(width as i64), Some(height as i64)))
.unwrap_or((None, None));
if out_path.exists() {
return Ok(GeneratedThumbnail {
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
});
}
let reader = image::ImageReader::open(image_path)?.with_guessed_format()?;
let mut decoder = reader.into_decoder()?;
let orientation = decoder.orientation()?;
let mut img = image::DynamicImage::from_decoder(decoder)?;
img.apply_orientation(orientation);
let src = image::DynamicImage::ImageRgba8(img.into_rgba8());
let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE);
let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap());
let mut resizer = fir::Resizer::new();
let options = fir::ResizeOptions::new()
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3));
resizer.resize(&src, &mut dst, Some(&options))?;
let thumb = image::RgbaImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
image::DynamicImage::ImageRgba8(thumb).save_with_format(&out_path, ImageFormat::WebP)?;
Ok(GeneratedThumbnail {
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
})
}
pub fn generate_video_thumbnail(
tools: &MediaTools,
video_path: &Path,
cache_dir: &Path,
) -> Result<GeneratedThumbnail> {
let path_str = video_path.to_string_lossy();
let out_path = video_poster_path(cache_dir, &path_str);
if out_path.exists() {
return Ok(GeneratedThumbnail {
path: out_path,
width: None,
height: None,
});
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let output_path = out_path.to_string_lossy().into_owned();
let attempts: [&[&str]; 3] = [
&[
"-y",
"-ss",
"00:00:00.000",
"-i",
path_str.as_ref(),
"-frames:v",
"1",
"-vf",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v",
"4",
&output_path,
],
&[
"-y",
"-ss",
"00:00:00.250",
"-i",
path_str.as_ref(),
"-frames:v",
"1",
"-vf",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v",
"4",
&output_path,
],
&[
"-y",
"-i",
path_str.as_ref(),
"-frames:v",
"1",
"-vf",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v",
"4",
&output_path,
],
];
let mut last_error = String::new();
for args in attempts {
let output = tools.ffmpeg_command().args(args).output()?;
if output.status.success() && out_path.exists() {
return Ok(GeneratedThumbnail {
path: out_path,
width: None,
height: None,
});
}
last_error = String::from_utf8_lossy(&output.stderr).to_string();
}
Err(anyhow!(
"ffmpeg failed generating poster for {}: {}",
path_str,
last_error
))
}
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
thumb_path_with_ext(cache_dir, image_path, "webp")
}
@@ -19,70 +159,19 @@ fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> P
}
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())
format!("{:016x}", xxhash_rust::xxh3::xxh3_64(s.as_bytes()))
}
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);
fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) {
if width <= max_size && height <= max_size {
return (width.max(1), height.max(1));
}
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)?;
if width >= height {
let scaled_height = ((height as f64 / width as f64) * max_size as f64).round() as u32;
(max_size, scaled_height.max(1))
} else {
let scaled_width = ((width as f64 / height as f64) * max_size as f64).round() as u32;
(scaled_width.max(1), max_size)
}
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)> {
image::image_dimensions(image_path).ok()
}
+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);
+58 -11
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,19 +111,20 @@ 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"
>
<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
@@ -145,6 +164,8 @@ export function Lightbox() {
</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();
};