From 3da18b930999f1946b6dc41e735d25d9b5b80d10 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 25 Oct 2026 20:48:22 +0100 Subject: [PATCH] feat(photos): port the C# album capabilities Ports upstream a36cc423. add_photos_to_album now uses the timeline transfer driver, generalised over a PhotoTransferTarget. A photo on another volume, from an album shared with us, is copied into the album instead of failing. A photo whose group the server reports incomplete is sent once more with the related photos it named. Outcomes stay in input order. New set_album_cover, and delete_album_saving_photos, which saves the photos that exist only in the album (code 200302, now AlbumContainsPhotosNotInTimeline) before deleting it. remove_photos_from_album fails a photo on another volume without sending it. AlbumUpdateRequest leaves out a missing cover or name, as C# does. Pins upstream to b68d64bd. The search commit e861ef99 is not ported: its engine source is not public. --- UPSTREAM_SYNC.md | 5 +- crates/proton-drive-rs/src/client.rs | 713 +++++++++++--------- crates/proton-drive-rs/src/dtos.rs | 29 +- crates/proton-drive-rs/src/photos.rs | 52 +- crates/proton-drive-rs/tests/live_albums.rs | 111 +++ crates/proton-sdk/src/api.rs | 4 + 6 files changed, 551 insertions(+), 363 deletions(-) create mode 100644 crates/proton-drive-rs/tests/live_albums.rs diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md index 2a1347c..ad4dc25 100644 --- a/UPSTREAM_SYNC.md +++ b/UPSTREAM_SYNC.md @@ -7,8 +7,8 @@ gitignored, so this file is the only durable record of what we last reviewed. - **Upstream**: https://github.com/ProtonDriveApps/sdk - **Reconciled subtree**: `client/cs/src` (was `client/cs/sdk/src` before the mid-2026 projects reorg; the script watches both) -- **Pinned**: `8ffae007` -- **Date**: 2026-10-15 +- **Pinned**: `b68d64bd` +- **Date**: 2026-10-25 > **Note**: upstream force-pushes — the SHAs in rows before 2026-07-14 no longer > exist in its history. Triage by commit subject/date when a pin goes stale. @@ -43,3 +43,4 @@ gitignored, so this file is the only durable record of what we last reviewed. | 2026-10-06 | `f28a93ce` → `df3a717b` | `0aabea59` | ported: **recently accessed report** (`ProtonDriveClient::report_recently_accessed` → `POST recently-accessed-items`, `ProtonPhotosClient::report_recently_accessed` → `POST photos/recently-accessed-items`; batches of 50, missing `AccessTime` defaults to now, epoch seconds; `RecentlyAccessedReportItem` takes `Option` rather than `DateTimeOffset`). Also ported the DTO half of `f257c20e`: `LinkDto` / `ActiveRevisionDto` read `ThirdParty` and `Sdk` (default `false`). not ported: rest of `f257c20e` (`DecryptionErrorEvent` / `VerificationErrorEvent` with `Recency` and `CreatedBy` — telemetry plumbing for C#'s interop metric callback, we emit no such events; same reasoning as `29e58378`). Live validation pending: the recently-accessed endpoints. | | 2026-10-15 | `df3a717b` → `8ffae007` | `af391cbb` | ported: **folder-aware available names** (`af391cbb`, breaking upstream and here): `get_available_name` takes an `AvailableNameTarget` (`File` / `Folder`). A file keeps its extension last (`name (1).ext`, as before); a folder appends to the whole name (`name (1)`), so a folder named `v1.2` no longer resolves to `v1 (1).2`. `alternate_names` split into `alternate_file_names` / `alternate_folder_names` (C# `AlternateFileNameGenerator.GetFileNames` / `GetFolderNames`). no-op: `c6b45188` (C# transfer buffers optionally backed by an unlinked `mkstemp` file instead of `RecyclableMemoryStream`, behind a compile-time flag) — memory plumbing only; our blocks are `Vec` bounded by the client-wide in-flight block semaphore, and the verifier prefix it now reads from the stream is the same `min(prefix, len)` bytes our `verification_token` already zero-pads. **Downstream:** no `get_available_name` caller in `proton-drive-linux` or `proton-stream`. | | 2026-10-25 | — (no C# delta) | — | **not an upstream port of C#** — two ports from the **TypeScript** SDK, plus one change of ours. **Folder size** (TS `getFolderSize`, `243f11f4`): `ProtonDriveClient::get_folder_size` → `GET volumes/{vid}/folders/{lid}/calculate-descendents-size`, returning `FolderSize { size, descendants }`. Live-verified, which settled what the TS docs leave open: folders count as descendants, and the size is encrypted bytes on storage (two single-block files of 301 000 plaintext bytes report 301 112). **Copy** (TS `NodesManagement.copyNode`): `ProtonDriveClient::copy_node(uid, new_parent, name)` → `POST volumes/{vid}/links/{lid}/copy`, a server-side copy of a file with its content or a folder with its subtree. The crypto is the move's (`build_move_parts`: passphrase rewrapped to the new parent, name encrypted and signed by the My Files address, signature fields only for an anonymously signed node); `CopyPhotoResponse` became the shared `CopyLinkResponse`. Diverged deliberately: the returned uid takes the **target** parent's volume, where TS uses the source's, which only holds within one volume; no `copyNodes` batch, which in TS is a sequential loop over `copyNode`. Live-verified: file content, rename-on-copy, folder subtree, and `AlreadyExists` for a taken name. **Ours**: reads are retried after a dropped connection — `send_retrying` reads the body inside its loop and takes a `Replay` mode; a `GET`, a block download and the new `ApiHttpClient::post_query` (link details, thumbnail URLs, photo duplicates, `checkAvailableHashes`) are sent again when the connection drops mid-request or mid-body, while writes keep the old rule. | +| 2026-10-25 | `8ffae007` → `b68d64bd` | `a36cc423` | ported: **album capabilities** (`a36cc423`), the C# album write API, against the TS-sourced album writes we already had. `add_photos_to_album` now goes through the timeline transfer driver, renamed `transfer_photos` over a `PhotoTransferTarget` with a `PhotoTransferKind` (C# `TransferPhotosToTargetAsync` / `PhotoTransferTarget`): same-volume photos through `add-multiple` (the per-link answers share `TransferPhotosResponse`, so `AddPhotosToAlbumResponse` is gone), photos on **another volume** copied into the album through `POST volumes/{vid}/links/{lid}/copy` instead of failing, and a photo whose group the server reports incomplete re-sent once with the related photos it named; a related photo that cannot be re-encrypted is skipped rather than failing its main photo. The call still answers in input order. `add-multiple` entries never carry a passphrase signature (C# `ToAlbumPhotoLinkItem`). New `ProtonPhotosClient::set_album_cover` (the cover half of `UpdateAlbumAsync`; same volume only, refused before sending), and `AlbumUpdateRequest` now omits a `None` cover or link like C#. New `delete_album_saving_photos` (`DeleteAlbumAsync` with `saveToTimeline`): on `ResponseCode::AlbumContainsPhotosNotInTimeline` (200302, new) it saves the photos `Details.ChildLinkIDs` names, then deletes again; the first photo that cannot be saved fails the call. `remove_photos_from_album` fails a photo on another volume without sending it. Kept additive: `delete_album(uid, delete_photos)` and `rename_album` keep their signatures (`proton-drive-linux` calls both) rather than taking C#'s `(force, saveToTimeline)` and `(name?, cover?)`. Live-verified on the test account: add, adding twice (`Photo already exists in the album.`), cover, remove, delete keeping photos. Not live-verified: the cross-volume copy and the 200302 save, which need a second account sharing an album. **not ported: experimental search** (`e861ef99`): the engine is a Rust crate under `incubating/search` whose source is not in the public tree — only its C# bindings' wiring (`Search.targets`, `ProtonDriveSearchClient`) and a `BinaryKeyValueStore` for its index are. The rest of that commit is cosmetic (`await foreach` over a local, `SqliteConnectionStringBuilder`). | diff --git a/crates/proton-drive-rs/src/client.rs b/crates/proton-drive-rs/src/client.rs index 325afee..9243e68 100644 --- a/crates/proton-drive-rs/src/client.rs +++ b/crates/proton-drive-rs/src/client.rs @@ -45,38 +45,37 @@ use crate::crypto::{ }; use crate::devices::{Device, DeviceMetadata, DeviceType}; use crate::dtos::{ - AcceptInvitationRequest, AddPhotoToAlbumEntry, AddPhotosToAlbumRequest, - AddPhotosToAlbumResponse, AggregateLinksResponse, AlbumCreationLink, AlbumCreationRequest, - AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, AlbumUpdateLink, - AlbumUpdateRequest, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, - BlockUploadPreparationResponse, BlockUploadTarget, BlockVerificationInputResponse, - BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, CommonExtendedAttributes, - ContextShareResponse, CopyLinkRequest, CopyLinkResponse, CopyPhotoContent, - CopyPhotoRelatedItem, CopyPhotoRequest, CreateBookmarkRequest, CreatePublicLinkRequest, - CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto, - DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, - DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, - ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse, - FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest, - FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest, - FolderCreationResponse, FolderSizeResponse, InvitationDetailsResponse, InvitationsListResponse, - InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse, - InviteProtonUserInvitationDto, InviteProtonUserRequest, InviteProtonUserResponse, - LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto, - LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest, - MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest, - NodeNameAvailabilityResponse, PhotoTagsRequest, PhotosAttributesDto, - RemovePhotosFromAlbumRequest, RenameLinkRequest, ReportRecentlyAccessedItem, - ReportRecentlyAccessedRequest, RevisionConflict, RevisionCreationRequest, - RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse, - RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse, - ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse, ShareTargetType, ShareUrlDto, - ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse, SharedWithMeResponse, - SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse, - ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, - TimelinePhotoListResponse, TransferPhotoLinkItem, TransferPhotosRequest, - TransferPhotosResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, - VolumeEventListResponse, VolumeListResponse, VolumeTrashResponse, + AcceptInvitationRequest, AddPhotoToAlbumEntry, AddPhotosToAlbumRequest, AggregateLinksResponse, + AlbumCreationLink, AlbumCreationRequest, AlbumCreationResponse, AlbumItemListResponse, + AlbumListResponse, AlbumUpdateLink, AlbumUpdateRequest, BlockCreationRequest, BlockDto, + BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget, + BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, + CommonExtendedAttributes, ContextShareResponse, CopyLinkRequest, CopyLinkResponse, + CopyPhotoContent, CopyPhotoRelatedItem, CopyPhotoRequest, CreateBookmarkRequest, + CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, + DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, + DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, + ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto, + ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse, + FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse, + FolderCreationRequest, FolderCreationResponse, FolderSizeResponse, InvitationDetailsResponse, + InvitationsListResponse, InviteEmailDetailsDto, InviteExternalUserRequest, + InviteExternalUserResponse, InviteProtonUserInvitationDto, InviteProtonUserRequest, + InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, + LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest, + MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, + NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotoTagsRequest, + PhotosAttributesDto, RemovePhotosFromAlbumRequest, RenameLinkRequest, + ReportRecentlyAccessedItem, ReportRecentlyAccessedRequest, RevisionConflict, + RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto, + RevisionListResponse, RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, + ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse, + ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse, + SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, + SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse, + ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, TransferPhotoLinkItem, + TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest, VolumeCreationRequest, + VolumeEventDto, VolumeEventListResponse, VolumeListResponse, VolumeTrashResponse, }; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{ @@ -6216,150 +6215,59 @@ impl ProtonDriveClient { )) } - /// Add photos that already live on the album's volume to `album_uid`, - /// returning one outcome per input photo in input order. + /// Add photos to `album_uid`, returning one outcome per input photo in + /// input order. The photos stay where they are, timeline included. /// - /// Ported from the TypeScript SDK (`AddToAlbumProcess.processSameVolumeQueue` - /// with `PhotoTransferPayloadBuilder`). Each photo's name and node passphrase - /// are re-encrypted to the album key and its name/content hashes recomputed - /// under the album's hash key; the photo itself is not re-uploaded and stays - /// in the timeline. A main photo's related photos (live-photo video, burst - /// siblings) travel with it, because the server refuses the group otherwise. - /// - /// Photos on a *different* volume (an album shared with us) need the TS - /// `copyPhoto` path, which is not ported — those fail their own outcome - /// rather than the whole call. + /// Mirrors C# `PhotoOperations.AddPhotosToAlbumAsync` (itself the TS + /// `AddToAlbumProcess`). A photo on the album's volume is added through + /// `POST photos/volumes/{vid}/albums/{lid}/add-multiple`; one on another + /// volume — from an album shared with us — is copied into the album + /// through `POST volumes/{vid}/links/{lid}/copy`. Each photo's name and node + /// passphrase are re-encrypted to the album key and its name/content hashes + /// recomputed under the album's hash key; the photo itself is not + /// re-uploaded. A main photo's related photos (live-photo video, burst + /// siblings) travel with it, and when the server names related photos it + /// still wants, the photo is sent once more with them, as in + /// [`save_photos_to_timeline`](Self::save_photos_to_timeline). A photo + /// already in the album fails its own outcome. pub(crate) async fn add_photos_to_album( &self, album_uid: &NodeUid, photo_uids: &[NodeUid], ) -> Result)>> { - /// Photos per `add-multiple` request, related photos included (TS - /// `ADD_PHOTOS_BATCH_SIZE`). - const ADD_BATCH: usize = 10; - let mut timer = self.telemetry.start("add_photos_to_album"); - let mut outcomes: Vec<(NodeUid, Result<()>)> = Vec::with_capacity(photo_uids.len()); + timer.attr("photo_count", photo_uids.len()); if photo_uids.is_empty() { timer.success(); - return Ok(outcomes); + return Ok(Vec::new()); } - let album_key = self.folder_node_key(album_uid).await?; - let album_hash_key = self - .parent_hash_key_ctx(album_uid, &album_key, true) - .await?; + let key = self.folder_node_key(album_uid).await?; + let hash_key = self.parent_hash_key_ctx(album_uid, &key, true).await?; let (_address_id, email, signing_key) = self.photos_membership_address().await?; + let target = PhotoTransferTarget { + uid: album_uid.clone(), + kind: PhotoTransferKind::Album, + key, + hash_key, + email, + signing_key, + }; + let mut outcomes = self.transfer_photos(&target, photo_uids).await?; - let mut wanted: Vec<&NodeUid> = Vec::new(); - for uid in photo_uids { - if uid.volume_id != album_uid.volume_id { - outcomes.push(( - uid.clone(), - Err(ProtonError::invalid_operation(format!( - "photo {uid} is on another volume than album {album_uid}; \ - copying photos across volumes is not implemented" - ))), - )); - continue; - } - wanted.push(uid); - } - - // One `add-multiple` batch at a time: build each photo's payload, fan - // the related photos in beside it, then post and map the per-link - // outcomes back onto the *main* photos the caller asked about. - let mut batch: Vec = Vec::new(); - let mut entries: Vec = Vec::new(); - let mut queue: std::collections::VecDeque = wanted.into_iter().cloned().collect(); - - while let Some(uid) = queue.pop_front() { - let payload = match self - .album_photo_entries(&uid, &album_key, &album_hash_key, &email, &signing_key) - .await - { - Ok(payload) => payload, - Err(error) => { - outcomes.push((uid, Err(error))); - continue; - } - }; - - // A photo plus its related photos must not be split across requests. - if !entries.is_empty() && entries.len() + payload.len() > ADD_BATCH { - self.post_album_additions(album_uid, &entries, &batch, &mut outcomes) - .await?; - entries.clear(); - batch.clear(); - } - entries.extend(payload); - batch.push(uid); - } - if !entries.is_empty() { - self.post_album_additions(album_uid, &entries, &batch, &mut outcomes) - .await?; - } - + // The transfer reports in completion order; this call has always + // answered in input order. + let position: HashMap<&NodeUid, usize> = photo_uids + .iter() + .enumerate() + .rev() + .map(|(index, uid)| (uid, index)) + .collect(); + outcomes.sort_by_key(|(uid, _)| position.get(uid).copied().unwrap_or(usize::MAX)); timer.success(); Ok(outcomes) } - /// Build the `AlbumData` entries for one photo: the photo itself first, then - /// each of its related photos (TS `preparePhotoPayload`). - async fn album_photo_entries( - &self, - uid: &NodeUid, - album_key: &PrivateKey, - album_hash_key: &[u8], - email: &str, - signing_key: &PrivateKey, - ) -> Result> { - let node = self - .get_photos_node(uid) - .await? - .ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?; - - let related: Vec = node - .photo - .as_ref() - .map(|photo| photo.related_photo_uids.clone()) - .unwrap_or_default(); - - let mut entries = vec![ - self.album_photo_entry(uid, album_key, album_hash_key, email, signing_key) - .await?, - ]; - for related_uid in related { - entries.push( - self.album_photo_entry(&related_uid, album_key, album_hash_key, email, signing_key) - .await?, - ); - } - Ok(entries) - } - - /// Re-encrypt one photo's name and passphrase to the album key. - async fn album_photo_entry( - &self, - uid: &NodeUid, - album_key: &PrivateKey, - album_hash_key: &[u8], - email: &str, - signing_key: &PrivateKey, - ) -> Result { - let payload = self - .photo_target_payload(uid, album_key, album_hash_key, email, signing_key) - .await?; - Ok(AddPhotoToAlbumEntry { - link_id: payload.uid.link_id, - name_hash: payload.name_hash, - name: payload.name, - name_signature_email: payload.name_signature_email, - passphrase: payload.passphrase, - content_hash: payload.content_hash, - }) - } - /// Re-encrypt one photo's name, passphrase and hashes for a target node — /// an album or the timeline root (C# `PhotosCrypto.EncryptPhotoForTarget` /// via `PhotoTransferPayloadBuilder`, TS `preparePhotoPayload`). @@ -6425,74 +6333,6 @@ impl ProtonDriveClient { }) } - /// Post one `add-multiple` batch and record an outcome for every photo in - /// `batch` (the main photos; a related photo's failure fails its main one). - async fn post_album_additions( - &self, - album_uid: &NodeUid, - entries: &[AddPhotoToAlbumEntry], - batch: &[NodeUid], - outcomes: &mut Vec<(NodeUid, Result<()>)>, - ) -> Result<()> { - /// `ResponseCode::Success` as it arrives inside a per-link sub-response. - const SUCCESS_CODE: i32 = 1000; - - let path = format!( - "photos/volumes/{}/albums/{}/add-multiple", - album_uid.volume_id, album_uid.link_id - ); - let response: AddPhotosToAlbumResponse = self - .http - .post( - &path, - &AddPhotosToAlbumRequest { - album_data: entries.to_vec(), - }, - ) - .await?; - - // Only the links the server reports on failed; everything else in the - // batch succeeded (TS builds the same error map and yields ok for the - // rest). - let mut failures: HashMap = HashMap::new(); - for outcome in response.responses { - let body = &outcome.response; - // `Code == 1000` and no `Error` is the only success shape; a missing - // code (0) counts as a failure, as it does in TS. - if body.code == SUCCESS_CODE && body.error.is_none() { - continue; - } - let missing = body - .details - .as_ref() - .map(|details| details.missing.as_slice()) - .unwrap_or_default(); - let message = if !missing.is_empty() { - let missing: Vec = missing.iter().map(|id| id.to_string()).collect(); - format!( - "server wants related photos added with it: {}", - missing.join(", ") - ) - } else { - body.error - .clone() - .unwrap_or_else(|| format!("add to album failed with code {}", body.code)) - }; - failures.insert(outcome.link_id.to_string(), message); - } - - for uid in batch { - match failures.get(&uid.link_id.to_string()) { - Some(message) => outcomes.push(( - uid.clone(), - Err(ProtonError::invalid_operation(message.clone())), - )), - None => outcomes.push((uid.clone(), Ok(()))), - } - } - Ok(()) - } - /// Rename the album `album_uid` to `name`. /// /// Ported from the TypeScript SDK (`AlbumsManager.updateAlbum` + @@ -6550,13 +6390,43 @@ impl ProtonDriveClient { Ok(()) } + /// Set the cover photo of the album `album_uid`. + /// + /// Mirrors the cover half of C# `PhotoOperations.UpdateAlbumAsync` + /// (`PUT photos/volumes/{vid}/albums/{lid}` with only `CoverLinkID`). The + /// cover must be on the album's volume; upstream refuses anything else + /// before sending. + pub(crate) async fn set_album_cover( + &self, + album_uid: &NodeUid, + cover_uid: &NodeUid, + ) -> Result<()> { + let mut timer = self.telemetry.start("set_album_cover"); + if cover_uid.volume_id != album_uid.volume_id { + return Err(ProtonError::invalid_operation( + "the cover photo must be on the same volume as the album", + )); + } + let request = AlbumUpdateRequest { + cover_link_id: Some(cover_uid.link_id.clone()), + link: None, + }; + let path = format!( + "photos/volumes/{}/albums/{}", + album_uid.volume_id, album_uid.link_id + ); + let _: proton_sdk::api::ApiResponse = self.http.put(&path, &request).await?; + timer.success(); + Ok(()) + } + /// Delete the album `album_uid`. /// /// Ported from the TypeScript SDK (`PhotosAPIService.deleteAlbum`). With /// `delete_photos` false the photos stay in the timeline, and the server - /// refuses (code 200302) when the album holds photos that exist only in it, - /// such as ones saved from a shared album; with it true those are deleted - /// together with the album. + /// refuses with [`ResponseCode::AlbumContainsPhotosNotInTimeline`] when the + /// album holds photos that exist only in it, such as ones saved from a + /// shared album; with it true those are deleted together with the album. pub(crate) async fn delete_album( &self, album_uid: &NodeUid, @@ -6574,6 +6444,34 @@ impl ProtonDriveClient { Ok(()) } + /// Delete the album `album_uid`, first saving to the timeline the photos + /// that exist only in it, so no photo is lost. + /// + /// Mirrors C# `PhotoOperations.DeleteAlbumAsync` with `saveToTimeline`: the + /// delete is tried as is; when the server refuses with + /// [`ResponseCode::AlbumContainsPhotosNotInTimeline`], the photos it names + /// are saved through [`save_photos_to_timeline`](Self::save_photos_to_timeline) + /// and the delete is sent once more. The first photo that cannot be saved + /// fails the call, and the album stays. + pub(crate) async fn delete_album_saving_photos(&self, album_uid: &NodeUid) -> Result<()> { + let only_in_album = match self.delete_album(album_uid, false).await { + Err(ProtonError::Api(error)) + if error.code == ResponseCode::AlbumContainsPhotosNotInTimeline => + { + let photos = photos_only_in_album(&error, &album_uid.volume_id); + if photos.is_empty() { + return Err(ProtonError::Api(error)); + } + photos + } + done => return done, + }; + for (_, outcome) in self.save_photos_to_timeline(&only_in_album).await? { + outcome?; + } + self.delete_album(album_uid, false).await + } + /// Take photos out of `album_uid`, one outcome per input photo in input /// order. The photos stay in the timeline. /// @@ -6593,26 +6491,47 @@ impl ProtonDriveClient { "photos/volumes/{}/albums/{}/remove-multiple", album_uid.volume_id, album_uid.link_id ); - let mut outcomes = Vec::with_capacity(photo_uids.len()); - for batch in photo_uids.chunks(REMOVE_BATCH) { + // A photo on another volume cannot be in this album's request; it fails + // its own outcome without being sent, as upstream does. + let mut results: Vec>> = photo_uids + .iter() + .map(|uid| { + (uid.volume_id != album_uid.volume_id).then(|| { + Err(ProtonError::invalid_operation( + "the photo must be on the same volume as the album", + )) + }) + }) + .collect(); + let sendable: Vec = (0..photo_uids.len()) + .filter(|&index| results[index].is_none()) + .collect(); + for batch in sendable.chunks(REMOVE_BATCH) { let request = RemovePhotosFromAlbumRequest { - link_ids: batch.iter().map(|uid| uid.link_id.clone()).collect(), + link_ids: batch + .iter() + .map(|&index| photo_uids[index].link_id.clone()) + .collect(), }; let result: Result = self.http.post(&path, &request).await; - match result { - Ok(_) => outcomes.extend(batch.iter().map(|uid| (uid.clone(), Ok(())))), - Err(error) => { - let message = error.to_string(); - outcomes.extend(batch.iter().map(|uid| { - ( - uid.clone(), - Err(ProtonError::invalid_operation(message.clone())), - ) - })); - } + let message = result.err().map(|error| error.to_string()); + for &index in batch { + results[index] = Some(match &message { + None => Ok(()), + Some(message) => Err(ProtonError::invalid_operation(message.clone())), + }); } } + let outcomes: Vec<(NodeUid, Result<()>)> = photo_uids + .iter() + .cloned() + .zip( + results + .into_iter() + .map(|result| result.expect("every photo is either refused or sent")), + ) + .collect(); timer.success(); Ok(outcomes) } @@ -6638,13 +6557,6 @@ impl ProtonDriveClient { &self, photo_uids: &[NodeUid], ) -> Result)>> { - /// Photos whose payloads are built before a round of requests goes out - /// (C# `SaveToTimelineBatchSize`). - const SAVE_BATCH: usize = 20; - /// Links — main photos plus related ones — per `transfer-multiple` - /// request (C# `MaxLinksPerTransferBatch`). - const MAX_LINKS_PER_TRANSFER: usize = 10; - let mut timer = self.telemetry.start("save_photos_to_timeline"); timer.attr("photo_count", photo_uids.len()); if photo_uids.is_empty() { @@ -6671,14 +6583,41 @@ impl ProtonDriveClient { .parent_hash_key_ctx(&target_uid, &target_key, true) .await?; let (_address_id, email, signing_key) = self.photos_membership_address().await?; - let target = TimelineTarget { + let target = PhotoTransferTarget { uid: target_uid, + kind: PhotoTransferKind::Timeline, key: target_key, hash_key: target_hash_key, email, signing_key, }; + let outcomes = self.transfer_photos(&target, photo_uids).await?; + timer.success(); + Ok(outcomes) + } + + /// Bring photos into `target`, the timeline root or an album, with one + /// outcome per photo in completion order (C# + /// `PhotoOperations.TransferPhotosToTargetAsync`). + /// + /// Photos on the target's volume go in batches through the target's own + /// endpoint (see [`PhotoTransferKind`]); photos on another volume are + /// copied one at a time. A photo whose group the server reports + /// incomplete is re-queued once with the related photos it named, and only + /// its second answer becomes an outcome. + async fn transfer_photos( + &self, + target: &PhotoTransferTarget, + photo_uids: &[NodeUid], + ) -> Result)>> { + /// Photos whose payloads are built before a round of requests goes out + /// (C# `PhotoTransferLoadBatchSize`). + const LOAD_BATCH: usize = 20; + /// Links — main photos plus related ones — per same-volume request + /// (C# `MaxLinksPerTransferBatch`). + const MAX_LINKS_PER_TRANSFER: usize = 10; + let mut outcomes: Vec<(NodeUid, Result<()>)> = Vec::with_capacity(photo_uids.len()); // Each entry is a photo plus the related photos the server asked for on // a previous attempt; `retried` is what keeps that a single retry. @@ -6692,12 +6631,12 @@ impl ProtonDriveClient { while !queue.is_empty() { let mut same_volume: Vec = Vec::new(); let mut cross_volume: Vec = Vec::new(); - for _ in 0..SAVE_BATCH { + for _ in 0..LOAD_BATCH { let Some((uid, extra_related)) = queue.pop_front() else { break; }; match self - .timeline_photo_payload(&uid, &extra_related, &target) + .transfer_photo_payload(&uid, &extra_related, target) .await { Ok(payload) if payload.uid.volume_id == target.uid.volume_id => { @@ -6715,8 +6654,8 @@ impl ProtonDriveClient { for payload in same_volume { let count = 1 + payload.related.len(); if !batch.is_empty() && links + count > MAX_LINKS_PER_TRANSFER { - self.transfer_photos_to_timeline( - &target, + self.transfer_photo_batch( + target, &batch, &mut queue, &mut retried, @@ -6730,19 +6669,13 @@ impl ProtonDriveClient { batch.push(payload); } if !batch.is_empty() { - self.transfer_photos_to_timeline( - &target, - &batch, - &mut queue, - &mut retried, - &mut outcomes, - ) - .await?; + self.transfer_photo_batch(target, &batch, &mut queue, &mut retried, &mut outcomes) + .await?; } for payload in cross_volume { - self.copy_photo_to_timeline( - &target, + self.copy_photo_to_target( + target, &payload, &mut queue, &mut retried, @@ -6751,23 +6684,21 @@ impl ProtonDriveClient { .await; } } - - timer.success(); Ok(outcomes) } - /// One photo's timeline payload: the photo itself plus its related photos, - /// re-encrypted for the timeline root (C# + /// One photo's transfer payload: the photo itself plus its related photos, + /// re-encrypted for `target` (C# /// `PhotoTransferPayloadBuilder.BuildPayloadsAsync`). /// /// `extra_related` are the ones the server demanded on a previous attempt, /// merged with what the photo itself declares. A related photo that cannot /// be built is skipped rather than failing its main photo, as upstream does. - async fn timeline_photo_payload( + async fn transfer_photo_payload( &self, uid: &NodeUid, extra_related: &[NodeUid], - target: &TimelineTarget, + target: &PhotoTransferTarget, ) -> Result { let mut payload = self .photo_target_payload( @@ -6778,6 +6709,8 @@ impl ProtonDriveClient { &target.signing_key, ) .await?; + // Album membership is not parentage, so only the timeline can tell here; + // the album endpoint reports a photo already in it per link. if payload.parent_uid.as_ref() == Some(&target.uid) { return Err(ProtonError::invalid_operation(format!( "photo {uid} is already in the timeline" @@ -6816,63 +6749,103 @@ impl ProtonDriveClient { Ok(payload) } - /// Post one `transfer-multiple` batch and record an outcome for each of its - /// main photos (C# `TransferSameVolumeBatchAsync` + `RecordTransferOutcomes`). - async fn transfer_photos_to_timeline( + /// Post one same-volume batch — `transfer-multiple` into the timeline, + /// `add-multiple` into an album — and record an outcome for each of its main + /// photos (C# `TransferSameVolumeBatchAsync` / `AddToAlbumSameVolumeBatchAsync` + /// + `RecordTransferOutcomes`). + async fn transfer_photo_batch( &self, - target: &TimelineTarget, + target: &PhotoTransferTarget, payloads: &[PhotoTargetPayload], queue: &mut VecDeque<(NodeUid, Vec)>, retried: &mut HashSet, outcomes: &mut Vec<(NodeUid, Result<()>)>, + ) -> Result<()> { + let response: TransferPhotosResponse = match target.kind { + PhotoTransferKind::Timeline => { + let mut links: Vec = Vec::new(); + let mut sent: Vec<&PhotoTargetPayload> = Vec::new(); + for payload in payloads { + // The endpoint matches each link on the hash it currently has, + // so a photo whose link carries none cannot be moved. + let items: Option> = std::iter::once(payload) + .chain(payload.related.iter()) + .map(transfer_link_item) + .collect(); + match items { + Some(items) => { + links.extend(items); + sent.push(payload); + } + None => outcomes.push(( + payload.uid.clone(), + Err(ProtonError::invalid_operation(format!( + "photo {} has no name hash on its current parent; it cannot be moved", + payload.uid + ))), + )), + } + } + if links.is_empty() { + return Ok(()); + } + let path = format!( + "photos/volumes/{}/links/transfer-multiple", + target.uid.volume_id + ); + let response = self + .http + .put( + &path, + &TransferPhotosRequest { + parent_link_id: target.uid.link_id.clone(), + links, + name_signature_email: target.email.clone(), + signature_email: None, + }, + ) + .await?; + return self + .record_transfer_outcomes(target, response, sent, queue, retried, outcomes); + } + PhotoTransferKind::Album => { + let album_data: Vec = payloads + .iter() + .flat_map(|payload| std::iter::once(payload).chain(payload.related.iter())) + .map(album_entry) + .collect(); + let path = format!( + "photos/volumes/{}/albums/{}/add-multiple", + target.uid.volume_id, target.uid.link_id + ); + self.http + .post(&path, &AddPhotosToAlbumRequest { album_data }) + .await? + } + }; + self.record_transfer_outcomes( + target, + response, + payloads.iter().collect(), + queue, + retried, + outcomes, + ) + } + + /// Map one same-volume batch's per-link answers onto the main photos `sent`. + fn record_transfer_outcomes( + &self, + target: &PhotoTransferTarget, + response: TransferPhotosResponse, + sent: Vec<&PhotoTargetPayload>, + queue: &mut VecDeque<(NodeUid, Vec)>, + retried: &mut HashSet, + outcomes: &mut Vec<(NodeUid, Result<()>)>, ) -> Result<()> { /// `ResponseCode::Success` as it arrives inside a per-link sub-response. const SUCCESS_CODE: i32 = 1000; - let mut links: Vec = Vec::new(); - let mut sent: Vec<&PhotoTargetPayload> = Vec::new(); - for payload in payloads { - // The endpoint matches each link on the hash it currently has, so a - // photo whose link carries none cannot be moved. - let items: Option> = std::iter::once(payload) - .chain(payload.related.iter()) - .map(transfer_link_item) - .collect(); - match items { - Some(items) => { - links.extend(items); - sent.push(payload); - } - None => outcomes.push(( - payload.uid.clone(), - Err(ProtonError::invalid_operation(format!( - "photo {} has no name hash on its current parent; it cannot be moved", - payload.uid - ))), - )), - } - } - if links.is_empty() { - return Ok(()); - } - - let path = format!( - "photos/volumes/{}/links/transfer-multiple", - target.uid.volume_id - ); - let response: TransferPhotosResponse = self - .http - .put( - &path, - &TransferPhotosRequest { - parent_link_id: target.uid.link_id.clone(), - links, - name_signature_email: target.email.clone(), - signature_email: None, - }, - ) - .await?; - // Only the links the server reports on failed; a main photo without an // entry succeeded, and a related photo's own failure is not reported // (the incomplete-group case is keyed on the main link), exactly as C# @@ -6905,16 +6878,16 @@ impl ProtonDriveClient { for payload in sent { let failure = failures.remove(&payload.uid.link_id.to_string()); - record_timeline_outcome(payload.uid.clone(), failure, queue, retried, outcomes); + record_transfer_outcome(payload.uid.clone(), failure, queue, retried, outcomes); } Ok(()) } - /// Copy one photo that lives on another volume into the timeline (C# - /// `CopyCrossVolumePhotoAsync`, JS `copyPhoto`). - async fn copy_photo_to_timeline( + /// Copy one photo that lives on another volume into `target`, the timeline + /// root or an album (C# `CopyCrossVolumePhotoAsync`, JS `copyPhoto`). + async fn copy_photo_to_target( &self, - target: &TimelineTarget, + target: &PhotoTransferTarget, payload: &PhotoTargetPayload, queue: &mut VecDeque<(NodeUid, Vec)>, retried: &mut HashSet, @@ -6964,7 +6937,7 @@ impl ProtonDriveClient { } Err(error) => Some((Vec::new(), error)), }; - record_timeline_outcome(payload.uid.clone(), failure, queue, retried, outcomes); + record_transfer_outcome(payload.uid.clone(), failure, queue, retried, outcomes); } /// Page the albums on the account's photos volume. @@ -8709,6 +8682,20 @@ fn share_membership_from_dto(dto: &ShareMembershipSummaryDto) -> ShareMembership } } +/// One `add-multiple` entry. It never carries a passphrase signature, not even +/// for an anonymously signed photo: the endpoint rejects the field (C# +/// `ToAlbumPhotoLinkItem`). +fn album_entry(payload: &PhotoTargetPayload) -> AddPhotoToAlbumEntry { + AddPhotoToAlbumEntry { + link_id: payload.uid.link_id.clone(), + name_hash: payload.name_hash.clone(), + name: payload.name.clone(), + name_signature_email: payload.name_signature_email.clone(), + passphrase: payload.passphrase.clone(), + content_hash: payload.content_hash.clone(), + } +} + /// One `transfer-multiple` link item, or `None` when the photo's link carries /// no name hash for the parent it is leaving — the endpoint needs it to match. fn transfer_link_item(payload: &PhotoTargetPayload) -> Option { @@ -8723,6 +8710,25 @@ fn transfer_link_item(payload: &PhotoTargetPayload) -> Option Vec { + error + .details + .as_ref() + .and_then(|details| details.get("ChildLinkIDs")) + .and_then(|links| links.as_array()) + .map(|links| { + links + .iter() + .filter_map(|link_id| link_id.as_str()) + .map(|link_id| NodeUid::new(volume_id.clone(), LinkId::from(link_id.to_string()))) + .collect() + }) + .unwrap_or_default() +} + /// The related photos an error envelope's `Details.Missing` names, on the volume /// the failing photo lives on (C# `MissingRelatedPhotosException`). fn missing_related_links(error: &ProtonApiError, volume_id: &VolumeId) -> Vec { @@ -8744,7 +8750,7 @@ fn missing_related_links(error: &ProtonApiError, volume_id: &VolumeId) -> Vec ProtonError { let missing: Vec = missing.iter().map(|uid| uid.to_string()).collect(); ProtonError::invalid_operation(format!( - "server wants related photos saved with it: {}", + "server wants related photos sent with it: {}", missing.join(", ") )) } @@ -8752,7 +8758,7 @@ fn missing_related_photos_error(missing: &[NodeUid]) -> ProtonError { /// Turn one photo's answer into an outcome, or re-queue it once when the server /// asked for its related photos (C# `PhotoOperations.RecordOutcome`). `failure` /// carries the related photos the server named, when it named any. -fn record_timeline_outcome( +fn record_transfer_outcome( uid: NodeUid, failure: Option<(Vec, ProtonError)>, queue: &mut VecDeque<(NodeUid, Vec)>, @@ -8770,10 +8776,23 @@ fn record_timeline_outcome( } } -/// The timeline root and the keys photos are re-encrypted for on their way into -/// it (C# `PhotoOperations.TimelineTarget`). -struct TimelineTarget { +/// Where same-volume photos go in, which decides the endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PhotoTransferKind { + /// The timeline root: the photos are *moved* in through + /// `transfer-multiple`. + Timeline, + /// An album: the photos are *added* through `add-multiple` and stay where + /// they are. + Album, +} + +/// The node photos are brought into — the timeline root or an album — and the +/// keys they are re-encrypted for on their way (C# +/// `PhotoOperations.PhotoTransferTarget`). +struct PhotoTransferTarget { uid: NodeUid, + kind: PhotoTransferKind, key: PrivateKey, hash_key: Vec, /// The photos share membership address: what signs the re-encrypted names. @@ -8834,9 +8853,10 @@ mod tests { alternate_folder_names, assemble_manifest, context_share_path, drive_items, epoch_to_iso8601, has_active_regular_volume, is_expired_upload_token, is_listable_revision_state, is_upload_timeout, join_node_path, missing_related_links, - path_segments, record_timeline_outcome, resolve_parent_keys, run_context_share_mutation, - share_membership_from_dto, small_upload_applicable, small_upload_should_fall_back, - take_block_target, take_thumbnail_target, to_drive_event, validate_node_name, + path_segments, photos_only_in_album, record_transfer_outcome, resolve_parent_keys, + run_context_share_mutation, share_membership_from_dto, small_upload_applicable, + small_upload_should_fall_back, take_block_target, take_thumbnail_target, to_drive_event, + validate_node_name, }; use crate::dtos::{ AggregateLinksResponse, BlockUploadTarget, LinkDetailsDto, LinkDto, LinkIdResponsePair, @@ -9519,7 +9539,7 @@ mod tests { }; // First answer: re-queued with the related photo, no outcome yet. - record_timeline_outcome( + record_transfer_outcome( uid.clone(), failure(), &mut queue, @@ -9534,7 +9554,7 @@ mod tests { // Second: the retry is spent, so it becomes a failed outcome instead of // going round again. - record_timeline_outcome( + record_transfer_outcome( uid.clone(), failure(), &mut queue, @@ -9553,13 +9573,32 @@ mod tests { let mut retried = std::collections::HashSet::new(); let mut outcomes = Vec::new(); - record_timeline_outcome(uid.clone(), None, &mut queue, &mut retried, &mut outcomes); + record_transfer_outcome(uid.clone(), None, &mut queue, &mut retried, &mut outcomes); assert!(queue.is_empty()); assert_eq!(outcomes.len(), 1); assert!(outcomes[0].1.is_ok()); } + #[test] + fn photos_only_in_album_reads_the_error_details() { + let volume_id = VolumeId::new("vol"); + let error = ProtonApiError { + code: ResponseCode::from_raw(200_302), + http_status: 422, + message: "Album contains photos not in timeline".into(), + details: Some(serde_json::json!({ "ChildLinkIDs": ["a", "b"] })), + }; + assert_eq!(error.code, ResponseCode::AlbumContainsPhotosNotInTimeline); + assert_eq!( + photos_only_in_album(&error, &volume_id), + vec![ + NodeUid::new(volume_id.clone(), LinkId::new("a")), + NodeUid::new(volume_id.clone(), LinkId::new("b")), + ] + ); + } + #[test] fn missing_related_links_reads_the_error_details() { let volume_id = VolumeId::new("vol"); diff --git a/crates/proton-drive-rs/src/dtos.rs b/crates/proton-drive-rs/src/dtos.rs index 12db213..30a008e 100644 --- a/crates/proton-drive-rs/src/dtos.rs +++ b/crates/proton-drive-rs/src/dtos.rs @@ -412,12 +412,13 @@ pub struct AlbumCreationLinkResponse { /// `PUT photos/volumes/{vid}/albums/{lid}` — rename an album or change its /// cover. Ported from the TypeScript SDK (`PhotosAPIService.updateAlbum`); a -/// `None` field leaves that part of the album as it is. +/// `None` field is left out and leaves that part of the album as it is (C# +/// `UpdateAlbumRequest`). #[derive(Debug, Serialize)] pub struct AlbumUpdateRequest { - #[serde(rename = "CoverLinkID")] + #[serde(rename = "CoverLinkID", skip_serializing_if = "Option::is_none")] pub cover_link_id: Option, - #[serde(rename = "Link")] + #[serde(rename = "Link", skip_serializing_if = "Option::is_none")] pub link: Option, } @@ -494,12 +495,6 @@ pub struct AddPhotoToAlbumEntry { pub content_hash: String, } -#[derive(Debug, Deserialize)] -pub struct AddPhotosToAlbumResponse { - #[serde(rename = "Responses", default)] - pub responses: Vec, -} - #[derive(Debug, Deserialize)] pub struct PhotoLinkOutcome { #[serde(rename = "LinkID")] @@ -566,6 +561,9 @@ pub struct TransferPhotoLinkItem { pub passphrase_signature: Option, } +/// The per-link answers of `transfer-multiple` and of an album's +/// `add-multiple`, which share one shape (C# +/// `AggregateApiResponse`). #[derive(Debug, Deserialize)] pub struct TransferPhotosResponse { #[serde(rename = "Responses", default)] @@ -1844,6 +1842,18 @@ mod tests { assert!(!revision.third_party && !revision.sdk); } + #[test] + fn album_cover_update_sends_the_cover_and_no_name() { + let request = AlbumUpdateRequest { + cover_link_id: Some(LinkId::new("photo")), + link: None, + }; + assert_eq!( + serde_json::to_value(&request).expect("album update"), + serde_json::json!({ "CoverLinkID": "photo" }) + ); + } + #[test] fn album_rename_sends_the_name_and_no_cover() { let request = AlbumUpdateRequest { @@ -1858,7 +1868,6 @@ mod tests { assert_eq!( serde_json::to_value(&request).expect("album update"), serde_json::json!({ - "CoverLinkID": null, "Link": { "Name": "armored", "Hash": "new", diff --git a/crates/proton-drive-rs/src/photos.rs b/crates/proton-drive-rs/src/photos.rs index 4536bc5..cf35ab0 100644 --- a/crates/proton-drive-rs/src/photos.rs +++ b/crates/proton-drive-rs/src/photos.rs @@ -307,16 +307,16 @@ impl ProtonPhotosClient { self.drive.create_album(name).await } - /// Add photos already on the album's volume to `album_uid`, one outcome per - /// input photo in input order (a photo that fails does not stop the others). + /// Add photos to `album_uid`, one outcome per input photo in input order (a + /// photo that fails does not stop the others). /// - /// Ported from the TypeScript SDK (`AlbumsManager.addPhotos`). The photos - /// stay in the timeline — an album membership re-encrypts a photo's name and - /// passphrase to the album key, it does not copy content. Related photos - /// (live-photo video, burst siblings) are sent alongside their main photo - /// because the server rejects the group otherwise. Photos on another volume - /// (from an album shared with us) need the unported `copyPhoto` path and - /// fail their own outcome. + /// Mirrors C# `ProtonPhotosClient.AddPhotosToAlbumAsync` (TS + /// `AlbumsManager.addPhotos`). The photos stay in the timeline — an album + /// membership re-encrypts a photo's name and passphrase to the album key, it + /// does not copy content. A photo on another volume, from an album shared + /// with us, is copied into the album instead. Related photos (live-photo + /// video, burst siblings) are sent alongside their main photo because the + /// server rejects the group otherwise. pub async fn add_photos_to_album( &self, album_uid: &NodeUid, @@ -333,21 +333,44 @@ impl ProtonPhotosClient { self.drive.rename_album(album_uid, name).await } + /// Set the cover photo of the album `album_uid`. The cover must be on the + /// album's volume. + /// + /// Mirrors the cover half of C# `ProtonPhotosClient.UpdateAlbumAsync`; + /// [`rename_album`](Self::rename_album) is the name half. + pub async fn set_album_cover(&self, album_uid: &NodeUid, cover_uid: &NodeUid) -> Result<()> { + self.drive.set_album_cover(album_uid, cover_uid).await + } + /// Delete the album `album_uid`. /// /// Ported from the TypeScript SDK (`AlbumsManager.deleteAlbum`). With /// `delete_photos` false the photos stay in the timeline; the server then - /// refuses to delete an album holding photos that exist only in it (API - /// code 200302). With it true those photos are deleted too. + /// refuses to delete an album holding photos that exist only in it, with + /// [`ResponseCode::AlbumContainsPhotosNotInTimeline`](proton_sdk::api::ResponseCode::AlbumContainsPhotosNotInTimeline). + /// With it true those photos are deleted too; + /// [`delete_album_saving_photos`](Self::delete_album_saving_photos) keeps + /// them instead. pub async fn delete_album(&self, album_uid: &NodeUid, delete_photos: bool) -> Result<()> { self.drive.delete_album(album_uid, delete_photos).await } + /// Delete the album `album_uid`, first saving to the timeline the photos + /// that exist only in it, so no photo is lost. + /// + /// Mirrors C# `ProtonPhotosClient.DeleteAlbumAsync` with `saveToTimeline`. + /// The first photo that cannot be saved fails the call, and the album + /// stays. + pub async fn delete_album_saving_photos(&self, album_uid: &NodeUid) -> Result<()> { + self.drive.delete_album_saving_photos(album_uid).await + } + /// Take photos out of `album_uid`, one outcome per input photo in input /// order. The photos stay in the timeline; this is not a trash. /// /// Ported from the TypeScript SDK (`AlbumsManager.removePhotos`). The - /// server answers per batch of ten, so a failure fails its whole batch. + /// server answers per batch of ten, so a failure fails its whole batch. A + /// photo on another volume than the album fails without being sent. pub async fn remove_photos_from_album( &self, album_uid: &NodeUid, @@ -495,7 +518,8 @@ mod tests { #[test] fn add_photos_response_reports_missing_related_photos() { - // The per-link sub-responses of `albums/{lid}/add-multiple`: one plain + // The per-link sub-responses of `albums/{lid}/add-multiple`, which share + // the `transfer-multiple` shape: one plain // success, one failure naming the related photos the server wants sent // alongside the main one. let raw = r#"{ @@ -506,7 +530,7 @@ mod tests { } } ] }"#; - let parsed: crate::dtos::AddPhotosToAlbumResponse = serde_json::from_str(raw).unwrap(); + let parsed: crate::dtos::TransferPhotosResponse = serde_json::from_str(raw).unwrap(); assert_eq!(parsed.responses.len(), 2); assert_eq!(parsed.responses[0].response.code, 1000); assert!(parsed.responses[0].response.error.is_none()); diff --git a/crates/proton-drive-rs/tests/live_albums.rs b/crates/proton-drive-rs/tests/live_albums.rs new file mode 100644 index 0000000..30a39d6 --- /dev/null +++ b/crates/proton-drive-rs/tests/live_albums.rs @@ -0,0 +1,111 @@ +//! Live integration: album writes on the photos volume against a real Proton +//! account. +//! +//! Skipped by default, and skipped at run time when the account has no photos +//! volume. Run against the test account with: +//! cargo test -p proton-drive-rs --test live_albums -- --ignored --nocapture +//! +//! The test deletes the album and the photo it uploads. + +mod common; + +use proton_drive_rs::{PhotoUploadMetadata, ProtonPhotosClient}; + +/// add → add again → cover → remove → add → delete keeping photos, on a photo +/// that is also in the timeline. +#[tokio::test] +#[ignore = "live: needs test-account credentials"] +async fn album_add_cover_remove_and_delete_keeping_photos() { + let Some(live) = common::live_client().await else { + return; + }; + let photos = ProtonPhotosClient::from_drive_client(live.client.clone()); + if photos + .get_photos_root() + .await + .expect("photos root") + .is_none() + { + eprintln!("[albums] the account has no photos volume; skipping"); + return; + } + + let suffix = common::unique_suffix(); + let photo = photos + .upload_photo( + &format!("album-ops-{suffix}.jpg"), + "image/jpeg", + b"\xff\xd8\xff\xe0 not really a jpeg", + PhotoUploadMetadata { + capture_time: Some(1_700_000_000), + ..Default::default() + }, + ) + .await + .expect("upload photo"); + let album = photos + .create_album(&format!("album-ops-{suffix}")) + .await + .expect("create album"); + + let added = photos + .add_photos_to_album(&album, std::slice::from_ref(&photo)) + .await + .expect("add to album"); + assert_eq!(added.len(), 1); + assert!(added[0].1.is_ok(), "the photo is added: {:?}", added[0].1); + + let again = photos + .add_photos_to_album(&album, std::slice::from_ref(&photo)) + .await + .expect("add to album again"); + eprintln!("[albums] adding twice: {:?}", again[0].1); + assert!( + again[0].1.is_err(), + "a photo already in the album fails its outcome" + ); + + photos + .set_album_cover(&album, &photo) + .await + .expect("set album cover"); + let items = photos.enumerate_album(&album).await.expect("list album"); + assert!( + items.iter().any(|item| item.uid == photo), + "the album lists the photo" + ); + + let removed = photos + .remove_photos_from_album(&album, std::slice::from_ref(&photo)) + .await + .expect("remove from album"); + assert!( + removed[0].1.is_ok(), + "the photo is removed: {:?}", + removed[0].1 + ); + + photos + .add_photos_to_album(&album, std::slice::from_ref(&photo)) + .await + .expect("add to album once more"); + photos + .delete_album_saving_photos(&album) + .await + .expect("delete the album, keeping its photos"); + assert!( + photos.get_node(&photo).await.expect("read photo").is_some(), + "the photo outlives the album" + ); + + let drive = photos.drive_client(); + let uids = [photo]; + match drive.trash_nodes(&uids).await { + Ok(outcomes) => common::log_outcomes("trash", &outcomes), + Err(e) => eprintln!("[cleanup] trash failed: {e}"), + } + match drive.delete_nodes(&uids).await { + Ok(outcomes) => common::log_outcomes("delete", &outcomes), + Err(e) => eprintln!("[cleanup] delete failed: {e}"), + } +} diff --git a/crates/proton-sdk/src/api.rs b/crates/proton-sdk/src/api.rs index ce77978..82186e8 100644 --- a/crates/proton-sdk/src/api.rs +++ b/crates/proton-sdk/src/api.rs @@ -163,6 +163,9 @@ pub enum ResponseCode { InsufficientVolumeQuota = 200_100, TooManyChildren = 200_300, NestingTooDeep = 200_301, + /// An album cannot be deleted because some of its photos exist only in it; + /// `Details.ChildLinkIDs` names them. + AlbumContainsPhotosNotInTimeline = 200_302, } impl ResponseCode { @@ -204,6 +207,7 @@ impl ResponseCode { 200_100 => Self::InsufficientVolumeQuota, 200_300 => Self::TooManyChildren, 200_301 => Self::NestingTooDeep, + 200_302 => Self::AlbumContainsPhotosNotInTimeline, _ => Self::Unknown, } }