mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
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.
564 lines
23 KiB
Rust
564 lines
23 KiB
Rust
//! 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<Self> {
|
|
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<PhotoTag>,
|
|
pub tags_to_remove: Vec<PhotoTag>,
|
|
}
|
|
|
|
/// 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<i64>,
|
|
/// The main photo this one is related to (live photo / burst grouping).
|
|
pub main_photo_uid: Option<NodeUid>,
|
|
pub tags: Vec<PhotoTag>,
|
|
}
|
|
|
|
/// 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<Vec<u8>>) -> 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<Option<Node>> {
|
|
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<Option<Node>> {
|
|
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<Vec<Node>> {
|
|
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<Vec<PhotosTimelineItem>> {
|
|
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<Vec<NodeUid>> {
|
|
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<Vec<u8>> {
|
|
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<W: std::io::Write>(
|
|
&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<NodeUid> {
|
|
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<R: Read + Send>(
|
|
&self,
|
|
name: &str,
|
|
media_type: &str,
|
|
reader: R,
|
|
intended_size: i64,
|
|
thumbnails: Vec<Thumbnail>,
|
|
metadata: PhotoUploadMetadata,
|
|
aead: bool,
|
|
) -> Result<NodeUid> {
|
|
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<Option<Vec<u8>>> {
|
|
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<Vec<FileThumbnail>> {
|
|
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<Vec<NodeUid>> {
|
|
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 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<Vec<(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<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.
|
|
/// 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<Vec<NodeUid>> {
|
|
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<Vec<AlbumItem>> {
|
|
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<Vec<NodeUid>> {
|
|
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<Vec<NodeUid>> {
|
|
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<Vec<(NodeUid, 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<Vec<(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<Vec<DriveEvent>> {
|
|
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<PhotoUpdateResult>`. 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<Item = Result<(NodeUid, Result<()>)>> + '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");
|
|
}
|
|
}
|