mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
feat(gui,fuse): delete photos from the gallery and notice remote deletions
The gallery could show photos and nothing else: a photo deleted on the phone stayed on the page for minutes, and a photo you wanted gone had to be deleted on another device. Both are now handled here. Deleting needs a uid-addressed request, because `Request::Delete` takes a mountpoint path and the photos volume is not in the FUSE mount. `TrashNodes` trashes through the SDK and answers per-uid outcomes, so a partial failure is reportable; the daemon then drops those rows with `Db::photos_delete`, album membership included, rather than leaving the gallery to wait for the next timeline refresh. The GUI removes the tiles immediately with an Undo toast and puts back anything the server refused — the photos are in Proton's trash and stay recoverable there either way. Selection is Ctrl/Shift+click, the Select toggle or Delete in the lightbox. Remote deletions arrive on their own now. The Drive volume and the photos volume have separate event streams, so the photos volume gets its own poller and its own cursor. Trash and delete events remove rows; anything else only marks the timeline stale, because there is no inode space to converge on this volume. With the timeline TTL down to 60 s and an explicit Refresh that waits for the server instead of only clearing the freshness stamp, a photo deleted elsewhere leaves the grid in about ten seconds.
This commit is contained in:
1 parent
5a8e73657c
commit
c6bdba024f
13 files changed
+724
-10
No files matched your search
@@ -425,6 +425,113 @@ pub(super) async fn run_event_sync(
|
||||
}
|
||||
}
|
||||
|
||||
/// The state key holding the photos volume's event cursor. Separate from the
|
||||
/// Drive cursor because the two volumes have their own event streams, and one
|
||||
/// cursor cannot address both.
|
||||
const PHOTOS_EVENT_CURSOR: &str = "photos_event_cursor";
|
||||
|
||||
/// Poll the photos volume's event stream, so a photo deleted or added on another
|
||||
/// device shows up in the gallery within one poll instead of on the timeline's
|
||||
/// freshness window.
|
||||
///
|
||||
/// Deliberately much smaller than [`run_event_sync`]: the photos volume is not
|
||||
/// part of the FUSE mount, so there is no inode space to converge and nothing to
|
||||
/// invalidate in the kernel. A trash or a delete removes the rows behind the
|
||||
/// gallery; anything else only drops the timeline's freshness stamp, and the
|
||||
/// next read re-enumerates. Getting that wrong costs a stale tile, not a wrong
|
||||
/// file.
|
||||
pub(super) async fn run_photos_event_sync(core: Core) {
|
||||
let photos = core.photos();
|
||||
// The photos volume only exists once the user has used Photos at all, and
|
||||
// the daemon may well start before the network does.
|
||||
let scope = loop {
|
||||
match photos.get_photos_root().await {
|
||||
Ok(Some(root)) => break root.tree_event_scope_id(),
|
||||
Ok(None) => {
|
||||
debug!("no photos volume; photo event sync not started");
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%error, "reading the photos root failed; retrying");
|
||||
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut cursor: Option<DriveEventId> = core
|
||||
.db
|
||||
.state_str(PHOTOS_EVENT_CURSOR)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(DriveEventId::from);
|
||||
info!(?cursor, "photo event sync started");
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
let events = match photos.enumerate_events(&scope, cursor.as_ref()).await {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
warn!(%error, "photo event poll failed; retrying after interval");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(head) = events.last().map(|event| event.id().clone()) else {
|
||||
continue;
|
||||
};
|
||||
apply_photo_events(&core, &events);
|
||||
// One cursor write per batch, after the batch is applied: a crash in
|
||||
// between replays events whose effects are idempotent.
|
||||
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
|
||||
warn!(%error, "persisting the photo event cursor failed");
|
||||
continue;
|
||||
}
|
||||
cursor = Some(head);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one batch of photos-volume events to the gallery's tables.
|
||||
fn apply_photo_events(core: &Core, events: &[DriveEvent]) {
|
||||
let mut gone: Vec<String> = Vec::new();
|
||||
let mut refresh = false;
|
||||
for event in events {
|
||||
match event {
|
||||
DriveEvent::NodeDeleted { node_uid, .. } => gone.push(node_uid.to_string()),
|
||||
DriveEvent::NodeUpdated {
|
||||
node_uid,
|
||||
is_trashed,
|
||||
..
|
||||
} => {
|
||||
if *is_trashed {
|
||||
gone.push(node_uid.to_string());
|
||||
} else {
|
||||
// An untrash, a rename, a new upload from the phone: all of
|
||||
// them need the timeline itself, which only a refresh has.
|
||||
refresh = true;
|
||||
}
|
||||
}
|
||||
// Nothing here can be applied event by event, so the whole timeline
|
||||
// is re-read instead.
|
||||
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
||||
refresh = true;
|
||||
}
|
||||
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
||||
}
|
||||
}
|
||||
if !gone.is_empty() {
|
||||
match core.db.photos_delete(&gone) {
|
||||
Ok(removed) if removed > 0 => {
|
||||
debug!(removed, "photos removed by a remote event");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => warn!(%error, "removing remotely deleted photos failed"),
|
||||
}
|
||||
}
|
||||
if refresh || !gone.is_empty() {
|
||||
core.invalidate_photos();
|
||||
core.invalidate_albums();
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the local-file index fresh for the launcher prompt's "This computer"
|
||||
/// results. Rebuilds the index whenever it is older than [`LOCAL_INDEX_TTL`],
|
||||
/// then sleeps; runs on its own thread for the life of the daemon.
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::time::{Duration, Instant};
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||
TransferDirection,
|
||||
TransferDirection, TrashFailure,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
@@ -260,7 +260,11 @@ 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();
|
||||
Ok(())
|
||||
// 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(|_| ())
|
||||
}
|
||||
};
|
||||
match result {
|
||||
@@ -342,6 +346,19 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
items: core.photo_thumbs(&parsed),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) {
|
||||
Ok(parsed) => match core.trash_photos(&parsed) {
|
||||
Ok((trashed, failed)) => CtlResponse::Trashed {
|
||||
trashed,
|
||||
failed: failed
|
||||
.into_iter()
|
||||
.map(|(uid, message)| TrashFailure { uid, message })
|
||||
.collect(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::FileThumbs { items, generation }) => {
|
||||
match core.file_thumbs(&items, generation) {
|
||||
Some(items) => CtlResponse::Thumbs { items },
|
||||
|
||||
@@ -105,7 +105,7 @@ mod transfers;
|
||||
mod upload;
|
||||
mod r#virtual;
|
||||
mod workers;
|
||||
use background::{run_event_sync, run_local_index};
|
||||
use background::{run_event_sync, run_local_index, run_photos_event_sync};
|
||||
pub(crate) use mount::is_stale_mount;
|
||||
pub use mount::{MountOptions, MountOutcome, mount};
|
||||
use mount::{
|
||||
@@ -203,7 +203,11 @@ const ONLINE_PROBE_MAX: Duration = Duration::from_secs(300);
|
||||
/// revalidates it. The SDK hands back the whole timeline at once, so it is stored
|
||||
/// in the DB and every page is sliced from there; a stale one is still served
|
||||
/// immediately and refreshed in the background.
|
||||
const TIMELINE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
///
|
||||
/// Short, because the cost of a stale timeline is a photo the user deleted on
|
||||
/// their phone still sitting in the gallery, and the cost of the check is one
|
||||
/// listing call that only runs when somebody is actually looking at the page.
|
||||
const TIMELINE_TTL: Duration = Duration::from_secs(60);
|
||||
/// How many photo nodes are resolved per [`ProtonPhotosClient::enumerate_nodes`]
|
||||
/// call when enriching a refreshed timeline with names and media types (for the
|
||||
/// Photos / Videos / Raw split). Batched so a large library is a handful of
|
||||
|
||||
@@ -591,6 +591,9 @@ pub fn mount(
|
||||
}
|
||||
};
|
||||
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||
// The photos volume has its own event stream; without it the gallery only
|
||||
// learns about a phone-side deletion when the timeline goes stale.
|
||||
rt.spawn(run_photos_event_sync(core.clone()));
|
||||
if online {
|
||||
let repair_core = core.clone();
|
||||
let root_uid = core.primary_root_uid.clone();
|
||||
|
||||
@@ -402,6 +402,10 @@ fn extract_raw_preview(
|
||||
.map(|preview| (preview, orientation)))
|
||||
}
|
||||
|
||||
/// What a [`Core::trash_photos`] batch did: the uids that left the server, and
|
||||
/// the ones that did not with the reason each was refused.
|
||||
pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>);
|
||||
|
||||
/// Formats supported by the local thumbnail decoder and exposed by the GUI.
|
||||
impl Core {
|
||||
pub(crate) fn photos_timeline(
|
||||
@@ -489,6 +493,53 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trash photos by uid, and forget them locally.
|
||||
///
|
||||
/// The gallery is not part of the FUSE mount, so this is the photos-side
|
||||
/// counterpart of [`Core::delete`]: same destination (Proton trash, so the
|
||||
/// Trash page can restore them), addressed by uid because that is all a tile
|
||||
/// has.
|
||||
///
|
||||
/// The server answers per node, and so does this: a batch where one photo
|
||||
/// fails still removes the ones that went, and the caller is told which is
|
||||
/// which. Only what actually left the server is forgotten locally.
|
||||
pub(crate) fn trash_photos(&self, uids: &[NodeUid]) -> CoreResult<TrashOutcome> {
|
||||
if uids.is_empty() {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
for uid in uids {
|
||||
self.require_uid_writable(uid)
|
||||
.map_err(|errno| self.errno_error(errno, "trash access"))?;
|
||||
}
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "trash"))?;
|
||||
let (trashed, failed) = pdfs_core::batch::split(outcomes);
|
||||
|
||||
let trashed: Vec<String> = trashed.iter().map(|uid| uid.to_string()).collect();
|
||||
for uid in &trashed {
|
||||
if let Some(parsed) = parse_uid(uid) {
|
||||
self.cache.evict(&parsed);
|
||||
self.evict_reader(&parsed);
|
||||
}
|
||||
}
|
||||
// The timeline is served from the database, so removing the rows is what
|
||||
// makes the photos disappear now rather than at the next refresh.
|
||||
if let Err(error) = self.db.photos_delete(&trashed) {
|
||||
warn!(%error, "trashed photos could not be removed from the timeline");
|
||||
}
|
||||
self.invalidate_photos();
|
||||
self.invalidate_albums();
|
||||
self.invalidate_trash();
|
||||
|
||||
let failed = failed
|
||||
.into_iter()
|
||||
.map(|(uid, error)| (uid.to_string(), error.to_string()))
|
||||
.collect();
|
||||
Ok((trashed, failed))
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
Reference in new issue
Block a user