feat(drive): album rename, delete and remove photos

Port AlbumsManager.updateAlbum (name only), deleteAlbum and removePhotos
from the TypeScript SDK: PUT photos/volumes/{vid}/albums/{lid},
DELETE ...?DeleteAlbumPhotos= and POST .../remove-multiple in batches of
ten.
This commit is contained in:
nvrl committed 2026-09-23 03:15:10 +02:00
1 parent 1f76f33d49
commit 931a58794b
3 files changed
+248 -20

No files matched your search

+146 -20
View File
@@ -47,16 +47,17 @@ use crate::devices::{Device, DeviceMetadata, DeviceType};
use crate::dtos::{ use crate::dtos::{
AcceptInvitationRequest, AddPhotoToAlbumEntry, AddPhotosToAlbumRequest, AcceptInvitationRequest, AddPhotoToAlbumEntry, AddPhotosToAlbumRequest,
AddPhotosToAlbumResponse, AggregateLinksResponse, AlbumCreationLink, AlbumCreationRequest, AddPhotosToAlbumResponse, AggregateLinksResponse, AlbumCreationLink, AlbumCreationRequest,
AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, BlockCreationRequest, AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, AlbumUpdateLink,
BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget, AlbumUpdateRequest, BlockCreationRequest, BlockDto, BlockUploadPreparationRequest,
BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, BlockUploadPreparationResponse, BlockUploadTarget, BlockVerificationInputResponse,
CommonExtendedAttributes, ContextShareResponse, CopyPhotoContent, CopyPhotoRelatedItem, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse, CommonExtendedAttributes,
CopyPhotoRequest, CopyPhotoResponse, CreateBookmarkRequest, CreatePublicLinkRequest, ContextShareResponse, CopyPhotoContent, CopyPhotoRelatedItem, CopyPhotoRequest,
CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto, CopyPhotoResponse, CreateBookmarkRequest, CreatePublicLinkRequest, CreatePublicLinkResponse,
DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto, DeviceCreationLinkDto,
DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto, DeviceListResponse,
ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes, ExternalInvitationDto,
FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest, ExternalInvitationResponseDto, ExternalInvitationsResponse, FileContentDigests,
FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest,
FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest,
FolderCreationResponse, InvitationDetailsResponse, InvitationsListResponse, FolderCreationResponse, InvitationDetailsResponse, InvitationsListResponse,
InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse, InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse,
@@ -64,16 +65,17 @@ use crate::dtos::{
LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto,
LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest, LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest,
MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest, MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest,
NodeNameAvailabilityResponse, PhotoTagsRequest, PhotosAttributesDto, RenameLinkRequest, NodeNameAvailabilityResponse, PhotoTagsRequest, PhotosAttributesDto,
RevisionConflict, RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RemovePhotosFromAlbumRequest, RenameLinkRequest, RevisionConflict, RevisionCreationRequest,
RevisionListItemDto, RevisionListResponse, RevisionMetadataResponse, RevisionUpdateRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse,
ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto, RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse,
ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedAlbumsResponse, ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse, ShareTargetType, ShareUrlDto,
SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest, ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse, SharedWithMeResponse,
SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse,
ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto,
TransferPhotoLinkItem, TransferPhotosRequest, TransferPhotosResponse, UpdatePermissionsRequest, TimelinePhotoListResponse, TransferPhotoLinkItem, TransferPhotosRequest,
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse, TransferPhotosResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto,
VolumeEventListResponse, VolumeTrashResponse,
}; };
use crate::events::{DriveEvent, DriveEventScopeId}; use crate::events::{DriveEvent, DriveEventScopeId};
use crate::node::{ use crate::node::{
@@ -6293,6 +6295,130 @@ impl ProtonDriveClient {
Ok(()) Ok(())
} }
/// Rename the album `album_uid` to `name`.
///
/// Ported from the TypeScript SDK (`AlbumsManager.updateAlbum` +
/// `AlbumsCryptoService.renameAlbum`). An album's name is encrypted to the
/// photos root like any child name, so this is a rename sent to the album
/// endpoint: the new name encrypted and signed by the photos share's
/// membership address, its hash under the root's hash key, and the current
/// hash as `OriginalHash`. The cover photo is left alone.
pub(crate) async fn rename_album(&self, album_uid: &NodeUid, name: &str) -> Result<()> {
let mut timer = self.telemetry.start("rename_album");
validate_node_name(name)?;
let details = self
.get_link_details_ctx(
&album_uid.volume_id,
std::slice::from_ref(&album_uid.link_id),
true,
)
.await?;
let link = details
.links
.into_iter()
.next()
.ok_or_else(|| ProtonError::invalid_operation(format!("album {album_uid} not found")))?
.link;
let parent_id = link
.parent_id
.clone()
.ok_or_else(|| ProtonError::invalid_operation("an album has a parent"))?;
let parent_uid = NodeUid::new(album_uid.volume_id.clone(), parent_id);
let parent_key = self.folder_node_key(&parent_uid).await?;
let parent_hash_key = self
.parent_hash_key_ctx(&parent_uid, &parent_key, true)
.await?;
let (_address_id, email, signing_key) = self.photos_membership_address().await?;
let original_hash = self
.original_name_hash(album_uid, &link, &parent_key, &parent_hash_key)
.await?;
let request = AlbumUpdateRequest {
cover_link_id: None,
link: Some(AlbumUpdateLink {
name: parent_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?,
name_hash: hex::encode(hmac_sha256(&parent_hash_key, name.as_bytes())),
original_hash,
name_signature_email: email,
}),
};
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.
pub(crate) async fn delete_album(
&self,
album_uid: &NodeUid,
delete_photos: bool,
) -> Result<()> {
let mut timer = self.telemetry.start("delete_album");
let path = format!(
"photos/volumes/{}/albums/{}?DeleteAlbumPhotos={}",
album_uid.volume_id,
album_uid.link_id,
u8::from(delete_photos)
);
let _: proton_sdk::api::ApiResponse = self.http.delete(&path).await?;
timer.success();
Ok(())
}
/// Take photos out of `album_uid`, one outcome per input photo in input
/// order. The photos stay in the timeline.
///
/// Ported from the TypeScript SDK (`PhotosAPIService.removePhotosFromAlbum`):
/// batches of ten, and since the server reports no per-photo result, a
/// failed batch fails every photo in it.
pub(crate) async fn remove_photos_from_album(
&self,
album_uid: &NodeUid,
photo_uids: &[NodeUid],
) -> Result<Vec<(NodeUid, Result<()>)>> {
/// Photos per `remove-multiple` request (TS `batchSize`).
const REMOVE_BATCH: usize = 10;
let mut timer = self.telemetry.start("remove_photos_from_album");
let path = format!(
"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) {
let request = RemovePhotosFromAlbumRequest {
link_ids: batch.iter().map(|uid| uid.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())),
)
}));
}
}
}
timer.success();
Ok(outcomes)
}
/// Copy photos into this account's own timeline, keeping them even after /// Copy photos into this account's own timeline, keeping them even after
/// the originals stop being shared. One outcome per input photo. /// the originals stop being shared. One outcome per input photo.
/// ///
+69
View File
@@ -391,6 +391,39 @@ pub struct AlbumCreationLinkResponse {
pub link_id: LinkId, pub link_id: LinkId,
} }
/// `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.
#[derive(Debug, Serialize)]
pub struct AlbumUpdateRequest {
#[serde(rename = "CoverLinkID")]
pub cover_link_id: Option<LinkId>,
#[serde(rename = "Link")]
pub link: Option<AlbumUpdateLink>,
}
/// The new name of an album, encrypted to the photos root like a rename.
#[derive(Debug, Serialize)]
pub struct AlbumUpdateLink {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Hash")]
pub name_hash: String,
#[serde(rename = "OriginalHash")]
pub original_hash: String,
#[serde(rename = "NameSignatureEmail")]
pub name_signature_email: String,
}
/// `POST photos/volumes/{vid}/albums/{lid}/remove-multiple` — take photos out
/// of an album; they stay in the timeline. Ported from the TypeScript SDK
/// (`PhotosAPIService.removePhotosFromAlbum`).
#[derive(Debug, Serialize)]
pub struct RemovePhotosFromAlbumRequest {
#[serde(rename = "LinkIDs")]
pub link_ids: Vec<LinkId>,
}
/// `POST photos/volumes/{vid}/albums/{lid}/add-multiple` — add photos that /// `POST photos/volumes/{vid}/albums/{lid}/add-multiple` — add photos that
/// already live on the album's own volume. Ported from the TypeScript SDK /// already live on the album's own volume. Ported from the TypeScript SDK
/// (`PhotosAPIService.addPhotosToAlbum`). Each entry re-encrypts one photo's /// (`PhotosAPIService.addPhotosToAlbum`). Each entry re-encrypts one photo's
@@ -1665,6 +1698,42 @@ pub struct DeviceUpdateShareDto {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn album_rename_sends_the_name_and_no_cover() {
let request = AlbumUpdateRequest {
cover_link_id: None,
link: Some(AlbumUpdateLink {
name: "armored".to_string(),
name_hash: "new".to_string(),
original_hash: "old".to_string(),
name_signature_email: "me@example.com".to_string(),
}),
};
assert_eq!(
serde_json::to_value(&request).expect("album update"),
serde_json::json!({
"CoverLinkID": null,
"Link": {
"Name": "armored",
"Hash": "new",
"OriginalHash": "old",
"NameSignatureEmail": "me@example.com",
},
})
);
}
#[test]
fn album_removal_lists_link_ids() {
let request = RemovePhotosFromAlbumRequest {
link_ids: vec![LinkId::new("a"), LinkId::new("b")],
};
assert_eq!(
serde_json::to_value(&request).expect("album removal"),
serde_json::json!({ "LinkIDs": ["a", "b"] })
);
}
#[test] #[test]
fn context_share_response_parses_required_share_id() { fn context_share_response_parses_required_share_id() {
let response: ContextShareResponse = let response: ContextShareResponse =
+33
View File
@@ -315,6 +315,39 @@ impl ProtonPhotosClient {
self.drive.add_photos_to_album(album_uid, photo_uids).await self.drive.add_photos_to_album(album_uid, photo_uids).await
} }
/// Rename the album `album_uid` to `name`.
///
/// Ported from the TypeScript SDK (`AlbumsManager.updateAlbum`). Like
/// [`create_album`](Self::create_album), the name is not deduplicated.
pub async fn rename_album(&self, album_uid: &NodeUid, name: &str) -> Result<()> {
self.drive.rename_album(album_uid, name).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.
pub async fn delete_album(&self, album_uid: &NodeUid, delete_photos: bool) -> Result<()> {
self.drive.delete_album(album_uid, delete_photos).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.
pub async fn remove_photos_from_album(
&self,
album_uid: &NodeUid,
photo_uids: &[NodeUid],
) -> Result<Vec<(NodeUid, Result<()>)>> {
self.drive
.remove_photos_from_album(album_uid, photo_uids)
.await
}
/// The albums on the account's photos volume, as [`NodeUid`]s. /// The albums on the account's photos volume, as [`NodeUid`]s.
/// C# `ProtonPhotosClient.EnumerateAlbumNodeUidsAsync`. Empty when the /// C# `ProtonPhotosClient.EnumerateAlbumNodeUidsAsync`. Empty when the
/// account has no photos volume. Materialize with /// account has no photos volume. Materialize with