mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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.
This commit is contained in:
1 parent
c30b107382
commit
3da18b9309
6 files changed
+551
-363
No files matched your search
@@ -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<Vec<(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<NodeUid> = Vec::new();
|
||||
let mut entries: Vec<AddPhotoToAlbumEntry> = Vec::new();
|
||||
let mut queue: std::collections::VecDeque<NodeUid> = 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<Vec<AddPhotoToAlbumEntry>> {
|
||||
let node = self
|
||||
.get_photos_node(uid)
|
||||
.await?
|
||||
.ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?;
|
||||
|
||||
let related: Vec<NodeUid> = 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<AddPhotoToAlbumEntry> {
|
||||
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<String, String> = 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<String> = 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<Option<Result<()>>> = 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<usize> = (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<proton_sdk::api::ApiResponse> =
|
||||
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<Vec<(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<Vec<(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<PhotoTargetPayload> = Vec::new();
|
||||
let mut cross_volume: Vec<PhotoTargetPayload> = 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<PhotoTargetPayload> {
|
||||
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<NodeUid>)>,
|
||||
retried: &mut HashSet<NodeUid>,
|
||||
outcomes: &mut Vec<(NodeUid, Result<()>)>,
|
||||
) -> Result<()> {
|
||||
let response: TransferPhotosResponse = match target.kind {
|
||||
PhotoTransferKind::Timeline => {
|
||||
let mut links: Vec<TransferPhotoLinkItem> = 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<Vec<TransferPhotoLinkItem>> = 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<AddPhotoToAlbumEntry> = 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<NodeUid>)>,
|
||||
retried: &mut HashSet<NodeUid>,
|
||||
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<TransferPhotoLinkItem> = 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<Vec<TransferPhotoLinkItem>> = 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<NodeUid>)>,
|
||||
retried: &mut HashSet<NodeUid>,
|
||||
@@ -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<TransferPhotoLinkItem> {
|
||||
@@ -8723,6 +8710,25 @@ fn transfer_link_item(payload: &PhotoTargetPayload) -> Option<TransferPhotoLinkI
|
||||
})
|
||||
}
|
||||
|
||||
/// The photos an album delete was refused for, from the error envelope's
|
||||
/// `Details.ChildLinkIDs` (C# `DeleteAlbumFailureDetails`), on the album's
|
||||
/// volume.
|
||||
fn photos_only_in_album(error: &ProtonApiError, volume_id: &VolumeId) -> Vec<NodeUid> {
|
||||
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<NodeUid> {
|
||||
@@ -8744,7 +8750,7 @@ fn missing_related_links(error: &ProtonApiError, volume_id: &VolumeId) -> Vec<No
|
||||
fn missing_related_photos_error(missing: &[NodeUid]) -> ProtonError {
|
||||
let missing: Vec<String> = 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<NodeUid>, ProtonError)>,
|
||||
queue: &mut VecDeque<(NodeUid, Vec<NodeUid>)>,
|
||||
@@ -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<u8>,
|
||||
/// 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");
|
||||
|
||||
@@ -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<LinkId>,
|
||||
#[serde(rename = "Link")]
|
||||
#[serde(rename = "Link", skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<AlbumUpdateLink>,
|
||||
}
|
||||
|
||||
@@ -494,12 +495,6 @@ pub struct AddPhotoToAlbumEntry {
|
||||
pub content_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddPhotosToAlbumResponse {
|
||||
#[serde(rename = "Responses", default)]
|
||||
pub responses: Vec<PhotoLinkOutcome>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PhotoLinkOutcome {
|
||||
#[serde(rename = "LinkID")]
|
||||
@@ -566,6 +561,9 @@ pub struct TransferPhotoLinkItem {
|
||||
pub passphrase_signature: Option<String>,
|
||||
}
|
||||
|
||||
/// The per-link answers of `transfer-multiple` and of an album's
|
||||
/// `add-multiple`, which share one shape (C#
|
||||
/// `AggregateApiResponse<TransferPhotoResponsePair>`).
|
||||
#[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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user