feature(photos) implemented dups, import from takeout

This commit is contained in:
nvrl committed 2026-08-18 01:30:45 +02:00
1 parent 0cf760362a
commit 61117fd7dc
7 files changed
+712 -30

No files matched your search

Generated
+2 -2
View File
@@ -2072,7 +2072,7 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.6.0" version = "0.6.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
@@ -2097,7 +2097,7 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.6.0" version = "0.6.1"
dependencies = [ dependencies = [
"aes-gcm 0.11.0", "aes-gcm 0.11.0",
"async-trait", "async-trait",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/proton-sdk", "crates/proton-drive-rs"] members = ["crates/proton-sdk", "crates/proton-drive-rs"]
[workspace.package] [workspace.package]
version = "0.6.0" version = "0.6.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-sdk-rs" repository = "https://github.com/narrrl/proton-sdk-rs"
+1
View File
@@ -35,3 +35,4 @@ gitignored, so this file is the only durable record of what we last reviewed.
| 2026-08-01 | — (no upstream delta) | — | **not an upstream port.** Extended the TypeScript-sourced public-link consume side with a streaming surface that has **no TS or C# counterpart** and is ours: `open_revision` / `open_revision_at` / `download_range` / `download_thumbnail` / `enumerate_thumbnails` / `refresh_session` / `with_max_inflight_blocks` on `ProtonDrivePublicLinkClient`. Enabling change: `RevisionReader` was welded to `ProtonDriveClient`, so it now takes a `RevisionTransport` (new `transport.rs` — block permits + a route-prefixed `ApiHttpClient` + a `BlockSession` for renewal); `fetch_revision_blocks` and the block-size ranking moved there out of `client.rs`. Also `ApiHttpClient::without_token_refresh` (proton-sdk), because the anonymous session's refresh token is empty and the built-in 401 refresh masked the real 401. Perf on the visitor path: LRU node-key cache + iterative ancestor walk behind `SingleFlight` (was re-deriving the whole chain, at an S2K each, per child), `enumerate_nodes` chunked at 150 and fanned out (it did not chunk at all), `download_file_to` paged (a single un-paged GET truncated anything past 50 blocks) and concurrent. Live-verified against the test account, including the one uncertain fact the design rested on: the `drive/unauth/` revision response **does** carry `XAttr`, so a visitor resolves true per-block sizes and can seek. | | 2026-08-01 | — (no upstream delta) | — | **not an upstream port.** Extended the TypeScript-sourced public-link consume side with a streaming surface that has **no TS or C# counterpart** and is ours: `open_revision` / `open_revision_at` / `download_range` / `download_thumbnail` / `enumerate_thumbnails` / `refresh_session` / `with_max_inflight_blocks` on `ProtonDrivePublicLinkClient`. Enabling change: `RevisionReader` was welded to `ProtonDriveClient`, so it now takes a `RevisionTransport` (new `transport.rs` — block permits + a route-prefixed `ApiHttpClient` + a `BlockSession` for renewal); `fetch_revision_blocks` and the block-size ranking moved there out of `client.rs`. Also `ApiHttpClient::without_token_refresh` (proton-sdk), because the anonymous session's refresh token is empty and the built-in 401 refresh masked the real 401. Perf on the visitor path: LRU node-key cache + iterative ancestor walk behind `SingleFlight` (was re-deriving the whole chain, at an S2K each, per child), `enumerate_nodes` chunked at 150 and fanned out (it did not chunk at all), `download_file_to` paged (a single un-paged GET truncated anything past 50 blocks) and concurrent. Live-verified against the test account, including the one uncertain fact the design rested on: the `drive/unauth/` revision response **does** carry `XAttr`, so a visitor resolves true per-block sizes and can seek. |
| 2026-08-10 | `941aafa5` → `df6b6bfd` | `f493c5ee`, `62895702` | ported: **per-node trash/restore/delete outcomes, streamed**. Upstream turned `TrashNodesAsync` / `RestoreNodesAsync` / `DeleteNodesAsync` from `ValueTask<IReadOnlyDictionary<NodeUid, Result<Exception>>>` into `IAsyncEnumerable<NodeActionResult>`, and `UpdatePhotosAsync` into `IAsyncEnumerable<PhotoUpdateResult>`. Rust equivalent: `trash_nodes` / `restore_nodes` / `delete_nodes` now return `Vec<(NodeUid, Result<()>)>` (**breaking** — they returned `Result<()>` and failed the whole call through `check_aggregate`, which is now gone), plus `*_streaming` siblings returning `impl Stream<Item = Result<(NodeUid, Result<()>)>>` that yield each batch's outcomes as the batch lands; `update_photos_streaming` does the same for photo tag updates. Kept the `(NodeUid, Result<()>)` tuple used by `move_nodes` rather than adding `NodeActionResult` / `PhotoUpdateResult` record types — C# needs a named type for its iterators, Rust does not. Outcome mapping mirrors upstream exactly: only the links the aggregate envelope reports on produce an outcome, and the stream's outer `Err` (a request that never produced an envelope) ends it. diverged deliberately: upstream's rewrite also dropped its per-volume `Task.WhenAll`, so both sides are now sequential over volume groups — ours already was. not ported: `9dd63423` (explicit stream disposal through interop — CExports/proto only; Rust ownership drops readers/writers at scope end). | | 2026-08-10 | `941aafa5` → `df6b6bfd` | `f493c5ee`, `62895702` | ported: **per-node trash/restore/delete outcomes, streamed**. Upstream turned `TrashNodesAsync` / `RestoreNodesAsync` / `DeleteNodesAsync` from `ValueTask<IReadOnlyDictionary<NodeUid, Result<Exception>>>` into `IAsyncEnumerable<NodeActionResult>`, and `UpdatePhotosAsync` into `IAsyncEnumerable<PhotoUpdateResult>`. Rust equivalent: `trash_nodes` / `restore_nodes` / `delete_nodes` now return `Vec<(NodeUid, Result<()>)>` (**breaking** — they returned `Result<()>` and failed the whole call through `check_aggregate`, which is now gone), plus `*_streaming` siblings returning `impl Stream<Item = Result<(NodeUid, Result<()>)>>` that yield each batch's outcomes as the batch lands; `update_photos_streaming` does the same for photo tag updates. Kept the `(NodeUid, Result<()>)` tuple used by `move_nodes` rather than adding `NodeActionResult` / `PhotoUpdateResult` record types — C# needs a named type for its iterators, Rust does not. Outcome mapping mirrors upstream exactly: only the links the aggregate envelope reports on produce an outcome, and the stream's outer `Err` (a request that never produced an envelope) ends it. diverged deliberately: upstream's rewrite also dropped its per-volume `Task.WhenAll`, so both sides are now sequential over volume groups — ours already was. not ported: `9dd63423` (explicit stream disposal through interop — CExports/proto only; Rust ownership drops readers/writers at scope end). |
| 2026-08-10 | — (no upstream delta) | — | **not an upstream port.** `ProtonDriveClient::with_entity_repository` — the chainable form of `with_entity_cache`, added because that one is a *constructor* and so could not be combined with `with_key_salts`; a client that resumes a persisted session needs the salts and wants a persistent entity cache, and upstream C# has no equivalent tension (its cache is set on the client options object). Additive only: `with_entity_cache` and the in-memory default are unchanged. Consumed by `proton-drive-linux`, which now backs the entity cache with an encrypted SQLite store. | | 2026-08-10 | — (no upstream delta) | — | **not an upstream port.** `ProtonDriveClient::with_entity_repository` — the chainable form of `with_entity_cache`, added because that one is a *constructor* and so could not be combined with `with_key_salts`; a client that resumes a persisted session needs the salts and wants a persistent entity cache, and upstream C# has no equivalent tension (its cache is set on the client options object). Additive only: `with_entity_cache` and the in-memory default are unchanged. Consumed by `proton-drive-linux`, which now backs the entity cache with an encrypted SQLite store. |
| 2026-08-18 | — (no upstream delta) | — | **not an upstream port of C#** — ported from the **TypeScript** SDK, which is the only upstream with an album write API. Ported: **album creation** (`ProtonPhotosClient::create_album` — `POST photos/volumes/{vid}/albums`, from `AlbumsManager.createAlbum` + `AlbumsCryptoService.createAlbum`; an album is a folder node parented to the photos root, so the crypto is the folder create: generated node key, passphrase encrypted to the photos root key and signed by the photos share's membership address, plus the album's own child-name hash key, and TS's `Locked: false`) and **adding photos to an album** (`add_photos_to_album` — `POST photos/volumes/{vid}/albums/{lid}/add-multiple`, from `AddToAlbumProcess.processSameVolumeQueue` + `PhotoTransferPayloadBuilder`; per photo the name is re-encrypted to the album key, name/content hashes recomputed under the album hash key, and the node passphrase **rewrapped** via `rewrap_message_to` rather than re-encrypted from plaintext — the ciphertext and therefore the original `NodePassphraseSignature` survive, which is why the entry carries no signature of its own; related photos are flattened in beside their main photo, batches of 10 like TS's `ADD_PHOTOS_BATCH_SIZE`, one outcome per input photo in input order like `move_nodes`). Not ported: TS `copyPhoto` (adding a photo that lives on **another** volume, i.e. from an album shared with us — needs the cross-volume copy path; those inputs fail their own outcome), `updateAlbum` (rename / cover photo) and `deleteAlbum`. Also **ours, with no upstream counterpart**: `find_duplicates_many` and `name_collisions` — the duplicates endpoint already takes a `NameHashes` array but both SDKs only ever send one, and a Google Takeout import asks the question tens of thousands of times; `name_collisions` answers on the name hash alone so the caller only has to read and SHA-1 the files whose names actually collide. Live validation pending: everything here needs a photos-volume account. |
+1 -1
View File
@@ -14,7 +14,7 @@ readme = "../../README.md"
documentation = "https://docs.rs/proton-drive-rs" documentation = "https://docs.rs/proton-drive-rs"
[dependencies] [dependencies]
proton-sdk = { path = "../proton-sdk", version = "0.6.0" } proton-sdk = { path = "../proton-sdk", version = "0.6.1" }
tokio.workspace = true tokio.workspace = true
reqwest.workspace = true reqwest.workspace = true
serde.workspace = true serde.workspace = true
+505 -26
View File
@@ -44,32 +44,34 @@ use crate::crypto::{
}; };
use crate::devices::{Device, DeviceMetadata, DeviceType}; use crate::devices::{Device, DeviceMetadata, DeviceType};
use crate::dtos::{ use crate::dtos::{
AcceptInvitationRequest, AggregateLinksResponse, AlbumItemListResponse, AlbumListResponse, AcceptInvitationRequest, AddPhotoToAlbumEntry, AddPhotosToAlbumRequest,
BlockCreationRequest, BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, AddPhotosToAlbumResponse, AggregateLinksResponse, AlbumCreationLink, AlbumCreationRequest,
BlockUploadTarget, BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, AlbumCreationResponse, AlbumItemListResponse, AlbumListResponse, BlockCreationRequest,
BookmarksResponse, CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest, BlockDto, BlockUploadPreparationRequest, BlockUploadPreparationResponse, BlockUploadTarget,
CreatePublicLinkRequest, CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, BlockVerificationInputResponse, BlockVerifier, BookmarkShareUrlDto, BookmarksResponse,
DeviceCreationDeviceDto, DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, CommonExtendedAttributes, ContextShareResponse, CreateBookmarkRequest, CreatePublicLinkRequest,
DeviceCreationShareDto, DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, CreatePublicLinkResponse, CreateShareRequest, CreateShareResponse, DeviceCreationDeviceDto,
ExtendedAttributes, ExternalInvitationDto, ExternalInvitationResponseDto, DeviceCreationLinkDto, DeviceCreationRequest, DeviceCreationResponse, DeviceCreationShareDto,
ExternalInvitationsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse, DeviceListResponse, DeviceUpdateRequest, DeviceUpdateShareDto, ExtendedAttributes,
FindPhotoDuplicatesRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse, ExternalInvitationDto, ExternalInvitationResponseDto, ExternalInvitationsResponse,
FolderCreationRequest, FolderCreationResponse, InvitationDetailsResponse, FileContentDigests, FileCreationRequest, FileCreationResponse, FindPhotoDuplicatesRequest,
InvitationsListResponse, InviteEmailDetailsDto, InviteExternalUserRequest, FindPhotoDuplicatesResponse, FolderChildrenResponse, FolderCreationRequest,
InviteExternalUserResponse, InviteProtonUserInvitationDto, InviteProtonUserRequest, FolderCreationResponse, InvitationDetailsResponse, InvitationsListResponse,
InviteProtonUserResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, InviteEmailDetailsDto, InviteExternalUserRequest, InviteExternalUserResponse,
LinkDetailsResponse, LinkDto, LinkType, ModulusResponse, MoveLinkRequest, InviteProtonUserInvitationDto, InviteProtonUserRequest, InviteProtonUserResponse,
MoveMultipleLinksItem, MoveMultipleLinksRequest, MultipleLinksRequest, MyFilesShareResponse, LatestVolumeEventResponse, LinkDetailsDto, LinkDetailsRequest, LinkDetailsResponse, LinkDto,
NodeNameAvailabilityRequest, NodeNameAvailabilityResponse, PhotoTagsRequest, LinkType, ModulusResponse, MoveLinkRequest, MoveMultipleLinksItem, MoveMultipleLinksRequest,
PhotosAttributesDto, RenameLinkRequest, RevisionConflict, RevisionCreationRequest, MultipleLinksRequest, MyFilesShareResponse, NodeNameAvailabilityRequest,
RevisionCreationResponse, RevisionDto, RevisionListItemDto, RevisionListResponse, NodeNameAvailabilityResponse, PhotoTagsRequest, PhotosAttributesDto, RenameLinkRequest,
RevisionMetadataResponse, RevisionUpdateRequest, ShareInvitationDto, ShareInvitationsResponse, RevisionConflict, RevisionCreationRequest, RevisionCreationResponse, RevisionDto,
ShareMembersResponse, ShareMembershipSummaryDto, ShareResponse, ShareTargetType, ShareUrlDto, RevisionListItemDto, RevisionListResponse, RevisionMetadataResponse, RevisionUpdateRequest,
ShareUrlsResponse, SharedAlbumsResponse, SharedByMeResponse, SharedWithMeResponse, ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto,
SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse, ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedAlbumsResponse,
ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, SharedByMeResponse, SharedWithMeResponse, SmallFileUploadMetadataRequest,
TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, SmallRevisionUploadMetadataRequest, SmallUploadResponse, ThumbnailBlockListRequest,
VolumeEventListResponse, VolumeTrashResponse, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse,
UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse,
VolumeTrashResponse,
}; };
use crate::events::{DriveEvent, DriveEventScopeId}; use crate::events::{DriveEvent, DriveEventScopeId};
use crate::node::{ use crate::node::{
@@ -5616,6 +5618,483 @@ impl ProtonDriveClient {
.collect()) .collect())
} }
/// Batch form of [`find_photo_duplicates`](Self::find_photo_duplicates):
/// one `POST volumes/{vid}/photos/duplicates` per [`DUPLICATE_BATCH`] input
/// pairs instead of one per photo.
///
/// Each input is `(name, sha1_hex)` — the *plaintext* SHA-1 of the photo's
/// contents, lowercase hex, which the caller usually already computed while
/// reading the file. The result is one match list per input, in input order.
///
/// Not an upstream port: the TS/C# SDKs only expose the one-photo call, but
/// the endpoint already takes a `NameHashes` array, and a Google Takeout
/// import asks this question tens of thousands of times.
pub(crate) async fn find_photo_duplicates_many(
&self,
items: &[(String, String)],
) -> Result<Vec<Vec<NodeUid>>> {
const ACTIVE_LINK_STATE: i32 = 1;
/// Name hashes per duplicates request.
const DUPLICATE_BATCH: usize = 150;
let mut out = vec![Vec::new(); items.len()];
if items.is_empty() {
return Ok(out);
}
if !self.ensure_photos().await? {
return Err(ProtonError::invalid_operation(
"account has no photos volume",
));
}
let photos_root = self
.cache
.lock()
.await
.photos_root
.clone()
.expect("ensure_photos populated the photos root");
let root_key = self.folder_node_key(&photos_root).await?;
let hash_key = self
.parent_hash_key_ctx(&photos_root, &root_key, true)
.await?;
// Two inputs can share a name (the same photo under two Takeout album
// folders), so a name hash maps to *every* input position that produced
// it, and each position is matched on its own content hash.
let mut positions: HashMap<String, Vec<usize>> = HashMap::new();
let mut name_hashes: Vec<String> = Vec::new();
let mut content_hashes: Vec<String> = Vec::with_capacity(items.len());
for (index, (name, sha1_hex)) in items.iter().enumerate() {
let name_hash = hex::encode(hmac_sha256(&hash_key, name.as_bytes()));
positions
.entry(name_hash.clone())
.or_insert_with(|| {
name_hashes.push(name_hash.clone());
Vec::new()
})
.push(index);
content_hashes.push(hex::encode(hmac_sha256(
&hash_key,
sha1_hex.to_ascii_lowercase().as_bytes(),
)));
}
for chunk in name_hashes.chunks(DUPLICATE_BATCH) {
let path = format!("volumes/{}/photos/duplicates", photos_root.volume_id);
let response: FindPhotoDuplicatesResponse = self
.http
.post(
&path,
&FindPhotoDuplicatesRequest {
name_hashes: chunk.to_vec(),
},
)
.await?;
for duplicate in response.duplicate_hashes {
let Some(link_id) = duplicate.link_id else {
continue;
};
if duplicate.link_state != Some(ACTIVE_LINK_STATE)
|| duplicate.name_hash.is_empty()
|| duplicate.content_hash.is_empty()
{
continue;
}
// The server echoes the name hash it matched; find the inputs
// that asked for it and keep the ones whose content agrees too.
let Some(indices) = positions.get(&duplicate.name_hash.to_ascii_lowercase()) else {
continue;
};
for &index in indices {
if duplicate
.content_hash
.eq_ignore_ascii_case(&content_hashes[index])
{
out[index]
.push(NodeUid::new(photos_root.volume_id.clone(), link_id.clone()));
}
}
}
}
Ok(out)
}
/// Which of `names` any *active* photo on the volume already carries.
///
/// The cheap half of [`find_photo_duplicates_many`](Self::find_photo_duplicates_many):
/// it matches on the name hash alone, so it needs no content digest and
/// therefore no reading of the candidate files. A `true` is "maybe a
/// duplicate, hash it and ask again"; a `false` is conclusive.
pub(crate) async fn photo_name_collisions(&self, names: &[String]) -> Result<Vec<bool>> {
const ACTIVE_LINK_STATE: i32 = 1;
const DUPLICATE_BATCH: usize = 150;
let mut out = vec![false; names.len()];
if names.is_empty() {
return Ok(out);
}
if !self.ensure_photos().await? {
return Err(ProtonError::invalid_operation(
"account has no photos volume",
));
}
let photos_root = self
.cache
.lock()
.await
.photos_root
.clone()
.expect("ensure_photos populated the photos root");
let root_key = self.folder_node_key(&photos_root).await?;
let hash_key = self
.parent_hash_key_ctx(&photos_root, &root_key, true)
.await?;
let mut positions: HashMap<String, Vec<usize>> = HashMap::new();
let mut name_hashes: Vec<String> = Vec::new();
for (index, name) in names.iter().enumerate() {
let name_hash = hex::encode(hmac_sha256(&hash_key, name.as_bytes()));
positions
.entry(name_hash.clone())
.or_insert_with(|| {
name_hashes.push(name_hash.clone());
Vec::new()
})
.push(index);
}
for chunk in name_hashes.chunks(DUPLICATE_BATCH) {
let path = format!("volumes/{}/photos/duplicates", photos_root.volume_id);
let response: FindPhotoDuplicatesResponse = self
.http
.post(
&path,
&FindPhotoDuplicatesRequest {
name_hashes: chunk.to_vec(),
},
)
.await?;
for duplicate in response.duplicate_hashes {
if duplicate.link_id.is_none() || duplicate.link_state != Some(ACTIVE_LINK_STATE) {
continue;
}
if let Some(indices) = positions.get(&duplicate.name_hash.to_ascii_lowercase()) {
for &index in indices {
out[index] = true;
}
}
}
}
Ok(out)
}
/// Create an album named `name` on the account's photos volume.
///
/// Ported from the TypeScript SDK (`AlbumsManager.createAlbum` +
/// `AlbumsCryptoService.createAlbum`); C# exposes no album write API. An
/// album is a folder node parented to the photos root, so the crypto is the
/// folder create: a generated node key locked to a fresh passphrase that is
/// encrypted to the photos root key and signed by the photos share's
/// membership address, plus the album's own child-name hash key.
pub(crate) async fn create_album(&self, name: &str) -> Result<NodeUid> {
let mut timer = self.telemetry.start("create_album");
validate_node_name(name)?;
if !self.ensure_photos().await? {
return Err(ProtonError::invalid_operation(
"account has no photos volume",
));
}
let photos_root = self
.cache
.lock()
.await
.photos_root
.clone()
.expect("ensure_photos populated the photos root");
let root_key = self.folder_node_key(&photos_root).await?;
let root_hash_key = self
.parent_hash_key_ctx(&photos_root, &root_key, true)
.await?;
let (_address_id, email, signing_key) = self.photos_membership_address().await?;
let node = generate_node_key()?;
let node_hash_key = generate_node_hash_key(&node.key)?;
let request = AlbumCreationRequest {
locked: false,
link: AlbumCreationLink {
name: root_key.encrypt_and_sign(&signing_key, name.as_bytes(), true, false)?,
name_hash: hex::encode(hmac_sha256(&root_hash_key, name.as_bytes())),
key: node.locked_armored,
passphrase: root_key.encrypt(&node.passphrase)?,
passphrase_signature: signing_key.sign_detached(&node.passphrase)?,
signature_email: email,
node_hash_key,
extended_attributes: None,
},
};
let path = format!("photos/volumes/{}/albums", photos_root.volume_id);
let created: AlbumCreationResponse = self.http.post(&path, &request).await?;
timer.success();
Ok(NodeUid::new(
photos_root.volume_id,
created.album.link.link_id,
))
}
/// Add photos that already live on the album's volume to `album_uid`,
/// returning one outcome per input photo in input order.
///
/// 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.
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());
if photo_uids.is_empty() {
timer.success();
return Ok(outcomes);
}
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 (_address_id, email, signing_key) = self.photos_membership_address().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?;
}
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.
///
/// The passphrase is *rewrapped*, not re-encrypted from plaintext: only its
/// key packet changes recipient, so the locked node key still unlocks and
/// the original `NodePassphraseSignature` stays valid (which is why the
/// entry carries no signature of its own — TS sends one only for photos
/// whose key author is anonymous).
async fn album_photo_entry(
&self,
uid: &NodeUid,
album_key: &PrivateKey,
album_hash_key: &[u8],
email: &str,
signing_key: &PrivateKey,
) -> Result<AddPhotoToAlbumEntry> {
let node = self
.get_photos_node(uid)
.await?
.ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?;
let NodeKind::File { content_sha1, .. } = &node.kind else {
return Err(ProtonError::invalid_operation(format!(
"{uid} is not a photo file"
)));
};
let sha1 = content_sha1.clone().ok_or_else(|| {
ProtonError::invalid_operation(format!(
"photo {uid} has no content digest; it cannot be added to an album"
))
})?;
let details = self
.get_link_details_ctx(&uid.volume_id, std::slice::from_ref(&uid.link_id), true)
.await?;
let link = &details
.links
.first()
.ok_or_else(|| ProtonError::invalid_operation(format!("photo {uid} not found")))?
.link;
let parent_key = self
.resolve_parent_key_ctx(&uid.volume_id, link, true)
.await?;
let passphrase = parent_key.rewrap_message_to(&link.passphrase, album_key)?;
Ok(AddPhotoToAlbumEntry {
link_id: uid.link_id.clone(),
name_hash: hex::encode(hmac_sha256(album_hash_key, node.name.as_bytes())),
name: album_key.encrypt_and_sign(signing_key, node.name.as_bytes(), true, false)?,
name_signature_email: email.to_string(),
passphrase,
content_hash: hex::encode(hmac_sha256(
album_hash_key,
sha1.to_ascii_lowercase().as_bytes(),
)),
})
}
/// 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(())
}
/// Page the albums on the account's photos volume. /// Page the albums on the account's photos volume.
/// ///
/// Mirrors C# `PhotosNodeOperations.EnumerateAlbumNodeUidsAsync`: /// Mirrors C# `PhotosNodeOperations.EnumerateAlbumNodeUidsAsync`:
+114
View File
@@ -327,6 +327,120 @@ pub struct SharedAlbumDto {
pub link_id: LinkId, pub link_id: LinkId,
} }
/// `POST photos/volumes/{vid}/albums` — create an album. Ported from the
/// TypeScript SDK (`PhotosAPIService.createAlbum`); C# has no public album
/// write API. The album is a folder node on the photos volume, so its `Link`
/// block carries exactly what a folder create does.
#[derive(Debug, Serialize)]
pub struct AlbumCreationRequest {
/// TS sends `Locked: false` — a locked album is one that cannot take new
/// photos, which a freshly created album never is.
#[serde(rename = "Locked")]
pub locked: bool,
#[serde(rename = "Link")]
pub link: AlbumCreationLink,
}
#[derive(Debug, Serialize)]
pub struct AlbumCreationLink {
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Hash")]
pub name_hash: String,
#[serde(rename = "NodeKey")]
pub key: String,
#[serde(rename = "NodePassphrase")]
pub passphrase: String,
#[serde(rename = "NodePassphraseSignature")]
pub passphrase_signature: String,
#[serde(rename = "SignatureEmail")]
pub signature_email: String,
#[serde(rename = "NodeHashKey")]
pub node_hash_key: String,
#[serde(rename = "XAttr")]
pub extended_attributes: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AlbumCreationResponse {
#[serde(rename = "Album")]
pub album: AlbumCreationAlbum,
}
#[derive(Debug, Deserialize)]
pub struct AlbumCreationAlbum {
#[serde(rename = "Link")]
pub link: AlbumCreationLinkResponse,
}
#[derive(Debug, Deserialize)]
pub struct AlbumCreationLinkResponse {
#[serde(rename = "LinkID")]
pub link_id: LinkId,
}
/// `POST photos/volumes/{vid}/albums/{lid}/add-multiple` — add photos that
/// already live on the album's own volume. Ported from the TypeScript SDK
/// (`PhotosAPIService.addPhotosToAlbum`). Each entry re-encrypts one photo's
/// name and passphrase to the album key; a main photo's related photos are
/// flattened into the same list.
#[derive(Debug, Serialize)]
pub struct AddPhotosToAlbumRequest {
#[serde(rename = "AlbumData")]
pub album_data: Vec<AddPhotoToAlbumEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AddPhotoToAlbumEntry {
#[serde(rename = "LinkID")]
pub link_id: LinkId,
/// Name hash under the *album's* hash key.
#[serde(rename = "Hash")]
pub name_hash: String,
/// The photo's name re-encrypted to the album key.
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "NameSignatureEmail")]
pub name_signature_email: String,
/// The photo's node passphrase re-encrypted to the album key.
#[serde(rename = "NodePassphrase")]
pub passphrase: String,
/// HMAC of the plaintext SHA-1 under the album's hash key.
#[serde(rename = "ContentHash")]
pub content_hash: String,
}
#[derive(Debug, Deserialize)]
pub struct AddPhotosToAlbumResponse {
#[serde(rename = "Responses", default)]
pub responses: Vec<AddPhotoToAlbumOutcome>,
}
#[derive(Debug, Deserialize)]
pub struct AddPhotoToAlbumOutcome {
#[serde(rename = "LinkID")]
pub link_id: LinkId,
#[serde(rename = "Response")]
pub response: AddPhotoToAlbumOutcomeBody,
}
#[derive(Debug, Deserialize)]
pub struct AddPhotoToAlbumOutcomeBody {
#[serde(rename = "Code", default)]
pub code: i32,
#[serde(rename = "Error", default)]
pub error: Option<String>,
/// Carries `Missing` — related photos the server demands alongside this one.
#[serde(rename = "Details", default)]
pub details: Option<AddPhotoToAlbumOutcomeDetails>,
}
#[derive(Debug, Deserialize)]
pub struct AddPhotoToAlbumOutcomeDetails {
#[serde(rename = "Missing", default)]
pub missing: Vec<LinkId>,
}
/// `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` (C# `PhotoTagsRequest`). /// `POST`/`DELETE photos/volumes/{vid}/links/{lid}/tags` (C# `PhotoTagsRequest`).
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct PhotoTagsRequest { pub struct PhotoTagsRequest {
+88
View File
@@ -258,6 +258,62 @@ impl ProtonPhotosClient {
self.drive.find_photo_duplicates(name, contents).await self.drive.find_photo_duplicates(name, contents).await
} }
/// Batch [`find_duplicates`](Self::find_duplicates) over many photos the
/// caller has already hashed: each input is `(name, sha1_hex)` — the
/// lowercase-hex plaintext SHA-1 of that photo's contents — and the result
/// holds one match list per input, in input order.
///
/// Prefer this when checking a whole library (a Google Takeout import): it
/// asks the duplicates endpoint about 150 names per request rather than one,
/// and never needs the photo bytes in memory.
pub async fn find_duplicates_many(
&self,
items: &[(String, String)],
) -> Result<Vec<Vec<NodeUid>>> {
self.drive.find_photo_duplicates_many(items).await
}
/// Which of `names` an active photo on the volume already carries — the
/// name-hash-only prefilter in front of
/// [`find_duplicates_many`](Self::find_duplicates_many).
///
/// A `true` means "there is a same-named photo, so hash this file and check
/// its content too"; a `false` is conclusive and saves reading the file at
/// all. Importing a photo library is mostly `false`s.
pub async fn name_collisions(&self, names: &[String]) -> Result<Vec<bool>> {
self.drive.photo_name_collisions(names).await
}
/// Create an album named `name` on the account's photos volume, returning
/// its [`NodeUid`].
///
/// Ported from the TypeScript SDK (`AlbumsManager.createAlbum`) — C# has no
/// public album write API. Errors when the account has no photos volume;
/// the name is *not* deduplicated, so two albums may share a name unless the
/// caller checks [`enumerate_album_node_uids`](Self::enumerate_album_node_uids)
/// first.
pub async fn create_album(&self, name: &str) -> Result<NodeUid> {
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).
///
/// 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.
pub async fn add_photos_to_album(
&self,
album_uid: &NodeUid,
photo_uids: &[NodeUid],
) -> Result<Vec<(NodeUid, Result<()>)>> {
self.drive.add_photos_to_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
@@ -358,6 +414,38 @@ mod tests {
assert_eq!(parsed.photos[1].content_hash.as_deref(), Some("ff")); assert_eq!(parsed.photos[1].content_hash.as_deref(), Some("ff"));
} }
#[test]
fn album_creation_response_deserializes_server_shape() {
let raw = r#"{ "Album": { "Link": { "LinkID": "album-1" } } }"#;
let parsed: crate::dtos::AlbumCreationResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.album.link.link_id.to_string(), "album-1");
}
#[test]
fn add_photos_response_reports_missing_related_photos() {
// The per-link sub-responses of `albums/{lid}/add-multiple`: one plain
// success, one failure naming the related photos the server wants sent
// alongside the main one.
let raw = r#"{
"Responses": [
{ "LinkID": "photo-1", "Response": { "Code": 1000 } },
{ "LinkID": "photo-2", "Response": {
"Code": 2000, "Error": "missing", "Details": { "Missing": ["photo-3"] }
} }
]
}"#;
let parsed: crate::dtos::AddPhotosToAlbumResponse = 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());
let failed = &parsed.responses[1].response;
assert_eq!(failed.error.as_deref(), Some("missing"));
assert_eq!(
failed.details.as_ref().unwrap().missing[0].to_string(),
"photo-3"
);
}
#[test] #[test]
fn duplicate_response_deserializes_server_shape() { fn duplicate_response_deserializes_server_shape() {
let raw = r#"{ let raw = r#"{