Skip to main content

cyberdrop_client/
albums.rs

1use serde::{Deserialize, Serialize};
2
3use crate::CyberdropError;
4use crate::client::CyberdropClient;
5use crate::files::AlbumFile;
6
7/// Album metadata as returned by the Cyberdrop API.
8///
9/// Field semantics (timestamps/flags) are intentionally documented minimally: values are exposed
10/// as returned by the service without additional interpretation.
11#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct Album {
14    /// Album numeric ID.
15    pub id: u64,
16    /// Display name.
17    pub name: String,
18    /// Service-provided timestamp value.
19    #[serde(default)]
20    pub timestamp: u64,
21    /// Service-provided identifier string.
22    pub identifier: String,
23    /// Service-provided "edited at" timestamp value.
24    #[serde(default)]
25    pub edited_at: u64,
26    /// Service-provided download flag.
27    #[serde(default)]
28    pub download: bool,
29    /// Service-provided public flag.
30    #[serde(default)]
31    pub public: bool,
32    /// Album description (may be empty).
33    #[serde(default)]
34    pub description: String,
35    /// Number of files in the album.
36    #[serde(default)]
37    pub files: u64,
38}
39
40/// Files returned by the album listing endpoint.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct AlbumFiles {
43    /// Files collected for the album.
44    pub files: Vec<AlbumFile>,
45    /// Total number of files in the album.
46    pub count: u64,
47}
48
49#[derive(Debug, Serialize)]
50#[serde(rename_all = "camelCase")]
51pub(crate) struct CreateAlbumRequest {
52    pub(crate) name: String,
53    pub(crate) description: Option<String>,
54}
55
56#[derive(Debug, Deserialize)]
57pub(crate) struct CreateAlbumResponse {
58    pub(crate) success: Option<bool>,
59    pub(crate) id: Option<u64>,
60    pub(crate) message: Option<String>,
61    pub(crate) description: Option<String>,
62}
63
64#[derive(Debug, Serialize)]
65#[serde(rename_all = "camelCase")]
66pub(crate) struct EditAlbumRequest {
67    pub(crate) id: u64,
68    pub(crate) name: String,
69    pub(crate) description: String,
70    pub(crate) download: bool,
71    pub(crate) public: bool,
72    #[serde(rename = "requestLink")]
73    pub(crate) request_link: bool,
74}
75
76#[derive(Debug, Deserialize)]
77pub(crate) struct EditAlbumResponse {
78    pub(crate) success: Option<bool>,
79    pub(crate) name: Option<String>,
80    pub(crate) identifier: Option<String>,
81    pub(crate) message: Option<String>,
82    pub(crate) description: Option<String>,
83}
84
85/// Result of editing an album via [`crate::CyberdropClient::edit_album`].
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct EditAlbumResult {
88    /// Updated name if the API returned it.
89    pub name: Option<String>,
90    /// New identifier if `request_new_link` was set and the API returned it.
91    pub identifier: Option<String>,
92}
93
94#[derive(Debug, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub(crate) struct AlbumsResponse {
97    pub(crate) success: Option<bool>,
98    pub(crate) albums: Option<Vec<Album>>,
99}
100
101#[derive(Debug, Deserialize)]
102pub(crate) struct AlbumFilesResponse {
103    pub(crate) success: Option<bool>,
104    pub(crate) files: Option<Vec<AlbumFile>>,
105    pub(crate) count: Option<u64>,
106    pub(crate) message: Option<String>,
107    pub(crate) description: Option<String>,
108}
109
110impl CyberdropClient {
111    /// Fetch an album from [`CyberdropClient::list_albums`] by numeric ID.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`CyberdropError::AlbumNotFound`] if the authenticated account has no album with
116    /// `album_id`; otherwise returns the same errors as [`CyberdropClient::list_albums`].
117    pub async fn get_album_by_id(&self, album_id: u64) -> Result<Album, CyberdropError> {
118        let albums = self.list_albums().await?;
119        albums
120            .into_iter()
121            .find(|album| album.id == album_id)
122            .ok_or(CyberdropError::AlbumNotFound(album_id))
123    }
124
125    /// List albums for the authenticated user.
126    ///
127    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
128    ///
129    /// # Errors
130    ///
131    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
132    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
133    /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
134    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
135    pub async fn list_albums(&self) -> Result<Vec<Album>, CyberdropError> {
136        let response: AlbumsResponse = self
137            .get_json_with_header("api/albums", true, "Simple", "1")
138            .await?;
139
140        if !response.success.unwrap_or(false) {
141            return Err(CyberdropError::Api("failed to fetch albums".into()));
142        }
143
144        response.albums.ok_or(CyberdropError::MissingField(
145            "albums response missing albums",
146        ))
147    }
148
149    /// List all files in an album ("folder") by iterating pages until exhaustion.
150    ///
151    /// Starts at `page = 0` and stops when:
152    /// - enough files have been collected to satisfy the API-reported `count`, or
153    /// - a page returns zero files.
154    ///
155    /// Requires an auth token (see [`CyberdropClient::with_auth_token`]).
156    ///
157    /// # Returns
158    ///
159    /// An [`AlbumFiles`] value containing all collected files and the API-reported total count.
160    ///
161    /// # Errors
162    ///
163    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
164    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
165    /// - [`CyberdropError::Api`] for service-reported failures
166    /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
167    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
168    pub async fn list_album_files(&self, album_id: u64) -> Result<AlbumFiles, CyberdropError> {
169        let mut page = 0u64;
170        let mut all_files = Vec::new();
171        let mut total_count = None::<u64>;
172
173        loop {
174            let path = format!("api/album/{album_id}/{page}");
175            let response: AlbumFilesResponse = self.get_json(&path, true).await?;
176
177            if !response.success.unwrap_or(false) {
178                let msg = response
179                    .description
180                    .or(response.message)
181                    .unwrap_or_else(|| "failed to fetch album files".to_string());
182                return Err(CyberdropError::Api(msg));
183            }
184
185            let mut res = AlbumFiles {
186                files: response.files.ok_or(CyberdropError::MissingField(
187                    "album files response missing files",
188                ))?,
189                count: response.count.ok_or(CyberdropError::MissingField(
190                    "album files response missing count",
191                ))?,
192            };
193
194            if total_count.is_none() {
195                total_count = Some(res.count);
196            }
197
198            if res.files.is_empty() {
199                break;
200            }
201
202            all_files.append(&mut res.files);
203
204            if let Some(total) = total_count
205                && all_files.len() as u64 >= total
206            {
207                break;
208            }
209
210            page += 1;
211        }
212
213        Ok(AlbumFiles {
214            files: all_files,
215            count: total_count.unwrap_or(0),
216        })
217    }
218
219    /// Create a new album and return its numeric ID.
220    ///
221    /// Requires an auth token. If the service reports that an album with a similar name already
222    /// exists, this returns [`CyberdropError::AlbumAlreadyExists`].
223    ///
224    /// # Errors
225    ///
226    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
227    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
228    /// - [`CyberdropError::AlbumAlreadyExists`] if the service indicates an album already exists
229    /// - [`CyberdropError::Api`] for other service-reported failures
230    /// - [`CyberdropError::MissingField`] if expected fields are missing in the response body
231    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
232    pub async fn create_album(
233        &self,
234        name: impl Into<String>,
235        description: Option<impl Into<String>>,
236    ) -> Result<u64, CyberdropError> {
237        let payload = CreateAlbumRequest {
238            name: name.into(),
239            description: description.map(Into::into),
240        };
241
242        let response: CreateAlbumResponse = self.post_json("api/albums", &payload, true).await?;
243
244        if response.success.unwrap_or(false) {
245            return response.id.ok_or(CyberdropError::MissingField(
246                "create album response missing id",
247            ));
248        }
249
250        let msg = response
251            .description
252            .or(response.message)
253            .unwrap_or_else(|| "create album failed".to_string());
254
255        if msg.to_lowercase().contains("already an album") {
256            Err(CyberdropError::AlbumAlreadyExists(msg))
257        } else {
258            Err(CyberdropError::Api(msg))
259        }
260    }
261
262    /// Edit an existing album ("folder").
263    ///
264    /// This endpoint updates album metadata such as name/description and visibility flags.
265    /// It can also request a new link identifier.
266    ///
267    /// Requires an auth token.
268    ///
269    /// # Returns
270    ///
271    /// The API returns either a `name` (typical edits) or an `identifier` (when requesting a new
272    /// link). This crate exposes both as optional fields on [`EditAlbumResult`].
273    ///
274    /// # Errors
275    ///
276    /// - [`CyberdropError::MissingAuthToken`] if the client has no configured token
277    /// - [`CyberdropError::AuthenticationFailed`] / [`CyberdropError::RequestFailed`] for non-2xx statuses
278    /// - [`CyberdropError::Api`] for service-reported failures
279    /// - [`CyberdropError::MissingField`] if the response is missing expected fields
280    /// - [`CyberdropError::Http`] for transport failures (including timeouts)
281    pub async fn edit_album(
282        &self,
283        id: u64,
284        name: impl Into<String>,
285        description: impl Into<String>,
286        download: bool,
287        public: bool,
288        request_new_link: bool,
289    ) -> Result<EditAlbumResult, CyberdropError> {
290        let payload = EditAlbumRequest {
291            id,
292            name: name.into(),
293            description: description.into(),
294            download,
295            public,
296            request_link: request_new_link,
297        };
298
299        let response: EditAlbumResponse = self.post_json("api/albums/edit", &payload, true).await?;
300
301        if !response.success.unwrap_or(false) {
302            let msg = response
303                .description
304                .or(response.message)
305                .unwrap_or_else(|| "edit album failed".to_string());
306            return Err(CyberdropError::Api(msg));
307        }
308
309        if response.name.is_none() && response.identifier.is_none() {
310            return Err(CyberdropError::MissingField(
311                "edit album response missing name/identifier",
312            ));
313        }
314
315        Ok(EditAlbumResult {
316            name: response.name,
317            identifier: response.identifier,
318        })
319    }
320}