Skip to main content

mimick/api_client/
library.rs

1//! Library browsing: fetch albums, thumbnails, asset details, download originals, delete.
2//!
3//! Implements the read-side API surface used by the library window:
4//! paginated asset lists, thumbnail downloads (with semaphore-limited
5//! concurrency), EXIF metadata, and original-file downloads. Asset
6//! deletion sends items to Immich trash rather than permanent removal.
7
8use std::time::Duration;
9
10use super::errors::{RequestContext, classify_http_issue, classify_network_issue};
11use super::{
12    AssetDetails, ImmichApiClient, LibraryAlbum, LibraryAsset, SortOrder, ThumbnailSize,
13    TransferProgressCallback,
14};
15
16impl ImmichApiClient {
17    /// Retrieve the complete list of albums from the Immich server for library display.
18    pub async fn fetch_library_albums(&self) -> Result<Vec<LibraryAlbum>, String> {
19        let base_url = self
20            .get_active_url()
21            .await
22            .ok_or_else(|| "No active connection".to_string())?;
23        let settings = self.settings_snapshot();
24        let url = format!("{}/api/albums", base_url);
25
26        match self
27            .client
28            .get(&url)
29            .header("x-api-key", &settings.api_key)
30            .header("Accept", "application/json")
31            .timeout(Duration::from_secs(10))
32            .send()
33            .await
34        {
35            Ok(resp) if resp.status().is_success() => {
36                let albums = resp
37                    .json::<Vec<LibraryAlbum>>()
38                    .await
39                    .map_err(|err| err.to_string())?;
40                self.clear_issue().await;
41                Ok(albums)
42            }
43            Ok(resp) => {
44                self.set_issue(classify_http_issue(
45                    RequestContext::Albums,
46                    resp.status().as_u16(),
47                    None,
48                ))
49                .await;
50                Err(format!("HTTP {}", resp.status()))
51            }
52            Err(err) => {
53                *self.active_url.lock().await = None;
54                self.set_issue(classify_network_issue(RequestContext::Albums, &err))
55                    .await;
56                Err(err.to_string())
57            }
58        }
59    }
60
61    /// Retrieve paginated assets contained in a specific album.
62    pub async fn fetch_album_assets(
63        &self,
64        album_id: &str,
65        page: u32,
66        size: u32,
67        order: Option<SortOrder>,
68    ) -> Result<(Vec<LibraryAsset>, bool), String> {
69        let mut body = serde_json::json!({
70            "albumIds": [album_id],
71            "page": page,
72            "size": size.max(1),
73        });
74        if let Some(order) = order
75            && let Some(obj) = body.as_object_mut()
76        {
77            obj.insert(
78                "order".into(),
79                serde_json::to_value(order).unwrap_or(serde_json::Value::Null),
80            );
81        }
82        self.fetch_search_assets(
83            "/api/search/metadata",
84            body,
85            RequestContext::AssetList,
86            Some(album_id),
87        )
88        .await
89    }
90
91    /// Retrieve raw thumbnail/preview image byte array for a given asset ID.
92    pub async fn fetch_thumbnail(
93        &self,
94        asset_id: &str,
95        size: ThumbnailSize,
96    ) -> Result<Vec<u8>, String> {
97        let _permit = self
98            .thumbnail_semaphore
99            .clone()
100            .acquire_owned()
101            .await
102            .map_err(|err| err.to_string())?;
103        let base_url = self
104            .get_active_url()
105            .await
106            .ok_or_else(|| "No active connection".to_string())?;
107        let settings = self.settings_snapshot();
108        let url = format!(
109            "{}/api/assets/{}/thumbnail?size={}",
110            base_url,
111            asset_id,
112            size.as_str()
113        );
114
115        match self
116            .client
117            .get(&url)
118            .header("x-api-key", &settings.api_key)
119            .header("Accept", "application/octet-stream")
120            .timeout(Duration::from_secs(10))
121            .send()
122            .await
123        {
124            Ok(resp) if resp.status().is_success() => {
125                let bytes = resp.bytes().await.map_err(|err| err.to_string())?;
126                self.clear_issue().await;
127                Ok(bytes.to_vec())
128            }
129            Ok(resp) => {
130                self.set_issue(classify_http_issue(
131                    RequestContext::ThumbnailFetch,
132                    resp.status().as_u16(),
133                    Some(asset_id),
134                ))
135                .await;
136                Err(format!("HTTP {}", resp.status()))
137            }
138            Err(err) => {
139                *self.active_url.lock().await = None;
140                self.set_issue(classify_network_issue(RequestContext::ThumbnailFetch, &err))
141                    .await;
142                Err(err.to_string())
143            }
144        }
145    }
146
147    /// Generic helper to fetch asset JSON details.
148    async fn fetch_asset_generic<T: serde::de::DeserializeOwned>(
149        &self,
150        asset_id: &str,
151    ) -> Result<T, String> {
152        let base_url = self
153            .get_active_url()
154            .await
155            .ok_or_else(|| "No active connection".to_string())?;
156        let settings = self.settings_snapshot();
157        let url = format!("{}/api/assets/{}", base_url, asset_id);
158        match self
159            .client
160            .get(&url)
161            .header("x-api-key", &settings.api_key)
162            .header("Accept", "application/json")
163            .timeout(Duration::from_secs(10))
164            .send()
165            .await
166        {
167            Ok(resp) if resp.status().is_success() => {
168                resp.json::<T>().await.map_err(|err| err.to_string())
169            }
170            Ok(resp) => Err(format!("HTTP {}", resp.status())),
171            Err(err) => Err(err.to_string()),
172        }
173    }
174
175    /// Retrieve full EXIF metadata and details for a given asset ID.
176    pub async fn fetch_asset_details(&self, asset_id: &str) -> Result<AssetDetails, String> {
177        self.fetch_asset_generic(asset_id).await
178    }
179
180    /// Fetch a single asset as a `LibraryAsset` by its ID.
181    pub async fn fetch_asset_by_id(&self, asset_id: &str) -> Result<LibraryAsset, String> {
182        self.fetch_asset_generic(asset_id).await
183    }
184
185    /// Download original source file of a given asset ID and save it locally.
186    pub async fn download_original_to_file(
187        &self,
188        asset_id: &str,
189        output_path: &std::path::Path,
190        progress: Option<TransferProgressCallback>,
191    ) -> Result<(), String> {
192        let base_url = self
193            .get_active_url()
194            .await
195            .ok_or_else(|| "No active connection".to_string())?;
196        let settings = self.settings_snapshot();
197        let url = format!("{}/api/assets/{}/original", base_url, asset_id);
198
199        match self
200            .client
201            .get(&url)
202            .header("x-api-key", &settings.api_key)
203            .header("Accept", "application/octet-stream")
204            .timeout(Duration::from_secs(300))
205            .send()
206            .await
207        {
208            Ok(mut resp) if resp.status().is_success() => {
209                use tokio::io::AsyncWriteExt;
210                let total_bytes = resp.content_length();
211                let mut written = 0_u64;
212                let mut file = tokio::fs::File::create(output_path)
213                    .await
214                    .map_err(|e| e.to_string())?;
215                while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
216                    file.write_all(&chunk).await.map_err(|e| e.to_string())?;
217                    written = written.saturating_add(chunk.len() as u64);
218                    if let Some(callback) = &progress {
219                        callback(written, total_bytes);
220                    }
221                }
222                self.clear_issue().await;
223                Ok(())
224            }
225            Ok(resp) => {
226                self.set_issue(classify_http_issue(
227                    RequestContext::AssetDownload,
228                    resp.status().as_u16(),
229                    Some(asset_id),
230                ))
231                .await;
232                Err(format!("HTTP {}", resp.status()))
233            }
234            Err(err) => {
235                *self.active_url.lock().await = None;
236                self.set_issue(classify_network_issue(RequestContext::AssetDownload, &err))
237                    .await;
238                Err(err.to_string())
239            }
240        }
241    }
242
243    /// Fetch unique user ID of the logged-in API user.
244    pub async fn fetch_current_user_id(&self) -> Result<String, String> {
245        let base_url = self
246            .get_active_url()
247            .await
248            .ok_or_else(|| "No active connection".to_string())?;
249        let settings = self.settings_snapshot();
250        let url = format!("{}/api/users/me", base_url);
251        match self
252            .client
253            .get(&url)
254            .header("x-api-key", &settings.api_key)
255            .header("Accept", "application/json")
256            .timeout(Duration::from_secs(10))
257            .send()
258            .await
259        {
260            Ok(resp) if resp.status().is_success() => {
261                let json: serde_json::Value = resp.json().await.map_err(|err| err.to_string())?;
262                json.get("id")
263                    .and_then(|v| v.as_str())
264                    .map(|s| s.to_string())
265                    .ok_or_else(|| "Missing id in /users/me response".to_string())
266            }
267            Ok(resp) => Err(format!("HTTP {}", resp.status())),
268            Err(err) => Err(err.to_string()),
269        }
270    }
271
272    /// Soft-delete specified assets from the Immich server.
273    pub async fn delete_assets(&self, asset_ids: &[String]) -> Result<(), String> {
274        if asset_ids.is_empty() {
275            return Ok(());
276        }
277        let base_url = self
278            .get_active_url()
279            .await
280            .ok_or_else(|| "No active connection".to_string())?;
281        let settings = self.settings_snapshot();
282        let url = format!("{}/api/assets", base_url);
283        let body = serde_json::json!({
284            "ids": asset_ids,
285            "force": false,
286        });
287        match self
288            .client
289            .delete(&url)
290            .header("x-api-key", &settings.api_key)
291            .header("Content-Type", "application/json")
292            .header("Accept", "application/json")
293            .timeout(Duration::from_secs(15))
294            .body(body.to_string())
295            .send()
296            .await
297        {
298            Ok(resp) if resp.status().is_success() => {
299                self.clear_issue().await;
300                Ok(())
301            }
302            Ok(resp) => Err(format!("HTTP {}", resp.status())),
303            Err(err) => Err(err.to_string()),
304        }
305    }
306}