├── .vscode ├── settings.json └── launch.json ├── images └── downloaded-images-go-here.txt ├── requirements.txt ├── etc └── credentials.ini ├── Makefile ├── LICENSE ├── .gitignore ├── README.md └── download-aura-photos.py /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/downloaded-images-go-here.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # general good to have packages 2 | wheel 3 | setuptools 4 | 5 | # needed to talk to the API 6 | requests 7 | 8 | # The linter 9 | prospector 10 | -------------------------------------------------------------------------------- /etc/credentials.ini: -------------------------------------------------------------------------------- 1 | # Credentials to log into api.auraframes.com 2 | [login] 3 | email = myemail2@gmail.com 4 | password = mYpa$$w0rd-11 5 | 6 | # Defined frames, one section per frame 7 | [frame_1] 8 | file_path = ./images 9 | frame_id = abf53be3-b73d-4de3-98cd-cfd289bd82df 10 | 11 | [frame_2] 12 | file_path = /images-frame_2 13 | frame_id = b69ddd8d-bcad-483f-adf4-e15ff9a48c47 14 | 15 | [frame_3] 16 | file_path = ./images-frame_3 17 | frame_id = cd3e8813-8fb6-434f-b709-e66deb3ea2a6 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # Use the default_python_version set in .zshrc 3 | # Edit if it needs to be over-written for the project 4 | PYTHON_VERSION=${default_python_version} 5 | 6 | default: test lint 7 | 8 | help: 9 | @echo 10 | @echo "Makefile commands are:" 11 | @echo " default - runs make lint" 12 | @echo " install - install a new runtime virtual env" 13 | @echo " lint - run prospector linter" 14 | @echo 15 | 16 | install: clean-venv install-venv install-packages 17 | 18 | clean-venv: 19 | @if [ -d ./venv ]; then \ 20 | echo "--> Removing old ./venv"; \ 21 | rm -r ./venv; \ 22 | fi 23 | 24 | install-venv: 25 | @echo "--> Installing new ./venv" 26 | ${HOME}/.pyenv/versions/${PYTHON_VERSION}/bin/python -m venv venv 27 | 28 | install-packages: 29 | @echo "--> Installing runtime packages into the venv" 30 | ./venv/bin/pip install --upgrade pip setuptools wheel 31 | ./venv/bin/pip install -r ./requirements.txt 32 | 33 | lint: 34 | prospector 35 | 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alex Meub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Current Python File", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "${file}", 12 | "cwd": "${fileDirname}", 13 | "console": "integratedTerminal", 14 | "justMyCode": true 15 | }, 16 | { 17 | "name": "Download", 18 | "type": "debugpy", 19 | "request": "launch", 20 | "program": "./download-aura-photos.py", 21 | "args": ["${input:frameName}"], 22 | "cwd": "${fileDirname}", 23 | "console": "integratedTerminal", 24 | "justMyCode": true 25 | }, 26 | { 27 | "name": "Download by year", 28 | "type": "debugpy", 29 | "request": "launch", 30 | "program": "./download-aura-photos.py", 31 | "args": ["--years", "${input:frameName}"], 32 | "cwd": "${fileDirname}", 33 | "console": "integratedTerminal", 34 | "justMyCode": true 35 | }, 36 | ], 37 | "inputs": [ 38 | { 39 | "id": "frameName", 40 | "type": "promptString", 41 | "description": "Frame to download" 42 | } 43 | ] 44 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # custom image directories 4 | images-* 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aura Frame Downloader 2 | 3 | This is a script to bulk download photos from an Aura digital picture frame (auraframes.com). Aura provides no easy way to bulk download photos so I created this for use with Python. Aura stores all photos on their servers so no physical access to the frame is necessary to download them. 4 | 5 | ### Setup 6 | 7 | This script requires Python 3 and depends on the Python [requests](https://github.com/psf/requests) module. Before running this script you need to set up a configuration file that contains your Aura email, password, and the file_path and frame_id for each Aura frame you want to download from. This allows you to keep your Aura login credentials out of the repository and allows you to set up multiple frames to download. The frame names in the config file don't need to match anything, it's just something you'll be able to reference when you run the command. However, the frame_id must match exactly. 8 | 9 | ### Getting your frame ID 10 | 11 | You can get the frame ID by doing the following: 12 | 13 | * Go to https://app.auraframes.com and log in 14 | * Click on the Frame name 15 | * Click on "View Photos" underneath the frame 16 | * Then grab the ID from the URL: `https://app.auraframes.com/frame/` 17 | 18 | 19 | ### Configuration File 20 | 21 | The default configuration file locations are below. They can be overridded using the --config /path/to/config.ini command line option 22 | 23 | * Windows: %USERPROFILE%/etc/aura/credentials.ini 24 | * Linux and Mac : $HOME/etc/aura/credentials.ini 25 | 26 | An example file can found under etc/credentials.ini 27 | 28 | 29 | #### Config file first section: credentials to log into api.auraframes.com 30 | 31 | [login] 32 | email = myemail2@gmail.com 33 | password = mYpa$$w0rd-11 34 | 35 | #### Config file second section: defined frames, one section per frame 36 | 37 | The frame names in the config file don't need match anything. They are just used so you can reference them when running the commands. 38 | 39 | [myframe] 40 | file_path = ./images 41 | frame_id = abf53be3-b73d-4de3-98cd-cfd289bd82df 42 | 43 | [anotherframe] 44 | file_path = ./images-another-frame 45 | frame_id = b69ddd8d-bcad-483f-adf4-e15ff9a48c47 46 | 47 | [alastframe] 48 | file_path = ./images-last-frame 49 | frame_id = cd3e8813-8fb6-434f-b709-e66deb3ea2a6 50 | 51 | 52 | 53 | ### Usage 54 | 55 | usage: download-aura-photos.py [-h] [--config CONFIG] [--debug] [--count] [--years] [frame] 56 | 57 | positional arguments: 58 | frame 59 | 60 | options: 61 | -h, --help show this help message and exit 62 | --config CONFIG configuration file 63 | --debug debug log output 64 | --count show count of photos then exit 65 | --=years store pictures in a directory for the year in the 66 | json 'taken_at' data. 67 | 68 | # example commands 69 | python download-aura-photos.py myframe 70 | python download-aura-photos.py --config /alternate/path/to/credentials.ini myframe 71 | python download-aura-photos.py --count myframe 72 | python download-aura-photos.py --count --config /alternate/path/to/credentials.ini myframe 73 | python download-aura-photos.py --years myframe 74 | 75 | 76 | Photos will be downloaded to the folder defined by the frame's file_path parameter in the configuration file. The Aura API will throttle the downloads so you may have to restart the script multiple times to fully download all of your photos. 77 | 78 | The good thing is that download progress is saved so photos that are already downloaded will be skipped when restarting the script. You can also adjust the `time.sleep(2)` to something longer if throttling becomes a problem. 79 | 80 | The script creates the local image file name using the following attributes from the 81 | item JSON data. 82 | * 'taken_at' (a timestamp) 83 | * 'id' (a unique identifier in the Aura frame) 84 | * 'file_name' (the extension only) 85 | 86 | Example filename: 2012-04-15-03-15-04.000_B9A0E367-FA8D-4157-A090-7EE33F603312.jpeg 87 | 88 | When the --years command line argument is used, the script combines the frame's 89 | file_path from the configuration and the year from the json data for the final output 90 | directory name. It then creates the directory if needed and saves the file there. 91 | This improved performance when viewing files as icons in Windows Explorer by reducing 92 | the number of files per directory. 93 | 94 | Example. 95 | Using file_path = ./images-another-frame and the example filename above, 96 | the downloaded file will be stored in : 97 | ./images-another-frame/2012/2012-04-15-03-15-04.000_B9A0E367-FA8D-4157-A090-7EE33F603312.jpeg 98 | 99 | Note: It's possible for the same picture file to be uploaded to an Aura frame by different people. This will result in each picture being downloaded to a separate filename under images/. If there are a lot of people updating a frame, you may want to run a duplicate photo finder on the downloaded photos. 100 | 101 | ### Development notes 102 | 103 | The Makefile is set up to install a python virtual environment with the requests and prospector modules installed under the venv folder. 104 | 105 | $ make install 106 | 107 | 108 | To use the virtual environment as the default python, tell your IDE to use venv/bin/python 109 | for the project, or activate it manually. 110 | 111 | $ . venv/bin/activate 112 | 113 | Then run the script. 114 | 115 | $ python ./download-aura-photos.py [--count] [--config path] [--years] frame_name -------------------------------------------------------------------------------- /download-aura-photos.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import configparser 3 | import json 4 | import os 5 | import os.path 6 | import shutil 7 | import time 8 | import sys 9 | import requests 10 | import logging 11 | 12 | LOGGER = logging.getLogger(__name__) 13 | 14 | def parse_command_line(): 15 | """ 16 | Parse the command line options 17 | :return: the parsed command line args 18 | """ 19 | parser = argparse.ArgumentParser() 20 | parser.add_argument( 21 | "--config", 22 | help="configuration file", 23 | default=os.path.join(os.path.expanduser('~'),'etc','aura','credentials.ini'), 24 | required=False, 25 | ) 26 | 27 | parser.add_argument( 28 | "--debug", 29 | help="debug log output", 30 | action="store_true", 31 | default=False, 32 | required=False, 33 | ) 34 | 35 | parser.add_argument( 36 | "--years", 37 | help="save pictures folder by year", 38 | action="store_true", 39 | default=False, 40 | required=False, 41 | ) 42 | 43 | parser.add_argument( 44 | "--count", 45 | help="show count of photos then exit", 46 | action="store_true", 47 | default=False, 48 | required=False, 49 | ) 50 | 51 | parser.add_argument('frame', nargs='?') 52 | args = parser.parse_args() 53 | return args 54 | 55 | def setup_logger(log_debug=False): 56 | """ 57 | Sets up default logging options 58 | :param log_debug: True sets logging.DEBUG, False sets logging.INFO 59 | :return: 60 | """ 61 | logging_level = logging.DEBUG if log_debug else logging.INFO 62 | logging.basicConfig( 63 | stream=sys.stdout, 64 | format="%(asctime)s [%(levelname)s]: %(message)s", 65 | datefmt="%H:%M:%S", 66 | level=logging_level, 67 | ) 68 | 69 | LOGGER.debug("Debug logging enabled.") 70 | 71 | 72 | # Main download function 73 | def download_photos_from_aura(email, password, frame_id, file_path, args): 74 | # define URLs and payload format 75 | login_url = "https://api.pushd.com/v5/login.json" 76 | frame_url = f"https://api.pushd.com/v5/frames/{frame_id}/assets.json?side_load_users=false" 77 | login_payload = { 78 | "identifier_for_vendor": "does-not-matter", 79 | "client_device_id": "does-not-matter", 80 | "app_identifier": "com.pushd.Framelord", 81 | "locale": "en", 82 | "user": { 83 | "email": email, 84 | "password": password 85 | } 86 | } 87 | 88 | # make login request with credentials 89 | s = requests.Session() 90 | r = s.post(login_url, json=login_payload) 91 | 92 | if r.status_code != 200: 93 | LOGGER.error("Login Error: Check your credentials") 94 | sys.exit(1) 95 | 96 | LOGGER.info("Login Success") 97 | 98 | # get json and update user and auth token headers for next request 99 | json_data = r.json() 100 | s.headers.update({'X-User-Id': json_data['result']['current_user']['id'], 101 | 'X-Token-Auth': json_data['result']['current_user']['auth_token']}) 102 | 103 | # make request to get all phtos (frame assets) 104 | r = s.get(frame_url) 105 | json_data = json.loads(r.text) 106 | counter = 0 107 | skipped = 0 108 | 109 | # check to make sure the frame assets array exists 110 | if "assets" not in json_data: 111 | LOGGER.error("Download Error: No images returned from this Aura Frame. API responded with:") 112 | LOGGER.error(json_data) 113 | sys.exit(0) 114 | 115 | photo_count = len(json_data["assets"]) 116 | LOGGER.info("Found %s photos.", photo_count) 117 | if args.count: sys.exit() 118 | 119 | LOGGER.info("Starting download process") 120 | 121 | for item in json_data["assets"]: 122 | 123 | try: 124 | # construct the raw photo URL 125 | url = f"https://imgproxy.pushd.com/{item['user_id']}/{item['file_name']}" 126 | # make a unique new_filename using 127 | # item['taken_at'] + item['id'] + item['file_name']'s extension 128 | # But clean the timestamp to be Windows-friendly 129 | clean_time = item['taken_at'].replace(':', '-') 130 | new_filename = clean_time + "_" + item['id'] + os.path.splitext(item['file_name'])[1] 131 | 132 | if args.years: 133 | # download picture to file_path/year/picture, 134 | # creating file_path/year if necessary 135 | year_dir = os.path.join(file_path,clean_time[:4]) 136 | if not os.path.isdir(year_dir): 137 | LOGGER.debug("Creating new year directory: %s", year_dir) 138 | os.makedirs(year_dir) 139 | file_to_write = os.path.join(year_dir, new_filename) 140 | else: 141 | # default to download picture to file_path/picture 142 | file_to_write = os.path.join(file_path, new_filename) 143 | 144 | # Bump the counter and print the new_filename out to track progress 145 | counter += 1 146 | 147 | # check if file exists and skip it if so 148 | if os.path.isfile(file_to_write): 149 | LOGGER.info("%i: Skipping %s, already downloaded", counter, new_filename) 150 | skipped += 1 151 | continue 152 | 153 | # Get the photo from the url 154 | LOGGER.info("%i: Downloading %s", counter, new_filename) 155 | response = requests.get(url, stream=True, timeout=90) 156 | 157 | # write to a file 158 | with open(file_to_write, 'wb') as out_file: 159 | shutil.copyfileobj(response.raw, out_file) 160 | del response 161 | 162 | # wait a bit to avoid throttling 163 | time.sleep(2) 164 | 165 | except KeyboardInterrupt: 166 | LOGGER.info('Exiting from keyboard interrupt') 167 | break 168 | 169 | except Exception as e: 170 | LOGGER.error("Item: %i failed to download, probably due to throttling", counter) 171 | LOGGER.error(str(e)) 172 | time.sleep(10) 173 | 174 | return counter - skipped 175 | 176 | def app(): 177 | # parse the command line args 178 | # set up logging 179 | # check the args before continuing 180 | args = parse_command_line() 181 | setup_logger(args.debug) 182 | 183 | aok = True 184 | if not os.path.exists(args.config): 185 | LOGGER.error("Config file '%s' not found", args.config) 186 | aok = False 187 | 188 | if not args.frame: 189 | LOGGER.error("No frame name supplied on the command line") 190 | aok = False 191 | 192 | if not aok: 193 | sys.exit(1) 194 | 195 | try : 196 | # Read the frame and login credentials from outside the repo 197 | LOGGER.info("Using credentials file '%s'", args.config) 198 | config = configparser.ConfigParser() 199 | config.read(args.config) 200 | 201 | if not config.has_section('login'): 202 | LOGGER.error("No [login] section found in file '%s'.", args.config) 203 | sys.exit(1) 204 | 205 | if not config.has_section(args.frame): 206 | LOGGER.error("No frame [%s] found in file '%s'.", args.frame, args.config) 207 | sys.exit(1) 208 | 209 | email = config['login']['email'] 210 | password = config['login']['password'] 211 | frame_id = config[args.frame]['frame_id'] 212 | file_path = config[args.frame]['file_path'] 213 | 214 | except Exception: 215 | LOGGER.error("Error parsing config file '%s'.", args.config) 216 | sys.exit(1) 217 | 218 | # Check the output directory exists in case the script is moved 219 | # or the file_path is changed. 220 | if not os.path.isdir(file_path): 221 | LOGGER.info("Creating new images directory: %s", file_path) 222 | os.makedirs(file_path) 223 | 224 | total = download_photos_from_aura(email, password, frame_id, file_path, args) 225 | LOGGER.info("Downloaded %i photos", total) 226 | 227 | 228 | if __name__ == '__main__': 229 | app() 230 | --------------------------------------------------------------------------------