added photos thumbnail caching + bigger photos

This commit is contained in:
nvrl committed 2026-07-15 01:00:43 +02:00
1 parent ae0d374ec0
commit cc744d56c7
8 files changed
+1616 -144

No files matched your search

Generated
+604
View File
@@ -74,6 +74,24 @@ dependencies = [
"memchr", "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]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -148,6 +166,23 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" 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]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@@ -161,6 +196,21 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -301,6 +351,49 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" 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]] [[package]]
name = "aws-lc-rs" name = "aws-lc-rs"
version = "1.17.0" version = "1.17.0"
@@ -354,6 +447,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]] [[package]]
name = "bitfields" name = "bitfields"
version = "1.0.3" version = "1.0.3"
@@ -387,6 +486,15 @@ version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" 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]] [[package]]
name = "bitvec" name = "bitvec"
version = "1.1.1" version = "1.1.1"
@@ -487,18 +595,36 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "built"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.12.0" version = "1.12.0"
@@ -728,6 +854,12 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -814,6 +946,15 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" 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]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.7" version = "0.8.7"
@@ -839,6 +980,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-bigint" name = "crypto-bigint"
version = "0.5.5" version = "0.5.5"
@@ -1230,6 +1377,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]] [[package]]
name = "elliptic-curve" name = "elliptic-curve"
version = "0.13.8" version = "0.13.8"
@@ -1282,6 +1435,26 @@ dependencies = [
"syn", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1319,6 +1492,23 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -1337,6 +1527,21 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" 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]] [[package]]
name = "ff" name = "ff"
version = "0.13.1" version = "0.13.1"
@@ -1376,6 +1581,7 @@ version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [ dependencies = [
"crc32fast",
"miniz_oxide", "miniz_oxide",
"zlib-rs", "zlib-rs",
] ]
@@ -1644,6 +1850,16 @@ dependencies = [
"polyval", "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]] [[package]]
name = "gio" name = "gio"
version = "0.20.12" version = "0.20.12"
@@ -1859,6 +2075,17 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -2210,6 +2437,44 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -2239,6 +2504,17 @@ dependencies = [
"hybrid-array", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -2251,6 +2527,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -2387,6 +2672,12 @@ dependencies = [
"spin", "spin",
] ]
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]] [[package]]
name = "libadwaita" name = "libadwaita"
version = "0.7.2" version = "0.7.2"
@@ -2439,6 +2730,16 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "libm" name = "libm"
version = "0.2.16" version = "0.2.16"
@@ -2492,6 +2793,15 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loop9"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
"imgref",
]
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.2"
@@ -2507,6 +2817,16 @@ dependencies = [
"regex-automata", "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]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.10.6"
@@ -2569,12 +2889,28 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "mutate_once" name = "mutate_once"
version = "0.1.2" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.29.0" version = "0.29.0"
@@ -2601,6 +2937,15 @@ dependencies = [
"memoffset", "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]] [[package]]
name = "nom" name = "nom"
version = "8.0.0" version = "8.0.0"
@@ -2610,6 +2955,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -2666,9 +3017,21 @@ version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [ dependencies = [
"bytemuck",
"num-traits", "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]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@@ -2896,6 +3259,18 @@ dependencies = [
"subtle", "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]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "0.4.0" version = "0.4.0"
@@ -2934,6 +3309,7 @@ name = "pdfs-fuse"
version = "0.4.0" version = "0.4.0"
dependencies = [ dependencies = [
"fuser", "fuser",
"image",
"libc", "libc",
"pdfs-core", "pdfs-core",
"proton-drive-rs", "proton-drive-rs",
@@ -3088,6 +3464,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" 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]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -3159,6 +3548,25 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.1.5" version = "0.1.5"
@@ -3215,6 +3623,41 @@ dependencies = [
"tracing", "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]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.11" version = "0.11.11"
@@ -3357,6 +3800,91 @@ dependencies = [
"getrandom 0.3.4", "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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -3472,6 +4000,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3935,6 +4469,15 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" 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]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -4151,6 +4694,20 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@@ -4485,6 +5042,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 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]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -4655,6 +5223,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -5100,6 +5674,12 @@ version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -5285,6 +5865,30 @@ 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 = "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]] [[package]]
name = "zvariant" name = "zvariant"
version = "4.2.0" version = "4.2.0"
+4
View File
@@ -27,6 +27,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5" directories = "5"
ignore = "0.4" ignore = "0.4"
sha2 = "0.10" 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"] } rusqlite = { version = "0.32", features = ["bundled"] }
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
+21 -2
View File
@@ -213,15 +213,34 @@ pub struct PhotoItem {
/// `None` here means "not fetched yet", not "has no thumbnail" — ask for it /// `None` here means "not fetched yet", not "has no thumbnail" — ask for it
/// with [`Request::PhotoThumbs`]. /// with [`Request::PhotoThumbs`].
pub thumb_path: Option<String>, pub thumb_path: Option<String>,
/// File name, when the daemon knows it.
pub name: Option<String>,
/// 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<f64>,
/// 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. /// One thumbnail in a [`Response::Thumbs`] batch.
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoThumb { pub struct PhotoThumb {
pub uid: String, pub uid: String,
/// On-disk path, or `None` when the photo genuinely has no thumbnail (or the /// On-disk path, or `None` when there is no thumbnail to serve *yet*.
/// fetch failed) — a front-end can then stop asking for it.
pub path: Option<String>, pub path: Option<String>,
/// 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`]. /// The daemon's reply to a [`Request`].
+356 -1
View File
@@ -10,6 +10,7 @@
//! applies the forward-only schema migrations. Write-through of nodes, the //! 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. //! event cursor, FTS, and the cache index land in later phases on this schema.
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
@@ -21,7 +22,16 @@ use crate::Result;
use crate::localindex::LocalEntry; use crate::localindex::LocalEntry;
/// Current schema version. Bump on every forward migration added below. /// 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 /// 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`. /// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
@@ -42,6 +52,31 @@ pub struct SearchHit {
pub path: String, 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<String>,
/// Aspect ratio (w/h), known once a thumbnail has been decoded.
pub ratio: Option<f64>,
/// 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. /// Handle to the unified metadata database.
/// ///
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through /// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
@@ -123,6 +158,9 @@ impl Db {
if current < 6 { if current < 6 {
tx.execute_batch(MIGRATION_V6)?; tx.execute_batch(MIGRATION_V6)?;
} }
if current < 7 {
tx.execute_batch(MIGRATION_V7)?;
}
tx.execute( tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value", ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -299,6 +337,189 @@ impl Db {
Ok(()) 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<Option<i64>> {
let conn = self.conn.lock().unwrap();
let v: Option<String> = 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<String>)]) -> Result<()> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
let learned: HashMap<String, (Option<f64>, 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::<rusqlite::Result<_>>()?
};
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<Vec<StoredPhoto>> {
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<Vec<StoredPhoto>> {
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<f64>) -> 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<usize> {
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<Vec<StoredTrash>> {
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 /// Children of `parent`, for the offline `ensure_children` fast path when a
/// listed folder's `children` map entry was trimmed mid-run. Returns `None` /// listed folder's `children` map entry was trimmed mid-run. Returns `None`
/// when the folder is not marked `listed` (listing unknown, must re-fetch). /// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -879,6 +1129,111 @@ mod tests {
serde_json::from_value(v).unwrap() 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::<Vec<_>>(),
["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::<Vec<_>>(),
["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::<Vec<_>>(),
["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 { fn folder(link: &str, parent: Option<&str>, name: &str) -> Node {
let parent = match parent { let parent = match parent {
Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}), Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}),
+1
View File
@@ -15,3 +15,4 @@ tracing.workspace = true
fuser.workspace = true fuser.workspace = true
libc.workspace = true libc.workspace = true
serde_json.workspace = true serde_json.workspace = true
image.workspace = true
+525 -124
View File
@@ -31,7 +31,7 @@
//! set, so a multi-GiB write never buffers in RAM and only the untouched //! 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. //! 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::ffi::OsStr;
use std::fs::File; use std::fs::File;
use std::io::{BufRead, BufReader, Write as _}; 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, DirEntry, LocalHit, PhotoItem, PhotoThumb, Request as CtlRequest, Response as CtlResponse,
SearchHit, TransferDirection, SearchHit, TransferDirection,
}; };
use pdfs_core::db::{Db, StoredNode}; use pdfs_core::db::{self, Db, StoredNode, StoredPhoto, StoredTrash};
use pdfs_core::localindex; use pdfs_core::localindex;
use proton_drive_rs::proton_sdk::error::ProtonError; use proton_drive_rs::proton_sdk::error::ProtonError;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
use proton_drive_rs::{ use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeKind, PhotosTimelineItem, ProtonDriveClient, DriveEvent, DriveEventScopeId, Node, NodeKind, ProtonDriveClient, ProtonPhotosClient,
ProtonPhotosClient, ThumbnailType, ThumbnailType,
}; };
mod transfers; mod transfers;
@@ -75,10 +75,33 @@ const TTL: Duration = Duration::from_secs(30);
/// How often the background task polls the remote event cursor. /// How often the background task polls the remote event cursor.
const POLL_INTERVAL: Duration = Duration::from_secs(10); const POLL_INTERVAL: Duration = Duration::from_secs(10);
/// How long a fetched photos timeline stays good before the next page request /// How long the persisted photos timeline stays good before a page request
/// re-fetches it. The SDK hands back the whole timeline at once, so we cache it /// revalidates it. The SDK hands back the whole timeline at once, so it is stored
/// in [`Core`] and serve every page from memory until it goes stale. /// in the DB and every page is sliced from there; a stale one is still served
const TIMELINE_TTL: Duration = Duration::from_secs(60); /// 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 /// 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 /// 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 /// `State` maps. Every mutation writes through here, and the maps rehydrate
/// from it on mount (plan.md P1). /// from it on mount (plan.md P1).
db: Arc<Db>, db: Arc<Db>,
/// Cached full photos timeline (newest first), so paged `PhotosTimeline` /// True while a background refresh of the photos timeline (resp. the trash) is
/// requests slice memory instead of re-fetching the whole timeline per page. /// already running, so a burst of page requests against a stale listing kicks
/// `None` until first fetched; refreshed once older than [`TIMELINE_TTL`]. /// off one refresh rather than one per request.
timeline: Arc<Mutex<Option<TimelineCache>>>, timeline_refreshing: Arc<AtomicBool>,
trash_refreshing: Arc<AtomicBool>,
/// 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<Mutex<HashSet<NodeUid>>>,
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared /// In-flight upload/download progress, served to `GetQueueStatus`. Shared
/// across the FUSE session and the control-socket task. /// across the FUSE session and the control-socket task.
transfers: Arc<TransferRegistry>, transfers: Arc<TransferRegistry>,
@@ -322,12 +351,6 @@ struct Core {
indexing: Arc<AtomicBool>, indexing: Arc<AtomicBool>,
} }
/// The whole photos timeline plus when it was fetched, for TTL freshness.
struct TimelineCache {
items: Vec<PhotosTimelineItem>,
fetched: Instant,
}
impl Core { impl Core {
/// Rehydrate the in-memory `State` maps from the DB on mount, so a cold /// Rehydrate the in-memory `State` maps from the DB on mount, so a cold
/// start serves previously-seen metadata (stable inodes, instant listings) /// start serves previously-seen metadata (stable inodes, instant listings)
@@ -1047,132 +1070,314 @@ impl Core {
.collect()) .collect())
} }
/// A page of the photos timeline (newest first). Metadata only: a thumbnail /// A page of the photos timeline (newest first), sliced out of the DB.
/// path is attached for photos whose thumbnail is *already* cached, and no /// `Ok(None)` when the account has no photos volume.
/// 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.
/// ///
/// The SDK's `enumerate_timeline` returns the *whole* timeline, so the first /// Stale-while-revalidate: a persisted timeline is served *immediately*, and
/// request (or the first after [`TIMELINE_TTL`] elapses) fetches and caches /// refreshed on the runtime if it is older than [`TIMELINE_TTL`] — so opening
/// it in [`Core::timeline`]; every page then slices that cached vec, so /// the app paints from disk rather than waiting on a full `enumerate_timeline`
/// "load more" scrolling doesn't re-hit the network per page. /// (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( fn photos_timeline(
&self, &self,
offset: usize, offset: usize,
limit: usize, limit: usize,
) -> Result<Option<Vec<PhotoItem>>, String> { ) -> Result<Option<Vec<PhotoItem>>, String> {
let page: Vec<PhotosTimelineItem> = { let count = self.db.photos_count().map_err(|e| e.to_string())?;
// Refresh the cached timeline if absent or stale. The lock is only if count == 0 {
// held for the freshness check and the store, never across the fetch. // Nothing to serve, so this one request has to wait for the fetch —
let stale = { // unless we already know the account has no photos volume and the
let guard = self.timeline.lock().unwrap(); // answer is a fresh "no".
guard let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
.as_ref() if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
.is_none_or(|c| c.fetched.elapsed() >= TIMELINE_TTL) return Ok(None);
};
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 guard = self.timeline.lock().unwrap(); if !self.rt.block_on(self.refresh_timeline())? {
match guard.as_ref() { return Ok(None);
Some(c) => c.items.iter().skip(offset).take(limit).cloned().collect(),
None => return Ok(None),
} }
}; } else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
let ttype = ThumbnailType::Thumbnail.as_i32(); self.spawn_timeline_refresh();
}
let page = self
.db
.photos_page(offset, limit)
.map_err(|e| e.to_string())?;
Ok(Some( Ok(Some(
page.into_iter() page.into_iter().map(|p| self.photo_item(p)).collect(),
.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(),
)) ))
} }
/// Thumbnails for `uids`, served from the cache and downloaded (in one /// Project a persisted photo into the wire item the front-end paints: its
/// batched SDK call) for whatever is missing. Requested on demand as tiles /// learned aspect ratio, its thumbnail verdict, and the on-disk path of its
/// scroll into view, so a cold timeline paints immediately and only the /// thumbnail when one is cached (tagged with the capture time, which is the
/// photos actually on screen cost a round-trip. /// 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 /// A photo absent from the persisted timeline is skipped: its capture time is
/// the timeline carries — a uid absent from the cached timeline therefore /// the cache's validity tag, and guessing that would poison the cache.
/// can't be validated and is skipped rather than guessed at.
fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> { fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
let ttype = ThumbnailType::Thumbnail.as_i32(); let ttype = ThumbnailType::Thumbnail.as_i32();
let tags: HashMap<NodeUid, i64> = { let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
let guard = self.timeline.lock().unwrap(); let stored = self.db.photos_by_uid(&keys).unwrap_or_default();
match guard.as_ref() { let tags: HashMap<String, i64> = stored
Some(c) => c .iter()
.items .map(|p| (p.uid.clone(), p.capture_time))
.iter() .collect();
.map(|it| (it.uid.clone(), it.capture_time))
.collect(),
None => HashMap::new(),
}
};
// 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<NodeUid> = uids let want: Vec<NodeUid> = uids
.iter() .iter()
.filter(|uid| { .filter(|uid| {
tags.get(*uid) let key = uid.to_string();
.is_some_and(|&tag| self.cache.cached_thumbnail_path(uid, ttype, tag).is_none()) 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() .cloned()
.collect(); .collect();
if !want.is_empty() { if !want.is_empty() {
let mut missing = want.clone();
match self.rt.block_on( match self.rt.block_on(
self.photos() self.photos()
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail), .enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
) { ) {
Ok(thumbs) => { Ok(thumbs) => {
for ft in thumbs { for ft in thumbs {
if let (Ok(bytes), Some(&tag)) = (ft.result, tags.get(&ft.file_uid)) { let Some(&tag) = tags.get(&ft.file_uid.to_string()) else {
let _ = self.cache.store_thumbnail(&ft.file_uid, ttype, tag, &bytes); 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"), 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() uids.iter()
.map(|uid| PhotoThumb { .map(|uid| PhotoThumb {
uid: uid.to_string(), uid: uid.to_string(),
path: tags.get(uid).and_then(|&tag| { path: tags.get(&uid.to_string()).and_then(|&tag| {
self.cache self.cache
.cached_thumbnail_path(uid, ttype, tag) .cached_thumbnail_path(uid, ttype, tag)
.map(|p| p.display().to_string()) .map(|p| p.display().to_string())
}), }),
pending: pending.contains(uid),
}) })
.collect() .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<NodeUid>, tags: &HashMap<String, i64>) {
let fresh: Vec<NodeUid> = {
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<String, i64>) {
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<f64>) {
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<bool, String> {
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<String>)> = 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 /// Download a photo's full content into the content cache, returning its
/// on-disk path (served from cache when a fresh blob already exists). /// on-disk path (served from cache when a fresh blob already exists).
fn open_photo(&self, uid: &NodeUid) -> Result<PathBuf, String> { fn open_photo(&self, uid: &NodeUid) -> Result<PathBuf, String> {
@@ -1300,42 +1505,101 @@ impl Core {
.unwrap_or_default(); .unwrap_or_default();
self.cache.evict(&uid); self.cache.evict(&uid);
self.invalidate_parent_listing(rel); self.invalidate_parent_listing(rel);
self.invalidate_trash();
Ok(name) Ok(name)
} }
/// List the account's trash. Trashed nodes are outside the mounted tree — the /// List the account's trash, from the DB. Trashed nodes are outside the
/// FUSE side forgot them when they were trashed — so this always goes to the /// mounted tree — the FUSE side forgot them when they were trashed — so the
/// server rather than to `State` or the DB, and each entry is identified by /// listing is persisted in its own table rather than derived from `State`, and
/// its uid (its only remaining handle) with an empty path. /// 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<Vec<DirEntry>, String> { fn list_trash(&self) -> Result<Vec<DirEntry>, String> {
let uids = self let stale = self.listing_stale(TRASH_SYNCED_MS, TRASH_TTL);
.rt // Never fetched: this request has to wait for it.
.block_on(self.client.enumerate_trash_node_uids()) if self.db.state_i64(TRASH_SYNCED_MS).ok().flatten().is_none() {
.map_err(|e| format!("enumerate trash: {e}"))?; self.rt.block_on(self.refresh_trash())?;
if uids.is_empty() { } else if stale {
return Ok(Vec::new()); self.spawn_trash_refresh();
} }
let nodes = self
.rt Ok(self
.block_on(self.client.enumerate_nodes(&uids)) .db
.map_err(|e| format!("enumerate nodes: {e}"))?; .trash_list()
Ok(nodes .map_err(|e| e.to_string())?
.into_iter() .into_iter()
.map(|node| DirEntry { .map(|item| DirEntry {
name: node.name.clone(), name: item.name,
is_dir: node.is_folder(), is_dir: item.is_dir,
size: node_size(&node), size: item.size.max(0) as u64,
modified: node.modification_time, modified: item.mtime,
// A trashed node can't be pinned or served from the mount, so its // A trashed node can't be pinned or served from the mount, so its
// content is never current cache: report neither. // content is never current cache: report neither.
pinned: false, pinned: false,
cached: false, cached: false,
uid: node.uid.to_string(), uid: item.uid,
path: String::new(), path: String::new(),
}) })
.collect()) .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<StoredTrash> = 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 /// Parse wire uids (`volume~link`) into [`NodeUid`]s, rejecting the whole
/// batch if any is malformed — a partial trash mutation is worse than none. /// batch if any is malformed — a partial trash mutation is worse than none.
fn parse_uids(uids: &[String]) -> Result<Vec<NodeUid>, String> { fn parse_uids(uids: &[String]) -> Result<Vec<NodeUid>, String> {
@@ -1363,12 +1627,15 @@ impl Core {
self.rt self.rt
.block_on(self.client.restore_nodes(&parsed)) .block_on(self.client.restore_nodes(&parsed))
.map_err(|e| format!("restore: {e}"))?; .map_err(|e| format!("restore: {e}"))?;
let mut st = self.state.lock().unwrap(); {
for parent in parents { let mut st = self.state.lock().unwrap();
if let Some(&ino) = st.by_uid.get(&parent) { for parent in parents {
st.invalidate_listing(ino); if let Some(&ino) = st.by_uid.get(&parent) {
st.invalidate_listing(ino);
}
} }
} }
self.invalidate_trash();
Ok(parsed.len()) Ok(parsed.len())
} }
@@ -1380,6 +1647,7 @@ impl Core {
.block_on(self.client.delete_nodes(&parsed)) .block_on(self.client.delete_nodes(&parsed))
.map_err(|e| format!("delete: {e}"))?; .map_err(|e| format!("delete: {e}"))?;
self.drop_local(&parsed); self.drop_local(&parsed);
self.invalidate_trash();
Ok(parsed.len()) Ok(parsed.len())
} }
@@ -1395,6 +1663,7 @@ impl Core {
.block_on(self.client.empty_trash()) .block_on(self.client.empty_trash())
.map_err(|e| format!("empty trash: {e}"))?; .map_err(|e| format!("empty trash: {e}"))?;
self.drop_local(&uids); self.drop_local(&uids);
self.invalidate_trash();
Ok(uids.len()) Ok(uids.len())
} }
@@ -1573,6 +1842,7 @@ impl ProtonFs {
} }
self.core.state.lock().unwrap().forget(&uid); self.core.state.lock().unwrap().forget(&uid);
self.core.cache.evict(&uid); self.core.cache.evict(&uid);
self.core.invalidate_trash();
reply.ok(); 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<f64> {
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<u8>,
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<GeneratedThumb> {
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`. /// Convert a Unix timestamp (seconds, possibly negative) to a `SystemTime`.
fn unix_secs(secs: i64) -> SystemTime { fn unix_secs(secs: i64) -> SystemTime {
if secs >= 0 { if secs >= 0 {
@@ -2670,7 +3006,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
false, false,
)) { )) {
Ok(uid) => { 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 { CtlResponse::Ok {
message: format!("uploaded photo with uid {uid}"), message: format!("uploaded photo with uid {uid}"),
} }
@@ -2929,7 +3271,9 @@ pub fn mount(
})), })),
cache: Arc::new(cache), cache: Arc::new(cache),
db, 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(), transfers: TransferRegistry::new(),
indexing: Arc::new(AtomicBool::new(false)), indexing: Arc::new(AtomicBool::new(false)),
}; };
@@ -3047,6 +3391,63 @@ pub fn mount(
Ok(outcome) 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<u8> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::Intervals; use super::Intervals;
+104 -16
View File
@@ -60,11 +60,11 @@ const SEARCH_LIMIT: usize = 200;
/// content width, so this is the *target* height a row lands near, not an exact /// content width, so this is the *target* height a row lands near, not an exact
/// tile size (see [`justify_rows`]). /// tile size (see [`justify_rows`]).
const TILE_MIN: i32 = 90; const TILE_MIN: i32 = 90;
const TILE_MAX: i32 = 300; const TILE_MAX: i32 = 340;
const TILE_DEFAULT: i32 = 180; const TILE_DEFAULT: i32 = 220;
const TILE_STEP: i32 = 30; const TILE_STEP: i32 = 30;
/// Gap between tiles, horizontally and vertically. /// 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 /// 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 /// 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 /// 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 /// 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. /// one request per settle instead of one per row that flickers past.
const THUMB_DEBOUNCE: Duration = Duration::from_millis(60); 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; /// 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. /// this caps the gallery's footprint while covering several screens of scroll.
const TEXTURE_CACHE_MAX: usize = 600; const TEXTURE_CACHE_MAX: usize = 600;
@@ -457,11 +461,13 @@ fn load_proton_theme() {
.viewer-spinner {{ color: white; }}\n\ .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-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\ .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\ .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: 6px; background: alpha(currentColor, 0.08); transition: filter 150ms ease; }}\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.08); }}\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-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 {{ 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\ .card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\ .navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
@@ -4134,9 +4140,14 @@ fn gallery_width(ui: &Rc<Ui>) -> i32 {
} }
} }
/// One photo tile: a fixed-size button wrapping the thumbnail. A button (rather /// One photo tile: a fixed-size button wrapping the thumbnail, with the capture
/// than a bare picture) so the tile is focusable, keyboard-activatable and gets /// time revealed on hover over a bottom scrim. A button (rather than a bare
/// hover feedback for free. /// 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<Ui>, tile: Tile) -> gtk4::Button { fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
let picture = gtk4::Picture::builder() let picture = gtk4::Picture::builder()
// The tile is exactly the thumbnail's own aspect ratio, so Cover scales // The tile is exactly the thumbnail's own aspect ratio, so Cover scales
@@ -4146,8 +4157,34 @@ fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
.can_shrink(true) .can_shrink(true)
.build(); .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() let button = gtk4::Button::builder()
.child(&picture) .child(&overlay)
.width_request(tile.width) .width_request(tile.width)
.height_request(tile.height) .height_request(tile.height)
.tooltip_text(format_capture_time(tile.photo.capture_time)) .tooltip_text(format_capture_time(tile.photo.capture_time))
@@ -4176,10 +4213,10 @@ fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::Picture) {
picture.set_paintable(Some(texture)); picture.set_paintable(Some(texture));
return; return;
} }
// No thumbnail will ever come for this one: leave the tile as its placeholder // No thumbnail will ever come for this one — not from the server, and not
// (still clickable — the full photo may open fine). // 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) { if ui.photo_nothumb.borrow().contains(&photo.uid) {
picture.add_css_class("photo-missing");
return; return;
} }
@@ -4203,6 +4240,25 @@ fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::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<Ui>, uids: Vec<String>) {
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 /// 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. /// coalesces into one batch per settle rather than one per row it flew past.
fn schedule_thumbs(ui: &Rc<Ui>) { fn schedule_thumbs(ui: &Rc<Ui>) {
@@ -4253,11 +4309,15 @@ fn flush_thumbs(ui: &Rc<Ui>) {
Ok(Ok(Response::Thumbs { items })) => { Ok(Ok(Response::Thumbs { items })) => {
let mut decode = ui.decode_queue.borrow_mut(); let mut decode = ui.decode_queue.borrow_mut();
let mut nothumb = ui.photo_nothumb.borrow_mut(); let mut nothumb = ui.photo_nothumb.borrow_mut();
let mut pending = Vec::new();
for item in items { for item in items {
match item.path { match item.path {
Some(path) => decode.push_back((item.uid, path)), Some(path) => decode.push_back((item.uid, path)),
// The photo has no thumbnail at all (or its fetch failed): // The daemon is making this one itself, from the photo's
// remember that, so scrolling past it doesn't re-ask. // 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 => { None => {
nothumb.insert(item.uid); nothumb.insert(item.uid);
} }
@@ -4265,6 +4325,9 @@ fn flush_thumbs(ui: &Rc<Ui>) {
} }
drop((decode, nothumb)); drop((decode, nothumb));
schedule_decode(&ui); 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 // 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. // 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 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 /// the button/key handlers so each one takes a single handle instead of a dozen
/// widget arguments. /// widget arguments.
@@ -5200,6 +5272,22 @@ fn load_gallery(ui: &Rc<Ui>, append: bool) {
); );
return; 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 { for item in &items {
ui.gallery_model.append(&BoxedAnyObject::new(item.clone())); ui.gallery_model.append(&BoxedAnyObject::new(item.clone()));
} }
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=0.3.0 pkgver=0.4.0
pkgrel=1 pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')