├── .editorconfig ├── .flake8 ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── SUPPORT.md └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── Makefile ├── conf.py ├── index.rst └── make.bat ├── noxfile.py ├── pyproject.toml └── src └── google_music_scripts ├── __about__.py ├── __init__.py ├── __main__.py ├── cli.py ├── commands.py ├── config.py ├── constants.py ├── core.py └── utils.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [CHANGELOG.md] 15 | indent_size = 2 16 | 17 | [*.yml] 18 | indent_style = space 19 | indent_size = 2 20 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E117, W191, W503, F403, F405 3 | max-line-length = 150 4 | import-order-style = tbm 5 | exclude = 6 | __pycache__ 7 | .nox 8 | dist 9 | docs 10 | enable-plugins = 11 | builtins 12 | comprehensions 13 | import_order 14 | application-import-names = 15 | google_music_scripts 16 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at mail@thebigmunch.me. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | 78 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | **Due to Google Music shutting down in favor of YouTube Music, this project has ended.** 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: 13 | - https://bit.ly/PayPal-thebigmunch 14 | - https://bit.ly/DigitalOcean-tbm-referral 15 | - http://bit.ly/Namecheap-tbm-referral 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Please read the contributing guidelines linked above and delete this message before opening an issue.** 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Please read the contributing guidelines linked above and delete this message before creating a pull request.** 2 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | The issue tracker is for feature requests and bug reports only. 2 | 3 | For discussion and support, use the [Discourse forum](https://forum.thebigmunch.me). 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags-ignore: 8 | - '**' 9 | pull_request: 10 | branches: 11 | - master 12 | # Allow rebuilds via API. 13 | repository_dispatch: 14 | types: 15 | - rerun 16 | 17 | jobs: 18 | lint: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v1 23 | - uses: actions/setup-python@v1 24 | with: 25 | python-version: 3.8 26 | - run: pip install -U nox 27 | - run: nox -s lint 28 | 29 | doc: 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v1 34 | - uses: actions/setup-python@v1 35 | with: 36 | python-version: 3.8 37 | - run: pip install -U nox 38 | - run: nox -s doc 39 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.8 16 | - uses: dschep/install-poetry-action@v1.3 17 | with: 18 | create_virtualenvs: true 19 | - run: poetry publish --build --username __token__ --password ${{ secrets.PYPI_TOKEN }} 20 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | pip-wheel-metadata/ 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Sphinx documentation 59 | docs/_build/ 60 | 61 | # PyBuilder 62 | target/ 63 | 64 | # Ipython Notebook 65 | .ipynb_checkpoints 66 | 67 | # pyenv 68 | .python-version 69 | 70 | # poetry 71 | poetry.lock 72 | 73 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | build: 2 | image: latest 3 | 4 | python: 5 | version: 3 6 | pip_install: true 7 | extra_requirements: 8 | - doc 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | Notable changes to this project based on the [Keep a Changelog](https://keepachangelog.com) format. 4 | This project adheres to [Semantic Versioning](https://semver.org). 5 | 6 | 7 | ## [Unreleased](https://github.com/thebigmunch/google-music-scripts/tree/master) 8 | 9 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/4.5.0...master) 10 | 11 | ### Changed 12 | 13 | * Silence warnings from audio-metadata. 14 | * Handle exceptions when loading audio metadata of downloaded songs. 15 | 16 | 17 | ## [4.5.0](https://github.com/thebigmunch/google-music-scripts/releases/tag/4.5.0) (2020-05-01) 18 | 19 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/4.4.0...4.5.0) 20 | 21 | ### Changed 22 | 23 | * Catch exceptions from google_music.MusicManager.upload call. 24 | 25 | 26 | ## [4.4.0](https://github.com/thebigmunch/google-music-scripts/releases/tag/4.4.0) (2020-04-08) 27 | 28 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/4.3.0...4.4.0) 29 | 30 | ### Changed 31 | 32 | * Update dependency versions. 33 | 34 | 35 | ## [4.3.0](https://github.com/thebigmunch/google-music-scripts/releases/tag/4.3.0) (2020-03-05) 36 | 37 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/4.2.1...4.3.0) 38 | 39 | ### Added 40 | 41 | * Support for uploading Ogg Vorbis and Ogg Opus files. 42 | 43 | 44 | ## [4.2.1](https://github.com/thebigmunch/google-music-scripts/releases/tag/4.2.1) (2020-02-23) 45 | 46 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/4.2.0...4.2.1) 47 | 48 | ### Fixed 49 | 50 | * Incorrect max verbosity check. 51 | * Method name: ``song_delete`` -> ``songs_delete``. 52 | 53 | 54 | ## [4.2.0](https://github.com/thebigmunch/google-music-scripts/releases/tag/4.2.0) (2019-11-07) 55 | 56 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/4.1.1...4.2.0) 57 | 58 | ### Added 59 | 60 | * Re-add download/upload progress messages at trace level. 61 | * Ability to use 'today' and 'yesterday' in date filter options. 62 | 63 | ### Changed 64 | 65 | * Failed action messages now require less 66 | verbosity than successful action messages. 67 | 68 | 69 | ## [4.1.1](https://github.com/thebigmunch/google-music-scripts/releases/tag/4.1.1) (2019-07-22) 70 | 71 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/4.1.0...4.1.1) 72 | 73 | ### Fixed 74 | 75 | * Fix audio-metadata dependency version. 76 | 77 | 78 | ## [4.1.0](https://github.com/thebigmunch/google-music-scripts/releases/tag/4.1.0) (2019-07-22) 79 | 80 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/4.0.1...4.1.0) 81 | 82 | ### Added 83 | 84 | * Logging options: 85 | * ``--log-to-stdout`` 86 | * ``--no-log-to-stdout`` 87 | * ``--no-log-to-file`` 88 | 89 | ### Changed 90 | 91 | * Output message verbosity hierarchy. 92 | There is now a more coherent, consistent strategy. 93 | * Make logging to stdout and file independently configurable. 94 | 95 | 96 | ## [4.0.1](https://github.com/thebigmunch/google-music-scripts/releases/tag/4.0.1) (2019-02-08) 97 | 98 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/4.0.0...4.0.1) 99 | 100 | ### Fixed 101 | 102 | * ``max_depth`` parameter to ``get_local_songs`` not functioning. 103 | 104 | 105 | ## [4.0.0](https://github.com/thebigmunch/google-music-scripts/releases/tag/4.0.0) (2019-02-06) 106 | 107 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/3.0.0...4.0.0) 108 | 109 | ### Added 110 | 111 | * Sync functionality to ``download`` and ``upload`` commands. 112 | * ``--use-audio-hash/--no-use-audio-hash`` option to 113 | ``download`` and ``upload`` commands. 114 | * ``--use-metadata/--no-use-metadata`` option to 115 | ``download`` and ``upload`` commands. 116 | * Options to exclude local filepaths in different ways: 117 | * ``-xp, --exclude-path`` 118 | * ``-xr, --exclude-regex`` 119 | * ``-xg, --exclude-glob`` 120 | * Options to filter songs by date for ``download`` and ``upload`` commands: 121 | * ``--created-in`` 122 | * ``--created-on`` 123 | * ``--created-before`` 124 | * ``--created-after`` 125 | * ``--modified-in`` 126 | * ``--modified-on`` 127 | * ``--modified-before`` 128 | * ``--modified-after`` 129 | * ``--debug`` option to enable logging messages from dependencies. 130 | 131 | ### Changed 132 | 133 | * Use argparse instead of click for CLI. 134 | * Refactor ``download``, ``sync``, and ``upload`` commands. 135 | * Remove sync commands. 136 | * Add sync options to ``download`` and ``upload`` commands: 137 | * ``--use-audio-hash/--no-use-audio-hash`` to sync based on 138 | audio hash of file as generated by Google Music. 139 | * ``--use-metadata/--no-use-metadata`` to sync based on metadata. 140 | * ``-l, --log`` option to ``--log-to-file``. 141 | 142 | ### Removed 143 | 144 | * ``sync`` commands. 145 | 146 | 147 | ## [3.0.0](https://github.com/thebigmunch/google-music-scripts/releases/tag/3.0.0) (2019-01-15) 148 | 149 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/2.0.0...3.0.0) 150 | 151 | ### Added 152 | 153 | * Option to specify external album art names/paths with upload commands. 154 | * ``--no-sample`` option to ``gms upload`` and ``gms sync up``. 155 | This sends an empty audio sample instead of creating one with ffmpeg/avconv. 156 | If uploading MP3s, this option completely removes the ffmpeg/avconv requirement. 157 | Otherwise, this will save time/bandwidth by not creating nor sending a sample. 158 | 159 | ### Changed 160 | 161 | * ``--filters`` long option to ``--filter``. 162 | 163 | ### Removed 164 | 165 | * Transcoding options from upload commands. 166 | See [#9](https://github.com/thebigmunch/google-music-scripts/issues/9) 167 | for explanation. 168 | 169 | ### Fixed 170 | 171 | * ``TypeError`` when sorting Google Music songs due to no defaults 172 | being set for ``get`` calls. 173 | 174 | 175 | ## [2.0.0](https://github.com/thebigmunch/google-music-scripts/releases/tag/2.0.0) (2018-11-26) 176 | 177 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/1.1.1...2.0.0) 178 | 179 | ### Added 180 | 181 | * Configuration file. 182 | * Ability to set option defaults in configuration file. 183 | 184 | ### Changed 185 | 186 | * Filtering is now done through one option ``-f, --filters`` with different syntax and semantics. 187 | * ``+field[value]`` is the new syntax for an include filter condition. 188 | * ``-field[value]`` is the new syntax for an exclude filter condition. 189 | * Multiple filters can be set in one call. 190 | * Multiple conditions can be chained in one filter. 191 | * Values can still be valid Python regex. 192 | * Matching is still done case-insensitively. 193 | 194 | E.g: 195 | * ``gms download -f 'artist[Beck]+album[Guero]-title[E-Pro]'`` 196 | would download all songs by Beck from the album Guero without E-Pro in the title. 197 | * ``gms download -f 'artist[Beck]+album[Guero]-title[E-Pro]' -f 'artist[Daft Punk]'`` 198 | would download all songs by Beck from the album Guero without E-Pro in the title 199 | as well as all songs by Daft Punk. 200 | 201 | ### Removed 202 | 203 | * Legacy command entry points (gmupload, gmdownload, etc). 204 | Use the ``gms`` subcommands instead. 205 | 206 | 207 | ## [1.1.1](https://github.com/thebigmunch/google-music-scripts/releases/tag/1.1.1) (2018-11-13) 208 | 209 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/1.1.0...1.1.1) 210 | 211 | ### Fixed 212 | 213 | * Update required google-music version. 214 | 215 | 216 | ## [1.1.0](https://github.com/thebigmunch/google-music-scripts/releases/tag/1.1.0) (2018-11-13) 217 | 218 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/1.0.1...1.1.0) 219 | 220 | ### Fixed 221 | 222 | * Fixed various issues related to porting code to new framework. 223 | 224 | ### Changed 225 | 226 | * Refactored package structure. 227 | 228 | 229 | ## [1.0.1](https://github.com/thebigmunch/google-music-scripts/releases/tag/1.0.1) (2018-10-28) 230 | 231 | [Commits](https://github.com/thebigmunch/google-music-scripts/compare/1.0.0...1.0.1) 232 | 233 | ### Fixed 234 | 235 | * Fix incorrect order of variable assignment. 236 | 237 | 238 | ## [1.0.0](https://github.com/thebigmunch/google-music-scripts/releases/tag/1.0.0) (2018-10-19) 239 | 240 | [Commits](https://github.com/thebigmunch/google-music-scripts/commit/e14718c875434922b451d0598da021c6617afdb0) 241 | 242 | * Initial release. 243 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2020 thebigmunch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # google-music-scripts 2 | 3 | **Due to Google Music shutting down in favor of YouTube Music, this project has ended.** 4 | 5 | [google-music-scripts](https://github.com/thebigmunch/google-music-scripts) 6 | is a CLI utility for interacting with Google Music using my alternative to 7 | gmusicapi, [google-music](https://github.com/thebigmunch/google-music). 8 | 9 | 10 | ## Installation 11 | 12 | ``pip install -U google-music-scripts`` 13 | 14 | 15 | ## Usage 16 | 17 | For the release version, see the [stable docs](https://google-music-scripts.readthedocs.io/en/stable/). 18 | For the development version, see the [latest docs](https://google-music-scripts.readthedocs.io/en/latest/). 19 | 20 | 21 | ## Appreciation 22 | 23 | Showing appreciation is always welcome. 24 | 25 | #### Thank 26 | 27 | [![Say Thanks](https://img.shields.io/badge/thank-thebigmunch-blue.svg?style=flat-square)](https://saythanks.io/to/thebigmunch) 28 | 29 | Get your own thanks inbox at [SayThanks.io](https://saythanks.io/). 30 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = google-music-scripts 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | 6 | import sphinx_material 7 | 8 | project_dir = os.path.abspath(os.pardir) 9 | 10 | sys.path.insert(0, project_dir) 11 | 12 | about = {} 13 | with open(os.path.join(project_dir, 'src', 'google_music_scripts', '__about__.py')) as f: 14 | exec(f.read(), about) 15 | 16 | # Add any Sphinx extension module names here, as strings. They can be 17 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 18 | # ones. 19 | extensions = [ 20 | 'sphinxarg.ext', 21 | 'sphinx_material', 22 | ] 23 | 24 | # Add any paths that contain templates here, relative to this directory. 25 | templates_path = ['_templates'] 26 | 27 | # The suffix(es) of source filenames. 28 | # You can specify multiple suffix as a list of string: 29 | # 30 | # source_suffix = ['.rst', '.md'] 31 | source_suffix = '.rst' 32 | 33 | # The master toctree document. 34 | master_doc = 'index' 35 | 36 | # General information about the project. 37 | project = about['__title__'] 38 | copyright = about['__copyright__'] 39 | author = about['__author__'] 40 | 41 | # The version info for the project you're documenting, acts as replacement for 42 | # |version| and |release|, also used in various other places throughout the 43 | # built documents. 44 | # 45 | # The short X.Y version. 46 | version = about['__version__'] 47 | # The full version, including alpha/beta/rc tags. 48 | release = about['__version__'] 49 | 50 | # The language for content autogenerated by Sphinx. Refer to documentation 51 | # for a list of supported languages. 52 | # 53 | # This is also used if you do content translation via gettext catalogs. 54 | # Usually you set "language" from the command line for these cases. 55 | language = None 56 | 57 | # List of patterns, relative to source directory, that match files and 58 | # directories to ignore when looking for source files. 59 | # This patterns also effect to html_static_path and html_extra_path 60 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 61 | 62 | # If true, '()' will be appended to :func: etc. cross-reference text. 63 | add_function_parentheses = False 64 | 65 | # If true, the current module name will be prepended to all description 66 | # unit titles (such as .. function::). 67 | add_module_names = False 68 | 69 | # The name of the Pygments (syntax highlighting) style to use. 70 | pygments_style = 'sphinx' 71 | 72 | highlight_language = 'python3' 73 | 74 | # If true, `todo` and `todoList` produce output, else they produce nothing. 75 | todo_include_todos = True 76 | 77 | # Example configuration for intersphinx: refer to the Python standard library. 78 | intersphinx_mapping = { 79 | 'python': ('https://docs.python.org/3', None) 80 | } 81 | 82 | # -- Options for HTML output ---------------------------------------------- 83 | 84 | html_theme = 'sphinx_material' 85 | 86 | # Theme options are theme-specific and customize the look and feel of a theme 87 | # further. For a list of options available for each theme, see the 88 | # documentation. 89 | # 90 | html_theme_options = { 91 | 'nav_title': 'google-music-scripts', 92 | 'color_primary': 'blue', 93 | 'color_accent': 'deep-orange', 94 | 'repo_url': 'https://github.com/thebigmunch/google-music-scripts', 95 | 'repo_name': 'google-music-scripts', 96 | 'globaltoc_includehidden': True, 97 | 'master_doc': False, 98 | } 99 | 100 | # Get the them path 101 | html_theme_path = sphinx_material.html_theme_path() 102 | # Register the required helpers for the html context 103 | html_context = sphinx_material.get_html_context() 104 | 105 | # Add any paths that contain custom static files (such as style sheets) here, 106 | # relative to this directory. They are copied after the builtin static files, 107 | # so a file named "default.css" will overwrite the builtin "default.css". 108 | # html_static_path = ['_static'] 109 | 110 | # If true, links to the reST sources are added to the pages. 111 | html_show_sourcelink = False 112 | 113 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 114 | html_show_sphinx = False 115 | 116 | html_sidebars = { 117 | '**': [ 118 | 'globaltoc.html', 119 | 'localtoc.html', 120 | 'searchbox.html', 121 | ] 122 | } 123 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | google-music-scripts 2 | ==================== 3 | 4 | **Due to Google Music shutting down in favor of YouTube Music, this project has ended.** 5 | 6 | Legacy Commands 7 | --------------- 8 | 9 | As of version **2.0.0**, google-music-scripts no longer installs the entry points 10 | for the legacy commands (``gmupload, gmdownload, etc.``). Use the ``gms`` command 11 | with subcommands instead. 12 | 13 | As of version **4.0.0**, the ``sync`` commands are removed in favor of syncing 14 | options for the ``download`` and ``upload`` commands. These allow syncing based 15 | on the hash of the audio data ('clientId' on Google Music) and/or the metadata. 16 | Both sync options are enabled by default. This adds a dependency on the mobile 17 | API, so users will have to authenticate the Mobile Client on first run if they 18 | haven't previously done so through the Mobile API-based commands. 19 | 20 | 21 | Configuration 22 | ------------- 23 | 24 | The configuration file uses the `TOML `_ format. 25 | It is located in the `user config directory 26 | `_ 27 | for your operating system with the app author being **thebigmunch** and app name being 28 | **google-music-scripts**. If the ``-u, --username`` option is given to a command, the 29 | configuration file from subdirectory **username** is used. 30 | 31 | google-music-scripts allows configuration of option defaults using the defaults table. 32 | Use option long names (e.g. device-id, uploader-id) as the key. 33 | Defaults can be set for all commands, specific commands, or both with 34 | command-specific defaults taking precedence. 35 | 36 | Use **device-id1** for all commands with ``device-id`` argument 37 | and **uploader-id1** for all commands with ``uploader-id`` argument:: 38 | 39 | [defaults] 40 | device-id = "device-id1" 41 | uploader-id = "uploader-id1" 42 | 43 | Use **uploader-id2** for only the **upload** command:: 44 | 45 | [defaults.upload] 46 | uploader-id = "uploader-id2" 47 | 48 | Combine them to use **device-id1** for all commands with ``device-id`` argument, 49 | **uploader-id1** for all commands with ``uploader-id`` argument except **upload**, 50 | and **uploader-id2** for the **upload** command:: 51 | 52 | [defaults] 53 | device-id = "device-id1" 54 | uploader-id = "uploader-id1" 55 | 56 | [defaults.upload] 57 | uploader-id = "uploader-id2" 58 | 59 | 60 | Filtering 61 | --------- 62 | 63 | Metadata 64 | ^^^^^^^^ 65 | 66 | Some ``gms`` commands allow filtering results based on song metadata (``-f, --filter``). 67 | The syntax is as follows: 68 | 69 | * ``+field[value]`` to include filter condition. 70 | * ``-field[value]`` to exclude filter condition. 71 | * Multiple filters can be set in one call: ``-f +field[value] -f +field2[value2]`` 72 | * Multiple conditions can be chained in one filter: ``+field[value]+field2[value2]-field3[value3]``. 73 | * Values can be valid Python regex. 74 | * Matching is done case-insensitively. 75 | * For convenience, a single or first condition can leave off the ``+``, but not ``-``. 76 | 77 | Examples: 78 | * ``gms download -f 'artist[Beck]+album[Guero]-title[E-Pro]'`` 79 | would download all songs by Beck from the album Guero without E-Pro in the title. 80 | * ``gms download -f 'artist[Beck]+album[Guero]-title[E-Pro]' -f 'artist[Daft Punk]'`` 81 | would download all songs by Beck from the album Guero without E-Pro in the title 82 | as well as all songs by Daft Punk. 83 | 84 | Dates 85 | ^^^^^ 86 | 87 | Some ``gms`` commands allow filtering results based on creation/modification times. 88 | The following options are available: 89 | 90 | * ``--created-in``/``--modified-in`` to include results from year or year/month. 91 | * ``--created-on``/``--modified-on`` to include results from date. 92 | * ``--created-before``/``--modified-before`` to include results from before datetime. 93 | * ``--created-after``/``--modified-after`` to include results from after datetime. 94 | 95 | 96 | The format supported follows ISO 8061 with the abilility to use partial datetimes. 97 | A regex test is found 98 | `here `_. 99 | 100 | Examples: 101 | * ``gms upload --created-in 2019`` would upload files created in 2019. 102 | * ``gms upload --created-in 2019-02`` would upload files created in February 2019. 103 | * ``gms download --created-on 2019-02-04`` would download songs uploaded to 104 | Google Music on February 4th, 2019. 105 | * ``gms download --created-before 2019`` would download songs uploaded to 106 | Google Music before 2019 (i.e. 2018 or earlier). 107 | * ``gms download --created-after '2019-02-04 12:00:00`` would download songs 108 | uploaded to Google Music after 12 noon (UTC) on February 4th, 2019. 109 | * ``gms delete --created-after '2019-02-04 12:00:00-05:00`` would delete 110 | songs uploaded to Google Music after 12 noon (GMT-5:00) on February 4th, 2019. 111 | 112 | 113 | Output Templates 114 | ---------------- 115 | 116 | The ``download`` command supports defining an output template. 117 | An output template uses patterns, as described below, to use 118 | values from metadata fields in the output. 119 | If a field for a pattern does not exist in the song, 120 | the pattern remains in the download filepath. 121 | 122 | +----------------+-----------------+ 123 | | Pattern | Fields | 124 | +================+=================+ 125 | | %album% | - album | 126 | +----------------+-----------------+ 127 | | %albumartist% | - albumartist | 128 | | | - album_artist | 129 | | | - albumArtist | 130 | +----------------+-----------------+ 131 | | %artist% | - artist | 132 | +----------------+-----------------+ 133 | | %date% | - date | 134 | +----------------+-----------------+ 135 | | %disc% | - discnumber | 136 | | | - disc_number | 137 | | | - discNumber | 138 | +----------------+-----------------+ 139 | | %disc2% | - discnumber | 140 | | | - disc_number | 141 | | | - discNumber | 142 | | | | 143 | | | (zero-padded) | 144 | +----------------+-----------------+ 145 | | %discnumber% | - discnumber | 146 | | | - disc_number | 147 | | | - discNumber | 148 | +----------------+-----------------+ 149 | | %discnumber2% | - discnumber | 150 | | | - disc_number | 151 | | | - discNumber | 152 | | | | 153 | | | (zero-padded) | 154 | +----------------+-----------------+ 155 | | %genre% | - genre | 156 | +----------------+-----------------+ 157 | | %title% | - title | 158 | +----------------+-----------------+ 159 | | %track% | - tracknumber | 160 | | | - track_number | 161 | | | - trackNumber | 162 | +----------------+-----------------+ 163 | | %track2% | - tracknumber | 164 | | | - track_number | 165 | | | - trackNumber | 166 | | | | 167 | | | (zero-padded) | 168 | +----------------+-----------------+ 169 | | %tracknumber% | - tracknumber | 170 | | | - track_number | 171 | | | - trackNumber | 172 | +----------------+-----------------+ 173 | | %tracknumber2% | - tracknumber | 174 | | | - track_number | 175 | | | - trackNumber | 176 | | | | 177 | | | (zero-padded) | 178 | +----------------+-----------------+ 179 | 180 | Examples: 181 | * ``%track% - %title%`` 182 | * ``%artist%/%album%/%track2% - %title%`` 183 | 184 | 185 | Transcoding - ffmpeg/avconv 186 | --------------------------- 187 | 188 | Non-MP3 files require ffmpeg or avconv to be in your 189 | PATH to transcode them to MP3 for upload 190 | 191 | Google Music requires an audio sample be sent for most uploads. 192 | ffmpeg/avconv is used for this as well unless the ``--no-sample`` 193 | option is given. In this case, an empty audio sample is sent. 194 | If uploading MP3s, ffmpeg/avconv is not required with ``--no-sample``. 195 | 196 | 197 | Aliases 198 | ------- 199 | 200 | Some commands have shorter aliases to limit the necessary typing in the terminal. 201 | 202 | ======== ===== 203 | Command Alias 204 | ======== ===== 205 | delete del 206 | download down 207 | upload up 208 | ======== ===== 209 | 210 | 211 | Command-Line Interface 212 | ---------------------- 213 | 214 | Use ``-h, --help`` to display the help for any command. 215 | 216 | .. argparse:: 217 | :module: google_music_scripts.cli 218 | :func: gms 219 | :prog: gms 220 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=google-music-scripts 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | import nox 4 | 5 | nox.options.reuse_existing_virtualenvs = True 6 | 7 | 8 | @nox.session 9 | def lint(session): 10 | session.install('-U', '.[lint]') 11 | session.run('flake8', 'src/') 12 | 13 | 14 | @nox.session 15 | def doc(session): 16 | shutil.rmtree('docs/_build', ignore_errors=True) 17 | session.install('-U', '.[doc]') 18 | session.cd('docs') 19 | session.run( 20 | 'sphinx-build', 21 | '-b', 22 | 'html', 23 | '-W', 24 | '-d', 25 | '_build/doctrees', 26 | '.', 27 | '_build/html' 28 | ) 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ "poetry>=1.0.0" ] 3 | build-backend = "poetry.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "google-music-scripts" 7 | description = "A CLI utility for interacting with Google Music." 8 | version = "4.5.0" 9 | 10 | license = "MIT" 11 | 12 | authors = ["thebigmunch "] 13 | 14 | readme = "README.md" 15 | 16 | repository = "https://github.com/thebigmunch/google-music-scripts" 17 | homepage = "https://github.com/thebigmunch/google-music-scripts" 18 | 19 | [tool.poetry.dependencies] 20 | python = "^3.6" 21 | 22 | appdirs = "^1.0" 23 | attrs = ">=18.2,<19.4" 24 | audio-metadata = ">=0.10" 25 | google-music = "^3.4" 26 | google-music-proto = "^2.8" 27 | google-music-utils = "^2.5" 28 | loguru = "0.4.*" 29 | pendulum = ">=2.0,<=3.0,!=2.0.5,!=2.1.0" # Work around https://github.com/sdispater/pendulum/issues/454 30 | pprintpp = "0.*" 31 | natsort = ">=5.0,<8.0" 32 | tbm-utils = "^2.3" 33 | tomlkit = "^0.5" 34 | 35 | flake8 = { version = "^3.5", optional = true } 36 | flake8-builtins = { version = "^1.0", optional = true } 37 | flake8-comprehensions = { version = ">=2.0,<=4.0", optional = true } 38 | flake8-import-order = { version = "^0.18", optional = true } 39 | flake8-import-order-tbm = { version = "^1.2", optional = true } 40 | nox = { version = "^2019", optional = true } 41 | sphinx = { version = "^2.0", optional = true} 42 | sphinx-argparse = { version = "^0.2", optional = true } 43 | sphinx-material = { version = "0.*", optional = true } 44 | 45 | [tool.poetry.extras] 46 | dev = [ 47 | "flake8", 48 | "flake8-builtins", 49 | "flake8-comprehensions", 50 | "flake8-import-order", 51 | "flake8-import-order-tbm", 52 | "nox", 53 | "sphinx", 54 | "sphinx-argparse", 55 | "sphinx-material", 56 | ] 57 | doc = [ 58 | "sphinx", 59 | "sphinx-argparse", 60 | "sphinx-material", 61 | ] 62 | lint = [ 63 | "flake8", 64 | "flake8-builtins", 65 | "flake8-comprehensions", 66 | "flake8-import-order", 67 | "flake8-import-order-tbm", 68 | ] 69 | 70 | [tool.poetry.scripts] 71 | gms = "google_music_scripts.cli:run" 72 | -------------------------------------------------------------------------------- /src/google_music_scripts/__about__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | '__author__', 3 | '__author_email__', 4 | '__copyright__', 5 | '__license__', 6 | '__summary__', 7 | '__title__', 8 | '__url__', 9 | '__version__', 10 | '__version_info__', 11 | ] 12 | 13 | __title__ = 'google-music-scripts' 14 | __summary__ = 'A collection of scripts to interact with Google Music.' 15 | __url__ = 'https://github.com/thebigmunch/google-music-scripts' 16 | 17 | __version__ = '4.5.0' 18 | __version_info__ = tuple(int(i) for i in __version__.split('.') if i.isdigit()) 19 | 20 | __author__ = 'thebigmunch' 21 | __author_email__ = 'mail@thebigmunch.me' 22 | 23 | __license__ = 'MIT' 24 | __copyright__ = f'2018-2020 {__author__} <{__author_email__}>' 25 | -------------------------------------------------------------------------------- /src/google_music_scripts/__init__.py: -------------------------------------------------------------------------------- 1 | from .__about__ import * 2 | 3 | __all__ = [*__about__.__all__] 4 | -------------------------------------------------------------------------------- /src/google_music_scripts/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from . import cli 4 | 5 | if __name__ == '__main__': 6 | cli.run() 7 | -------------------------------------------------------------------------------- /src/google_music_scripts/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import math 3 | import re 4 | import warnings 5 | from pathlib import Path 6 | 7 | from attr import attrib, attrs 8 | from audio_metadata import AudioMetadataWarning 9 | from loguru import logger 10 | from tbm_utils import ( 11 | Namespace, 12 | SubcommandHelpFormatter, 13 | UsageHelpFormatter, 14 | create_parser_dry_run, 15 | create_parser_filter_dates, 16 | create_parser_local, 17 | create_parser_logging, 18 | create_parser_meta, 19 | create_parser_yes, 20 | custom_path, 21 | datetime_string_to_time_period, 22 | get_defaults, 23 | merge_defaults, 24 | parse_args 25 | ) 26 | 27 | from .__about__ import __title__, __version__ 28 | from .commands import ( 29 | do_delete, 30 | do_download, 31 | do_quota, 32 | do_search, 33 | do_upload, 34 | ) 35 | from .config import configure_logging, read_config_file 36 | 37 | COMMAND_ALIASES = { 38 | 'del': 'delete', 39 | 'delete': 'del', 40 | 'down': 'delete', 41 | 'download': 'down', 42 | 'up': 'upload', 43 | 'upload': 'up' 44 | } 45 | 46 | COMMAND_KEYS = { 47 | 'del', 48 | 'delete', 49 | 'down', 50 | 'download', 51 | 'quota', 52 | 'search', 53 | 'up', 54 | 'upload', 55 | } 56 | 57 | FILTER_RE = re.compile(r'(([+-]+)?(.*?)\[(.*?)\])', re.I) 58 | 59 | 60 | @attrs(slots=True, frozen=True) 61 | class FilterCondition: 62 | oper = attrib(converter=lambda o: '+' if o == '' else o) 63 | field = attrib() 64 | pattern = attrib() 65 | 66 | 67 | def parse_filter(value): 68 | conditions = FILTER_RE.findall(value) 69 | if not conditions: 70 | raise ValueError(f"'{value}' is not a valid filter.") 71 | 72 | filter_ = [ 73 | FilterCondition(*condition[1:]) 74 | for condition in conditions 75 | ] 76 | 77 | return filter_ 78 | 79 | 80 | def split_album_art_paths(value): 81 | paths = value 82 | if value: 83 | paths = [] 84 | 85 | if not isinstance(value, list): 86 | value = value.split(',') 87 | 88 | for val in value: 89 | paths.append(custom_path(val.strip())) 90 | 91 | return paths 92 | 93 | 94 | ######## 95 | # Meta # 96 | ######## 97 | 98 | meta = create_parser_meta(__title__, __version__) 99 | 100 | 101 | ########## 102 | # Action # 103 | ########## 104 | 105 | dry_run = create_parser_dry_run() 106 | yes = create_parser_yes() 107 | 108 | 109 | ########### 110 | # Logging # 111 | ########### 112 | 113 | logging_ = create_parser_logging() 114 | 115 | 116 | ################## 117 | # Identification # 118 | ################## 119 | 120 | ident = argparse.ArgumentParser( 121 | argument_default=argparse.SUPPRESS, 122 | add_help=False) 123 | 124 | ident_options = ident.add_argument_group("Identification") 125 | ident_options.add_argument( 126 | '-u', '--username', 127 | metavar='USER', 128 | help=( 129 | "Your Google username or e-mail address.\n" 130 | "Used to separate saved credentials." 131 | ) 132 | ) 133 | 134 | # Mobile Client 135 | 136 | mc_ident = argparse.ArgumentParser( 137 | argument_default=argparse.SUPPRESS, 138 | add_help=False 139 | ) 140 | 141 | mc_ident_options = mc_ident.add_argument_group("Identification") 142 | mc_ident_options.add_argument( 143 | '--device-id', 144 | metavar='ID', 145 | help="A mobile device id." 146 | ) 147 | 148 | # Music Manager 149 | 150 | mm_ident = argparse.ArgumentParser( 151 | argument_default=argparse.SUPPRESS, 152 | add_help=False 153 | ) 154 | 155 | mm_ident_options = mm_ident.add_argument_group("Identification") 156 | mm_ident_options.add_argument( 157 | '--uploader-id', 158 | metavar='ID', 159 | help=( 160 | "A unique id given as a MAC address (e.g. '00:11:22:33:AA:BB').\n" 161 | "This should only be provided when the default does not work." 162 | ) 163 | ) 164 | 165 | 166 | ######### 167 | # Local # 168 | ######### 169 | 170 | local = create_parser_local() 171 | 172 | 173 | ########## 174 | # Filter # 175 | ########## 176 | 177 | # Metadata 178 | 179 | filter_metadata = argparse.ArgumentParser( 180 | argument_default=argparse.SUPPRESS, 181 | add_help=False 182 | ) 183 | 184 | metadata_options = filter_metadata.add_argument_group("Filter") 185 | metadata_options.add_argument( 186 | '-f', '--filter', 187 | metavar='FILTER', 188 | action='append', 189 | dest='filters', 190 | type=parse_filter, 191 | help=( 192 | "Metadata filters.\n" 193 | "Can be specified multiple times." 194 | ) 195 | ) 196 | 197 | # Dates 198 | 199 | filter_dates = create_parser_filter_dates() 200 | 201 | 202 | ############### 203 | # Upload Misc # 204 | ############### 205 | 206 | 207 | upload_misc = argparse.ArgumentParser( 208 | argument_default=argparse.SUPPRESS, 209 | add_help=False 210 | ) 211 | 212 | upload_misc_options = upload_misc.add_argument_group("Misc") 213 | upload_misc_options.add_argument( 214 | '--delete-on-success', 215 | action='store_true', 216 | help="Delete successfully uploaded local files." 217 | ) 218 | upload_misc_options.add_argument( 219 | '--no-sample', 220 | action='store_true', 221 | help=( 222 | "Don't create audio sample with ffmpeg/avconv.\n" 223 | "Send empty audio sample." 224 | ) 225 | ) 226 | upload_misc_options.add_argument( 227 | '--album-art', 228 | metavar='ART_PATHS', 229 | type=split_album_art_paths, 230 | help=( 231 | "Comma-separated list of album art filepaths.\n" 232 | "Can be relative filenames and/or absolute filepaths." 233 | ) 234 | ) 235 | 236 | 237 | ######## 238 | # Sync # 239 | ######## 240 | 241 | 242 | sync = argparse.ArgumentParser( 243 | argument_default=argparse.SUPPRESS, 244 | add_help=False 245 | ) 246 | 247 | sync_options = sync.add_argument_group("Sync") 248 | sync_options.add_argument( 249 | '--use-hash', 250 | action='store_true', 251 | help="Use audio hash to sync songs." 252 | ) 253 | sync_options.add_argument( 254 | '--no-use-hash', 255 | action='store_true', 256 | help="Don't use audio hash to sync songs." 257 | ) 258 | sync_options.add_argument( 259 | '--use-metadata', 260 | action='store_true', 261 | help="Use metadata to sync songs." 262 | ) 263 | sync_options.add_argument( 264 | '--no-use-metadata', 265 | action='store_true', 266 | help="Don't use metadata to sync songs." 267 | ) 268 | 269 | 270 | ########## 271 | # Output # 272 | ########## 273 | 274 | output = argparse.ArgumentParser( 275 | argument_default=argparse.SUPPRESS, 276 | add_help=False 277 | ) 278 | 279 | output_options = output.add_argument_group("Output") 280 | output_options.add_argument( 281 | '-o', '--output', 282 | metavar='TEMPLATE_PATH', 283 | type=lambda t: str(custom_path(t)), 284 | help="Output file or directory name which can include template patterns." 285 | ) 286 | 287 | 288 | ########### 289 | # Include # 290 | ########### 291 | 292 | include = argparse.ArgumentParser( 293 | argument_default=argparse.SUPPRESS, 294 | add_help=False 295 | ) 296 | 297 | include_options = include.add_argument_group("Include") 298 | include_options.add_argument( 299 | 'include', 300 | metavar='PATH', 301 | type=lambda p: custom_path(p).resolve(), 302 | nargs='*', 303 | help="Local paths to include songs from." 304 | ) 305 | 306 | 307 | ####### 308 | # gms # 309 | ####### 310 | 311 | gms = argparse.ArgumentParser( 312 | prog='gms', 313 | description="A collection of scripts to interact with Google Music.", 314 | usage=argparse.SUPPRESS, 315 | parents=[meta], 316 | formatter_class=SubcommandHelpFormatter, 317 | add_help=False 318 | ) 319 | 320 | subcommands = gms.add_subparsers( 321 | title="Commands", 322 | dest='_command', 323 | metavar="" 324 | ) 325 | 326 | 327 | ########## 328 | # Delete # 329 | ########## 330 | 331 | delete_command = subcommands.add_parser( 332 | 'delete', 333 | aliases=['del'], 334 | description="Delete song(s) from Google Music.", 335 | help="Delete song(s) from Google Music.", 336 | formatter_class=UsageHelpFormatter, 337 | usage="gms delete [OPTIONS]", 338 | parents=[ 339 | meta, 340 | dry_run, 341 | yes, 342 | logging_, 343 | ident, 344 | mc_ident, 345 | filter_metadata, 346 | filter_dates, 347 | ], 348 | add_help=False 349 | ) 350 | delete_command.set_defaults(func=do_delete) 351 | 352 | 353 | ############ 354 | # Download # 355 | ############ 356 | 357 | download_command = subcommands.add_parser( 358 | 'download', 359 | aliases=['down'], 360 | description="Download song(s) from Google Music.", 361 | help="Download song(s) from Google Music.", 362 | formatter_class=UsageHelpFormatter, 363 | usage="gms download [OPTIONS]", 364 | parents=[ 365 | meta, 366 | dry_run, 367 | logging_, 368 | ident, 369 | mm_ident, 370 | mc_ident, 371 | local, 372 | filter_metadata, 373 | filter_dates, 374 | sync, 375 | output, 376 | include, 377 | ], 378 | add_help=False 379 | ) 380 | download_command.set_defaults(func=do_download) 381 | 382 | 383 | ######### 384 | # Quota # 385 | ######### 386 | 387 | quota_command = subcommands.add_parser( 388 | 'quota', 389 | description="Get the uploaded song count and allowance.", 390 | help="Get the uploaded song count and allowance.", 391 | formatter_class=UsageHelpFormatter, 392 | usage="gms quota [OPTIONS]", 393 | parents=[ 394 | meta, 395 | logging_, 396 | ident, 397 | mm_ident, 398 | ], 399 | add_help=False 400 | ) 401 | quota_command.set_defaults(func=do_quota) 402 | 403 | 404 | ########## 405 | # Search # 406 | ########## 407 | 408 | search_command = subcommands.add_parser( 409 | 'search', 410 | description="Search a Google Music library for songs.", 411 | help="Search for Google Music library songs.", 412 | formatter_class=UsageHelpFormatter, 413 | usage="gms search [OPTIONS]", 414 | parents=[ 415 | meta, 416 | yes, 417 | logging_, 418 | mc_ident, 419 | filter_metadata, 420 | ], 421 | add_help=False 422 | ) 423 | search_command.set_defaults(func=do_search) 424 | 425 | 426 | ########## 427 | # Upload # 428 | ########## 429 | 430 | upload_command = subcommands.add_parser( 431 | 'upload', 432 | aliases=['up'], 433 | description="Upload song(s) to Google Music.", 434 | help="Upload song(s) to Google Music.", 435 | formatter_class=UsageHelpFormatter, 436 | usage="gms upload [OPTIONS] [INCLUDE_PATH]...", 437 | parents=[ 438 | meta, 439 | dry_run, 440 | logging_, 441 | ident, 442 | mm_ident, 443 | mc_ident, 444 | local, 445 | filter_metadata, 446 | filter_dates, 447 | upload_misc, 448 | sync, 449 | include, 450 | ], 451 | add_help=False 452 | ) 453 | upload_command.set_defaults(func=do_upload) 454 | 455 | 456 | def check_args(args): 457 | if all( 458 | option in args 459 | for option in ['use_hash', 'no_use_hash'] 460 | ): 461 | raise ValueError( 462 | "Use one of --use-hash/--no-use-hash', not both." 463 | ) 464 | 465 | if all( 466 | option in args 467 | for option in ['use_metadata', 'no_use_metadata'] 468 | ): 469 | raise ValueError( 470 | "Use one of --use-metadata/--no-use-metadata', not both." 471 | ) 472 | 473 | 474 | def default_args(args): 475 | defaults = Namespace() 476 | 477 | # Set defaults. 478 | defaults.verbose = 0 479 | defaults.quiet = 0 480 | defaults.debug = False 481 | defaults.dry_run = False 482 | defaults.username = '' 483 | defaults.filters = [] 484 | 485 | if 'no_log_to_stdout' in args: 486 | defaults.log_to_stdout = False 487 | defaults.no_log_to_stdout = True 488 | else: 489 | defaults.log_to_stdout = True 490 | defaults.no_log_to_stdout = False 491 | 492 | if 'log_to_file' in args: 493 | defaults.log_to_file = True 494 | defaults.no_log_to_file = False 495 | else: 496 | defaults.log_to_file = False 497 | defaults.no_log_to_file = True 498 | 499 | if args._command in ['down', 'download', 'up', 'upload']: 500 | defaults.uploader_id = None 501 | defaults.device_id = None 502 | elif args._command in ['quota']: 503 | defaults.uploader_id = None 504 | else: 505 | defaults.device_id = None 506 | 507 | if args._command in ['down', 'download', 'up', 'upload']: 508 | defaults.no_recursion = False 509 | defaults.max_depth = math.inf 510 | defaults.exclude_paths = [] 511 | defaults.exclude_regexes = [] 512 | defaults.exclude_globs = [] 513 | 514 | if 'no_use_hash' in args: 515 | defaults.use_hash = False 516 | defaults.no_use_hash = True 517 | else: 518 | defaults.use_hash = True 519 | defaults.no_use_hash = False 520 | 521 | if 'no_use_metadata' in args: 522 | defaults.use_metadata = False 523 | defaults.no_use_metadata = True 524 | else: 525 | defaults.use_metadata = True 526 | defaults.no_use_metadata = False 527 | 528 | if args._command in ['down', 'download']: 529 | defaults.output = str(Path('.').resolve()) 530 | defaults.include = [] 531 | elif args._command in ['up', 'upload']: 532 | defaults.include = [custom_path('.').resolve()] 533 | defaults.delete_on_success = False 534 | defaults.no_sample = False 535 | defaults.album_art = None 536 | 537 | if args._command in ['del', 'delete', 'search']: 538 | defaults.yes = False 539 | 540 | config_defaults = get_defaults( 541 | args._command, 542 | read_config_file( 543 | username=args.get('username') 544 | ), 545 | command_keys=COMMAND_KEYS, 546 | command_aliases=COMMAND_ALIASES 547 | ) 548 | 549 | for k, v in config_defaults.items(): 550 | if k == 'album_art': 551 | defaults.album_art = split_album_art_paths(v) 552 | elif k == 'filters': 553 | defaults.filters = [ 554 | parse_filter(filter_) 555 | for filter_ in v 556 | ] 557 | elif k == 'max_depth': 558 | defaults.max_depth = int(v) 559 | elif k == 'output': 560 | defaults.output = str(custom_path(v)) 561 | elif k == 'include': 562 | defaults.include = [ 563 | custom_path(val) 564 | for val in v 565 | ] 566 | elif k in [ 567 | 'log_to_stdout', 568 | 'log_to_file', 569 | 'use_hash', 570 | 'use_metadata', 571 | ]: 572 | defaults[k] = v 573 | defaults[f"no_{k}"] = not v 574 | elif k in [ 575 | 'no_log_to_stdout', 576 | 'no_log_to_file', 577 | 'no_use_hash', 578 | 'no_use_metadata', 579 | ]: 580 | defaults[k] = v 581 | defaults[k.replace('no_', '')] = not v 582 | elif k.startswith(('created', 'modified')): 583 | if k.endswith('in'): 584 | defaults[k] = datetime_string_to_time_period(v, in_=True) 585 | elif k.endswith('on'): 586 | defaults[k] = datetime_string_to_time_period(v, on=True) 587 | elif k.endswith('before'): 588 | defaults[k] = datetime_string_to_time_period(v, before=True) 589 | elif k.endswith('after'): 590 | defaults[k] = datetime_string_to_time_period(v, after=True) 591 | else: 592 | defaults[k] = v 593 | 594 | return defaults 595 | 596 | 597 | def run(): 598 | warnings.simplefilter( 599 | 'ignore', 600 | category=AudioMetadataWarning, 601 | ) 602 | 603 | try: 604 | parsed = parse_args(gms) 605 | 606 | if parsed._command is None: 607 | gms.parse_args(['-h']) 608 | 609 | check_args(parsed) 610 | 611 | defaults = default_args(parsed) 612 | args = merge_defaults(defaults, parsed) 613 | 614 | if args.get('no_recursion'): 615 | args.max_depth = 0 616 | 617 | configure_logging( 618 | args.verbose - args.quiet, 619 | username=args.username, 620 | debug=args.debug, 621 | log_to_stdout=args.log_to_stdout, 622 | log_to_file=args.log_to_file 623 | ) 624 | 625 | args.func(args) 626 | 627 | logger.log('NORMAL', "All done!") 628 | except KeyboardInterrupt: 629 | gms.exit(130, "\nInterrupted by user") 630 | -------------------------------------------------------------------------------- /src/google_music_scripts/commands.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import google_music 4 | import google_music_utils as gm_utils 5 | from google_music_proto.musicmanager.utils import generate_client_id 6 | from loguru import logger 7 | from more_itertools import first_true 8 | from natsort import natsorted 9 | from tbm_utils import filter_filepaths_by_dates 10 | 11 | from .core import ( 12 | download_songs, 13 | filter_google_dates, 14 | get_google_songs, 15 | get_local_songs, 16 | upload_songs, 17 | ) 18 | from .utils import template_to_base_path 19 | 20 | 21 | def do_delete(args): 22 | logger.log('NORMAL', "Logging in to Mobile Client") 23 | mc = google_music.mobileclient(args.username, device_id=args.device_id) 24 | if not mc.is_authenticated: 25 | sys.exit("Failed to authenticate Mobile Client") 26 | 27 | to_delete = filter_google_dates( 28 | get_google_songs(mc, filters=args.filters), 29 | created_in=args.get('created_in'), 30 | created_on=args.get('created_on'), 31 | created_before=args.get('created_before'), 32 | created_after=args.get('created_after'), 33 | modified_in=args.get('modified_in'), 34 | modified_on=args.get('modified_on'), 35 | modified_before=args.get('modified_before'), 36 | modified_after=args.get('modified_after') 37 | ) 38 | 39 | logger.info("Found {} songs to delete", len(to_delete)) 40 | 41 | if not to_delete: 42 | logger.log('NORMAL', "No songs to delete") 43 | elif not args.dry_run: 44 | confirm = args.yes or input( 45 | f"\nAre you sure you want to delete {len(to_delete)} song(s) from Google Music? (y/n) " 46 | ) in ("y", "Y") 47 | 48 | if confirm: 49 | logger.log('NORMAL', "Deleting songs") 50 | 51 | song_num = 0 52 | total = len(to_delete) 53 | pad = len(str(total)) 54 | 55 | for song in to_delete: 56 | song_num += 1 57 | 58 | title = song.get('title', "") 59 | artist = song.get('artist', "") 60 | album = song.get('album', "") 61 | song_id = song['id'] 62 | 63 | logger.trace( 64 | "Deleting {} -- {} -- {} ({})", 65 | title, 66 | artist, 67 | album, 68 | song_id 69 | ) 70 | 71 | mc.songs_delete(song) 72 | 73 | logger.info( 74 | "Deleted {:>{}}/{}", 75 | song_num, 76 | pad, 77 | total 78 | ) 79 | else: 80 | logger.info("No songs deleted") 81 | elif logger._core.min_level <= 15: 82 | for song in to_delete: 83 | title = song.get('title', "") 84 | artist = song.get('artist', "") 85 | album = song.get('album', "") 86 | song_id = song['id'] 87 | 88 | logger.log( 89 | 'ACTION_SUCCESS', 90 | "{} -- {} -- {} ({})", 91 | title, 92 | artist, 93 | album, 94 | song_id 95 | ) 96 | 97 | 98 | def do_download(args): 99 | logger.log('NORMAL', "Logging in to Music Manager") 100 | mm = google_music.musicmanager(args.username, uploader_id=args.uploader_id) 101 | if not mm.is_authenticated: 102 | sys.exit("Failed to authenticate Music Manager") 103 | 104 | logger.log('NORMAL', "Logging in to Mobile Client") 105 | mc = google_music.mobileclient(args.username, device_id=args.device_id) 106 | if not mc.is_authenticated: 107 | sys.exit("Failed to authenticate Mobile Client") 108 | 109 | google_songs = get_google_songs(mm, filters=args.filters) 110 | base_path = template_to_base_path(args.output, google_songs) 111 | filepaths = [base_path, *args.include] 112 | 113 | mc_songs = get_google_songs(mc, filters=args.filters) 114 | 115 | creation_dates = [ 116 | args[option] 117 | for option in [ 118 | 'created_in', 119 | 'created_on', 120 | 'created_before', 121 | 'created_after', 122 | ] 123 | if option in args 124 | ] 125 | 126 | modification_dates = [ 127 | args[option] 128 | for option in [ 129 | 'modified_in', 130 | 'modified_on', 131 | 'modified_before', 132 | 'modified_after', 133 | ] 134 | if option in args 135 | ] 136 | 137 | mc_songs = filter_google_dates( 138 | mc_songs, 139 | creation_dates=creation_dates, 140 | modification_dates=modification_dates, 141 | ) 142 | 143 | local_songs = get_local_songs( 144 | filepaths, 145 | filters=args.filters, 146 | max_depth=args.max_depth, 147 | exclude_paths=args.exclude_paths, 148 | exclude_regexes=args.exclude_regexes, 149 | exclude_globs=args.exclude_globs 150 | ) 151 | 152 | missing_songs = [] 153 | existing_songs = [] 154 | if args.use_hash: 155 | if google_songs and local_songs: 156 | logger.log('NORMAL', "Comparing hashes") 157 | 158 | google_client_id_map = { 159 | mc_song.get('clientId'): mc_song 160 | for mc_song in mc_songs 161 | } 162 | local_client_ids = {generate_client_id(song) for song in local_songs} 163 | for client_id, mc_song in google_client_id_map.items(): 164 | song = first_true( 165 | (song for song in google_songs), 166 | pred=lambda song: song.get('id') == mc_song.get('id') 167 | ) 168 | 169 | if song is not None: 170 | if client_id not in local_client_ids: 171 | missing_songs.append(song) 172 | else: 173 | existing_songs.append(song) 174 | 175 | logger.info("Found {} songs already exist by audio hash", len(existing_songs)) 176 | 177 | if logger._core.min_level <= 5: 178 | for song in existing_songs: 179 | title = song.get('title', "") 180 | artist = song.get('artist', "<artist>") 181 | album = song.get('album', "<album>") 182 | song_id = song['id'] 183 | 184 | logger.trace( 185 | "{} -- {} -- {} ({})", 186 | title, 187 | artist, 188 | album, 189 | song_id 190 | ) 191 | else: 192 | missing_songs = google_songs 193 | 194 | if not google_songs and not local_songs: 195 | logger.log('NORMAL', "No songs to compare hashes.") 196 | elif not google_songs: 197 | logger.log('NORMAL', "No Google songs to compare hashes.") 198 | elif not local_songs: 199 | logger.log('NORMAL', "No local songs to compare hashes.") 200 | 201 | if args.use_metadata: 202 | if args.use_hash: 203 | google_songs = missing_songs 204 | 205 | if google_songs and local_songs: 206 | logger.log('NORMAL', "Comparing metadata") 207 | 208 | missing_songs = natsorted( 209 | gm_utils.find_missing_items( 210 | google_songs, 211 | local_songs, 212 | fields=['artist', 'album', 'title', 'tracknumber'], 213 | normalize_values=True 214 | ) 215 | ) 216 | 217 | existing_songs = natsorted( 218 | gm_utils.find_existing_items( 219 | google_songs, 220 | local_songs, 221 | fields=['artist', 'album', 'title', 'tracknumber'], 222 | normalize_values=True 223 | ) 224 | ) 225 | 226 | logger.info( 227 | "Found {} songs already exist by metadata", 228 | len(existing_songs) 229 | ) 230 | 231 | if logger._core.min_level <= 5: 232 | for song in existing_songs: 233 | title = song.get('title', "<title>") 234 | artist = song.get('artist', "<artist>") 235 | album = song.get('album', "<album>") 236 | song_id = song['id'] 237 | 238 | logger.trace( 239 | "{} -- {} -- {} ({})", 240 | title, 241 | artist, 242 | album, 243 | song_id 244 | ) 245 | else: 246 | if not google_songs and not local_songs: 247 | logger.log('NORMAL', "No songs to compare metadata.") 248 | elif not google_songs: 249 | logger.log('NORMAL', "No Google songs to compare metadata.") 250 | elif not local_songs: 251 | logger.log('NORMAL', "No local songs to compare metadata.") 252 | 253 | if not args.use_hash and not args.use_metadata: 254 | missing_songs = google_songs 255 | 256 | logger.log('NORMAL', "Sorting songs") 257 | 258 | to_download = natsorted(missing_songs) 259 | 260 | logger.info("Found {} songs to download", len(to_download)) 261 | 262 | if not args.dry_run: 263 | download_songs(mm, to_download, template=args.output) 264 | elif logger._core.min_level <= 15: 265 | for song in to_download: 266 | title = song.get('title', "<title>") 267 | artist = song.get('artist', "<artist>") 268 | album = song.get('album', "<album>") 269 | song_id = song['id'] 270 | 271 | logger.log( 272 | 'ACTION_SUCCESS', 273 | "{} -- {} -- {} ({})", 274 | title, 275 | artist, 276 | album, 277 | song_id 278 | ) 279 | 280 | 281 | def do_quota(args): 282 | logger.log('NORMAL', "Logging in to Music Manager") 283 | mm = google_music.musicmanager(args.username, uploader_id=args.uploader_id) 284 | if not mm.is_authenticated: 285 | sys.exit("Failed to authenticate Music Manager") 286 | 287 | uploaded, allowed = mm.quota() 288 | 289 | logger.log( 290 | 'NORMAL', 291 | "Quota -- {}/{} ({:.2%})", 292 | uploaded, 293 | allowed, 294 | uploaded / allowed 295 | ) 296 | 297 | 298 | def do_search(args): 299 | logger.log('NORMAL', "Logging in to Mobile Client") 300 | mc = google_music.mobileclient(args.username, device_id=args.device_id) 301 | if not mc.is_authenticated: 302 | sys.exit("Failed to authenticate Mobile Client") 303 | 304 | search_results = get_google_songs(mc, filters=args.filters) 305 | 306 | creation_dates = [ 307 | args[option] 308 | for option in [ 309 | 'created_in', 310 | 'created_on', 311 | 'created_before', 312 | 'created_after', 313 | ] 314 | if option in args 315 | ] 316 | 317 | modification_dates = [ 318 | args[option] 319 | for option in [ 320 | 'modified_in', 321 | 'modified_on', 322 | 'modified_before', 323 | 'modified_after', 324 | ] 325 | if option in args 326 | ] 327 | 328 | search_results = filter_google_dates( 329 | search_results, 330 | creation_dates=creation_dates, 331 | modification_dates=modification_dates, 332 | ) 333 | 334 | search_results = natsorted( 335 | search_results, 336 | key=lambda song: ( 337 | song.get('artist', ''), 338 | song.get('album', ''), 339 | song.get('trackNumber', 0) 340 | ) 341 | ) 342 | 343 | if search_results: 344 | result_num = 0 345 | total = len(search_results) 346 | pad = len(str(total)) 347 | 348 | confirm = ( 349 | args.yes 350 | or input( 351 | f"\nDisplay {len(search_results)} results? (y/n) " 352 | ) in ("y", "Y") 353 | ) 354 | 355 | if confirm: 356 | for result in search_results: 357 | result_num += 1 358 | 359 | title = result.get('title', "<empty>") 360 | artist = result.get('artist', "<empty>") 361 | album = result.get('album', "<empty>") 362 | song_id = result['id'] 363 | 364 | logger.log( 365 | 'NORMAL', 366 | "{:>{}}/{} {} -- {} -- {} ({})", 367 | result_num, 368 | pad, 369 | total, 370 | title, 371 | artist, 372 | album, 373 | song_id 374 | ) 375 | else: 376 | logger.log('NORMAL', "No songs found matching query") 377 | 378 | 379 | def do_upload(args): 380 | logger.log('NORMAL', "Logging in to Music Manager") 381 | mm = google_music.musicmanager(args.username, uploader_id=args.uploader_id) 382 | if not mm.is_authenticated: 383 | sys.exit("Failed to authenticate Music Manager") 384 | 385 | logger.log('NORMAL', "Logging in to Mobile Client") 386 | mc = google_music.mobileclient(args.username, device_id=args.device_id) 387 | if not mc.is_authenticated: 388 | sys.exit("Failed to authenticate Mobile Client") 389 | 390 | local_songs = get_local_songs( 391 | args.include, 392 | filters=args.filters, 393 | max_depth=args.max_depth, 394 | exclude_paths=args.exclude_paths, 395 | exclude_regexes=args.exclude_regexes, 396 | exclude_globs=args.exclude_globs 397 | ) 398 | 399 | creation_dates = [ 400 | args[option] 401 | for option in [ 402 | 'created_in', 403 | 'created_on', 404 | 'created_before', 405 | 'created_after', 406 | ] 407 | if option in args 408 | ] 409 | 410 | modification_dates = [ 411 | args[option] 412 | for option in [ 413 | 'modified_in', 414 | 'modified_on', 415 | 'modified_before', 416 | 'modified_after', 417 | ] 418 | if option in args 419 | ] 420 | 421 | local_songs = filter_filepaths_by_dates( 422 | local_songs, 423 | creation_dates=creation_dates, 424 | modification_dates=modification_dates, 425 | ) 426 | 427 | missing_songs = [] 428 | if args.use_hash: 429 | logger.log('NORMAL', "Comparing hashes") 430 | 431 | existing_songs = [] 432 | google_client_ids = {song.get('clientId', '') for song in get_google_songs(mc)} 433 | for song in local_songs: 434 | if generate_client_id(song) not in google_client_ids: 435 | missing_songs.append(song) 436 | else: 437 | existing_songs.append(song) 438 | 439 | logger.info("Found {} songs already exist by audio hash", len(existing_songs)) 440 | 441 | if logger._core.min_level <= 5: 442 | for song in natsorted(existing_songs): 443 | logger.trace(song) 444 | 445 | if args.use_metadata: 446 | if args.use_hash: 447 | local_songs = missing_songs 448 | 449 | if local_songs: 450 | logger.log('NORMAL', "Comparing metadata") 451 | 452 | google_songs = get_google_songs(mm, filters=args.filters) 453 | 454 | missing_songs = natsorted( 455 | gm_utils.find_missing_items( 456 | local_songs, 457 | google_songs, 458 | fields=['artist', 'album', 'title', 'tracknumber'], 459 | normalize_values=True 460 | ) 461 | ) 462 | 463 | existing_songs = natsorted( 464 | gm_utils.find_existing_items( 465 | local_songs, 466 | google_songs, 467 | fields=['artist', 'album', 'title', 'tracknumber'], 468 | normalize_values=True 469 | ) 470 | ) 471 | 472 | logger.info("Found {} songs already exist by metadata", len(existing_songs)) 473 | 474 | if logger._core.min_level <= 5: 475 | for song in existing_songs: 476 | logger.trace(song) 477 | 478 | if not args.use_hash and not args.use_metadata: 479 | missing_songs = local_songs 480 | 481 | logger.log('NORMAL', "Sorting songs") 482 | 483 | to_upload = natsorted(missing_songs) 484 | 485 | logger.info("Found {} songs to upload", len(to_upload)) 486 | 487 | if not args.dry_run: 488 | upload_songs( 489 | mm, 490 | to_upload, 491 | album_art=args.album_art, 492 | no_sample=args.no_sample, 493 | delete_on_success=args.delete_on_success 494 | ) 495 | elif logger._core.min_level <= 15: 496 | for song in to_upload: 497 | logger.log( 498 | 'ACTION_SUCCESS', 499 | song 500 | ) 501 | -------------------------------------------------------------------------------- /src/google_music_scripts/config.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | from pathlib import Path 4 | 5 | import appdirs 6 | from loguru import logger 7 | from tomlkit.toml_document import TOMLDocument 8 | from tomlkit.toml_file import TOMLFile 9 | 10 | from .__about__ import __author__, __title__ 11 | 12 | CONFIG_BASE_PATH = Path(appdirs.user_config_dir(__title__, __author__)) 13 | 14 | LOG_BASE_PATH = Path(appdirs.user_data_dir(__title__, __author__)) 15 | LOG_FORMAT = '<lvl>[{time:YYYY-MM-DD HH:mm:ss}]</lvl> {message}' 16 | LOG_DEBUG_FORMAT = LOG_FORMAT 17 | 18 | logger.level('NORMAL', no=25, color="<green>") 19 | logger.level('INFO', no=20, color="<green><bold>") 20 | logger.level('ACTION_FAILURE', no=16, color="<red>") 21 | logger.level('ACTION_SUCCESS', no=15, color="<cyan>") 22 | 23 | VERBOSITY_LOG_LEVELS = { 24 | 0: 50, 25 | 1: 40, 26 | 2: 30, 27 | 3: 25, 28 | 4: 20, 29 | 5: 16, 30 | 6: 15, 31 | 7: 10, 32 | 8: 5, 33 | } 34 | 35 | 36 | def read_config_file(username=None): 37 | config_path = CONFIG_BASE_PATH / (username or '') / 'google-music-scripts.toml' 38 | config_file = TOMLFile(config_path) 39 | 40 | try: 41 | config = config_file.read() 42 | except FileNotFoundError: 43 | config = TOMLDocument() 44 | 45 | write_config_file(config, username=username) 46 | 47 | return config 48 | 49 | 50 | def write_config_file(config, username=None): 51 | config_path = CONFIG_BASE_PATH / (username or '') / 'google-music-scripts.toml' 52 | config_path.parent.mkdir(parents=True, exist_ok=True) 53 | config_path.touch() 54 | 55 | config_file = TOMLFile(config_path) 56 | config_file.write(config) 57 | 58 | 59 | def ensure_log_dir(username=None): 60 | log_dir = LOG_BASE_PATH / (username or '') / 'logs' 61 | log_dir.mkdir(parents=True, exist_ok=True) 62 | 63 | return log_dir 64 | 65 | 66 | def configure_logging( 67 | modifier=0, 68 | *, 69 | username=None, 70 | debug=False, 71 | log_to_stdout=True, 72 | log_to_file=False 73 | ): 74 | logger.remove() 75 | 76 | if debug: 77 | logger.enable('audio_metadata') 78 | logger.enable('google_music') 79 | logger.enable('google_music-proto') 80 | logger.enable('google_music_utils') 81 | 82 | verbosity = 3 + modifier 83 | 84 | if verbosity < 0: 85 | verbosity = 0 86 | elif verbosity > 8: 87 | verbosity = 8 88 | 89 | log_level = VERBOSITY_LOG_LEVELS[verbosity] 90 | 91 | if log_to_stdout: 92 | logger.add( 93 | sys.stdout, 94 | level=log_level, 95 | format=LOG_FORMAT, 96 | backtrace=False 97 | ) 98 | 99 | if log_to_file: 100 | log_dir = ensure_log_dir(username=username) 101 | log_file = (log_dir / time.strftime('%Y-%m-%d_%H-%M-%S')).with_suffix('.log') 102 | 103 | logger.success("Logging to file: {}", log_file) 104 | 105 | logger.add( 106 | log_file, 107 | level=log_level, 108 | format=LOG_FORMAT, 109 | backtrace=False, 110 | encoding='utf8', 111 | newline='\n' 112 | ) 113 | -------------------------------------------------------------------------------- /src/google_music_scripts/constants.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | 'CHARACTER_REPLACEMENTS', 3 | 'TEMPLATE_PATTERNS' 4 | ] 5 | 6 | from google_music_utils import CHARACTER_REPLACEMENTS, TEMPLATE_PATTERNS 7 | -------------------------------------------------------------------------------- /src/google_music_scripts/core.py: -------------------------------------------------------------------------------- 1 | import math 2 | from collections import defaultdict 3 | from pathlib import Path 4 | 5 | import audio_metadata 6 | import google_music_utils as gm_utils 7 | import pendulum 8 | from loguru import logger 9 | from tbm_utils import get_filepaths 10 | 11 | from .utils import get_album_art_path 12 | 13 | 14 | def download_songs(mm, songs, template=None): 15 | if not songs: 16 | logger.log('NORMAL', "No songs to download") 17 | else: 18 | logger.log('NORMAL', "Downloading songs from Google Music") 19 | 20 | if not template: 21 | template = Path.cwd() 22 | 23 | songnum = 0 24 | total = len(songs) 25 | pad = len(str(total)) 26 | 27 | for song in songs: 28 | songnum += 1 29 | 30 | logger.trace( 31 | "Downloading -- {} - {} - {} ({})", 32 | song.get('title', "<title>"), 33 | song.get('artist', "<artist>"), 34 | song.get('album', "<album>"), 35 | song['id'] 36 | ) 37 | 38 | try: 39 | audio, _ = mm.download(song) 40 | except Exception as e: # TODO: More specific exception. 41 | logger.log( 42 | 'ACTION_FAILURE', 43 | "({:>{}}/{}) Failed -- {} | {}", 44 | songnum, 45 | pad, 46 | total, 47 | song, 48 | e 49 | ) 50 | else: 51 | try: 52 | tags = audio_metadata.loads(audio).tags 53 | except audio_metadata.AudioMetadataException as e: 54 | logger.log( 55 | 'ACTION_FAILURE', 56 | "({:>{}}/{}) Failed -- {} | {}", 57 | songnum, 58 | pad, 59 | total, 60 | song, 61 | e 62 | ) 63 | else: 64 | filepath = gm_utils.template_to_filepath(template, tags).with_suffix('.mp3') 65 | if filepath.is_file(): 66 | filepath.unlink() 67 | 68 | filepath.parent.mkdir(parents=True, exist_ok=True) 69 | filepath.touch() 70 | filepath.write_bytes(audio) 71 | 72 | logger.log( 73 | 'ACTION_SUCCESS', 74 | "({:>{}}/{}) Downloaded -- {} ({})", 75 | songnum, 76 | pad, 77 | total, 78 | filepath, 79 | song['id'] 80 | ) 81 | 82 | 83 | def filter_google_dates( 84 | songs, 85 | *, 86 | creation_dates=None, 87 | modification_dates=None, 88 | ): 89 | matched_songs = songs 90 | 91 | def _dt_from_gm_timestamp(gm_timestamp): 92 | return pendulum.from_timestamp(gm_utils.from_gm_timestamp(gm_timestamp)) 93 | 94 | def _match_created_date(songs, period): 95 | return ( 96 | song 97 | for song in songs 98 | if _dt_from_gm_timestamp(song['creationTimestamp']) in period 99 | ) 100 | 101 | def _match_modified_date(songs, period): 102 | return ( 103 | song 104 | for song in songs 105 | if _dt_from_gm_timestamp(song['lastModifiedTimestamp']) in period 106 | ) 107 | 108 | if creation_dates: 109 | for period in creation_dates: 110 | matched_songs = _match_created_date(matched_songs, period) 111 | 112 | if modification_dates: 113 | for period in modification_dates: 114 | matched_songs = _match_modified_date(matched_songs, period) 115 | 116 | return list(matched_songs) 117 | 118 | 119 | def filter_metadata(songs, filters): 120 | if filters: 121 | logger.log('NORMAL', "Filtering songs by metadata") 122 | matched_songs = [] 123 | 124 | for filter_ in filters: 125 | include_filters = defaultdict(list) 126 | exclude_filters = defaultdict(list) 127 | 128 | for condition in filter_: 129 | if condition.oper == '+': 130 | include_filters[condition.field].append(condition.pattern) 131 | elif condition.oper == '-': 132 | exclude_filters[condition.field].append(condition.pattern) 133 | 134 | matched = songs 135 | 136 | # Use all if multiple conditions for inclusion. 137 | i_use_all = ( 138 | (len(include_filters) > 1) 139 | or any( 140 | len(v) > 1 141 | for v in include_filters.values() 142 | ) 143 | ) 144 | i_any_all = all if i_use_all else any 145 | matched = gm_utils.include_items( 146 | matched, any_all=i_any_all, ignore_case=True, **include_filters 147 | ) 148 | 149 | # Use any if multiple conditions for exclusion. 150 | e_use_all = not ( 151 | (len(exclude_filters) > 1) 152 | or any( 153 | len(v) > 1 154 | for v in exclude_filters.values() 155 | ) 156 | ) 157 | e_any_all = all if e_use_all else any 158 | matched = gm_utils.exclude_items( 159 | matched, any_all=e_any_all, ignore_case=True, **exclude_filters 160 | ) 161 | 162 | for song in matched: 163 | if song not in matched_songs: 164 | matched_songs.append(song) 165 | 166 | logger.info("Filtered {} songs by metadata", len(songs) - len(matched_songs)) 167 | else: 168 | matched_songs = songs 169 | 170 | return matched_songs 171 | 172 | 173 | def get_google_songs(client, *, filters=None): 174 | logger.log('NORMAL', "Loading Google songs with {}", client.__class__.__name__) 175 | 176 | google_songs = client.songs() 177 | 178 | logger.info( 179 | "Found {} Google songs with {}", len(google_songs), client.__class__.__name__ 180 | ) 181 | 182 | matched_songs = filter_metadata(google_songs, filters) 183 | 184 | return matched_songs 185 | 186 | 187 | def get_local_songs( 188 | paths, 189 | *, 190 | filters=None, 191 | max_depth=math.inf, 192 | exclude_paths=None, 193 | exclude_regexes=None, 194 | exclude_globs=None 195 | ): 196 | logger.log('NORMAL', "Loading local songs") 197 | 198 | local_songs = [ 199 | filepath 200 | for filepath in get_filepaths( 201 | paths, 202 | max_depth=max_depth, 203 | exclude_paths=exclude_paths, 204 | exclude_regexes=exclude_regexes, 205 | exclude_globs=exclude_globs 206 | ) 207 | if audio_metadata.determine_format(filepath) in [ 208 | audio_metadata.FLAC, 209 | audio_metadata.MP3, 210 | audio_metadata.OggOpus, 211 | audio_metadata.OggVorbis, 212 | audio_metadata.WAVE, 213 | ] 214 | ] 215 | 216 | logger.info("Found {} local songs", len(local_songs)) 217 | 218 | matched_songs = filter_metadata(local_songs, filters) 219 | 220 | return matched_songs 221 | 222 | 223 | def upload_songs( 224 | mm, 225 | filepaths, 226 | *, 227 | album_art=None, 228 | no_sample=False, 229 | delete_on_success=False 230 | ): 231 | if not filepaths: 232 | logger.log('NORMAL', "No songs to upload") 233 | else: 234 | logger.log('NORMAL', "Uploading songs") 235 | 236 | filenum = 0 237 | total = len(filepaths) 238 | pad = len(str(total)) 239 | 240 | for song in filepaths: 241 | filenum += 1 242 | 243 | logger.trace( 244 | "Uploading -- {}", 245 | song 246 | ) 247 | 248 | album_art_path = get_album_art_path(song, album_art) 249 | 250 | try: 251 | result = mm.upload( 252 | song, 253 | album_art_path=album_art_path, 254 | no_sample=no_sample 255 | ) 256 | except Exception as e: # TODO: More specific exception. 257 | result = { 258 | 'filepath': song, 259 | 'success': False, 260 | 'reason': e, 261 | } 262 | 263 | if logger._core.min_level <= 15: 264 | if result['reason'] == 'Uploaded': 265 | logger.log( 266 | 'ACTION_SUCCESS', 267 | "({:>{}}/{}) Uploaded -- {} ({})", 268 | filenum, 269 | pad, 270 | total, 271 | result['filepath'], 272 | result['song_id'] 273 | ) 274 | elif result['reason'] == 'Matched': 275 | logger.log( 276 | 'ACTION_SUCCESS', 277 | "({:>{}}/{}) Matched -- {} ({})", 278 | filenum, 279 | pad, 280 | total, 281 | result['filepath'], 282 | result['song_id'] 283 | ) 284 | else: 285 | if 'song_id' in result: 286 | logger.log( 287 | 'ACTION_SUCCESS', 288 | "({:>{}}/{}) Already exists -- {} ({})", 289 | filenum, 290 | pad, 291 | total, 292 | result['filepath'], 293 | result['song_id'] 294 | ) 295 | else: 296 | logger.log( 297 | 'ACTION_FAILURE', 298 | "({:>{}}/{}) Failed -- {} | {}", 299 | filenum, 300 | pad, 301 | total, 302 | result['filepath'], 303 | result['reason'] 304 | ) 305 | 306 | if delete_on_success and 'song_id' in result: 307 | try: 308 | result['filepath'].unlink() 309 | except Exception: 310 | logger.warning( 311 | "Failed to remove {} after successful upload", result['filepath'] 312 | ) 313 | -------------------------------------------------------------------------------- /src/google_music_scripts/utils.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | 'get_album_art_path', 3 | 'template_to_base_path', 4 | ] 5 | 6 | import os 7 | from pathlib import Path 8 | 9 | import google_music_utils as gm_utils 10 | 11 | 12 | def get_album_art_path(song, album_art_paths): 13 | album_art_path = None 14 | if album_art_paths: 15 | for path in album_art_paths: 16 | if ( 17 | path.is_absolute() 18 | and path.is_file() 19 | ): 20 | album_art_path = path 21 | break 22 | else: 23 | path = song.parent / path 24 | if path.is_file(): 25 | album_art_path = path 26 | break 27 | 28 | return album_art_path 29 | 30 | 31 | def template_to_base_path(template, google_songs): 32 | """Get base output path for a list of songs for download.""" 33 | 34 | path = Path(template) 35 | 36 | if ( 37 | ( 38 | path == Path.cwd() 39 | or path == Path() 40 | ) 41 | or path == Path('%suggested%') 42 | ): 43 | base_path = Path.cwd() 44 | else: 45 | song_paths = [ 46 | gm_utils.template_to_filepath(template, song) 47 | for song in google_songs 48 | ] 49 | if song_paths: 50 | base_path = Path(os.path.commonpath(song_paths)) 51 | else: 52 | base_path = Path.cwd() 53 | 54 | return base_path.resolve() 55 | --------------------------------------------------------------------------------