//! Importing a Google Photos Takeout export into Proton Photos. //! //! [`pdfs_core::takeout`] says what is in the archives; this is the part that //! talks to Proton. One import runs on its own thread and moves through four //! phases, reporting into the transfer registry as a job so the GUI's transfer //! list shows it without a bespoke widget: //! //! 1. **Scan** — read the archives' central directories and sidecars. //! 2. **Deduplicate against the account** — ask the photos volume which of the //! names already exist ([`ProtonPhotosClient::name_collisions`]), hash only //! those files, then confirm with the content digest. A library that is //! already half-synced by Proton Photos on a phone therefore costs one cheap //! request per 150 names and reads no media at all for the misses. //! 3. **Upload** the survivors, streaming each entry straight out of its zip — //! no extraction to disk, so the import needs no scratch space. //! 4. **Albums** — create the albums the export names that the account lacks, //! then add every photo of each album, the ones that were already on the //! account included. //! //! Re-running an interrupted import is the resume story: phase 2 finds //! everything the previous run uploaded and skips it. Nothing is persisted //! between runs. use std::collections::{BTreeMap, HashMap}; use std::io::Read; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection}; use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives}; use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata}; use sha1::{Digest, Sha1}; use tracing::{info, warn}; use super::Core; use crate::photos::can_make_thumbnail; use crate::transfers::CountingReader; /// The one import this daemon may be running, and how the last one ended. /// /// Process-global rather than a [`Core`] field because it must be *one* import /// per daemon however many `Core` clones exist (the forked on-demand mounts each /// hold one), and because a front-end asking for the result after the import /// finished must still find it. static IMPORT: std::sync::LazyLock> = std::sync::LazyLock::new(|| parking_lot::Mutex::new(ImportState::default())); #[derive(Default)] struct ImportState { running: bool, cancel: Option>, last: Option, } /// Whether an import is running, and the last one's report. pub(crate) fn import_status() -> (bool, Option) { let state = IMPORT.lock(); (state.running, state.last.clone()) } /// Ask the running import to stop. False when none is running. pub(crate) fn cancel_import() -> bool { match &IMPORT.lock().cancel { Some(flag) => { flag.store(true, Ordering::Relaxed); true } None => false, } } /// One photo to import, after the copies Google keeps per album are folded into /// a single upload that joins several albums. struct Planned { photo: TakeoutPhoto, /// Every album this photo belongs to, by export title. albums: Vec, /// Filled in phase 2/3: where the photo ended up on the account. uid: Option, } /// Fold the scan into one entry per distinct photo. /// /// Google stores a full copy of a photo in every album folder it appears in, so /// the same file is in the export two or three times over. They are folded on /// `(name, size)`: a byte-identical copy under the same name is the same photo, /// and the album memberships of the copies are unioned onto the survivor. Two /// genuinely different photos sharing both a name and an exact size would be /// merged — vanishingly unlikely, and the cost of being wrong is one photo /// filed under an extra album rather than lost data. fn plan(scan: &TakeoutScan) -> Vec { let mut order: Vec<(String, u64)> = Vec::new(); let mut by_key: HashMap<(String, u64), Planned> = HashMap::new(); for photo in &scan.photos { let key = (photo.name.clone(), photo.size); let entry = by_key.entry(key.clone()).or_insert_with(|| { order.push(key.clone()); Planned { photo: photo.clone(), albums: Vec::new(), uid: None, } }); if let Bucket::Album(title) = &photo.bucket && !entry.albums.iter().any(|existing| existing == title) { entry.albums.push(title.clone()); } // A copy that carries a capture time wins over one that does not: the // album copy and the year-folder copy do not always both have a sidecar. if entry.photo.capture_time.is_none() && photo.capture_time.is_some() { entry.photo.capture_time = photo.capture_time; } entry.photo.favorite |= photo.favorite; } order .into_iter() .filter_map(|key| by_key.remove(&key)) .collect() } /// Open one archive and stream `entry` through `sink`. fn read_entry( archive: &mut zip::ZipArchive>, entry: &str, sink: impl FnOnce(&mut (dyn Read + Send)) -> R, ) -> CoreResult { let mut file = archive .by_name(entry) .map_err(|e| CoreError::invalid(format!("{entry}: {e}")))?; Ok(sink(&mut file)) } impl Core { /// Copy archive entry `entry` into the thumbnail staging folder and return /// its path. The caller removes it. fn stage_takeout_entry( &self, archive: &mut zip::ZipArchive>, entry: &str, ) -> CoreResult { static STAGED: AtomicU64 = AtomicU64::new(0); let path = self.cache.raw_thumbnail_staging_dir().join(format!( "takeout-{}-{}", std::process::id(), STAGED.fetch_add(1, Ordering::Relaxed) )); let copied = read_entry(archive, entry, |reader| { let mut file = std::fs::File::create(&path)?; std::io::copy(reader, &mut file) })?; if let Err(e) = copied { let _ = std::fs::remove_file(&path); return Err(CoreError::internal(format!("copy {entry}: {e}"))); } Ok(path) } /// Import Google Photos Takeout archives. Blocking; run on its own thread. /// /// `cancel` stops the run at the next photo boundary — an upload already on /// the wire is allowed to finish, so a cancelled import never leaves a /// half-written revision behind. pub(crate) fn import_takeout( &self, archives: Vec, dry_run: bool, cancel: Arc, ) -> CoreResult { let job = self.transfers.begin_job("Importing Google Photos"); job.detail("reading archives"); let scan = scan_archives(&archives)?; let mut planned = plan(&scan); let mut summary = ImportSummary { found: planned.len(), skipped_trashed: scan.skipped_trashed, ..Default::default() }; job.set_total(planned.len() as u64); info!( archives = archives.len(), photos = summary.found, albums = scan.albums.len(), trashed = summary.skipped_trashed, "takeout scan complete" ); let photos = self.photos(); if self .rt .block_on(photos.get_photos_root()) .map_err(|e| CoreError::from_api(&e, "resolve photos library"))? .is_none() { return Err(CoreError::invalid( "this account has no Proton Photos library yet; open Proton Photos once to create it, then import again", )); } // Phase 2: which of these are already on the account. job.detail("checking for photos you already have"); self.mark_existing(&mut planned, &archives, &mut summary, &cancel)?; if dry_run { summary.duplicates = planned.iter().filter(|p| p.uid.is_some()).count(); return Ok(summary); } // Phase 3: upload what is left, one archive at a time so each zip's // central directory is parsed once. self.upload_planned(&mut planned, &archives, &mut summary, &cancel, &job)?; // Phase 4: albums. Attempted even after a cancel — the photos that did // upload should still land in their albums. if !scan.albums.is_empty() { job.detail("sorting photos into albums"); if let Err(e) = self.link_albums(&planned, &scan.albums, &mut summary) { warn!(error = %e, "takeout album linking failed"); } } self.spawn_timeline_refresh(); summary.cancelled = cancel.load(Ordering::Relaxed); Ok(summary) } /// Fill in `uid` for every planned photo the account already holds. /// /// Two passes on purpose: the name-only query is one request per 150 photos /// and needs no file contents, so the expensive SHA-1 pass only runs for the /// handful of names that actually collide. fn mark_existing( &self, planned: &mut [Planned], archives: &[PathBuf], summary: &mut ImportSummary, cancel: &AtomicBool, ) -> CoreResult<()> { let photos = self.photos(); let names: Vec = planned.iter().map(|p| p.photo.name.clone()).collect(); let collisions = self .rt .block_on(photos.name_collisions(&names)) .map_err(|e| CoreError::from_api(&e, "check for existing photos"))?; // Hash the colliding files, grouped by archive so each zip opens once. let mut by_archive: BTreeMap> = BTreeMap::new(); for (index, planned) in planned.iter().enumerate() { if collisions.get(index).copied().unwrap_or(false) { by_archive .entry(planned.photo.archive) .or_default() .push(index); } } // Kept as a pair list, not a map: the duplicate lookup answers in input // order, so the order these were built in is what maps answers back to // planned photos. let mut digests: Vec<(usize, String)> = Vec::new(); for (archive_index, indices) in by_archive { if cancel.load(Ordering::Relaxed) { break; } let mut archive = open_archive(archives, archive_index)?; for index in indices { let entry = planned[index].photo.entry.clone(); match read_entry(&mut archive, &entry, sha1_of) { Ok(Ok(digest)) => digests.push((index, digest)), Ok(Err(e)) | Err(e) => { warn!(entry, error = %e, "cannot hash takeout entry"); } } } } if digests.is_empty() { return Ok(()); } let items: Vec<(String, String)> = digests .iter() .map(|(index, digest)| (planned[*index].photo.name.clone(), digest.clone())) .collect(); let matches = self .rt .block_on(photos.find_duplicates_many(&items)) .map_err(|e| CoreError::from_api(&e, "check for existing photos"))?; for (slot, (index, _)) in digests.iter().enumerate() { if let Some(uid) = matches.get(slot).and_then(|uids| uids.first()) { planned[*index].uid = Some(uid.clone()); summary.duplicates += 1; } } Ok(()) } /// Upload every planned photo that is not already on the account. fn upload_planned( &self, planned: &mut [Planned], archives: &[PathBuf], summary: &mut ImportSummary, cancel: &AtomicBool, job: &crate::transfers::JobGuard, ) -> CoreResult<()> { let photos = self.photos(); let mut favorites: Vec = Vec::new(); let mut by_archive: BTreeMap> = BTreeMap::new(); for (index, item) in planned.iter().enumerate() { if item.uid.is_none() { by_archive .entry(item.photo.archive) .or_default() .push(index); } } for (archive_index, indices) in by_archive { if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() { break; } let mut archive = open_archive(archives, archive_index)?; for index in indices { if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() { break; } let photo = planned[index].photo.clone(); job.detail(photo.name.clone()); let guard = self.transfers.begin( photo.name.clone(), "", TransferDirection::Upload, photo.size, ); let metadata = PhotoUploadMetadata { capture_time: photo.capture_time, ..Default::default() }; // A thumbnail is made from a file, so a photo that can have one // is copied out of the archive first and uploaded from the copy. // Without one, Proton's apps show a blank tile until they make // their own. let staged = can_make_thumbnail(&photo.name, photo.size) .then(|| self.stage_takeout_entry(&mut archive, &photo.entry)) .and_then(|staged| { staged .inspect_err(|e| { warn!(name = photo.name, error = %e, "copying a takeout photo out failed; uploading it without a thumbnail") }) .ok() }); let uploaded = match &staged { Some(path) => { let thumbnails = self.upload_thumbnails(path, &photo.name); // No sidecar and no date in the name: the photo's own // EXIF, before Drive files it under today. let metadata = PhotoUploadMetadata { capture_time: metadata .capture_time .or_else(|| pdfs_core::exiftime::capture_time_of(path)), ..metadata }; std::fs::File::open(path) .map_err(|e| { CoreError::internal(format!("open {}: {e}", path.display())) }) .map(|file| { let counting = CountingReader::new(std::io::BufReader::new(file), &guard); self.rt.block_on(photos.upload_photo_from( &photo.name, &photo.media_type, counting, photo.size as i64, thumbnails, metadata, false, )) }) } None => read_entry(&mut archive, &photo.entry, |reader| { let counting = CountingReader::new(reader, &guard); self.rt.block_on(photos.upload_photo_from( &photo.name, &photo.media_type, counting, photo.size as i64, Vec::new(), metadata, false, )) }), }; if let Some(path) = staged { let _ = std::fs::remove_file(path); } drop(guard); job.step(); match uploaded { Ok(Ok(uid)) => { summary.uploaded += 1; summary.bytes += photo.size; if photo.favorite { favorites.push(uid.clone()); } planned[index].uid = Some(uid); } Ok(Err(e)) => { summary.failed += 1; warn!(name = photo.name, error = %e, "takeout photo upload failed"); } Err(e) => { summary.failed += 1; warn!(name = photo.name, error = %e, "cannot read takeout entry"); } } } } // Favorites are a separate call: the upload seal takes classification // tags, but `Favorite` is not one of them — it goes through the photos // volume's dedicated favorite endpoint. if !favorites.is_empty() { let updates: Vec = favorites .into_iter() .map(|node_uid| PhotoTagsUpdate { node_uid, tags_to_add: vec![PhotoTag::Favorite], tags_to_remove: Vec::new(), }) .collect(); if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) { warn!(error = %e, "marking imported favorites failed"); } } Ok(()) } /// Create the albums the export names that the account lacks, then add each /// album's photos to it. fn link_albums( &self, planned: &[Planned], albums: &[String], summary: &mut ImportSummary, ) -> CoreResult<()> { let photos = self.photos(); // Existing albums by decrypted name, so a second import of the same // export adds to the album it made the first time instead of a twin. let mut existing: HashMap = HashMap::new(); let uids = self .rt .block_on(photos.enumerate_album_node_uids()) .map_err(|e| CoreError::from_api(&e, "list albums"))?; if !uids.is_empty() { let albums_nodes = self .rt .block_on(photos.enumerate_nodes(&uids)) .map_err(|e| CoreError::from_api(&e, "read albums"))?; for node in albums_nodes { existing.entry(node.name.clone()).or_insert(node.uid); } } for title in albums { let members: Vec = planned .iter() .filter(|item| item.albums.iter().any(|album| album == title)) .filter_map(|item| item.uid.clone()) .collect(); if members.is_empty() { continue; } let album_uid = match existing.get(title) { Some(uid) => uid.clone(), None => match self.rt.block_on(photos.create_album(title)) { Ok(uid) => { summary.albums_created += 1; existing.insert(title.clone(), uid.clone()); uid } Err(e) => { warn!(album = title, error = %e, "cannot create album"); continue; } }, }; match self .rt .block_on(photos.add_photos_to_album(&album_uid, &members)) { Ok(outcomes) => { for (uid, outcome) in outcomes { match outcome { Ok(()) => summary.album_links += 1, // A photo already in the album is the expected // outcome of a re-run, not a failure worth counting. Err(e) => { warn!(album = title, %uid, error = %e, "cannot add photo to album") } } } } Err(e) => warn!(album = title, error = %e, "album addition failed"), } } Ok(()) } /// Run an import on a background thread, logging its outcome to the activity /// feed. Returns immediately. /// /// Refuses while one is already running: two imports of the same export /// would race each other's duplicate detection and upload everything twice. pub(crate) fn spawn_takeout_import( &self, archives: Vec, dry_run: bool, ) -> CoreResult<()> { let cancel = Arc::new(AtomicBool::new(false)); { let mut state = IMPORT.lock(); if state.running { return Err(CoreError::conflict( "an import is already running; wait for it to finish or cancel it", )); } state.running = true; state.cancel = Some(cancel.clone()); state.last = None; } let core = self.clone(); std::thread::spawn(move || { let outcome = core.import_takeout(archives, dry_run, cancel); { let mut state = IMPORT.lock(); state.running = false; state.cancel = None; // A failed run is reported too: without it, `--wait` read // "no import has run" and exited as if all was well. state.last = Some(match &outcome { Ok(summary) => summary.clone(), Err(e) => ImportSummary { error: Some(e.to_string()), ..Default::default() }, }); } match outcome { Ok(summary) => { let mut parts = vec![format!("{} uploaded", summary.uploaded)]; if summary.duplicates > 0 { parts.push(format!("{} already there", summary.duplicates)); } if summary.albums_created > 0 { parts.push(format!("{} albums", summary.albums_created)); } if summary.failed > 0 { parts.push(format!("{} failed", summary.failed)); } if summary.cancelled { parts.push("cancelled".to_string()); } core.log_activity( ActivityKind::Upload, "Google Photos import", parts.join(" · "), summary.failed == 0 && !summary.cancelled, ); } Err(e) => { warn!(error = %e, "takeout import failed"); core.log_activity(ActivityKind::Upload, "Google Photos import", &e, false); } } }); Ok(()) } } /// Open one of the import's archives by index. fn open_archive( archives: &[PathBuf], index: usize, ) -> CoreResult>> { let path = archives .get(index) .ok_or_else(|| CoreError::internal(format!("archive {index} is not in the import")))?; let file = std::fs::File::open(path) .map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?; zip::ZipArchive::new(std::io::BufReader::new(file)) .map_err(|e| CoreError::invalid(format!("{} is not a readable zip: {e}", path.display()))) } /// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's /// photo duplicate detection is keyed on. pub(crate) fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult { let mut hasher = Sha1::new(); let mut buffer = vec![0u8; 256 * 1024]; loop { let read = reader .read(&mut buffer) .map_err(|e| CoreError::invalid(format!("read failed: {e}")))?; if read == 0 { break; } hasher.update(&buffer[..read]); } Ok(hex_lower(&hasher.finalize())) } pub(crate) fn hex_lower(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len() * 2); for byte in bytes { out.push_str(&format!("{byte:02x}")); } out } #[cfg(test)] mod tests { use super::*; use pdfs_core::takeout::TakeoutPhoto; fn photo(name: &str, size: u64, bucket: Bucket) -> TakeoutPhoto { TakeoutPhoto { archive: 0, entry: format!("Takeout/Google Photos/x/{name}"), name: name.to_string(), size, media_type: "image/jpeg".to_string(), capture_time: None, favorite: false, bucket, } } #[test] fn album_copies_of_one_photo_fold_into_a_single_upload() { let scan = TakeoutScan { photos: vec![ photo("IMG_1.jpg", 100, Bucket::Timeline), photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into())), photo("IMG_1.jpg", 100, Bucket::Album("Best of".into())), photo("IMG_2.jpg", 200, Bucket::Timeline), ], albums: vec!["Iceland".into(), "Best of".into()], ..Default::default() }; let planned = plan(&scan); assert_eq!(planned.len(), 2, "the three copies are one upload"); assert_eq!(planned[0].albums, vec!["Iceland", "Best of"]); assert!(planned[1].albums.is_empty()); } #[test] fn same_name_different_size_stays_two_photos() { let scan = TakeoutScan { photos: vec![ photo("IMG_1.jpg", 100, Bucket::Timeline), photo("IMG_1.jpg", 101, Bucket::Album("Iceland".into())), ], ..Default::default() }; assert_eq!(plan(&scan).len(), 2); } #[test] fn metadata_from_any_copy_survives_the_fold() { // Only the album copy carries a sidecar; the fold must not lose it. let mut bare = photo("IMG_1.jpg", 100, Bucket::Timeline); bare.capture_time = None; let mut rich = photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into())); rich.capture_time = Some(1_560_000_000); rich.favorite = true; let planned = plan(&TakeoutScan { photos: vec![bare, rich], ..Default::default() }); assert_eq!(planned[0].photo.capture_time, Some(1_560_000_000)); assert!(planned[0].photo.favorite); } #[test] fn sha1_matches_the_known_digest_of_abc() { let mut reader: &[u8] = b"abc"; assert_eq!( sha1_of(&mut reader).unwrap(), "a9993e364706816aba3e25717850c26c9cd0d89d" ); } }