├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── package-install-prompt-issue.md ├── .gitignore ├── LICENSE ├── README.md ├── build.sh ├── loopdown ├── pyproject.toml ├── requirements.txt ├── src ├── __main__.py └── ldilib │ ├── __init__.py │ ├── arguments │ ├── __init__.py │ └── constructor.py │ ├── clargs.py │ ├── logger.py │ ├── models.py │ ├── parsers.py │ ├── request.py │ ├── utils.py │ └── wrappers.py └── wiki └── content └── images └── loops.png /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Screenshots** 14 | If applicable, add screenshots to help explain your problem. 15 | 16 | **Exception Errors** 17 | If applicable, please add include the exception errors here; for example: 18 | ``` 19 | [jappleseed@ungoliant]:loopdown # ./loopdown -n -m -p garageband1047 --create-mirror /tmp/foo 20 | Processing content for: 'garageband1047' 21 | Traceback (most recent call last): 22 | File "", line 198, in _run_module_as_main 23 | File "", line 88, in _run_code 24 | File "/Users/jappleseed/Documents/github/loopdown/./loopdown/__main__.py", line 118, in 25 | File "/Users/jappleseed/Documents/github/loopdown/./loopdown/__main__.py", line 66, in main 26 | File "/Users/jappleseed/Documents/github/loopdown/./loopdown/ldilib/parsers.py", line 68, in parse_packages 27 | File "/Users/jappleseed/Documents/github/loopdown/./loopdown/ldilib/parsers.py", line 278, in parse_package_for_attrs 28 | File "/Users/jappleseed/Documents/github/loopdown/./loopdown/ldilib/parsers.py", line 311, in parse_updated_package_attr_vals 29 | KeyError: 'foo' 30 | ``` 31 | 32 | **Debug log** 33 | If possible, please re-run `loopdown` with the `--log-level debug` argument parameter and attached the `/Users/Shared/loopdown/loopdown.log` file once `loopdown` has finished. 34 | 35 | **Which macOS Version?** 36 | Output of `sw_vers`: 37 | 38 | **Additional context** 39 | Add any other context about the problem here. 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/package-install-prompt-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Package Install Prompt Issue 3 | about: Application prompting to install package already installed by loopdown 4 | title: '' 5 | labels: install prompt 6 | assignees: carlashley 7 | 8 | --- 9 | 10 | Please use this to raise an issue when a package is installed by `loopdown` but the target application continues to prompt to install it. 11 | 12 | **Which `loopdown` version?** 13 | Output of `loopdown --version`: 14 | 15 | **What applications are you installing packages for (delete those that don't apply)?** 16 | - :white_check_mark: GarageBand (version: ) 17 | - :white_check_mark: Logic Pro (version: ) 18 | - :white_check_mark: MainStage 3 (version: ) 19 | 20 | **Are the packages being installed for the first time, or an upgrade?** 21 | - :white_check_mark: First Time 22 | - :white_check_mark: Upgrade 23 | - :black_square_button: Not Applicable 24 | 25 | **Which packages are installed but still prompt to be installed?** 26 | Please include the folder path and package name including the file extension; for example: 27 | - `lp10_ms3_content_2016/MAContent10_AssetPack_0668_AppleLoopsReggaetonPop.pkg` 28 | 29 | **Is the `--pkg-server` argument being used?** 30 | - :white_check_mark: Yes 31 | - :negative_squared_cross_mark: No 32 | - :black_square_button: Not Applicable 33 | 34 | **If the `--pkg-server` argument has been used, have the packages been refreshed within the last week?** 35 | - :white_check_mark: Yes 36 | - :negative_squared_cross_mark: No 37 | - :black_square_button: Not Applicable 38 | 39 | **Is the `--cache-server` argument being used?** 40 | - :white_check_mark: Yes 41 | - :negative_squared_cross_mark: No 42 | - :black_square_button: Not Applicable 43 | 44 | **If the `--cache-server` has been used, has `loopdown` been run on one Mac to pre-warm the cache?** 45 | - :white_check_mark: Yes 46 | - :negative_squared_cross_mark: No 47 | - :black_square_button: Not Applicable 48 | 49 | 50 | Has the `--log-level debug` argument and parameter been used? 51 | If `loopdown` has been run with `--log-level debug`, please attach the output of the log run. 52 | Log files are stored in `/Users/Shared/loopdown`; the most recent log will be `/Users/Shared/loopdown/loopdown.log` 53 | - :white_check_mark: Yes 54 | - :negative_squared_cross_mark: No 55 | 56 | 57 | 58 | **Which macOS Version?** 59 | Output of `sw_vers -productVersion`: 60 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | ./src/packaging* 29 | packaging 30 | packaging* 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | I cannot continue maintaining this anymore, I tried, but I'm just not in a space where I can keep up with this, and I have no passion/desire to learn Swift to migrate this tool (which desperately needs doing). 2 | 3 | I really hope someone else can step up, or better yet, Apple can get their act together and make available any tools that they have that could make the lives better for Mac admins that need to deploy Garageband, et al. 4 | 5 | 6 | Thanks. 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | # loopdown 50 | ## Requirements 51 | This should run on any version of Python 3 after/including 3.10; `packaging` will need to be installed (a `requirements.txt` file is included in this repo) as `distutils` is deprecated. 52 | 53 | ## Build 54 | Run `./build.sh` with no additional arguments to create a compressed zipapp version of this utility; please note the default Python interpreter and shebang used is `/usr/bin/env python3`, if a more specific shebang needs to be used, run `./build.sh /new/shebang/path`, for example: `./build.sh /usr/local/bin/python3`. This will generate a new "build" in `./dist/zipapp/usr/local/bin/custom/`. 55 | 56 | ## Support 57 | This tool is provided 'as is', if an application is prompting to install a package that `loopdown` has installed, please use this [issue form](https://github.com/carlashley/loopdown/issues/new?assignees=carlashley&labels=install+prompt&projects=&template=package-install-prompt-issue.md&title= "raise an issue"). 58 | Please note, responses to issues raised may be delayed. 59 | 60 | Feature requests can be made, but this project has been deliberately kept to a very defined scope of behaviour/capability. 61 | 62 | ## License 63 | Licensed under the Apache License Version 2.0. See `LICENSE` for the full license. 64 | 65 | ## Usage 66 | ``` 67 | usage: loopdown [-h] [--advanced-help] [--version] [--log-level [level]] [-n] [-a [app] [[app] ...] | -p [plist] [[plist] ...]] [-m] [-o] [-f] [-i] [-s] [--create-mirror [path]] 68 | [--cache-server server] [--pkg-server [server]] [--discover-plists] 69 | 70 | loopdown can be used to download, install, mirror, or discover information about the additional audio content that Apple provides for the audio editing/mixing software programs 71 | GarageBand, LogicPro X , and MainStage3. 72 | 73 | options: 74 | -h, --help show this help message and exit 75 | --advanced-help 76 | --version show program's version number and exit 77 | --log-level [level] sets the logging level; valid options are: (choices); default is 'info' 78 | -n, --dry-run perform a dry run 79 | -a [app] [[app] ...], --apps [app] [[app] ...] 80 | application/s to process package content from; valid options are: 'all', 'garageband', 'logicpro', or 'mainstage', cannot be used with '--discover-plists', 81 | requires either '--create-mirror' or '-i/--install' 82 | -p [plist] [[plist] ...], --plist [plist] [[plist] ...] 83 | property list/s to process package content from, use '--discover-plists' to find valid options, cannot be used with '--discover-plists', requires 84 | either/both '--cache-server' or '--create-mirror' 85 | -m, --mandatory process all mandatory package content, cannot be used with '--discover-plists' 86 | -o, --optional process all optional package content, cannot be used with '--discover-plists' 87 | -f, --force force install or download regardless of pre-existing installs/download data, cannot be used with '--discover-plists' 88 | -i, --install install the audio content packages based on the app/s and mandatory/optional options specified, cannot be used with '--discover-plists', requires 89 | either/both '-m/--mandatory' or '-o/--optional', cannot be used with '--create-mirror' or '-p/--plists' 90 | -s, --silent suppresses all output on stdout and stderr, cannot be used with '--discover-plists' 91 | --create-mirror [path] 92 | create a local mirror of the 'https://audiocontentdownload.apple.com' directory structure based on the app/s or property list/s being processed, cannot be 93 | used with '--discover-plists', requires either/both '-m/--mandatory' or '-o/--optional' 94 | --cache-server server 95 | specify 'auto' for autodiscovery of a caching server or provide a url, for example: 'http://example.org:51000', cannot be used with '--discover-plists', 96 | requires either '--create-mirror' or '-i/--install' 97 | --pkg-server [server] 98 | local server of mirrored content, for example 'http://example.org', cannot be used with'--discover-plists', requires '-i/--install' 99 | --discover-plists discover the property lists hosted by Apple for GarageBand, Logic Pro X, and MainStage 3 100 | 101 | loopdown v1.0.20230726, licensed under the Apache License Version 2.0 102 | ``` 103 | 104 | ### How this works 105 | #### Installation/updating on a client Mac - no mirror/caching server 106 | On a Mac with any of the three audio applications installed, `loopdown` inspects the application bundle for a property list file that contains metadata about the audio contents for that version of the application, and based on this information and the options provided to the utility, will either install or update the necessary packages. 107 | 108 | #### Installation/updating on a client Mac - mirror/caching server 109 | ##### pkg-server 110 | If the `--pkg-server` argument is supplied with a web server as the parameter, `loopdown` will use that URL in place of the Apple CDN. The paths to each package must be an exact mirror of the Apple paths (this can be done by using the `--create-mirror [path]` argument and parameter on a standalone Mac). 111 | 112 | For example, if mirroring from `https://example.org`, then the folder paths must match `https://example.org/lp10_ms3_content_2013/[package files]` and/org `https://example.org/lp10_ms3_content_2016/[package files]`. 113 | 114 | ##### cache-server 115 | If the `--cache-server` argument is supplied with an Apple caching server URL as the parameter, `loopdown` will use that caching server just the same as if the audio application itself was downloading the audio content. 116 | 117 | In this particular scenario, if you are _installing_ the audio content packages on multiple clients, best practice is to do this one one Mac first, before proceeding with additional clients. 118 | 119 | #### Creating a mirror 120 | A mirror copy of the audio content files can be created on a standalone Mac, it is preferable to have the relevant audio applications installed, but it is not required. 121 | 122 | If the applications are installed, the mirror can be created with: 123 | ``` 124 | > ./loopdown -m -o --create-mirror [path] -a [app] 125 | ``` 126 | 127 | If the applications are not installed, the mirror can be created with: 128 | ``` 129 | > ./loopdown -m -o --create-mirror [path] -p [plist] 130 | ``` 131 | 132 | #### Discovering content 133 | To find the relevant property lists for use with the `-p/--plist` argument, use the `--discover-plists` argument. 134 | 135 | 136 | #### Advanced Configuration/Overriding Default Configuration 137 | There are a number of command line arguments that are hidden from the `-h/--help` argument by default, these are used for configuring default parameters, but if necessary these default parameters can be overridden; use the `--advanced-help` argument to show these options. 138 | 139 | It is recommended to leave these options as their default settings. 140 | 141 | 142 | #### Logging 143 | By default, logs are stored in `/Users/Shared/loopdown`. If you need more detailed logging than the standard level, use the `--log-level [level]` argument and parameter. 144 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | NAME="loopdown" 4 | BUILD_DIR=./dist/zipapp/usr/local/bin 5 | VERSION=$(/usr/bin/awk -F ' ' '/_version: / {print $NF}' ./src/ldilib/__init__.py | /usr/bin/sed 's/"//g') 6 | ARCHIVE_V=${BUILD_DIR}/${NAME}-${VERSION} 7 | BUILD_OUT=./${NAME} 8 | LOCAL_PYTHON=$(echo "/usr/bin/env python3") 9 | INTERPRETER=$1 10 | 11 | if [[ -z ${INTERPRETER} ]]; then 12 | INTERPRETER="/usr/bin/env python3" 13 | fi 14 | 15 | if [[ ! -d ${BUILD_DIR} ]]; then 16 | mkdir -p ${BUILD_DIR} 17 | fi 18 | 19 | if [[ ! ${INTERPRETER} == "/usr/bin/env python3" ]]; then 20 | mkdir -p ${BUILD_DIR}/custom 21 | ARCHIVE_V=${BUILD_DIR}/custom/${NAME}-${VERSION} 22 | fi 23 | 24 | if [[ ! -d ./src/packaging ]]; then 25 | python3 -m pip install -r requirements.txt --target ./src 26 | fi 27 | 28 | BUILD_CMD=$(echo "${LOCAL_PYTHON}" -m zipapp src --compress --output ${ARCHIVE_V} --python=\"${INTERPRETER}\") 29 | echo ${BUILD_CMD} 30 | eval ${BUILD_CMD} 31 | 32 | 33 | if [[ $? == 0 ]] && [[ -f ${ARCHIVE_V} ]]; then 34 | if [[ ${INTERPRETER} == "/usr/bin/env python3" ]]; then 35 | /bin/cp ${ARCHIVE_V} ${BUILD_OUT} 36 | /bin/echo Built ${BUILD_OUT} 37 | else 38 | /bin/cp ${ARCHIVE_V} ${BUILD_DIR}/custom/${NAME} 39 | /bin/echo Built ${BUILD_DIR}/custom/${NAME} 40 | fi 41 | fi 42 | -------------------------------------------------------------------------------- /loopdown: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlashley/loopdown/ee2e7cba513c1f7ae7afceefefb6324ff542e06f/loopdown -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pylint.master] 2 | jobs = 0 3 | 4 | [tool.pylint.format] 5 | max-line-length = 119 6 | 7 | [tool.pylint.variables] 8 | ignored-argument-names = "kwargs|args" 9 | 10 | [tool.isort] 11 | combine_as_imports = true 12 | include_trailing_comma = true 13 | line_length = 119 14 | multi_line_output = 3 15 | force_grid_wrap = 0 16 | use_parentheses = true 17 | ensure_newline_before_comments = true 18 | profile = "black" 19 | 20 | [tool.black] 21 | line-length = 119 22 | include = '\.pyi?$' 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | packaging 2 | -------------------------------------------------------------------------------- /src/__main__.py: -------------------------------------------------------------------------------- 1 | """Main""" 2 | import sys 3 | 4 | maj_r, min_r = 3, 10 5 | major, minor, _, _, _ = sys.version_info 6 | pyexe = sys.executable 7 | reqd = f"{maj_r}.{min_r}" 8 | 9 | if not (major >= maj_r and minor >= min_r): 10 | msg = f"Python {major}.{minor} at {pyexe!r} is not supported, minimum version required is Python {reqd}; exiting." 11 | print(msg, file=sys.stderr) 12 | sys.exit(2) 13 | 14 | 15 | from urllib.parse import urlparse # noqa 16 | 17 | from ldilib import Loopdown # noqa 18 | from ldilib.clargs import arguments # noqa 19 | from ldilib.logger import construct_logger # noqa 20 | from ldilib.utils import debugging_info # noqa 21 | 22 | 23 | def main() -> None: 24 | """Main method""" 25 | args = arguments() 26 | log = construct_logger(level=args.log_level, dest=args.default_log_directory, silent=args.silent) 27 | log.debug(debugging_info(args)) 28 | 29 | ld = Loopdown( 30 | dry_run=args.dry_run, 31 | mandatory=args.mandatory, 32 | optional=args.optional, 33 | apps=args.apps, 34 | plists=args.plists, 35 | cache_server=args.cache_server, 36 | pkg_server=args.pkg_server, 37 | create_mirror=args.create_mirror, 38 | install=args.install, 39 | force=args.force, 40 | silent=args.silent, 41 | feed_base_url=args.feed_base_url, 42 | default_packages_download_dest=args.default_packages_download_dest, 43 | default_working_download_dest=args.default_working_download_dest, 44 | default_log_directory=args.default_log_directory, 45 | max_retries=args.max_retries, 46 | max_retry_time_limit=args.max_retry_time_limit, 47 | proxy_args=args.proxy_args, 48 | log=log, 49 | ) 50 | ld.cleanup_working_dirs() 51 | 52 | try: 53 | if args.discover_plists: 54 | ld.parse_discovery(args.apps, args.discover_plists_range) 55 | sys.exit() 56 | if not args.discover_plists: 57 | processing_str = ", ".join(f"'{item}'" for item in args.apps or args.plists) 58 | processing_msg = f"Processing content for: {processing_str}" 59 | 60 | if args.pkg_server: 61 | url = urlparse(args.pkg_server) 62 | processing_msg = f"{processing_msg} via '{url.scheme}://{url.netloc}'" 63 | 64 | if args.cache_server: 65 | processing_msg = f"{processing_msg} using caching server '{args.cache_server}'" 66 | 67 | log.info(processing_msg) 68 | ld.process_metadata(args.apps, args.plists) 69 | ld.cleanup_working_dirs() 70 | 71 | if not ld.has_packages: 72 | log.info(ld) 73 | sys.exit() 74 | 75 | has_freespace, disk_usage_message = ld.has_enough_disk_space() 76 | disk_usage_message = f"{disk_usage_message}; {'passes' if has_freespace else 'fails'}" 77 | 78 | if has_freespace: 79 | log.info(f"{disk_usage_message} disk usage check") 80 | packages = ld.sort_packages() 81 | errors, install_failures = ld.download_or_install(packages) 82 | 83 | if not ld.generate_warning_message(errors, len(packages), args.default_warn_threshold): 84 | log.info(ld) 85 | else: 86 | log_fp = args.default_log_directory.joinpath("loopdown.log") 87 | prefix = "Warning: A number of packages will not be downloaded and/or installed, please check" 88 | log.error(f"{prefix} '{log_fp}' for more information") 89 | sys.exit(12) 90 | else: 91 | if args.install or args.create_mirror: 92 | log.info(f"{disk_usage_message} disk usage check") 93 | sys.exit(3) 94 | except KeyboardInterrupt: 95 | ld.cleanup_working_dirs() 96 | 97 | 98 | if __name__ == "__main__": 99 | main() 100 | -------------------------------------------------------------------------------- /src/ldilib/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pathlib import Path 4 | from typing import Optional 5 | from .parsers import ParsersMixin 6 | from .request import RequestMixin 7 | from .utils import clean_up_dirs 8 | 9 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 10 | 11 | 12 | _license: str = "Apache License Version 2.0" 13 | _script_name: str = "loopdown" 14 | _version: str = "1.0.20240525" 15 | _version_string: str = f"{_script_name} v{_version}, licensed under the {_license}" 16 | 17 | 18 | class Loopdown(ParsersMixin, RequestMixin): 19 | def __init__( 20 | self, 21 | dry_run: bool = False, 22 | mandatory: bool = False, 23 | optional: bool = False, 24 | apps: Optional[list[str]] = None, 25 | plists: Optional[str] = None, 26 | cache_server: Optional[str] = None, 27 | pkg_server: Optional[str] = None, 28 | create_mirror: Optional[Path] = None, 29 | install: Optional[bool] = False, 30 | force: Optional[bool] = False, 31 | silent: Optional[bool] = False, 32 | feed_base_url: Optional[str] = None, 33 | default_packages_download_dest: Optional[Path] = None, 34 | default_working_download_dest: Optional[Path] = None, 35 | default_log_directory: Optional[Path] = None, 36 | max_retries: Optional[int] = None, 37 | max_retry_time_limit: Optional[int] = None, 38 | proxy_args: Optional[list[str]] = None, 39 | log: Optional[logging.Logger] = None, 40 | ) -> None: 41 | self.dry_run = dry_run 42 | self.mandatory = mandatory 43 | self.optional = optional 44 | self.apps = apps 45 | self.plists = plists 46 | self.cache_server = cache_server 47 | self.pkg_server = pkg_server 48 | self.create_mirror = create_mirror 49 | self.install = install 50 | self.force = force 51 | self.silent = silent 52 | self.feed_base_url = feed_base_url 53 | self.default_packages_download_dest = default_packages_download_dest 54 | self.default_working_download_dest = default_working_download_dest 55 | self.default_log_directory = default_log_directory 56 | self.max_retries = max_retries 57 | self.max_retry_time_limit = max_retry_time_limit 58 | self.log = log 59 | 60 | # curl/request related arg lists 61 | self._useragt = ["--user-agent", f"{_script_name}/{_version_string}"] 62 | self._retries = ["--retry", self.max_retries, "--retry-max-time", self.max_retry_time_limit] 63 | self._noproxy = ["--noproxy", "*"] if self.cache_server else [] # for caching server 64 | self._proxy_args = proxy_args or [] 65 | 66 | # Clean up before starting, just incase cruft is left over. 67 | self.cleanup_working_dirs() 68 | 69 | # This will/does contain all the mandatory/optional packages that will need to be processed 70 | # It's updated by 'ParserMixin.parse_packages' 71 | self.packages = {"mandatory": set(), "optional": set()} 72 | 73 | def __repr__(self): 74 | """String representation of class.""" 75 | if self.has_packages: 76 | return self.parse_download_install_statement() 77 | else: 78 | msg = "No packages found; there may be no packages to download/install" 79 | suffix = None 80 | 81 | if self.apps: 82 | suffix = "application/s installed" 83 | elif self.plists: 84 | suffix = "metadata property list file/s found for processing" 85 | 86 | if suffix: 87 | return f"{msg} or no {suffix}" 88 | else: 89 | return f"{msg}" 90 | 91 | def process_metadata(self, apps_arg: list[str], plists_arg: list[str]) -> None: 92 | """Process metadata from apps/plists for downloading/installing""" 93 | if apps_arg: 94 | for app in apps_arg: 95 | source_file = self.parse_application_plist_source_file(app) 96 | 97 | if source_file and source_file.exists(): 98 | packages = self.parse_plist_source_file(source_file) 99 | self.parse_packages(packages) 100 | 101 | if plists_arg: 102 | for plist in plists_arg: 103 | source_file = self.parse_plist_remote_source_file(plist) 104 | 105 | if source_file and source_file.exists(): 106 | packages = self.parse_plist_source_file(source_file) 107 | self.parse_packages(packages) 108 | 109 | @property 110 | def has_packages(self) -> bool: 111 | """Test if metadata processing has yielded packages to download/install.""" 112 | return (len(self.packages["mandatory"]) or len(self.packages["optional"])) > 0 113 | 114 | def sort_packages(self) -> set: 115 | """Sort packages into a single set.""" 116 | return sorted(list(self.packages["mandatory"].union(self.packages["optional"])), key=lambda x: x.download_name) 117 | 118 | def download_or_install(self, packages: set) -> tuple[int, int]: 119 | """Perform the download or install, or output dry-run information as appropriate. 120 | :param packages: a set of package objects for downloading/installing""" 121 | errors, install_failures, total_packages = 0, [], len(packages) 122 | 123 | for package in packages: 124 | pkg = None 125 | counter = f"{packages.index(package) + 1} of {total_packages}" 126 | 127 | if package.status_ok: 128 | if self.dry_run: 129 | prefix = "Download" if not self.install else "Download and install" 130 | elif not self.dry_run: 131 | prefix = "Downloading" if not self.install else "Downloading and installing" 132 | else: 133 | prefix = "Package error" 134 | errors += 1 135 | 136 | self.log.info(f"{prefix} {counter} - {package}") 137 | 138 | if not self.dry_run: 139 | # Force download, Will not resume partials! 140 | if self.force and package.download_dest.exists(): 141 | package.download_dest.unlink(missing_ok=True) 142 | 143 | if package.status_ok: 144 | pkg = self.get_file(package.download_url, package.download_dest, self.silent) 145 | 146 | if self.install and pkg: 147 | self.log.info(f"Installing {counter} - {package.download_dest.name!r}") 148 | installed = self.install_pkg(package.download_dest, package.install_target) 149 | 150 | if installed: 151 | self.log.info(f" {package.download_dest} was installed") 152 | else: 153 | pkg_fp = package.download_dest 154 | inst_fp = "/var/log/install.log" 155 | log_fp = self.default_log_directory.joinpath("loopdown.log") 156 | install_failures.append(package.download_dest.name) 157 | self.log.error( 158 | f" {pkg_fp} was not installed; see '{inst_fp}' or '{log_fp}' for more information." 159 | ) 160 | 161 | package.download_dest.unlink(missing_ok=True) 162 | 163 | return (errors, install_failures) 164 | 165 | def generate_warning_message(self, errors: int, total: int, threshold: float = 0.5) -> None: 166 | """Generate warning message if the number of package fetch errors exceeds a threshold value. 167 | :param errors: total number of errors when fetching packages 168 | :param total: total number of packages 169 | :param threshold: value to use for calculating the threshold; default is 0.5 (50%)""" 170 | return float(errors) >= float(total * threshold) 171 | 172 | def cleanup_working_dirs(self) -> None: 173 | """Clean up all working directories.""" 174 | if self.default_working_download_dest.exists(): 175 | clean_up_dirs(self.default_working_download_dest) 176 | 177 | if self.default_working_download_dest.exists(): 178 | self.log.error( 179 | f"Unable to delete {str(self.default_working_download_dest)!r}, please delete manually.", 180 | ) 181 | 182 | if self.install and not self.dry_run and self.default_packages_download_dest.exists(): 183 | clean_up_dirs(self.default_packages_download_dest) 184 | 185 | if self.default_packages_download_dest.exists(): 186 | self.log.error( 187 | f"Unable to delete ({str(self.default_packages_download_dest)!r}, please delete manually)" 188 | ) 189 | -------------------------------------------------------------------------------- /src/ldilib/arguments/__init__.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | from pathlib import Path 4 | from .. import _version_string 5 | 6 | arg_config = [ 7 | { 8 | "args": ["--version"], 9 | "kwargs": { 10 | "action": "version", 11 | "dest": "version", 12 | "version": _version_string, 13 | }, 14 | }, 15 | { 16 | "args": ["--log-level"], 17 | "kwargs": { 18 | "dest": "log_level", 19 | "metavar": "[level]", 20 | "choices": ["info", "debug"], 21 | "default": "info", 22 | "required": False, 23 | "help": "sets the logging level; valid options are: (choices); default is (default)", 24 | }, 25 | }, 26 | { 27 | "args": ["-n", "--dry-run"], 28 | "kwargs": { 29 | "action": "store_true", 30 | "dest": "dry_run", 31 | "required": False, 32 | "help": "perform a dry run", 33 | }, 34 | }, 35 | { 36 | "parser": "apps", 37 | "args": [ 38 | "-a", 39 | "--apps", 40 | ], 41 | "kwargs": { 42 | "dest": "apps", 43 | "nargs": "+", 44 | "metavar": "[app]", 45 | "choices": ["all", "garageband", "logicpro", "mainstage"], 46 | "required": False, 47 | "help": ( 48 | "application/s to process package content from; valid options are: (choices), " 49 | "cannot be used with '--discover-plists', requires either '--create-mirror' or '-i/--install'" 50 | ), 51 | }, 52 | }, 53 | { 54 | "parser": "apps", 55 | "args": [ 56 | "-p", 57 | "--plist", 58 | ], 59 | "kwargs": { 60 | "dest": "plists", 61 | "nargs": "+", 62 | "metavar": "[plist]", 63 | "required": False, 64 | "help": ( 65 | "property list/s to process package content from, use '--discover-plists' to find valid options, " 66 | "cannot be used with '--discover-plists', requires either/both '--cache-server' or '--create-mirror'" 67 | ), 68 | }, 69 | }, 70 | { 71 | "args": ["-m", "--mandatory"], 72 | "kwargs": { 73 | "action": "store_true", 74 | "dest": "mandatory", 75 | "required": False, 76 | "help": "process all mandatory package content, cannot be used with '--discover-plists'", 77 | }, 78 | }, 79 | { 80 | "args": ["-o", "--optional"], 81 | "kwargs": { 82 | "action": "store_true", 83 | "dest": "optional", 84 | "required": False, 85 | "help": "process all optional package content, cannot be used with '--discover-plists'", 86 | }, 87 | }, 88 | { 89 | "args": ["-f", "--force"], 90 | "kwargs": { 91 | "action": "store_true", 92 | "dest": "force", 93 | "required": False, 94 | "help": ( 95 | "force install or download regardless of pre-existing installs/download data, " 96 | "cannot be used with '--discover-plists'" 97 | ), 98 | }, 99 | }, 100 | { 101 | "parser": "optn", 102 | "requires_or": ["mandatory", "optional"], 103 | "args": [ 104 | "-i", 105 | "--install", 106 | ], 107 | "kwargs": { 108 | "action": "store_true", 109 | "dest": "install", 110 | "required": False, 111 | "help": ( 112 | "install the audio content packages based on the app/s and mandatory/optional options " 113 | "specified, cannot be used with '--discover-plists', " 114 | "requires either/both '-m/--mandatory' or '-o/--optional', cannot be used with '--create-mirror' or " 115 | "'-p/--plists'" 116 | ), 117 | }, 118 | }, 119 | { 120 | "args": ["-s", "--silent"], 121 | "kwargs": { 122 | "action": "store_true", 123 | "dest": "silent", 124 | "required": False, 125 | "help": "suppresses all output on stdout and stderr, cannot be used with '--discover-plists'", 126 | }, 127 | }, 128 | { 129 | "parser": "optn", 130 | "args": ["--create-mirror"], 131 | "kwargs": { 132 | "dest": "create_mirror", 133 | "type": Path, 134 | "metavar": "[path]", 135 | "required": False, 136 | "help": ( 137 | "create a local mirror of the 'https://audiocontentdownload.apple.com' directory structure " 138 | "based on the app/s or property list/s being processed, cannot be used with '--discover-plists', " 139 | "requires either/both '-m/--mandatory' or '-o/--optional'" 140 | ), 141 | }, 142 | }, 143 | { 144 | "parser": "down", 145 | "args": ["--cache-server"], 146 | "kwargs": { 147 | "dest": "cache_server", 148 | "type": str, 149 | "metavar": "server", 150 | "required": False, 151 | "help": ( 152 | "specify 'auto' for autodiscovery of a caching server or provide a url, for example: " 153 | "'http://example.org:51000', cannot be used with '--discover-plists', requires either " 154 | "'--create-mirror' or '-i/--install'" 155 | ), 156 | }, 157 | }, 158 | { 159 | "parser": "down", 160 | "args": ["--pkg-server"], 161 | "kwargs": { 162 | "dest": "pkg_server", 163 | "type": str, 164 | "metavar": "[server]", 165 | "required": False, 166 | "help": ( 167 | "local server of mirrored content, for example 'http://example.org', cannot be used with" 168 | "'--discover-plists', requires '-i/--install'" 169 | ), 170 | }, 171 | }, 172 | { 173 | "hidden": True, 174 | "args": ["--default-caching-server-rank"], 175 | "kwargs": { 176 | "dest": "default_caching_server_rank", 177 | "type": int, 178 | "metavar": "[rank]", 179 | "default": 1, 180 | "required": False, 181 | "help": "specify the default ranking value when looking for caching server; default is (default)", 182 | }, 183 | }, 184 | { 185 | "hidden": True, 186 | "args": ["--default-caching-server-type"], 187 | "kwargs": { 188 | "dest": "default_caching_server_type", 189 | "metavar": "[type]", 190 | "choices": ["system", "user"], 191 | "default": "system", 192 | "required": False, 193 | "help": ( 194 | "specify the default server type when looking for caching server; valid options are: (choices); " 195 | "default is (default)" 196 | ), 197 | }, 198 | }, 199 | { 200 | "hidden": True, 201 | "args": ["--default-log-directory"], 202 | "kwargs": { 203 | "dest": "default_log_directory", 204 | "type": Path, 205 | "metavar": "[dir]", 206 | "default": Path("/Users/Shared/loopdown"), 207 | "required": False, 208 | "help": "specify the default directory the log is stored in; default is (default)", 209 | }, 210 | }, 211 | { 212 | "hidden": True, 213 | "args": ["--default-packages-download-dest"], 214 | "kwargs": { 215 | "dest": "default_packages_download_dest", 216 | "metavar": "[path]", 217 | "default": Path("/tmp/loopdown"), 218 | "required": False, 219 | "help": "specify the package download path; default is (default)", 220 | }, 221 | }, 222 | { 223 | "hidden": True, 224 | "args": ["--default-working-download-dest"], 225 | "kwargs": { 226 | "dest": "default_working_download_dest", 227 | "type": Path, 228 | "metavar": "[path]", 229 | "default": Path(tempfile.gettempdir()).joinpath("loopdown"), 230 | "required": False, 231 | "help": ( 232 | "specify the package working download path; default is (default) - note, this may change location " 233 | "per run" 234 | ), 235 | }, 236 | }, 237 | { 238 | "hidden": True, 239 | "args": ["--default-warning-threshold"], 240 | "kwargs": { 241 | "dest": "default_warn_threshold", 242 | "type": float, 243 | "metavar": "[path]", 244 | "default": 0.5, 245 | "required": False, 246 | "help": "specify the default threshold at which a warning about mising downloads and install issues is " 247 | "displayed; default is (default)", 248 | }, 249 | }, 250 | { 251 | "parser": "down", 252 | "args": ["--discover-plists"], 253 | "kwargs": { 254 | "action": "store_true", 255 | "dest": "discover_plists", 256 | "required": False, 257 | "help": "discover the property lists hosted by Apple for GarageBand, Logic Pro X, and MainStage 3", 258 | }, 259 | }, 260 | { 261 | "hidden": True, 262 | "args": ["--discover-plists-range"], 263 | "kwargs": { 264 | "dest": "discover_plists_range", 265 | "type": int, 266 | "nargs": 2, 267 | "metavar": ("[min]", "[max]"), 268 | "default": [0, 99], 269 | "required": False, 270 | "help": ( 271 | "specify the start/finish range for property list files; default is (default), " 272 | "requires '--discover-plists'" 273 | ), 274 | }, 275 | }, 276 | { 277 | "hidden": True, 278 | "args": ["--feed-base-url"], 279 | "kwargs": { 280 | "dest": "feed_base_url", 281 | "type": str, 282 | "metavar": "[url]", 283 | "default": "https://audiocontentdownload.apple.com/lp10_ms3_content_2016/", 284 | "required": False, 285 | "help": "specify the default base url for fetching remote property lists; default is (default)", 286 | }, 287 | }, 288 | { 289 | "hidden": True, 290 | "args": ["--max-retries"], 291 | "kwargs": { 292 | "dest": "max_retries", 293 | "type": int, 294 | "metavar": "[retries]", 295 | "default": "5", 296 | "required": False, 297 | "help": "specify the maximum number of retries for all curl calls; default is (default)", 298 | }, 299 | }, 300 | { 301 | "hidden": True, 302 | "args": ["--max-timeout"], 303 | "kwargs": { 304 | "dest": "max_retry_time_limit", 305 | "type": int, 306 | "metavar": "[retries]", 307 | "default": "60", 308 | "required": False, 309 | "help": "specify the maximum timeout (in seconds) for all curl calls; default is (default) seconds", 310 | }, 311 | }, 312 | { 313 | "hidden": True, 314 | "args": ["--additional-curl-args"], 315 | "kwargs": { 316 | "dest": "proxy_args", 317 | "nargs": "+", 318 | "metavar": "[arg]", 319 | "required": False, 320 | "help": "provide additional arguments and parameters to all curl calls (use with caution)", 321 | }, 322 | }, 323 | ] 324 | -------------------------------------------------------------------------------- /src/ldilib/arguments/constructor.py: -------------------------------------------------------------------------------- 1 | """Argument constructor.""" 2 | import argparse 3 | import sys 4 | 5 | from typing import Any 6 | from .. import _version_string as vers_str 7 | 8 | 9 | def construct_arguments(config: list[dict[str, Any]]) -> argparse.ArgumentParser: 10 | """Internal constructor of the command line arguments. 11 | :param config: a list of dictionary objects representing argument configuration""" 12 | desc = ( 13 | "loopdown can be used to download, install, mirror, or discover information about the additional " 14 | "audio content that Apple provides for the audio editing/mixing software programs GarageBand, LogicPro X " 15 | ", and MainStage3." 16 | ) 17 | help_parser = argparse.ArgumentParser(description=desc, epilog=vers_str, add_help=False) 18 | help_parser.add_argument("--advanced-help", required=False, action="store_true", dest="advanced_help") 19 | help_args, _ = help_parser.parse_known_args() 20 | 21 | opts_map = {} 22 | parser = argparse.ArgumentParser(description=desc, epilog=vers_str, parents=[help_parser]) 23 | exclusive_groups = { 24 | "apps": parser.add_mutually_exclusive_group(), 25 | "optn": parser.add_mutually_exclusive_group(), 26 | "down": parser.add_mutually_exclusive_group(), 27 | } 28 | 29 | for c in config: 30 | args = c.get("args") 31 | kwargs = c.get("kwargs") 32 | choices = kwargs.get("choices") 33 | default = kwargs.get("default") 34 | choices_tgt = "(choices)" 35 | default_tgt = "(default)" 36 | help_str = kwargs.get("help") 37 | excl_parser = c.get("parser") 38 | opts_map[kwargs["dest"]] = "/".join(args) 39 | 40 | if help_str: 41 | if choices and choices_tgt in help_str: 42 | first_choice_str = ", ".join(f"'{c}'" for c in choices[0:-1]) 43 | choice_str = f"{first_choice_str}, or '{choices[-1]}'" 44 | help_str = help_str.replace(choices_tgt, choice_str) 45 | 46 | if default and default_tgt in help_str: 47 | if isinstance(default, (list, set, tuple)): 48 | default_str = ", ".join(f"'{d}'" for d in default) 49 | else: 50 | default_str = f"'{default}'" 51 | 52 | help_str = help_str.replace(default_tgt, default_str) 53 | 54 | kwargs["help"] = help_str 55 | 56 | if c.get("hidden", False) and not help_args.advanced_help: 57 | kwargs["help"] = argparse.SUPPRESS 58 | 59 | try: 60 | exclusive_groups[excl_parser].add_argument(*args, **kwargs) 61 | except KeyError: 62 | parser.add_argument(*args, **kwargs) 63 | 64 | if not len(sys.argv) > 1 or help_args.advanced_help: 65 | parser.print_help(sys.stderr) 66 | sys.exit() 67 | 68 | args = parser.parse_args() 69 | return (args, parser, opts_map) 70 | -------------------------------------------------------------------------------- /src/ldilib/clargs.py: -------------------------------------------------------------------------------- 1 | """Arguments for command line use.""" 2 | import argparse 3 | 4 | from urllib.parse import urlparse 5 | 6 | from .arguments import arg_config 7 | from .arguments.constructor import construct_arguments 8 | from .utils import ( 9 | is_root, 10 | locate_caching_server, 11 | validate_caching_server_url, 12 | CachingServerAutoLocateException, 13 | CachingServerMissingSchemeException, 14 | CachingServerPortException, 15 | CachingServerSchemeException, 16 | ) 17 | 18 | 19 | def arguments() -> argparse.Namespace: 20 | """Public function for arguments, includes additional checks that argparse cannot do, such as 21 | mutually exclusive arguments that are cross exclusive.""" 22 | 23 | def join_args(a: list[str], sep: str = ", ", suffix: str = "or") -> str: 24 | """Joins a group of argument strings into a single string. 25 | :param a: list of argument string values; for example ['-a/--apple', '--bannana'] 26 | :param sep: seperator string; default is ',' 27 | :param suffix: the suffix string that 'joins' a list of more than 1 argument; default is 'or'""" 28 | if len(a) >= 2: 29 | return f"{sep}".join(a[0:-1]) + f" {suffix} {a[-1]}" 30 | else: 31 | return f"{sep}".join(a) 32 | 33 | args, parser, opts_map = construct_arguments(arg_config) 34 | 35 | # Convert specific args to correct types. 36 | args.max_retries = str(args.max_retries) 37 | args.max_retry_time_limit = str(args.max_retry_time_limit) 38 | 39 | # -a/--apps must be corrected here to ensure the right list of values is passed on 40 | if (args.apps and "all" in args.apps) or (args.discover_plists and not args.apps): 41 | args.apps = ["garageband", "logicpro", "mainstage"] 42 | 43 | # A minimum of -a/--apps, --create-mirror, --discover-plists, or --plists is required 44 | if not any([args.apps, args.create_mirror, args.discover_plists, args.plists]): 45 | argstr = join_args([opts_map[arg] for arg in ["apps", "create_mirror", "discover_plists", "plists"]]) 46 | parser.error(f"one or more of these arguments is required: {argstr}") 47 | 48 | # -i/--install and --create-mirror require either -m/--mandatory and/or -o/--optional 49 | if (args.install or args.create_mirror) and not any([args.mandatory, args.optional]): 50 | prefix = opts_map["install" if args.install else "create_mirror"] 51 | argstr = join_args([opts_map[arg] for arg in ["mandatory", "optional"]], suffix="and/or") 52 | parser.error(f"argument {prefix}: not allowed without: {argstr}") 53 | 54 | # -i/--install is not allowed with --create-mirror or -p/--plists 55 | if args.install and any([args.create_mirror, args.plists]): 56 | prefix = opts_map["install"] 57 | argstr = join_args([opts_map[arg] for arg in ["create_mirror", "plists"]]) 58 | parser.error(f"argument {prefix}: not allowed with {argstr}") 59 | 60 | # -a/--apps is not allowed without --create-mirror, --discover-plists, or -i/--install 61 | if args.apps and not any([args.create_mirror, args.discover_plists, args.install]): 62 | prefix = opts_map["apps"] 63 | argstr = join_args([opts_map[arg] for arg in ["create_mirror", "discover_plists", "install"]]) 64 | parser.error(f"argument {prefix}: not allowed without: {argstr}") 65 | 66 | # -p/--plists not allowd without --create-mirror 67 | if args.plists and not args.create_mirror: 68 | prefix = opts_map["plists"] 69 | argstr = join_args([opts_map["create_mirror"]]) 70 | parser.error(f"argument {prefix}: not allowed without: {argstr}") 71 | 72 | # --discover-plists not allowed with -a/--apps, --cache-server, --create-mirror, -n/--dry-run, 73 | # -f/--force, -i/--install, -m/--mandatory, -o/--optional, -p/--plists, --pkg-server, or -s/--silent 74 | if args.discover_plists and any( 75 | [ 76 | args.cache_server, 77 | args.create_mirror, 78 | args.dry_run, 79 | args.force, 80 | args.install, 81 | args.mandatory, 82 | args.optional, 83 | args.plists, 84 | args.pkg_server, 85 | args.silent, 86 | ] 87 | ): 88 | if args.cache_server: 89 | prefix = opts_map["cache_server"] 90 | elif args.create_mirror: 91 | prefix = opts_map["create_mirror"] 92 | elif args.dry_run: 93 | prefix = opts_map["dry_run"] 94 | elif args.force: 95 | prefix = opts_map["force"] 96 | elif args.install: 97 | prefix = opts_map["install"] 98 | elif args.mandatory: 99 | prefix = opts_map["mandatory"] 100 | elif args.optional: 101 | prefix = opts_map["optional"] 102 | elif args.plists: 103 | prefix = opts_map["plists"] 104 | elif args.pkg_server: 105 | prefix = opts_map["pkg_server"] 106 | elif args.silent: 107 | prefix = opts_map["silent"] 108 | 109 | argstr = join_args([opts_map["discover_plists"]]) 110 | parser.error(f"argument {prefix}: not allowed with {argstr}") 111 | 112 | # --discover-plists-range not allowed without --discover-plists 113 | if args.discover_plists_range and not args.discover_plists_range == [0, 99] and not args.discover_plists: 114 | prefix = opts_map["discover_plists_range"] 115 | argstr = join_args([opts_map["discover_plists"]]) 116 | parser.error(f"argument {prefix}: not allowed without: {argstr}") 117 | 118 | # -f/--force not allowed without --create-mirror or -i/--install 119 | if args.force and not any([args.create_mirror, args.install]): 120 | prefix = opts_map["force"] 121 | argstr = join_args([opts_map[arg] for arg in ["create_mirror", "force"]]) 122 | parser.error(f"argument {prefix}: not allowed without {argstr}") 123 | 124 | # --cache-server not allowed with --create-mirror 125 | if args.cache_server and args.create_mirror: 126 | prefix = opts_map["cache_server"] 127 | argstr = join_args([opts_map["create_mirror"]]) 128 | parser.error(f"argument {prefix}: not allowed without {argstr}") 129 | 130 | # --pkg-server not allowed without -i/--install 131 | if args.pkg_server and not args.install: 132 | prefix = opts_map["pkg_server"] 133 | argstr = join_args([opts_map["install"]]) 134 | parser.error(f"argument {prefix}: not allowed without {argstr}") 135 | 136 | # --install requires higher privileges 137 | if args.install and not is_root(): 138 | prefix = opts_map["install"] 139 | parser.error(f"argument {prefix}: you must be root to use this argument") 140 | 141 | # --default-warning-threshold checks 142 | if args.default_warn_threshold and not args.discover_plists: 143 | min_v, max_v = 0.1, 1.0 144 | prefix = opts_map["default_warn_threshold"] 145 | 146 | # --default-warning-threshold not allowed without --create-mirror or -i/--install 147 | if not any([args.create_mirror, args.install]): 148 | argstr = join_args([opts_map[arg] for arg in ["create_mirror", "install"]]) 149 | parser.error(f"argument {prefix}: not allowed without {argstr}") 150 | 151 | # --default-warning-threshold must be between acceptable min/max float value 152 | if not min_v <= args.default_warn_threshold <= max_v: 153 | parser.error(f"argument {prefix}: argument value must be between '{min_v}' and '{max_v}'") 154 | 155 | # --default-log-directory must exist and be a directory 156 | if args.default_log_directory: 157 | prefix = opts_map["default_log_directory"] 158 | log_dir_exists = args.default_log_directory.resolve().exists() 159 | log_dir_is_dir = args.default_log_directory.resolve().is_dir() 160 | log_dir_is_syl = args.default_log_directory.resolve().is_symlink() 161 | path_type = "symlink" if log_dir_is_syl else "file" if not log_dir_is_dir else "directory" 162 | 163 | if log_dir_exists and not log_dir_is_dir: 164 | parser.error(f"argument {prefix}: argument value must be a directory path, not a {path_type}") 165 | 166 | # Make sure args.pkg_server points to the Apple server when installing if no cache server value provided 167 | if (args.install or args.create_mirror) and not args.pkg_server: 168 | args.pkg_server = args.feed_base_url 169 | 170 | # Process the cache server argument with custom checks but only if installing/creating a mirror 171 | if args.cache_server and args.install: 172 | prefix = opts_map["cache_server"] 173 | 174 | if not args.cache_server == "auto": 175 | try: 176 | validate_caching_server_url(args.cache_server) 177 | except CachingServerMissingSchemeException as me: 178 | parser.error(f"argument {prefix}: parameter is {me}") 179 | except CachingServerSchemeException as se: 180 | parser.error(f"argument {prefix}: {se}") 181 | except CachingServerPortException as pe: 182 | parser.error(f"argument {prefix}: {pe}") 183 | elif args.cache_server == "auto": 184 | try: 185 | args.cache_server = locate_caching_server( 186 | args.default_caching_server_type, args.default_caching_server_rank 187 | ) 188 | except CachingServerAutoLocateException as le: 189 | parser.error(f"argument {prefix}: {le}") 190 | 191 | # The package server argument must end in '/lp10_ms3_content_2016/' 192 | if args.pkg_server: 193 | pkg_ew_str = "/lp10_ms3_content_2016/" 194 | prefix = opts_map["pkg_server"] 195 | scheme = urlparse(args.pkg_server).scheme 196 | 197 | if scheme not in ["http", "https"] or scheme is None: 198 | parser.error(f"argument {prefix}: a valid url scheme is required, either 'http' or 'https'") 199 | 200 | if not args.pkg_server.endswith(pkg_ew_str): 201 | args.pkg_server = f"{args.pkg_server.removesuffix('/')}/{pkg_ew_str}" 202 | 203 | return args 204 | -------------------------------------------------------------------------------- /src/ldilib/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from logging.handlers import RotatingFileHandler 5 | from pathlib import Path 6 | from typing import TextIO 7 | 8 | LOG_FMT: str = "%(asctime)s - %(name)s.%(funcName)s - %(levelname)s - %(message)s" 9 | LOG_DATE_FMT: str = "%Y-%m-%d %H:%M:%S" 10 | STDOUT_FILTERS: list[int] = [logging.INFO] 11 | STDERR_FILTERS: list[int] = [logging.DEBUG, logging.ERROR, logging.CRITICAL] 12 | 13 | 14 | def add_stream(stream: TextIO, filters: list[int], log: logging.Logger) -> None: 15 | """Add a stdout or stderr stream handler 16 | :param stream: sys.stdout or sys.stderr 17 | :param filters: logging levels to filter 18 | :param log: logger to add handlers to""" 19 | h: logging.StreamHandler = logging.StreamHandler(stream) 20 | h.addFilter(lambda log: log.levelno in filters) 21 | 22 | if stream == sys.stdout: 23 | h.setLevel(logging.INFO) 24 | elif stream == sys.stderr: 25 | h.setLevel(logging.ERROR) 26 | 27 | log.addHandler(h) 28 | 29 | 30 | def construct_logger( 31 | level: str = "INFO", dest: Path = Path("/Users/Shared/loopdown"), silent: bool = False 32 | ) -> logging.Logger: 33 | """Construct logging. 34 | :param name: log name (use '__name__' when calling this function). 35 | :param dest: log directory destination; this should be a directory only, not an actual file 36 | :param level: log level value, default is 'INFO'.""" 37 | name = __name__ 38 | fp = dest.joinpath("loopdown.log") 39 | 40 | # Create parent log directory path if it doesn't exist 41 | if not fp.parent.exists(): 42 | fp.parent.mkdir(parents=True, exist_ok=True) 43 | 44 | # Construct the logger instance 45 | log = logging.getLogger(name) 46 | log.setLevel(level.upper()) 47 | formatter = logging.Formatter(fmt=LOG_FMT, datefmt=LOG_DATE_FMT) 48 | fh = RotatingFileHandler(fp, backupCount=14) # keep the last 14 logs 49 | fh.setFormatter(formatter) 50 | log.addHandler(fh) 51 | 52 | # errors always print to sys.stderr even with silent mode 53 | add_stream(stream=sys.stderr, filters=STDERR_FILTERS, log=log) 54 | 55 | # Add the stdout log stream if not silent 56 | if not silent: 57 | add_stream(stream=sys.stdout, filters=STDOUT_FILTERS, log=log) 58 | 59 | if fp.exists() and fp.stat().st_size > 0: 60 | fh.doRollover() 61 | 62 | return log 63 | -------------------------------------------------------------------------------- /src/ldilib/models.py: -------------------------------------------------------------------------------- 1 | """Model classes""" 2 | from dataclasses import dataclass, field 3 | from pathlib import Path 4 | 5 | from .utils import bytes2hr 6 | 7 | 8 | @dataclass 9 | class Size: 10 | filesize: int | float = field(default=None) 11 | 12 | def __repr__(self): 13 | return bytes2hr(self.filesize) 14 | 15 | def __str__(self): 16 | return bytes2hr(self.filesize) 17 | 18 | 19 | @dataclass(eq=True, frozen=True) 20 | class LoopDownloadPackage: 21 | is_mandatory: bool = field(default=None, hash=False, compare=False) 22 | download_name: str = field(default=None, hash=False, compare=False) 23 | download_dest: Path = field(default=None, hash=False, compare=False) 24 | download_size: int | float = field(default=None, hash=False, compare=False) 25 | download_url: str = field(default=None, hash=False, compare=False) 26 | package_id: str = field(default=None, hash=True, compare=True) 27 | status_code: int | str = field(default=None, hash=False, compare=False) 28 | status_ok: bool = field(default=None, hash=False, compare=False) 29 | is_compressed: bool = field(default=None, hash=False, compare=False) 30 | is_installed: bool = field(default=None, hash=True, compare=True) 31 | file_check: list[str] | str = field(default=None, hash=False, compare=False) 32 | install_size: int | float = field(default=None, hash=False, compare=False) 33 | install_target: str = field(default="/", hash=False, compare=False) 34 | package_vers: str = field(default="0.0.0", hash=False, compare=False) 35 | 36 | # String representation for simple output 37 | def __repr__(self): 38 | if not self.status_ok: 39 | s_msg = f"HTTP error {self.status_code}" if not self.status_code == -999 else "curl error" 40 | return f"{self.download_url} ({s_msg})" 41 | else: 42 | return f"{self.download_url} ({self.download_size} download)" 43 | -------------------------------------------------------------------------------- /src/ldilib/parsers.py: -------------------------------------------------------------------------------- 1 | """Parsing mixin.""" 2 | import plistlib 3 | import re 4 | import sys 5 | 6 | from packaging import version as vp 7 | from pathlib import Path 8 | from typing import Any, Optional 9 | from urllib.parse import urljoin, urlparse 10 | 11 | from .models import LoopDownloadPackage, Size 12 | from .utils import bytes2hr 13 | from .wrappers import diskutil, installer, pkgutil 14 | 15 | 16 | class ParsersMixin: 17 | """Parsers mixin.""" 18 | 19 | APPLICATION_PATHS: dict[str, str] = { 20 | "garageband": "/Applications/GarageBand.app", 21 | "logicpro": "/Applications/Logic Pro X.app", 22 | "mainstage": "/Applications/MainStage 3.app", 23 | } 24 | 25 | # Create a set to store processed package names in to avoid duplication 26 | PROCESSED_PKGS = set() 27 | 28 | @property 29 | def mandatory_pkgs(self) -> set: 30 | """Return all mandatory packages.""" 31 | return self.packages["mandatory"] 32 | 33 | @property 34 | def optional_pkgs(self) -> set: 35 | """Return all optional packages.""" 36 | return self.packages["optional"] 37 | 38 | @property 39 | def mandatory_pkgs_download_size(self) -> int: 40 | return sum([pkg.download_size.filesize for pkg in self.mandatory_pkgs]) 41 | 42 | @property 43 | def optional_pkgs_download_size(self) -> int: 44 | return sum([pkg.download_size.filesize for pkg in self.optional_pkgs]) 45 | 46 | @property 47 | def mandatory_pkgs_installed_size(self) -> int: 48 | return sum([pkg.install_size.filesize for pkg in self.mandatory_pkgs]) 49 | 50 | @property 51 | def optional_pkgs_installed_size(self) -> int: 52 | return sum([pkg.install_size.filesize for pkg in self.optional_pkgs]) 53 | 54 | def include_package(self, p: LoopDownloadPackage) -> bool: 55 | """Determination check that a package needs to be included. 56 | :param p: instance of 'LoopDownloadPackage'""" 57 | return (self.create_mirror or not p.is_installed or self.force) and ( 58 | (self.mandatory and p.is_mandatory) or (self.optional and not p.is_mandatory) 59 | ) 60 | 61 | def parse_packages(self, pkgs: dict[str, dict[str, Any]]) -> None: 62 | """Parse packages from a source property list file representing audio content packages to download/install. 63 | :param pkgs: dictionary object representing package metadata""" 64 | for name, pkg in pkgs.items(): 65 | if pkg["DownloadName"] not in self.PROCESSED_PKGS: 66 | package = self.parse_package_for_attrs(pkg) 67 | 68 | if self.include_package(package): 69 | if package.is_mandatory: 70 | append_to = "mandatory" 71 | else: 72 | append_to = "optional" 73 | 74 | self.packages[append_to].add(package) 75 | 76 | def install_pkg(self, fp: Path, _target: str) -> bool: 77 | """Install a package. 78 | :param fp: package file path""" 79 | args = ["-dumplog", "-pkg", str(fp), "-target", _target] 80 | kwargs = {"capture_output": True, "encoding": "utf-8"} 81 | p = installer(*args, **kwargs) 82 | 83 | self.log.debug(f"installer called with: {args}") 84 | self.log.debug(p.stdout.strip()) 85 | self.log.warning(p.stderr.strip()) 86 | 87 | return p.returncode == 0 88 | 89 | def any_files_installed(self, files: str | list[str]) -> bool: 90 | """Determines if all files that are used to determine a package is installed exist on the 91 | local filesystem. 92 | :param files: a string or list of string representations of all files that must be checked""" 93 | return any(Path(file).exists() for file in files) 94 | 95 | def fetch_remote_source_file(self, url: str, dest: Path) -> Optional[Path]: 96 | """Fetches a source file from a remote location. This is used to fetch the property list files 97 | that contain the package metadata for downloading/installing. This will always be a silent fetch 98 | request. 99 | :param url: url of remote resource to be fetched 100 | :param dest: destination path to save resource to""" 101 | return self.get_file(url, dest, silent=True) 102 | 103 | def has_enough_disk_space(self) -> bool: 104 | """Check the disk space available is sufficient for download/install operations.""" 105 | dld_size = sum([self.mandatory_pkgs_download_size, self.optional_pkgs_download_size]) 106 | inst_size = sum([self.mandatory_pkgs_installed_size, self.optional_pkgs_installed_size]) 107 | 108 | if self.install: 109 | required_space = dld_size + inst_size 110 | else: 111 | required_space = dld_size 112 | 113 | args = ["info", "-plist", "/"] 114 | kwargs = {"capture_output": True} 115 | p = diskutil(*args, **kwargs) 116 | 117 | self.log.debug(f"diskutil called with: {args}") 118 | if p.returncode == 0: 119 | data = plistlib.loads(p.stdout) 120 | freespace = data.get("APFSContainerFree", data.get("FreeSpace")) 121 | has_freespace = freespace > required_space 122 | prefix = f"Disk space required: {bytes2hr(required_space)}, {bytes2hr(freespace)} available" 123 | return (has_freespace, prefix) 124 | else: 125 | self.log.error(f"Error performing disk space check: {p.stderr.decode('utf-8').strip()}") 126 | sys.exit(88) 127 | 128 | def local_pkg_vers_gt_remote_pkg_vers(self, vers_a: str | int | float, vers_b: str | int | float) -> bool: 129 | """Compares versions to determine if install or upgrade action must occur. 130 | :param vers_a: string representation of the current version of the package that is/is not installed 131 | :param vers_b: string representation of a version for the package that is being install/upgrade checked""" 132 | vers_a, vers_b = vp.parse(str(vers_a)), vp.parse(str(vers_b)) 133 | 134 | return (vers_a > vers_b) or not (vers_a == vers_b) 135 | 136 | def package_is_installed(self, pkg_id: str, pkg_vers: str, _installed_pkg_vers: str = "0.0.0") -> bool: 137 | """Determine's if a package is installed based on whether there is a BOM found by 'pkgutil'. 138 | :param pkg_id: string representation of a pacakge id; 139 | for example: 'com.apple.pkg.MAContent10_AssetPack_0718_EXS_MusicBox' 140 | :param pkg_vers: string representation of the package version to compare against the installed 141 | version, if the package is installed""" 142 | if self.force: 143 | return False 144 | else: 145 | args = ["--pkg-info-plist", pkg_id] 146 | kwargs = {"capture_output": True} 147 | p = pkgutil(*args, **kwargs) 148 | 149 | self.log.debug(f"pkgutil called with: {args}") 150 | if p.returncode == 0: 151 | data = plistlib.loads(p.stdout) 152 | _installed_pkg_vers = data.get("pkg-version", "0.0.0") 153 | 154 | if _installed_pkg_vers == "0.0.0": 155 | return False 156 | else: 157 | return self.local_pkg_vers_gt_remote_pkg_vers(_installed_pkg_vers, pkg_vers) 158 | 159 | def parse_application_plist_source_file( 160 | self, app: str, _gp: str = "*.plist", _pattern: Optional[re.Pattern] = None 161 | ) -> Optional[Path]: 162 | """Parse an application directory for the property list file containing package metadata. 163 | :param app: string value of the app to parse data for; this is a simple representation of the 164 | actual app, supported values are 'all'', 'garageband', 'logicpro', 'mainstage'""" 165 | _pattern = _pattern or re.compile(rf"{app}\d+.plist") 166 | files = sorted( 167 | [ 168 | fp 169 | for fp in Path(self.APPLICATION_PATHS[app]).joinpath("Contents/Resources").rglob(_gp) 170 | if _pattern.search(fp.name) 171 | ], 172 | reverse=True, 173 | ) 174 | 175 | if files and len(files) >= 1: 176 | return files[-1] 177 | 178 | def parse_plist_remote_source_file(self, plist: str) -> Optional[Path]: 179 | """Parse a remote source file, attempts to find it locally in an application folder first.""" 180 | result = None 181 | if not plist.endswith(".plist"): 182 | plist = f"{plist}.plist" 183 | 184 | pattern = re.compile(rf"{plist}") 185 | 186 | for app, _ in self.APPLICATION_PATHS.items(): 187 | result = self.parse_application_plist_source_file(app, _pattern=pattern) 188 | break 189 | 190 | if not result: 191 | url = urljoin(self.feed_base_url, plist) 192 | path = urlparse(url).path.removeprefix("/") 193 | dest = self.default_working_download_dest.joinpath(path) 194 | result = self.fetch_remote_source_file(url, dest) 195 | 196 | return result 197 | 198 | def parse_caching_server_url(self, url: str, server: str, def_scheme: str = "https") -> str: 199 | """Formats a url into the correct format required for pulling a file through Apple content caching 200 | server/s. 201 | :param url: url to be formatted 202 | :param server: the caching url; this must be in the format of 'http://example.org:port' 203 | :param def_scheme: default scheme to use as the 'sourceScheme' value in the reconstructed 204 | url; default is 'https' - this should always be 'https' as the package server 205 | value when pulling packages through the caching server should always be 206 | an Apple source""" 207 | p_url = urlparse(url.replace("https", "http")) 208 | scheme, path, netloc = p_url.scheme, p_url.path, p_url.netloc 209 | 210 | if scheme not in ["http", "https"] or scheme is None: 211 | scheme = "https" 212 | 213 | return f"{server}{path}?source={netloc}&sourceScheme={scheme}" 214 | 215 | def parse_download_install_statement(self) -> str: 216 | """Parse the total size of packages to be downloaded, how many are mandatory, optional.""" 217 | mnd_count, opt_count = len(self.mandatory_pkgs), len(self.optional_pkgs) 218 | tot_count = sum([mnd_count, opt_count]) 219 | 220 | mnd_dld_size, opt_dld_size = self.mandatory_pkgs_download_size, self.optional_pkgs_download_size 221 | tot_dld_size = bytes2hr(sum([mnd_dld_size, opt_dld_size])) 222 | mnd_dld_size = bytes2hr(mnd_dld_size) 223 | opt_dld_size = bytes2hr(opt_dld_size) 224 | 225 | mnd_inst_size, opt_inst_size = self.mandatory_pkgs_installed_size, self.optional_pkgs_installed_size 226 | tot_inst_size = bytes2hr(sum([mnd_inst_size, opt_inst_size])) 227 | mnd_inst_size = bytes2hr(mnd_inst_size) 228 | opt_inst_size = bytes2hr(opt_inst_size) 229 | 230 | mnd_opt_statement = [] 231 | 232 | if self.install: 233 | total_statement = f"Total packages: {tot_count} ({tot_dld_size} download, {tot_inst_size} installed)" 234 | mnd_dld_str = f"{mnd_count} mandatory packages ({mnd_dld_size} download, {mnd_inst_size} installed)" 235 | opt_dld_str = f"{opt_count} optional packages ({opt_dld_size} download, {opt_inst_size} installed)" 236 | else: 237 | total_statement = f"Total packages: {tot_count} ({tot_dld_size} download)" 238 | mnd_dld_str = f"{mnd_count} mandatory packages ({mnd_dld_size} download)" 239 | opt_dld_str = f"{opt_count} optional packages ({opt_dld_size} download)" 240 | 241 | if self.mandatory and len(self.packages["mandatory"]) > 0: 242 | mnd_opt_statement.append(f"\n - {mnd_dld_str}") 243 | 244 | if self.optional and len(self.packages["optional"]) > 0: 245 | mnd_opt_statement.append(f"\n - {opt_dld_str}") 246 | 247 | mnd_opt_statement = "".join(mnd_opt_statement) 248 | return f"{total_statement}{mnd_opt_statement}" 249 | 250 | def parse_package_for_attrs(self, p: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: 251 | """Parse a package dictionary object out into a tuple of two dictionaries representing 252 | the download package and the install package. 253 | Used to create a dataclass representation of the download package attributes and install 254 | package attributes. 255 | :param p: dictionary object representing a package""" 256 | result = {} # ensure this attribute is always present; default is False anyway 257 | pkg_attrs = { 258 | "IsMandatory": "is_mandatory", 259 | "DownloadName": "download_name", 260 | "DownloadSize": "download_size", 261 | "PackageID": "package_id", 262 | "FileCheck": "file_check", 263 | "InstalledSize": "install_size", 264 | "PackageVersion": "package_vers", 265 | } 266 | 267 | for k, v in p.items(): 268 | # Ensure string values have no bad trailing characters, some package id's do 269 | if isinstance(v, str): 270 | v = v.strip() 271 | 272 | # Ensure the 'file_check' attribute has a list of strings 273 | if k == "FileCheck" and isinstance(v, str): 274 | v = [v] 275 | 276 | if k in pkg_attrs: 277 | attr = pkg_attrs[k] 278 | result[attr] = v 279 | 280 | result.update(self.parse_updated_package_attr_vals(result)) 281 | self.PROCESSED_PKGS.add(result.get("download_name")) # Track already processed packages 282 | package = LoopDownloadPackage(**result) 283 | 284 | return package 285 | 286 | def parse_plist_source_file(self, fp: Path) -> Optional[dict[Any, Any]]: 287 | """Parse a property list source file for the package metadata dictionary. 288 | :param fp: file path""" 289 | with fp.open("rb") as f: 290 | data = plistlib.load(f) 291 | 292 | if data: 293 | return data.get("Packages", {}) 294 | 295 | def parse_updated_package_attr_vals(self, pkg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: 296 | """Parse updated attribute values for a download package dictionary. 297 | :param pkg: the download package dictionary""" 298 | dest_base = self.create_mirror or self.default_packages_download_dest 299 | name = pkg["download_name"] 300 | size_fallback = pkg["download_size"] 301 | url = self.parse_package_url_from_name(name, self.pkg_server or self.cache_server) 302 | parsed_url = self.parse_caching_server_url(url, self.cache_server) if self.cache_server else url 303 | pkg_path = urlparse(url).path 304 | 305 | pkg["download_url"] = parsed_url 306 | pkg["download_dest"] = dest_base.joinpath(pkg_path.removeprefix("/")) 307 | pkg["download_size"] = Size(filesize=self.get_headers(parsed_url).get("content-size", size_fallback)) 308 | pkg["is_compressed"] = self.is_compressed(parsed_url) 309 | pkg["status_code"], pkg["status_ok"] = self.is_status_ok(parsed_url) 310 | pkg["install_target"] = "/" 311 | 312 | if not pkg.get("is_mandatory"): 313 | pkg["is_mandatory"] = False 314 | 315 | pkg["is_installed"] = self.any_files_installed(pkg["file_check"]) and self.package_is_installed( 316 | pkg["package_id"], pkg.get("package_vers", "0.0.0") 317 | ) 318 | pkg["install_size"] = Size(filesize=pkg["install_size"]) 319 | 320 | return pkg 321 | 322 | def parse_package_url_from_name(self, name: str, base: str) -> str: 323 | """Parse the full package url from the package name value. 324 | Where a url contains 'lp10_ms3_content_2016/../lp10_ms3_content_2013', it is correctly parsed to the right 325 | directory structure. 326 | :param name: string representation of package name; for example: 'MAContent10_GarageBand6Legacy.pkg' 327 | :param base: string representation of package base url; for example: 328 | 'https://audiocontentdownload.apple.com/'""" 329 | base = f"{base}/" if not base.endswith("/") else base # Ensure 'urljoin' correctly joins url paths 330 | return urljoin(base, name) 331 | 332 | def parse_discovery(self, apps: list[str], r: list[int]) -> list[str]: 333 | """Discovery property lists for audio content downloads. 334 | :param r: range starting from a minimum value to a maximum value""" 335 | major_release_vers = { 336 | "garageband": sorted([10]), 337 | "logicpro": sorted([10, 11]), 338 | "mainstage": sorted([3]), 339 | } 340 | start, finish = r 341 | 342 | for app in apps: 343 | versions = major_release_vers.get(app, []) 344 | 345 | for app_ver in versions: 346 | self.log.info(f"Discovering property list files for {app!r}, major version {app_ver!r}") 347 | 348 | for target in range(start, finish + 1): 349 | target = f"{target:02d}" # this doesn't seem to hurt MainStage 3.x releases. 350 | 351 | url = urljoin(self.feed_base_url, f"{app}{app_ver}{target}.plist") 352 | status_code, status_ok = self.is_status_ok(url) 353 | 354 | if status_ok: 355 | self.log.info(f"Found property list file: {Path(url).name!r}") 356 | -------------------------------------------------------------------------------- /src/ldilib/request.py: -------------------------------------------------------------------------------- 1 | """CURL request handling""" 2 | import subprocess 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | from .wrappers import curl 7 | 8 | 9 | class RequestMixin: 10 | def _expand_curl_args(self) -> list[str]: 11 | """Expands a series of core arguments that need to be included in every curl request.""" 12 | return [*self._noproxy, *self._useragt, *self._retries, *self._proxy_args] 13 | 14 | def curl_error(self, args: list[str], p: subprocess.CompletedProcess) -> None: 15 | """Log when methods using the 'curl' wrapper exit with non-zero return codes. 16 | :param args: list of arguments passed to the 'curl' wrapper 17 | :param p: the completed subprocess object""" 18 | args = " ".join([str(arg) for arg in args]) 19 | msg = f"curl exited with returncode: {p.returncode}" 20 | 21 | if p.stderr: 22 | msg = f"{msg} - error message: {p.stderr.strip()}" 23 | 24 | self.log.debug(f"'curl -L {args}'") 25 | self.log.debug(msg) 26 | 27 | def get_headers(self, url: str, _sep: str = ": ") -> dict[str, str | int]: 28 | """Get the headers for a given URL, returned as a dictionary object.""" 29 | result = {} 30 | args = self._expand_curl_args() 31 | kwargs = {"capture_output": True, "encoding": "utf-8"} 32 | p = curl(*args, **kwargs) 33 | 34 | if p.returncode == 0: 35 | redir_str = "\r\n\r" # url redirects output includes this line break pattern 36 | 37 | # Handle redirect codes 301, 302 Found/Moved Temp, 303, 307, and/or 308 38 | if redir_str in p.stdout.strip(): 39 | stdout = p.stdout.strip().split(redir_str)[-1] # last "line" is the header to parse 40 | else: 41 | stdout = p.stdout.strip() 42 | 43 | # Now construct dictionary of header values 44 | for line in stdout.splitlines(): 45 | line = line.strip() 46 | 47 | # HTTP status line doesn't have the ":" header separator in it 48 | if any(line.startswith(prfx) for prfx in ["HTTP/1", "HTTP/2", "HTTP/3"]): 49 | result["status"] = int(line.split(" ")[1]) 50 | else: 51 | key_val = line.split(_sep) 52 | key, val = key_val[0].lower(), "".join(key_val[1:]) 53 | val = None if val == "" else val 54 | 55 | if val and all(char.isdigit() for char in val): 56 | val = int(val) 57 | 58 | if not key == "": 59 | result[key] = val 60 | else: 61 | self.curl_error(args, p) 62 | 63 | return result 64 | 65 | def is_compressed(self, url: str, _header: str = "content-encoding", _encoding: str = "gzip") -> bool: 66 | """Determine if the URL has a 'Content-Encoding' header and a value that represents a compressed file type. 67 | :param url: url to check the content-encoding header value of""" 68 | return self.get_headers(url).get(_header, False) == _encoding 69 | 70 | def is_status_ok(self, url: str, _ok_statuses: list[int] = [*range(200, 300)]) -> bool: 71 | """Determine if the URL has a status code that is in an OK range. 72 | :param url: url to check the status code of""" 73 | args = self._expand_curl_args() 74 | args.extend(["-I", "--silent", "-o", "/dev/null", "-w", "%{http_code}", url]) 75 | kwargs = {"capture_output": True, "encoding": "utf-8"} 76 | p = curl(*args, **kwargs) 77 | 78 | if p.returncode == 0: 79 | status = int(p.stdout.strip()) 80 | self.log.warning(f"{url} - HTTP {status}") 81 | return (status, status in _ok_statuses) 82 | else: 83 | self.curl_error(args, p) 84 | return (-999, False) 85 | 86 | def get_file(self, url: str, dest: Path, silent: bool = False) -> Optional[Path]: 87 | """Get a file from the provided URL. This method checks if the requested file is compressed, automatically 88 | using the curl parameter '--compressed' if the 'content-encoding' header exists and has a 'gzip' value (this 89 | is the compression type used by Apple for these downloads). 90 | :param url: url to retrieve 91 | :param dest: destination the file will be saved to 92 | :param silent: perform file retrieval silently; default False""" 93 | args = self._expand_curl_args() 94 | args.extend(["--silent" if silent else "--progress-bar", url, "-o", str(dest), "--create-dirs"]) 95 | kwargs = {"capture_output": silent} 96 | 97 | if self.is_compressed(url): 98 | args.extend(["--compressed"]) 99 | 100 | p = curl(*args, **kwargs) 101 | 102 | self.log.debug(f"curl called with: {args}") 103 | if p.returncode == 0: 104 | if dest.exists() and dest.stat().st_size > 0: 105 | return dest 106 | else: 107 | self.curl_error(args, p) 108 | -------------------------------------------------------------------------------- /src/ldilib/utils.py: -------------------------------------------------------------------------------- 1 | """Utils used in various package files.""" 2 | import argparse 3 | import json 4 | import shutil 5 | import sys 6 | 7 | from functools import partial 8 | from os import geteuid 9 | from pathlib import Path 10 | from time import sleep 11 | from typing import Any, Optional 12 | from urllib.parse import urlparse 13 | 14 | from .wrappers import assetcachelocatorutil, sw_vers 15 | 16 | 17 | class CachingServerAutoLocateException(Exception): 18 | """Handle exceptions when the caching server cannot be automatically located.""" 19 | 20 | pass 21 | 22 | 23 | class CachingServerMissingSchemeException(Exception): 24 | """Handle exceptions when the caching server scheme is missing.""" 25 | 26 | pass 27 | 28 | 29 | class CachingServerPortException(Exception): 30 | """Handle exceptions when the caching server url is missing a port value.""" 31 | 32 | pass 33 | 34 | 35 | class CachingServerSchemeException(Exception): 36 | """Handle exceptions when the caching server scheme is unsupported (https instead of http).""" 37 | 38 | pass 39 | 40 | 41 | def bytes2hr(b: str | int, bs: int = 1024) -> str: 42 | """Converts bytes (file size/disk space) to a human readable value. 43 | :param b: byte value to convert 44 | :param bs: integer representation of blocksize for size calculation; default is 1024""" 45 | b, idx = int(b), 0 46 | suffixes: list[str] = ["B", "KB", "MB", "GB", "TB", "PB"] 47 | 48 | while b > bs and idx < len(suffixes): 49 | idx += 1 50 | b /= float(bs) 51 | 52 | return f"{b:.2f}{suffixes[idx]}" 53 | 54 | 55 | def clean_up_dirs(fp: Path) -> None: 56 | """Clean up directory using recursive delete (ensure's directory contents are removed). 57 | :param fp: directory as a Path object""" 58 | shutil.rmtree(fp, ignore_errors=True) 59 | 60 | 61 | def debugging_info(args: argparse.Namespace) -> str: 62 | """Returns a string of useful debugging information.""" 63 | sw_vers_kwargs = {"capture_output": True, "encoding": "utf-8"} 64 | pn = partial(sw_vers, *["--productName"], **sw_vers_kwargs) 65 | pv = partial(sw_vers, *["--productVersion"], **sw_vers_kwargs) 66 | bv = partial(sw_vers, *["--buildVersion"], **sw_vers_kwargs) 67 | mac_os_vers_str = f"{pn().stdout.strip()} {pv().stdout.strip()} ({bv().stdout.strip()})" 68 | args = vars(args) 69 | args_str = "" 70 | 71 | for k, v in args.items(): 72 | args_str = f"{args_str} '{k}'='{v}',".strip() 73 | 74 | return ( 75 | f"OS Version: {mac_os_vers_str!r}; Executable: {sys.executable!r}; Python Version: {sys.version!r}" 76 | f" Arguments: {args_str}" 77 | ) 78 | 79 | 80 | def locate_caching_server(prefer: str = "system", rank_val: int = 1, retries: int = 3) -> Optional[str]: 81 | """Internal parser for determining a caching server value (url and port in the required format. 82 | This 'prefers' system data over current user data, and will always look for a 'saved server' with a ranking value 83 | of '1' by default. 84 | :param prefer: string representation of the preferred 'server' source from the binary result data, accepted values 85 | are either 'system' or 'current user'; default is 'server' 86 | :param rank_val: integer value representing the rank value that is preferred; default value is '1' 87 | :param retries: integer value representing the maximum number of retries to find a caching server; when a client 88 | has not initially seen a caching server the 'AssetCacheLocatorUtil' binary may return no result, 89 | so a second scan should detect it""" 90 | prefer_map = {"system": "system", "user": "current user"} 91 | 92 | def is_favoured(s: dict[str, Any], r: int) -> bool: 93 | """Truth test for server favoured-ness. 94 | :param s: dictionary object representing server 95 | :param r: integer value representing rank value to test on""" 96 | favored, has_port, healthy = s.get("favored", False), s.get("hostport", False), s.get("healthy", False) 97 | matches_ranking = s.get("rank", -999) == r 98 | return favored and has_port and healthy and matches_ranking 99 | 100 | def _parse_servers_from_cache_locator(d: dict[str, Any]) -> Optional[dict[str, Any]]: 101 | """Internal parser to process server objects from the assetcachelocatorutil util result.""" 102 | if d.get("results"): 103 | return d["results"].get(prefer_map[prefer]) 104 | 105 | attempts = 0 106 | 107 | while attempts < retries: 108 | if not attempts == 0: 109 | sleep(2) 110 | 111 | p = assetcachelocatorutil(*["--json"], **{"capture_output": True, "encoding": "utf-8"}) 112 | 113 | if p.returncode == 0 and p.stdout: 114 | data = json.loads(p.stdout.strip()) 115 | saved_servers = _parse_servers_from_cache_locator(data).get("saved servers") 116 | 117 | if saved_servers: 118 | for server in saved_servers.get("all servers", []): 119 | if is_favoured(server, rank_val): 120 | hostport = server.get("hostport") 121 | return f"http://{hostport}" 122 | attempts += 1 123 | 124 | raise CachingServerAutoLocateException("a caching server could not be found") 125 | 126 | 127 | def is_root() -> bool: 128 | """Is the effective user id root.""" 129 | return geteuid() == 0 130 | 131 | 132 | def validate_caching_server_url(url: str) -> None: 133 | """Validates that the caching server url contains the required scheme (http) and a port number 134 | :param url: caching server url""" 135 | url = urlparse(url) 136 | scheme, port = url.scheme, url.port 137 | 138 | # This order is important, because 'urlparse' doesn't handle circumstances where a scheme 139 | # is missing from a url, for example, if the url is example.org:22, the scheme becomes 140 | # example.org and the path becomes 22 141 | if not scheme or scheme and scheme not in ("http", "https"): 142 | raise CachingServerMissingSchemeException("missing http:// scheme prefix") 143 | 144 | if scheme and scheme == "https" or not scheme == "http": 145 | raise CachingServerSchemeException(f"invalid scheme {scheme}, only http:// scheme is supported") 146 | 147 | if not port: 148 | raise CachingServerPortException(f"no port number specified in {url!r}") 149 | -------------------------------------------------------------------------------- /src/ldilib/wrappers.py: -------------------------------------------------------------------------------- 1 | """Python wrappers of various system binaries or other Python packages.""" 2 | import subprocess 3 | 4 | 5 | def assetcachelocatorutil(*args, **kwargs) -> subprocess.CompletedProcess: 6 | """Wraps '/usr/bin/AssetCacheLocatorUtil'. 7 | Note: The only valid argument that this binary accepts is '--json'. 8 | This binary returns the json output on stdout, but also returns a bunch of 'normal' output to stderr. 9 | :param args: argument to pass on to the system binary""" 10 | cmd = ["AssetCacheLocatorUtil", *args] 11 | return subprocess.run(cmd, **kwargs) 12 | 13 | 14 | def curl(*args, **kwargs) -> subprocess.CompletedProcess: 15 | """Wraps '/usr/bin/curl'. 16 | This wrapper defaults to following redirects (max follow depth of 5 redirects), and includes 17 | the user agent string in all operations. 18 | :param *args: arguments to pass to the system binary 19 | :param **kwargs: arguments to pass to the subprocess call""" 20 | cmd = ["/usr/bin/curl", "-L", *args] 21 | return subprocess.run(cmd, **kwargs) 22 | 23 | 24 | def diskutil(*args, **kwargs) -> subprocess.CompletedProcess: 25 | """Wraps '/usr/sbin/diskutil'. 26 | :param *args: arguments to pass to the system binary 27 | :param **kwargs: arguments to pass to the subprocess call""" 28 | cmd = ["/usr/sbin/diskutil", *args] 29 | return subprocess.run(cmd, **kwargs) 30 | 31 | 32 | def installer(*args, **kwargs) -> subprocess.CompletedProcess: 33 | """Wraps '/usr/sbin/installer'. 34 | :param *args: arguments to pass to the system binary 35 | :param **kwargs: arguments to pass to the subprocess call""" 36 | cmd = ["/usr/sbin/installer", *args] 37 | return subprocess.run(cmd, **kwargs) 38 | 39 | 40 | def pkgutil(*args, **kwargs) -> subprocess.CompletedProcess: 41 | """Wraps '/usr/sbin/pkgutil'. 42 | :param *args: arguments to pass to the system binary 43 | :param **kwargs: arguments to pass to the subprocess call""" 44 | cmd = ["/usr/sbin/pkgutil", *args] 45 | return subprocess.run(cmd, **kwargs) 46 | 47 | 48 | def sw_vers(*args, **kwargs) -> subprocess.CompletedProcess: 49 | """Wraps '/usr/bin/sw_vers'. 50 | :param *args: arguments to pass to the system binary 51 | :param **kwargs: arguments to pass to the subprocess call""" 52 | cmd = ["/usr/bin/sw_vers", *args] 53 | return subprocess.run(cmd, **kwargs) 54 | -------------------------------------------------------------------------------- /wiki/content/images/loops.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carlashley/loopdown/ee2e7cba513c1f7ae7afceefefb6324ff542e06f/wiki/content/images/loops.png --------------------------------------------------------------------------------