├── .github └── FUNDING.yml ├── .gitignore ├── .travis.yml ├── CHANGES.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── advanced-usage.rst │ ├── api.rst │ ├── available-options.rst │ ├── badges.rst │ ├── conf.py │ ├── download-tracks.rst │ ├── faq.rst │ ├── home.rst │ ├── index.rst │ └── installation.rst ├── setup.cfg ├── setup.py └── spotdl ├── __init__.py ├── authorize ├── __init__.py ├── authorize_base.py ├── exceptions.py ├── services │ ├── __init__.py │ ├── spotify.py │ └── tests │ │ └── test_spotify.py └── tests │ ├── test_authorize_base.py │ └── test_authorize_exceptions.py ├── command_line ├── __init__.py ├── __main__.py ├── arguments.py ├── core.py ├── exceptions.py ├── exitcodes.py └── tests │ └── test_arguments.py ├── config.py ├── encode ├── __init__.py ├── encode_base.py ├── encoders │ ├── __init__.py │ ├── ffmpeg.py │ └── tests │ │ ├── __init__.py │ │ └── test_ffmpeg.py ├── exceptions.py └── tests │ ├── __init__.py │ ├── test_encode_base.py │ └── test_encode_exceptions.py ├── helpers ├── __init__.py ├── exceptions.py └── spotify.py ├── lyrics ├── __init__.py ├── exceptions.py ├── lyric_base.py ├── providers │ ├── __init__.py │ ├── genius.py │ ├── lyricwikia_wrapper.py │ └── tests │ │ ├── __init__.py │ │ ├── test_genius.py │ │ └── test_lyricwikia_wrapper.py └── tests │ ├── test_lyric_base.py │ └── test_lyrics_exceptions.py ├── metadata ├── __init__.py ├── embedder_base.py ├── embedders │ ├── __init__.py │ ├── default_embedder.py │ └── tests │ │ └── test_default_embedder.py ├── exceptions.py ├── formatter.py ├── provider_base.py ├── providers │ ├── __init__.py │ ├── spotify.py │ ├── tests │ │ ├── __init__.py │ │ ├── data │ │ │ ├── streams.dump │ │ │ ├── youtube_no_search_results.html.test │ │ │ └── youtube_search_results.html.test │ │ ├── test_spotify.py │ │ └── test_youtube.py │ └── youtube.py └── tests │ ├── __init__.py │ ├── test_embedder_base.py │ ├── test_metadata_exceptions.py │ └── test_provider_base.py ├── metadata_search.py ├── tests ├── test_config.py └── test_util.py ├── track.py ├── util.py └── version.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: ritiek 2 | custom: ['https://paypal.me/ritiek'] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Spotdl generated files 2 | *.m4a 3 | *.webm 4 | *.mp3 5 | *.opus 6 | *.flac 7 | *.temp 8 | config.yml 9 | Music/ 10 | *.txt 11 | *.m3u 12 | .cache-* 13 | docs/build/ 14 | docs/source/api 15 | .pytest_cache/ 16 | 17 | 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | *.egg-info/ 41 | .installed.cfg 42 | *.egg 43 | MANIFEST 44 | 45 | # PyInstaller 46 | # Usually these files are written by a python script from a template 47 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 48 | *.manifest 49 | *.spec 50 | 51 | # Installer logs 52 | pip-log.txt 53 | pip-delete-this-directory.txt 54 | 55 | # Unit test / coverage reports 56 | htmlcov/ 57 | .tox/ 58 | .coverage 59 | .coverage.* 60 | .cache 61 | nosetests.xml 62 | coverage.xml 63 | *.cover 64 | .hypothesis/ 65 | 66 | # Translations 67 | *.mo 68 | *.pot 69 | 70 | # Django stuff: 71 | *.log 72 | .static_storage/ 73 | .media/ 74 | local_settings.py 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # celery beat schedule file 96 | celerybeat-schedule 97 | 98 | # SageMath parsed files 99 | *.sage.py 100 | 101 | # Environments 102 | .env 103 | .venv 104 | env/ 105 | venv/ 106 | ENV/ 107 | env.bak/ 108 | venv.bak/ 109 | 110 | # Spyder project settings 111 | .spyderproject 112 | .spyproject 113 | 114 | # Rope project settings 115 | .ropeproject 116 | 117 | # mkdocs documentation 118 | /site 119 | 120 | # mypy 121 | .mypy_cache/ 122 | 123 | # vscode 124 | .vscode 125 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | - "3.7" 5 | - "3.8" 6 | before_install: 7 | - pip install tinydownload 8 | - pip install "pytest>=5.4.1" 9 | - pip install "pytest-cov>=2.8.1" 10 | addons: 11 | apt: 12 | packages: 13 | - xdg-user-dirs 14 | - automake 15 | - autoconf 16 | - build-essential 17 | - libass-dev 18 | - libfreetype6-dev 19 | - libtheora-dev 20 | - libtool 21 | - libva-dev 22 | - libvdpau-dev 23 | - libvorbis-dev 24 | - libxcb1-dev 25 | - libxcb-shm0-dev 26 | - libxcb-xfixes0-dev 27 | - libfdk-aac-dev 28 | - libopus-dev 29 | - pkg-config 30 | - texinfo 31 | - zlib1g-dev 32 | - yasm 33 | - nasm 34 | - libmp3lame-dev 35 | - libav-tools 36 | install: 37 | - pip install -e . 38 | - tinydownload 07426048687547254773 -o ~/bin/ffmpeg 39 | - chmod 755 ~/bin/ffmpeg 40 | - xdg-user-dirs-update 41 | script: travis_retry pytest --cov=. 42 | after_success: 43 | - pip install codecov 44 | - codecov 45 | -------------------------------------------------------------------------------- /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, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | 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 owner at ritiekmalhotra123@gmail.com. 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 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | - Want to contribute to [spotify-downloader](https://github.com/ritiek/spotify-downloader)? 4 | That's great. We are happy to have you! 5 | - Here is a basic outline on opening issues and making PRs: 6 | 7 | ## Opening Issues 8 | 9 | - Search for your problem in the 10 | [issues section](https://github.com/ritiek/spotify-downloader/issues) 11 | before opening a new ticket. It might be already answered and save both you and us time. :smile: 12 | - Provide as much information as possible when opening your ticket, including any relevant examples (if any). 13 | - If your issue is a *bug*, make sure you pass `--log-level=DEBUG` when invoking 14 | `spotdl.py` and paste the output in your issue. 15 | - If you think your question is naive or something and you can't find anything related, 16 | don't feel bad. Open an issue any way! 17 | 18 | ## Making Pull Requests 19 | 20 | - Look up for open issues and see if you can help out there. 21 | - Easy issues for newcomers are usually labelled as 22 | [good-first-issue](https://github.com/ritiek/spotify-downloader/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). 23 | - When making a PR, point it to the [master branch](https://github.com/ritiek/spotify-downloader/tree/master) 24 | unless mentioned otherwise. 25 | - Code should be formatted using [black](https://github.com/ambv/black). Don't worry if you forgot or don't know how to do this, the codebase will be black-formatted with each release. 26 | - All tests are placed in the [test directory](https://github.com/ritiek/spotify-downloader/tree/master/test). We use [pytest](https://github.com/pytest-dev/pytest) 27 | to run the test suite: `$ pytest`. 28 | If you don't have pytest, you can install it with `$ pip3 install pytest`. 29 | - Add a note about the changes, your GitHub username and a reference to the PR to the `Unreleased` section of the [`CHANGES.md`](CHANGES.md) file (see existing releases for examples), add the appropriate section ("Added", "Changed", "Fixed" etc.) if necessary. You don't have to increment version numbers. See https://keepachangelog.com/en/1.0.0/ for more information. 30 | - If you are planning to work on something big, let us know through an issue. So we can discuss more about it. 31 | - Lastly, please don't hesitate to ask if you have any questions! 32 | Let us know (through an issue) if you are facing any trouble making a PR, we'd be glad to help you out! 33 | 34 | ## Related Resources 35 | 36 | - There's also a web-based front-end to operate this tool, which under (major) construction 37 | called [spotifube](https://github.com/linusg/spotifube). 38 | Check it out if you'd like to contribute to it! 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-alpine 2 | 3 | RUN apk add --no-cache \ 4 | ffmpeg 5 | 6 | ADD spotdl/ /spotify-downloader/spotdl 7 | ADD setup.py /spotify-downloader/setup.py 8 | ADD README.md /spotify-downloader/README.md 9 | 10 | WORKDIR /spotify-downloader 11 | RUN pip install . 12 | 13 | RUN mkdir /music 14 | WORKDIR /music 15 | 16 | ENTRYPOINT ["spotdl"] 17 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | 14 | #### What is the purpose of your *issue*? 15 | - [ ] Bug 16 | - [ ] Feature Request 17 | - [ ] Question 18 | - [ ] Other 19 | 20 | ### Description 21 | 22 | 23 | 24 | ### Log 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2018 Ritiek Malhotra 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spotify-Downloader 2 | 3 | **CHECK OUT THE LATEST SPOTDL ON https://github.com/spotDL/spotify-downloader** 4 | 5 | **THIS REPOSITORY IS FOR THE OUTDATED V2 AND SHOULD NOT BE USED.** 6 | 7 | ------------------------------------------------- 8 | 9 | **Install the last v2 release using: `$ pip3 install git+https://github.com/ritiek/spotify-downloader.git` (do not install 10 | from PyPI or any other way even if mentioned in the docs later here, you'll probably end up with v3 that way) and read 11 | below.** 12 | 13 | [![PyPi](https://img.shields.io/pypi/v/spotdl.svg)](https://pypi.org/project/spotdl/2.2.2/) 14 | [![Docs Build Status](https://readthedocs.org/projects/spotdl/badge/?version=v2.2.2)](https://spotdl.readthedocs.io/en/v2.2.2/) 15 | [![Build Status](https://travis-ci.org/ritiek/spotify-downloader.svg?branch=master)](https://travis-ci.org/ritiek/spotify-downloader) 16 | [![Coverage Status](https://codecov.io/gh/ritiek/spotify-downloader/branch/master/graph/badge.svg)](https://codecov.io/gh/ritiek/spotify-downloader) 17 | [![Docker Build Status](https://img.shields.io/docker/build/ritiek/spotify-downloader.svg)](https://hub.docker.com/r/ritiek/spotify-downloader) 18 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 19 | [![Gitter Chat](https://badges.gitter.im/ritiek/spotify-downloader/Lobby.svg)](https://gitter.im/spotify-downloader/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 20 | 21 | - Downloads songs from YouTube in an MP3 format by using Spotify's HTTP link. 22 | - Can also download a song by entering its artist and song name (in case if you don't have the Spotify's HTTP link for some song). 23 | - Automatically applies metadata to the downloaded song which includes: 24 | 25 | - `Title`, `Artist`, `Album`, `Album art`, `Lyrics` (if found either on [Genius](https://genius.com/)), `Album artist`, `Genre`, `Track number`, `Disc number`, `Release date`, and more... 26 | 27 | - Works straight out of the box and does not require you to generate or mess with your API keys (already included). 28 | 29 | Below is how your music library will look! 30 | 31 | 32 | 33 | ## Installation 34 | 35 | ❗️ **This tool works only with Python 3.6+** 36 | 37 | spotify-downloader works with all major distributions and even on low-powered devices such as a Raspberry Pi. 38 | 39 | spotify-downloader can be installed via pip with: 40 | ```console 41 | $ pip3 install git+https://github.com/ritiek/spotify-downloader.git 42 | ``` 43 | 44 | but be sure to check out the [Installation](https://spotdl.readthedocs.io/en/v2.2.2/installation.html) docs 45 | for detailed OS-specific instructions to get it and other dependencies it relies on working on your system. 46 | 47 | ## Usage 48 | 49 | For the most basic usage, downloading tracks is as easy as 50 | 51 | ```console 52 | $ spotdl --song https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ 53 | $ spotdl --song "ncs - spectre" 54 | ``` 55 | 56 | For downloading playlist and albums, you need to first load all the tracks into text file and then pass 57 | this text file to `--list` argument. Here is how you would do it for a playlist 58 | 59 | ```console 60 | $ spotdl --playlist https://open.spotify.com/user/nocopyrightsounds/playlist/7sZbq8QGyMnhKPcLJvCUFD 61 | INFO: Writing 62 tracks to ncs-releases.txt 62 | $ spotdl --list ncs-releases.txt 63 | ``` 64 | 65 | Run `spotdl --help` to get a list of all available options in spotify-downloader. 66 | 67 | Check out the [Available options](https://spotdl.readthedocs.io/en/v2.2.2/available-options.html) 68 | page for the list of currently available options with their description. 69 | 70 | The docs on [Downloading Tracks](https://spotdl.readthedocs.io/en/v2.2.2/download-tracks.html) 71 | contains detailed information about different available ways to download tracks. 72 | 73 | ## FAQ 74 | 75 | All FAQs will be mentioned in our [FAQ docs](https://spotdl.readthedocs.io/en/v2.2.2/faq.html). 76 | 77 | ## Contributing 78 | 79 | Check out [CONTRIBUTING.md](CONTRIBUTING.md) for more info. 80 | 81 | ## Running Tests 82 | 83 | ```console 84 | $ pytest 85 | ``` 86 | 87 | Obviously this requires the `pytest` module to be installed. 88 | 89 | ## Disclaimer 90 | 91 | Downloading copyright songs may be illegal in your country. 92 | This tool is for educational purposes only and was created only to show 93 | how Spotify's API can be exploited to download music from YouTube. 94 | Please support the artists by buying their music. 95 | 96 | ## License 97 | 98 | [![License](https://img.shields.io/github/license/ritiek/spotify-downloader.svg)](https://github.com/ritiek/spotify-downloader/blob/master/LICENSE) 99 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 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/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=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx_rtd_theme 2 | sphinxcontrib-programoutput 3 | # This should be removed `sphinx.ext.autodoc` is enough 4 | # sphinx-automodapi 5 | 6 | -------------------------------------------------------------------------------- /docs/source/advanced-usage.rst: -------------------------------------------------------------------------------- 1 | Advanced Usage 2 | ************** 3 | 4 | This page contains information on non-general features. 5 | 6 | 7 | Configuration file 8 | ================== 9 | 10 | At first run, this tool will generate a *config.yml* and mention the 11 | location where it gets placed to. You can then modify this *config.yml* 12 | to override any default options. 13 | 14 | This *config.yml* will ideally be written to: 15 | 16 | * For Linux: *~/.config/spotdl/config.yml* 17 | * For Windows: *C:\\Users\\\\AppData\\Local\\spotdl\\config.yml* 18 | * For macOS: *~/Library/Application Support/spotdl/config.yml* 19 | 20 | You can know the location where your *config.yml* is in by running: 21 | 22 | .. CODE:: 23 | 24 | $ spotdl --help 25 | 26 | and looking for something like this in the output: 27 | 28 | .. CODE:: 29 | 30 | -c CONFIG, --config CONFIG 31 | path to custom config.yml file (default: 32 | /home/ritiek/.config/spotdl/config.yml) 33 | 34 | Also note that config options are overridden by command-line arguments. 35 | 36 | If you want to use custom *.yml* configuration instead of the default 37 | one, you can use the *-c*/*\--config* argument. 38 | 39 | **For example:** 40 | 41 | .. CODE:: 42 | 43 | $ spotdl -s "ncs spectre" -c "/home/user/customConfig.yml" 44 | 45 | Here's a sample config file depicting how the file should look like: 46 | 47 | .. CODE:: 48 | 49 | spotify-downloader: 50 | dry_run: false 51 | input_ext: automatic 52 | log_level: INFO 53 | manual: false 54 | no_encode: false 55 | no_metadata: false 56 | no_spaces: false 57 | output_ext: mp3 58 | output_file: '{artist} - {track-name}.{output-ext}' 59 | overwrite: prompt 60 | quality: best 61 | search_format: '{artist} - {track-name} lyrics' 62 | skip_file: null 63 | spotify_client_id: 4fe3fecfe5334023a1472516cc99d805 64 | spotify_client_secret: 0f02b7c483c04257984695007a4a8d5c 65 | trim_silence: false 66 | write_successful_file: null 67 | write_to: null 68 | 69 | 70 | Set custom YouTube search query 71 | =============================== 72 | 73 | If you're not matching with correct tracks on YouTube, you can tweak 74 | the search query (*search-format*) in *config.yml* or on the 75 | command-line according to your needs. Currently the default search 76 | query is set to *"{artist} - {track-name} lyrics"*. You can specify 77 | other special tag attributes in curly braces too. The following tag 78 | attributes are supported: 79 | 80 | * *track-name* 81 | * *artist* 82 | * *album* 83 | * *album-artist* 84 | * *genre* 85 | * *disc-number* 86 | * *duration* 87 | * *year* 88 | * *original-date* 89 | * *track-number* 90 | * *total-tracks* 91 | * *isrc* 92 | * *track-id* 93 | * *output-ext* 94 | 95 | **For example:** 96 | 97 | .. CODE:: 98 | 99 | $ spotdl -s https://open.spotify.com/track/4wX3QCU2hWtuPiv8GSakrz -sf "{artist} - {track-name} nightcore" 100 | 101 | This will attempt to download the Nightcore version of the track. 102 | 103 | 104 | Set custom filenames 105 | ==================== 106 | 107 | You can also set custom filenames for downloaded tracks in your 108 | *config.yml* under *output-file* key or with *-f* option. By default, 109 | downloaded tracks are renamed to 110 | *"{artist} - {track-name}.{output-ext}"*. You can also create 111 | sub-directories with this option. Such as setting the *output-file* key 112 | to *"{artist}/{track-name}.{output-ext}"* will create a directory with 113 | the artist's name and download the matched track to this directory. 114 | 115 | .. TIP:: 116 | This option supports same tag attributes as in 117 | `setting custom YouTube search queries <#set-custom-youtube-search-query>`_. 118 | 119 | **For example:** 120 | 121 | .. CODE:: 122 | 123 | $ spotdl -s https://open.spotify.com/track/4wX3QCU2hWtuPiv8GSakrz -f "{artist}/{album}/{track-name}.{output-ext}" 124 | 125 | This will create a directory with the artist's name and another sub-directory inside with the 126 | album name and download the track here. 127 | 128 | 129 | Use UNIX pipelines 130 | ================== 131 | 132 | The *\--song* argument supports reading input from STDIN. 133 | You can also write tracks to STDOUT in as they are being downloaded by passing *-f -*. Similarly, 134 | you can also write tracks from *\--playlist*, *\--album*, etc. to STDOUT by passing *\--write-to -*. 135 | 136 | - **For example**, reading "to-download" tracks from STDIN: 137 | 138 | .. CODE:: 139 | 140 | $ echo "last heroes - eclipse" | spotdl -s - 141 | 142 | Multiple tracks must be separated with a line break character *\\n*, such as: 143 | 144 | .. CODE:: 145 | 146 | $ echo "last heroes - eclipse\n" "culture code - make me move" | spotdl -s - 147 | 148 | - **For example**, to pipe a track to mpv player for it to play via STDOUT: 149 | 150 | .. CODE:: 151 | 152 | $ spotdl -s "last heroes - eclipse" -f - | mpv - 153 | 154 | This will download, encode and pass the output to mpv for playing, in real-time. 155 | If you'd like to avoid encoding, pass *\--no-encode* like so: 156 | 157 | .. CODE:: 158 | 159 | $ spotdl -s "last heroes - eclipse" -f - --no-encode | mpv - 160 | 161 | 162 | .. WARNING:: 163 | Writing to STDOUT assumes *\--no-metadata* and should display an 164 | appropriate warning. 165 | 166 | 167 | Embed spotdl in Python scripts 168 | ============================== 169 | 170 | Check out the `API docs `_. 171 | 172 | 173 | Maintain a skip tracks file 174 | =========================== 175 | 176 | You can keep a skip file to prevent the tracks present in skip from 177 | being downloaded again. This is faster than having the tool 178 | automatically check (which may sometimes also result in poor detection) 179 | whether a previous track with same filename has been already downloaded. 180 | 181 | This skip file can be then passed to *\--skip-file* argument when 182 | downloading using *\--list* argument which will skip all the tracks 183 | mentioned in the skip file. 184 | 185 | This maybe be useful with *\--write-successful-file* argument which 186 | writes the successfully downloaded tracks to the filename passed. 187 | 188 | .. HINT:: 189 | *\--skip-file* and *\--write-successful-file* arguments may also 190 | point to the same file. 191 | 192 | For more info; see the relevant issue 193 | `#296 `_ 194 | and PR `#386 `_. 195 | 196 | 197 | Apply metadata from a different track 198 | ===================================== 199 | 200 | You can download one track and apply metadata to this track from 201 | another track. *-s* accepts another track which can be used as a 202 | metadata source. This metadata source track needs to be separated 203 | using "::" from the track to be downloaded. 204 | 205 | **For example:** 206 | 207 | .. CODE:: 208 | 209 | $ spotdl -s "nightcore janji heroes::janji heroes" 210 | 211 | This will download the nightcore version of the track but the original 212 | track would be used for metadata. Similarly, one may also pass Spotify 213 | URIs or YouTube URLs (instead of search queries) in either of these two 214 | tracks. 215 | 216 | 217 | Use a proxy server 218 | ================== 219 | 220 | To use a proxy server you can set the *http_proxy* and *https_proxy* 221 | environment variables. 222 | 223 | **For example:** 224 | 225 | .. CODE:: 226 | 227 | $ http_proxy=http://127.0.0.1:1080 https_proxy=https://127.0.0.1:1081 spotdl 228 | 229 | For a detailed explanation see 230 | `#505 (comment) `_. 231 | 232 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | *** 3 | 4 | If you recognize any shortcomings while using the API, please report! 5 | 6 | 7 | API Reference 8 | ============= 9 | 10 | spotdl.command_line 11 | ------------------- 12 | 13 | .. autoclass:: spotdl.command_line.core.Spotdl 14 | :members: 15 | 16 | spotdl.track 17 | ------------ 18 | 19 | .. autoclass:: spotdl.track.Track 20 | :members: 21 | 22 | spotdl.metadata_search 23 | ---------------------- 24 | 25 | .. autoclass:: spotdl.metadata_search.MetadataSearch 26 | :members: 27 | 28 | spotdl.metadata 29 | --------------- 30 | 31 | .. autofunction:: spotdl.metadata.format_string 32 | 33 | .. autoclass:: spotdl.metadata.providers.ProviderSpotify 34 | :members: 35 | 36 | .. autoclass:: spotdl.metadata.providers.ProviderYouTube 37 | :members: 38 | 39 | .. autoclass:: spotdl.metadata.providers.YouTubeSearch 40 | :members: 41 | 42 | .. autoclass:: spotdl.metadata.providers.youtube.YouTubeStreams 43 | :members: 44 | 45 | .. autoclass:: spotdl.metadata.embedders.EmbedderDefault 46 | :members: 47 | 48 | spotdl.encode 49 | ------------- 50 | 51 | .. autoclass:: spotdl.encode.encoders.EncoderFFmpeg 52 | :members: 53 | 54 | spotdl.lyrics 55 | ------------- 56 | 57 | .. autoclass:: spotdl.lyrics.providers.Genius 58 | :members: 59 | 60 | .. autoclass:: spotdl.lyrics.providers.LyricWikia 61 | :members: 62 | 63 | spotdl.authorize 64 | ---------------- 65 | 66 | .. autoclass:: spotdl.authorize.services.AuthorizeSpotify 67 | :members: 68 | 69 | spotdl.helpers 70 | -------------- 71 | 72 | .. autoclass:: spotdl.helpers.SpotifyHelpers 73 | :members: 74 | 75 | 76 | Abstract Classes 77 | ================ 78 | 79 | spotdl.encode 80 | ------------- 81 | 82 | .. autoclass:: spotdl.encode.EncoderBase 83 | :members: 84 | 85 | spotdl.metadata 86 | --------------- 87 | 88 | .. autoclass:: spotdl.metadata.EmbedderBase 89 | :members: 90 | 91 | .. autoclass:: spotdl.metadata.ProviderBase 92 | :members: 93 | 94 | .. autoclass:: spotdl.metadata.StreamsBase 95 | :members: 96 | 97 | 98 | Examples 99 | ======== 100 | 101 | + Setup the internal logger and download tracks from a Spotify playlist: 102 | 103 | .. CODE:: python 104 | 105 | from spotdl.helpers.spotify import SpotifyHelpers 106 | from spotdl import Spotdl 107 | 108 | from spotdl import util 109 | import logging 110 | util.install_logger(logging.INFO) 111 | 112 | playlist_uri = "https://open.spotify.com/user/nocopyrightsounds/playlist/7sZbq8QGyMnhKPcLJvCUFD" 113 | tracks_file = "tracks.txt" 114 | 115 | with Spotdl() as spotdl_handler: 116 | spotify_tools = SpotifyHelpers() 117 | playlist = spotify_tools.fetch_playlist(playlist_uri) 118 | spotify_tools.write_playlist_tracks(playlist, tracks_file) 119 | spotdl_handler.download_tracks_from_file(tracks_file) 120 | -------------------------------------------------------------------------------- /docs/source/available-options.rst: -------------------------------------------------------------------------------- 1 | Available Options 2 | ***************** 3 | 4 | This page contains a complete list of all available options with their 5 | description. For advanced usage of the program check out the `advanced 6 | usage `_ docs. 7 | 8 | .. PROGRAM-OUTPUT:: spotdl -h 9 | 10 | -------------------------------------------------------------------------------- /docs/source/badges.rst: -------------------------------------------------------------------------------- 1 | .. |pypi.python.org| image:: https://img.shields.io/pypi/v/spotdl.svg?branch=master 2 | :target: https://pypi.org/project/spotdl 3 | .. |readthedocs.org| image:: https://readthedocs.org/projects/spotdl/badge/?version=latest 4 | :target: https://spotdl.readthedocs.io/en/latest/home.html 5 | .. |travis-ci.org| image:: https://travis-ci.org/ritiek/spotify-downloader.svg?branch=master 6 | :target: https://travis-ci.org/ritiek/spotify-downloader 7 | .. |codecov.io| image:: https://codecov.io/gh/ritiek/spotify-downloader/branch/master/graph/badge.svg 8 | :target: https://codecov.io/gh/ritiek/spotify-downloader 9 | .. |hub.docker.com| image:: https://img.shields.io/docker/build/ritiek/spotify-downloader.svg 10 | :target: https://hub.docker.com/r/ritiek/spotify-downloader 11 | .. |hub.docker.com pulls| image:: https://img.shields.io/docker/pulls/ritiek/spotify-downloader.svg 12 | :target: https://hub.docker.com/r/ritiek/spotify-downloader 13 | .. |codestyle-black| image:: https://img.shields.io/badge/code%20style-black-000000.svg 14 | :target: https://github.com/psf/black 15 | .. |gitter.im| image:: https://badges.gitter.im/ritiek/spotify-downloader/Lobby.svg 16 | :target: https://gitter.im/spotify-downloader/Lobby 17 | .. |license| image:: https://img.shields.io/github/license/ritiek/spotify-downloader.svg 18 | :target: https://github.com/ritiek/spotify-downloader/blob/master/LICENSE 19 | 20 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | import os 18 | import sys 19 | sys.path.insert(0, os.path.abspath(os.path.join("..", "..", "spotdl"))) 20 | 21 | # __version__ comes into namespace from here 22 | with open(os.path.join("..", "..", "spotdl", "version.py")) as version_file: 23 | exec(version_file.read()) 24 | 25 | # -- Project information ----------------------------------------------------- 26 | 27 | project = 'spotdl' 28 | copyright = '2020, Ritiek Malhotra' 29 | author = 'Ritiek Malhotra' 30 | 31 | # The full version, including alpha/beta/rc tags 32 | release = __version__ 33 | 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # Entry file 38 | master_doc = 'index' 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 42 | # ones. 43 | extensions = [ 44 | # XXX: Not sure which one is better here. 45 | # `sphinx_automodapi.automodapi` generates nice looking API 46 | # docs but is not built-in, while both being conversely true 47 | # for `sphinx.ext.autodoc`. 48 | "sphinx.ext.autodoc", 49 | # "sphinx_automodapi.automodapi", 50 | 51 | "sphinxcontrib.programoutput", 52 | # This adds support for Googley formatted docstrings as they are 53 | # easier to read than reStructuredText.. 54 | "sphinx.ext.napoleon" 55 | ] 56 | 57 | # Add any paths that contain templates here, relative to this directory. 58 | templates_path = ['_templates'] 59 | 60 | # List of patterns, relative to source directory, that match files and 61 | # directories to ignore when looking for source files. 62 | # This pattern also affects html_static_path and html_extra_path. 63 | exclude_patterns = [] 64 | 65 | 66 | # -- Options for HTML output ------------------------------------------------- 67 | 68 | # The theme to use for HTML and HTML Help pages. See the documentation for 69 | # a list of builtin themes. 70 | # 71 | # html_theme = 'alabaster' 72 | html_theme = 'sphinx_rtd_theme' 73 | 74 | # Add any paths that contain custom static files (such as style sheets) here, 75 | # relative to this directory. They are copied after the builtin static files, 76 | # so a file named "default.css" will overwrite the builtin "default.css". 77 | html_static_path = [] 78 | # html_static_path = ['_static'] 79 | -------------------------------------------------------------------------------- /docs/source/download-tracks.rst: -------------------------------------------------------------------------------- 1 | Download Tracks 2 | *************** 3 | 4 | This page documents all the available ways for downloading tracks. 5 | 6 | 7 | Download a Track 8 | ================ 9 | 10 | You can download single tracks using the *\--song* argument. Here's 11 | what all parameters can be passed to *\--song*. 12 | 13 | Download by Name 14 | ---------------- 15 | 16 | **For example:** 17 | 18 | - We want to download *Fade* by *Alan Walker*, run the command: 19 | 20 | .. CODE:: 21 | 22 | $ spotdl --song "alan walker fade" 23 | 24 | - The tool will automatically look for the best matching song and 25 | download it in the current working directory. 26 | 27 | - It will also encode the track to an mp3 while it is being downloaded. 28 | 29 | - Once it has both been downloaded and encoded, it will proceed to 30 | write metadata on this track. 31 | 32 | .. TIP:: 33 | The `\--song` parameter can also accept multiple tracks like so: 34 | 35 | .. CODE:: 36 | 37 | $ spotdl --song "alan walker fade" "tobu candyland" 38 | 39 | 40 | Download by Spotify URI 41 | ----------------------- 42 | 43 | .. IMPORTANT:: 44 | It is recommened to use Spotify URIs for downloading tracks as it 45 | guarantees that the track exists on Spotify which allows the tool 46 | to always fetch the accurate metadata. 47 | 48 | **For example:** 49 | 50 | - We want to download the same song (i.e: *Fade* by *Alan Walker*) but using 51 | Spotify URI this time that looks like `https://open.spotify.com/track/2lfPecqFbH8X4lHSpTxt8l`. 52 | 53 | .. HINT:: 54 | You can copy this URI from your Spotify desktop or mobile app by 55 | right clicking or long tap on the song and copy HTTP link. 56 | 57 | - Then run this command: 58 | 59 | .. CODE:: 60 | 61 | $ spotdl --song https://open.spotify.com/track/2lfPecqFbH8X4lHSpTxt8l 62 | 63 | - Just like before, the track will be both downloaded and encoded, but 64 | since we used the Spotify URI this time, the tool is guaranteed to 65 | fetch the correct metadata. 66 | 67 | 68 | Download by YouTube Link 69 | ------------------------ 70 | 71 | - You can copy the YouTube URL or ID of a video and pass it in `-s` argument. 72 | 73 | **For example:** 74 | 75 | - Run the command: 76 | 77 | .. CODE:: 78 | 79 | $ spotdl -s https://www.youtube.com/watch?v=lc4Tt-CU-i0 80 | 81 | - It should download *2SCOOPS* by *Donuts*. 82 | 83 | 84 | Download from File 85 | ================== 86 | 87 | You can also pass a file filled with tracks to the tool for download. 88 | This allows for some features not available with passing multiple 89 | tracks to *\--song* argument. 90 | 91 | **For example:** 92 | 93 | - We want to download *Fade* by *Alan Walker*, *Sky High* by *Elektromania* 94 | and *Fire* by *Elektromania* just using a single command. 95 | 96 | - Let's suppose, we have the Spotify link for only *Fade* by *Alan Walker* and 97 | *Fire by Elektromania*. 98 | 99 | - Make a *list.txt* anywhere on your drive and add all the songs you want to 100 | download, in our case they are: 101 | 102 | .. CODE:: 103 | 104 | https://open.spotify.com/track/2lfPecqFbH8X4lHSpTxt8l 105 | elektromania sky high 106 | https://open.spotify.com/track/0fbspWuEdaaT9vfmbAZr1C 107 | 108 | - Now pass *\--list=/path/to/list.txt* to the script, i.e: 109 | 110 | .. CODE:: 111 | 112 | $ spotdl --list=/path/to/list.txt 113 | 114 | and it will start downloading songs mentioned in *list.txt*. 115 | 116 | - The tool will remove the track from the list file once it gets 117 | downloaded, and then continue to the next track. 118 | 119 | .. NOTE:: 120 | Songs that are already downloaded will prompt you to overwrite or 121 | skip. This default behaviour can be changed by passing one of 122 | *\--overwrite {prompt,skip,force}*. 123 | 124 | .. TIP:: 125 | In case you accidentally interrupt the download, you can always 126 | re-launch the same command back and the script will continue 127 | downloading the tracks from where it left off. 128 | 129 | 130 | Download Playlists and Albums 131 | ============================= 132 | 133 | For downloading playlists or albums, you need to first load all the 134 | tracks into a file and then pass this file to the tool for download. 135 | 136 | Download by Playlist URI 137 | ------------------------ 138 | 139 | - You can copy the Spotify URI of the playlist and pass it in *\--playlist* argument. 140 | 141 | .. NOTE:: 142 | This method works for both public as well as private playlists. 143 | 144 | **For example:** 145 | 146 | - To download the playlist 147 | *https://open.spotify.com/user/nocopyrightsounds/playlist/7sZbq8QGyMnhKPcLJvCUFD*, 148 | run the command: 149 | 150 | .. CODE:: 151 | 152 | $ spotdl --playlist https://open.spotify.com/user/nocopyrightsounds/playlist/7sZbq8QGyMnhKPcLJvCUFD 153 | 154 | - This will load all the tracks from the playlist into *.txt*. 155 | 156 | - Now run the command: 157 | 158 | .. CODE:: 159 | 160 | $ spotdl --list=.txt 161 | 162 | to download all the tracks (see `#download-from-file <#download-from-file>`_ for more info). 163 | 164 | .. TIP:: 165 | By default, the tracks are written to *.txt*. You can 166 | specify a custom target file by passing *\--write-to *. 167 | 168 | 169 | Download by Album URI 170 | --------------------- 171 | 172 | - You can copy the Spotify URI of the album and pass it in *\--album* argument. 173 | 174 | **For example:** 175 | 176 | - To download the album 177 | *https://open.spotify.com/album/499J8bIsEnU7DSrosFDJJg*, 178 | run the command: 179 | 180 | .. CODE:: 181 | 182 | $ spotdl --album https://open.spotify.com/album/499J8bIsEnU7DSrosFDJJg 183 | 184 | - The script will load all the tracks from the album into *.txt*. 185 | 186 | - Now run the command: 187 | 188 | .. CODE:: 189 | 190 | $ spotdl --list=.txt 191 | 192 | to download all the tracks (see `#download-from-file <#download-from-file>`_ for more info). 193 | 194 | 195 | Download by Username 196 | -------------------- 197 | 198 | - You can also load songs using Spotify username if you don't have the playlist URL. 199 | 200 | .. HINT:: 201 | If you don't know the Spotify username. Open the user's profile in 202 | Spotify, click on the three little dots below name -> *Share* -> 203 | *Copy to clipboard*. Now you'll have something like this in your 204 | clipboard: *https://open.spotify.com/user/12345abcde*. 205 | The last numbers or text is the username. 206 | 207 | - Run the command: 208 | 209 | .. CODE:: 210 | 211 | $ spotdl -u 212 | 213 | - Once you select the one you want to download, the script will load all the tracks 214 | from the playlist into *.txt*. 215 | 216 | - Now run the command: 217 | 218 | .. CODE:: 219 | 220 | $ spotdl --list=.txt 221 | 222 | to download all the tracks (see `#download-from-file <#download-from-file>`_ for more info). 223 | 224 | .. NOTE:: 225 | When using the *username* to display the playlists, only public 226 | playlists will be delivered. Collaborative and private playlists 227 | will not be delivered. 228 | 229 | 230 | Download by Artist URI 231 | ---------------------- 232 | 233 | - You can copy the Spotify URI of the artist and pass it in *\--all-albums* argument. 234 | 235 | **For example:** 236 | 237 | - To download all albums of the artist 238 | *https://open.spotify.com/artist/1feoGrmmD8QmNqtK2Gdwy8*, 239 | run the command: 240 | 241 | .. CODE:: 242 | 243 | $ spotdl --all-albums https://open.spotify.com/artist/1feoGrmmD8QmNqtK2Gdwy8 244 | 245 | - The script will load all the tracks from artist's available albums into *.txt* 246 | 247 | - Now run the command: 248 | 249 | .. CODE:: 250 | 251 | $ spotdl --list=.txt 252 | 253 | to download all the tracks (see `#download-from-file <#download-from-file>`_ for more info). 254 | 255 | -------------------------------------------------------------------------------- /docs/source/faq.rst: -------------------------------------------------------------------------------- 1 | FAQ 2 | *** 3 | 4 | All FAQs will be mentioned here. 5 | 6 | 7 | How to specify a custom folder where tracks should be downloaded? 8 | ================================================================= 9 | 10 | If you don't want to download all the tracks into your current 11 | directory, you can use the *-f* option to specify another directory. 12 | 13 | **For example:** 14 | 15 | .. CODE:: 16 | 17 | $ spotdl -s "ncs - spectre" -f "/home/user/Happy-Music/" 18 | 19 | This works with both relative and absolute paths. 20 | 21 | .. WARNING:: 22 | If you do not specify a file naming scheme, a warning will be produced 23 | and the program will use the default naming scheme, which means the 24 | above command would automatically expand to: 25 | 26 | .. CODE:: 27 | 28 | $ spotdl -s "ncs - spectre" -f "/home/user/Happy-Music/{artist} - {track-name}.{output-ext}" 29 | 30 | 31 | Where is my config.yml? 32 | ======================= 33 | 34 | Check out the docs for `advanced-usage#config-file `_. 35 | 36 | 37 | How to skip already downloaded tracks by default? 38 | ================================================= 39 | 40 | If there exists a track in your download directory with filename same 41 | as the one being downloaded,the tool will prompt on whether to skip 42 | downloaded the current track or overwrite the previously downloaded 43 | track. You can change this behaviour by passing one of *prompt*, 44 | *skip*, or *force* in the *\--overwrite* argument. 45 | 46 | **For example:** 47 | 48 | .. CODE:: 49 | 50 | $ spotdl -l=/home/user/my_fav_tracks.txt --overwrite skip 51 | 52 | This will automatically skip downloaded tracks which are already present in the 53 | download directory. 54 | 55 | From where are the tracks being downloaded from? Is it directly from Spotify? 56 | ============================================================================= 57 | 58 | No. The download happens from YouTube. Spotify is only used as a source 59 | of metadata. 60 | 61 | **spotdl typically follows the below process to download a Spotify track:** 62 | 63 | - Get the artist and track name of the track from Spotify 64 | - Search for this track on YouTube 65 | - Get lyrics for the track from Genius 66 | - Downloads the audiostream from YouTube in an *.opus* or *.webm* 67 | format (or as specified in input format) and simuntaneously encodes it 68 | to an *.mp3* format (or as specified in output format) 69 | - Finally apply metadata from Spotify to this encoded track 70 | 71 | The download bitrate is very low? 72 | ================================= 73 | 74 | If there were a way to get better audio quality, someone would have already done it. 75 | Also see `#499 `_, 76 | `#137 `_, 77 | `#52 `_. 78 | 79 | I get this youtube-dl error all of a sudden? 80 | ============================================== 81 | 82 | You mean something like this? 83 | 84 | .. CODE:: 85 | 86 | youtube_dl.utils.ExtractorError: Could not find JS function 'encodeURIComponent'; please report this issue on https://yt-dl.org/bug . Make sure you are using the latest version; see https://yt-dl.org/update on how to update. Be sure to call youtube-dl with the --verbose flag and include its complete output. 87 | (caused by ExtractorError("Could not find JS function 'encodeURIComponent'; please report this issue on https://yt-dl.org/bug . Make sure you are using the latest version; see https://yt-dl.org/update on how to update. Be sure to call youtube-dl with the --verbose flag and include its complete output.")); please report this issue on https://yt-dl.org/bug . Make sure you are using the latest version; see https://yt-dl.org/update on how to update. Be sure to call youtube-dl with the --verbose flag and include its complete output. 88 | 89 | 90 | Also see `#488 `_, 91 | `#484 `_, 92 | `#466 `_, 93 | `#433 `_, 94 | `#399 `_, 95 | `#388 `_, 96 | `#385 `_, 97 | `#341 `_. 98 | 99 | Usually youtube-dl devs have already published a fix on PyPI by the 100 | time you realize this error. You can update your `youtube-dl` 101 | installation with: 102 | 103 | .. CODE:: 104 | 105 | $ pip3 install youtube-dl -U 106 | 107 | .. NOTE:: 108 | In case this still doesn't fix it for you and the above linked 109 | issues are not of any help either, feel free to open a 110 | `new issue `_. 111 | It would be a good idea to bring this problem to light in the 112 | community. 113 | 114 | I get this error: ssl.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed? 115 | =========================================================================================== 116 | 117 | If you're on OS X, see this 118 | `Stack Overflow post `_. 119 | 120 | If you're still getting the same error, also try running: 121 | 122 | .. CODE:: 123 | 124 | $ pip3 install --upgrade certifi 125 | 126 | For related OS X issues, see 127 | `#125 `_ 128 | `#143 `_ 129 | `#214 `_ 130 | `#245 `_ 131 | `#443 `_ 132 | `#480 `_ 133 | `#515 `_. 134 | 135 | If you're on a Linux based distro, check out 136 | `#480 (comment) `_ 137 | for possible solutions. 138 | 139 | The downloads used to work but now I get: HTTP Error 429: Too Many Requests? 140 | ============================================================================ 141 | 142 | You probably have been downloading too may tracks (~1k) per day. This 143 | error should automatically go away after waiting for a while (some 144 | hours or even a day). See 145 | `#560 `_ for 146 | more information. If you want to keep downloading more without the 147 | wait, consider setting up/switching to a different proxy/VPN or 148 | anything that does not send your old IP address to YouTube. Rebooting 149 | your modem or restarting mobile data services sometimes helps too. 150 | 151 | .. toctree:: 152 | :maxdepth: 1 153 | -------------------------------------------------------------------------------- /docs/source/home.rst: -------------------------------------------------------------------------------- 1 | .. INCLUDE:: badges.rst 2 | 3 | Home 4 | **** 5 | 6 | |pypi.python.org| |travis-ci.org| |codecov.io| 7 | 8 | |readthedocs.org| |hub.docker.com| 9 | 10 | Download Spotify playlists from YouTube with albumart and metadata. 11 | 12 | If you haven't already, check out the 13 | `README `_ 14 | to get a basic overview for what this tool is about. More advanced 15 | topics and use-cases will be covered here. Start by choosing a topic you wish 16 | to learn more about from the sidebar. 17 | 18 | For contributing, check out the 19 | `GitHub repository `_. 20 | For other queries, check out the 21 | `Gitter channel `_. 22 | 23 | |codestyle-black| |gitter.im| |license| 24 | 25 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Index 2 | ***** 3 | 4 | .. TOCTREE:: 5 | :maxdepth: 2 6 | 7 | home 8 | installation 9 | download-tracks 10 | available-options 11 | faq 12 | advanced-usage 13 | api 14 | 15 | 16 | .. Enable below once have API documented. 17 | 18 | .. Indices and tables 19 | .. ================== 20 | 21 | .. * :ref:`genindex` 22 | .. * :ref:`modindex` 23 | .. * :ref:`search` 24 | 25 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | .. include:: badges.rst 2 | 3 | Installation 4 | ************ 5 | 6 | .. NOTE:: 7 | Only Python 3.6+ is supported. Anything lower is not supported. 8 | 9 | 10 | Debian-like GNU/Linux & macOS 11 | ============================= 12 | 13 | You can install the latest release by invoking pip with: 14 | 15 | .. CODE:: 16 | 17 | $ pip install -U spotdl 18 | 19 | .. ATTENTION:: 20 | If you have both Python 2 and 3 installed, the *pip* command 21 | could invoke an installation for Python 2. To see which Python 22 | version *pip* refers to, try running: 23 | 24 | .. CODE:: 25 | 26 | $ pip -V 27 | 28 | If it turns out that *pip* points to Python 2, try running: 29 | 30 | .. CODE:: 31 | 32 | $ pip3 install -U spotdl 33 | 34 | instead. 35 | 36 | You'll also need to install FFmpeg for conversion: 37 | 38 | - **Debian-like GNU/Linux** 39 | 40 | .. CODE:: 41 | 42 | $ sudo apt-get install ffmpeg 43 | 44 | - **macOS** 45 | 46 | .. CODE:: 47 | 48 | $ brew install ffmpeg --with-libmp3lame --with-libass --with-opus --with-fdk-aac 49 | 50 | If FFmpeg does not install correctly in the above step, depending on 51 | your OS, you may have to build it from source. 52 | For more information see `FFmpeg's compilation guide `_. 53 | 54 | 55 | Windows 56 | ======= 57 | 58 | Assuming you have Python 3.6 already installed and in PATH. 59 | 60 | Open *command-prompt* and run: 61 | 62 | .. CODE:: 63 | 64 | $ pip install -U spotdl 65 | 66 | to install spotdl. 67 | 68 | .. ATTENTION:: 69 | If you have both Python 2 and 3 installed, the *pip* command 70 | could invoke an installation for Python 2. To see which Python 71 | version *pip* refers to, try running: 72 | 73 | .. CODE:: 74 | 75 | $ pip -V 76 | 77 | If it turns out that *pip* points to Python 2, try running: 78 | 79 | .. CODE:: 80 | 81 | $ pip3 install -U spotdl 82 | 83 | instead. 84 | 85 | You'll also need to download zip file for an `FFmpeg build `_. 86 | Extract it and then place *ffmpeg.exe* in a directory included in your 87 | PATH variable. Placing *ffmpeg.exe* in *C:\Windows\\System32* usually 88 | works fine. 89 | 90 | To confirm whether *ffmpeg.exe* was correctly installed in PATH, from 91 | any arbitrary working directory try executing: 92 | 93 | .. CODE:: 94 | 95 | $ ffmpeg 96 | 97 | If you get a *command-not-found* error, that means something's still off. 98 | 99 | .. NOTE:: 100 | In some cases placing *ffmpeg.exe* in *System32* directory doesn't 101 | work, if this seems to be happening with you then you need to 102 | manually add the FFmpeg directory to PATH. Refer to 103 | `this post `_ 104 | or `this video `_. 105 | 106 | Android (Termux) 107 | ================ 108 | 109 | Install Python and FFmpeg: 110 | 111 | .. CODE:: 112 | 113 | $ pkg update 114 | $ pkg install python ffmpeg 115 | 116 | Then install spotdl with: 117 | 118 | .. CODE:: 119 | 120 | $ pip install spotdl 121 | 122 | Docker Image 123 | ============ 124 | 125 | |hub.docker.com| |hub.docker.com pulls| 126 | 127 | We also provide the latest docker image on `DockerHub `_. 128 | 129 | - Pull (or update) the image with: 130 | 131 | .. CODE:: 132 | 133 | $ docker pull ritiek/spotify-downloader 134 | 135 | - Run it with: 136 | 137 | .. CODE:: 138 | 139 | $ docker run --rm -it -v $(pwd):/music ritiek/spotify-downloader 140 | 141 | - The container will download music and write tracks in your current working directory. 142 | 143 | **Example - Downloading a Playlist:** 144 | 145 | .. CODE:: 146 | 147 | $ docker run --rm -it -v $(pwd):/music ritiek/spotify-downloader -p https://open.spotify.com/user/nocopyrightsounds/playlist/7sZbq8QGyMnhKPcLJvCUFD 148 | $ docker run --rm -it -v $(pwd):/music ritiek/spotify-downloader -l ncs-releases.txt 149 | 150 | .. IMPORTANT:: 151 | Changing the root directory where the tracks get downloaded to any 152 | different than the current directory tree with the *-f* argument 153 | will **NOT** work with Docker containers. 154 | 155 | 156 | Vagrant Box 157 | =========== 158 | 159 | `@csimpi `_ has posted very detailed 160 | instructions `here `_. 161 | However, they are now a bit-outdated. So, here's the updated bit based 162 | on the original instructions: 163 | 164 | **Installing Virtualbox and Vagrant** 165 | 166 | You will need Vagrant from http://vagrantup.com, and virtualbox from https://www.virtualbox.org/. 167 | 168 | Setting up Vagrant virtual machine 169 | Make an empty folder, this folder will contain your vagrant machine. 170 | Step into the folder and run the following commands: 171 | 172 | .. CODE:: 173 | 174 | $ vagrant init ubuntu/trusty64 175 | $ vagrant up 176 | 177 | Log in to the installed linux instance **(password: vagrant)** 178 | 179 | .. CODE:: 180 | 181 | $ vagrant ssh 182 | 183 | Run the following commands: 184 | 185 | .. CODE:: 186 | 187 | $ sudo apt-get update 188 | $ sudo apt-get -y upgrade 189 | $ sudo apt-get install -y python3-pip 190 | $ sudo apt-get install build-essential libssl-dev libffi-dev python-dev 191 | $ sudo apt-get install git 192 | 193 | $ cd ~ 194 | $ git clone https://github.com/ritiek/spotify-downloader 195 | $ cd spotify-downloader 196 | $ sudo apt-get install ffmpeg 197 | $ pip3 install . 198 | 199 | You can link your inside download folder to your physical hard 200 | drive, so you will be able to get your downloaded files directly 201 | from your main operation system, don't need to be logged in to the 202 | Vagrant virtual machine, your downloaded files will be there on 203 | your computer, and the library won't make any footprint in your 204 | system. 205 | 206 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = --strict-markers -m "not network" 3 | markers = 4 | network: marks test which rely on external network resources (select with '-m network' or run all with '-m "network, not network"') 5 | 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | with open("README.md", "r", encoding="utf-8") as f: 5 | long_description = f.read() 6 | 7 | # __version__ comes into namespace from here 8 | with open(os.path.join("spotdl", "version.py")) as version_file: 9 | exec(version_file.read()) 10 | 11 | setup( 12 | # 'spotify-downloader' was already taken :/ 13 | name="spotdl", 14 | # Tests are included automatically: 15 | # https://docs.python.org/3.6/distutils/sourcedist.html#specifying-the-files-to-distribute 16 | packages=[ 17 | "spotdl", 18 | "spotdl.command_line", 19 | "spotdl.lyrics", 20 | "spotdl.lyrics.providers", 21 | "spotdl.encode", 22 | "spotdl.encode.encoders", 23 | "spotdl.metadata", 24 | "spotdl.metadata.embedders", 25 | "spotdl.metadata.providers", 26 | "spotdl.lyrics", 27 | "spotdl.lyrics.providers", 28 | "spotdl.authorize", 29 | "spotdl.authorize.services", 30 | "spotdl.helpers", 31 | ], 32 | version=__version__, 33 | install_requires=[ 34 | "pathlib >= 1.0.1", 35 | "youtube_dl >= 2017.9.26", 36 | "pytube3 >= 9.5.5", 37 | "spotipy >= 2.12.0", 38 | "mutagen >= 1.41.1", 39 | "beautifulsoup4 >= 4.6.3", 40 | "unicode-slugify >= 0.1.3", 41 | "coloredlogs >= 14.0", 42 | "lyricwikia >= 0.1.8", 43 | "PyYAML >= 3.13", 44 | "appdirs >= 1.4.3", 45 | "tqdm >= 4.45.0" 46 | ], 47 | description="Download Spotify playlists from YouTube with albumart and metadata", 48 | long_description=long_description, 49 | long_description_content_type="text/markdown", 50 | author="Ritiek Malhotra", 51 | author_email="ritiekmalhotra123@gmail.com", 52 | license="MIT", 53 | python_requires=">=3.6", 54 | url="https://github.com/ritiek/spotify-downloader", 55 | download_url="https://pypi.org/project/spotdl/", 56 | keywords=[ 57 | "spotify", 58 | "downloader", 59 | "download", 60 | "music", 61 | "youtube", 62 | "mp3", 63 | "album", 64 | "metadata", 65 | ], 66 | classifiers=[ 67 | "Intended Audience :: End Users/Desktop", 68 | "License :: OSI Approved :: MIT License", 69 | "Programming Language :: Python", 70 | "Programming Language :: Python :: 3", 71 | "Programming Language :: Python :: 3.6", 72 | "Programming Language :: Python :: 3.7", 73 | "Programming Language :: Python :: 3.8", 74 | "Programming Language :: Python :: 3 :: Only", 75 | "Topic :: Multimedia", 76 | "Topic :: Multimedia :: Sound/Audio", 77 | "Topic :: Utilities", 78 | ], 79 | entry_points={"console_scripts": ["spotdl = spotdl.command_line.__main__:main"]}, 80 | ) 81 | -------------------------------------------------------------------------------- /spotdl/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.version import __version__ 2 | from spotdl.command_line.core import Spotdl 3 | 4 | -------------------------------------------------------------------------------- /spotdl/authorize/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.authorize.authorize_base import AuthorizeBase 2 | 3 | from spotdl.authorize.exceptions import AuthorizationError 4 | from spotdl.authorize.exceptions import SpotifyAuthorizationError 5 | from spotdl.authorize.exceptions import YouTubeAuthorizationError 6 | 7 | -------------------------------------------------------------------------------- /spotdl/authorize/authorize_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from abc import abstractmethod 3 | 4 | class AuthorizeBase(ABC): 5 | """ 6 | Defined service authenticators must inherit from this abstract 7 | base class and implement their own functionality for the below 8 | defined methods. 9 | """ 10 | 11 | @abstractmethod 12 | def authorize(self): 13 | """ 14 | This method must authorize with the corresponding service 15 | and return an object that can be utilized in making 16 | authenticated requests. 17 | """ 18 | pass 19 | 20 | -------------------------------------------------------------------------------- /spotdl/authorize/exceptions.py: -------------------------------------------------------------------------------- 1 | class AuthorizationError(Exception): 2 | __module__ = Exception.__module__ 3 | 4 | def __init__(self, message=None): 5 | super().__init__(message) 6 | 7 | 8 | class SpotifyAuthorizationError(AuthorizationError): 9 | __module__ = Exception.__module__ 10 | 11 | def __init__(self, message=None): 12 | super().__init__(message) 13 | 14 | 15 | class YouTubeAuthorizationError(AuthorizationError): 16 | __module__ = Exception.__module__ 17 | 18 | def __init__(self, message=None): 19 | super().__init__(message) 20 | 21 | -------------------------------------------------------------------------------- /spotdl/authorize/services/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.authorize.services.spotify import AuthorizeSpotify 2 | 3 | -------------------------------------------------------------------------------- /spotdl/authorize/services/spotify.py: -------------------------------------------------------------------------------- 1 | from spotdl.authorize import AuthorizeBase 2 | from spotdl.authorize.exceptions import SpotifyAuthorizationError 3 | 4 | import spotipy 5 | import spotipy.oauth2 as oauth2 6 | 7 | import logging 8 | logger = logging.getLogger(__name__) 9 | 10 | # This masterclient is used to keep the last logged-in client 11 | # object in memory for for persistence. If credentials aren't 12 | # provided when creating further objects, the last authenticated 13 | # client object with correct credentials is returned when 14 | # `AuthorizeSpotify().authorize()` is called. 15 | masterclient = None 16 | 17 | class AuthorizeSpotify(spotipy.Spotify): 18 | """ 19 | Allows for a single-time authentication for accessing the Spotify 20 | API which can later globally be re-used later on in other modules 21 | if needed. 22 | 23 | Parameters 24 | ---------- 25 | client_id: `str` 26 | Your Spotify API Client ID. 27 | 28 | client_secret: `str` 29 | Your Spotify API Client Secret. 30 | 31 | Examples 32 | -------- 33 | >>> # Module A: 34 | >>> from spotdl.authorize.services import AuthorizeSpotify 35 | >>> # Authorize once. 36 | >>> AuthorizeSpotify( 37 | ... client_id="your_spotify_client_id", 38 | ... client_secret="your_spotify_client_secret", 39 | ... ) 40 | >>> 41 | >>> # Module B: 42 | >>> from spotdl.authorize.services import AuthorizeSpotify 43 | >>> # Re-use later in other modules. 44 | >>> sp = AuthorizeSpotify() 45 | >>> results = sp.search("illenium - good things fall apart") 46 | >>> print(results) 47 | """ 48 | 49 | def __init__(self, client_id=None, client_secret=None): 50 | global masterclient 51 | # `spotipy.Spotify` makes use of `self._session` and would 52 | # result in an error. The below line is a workaround. 53 | self._session = None 54 | 55 | credentials_provided = client_id is not None \ 56 | and client_secret is not None 57 | valid_input = credentials_provided or masterclient is not None 58 | 59 | if not valid_input: 60 | raise SpotifyAuthorizationError( 61 | "You must pass in client_id and client_secret to this method " 62 | "when authenticating for the first time." 63 | ) 64 | 65 | if masterclient: 66 | logger.debug("Reading cached master Spotify credentials.") 67 | # Use cached client instead of authorizing again 68 | # and thus wasting time. 69 | self.__dict__.update(masterclient.__dict__) 70 | else: 71 | logger.debug("Setting master Spotify credentials.") 72 | credential_manager = oauth2.SpotifyClientCredentials( 73 | client_id=client_id, 74 | client_secret=client_secret 75 | ) 76 | super().__init__(client_credentials_manager=credential_manager) 77 | # Cache current client 78 | masterclient = self 79 | 80 | -------------------------------------------------------------------------------- /spotdl/authorize/services/tests/test_spotify.py: -------------------------------------------------------------------------------- 1 | from spotdl.authorize.services import AuthorizeSpotify 2 | 3 | import pytest 4 | 5 | class TestSpotifyAuthorize: 6 | # TODO: Test these once we a have config.py 7 | # storing pre-defined default credentials. 8 | # 9 | # We'll use these credentials to create 10 | # a spotipy object via below tests 11 | 12 | @pytest.mark.xfail 13 | def test_generate_token(self): 14 | raise NotImplementedError 15 | 16 | @pytest.mark.xfail 17 | def test_authorize(self): 18 | raise NotImplementedError 19 | 20 | -------------------------------------------------------------------------------- /spotdl/authorize/tests/test_authorize_base.py: -------------------------------------------------------------------------------- 1 | from spotdl.authorize import AuthorizeBase 2 | 3 | import pytest 4 | 5 | class TestAbstractBaseClass: 6 | def test_error_abstract_base_class_authorizebase(self): 7 | with pytest.raises(TypeError): 8 | AuthorizeBase() 9 | 10 | def test_inherit_abstract_base_class_authorizebase(self): 11 | class AuthorizeKid(AuthorizeBase): 12 | def authorize(self): 13 | pass 14 | 15 | AuthorizeKid() 16 | 17 | -------------------------------------------------------------------------------- /spotdl/authorize/tests/test_authorize_exceptions.py: -------------------------------------------------------------------------------- 1 | from spotdl.authorize.exceptions import AuthorizationError 2 | from spotdl.authorize.exceptions import SpotifyAuthorizationError 3 | from spotdl.authorize.exceptions import YouTubeAuthorizationError 4 | 5 | 6 | class TestEncoderNotFoundSubclass: 7 | def test_authozation_error_subclass(self): 8 | assert issubclass(AuthorizationError, Exception) 9 | 10 | def test_spotify_authorization_error_subclass(self): 11 | assert issubclass(SpotifyAuthorizationError, AuthorizationError) 12 | 13 | def test_youtube_authorization_error_subclass(self): 14 | assert issubclass(YouTubeAuthorizationError, AuthorizationError) 15 | 16 | -------------------------------------------------------------------------------- /spotdl/command_line/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/command_line/__init__.py -------------------------------------------------------------------------------- /spotdl/command_line/__main__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from spotdl.command_line.core import Spotdl 5 | from spotdl.command_line import arguments 6 | from spotdl.command_line.exceptions import ArgumentError 7 | 8 | import spotdl.command_line.exitcodes 9 | import spotdl.version 10 | 11 | # hardcode loglevel for dependencies so that they do not spew generic 12 | # log messages along with spotdl. 13 | logger = logging.getLogger(name=__name__) 14 | 15 | 16 | def main(): 17 | try: 18 | parser = arguments.get_arguments() 19 | except ArgumentError as e: 20 | logger.info(e.args[0]) 21 | sys.exit(spotdl.command_line.exitcodes.ARGUMENT_ERROR) 22 | else: 23 | args = parser.parse_args().__dict__ 24 | logger.debug("spotdl {}".format(spotdl.version.__version__)) 25 | try: 26 | with Spotdl(args) as spotdl_handler: 27 | exitcode = spotdl_handler.match_arguments() 28 | except ArgumentError as e: 29 | parser.error( 30 | e.args[0], 31 | exitcode=spotdl.command_line.exitcodes.ARGUMENT_ERROR 32 | ) 33 | except KeyboardInterrupt as e: 34 | print("", file=sys.stderr) 35 | logger.exception(e) 36 | sys.exit(spotdl.command_line.exitcodes.KEYBOARD_INTERRUPT) 37 | else: 38 | sys.exit(exitcode) 39 | 40 | 41 | if __name__ == "__main__": 42 | main() 43 | 44 | -------------------------------------------------------------------------------- /spotdl/command_line/arguments.py: -------------------------------------------------------------------------------- 1 | import appdirs 2 | 3 | import argparse 4 | import os 5 | import sys 6 | import shutil 7 | 8 | from spotdl.command_line.exceptions import ArgumentError 9 | import spotdl.util 10 | import spotdl.config 11 | import spotdl.command_line.exitcodes 12 | 13 | from collections.abc import Sequence 14 | import logging 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | if os.path.isfile(spotdl.config.DEFAULT_CONFIG_FILE): 19 | saved_config = spotdl.config.read_config(spotdl.config.DEFAULT_CONFIG_FILE) 20 | else: 21 | saved_config = {"spotify-downloader": {}} 22 | 23 | _LOG_LEVELS = { 24 | "INFO": logging.INFO, 25 | "WARNING": logging.WARNING, 26 | "ERROR": logging.ERROR, 27 | "DEBUG": logging.DEBUG, 28 | } 29 | 30 | _CONFIG_BASE = spotdl.util.merge_copy( 31 | spotdl.config.DEFAULT_CONFIGURATION, 32 | saved_config, 33 | ) 34 | 35 | 36 | class ArgumentParser(argparse.ArgumentParser): 37 | def error(self, message, exitcode=spotdl.command_line.exitcodes.ARGUMENT_ERROR): 38 | self.print_usage(sys.stderr) 39 | self.exit( 40 | exitcode, 41 | '%s: error: %s\n' % (self.prog, message) 42 | ) 43 | 44 | def parse_args(self, args=None): 45 | configured_args = super().parse_args(args=args) 46 | config_file = configured_args.config 47 | if config_file and os.path.isfile(config_file): 48 | config = spotdl.config.read_config(config_file) 49 | self.set_defaults(**config["spotify-downloader"]) 50 | configured_args = super().parse_args(args=args) 51 | logging_level = _LOG_LEVELS[configured_args.log_level] 52 | spotdl.util.install_logger(logging_level) 53 | del configured_args.config 54 | return configured_args 55 | 56 | 57 | def get_arguments(config_base=_CONFIG_BASE): 58 | parser = ArgumentParser( 59 | description="Download and convert tracks from Spotify, Youtube, etc.", 60 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 61 | ) 62 | 63 | defaults = config_base["spotify-downloader"] 64 | 65 | to_remove_config = "--remove-config" in sys.argv[1:] 66 | if not to_remove_config and "download-only-metadata" in defaults: 67 | logger = spotdl.util.install_logger(logging.INFO) 68 | raise ArgumentError( 69 | "The default configuration file currently set is not suitable for spotdl>=2.0.0.\n" 70 | "You need to remove your previous `config.yml` due to breaking changes\n" 71 | "introduced in v2.0.0, new options being added, and old ones being removed\n" 72 | "You may want to first backup your old configuration for reference. You can\n" 73 | "then remove your current configuration by running:\n" 74 | "```\n" 75 | "$ spotdl --remove-config\n" 76 | "```\n" 77 | "spotdl will automatically generate a new configuration file on the next run.\n" 78 | "You can then replace the appropriate fields in the newly generated\n" 79 | "configuration file by referring to your old configuration file.\n\n" 80 | "For the list of OTHER BREAKING CHANGES and release notes check out:\n" 81 | "https://github.com/ritiek/spotify-downloader/releases/tag/v2.0.0" 82 | ) 83 | 84 | possible_special_tags = ( 85 | "{track-name}", 86 | "{artist}", 87 | "{album}", 88 | "{album-artist}", 89 | "{genre}", 90 | "{disc-number}", 91 | "{duration}", 92 | "{year}", 93 | "{original-date}", 94 | "{track-number}", 95 | "{total-tracks}", 96 | "{isrc}", 97 | "{track-id}", 98 | "{output-ext}", 99 | ) 100 | 101 | # `--remove-config` does not require the any of the group arguments to be passed. 102 | group = parser.add_mutually_exclusive_group(required=not to_remove_config) 103 | 104 | group.add_argument( 105 | "-s", 106 | "--song", 107 | nargs="+", 108 | help="download track(s) by spotify link, name, or youtube url." 109 | ) 110 | group.add_argument( 111 | "-l", 112 | "--list", 113 | help="download tracks from a file (WARNING: this file will be modified!)" 114 | ) 115 | group.add_argument( 116 | "-p", 117 | "--playlist", 118 | help="load tracks from playlist URL into .txt or " 119 | "if `--write-to=` has been passed", 120 | ) 121 | group.add_argument( 122 | "-a", 123 | "--album", 124 | help="load tracks from album URL into .txt or if " 125 | "`--write-to=` has been passed" 126 | ) 127 | group.add_argument( 128 | "-aa", 129 | "--all-albums", 130 | help="load all tracks from artist URL into .txt " 131 | "or if `--write-to=` has been passed" 132 | ) 133 | group.add_argument( 134 | "-u", 135 | "--username", 136 | help="load tracks from user's playlist into .txt " 137 | "or if `--write-to=` has been passed" 138 | ) 139 | 140 | parser.add_argument( 141 | "--write-m3u", 142 | help="generate an .m3u playlist file with youtube links given " 143 | "a text file containing tracks", 144 | action="store_true", 145 | ) 146 | parser.add_argument( 147 | "-m", 148 | "--manual", 149 | default=defaults["manual"], 150 | help="choose the track to download manually from a list of matching tracks", 151 | action="store_true", 152 | ) 153 | parser.add_argument( 154 | "-nm", 155 | "--no-metadata", 156 | default=defaults["no_metadata"], 157 | help="do not embed metadata in tracks", 158 | action="store_true", 159 | ) 160 | parser.add_argument( 161 | "-ne", 162 | "--no-encode", 163 | default=defaults["no_encode"], 164 | action="store_true", 165 | help="do not encode media using FFmpeg", 166 | ) 167 | parser.add_argument( 168 | "--overwrite", 169 | default=defaults["overwrite"], 170 | choices={"prompt", "force", "skip"}, 171 | help="change the overwrite policy", 172 | ) 173 | parser.add_argument( 174 | "-q", 175 | "--quality", 176 | default=defaults["quality"], 177 | choices={"best", "worst"}, 178 | help="preferred audio quality", 179 | ) 180 | parser.add_argument( 181 | "-i", 182 | "--input-ext", 183 | default=defaults["input_ext"], 184 | choices={"automatic", "m4a", "opus"}, 185 | help="preferred input format", 186 | ) 187 | parser.add_argument( 188 | "-o", 189 | "--output-ext", 190 | default=defaults["output_ext"], 191 | choices={"mp3", "m4a", "flac", "ogg", "opus"}, 192 | help="preferred output format", 193 | ) 194 | parser.add_argument( 195 | "--write-to", 196 | default=defaults["write_to"], 197 | help="write tracks from Spotify playlist, album, etc. to this file", 198 | ) 199 | parser.add_argument( 200 | "-f", 201 | "--output-file", 202 | default=defaults["output_file"], 203 | help="path where to write the downloaded track to, special tags " 204 | "are to be surrounded by curly braces. Possible tags: {}".format( 205 | possible_special_tags 206 | ) 207 | ) 208 | parser.add_argument( 209 | "--trim-silence", 210 | default=defaults["trim_silence"], 211 | help="remove silence from the start of the audio", 212 | action="store_true", 213 | ) 214 | parser.add_argument( 215 | "-sf", 216 | "--search-format", 217 | default=defaults["search_format"], 218 | help="search format to search for on YouTube, special tags " 219 | "are to be surrounded by curly braces. Possible tags: {}".format( 220 | possible_special_tags 221 | ) 222 | ) 223 | parser.add_argument( 224 | "-d", 225 | "--dry-run", 226 | default=defaults["dry_run"], 227 | help="show only track title and YouTube URL, and then skip " 228 | "to the next track (if any)", 229 | action="store_true", 230 | ) 231 | parser.add_argument( 232 | "--processor", 233 | default="synchronous", 234 | choices={"synchronous", "threaded"}, 235 | # help='list downloading strategy: - "synchronous" downloads ' 236 | # 'tracks one-by-one. - "threaded" (highly experimental at the ' 237 | # 'moment! expect it to slash & burn) pre-fetches the next ' 238 | # 'track\'s metadata for more efficient downloading' 239 | # XXX: Still very experimental to be exposed 240 | help=argparse.SUPPRESS, 241 | ) 242 | parser.add_argument( 243 | "-ns", 244 | "--no-spaces", 245 | default=defaults["no_spaces"], 246 | help="replace spaces in metadata values with underscores when " 247 | "generating filenames", 248 | action="store_true", 249 | ) 250 | parser.add_argument( 251 | "-sk", 252 | "--skip-file", 253 | default=defaults["skip_file"], 254 | help="path to file containing tracks to skip", 255 | ) 256 | parser.add_argument( 257 | "-w", 258 | "--write-successful-file", 259 | default=defaults["write_successful_file"], 260 | help="path to file to write successful tracks to", 261 | ) 262 | parser.add_argument( 263 | "--spotify-client-id", 264 | default=defaults["spotify_client_id"], 265 | help=argparse.SUPPRESS, 266 | ) 267 | parser.add_argument( 268 | "--spotify-client-secret", 269 | default=defaults["spotify_client_secret"], 270 | help=argparse.SUPPRESS, 271 | ) 272 | parser.add_argument( 273 | "-ll", 274 | "--log-level", 275 | default=defaults["log_level"], 276 | choices=_LOG_LEVELS.keys(), 277 | type=str.upper, 278 | help="set log verbosity", 279 | ) 280 | parser.add_argument( 281 | "-c", 282 | "--config", 283 | default=spotdl.config.DEFAULT_CONFIG_FILE, 284 | help="path to custom config.yml file" 285 | ) 286 | parser.add_argument( 287 | "--remove-config", 288 | default=False, 289 | action="store_true", 290 | help="remove previously saved config" 291 | ) 292 | parser.add_argument( 293 | "-V", 294 | "--version", 295 | action="version", 296 | version="%(prog)s {}".format(spotdl.__version__), 297 | ) 298 | 299 | return parser 300 | 301 | 302 | class ArgumentHandler: 303 | def __init__(self, args={}, config_base=_CONFIG_BASE): 304 | self.config_base = config_base 305 | self.args = args 306 | self.configured_args = self._from_args(args) 307 | 308 | def _from_args(self, args): 309 | config_file = args.get("config") 310 | defaults = self.config_base["spotify-downloader"] 311 | # Make sure the passed `config_file` exists before 312 | # doing anything. 313 | if config_file and os.path.isfile(config_file): 314 | config = spotdl.config.read_config(config_file) 315 | config = config["spotify-downloader"] 316 | configured_args = spotdl.util.merge_copy(defaults, config) 317 | else: 318 | configured_args = defaults.copy() 319 | configured_args = spotdl.util.merge_copy(configured_args, args) 320 | return configured_args 321 | 322 | def run_errands(self): 323 | args = self.configured_args 324 | 325 | if (args.get("list")): 326 | textchars = bytearray({7,8,9,10,12,13,27} | set(range(0x20, 0x7e))) 327 | is_binary_string = lambda bytes: bool(bytes.translate(None, textchars)) 328 | with open(args["list"], 'rb') as file: 329 | if is_binary_string(file.read(1024)): 330 | raise ArgumentError( 331 | "{0} is not of a valid argument to --list, it must be a text file".format( 332 | args["list"] 333 | ) 334 | ) 335 | 336 | if args.get("write_m3u") and not args.get("list"): 337 | raise ArgumentError("--write-m3u can only be used with --list.") 338 | 339 | if args["write_to"] and not ( 340 | args.get("playlist") or args.get("album") or args.get("all_albums") or args.get("username") or args.get("write_m3u") 341 | ): 342 | raise ArgumentError( 343 | "--write-to can only be used with --playlist, --album, --all-albums, --username, or --write-m3u." 344 | ) 345 | 346 | ffmpeg_exists = shutil.which("ffmpeg") 347 | if not ffmpeg_exists: 348 | logger.warn("FFmpeg was not found in PATH. Will not re-encode media to specified output format.") 349 | args["no_encode"] = True 350 | 351 | if args["no_encode"] and args["trim_silence"]: 352 | logger.warn("--trim-silence can only be used when an encoder is set.") 353 | 354 | if args["output_file"] == "-" and args["no_metadata"] is False: 355 | logger.warn( 356 | "Cannot write metadata when target is STDOUT. Pass " 357 | "--no-metadata explicitly to hide this warning." 358 | ) 359 | args["no_metadata"] = True 360 | elif os.path.isdir(args["output_file"]): 361 | adjusted_output_file = os.path.join( 362 | args["output_file"], 363 | self.config_base["spotify-downloader"]["output_file"] 364 | ) 365 | logger.warn( 366 | "Given output file is a directory. Will download tracks " 367 | "in this directory with their filename as per the default " 368 | "file format. Pass --output-file=\"{}\" to hide this " 369 | "warning.".format( 370 | adjusted_output_file 371 | ) 372 | ) 373 | args["output_file"] = adjusted_output_file 374 | 375 | return args 376 | 377 | -------------------------------------------------------------------------------- /spotdl/command_line/exceptions.py: -------------------------------------------------------------------------------- 1 | class NoYouTubeVideoFoundError(Exception): 2 | __module__ = Exception.__module__ 3 | 4 | def __init__(self, message=None): 5 | super().__init__(message) 6 | 7 | 8 | class NoYouTubeVideoMatchError(Exception): 9 | __module__ = Exception.__module__ 10 | 11 | def __init__(self, message=None): 12 | super().__init__(message) 13 | 14 | 15 | class ArgumentError(Exception): 16 | __module__ = Exception.__module__ 17 | 18 | def __init__(self, message=None): 19 | super().__init__(message) 20 | 21 | -------------------------------------------------------------------------------- /spotdl/command_line/exitcodes.py: -------------------------------------------------------------------------------- 1 | KEYBOARD_INTERRUPT = 1 2 | ARGUMENT_ERROR = 2 3 | 4 | # When playlists, albums, artists, users aren't found. 5 | URI_NOT_FOUND_ERROR = 5 6 | 7 | -------------------------------------------------------------------------------- /spotdl/command_line/tests/test_arguments.py: -------------------------------------------------------------------------------- 1 | import spotdl.command_line.arguments 2 | from spotdl.command_line.exceptions import ArgumentError 3 | 4 | import logging 5 | import sys 6 | import pytest 7 | 8 | 9 | def test_logging_levels(): 10 | expect_logging_levels = { 11 | "INFO": logging.INFO, 12 | "WARNING": logging.WARNING, 13 | "DEBUG": logging.DEBUG, 14 | "ERROR": logging.ERROR, 15 | } 16 | assert spotdl.command_line.arguments._LOG_LEVELS == expect_logging_levels 17 | 18 | 19 | class TestBadArguments: 20 | def test_error_m3u_without_list(self): 21 | previous_argv = sys.argv 22 | sys.argv[1:] = ["-s", "cool song", "--write-m3u"] 23 | parser = spotdl.command_line.arguments.get_arguments() 24 | args = parser.parse_args().__dict__ 25 | argument_handler = spotdl.command_line.arguments.ArgumentHandler(args) 26 | with pytest.raises(ArgumentError): 27 | argument_handler.run_errands() 28 | sys.argv[1:] = previous_argv[1:] 29 | 30 | def test_write_to_error(self): 31 | previous_argv = sys.argv 32 | sys.argv[1:] = ["-s", "sekai all i had", "--write-to", "output.txt"] 33 | parser = spotdl.command_line.arguments.get_arguments() 34 | args = parser.parse_args().__dict__ 35 | argument_handler = spotdl.command_line.arguments.ArgumentHandler(args) 36 | with pytest.raises(ArgumentError): 37 | argument_handler.run_errands() 38 | sys.argv[1:] = previous_argv[1:] 39 | 40 | 41 | class TestArguments: 42 | def test_general_arguments(self): 43 | previous_argv = sys.argv 44 | sys.argv[1:] = ["-s", "elena coats - one last song"] 45 | parser = spotdl.command_line.arguments.get_arguments() 46 | args = parser.parse_args().__dict__ 47 | 48 | expect_args = { 49 | 'song': ['elena coats - one last song'], 50 | 'list': None, 51 | 'playlist': None, 52 | 'album': None, 53 | 'all_albums': None, 54 | 'username': None, 55 | 'write_m3u': False, 56 | 'manual': False, 57 | 'no_metadata': False, 58 | 'no_encode': False, 59 | 'overwrite': 'prompt', 60 | 'quality': 'best', 61 | 'input_ext': 'automatic', 62 | 'output_ext': 'mp3', 63 | 'write_to': None, 64 | 'output_file': '{artist} - {track-name}.{output-ext}', 65 | 'trim_silence': False, 66 | 'search_format': '{artist} - {track-name} lyrics', 67 | 'dry_run': False, 68 | 'processor': 'synchronous', 69 | 'no_spaces': False, 70 | 'skip_file': None, 71 | 'write_successful_file': None, 72 | 'spotify_client_id': '4fe3fecfe5334023a1472516cc99d805', 73 | 'spotify_client_secret': '0f02b7c483c04257984695007a4a8d5c', 74 | 'log_level': 'INFO', 75 | 'remove_config': False 76 | } 77 | 78 | assert args == expect_args 79 | 80 | def test_grouped_arguments(self): 81 | previous_argv = sys.argv 82 | sys.argv[1:] = [] 83 | parser = spotdl.command_line.arguments.get_arguments() 84 | with pytest.raises(SystemExit): 85 | parser.parse_args() 86 | sys.argv[1:] = previous_argv[1:] 87 | 88 | -------------------------------------------------------------------------------- /spotdl/config.py: -------------------------------------------------------------------------------- 1 | import appdirs 2 | import yaml 3 | import os 4 | 5 | import spotdl.util 6 | import logging 7 | logger = logging.getLogger(__name__) 8 | 9 | DEFAULT_CONFIGURATION = { 10 | "spotify-downloader": { 11 | "manual": False, 12 | "no_metadata": False, 13 | "no_encode": False, 14 | "overwrite": "prompt", 15 | "quality": "best", 16 | "input_ext": "automatic", 17 | "output_ext": "mp3", 18 | "write_to": None, 19 | "trim_silence": False, 20 | "search_format": "{artist} - {track-name} lyrics", 21 | "dry_run": False, 22 | "no_spaces": False, 23 | # "processor": "synchronous", 24 | "output_file": "{artist} - {track-name}.{output-ext}", 25 | "skip_file": None, 26 | "write_successful_file": None, 27 | "spotify_client_id": "4fe3fecfe5334023a1472516cc99d805", 28 | "spotify_client_secret": "0f02b7c483c04257984695007a4a8d5c", 29 | "log_level": "INFO", 30 | } 31 | } 32 | 33 | DEFAULT_CONFIG_FILE = os.path.join( 34 | appdirs.user_config_dir(), 35 | "spotdl", 36 | "config.yml" 37 | ) 38 | 39 | def read_config(config_file): 40 | with open(config_file, "r") as ymlfile: 41 | config = yaml.safe_load(ymlfile) 42 | return config 43 | 44 | 45 | def dump_config(config_file=None, config=DEFAULT_CONFIGURATION): 46 | if config_file is None: 47 | config = yaml.dump(config, default_flow_style=False) 48 | return config 49 | 50 | with open(config_file, "w") as ymlfile: 51 | yaml.dump(config, ymlfile, default_flow_style=False) 52 | 53 | -------------------------------------------------------------------------------- /spotdl/encode/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.encode.encode_base import EncoderBase 2 | -------------------------------------------------------------------------------- /spotdl/encode/encode_base.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import os 3 | 4 | from abc import ABC 5 | from abc import abstractmethod 6 | 7 | from spotdl.encode.exceptions import EncoderNotFoundError 8 | 9 | """ 10 | NOTE ON ENCODERS 11 | ================ 12 | 13 | * FFmeg encoders sorted in descending order based 14 | on the quality of audio produced: 15 | libopus > libvorbis >= libfdk_aac > aac > libmp3lame 16 | 17 | * libfdk_aac encoder, due to copyrights needs to be compiled 18 | by end user on MacOS brew install ffmpeg --with-fdk-aac 19 | will do just that. Other OS? See: 20 | https://trac.ffmpeg.org/wiki/Encode/AAC 21 | 22 | """ 23 | 24 | _TARGET_FORMATS_FROM_ENCODING = { 25 | "m4a": "mp4", 26 | "mp3": "mp3", 27 | "opus": "opus", 28 | "flac": "flac", 29 | "ogg": "ogg", 30 | } 31 | 32 | 33 | class EncoderBase(ABC): 34 | """ 35 | Defined encoders must inherit from this abstract base class 36 | and implement their own functionality for the below defined 37 | methods. 38 | """ 39 | 40 | @abstractmethod 41 | def __init__(self, encoder_path, must_exist, loglevel, additional_arguments=[]): 42 | if must_exist and shutil.which(encoder_path) is None: 43 | raise EncoderNotFoundError( 44 | "{} executable does not exist or was not found in PATH.".format( 45 | encoder_path 46 | ) 47 | ) 48 | self.encoder_path = encoder_path 49 | self._loglevel = loglevel 50 | self._additional_arguments = additional_arguments 51 | self._target_formats_from_encoding = _TARGET_FORMATS_FROM_ENCODING 52 | 53 | def set_argument(self, argument): 54 | """ 55 | Set any arbitrary arguments which are passed when the encoder 56 | is invoked. 57 | 58 | Parameters 59 | ---------- 60 | argument: `str` 61 | """ 62 | self._additional_arguments += argument.split() 63 | 64 | def get_encoding(self, path): 65 | """ 66 | Determine the encoding for a local audio file from its file 67 | extnension. Whether is it an "mp3", "wav", "m4a", etc. 68 | 69 | Parameters 70 | ---------- 71 | path: `str` 72 | Path to media file. 73 | 74 | Returns 75 | ------- 76 | encoding: `str` 77 | """ 78 | _, extension = os.path.splitext(path) 79 | # Ignore the initial dot from file extension 80 | encoding = extension[1:] 81 | return encoding 82 | 83 | @abstractmethod 84 | def set_debuglog(self): 85 | """ 86 | Enable verbose logging on the encoder. 87 | """ 88 | pass 89 | 90 | @abstractmethod 91 | def _generate_encode_command(self, input_path, target_path): 92 | """ 93 | This method must generate the complete command for that would 94 | be used to invoke the encoder and perform the encoding. 95 | """ 96 | pass 97 | 98 | @abstractmethod 99 | def _generate_encoding_arguments(self, input_encoding, target_encoding): 100 | """ 101 | This method must return the core arguments for the defined 102 | encoder such as defining the sample rate, audio bitrate, 103 | etc. 104 | """ 105 | pass 106 | 107 | @abstractmethod 108 | def re_encode(self, input_path, target_path, target_encoding=None, delete_original=False): 109 | """ 110 | Re-encode a given input file to a specified output file. 111 | 112 | Parameters 113 | ---------- 114 | input_path: `str` 115 | Path to media file that needs re-encoding. 116 | 117 | target_path: `str` 118 | Path to output media file. 119 | 120 | target_encoding: `str`, `None` 121 | It maybe "mp3", "opus", etc. If ``None``, it is 122 | determined from the file extension passed in 123 | ``target_path``. 124 | 125 | delete_original: `bool` 126 | Whether or not to delete the original media file. 127 | """ 128 | pass 129 | 130 | def target_format_from_encoding(self, encoding): 131 | """ 132 | Determines the target stream format from given input encoding 133 | for use with encoder. 134 | 135 | Parameters 136 | ---------- 137 | encoding: `str` 138 | 139 | Returns 140 | ------- 141 | target_format: `str` 142 | Target format which can be accepted by most mainstream encoders. 143 | """ 144 | target_format = self._target_formats_from_encoding[encoding] 145 | return target_format 146 | 147 | def re_encode_from_stdin(self, input_encoding, target_path, target_encoding=None): 148 | """ 149 | Read a file from STDIN and re-encode it to a specified output 150 | file. 151 | 152 | Parameters 153 | ---------- 154 | input_encoding: `str` 155 | Path to media file that needs re-encoding. 156 | 157 | target_path: `str` 158 | Path to output media file. 159 | 160 | target_encoding: `str`, `None` 161 | It maybe "mp3", "opus", etc. If ``None``, it is 162 | determined from the file extension passed in 163 | ``target_path``. 164 | """ 165 | raise NotImplementedError 166 | 167 | -------------------------------------------------------------------------------- /spotdl/encode/encoders/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.encode.encoders.ffmpeg import EncoderFFmpeg 2 | -------------------------------------------------------------------------------- /spotdl/encode/encoders/ffmpeg.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | 4 | from spotdl.encode import EncoderBase 5 | from spotdl.encode.exceptions import EncoderNotFoundError 6 | from spotdl.encode.exceptions import FFmpegNotFoundError 7 | 8 | import logging 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | # Key: from format 13 | # Subkey: to format 14 | RULES = { 15 | "m4a": { 16 | "mp3": "-codec:v copy -codec:a libmp3lame", 17 | "opus": "-codec:a libopus", 18 | "m4a": "-acodec copy", 19 | "flac": "-codec:a flac", 20 | "ogg": "-codec:a libvorbis -q:a 5", 21 | }, 22 | "opus": { 23 | "mp3": "-codec:a libmp3lame", 24 | "m4a": "-cutoff 20000 -codec:a aac", 25 | "flac": "-codec:a flac", 26 | "ogg": "-codec:a libvorbis -q:a 5", 27 | "opus": "-acodec copy", 28 | }, 29 | } 30 | 31 | 32 | class EncoderFFmpeg(EncoderBase): 33 | """ 34 | A class for encoding media files using FFmpeg. 35 | 36 | Parameters 37 | ---------- 38 | encoder_path: `str` 39 | Path to FFmpeg. 40 | 41 | must_exist: `bool` 42 | Error out immediately if the encoder isn't found in 43 | ``encoder_path``. 44 | 45 | Examples 46 | -------- 47 | + Re-encode an OPUS stream from STDIN to an MP3: 48 | 49 | >>> import os 50 | >>> input_path = "audio.opus" 51 | >>> target_path = "audio.mp3" 52 | >>> input_path_size = os.path.getsize(input_path) 53 | >>> 54 | >>> from spotdl.encode.encoders import EncoderFFmpeg 55 | >>> ffmpeg = EncoderFFmpeg() 56 | >>> process = ffmpeg.re_encode_from_stdin( 57 | ... input_encoding="opus", 58 | ... target_path=target_path 59 | ... ) 60 | >>> 61 | >>> chunk_size = 4096 62 | >>> total_chunks = (input_path_size // chunk_size) + 1 63 | >>> 64 | >>> with open(input_path, "rb") as fin: 65 | ... for chunk_number in range(1, total_chunks+1): 66 | ... chunk = fin.read(chunk_size) 67 | ... process.stdin.write(chunk) 68 | ... print("chunks encoded: {}/{}".format( 69 | ... chunk_number, 70 | ... total_chunks, 71 | ... )) 72 | >>> 73 | >>> process.stdin.close() 74 | >>> process.wait() 75 | """ 76 | 77 | def __init__(self, encoder_path="ffmpeg", must_exist=True): 78 | _loglevel = "-hide_banner -nostats -v warning" 79 | _additional_arguments = ["-b:a", "192k", "-vn"] 80 | try: 81 | super().__init__(encoder_path, must_exist, _loglevel, _additional_arguments) 82 | except EncoderNotFoundError as e: 83 | raise FFmpegNotFoundError(e.args[0]) 84 | self._rules = RULES 85 | 86 | def set_trim_silence(self): 87 | self.set_argument("-af silenceremove=start_periods=1") 88 | 89 | def get_encoding(self, path): 90 | return super().get_encoding(path) 91 | 92 | def _generate_encoding_arguments(self, input_encoding, target_encoding): 93 | initial_arguments = self._rules.get(input_encoding) 94 | if initial_arguments is None: 95 | raise TypeError( 96 | 'The input format ("{}") is not supported.'.format( 97 | input_encoding, 98 | )) 99 | arguments = initial_arguments.get(target_encoding) 100 | if arguments is None: 101 | raise TypeError( 102 | 'The output format ("{}") is not supported.'.format( 103 | target_encoding, 104 | )) 105 | return arguments 106 | 107 | def set_debuglog(self): 108 | self._loglevel = "-loglevel debug" 109 | 110 | def _generate_encode_command(self, input_path, target_path, 111 | input_encoding=None, target_encoding=None): 112 | if input_encoding is None: 113 | input_encoding = self.get_encoding(input_path) 114 | if target_encoding is None: 115 | target_encoding = self.get_encoding(target_path) 116 | arguments = self._generate_encoding_arguments( 117 | input_encoding, 118 | target_encoding 119 | ) 120 | command = [self.encoder_path] \ 121 | + ["-y", "-nostdin"] \ 122 | + self._loglevel.split() \ 123 | + ["-i", input_path] \ 124 | + arguments.split() \ 125 | + self._additional_arguments \ 126 | + ["-f", self.target_format_from_encoding(target_encoding)] \ 127 | + [target_path] 128 | 129 | return command 130 | 131 | def re_encode(self, input_path, target_path, target_encoding=None, delete_original=False): 132 | encode_command = self._generate_encode_command( 133 | input_path, 134 | target_path, 135 | target_encoding=target_encoding 136 | ) 137 | logger.debug("Calling FFmpeg with:\n{command}".format( 138 | command=encode_command, 139 | )) 140 | process = subprocess.Popen(encode_command) 141 | process.wait() 142 | encode_successful = process.returncode == 0 143 | if encode_successful and delete_original: 144 | os.remove(input_path) 145 | return process 146 | 147 | def re_encode_from_stdin(self, input_encoding, target_path, target_encoding=None): 148 | encode_command = self._generate_encode_command( 149 | "-", 150 | target_path, 151 | input_encoding=input_encoding, 152 | target_encoding=target_encoding, 153 | ) 154 | logger.debug("Calling FFmpeg with:\n{command}".format( 155 | command=encode_command, 156 | )) 157 | process = subprocess.Popen(encode_command, stdin=subprocess.PIPE) 158 | return process 159 | 160 | -------------------------------------------------------------------------------- /spotdl/encode/encoders/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/encode/encoders/tests/__init__.py -------------------------------------------------------------------------------- /spotdl/encode/encoders/tests/test_ffmpeg.py: -------------------------------------------------------------------------------- 1 | from spotdl.encode import EncoderBase 2 | from spotdl.encode.exceptions import FFmpegNotFoundError 3 | from spotdl.encode.encoders import EncoderFFmpeg 4 | 5 | import pytest 6 | 7 | class TestEncoderFFmpeg: 8 | def test_subclass(self): 9 | assert issubclass(EncoderFFmpeg, EncoderBase) 10 | 11 | def test_ffmpeg_not_found_error(self): 12 | with pytest.raises(FFmpegNotFoundError): 13 | EncoderFFmpeg(encoder_path="/a/nonexistent/path") 14 | 15 | 16 | class TestEncodingDefaults: 17 | def m4a_to_mp3_encoder(input_path, target_path): 18 | command = [ 19 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 20 | '-i', input_path, 21 | '-codec:v', 'copy', 22 | '-codec:a', 'libmp3lame', 23 | '-b:a', '192k', 24 | '-vn', 25 | '-f', 'mp3', 26 | target_path 27 | ] 28 | return command 29 | 30 | def m4a_to_opus_encoder(input_path, target_path): 31 | command = [ 32 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 33 | '-i', input_path, 34 | '-codec:a', 'libopus', 35 | '-b:a', '192k', 36 | '-vn', 37 | '-f', 'opus', 38 | target_path 39 | ] 40 | return command 41 | 42 | def m4a_to_m4a_encoder(input_path, target_path): 43 | command = [ 44 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 45 | '-i', input_path, 46 | '-acodec', 'copy', 47 | '-b:a', '192k', 48 | '-vn', 49 | '-f', 'mp4', 50 | target_path 51 | ] 52 | return command 53 | 54 | def m4a_to_flac_encoder(input_path, target_path): 55 | command = [ 56 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 57 | '-i', input_path, 58 | '-codec:a', 'flac', 59 | '-b:a', '192k', 60 | '-vn', 61 | '-f', 'flac', 62 | target_path 63 | ] 64 | return command 65 | 66 | def m4a_to_ogg_encoder(input_path, target_path): 67 | command = [ 68 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 69 | '-i', input_path, 70 | '-codec:a', 'libvorbis', 71 | '-q:a', '5', 72 | '-b:a', '192k', 73 | '-vn', 74 | '-f', 'ogg', 75 | target_path 76 | ] 77 | return command 78 | 79 | def m4a_to_opus_encoder(input_path, target_path): 80 | command = [ 81 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 82 | '-i', input_path, 83 | '-codec:a', 'libopus', 84 | '-b:a', '192k', 85 | '-vn', 86 | '-f', 'opus', 87 | target_path 88 | ] 89 | return command 90 | 91 | @pytest.mark.parametrize("files, expected_command", [ 92 | (("test.m4a", "test.mp3"), m4a_to_mp3_encoder("test.m4a", "test.mp3")), 93 | (("abc.m4a", "cba.opus"), m4a_to_opus_encoder("abc.m4a", "cba.opus")), 94 | (("bla bla.m4a", "ble ble.m4a"), m4a_to_m4a_encoder("bla bla.m4a", "ble ble.m4a")), 95 | (("😛.m4a", "• tongue.flac"), m4a_to_flac_encoder("😛.m4a", "• tongue.flac")), 96 | (("example.m4a", "example.ogg"), m4a_to_ogg_encoder("example.m4a", "example.ogg")), 97 | (("example.m4a", "example.opus"), m4a_to_opus_encoder("example.m4a", "example.opus")), 98 | ]) 99 | def test_generate_encode_command(self, files, expected_command): 100 | encoder = EncoderFFmpeg() 101 | assert encoder._generate_encode_command(*files) == expected_command 102 | 103 | 104 | class TestEncodingInDebugMode: 105 | def m4a_to_mp3_encoder_with_debug(input_path, target_path): 106 | command = [ 107 | 'ffmpeg', '-y', '-nostdin', '-loglevel', 'debug', 108 | '-i', input_path, 109 | '-codec:v', 'copy', 110 | '-codec:a', 'libmp3lame', 111 | '-b:a', '192k', 112 | '-vn', 113 | '-f', 'mp3', 114 | target_path 115 | ] 116 | return command 117 | 118 | def m4a_to_opus_encoder_with_debug(input_path, target_path): 119 | command = [ 120 | 'ffmpeg', '-y', '-nostdin', '-loglevel', 'debug', 121 | '-i', input_path, 122 | '-codec:a', 'libopus', 123 | '-b:a', '192k', 124 | '-vn', 125 | '-f', 'opus', 126 | target_path 127 | ] 128 | return command 129 | 130 | def m4a_to_m4a_encoder_with_debug(input_path, target_path): 131 | command = [ 132 | 'ffmpeg', '-y', '-nostdin', '-loglevel', 'debug', 133 | '-i', input_path, 134 | '-acodec', 'copy', 135 | '-b:a', '192k', 136 | '-vn', 137 | '-f', 'mp4', 138 | target_path 139 | ] 140 | return command 141 | 142 | def m4a_to_flac_encoder_with_debug(input_path, target_path): 143 | command = [ 144 | 'ffmpeg', '-y', '-nostdin', '-loglevel', 'debug', 145 | '-i', input_path, 146 | '-codec:a', 'flac', 147 | '-b:a', '192k', 148 | '-vn', 149 | '-f', 'flac', 150 | target_path 151 | ] 152 | return command 153 | 154 | def m4a_to_ogg_encoder_with_debug(input_path, target_path): 155 | command = [ 156 | 'ffmpeg', '-y', '-nostdin', '-loglevel', 'debug', 157 | '-i', input_path, 158 | '-codec:a', 'libvorbis', 159 | '-q:a', '5', 160 | '-b:a', '192k', 161 | '-vn', 162 | '-f', 'ogg', 163 | target_path 164 | ] 165 | return command 166 | 167 | def m4a_to_opus_encoder_with_debug(input_path, target_path): 168 | command = [ 169 | 'ffmpeg', '-y', '-nostdin', '-loglevel', 'debug', 170 | '-i', input_path, 171 | '-codec:a', 'libopus', 172 | '-b:a', '192k', 173 | '-vn', 174 | '-f', 'opus', 175 | target_path 176 | ] 177 | return command 178 | 179 | @pytest.mark.parametrize("files, expected_command", [ 180 | (("test.m4a", "test.mp3"), m4a_to_mp3_encoder_with_debug("test.m4a", "test.mp3")), 181 | (("abc.m4a", "cba.opus"), m4a_to_opus_encoder_with_debug("abc.m4a", "cba.opus")), 182 | (("bla bla.m4a", "ble ble.m4a"), m4a_to_m4a_encoder_with_debug("bla bla.m4a", "ble ble.m4a")), 183 | (("😛.m4a", "• tongue.flac"), m4a_to_flac_encoder_with_debug("😛.m4a", "• tongue.flac")), 184 | (("example.m4a", "example.ogg"), m4a_to_ogg_encoder_with_debug("example.m4a", "example.ogg")), 185 | (("example.m4a", "example.opus"), m4a_to_opus_encoder_with_debug("example.m4a", "example.opus")), 186 | ]) 187 | def test_generate_encode_command_with_debug(self, files, expected_command): 188 | encoder = EncoderFFmpeg() 189 | encoder.set_debuglog() 190 | assert encoder._generate_encode_command(*files) == expected_command 191 | 192 | 193 | class TestEncodingAndTrimSilence: 194 | def m4a_to_mp3_encoder_and_trim_silence(input_path, target_path): 195 | command = [ 196 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 197 | '-i', input_path, 198 | '-codec:v', 'copy', 199 | '-codec:a', 'libmp3lame', 200 | '-b:a', '192k', 201 | '-vn', 202 | '-af', 'silenceremove=start_periods=1', 203 | '-f', 'mp3', 204 | target_path 205 | ] 206 | return command 207 | 208 | def m4a_to_opus_encoder_and_trim_silence(input_path, target_path): 209 | command = [ 210 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 211 | '-i', input_path, 212 | '-codec:a', 'libopus', 213 | '-b:a', '192k', 214 | '-vn', 215 | '-af', 'silenceremove=start_periods=1', 216 | '-f', 'opus', 217 | target_path 218 | ] 219 | return command 220 | 221 | def m4a_to_m4a_encoder_and_trim_silence(input_path, target_path): 222 | command = [ 223 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 224 | '-i', input_path, 225 | '-acodec', 'copy', 226 | '-b:a', '192k', 227 | '-vn', 228 | '-af', 'silenceremove=start_periods=1', 229 | '-f', 'mp4', 230 | target_path 231 | ] 232 | return command 233 | 234 | def m4a_to_flac_encoder_and_trim_silence(input_path, target_path): 235 | command = [ 236 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 237 | '-i', input_path, 238 | '-codec:a', 'flac', 239 | '-b:a', '192k', 240 | '-vn', 241 | '-af', 'silenceremove=start_periods=1', 242 | '-f', 'flac', 243 | target_path 244 | ] 245 | return command 246 | 247 | def m4a_to_ogg_encoder_and_trim_silence(input_path, target_path): 248 | command = [ 249 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 250 | '-i', input_path, 251 | '-codec:a', 'libvorbis', 252 | '-q:a', '5', 253 | '-b:a', '192k', 254 | '-vn', 255 | '-af', 'silenceremove=start_periods=1', 256 | '-f', 'ogg', 257 | target_path 258 | ] 259 | return command 260 | 261 | def m4a_to_opus_encoder_and_trim_silence(input_path, target_path): 262 | command = [ 263 | 'ffmpeg', '-y', '-nostdin', '-hide_banner', '-nostats', '-v', 'warning', 264 | '-i', input_path, 265 | '-codec:a', 'libopus', 266 | '-b:a', '192k', 267 | '-vn', 268 | '-af', 'silenceremove=start_periods=1', 269 | '-f', 'opus', 270 | target_path 271 | ] 272 | return command 273 | 274 | @pytest.mark.parametrize("files, expected_command", [ 275 | (("test.m4a", "test.mp3"), m4a_to_mp3_encoder_and_trim_silence("test.m4a", "test.mp3")), 276 | (("abc.m4a", "cba.opus"), m4a_to_opus_encoder_and_trim_silence("abc.m4a", "cba.opus")), 277 | (("bla bla.m4a", "ble ble.m4a"), m4a_to_m4a_encoder_and_trim_silence("bla bla.m4a", "ble ble.m4a")), 278 | (("😛.m4a", "• tongue.flac"), m4a_to_flac_encoder_and_trim_silence("😛.m4a", "• tongue.flac")), 279 | (("example.m4a", "example.ogg"), m4a_to_ogg_encoder_and_trim_silence("example.m4a", "example.ogg")), 280 | (("example.m4a", "example.opus"), m4a_to_opus_encoder_and_trim_silence("example.m4a", "example.opus")), 281 | ]) 282 | def test_generate_encode_command_and_trim_silence(self, files, expected_command): 283 | encoder = EncoderFFmpeg() 284 | encoder.set_trim_silence() 285 | assert encoder._generate_encode_command(*files) == expected_command 286 | -------------------------------------------------------------------------------- /spotdl/encode/exceptions.py: -------------------------------------------------------------------------------- 1 | class EncoderNotFoundError(Exception): 2 | __module__ = Exception.__module__ 3 | 4 | def __init__(self, message=None): 5 | super().__init__(message) 6 | 7 | 8 | class FFmpegNotFoundError(EncoderNotFoundError): 9 | __module__ = Exception.__module__ 10 | 11 | def __init__(self, message=None): 12 | super().__init__(message) 13 | 14 | -------------------------------------------------------------------------------- /spotdl/encode/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/encode/tests/__init__.py -------------------------------------------------------------------------------- /spotdl/encode/tests/test_encode_base.py: -------------------------------------------------------------------------------- 1 | from spotdl.encode import EncoderBase 2 | from spotdl.encode.exceptions import EncoderNotFoundError 3 | 4 | import pytest 5 | 6 | class TestAbstractBaseClass: 7 | def test_error_abstract_base_class_encoderbase(self): 8 | encoder_path = "ffmpeg" 9 | _loglevel = "-hide_banner -nostats -v panic" 10 | _additional_arguments = ["-b:a", "192k", "-vn"] 11 | 12 | with pytest.raises(TypeError): 13 | # This abstract base class must be inherited from 14 | # for instantiation 15 | EncoderBase(encoder_path, _loglevel, _additional_arguments) 16 | 17 | 18 | def test_inherit_abstract_base_class_encoderbase(self): 19 | class EncoderKid(EncoderBase): 20 | def __init__(self, encoder_path, _loglevel, _additional_arguments): 21 | super().__init__(encoder_path, _loglevel, _additional_arguments) 22 | 23 | def _generate_encode_command(self): 24 | pass 25 | 26 | def _generate_encoding_arguments(self): 27 | pass 28 | 29 | def re_encode(self): 30 | pass 31 | 32 | def set_debuglog(self): 33 | pass 34 | 35 | 36 | encoder_path = "ffmpeg" 37 | _loglevel = "-hide_banner -nostats -v panic" 38 | _additional_arguments = ["-b:a", "192k", "-vn"] 39 | 40 | EncoderKid(encoder_path, _loglevel, _additional_arguments) 41 | 42 | 43 | class TestMethods: 44 | class EncoderKid(EncoderBase): 45 | def __init__(self, encoder_path, _loglevel, _additional_arguments): 46 | super().__init__(encoder_path, _loglevel, _additional_arguments) 47 | 48 | def _generate_encode_command(self, input_path, target_path): 49 | pass 50 | 51 | def _generate_encoding_arguments(self, input_encoding, target_encoding): 52 | pass 53 | 54 | def re_encode(self, input_encoding, target_encoding): 55 | pass 56 | 57 | def set_debuglog(self): 58 | pass 59 | 60 | 61 | @pytest.fixture(scope="module") 62 | def encoderkid(self): 63 | encoder_path = "ffmpeg" 64 | _loglevel = "-hide_banner -nostats -v panic" 65 | _additional_arguments = [] 66 | 67 | encoderkid = self.EncoderKid(encoder_path, _loglevel, _additional_arguments) 68 | return encoderkid 69 | 70 | def test_set_argument(self, encoderkid): 71 | encoderkid.set_argument("-parameter argument") 72 | assert encoderkid._additional_arguments == [ 73 | "-parameter", 74 | "argument", 75 | ] 76 | 77 | @pytest.mark.parametrize("filename, encoding", [ 78 | ("example.m4a", "m4a"), 79 | ("exampley.mp3", "mp3"), 80 | ("test 123.opus", "opus"), 81 | ("flakey.flac", "flac"), 82 | ("example.ogg", "ogg"), 83 | ("example.opus", "opus"), 84 | ]) 85 | def test_get_encoding(self, encoderkid, filename, encoding): 86 | assert encoderkid.get_encoding(filename) == encoding 87 | 88 | def test_encoder_not_found_error(self): 89 | with pytest.raises(EncoderNotFoundError): 90 | self.EncoderKid("/a/nonexistent/path", "0", []) 91 | 92 | @pytest.mark.parametrize("encoding, target_format", [ 93 | ("m4a", "mp4"), 94 | ("mp3", "mp3"), 95 | ("opus", "opus"), 96 | ("flac", "flac"), 97 | ("ogg", "ogg"), 98 | ]) 99 | def test_target_format_from_encoding(self, encoderkid, encoding, target_format): 100 | assert encoderkid.target_format_from_encoding(encoding) == target_format 101 | -------------------------------------------------------------------------------- /spotdl/encode/tests/test_encode_exceptions.py: -------------------------------------------------------------------------------- 1 | from spotdl.encode.exceptions import EncoderNotFoundError 2 | from spotdl.encode.exceptions import FFmpegNotFoundError 3 | 4 | 5 | class TestEncoderNotFoundSubclass: 6 | def test_encoder_not_found_subclass(self): 7 | assert issubclass(FFmpegNotFoundError, Exception) 8 | 9 | def test_ffmpeg_not_found_subclass(self): 10 | assert issubclass(FFmpegNotFoundError, EncoderNotFoundError) 11 | 12 | -------------------------------------------------------------------------------- /spotdl/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.helpers.spotify import SpotifyHelpers 2 | 3 | -------------------------------------------------------------------------------- /spotdl/helpers/exceptions.py: -------------------------------------------------------------------------------- 1 | class PlaylistNotFoundError(Exception): 2 | __module__ = Exception.__module__ 3 | 4 | def __init__(self, message=None): 5 | super().__init__(message) 6 | 7 | 8 | class AlbumNotFoundError(Exception): 9 | __module__ = Exception.__module__ 10 | 11 | def __init__(self, message=None): 12 | super().__init__(message) 13 | 14 | 15 | class ArtistNotFoundError(Exception): 16 | __module__ = Exception.__module__ 17 | 18 | def __init__(self, message=None): 19 | super().__init__(message) 20 | 21 | 22 | class UserNotFoundError(Exception): 23 | __module__ = Exception.__module__ 24 | 25 | def __init__(self, message=None): 26 | super().__init__(message) 27 | 28 | 29 | class SpotifyPlaylistNotFoundError(PlaylistNotFoundError): 30 | __module__ = Exception.__module__ 31 | 32 | def __init__(self, message=None): 33 | super().__init__(message) 34 | 35 | 36 | class SpotifyAlbumNotFoundError(AlbumNotFoundError): 37 | __module__ = Exception.__module__ 38 | 39 | def __init__(self, message=None): 40 | super().__init__(message) 41 | 42 | 43 | class SpotifyArtistNotFoundError(ArtistNotFoundError): 44 | __module__ = Exception.__module__ 45 | 46 | def __init__(self, message=None): 47 | super().__init__(message) 48 | 49 | 50 | class SpotifyUserNotFoundError(UserNotFoundError): 51 | __module__ = Exception.__module__ 52 | 53 | def __init__(self, message=None): 54 | super().__init__(message) 55 | 56 | -------------------------------------------------------------------------------- /spotdl/helpers/spotify.py: -------------------------------------------------------------------------------- 1 | from spotdl.authorize.services import AuthorizeSpotify 2 | import spotdl.helpers.exceptions 3 | import spotdl.util 4 | 5 | import sys 6 | import spotipy 7 | 8 | import logging 9 | logger = logging.getLogger(__name__) 10 | 11 | try: 12 | from slugify import SLUG_OK, slugify 13 | except ImportError: 14 | logger.error("Oops! `unicode-slugify` was not found.") 15 | logger.info("Please remove any other slugify library and install `unicode-slugify`.") 16 | raise 17 | 18 | 19 | class SpotifyHelpers: 20 | """ 21 | Provides helper methods for accessing the Spotify API. 22 | 23 | Parameters 24 | ---------- 25 | spotify: :class:`spotdl.authorize.services.AuthorizeSpotify`, :class:`spotipy.Spotify`, ``None`` 26 | An authorized instance to make API calls to Spotify endpoints. 27 | 28 | If ``None``, it will attempt to reference an already created 29 | :class:`spotdl.authorize.services.AuthorizeSpotify` instance. 30 | """ 31 | 32 | def __init__(self, spotify=None): 33 | if spotify is None: 34 | spotify = AuthorizeSpotify() 35 | self.spotify = spotify 36 | 37 | def prompt_for_user_playlist(self, username): 38 | """ 39 | An interactive method that will display user's playlists 40 | and prompt to make a selection. 41 | 42 | Parameters 43 | ---------- 44 | username: `str` 45 | Spotfiy username. 46 | 47 | Returns 48 | ------- 49 | spotify_uri: `str` 50 | Spotify URI for the selected playlist. 51 | """ 52 | playlists = self.fetch_user_playlist_urls(username) 53 | for i, playlist in enumerate(playlists, 1): 54 | playlist_details = "{0}. {1:<30} ({2} tracks)".format( 55 | i, playlist["name"], playlist["tracks"]["total"] 56 | ) 57 | print(playlist_details, file=sys.stderr) 58 | print("", file=sys.stderr) 59 | playlist = spotdl.util.prompt_user_for_selection(playlists) 60 | return playlist["external_urls"]["spotify"] 61 | 62 | def fetch_user_playlist_urls(self, username): 63 | """ 64 | Fetches all user's playlists. 65 | 66 | Parameters 67 | ---------- 68 | username: `str` 69 | Spotfiy username. 70 | 71 | Returns 72 | ------- 73 | playlist_uris: `list` 74 | Containing all playlist URIs. 75 | """ 76 | logger.debug('Fetching playlists for "{username}".'.format(username=username)) 77 | try: 78 | playlists = self.spotify.user_playlists(username) 79 | except spotipy.client.SpotifyException: 80 | msg = ('Unable to find user "{}". Make sure the the user ID is correct ' 81 | 'and then try again.'.format(username)) 82 | logger.error(msg) 83 | raise spotdl.helpers.exceptions.SpotifyUserNotFoundError(msg) 84 | else: 85 | collected_playlists = [] 86 | while True: 87 | for playlist in playlists["items"]: 88 | # in rare cases, playlists may not be found, so playlists['next'] 89 | # is None. Skip these. Also see Issue #91. 90 | if playlist["name"] is not None: 91 | collected_playlists.append(playlist) 92 | if playlists["next"]: 93 | playlists = self.spotify.next(playlists) 94 | else: 95 | break 96 | return collected_playlists 97 | 98 | def fetch_playlist(self, playlist_uri): 99 | """ 100 | Fetches playlist. 101 | 102 | Parameters 103 | ---------- 104 | playlist_uri: `str` 105 | Spotify playlist URI. 106 | 107 | Returns 108 | ------- 109 | playlist: `dict` 110 | Spotify API response object for the playlist endpoint. 111 | """ 112 | logger.debug('Fetching playlist "{playlist}".'.format(playlist=playlist_uri)) 113 | try: 114 | playlist = self.spotify.playlist(playlist_uri, fields="tracks,next,name") 115 | except spotipy.client.SpotifyException: 116 | msg = ('Unable to find playlist "{}". Make sure the the playlist ID is correct ' 117 | 'and the playlist is set to publicly visible, and then try again.'.format( 118 | playlist_uri 119 | )) 120 | logger.error(msg) 121 | raise spotdl.helpers.exceptions.SpotifyPlaylistNotFoundError(msg) 122 | else: 123 | return playlist 124 | 125 | def write_playlist_tracks(self, playlist, target_path=None): 126 | """ 127 | Writes playlist track URIs to file. 128 | 129 | Parameters 130 | ---------- 131 | playlist: `dict` 132 | Spotify API response object for the playlist endpoint. 133 | 134 | target_path: `str` 135 | Write Spotify track URIs to this file. 136 | """ 137 | tracks = playlist["tracks"] 138 | if not target_path: 139 | target_path = u"{0}.txt".format(slugify(playlist["name"], ok="-_()[]{}")) 140 | return self.write_tracks(tracks, target_path) 141 | 142 | def fetch_album(self, album_uri): 143 | """ 144 | Fetches album. 145 | 146 | Parameters 147 | ---------- 148 | album_uri: `str` 149 | Spotify album URI. 150 | 151 | Returns 152 | ------- 153 | album: `dict` 154 | Spotify API response object for the album endpoint. 155 | """ 156 | logger.debug('Fetching album "{album}".'.format(album=album_uri)) 157 | try: 158 | album = self.spotify.album(album_uri) 159 | except spotipy.client.SpotifyException: 160 | msg = ('Unable to find album "{}". Make sure the album ID is correct ' 161 | 'and then try again.'.format(album_uri)) 162 | logger.error(msg) 163 | raise spotdl.helpers.exceptions.SpotifyAlbumNotFoundError(msg) 164 | else: 165 | return album 166 | 167 | def write_album_tracks(self, album, target_path=None): 168 | """ 169 | Writes album track URIs to file. 170 | 171 | Parameters 172 | ---------- 173 | album: `dict` 174 | Spotify API response object for the album endpoint. 175 | 176 | target_path: `str` 177 | Write Spotify track URIs to this file. 178 | """ 179 | tracks = self.spotify.album_tracks(album["id"]) 180 | if not target_path: 181 | target_path = u"{0}.txt".format(slugify(album["name"], ok="-_()[]{}")) 182 | return self.write_tracks(tracks, target_path) 183 | 184 | def fetch_albums_from_artist(self, artist_uri, album_type=None): 185 | """ 186 | Fetches all Spotify albums from a Spotify artist in the US 187 | market. 188 | 189 | Parameters 190 | ---------- 191 | artist_uri: `str` 192 | Spotify artist URI. 193 | 194 | album_type: `str` 195 | The type of album to fetch (ex: single) the default is 196 | all albums. 197 | 198 | Returns 199 | ------- 200 | abums: `str` 201 | All albums received in the Spotify API response object. 202 | """ 203 | logger.debug('Fetching all albums for "{artist}".'.format(artist=artist_uri)) 204 | # fetching artist's albums limitting the results to the US to avoid duplicate 205 | # albums from multiple markets 206 | try: 207 | results = self.spotify.artist_albums(artist_uri, album_type=album_type, country="US") 208 | except spotipy.client.SpotifyException: 209 | msg = ('Unable to find artist "{}". Make sure the artist ID is correct ' 210 | 'and then try again.'.format(artist_uri)) 211 | logger.error(msg) 212 | raise spotdl.helpers.exceptions.SpotifyArtistNotFoundError(msg) 213 | else: 214 | albums = results["items"] 215 | # indexing all pages of results 216 | while results["next"]: 217 | results = self.spotify.next(results) 218 | albums.extend(results["items"]) 219 | return albums 220 | 221 | def write_all_albums(self, albums, target_path=None): 222 | """ 223 | Writes tracks from all albums into a file. 224 | 225 | Parameters 226 | ---------- 227 | albums: `str` 228 | Spotfiy API response received in :func:`fetch_albums_from_artist`. 229 | 230 | target_path: `str` 231 | Write Spotify track URIs to this file. 232 | """ 233 | # if no file if given, the default save file is in the current working 234 | # directory with the name of the artist 235 | if target_path is None: 236 | target_path = albums[0]["artists"][0]["name"] + ".txt" 237 | 238 | for album in albums: 239 | logger.info('Fetching album "{album}".'.format(album=album["name"])) 240 | self.write_album_tracks(album, target_path=target_path) 241 | 242 | def write_tracks(self, tracks, target_path): 243 | """ 244 | Writes Spotify track URIs to file 245 | 246 | Parameters 247 | ---------- 248 | tracks: `list` 249 | As returned in the Spotify API response. 250 | 251 | target_path: `str` 252 | Writes track URIs to this file. 253 | """ 254 | def writer(tracks, file_io): 255 | track_urls = [] 256 | while True: 257 | for item in tracks["items"]: 258 | if "track" in item: 259 | track = item["track"] 260 | else: 261 | track = item 262 | # Spotify sometimes returns additional empty "tracks" in the API 263 | # response. We need to discard such "tracks". 264 | # See https://github.com/spotify/web-api/issues/1562 265 | if track is None: 266 | continue 267 | try: 268 | track_url = track["external_urls"]["spotify"] 269 | file_io.write(track_url + "\n") 270 | track_urls.append(track_url) 271 | except KeyError: 272 | # FIXME: Write "{artist} - {name}" instead of Spotify URI for 273 | # "local only" tracks. 274 | logger.warning( 275 | 'Skipping track "{0}" by "{1}" (local only?)'.format( 276 | track["name"], track["artists"][0]["name"] 277 | ) 278 | ) 279 | # 1 page = 50 results 280 | # check if there are more pages 281 | if tracks["next"]: 282 | tracks = self.spotify.next(tracks) 283 | else: 284 | break 285 | return track_urls 286 | 287 | logger.info(u"Writing {0} tracks to {1}.".format(tracks["total"], target_path)) 288 | write_to_stdout = target_path == "-" 289 | if write_to_stdout: 290 | file_out = sys.stdout 291 | track_urls = writer(tracks, file_out) 292 | else: 293 | with open(target_path, "a") as file_out: 294 | track_urls = writer(tracks, file_out) 295 | return track_urls 296 | 297 | -------------------------------------------------------------------------------- /spotdl/lyrics/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.lyrics.lyric_base import LyricBase 2 | -------------------------------------------------------------------------------- /spotdl/lyrics/exceptions.py: -------------------------------------------------------------------------------- 1 | class LyricsNotFoundError(Exception): 2 | __module__ = Exception.__module__ 3 | 4 | def __init__(self, message=None): 5 | super().__init__(message) 6 | -------------------------------------------------------------------------------- /spotdl/lyrics/lyric_base.py: -------------------------------------------------------------------------------- 1 | import lyricwikia 2 | 3 | from abc import ABC 4 | from abc import abstractmethod 5 | 6 | 7 | class LyricBase(ABC): 8 | """ 9 | Defined lyric providers must inherit from this abstract base 10 | class and implement their own functionality for the below 11 | defined methods. 12 | """ 13 | 14 | def from_url(self, url, linesep="\n", timeout=None): 15 | """ 16 | Fetches lyrics given a URL. 17 | 18 | Parameters 19 | ---------- 20 | url: `str` 21 | URL to fetch lyrics from. 22 | 23 | linesep: `str` 24 | Use this separator between every line of the lyrics. 25 | 26 | timeout: `int`, `None` 27 | Timeout duration such as if the server doesn't return a 28 | response in an expected time frame. 29 | 30 | Returns 31 | ------- 32 | lyrics: `str` 33 | """ 34 | raise NotImplementedError 35 | 36 | def from_artist_and_track(self, artist, track, linesep="\n", timeout=None): 37 | """ 38 | Fetches lyrics given an artist and the track name. 39 | 40 | Parameters 41 | ---------- 42 | artist: `str` 43 | Artist name. 44 | 45 | track: `str` 46 | Track name. 47 | 48 | linesep: `str` 49 | Use this separator between every line of the lyrics. 50 | 51 | timeout: `int`, `None` 52 | Timeout duration such as if the server doesn't return a 53 | response in an expected time frame. 54 | 55 | Returns 56 | ------- 57 | lyrics: `str` 58 | """ 59 | raise NotImplementedError 60 | 61 | def from_query(self, query, linesep="\n", timeout=None): 62 | """ 63 | Fetches lyrics given a search query. 64 | 65 | Parameters 66 | ---------- 67 | query: `str` 68 | A search query. 69 | 70 | linesep: `str` 71 | Use this separator between every line of the lyrics. 72 | 73 | timeout: `int`, `None` 74 | Timeout duration such as if the server doesn't return a 75 | response in an expected time frame. 76 | 77 | Returns 78 | ------- 79 | lyrics: `str` 80 | """ 81 | raise NotImplementedError 82 | 83 | -------------------------------------------------------------------------------- /spotdl/lyrics/providers/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.lyrics.providers.genius import Genius 2 | from spotdl.lyrics.providers.lyricwikia_wrapper import LyricWikia 3 | 4 | LyricClasses = (Genius, LyricWikia) 5 | -------------------------------------------------------------------------------- /spotdl/lyrics/providers/genius.py: -------------------------------------------------------------------------------- 1 | from bs4 import BeautifulSoup 2 | import urllib.request 3 | import json 4 | 5 | from spotdl.lyrics.lyric_base import LyricBase 6 | from spotdl.lyrics.exceptions import LyricsNotFoundError 7 | 8 | import logging 9 | logger = logging.getLogger(__name__) 10 | 11 | BASE_URL = "https://genius.com" 12 | BASE_SEARCH_URL = BASE_URL + "/api/search/multi?per_page=1&q=" 13 | 14 | # FIXME: Make Genius a metadata provider instead of lyric provider 15 | # Since, Genius parses additional metadata too (such as track 16 | # name, artist name, albumart url). For example, fetch this URL: 17 | # https://genius.com/api/search/multi?per_page=1&q=artist+trackname 18 | 19 | class Genius(LyricBase): 20 | """ 21 | Fetch lyrics from Genius. 22 | 23 | Examples 24 | -------- 25 | + Fetching lyrics for *"Tobu - Cruel"*: 26 | 27 | >>> from spotdl.lyrics.providers import Genius 28 | >>> genius = Genius() 29 | >>> lyrics = genius.from_query("tobu cruel") 30 | >>> print(lyrics) 31 | """ 32 | 33 | def __init__(self): 34 | self.base_url = BASE_URL 35 | self.base_search_url = BASE_SEARCH_URL 36 | 37 | def _guess_lyric_url_from_artist_and_track(self, artist, track): 38 | """ 39 | Returns the possible lyric URL for the track available on 40 | Genius. This may not always be a valid URL. 41 | 42 | Parameters 43 | ---------- 44 | artist: `str` 45 | Artist name. 46 | 47 | track: `str` 48 | Track name. 49 | """ 50 | query = "/{} {} lyrics".format(artist, track) 51 | query = query.replace(" ", "-") 52 | encoded_query = urllib.request.quote(query) 53 | lyric_url = self.base_url + encoded_query 54 | return lyric_url 55 | 56 | def _fetch_url_page(self, url, timeout=None): 57 | """ 58 | Makes a GET request to the given lyrics page URL and returns 59 | the HTML content in the case of a valid response. 60 | """ 61 | request = urllib.request.Request(url) 62 | request.add_header("User-Agent", "urllib") 63 | try: 64 | response = urllib.request.urlopen(request, timeout=timeout) 65 | except urllib.request.HTTPError: 66 | raise LyricsNotFoundError( 67 | "Could not find Genius lyrics at URL: {}".format(url) 68 | ) 69 | else: 70 | return response.read() 71 | 72 | def _get_lyrics_text(self, paragraph): 73 | """ 74 | Extracts and returns the lyric content from the provided HTML. 75 | """ 76 | if paragraph: 77 | return paragraph.get_text() 78 | else: 79 | raise LyricsNotFoundError( 80 | "The lyrics for this track are yet to be released on Genius." 81 | ) 82 | 83 | def _fetch_search_page(self, url, timeout=None): 84 | """ 85 | Returns search results from a given URL in JSON. 86 | """ 87 | request = urllib.request.Request(url) 88 | request.add_header("User-Agent", "urllib") 89 | response = urllib.request.urlopen(request, timeout=timeout) 90 | metadata = json.loads(response.read()) 91 | if len(metadata["response"]["sections"][0]["hits"]) == 0: 92 | raise LyricsNotFoundError( 93 | "Genius returned no lyric results for the search URL: {}".format(url) 94 | ) 95 | return metadata 96 | 97 | def best_matching_lyric_url_from_query(self, query): 98 | """ 99 | Fetches the best matching lyric track URL for a given query. 100 | 101 | Parameters 102 | ---------- 103 | query: `str` 104 | The search query. 105 | 106 | Returns 107 | ------- 108 | lyric_url: `str` 109 | The best matching track lyric URL on Genius. 110 | """ 111 | encoded_query = urllib.request.quote(query.replace(" ", "+")) 112 | search_url = self.base_search_url + encoded_query 113 | logger.debug('Fetching Genius search results from "{}".'.format(search_url)) 114 | metadata = self._fetch_search_page(search_url) 115 | 116 | lyric_url = None 117 | for section in metadata["response"]["sections"]: 118 | result = section["hits"][0]["result"] 119 | try: 120 | lyric_url = result["path"] 121 | break 122 | except KeyError: 123 | pass 124 | 125 | if lyric_url is None: 126 | raise LyricsNotFoundError( 127 | "Could not find any valid lyric paths in Genius " 128 | "lyrics API response for the query {}.".format(query) 129 | ) 130 | 131 | return self.base_url + lyric_url 132 | 133 | def from_query(self, query, linesep="\n", timeout=None): 134 | logger.debug('Fetching lyrics for the search query on "{}".'.format(query)) 135 | try: 136 | lyric_url = self.best_matching_lyric_url_from_query(query) 137 | except LyricsNotFoundError: 138 | raise LyricsNotFoundError( 139 | 'Genius returned no lyric results for the search query "{}".'.format(query) 140 | ) 141 | else: 142 | return self.from_url(lyric_url, linesep, timeout=timeout) 143 | 144 | def from_artist_and_track(self, artist, track, linesep="\n", timeout=None): 145 | lyric_url = self._guess_lyric_url_from_artist_and_track(artist, track) 146 | return self.from_url(lyric_url, linesep, timeout=timeout) 147 | 148 | def from_url(self, url, linesep="\n", retries=5, timeout=None): 149 | logger.debug('Fetching lyric text from "{}".'.format(url)) 150 | lyric_html_page = self._fetch_url_page(url, timeout=timeout) 151 | soup = BeautifulSoup(lyric_html_page, "html.parser") 152 | paragraph = soup.find("p") 153 | # If

is not found or 154 | # if

has a class (like

), then we got an invalid 155 | # response. 156 | # Retry in such a case. 157 | invalid_response = paragraph is None or paragraph.get("class") is not None 158 | to_retry = retries > 0 and invalid_response 159 | if to_retry: 160 | logger.debug( 161 | "Retrying since Genius returned invalid response for search " 162 | "results. Retries left: {retries}.".format(retries=retries) 163 | ) 164 | return self.from_url(url, linesep=linesep, retries=retries-1, timeout=timeout) 165 | 166 | if invalid_response: 167 | raise LyricsNotFoundError( 168 | 'Genius returned invalid response for the search URL "{}".'.format(url) 169 | ) 170 | lyrics = self._get_lyrics_text(paragraph) 171 | return lyrics.replace("\n", linesep) 172 | 173 | -------------------------------------------------------------------------------- /spotdl/lyrics/providers/lyricwikia_wrapper.py: -------------------------------------------------------------------------------- 1 | import lyricwikia 2 | 3 | from spotdl.lyrics.lyric_base import LyricBase 4 | from spotdl.lyrics.exceptions import LyricsNotFoundError 5 | 6 | 7 | class LyricWikia(LyricBase): 8 | """ 9 | Fetch lyrics from LyricWikia. 10 | 11 | Examples 12 | -------- 13 | + Fetching lyrics for *"Tobu - Cruel"*: 14 | 15 | >>> from spotdl.lyrics.providers import LyricWikia 16 | >>> genius = LyricWikia() 17 | >>> lyrics = genius.from_artist_and_track("Tobu", "Cruel") 18 | >>> print(lyrics) 19 | """ 20 | 21 | def from_artist_and_track(self, artist, track, linesep="\n", timeout=None): 22 | """ 23 | Returns the lyric string for the given artist and track. 24 | """ 25 | try: 26 | lyrics = lyricwikia.get_lyrics(artist, track, linesep, timeout) 27 | except lyricwikia.LyricsNotFound as e: 28 | raise LyricsNotFoundError(e.args[0]) 29 | return lyrics 30 | 31 | -------------------------------------------------------------------------------- /spotdl/lyrics/providers/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/lyrics/providers/tests/__init__.py -------------------------------------------------------------------------------- /spotdl/lyrics/providers/tests/test_genius.py: -------------------------------------------------------------------------------- 1 | from spotdl.lyrics import LyricBase 2 | from spotdl.lyrics import exceptions 3 | from spotdl.lyrics.providers import Genius 4 | 5 | import urllib.request 6 | import json 7 | import pytest 8 | 9 | class TestGenius: 10 | def test_subclass(self): 11 | assert issubclass(Genius, LyricBase) 12 | 13 | @pytest.fixture(scope="module") 14 | def expect_lyrics_count(self): 15 | # This is the number of characters in lyrics found 16 | # for the track in `lyric_url` fixture below 17 | return 1845 18 | 19 | @pytest.fixture(scope="module") 20 | def genius(self): 21 | return Genius() 22 | 23 | def test_base_url(self, genius): 24 | assert genius.base_url == "https://genius.com" 25 | 26 | @pytest.fixture(scope="module") 27 | def artist(self): 28 | return "selena gomez" 29 | 30 | @pytest.fixture(scope="module") 31 | def track(self): 32 | return "wolves" 33 | 34 | @pytest.fixture(scope="module") 35 | def query(self, artist, track): 36 | return "{} {}".format(artist, track) 37 | 38 | @pytest.fixture(scope="module") 39 | def guess_url(self, query): 40 | return "https://genius.com/selena-gomez-wolves-lyrics" 41 | 42 | @pytest.fixture(scope="module") 43 | def lyric_url(self): 44 | return "https://genius.com/Selena-gomez-and-marshmello-wolves-lyrics" 45 | 46 | def test_guess_lyric_url_from_artist_and_track(self, genius, artist, track, guess_url): 47 | url = genius._guess_lyric_url_from_artist_and_track(artist, track) 48 | assert url == guess_url 49 | 50 | class MockHTTPResponse: 51 | expect_lyrics = "" 52 | 53 | def __init__(self, request, timeout=None): 54 | search_results_url = "https://genius.com/api/search/multi?per_page=1&q=selena%2Bgomez%2Bwolves" 55 | if request._full_url == search_results_url: 56 | read_method = lambda: json.dumps({ 57 | "response": {"sections": [{"hits": [{"result": { 58 | "path": "/Selena-gomez-and-marshmello-wolves-lyrics" 59 | } }] }] } 60 | }) 61 | else: 62 | read_method = lambda: "

" + self.expect_lyrics + "

" 63 | 64 | self.read = read_method 65 | 66 | @pytest.mark.network 67 | def test_best_matching_lyric_url_from_query(self, genius, query, lyric_url): 68 | url = genius.best_matching_lyric_url_from_query(query) 69 | assert url == lyric_url 70 | 71 | def test_mock_best_matching_lyric_url_from_query(self, genius, query, lyric_url, monkeypatch): 72 | monkeypatch.setattr("urllib.request.urlopen", self.MockHTTPResponse) 73 | self.test_best_matching_lyric_url_from_query(genius, query, lyric_url) 74 | 75 | @pytest.mark.network 76 | def test_from_url(self, genius, lyric_url, expect_lyrics_count): 77 | lyrics = genius.from_url(lyric_url) 78 | assert len(lyrics) == expect_lyrics_count 79 | 80 | def test_mock_from_url(self, genius, lyric_url, expect_lyrics_count, monkeypatch): 81 | self.MockHTTPResponse.expect_lyrics = "a" * expect_lyrics_count 82 | monkeypatch.setattr("urllib.request.urlopen", self.MockHTTPResponse) 83 | self.test_from_url(genius, lyric_url, expect_lyrics_count) 84 | 85 | @pytest.mark.network 86 | def test_from_artist_and_track(self, genius, artist, track, expect_lyrics_count): 87 | lyrics = genius.from_artist_and_track(artist, track) 88 | assert len(lyrics) == expect_lyrics_count 89 | 90 | def test_mock_from_artist_and_track(self, genius, artist, track, expect_lyrics_count, monkeypatch): 91 | self.MockHTTPResponse.expect_lyrics = "a" * expect_lyrics_count 92 | monkeypatch.setattr("urllib.request.urlopen", self.MockHTTPResponse) 93 | self.test_from_artist_and_track(genius, artist, track, expect_lyrics_count) 94 | 95 | @pytest.mark.network 96 | def test_from_query(self, genius, query, expect_lyrics_count): 97 | lyrics = genius.from_query(query) 98 | assert len(lyrics) == expect_lyrics_count 99 | 100 | def test_mock_from_query(self, genius, query, expect_lyrics_count, monkeypatch): 101 | self.MockHTTPResponse.expect_lyrics = "a" * expect_lyrics_count 102 | monkeypatch.setattr("urllib.request.urlopen", self.MockHTTPResponse) 103 | self.test_from_query(genius, query, expect_lyrics_count) 104 | 105 | @pytest.mark.network 106 | def test_lyrics_not_found_error(self, genius): 107 | with pytest.raises(exceptions.LyricsNotFoundError): 108 | genius.from_artist_and_track(self, "nonexistent_artist", "nonexistent_track") 109 | 110 | def test_mock_lyrics_not_found_error(self, genius, monkeypatch): 111 | def mock_urlopen(url, timeout=None): 112 | raise urllib.request.HTTPError("", "", "", "", "") 113 | 114 | monkeypatch.setattr("urllib.request.urlopen", mock_urlopen) 115 | self.test_lyrics_not_found_error(genius) 116 | 117 | -------------------------------------------------------------------------------- /spotdl/lyrics/providers/tests/test_lyricwikia_wrapper.py: -------------------------------------------------------------------------------- 1 | import lyricwikia 2 | 3 | from spotdl.lyrics import LyricBase 4 | from spotdl.lyrics import exceptions 5 | from spotdl.lyrics.providers import LyricWikia 6 | 7 | import pytest 8 | 9 | 10 | class TestLyricWikia: 11 | def test_subclass(self): 12 | assert issubclass(LyricWikia, LyricBase) 13 | 14 | def test_from_artist_and_track(self, monkeypatch): 15 | # `LyricWikia` class uses the 3rd party method `lyricwikia.get_lyrics` 16 | # internally and there is no need to test a 3rd party library as they 17 | # have their own implementation of tests. 18 | monkeypatch.setattr( 19 | "lyricwikia.get_lyrics", lambda a, b, c, d: "awesome lyrics!" 20 | ) 21 | artist, track = "selena gomez", "wolves" 22 | lyrics = LyricWikia().from_artist_and_track(artist, track) 23 | assert lyrics == "awesome lyrics!" 24 | 25 | def test_lyrics_not_found_error(self, monkeypatch): 26 | def lyricwikia_lyrics_not_found(msg): 27 | raise lyricwikia.LyricsNotFound(msg) 28 | 29 | # Wrap `lyricwikia.LyricsNotFoundError` with `exceptions.LyricsNotFoundError` error. 30 | monkeypatch.setattr( 31 | "lyricwikia.get_lyrics", 32 | lambda a, b, c, d: lyricwikia_lyrics_not_found("Nope, no lyrics."), 33 | ) 34 | artist, track = "nonexistent_artist", "nonexistent_track" 35 | with pytest.raises(exceptions.LyricsNotFoundError): 36 | LyricWikia().from_artist_and_track(artist, track) 37 | -------------------------------------------------------------------------------- /spotdl/lyrics/tests/test_lyric_base.py: -------------------------------------------------------------------------------- 1 | from spotdl.lyrics import LyricBase 2 | 3 | import pytest 4 | 5 | 6 | class TestAbstractBaseClass: 7 | def test_lyricbase(self): 8 | assert LyricBase() 9 | 10 | def test_inherit_abstract_base_class_encoderbase(self): 11 | class LyricKid(LyricBase): 12 | def from_query(self, query): 13 | raise NotImplementedError 14 | 15 | def from_artist_and_track(self, artist, track): 16 | pass 17 | 18 | def from_url(self, url): 19 | raise NotImplementedError 20 | 21 | LyricKid() 22 | -------------------------------------------------------------------------------- /spotdl/lyrics/tests/test_lyrics_exceptions.py: -------------------------------------------------------------------------------- 1 | from spotdl.lyrics.exceptions import LyricsNotFoundError 2 | 3 | def test_lyrics_not_found_subclass(): 4 | assert issubclass(LyricsNotFoundError, Exception) 5 | 6 | -------------------------------------------------------------------------------- /spotdl/metadata/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.provider_base import ProviderBase 2 | from spotdl.metadata.provider_base import StreamsBase 3 | 4 | from spotdl.metadata.exceptions import BadMediaFileError 5 | from spotdl.metadata.exceptions import MetadataNotFoundError 6 | from spotdl.metadata.exceptions import SpotifyMetadataNotFoundError 7 | from spotdl.metadata.exceptions import YouTubeMetadataNotFoundError 8 | 9 | from spotdl.metadata.embedder_base import EmbedderBase 10 | 11 | from spotdl.metadata.formatter import format_string 12 | 13 | -------------------------------------------------------------------------------- /spotdl/metadata/embedder_base.py: -------------------------------------------------------------------------------- 1 | import mutagen 2 | import os 3 | 4 | from abc import ABC 5 | from abc import abstractmethod 6 | 7 | import urllib.request 8 | 9 | from spotdl.metadata import BadMediaFileError 10 | 11 | class EmbedderBase(ABC): 12 | """ 13 | The subclass must define the supported media file encoding 14 | formats here using a static variable - such as: 15 | 16 | >>> supported_formats = ("mp3", "m4a", "flac", "ogg", "opus") 17 | """ 18 | supported_formats = () 19 | 20 | @abstractmethod 21 | def __init__(self): 22 | """ 23 | For every supported format, there must be a corresponding 24 | method that applies metadata on this format. 25 | 26 | Such as if mp3 is supported, there must exist a method named 27 | `as_mp3` on this class that applies metadata on mp3 files. 28 | """ 29 | # self.targets = { fmt: eval(str("self.as_" + fmt)) 30 | # for fmt in self.supported_formats } 31 | # 32 | # TODO: The above code seems to fail for some reason 33 | # I do not know. 34 | self.targets = {} 35 | for fmt in self.supported_formats: 36 | # FIXME: Calling `eval` is dangerous here! 37 | self.targets[fmt] = eval("self.as_" + fmt) 38 | 39 | def get_encoding(self, path): 40 | """ 41 | This method must determine the encoding for a local 42 | audio file. Such as "mp3", "wav", "m4a", etc. 43 | """ 44 | _, extension = os.path.splitext(path) 45 | # Ignore the initial dot from file extension 46 | return extension[1:] 47 | 48 | def apply_metadata(self, path, metadata, cached_albumart=None, encoding=None): 49 | """ 50 | This method must automatically detect the media encoding 51 | format from file path and embed the corresponding metadata 52 | on the given file by calling an appropriate submethod. 53 | """ 54 | if cached_albumart is None: 55 | cached_albumart = urllib.request.urlopen( 56 | metadata["album"]["images"][0]["url"], 57 | ).read() 58 | if encoding is None: 59 | encoding = self.get_encoding(path) 60 | if encoding not in self.supported_formats: 61 | raise BadMediaFileError( 62 | 'The input format ("{}") is not supported.'.format( 63 | encoding, 64 | )) 65 | embed_on_given_format = self.targets[encoding] 66 | try: 67 | embed_on_given_format(path, metadata, cached_albumart=cached_albumart) 68 | except (mutagen.id3.error, mutagen.flac.error, mutagen.oggopus.error): 69 | raise BadMediaFileError( 70 | 'Cannot apply metadata as "{}" is badly encoded as ' 71 | '"{}".'.format(path, encoding) 72 | ) 73 | 74 | def as_mp3(self, path, metadata, cached_albumart=None): 75 | """ 76 | Method for mp3 support. This method might be defined in 77 | a subclass. 78 | 79 | Other methods for additional supported formats must also 80 | be declared here. 81 | """ 82 | raise NotImplementedError 83 | 84 | def as_m4a(self, path, metadata, cached_albumart=None): 85 | """ 86 | Method for m4a support. This method might be defined in 87 | a subclass. 88 | 89 | Other methods for additional supported formats must also 90 | be declared here. 91 | """ 92 | raise NotImplementedError 93 | 94 | def as_flac(self, path, metadata, cached_albumart=None): 95 | """ 96 | Method for flac support. This method might be defined in 97 | a subclass. 98 | 99 | Other methods for additional supported formats must also 100 | be declared here. 101 | """ 102 | raise NotImplementedError 103 | 104 | def as_ogg(self, path, metadata, cached_albumart=None): 105 | """ 106 | Method for ogg support. This method might be defined in 107 | a subclass. 108 | 109 | Other methods for additional supported formats must also 110 | be declared here. 111 | """ 112 | raise NotImplementedError 113 | 114 | def as_opus(self, path, metadata, cached_albumart=None): 115 | """ 116 | Method for opus support. This method might be defined in 117 | a subclass. 118 | 119 | Other methods for additional supported formats must also 120 | be declared here. 121 | """ 122 | raise NotImplementedError 123 | -------------------------------------------------------------------------------- /spotdl/metadata/embedders/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.embedders.default_embedder import EmbedderDefault 2 | 3 | -------------------------------------------------------------------------------- /spotdl/metadata/embedders/default_embedder.py: -------------------------------------------------------------------------------- 1 | from mutagen.easyid3 import EasyID3 2 | from mutagen.id3 import ID3, TORY, TYER, TPUB, APIC, USLT, COMM 3 | from mutagen.mp4 import MP4, MP4Cover 4 | from mutagen.flac import Picture, FLAC 5 | from mutagen.oggvorbis import OggVorbis 6 | from mutagen.oggopus import OggOpus 7 | 8 | import urllib.request 9 | import base64 10 | 11 | from spotdl.metadata import EmbedderBase 12 | from spotdl.metadata import BadMediaFileError 13 | 14 | import logging 15 | logger = logging.getLogger(__name__) 16 | 17 | # Apple has specific tags - see mutagen docs - 18 | # http://mutagen.readthedocs.io/en/latest/api/mp4.html 19 | M4A_TAG_PRESET = { 20 | "album": "\xa9alb", 21 | "artist": "\xa9ART", 22 | "date": "\xa9day", 23 | "title": "\xa9nam", 24 | "year": "\xa9day", 25 | "originaldate": "purd", 26 | "comment": "\xa9cmt", 27 | "group": "\xa9grp", 28 | "writer": "\xa9wrt", 29 | "genre": "\xa9gen", 30 | "tracknumber": "trkn", 31 | "albumartist": "aART", 32 | "discnumber": "disk", 33 | "cpil": "cpil", 34 | "albumart": "covr", 35 | "copyright": "cprt", 36 | "tempo": "tmpo", 37 | "lyrics": "\xa9lyr", 38 | "comment": "\xa9cmt", 39 | "explicit": "rtng", 40 | } 41 | 42 | TAG_PRESET = {} 43 | for key in M4A_TAG_PRESET.keys(): 44 | TAG_PRESET[key] = key 45 | 46 | 47 | class EmbedderDefault(EmbedderBase): 48 | """ 49 | A class for applying metadata on media files. 50 | 51 | Examples 52 | -------- 53 | - Applying metadata on an already downloaded MP3 file: 54 | 55 | >>> from spotdl.metadata_search import MetadataSearch 56 | >>> provider = MetadataSearch("ncs spectre") 57 | >>> metadata = provider.on_youtube() 58 | >>> from spotdl.metadata.embedders import EmbedderDefault 59 | >>> embedder = EmbedderDefault() 60 | >>> embedder.as_mp3("media.mp3", metadata) 61 | """ 62 | supported_formats = ("mp3", "m4a", "flac", "ogg", "opus") 63 | 64 | def __init__(self): 65 | super().__init__() 66 | self._m4a_tag_preset = M4A_TAG_PRESET 67 | self._tag_preset = TAG_PRESET 68 | # self.provider = "spotify" if metadata["spotify_metadata"] else "youtube" 69 | def as_mp3(self, path, metadata, cached_albumart=None): 70 | """ 71 | Apply metadata on MP3 media files. 72 | 73 | Parameters 74 | ---------- 75 | path: `str` 76 | Path to the media file. 77 | 78 | metadata: `dict` 79 | Metadata (standardized) to apply to the media file. 80 | 81 | cached_albumart: `bool` 82 | An albumart image binary. If passed, the albumart URL 83 | present in the ``metadata`` won't be downloaded or used. 84 | """ 85 | logger.debug('Writing MP3 metadata to "{path}".'.format(path=path)) 86 | # EasyID3 is fun to use ;) 87 | # For supported easyid3 tags: 88 | # https://github.com/quodlibet/mutagen/blob/master/mutagen/easyid3.py 89 | # Check out somewhere at end of above linked file 90 | audiofile = EasyID3(path) 91 | self._embed_basic_metadata(audiofile, metadata, "mp3", preset=TAG_PRESET) 92 | audiofile["media"] = metadata["type"] 93 | audiofile["author"] = metadata["artists"][0]["name"] 94 | audiofile["lyricist"] = metadata["artists"][0]["name"] 95 | audiofile["arranger"] = metadata["artists"][0]["name"] 96 | audiofile["performer"] = metadata["artists"][0]["name"] 97 | provider = metadata["provider"] 98 | audiofile["website"] = metadata["external_urls"][provider] 99 | audiofile["length"] = str(metadata["duration"]) 100 | if metadata["publisher"]: 101 | audiofile["encodedby"] = metadata["publisher"] 102 | if metadata["external_ids"]["isrc"]: 103 | audiofile["isrc"] = metadata["external_ids"]["isrc"] 104 | audiofile.save(v2_version=3) 105 | 106 | # For supported id3 tags: 107 | # https://github.com/quodlibet/mutagen/blob/master/mutagen/id3/_frames.py 108 | # Each class in the linked source file represents an id3 tag 109 | audiofile = ID3(path) 110 | if metadata["year"]: 111 | audiofile["TORY"] = TORY(encoding=3, text=metadata["year"]) 112 | audiofile["TYER"] = TYER(encoding=3, text=metadata["year"]) 113 | if metadata["publisher"]: 114 | audiofile["TPUB"] = TPUB(encoding=3, text=metadata["publisher"]) 115 | provider = metadata["provider"] 116 | audiofile["COMM"] = COMM( 117 | encoding=3, text=metadata["external_urls"][provider] 118 | ) 119 | if metadata["lyrics"]: 120 | audiofile["USLT"] = USLT( 121 | encoding=3, desc=u"Lyrics", text=metadata["lyrics"] 122 | ) 123 | if cached_albumart is None: 124 | cached_albumart = urllib.request.urlopen( 125 | metadata["album"]["images"][0]["url"] 126 | ).read() 127 | try: 128 | audiofile["APIC"] = APIC( 129 | encoding=3, 130 | mime="image/jpeg", 131 | type=3, 132 | desc=u"Cover", 133 | data=cached_albumart, 134 | ) 135 | except IndexError: 136 | pass 137 | 138 | audiofile.save(v2_version=3) 139 | 140 | def as_m4a(self, path, metadata, cached_albumart=None): 141 | """ 142 | Apply metadata on FLAC media files. 143 | 144 | Parameters 145 | ---------- 146 | path: `str` 147 | Path to the media file. 148 | 149 | metadata: `dict` 150 | Metadata (standardized) to apply to the media file. 151 | 152 | cached_albumart: `bool` 153 | An albumart image binary. If passed, the albumart URL 154 | present in the ``metadata`` won't be downloaded or used. 155 | """ 156 | 157 | logger.debug('Writing M4A metadata to "{path}".'.format(path=path)) 158 | # For supported m4a tags: 159 | # https://github.com/quodlibet/mutagen/blob/master/mutagen/mp4/__init__.py 160 | # Look for the class named `MP4Tags` in the linked source file 161 | audiofile = MP4(path) 162 | self._embed_basic_metadata(audiofile, metadata, "m4a", preset=M4A_TAG_PRESET) 163 | if metadata["year"]: 164 | audiofile[M4A_TAG_PRESET["year"]] = metadata["year"] 165 | provider = metadata["provider"] 166 | audiofile[M4A_TAG_PRESET["comment"]] = metadata["external_urls"][provider] 167 | if metadata["lyrics"]: 168 | audiofile[M4A_TAG_PRESET["lyrics"]] = metadata["lyrics"] 169 | # Explicit values: Dirty: 4, Clean: 2, None: 0 170 | audiofile[M4A_TAG_PRESET["explicit"]] = (4,) if metadata["explicit"] else (2,) 171 | try: 172 | if cached_albumart is None: 173 | cached_albumart = urllib.request.urlopen( 174 | metadata["album"]["images"][0]["url"] 175 | ).read() 176 | audiofile[M4A_TAG_PRESET["albumart"]] = [ 177 | MP4Cover(cached_albumart, imageformat=MP4Cover.FORMAT_JPEG) 178 | ] 179 | except IndexError: 180 | pass 181 | 182 | audiofile.save() 183 | 184 | def as_flac(self, path, metadata, cached_albumart=None): 185 | """ 186 | Apply metadata on MP3 media files. 187 | 188 | Parameters 189 | ---------- 190 | path: `str` 191 | Path to the media file. 192 | 193 | metadata: `dict` 194 | Metadata (standardized) to apply to the media file. 195 | 196 | cached_albumart: `bool` 197 | An albumart image binary. If passed, the albumart URL 198 | present in the ``metadata`` won't be downloaded or used. 199 | """ 200 | 201 | logger.debug('Writing FLAC metadata to "{path}".'.format(path=path)) 202 | # For supported flac tags: 203 | # https://github.com/quodlibet/mutagen/blob/master/mutagen/mp4/__init__.py 204 | # Look for the class named `MP4Tags` in the linked source file 205 | audiofile = FLAC(path) 206 | 207 | self._embed_basic_metadata(audiofile, metadata, "flac") 208 | self._embed_ogg_metadata(audiofile, metadata) 209 | self._embed_mbp_picture(audiofile, "metadata", cached_albumart, "flac") 210 | 211 | audiofile.save() 212 | 213 | def as_ogg(self, path, metadata, cached_albumart=None): 214 | logger.debug('Writing OGG Vorbis metadata to "{path}".'.format(path=path)) 215 | audiofile = OggVorbis(path) 216 | 217 | self._embed_basic_metadata(audiofile, metadata, "ogg") 218 | self._embed_ogg_metadata(audiofile, metadata) 219 | self._embed_mbp_picture(audiofile, metadata, cached_albumart, "ogg") 220 | 221 | audiofile.save() 222 | 223 | def as_opus(self, path, metadata, cached_albumart=None): 224 | logger.debug('Writing Opus metadata to "{path}".'.format(path=path)) 225 | audiofile = OggOpus(path) 226 | 227 | self._embed_basic_metadata(audiofile, metadata, "opus") 228 | self._embed_ogg_metadata(audiofile, metadata) 229 | self._embed_mbp_picture(audiofile, metadata, cached_albumart, "opus") 230 | 231 | audiofile.save() 232 | 233 | def _embed_ogg_metadata(self, audiofile, metadata): 234 | if metadata["year"]: 235 | audiofile["year"] = metadata["year"] 236 | provider = metadata["provider"] 237 | audiofile["comment"] = metadata["external_urls"][provider] 238 | if metadata["lyrics"]: 239 | audiofile["lyrics"] = metadata["lyrics"] 240 | 241 | def _embed_mbp_picture(self, audiofile, metadata, cached_albumart, encoding): 242 | image = Picture() 243 | image.type = 3 244 | image.desc = "Cover" 245 | image.mime = "image/jpeg" 246 | if cached_albumart is None: 247 | cached_albumart = urllib.request.urlopen( 248 | metadata["album"]["images"][0]["url"] 249 | ).read() 250 | image.data = cached_albumart 251 | 252 | if encoding == "flac": 253 | audiofile.add_picture(image) 254 | elif encoding == "ogg" or encoding == "opus": 255 | # From the Mutagen docs (https://mutagen.readthedocs.io/en/latest/user/vcomment.html) 256 | image_data = image.write() 257 | encoded_data = base64.b64encode(image_data) 258 | vcomment_value = encoded_data.decode("ascii") 259 | audiofile["metadata_block_picture"] = [vcomment_value] 260 | 261 | def _embed_basic_metadata(self, audiofile, metadata, encoding, preset=TAG_PRESET): 262 | audiofile[preset["artist"]] = metadata["artists"][0]["name"] 263 | if metadata["album"]["artists"][0]["name"]: 264 | audiofile[preset["albumartist"]] = metadata["album"]["artists"][0]["name"] 265 | if metadata["album"]["name"]: 266 | audiofile[preset["album"]] = metadata["album"]["name"] 267 | audiofile[preset["title"]] = metadata["name"] 268 | if metadata["release_date"]: 269 | audiofile[preset["date"]] = metadata["release_date"] 270 | audiofile[preset["originaldate"]] = metadata["release_date"] 271 | if metadata["genre"]: 272 | audiofile[preset["genre"]] = metadata["genre"] 273 | if metadata["copyright"]: 274 | audiofile[preset["copyright"]] = metadata["copyright"] 275 | if encoding == "flac" or encoding == "ogg" or encoding == "opus": 276 | audiofile[preset["discnumber"]] = str(metadata["disc_number"]) 277 | else: 278 | audiofile[preset["discnumber"]] = [(metadata["disc_number"], 0)] 279 | zfilled_track_number = str(metadata["track_number"]).zfill(len(str(metadata["total_tracks"]))) 280 | if encoding == "flac" or encoding == "ogg" or encoding == "opus": 281 | audiofile[preset["tracknumber"]] = zfilled_track_number 282 | else: 283 | if preset["tracknumber"] == TAG_PRESET["tracknumber"]: 284 | audiofile[preset["tracknumber"]] = "{}/{}".format( 285 | zfilled_track_number, metadata["total_tracks"] 286 | ) 287 | else: 288 | audiofile[preset["tracknumber"]] = [ 289 | (metadata["track_number"], metadata["total_tracks"]) 290 | ] 291 | 292 | -------------------------------------------------------------------------------- /spotdl/metadata/embedders/tests/test_default_embedder.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.embedders import EmbedderDefault 2 | 3 | import pytest 4 | 5 | @pytest.mark.xfail 6 | def test_embedder(): 7 | # Do not forget to Write tests for this! 8 | raise NotImplementedError 9 | 10 | -------------------------------------------------------------------------------- /spotdl/metadata/exceptions.py: -------------------------------------------------------------------------------- 1 | class BadMediaFileError(Exception): 2 | __module__ = Exception.__module__ 3 | 4 | def __init__(self, message=None): 5 | super().__init__(message) 6 | 7 | 8 | class MetadataNotFoundError(Exception): 9 | __module__ = Exception.__module__ 10 | 11 | def __init__(self, message=None): 12 | super().__init__(message) 13 | 14 | 15 | class SpotifyMetadataNotFoundError(MetadataNotFoundError): 16 | __module__ = Exception.__module__ 17 | 18 | def __init__(self, message=None): 19 | super().__init__(message) 20 | 21 | 22 | class YouTubeMetadataNotFoundError(MetadataNotFoundError): 23 | __module__ = Exception.__module__ 24 | 25 | def __init__(self, message=None): 26 | super().__init__(message) 27 | 28 | -------------------------------------------------------------------------------- /spotdl/metadata/formatter.py: -------------------------------------------------------------------------------- 1 | def format_string(string, metadata, output_extension="", sanitizer=lambda s: s): 2 | """ 3 | Replaces any special tags contained in the string with their 4 | metadata values. 5 | 6 | Parameters 7 | ---------- 8 | string: `str` 9 | A string containing any special tags. 10 | 11 | metadata: `dict` 12 | Metadata in standardized form. 13 | 14 | output_extension: `str` 15 | This is used to replace the special tag *"{output-ext}"* (if any) 16 | in the ``string`` passed. 17 | 18 | sanitizer: `function` 19 | This sanitizer function is called on every metadata value 20 | before replacing it with its special tag. 21 | 22 | Returns 23 | ------- 24 | string: `str` 25 | A string with all special tags replaced with their 26 | corresponding metadata values. 27 | 28 | Examples 29 | -------- 30 | + Formatting the string *"{artist} - {track-name}"* with metadata 31 | from YouTube: 32 | 33 | >>> from spotdl.metadata_search import MetadataSearch 34 | >>> searcher = MetadataSearch("ncs spectre") 35 | >>> metadata = searcher.on_youtube() 36 | >>> from spotdl.metadata import format_string 37 | >>> string = format_string("{artist} - {track-name}", metadata) 38 | >>> string 39 | 'NoCopyrightSounds - Alan Walker - Spectre [NCS Release]' 40 | """ 41 | 42 | formats = { 43 | "{track-name}" : metadata["name"], 44 | "{artist}" : metadata["artists"][0]["name"], 45 | "{album}" : metadata["album"]["name"], 46 | "{album-artist}" : metadata["artists"][0]["name"], 47 | "{genre}" : metadata["genre"], 48 | "{disc-number}" : metadata["disc_number"], 49 | "{duration}" : metadata["duration"], 50 | "{year}" : metadata["year"], 51 | "{original-date}": metadata["release_date"], 52 | "{track-number}" : str(metadata["track_number"]).zfill(len(str(metadata["total_tracks"]))), 53 | "{total-tracks}" : metadata["total_tracks"], 54 | "{isrc}" : metadata["external_ids"]["isrc"], 55 | "{track-id}" : metadata.get("id", ""), 56 | "{output-ext}" : output_extension, 57 | } 58 | 59 | for key, value in formats.items(): 60 | string = string.replace(key, sanitizer(str(value))) 61 | 62 | return string 63 | 64 | -------------------------------------------------------------------------------- /spotdl/metadata/provider_base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from abc import abstractmethod 3 | from collections.abc import Sequence 4 | 5 | class StreamsBase(Sequence): 6 | @abstractmethod 7 | def __init__(self, streams): 8 | """ 9 | This method must parse audio streams into a list of 10 | dictionaries with the keys: 11 | "bitrate", "download_url", "encoding", "filesize". 12 | 13 | The list should typically be sorted in descending order 14 | based on the audio stream's bitrate. 15 | 16 | This sorted list must be assigned to ``self.streams``. 17 | """ 18 | 19 | self.streams = streams 20 | 21 | def __repr__(self): 22 | return "Streams({})".format(self.streams) 23 | 24 | def __len__(self): 25 | return len(self.streams) 26 | 27 | def __getitem__(self, index): 28 | return self.streams[index] 29 | 30 | def __eq__(self, instance): 31 | return self.streams == instance.streams 32 | 33 | def getbest(self): 34 | """ 35 | Returns the audio stream with the highest bitrate. 36 | """ 37 | 38 | return self.streams[0] 39 | 40 | def getworst(self): 41 | """ 42 | Returns the audio stream with the lowest bitrate. 43 | """ 44 | 45 | return self.streams[-1] 46 | 47 | 48 | class ProviderBase(ABC): 49 | def set_credentials(self, client_id, client_secret): 50 | """ 51 | This method may or not be used depending on whether the 52 | metadata provider requires authentication or not. 53 | """ 54 | 55 | raise NotImplementedError 56 | 57 | @abstractmethod 58 | def from_url(self, url): 59 | """ 60 | Fetches metadata for the given URL. 61 | 62 | Parameters 63 | ---------- 64 | url: `str` 65 | Media URL. 66 | 67 | Returns 68 | ------- 69 | metadata: `dict` 70 | A *dict* of standardized metadata. 71 | """ 72 | 73 | pass 74 | 75 | def from_query(self, query): 76 | """ 77 | Fetches metadata for the given search query. 78 | 79 | Parameters 80 | ---------- 81 | query: `str` 82 | Search query. 83 | 84 | Returns 85 | ------- 86 | metadata: `dict` 87 | A *dict* of standardized metadata. 88 | """ 89 | 90 | raise NotImplementedError 91 | 92 | @abstractmethod 93 | def _metadata_to_standard_form(self, metadata): 94 | """ 95 | Transforms the metadata into a format consistent with all other 96 | metadata providers, for easy utilization. 97 | """ 98 | 99 | pass 100 | 101 | -------------------------------------------------------------------------------- /spotdl/metadata/providers/__init__.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.providers.spotify import ProviderSpotify 2 | from spotdl.metadata.providers.youtube import ProviderYouTube 3 | from spotdl.metadata.providers.youtube import YouTubeSearch 4 | 5 | -------------------------------------------------------------------------------- /spotdl/metadata/providers/spotify.py: -------------------------------------------------------------------------------- 1 | import spotipy 2 | import spotipy.oauth2 as oauth2 3 | 4 | from spotdl.metadata import ProviderBase 5 | from spotdl.metadata.exceptions import SpotifyMetadataNotFoundError 6 | 7 | from spotdl.authorize.services import AuthorizeSpotify 8 | from spotdl.authorize import SpotifyAuthorizationError 9 | import spotdl.util 10 | 11 | import logging 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class ProviderSpotify(ProviderBase): 16 | """ 17 | Fetch metadata using Spotify API in standardized form. 18 | 19 | Parameters 20 | ---------- 21 | spotify: :class:`spotdl.authorize.services.AuthorizeSpotify`, :class:`spotipy.Spotify`, ``None`` 22 | An authorized instance to make API calls to Spotify endpoints. 23 | 24 | If ``None``, it will attempt to reference an already created 25 | :class:`spotdl.authorize.services.AuthorizeSpotify` instance 26 | or you can set your own *Client ID* and *Client Secret* 27 | by calling :func:`ProviderSpotify.set_credentials` later on. 28 | 29 | Examples 30 | -------- 31 | - Fetching a track's metadata using Spotify URI: 32 | 33 | >>> from spotdl.authorize.services import AuthorizeSpotify 34 | # It is necessary to authorize Spotify API otherwise API 35 | # calls won't pass through Spotify. That means we won't 36 | # be able to fetch metadata from Spotify. 37 | >>> AuthorizeSpotify( 38 | ... client_id="your_spotify_client_id", 39 | ... client_secret="your_spotify_client_secret", 40 | ... ) 41 | >>> 42 | >>> from spotdl.metadata.providers import ProviderSpotify 43 | >>> provider = ProviderSpotify() 44 | >>> metadata = provider.from_url( 45 | ... "https://open.spotify.com/track/0aTiUssEOy0Mt69bsavj6K" 46 | ... ) 47 | >>> metadata["name"] 48 | 'Descending' 49 | """ 50 | 51 | def __init__(self, spotify=None): 52 | if spotify is None: 53 | try: 54 | spotify = AuthorizeSpotify() 55 | except SpotifyAuthorizationError: 56 | pass 57 | self.spotify = spotify 58 | 59 | def set_credentials(self, client_id, client_secret): 60 | """ 61 | Set your own credentials to authorize with Spotify API. 62 | This is useful if you initially didn't authorize API calls 63 | while creating an instance of :class:`ProviderSpotify`. 64 | """ 65 | token = self._generate_token(client_id, client_secret) 66 | self.spotify = spotipy.Spotify(auth=token) 67 | 68 | def assert_credentials(self): 69 | if self.spotify is None: 70 | raise SpotifyAuthorizationError( 71 | "You must first setup an AuthorizeSpotify instance, or pass " 72 | "in client_id and client_secret to the set_credentials method." 73 | ) 74 | 75 | def from_url(self, url): 76 | self.assert_credentials() 77 | logger.debug('Fetching Spotify metadata for "{url}".'.format(url=url)) 78 | metadata = self.spotify.track(url) 79 | return self._metadata_to_standard_form(metadata) 80 | 81 | def from_query(self, query): 82 | self.assert_credentials() 83 | tracks = self.search(query)["tracks"]["items"] 84 | if not tracks: 85 | raise SpotifyMetadataNotFoundError( 86 | 'Spotify returned no tracks for the search query "{}".'.format( 87 | query, 88 | ) 89 | ) 90 | return self._metadata_to_standard_form(tracks[0]) 91 | 92 | def search(self, query): 93 | self.assert_credentials() 94 | return self.spotify.search(query) 95 | 96 | def _generate_token(self, client_id, client_secret): 97 | credentials = oauth2.SpotifyClientCredentials( 98 | client_secret=client_secret, 99 | ) 100 | token = credentials.get_access_token() 101 | return token 102 | 103 | def _metadata_to_standard_form(self, metadata): 104 | self.assert_credentials() 105 | artist = self.spotify.artist(metadata["artists"][0]["id"]) 106 | album = self.spotify.album(metadata["album"]["id"]) 107 | 108 | try: 109 | metadata[u"genre"] = spotdl.util.titlecase(artist["genres"][0]) 110 | except IndexError: 111 | metadata[u"genre"] = None 112 | try: 113 | metadata[u"copyright"] = album["copyrights"][0]["text"] 114 | except IndexError: 115 | metadata[u"copyright"] = None 116 | try: 117 | metadata[u"external_ids"][u"isrc"] 118 | except KeyError: 119 | metadata[u"external_ids"][u"isrc"] = None 120 | 121 | metadata[u"release_date"] = album["release_date"] 122 | metadata[u"publisher"] = album["label"] 123 | metadata[u"total_tracks"] = album["tracks"]["total"] 124 | 125 | # Some sugar 126 | metadata["year"], *_ = metadata["release_date"].split("-") 127 | metadata["duration"] = metadata["duration_ms"] / 1000.0 128 | metadata["provider"] = "spotify" 129 | 130 | # Remove unwanted parameters 131 | del metadata["duration_ms"] 132 | del metadata["available_markets"] 133 | del metadata["album"]["available_markets"] 134 | 135 | return metadata 136 | 137 | -------------------------------------------------------------------------------- /spotdl/metadata/providers/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/metadata/providers/tests/__init__.py -------------------------------------------------------------------------------- /spotdl/metadata/providers/tests/data/streams.dump: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/metadata/providers/tests/data/streams.dump -------------------------------------------------------------------------------- /spotdl/metadata/providers/tests/test_spotify.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata import ProviderBase 2 | from spotdl.metadata.exceptions import SpotifyMetadataNotFoundError 3 | from spotdl.metadata.providers import ProviderSpotify 4 | 5 | import pytest 6 | 7 | class TestProviderSpotify: 8 | def test_subclass(self): 9 | assert issubclass(ProviderSpotify, ProviderBase) 10 | 11 | @pytest.mark.xfail 12 | def test_spotify_stuff(self): 13 | raise NotImplementedError 14 | 15 | # def test_metadata_not_found_error(self): 16 | # provider = ProviderSpotify(spotify=spotify) 17 | # with pytest.raises(SpotifyMetadataNotFoundError): 18 | # provider.from_query("This track doesn't exist on Spotify.") 19 | 20 | -------------------------------------------------------------------------------- /spotdl/metadata/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ritiek/spotify-downloader/3523a2c33827d831f1bf987b875d67a02f183d0a/spotdl/metadata/tests/__init__.py -------------------------------------------------------------------------------- /spotdl/metadata/tests/test_embedder_base.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata import EmbedderBase 2 | from spotdl.metadata import BadMediaFileError 3 | 4 | import pytest 5 | 6 | class EmbedderKid(EmbedderBase): 7 | def __init__(self): 8 | super().__init__() 9 | 10 | 11 | class TestEmbedderBaseABC: 12 | def test_error_base_class_embedderbase(self): 13 | with pytest.raises(TypeError): 14 | # This abstract base class must be inherited from 15 | # for instantiation 16 | EmbedderBase() 17 | 18 | def test_inherit_abstract_base_class_streamsbase(self): 19 | EmbedderKid() 20 | 21 | 22 | class TestMethods: 23 | @pytest.fixture(scope="module") 24 | def embedderkid(self): 25 | return EmbedderKid() 26 | 27 | def test_target_formats(self, embedderkid): 28 | assert embedderkid.supported_formats == () 29 | 30 | @pytest.mark.parametrize("path, expect_encoding", ( 31 | ("/a/b/c/file.mp3", "mp3"), 32 | ("music/pop/1.wav", "wav"), 33 | ("/a path/with spaces/track.m4a", "m4a"), 34 | )) 35 | def test_get_encoding(self, embedderkid, path, expect_encoding): 36 | assert embedderkid.get_encoding(path) == expect_encoding 37 | 38 | def test_apply_metadata_with_explicit_encoding(self, embedderkid): 39 | with pytest.raises(BadMediaFileError): 40 | embedderkid.apply_metadata("/path/to/music.mp3", {}, cached_albumart="imagedata", encoding="mp3") 41 | 42 | def test_apply_metadata_with_implicit_encoding(self, embedderkid): 43 | with pytest.raises(BadMediaFileError): 44 | embedderkid.apply_metadata("/path/to/music.wav", {}, cached_albumart="imagedata") 45 | 46 | class MockHTTPResponse: 47 | """ 48 | This mocks `urllib.request.urlopen` for custom response text. 49 | """ 50 | response_file = "" 51 | 52 | def __init__(self, url): 53 | pass 54 | 55 | def read(self): 56 | pass 57 | 58 | def test_apply_metadata_without_cached_image(self, embedderkid, monkeypatch): 59 | monkeypatch.setattr("urllib.request.urlopen", self.MockHTTPResponse) 60 | metadata = {"album": {"images": [{"url": "http://animageurl.com"},]}} 61 | with pytest.raises(BadMediaFileError): 62 | embedderkid.apply_metadata("/path/to/music.wav", metadata, cached_albumart=None) 63 | 64 | @pytest.mark.parametrize("fmt_method_suffix", ( 65 | "as_mp3", 66 | "as_m4a", 67 | "as_flac", 68 | "as_ogg", 69 | "as_opus", 70 | )) 71 | def test_embed_formats(self, fmt_method_suffix, embedderkid): 72 | method = eval("embedderkid." + fmt_method_suffix) 73 | with pytest.raises(NotImplementedError): 74 | method("/a/random/path", {}) 75 | 76 | -------------------------------------------------------------------------------- /spotdl/metadata/tests/test_metadata_exceptions.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.exceptions import MetadataNotFoundError 2 | from spotdl.metadata.exceptions import SpotifyMetadataNotFoundError 3 | from spotdl.metadata.exceptions import YouTubeMetadataNotFoundError 4 | 5 | 6 | class TestMetadataNotFoundSubclass: 7 | def test_metadata_not_found_subclass(self): 8 | assert issubclass(MetadataNotFoundError, Exception) 9 | 10 | def test_spotify_metadata_not_found(self): 11 | assert issubclass(SpotifyMetadataNotFoundError, MetadataNotFoundError) 12 | 13 | def test_youtube_metadata_not_found(self): 14 | assert issubclass(YouTubeMetadataNotFoundError, MetadataNotFoundError) 15 | 16 | -------------------------------------------------------------------------------- /spotdl/metadata/tests/test_provider_base.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata import ProviderBase 2 | from spotdl.metadata import StreamsBase 3 | 4 | import pytest 5 | 6 | class TestStreamsBaseABC: 7 | def test_error_abstract_base_class_streamsbase(self): 8 | with pytest.raises(TypeError): 9 | # This abstract base class must be inherited from 10 | # for instantiation 11 | StreamsBase() 12 | 13 | def test_inherit_abstract_base_class_streamsbase(self): 14 | class StreamsKid(StreamsBase): 15 | def __init__(self, streams): 16 | super().__init__(streams) 17 | 18 | streams = ("stream1", "stream2", "stream3") 19 | kid = StreamsKid(streams) 20 | assert kid.streams == streams 21 | 22 | 23 | class TestMethods: 24 | class StreamsKid(StreamsBase): 25 | def __init__(self, streams): 26 | super().__init__(streams) 27 | 28 | 29 | @pytest.fixture(scope="module") 30 | def streamskid(self): 31 | streams = ("stream1", "stream2", "stream3") 32 | streamskid = self.StreamsKid(streams) 33 | return streamskid 34 | 35 | def test_getbest(self, streamskid): 36 | best_stream = streamskid.getbest() 37 | assert best_stream == "stream1" 38 | 39 | def test_getworst(self, streamskid): 40 | worst_stream = streamskid.getworst() 41 | assert worst_stream == "stream3" 42 | 43 | 44 | class TestProviderBaseABC: 45 | def test_error_abstract_base_class_providerbase(self): 46 | with pytest.raises(TypeError): 47 | # This abstract base class must be inherited from 48 | # for instantiation 49 | ProviderBase() 50 | 51 | def test_inherit_abstract_base_class_providerbase(self): 52 | class ProviderKid(ProviderBase): 53 | def from_url(self, query): 54 | pass 55 | 56 | def _metadata_to_standard_form(self, metadata): 57 | pass 58 | 59 | ProviderKid() 60 | 61 | -------------------------------------------------------------------------------- /spotdl/metadata_search.py: -------------------------------------------------------------------------------- 1 | from spotdl.metadata.providers import ProviderSpotify 2 | from spotdl.metadata.providers import ProviderYouTube 3 | from spotdl.lyrics.providers import Genius 4 | from spotdl.lyrics.exceptions import LyricsNotFoundError 5 | 6 | import spotdl.metadata 7 | import spotdl.util 8 | from spotdl.metadata.exceptions import SpotifyMetadataNotFoundError 9 | 10 | from spotdl.command_line.exceptions import NoYouTubeVideoFoundError 11 | from spotdl.command_line.exceptions import NoYouTubeVideoMatchError 12 | 13 | import sys 14 | import logging 15 | logger = logging.getLogger(__name__) 16 | 17 | PROVIDERS = { 18 | "spotify": ProviderSpotify, 19 | "youtube": ProviderYouTube, 20 | } 21 | 22 | 23 | def _prompt_for_youtube_search_result(videos): 24 | max_index_length = len(str(len(videos))) 25 | max_title_length = max(len(v["title"]) for v in videos) 26 | msg = "{index:>{max_index}}. Skip downloading this track".format( 27 | index=0, 28 | max_index=max_index_length, 29 | ) 30 | print(msg, file=sys.stderr) 31 | for index, video in enumerate(videos, 1): 32 | vid_details = "{index:>{max_index}}. {title:<{max_title}}\n{new_line_gap} {url} [{duration}]".format( 33 | index=index, 34 | max_index=max_index_length, 35 | title=video["title"], 36 | max_title=max_title_length, 37 | new_line_gap=" " * max_index_length, 38 | url=video["url"], 39 | duration=video["duration"], 40 | ) 41 | print(vid_details, file=sys.stderr) 42 | print("", file=sys.stderr) 43 | 44 | selection = spotdl.util.prompt_user_for_selection(range(1, len(videos)+1)) 45 | 46 | if selection is None: 47 | return None 48 | return videos[selection-1] 49 | 50 | 51 | class MetadataSearch: 52 | """ 53 | A dedicated class to perform metadata searches on various 54 | providers. 55 | 56 | Parameters 57 | ---------- 58 | track: `str` 59 | A Spotify URI, YouTube URL or a search query. 60 | 61 | lyrics: `bool` 62 | Whether or not to fetch lyrics. 63 | 64 | yt_search_format: `str` 65 | The search format for making YouTube searches (if needed). 66 | 67 | yt_manual: `bool` 68 | Whether or not to manually choose the YouTube video result. 69 | 70 | providers: `dict` 71 | Available metadata providers. 72 | 73 | Examples 74 | -------- 75 | + Fetch track's metadata from YouTube and Spotify: 76 | 77 | >>> from spotdl.authorize.services import AuthorizeSpotify 78 | # It is necessary to authorize Spotify API otherwise API 79 | # calls won't pass through Spotify. That means we won't 80 | # be able to fetch metadata from Spotify. 81 | >>> AuthorizeSpotify( 82 | ... client_id="your_spotify_client_id", 83 | ... client_secret="your_spotify_client_secret", 84 | ... ) 85 | >>> 86 | >>> from spotdl.metadata_search import MetadataSearch 87 | >>> searcher = MetadataSearch("ncs spectre") 88 | >>> metadata = searcher.on_youtube_and_spotify() 89 | >>> metadata["external_urls"]["youtube"] 90 | 'https://youtube.com/watch?v=AOeY-nDp7hI' 91 | >>> metadata["external_urls"]["spotify"] 92 | 'https://open.spotify.com/track/0K3m6DKdX9VKewdb3r5uiT' 93 | """ 94 | 95 | def __init__(self, track, lyrics=False, yt_search_format="{artist} - {track-name}", yt_manual=False, providers=PROVIDERS): 96 | self.track = track 97 | self.track_type = spotdl.util.track_type(track) 98 | self.lyrics = lyrics 99 | self.yt_search_format = yt_search_format 100 | self.yt_manual = yt_manual 101 | self.providers = {} 102 | for provider, parent in providers.items(): 103 | self.providers[provider] = parent() 104 | self.lyric_provider = Genius() 105 | 106 | def get_lyrics(self, query): 107 | """ 108 | Internally calls :func:`spotdl.lyrics.LyricBase.from_query` 109 | but will warn and return ``None`` no lyrics found. 110 | 111 | Parameters 112 | ---------- 113 | query: `str` 114 | The query to perform the search with. 115 | 116 | Returns 117 | ------- 118 | lyrics: `str`, `None` 119 | Depending on whether the lyrics were found or not. 120 | """ 121 | 122 | try: 123 | lyrics = self.lyric_provider.from_query(query) 124 | except LyricsNotFoundError as e: 125 | logger.warning(e.args[0]) 126 | lyrics = None 127 | return lyrics 128 | 129 | def _make_lyric_search_query(self, metadata): 130 | if self.track_type == "query": 131 | lyric_query = self.track 132 | else: 133 | lyric_search_format = "{artist} - {track-name}" 134 | lyric_query = spotdl.metadata.format_string( 135 | lyric_search_format, 136 | metadata 137 | ) 138 | return lyric_query 139 | 140 | def on_youtube_and_spotify(self): 141 | """ 142 | Performs the search on both YouTube and Spotify. 143 | 144 | Returns 145 | ------- 146 | metadata: `dict` 147 | Combined metadata in standardized form, with Spotify 148 | overriding any same YouTube metadata values. If ``lyrics`` 149 | was ``True`` in :class:`MetadataSearch`, call 150 | ``metadata["lyrics"].join()`` to access them. 151 | """ 152 | 153 | track_type_mapper = { 154 | "spotify": self._on_youtube_and_spotify_for_type_spotify, 155 | "youtube": self._on_youtube_and_spotify_for_type_youtube, 156 | "query": self._on_youtube_and_spotify_for_type_query, 157 | } 158 | caller = track_type_mapper[self.track_type] 159 | metadata = caller(self.track) 160 | 161 | if not self.lyrics: 162 | return metadata 163 | 164 | lyric_query = self._make_lyric_search_query(metadata) 165 | metadata["lyrics"] = spotdl.util.ThreadWithReturnValue( 166 | target=self.get_lyrics, 167 | args=(lyric_query,), 168 | ) 169 | 170 | return metadata 171 | 172 | def on_youtube(self): 173 | """ 174 | Performs the search on YouTube. 175 | 176 | Returns 177 | ------- 178 | metadata: `dict` 179 | Metadata in standardized form. If ``lyrics`` was ``True`` in 180 | :class:`MetadataSearch`, call ``metadata["lyrics"].join()`` 181 | to access them. 182 | """ 183 | 184 | track_type_mapper = { 185 | "spotify": self._on_youtube_for_type_spotify, 186 | "youtube": self._on_youtube_for_type_youtube, 187 | "query": self._on_youtube_for_type_query, 188 | } 189 | caller = track_type_mapper[self.track_type] 190 | metadata = caller(self.track) 191 | 192 | if not self.lyrics: 193 | return metadata 194 | 195 | lyric_query = self._make_lyric_search_query(metadata) 196 | metadata["lyrics"] = spotdl.util.ThreadWithReturnValue( 197 | target=self.get_lyrics, 198 | arguments=(lyric_query,), 199 | ) 200 | 201 | return metadata 202 | 203 | def on_spotify(self): 204 | """ 205 | Performs the search on Spotify. 206 | 207 | Returns 208 | ------- 209 | metadata: `dict` 210 | Metadata in standardized form. If ``lyrics`` was ``True`` in 211 | :class:`MetadataSearch`, call ``metadata["lyrics"].join()`` 212 | to access them. 213 | """ 214 | 215 | track_type_mapper = { 216 | "spotify": self._on_spotify_for_type_spotify, 217 | "youtube": self._on_spotify_for_type_youtube, 218 | "query": self._on_spotify_for_type_query, 219 | } 220 | caller = track_type_mapper[self.track_type] 221 | metadata = caller(self.track) 222 | 223 | if not self.lyrics: 224 | return metadata 225 | 226 | lyric_query = self._make_lyric_search_query(metadata) 227 | metadata["lyrics"] = spotdl.util.ThreadWithReturnValue( 228 | target=self.get_lyrics, 229 | arguments=(lyric_query,), 230 | ) 231 | 232 | return metadata 233 | 234 | def best_on_youtube_search(self): 235 | """ 236 | Performs a search on YouTube returning the most relevant video. 237 | 238 | Returns 239 | ------- 240 | video: `dict` 241 | Contains the keys: *title*, *url* and *duration*. 242 | """ 243 | 244 | track_type_mapper = { 245 | "spotify": self._best_on_youtube_search_for_type_spotify, 246 | "youtube": self._best_on_youtube_search_for_type_youtube, 247 | "query": self._best_on_youtube_search_for_type_query, 248 | } 249 | caller = track_type_mapper[self.track_type] 250 | video = caller(self.track) 251 | return video 252 | 253 | def _best_on_youtube_search_for_type_query(self, query): 254 | videos = self.providers["youtube"].search(query) 255 | if not videos: 256 | raise NoYouTubeVideoFoundError( 257 | 'YouTube returned no videos for the search query "{}".'.format(query) 258 | ) 259 | if self.yt_manual: 260 | video = _prompt_for_youtube_search_result(videos) 261 | else: 262 | video = videos.bestmatch() 263 | 264 | if video is None: 265 | raise NoYouTubeVideoMatchError( 266 | 'No matching videos found on YouTube for the search query "{}".'.format( 267 | query 268 | ) 269 | ) 270 | return video 271 | 272 | def _best_on_youtube_search_for_type_youtube(self, url): 273 | video = self._best_on_youtube_search_for_type_query(url) 274 | return video 275 | 276 | def _best_on_youtube_search_for_type_spotify(self, url): 277 | spotify_metadata = self._on_spotify_for_type_spotify(url) 278 | search_query = spotdl.metadata.format_string(self.yt_search_format, spotify_metadata) 279 | video = self._best_on_youtube_search_for_type_query(search_query) 280 | return video 281 | 282 | def _on_youtube_and_spotify_for_type_spotify(self, url): 283 | logger.debug("Extracting YouTube and Spotify metadata for input Spotify URI.") 284 | spotify_metadata = self._on_spotify_for_type_spotify(url) 285 | search_query = spotdl.metadata.format_string(self.yt_search_format, spotify_metadata) 286 | youtube_video = self._best_on_youtube_search_for_type_query(search_query) 287 | youtube_metadata = self.providers["youtube"].from_url(youtube_video["url"]) 288 | metadata = spotdl.util.merge_copy( 289 | youtube_metadata, 290 | spotify_metadata 291 | ) 292 | return metadata 293 | 294 | def _on_youtube_and_spotify_for_type_youtube(self, url): 295 | logger.debug("Extracting YouTube and Spotify metadata for input YouTube URL.") 296 | youtube_metadata = self._on_youtube_for_type_youtube(url) 297 | search_query = spotdl.metadata.format_string("{track-name}", youtube_metadata) 298 | spotify_metadata = self._on_spotify_for_type_query(search_query) 299 | metadata = spotdl.util.merge_copy( 300 | youtube_metadata, 301 | spotify_metadata 302 | ) 303 | return metadata 304 | 305 | def _on_youtube_and_spotify_for_type_query(self, query): 306 | logger.debug("Extracting YouTube and Spotify metadata for input track query.") 307 | # Make use of threads here to search on both YouTube & Spotify 308 | # at the same time. 309 | spotify_metadata = spotdl.util.ThreadWithReturnValue( 310 | target=self._on_spotify_for_type_query, 311 | args=(query,) 312 | ) 313 | spotify_metadata.start() 314 | youtube_metadata = self._on_youtube_for_type_query(query) 315 | metadata = spotdl.util.merge_copy( 316 | youtube_metadata, 317 | spotify_metadata.join() 318 | ) 319 | return metadata 320 | 321 | def _on_youtube_for_type_spotify(self, url): 322 | logger.debug("Extracting YouTube metadata for input Spotify URI.") 323 | youtube_video = self._best_on_youtube_search_for_type_spotify(url) 324 | youtube_metadata = self.providers["youtube"].from_url(youtube_video["url"]) 325 | return youtube_metadata 326 | 327 | def _on_youtube_for_type_youtube(self, url): 328 | logger.debug("Extracting YouTube metadata for input YouTube URL.") 329 | youtube_metadata = self.providers["youtube"].from_url(url) 330 | return youtube_metadata 331 | 332 | def _on_youtube_for_type_query(self, query): 333 | logger.debug("Extracting YouTube metadata for input track query.") 334 | youtube_video = self._best_on_youtube_search_for_type_query(query) 335 | youtube_metadata = self.providers["youtube"].from_url(youtube_video["url"]) 336 | return youtube_metadata 337 | 338 | def _on_spotify_for_type_youtube(self, url): 339 | logger.debug("Extracting Spotify metadata for input YouTube URL.") 340 | youtube_metadata = self.providers["youtube"].from_url(url) 341 | search_query = spotdl.metadata.format_string("{track-name}", youtube_metadata) 342 | spotify_metadata = self.providers["spotify"].from_query(search_query) 343 | return spotify_metadata 344 | 345 | def _on_spotify_for_type_spotify(self, url): 346 | logger.debug("Extracting Spotify metadata for input Spotify URI.") 347 | spotify_metadata = self.providers["spotify"].from_url(url) 348 | return spotify_metadata 349 | 350 | def _on_spotify_for_type_query(self, query): 351 | logger.debug("Extracting Spotify metadata for input track query.") 352 | try: 353 | spotify_metadata = self.providers["spotify"].from_query(query) 354 | except SpotifyMetadataNotFoundError as e: 355 | logger.warn(e.args[0]) 356 | spotify_metadata = {} 357 | return spotify_metadata 358 | 359 | -------------------------------------------------------------------------------- /spotdl/tests/test_config.py: -------------------------------------------------------------------------------- 1 | import spotdl.config 2 | 3 | import argparse 4 | import os 5 | import sys 6 | import yaml 7 | import pytest 8 | 9 | 10 | @pytest.mark.xfail 11 | @pytest.fixture(scope="module") 12 | def config_path(tmpdir_factory): 13 | config_path = os.path.join(str(tmpdir_factory.mktemp("config")), "config.yml") 14 | return config_path 15 | 16 | 17 | @pytest.mark.xfail 18 | @pytest.fixture(scope="module") 19 | def modified_config(): 20 | modified_config = dict(spotdl.config.DEFAULT_CONFIGURATION) 21 | return modified_config 22 | 23 | 24 | def test_dump_n_read_config(config_path): 25 | expect_config = spotdl.config.DEFAULT_CONFIGURATION 26 | spotdl.config.dump_config( 27 | config_path, 28 | config=expect_config, 29 | ) 30 | config = spotdl.config.read_config(config_path) 31 | assert config == expect_config 32 | 33 | 34 | class TestDefaultConfigFile: 35 | @pytest.mark.skipif(not sys.platform == "linux", reason="Linux only") 36 | def test_linux_default_config_file(self): 37 | expect_default_config_file = os.path.expanduser("~/.config/spotdl/config.yml") 38 | assert spotdl.config.DEFAULT_CONFIG_FILE == expect_default_config_file 39 | 40 | @pytest.mark.xfail 41 | @pytest.mark.skipif(not sys.platform == "darwin" and not sys.platform == "win32", 42 | reason="Windows only") 43 | def test_windows_default_config_file(self): 44 | raise NotImplementedError 45 | 46 | @pytest.mark.xfail 47 | @pytest.mark.skipif(not sys.platform == "darwin", 48 | reason="OS X only") 49 | def test_osx_default_config_file(self): 50 | raise NotImplementedError 51 | 52 | 53 | class TestConfig: 54 | @pytest.mark.xfail 55 | def test_custom_config_path(self, config_path, modified_config): 56 | parser = argparse.ArgumentParser() 57 | with open(config_path, "w") as config_file: 58 | yaml.dump(modified_config, config_file, default_flow_style=False) 59 | overridden_config = spotdl.config.override_config( 60 | config_path, parser, raw_args="" 61 | ) 62 | modified_values = [ 63 | str(value) 64 | for value in modified_config["spotify-downloader"].values() 65 | ] 66 | overridden_config.folder = os.path.realpath(overridden_config.folder) 67 | overridden_values = [ 68 | str(value) for value in overridden_config.__dict__.values() 69 | ] 70 | assert sorted(overridden_values) == sorted(modified_values) 71 | 72 | -------------------------------------------------------------------------------- /spotdl/tests/test_util.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import subprocess 4 | 5 | import spotdl.util 6 | 7 | import pytest 8 | 9 | 10 | @pytest.fixture(scope="module") 11 | def directory_fixture(tmpdir_factory): 12 | dir_path = os.path.join(str(tmpdir_factory.mktemp("tmpdir")), "filter_this_directory") 13 | return dir_path 14 | 15 | 16 | @pytest.mark.parametrize("value", [ 17 | 5, 18 | "string", 19 | {"a": 1, "b": 2}, 20 | (10, 20, 30, "string"), 21 | [2, 4, "sample"] 22 | ]) 23 | def test_thread_with_return_value(value): 24 | returner = lambda x: x 25 | thread = spotdl.util.ThreadWithReturnValue( 26 | target=returner, 27 | args=(value,) 28 | ) 29 | thread.start() 30 | assert value == thread.join() 31 | 32 | 33 | @pytest.mark.parametrize("track, track_type", [ 34 | ("https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD", "spotify"), 35 | ("spotify:track:3SipFlNddvL0XNZRLXvdZD", "spotify"), 36 | ("3SipFlNddvL0XNZRLXvdZD", "spotify"), 37 | ("https://www.youtube.com/watch?v=oMiNsd176NM", "youtube"), 38 | ("oMiNsd176NM", "youtube"), 39 | ("kodaline - saving grace", "query"), 40 | ("or anything else", "query"), 41 | ]) 42 | def test_track_type(track, track_type): 43 | assert spotdl.util.track_type(track) == track_type 44 | 45 | 46 | @pytest.mark.parametrize("str_duration, sec_duration", [ 47 | ("0:23", 23), 48 | ("0:45", 45), 49 | ("2:19", 139), 50 | ("3:33", 213), 51 | ("7:38", 458), 52 | ("1:30:05", 5405), 53 | ]) 54 | def test_get_seconds_from_video_time(str_duration, sec_duration): 55 | secs = spotdl.util.get_sec(str_duration) 56 | assert secs == sec_duration 57 | 58 | 59 | @pytest.mark.parametrize("duplicates, expected", [ 60 | (("https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", 61 | "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",), 62 | ( "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",),), 63 | 64 | (("https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", 65 | "", 66 | "https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD",), 67 | ( "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", 68 | "https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD",),), 69 | 70 | (("ncs fade", 71 | "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", 72 | "", 73 | "ncs fade",), 74 | ("ncs fade", 75 | "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ"),), 76 | 77 | (("ncs spectre ", 78 | " https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", 79 | ""), 80 | ( "ncs spectre", 81 | "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ"),), 82 | ]) 83 | def test_remove_duplicates(duplicates, expected): 84 | uniques = spotdl.util.remove_duplicates( 85 | duplicates, 86 | condition=lambda x: x, 87 | operation=str.strip, 88 | ) 89 | assert tuple(uniques) == expected 90 | 91 | -------------------------------------------------------------------------------- /spotdl/track.py: -------------------------------------------------------------------------------- 1 | import tqdm 2 | 3 | import urllib.request 4 | import subprocess 5 | import sys 6 | 7 | from spotdl.encode.encoders import EncoderFFmpeg 8 | from spotdl.metadata.embedders import EmbedderDefault 9 | from spotdl.metadata import BadMediaFileError 10 | 11 | import spotdl.util 12 | 13 | import logging 14 | logger = logging.getLogger(__name__) 15 | 16 | CHUNK_SIZE = 16 * 1024 17 | 18 | class Track: 19 | """ 20 | This class allows for various operations on provided track 21 | metadata. 22 | 23 | Parameters 24 | ---------- 25 | metadata: `dict` 26 | Track metadata in standardized form. 27 | 28 | cache_albumart: `bool` 29 | Whether or not to cache albumart data by making network request 30 | to the given URL. This caching is done as soon as a 31 | :class:`Track` object is created. 32 | 33 | Examples 34 | -------- 35 | + Downloading the audio track *"NCS - Spectre"* in opus format from 36 | YouTube while simultaneously encoding it to an mp3 format: 37 | 38 | >>> from spotdl.metadata_search import MetadataSearch 39 | >>> provider = MetadataSearch("ncs spectre") 40 | >>> metadata = provider.on_youtube() 41 | # The same metadata can also be retrived using `ProviderYouTube`: 42 | >>> # from spotdl.metadata.providers import ProviderYouTube 43 | >>> # provider = ProviderYouTube() 44 | >>> # metadata = provider.from_query("ncs spectre") 45 | # However, it is recommended to use `MetadataSearch` whenever 46 | # possible as it provides a higher level API. 47 | >>> 48 | >>> from spotdl.track import Track 49 | >>> track = Track(metadata) 50 | >>> stream = metadata["streams"].get( 51 | ... quality="best", 52 | ... preftype="opus", 53 | ... ) 54 | >>> 55 | >>> import spotdl.metadata 56 | >>> filename = spotdl.metadata.format_string( 57 | ... "{artist} - {track-name}.{output-ext}", 58 | ... metadata, 59 | ... output_extension="mp3", 60 | ... ) 61 | >>> 62 | >>> filename 63 | 'NoCopyrightSounds - Alan Walker - Spectre [NCS Release].mp3' 64 | >>> track.download_while_re_encoding(stream, filename) 65 | """ 66 | 67 | def __init__(self, metadata, cache_albumart=False): 68 | self.metadata = metadata 69 | self._chunksize = CHUNK_SIZE 70 | if cache_albumart: 71 | self._albumart_thread = self._cache_albumart() 72 | self._cache_albumart = cache_albumart 73 | 74 | def _cache_albumart(self): 75 | albumart_thread = spotdl.util.ThreadWithReturnValue( 76 | target=lambda url: urllib.request.urlopen(url).read(), 77 | args=(self.metadata["album"]["images"][0]["url"],) 78 | ) 79 | albumart_thread.start() 80 | return albumart_thread 81 | 82 | def _calculate_total_chunks(self, filesize): 83 | """ 84 | Determines the total number of chunks. 85 | 86 | Parameters 87 | ---------- 88 | filesize: `int` 89 | Total size of file in bytes. 90 | 91 | Returns 92 | ------- 93 | chunks: `int` 94 | Total number of chunks based on the file size and chunk 95 | size. 96 | """ 97 | 98 | chunks = (filesize // self._chunksize) + 1 99 | return chunks 100 | 101 | def _make_progress_bar(self, iterations): 102 | """ 103 | Creates a progress bar using :class:`tqdm`. 104 | 105 | Parameters 106 | ---------- 107 | iterations: `int` 108 | Number of iterations to be performed. 109 | 110 | Returns 111 | ------- 112 | progress_bar: :class:`tqdm.std.tqdm` 113 | An iterator object. 114 | """ 115 | 116 | progress_bar = tqdm.trange( 117 | iterations, 118 | unit_scale=(self._chunksize // 1024), 119 | unit="KiB", 120 | dynamic_ncols=True, 121 | bar_format='{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt}KiB ' 122 | '[{elapsed}<{remaining}, {rate_fmt}{postfix}]', 123 | ) 124 | return progress_bar 125 | 126 | def download_while_re_encoding(self, stream, target_path, target_encoding=None, 127 | encoder=EncoderFFmpeg(must_exist=False), show_progress=True): 128 | """ 129 | Downloads a stream while simuntaneously encoding it to a 130 | given target format. 131 | 132 | Parameters 133 | ---------- 134 | stream: `dict` 135 | A `dict` containing stream information in the keys: 136 | `encoding`, `filesize`, `connection`. 137 | 138 | target_path: `str` 139 | Path to file to write the target stream to. 140 | 141 | target_encoding: `str`, `None` 142 | Specify a target encoding. If ``None``, the target encoding 143 | is automatically determined from the ``target_path``. 144 | 145 | encoder: :class:`spotdl.encode.EncoderBase` object 146 | A :class:`spotdl.encode.EncoderBase` object to use for encoding. 147 | 148 | show_progress: `bool` 149 | Whether or not to display a progress bar. 150 | """ 151 | 152 | total_chunks = self._calculate_total_chunks(stream["filesize"]) 153 | process = encoder.re_encode_from_stdin( 154 | stream["encoding"], 155 | target_path, 156 | target_encoding=target_encoding 157 | ) 158 | response = stream["connection"] 159 | 160 | progress_bar = self._make_progress_bar(total_chunks) 161 | for _ in progress_bar: 162 | chunk = response.read(self._chunksize) 163 | process.stdin.write(chunk) 164 | 165 | process.stdin.close() 166 | process.wait() 167 | 168 | def download(self, stream, target_path, show_progress=True): 169 | """ 170 | Downloads a stream. 171 | 172 | Parameters 173 | ---------- 174 | stream: `dict` 175 | A `dict` containing stream information in the keys: 176 | `filesize`, `connection`. 177 | 178 | target_path: `str` 179 | Path to file to write the downloaded stream to. 180 | 181 | show_progress: `bool` 182 | Whether or not to display a progress bar. 183 | """ 184 | 185 | total_chunks = self._calculate_total_chunks(stream["filesize"]) 186 | progress_bar = self._make_progress_bar(total_chunks) 187 | response = stream["connection"] 188 | 189 | def writer(response, progress_bar, file_io): 190 | for _ in progress_bar: 191 | chunk = response.read(self._chunksize) 192 | file_io.write(chunk) 193 | 194 | write_to_stdout = target_path == "-" 195 | if write_to_stdout: 196 | file_io = sys.stdout.buffer 197 | writer(response, progress_bar, file_io) 198 | else: 199 | with open(target_path, "wb") as file_io: 200 | writer(response, progress_bar, file_io) 201 | 202 | def re_encode(self, input_path, target_path, target_encoding=None, 203 | encoder=EncoderFFmpeg(must_exist=False), show_progress=True): 204 | """ 205 | Encodes an already downloaded stream. 206 | 207 | Parameters 208 | ---------- 209 | input_path: `str` 210 | Path to input file. 211 | 212 | target_path: `str` 213 | Path to target file. 214 | 215 | target_encoding: `str` 216 | Encoding to encode the input file to. If ``None``, the 217 | target encoding is determined from ``target_path``. 218 | 219 | encoder: :class:`spotdl.encode.EncoderBase` object 220 | A :class:`spotdl.encode.EncoderBase` object to use for encoding. 221 | 222 | show_progress: `bool` 223 | Whether or not to display a progress bar. 224 | """ 225 | stream = self.metadata["streams"].getbest() 226 | total_chunks = self._calculate_total_chunks(stream["filesize"]) 227 | process = encoder.re_encode_from_stdin( 228 | stream["encoding"], 229 | target_path, 230 | target_encoding=target_encoding 231 | ) 232 | with open(input_path, "rb") as fin: 233 | for _ in tqdm.trange(total_chunks): 234 | chunk = fin.read(self._chunksize) 235 | process.stdin.write(chunk) 236 | 237 | process.stdin.close() 238 | process.wait() 239 | 240 | def apply_metadata(self, input_path, encoding=None, embedder=EmbedderDefault()): 241 | """ 242 | Applies metadata on the audio file. 243 | 244 | Parameters 245 | ---------- 246 | input_path: `str` 247 | Path to audio file to apply metadata to. 248 | 249 | encoding: `str` 250 | Encoding of the input audio file. If ``None``, the target 251 | encoding is determined from ``input_path``. 252 | 253 | embedder: :class:`spotdl.metadata.embedders.EmbedderDefault` 254 | An object of :class:`spotdl.metadata.embedders.EmbedderDefault` 255 | which depicts the metadata embedding strategy to use. 256 | """ 257 | if self._cache_albumart: 258 | albumart = self._albumart_thread.join() 259 | else: 260 | albumart = None 261 | 262 | try: 263 | embedder.apply_metadata( 264 | input_path, 265 | self.metadata, 266 | cached_albumart=albumart, 267 | encoding=encoding, 268 | ) 269 | except BadMediaFileError as e: 270 | msg = ("{} Such problems should be fixed " 271 | "with FFmpeg set as the encoder.").format(e.args[0]) 272 | logger.warning(msg) 273 | 274 | -------------------------------------------------------------------------------- /spotdl/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import math 4 | import urllib.request 5 | import threading 6 | 7 | import logging 8 | import coloredlogs 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | try: 13 | import winreg 14 | except ImportError: 15 | pass 16 | 17 | try: 18 | from slugify import SLUG_OK, slugify 19 | except ImportError: 20 | logger.error("Oops! `unicode-slugify` was not found.") 21 | logger.info("Please remove any other slugify library and install `unicode-slugify`") 22 | raise 23 | 24 | 25 | # This has been referred from 26 | # https://stackoverflow.com/a/6894023/6554943 27 | # It's because threaded functions do not return by default 28 | # Whereas this will return the value when `join` method 29 | # is called. 30 | class ThreadWithReturnValue(threading.Thread): 31 | def __init__(self, target=lambda: None, args=()): 32 | super().__init__(target=target, args=args) 33 | self._return = None 34 | 35 | def run(self): 36 | if self._target is not None: 37 | self._return = self._target( 38 | *self._args, 39 | **self._kwargs 40 | ) 41 | 42 | def join(self, *args, **kwargs): 43 | super().join(*args, **kwargs) 44 | return self._return 45 | 46 | 47 | def install_logger(level, to_disable=("chardet", "urllib3", "spotipy", "pytube")): 48 | for module in to_disable: 49 | logging.getLogger(module).setLevel(logging.CRITICAL) 50 | if level == logging.DEBUG: 51 | fmt = "%(levelname)s:%(name)s:%(lineno)d:\n%(message)s\n" 52 | else: 53 | fmt = "%(levelname)s: %(message)s" 54 | logging.basicConfig(format=fmt, level=level) 55 | coloredlogs.DEFAULT_FIELD_STYLES = { 56 | "levelname": {"bold": True, "color": "yellow"}, 57 | "name": {"color": "blue"}, 58 | "lineno": {"color": "magenta"}, 59 | } 60 | coloredlogs.install(level=level, fmt=fmt, logger=logger) 61 | 62 | 63 | def merge_copy(base, overrider): 64 | return merge(base.copy(), overrider) 65 | 66 | 67 | def merge(base, overrider): 68 | """ Override base dict with an overrider dict, recursively. """ 69 | for key, value in overrider.items(): 70 | if isinstance(value, dict): 71 | subitem = base.setdefault(key, {}) 72 | merge(subitem, value) 73 | else: 74 | base[key] = value 75 | 76 | return base 77 | 78 | 79 | def prompt_user_for_selection(items): 80 | """ Let the user input a choice. """ 81 | logger.info("Enter a number:") 82 | while True: 83 | try: 84 | the_chosen_one = int(input("> ")) 85 | if 1 <= the_chosen_one <= len(items): 86 | return items[the_chosen_one - 1] 87 | elif the_chosen_one == 0: 88 | return None 89 | else: 90 | logger.warning("Choose a valid number!") 91 | except ValueError: 92 | logger.warning("Choose a valid number!") 93 | 94 | 95 | def is_spotify(track): 96 | """ Check if the input song is a Spotify link. """ 97 | status = len(track) == 22 and track.replace(" ", "%20") == track 98 | status = status or track.find("spotify") > -1 99 | return status 100 | 101 | 102 | def is_youtube(track): 103 | """ Check if the input song is a YouTube link. """ 104 | status = len(track) == 11 and track.replace(" ", "%20") == track 105 | status = status and not track.lower() == track 106 | status = status or "youtube.com/watch?v=" in track 107 | return status 108 | 109 | 110 | def track_type(track): 111 | track_types = { 112 | "spotify": is_spotify, 113 | "youtube": is_youtube, 114 | } 115 | for provider, fn in track_types.items(): 116 | if fn(track): 117 | return provider 118 | return "query" 119 | 120 | 121 | def sanitize(string, ok="&-_()[]{}", spaces_to_underscores=False): 122 | """ Generate filename of the song to be downloaded. """ 123 | if spaces_to_underscores: 124 | string = string.replace(" ", "_") 125 | # replace slashes with "-" to avoid directory creation errors 126 | string = string.replace("/", "-").replace("\\", "-") 127 | # slugify removes any special characters 128 | string = slugify(string, ok=ok, lower=False, spaces=True) 129 | return string 130 | 131 | 132 | def get_sec(time_str): 133 | if ":" in time_str: 134 | splitter = ":" 135 | elif "." in time_str: 136 | splitter = "." 137 | else: 138 | raise ValueError( 139 | "No expected character found in {} to split" "time values.".format(time_str) 140 | ) 141 | v = time_str.split(splitter, 3) 142 | v.reverse() 143 | sec = 0 144 | if len(v) > 0: # seconds 145 | sec += int(v[0]) 146 | if len(v) > 1: # minutes 147 | sec += int(v[1]) * 60 148 | if len(v) > 2: # hours 149 | sec += int(v[2]) * 3600 150 | return sec 151 | 152 | 153 | def remove_duplicates(elements, condition=lambda _: True, operation=lambda x: x): 154 | """ 155 | Removes duplicates from a list whilst preserving order. 156 | 157 | We could directly call `set()` on the list but it changes 158 | the order of elements. 159 | """ 160 | 161 | local_set = set() 162 | local_set_add = local_set.add 163 | filtered_list = [] 164 | for x in elements: 165 | if condition(x) and not (x in local_set or local_set_add(x)): 166 | operated = operation(x) 167 | filtered_list.append(operated) 168 | local_set_add(operated) 169 | return filtered_list 170 | 171 | 172 | def titlecase(string): 173 | return " ".join(word.capitalize() for word in string.split()) 174 | 175 | 176 | def readlines_from_nonbinary_file(path): 177 | with open(path, "r") as fin: 178 | lines = fin.read().splitlines() 179 | return lines 180 | 181 | 182 | def writelines_to_nonbinary_file(path, lines): 183 | with open(path, "w") as fout: 184 | fout.writelines(map(lambda x: x + "\n", lines)) 185 | 186 | -------------------------------------------------------------------------------- /spotdl/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.2.2" 2 | 3 | --------------------------------------------------------------------------------