1use 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 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 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 }
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 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 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 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 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 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 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 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 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}