1use anyhow::{Context, Result};
14use chrono::{Datelike, NaiveDate};
15use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17use tokio::sync::{Mutex, OnceCell};
18
19use crate::api::SmugMugClient;
20use crate::api::albums::{Album, ChildNode};
21use crate::api::images::AlbumImage;
22use crate::uploader::album_series::{AlbumSeries, AlbumSeriesBackend, MAX_ALBUM_IMAGES};
23
24#[allow(async_fn_in_trait)]
28pub trait Smug {
29 async fn children(&self, node_uri: &str) -> Result<Vec<ChildNode>>;
30 async fn create_folder(&self, parent_node_uri: &str, name: &str) -> Result<String>;
33 async fn create_album(&self, parent_node_uri: &str, name: &str) -> Result<Album>;
35 async fn image_count(&self, album: &Album) -> Result<u64>;
36 async fn album_images(&self, album_key: &str) -> Result<Vec<AlbumImage>>;
37}
38
39impl Smug for SmugMugClient {
40 async fn children(&self, node_uri: &str) -> Result<Vec<ChildNode>> {
41 self.list_children(node_uri).await
42 }
43 async fn create_folder(&self, parent_node_uri: &str, name: &str) -> Result<String> {
44 SmugMugClient::create_sorted_folder(self, parent_node_uri, name, Some("Private")).await
45 }
46 async fn create_album(&self, parent_node_uri: &str, name: &str) -> Result<Album> {
47 SmugMugClient::create_album(self, name, Some(parent_node_uri), "Private").await
48 }
49 async fn image_count(&self, album: &Album) -> Result<u64> {
50 Ok(self
51 .get_album(&album.album_key)
52 .await?
53 .image_count
54 .unwrap_or(0))
55 }
56 async fn album_images(&self, album_key: &str) -> Result<Vec<AlbumImage>> {
57 self.list_album_images(album_key).await
58 }
59}
60
61pub fn day_album_name(date: NaiveDate) -> String {
63 date.format("%Y-%m-%d").to_string()
64}
65
66fn month_path(date: NaiveDate) -> [String; 2] {
67 [
68 format!("{:04}", date.year()),
69 format!("{:02}", date.month()),
70 ]
71}
72
73#[derive(Default)]
75struct Folders {
76 known: HashMap<String, Option<String>>,
78 listings: HashMap<String, Arc<Vec<ChildNode>>>,
79}
80
81struct Inner<T> {
82 smug: Arc<T>,
83 root: Option<String>,
85 dry_run: bool,
86 folders: Mutex<Folders>,
87}
88
89impl<T: Smug> Inner<T> {
90 async fn listing(&self, folders: &mut Folders, node_uri: &str) -> Result<Arc<Vec<ChildNode>>> {
91 if let Some(listing) = folders.listings.get(node_uri) {
92 return Ok(listing.clone());
93 }
94 let listing = Arc::new(
95 self.smug
96 .children(node_uri)
97 .await
98 .with_context(|| format!("Failed to list folder {}", node_uri))?,
99 );
100 folders
101 .listings
102 .insert(node_uri.to_string(), listing.clone());
103 Ok(listing)
104 }
105
106 async fn folder(&self, path: &[String], create: bool) -> Result<Option<String>> {
109 let Some(root) = &self.root else {
110 return Ok(None);
111 };
112 let mut folders = self.folders.lock().await;
113 let mut parent = root.clone();
114 let mut key = String::new();
115 for part in path {
116 if !key.is_empty() {
117 key.push('/');
118 }
119 key.push_str(part);
120
121 let known = folders.known.get(&key).cloned();
122 let found = match known {
123 Some(Some(uri)) => Some(uri),
124 Some(None) if !create => return Ok(None),
125 _ => self
126 .listing(&mut folders, &parent)
127 .await?
128 .iter()
129 .find(|c| c.node_type == "Folder" && c.name == *part)
130 .map(|c| c.uri.clone()),
131 };
132 let uri = match found {
133 Some(uri) => uri,
134 None if create && !self.dry_run => {
135 let uri = self
136 .smug
137 .create_folder(&parent, part)
138 .await
139 .with_context(|| format!("Failed to create folder {}", key))?;
140 folders.listings.insert(uri.clone(), Arc::new(Vec::new()));
141 uri
142 }
143 None => {
144 folders.known.insert(key, None);
145 return Ok(None);
146 }
147 };
148 folders.known.insert(key.clone(), Some(uri.clone()));
149 parent = uri;
150 }
151 Ok(Some(parent))
152 }
153
154 async fn albums_in(&self, folder_uri: &str) -> Result<Vec<Album>> {
155 let mut folders = self.folders.lock().await;
156 Ok(self
157 .listing(&mut folders, folder_uri)
158 .await?
159 .iter()
160 .filter_map(ChildNode::album)
161 .collect())
162 }
163}
164
165pub struct DayBackend<T> {
167 inner: Arc<Inner<T>>,
168 date: NaiveDate,
169}
170
171impl<T: Smug> AlbumSeriesBackend for DayBackend<T> {
172 async fn find_album(&self, name: &str) -> Result<Option<Album>> {
173 let Some(month) = self.inner.folder(&month_path(self.date), false).await? else {
174 return Ok(None);
175 };
176 Ok(self
177 .inner
178 .albums_in(&month)
179 .await?
180 .into_iter()
181 .find(|a| a.name == name))
182 }
183
184 async fn image_count(&self, album: &Album) -> Result<u64> {
185 self.inner.smug.image_count(album).await
186 }
187
188 async fn create_album(&self, name: &str) -> Result<Album> {
189 let month = self
190 .inner
191 .folder(&month_path(self.date), true)
192 .await?
193 .context("The day's folder couldn't be created")?;
194 self.inner.smug.create_album(&month, name).await
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct NamedImage {
201 pub md5: Option<String>,
203 pub uri: String,
205 pub album_key: String,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum NameCheck {
211 Upload(String),
214 Present(NamedImage),
216}
217
218pub struct Day<T> {
220 pub series: AlbumSeries<DayBackend<T>>,
221 smug: Arc<T>,
222 names: OnceCell<std::sync::Mutex<HashMap<String, NamedImage>>>,
223}
224
225pub fn renamed(file_name: &str, md5: &str) -> String {
227 let tag = &md5[..md5.len().min(8)];
228 match file_name.rsplit_once('.') {
229 Some((stem, ext)) if !stem.is_empty() => format!("{}~{}.{}", stem, tag, ext),
230 _ => format!("{}~{}", file_name, tag),
231 }
232}
233
234impl<T: Smug> Day<T> {
235 async fn names(&self) -> Result<&std::sync::Mutex<HashMap<String, NamedImage>>> {
236 self.names
237 .get_or_try_init(|| async {
238 let mut names = HashMap::new();
239 for existing in self.series.existing_albums().await {
240 let key = existing.album.album_key;
241 for image in
242 self.smug.album_images(&key).await.with_context(|| {
243 format!("Failed to list album {}", existing.album.name)
244 })?
245 {
246 names.insert(
247 image.file_name,
248 NamedImage {
249 md5: image.archived_md5.map(|m| m.to_lowercase()),
250 uri: image.uri,
251 album_key: key.clone(),
252 },
253 );
254 }
255 }
256 Ok::<_, anyhow::Error>(std::sync::Mutex::new(names))
257 })
258 .await
259 }
260
261 pub async fn check_name(&self, file_name: &str, md5: &str) -> Result<NameCheck> {
264 let names = self.names().await?;
265 let mut names = names.lock().unwrap();
266 let alternative = renamed(file_name, md5);
267 for candidate in [file_name, alternative.as_str()] {
268 match names.get(candidate) {
269 None => {
270 names.insert(
271 candidate.to_string(),
272 NamedImage {
273 md5: Some(md5.to_string()),
274 uri: String::new(),
275 album_key: String::new(),
276 },
277 );
278 return Ok(NameCheck::Upload(candidate.to_string()));
279 }
280 Some(existing)
281 if existing.md5.as_deref() == Some(md5) && !existing.uri.is_empty() =>
282 {
283 return Ok(NameCheck::Present(existing.clone()));
284 }
285 Some(_) => {}
286 }
287 }
288 let mut n = 2;
290 loop {
291 let candidate = renamed(file_name, &format!("{}-{}", md5, n));
292 if !names.contains_key(&candidate) {
293 names.insert(
294 candidate.clone(),
295 NamedImage {
296 md5: Some(md5.to_string()),
297 uri: String::new(),
298 album_key: String::new(),
299 },
300 );
301 return Ok(NameCheck::Upload(candidate));
302 }
303 n += 1;
304 }
305 }
306
307 pub async fn name_uploaded(&self, name: &str, uri: &str, album_key: &str) {
309 if let Some(names) = self.names.get()
310 && let Some(entry) = names.lock().unwrap().get_mut(name)
311 {
312 entry.uri = uri.to_string();
313 entry.album_key = album_key.to_string();
314 }
315 }
316
317 pub async fn release_name(&self, name: &str) {
319 if let Some(names) = self.names.get() {
320 let mut names = names.lock().unwrap();
321 if names.get(name).is_some_and(|e| e.uri.is_empty()) {
322 names.remove(name);
323 }
324 }
325 }
326
327 pub async fn album_keys(&self) -> HashSet<String> {
329 self.series
330 .albums()
331 .await
332 .into_iter()
333 .map(|a| a.album.album_key)
334 .filter(|k| !k.is_empty())
335 .collect()
336 }
337}
338
339type DayCell<T> = Arc<OnceCell<Arc<Day<T>>>>;
341
342pub struct DayAlbums<T> {
344 inner: Arc<Inner<T>>,
345 days: std::sync::Mutex<HashMap<NaiveDate, DayCell<T>>>,
346}
347
348impl<T: Smug> DayAlbums<T> {
349 pub fn new(smug: Arc<T>, root: Option<String>, dry_run: bool) -> Self {
352 DayAlbums {
353 inner: Arc::new(Inner {
354 smug,
355 root,
356 dry_run,
357 folders: Mutex::new(Folders::default()),
358 }),
359 days: std::sync::Mutex::new(HashMap::new()),
360 }
361 }
362
363 pub async fn day(&self, date: NaiveDate) -> Result<Arc<Day<T>>> {
366 let cell = self
367 .days
368 .lock()
369 .unwrap()
370 .entry(date)
371 .or_insert_with(|| Arc::new(OnceCell::new()))
372 .clone();
373 cell.get_or_try_init(|| async {
374 let backend = DayBackend {
375 inner: self.inner.clone(),
376 date,
377 };
378 let series = AlbumSeries::load(
379 backend,
380 &day_album_name(date),
381 MAX_ALBUM_IMAGES,
382 self.inner.dry_run,
383 )
384 .await
385 .with_context(|| format!("Failed to look up the albums for {}", date))?;
386 Ok::<_, anyhow::Error>(Arc::new(Day {
387 series,
388 smug: self.inner.smug.clone(),
389 names: OnceCell::new(),
390 }))
391 })
392 .await
393 .cloned()
394 }
395
396 pub fn loaded_days(&self) -> Vec<Arc<Day<T>>> {
398 self.days
399 .lock()
400 .unwrap()
401 .values()
402 .filter_map(|cell| cell.get().cloned())
403 .collect()
404 }
405}
406
407#[cfg(test)]
408pub(crate) mod tests {
409 use super::*;
410 use std::sync::Mutex as StdMutex;
411
412 #[derive(Default)]
414 pub struct FakeSmug {
415 pub nodes: StdMutex<HashMap<String, Vec<ChildNode>>>,
417 pub images: StdMutex<HashMap<String, Vec<AlbumImage>>>,
419 pub calls: StdMutex<Vec<String>>,
420 pub refuse: StdMutex<HashSet<String>>,
422 next: StdMutex<u32>,
423 }
424
425 impl FakeSmug {
426 pub fn id(&self) -> u32 {
427 let mut n = self.next.lock().unwrap();
428 *n += 1;
429 *n
430 }
431
432 pub fn with_root() -> (Arc<Self>, String) {
433 let fake = Arc::new(FakeSmug::default());
434 fake.nodes
435 .lock()
436 .unwrap()
437 .insert("/node/root".into(), Vec::new());
438 (fake, "/node/root".into())
439 }
440
441 pub fn add_folder(&self, parent: &str, name: &str) -> String {
442 let uri = format!("/node/f{}", self.id());
443 self.nodes
444 .lock()
445 .unwrap()
446 .get_mut(parent)
447 .unwrap()
448 .push(ChildNode {
449 name: name.into(),
450 node_type: "Folder".into(),
451 uri: uri.clone(),
452 node_id: String::new(),
453 url_name: String::new(),
454 web_uri: None,
455 uris: None,
456 sort_method: None,
457 sort_direction: None,
458 });
459 self.nodes.lock().unwrap().insert(uri.clone(), Vec::new());
460 uri
461 }
462
463 pub fn add_album(&self, parent: &str, name: &str, images: &[(&str, &str)]) -> Album {
464 let id = self.id();
465 let key = format!("A{}", id);
466 let album_uri = format!("/api/v2/album/{}", key);
467 self.nodes
468 .lock()
469 .unwrap()
470 .get_mut(parent)
471 .unwrap()
472 .push(ChildNode {
473 name: name.into(),
474 node_type: "Album".into(),
475 uri: format!("/node/a{}", id),
476 node_id: format!("a{}", id),
477 url_name: name.into(),
478 web_uri: None,
479 uris: Some(crate::api::albums::ChildNodeUris {
480 album: Some(crate::api::albums::UriRef {
481 uri: album_uri.clone(),
482 }),
483 }),
484 sort_method: None,
485 sort_direction: None,
486 });
487 let images = images
488 .iter()
489 .map(|(file, md5)| AlbumImage {
490 image_key: format!("{}-{}", key, file),
491 file_name: file.to_string(),
492 archived_uri: String::new(),
493 file_size: 1,
494 format: "JPG".into(),
495 uri: format!("{}/image/{}", album_uri, file),
496 title: None,
497 archived_md5: Some(md5.to_string()),
498 })
499 .collect();
500 self.images.lock().unwrap().insert(key.clone(), images);
501 Album {
502 album_key: key,
503 name: name.into(),
504 url_name: name.into(),
505 node_id: format!("a{}", id),
506 uri: album_uri,
507 web_uri: None,
508 uris: None,
509 image_count: None,
510 }
511 }
512
513 pub fn calls(&self, prefix: &str) -> usize {
514 self.calls
515 .lock()
516 .unwrap()
517 .iter()
518 .filter(|c| c.starts_with(prefix))
519 .count()
520 }
521 }
522
523 impl Smug for FakeSmug {
524 async fn children(&self, node_uri: &str) -> Result<Vec<ChildNode>> {
525 self.calls
526 .lock()
527 .unwrap()
528 .push(format!("children {}", node_uri));
529 self.nodes
530 .lock()
531 .unwrap()
532 .get(node_uri)
533 .cloned()
534 .context("no such node")
535 }
536 async fn create_folder(&self, parent: &str, name: &str) -> Result<String> {
537 self.calls
538 .lock()
539 .unwrap()
540 .push(format!("create_folder {}", name));
541 Ok(self.add_folder(parent, name))
542 }
543 async fn create_album(&self, parent: &str, name: &str) -> Result<Album> {
544 self.calls
545 .lock()
546 .unwrap()
547 .push(format!("create_album {}", name));
548 Ok(self.add_album(parent, name, &[]))
549 }
550 async fn image_count(&self, album: &Album) -> Result<u64> {
551 Ok(self
552 .images
553 .lock()
554 .unwrap()
555 .get(&album.album_key)
556 .map_or(0, |i| i.len() as u64))
557 }
558 async fn album_images(&self, album_key: &str) -> Result<Vec<AlbumImage>> {
559 self.calls
560 .lock()
561 .unwrap()
562 .push(format!("album_images {}", album_key));
563 Ok(self
564 .images
565 .lock()
566 .unwrap()
567 .get(album_key)
568 .cloned()
569 .unwrap_or_default())
570 }
571 }
572
573 fn d(y: i32, m: u32, day: u32) -> NaiveDate {
574 NaiveDate::from_ymd_opt(y, m, day).unwrap()
575 }
576
577 #[tokio::test]
578 async fn finds_existing_day_albums_without_creating_anything() {
579 let (fake, root) = FakeSmug::with_root();
580 let year = fake.add_folder(&root, "2014");
581 let month = fake.add_folder(&year, "07");
582 let album = fake.add_album(&month, "2014-07-12", &[("a.jpg", "aa")]);
583
584 let days = DayAlbums::new(fake.clone(), Some(root), false);
585 let day = days.day(d(2014, 7, 12)).await.unwrap();
586 let existing = day.series.existing_albums().await;
587 assert_eq!(existing.len(), 1);
588 assert_eq!(existing[0].album.album_key, album.album_key);
589 assert_eq!(fake.calls("create"), 0);
590
591 let before = fake.calls("children");
593 days.day(d(2014, 7, 13)).await.unwrap();
594 assert_eq!(fake.calls("children"), before);
595 }
596
597 #[tokio::test]
598 async fn creates_year_month_and_day_only_when_claimed() {
599 let (fake, root) = FakeSmug::with_root();
600 let days = DayAlbums::new(fake.clone(), Some(root.clone()), false);
601 let day = days.day(d(2020, 1, 5)).await.unwrap();
602 assert_eq!(fake.calls("create"), 0);
603
604 let album = day.series.claim().await.unwrap();
605 assert_eq!(album.name, "2020-01-05");
606 assert_eq!(fake.calls("create_folder"), 2);
607 assert_eq!(fake.calls("create_album"), 1);
608
609 let other = days.day(d(2020, 1, 6)).await.unwrap();
611 other.series.claim().await.unwrap();
612 assert_eq!(fake.calls("create_folder"), 2);
613
614 let nodes = fake.nodes.lock().unwrap();
615 let year = nodes[&root].iter().find(|c| c.name == "2020").unwrap();
616 let month = nodes[&year.uri].iter().find(|c| c.name == "01").unwrap();
617 let names: Vec<_> = nodes[&month.uri].iter().map(|c| c.name.clone()).collect();
618 assert_eq!(names, vec!["2020-01-05", "2020-01-06"]);
619 }
620
621 #[tokio::test]
622 async fn dry_run_without_root_uses_placeholders() {
623 let (fake, _) = FakeSmug::with_root();
624 let days = DayAlbums::new(fake.clone(), None, true);
625 let day = days.day(d(2020, 1, 5)).await.unwrap();
626 let album = day.series.claim().await.unwrap();
627 assert_eq!(album.name, "2020-01-05");
628 assert!(album.album_key.is_empty());
629 assert!(fake.calls.lock().unwrap().is_empty());
630 }
631
632 #[tokio::test]
633 async fn dry_run_creates_nothing() {
634 let (fake, root) = FakeSmug::with_root();
635 let days = DayAlbums::new(fake.clone(), Some(root), true);
636 let day = days.day(d(2020, 1, 5)).await.unwrap();
637 day.series.claim().await.unwrap();
638 assert_eq!(fake.calls("create"), 0);
639 }
640
641 #[tokio::test]
642 async fn names_link_same_content_and_rename_different_content() {
643 let (fake, root) = FakeSmug::with_root();
644 let year = fake.add_folder(&root, "2014");
645 let month = fake.add_folder(&year, "07");
646 fake.add_album(&month, "2014-07-12", &[("IMG_0001.JPG", "aaaa1111")]);
647
648 let days = DayAlbums::new(fake.clone(), Some(root), false);
649 let day = days.day(d(2014, 7, 12)).await.unwrap();
650
651 match day.check_name("IMG_0001.JPG", "aaaa1111").await.unwrap() {
652 NameCheck::Present(image) => assert!(image.uri.ends_with("IMG_0001.JPG")),
653 other => panic!("{:?}", other),
654 }
655 assert_eq!(
656 day.check_name("IMG_0001.JPG", "bbbb2222cccc")
657 .await
658 .unwrap(),
659 NameCheck::Upload("IMG_0001~bbbb2222.JPG".into())
660 );
661 assert_eq!(
662 day.check_name("IMG_0002.JPG", "dddd").await.unwrap(),
663 NameCheck::Upload("IMG_0002.JPG".into())
664 );
665 assert_eq!(
667 day.check_name("IMG_0002.JPG", "eeee").await.unwrap(),
668 NameCheck::Upload("IMG_0002~eeee.JPG".into())
669 );
670 day.release_name("IMG_0002.JPG").await;
672 assert_eq!(
673 day.check_name("IMG_0002.JPG", "ffff").await.unwrap(),
674 NameCheck::Upload("IMG_0002.JPG".into())
675 );
676 day.name_uploaded("IMG_0002.JPG", "/img/2", "A9").await;
678 assert_eq!(
679 day.check_name("IMG_0002.JPG", "ffff").await.unwrap(),
680 NameCheck::Present(NamedImage {
681 md5: Some("ffff".into()),
682 uri: "/img/2".into(),
683 album_key: "A9".into()
684 })
685 );
686 assert_eq!(fake.calls("album_images"), 1);
687 }
688
689 #[test]
690 fn renaming() {
691 assert_eq!(
692 renamed("IMG_0001.JPG", "0123456789"),
693 "IMG_0001~01234567.JPG"
694 );
695 assert_eq!(renamed("noext", "0123456789"), "noext~01234567");
696 assert_eq!(renamed(".hidden", "ab"), ".hidden~ab");
697 }
698}