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