# coding: utf8 import logging import os import sys from argparse import ArgumentParser, Namespace from datetime import datetime from pathlib import Path from appdirs import AppDirs from gphotos import Utils from gphotos.authorize import Authorize from gphotos.Checks import do_check, get_check from gphotos.GoogleAlbumsSync import GoogleAlbumsSync from gphotos.GooglePhotosDownload import GooglePhotosDownload from gphotos.GooglePhotosIndex import GooglePhotosIndex from gphotos.LocalData import LocalData from gphotos.LocalFilesScan import LocalFilesScan from gphotos.Logging import setup_logging from gphotos.restclient import RestClient from gphotos.Settings import Settings from gphotos import __version__ if os.name == "nt": import subprocess orig_Popen = subprocess.Popen class Popen_patch(subprocess.Popen): def __init__(self, *args, **kargs): startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW kargs["startupinfo"] = startupinfo super().__init__(*args, **kargs) subprocess.Popen = Popen_patch else: import fcntl APP_NAME = "gphotos-sync" log = logging.getLogger(__name__) class GooglePhotosSyncMain: def __init__(self): self.data_store: LocalData = None self.google_photos_client: RestClient = None self.google_photos_idx: GooglePhotosIndex = None self.google_photos_down: GooglePhotosDownload = None self.google_albums_sync: GoogleAlbumsSync = None self.local_files_scan: LocalFilesScan = None self._start_date = None self._end_date = None self.auth: Authorize = None try: version_string = "version: {}, database schema version {}".format( __version__, LocalData.VERSION ) except TypeError: version_string = "(version not available)" parser = ArgumentParser( epilog=version_string, description="Google Photos download tool" ) parser.add_argument( "root_folder", help="root of the local folders to download into" ) parser.add_argument( "--album", action="store", help="only synchronize the contents of a single album." 'use quotes e.g. "album name" for album names with spaces', ) parser.add_argument( "--log-level", help="Set log level. Options: critical, error, warning, info, debug, trace. " "trace logs all Google API calls to a file with suffix .trace", default="warning", ) parser.add_argument( "--logfile", action="store", help="full path to debug level logfile, default: <root>/gphotos.log." "If a directory is specified then a unique filename will be" "generated.", ) parser.add_argument( "--compare-folder", action="store", help="root of the local folders to compare to the Photos Library", ) parser.add_argument( "--favourites-only", action="store_true", help="only download media marked as favourite (star)", ) parser.add_argument( "--flush-index", action="store_true", help="delete the index db, re-scan everything", ) parser.add_argument( "--rescan", action="store_true", help="rescan entire library, ignoring last scan date. Use this if you " "have added photos to the library that " "predate the last sync, or you have deleted some of the local " "files", ) parser.add_argument( "--retry-download", action="store_true", help="check for the existence of files marked as already downloaded " "and re-download any missing ones. Use " "this if you have deleted some local files", ) parser.add_argument( "--skip-video", action="store_true", help="skip video types in sync" ) parser.add_argument( "--skip-shared-albums", action="store_true", help="skip albums that only appear in 'Sharing'", ) parser.add_argument( "--album-date-by-first-photo", action="store_true", help="Make the album date the same as its earliest " "photo. The default is its last photo", ) parser.add_argument( "--start-date", help="Set the earliest date of files to sync" "format YYYY-MM-DD", default=None, ) parser.add_argument( "--end-date", help="Set the latest date of files to sync" "format YYYY-MM-DD", default=None, ) parser.add_argument( "--db-path", help="Specify a pre-existing folder for the index database. " "Defaults to the root of the local download folders", default=None, ) parser.add_argument( "--albums-path", help="Specify a folder for the albums " "Defaults to the 'albums' in the local download folders", default="albums", ) parser.add_argument( "--photos-path", help="Specify a folder for the photo files. " "Defaults to the 'photos' in the local download folders", default="photos", ) parser.add_argument( "--use-flat-path", action="store_true", help="Mandate use of a flat directory structure ('YYYY-MMM') and not " "a nested one ('YYYY/MM') . ", ) parser.add_argument( "--omit-album-date", action="store_true", help="Don't include year and month in album folder names.", ) parser.add_argument("--new-token", action="store_true", help="Request new token") parser.add_argument( "--index-only", action="store_true", help="Only build the index of files in .gphotos.db - no downloads", ) parser.add_argument( "--skip-index", action="store_true", help="Use index from previous run and start download immediately", ) parser.add_argument( "--do-delete", action="store_true", help="""Remove local copies of files that were deleted. Must be used with --flush-index since the deleted items must be removed from the index""", ) parser.add_argument( "--skip-files", action="store_true", help="Dont download files, just refresh the album links (for testing)", ) parser.add_argument( "--skip-albums", action="store_true", help="Dont download albums (for testing)" ) parser.add_argument( "--use-hardlinks", action="store_true", help="Use hardlinks instead of symbolic links in albums and comparison" " folders", ) parser.add_argument( "--no-album-index", action="store_true", help="only index the photos library - skip indexing of folder contents " "(for testing)", ) parser.add_argument( "--case-insensitive-fs", action="store_true", help="add this flag if your filesystem is case insensitive", ) parser.add_argument( "--max-retries", help="Set the number of retries on network timeout / failures", default=5, ) parser.add_argument( "--max-threads", help="Set the number of concurrent threads to use for parallel " "download of media - reduce this number if network load is " "excessive", default=20, ) parser.add_argument( "--secret", help="Path to client secret file (by default this is in the " "application config directory)", ) parser.add_argument( "--archived", action="store_true", help="Download media items that have been marked as archived", ) parser.add_argument( "--progress", action="store_true", help="show progress of indexing and downloading in warning log", ) parser.add_argument( "--max-filename", help="Set the maxiumum filename length for target filesystem." "This overrides the automatic detection.", default=0, ) parser.add_argument( "--ntfs", action="store_true", help="Declare that the target filesystem is ntfs (or ntfs like)." "This overrides the automatic detection.", ) parser.add_help = True def setup(self, args: Namespace, db_path: Path): root_folder = Path(args.root_folder).absolute() compare_folder = None if args.compare_folder: compare_folder = Path(args.compare_folder).absolute() app_dirs = AppDirs(APP_NAME) self.data_store = LocalData(db_path, args.flush_index) credentials_file = db_path / ".gphotos.token" if args.secret: secret_file = Path(args.secret) else: secret_file = Path(app_dirs.user_config_dir) / "client_secret.json" if args.new_token and credentials_file.exists(): credentials_file.unlink() scope = [ "https://www.googleapis.com/auth/photoslibrary.readonly", "https://www.googleapis.com/auth/photoslibrary.sharing", ] photos_api_url = ( "https://photoslibrary.googleapis.com/$discovery" "/rest?version=v1" ) self.auth = Authorize( scope, credentials_file, secret_file, int(args.max_retries) ) self.auth.authorize() settings = Settings( start_date=Utils.string_to_date(args.start_date), end_date=Utils.string_to_date(args.end_date), shared_albums=not args.skip_shared_albums, album_index=not args.no_album_index, use_start_date=args.album_date_by_first_photo, album=args.album, favourites_only=args.favourites_only, retry_download=args.retry_download, case_insensitive_fs=args.case_insensitive_fs, include_video=not args.skip_video, rescan=args.rescan, archived=args.archived, photos_path=Path(args.photos_path), albums_path=Path(args.albums_path), use_flat_path=args.use_flat_path, max_retries=int(args.max_retries), max_threads=int(args.max_threads), omit_album_date=args.omit_album_date, use_hardlinks=args.use_hardlinks, progress=args.progress, ) self.google_photos_client = RestClient(photos_api_url, self.auth.session) self.google_photos_idx = GooglePhotosIndex( self.google_photos_client, root_folder, self.data_store, settings ) self.google_photos_down = GooglePhotosDownload( self.google_photos_client, root_folder, self.data_store, settings ) self.google_albums_sync = GoogleAlbumsSync( self.google_photos_client, root_folder, self.data_store, args.flush_index or args.retry_download or args.rescan, settings, ) if args.compare_folder: self.local_files_scan = LocalFilesScan( root_folder, compare_folder, self.data_store ) def do_sync(self, args: Namespace): files_downloaded = 0 with self.data_store: if not args.skip_index: if not args.skip_files and not args.album: self.google_photos_idx.index_photos_media() if not args.index_only: if not args.skip_files: files_downloaded = self.google_photos_down.download_photo_media() if ( not args.skip_albums and not args.skip_index and (files_downloaded > 0 or args.skip_files or args.rescan) ) or args.album is not None: self.google_albums_sync.index_album_media() # run download again to pick up files indexed in albums only if not args.index_only: if not args.skip_files: files_downloaded = ( self.google_photos_down.download_photo_media() ) if not args.index_only: if ( not args.skip_albums and (files_downloaded > 0 or args.skip_files or args.rescan) or args.album is not None ): self.google_albums_sync.create_album_content_links() if args.do_delete: self.google_photos_idx.check_for_removed() if args.compare_folder: if not args.skip_index: self.local_files_scan.scan_local_files() self.google_photos_idx.get_extra_meta() self.local_files_scan.find_missing_gphotos() def start(self, args: Namespace): self.do_sync(args) @staticmethod def fs_checks(root_folder: Path, args: dict): Utils.minimum_date(root_folder) # store the root folder filesystem checks globally for all to inspect do_check(root_folder, int(args.max_filename), bool(args.ntfs)) # check if symlinks are supported if not get_check().is_symlink: args.skip_albums = True # check if file system is case sensitive if not args.case_insensitive_fs: if not get_check().is_case_sensitive: args.case_insensitive_fs = True return args def main(self, test_args: dict = None): start_time = datetime.now() args = self.parser.parse_args(test_args) root_folder = Path(args.root_folder).absolute() db_path = Path(args.db_path) if args.db_path else root_folder if not root_folder.exists(): root_folder.mkdir(parents=True, mode=0o700) setup_logging(args.log_level, args.logfile, root_folder) log.warning(f"gphotos-sync {__version__} {start_time}") args = self.fs_checks(root_folder, args) lock_file = db_path / "gphotos.lock" fp = lock_file.open("w") with fp: try: if os.name != "nt": fcntl.lockf(fp, fcntl.LOCK_EX | fcntl.LOCK_NB) except IOError: log.warning("EXITING: database is locked") sys.exit(0) log.info(self.version_string) # configure and launch # noinspection PyBroadException try: self.setup(args, db_path) self.start(args) except KeyboardInterrupt: log.error("User cancelled download") log.debug("Traceback", exc_info=True) except BaseException: log.error("\nProcess failed.", exc_info=True) finally: log.warning("Done.") elapsed_time = datetime.now() - start_time log.info("Elapsed time = %s", elapsed_time) def main(): GooglePhotosSyncMain().main()