diff --git a/Cargo.lock b/Cargo.lock index 3e405a7..bc982fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3372,7 +3372,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "pdfs-cli" -version = "1.10.2" +version = "1.11.0" dependencies = [ "anyhow", "clap", @@ -3387,7 +3387,7 @@ dependencies = [ [[package]] name = "pdfs-core" -version = "1.10.2" +version = "1.11.0" dependencies = [ "async-trait", "dbus-secret-service-keyring-store", @@ -3411,7 +3411,7 @@ dependencies = [ [[package]] name = "pdfs-fuse" -version = "1.10.2" +version = "1.11.0" dependencies = [ "base64 0.23.1", "fuser", @@ -3431,7 +3431,7 @@ dependencies = [ [[package]] name = "pdfs-gui" -version = "1.10.2" +version = "1.11.0" dependencies = [ "anyhow", "async-channel", diff --git a/Cargo.toml b/Cargo.toml index a37dc30..63c95f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] [workspace.package] -version = "1.10.2" +version = "1.11.0" edition = "2024" license = "MIT" authors = ["Nils Pukropp "] diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 7f70509..226329c 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -2277,14 +2277,43 @@ fn cmd_refresh(target: Option) -> Result<()> { path: path.unwrap_or("").to_string(), }, }; + let photos = scope == RefreshScope::Photos; match control_request(CtlRequest::Refresh { scope })? { CtlResponse::Ok { message } => println!("{message}"), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } + if photos { + wait_for_photos_refresh()?; + } Ok(()) } +/// How often [`wait_for_photos_refresh`] asks the daemon whether the timeline +/// refresh it started is done. +const PHOTOS_REFRESH_POLL: std::time::Duration = std::time::Duration::from_secs(2); + +/// Block until the daemon's timeline refresh has finished. +/// +/// The daemon answers `Refresh { Photos }` as soon as the refresh is running, +/// because re-reading a large library takes minutes and a control reply may not. +/// `pdfs refresh photos` means "the timeline is up to date when I return", so +/// the waiting happens here, where no socket read bound applies. +fn wait_for_photos_refresh() -> Result<()> { + loop { + std::thread::sleep(PHOTOS_REFRESH_POLL); + match control_request(CtlRequest::PhotosRefreshStatus)? { + CtlResponse::PhotosRefresh { running: true } => continue, + CtlResponse::PhotosRefresh { running: false } => { + println!("refreshed"); + return Ok(()); + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + } +} + /// Ask for a typed `yes` before an irreversible destroy. Errors (aborting the /// command) on anything else, including a non-interactive stdin: a piped or /// scripted invocation must not silently wipe the trash. diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 1e5c041..db60988 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -135,6 +135,12 @@ pub enum Request { CancelThumbnailBuild, /// Read progress for the recursive ordinary-file thumbnail build. ThumbnailBuildStatus, + /// Whether a photos-timeline refresh is running right now. Replies with + /// [`Response::PhotosRefresh`]. + /// + /// [`RefreshScope::Photos`] starts the refresh and returns at once, so this + /// is how a front-end knows when the refreshed timeline is worth re-reading. + PhotosRefreshStatus, /// Download a photo's full content into the cache; replies with its path. OpenPhoto { uid: String }, /// Add or remove Proton's `Favorite` tag on a photo. Replies with @@ -582,7 +588,10 @@ pub enum RefreshScope { Dir { path: String }, /// The trash listing. Trash, - /// The photos timeline. + /// The photos timeline. The reply comes back as soon as the refresh has + /// *started*, not when it has finished: re-reading a large library takes + /// minutes, which is far longer than a control reply may take. Poll + /// [`Request::PhotosRefreshStatus`] to know when the new timeline is there. Photos, } @@ -1636,6 +1645,9 @@ pub enum Response { FileThumbGeneration { generation: u64 }, /// Current recursive thumbnail-build progress. ThumbnailBuild { status: ThumbnailBuildStatus }, + /// Whether a photos-timeline refresh is still running (reply to + /// [`Request::PhotosRefreshStatus`]). + PhotosRefresh { running: bool }, /// An on-disk path the front-end can open (e.g. a downloaded photo). FilePath { path: String }, /// Full-text search results (reply to [`Request::Search`]). @@ -2065,6 +2077,21 @@ mod tests { assert_eq!(line, serde_json::to_string(&decoded).unwrap()); } + /// A photos refresh is followed, not awaited, so the request that reports + /// its progress is part of the wire contract. + #[test] + fn photos_refresh_status_roundtrips() { + let line = serde_json::to_string(&Request::PhotosRefreshStatus).unwrap(); + assert_eq!(line, r#""PhotosRefreshStatus""#); + let decoded: Request = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + + let line = serde_json::to_string(&Response::PhotosRefresh { running: true }).unwrap(); + assert_eq!(line, r#"{"PhotosRefresh":{"running":true}}"#); + let decoded: Response = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + } + #[test] fn unified_search_request_and_response_roundtrip() { let request = Request::SearchV2 { diff --git a/crates/pdfs-core/src/db/photos.rs b/crates/pdfs-core/src/db/photos.rs index 57cd7f0..09dfe8d 100644 --- a/crates/pdfs-core/src/db/photos.rs +++ b/crates/pdfs-core/src/db/photos.rs @@ -97,14 +97,39 @@ fn capture_day(conn: &rusqlite::Connection, capture_time: i64) -> Option } /// The file name without its extension, lowercased — `IMG_1234.CR2` and -/// `IMG_1234.JPG` share `img_1234`. +/// `IMG_1234.JPG` share `img_1234` — with the camera's own part-of-a-shot +/// marker removed, so the two files a raw capture writes share a stem too. fn name_stem(name: Option<&str>) -> Option { let name = name?; let stem = match name.rsplit_once('.') { Some((stem, _)) if !stem.is_empty() => stem, _ => name, }; - Some(stem.to_ascii_lowercase()) + let stem = stem.to_ascii_lowercase(); + Some(strip_shot_marker(&stem).to_string()) +} + +/// Drop a `.RAW-NN.ROLE` marker from a stem. +/// +/// Pixel stores one raw capture as `PXL_20260919_000625670.RAW-01.COVER.jpg` +/// and `PXL_20260919_000625670.RAW-02.ORIGINAL.dng`: one shot, two files, two +/// stems that differ in exactly the part naming which file of the shot this is. +/// Cutting the marker off is what lets rule 2 see the pair. The check is narrow +/// on purpose — digits, then a single role word — so an ordinary name that +/// happens to contain `.raw-` keeps its stem. +fn strip_shot_marker(stem: &str) -> &str { + let Some(at) = stem.rfind(".raw-") else { + return stem; + }; + let rest = &stem[at + ".raw-".len()..]; + let Some((index, role)) = rest.split_once('.') else { + return stem; + }; + let marked = !index.is_empty() + && index.bytes().all(|b| b.is_ascii_digit()) + && !role.is_empty() + && role.bytes().all(|b| b.is_ascii_alphabetic()); + if marked { &stem[..at] } else { stem } } /// One entry of the grouping pass: what rule 2 needs, plus what choosing a diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 47fdee6..68e80b0 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -4040,6 +4040,51 @@ fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() { assert_eq!(db.photos_group("alone").unwrap().len(), 1); } +/// Pixel names the two files of one raw capture after the role each plays, so +/// their stems differ. The shot is still one shot, and the grid shows the cover +/// JPEG. +#[test] +fn a_pixel_raw_capture_groups_with_its_cover_jpeg() { + let db = Db::open_in_memory().unwrap(); + db.photos_replace(&[ + TimelineRow { + name: Some("PXL_20260919_000625670.RAW-01.COVER.jpg".into()), + ..TimelineRow::new("cover", 1_789_776_385) + }, + TimelineRow { + name: Some("PXL_20260919_000625670.RAW-02.ORIGINAL.dng".into()), + ..TimelineRow::new("original", 1_789_776_385) + }, + // A name that merely contains the marker's opening is not a marker. + TimelineRow { + name: Some("scan.raw-notes.jpg".into()), + ..TimelineRow::new("notes", 1_789_776_385) + }, + TimelineRow { + name: Some("scan.raw-other.dng".into()), + ..TimelineRow::new("other", 1_789_776_385) + }, + ]) + .unwrap(); + + let page = db.photos_page(0, 10, None, None, false).unwrap(); + assert_eq!( + page.iter().map(|p| p.uid.as_str()).collect::>(), + ["cover", "notes", "other"], + "the capture shows once, as the cover JPEG" + ); + assert_eq!(page[0].group_size, 2); + assert!(page[0].has_raw); + assert_eq!( + db.photos_group("original") + .unwrap() + .iter() + .map(|p| p.uid.as_str()) + .collect::>(), + ["cover", "original"] + ); +} + /// Two files that merely share a name are not one shot: the same stem on /// different days, or two files of the same kind, stay apart. #[test] diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 27a3be5..4fb02ee 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -260,16 +260,23 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U // Whatever changed the timeline can just as easily have // changed an album; one scope covers the whole photos view. core.invalidate_albums(); - // Clearing the stamp alone only promises the *next* read - // will refetch, which means pressing Refresh paints the - // stale page and corrects it a moment later. The user - // pressed a button to see the truth, so this waits for it. - core.rt.block_on(core.refresh_timeline()).map(|_| ()) + // The refresh is started here, not awaited. A library of + // twelve thousand photos is re-read in chunks of + // `TIMELINE_ENRICH_CHUNK` nodes, which takes minutes — + // longer than the client's read bound, so awaiting it + // returned `Resource temporarily unavailable` to a caller + // whose refresh was in fact running fine (B92). The front + // end follows it with `PhotosRefreshStatus`. + core.spawn_timeline_refresh(); + Ok(()) } }; match result { Ok(()) => CtlResponse::Ok { - message: "refreshed".to_string(), + message: match scope { + RefreshScope::Photos => "refreshing".to_string(), + _ => "refreshed".to_string(), + }, }, Err(e) => CtlResponse::error(e), } @@ -395,6 +402,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild { status: core.thumbnail_build_status(), }, + Ok(CtlRequest::PhotosRefreshStatus) => CtlResponse::PhotosRefresh { + running: core.timeline_refreshing(), + }, Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) { Some(u) => match core.open_photo(&u) { Ok(p) => CtlResponse::FilePath { diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index 4c3fbf9..9ad453a 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -29,8 +29,8 @@ use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType}; use tracing::{info, warn}; use super::{ - Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, node_size, - now_ms, parse_uid, + Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority, + node_size, now_ms, parse_uid, }; /// Longest edge, in px, of a thumbnail generated locally for a photo the server @@ -540,6 +540,36 @@ impl Core { out } + /// Mutation admission for a photo, which [`Core::require_uid_writable`] + /// cannot answer on its own. + /// + /// Photos live on the photo volume, and that volume is not part of the FUSE + /// tree: a photo has no `nodes` row, so every authority comes back + /// [`WriteAuthority::Unknown`] and the plain check refuses the whole + /// library. Membership of our own timeline is the authority that does exist + /// here — the timeline is enumerated from our photo share, so a uid in it is + /// ours to trash. Anything else (an album photo on someone else's volume) + /// still fails closed. + fn require_photo_writable(&self, uid: &NodeUid) -> Result<(), fuser::Errno> { + match self.uid_write_authority(uid) { + WriteAuthority::Writable => Ok(()), + WriteAuthority::Denied => Err(fuser::Errno::EACCES), + WriteAuthority::Unknown => { + let key = uid.to_string(); + let known = self + .db + .photos_by_uid(std::slice::from_ref(&key)) + .map(|rows| !rows.is_empty()) + .unwrap_or(false); + if known { + Ok(()) + } else { + Err(fuser::Errno::EACCES) + } + } + } + } + /// Trash photos by uid, and forget them locally. /// /// The gallery is not part of the FUSE mount, so this is the photos-side @@ -561,7 +591,7 @@ impl Core { } let uids = &self.expand_photo_groups(uids); for uid in uids { - self.require_uid_writable(uid) + self.require_photo_writable(uid) .map_err(|errno| self.errno_error(errno, "trash access"))?; } let outcomes = self @@ -602,8 +632,36 @@ impl Core { /// /// 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. - pub(crate) fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec { + /// + /// A camera raw is served by the JPEG of the same shot when there is one: + /// the two files show one picture, and the JPEG is already decodable in + /// milliseconds where the raw costs a full download and a raw decode. The + /// tile still answers under the uid that was asked for. + pub(crate) fn photo_thumbs(&self, requested: &[NodeUid]) -> Vec { let ttype = ThumbnailType::Thumbnail.as_i32(); + // Only a raw can be stood in for, and that is one batched read; the + // per-shot group lookup is then paid for raws alone, not for a page of + // ordinary photos. + let asked: Vec = requested.iter().map(|u| u.to_string()).collect(); + let raws: HashSet = self + .db + .photos_by_uid(&asked) + .unwrap_or_default() + .into_iter() + .filter(|photo| photo.kind == PhotoKind::Raw) + .map(|photo| photo.uid) + .collect(); + let sources: Vec = requested + .iter() + .map(|uid| { + if raws.contains(&uid.to_string()) { + self.thumb_source(uid) + } else { + uid.clone() + } + }) + .collect(); + let uids = &sources; let keys: Vec = uids.iter().map(|u| u.to_string()).collect(); let mut stored = self.db.photos_by_uid(&keys).unwrap_or_default(); // Album covers and the contents of an album shared with us are not in our @@ -686,19 +744,47 @@ impl Core { } let pending = self.thumb_gen.lock(); - uids.iter() - .map(|uid| PhotoThumb { - uid: uid.to_string(), - path: tags.get(&uid.to_string()).and_then(|&tag| { + requested + .iter() + .zip(uids) + .map(|(asked, source)| PhotoThumb { + uid: asked.to_string(), + path: tags.get(&source.to_string()).and_then(|&tag| { self.cache - .cached_thumbnail_path(uid, ttype, tag) + .cached_thumbnail_path(source, ttype, tag) .map(|p| p.display().to_string()) }), - pending: pending.contains(uid), + pending: pending.contains(source), }) .collect() } + /// Which file's thumbnail stands for `uid` on a tile. + /// + /// Itself, for all but a camera raw that was written next to a JPEG of the + /// same shot. For that one, the JPEG: same picture, already compressed, + /// often carrying a server thumbnail the raw does not have — so the Raw tab + /// costs no raw download and no raw decode. A raw that stands alone keeps + /// its own local generation. + fn thumb_source(&self, uid: &NodeUid) -> NodeUid { + let members = match self.db.photos_group(&uid.to_string()) { + Ok(members) if members.len() > 1 => members, + _ => return uid.clone(), + }; + let key = uid.to_string(); + if !members + .iter() + .any(|photo| photo.uid == key && photo.kind == PhotoKind::Raw) + { + return uid.clone(); + } + members + .iter() + .find(|photo| photo.kind != PhotoKind::Raw) + .and_then(|photo| parse_uid(&photo.uid)) + .unwrap_or_else(|| uid.clone()) + } + /// Thumbnails for image files shown by the regular file browser, Shared and /// Trash pages. Unlike [`Core::photo_thumbs`], these nodes need not be in the /// Photos timeline: the request supplies each file's modification time as @@ -1313,6 +1399,7 @@ impl Core { /// Whether the listing stamped under `key` is older than `ttl` (or was never /// fetched). pub(crate) async fn refresh_timeline(&self) -> CoreResult { + let started = Instant::now(); let photos = self.photos(); if photos .get_photos_root() @@ -1407,6 +1494,14 @@ impl Core { self.db.photos_replace(&rows).map_err(CoreError::from)?; let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1); let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms()); + // How long this took is the first thing to know when a refresh looks + // like it never happened, and the chunk count is what it scales with. + info!( + photos = rows.len(), + chunks = uids.len().div_ceil(TIMELINE_ENRICH_CHUNK), + elapsed_ms = started.elapsed().as_millis() as u64, + "photos timeline refreshed" + ); Ok(true) } @@ -1425,6 +1520,12 @@ impl Core { }); } + /// Whether a timeline refresh is running. A front-end that asked for one + /// watches this to know when the new timeline is worth re-reading. + pub(crate) fn timeline_refreshing(&self) -> bool { + self.timeline_refreshing.load(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). pub(crate) fn open_photo(&self, uid: &NodeUid) -> CoreResult { diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 6acb734..880338f 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -78,6 +78,15 @@ const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2); /// reload by clearing the page's timestamp. const PAGE_TTL: Duration = Duration::from_secs(30); +/// How often the gallery asks whether the timeline refresh it started has +/// finished. +const PHOTOS_REFRESH_POLL: Duration = Duration::from_secs(2); + +/// How long the gallery keeps following one timeline refresh. A library of tens +/// of thousands of photos is re-read in batches over several minutes; past this +/// the daemon carries on and the page catches up on its next load. +const PHOTOS_REFRESH_FOLLOW_LIMIT: Duration = Duration::from_secs(15 * 60); + /// All widgets the periodic refresh and the action handlers mutate, plus the /// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout /// closure share one instance. @@ -874,11 +883,54 @@ fn reload_current_page(ui: &Rc) { /// how to report an unreachable daemon on its own page, and a refresh that fails /// silently would read as a dead button. fn refresh_then(ui: &Rc, scope: RefreshScope, load: fn(&Rc)) { + // A photos refresh answers as soon as it has started, because re-reading a + // large library takes minutes. The page is therefore loaded twice: once from + // what the daemon already holds, and again when the refresh has landed. + let follow = scope == RefreshScope::Photos; let rx = spawn_request(ui.dirs.control_socket(), Request::Refresh { scope }); let ui = ui.clone(); glib::spawn_future_local(async move { let _ = rx.recv().await; load(&ui); + if follow { + follow_photos_refresh(&ui, load); + } + }); +} + +/// Watch the timeline refresh the daemon just started, and load the page again +/// when it finishes. +/// +/// Giving up after [`PHOTOS_REFRESH_FOLLOW_LIMIT`] is not a failure: the refresh +/// keeps running in the daemon and the next visit to the page shows its result. +/// It only stops this front-end from polling a daemon forever. +fn follow_photos_refresh(ui: &Rc, load: fn(&Rc)) { + let ui = ui.clone(); + let give_up_at = Instant::now() + PHOTOS_REFRESH_FOLLOW_LIMIT; + glib::spawn_future_local(async move { + loop { + glib::timeout_future(PHOTOS_REFRESH_POLL).await; + // The person may have left the gallery in the meantime; reloading a + // page nobody is looking at would only cost the daemon work. + if ui.stack.visible_child_name().as_deref() != Some("gallery") { + return; + } + let rx = spawn_request(ui.dirs.control_socket(), Request::PhotosRefreshStatus); + match rx.recv().await { + Ok(Ok(Response::PhotosRefresh { running: true })) => { + if Instant::now() >= give_up_at { + return; + } + } + Ok(Ok(Response::PhotosRefresh { running: false })) => { + load(&ui); + return; + } + // An older daemon does not know the request, and anything else + // means we cannot tell — either way, stop asking. + _ => return, + } + } }); } diff --git a/docs/BUGS.md b/docs/BUGS.md index 71ac367..90eafbb 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -12,6 +12,45 @@ Conventions: --- +## B92 — Refreshing the gallery reported "Resource temporarily unavailable" + +**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real +library yet. +**Found:** 2026-09-19, while checking why a 12,445-photo timeline still showed the pre-grouping +layout after pressing Refresh. `pdfs refresh photos` failed after about two minutes with: + +``` +Caused by: + 0: io: Resource temporarily unavailable (os error 11) + 1: Resource temporarily unavailable (os error 11) +``` + +**Where:** `crates/pdfs-fuse/src/control.rs` (`RefreshScope::Photos`), +`crates/pdfs-core/src/control.rs` (`READ_TIMEOUT`, the new `PhotosRefreshStatus`), +`crates/pdfs-gui/src/app/main.rs` (`refresh_then`), `crates/pdfs-cli/src/main.rs` (`cmd_refresh`). + +**Cause.** `Refresh { Photos }` awaited `refresh_timeline()` before replying, on the reasoning that +somebody who presses Refresh wants the truth rather than a stale page. But the refresh resolves +every photo's node in batches of `TIMELINE_ENRICH_CHUNK` (200), so a 12,445-photo library is 63 +server round-trips — minutes of work. The client's read bound is `READ_TIMEOUT` = 120 s, so the +socket timed out with `EAGAIN` while the refresh was running perfectly. In the GUI the timeout was +invisible: `refresh_then` ignores the reply and loads the page anyway, which re-read the *old* +timeline and made Refresh look like a dead button. + +**What changed.** The daemon starts the refresh (`spawn_timeline_refresh`, which already dedupes) +and replies `refreshing` at once. `Request::PhotosRefreshStatus` reports whether one is running; +the gallery polls it every 2 s and reloads the page when it clears, and `pdfs refresh photos` +polls in the same way so the command still means "the timeline is up to date when I return". +`refresh_timeline` now logs `photos`, `chunks` and `elapsed_ms`. + +**Still open.** Every refresh re-resolves every photo, whatever changed. The metadata the resolve +learns (name, media type, content hash, photo relation) is already kept across refreshes, so the +pass could be limited to uids that have none — but the favourite tag is read from the same node, +and remote favourite changes arrive as `NodeUpdated` events that only ask for "a refresh". Making +the refresh incremental means deciding where favourites come from first. + +--- + ## B91 — The daemon hung overnight, and nothing could say what it was waiting on **Status:** Open — the hang itself is undiagnosed. What is fixed (unverified) is the diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b6a7104..d12c987 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,7 +9,7 @@ release ships. Migrations are forward-only — a database written by a newer cli refuse-to-open, not a downgrade, so rolling back a release means restoring the cache from scratch (user data in `staging/` and `recovery/` is never touched by this). -## [Unreleased] +## [1.11.0] — 2026-09-19 Schema: **30** (`photos.content_hash` / `main_uid` / `group_key`); SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.5**. @@ -27,10 +27,11 @@ Schema: **30** (`photos.content_hash` / `main_uid` / `group_key`); SDK bumped to holding 1500 textures instead of 600, and the rows just past the edge of the window are fetched ahead of the scroll direction. Scrolling fast through a large timeline no longer leaves a screen of blank tiles behind. -- **Refresh on the Photos page really refreshes.** It now waits for the timeline to come back - from the server before it answers, instead of only clearing the freshness stamp and letting - the next read serve the stale page. The timeline is also considered stale after 60 s rather - than 5 minutes. +- **Refresh on the Photos page really refreshes.** Instead of only clearing the freshness stamp + and letting the next read serve the stale page, it starts the timeline re-read and follows it: + the page paints from what the daemon holds, then paints again when the new timeline lands. The + timeline is also considered stale after 60 s rather than 5 minutes. `pdfs refresh photos` + blocks until the re-read is done, as it reads. - **Restore puts back the whole tree.** The trash is a flat list, but you delete a shape: a folder, and often things you had already deleted inside it. Restoring a folder now also @@ -66,6 +67,25 @@ Schema: **30** (`photos.content_hash` / `main_uid` / `group_key`); SDK bumped to daemon that stops answering is restarted instead of sitting there looking healthy (B91). ### Fixed +- **A Pixel raw capture groups with its JPEG.** Pixel names the two files of one shot + `PXL_….RAW-01.COVER.jpg` and `PXL_….RAW-02.ORIGINAL.dng`, so their stems differ and the pairing + rule never saw them as one shot. The part-of-a-shot marker is now cut off before the names are + compared, and the grid shows the cover JPEG once. The grouping is computed by a timeline + refresh, so an existing library pairs up after the next one. +- **The Raw tab no longer decodes a raw it does not have to.** A raw file that was written next + to a JPEG of the same shot is now given that JPEG's thumbnail, which the server usually already + has. No raw download and no raw decode for those tiles. +- **Photos can be moved to Trash from the gallery again.** Photos live on the photo volume, which + the mount never holds, so the write check found no node to consult and refused every photo with + "trash access: not allowed". Membership of our own timeline now answers for them; an album photo + on someone else's volume is still refused. +- **A photos refresh no longer fails with "Resource temporarily unavailable" (B92).** Re-reading a + twelve-thousand-photo library resolves the nodes in batches of 200 and takes minutes, but the + daemon held the control reply until it had finished — well past the client's 120 s read bound. + The caller therefore saw `io: Resource temporarily unavailable (os error 11)` for a refresh that + was running perfectly, and the gallery reloaded from the old timeline. The daemon now answers as + soon as the refresh is running, and the new `PhotosRefreshStatus` request reports when it is + done. Each refresh also logs its photo count, batch count and duration. - **A worker no longer panics during shutdown (B91).** The FUSE worker pool is joined before the tokio runtime is dropped, so a worker still inside a job does not find a runtime that is going away underneath it. diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 58d746d..f07e770 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -9,7 +9,7 @@ # Build + install: cd packaging && makepkg -fi pkgname=proton-drive-linux -pkgver=1.10.2 +pkgver=1.11.0 pkgrel=1 pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" arch=('x86_64')