mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fix(core,fuse,gui,cli): pair a Pixel raw with its JPEG, and stop refresh timing out
Three things the 12,445-photo library showed that the tests did not. A Pixel raw capture is two files named after the role each plays — `PXL_….RAW-01.COVER.jpg` and `PXL_….RAW-02.ORIGINAL.dng` — so their stems differ and the pairing rule never saw one shot. The marker is now cut off before names are compared, narrowly: digits, then one role word, so an ordinary name that happens to contain `.raw-` keeps its stem. Fed the real library's first 40 rows through the grouping, they collapse to 21 tiles. A raw that has a JPEG beside it is now painted with that JPEG's thumbnail, which the server usually already holds. The Raw tab costs no raw download and no raw decode for those tiles. Raws are found in one batched read, so an ordinary page pays nothing for the lookup. Photos could not be moved to Trash at all: they live on the photo volume, which the mount never holds, so no `nodes` row exists, every authority answered `Unknown`, and the write check refused the whole library with "trash access: not allowed". Membership of our own timeline is the authority that does exist for a photo, and it now answers. An album photo on someone else's volume still fails closed. Refreshing the gallery then reported `io: Resource temporarily unavailable (os error 11)` (B92). The daemon held the control reply until the whole timeline had been re-read — 63 batches of 200 nodes for this library, minutes of work against a 120 s read bound. The daemon now answers as soon as the refresh is running, `PhotosRefreshStatus` reports when it is done, and both front ends follow it: the gallery reloads the page when it clears, and `pdfs refresh photos` blocks so the command still means what it says. Each refresh logs its photo count, batch count and duration. Released as 1.11.0.
This commit is contained in:
1 parent
09c0684a5e
commit
376f3edb28
12 files changed
+378
-30
No files matched your search
Generated
+4
-4
@@ -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",
|
||||
|
||||
+1
-1
@@ -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 <contact@narl.io>"]
|
||||
|
||||
@@ -2277,14 +2277,43 @@ fn cmd_refresh(target: Option<String>) -> 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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -97,14 +97,39 @@ fn capture_day(conn: &rusqlite::Connection, capture_time: i64) -> Option<String>
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
["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::<Vec<_>>(),
|
||||
["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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+111
-10
@@ -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<PhotoThumb> {
|
||||
///
|
||||
/// 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<PhotoThumb> {
|
||||
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<String> = requested.iter().map(|u| u.to_string()).collect();
|
||||
let raws: HashSet<String> = 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<NodeUid> = requested
|
||||
.iter()
|
||||
.map(|uid| {
|
||||
if raws.contains(&uid.to_string()) {
|
||||
self.thumb_source(uid)
|
||||
} else {
|
||||
uid.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let uids = &sources;
|
||||
let keys: Vec<String> = 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<bool> {
|
||||
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<PathBuf> {
|
||||
|
||||
@@ -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<Ui>) {
|
||||
/// 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<Ui>, scope: RefreshScope, load: fn(&Rc<Ui>)) {
|
||||
// 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<Ui>, load: fn(&Rc<Ui>)) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
-5
@@ -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.
|
||||
|
||||
+1
-1
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user