Skip to main content

mimick/api_client/
albums.rs

1//! Album management: list, create, cache, and add assets to albums.
2//!
3//! Provides the `get_all_albums`, `get_or_create_album`, and
4//! `add_asset_to_album` methods that the upload and library flows
5//! depend on. Album metadata is cached in-memory after the first fetch.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9use std::time::Duration;
10
11use tokio::sync::Mutex;
12
13use super::errors::{RequestContext, classify_http_issue, classify_network_issue};
14use super::{AlbumSummary, ApiIssue, ImmichApiClient};
15
16impl ImmichApiClient {
17    /// Retrieve all albums from the Immich server, populating the local in-memory cache.
18    async fn fetch_all_albums(&self) {
19        let _fetch_guard = self.album_fetch_lock.lock().await;
20        if *self.albums_fetched.lock().await {
21            return;
22        }
23        self.fetch_all_albums_locked().await;
24    }
25
26    /// Inner fetch implementation. Assumes `album_fetch_lock` is held by the
27    /// caller and `albums_fetched` is already known to be false.
28    async fn fetch_all_albums_locked(&self) {
29        let base_url = match self.get_active_url().await {
30            Some(u) => u,
31            None => {
32                log::warn!("Cannot fetch albums: no active URL.");
33                self.set_issue(ApiIssue {
34                    summary: "Album list is unavailable".to_string(),
35                    guidance: "Reconnect to the Immich server before refreshing albums."
36                        .to_string(),
37                })
38                .await;
39                return;
40            }
41        };
42
43        let url = format!("{}/api/albums", base_url);
44        let api_key = self.settings.read().api_key.clone();
45        log::info!("Fetching album list...");
46
47        let result = self
48            .client
49            .get(&url)
50            .header("x-api-key", &api_key)
51            .header("Accept", "application/json")
52            .timeout(Duration::from_secs(10))
53            .send()
54            .await;
55
56        self.handle_fetch_albums_response(result).await;
57    }
58
59    async fn handle_fetch_albums_response(
60        &self,
61        result: Result<reqwest::Response, reqwest::Error>,
62    ) {
63        match result {
64            Ok(resp) if resp.status().is_success() => {
65                if let Ok(albums) = resp.json::<Vec<AlbumSummary>>().await {
66                    let total = albums.len();
67                    let (fresh, unique, duplicates) = Self::process_album_summaries(albums);
68                    {
69                        let mut cache = self.album_cache.lock().await;
70                        *cache = fresh;
71                    }
72                    *self.albums_fetched.lock().await = true;
73                    self.clear_issue().await;
74                    log::info!(
75                        "Cached {} unique album(s) from {} server entries ({} duplicate(s) ignored).",
76                        unique,
77                        total,
78                        duplicates
79                    );
80                }
81            }
82            Ok(resp) => {
83                log::error!("Failed to fetch albums: {}", resp.status());
84                self.set_issue(classify_http_issue(
85                    RequestContext::Albums,
86                    resp.status().as_u16(),
87                    None,
88                ))
89                .await;
90            }
91            Err(e) => {
92                log::error!("Network error fetching albums: {}", e);
93                let mut active = self.active_url.lock().await;
94                *active = None;
95                self.set_issue(classify_network_issue(RequestContext::Albums, &e))
96                    .await;
97            }
98        }
99    }
100
101    fn process_album_summaries(
102        albums: Vec<AlbumSummary>,
103    ) -> (HashMap<String, String>, usize, usize) {
104        let mut fresh: HashMap<String, String> = HashMap::with_capacity(albums.len());
105        let mut duplicates = 0usize;
106        for album in albums {
107            match fresh.get(&album.album_name) {
108                Some(existing_id) if existing_id != &album.id => {
109                    duplicates += 1;
110                    log::warn!(
111                        "Duplicate album on server: '{}' has both id {} and {} (keeping first). Future syncs will use the first.",
112                        album.album_name,
113                        existing_id,
114                        album.id
115                    );
116                }
117                Some(_) => {
118                    // Same name + same id: server returned an entry twice; ignore silently.
119                }
120                None => {
121                    fresh.insert(album.album_name, album.id);
122                }
123            }
124        }
125        let unique = fresh.len();
126        (fresh, unique, duplicates)
127    }
128
129    pub async fn refresh_album_cache(&self) {
130        let _fetch_guard = self.album_fetch_lock.lock().await;
131        {
132            let mut cache = self.album_cache.lock().await;
133            cache.clear();
134        }
135        *self.albums_fetched.lock().await = false;
136        self.fetch_all_albums_locked().await;
137    }
138
139    /// Return a snapshot of all cached albums as a list of (albumName, id)
140    pub async fn get_all_albums(&self) -> Result<Vec<(String, String)>, String> {
141        if !*self.albums_fetched.lock().await {
142            self.fetch_all_albums().await;
143        }
144        if !*self.albums_fetched.lock().await {
145            return Err("Failed to fetch albums".to_string());
146        }
147        let cache = self.album_cache.lock().await;
148        Ok(cache
149            .iter()
150            .map(|(n, id)| (n.clone(), id.clone()))
151            .collect())
152    }
153
154    /// Create a new album. Returns the new album ID.
155    pub async fn create_album(&self, album_name: &str) -> Result<Option<String>, String> {
156        let base_url = self
157            .get_active_url()
158            .await
159            .ok_or_else(|| "No active connection".to_string())?;
160        let url = format!("{}/api/albums", base_url);
161        let api_key = self.settings.read().api_key.clone();
162
163        log::info!("Creating album: '{}'", album_name);
164
165        let body = serde_json::json!({
166            "albumName": album_name,
167            "description": "Created by Mimick"
168        });
169
170        match self
171            .client
172            .post(&url)
173            .header("x-api-key", &api_key)
174            .header("Content-Type", "application/json")
175            .header("Accept", "application/json")
176            .json(&body)
177            .timeout(Duration::from_secs(10))
178            .send()
179            .await
180        {
181            Ok(resp) if resp.status().is_success() => {
182                let id = resp
183                    .json::<serde_json::Value>()
184                    .await
185                    .ok()
186                    .and_then(|json| json["id"].as_str().map(String::from));
187                if let Some(id_str) = id.as_ref() {
188                    let mut cache = self.album_cache.lock().await;
189                    cache.insert(album_name.to_string(), id_str.clone());
190                }
191                self.clear_issue().await;
192                log::info!("Album created: '{}' ({:?})", album_name, id);
193                Ok(id)
194            }
195            Ok(resp) => {
196                log::error!("Failed to create album '{}': {}", album_name, resp.status());
197                self.set_issue(classify_http_issue(
198                    RequestContext::AlbumCreate,
199                    resp.status().as_u16(),
200                    Some(album_name),
201                ))
202                .await;
203                Err(format!("HTTP {}", resp.status()))
204            }
205            Err(e) => {
206                log::error!("Network error creating album '{}': {}", album_name, e);
207                self.set_issue(classify_network_issue(RequestContext::AlbumCreate, &e))
208                    .await;
209                Err(e.to_string())
210            }
211        }
212    }
213
214    /// Return an existing album ID or create a new one.
215    pub async fn get_or_create_album(&self, album_name: &str) -> Result<Option<String>, String> {
216        if !*self.albums_fetched.lock().await {
217            self.fetch_all_albums().await;
218        }
219        {
220            let cache = self.album_cache.lock().await;
221            if let Some(id) = cache.get(album_name) {
222                log::debug!("Album found in cache: '{}' ({})", album_name, id);
223                return Ok(Some(id.clone()));
224            }
225        }
226        if !*self.albums_fetched.lock().await {
227            return Err("Cannot fetch albums to verify existence".to_string());
228        }
229
230        let create_lock = {
231            let mut locks = self.album_create_locks.lock().await;
232            locks
233                .entry(album_name.to_string())
234                .or_insert_with(|| Arc::new(Mutex::new(())))
235                .clone()
236        };
237        let _guard = create_lock.lock().await;
238
239        {
240            let cache = self.album_cache.lock().await;
241            if let Some(id) = cache.get(album_name) {
242                return Ok(Some(id.clone()));
243            }
244        }
245
246        self.create_album(album_name).await
247    }
248
249    /// Return an existing album ID without creating a new album as a side effect.
250    pub async fn get_album_id_if_exists(&self, album_name: &str) -> Result<Option<String>, String> {
251        if !*self.albums_fetched.lock().await {
252            self.fetch_all_albums().await;
253        }
254        if !*self.albums_fetched.lock().await {
255            return Err("Cannot fetch albums to verify existence".to_string());
256        }
257
258        let cache = self.album_cache.lock().await;
259        Ok(cache.get(album_name).cloned())
260    }
261
262    pub async fn resolve_album_by_name(
263        &self,
264        album_name: &str,
265        force_refresh: bool,
266    ) -> Result<Option<String>, String> {
267        if force_refresh {
268            self.refresh_album_cache().await;
269        }
270        self.get_or_create_album(album_name).await
271    }
272
273    /// Check whether an asset already exists on the server by checksum and return its asset ID.
274    /// Batch checksum existence check. Returns a map from checksum to asset id
275    /// for every checksum the server already has. Missing checksums are absent
276    /// from the result. The Immich endpoint accepts arbitrary batch sizes, but
277    /// we chunk to keep request bodies modest.
278    pub async fn bulk_existing_asset_ids(&self, checksums: &[String]) -> HashMap<String, String> {
279        const CHUNK: usize = 500;
280        let mut out: HashMap<String, String> = HashMap::new();
281        let Some(base_url) = self.get_active_url().await else {
282            return out;
283        };
284        let url = format!("{}/api/assets/bulk-upload-check", base_url);
285        let api_key = self.settings.read().api_key.clone();
286
287        for chunk in checksums.chunks(CHUNK) {
288            let assets: Vec<_> = chunk
289                .iter()
290                .map(|c| serde_json::json!({ "id": c, "checksum": c }))
291                .collect();
292            let body = serde_json::json!({ "assets": assets });
293
294            match self
295                .client
296                .post(&url)
297                .header("x-api-key", &api_key)
298                .header("Content-Type", "application/json")
299                .header("Accept", "application/json")
300                .json(&body)
301                .timeout(Duration::from_secs(30))
302                .send()
303                .await
304            {
305                Ok(resp) if resp.status().is_success() => {
306                    if let Ok(json) = resp.json::<serde_json::Value>().await
307                        && let Some(results) = json["results"].as_array()
308                    {
309                        for item in results {
310                            let id = item["id"].as_str();
311                            let asset_id = item["assetId"].as_str();
312                            if let (Some(checksum), Some(asset_id)) = (id, asset_id) {
313                                out.insert(checksum.to_string(), asset_id.to_string());
314                            }
315                        }
316                    }
317                }
318                Ok(resp) => log::warn!("Bulk upload check returned {}", resp.status()),
319                Err(err) => log::warn!("Bulk upload check request failed: {}", err),
320            }
321        }
322
323        out
324    }
325
326    pub async fn find_existing_asset_id(&self, checksum: &str) -> Option<String> {
327        let base_url = self.get_active_url().await?;
328        let url = format!("{}/api/assets/bulk-upload-check", base_url);
329        let api_key = self.settings.read().api_key.clone();
330        let body = serde_json::json!({
331            "assets": [
332                {
333                    "id": checksum,
334                    "checksum": checksum
335                }
336            ]
337        });
338
339        match self
340            .client
341            .post(&url)
342            .header("x-api-key", &api_key)
343            .header("Content-Type", "application/json")
344            .header("Accept", "application/json")
345            .json(&body)
346            .timeout(Duration::from_secs(10))
347            .send()
348            .await
349        {
350            Ok(resp) if resp.status().is_success() => {
351                let json = resp.json::<serde_json::Value>().await.ok()?;
352                json["results"]
353                    .as_array()
354                    .and_then(|results| results.first())
355                    .and_then(|item| item["assetId"].as_str())
356                    .map(ToString::to_string)
357            }
358            Ok(resp) => {
359                log::warn!(
360                    "Bulk upload check failed for checksum {}: {}",
361                    checksum,
362                    resp.status()
363                );
364                None
365            }
366            Err(err) => {
367                log::warn!(
368                    "Bulk upload check request failed for checksum {}: {}",
369                    checksum,
370                    err
371                );
372                None
373            }
374        }
375    }
376
377    /// Add a list of asset IDs to an album.
378    pub async fn add_assets_to_album(&self, album_id: &str, asset_ids: &[String]) -> bool {
379        if album_id.is_empty() || asset_ids.is_empty() {
380            log::warn!("Skipping add_assets_to_album: missing ID or assets.");
381            return false;
382        }
383
384        let base_url = match self.get_active_url().await {
385            Some(u) => u,
386            None => return false,
387        };
388
389        let url = format!("{}/api/albums/{}/assets", base_url, album_id);
390        let api_key = self.settings.read().api_key.clone();
391        let body = serde_json::json!({ "ids": asset_ids });
392
393        log::info!(
394            "Adding {} asset(s) to album '{}'",
395            asset_ids.len(),
396            album_id
397        );
398
399        match self
400            .client
401            .put(&url)
402            .header("x-api-key", &api_key)
403            .header("Content-Type", "application/json")
404            .header("Accept", "application/json")
405            .json(&body)
406            .timeout(Duration::from_secs(10))
407            .send()
408            .await
409        {
410            Ok(resp) if resp.status().is_success() => {
411                log::info!("Assets added to album successfully.");
412                self.clear_issue().await;
413                true
414            }
415            Ok(resp) => {
416                log::error!("Failed to add assets to album: {}", resp.status());
417                self.set_issue(classify_http_issue(
418                    RequestContext::AlbumAssign,
419                    resp.status().as_u16(),
420                    Some(album_id),
421                ))
422                .await;
423                false
424            }
425            Err(e) => {
426                log::error!("Network error adding assets to album: {}", e);
427                self.set_issue(classify_network_issue(RequestContext::AlbumAssign, &e))
428                    .await;
429                false
430            }
431        }
432    }
433
434    /// Count the albums that currently contain the given asset on the
435    /// server. Drives the trash-vs-remove-from-album decision when mirroring
436    /// a local deletion: an asset in multiple albums should only be unlinked
437    /// from the linked album, never destroyed.
438    pub async fn count_albums_for_asset(&self, asset_id: &str) -> Option<usize> {
439        let base_url = self.get_active_url().await?;
440        let url = format!("{}/api/albums?assetId={}", base_url, asset_id);
441        let api_key = self.settings.read().api_key.clone();
442        match self
443            .client
444            .get(&url)
445            .header("x-api-key", &api_key)
446            .header("Accept", "application/json")
447            .timeout(Duration::from_secs(10))
448            .send()
449            .await
450        {
451            Ok(resp) if resp.status().is_success() => {
452                let albums: Vec<serde_json::Value> = resp.json().await.ok()?;
453                Some(albums.len())
454            }
455            Ok(resp) => {
456                log::warn!(
457                    "count_albums_for_asset({}) returned {}",
458                    asset_id,
459                    resp.status()
460                );
461                None
462            }
463            Err(err) => {
464                log::warn!("count_albums_for_asset({}) failed: {}", asset_id, err);
465                None
466            }
467        }
468    }
469
470    /// Remove assets from an album without trashing them on the server. Used
471    /// when an asset is referenced from more than one watch folder — we want
472    /// to mirror the local deletion's album side, not destroy the asset.
473    pub async fn remove_assets_from_album(&self, album_id: &str, asset_ids: &[String]) -> bool {
474        if album_id.is_empty() || asset_ids.is_empty() {
475            return false;
476        }
477        let base_url = match self.get_active_url().await {
478            Some(u) => u,
479            None => return false,
480        };
481        let url = format!("{}/api/albums/{}/assets", base_url, album_id);
482        let api_key = self.settings.read().api_key.clone();
483        let body = serde_json::json!({ "ids": asset_ids });
484        match self
485            .client
486            .delete(&url)
487            .header("x-api-key", &api_key)
488            .header("Content-Type", "application/json")
489            .header("Accept", "application/json")
490            .json(&body)
491            .timeout(Duration::from_secs(10))
492            .send()
493            .await
494        {
495            Ok(resp) if resp.status().is_success() => {
496                log::info!(
497                    "Removed {} asset(s) from album '{}' (asset preserved on server)",
498                    asset_ids.len(),
499                    album_id
500                );
501                true
502            }
503            Ok(resp) => {
504                log::warn!("Remove-from-album returned {}", resp.status());
505                false
506            }
507            Err(err) => {
508                log::warn!("Remove-from-album request failed: {}", err);
509                false
510            }
511        }
512    }
513}