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
`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 ## 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. 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 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]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -739,6 +748,17 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "derive_more" name = "derive_more"
version = "0.99.20" version = "0.99.20"
@@ -850,6 +870,15 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "dom_query" name = "dom_query"
version = "0.27.0" version = "0.27.0"
@@ -1014,6 +1043,20 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" 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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.0" version = "2.4.0"
@@ -1049,6 +1092,19 @@ dependencies = [
"simd-adler32", "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]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@@ -1059,6 +1115,17 @@ dependencies = [
"rustc_version", "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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -1073,6 +1140,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide", "miniz_oxide",
"zlib-rs",
] ]
[[package]] [[package]]
@@ -1899,31 +1967,6 @@ dependencies = [
"zune-jpeg", "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]] [[package]]
name = "image-webp" name = "image-webp"
version = "0.2.4" version = "0.2.4"
@@ -2183,7 +2226,10 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
dependencies = [ dependencies = [
"bitflags 2.11.0",
"libc", "libc",
"plain",
"redox_syscall 0.7.3",
] ]
[[package]] [[package]]
@@ -2209,6 +2255,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -2224,6 +2276,17 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 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]] [[package]]
name = "mac" name = "mac"
version = "0.1.1" version = "0.1.1"
@@ -2631,7 +2694,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall", "redox_syscall 0.5.18",
"smallvec", "smallvec",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -2835,6 +2898,34 @@ dependencies = [
"siphasher 1.0.2", "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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -2858,6 +2949,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]] [[package]]
name = "plist" name = "plist"
version = "1.8.0" version = "1.8.0"
@@ -3216,6 +3313,15 @@ dependencies = [
"bitflags 2.11.0", "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]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@@ -3334,6 +3440,20 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.32.1" version = "0.32.1"
@@ -3376,6 +3496,41 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -3753,7 +3908,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"objc2-quartz-core", "objc2-quartz-core",
"raw-window-handle", "raw-window-handle",
"redox_syscall", "redox_syscall 0.5.18",
"tracing", "tracing",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
@@ -3856,6 +4011,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -3971,6 +4132,17 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -4734,6 +4906,41 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4765,6 +4972,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -5039,6 +5252,15 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.38.2" version = "0.38.2"
@@ -5275,6 +5497,15 @@ dependencies = [
"windows-targets 0.42.2", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -5702,6 +5933,31 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"
@@ -5827,6 +6083,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"
@@ -5860,12 +6122,44 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 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]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.1" version = "0.5.1"
+6 -3
View File
@@ -1,12 +1,12 @@
[package] [package]
name = "image-gallery" name = "phokus"
version = "0.1.0" version = "0.1.0"
description = "A performant image gallery application" description = "A performant image gallery application"
authors = ["you"] authors = ["JezzWTF"]
edition = "2021" edition = "2021"
[lib] [lib]
name = "image_gallery_lib" name = "phokus_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
@@ -24,6 +24,7 @@ r2d2 = "0.8"
r2d2_sqlite = "0.25" r2d2_sqlite = "0.25"
sqlite-vec = "=0.1.9" sqlite-vec = "=0.1.9"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff"] } 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" walkdir = "2"
rayon = "1" rayon = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
@@ -31,3 +32,5 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
anyhow = "1" anyhow = "1"
log = "0.4" 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, app: AppHandle,
db: State<'_, DbState>, db: State<'_, DbState>,
path: String, path: String,
cache_dir: String,
) -> Result<Folder, String> { ) -> Result<Folder, String> {
let folder_path = PathBuf::from(&path); let folder_path = PathBuf::from(&path);
@@ -65,8 +64,7 @@ pub async fn add_folder(
.find(|f| f.id == folder_id) .find(|f| f.id == folder_id)
.ok_or("Folder not found after insert")?; .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);
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path, cache_path);
Ok(folder) Ok(folder)
} }
@@ -135,7 +133,6 @@ pub async fn reindex_folder(
app: AppHandle, app: AppHandle,
db: State<'_, DbState>, db: State<'_, DbState>,
folder_id: i64, folder_id: i64,
cache_dir: String,
) -> Result<(), String> { ) -> Result<(), String> {
let folder_path = { let folder_path = {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
@@ -147,7 +144,6 @@ pub async fn reindex_folder(
.ok_or("Folder not found")? .ok_or("Folder not found")?
}; };
let cache_path = PathBuf::from(cache_dir); indexer::index_folder(app, db.inner().clone(), folder_id, folder_path);
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path, cache_path);
Ok(()) Ok(())
} }
+236 -26
View File
@@ -46,6 +46,11 @@ pub struct ImageRecord {
pub modified_at: Option<String>, pub modified_at: Option<String>,
pub mime_type: String, pub mime_type: String,
pub media_kind: 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 favorite: bool,
pub rating: i64, pub rating: i64,
pub embedding_status: String, pub embedding_status: String,
@@ -68,10 +73,34 @@ pub struct EmbeddingJob {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ThumbnailJob { pub struct ThumbnailJob {
pub image_id: i64, pub image_id: i64,
pub folder_id: i64,
pub path: String, pub path: String,
pub media_kind: 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> { pub fn create_pool(db_path: &Path) -> Result<DbPool> {
vector::register_sqlite_vec(); vector::register_sqlite_vec();
let manager = SqliteConnectionManager::file(db_path); let manager = SqliteConnectionManager::file(db_path);
@@ -127,10 +156,20 @@ pub fn migrate(conn: &Connection) -> Result<()> {
updated_at TEXT NOT NULL DEFAULT (datetime('now')) 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_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_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_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_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", "favorite", "INTEGER NOT NULL DEFAULT 0")?;
ensure_column(conn, "images", "rating", "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)?; vector::migrate(conn)?;
Ok(()) 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> { pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
let id = conn.query_row( 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) "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) 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 ON CONFLICT(path) DO UPDATE SET
folder_id = excluded.folder_id, folder_id = excluded.folder_id,
filename = excluded.filename, filename = excluded.filename,
@@ -184,6 +228,11 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
modified_at = excluded.modified_at, modified_at = excluded.modified_at,
mime_type = excluded.mime_type, mime_type = excluded.mime_type,
media_kind = excluded.media_kind, 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_status = excluded.embedding_status,
embedding_model = excluded.embedding_model, embedding_model = excluded.embedding_model,
embedding_updated_at = excluded.embedding_updated_at, 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.modified_at,
img.mime_type, img.mime_type,
img.media_kind, img.media_kind,
img.duration_ms,
img.video_codec,
img.audio_codec,
img.metadata_updated_at,
img.metadata_error,
img.favorite, img.favorite,
img.rating, img.rating,
img.embedding_status, img.embedding_status,
@@ -239,6 +293,19 @@ pub fn enqueue_thumbnail_job(conn: &Connection, image_id: i64) -> Result<()> {
Ok(()) 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)] #[allow(dead_code)]
pub fn get_next_embedding_job(conn: &Connection) -> Result<Option<EmbeddingJob>> { pub fn get_next_embedding_job(conn: &Connection) -> Result<Option<EmbeddingJob>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
@@ -312,26 +379,97 @@ pub fn update_folder_count(conn: &Connection, folder_id: i64) -> Result<()> {
Ok(()) 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( 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 FROM thumbnail_jobs j
JOIN images i ON i.id = j.image_id JOIN images i ON i.id = j.image_id
WHERE j.status = 'pending' WHERE j.status = 'pending'
ORDER BY j.updated_at, j.image_id 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::<rusqlite::Result<Vec<_>>>()?)
}
let mut rows = stmt.query([])?; pub fn get_pending_metadata_jobs(conn: &Connection, limit: usize) -> Result<Vec<MetadataJob>> {
let Some(row) = rows.next()? else { let mut stmt = conn.prepare(
return Ok(None); "SELECT j.image_id, i.folder_id, i.path
}; FROM metadata_jobs j
JOIN images i ON i.id = j.image_id
Ok(Some(ThumbnailJob { WHERE j.status = 'pending' AND i.media_kind = 'video'
image_id: row.get(0)?, ORDER BY j.updated_at, j.image_id
path: row.get(1)?, LIMIT ?1",
media_kind: row.get(2)?, )?;
})) 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<()> { pub fn mark_thumbnail_job_processing(conn: &Connection, image_id: i64) -> Result<()> {
@@ -348,10 +486,16 @@ pub fn mark_thumbnail_ready(
conn: &Connection, conn: &Connection,
image_id: i64, image_id: i64,
thumbnail_path: Option<&str>, thumbnail_path: Option<&str>,
width: Option<i64>,
height: Option<i64>,
) -> Result<ImageRecord> { ) -> Result<ImageRecord> {
conn.execute( conn.execute(
"UPDATE images SET thumbnail_path = ?2 WHERE id = ?1", "UPDATE images
params![image_id, thumbnail_path], 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])?; conn.execute("DELETE FROM thumbnail_jobs WHERE image_id = ?1", [image_id])?;
get_image_by_id(conn, 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(()) 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( pub fn update_image_details(
conn: &Connection, conn: &Connection,
image_id: i64, image_id: i64,
@@ -383,7 +585,8 @@ pub fn update_image_details(
conn.query_row( conn.query_row(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, "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 FROM images
WHERE id = ?1", WHERE id = ?1",
[image_id], [image_id],
@@ -395,7 +598,8 @@ pub fn update_image_details(
pub fn get_image_by_id(conn: &Connection, image_id: i64) -> Result<ImageRecord> { pub fn get_image_by_id(conn: &Connection, image_id: i64) -> Result<ImageRecord> {
conn.query_row( conn.query_row(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, "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 FROM images
WHERE id = ?1", WHERE id = ?1",
[image_id], [image_id],
@@ -443,7 +647,8 @@ pub fn get_images(
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let sql = format!( let sql = format!(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, "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 FROM images
WHERE (?1 IS NULL OR folder_id = ?1) WHERE (?1 IS NULL OR folder_id = ?1)
AND (?2 IS NULL OR filename LIKE ?2) 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)?, modified_at: row.get(9)?,
mime_type: row.get(10)?, mime_type: row.get(10)?,
media_kind: row.get(11)?, media_kind: row.get(11)?,
favorite: row.get::<_, i64>(12)? != 0, duration_ms: row.get(12)?,
rating: row.get(13)?, video_codec: row.get(13)?,
embedding_status: row.get(14)?, audio_codec: row.get(14)?,
embedding_model: row.get(15)?, metadata_updated_at: row.get(15)?,
embedding_updated_at: row.get(16)?, metadata_error: row.get(16)?,
embedding_error: row.get(17)?, 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::thumbnail;
use crate::vector; use crate::vector;
use anyhow::Result; use anyhow::Result;
use rayon::prelude::*; use rayon::prelude::*;
use serde::Serialize; use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -14,12 +18,497 @@ const IMAGE_EXTENSIONS: &[&str] = &[
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"]; 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 { fn is_supported_media(path: &Path) -> bool {
path.extension() path.extension()
.and_then(|e| e.to_str()) .and_then(|value| value.to_str())
.map(|e| { .map(|value| {
let ext = e.to_lowercase(); let extension = value.to_lowercase();
IMAGE_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str()) IMAGE_EXTENSIONS.contains(&extension.as_str())
|| VIDEO_EXTENSIONS.contains(&extension.as_str())
}) })
.unwrap_or(false) .unwrap_or(false)
} }
@@ -47,254 +536,3 @@ fn mime_for_ext(ext: &str) -> &'static str {
_ => "image/jpeg", _ => "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 commands;
mod db; mod db;
mod indexer; mod indexer;
mod media;
mod thumbnail; mod thumbnail;
mod vector; mod vector;
@@ -20,8 +21,11 @@ pub fn run() {
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir"); 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 db_path = app_dir.join("gallery.db");
let pool = db::create_pool(&db_path).expect("Failed to create database pool"); 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"); 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"); let thumb_dir = app_dir.join("thumbnails");
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir"); 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(pool);
app.manage(media_tools);
Ok(()) Ok(())
}) })
+1 -1
View File
@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { 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 anyhow::{anyhow, Result};
use fast_image_resize as fir;
use fast_image_resize::IntoImageView;
use image::ImageDecoder;
use image::ImageFormat; use image::ImageFormat;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
pub const THUMB_SIZE: u32 = 320; 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 { pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
thumb_path_with_ext(cache_dir, image_path, "webp") 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 { fn hash_path(s: &str) -> String {
use std::collections::hash_map::DefaultHasher; format!("{:016x}", xxhash_rust::xxh3::xxh3_64(s.as_bytes()))
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
format!("{:016x}", hasher.finish())
} }
pub fn generate_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<PathBuf> { fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) {
let path_str = image_path.to_string_lossy(); if width <= max_size && height <= max_size {
let out_path = thumb_path(cache_dir, &path_str); return (width.max(1), height.max(1));
if out_path.exists() {
return Ok(out_path);
} }
let img = image::open(image_path)?; if width >= height {
let thumb = img.thumbnail(THUMB_SIZE, THUMB_SIZE); let scaled_height = ((height as f64 / width as f64) * max_size as f64).round() as u32;
(max_size, scaled_height.max(1))
if let Some(parent) = out_path.parent() { } else {
std::fs::create_dir_all(parent)?; 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 { useEffect } from "react";
import { useGalleryStore } from "./store"; import { useGalleryStore } from "./store";
import { Sidebar } from "./components/Sidebar"; import { Sidebar } from "./components/Sidebar";
import { BackgroundTasks } from "./components/BackgroundTasks";
import { MenuBar } from "./components/MenuBar"; import { MenuBar } from "./components/MenuBar";
import { Toolbar } from "./components/Toolbar"; import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery"; import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox"; import { Lightbox } from "./components/Lightbox";
export default function App() { 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(() => { useEffect(() => {
loadFolders().then(() => loadImages(true)); loadFolders().then(() => loadImages(true));
@@ -26,6 +29,7 @@ export default function App() {
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0">
<MenuBar /> <MenuBar />
<Toolbar /> <Toolbar />
<BackgroundTasks />
<Gallery /> <Gallery />
</main> </main>
<Lightbox /> <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({ function ContextMenu({
x, x,
y, y,
@@ -174,6 +188,11 @@ function ImageTile({
{image.media_kind} {image.media_kind}
</div> </div>
<div className="flex items-center gap-1"> <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 ? ( {image.favorite ? (
<div className="rounded-full border border-white/10 bg-black/35 p-1 text-rose-300"> <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"> <svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20">
@@ -196,7 +215,12 @@ function ImageTile({
} }
export function Gallery() { 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 parentRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
+96 -49
View File
@@ -18,8 +18,26 @@ function formatDate(iso: string | null): string {
}); });
} }
function formatDuration(durationMs: number | null): string {
if (!durationMs || durationMs <= 0) return "Pending / unavailable";
const totalSeconds = Math.floor(durationMs / 1000);
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export function Lightbox() { 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 [zoom, setZoom] = useState(1);
const imageViewportRef = useRef<HTMLDivElement>(null); const imageViewportRef = useRef<HTMLDivElement>(null);
@@ -93,58 +111,61 @@ export function Lightbox() {
</svg> </svg>
</button> </button>
<motion.div <div className="flex flex-1 flex-col" onClick={(event) => event.stopPropagation()}>
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 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div <div
ref={imageViewportRef} ref={imageViewportRef}
className="group relative flex flex-1 items-center justify-center overflow-auto p-10" className="group relative flex flex-1 items-center justify-center overflow-auto p-10"
> >
{selectedImage.media_kind === "video" ? ( <AnimatePresence mode="wait">
<video <motion.div
src={convertFileSrc(selectedImage.path)} key={selectedImage.id}
controls className="flex items-center justify-center"
className="max-h-full max-w-full rounded-2xl shadow-2xl" initial={{ opacity: 0.3, scale: 0.985 }}
style={{ maxHeight: "calc(100vh - 10rem)" }} animate={{ opacity: 1, scale: 1 }}
/> exit={{ opacity: 0.3, scale: 0.985 }}
) : ( transition={{ duration: 0.12 }}
<> >
<img {selectedImage.media_kind === "video" ? (
src={convertFileSrc(selectedImage.path)} <video
alt={selectedImage.filename} src={convertFileSrc(selectedImage.path)}
className="max-w-full rounded-2xl shadow-2xl" controls
style={{ className="max-h-full max-w-full rounded-2xl shadow-2xl"
maxHeight: "calc(100vh - 10rem)", style={{ maxHeight: "calc(100vh - 10rem)" }}
transform: `scale(${zoom})`, />
transformOrigin: "center center", ) : (
}} <>
/> <img
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100"> src={convertFileSrc(selectedImage.path)}
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur"> alt={selectedImage.filename}
<button className="max-w-full rounded-2xl shadow-2xl"
className="px-2 text-sm text-gray-300 hover:text-white" style={{
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))} maxHeight: "calc(100vh - 10rem)",
> transform: `scale(${zoom})`,
- transformOrigin: "center center",
</button> }}
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span> />
<button <div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
className="px-2 text-sm text-gray-300 hover:text-white" <div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
onClick={() => setZoom((value) => Math.min(4, value + 0.25))} <button
> className="px-2 text-sm text-gray-300 hover:text-white"
+ onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))}
</button> >
</div> -
</div> </button>
</> <span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
)} <button
className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setZoom((value) => Math.min(4, value + 0.25))}
>
+
</button>
</div>
</div>
</>
)}
</motion.div>
</AnimatePresence>
</div> </div>
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 p-5"> <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> </p>
</div> </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> <div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Type</p> <p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Type</p>
<p className="text-white">{selectedImage.mime_type}</p> <p className="text-white">{selectedImage.mime_type}</p>
@@ -248,7 +295,7 @@ export function Lightbox() {
</div> </div>
</div> </div>
</div> </div>
</motion.div> </div>
<button <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" 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() { export function MenuBar() {
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null); const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const { const addFolder = useGalleryStore((state) => state.addFolder);
addFolder, const reindexFolder = useGalleryStore((state) => state.reindexFolder);
reindexFolder, const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
selectedFolderId, const zoomPreset = useGalleryStore((state) => state.zoomPreset);
zoomPreset, const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
setZoomPreset, const mediaFilter = useGalleryStore((state) => state.mediaFilter);
mediaFilter, const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
setMediaFilter, const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
favoritesOnly, const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
setFavoritesOnly,
} = useGalleryStore();
useEffect(() => { useEffect(() => {
const handlePointerDown = (event: MouseEvent) => { const handlePointerDown = (event: MouseEvent) => {
+6 -2
View File
@@ -95,8 +95,12 @@ function FolderItem({
} }
export function Sidebar() { export function Sidebar() {
const { folders, selectedFolderId, addFolder, indexingProgress, totalImages, selectFolder } = const folders = useGalleryStore((state) => state.folders);
useGalleryStore(); 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 handleAddFolder = async () => {
const selected = await open({ const selected = await open({
+14 -16
View File
@@ -34,22 +34,20 @@ function FilterChip({
} }
export function Toolbar() { export function Toolbar() {
const { const search = useGalleryStore((state) => state.search);
search, const setSearch = useGalleryStore((state) => state.setSearch);
setSearch, const sort = useGalleryStore((state) => state.sort);
sort, const setSort = useGalleryStore((state) => state.setSort);
setSort, const totalImages = useGalleryStore((state) => state.totalImages);
totalImages, const loadedCount = useGalleryStore((state) => state.loadedCount);
loadedCount, const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
selectedFolderId, const folders = useGalleryStore((state) => state.folders);
folders, const mediaFilter = useGalleryStore((state) => state.mediaFilter);
mediaFilter, const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
setMediaFilter, const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
favoritesOnly, const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
setFavoritesOnly, const zoomPreset = useGalleryStore((state) => state.zoomPreset);
zoomPreset, const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
setZoomPreset,
} = useGalleryStore();
const [searchValue, setSearchValue] = useState(search); const [searchValue, setSearchValue] = useState(search);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+58 -8
View File
@@ -28,6 +28,11 @@ export interface ImageRecord {
modified_at: string | null; modified_at: string | null;
mime_type: string; mime_type: string;
media_kind: MediaKind; 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; favorite: boolean;
rating: number; rating: number;
embedding_status: string; embedding_status: string;
@@ -44,6 +49,16 @@ export interface IndexProgress {
done: boolean; done: boolean;
} }
export interface FolderJobProgress {
folder_id: number;
thumbnail_pending: number;
metadata_pending: number;
}
export interface MediaJobProgressEvent {
progress: FolderJobProgress[];
}
export interface IndexedImagesBatch { export interface IndexedImagesBatch {
folder_id: number; folder_id: number;
images: ImageRecord[]; images: ImageRecord[];
@@ -75,6 +90,7 @@ interface GalleryState {
zoomPreset: ZoomPreset; zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null; selectedImage: ImageRecord | null;
indexingProgress: Record<number, IndexProgress>; indexingProgress: Record<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>;
cacheDir: string; cacheDir: string;
loadFolders: () => Promise<void>; loadFolders: () => Promise<void>;
@@ -98,6 +114,16 @@ interface GalleryState {
const PAGE_SIZE = 200; 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 { function matchesSearch(image: ImageRecord, search: string): boolean {
if (!search) return true; if (!search) return true;
return image.filename.toLowerCase().includes(search.toLowerCase()); return image.filename.toLowerCase().includes(search.toLowerCase());
@@ -175,6 +201,16 @@ function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: So
return mergeImages(images, [updatedImage], sort); 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 { export function tileSizeForZoom(zoomPreset: ZoomPreset): number {
switch (zoomPreset) { switch (zoomPreset) {
case "compact": case "compact":
@@ -200,6 +236,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
zoomPreset: "comfortable", zoomPreset: "comfortable",
selectedImage: null, selectedImage: null,
indexingProgress: {}, indexingProgress: {},
mediaJobProgress: {},
cacheDir: "", cacheDir: "",
setCacheDir: (cacheDir) => set({ cacheDir }), setCacheDir: (cacheDir) => set({ cacheDir }),
@@ -210,8 +247,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
addFolder: async (path) => { addFolder: async (path) => {
const { cacheDir, loadFolders } = get(); const { loadFolders } = get();
await invoke("add_folder", { path, cacheDir }); await invoke("add_folder", { path });
await loadFolders(); await loadFolders();
}, },
@@ -226,8 +263,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
reindexFolder: async (folderId) => { reindexFolder: async (folderId) => {
const { cacheDir, loadFolders } = get(); const { loadFolders } = get();
await invoke("reindex_folder", { folderId, cacheDir }); await invoke("reindex_folder", { folderId });
await loadFolders(); await loadFolders();
}, },
@@ -329,6 +366,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
if (progress.done) { if (progress.done) {
void get().loadFolders(); void get().loadFolders();
void get().loadImages(true);
setTimeout(() => { setTimeout(() => {
set((state) => { 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 unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
const batch = event.payload; const batch = event.payload;
@@ -359,16 +407,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
} }
const newVisibleCount = countNewImages(state.images, visibleImages); 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 { return {
images, images,
loadedCount: images.length, loadedCount: Math.max(state.loadedCount, Math.min(images.length, visibleWindow)),
totalImages: Math.max(state.totalImages + newVisibleCount, images.length), 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; const batch = event.payload;
set((state) => { set((state) => {
@@ -392,7 +441,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
} }
return { return {
images: mergeImages(state.images, visibleImages, state.sort), images: replaceExistingImages(state.images, visibleImages, state.sort),
selectedImage, selectedImage,
}; };
}); });
@@ -400,6 +449,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return () => { return () => {
unlistenProgress(); unlistenProgress();
unlistenMediaJobs();
unlistenImages(); unlistenImages();
unlistenThumbnails(); unlistenThumbnails();
}; };