//! The high-level Proton **Photos** client (Rust port of `ProtonPhotosClient`). //! //! Wraps a [`ProtonDriveClient`] and routes node/revision lookups through the //! photos endpoints (`v2/shares/photos`, `photos/volumes/{vid}/links`, //! `volumes/{vid}/photos`). This milestone covers the read surface: resolving //! the photos root, enumerating the timeline, fetching photo node metadata, //! downloading photo content and **uploading** photos //! ([`upload_photo`](ProtonPhotosClient::upload_photo)). Photos-volume //! **creation** reuses the (large) volume-create crypto and is not yet ported; //! Duplicate detection compares both the encrypted-name digest and the //! content SHA-1 through [`find_duplicates`](ProtonPhotosClient::find_duplicates). use std::io::{Cursor, Read}; use serde::{Deserialize, Serialize}; use proton_sdk::error::Result; use proton_sdk::ids::{DriveEventId, NodeUid}; use proton_sdk::session::ProtonApiSession; use crate::client::ProtonDriveClient; use crate::events::{DriveEvent, DriveEventScopeId}; use crate::node::{FileThumbnail, Node, RecentlyAccessedReportItem, Thumbnail, ThumbnailType}; /// One photos-timeline entry: a photo node and its capture time (epoch /// seconds). C# `PhotosTimelineItem(NodeUid Uid, DateTime CaptureTime)`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PhotosTimelineItem { pub uid: NodeUid, /// Capture time in seconds since the Unix epoch (server `CaptureTime`). pub capture_time: i64, } /// Photo classification tags. C# `Proton.Drive.Sdk.Nodes.PhotoTag`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[repr(i32)] pub enum PhotoTag { Favorite = 0, Screenshot = 1, Video = 2, LivePhoto = 3, MotionPhoto = 4, Selfie = 5, Portrait = 6, Burst = 7, Panorama = 8, Raw = 9, } impl PhotoTag { /// Map a wire tag discriminant, or `None` for one this SDK does not know /// (the server may add tags; an unknown one is dropped, not an error). pub fn from_raw(value: i32) -> Option { match value { 0 => Some(Self::Favorite), 1 => Some(Self::Screenshot), 2 => Some(Self::Video), 3 => Some(Self::LivePhoto), 4 => Some(Self::MotionPhoto), 5 => Some(Self::Selfie), 6 => Some(Self::Portrait), 7 => Some(Self::Burst), 8 => Some(Self::Panorama), 9 => Some(Self::Raw), _ => None, } } } /// One photo's tag changes, applied by /// [`ProtonPhotosClient::update_photos`]. Mirrors C# `PhotoTagsUpdate`. #[derive(Debug, Clone)] pub struct PhotoTagsUpdate { pub node_uid: NodeUid, pub tags_to_add: Vec, pub tags_to_remove: Vec, } /// One entry of an album listing: a photo and its capture time (epoch seconds). /// C# `AlbumItem(NodeUid Uid, DateTime CaptureTime)`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AlbumItem { pub uid: NodeUid, pub capture_time: i64, } /// Caller-supplied metadata for a photo upload. Mirrors C# /// `PhotosFileUploadMetadata`: all fields are optional. `capture_time` defaults /// to the upload time when unset; `main_photo_uid` links a related photo /// (live/burst) to its main photo; `tags` classify the photo. #[derive(Debug, Clone, Default)] pub struct PhotoUploadMetadata { /// Capture time in seconds since the Unix epoch. pub capture_time: Option, /// The main photo this one is related to (live photo / burst grouping). pub main_photo_uid: Option, pub tags: Vec, } /// High-level Proton Photos client. /// /// Holds a [`ProtonDriveClient`]; like the Drive client it needs the mailbox /// password to decrypt the photos share and node keys. #[derive(Clone)] pub struct ProtonPhotosClient { drive: ProtonDriveClient, } impl ProtonPhotosClient { /// Build a Photos client from a resumed session and the mailbox password /// (C# `ProtonPhotosClient(ProtonApiSession, ...)`). pub fn new(session: &ProtonApiSession, mailbox_password: impl Into>) -> Self { Self { drive: ProtonDriveClient::new(session, mailbox_password), } } /// Wrap an existing [`ProtonDriveClient`] (shares its caches and session). pub fn from_drive_client(drive: ProtonDriveClient) -> Self { Self { drive } } /// The underlying Drive client, for non-photos operations. pub fn drive_client(&self) -> &ProtonDriveClient { &self.drive } /// The photos root folder, or `None` when the account has no photos volume. /// Read-only: unlike C# `GetOrCreatePhotosFolderAsync`, it does not create /// one (volume creation is not yet ported). pub async fn get_photos_root(&self) -> Result> { self.drive.get_photos_root().await } /// Fetch a single photo node's decrypted metadata, or `None` if it does not /// exist. C# `ProtonPhotosClient.GetNodeAsync`. pub async fn get_node(&self, uid: &NodeUid) -> Result> { self.drive.get_photos_node(uid).await } /// Fetch decrypted metadata for many photo nodes in one pass. /// C# `ProtonPhotosClient.EnumerateNodesAsync`. pub async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result> { self.drive.enumerate_photos_nodes(uids).await } /// Tell the server which photos the user recently opened. See /// [`ProtonDriveClient::report_recently_accessed`]; this posts to the /// photos route. pub async fn report_recently_accessed( &self, items: &[RecentlyAccessedReportItem], ) -> Result<()> { self.drive.report_photos_recently_accessed(items).await } /// Enumerate the photos timeline newest-first. /// C# `ProtonPhotosClient.EnumerateTimelineAsync`. pub async fn enumerate_timeline(&self) -> Result> { self.drive.enumerate_photos_timeline().await } /// The photos I have shared with others, as [`NodeUid`]s — the photos-volume /// counterpart of `ProtonDriveClient::enumerate_shared_by_me_node_uids`. /// C# `ProtonPhotosClient.EnumerateSharedNodeUidsAsync`. Empty when the /// account has no photos volume. Materialize with /// [`enumerate_nodes`](Self::enumerate_nodes). pub async fn enumerate_shared_node_uids(&self) -> Result> { self.drive.enumerate_photos_shared_by_me_node_uids().await } /// Download and decrypt a photo's active revision, returning its plaintext. /// C# `PhotosFileDownloader`. pub async fn download_photo(&self, uid: &NodeUid) -> Result> { let mut buf = Vec::new(); self.drive.download_photo_to(uid, &mut buf).await?; Ok(buf) } /// Download and decrypt a photo's active revision into `output`. pub async fn download_photo_to( &self, uid: &NodeUid, output: &mut W, ) -> Result<()> { self.drive.download_photo_to(uid, output).await } /// Upload `contents` as a new photo named `name`, returning its [`NodeUid`]. /// /// Buffered, legacy SEIPDv1, no thumbnails. C# /// `ProtonPhotosClient.GetFileUploaderAsync`. Errors when the account has no /// photos volume. pub async fn upload_photo( &self, name: &str, media_type: &str, contents: &[u8], metadata: PhotoUploadMetadata, ) -> Result { self.upload_photo_from( name, media_type, Cursor::new(contents), contents.len() as i64, Vec::new(), metadata, false, ) .await } /// Streaming photo upload. See [`ProtonDriveClient::upload_file_from`] for the /// `reader` / `intended_size` / `thumbnails` / `aead` semantics; the seal /// additionally records the photo metadata (capture time, content hash, /// tags). #[allow(clippy::too_many_arguments)] pub async fn upload_photo_from( &self, name: &str, media_type: &str, reader: R, intended_size: i64, thumbnails: Vec, metadata: PhotoUploadMetadata, aead: bool, ) -> Result { self.drive .upload_photo_from( name, media_type, reader, intended_size, thumbnails, &metadata, aead, ) .await } /// Download and decrypt a single photo's thumbnail of the given type, or /// `None` when the photo has none. C# `ProtonPhotosClient` single-thumbnail /// access (routes through the photos endpoints). pub async fn download_thumbnail( &self, uid: &NodeUid, thumbnail_type: ThumbnailType, ) -> Result>> { self.drive .download_thumbnail_ctx(uid, thumbnail_type, true) .await } /// Batch-download the thumbnails of `uids` of the given type. /// C# `ProtonPhotosClient.EnumerateThumbnailsAsync` (`forPhotos: true`): /// per-photo failures are reported in the returned [`FileThumbnail`]s. pub async fn enumerate_thumbnails( &self, uids: &[NodeUid], thumbnail_type: ThumbnailType, ) -> Result> { self.drive .enumerate_thumbnails_ctx(uids, thumbnail_type, true) .await } /// Find active photos with the same name and plaintext SHA-1 digest. pub async fn find_duplicates(&self, name: &str, contents: &[u8]) -> Result> { 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>> { 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> { 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 { self.drive.create_album(name).await } /// Add photos to `album_uid`, one outcome per input photo in input order (a /// photo that fails does not stop the others). /// /// 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, photo_uids: &[NodeUid], ) -> Result)>> { 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 } /// 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, 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. A /// photo on another volume than the album fails without being sent. pub async fn remove_photos_from_album( &self, album_uid: &NodeUid, photo_uids: &[NodeUid], ) -> Result)>> { self.drive .remove_photos_from_album(album_uid, photo_uids) .await } /// The albums on the account's photos volume, as [`NodeUid`]s. /// C# `ProtonPhotosClient.EnumerateAlbumNodeUidsAsync`. Empty when the /// account has no photos volume. Materialize with /// [`enumerate_nodes`](Self::enumerate_nodes) — an album node is a folder /// carrying [`Node::album`](crate::Node::album). pub async fn enumerate_album_node_uids(&self) -> Result> { self.drive.enumerate_album_node_uids().await } /// The photos in `album_uid`, newest capture first. /// C# `ProtonPhotosClient.EnumerateAlbumAsync`. pub async fn enumerate_album(&self, album_uid: &NodeUid) -> Result> { self.drive.enumerate_album(album_uid).await } /// The photos and albums other users share with us, as [`NodeUid`]s — the /// photos counterpart of `ProtonDriveClient::enumerate_shared_with_me_node_uids` /// (C# `ProtonPhotosClient.EnumerateSharedWithMeNodeUidsAsync`, which filters /// `v2/sharedwithme` to the Photo/Album target types). pub async fn enumerate_shared_with_me_node_uids(&self) -> Result> { self.drive.enumerate_photos_shared_with_me_node_uids().await } /// The albums shared with us, as [`NodeUid`]s, from the dedicated /// `photos/albums/shared-with-me` listing (C# /// `PhotoOperations.EnumerateSharedWithMeAlbumUidsAsync`). Each row carries /// its own volume — these live on the sharer's photos volume. pub async fn enumerate_shared_with_me_album_uids(&self) -> Result> { self.drive.enumerate_shared_with_me_album_uids().await } /// Add and/or remove classification tags on photos. /// /// C# `ProtonPhotosClient.UpdatePhotosAsync`: one outcome per input update, /// in input order — a photo that fails does not stop the others. /// [`PhotoTag::Favorite`] is not a plain tag: it is set through the dedicated /// `favorite` endpoint, and only for photos that already live on our own /// photos volume. Favoriting a *shared* photo needs the photo re-encrypted /// for our timeline root, which is not ported yet and fails that update. /// Removing `Favorite` goes through the ordinary tag-removal endpoint. pub async fn update_photos( &self, updates: &[PhotoTagsUpdate], ) -> Result)>> { self.drive.update_photos(updates).await } /// Copy photos into this account's own timeline, keeping them even after /// the originals stop being shared. One outcome per input photo. /// /// C# `ProtonPhotosClient.SavePhotosToTimelineAsync`: a photo already on our /// photos volume is moved into the timeline root, one from another volume /// (a shared-with-me album) is copied. Related photos travel with their main /// photo. A photo already in the timeline fails its own outcome, and a photo /// whose group the server reports as incomplete is retried once with the /// related photos it named — so outcomes arrive in completion order, not /// input order. pub async fn save_photos_to_timeline( &self, photo_uids: &[NodeUid], ) -> Result)>> { self.drive.save_photos_to_timeline(photo_uids).await } /// Drive events on a photos scope, from `cursor` onwards /// (C# `ProtonPhotosClient.EnumerateEventsAsync`, which delegates to the /// Drive client exactly as this does). pub async fn enumerate_events( &self, scope: &DriveEventScopeId, cursor: Option<&DriveEventId>, ) -> Result> { self.drive.enumerate_events(scope, cursor).await } /// [`update_photos`](Self::update_photos) as a stream: each photo's outcome /// arrives as that photo's update finishes, mirroring the C# /// `IAsyncEnumerable`. The `Err` item is a failure that /// makes the whole call impossible and ends the stream. pub fn update_photos_streaming<'a>( &'a self, updates: &[PhotoTagsUpdate], ) -> impl futures::Stream)>> + 'a { self.drive.update_photos_streaming(updates) } } #[cfg(test)] mod tests { use super::*; use crate::dtos::{FindPhotoDuplicatesResponse, TimelinePhotoListResponse}; use proton_sdk::ids::VolumeId; #[test] fn timeline_item_round_trips() { let item = PhotosTimelineItem { uid: NodeUid::new(VolumeId::from("vol-1"), "link-9".into()), capture_time: 1_700_000_000, }; let json = serde_json::to_string(&item).unwrap(); let back: PhotosTimelineItem = serde_json::from_str(&json).unwrap(); assert_eq!(item, back); } #[test] fn photo_tag_discriminants_match_csharp() { assert_eq!(PhotoTag::Favorite as i32, 0); assert_eq!(PhotoTag::Video as i32, 2); assert_eq!(PhotoTag::Raw as i32, 9); } #[test] fn timeline_response_deserializes_server_shape() { // `LinkID` + epoch-seconds `CaptureTime` + hex `Hash`, mirroring the // wire shape of `GET volumes/{vid}/photos`. let raw = r#"{ "Photos": [ { "LinkID": "abc", "CaptureTime": 1700000000, "Hash": "deadbeef" }, { "LinkID": "def", "CaptureTime": 1700000100, "Hash": "cafe", "ContentHash": "ff" } ] }"#; let parsed: TimelinePhotoListResponse = serde_json::from_str(raw).unwrap(); assert_eq!(parsed.photos.len(), 2); assert_eq!(parsed.photos[0].id.to_string(), "abc"); assert_eq!(parsed.photos[0].capture_time, 1_700_000_000); 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`, 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#"{ "Responses": [ { "LinkID": "photo-1", "Response": { "Code": 1000 } }, { "LinkID": "photo-2", "Response": { "Code": 2000, "Error": "missing", "Details": { "Missing": ["photo-3"] } } } ] }"#; 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()); 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] fn duplicate_response_deserializes_server_shape() { let raw = r#"{ "DuplicateHashes": [{ "Hash": "deadbeef", "ContentHash": "cafe", "LinkState": 1, "ClientUID": "client", "LinkID": "photo-1" }] }"#; let parsed: FindPhotoDuplicatesResponse = serde_json::from_str(raw).unwrap(); let duplicate = &parsed.duplicate_hashes[0]; assert_eq!(duplicate.name_hash, "deadbeef"); assert_eq!(duplicate.content_hash, "cafe"); assert_eq!(duplicate.link_state, Some(1)); assert_eq!(duplicate.link_id.as_ref().unwrap().to_string(), "photo-1"); } }