├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── setup.py └── src ├── __init__.py ├── __main__.py └── download.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .DS_Store 132 | .idea/ 133 | jobs/ 134 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.2-slim-bullseye 2 | 3 | MAINTAINER Carlo Eugster 4 | 5 | RUN apt-get update \ 6 | && apt-get install -y wget xz-utils \ 7 | && apt-get clean \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | RUN mkdir /code \ 11 | && mkdir /jobs 12 | WORKDIR /code 13 | ADD . /code/ 14 | RUN pip install /code 15 | 16 | ENV PYTHONPATH "${PYTHONPATH}:/code" 17 | ENV MJDL_OUT_PATH "/jobs" 18 | ENTRYPOINT ["python", "src"] 19 | CMD ["--help"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nicky Reid 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Midjourney Downloader 2 | 3 | The Midjourney Downloader is a Python package that enables you to download all of your Midjourney gallery. This package is based on [midjourney-image-downloader](https://github.com/NickyReid/midjourney-image-downloader). 4 | 5 | ## Local Installation 6 | 7 | You can install the Midjourney Downloader package locally by running the following command: 8 | 9 | ```bash 10 | pip install -e . 11 | ``` 12 | 13 | To run the package, use the following command: 14 | 15 | ```bash 16 | mjdl --help 17 | ``` 18 | 19 | This command will output the usage options for the package: 20 | 21 | ```bash 22 | Usage: mjdl [OPTIONS] 23 | 24 | Options: 25 | -u, --user_id TEXT Your mj user id. [required] 26 | -t, --token TEXT Your mj session token (`__Secure-next- 27 | auth.session-token` cookie). [required] 28 | -k, --kind [grids|upscales|all] 29 | -s, --sort-order [new|oldest|hot|rising|top-today|top-week|top-month|top-all|like_count] 30 | Sort order by which to download images. 31 | [default: new] 32 | -a, --aggregate-by [prompt|month|day] 33 | The folder aggregation strategy. [default: 34 | day] 35 | -m, --save-models Save the JSON model along with the image. 36 | -p, --save-prompt Save the prompt as `prompt.txt` 37 | -c, --save-command Save the full command as `command.txt` 38 | -r, --skip-low-rated Skip downloading low-rated images. 39 | -o, --out PATH Base path where images are saved. [default: `pwd`] 40 | --stop-id TEXT Stop when reaching image with ID. 41 | --help Show this message and exit. 42 | ``` 43 | 44 | ## Folder Aggregation 45 | 46 | There are three options for how to aggregate data into folders locally: 47 | 48 | - `day`: Saves data under `////.png` 49 | - `month`: Saves data under `///.png` 50 | - `prompt`: Saves data under `//.png` 51 | 52 | ## Docker 53 | 54 | ### Build 55 | 56 | You can build a Docker image for the Midjourney Downloader by running the following command: 57 | 58 | ```bash 59 | docker build -t some-mjdl . 60 | ``` 61 | 62 | ### Run 63 | 64 | If you want to run the Midjourney Downloader inside a Docker container, you can use the following command: 65 | 66 | ```bash 67 | $ docker run --rf \ 68 | -v "$(pwd)/jobs:/jobs" \ 69 | some-mjdl -u -t 70 | ``` 71 | 72 | When running in Docker, you should use the `--rm` flag to clean up the container after use. Additionally, you should mount a volume so that you can access the downloaded files on the host. By default, the downloaded images will be saved in the `/jobs` directory inside the container. 73 | 74 | ## Automation 75 | 76 | All command line arguments can be specified as environment variables by using the `MJDL_` prefix. For example: 77 | 78 | ```bash 79 | $ export MJDL_TOKEN="" 80 | $ export MJDL_USER_ID="" 81 | 82 | # Now run the command without passing those args... 83 | $ mjdl --kind upscales 84 | 85 | # Or with docker... 86 | $ docker run --rm \ 87 | -e MJDL_TOKEN='' \ 88 | -e MJDL_USER_ID='' \ 89 | some-mjdl --kind upscales 90 | ``` 91 | 92 | ## License 93 | 94 | MIT 95 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | from io import open 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 7 | long_description = f.read() 8 | 9 | if not os.path.isdir("jobs"): 10 | os.makedirs("jobs") 11 | 12 | setup( 13 | name='midjourney-image-downloader', 14 | version='0.0.1', 15 | packages=find_packages(), 16 | url='https://github.com/carloe/midjourney-image-downloader', 17 | license='MIT', 18 | author='Carlo Eugster', 19 | author_email='carlo@relaun.ch', 20 | description='Download images from your Midjourney gallery', 21 | long_description=long_description, 22 | long_description_content_type='text/markdown', 23 | install_requires=[ 24 | "requests", 25 | "click", 26 | ], 27 | entry_points={ 28 | 'console_scripts': [ 29 | 'mjdl=src.__main__:cli', 30 | ], 31 | }, 32 | ) 33 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .download import Downloader, DownloadKind, DownloadAggregation -------------------------------------------------------------------------------- /src/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import click 4 | import sys 5 | from pathlib import Path 6 | from src import Downloader, DownloadKind, DownloadAggregation 7 | import re 8 | from click.core import Context 9 | from click.types import ParamType 10 | from typing import Optional 11 | 12 | 13 | uuid4_pattern = re.compile(r'^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}$') 14 | 15 | 16 | def validate_uuid_input(ctx: Context, param: ParamType, value: Optional[str]) -> Optional[str]: 17 | if value is None: 18 | return value 19 | if not uuid4_pattern.match(value): 20 | raise click.BadParameter('The input string must be a valid UUIDv4.') 21 | return value 22 | 23 | 24 | @click.command(context_settings={"auto_envvar_prefix": "MJDL"}) 25 | @click.option('--user_id', '-u', type=str, required=True, help='Your mj user id.') 26 | @click.option('--token', '-t', required=True, type=str, 27 | help='Your mj session token (`__Secure-next-auth.session-token` cookie).' 28 | ) 29 | @click.option('--kind', '-k', type=click.Choice(['grids', 'upscales', 'all'], case_sensitive=False)) 30 | @click.option('--sort-order', '-s', 'sort_oder', show_default=True, type=click.Choice([ 31 | "new", "oldest", "hot", "rising", "top-today", "top-week", "top-month", "top-all", "like_count" 32 | ], case_sensitive=False), default="new", help="Sort order by which to download images." 33 | ) 34 | @click.option('--aggregate-by', '-a', 'aggregation', show_default=True, type=click.Choice([ 35 | "prompt", "month", "day", 36 | ], case_sensitive=False), default="day", help="The folder aggregation strategy." 37 | ) 38 | @click.option('--save-models', '-m', 'save_model', is_flag=True, show_default=True, default=False, 39 | help="Save the JSON model along with the image." 40 | ) 41 | @click.option('--save-prompt', '-p', 'save_prompt', is_flag=True, show_default=True, default=False, 42 | help="Save the prompt as `prompt.txt`" 43 | ) 44 | @click.option('--save-command', '-c', 'save_command', is_flag=True, show_default=True, default=False, 45 | help="Save the full command as `command.txt`" 46 | ) 47 | @click.option('--skip-low-rated', '-r', 'skip_low_rated', is_flag=True, show_default=True, default=False, 48 | help="Skip downloading low-rated images." 49 | ) 50 | @click.option('--out', '-o', 'out_path', type=click.Path(path_type=Path), show_default=True, required=False, 51 | default=(Path().absolute() / 'jobs'), help='Base path where images are saved.' 52 | ) 53 | @click.option('--stop-id', 'stop_id', type=str, required=False, default=None, help='Stop when reaching image with ID.', 54 | callback=validate_uuid_input) 55 | def cli( 56 | user_id: str, 57 | kind: str, 58 | token: str, 59 | sort_oder: str, 60 | aggregation: str, 61 | save_model: bool, 62 | save_prompt: bool, 63 | save_command: bool, 64 | skip_low_rated: bool, 65 | out_path: Path, 66 | stop_id: str 67 | ): 68 | downloader = Downloader(user_id, token) 69 | download_kind = DownloadKind[kind] 70 | aggregate_by = DownloadAggregation[aggregation] 71 | click.echo("Starting download...") 72 | downloader.download( 73 | download_kind, 74 | sort_oder, 75 | aggregate_by, 76 | save_model, 77 | save_prompt, 78 | save_command, 79 | out_path, 80 | skip_low_rated, 81 | stop_id, 82 | ) 83 | 84 | 85 | if __name__ == "__main__": 86 | sys.exit(cli()) 87 | -------------------------------------------------------------------------------- /src/download.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import urllib.request 3 | from datetime import datetime 4 | from enum import Enum 5 | from typing import List, Any, Dict 6 | from pathlib import Path 7 | import json 8 | from typing import Optional 9 | import click 10 | 11 | class DownloadKind(Enum): 12 | grids = "grids" 13 | upscales = "upscales" 14 | all = "all" 15 | 16 | 17 | class DownloadAggregation(Enum): 18 | prompt = "prompt" 19 | month = "month" 20 | day = "day" 21 | 22 | 23 | class Parameters(): 24 | def __init__( 25 | self, 26 | download_kind: DownloadKind, 27 | order_by: str, 28 | aggregate_by: DownloadAggregation, 29 | save_model: bool, 30 | save_prompt: bool, 31 | save_command: bool, 32 | out_path: Path, 33 | skip_low_rated: bool, 34 | stop_id: Optional[str] = None, 35 | ): 36 | self.download_kind = download_kind 37 | self.skip_low_rated = skip_low_rated 38 | self.order_by = order_by 39 | self.out_path = out_path 40 | self.save_model = save_model 41 | self.save_prompt = save_prompt 42 | self.save_command = save_command 43 | self.aggregate_by = aggregate_by 44 | self.stop_id = stop_id 45 | 46 | 47 | class Downloader(): 48 | def __init__(self, user_id: str, session_token: str): 49 | self._user_id = user_id 50 | self._user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36' 51 | self._headers = {'User-Agent': self._user_agent } 52 | self._cookies = {'__Secure-next-auth.session-token': session_token} 53 | 54 | def download( 55 | self, 56 | download_kind: DownloadKind, 57 | order_by: str, 58 | aggregate_by: DownloadAggregation, 59 | save_model: bool, 60 | save_prompt: bool, 61 | save_command: bool, 62 | out_path: Path, 63 | skip_low_rated: bool, 64 | stop_id: Optional[str] = None, 65 | ) -> None: 66 | page_index = 1 67 | parameters = Parameters(download_kind, order_by, aggregate_by, save_model, save_prompt, save_command, out_path, skip_low_rated, stop_id) 68 | page_data = self._fetch_api_page(parameters, page_index) 69 | stopped = False 70 | while page_data and stopped == False: 71 | if isinstance(page_data, list) and len(page_data) > 0 and "no jobs" in page_data[0].get("msg", "").lower(): 72 | click.echo("Reached end of available results") 73 | break 74 | click.echo(f"Downloading page #{page_index} (order by '{parameters.order_by}')") 75 | stopped = self._download_page(page_data, parameters) 76 | if not stopped: 77 | page_index += 1 78 | page_data = self._fetch_api_page(parameters, page_index) 79 | 80 | def _fetch_api_page(self, parameters: Parameters, page: int) -> List[Dict[str, Any]]: 81 | api_url = "https://www.midjourney.com/api/app/recent-jobs/" \ 82 | f"?orderBy={parameters.order_by}&jobStatus=completed&userId={self._user_id}" \ 83 | f"&dedupe=true&refreshApi=0&amount=50&page={page}" 84 | 85 | match parameters.download_kind: 86 | case DownloadKind.upscales: 87 | api_url += "&jobType=upscale" 88 | case DownloadKind.grids: 89 | api_url += "&jobType=grid" 90 | case DownloadKind.all: 91 | pass 92 | 93 | try: 94 | response = requests.get(api_url, cookies=self._cookies, headers=self._headers) 95 | result = response.json() 96 | return result 97 | except requests.exceptions.RequestException as e: 98 | click.echo(f'HTTP Request failed: {e}') 99 | 100 | 101 | def _download_page(self, page_json, parameters: Parameters) -> bool: 102 | for idx, image_json in enumerate(page_json): 103 | if parameters.stop_id and image_json.get("id") == parameters.stop_id: 104 | click.echo(f"Reached stop ID {parameters.stop_id}. Stopping.") 105 | return True 106 | filename = self._download_image(image_json, parameters) 107 | if filename: 108 | click.echo(f"{idx+1}/{len(page_json)} Downloaded {filename}") 109 | return False 110 | 111 | def _download_image(self, image_json: List[Dict[str, Any]], parameters: Parameters) -> Optional[str]: 112 | image_paths = image_json.get("image_paths", []) 113 | filename = self._filename_for(parameters, image_json) 114 | output_path = self._output_path_for(parameters, image_json) 115 | 116 | ranking_by_user = image_json.get("ranking_by_user") 117 | if parameters.skip_low_rated and ranking_by_user and isinstance(ranking_by_user, int) and (ranking_by_user in [1, 2]): 118 | return None 119 | elif self._local_data_exists(output_path, filename): 120 | click.echo(f"Image {filename}.png exists. Skipping.") 121 | return None 122 | else: 123 | try: 124 | output_path.mkdir(parents=True, exist_ok=False) 125 | except FileExistsError: 126 | pass 127 | for idx, image_url in enumerate(image_paths): 128 | if idx > 0: 129 | filename = f"{filename}-{idx}" 130 | full_path = f"{output_path}/{filename}.png" 131 | opener = urllib.request.build_opener() 132 | opener.addheaders = [('User-agent', self._user_agent)] 133 | urllib.request.install_opener(opener) 134 | urllib.request.urlretrieve(image_url, full_path) 135 | if parameters.save_model is True: 136 | output_model_path = output_path / "model.json" 137 | with open(output_model_path, 'w') as outfile: 138 | json.dump(image_json, outfile, indent=4) 139 | if parameters.save_prompt is True: 140 | prompt = image_json.get("prompt") 141 | output_prompt_path = output_path / "prompt.txt" 142 | with open(output_prompt_path, 'w') as f: 143 | f.write(prompt) 144 | if parameters.save_command is True: 145 | command = image_json.get("full_command") 146 | output_command_path = output_path / "command.txt" 147 | with open(output_command_path, 'w') as f: 148 | f.write(command) 149 | return filename 150 | 151 | @staticmethod 152 | def _local_data_exists(path: Path, filename: str) -> bool: 153 | full_path = path / f'{filename}.png' 154 | return full_path.is_file() 155 | 156 | @staticmethod 157 | def _output_path_for(parameters: Parameters, image_json: List[Dict[str, Any]]) -> Path: 158 | prompt = image_json.get("prompt") 159 | enqueue_time_str = image_json.get("enqueue_time") 160 | enqueue_time = datetime.strptime(enqueue_time_str, "%Y-%m-%d %H:%M:%S.%f") 161 | year = enqueue_time.year 162 | month = enqueue_time.month 163 | day = enqueue_time.day 164 | match parameters.aggregate_by: 165 | case DownloadAggregation.prompt: 166 | sanitized = prompt.replace(" ", "_").replace(",", "").replace("*", "").replace("'", "").replace(":", "").replace( 167 | "__", "_").replace("<", "").replace(">", "").replace("/", "").replace(".", "").lower().strip("_*")[:100] 168 | return parameters.out_path / sanitized 169 | case DownloadAggregation.month: 170 | return parameters.out_path / f"{year}/{month}" 171 | case DownloadAggregation.day: 172 | return parameters.out_path / f"{year}/{month}/{day}" 173 | 174 | @staticmethod 175 | def _filename_for(parameters: Parameters, image_json: List[Dict[str, Any]]) -> str: 176 | if (parameters.aggregate_by == DownloadAggregation.day or parameters.aggregate_by == DownloadAggregation.month): 177 | prompt = image_json.get("prompt") 178 | return prompt.replace(" ", "_").replace(",", "").replace("*", "").replace("'", "").replace(":", "").replace( 179 | "__", "_").replace("<", "").replace(">", "").replace("/", "").replace(".", "").lower().strip("_*")[:97] 180 | return image_json.get("id") 181 | --------------------------------------------------------------------------------