├── Pipfile ├── client_id.json ├── README.md ├── Pipfile.lock └── upload.py /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | google-auth-oauthlib = "*" 8 | 9 | [dev-packages] 10 | 11 | [requires] 12 | python_version = "3.7" 13 | -------------------------------------------------------------------------------- /client_id.json: -------------------------------------------------------------------------------- 1 | { 2 | "installed": 3 | { 4 | "client_id":"YOUR_CLIENT_ID", 5 | "client_secret":"YOUR_CLIENT_SECRET", 6 | "auth_uri":"https://accounts.google.com/o/oauth2/auth", 7 | "token_uri":"https://www.googleapis.com/oauth2/v3/token", 8 | "auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs", 9 | "redirect_uris":["urn:ietf:wg:oauth:2.0:oob","http://localhost"] 10 | } 11 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gphotos-upload 2 | Simple but flexible script to upload photos to Google Photos. Useful if you have photos in a directory structure that you want to reflect as Google Photos albums. 3 | 4 | ## Usage 5 | 6 | ``` 7 | usage: upload.py [-h] [--auth auth_file] [--album album_name] 8 | [--log log_file] 9 | [photo [photo ...]] 10 | 11 | Upload photos to Google Photos. 12 | 13 | positional arguments: 14 | photo filename of a photo to upload 15 | 16 | optional arguments: 17 | -h, --help show this help message and exit 18 | --auth auth_file file for reading/storing user authentication tokens 19 | --album album_name name of photo album to create (if it doesn't exist). Any 20 | uploaded photos will be added to this album. 21 | --log log_file name of output file for log messages 22 | ``` 23 | 24 | 25 | ## Setup 26 | 27 | ### Obtaining a Google Photos API key 28 | 29 | 1. Obtain a Google Photos API key (Client ID and Client Secret) by following the instructions on [Getting started with Google Photos REST APIs](https://developers.google.com/photos/library/guides/get-started) 30 | 31 | **NOTE** When selecting your application type in Step 4 of "Request an OAuth 2.0 client ID", please select "Other". There's also no need to carry out step 5 in that section. 32 | 33 | 2. Replace `YOUR_CLIENT_ID` in the client_id.json file with the provided Client ID. 34 | 3. Replace `YOUR_CLIENT_SECRET` in the client_id.json file wiht the provided Client Secret. 35 | 36 | ### Installing dependencies and running the script 37 | 38 | 1. Make sure you have [Python 3.7](https://www.python.org/downloads/) installed on your system 39 | 2. If needed, install [pipenv](https://pypi.org/project/pipenv/) via `pip install pipenv` 40 | 3. Change to the directory where you installed this script 41 | 4. Run `pipenv install` to download and install all the dependencies 42 | 5. Run `pipenv shell` to open a shell with all the dependencies available (you'll need to do this every time you want to run the script) 43 | 6. Now run the script via `python upload.py` as desired. Use `python upload.py -h` to get help. 44 | 45 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "7d1ffb1bcf1a0de0795553d1a16be14932a754842443647a74e9216222088c95" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.7" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "cachetools": { 20 | "hashes": [ 21 | "sha256:90f1d559512fc073483fe573ef5ceb39bf6ad3d39edc98dc55178a2b2b176fa3", 22 | "sha256:d1c398969c478d336f767ba02040fa22617333293fb0b8968e79b16028dfee35" 23 | ], 24 | "version": "==2.1.0" 25 | }, 26 | "certifi": { 27 | "hashes": [ 28 | "sha256:339dc09518b07e2fa7eda5450740925974815557727d6bd35d319c1524a04a4c", 29 | "sha256:6d58c986d22b038c8c0df30d639f23a3e6d172a05c3583e766f4c0b785c0986a" 30 | ], 31 | "version": "==2018.10.15" 32 | }, 33 | "chardet": { 34 | "hashes": [ 35 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 36 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 37 | ], 38 | "version": "==3.0.4" 39 | }, 40 | "google-auth": { 41 | "hashes": [ 42 | "sha256:9ca363facbf2622d9ba828017536ccca2e0f58bd15e659b52f312172f8815530", 43 | "sha256:a4cf9e803f2176b5de442763bd339b313d3f1ed3002e3e1eb6eec1d7c9bbc9b4" 44 | ], 45 | "version": "==1.5.1" 46 | }, 47 | "google-auth-oauthlib": { 48 | "hashes": [ 49 | "sha256:226d1d0960f86ba5d9efd426a70b291eaba96f47d071657e0254ea969025728a", 50 | "sha256:81ba22acada4d13b1d83f9371ab19fd61f1250a542d21cf49e4dcf0637a7344a" 51 | ], 52 | "index": "pypi", 53 | "version": "==0.2.0" 54 | }, 55 | "idna": { 56 | "hashes": [ 57 | "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", 58 | "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" 59 | ], 60 | "version": "==2.7" 61 | }, 62 | "oauthlib": { 63 | "hashes": [ 64 | "sha256:ac35665a61c1685c56336bda97d5eefa246f1202618a1d6f34fccb1bdd404162", 65 | "sha256:d883b36b21a6ad813953803edfa563b1b579d79ca758fe950d1bc9e8b326025b" 66 | ], 67 | "version": "==2.1.0" 68 | }, 69 | "pyasn1": { 70 | "hashes": [ 71 | "sha256:b9d3abc5031e61927c82d4d96c1cec1e55676c1a991623cfed28faea73cdd7ca", 72 | "sha256:f58f2a3d12fd754aa123e9fa74fb7345333000a035f3921dbdaa08597aa53137" 73 | ], 74 | "version": "==0.4.4" 75 | }, 76 | "pyasn1-modules": { 77 | "hashes": [ 78 | "sha256:a0cf3e1842e7c60fde97cb22d275eb6f9524f5c5250489e292529de841417547", 79 | "sha256:a38a8811ea784c0136abfdba73963876328f66172db21a05a82f9515909bfb4e" 80 | ], 81 | "version": "==0.2.2" 82 | }, 83 | "requests": { 84 | "hashes": [ 85 | "sha256:99dcfdaaeb17caf6e526f32b6a7b780461512ab3f1d992187801694cba42770c", 86 | "sha256:a84b8c9ab6239b578f22d1c21d51b696dcfe004032bb80ea832398d6909d7279" 87 | ], 88 | "version": "==2.20.0" 89 | }, 90 | "requests-oauthlib": { 91 | "hashes": [ 92 | "sha256:8886bfec5ad7afb391ed5443b1f697c6f4ae98d0e5620839d8b4499c032ada3f", 93 | "sha256:e21232e2465808c0e892e0e4dbb8c2faafec16ac6dc067dd546e9b466f3deac8" 94 | ], 95 | "markers": "python_version != '3.1.*' and python_version != '3.3.*' and python_version >= '2.6' and python_version != '3.0.*' and python_version < '4' and python_version != '3.2.*'", 96 | "version": "==1.0.0" 97 | }, 98 | "rsa": { 99 | "hashes": [ 100 | "sha256:14ba45700ff1ec9eeb206a2ce76b32814958a98e372006c8fb76ba820211be66", 101 | "sha256:1a836406405730121ae9823e19c6e806c62bbad73f890574fff50efa4122c487" 102 | ], 103 | "version": "==4.0" 104 | }, 105 | "six": { 106 | "hashes": [ 107 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", 108 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" 109 | ], 110 | "version": "==1.11.0" 111 | }, 112 | "urllib3": { 113 | "hashes": [ 114 | "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", 115 | "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" 116 | ], 117 | "markers": "python_version != '3.1.*' and python_version != '3.3.*' and python_version >= '2.6' and python_version != '3.0.*' and python_version < '4' and python_version != '3.2.*'", 118 | "version": "==1.23" 119 | } 120 | }, 121 | "develop": {} 122 | } 123 | -------------------------------------------------------------------------------- /upload.py: -------------------------------------------------------------------------------- 1 | from google_auth_oauthlib.flow import InstalledAppFlow 2 | from google.auth.transport.requests import AuthorizedSession 3 | from google.oauth2.credentials import Credentials 4 | import json 5 | import os.path 6 | import argparse 7 | import logging 8 | 9 | def parse_args(arg_input=None): 10 | parser = argparse.ArgumentParser(description='Upload photos to Google Photos.') 11 | parser.add_argument('--auth ', metavar='auth_file', dest='auth_file', 12 | help='file for reading/storing user authentication tokens') 13 | parser.add_argument('--album', metavar='album_name', dest='album_name', 14 | help='name of photo album to create (if it doesn\'t exist). Any uploaded photos will be added to this album.') 15 | parser.add_argument('--log', metavar='log_file', dest='log_file', 16 | help='name of output file for log messages') 17 | parser.add_argument('photos', metavar='photo',type=str, nargs='*', 18 | help='filename of a photo to upload') 19 | return parser.parse_args(arg_input) 20 | 21 | 22 | def auth(scopes): 23 | flow = InstalledAppFlow.from_client_secrets_file( 24 | 'client_id.json', 25 | scopes=scopes) 26 | 27 | credentials = flow.run_local_server(host='localhost', 28 | port=8080, 29 | authorization_prompt_message="", 30 | success_message='The auth flow is complete; you may close this window.', 31 | open_browser=True) 32 | 33 | return credentials 34 | 35 | def get_authorized_session(auth_token_file): 36 | 37 | scopes=['https://www.googleapis.com/auth/photoslibrary', 38 | 'https://www.googleapis.com/auth/photoslibrary.sharing'] 39 | 40 | cred = None 41 | 42 | if auth_token_file: 43 | try: 44 | cred = Credentials.from_authorized_user_file(auth_token_file, scopes) 45 | except OSError as err: 46 | logging.debug("Error opening auth token file - {0}".format(err)) 47 | except ValueError: 48 | logging.debug("Error loading auth tokens - Incorrect format") 49 | 50 | 51 | if not cred: 52 | cred = auth(scopes) 53 | 54 | session = AuthorizedSession(cred) 55 | 56 | if auth_token_file: 57 | try: 58 | save_cred(cred, auth_token_file) 59 | except OSError as err: 60 | logging.debug("Could not save auth tokens - {0}".format(err)) 61 | 62 | return session 63 | 64 | 65 | def save_cred(cred, auth_file): 66 | 67 | cred_dict = { 68 | 'token': cred.token, 69 | 'refresh_token': cred.refresh_token, 70 | 'id_token': cred.id_token, 71 | 'scopes': cred.scopes, 72 | 'token_uri': cred.token_uri, 73 | 'client_id': cred.client_id, 74 | 'client_secret': cred.client_secret 75 | } 76 | 77 | with open(auth_file, 'w') as f: 78 | print(json.dumps(cred_dict), file=f) 79 | 80 | # Generator to loop through all albums 81 | 82 | def getAlbums(session, appCreatedOnly=False): 83 | 84 | params = { 85 | 'excludeNonAppCreatedData': appCreatedOnly 86 | } 87 | 88 | while True: 89 | 90 | albums = session.get('https://photoslibrary.googleapis.com/v1/albums', params=params).json() 91 | 92 | logging.debug("Server response: {}".format(albums)) 93 | 94 | if 'albums' in albums: 95 | 96 | for a in albums["albums"]: 97 | yield a 98 | 99 | if 'nextPageToken' in albums: 100 | params["pageToken"] = albums["nextPageToken"] 101 | else: 102 | return 103 | 104 | else: 105 | return 106 | 107 | def create_or_retrieve_album(session, album_title): 108 | 109 | # Find albums created by this app to see if one matches album_title 110 | 111 | for a in getAlbums(session, True): 112 | if a["title"].lower() == album_title.lower(): 113 | album_id = a["id"] 114 | logging.info("Uploading into EXISTING photo album -- \'{0}\'".format(album_title)) 115 | return album_id 116 | 117 | # No matches, create new album 118 | 119 | create_album_body = json.dumps({"album":{"title": album_title}}) 120 | #print(create_album_body) 121 | resp = session.post('https://photoslibrary.googleapis.com/v1/albums', create_album_body).json() 122 | 123 | logging.debug("Server response: {}".format(resp)) 124 | 125 | if "id" in resp: 126 | logging.info("Uploading into NEW photo album -- \'{0}\'".format(album_title)) 127 | return resp['id'] 128 | else: 129 | logging.error("Could not find or create photo album '\{0}\'. Server Response: {1}".format(album_title, resp)) 130 | return None 131 | 132 | def upload_photos(session, photo_file_list, album_name): 133 | 134 | album_id = create_or_retrieve_album(session, album_name) if album_name else None 135 | 136 | # interrupt upload if an upload was requested but could not be created 137 | if album_name and not album_id: 138 | return 139 | 140 | session.headers["Content-type"] = "application/octet-stream" 141 | session.headers["X-Goog-Upload-Protocol"] = "raw" 142 | 143 | for photo_file_name in photo_file_list: 144 | 145 | try: 146 | photo_file = open(photo_file_name, mode='rb') 147 | photo_bytes = photo_file.read() 148 | except OSError as err: 149 | logging.error("Could not read file \'{0}\' -- {1}".format(photo_file_name, err)) 150 | continue 151 | 152 | session.headers["X-Goog-Upload-File-Name"] = os.path.basename(photo_file_name) 153 | 154 | logging.info("Uploading photo -- \'{}\'".format(photo_file_name)) 155 | 156 | upload_token = session.post('https://photoslibrary.googleapis.com/v1/uploads', photo_bytes) 157 | 158 | if (upload_token.status_code == 200) and (upload_token.content): 159 | 160 | create_body = json.dumps({"albumId":album_id, "newMediaItems":[{"description":"","simpleMediaItem":{"uploadToken":upload_token.content.decode()}}]}, indent=4) 161 | 162 | resp = session.post('https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate', create_body).json() 163 | 164 | logging.debug("Server response: {}".format(resp)) 165 | 166 | if "newMediaItemResults" in resp: 167 | status = resp["newMediaItemResults"][0]["status"] 168 | if status.get("code") and (status.get("code") > 0): 169 | logging.error("Could not add \'{0}\' to library -- {1}".format(os.path.basename(photo_file_name), status["message"])) 170 | else: 171 | logging.info("Added \'{}\' to library and album \'{}\' ".format(os.path.basename(photo_file_name), album_name)) 172 | else: 173 | logging.error("Could not add \'{0}\' to library. Server Response -- {1}".format(os.path.basename(photo_file_name), resp)) 174 | 175 | else: 176 | logging.error("Could not upload \'{0}\'. Server Response - {1}".format(os.path.basename(photo_file_name), upload_token)) 177 | 178 | try: 179 | del(session.headers["Content-type"]) 180 | del(session.headers["X-Goog-Upload-Protocol"]) 181 | del(session.headers["X-Goog-Upload-File-Name"]) 182 | except KeyError: 183 | pass 184 | 185 | def main(): 186 | 187 | args = parse_args() 188 | 189 | logging.basicConfig(format='%(asctime)s %(module)s.%(funcName)s:%(levelname)s:%(message)s', 190 | datefmt='%m/%d/%Y %I_%M_%S %p', 191 | filename=args.log_file, 192 | level=logging.INFO) 193 | 194 | session = get_authorized_session(args.auth_file) 195 | 196 | upload_photos(session, args.photos, args.album_name) 197 | 198 | # As a quick status check, dump the albums and their key attributes 199 | 200 | print("{:<50} | {:>8} | {} ".format("PHOTO ALBUM","# PHOTOS", "IS WRITEABLE?")) 201 | 202 | for a in getAlbums(session): 203 | print("{:<50} | {:>8} | {} ".format(a["title"],a.get("mediaItemsCount", "0"), str(a.get("isWriteable", False)))) 204 | 205 | if __name__ == '__main__': 206 | main() 207 | --------------------------------------------------------------------------------