From c4036140e68f1069364dec1b16c4891855514d9e Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 5 Apr 2026 18:58:10 +0100 Subject: [PATCH] Improve media indexing and processing flow --- README.md | 2 - src-tauri/Cargo.lock | 348 +++++++++++-- src-tauri/Cargo.toml | 9 +- src-tauri/src/commands.rs | 8 +- src-tauri/src/db.rs | 262 +++++++++- src-tauri/src/indexer.rs | 750 +++++++++++++++++++---------- src-tauri/src/lib.rs | 13 +- src-tauri/src/main.rs | 2 +- src-tauri/src/media.rs | 139 ++++++ src-tauri/src/thumbnail.rs | 213 +++++--- src/App.tsx | 6 +- src/components/BackgroundTasks.tsx | 88 ++++ src/components/Gallery.tsx | 26 +- src/components/Lightbox.tsx | 145 ++++-- src/components/MenuBar.tsx | 20 +- src/components/Sidebar.tsx | 8 +- src/components/Toolbar.tsx | 30 +- src/store.ts | 66 ++- 18 files changed, 1663 insertions(+), 472 deletions(-) create mode 100644 src-tauri/src/media.rs create mode 100644 src/components/BackgroundTasks.tsx diff --git a/README.md b/README.md index 02698a5..a4b5578 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a22523f..de60fd8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a97ea99..7644bf9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c27fc27..2ece03c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -37,7 +37,6 @@ pub async fn add_folder( app: AppHandle, db: State<'_, DbState>, path: String, - cache_dir: String, ) -> Result { 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(()) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 0687f74..3eb5890 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -46,6 +46,11 @@ pub struct ImageRecord { pub modified_at: Option, pub mime_type: String, pub media_kind: String, + pub duration_ms: Option, + pub video_codec: Option, + pub audio_codec: Option, + pub metadata_updated_at: Option, + pub metadata_error: Option, 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, + 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 { 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 { pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result { 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 { 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 { 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> { 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> { +pub fn get_folder_media_index(conn: &Connection, folder_id: i64) -> Result> { 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::>>()?) +} + +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 { + 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> { + 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 rows = stmt.query_map([limit as i64], |row| { + Ok(ThumbnailJob { + image_id: row.get(0)?, + folder_id: row.get(1)?, + path: row.get(2)?, + media_kind: row.get(3)?, + }) + })?; + Ok(rows.collect::>>()?) +} - 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 get_pending_metadata_jobs(conn: &Connection, limit: usize) -> Result> { + 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::>>()?) } 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, + height: Option, ) -> Result { 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, + width: Option, + height: Option, + video_codec: Option<&str>, + audio_codec: Option<&str>, +) -> Result { + 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 { 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 { 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)?, }) } diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 00d8e43..4b84e0d 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -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>> = OnceLock::new(); +static ACTIVE_INDEXING_FOLDERS: OnceLock>> = 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, +} + +#[derive(Clone, Serialize)] +pub struct MediaUpdateBatch { + pub images: Vec, +} + +#[derive(Clone, Serialize)] +pub struct MediaJobProgressEvent { + pub progress: Vec, +} + +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::>(); + + let media_paths: Vec = 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 = 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::>(); + + { + 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 { + 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 = 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> { + 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::>() + }; + + 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::>(); + + 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::>(); + emit_media_updates( + app, + &MediaUpdateBatch { + images: updated_images, + }, + ); + emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::>()); + } + + 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::>() + }; + + 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::>(); + emit_media_updates( + app, + &MediaUpdateBatch { + images: updated_images, + }, + ); + emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::>()); + } + + Ok(()) +} + +fn active_indexing_folders() -> HashSet { + 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::>(); + 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::>(); + + 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, -} - -#[derive(Clone, Serialize)] -pub struct ThumbnailBatch { - pub images: Vec, -} - -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 = 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 = 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 { - 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 = 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> { - 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(()) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6603b7d..ad5139d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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(()) }) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7136a4e..0e2091a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - image_gallery_lib::run() + phokus_lib::run() } diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs new file mode 100644 index 0000000..e630be0 --- /dev/null +++ b/src-tauri/src/media.rs @@ -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, + pub width: Option, + pub height: Option, + pub video_codec: Option, + pub audio_codec: Option, +} + +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 { + 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 { + let seconds = value.parse::().ok()?; + Some((seconds * 1000.0).round() as i64) +} + +#[derive(Deserialize)] +struct FfprobeOutput { + format: Option, + #[serde(default)] + streams: Vec, +} + +#[derive(Deserialize)] +struct FfprobeFormat { + duration: Option, +} + +#[derive(Deserialize)] +struct FfprobeStream { + codec_type: Option, + codec_name: Option, + duration: Option, + width: Option, + height: Option, +} diff --git a/src-tauri/src/thumbnail.rs b/src-tauri/src/thumbnail.rs index fbd3f0d..641d69d 100644 --- a/src-tauri/src/thumbnail.rs +++ b/src-tauri/src/thumbnail.rs @@ -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, + pub height: Option, +} + +pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result { + 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 { + 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 { - 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 { - 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() } diff --git a/src/App.tsx b/src/App.tsx index 6c76de7..a71173a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() {
+
diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx new file mode 100644 index 0000000..4535730 --- /dev/null +++ b/src/components/BackgroundTasks.tsx @@ -0,0 +1,88 @@ +import { useMemo } from "react"; +import { useGalleryStore } from "../store"; + +function ProgressBar({ value }: { value: number }) { + return ( +
+
+
+ ); +} + +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 ( +
+
+

Background Tasks

+ {tasks.length} active +
+
+ {tasks.map((task) => ( +
+
+
+

{task.name}

+

+ {task.index && !task.index.done + ? `${task.index.indexed.toLocaleString()} of ${task.index.total.toLocaleString()} scanned` + : `${task.pendingMediaWork.toLocaleString()} media jobs remaining`} +

+
+
+ {task.jobs?.thumbnail_pending ?
{task.jobs.thumbnail_pending.toLocaleString()} thumbnails
: null} + {task.jobs?.metadata_pending ?
{task.jobs.metadata_pending.toLocaleString()} metadata
: null} +
+
+ + {task.index && !task.index.done ? ( +
+ +

{task.index.current_file || "Scanning..."}

+
+ ) : task.pendingMediaWork > 0 ? ( +
+ +

Processing thumbnails and metadata

+
+ ) : null} +
+ ))} +
+
+ ); +} diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index 68ac7f7..a7a8ed9 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -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}
+ {image.media_kind === "video" && image.duration_ms ? ( +
+ {formatDuration(image.duration_ms)} +
+ ) : null} {image.favorite ? (
@@ -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(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null); diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index a8b378e..adcc721 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -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(null); @@ -93,58 +111,61 @@ export function Lightbox() { - event.stopPropagation()} - > +
event.stopPropagation()}>
- {selectedImage.media_kind === "video" ? ( -
@@ -217,6 +238,32 @@ export function Lightbox() {

+ {selectedImage.media_kind === "video" ? ( + <> +
+

Duration

+

{formatDuration(selectedImage.duration_ms)}

+
+ +
+

Video codec

+

{selectedImage.video_codec ?? "Pending / unavailable"}

+
+ +
+

Audio codec

+

{selectedImage.audio_codec ?? "None / unavailable"}

+
+ + {selectedImage.metadata_error ? ( +
+

Metadata

+

{selectedImage.metadata_error}

+
+ ) : null} + + ) : null} +

Type

{selectedImage.mime_type}

@@ -248,7 +295,7 @@ export function Lightbox() {
-
+