From cc744d56c753668e9dbf4e458f1478ec82990de9 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 15 Jul 2026 01:00:43 +0200 Subject: [PATCH] added photos thumbnail caching + bigger photos --- Cargo.lock | 604 +++++++++++++++++++++++++++++ Cargo.toml | 4 + crates/pdfs-core/src/control.rs | 23 +- crates/pdfs-core/src/db.rs | 357 +++++++++++++++++- crates/pdfs-fuse/Cargo.toml | 1 + crates/pdfs-fuse/src/lib.rs | 649 ++++++++++++++++++++++++++------ crates/pdfs-gui/src/app.rs | 120 +++++- packaging/PKGBUILD | 2 +- 8 files changed, 1616 insertions(+), 144 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b8d50f..b355851 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -148,6 +166,23 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "argon2" version = "0.5.3" @@ -161,6 +196,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -301,6 +351,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.17.0" @@ -354,6 +447,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitfields" version = "1.0.3" @@ -387,6 +486,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "bitvec" version = "1.1.1" @@ -487,18 +595,36 @@ dependencies = [ "memchr", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.0" @@ -728,6 +854,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -814,6 +946,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -839,6 +980,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -1230,6 +1377,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1282,6 +1435,26 @@ dependencies = [ "syn", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1319,6 +1492,23 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1337,6 +1527,21 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -1376,6 +1581,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] @@ -1644,6 +1850,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.20.12" @@ -1859,6 +2075,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2210,6 +2437,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "ravif", + "rayon", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "2.14.0" @@ -2239,6 +2504,17 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2251,6 +2527,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2387,6 +2672,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libadwaita" version = "0.7.2" @@ -2439,6 +2730,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.16" @@ -2492,6 +2793,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2507,6 +2817,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2569,12 +2889,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "mutate_once" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.29.0" @@ -2601,6 +2937,15 @@ dependencies = [ "memoffset", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "8.0.0" @@ -2610,6 +2955,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2666,9 +3017,21 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ + "bytemuck", "num-traits", ] +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2896,6 +3259,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pdfs-cli" version = "0.4.0" @@ -2934,6 +3309,7 @@ name = "pdfs-fuse" version = "0.4.0" dependencies = [ "fuser", + "image", "libc", "pdfs-core", "proton-drive-rs", @@ -3088,6 +3464,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3159,6 +3548,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "proton-drive-rs" version = "0.1.5" @@ -3215,6 +3623,41 @@ dependencies = [ "tracing", ] +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.11" @@ -3357,6 +3800,91 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3472,6 +4000,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -3935,6 +4469,15 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "slab" version = "0.4.12" @@ -4151,6 +4694,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4485,6 +5042,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4655,6 +5223,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -5100,6 +5674,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.3" @@ -5285,6 +5865,30 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "4.2.0" diff --git a/Cargo.toml b/Cargo.toml index a8bf5d2..e215d4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } directories = "5" ignore = "0.4" sha2 = "0.10" +# Thumbnail generation for photos the server has none for. Decoders only for the +# formats a camera or phone actually produces — the rest of the crate's codecs +# would be dead weight in the daemon. +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] } rusqlite = { version = "0.32", features = ["bundled"] } keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } clap = { version = "4", features = ["derive"] } diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index f3ae755..e30b6fb 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -213,15 +213,34 @@ pub struct PhotoItem { /// `None` here means "not fetched yet", not "has no thumbnail" — ask for it /// with [`Request::PhotoThumbs`]. pub thumb_path: Option, + /// File name, when the daemon knows it. + pub name: Option, + /// Aspect ratio (w/h), remembered by the daemon from the last time this + /// photo's thumbnail was decoded. Lets the gallery justify its rows correctly + /// on the first frame instead of guessing and reflowing as images land. + pub ratio: Option, + /// True when this photo can never be given a thumbnail — the server has none + /// and its bytes could not be decoded locally. The tile shows a placeholder + /// rather than waiting for an image that will never come. + pub no_thumb: bool, } /// One thumbnail in a [`Response::Thumbs`] batch. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PhotoThumb { pub uid: String, - /// On-disk path, or `None` when the photo genuinely has no thumbnail (or the - /// fetch failed) — a front-end can then stop asking for it. + /// On-disk path, or `None` when there is no thumbnail to serve *yet*. pub path: Option, + /// True when the daemon is making this thumbnail itself, because the server + /// has none: the photo's full file is downloading and will be scaled when it + /// lands. A `None` path with `pending` set means "ask again shortly"; a `None` + /// path *without* it means the photo can never have a thumbnail, and a + /// front-end should stop asking. + /// + /// Generation is not made to block the reply: one 20 MB camera photo takes + /// far longer to fetch than the whole rest of a batch, and holding the batch + /// for it would leave a screenful of ready thumbnails unpainted. + pub pending: bool, } /// The daemon's reply to a [`Request`]. diff --git a/crates/pdfs-core/src/db.rs b/crates/pdfs-core/src/db.rs index 3c63e8e..d2661b8 100644 --- a/crates/pdfs-core/src/db.rs +++ b/crates/pdfs-core/src/db.rs @@ -10,6 +10,7 @@ //! applies the forward-only schema migrations. Write-through of nodes, the //! event cursor, FTS, and the cache index land in later phases on this schema. +use std::collections::HashMap; use std::path::Path; use std::sync::Mutex; @@ -21,7 +22,16 @@ use crate::Result; use crate::localindex::LocalEntry; /// Current schema version. Bump on every forward migration added below. -const SCHEMA_VERSION: i64 = 6; +const SCHEMA_VERSION: i64 = 7; + +/// A photo whose thumbnail state is not known yet: it has never been asked for. +pub const THUMB_UNKNOWN: i64 = 0; +/// A thumbnail exists for this photo — served by the server, or generated locally +/// from the full file when the server had none. +pub const THUMB_HAVE: i64 = 1; +/// This photo can never be given a thumbnail: the server has none and the bytes +/// could not be decoded locally either. Never retried. +pub const THUMB_NONE: i64 = 2; /// Below this length the trigram tokenizer indexes nothing (it needs 3-char /// grams), so short queries fall back to a `LIKE` scan over `nodes.name`. @@ -42,6 +52,31 @@ pub struct SearchHit { pub path: String, } +/// One photo of the persisted timeline. The timeline itself is server-ordered +/// (newest first) and stored with that order in `seq`; `ratio` and `thumb_state` +/// are locally learned and survive a refresh of the timeline. +#[derive(Clone, Debug, PartialEq)] +pub struct StoredPhoto { + pub uid: String, + pub capture_time: i64, + pub name: Option, + /// Aspect ratio (w/h), known once a thumbnail has been decoded. + pub ratio: Option, + /// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`]. + pub thumb_state: i64, +} + +/// One trashed node, as persisted for the Trash page. Trashed nodes live outside +/// the mounted tree, so a uid is their only handle. +#[derive(Clone, Debug, PartialEq)] +pub struct StoredTrash { + pub uid: String, + pub name: String, + pub is_dir: bool, + pub size: i64, + pub mtime: i64, +} + /// Handle to the unified metadata database. /// /// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through @@ -123,6 +158,9 @@ impl Db { if current < 6 { tx.execute_batch(MIGRATION_V6)?; } + if current < 7 { + tx.execute_batch(MIGRATION_V7)?; + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -299,6 +337,189 @@ impl Db { Ok(()) } + /// Read a `sync_state` value as an integer (the freshness stamps of the + /// photos timeline and the trash listing are kept there). + pub fn state_i64(&self, key: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let v: Option = conn + .query_row( + "SELECT value FROM sync_state WHERE key = ?1", + params![key], + |r| r.get(0), + ) + .optional()?; + Ok(v.and_then(|v| v.parse().ok())) + } + + /// Write a `sync_state` integer value. + pub fn set_state_i64(&self, key: &str, value: i64) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO sync_state (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![key, value.to_string()], + )?; + Ok(()) + } + + /// Drop a `sync_state` key, so whatever it stamped counts as never fetched. + pub fn clear_state(&self, key: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?; + Ok(()) + } + + /// Replace the persisted photos timeline with `items` (newest first), keeping + /// what was locally learned about the photos that are still there: a ratio or + /// a thumbnail verdict costs a download to rediscover, while the server order + /// and capture times are cheap and authoritative. + /// + /// Photos no longer in the timeline are dropped, so a deletion on another + /// client doesn't leave a ghost tile behind. + pub fn photos_replace(&self, items: &[(String, i64, Option)]) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let learned: HashMap, i64)> = { + let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state FROM photos")?; + let rows = stmt.query_map([], |r| { + Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?))) + })?; + rows.collect::>()? + }; + + tx.execute("DELETE FROM photos", [])?; + { + let mut stmt = tx.prepare( + "INSERT INTO photos (uid, capture_time, name, ratio, thumb_state, seq) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + )?; + for (seq, (uid, capture_time, name)) in items.iter().enumerate() { + let (ratio, thumb_state) = learned.get(uid).copied().unwrap_or((None, THUMB_UNKNOWN)); + stmt.execute(params![uid, capture_time, name, ratio, thumb_state, seq as i64])?; + } + } + tx.commit()?; + Ok(()) + } + + /// One page of the persisted timeline, newest first. + pub fn photos_page(&self, offset: usize, limit: usize) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT uid, capture_time, name, ratio, thumb_state FROM photos + ORDER BY seq LIMIT ?1 OFFSET ?2", + )?; + let rows = stmt.query_map(params![limit as i64, offset as i64], |r| { + Ok(StoredPhoto { + uid: r.get(0)?, + capture_time: r.get(1)?, + name: r.get(2)?, + ratio: r.get(3)?, + thumb_state: r.get(4)?, + }) + })?; + let mut photos = Vec::new(); + for row in rows { + photos.push(row?); + } + Ok(photos) + } + + /// The stored photos for `uids`, in no particular order. Used by the thumbnail + /// path, which needs each photo's capture time (the cache validity tag) and + /// its thumbnail verdict. + pub fn photos_by_uid(&self, uids: &[String]) -> Result> { + if uids.is_empty() { + return Ok(Vec::new()); + } + let conn = self.conn.lock().unwrap(); + let placeholders = vec!["?"; uids.len()].join(","); + let mut stmt = conn.prepare(&format!( + "SELECT uid, capture_time, name, ratio, thumb_state FROM photos + WHERE uid IN ({placeholders})" + ))?; + let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| { + Ok(StoredPhoto { + uid: r.get(0)?, + capture_time: r.get(1)?, + name: r.get(2)?, + ratio: r.get(3)?, + thumb_state: r.get(4)?, + }) + })?; + let mut photos = Vec::new(); + for row in rows { + photos.push(row?); + } + Ok(photos) + } + + /// Record what a thumbnail attempt learned: whether the photo now has one + /// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were + /// seen. A `None` ratio leaves any previously learned one alone. + pub fn photo_set_thumb(&self, uid: &str, state: i64, ratio: Option) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1", + params![uid, state, ratio], + )?; + Ok(()) + } + + /// Number of photos in the persisted timeline. + pub fn photos_count(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?; + Ok(n.max(0) as usize) + } + + /// Replace the persisted trash listing. + pub fn trash_replace(&self, items: &[StoredTrash]) -> Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + tx.execute("DELETE FROM trash", [])?; + { + let mut stmt = tx.prepare( + "INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)", + )?; + for item in items { + stmt.execute(params![ + item.uid, + item.name, + item.is_dir as i64, + item.size, + item.mtime + ])?; + } + } + tx.commit()?; + Ok(()) + } + + /// The persisted trash listing, folders first then by name — the order the + /// Trash page shows it in. + pub fn trash_list(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT uid, name, is_dir, size, mtime FROM trash + ORDER BY is_dir DESC, name COLLATE NOCASE", + )?; + let rows = stmt.query_map([], |r| { + Ok(StoredTrash { + uid: r.get(0)?, + name: r.get(1)?, + is_dir: r.get::<_, i64>(2)? != 0, + size: r.get(3)?, + mtime: r.get(4)?, + }) + })?; + let mut items = Vec::new(); + for row in rows { + items.push(row?); + } + Ok(items) + } + /// Children of `parent`, for the offline `ensure_children` fast path when a /// listed folder's `children` map entry was trimmed mid-run. Returns `None` /// when the folder is not marked `listed` (listing unknown, must re-fetch). @@ -849,6 +1070,35 @@ CREATE VIRTUAL TABLE local_fts USING fts5( ); "; +/// Schema v7: the photos timeline and the trash listing become persistent, so +/// opening the app paints them from disk instead of re-fetching the world (both +/// were memory-only: the timeline behind a 60 s TTL, the trash not cached at all). +/// +/// `photos.seq` preserves the server's newest-first order, which is the only order +/// the timeline has — `capture_time` ties are common (a burst of shots) and would +/// otherwise shuffle between refreshes. `ratio` and `thumb_state` are *locally +/// learned*: they cost a download to rediscover, so `photos_replace` carries them +/// across a refresh while capture times and order come from the server. +const MIGRATION_V7: &str = " +CREATE TABLE photos ( + uid TEXT PRIMARY KEY, + capture_time INTEGER NOT NULL, + name TEXT, + ratio REAL, + thumb_state INTEGER NOT NULL DEFAULT 0, + seq INTEGER NOT NULL +); +CREATE INDEX idx_photos_seq ON photos(seq); + +CREATE TABLE trash ( + uid TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_dir INTEGER NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + mtime INTEGER NOT NULL DEFAULT 0 +); +"; + #[cfg(test)] mod tests { use super::*; @@ -879,6 +1129,111 @@ mod tests { serde_json::from_value(v).unwrap() } + #[test] + fn photos_replace_keeps_what_was_learned_and_drops_what_left() { + let db = Db::open_in_memory().unwrap(); + db.photos_replace(&[ + ("p1".into(), 300, None), + ("p2".into(), 200, None), + ("p3".into(), 100, None), + ]) + .unwrap(); + + // A thumbnail attempt teaches us p1's ratio and that p2 can never have one. + db.photo_set_thumb("p1", THUMB_HAVE, Some(1.5)).unwrap(); + db.photo_set_thumb("p2", THUMB_NONE, None).unwrap(); + + // The next refresh brings a new photo, keeps p1 and p2, and loses p3. + db.photos_replace(&[ + ("p0".into(), 400, Some("new.jpg".into())), + ("p1".into(), 300, None), + ("p2".into(), 200, None), + ]) + .unwrap(); + + let page = db.photos_page(0, 10).unwrap(); + assert_eq!( + page.iter().map(|p| p.uid.as_str()).collect::>(), + ["p0", "p1", "p2"], + "server order is preserved, and the dropped photo is gone" + ); + assert_eq!(page[0].name.as_deref(), Some("new.jpg")); + // Ratios and verdicts cost a download to rediscover: they survive a refresh. + assert_eq!(page[1].ratio, Some(1.5)); + assert_eq!(page[1].thumb_state, THUMB_HAVE); + assert_eq!(page[2].thumb_state, THUMB_NONE); + // A photo we know nothing about yet starts blank. + assert_eq!(page[0].ratio, None); + assert_eq!(page[0].thumb_state, THUMB_UNKNOWN); + + assert_eq!(db.photos_count().unwrap(), 3); + let by_uid = db.photos_by_uid(&["p2".into()]).unwrap(); + assert_eq!(by_uid.len(), 1); + assert_eq!(by_uid[0].capture_time, 200); + } + + #[test] + fn photos_page_slices_the_timeline_in_order() { + let db = Db::open_in_memory().unwrap(); + let items: Vec<_> = (0..5) + .map(|i| (format!("p{i}"), 500 - i as i64, None)) + .collect(); + db.photos_replace(&items).unwrap(); + + let page = db.photos_page(2, 2).unwrap(); + assert_eq!( + page.iter().map(|p| p.uid.as_str()).collect::>(), + ["p2", "p3"] + ); + assert!(db.photos_page(9, 2).unwrap().is_empty()); + } + + #[test] + fn trash_replace_lists_folders_first() { + let db = Db::open_in_memory().unwrap(); + db.trash_replace(&[ + StoredTrash { + uid: "t1".into(), + name: "zeta.txt".into(), + is_dir: false, + size: 10, + mtime: 1, + }, + StoredTrash { + uid: "t2".into(), + name: "Alpha".into(), + is_dir: true, + size: 0, + mtime: 2, + }, + ]) + .unwrap(); + + let items = db.trash_list().unwrap(); + assert_eq!( + items.iter().map(|i| i.name.as_str()).collect::>(), + ["Alpha", "zeta.txt"] + ); + + // A replace is a replace: emptying the trash on the server empties it here. + db.trash_replace(&[]).unwrap(); + assert!(db.trash_list().unwrap().is_empty()); + } + + #[test] + fn state_stamps_round_trip_and_clear() { + let db = Db::open_in_memory().unwrap(); + assert_eq!(db.state_i64("photos_synced_ms").unwrap(), None); + db.set_state_i64("photos_synced_ms", 1234).unwrap(); + assert_eq!(db.state_i64("photos_synced_ms").unwrap(), Some(1234)); + db.clear_state("photos_synced_ms").unwrap(); + assert_eq!( + db.state_i64("photos_synced_ms").unwrap(), + None, + "a cleared stamp reads as never fetched, so the next request blocks on a refresh" + ); + } + fn folder(link: &str, parent: Option<&str>, name: &str) -> Node { let parent = match parent { Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}), diff --git a/crates/pdfs-fuse/Cargo.toml b/crates/pdfs-fuse/Cargo.toml index 2c4857d..e96ddd7 100644 --- a/crates/pdfs-fuse/Cargo.toml +++ b/crates/pdfs-fuse/Cargo.toml @@ -15,3 +15,4 @@ tracing.workspace = true fuser.workspace = true libc.workspace = true serde_json.workspace = true +image.workspace = true diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 0590c1d..01726b7 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -31,7 +31,7 @@ //! set, so a multi-GiB write never buffers in RAM and only the untouched //! remainder of the file is pulled from the remote — lazily, at commit. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ffi::OsStr; use std::fs::File; use std::io::{BufRead, BufReader, Write as _}; @@ -55,13 +55,13 @@ use pdfs_core::control::{ DirEntry, LocalHit, PhotoItem, PhotoThumb, Request as CtlRequest, Response as CtlResponse, SearchHit, TransferDirection, }; -use pdfs_core::db::{Db, StoredNode}; +use pdfs_core::db::{self, Db, StoredNode, StoredPhoto, StoredTrash}; use pdfs_core::localindex; use proton_drive_rs::proton_sdk::error::ProtonError; use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; use proton_drive_rs::{ - DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient, - ProtonPhotosClient, ThumbnailType, + DriveEvent, DriveEventScopeId, Node, NodeKind, ProtonDriveClient, ProtonPhotosClient, + ThumbnailType, }; mod transfers; @@ -75,10 +75,33 @@ const TTL: Duration = Duration::from_secs(30); /// How often the background task polls the remote event cursor. const POLL_INTERVAL: Duration = Duration::from_secs(10); -/// How long a fetched photos timeline stays good before the next page request -/// re-fetches it. The SDK hands back the whole timeline at once, so we cache it -/// in [`Core`] and serve every page from memory until it goes stale. -const TIMELINE_TTL: Duration = Duration::from_secs(60); +/// How long the persisted photos timeline stays good before a page request +/// revalidates it. The SDK hands back the whole timeline at once, so it is stored +/// in the DB and every page is sliced from there; a stale one is still served +/// immediately and refreshed in the background. +const TIMELINE_TTL: Duration = Duration::from_secs(5 * 60); +/// The same, for the persisted trash listing. Shorter, because the trash is the +/// one listing a user changes and then immediately looks at — though our own +/// mutations also invalidate it outright, so this only covers other clients. +const TRASH_TTL: Duration = Duration::from_secs(60); + +/// `sync_state` keys for the freshness stamps of the two persisted listings, and +/// for whether the account has a photos volume at all (so an account without one +/// doesn't re-ask the server on every page request). +const PHOTOS_SYNCED_MS: &str = "photos_synced_ms"; +const PHOTOS_AVAILABLE: &str = "photos_available"; +const TRASH_SYNCED_MS: &str = "trash_synced_ms"; + +/// Longest edge, in px, of a thumbnail generated locally for a photo the server +/// has none for. Matches the server's own thumbnail scale closely enough that a +/// tile can't tell them apart. +const THUMB_EDGE: u32 = 512; +/// JPEG quality of a locally generated thumbnail. +const THUMB_QUALITY: u8 = 82; +/// How many photos may be downloaded at once to generate their missing +/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise +/// saturate the link and starve the rest of the daemon. +const THUMB_GEN_CONCURRENCY: usize = 4; /// How stale the local-file index may get before the background scanner rebuilds /// it. A rescan is a full walk of `$HOME`, so this trades index freshness against @@ -310,10 +333,16 @@ struct Core { /// `State` maps. Every mutation writes through here, and the maps rehydrate /// from it on mount (plan.md P1). db: Arc, - /// Cached full photos timeline (newest first), so paged `PhotosTimeline` - /// requests slice memory instead of re-fetching the whole timeline per page. - /// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`]. - timeline: Arc>>, + /// True while a background refresh of the photos timeline (resp. the trash) is + /// already running, so a burst of page requests against a stale listing kicks + /// off one refresh rather than one per request. + timeline_refreshing: Arc, + trash_refreshing: Arc, + /// Photos whose missing thumbnail is being generated right now. A tile that is + /// still on screen asks for its thumbnail again every few seconds, and each of + /// those downloads is a full-size photo — so an in-flight uid is never started + /// twice. + thumb_gen: Arc>>, /// In-flight upload/download progress, served to `GetQueueStatus`. Shared /// across the FUSE session and the control-socket task. transfers: Arc, @@ -322,12 +351,6 @@ struct Core { indexing: Arc, } -/// The whole photos timeline plus when it was fetched, for TTL freshness. -struct TimelineCache { - items: Vec, - fetched: Instant, -} - impl Core { /// Rehydrate the in-memory `State` maps from the DB on mount, so a cold /// start serves previously-seen metadata (stable inodes, instant listings) @@ -1047,132 +1070,314 @@ impl Core { .collect()) } - /// A page of the photos timeline (newest first). Metadata only: a thumbnail - /// path is attached for photos whose thumbnail is *already* cached, and no - /// thumbnail is downloaded here — the page comes back as fast as the cached - /// timeline can be sliced, and the front-end pulls the thumbnails it actually - /// paints via [`Core::photo_thumbs`]. `Ok(None)` when the account has no - /// photos volume. + /// A page of the photos timeline (newest first), sliced out of the DB. + /// `Ok(None)` when the account has no photos volume. /// - /// The SDK's `enumerate_timeline` returns the *whole* timeline, so the first - /// request (or the first after [`TIMELINE_TTL`] elapses) fetches and caches - /// it in [`Core::timeline`]; every page then slices that cached vec, so - /// "load more" scrolling doesn't re-hit the network per page. + /// Stale-while-revalidate: a persisted timeline is served *immediately*, and + /// refreshed on the runtime if it is older than [`TIMELINE_TTL`] — so opening + /// the app paints from disk rather than waiting on a full `enumerate_timeline` + /// (which returns the whole timeline, not a page). Only an empty DB blocks on + /// the network, i.e. the very first run. + /// + /// Metadata only: a thumbnail path is attached for photos whose thumbnail is + /// already cached, and nothing is downloaded here — the front-end pulls the + /// thumbnails it actually paints via [`Core::photo_thumbs`]. fn photos_timeline( &self, offset: usize, limit: usize, ) -> Result>, String> { - let page: Vec = { - // Refresh the cached timeline if absent or stale. The lock is only - // held for the freshness check and the store, never across the fetch. - let stale = { - let guard = self.timeline.lock().unwrap(); - guard - .as_ref() - .is_none_or(|c| c.fetched.elapsed() >= TIMELINE_TTL) - }; - if stale { - let photos = self.photos(); - if self - .rt - .block_on(photos.get_photos_root()) - .map_err(|e| format!("photos root: {e}"))? - .is_none() - { - return Ok(None); - } - let items = self - .rt - .block_on(photos.enumerate_timeline()) - .map_err(|e| format!("timeline: {e}"))?; - *self.timeline.lock().unwrap() = Some(TimelineCache { - items, - fetched: Instant::now(), - }); + let count = self.db.photos_count().map_err(|e| e.to_string())?; + if count == 0 { + // Nothing to serve, so this one request has to wait for the fetch — + // unless we already know the account has no photos volume and the + // answer is a fresh "no". + let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0); + if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) { + return Ok(None); } - let guard = self.timeline.lock().unwrap(); - match guard.as_ref() { - Some(c) => c.items.iter().skip(offset).take(limit).cloned().collect(), - None => return Ok(None), + if !self.rt.block_on(self.refresh_timeline())? { + return Ok(None); } - }; - let ttype = ThumbnailType::Thumbnail.as_i32(); + } else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) { + self.spawn_timeline_refresh(); + } + + let page = self + .db + .photos_page(offset, limit) + .map_err(|e| e.to_string())?; Ok(Some( - page.into_iter() - .map(|it| { - let thumb_path = self - .cache - .cached_thumbnail_path(&it.uid, ttype, it.capture_time) - .map(|p| p.display().to_string()); - PhotoItem { - uid: it.uid.to_string(), - capture_time: it.capture_time, - thumb_path, - } - }) - .collect(), + page.into_iter().map(|p| self.photo_item(p)).collect(), )) } - /// Thumbnails for `uids`, served from the cache and downloaded (in one - /// batched SDK call) for whatever is missing. Requested on demand as tiles - /// scroll into view, so a cold timeline paints immediately and only the - /// photos actually on screen cost a round-trip. + /// Project a persisted photo into the wire item the front-end paints: its + /// learned aspect ratio, its thumbnail verdict, and the on-disk path of its + /// thumbnail when one is cached (tagged with the capture time, which is the + /// only revision handle a photo carries). + fn photo_item(&self, photo: StoredPhoto) -> PhotoItem { + let thumb_path = parse_uid(&photo.uid).and_then(|uid| { + self.cache + .cached_thumbnail_path(&uid, ThumbnailType::Thumbnail.as_i32(), photo.capture_time) + .map(|p| p.display().to_string()) + }); + PhotoItem { + uid: photo.uid, + capture_time: photo.capture_time, + thumb_path, + name: photo.name, + ratio: photo.ratio, + no_thumb: photo.thumb_state == db::THUMB_NONE, + } + } + + /// Thumbnails for `uids`, served from the cache, fetched from the server for + /// whatever is missing, and — for the photos the server has no thumbnail for + /// at all — *generated locally* from the full file (see + /// [`Core::generate_thumbs`]). Requested on demand as tiles scroll into view, + /// so a cold timeline paints immediately and only the photos actually on + /// screen cost a round-trip. /// - /// The cache tags a thumbnail with its photo's capture time, which is what - /// the timeline carries — a uid absent from the cached timeline therefore - /// can't be validated and is skipped rather than guessed at. + /// A photo absent from the persisted timeline is skipped: its capture time is + /// the cache's validity tag, and guessing that would poison the cache. fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec { let ttype = ThumbnailType::Thumbnail.as_i32(); - let tags: HashMap = { - let guard = self.timeline.lock().unwrap(); - match guard.as_ref() { - Some(c) => c - .items - .iter() - .map(|it| (it.uid.clone(), it.capture_time)) - .collect(), - None => HashMap::new(), - } - }; + let keys: Vec = uids.iter().map(|u| u.to_string()).collect(); + let stored = self.db.photos_by_uid(&keys).unwrap_or_default(); + let tags: HashMap = stored + .iter() + .map(|p| (p.uid.clone(), p.capture_time)) + .collect(); + // Ask the server only for photos that are missing a cached thumbnail and + // haven't already been written off as un-thumbnailable. let want: Vec = uids .iter() .filter(|uid| { - tags.get(*uid) - .is_some_and(|&tag| self.cache.cached_thumbnail_path(uid, ttype, tag).is_none()) + let key = uid.to_string(); + stored + .iter() + .find(|p| p.uid == key) + .is_some_and(|p| p.thumb_state != db::THUMB_NONE) + && tags.get(&key).is_some_and(|&tag| { + self.cache.cached_thumbnail_path(uid, ttype, tag).is_none() + }) }) .cloned() .collect(); + if !want.is_empty() { + let mut missing = want.clone(); match self.rt.block_on( self.photos() .enumerate_thumbnails(&want, ThumbnailType::Thumbnail), ) { Ok(thumbs) => { for ft in thumbs { - if let (Ok(bytes), Some(&tag)) = (ft.result, tags.get(&ft.file_uid)) { - let _ = self.cache.store_thumbnail(&ft.file_uid, ttype, tag, &bytes); + let Some(&tag) = tags.get(&ft.file_uid.to_string()) else { + continue; + }; + let Ok(bytes) = ft.result else { continue }; + if bytes.is_empty() { + continue; + } + if self + .cache + .store_thumbnail(&ft.file_uid, ttype, tag, &bytes) + .is_ok() + { + missing.retain(|uid| uid != &ft.file_uid); + self.record_thumb(&ft.file_uid, db::THUMB_HAVE, ratio_of(&bytes)); } } } + // A failed batch is not a verdict: leave every uid in `missing` so + // the local fallback still gives those tiles an image. Err(e) => warn!(error = %e, "batch photo thumbnails failed"), } + + // Whatever the server had nothing for gets a thumbnail made from its + // own bytes — this is what fills in camera photos uploaded by clients + // that never generated one. Off the request path: a full-size photo + // takes far longer to fetch than the whole rest of the batch, and the + // thumbnails that *are* ready must not wait behind it. + if !missing.is_empty() { + self.spawn_generate_thumbs(missing, &tags); + } } + let pending = self.thumb_gen.lock().unwrap(); uids.iter() .map(|uid| PhotoThumb { uid: uid.to_string(), - path: tags.get(uid).and_then(|&tag| { + path: tags.get(&uid.to_string()).and_then(|&tag| { self.cache .cached_thumbnail_path(uid, ttype, tag) .map(|p| p.display().to_string()) }), + pending: pending.contains(uid), }) .collect() } + /// Generate the missing thumbnails on the runtime, skipping any photo already + /// being generated. The uids are marked in-flight before the task starts, so + /// the reply this call is about to send already reports them as pending. + fn spawn_generate_thumbs(&self, uids: Vec, tags: &HashMap) { + let fresh: Vec = { + let mut inflight = self.thumb_gen.lock().unwrap(); + uids.into_iter() + .filter(|uid| inflight.insert(uid.clone())) + .collect() + }; + if fresh.is_empty() { + return; + } + + let core = self.clone(); + let tags = tags.clone(); + // `generate_thumbs` blocks on the runtime itself, so it belongs on the + // blocking pool rather than on an async worker. + self.rt.spawn_blocking(move || { + core.generate_thumbs(&fresh, &tags); + let mut inflight = core.thumb_gen.lock().unwrap(); + for uid in &fresh { + inflight.remove(uid); + } + }); + } + + /// Make thumbnails for photos the server has none for: download each full + /// file once, scale it to [`THUMB_EDGE`], and store the result in the thumbnail + /// cache exactly as if the server had served it. + /// + /// Bounded by [`THUMB_GEN_CONCURRENCY`] — these are full-size originals, and a + /// screenful of them at once would saturate the link. A photo whose bytes + /// can't be decoded (a codec `image` doesn't speak) is marked + /// [`db::THUMB_NONE`] and never attempted again. + fn generate_thumbs(&self, uids: &[NodeUid], tags: &HashMap) { + info!( + count = uids.len(), + "generating thumbnails the server has none for" + ); + let results: Vec<(NodeUid, ThumbAttempt)> = self.rt.block_on(async { + let mut out = Vec::with_capacity(uids.len()); + for chunk in uids.chunks(THUMB_GEN_CONCURRENCY) { + let mut set = tokio::task::JoinSet::new(); + for uid in chunk { + let client = self.client.clone(); + let uid = uid.clone(); + set.spawn(async move { + let bytes = match client.download_file(&uid).await { + Ok(bytes) => bytes, + Err(e) => { + warn!(%uid, error = %e, "photo download for thumbnail failed"); + return (uid, ThumbAttempt::Unavailable); + } + }; + // Decoding + scaling a 20 MP JPEG is CPU-bound and would + // stall the runtime's worker; hand it to the blocking pool. + let made = tokio::task::spawn_blocking(move || scale_thumbnail(&bytes)) + .await + .unwrap_or(None); + match made { + Some(thumb) => (uid, ThumbAttempt::Made(thumb)), + None => (uid, ThumbAttempt::Undecodable), + } + }); + } + while let Some(joined) = set.join_next().await { + if let Ok(result) = joined { + out.push(result); + } + } + } + out + }); + + let ttype = ThumbnailType::Thumbnail.as_i32(); + for (uid, attempt) in results { + match attempt { + ThumbAttempt::Made(thumb) => { + let Some(&tag) = tags.get(&uid.to_string()) else { + continue; + }; + match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) { + Ok(()) => self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)), + Err(e) => warn!(%uid, error = %e, "storing generated thumbnail failed"), + } + } + // The photo's own bytes aren't an image we can read: no thumbnail + // will ever exist for it, so stop trying. + ThumbAttempt::Undecodable => self.record_thumb(&uid, db::THUMB_NONE, None), + // The download failed — a dropped connection, an expired link. That + // is not a verdict on the photo: leave its state alone so the next + // scroll past it tries again. + ThumbAttempt::Unavailable => {} + } + } + } + + /// Persist what a thumbnail attempt learned about a photo. + fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option) { + if let Err(e) = self.db.photo_set_thumb(&uid.to_string(), state, ratio) { + warn!(%uid, error = %e, "recording thumbnail state failed"); + } + } + + /// Whether the listing stamped under `key` is older than `ttl` (or was never + /// fetched). + fn listing_stale(&self, key: &str, ttl: Duration) -> bool { + match self.db.state_i64(key).ok().flatten() { + Some(ms) => now_ms().saturating_sub(ms) >= ttl.as_millis() as i64, + None => true, + } + } + + /// Re-fetch the whole photos timeline and persist it. Returns whether the + /// account has a photos volume at all. + async fn refresh_timeline(&self) -> Result { + let photos = self.photos(); + if photos + .get_photos_root() + .await + .map_err(|e| format!("photos root: {e}"))? + .is_none() + { + let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0); + let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms()); + return Ok(false); + } + let items = photos + .enumerate_timeline() + .await + .map_err(|e| format!("timeline: {e}"))?; + let rows: Vec<(String, i64, Option)> = items + .iter() + .map(|it| (it.uid.to_string(), it.capture_time, None)) + .collect(); + self.db.photos_replace(&rows).map_err(|e| e.to_string())?; + let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1); + let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms()); + Ok(true) + } + + /// Refresh the timeline off the request path, so a stale page is still served + /// at DB speed. At most one refresh runs at a time. + fn spawn_timeline_refresh(&self) { + if self.timeline_refreshing.swap(true, Ordering::SeqCst) { + return; + } + let core = self.clone(); + self.rt.spawn(async move { + if let Err(e) = core.refresh_timeline().await { + warn!(error = %e, "background timeline refresh failed"); + } + core.timeline_refreshing.store(false, Ordering::SeqCst); + }); + } + /// Download a photo's full content into the content cache, returning its /// on-disk path (served from cache when a fresh blob already exists). fn open_photo(&self, uid: &NodeUid) -> Result { @@ -1300,42 +1505,101 @@ impl Core { .unwrap_or_default(); self.cache.evict(&uid); self.invalidate_parent_listing(rel); + self.invalidate_trash(); Ok(name) } - /// List the account's trash. Trashed nodes are outside the mounted tree — the - /// FUSE side forgot them when they were trashed — so this always goes to the - /// server rather than to `State` or the DB, and each entry is identified by - /// its uid (its only remaining handle) with an empty path. + /// List the account's trash, from the DB. Trashed nodes are outside the + /// mounted tree — the FUSE side forgot them when they were trashed — so the + /// listing is persisted in its own table rather than derived from `State`, and + /// each entry is identified by its uid (its only remaining handle) with an + /// empty path. + /// + /// Stale-while-revalidate, like the photos timeline: a persisted listing comes + /// back at DB speed and is refreshed in the background past [`TRASH_TTL`]. + /// Our own trash mutations invalidate it outright (see + /// [`Core::invalidate_trash`]), so the TTL only covers changes made elsewhere. fn list_trash(&self) -> Result, String> { - let uids = self - .rt - .block_on(self.client.enumerate_trash_node_uids()) - .map_err(|e| format!("enumerate trash: {e}"))?; - if uids.is_empty() { - return Ok(Vec::new()); + let stale = self.listing_stale(TRASH_SYNCED_MS, TRASH_TTL); + // Never fetched: this request has to wait for it. + if self.db.state_i64(TRASH_SYNCED_MS).ok().flatten().is_none() { + self.rt.block_on(self.refresh_trash())?; + } else if stale { + self.spawn_trash_refresh(); } - let nodes = self - .rt - .block_on(self.client.enumerate_nodes(&uids)) - .map_err(|e| format!("enumerate nodes: {e}"))?; - Ok(nodes + + Ok(self + .db + .trash_list() + .map_err(|e| e.to_string())? .into_iter() - .map(|node| DirEntry { - name: node.name.clone(), - is_dir: node.is_folder(), - size: node_size(&node), - modified: node.modification_time, + .map(|item| DirEntry { + name: item.name, + is_dir: item.is_dir, + size: item.size.max(0) as u64, + modified: item.mtime, // A trashed node can't be pinned or served from the mount, so its // content is never current cache: report neither. pinned: false, cached: false, - uid: node.uid.to_string(), + uid: item.uid, path: String::new(), }) .collect()) } + /// Re-fetch the trash listing from the server and persist it. + async fn refresh_trash(&self) -> Result<(), String> { + let uids = self + .client + .enumerate_trash_node_uids() + .await + .map_err(|e| format!("enumerate trash: {e}"))?; + let nodes = if uids.is_empty() { + Vec::new() + } else { + self.client + .enumerate_nodes(&uids) + .await + .map_err(|e| format!("enumerate nodes: {e}"))? + }; + let items: Vec = nodes + .into_iter() + .map(|node| StoredTrash { + uid: node.uid.to_string(), + name: node.name.clone(), + is_dir: node.is_folder(), + size: node_size(&node) as i64, + mtime: node.modification_time, + }) + .collect(); + self.db.trash_replace(&items).map_err(|e| e.to_string())?; + let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms()); + Ok(()) + } + + /// Refresh the trash off the request path. At most one refresh at a time. + fn spawn_trash_refresh(&self) { + if self.trash_refreshing.swap(true, Ordering::SeqCst) { + return; + } + let core = self.clone(); + self.rt.spawn(async move { + if let Err(e) = core.refresh_trash().await { + warn!(error = %e, "background trash refresh failed"); + } + core.trash_refreshing.store(false, Ordering::SeqCst); + }); + } + + /// Drop the persisted trash listing's freshness stamp after we changed the + /// trash ourselves. The next Trash page then *waits* for a fresh listing + /// rather than painting one from before the mutation — the user just made the + /// change and is about to look straight at it. + fn invalidate_trash(&self) { + let _ = self.db.clear_state(TRASH_SYNCED_MS); + } + /// Parse wire uids (`volume~link`) into [`NodeUid`]s, rejecting the whole /// batch if any is malformed — a partial trash mutation is worse than none. fn parse_uids(uids: &[String]) -> Result, String> { @@ -1363,12 +1627,15 @@ impl Core { self.rt .block_on(self.client.restore_nodes(&parsed)) .map_err(|e| format!("restore: {e}"))?; - let mut st = self.state.lock().unwrap(); - for parent in parents { - if let Some(&ino) = st.by_uid.get(&parent) { - st.invalidate_listing(ino); + { + let mut st = self.state.lock().unwrap(); + for parent in parents { + if let Some(&ino) = st.by_uid.get(&parent) { + st.invalidate_listing(ino); + } } } + self.invalidate_trash(); Ok(parsed.len()) } @@ -1380,6 +1647,7 @@ impl Core { .block_on(self.client.delete_nodes(&parsed)) .map_err(|e| format!("delete: {e}"))?; self.drop_local(&parsed); + self.invalidate_trash(); Ok(parsed.len()) } @@ -1395,6 +1663,7 @@ impl Core { .block_on(self.client.empty_trash()) .map_err(|e| format!("empty trash: {e}"))?; self.drop_local(&uids); + self.invalidate_trash(); Ok(uids.len()) } @@ -1573,6 +1842,7 @@ impl ProtonFs { } self.core.state.lock().unwrap().forget(&uid); self.core.cache.evict(&uid); + self.core.invalidate_trash(); reply.ok(); } } @@ -1613,6 +1883,72 @@ fn node_size(node: &Node) -> u64 { } } +/// Wall clock in milliseconds since the epoch — the unit the persisted listings +/// stamp their freshness in. +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// The aspect ratio (w/h) of an encoded image, read from its header alone — no +/// pixels are decoded. `None` when the format is unknown or the header is torn. +fn ratio_of(bytes: &[u8]) -> Option { + let (width, height) = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok()? + .into_dimensions() + .ok()?; + (height > 0).then(|| f64::from(width) / f64::from(height)) +} + +/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was +/// made from (free at that point — the full image had to be decoded anyway). +struct GeneratedThumb { + bytes: Vec, + ratio: f64, +} + +/// How one attempt at generating a missing thumbnail ended. The distinction that +/// matters is *permanent* versus *transient*: only bytes we cannot decode prove +/// the photo will never have a thumbnail, and only that verdict is persisted. +enum ThumbAttempt { + Made(GeneratedThumb), + /// Decoded nothing — a format this build has no decoder for. Permanent. + Undecodable, + /// The photo couldn't be downloaded. Transient: try again next time. + Unavailable, +} + +/// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its +/// longest side, JPEG, aspect ratio preserved. `None` when the bytes aren't an +/// image this build can decode — the caller then writes the photo off as +/// un-thumbnailable. +/// +/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool. +fn scale_thumbnail(bytes: &[u8]) -> Option { + let image = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok()? + .decode() + .ok()?; + let (width, height) = (image.width(), image.height()); + if width == 0 || height == 0 { + return None; + } + let ratio = f64::from(width) / f64::from(height); + + // `thumbnail` fits the image *inside* the box, so the longest edge lands on + // THUMB_EDGE and the ratio is untouched. + let thumb = image.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgb8(); + let mut bytes = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, THUMB_QUALITY) + .encode_image(&thumb) + .ok()?; + Some(GeneratedThumb { bytes, ratio }) +} + /// Convert a Unix timestamp (seconds, possibly negative) to a `SystemTime`. fn unix_secs(secs: i64) -> SystemTime { if secs >= 0 { @@ -2670,7 +3006,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U false, )) { Ok(uid) => { - *core.timeline.lock().unwrap() = None; + // The photo we just uploaded belongs at the head of the + // timeline, and the GUI reloads the gallery the moment this + // reply lands — so refresh now rather than leaving it to a + // background pass that would land just after that reload. + if let Err(e) = core.rt.block_on(core.refresh_timeline()) { + warn!(error = %e, "timeline refresh after upload failed"); + } CtlResponse::Ok { message: format!("uploaded photo with uid {uid}"), } @@ -2929,7 +3271,9 @@ pub fn mount( })), cache: Arc::new(cache), db, - timeline: Arc::new(Mutex::new(None)), + timeline_refreshing: Arc::new(AtomicBool::new(false)), + trash_refreshing: Arc::new(AtomicBool::new(false)), + thumb_gen: Arc::new(Mutex::new(HashSet::new())), transfers: TransferRegistry::new(), indexing: Arc::new(AtomicBool::new(false)), }; @@ -3047,6 +3391,63 @@ pub fn mount( Ok(outcome) } +#[cfg(test)] +mod thumb_tests { + use super::{THUMB_EDGE, ratio_of, scale_thumbnail}; + + /// A `width`×`height` JPEG, standing in for a camera photo the server never + /// generated a thumbnail for. + fn jpeg(width: u32, height: u32) -> Vec { + let image = image::RgbImage::from_fn(width, height, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, 128]) + }); + let mut bytes = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut bytes) + .encode_image(&image) + .unwrap(); + bytes + } + + #[test] + fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() { + let photo = jpeg(4000, 3000); + let thumb = scale_thumbnail(&photo).expect("a JPEG scales"); + + let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes)) + .with_guessed_format() + .unwrap() + .into_dimensions() + .unwrap(); + assert_eq!(width, THUMB_EDGE, "the long edge lands on the target"); + assert_eq!(height, THUMB_EDGE * 3 / 4, "and nothing is cropped"); + assert!((thumb.ratio - 4.0 / 3.0).abs() < 1e-6); + assert!( + thumb.bytes.len() < photo.len(), + "a thumbnail that isn't smaller than its photo is no thumbnail" + ); + } + + #[test] + fn a_portrait_photo_fits_its_long_edge_too() { + let thumb = scale_thumbnail(&jpeg(1000, 2000)).expect("a JPEG scales"); + assert!(thumb.ratio < 1.0, "portrait stays portrait"); + assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true)); + } + + #[test] + fn undecodable_bytes_are_not_a_thumbnail() { + // What a photo in a format this build has no decoder for looks like: the + // caller writes it off as un-thumbnailable rather than retrying forever. + assert!(scale_thumbnail(b"not an image at all").is_none()); + assert!(ratio_of(b"not an image at all").is_none()); + } + + #[test] + fn ratio_is_read_from_the_header_alone() { + assert_eq!(ratio_of(&jpeg(300, 200)), Some(1.5)); + } +} + #[cfg(test)] mod tests { use super::Intervals; diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs index d22ac8a..b7a423d 100644 --- a/crates/pdfs-gui/src/app.rs +++ b/crates/pdfs-gui/src/app.rs @@ -60,11 +60,11 @@ const SEARCH_LIMIT: usize = 200; /// content width, so this is the *target* height a row lands near, not an exact /// tile size (see [`justify_rows`]). const TILE_MIN: i32 = 90; -const TILE_MAX: i32 = 300; -const TILE_DEFAULT: i32 = 180; +const TILE_MAX: i32 = 340; +const TILE_DEFAULT: i32 = 220; const TILE_STEP: i32 = 30; /// Gap between tiles, horizontally and vertically. -const TILE_GAP: i32 = 4; +const TILE_GAP: i32 = 6; /// Aspect ratio (w/h) assumed for a photo whose thumbnail hasn't been decoded /// yet, so a tile can be laid out before its image exists. Landscape 3:2, the /// commonest camera/phone ratio; once the thumbnail lands the real ratio @@ -82,6 +82,10 @@ const THUMB_BATCH: usize = 16; /// Idle pause before a thumbnail batch is sent, so a fast scroll coalesces into /// one request per settle instead of one per row that flickers past. const THUMB_DEBOUNCE: Duration = Duration::from_millis(60); +/// How long to wait before asking again for a thumbnail the daemon is generating +/// itself. That means downloading the photo's full file, so the wait is measured +/// in seconds, not milliseconds. +const THUMB_RETRY: Duration = Duration::from_secs(4); /// Decoded thumbnails held in memory. Each is a few hundred KiB of GPU texture; /// this caps the gallery's footprint while covering several screens of scroll. const TEXTURE_CACHE_MAX: usize = 600; @@ -457,11 +461,13 @@ fn load_proton_theme() { .viewer-spinner {{ color: white; }}\n\ .viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\ .viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\ - .gallery-day {{ font-size: 1.05rem; font-weight: 700; padding: 4px 2px; }}\n\ - .photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: alpha(currentColor, 0.08); transition: filter 150ms ease; }}\n\ - .photo-tile:hover {{ filter: brightness(1.08); }}\n\ + .gallery-day {{ font-size: 1.05rem; font-weight: 700; padding: 2px 2px 6px 2px; }}\n\ + .photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 10px; background: alpha(currentColor, 0.06); box-shadow: none; transition: filter 160ms ease, box-shadow 160ms ease; }}\n\ + .photo-tile:hover {{ filter: brightness(1.06); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }}\n\ .photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\ - .photo-missing {{ background: alpha(currentColor, 0.12); }}\n\ + .photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\ + .photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 160ms ease; }}\n\ + .photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\ .card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\ .card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\ .navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\ @@ -4134,9 +4140,14 @@ fn gallery_width(ui: &Rc) -> i32 { } } -/// One photo tile: a fixed-size button wrapping the thumbnail. A button (rather -/// than a bare picture) so the tile is focusable, keyboard-activatable and gets -/// hover feedback for free. +/// One photo tile: a fixed-size button wrapping the thumbnail, with the capture +/// time revealed on hover over a bottom scrim. A button (rather than a bare +/// picture) so the tile is focusable, keyboard-activatable and gets hover feedback +/// for free. +/// +/// The picture sits in an overlay over a placeholder, so a tile is never a hole: +/// until the thumbnail lands it shows a dim card, and a photo that can never have +/// one keeps an image glyph instead of an empty rectangle. fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { let picture = gtk4::Picture::builder() // The tile is exactly the thumbnail's own aspect ratio, so Cover scales @@ -4146,8 +4157,34 @@ fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { .can_shrink(true) .build(); + let placeholder = gtk4::Image::builder() + .icon_name("image-x-generic-symbolic") + .pixel_size(24) + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .build(); + placeholder.add_css_class("photo-placeholder"); + + // The capture time, on a gradient that only exists while the pointer is over + // the tile — legible over any photo, invisible the rest of the time. + let caption = gtk4::Label::builder() + // Fill horizontally so the scrim spans the tile; the text itself stays + // left-aligned inside it. + .halign(gtk4::Align::Fill) + .valign(gtk4::Align::End) + .xalign(0.0) + .label(short_capture_time(tile.photo.capture_time)) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + caption.add_css_class("photo-caption"); + + let overlay = gtk4::Overlay::new(); + overlay.set_child(Some(&placeholder)); + overlay.add_overlay(&picture); + overlay.add_overlay(&caption); + let button = gtk4::Button::builder() - .child(&picture) + .child(&overlay) .width_request(tile.width) .height_request(tile.height) .tooltip_text(format_capture_time(tile.photo.capture_time)) @@ -4176,10 +4213,10 @@ fn want_thumb(ui: &Rc, photo: &PhotoItem, picture: >k4::Picture) { picture.set_paintable(Some(texture)); return; } - // No thumbnail will ever come for this one: leave the tile as its placeholder - // (still clickable — the full photo may open fine). + // No thumbnail will ever come for this one — not from the server, and not + // from the daemon's own scaling of the file. The tile keeps its placeholder + // glyph, and stays clickable: the full photo may still open fine. if ui.photo_nothumb.borrow().contains(&photo.uid) { - picture.add_css_class("photo-missing"); return; } @@ -4203,6 +4240,25 @@ fn want_thumb(ui: &Rc, photo: &PhotoItem, picture: >k4::Picture) { } } +/// Come back for thumbnails the daemon is still generating — it is downloading +/// each photo's full file to scale it, which takes far longer than a batch. The +/// tiles keep their placeholder until then, and a tile that has scrolled away is +/// dropped by [`flush_thumbs`] like any other queued uid. +fn retry_pending_thumbs(ui: &Rc, uids: Vec) { + let ui = ui.clone(); + glib::timeout_add_local_once(THUMB_RETRY, move || { + { + let mut queue = ui.thumb_queue.borrow_mut(); + for uid in uids { + if !queue.contains(&uid) { + queue.push_back(uid); + } + } + } + schedule_thumbs(&ui); + }); +} + /// Ask the daemon for the queued thumbnails after a short pause, so a fast scroll /// coalesces into one batch per settle rather than one per row it flew past. fn schedule_thumbs(ui: &Rc) { @@ -4253,11 +4309,15 @@ fn flush_thumbs(ui: &Rc) { Ok(Ok(Response::Thumbs { items })) => { let mut decode = ui.decode_queue.borrow_mut(); let mut nothumb = ui.photo_nothumb.borrow_mut(); + let mut pending = Vec::new(); for item in items { match item.path { Some(path) => decode.push_back((item.uid, path)), - // The photo has no thumbnail at all (or its fetch failed): - // remember that, so scrolling past it doesn't re-ask. + // The daemon is making this one itself, from the photo's + // full file — that takes a download, so come back for it. + None if item.pending => pending.push(item.uid), + // No thumbnail exists and none can be made: remember that, + // so scrolling past the tile doesn't re-ask forever. None => { nothumb.insert(item.uid); } @@ -4265,6 +4325,9 @@ fn flush_thumbs(ui: &Rc) { } drop((decode, nothumb)); schedule_decode(&ui); + if !pending.is_empty() { + retry_pending_thumbs(&ui, pending); + } } // A thumbnail that doesn't arrive is not worth a toast — the tile just // stays a placeholder, and the next scroll past it tries again. @@ -4491,6 +4554,15 @@ fn format_capture_time(secs: i64) -> String { } } +/// The capture time as a tile caption: the clock time alone, since the day is +/// already the section heading right above it. +fn short_capture_time(secs: i64) -> String { + glib::DateTime::from_unix_local(secs) + .and_then(|d| d.format("%H:%M")) + .map(|s| s.to_string()) + .unwrap_or_default() +} + /// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and /// the button/key handlers so each one takes a single handle instead of a dozen /// widget arguments. @@ -5200,6 +5272,22 @@ fn load_gallery(ui: &Rc, append: bool) { ); return; } + // Take the daemon's word on ratios and thumbnail verdicts before + // anything is laid out: it persists both, so the very first frame + // justifies its rows correctly instead of guessing RATIO_UNKNOWN + // and reflowing as the images land. + { + let mut ratios = ui.photo_ratio.borrow_mut(); + let mut nothumb = ui.photo_nothumb.borrow_mut(); + for item in &items { + if let Some(ratio) = item.ratio { + ratios.insert(item.uid.clone(), ratio); + } + if item.no_thumb { + nothumb.insert(item.uid.clone()); + } + } + } for item in &items { ui.gallery_model.append(&BoxedAnyObject::new(item.clone())); } diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 0e9cde7..ff25d20 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -9,7 +9,7 @@ # Build + install: cd packaging && makepkg -fi pkgname=proton-drive-linux -pkgver=0.3.0 +pkgver=0.4.0 pkgrel=1 pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" arch=('x86_64')