Improve media indexing and processing flow
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
Generated
+321
-27
@@ -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"
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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
@@ -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| {
|
||||||
let mut rows = stmt.query([])?;
|
Ok(ThumbnailJob {
|
||||||
let Some(row) = rows.next()? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(ThumbnailJob {
|
|
||||||
image_id: row.get(0)?,
|
image_id: row.get(0)?,
|
||||||
path: row.get(1)?,
|
folder_id: row.get(1)?,
|
||||||
media_kind: row.get(2)?,
|
path: row.get(2)?,
|
||||||
}))
|
media_kind: row.get(3)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_pending_metadata_jobs(conn: &Connection, limit: usize) -> Result<Vec<MetadataJob>> {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT j.image_id, i.folder_id, i.path
|
||||||
|
FROM metadata_jobs j
|
||||||
|
JOIN images i ON i.id = j.image_id
|
||||||
|
WHERE j.status = 'pending' AND i.media_kind = 'video'
|
||||||
|
ORDER BY j.updated_at, j.image_id
|
||||||
|
LIMIT ?1",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([limit as i64], |row| {
|
||||||
|
Ok(MetadataJob {
|
||||||
|
image_id: row.get(0)?,
|
||||||
|
folder_id: row.get(1)?,
|
||||||
|
path: row.get(2)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_thumbnail_job_processing(conn: &Connection, image_id: i64) -> Result<()> {
|
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
@@ -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
@@ -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(())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
+58
-11
@@ -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,19 +111,20 @@ 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"
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={selectedImage.id}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
initial={{ opacity: 0.3, scale: 0.985 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0.3, scale: 0.985 }}
|
||||||
|
transition={{ duration: 0.12 }}
|
||||||
>
|
>
|
||||||
{selectedImage.media_kind === "video" ? (
|
{selectedImage.media_kind === "video" ? (
|
||||||
<video
|
<video
|
||||||
@@ -145,6 +164,8 @@ export function Lightbox() {
|
|||||||
</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"
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user