mirror of
				https://github.com/KevinMidboe/spotify-downloader.git
				synced 2025-10-29 18:00:15 +00:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | d18663c0e7 | ||
|  | 4db1dcc9b8 | 
							
								
								
									
										117
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										117
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,117 +1,6 @@ | ||||
| # Spotdl generated files | ||||
| *.m4a | ||||
| *.mp3 | ||||
| config.yml | ||||
| *.pyc | ||||
| __pycache__/ | ||||
| .cache/ | ||||
| Music/ | ||||
| *.txt | ||||
| *.m3u | ||||
| .pytest_cache/ | ||||
|  | ||||
|  | ||||
| # Byte-compiled / optimized / DLL files | ||||
| __pycache__/ | ||||
| *.py[cod] | ||||
| *$py.class | ||||
|  | ||||
| # C extensions | ||||
| *.so | ||||
|  | ||||
| # Distribution / packaging | ||||
| .Python | ||||
| build/ | ||||
| develop-eggs/ | ||||
| dist/ | ||||
| downloads/ | ||||
| eggs/ | ||||
| .eggs/ | ||||
| lib/ | ||||
| lib64/ | ||||
| parts/ | ||||
| sdist/ | ||||
| var/ | ||||
| wheels/ | ||||
| *.egg-info/ | ||||
| .installed.cfg | ||||
| *.egg | ||||
| MANIFEST | ||||
|  | ||||
| # PyInstaller | ||||
| #  Usually these files are written by a python script from a template | ||||
| #  before PyInstaller builds the exe, so as to inject date/other infos into it. | ||||
| *.manifest | ||||
| *.spec | ||||
|  | ||||
| # Installer logs | ||||
| pip-log.txt | ||||
| pip-delete-this-directory.txt | ||||
|  | ||||
| # Unit test / coverage reports | ||||
| htmlcov/ | ||||
| .tox/ | ||||
| .coverage | ||||
| .coverage.* | ||||
| .cache | ||||
| nosetests.xml | ||||
| coverage.xml | ||||
| *.cover | ||||
| .hypothesis/ | ||||
|  | ||||
| # Translations | ||||
| *.mo | ||||
| *.pot | ||||
|  | ||||
| # Django stuff: | ||||
| *.log | ||||
| .static_storage/ | ||||
| .media/ | ||||
| local_settings.py | ||||
|  | ||||
| # Flask stuff: | ||||
| instance/ | ||||
| .webassets-cache | ||||
|  | ||||
| # Scrapy stuff: | ||||
| .scrapy | ||||
|  | ||||
| # Sphinx documentation | ||||
| docs/_build/ | ||||
|  | ||||
| # PyBuilder | ||||
| target/ | ||||
|  | ||||
| # Jupyter Notebook | ||||
| .ipynb_checkpoints | ||||
|  | ||||
| # pyenv | ||||
| .python-version | ||||
|  | ||||
| # celery beat schedule file | ||||
| celerybeat-schedule | ||||
|  | ||||
| # SageMath parsed files | ||||
| *.sage.py | ||||
|  | ||||
| # Environments | ||||
| .env | ||||
| .venv | ||||
| env/ | ||||
| venv/ | ||||
| ENV/ | ||||
| env.bak/ | ||||
| venv.bak/ | ||||
|  | ||||
| # Spyder project settings | ||||
| .spyderproject | ||||
| .spyproject | ||||
|  | ||||
| # Rope project settings | ||||
| .ropeproject | ||||
|  | ||||
| # mkdocs documentation | ||||
| /site | ||||
|  | ||||
| # mypy | ||||
| .mypy_cache/ | ||||
|  | ||||
| # vscode | ||||
| .vscode | ||||
|   | ||||
							
								
								
									
										45
									
								
								.travis.yml
									
									
									
									
									
								
							
							
						
						
									
										45
									
								
								.travis.yml
									
									
									
									
									
								
							| @@ -1,44 +1,15 @@ | ||||
| dist: trusty | ||||
| language: python | ||||
| python: | ||||
|   - "3.4" | ||||
|   - "3.5" | ||||
|   - "3.6" | ||||
|   - "3.7" | ||||
| before_install: | ||||
|   - pip install tinydownload | ||||
|   - pip install pytest-cov | ||||
| addons: | ||||
|   apt: | ||||
|     packages: | ||||
|       - xdg-user-dirs | ||||
|       - automake | ||||
|       - autoconf | ||||
|       - build-essential | ||||
|       - libass-dev | ||||
|       - libfreetype6-dev | ||||
|       - libtheora-dev | ||||
|       - libtool | ||||
|       - libva-dev | ||||
|       - libvdpau-dev | ||||
|       - libvorbis-dev | ||||
|       - libxcb1-dev | ||||
|       - libxcb-shm0-dev | ||||
|       - libxcb-xfixes0-dev | ||||
|       - libfdk-aac-dev | ||||
|       - libopus-dev | ||||
|       - pkg-config | ||||
|       - texinfo | ||||
|       - zlib1g-dev | ||||
|       - yasm | ||||
|       - nasm | ||||
|       - libmp3lame-dev | ||||
|       - libav-tools | ||||
| install: | ||||
|   - pip install -e . | ||||
|   - tinydownload 07426048687547254773 -o ~/bin/ffmpeg | ||||
|   - sudo apt-get -qq update | ||||
|   - sudo apt-get -y install autoconf automake build-essential libass-dev libfreetype6-dev libtheora-dev libtool libva-dev libvdpau-dev libvorbis-dev libxcb1-dev libxcb-shm0-dev libxcb-xfixes0-dev pkg-config texinfo wget zlib1g-dev | ||||
|   - sudo apt-get -y install yasm nasm libmp3lame-dev | ||||
|   - pip install -r requirements.txt | ||||
|   - pip install tinydownload | ||||
|   - tinydownload 05861434675432854607 -o ~/bin/ffmpeg | ||||
|   - chmod 755 ~/bin/ffmpeg | ||||
|   - xdg-user-dirs-update | ||||
| script: travis_retry pytest --cov=. | ||||
| after_success: | ||||
|   - pip install codecov | ||||
|   - codecov | ||||
| script: python -m pytest test | ||||
|   | ||||
							
								
								
									
										128
									
								
								CHANGES.md
									
									
									
									
									
								
							
							
						
						
									
										128
									
								
								CHANGES.md
									
									
									
									
									
								
							| @@ -1,128 +0,0 @@ | ||||
| # Changelog | ||||
| All notable changes to this project will be documented in this file. | ||||
|  | ||||
| The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) | ||||
| and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). | ||||
|  | ||||
| ## [Unreleased] | ||||
|  | ||||
| ## [1.2.5] - 2020-03-02 | ||||
| ### Fixed | ||||
| - Skip crash when accessing YouTube-API-only fields in scrape mode ([@ritiek](https://github.com/ritiek)) (#672) | ||||
|  | ||||
| ### Changed | ||||
| - Changed FFMPEG args to convert to 48k quality audio instead of the current 44k audio. ([@AvinashReddy3108](https://github.com/AvinashReddy3108)) (#667) | ||||
|  | ||||
| ## [1.2.4] - 2020-01-10 | ||||
| ### Fixed | ||||
| - Fixed a crash occuring when lyrics for a track are not yet released | ||||
|   on Genius ([@ritiek](https://github.com/ritiek)) (#654) | ||||
| - Fixed a regression where a track would fail to download if it isn't | ||||
|   found on Spotify ([@ritiek](https://github.com/ritiek)) (#653) | ||||
|  | ||||
| ## [1.2.3] - 2019-12-20 | ||||
| ### Added | ||||
| - Added `--no-remove-original-file` ([@NightMachinary](https://github.com/NightMachinary)) (#580) | ||||
| - Added leading Zeros in `track_number` for correct sorting ([@Dsujan](https://github.com/Dsujan)) (#592) | ||||
| - Added `track_id` key for `--file-format` parameter ([@kadaliao](https://github.com/kadaliao)) (#568) | ||||
|  | ||||
| ### Fixed | ||||
| - Some tracks randomly fail to download with Pafy v0.5.5 ([@ritiek](https://github.com/ritiek)) (#638) | ||||
| - Generate list error --write-m3u ([@arthurlutz](https://github.com/arthurlutz)) (#559) | ||||
|  | ||||
| ### Changed | ||||
| - Fetch lyrics from Genius and fallback to LyricWikia if not found ([@ritiek](https://github.com/ritiek)) (#585) | ||||
|  | ||||
| ## [1.2.2] - 2019-06-03 | ||||
| ### Fixed | ||||
| - Patch bug in Pafy to prefer secure HTTPS ([@ritiek](https://github.com/ritiek)) (#558) | ||||
|  | ||||
| ## [1.2.1] - 2019-04-28 | ||||
| ### Fixed | ||||
| - Patch bug in Pafy when fetching audiostreams with latest youtube-dl ([@ritiek](https://github.com/ritiek)) (#539) | ||||
|  | ||||
| ### Changed | ||||
| - Removed duplicate debug log entry from `internals.trim_song` ([@ritiek](https://github.com/ritiek)) (#519) | ||||
| - Fix YAMLLoadWarning ([@cyberboysumanjay](https://github.com/cyberboysumanjay)) (#517) | ||||
|  | ||||
| ## [1.2.0] - 2019-03-01 | ||||
| ### Added | ||||
| - `--write-to` parameter for setting custom file to write Spotify track URLs to ([@ritiek](https://github.com/ritiek)) (#507) | ||||
| - Set custom Spotify Client ID and Client Secret via config.yml ([@ManveerBasra](https://github.com/ManveerBasra)) (#502) | ||||
| - Use YouTube as fallback metadata if track not found on Spotify. Also added `--no-fallback-metadata` | ||||
|   to preserve old behaviour ([@ritiek](https://github.com/ritiek)) (#457) | ||||
|  | ||||
| ### Fixed | ||||
| - Fix already downloaded prompt when using "/" in `--file-format` to create sub-directories ([@ritiek](https://github.com/ritiek)) (#503) | ||||
| - Fix writing playlist tracks to file ([@ritiek](https://github.com/ritiek)) (#506) | ||||
|  | ||||
| ## [1.1.2] - 2019-02-10 | ||||
| ### Changed | ||||
| - Fetch all artist albums by default instead of only fetching the "album" type ([@ritiek](https://github.com/ritiek)) (#493) | ||||
| - Option `-f` (`--folder`) is used when exporting text files using `-p` (`--playlist`) for playlists or `-b` (`--album`) for albums ([@Silverfeelin](https://github.com/Silverfeelin)) (#476) | ||||
| - Use first artist from album object for album artist ([@tillhainbach](https://github.com/tillhainbach)) | ||||
|  | ||||
| ### Fixed | ||||
| - Fix renaming files when encoder is not found ([@ritiek](https://github.com/ritiek)) (#475) | ||||
| - Add missing `import time` ([@ifduyue](https://github.com/ifduyue)) (#465) | ||||
|  | ||||
| ## [1.1.1] - 2019-01-03 | ||||
| ### Added | ||||
| - Output informative message in case of no result found in YouTube search ([@Amit-L](https://github.com/Amit-L)) (#452) | ||||
| - Ability to pass multiple tracks with `-s` option ([@ritiek](https://github.com/ritiek)) (#442) | ||||
|  | ||||
| ### Changed | ||||
| - Allowed to fetch metadata from Spotify upon searching Spotify-URL and  `--no-metadata` to gather YouTube custom-search fields ([@Amit-L](https://github.com/Amit-L)) (#452) | ||||
| - Change FFmpeg to use the built-in encoder `aac` instead of 3rd party `libfdk-aac` which does not | ||||
|   ship with the apt package ([@ritiek](https://github.com/ritiek)) (#448) | ||||
| - Monkeypatch ever-changing network-relying tests ([@ritiek](https://github.com/ritiek)) (#448) | ||||
| - Correct `.m4a` container before writing metadata so metadata fields shows up properly in | ||||
|   media players (especially iTunes) ([@ritiek](https://github.com/ritiek) with thanks to [@Amit-L](https://github.com/Amit-L)!) (#453) | ||||
| - Refactored core downloading module ([@ritiek](https://github.com/ritiek)) (#410) | ||||
|  | ||||
| ### Fixed | ||||
| - Workaround conversion conflicts when input and output filename are same ([@ritiek](https://github.com/ritiek)) (#459) | ||||
| - Applied a check on result in case of search using Spotify-URL  `--no-metadata` option ([@Amit-L](https://github.com/Amit-L)) (#452) | ||||
| - Included a missing `import spotipy` in downloader.py ([@ritiek](https://github.com/ritiek)) (#440) | ||||
|  | ||||
| ## [1.1.0] - 2018-11-13 | ||||
| ### Added | ||||
| - Output error details when track download fails from list file ([@ManveerBasra](https://github.com/ManveerBasra)) (#406) | ||||
| - Add support for `.m3u` playlists ([@ritiek](https://github.com/ritiek)) (#401) | ||||
| - Introduce usage of black (code formatter) ([@linusg](https://github.com/linusg)) (#393) | ||||
| - Added command line option for getting all artist's songs ([@AlfredoSequeida](https://github.com/AlfredoSequeida)) (#389) | ||||
| - Added command line options for skipping tracks file and successful downloads file and | ||||
|   place newline before track URL when appending to track file ([@linusg](https://github.com/linusg)) (#386) | ||||
| - Overwrite track file with unique tracks ([@ritiek](https://github.com/ritiek)) (#380) | ||||
| - Embed comment metadata in `.m4a` ([@ritiek](https://github.com/ritiek)) (#379) | ||||
| - Added check for publisher tag before adding publisher id3 tag to audio file ([@gnodar01](https://github.com/gnodar01)) (#377) | ||||
|  | ||||
| ### Changed | ||||
| - `--list` flag accepts only text files using mimetypes ([@ManveerBasra](https://github.com/ManveerBasra)) (#414) | ||||
| - Refactored Spotify token refresh ([@ManveerBasra](https://github.com/ManveerBasra)) (#408) | ||||
| - Don't search song on Spotify if `--no-metadata` is passed ([@ManveerBasra](https://github.com/ManveerBasra)) (#404) | ||||
| - Changed test track to one whose lyrics are found ([@ManveerBasra](https://github.com/ManveerBasra)) (#400) | ||||
| - Windows - 'My Music' folder won't be assumed to be on C drive but looked up in Registry ([@SillySam](https://github.com/SillySam)) (#387) | ||||
| - Updated `setup.py` (fix PyPI URL, add Python 3.7 modifier) ([@linusg](https://github.com/linusg)) (#383) | ||||
| - Updated dependencies to their newest versions (as of 2018-10-02) ([@linusg](https://github.com/linusg)) (#382) | ||||
| - Remove duplicates from track file while preserving order ([@ritiek](https://github.com/ritiek)) (#369) | ||||
| - Moved a lot of content from `README.md` to the [repository's GitHub wiki](https://github.com/ritiek/spotify-downloader/wiki) ([@sdhutchins](https://github.com/sdhutchins), [@ritiek](https://github.com/ritiek)) (#361) | ||||
| - Refactored internal use of logging ([@arryon](https://github.com/arryon)) (#358) | ||||
|  | ||||
| ### Fixed | ||||
| - Check and replace slashes with dashes to avoid directory creation error ([@ManveerBasra](https://github.com/ManveerBasra)) (#402) | ||||
| - Filter unwanted text from Spotify URLs when extracting information ([@ritiek](https://github.com/ritiek)) (#394) | ||||
| - Correctly embed metadata in `.m4a` ([@arryon](https://github.com/arryon)) (#372) | ||||
| - Slugify will not ignore the `'` character (single quotation mark) anymore ([@jimangel2001](https://github.com/jimangel2001)) (#357) | ||||
|  | ||||
| ## [1.0.0] - 2018-09-09 | ||||
| ### Added | ||||
| - Initial complete release, recommended way to install is now from PyPI | ||||
|  | ||||
| ## 1.0.0-beta.1 - 2018-02-02 | ||||
| ### Added | ||||
| - Initial release, prepare for 1.0.0 | ||||
|  | ||||
| [Unreleased]: https://github.com/ritiek/spotify-downloader/compare/v1.1.0...HEAD | ||||
| [1.1.0]: https://github.com/ritiek/spotify-downloader/compare/v1.0.0...v1.1.0 | ||||
| [1.0.0]: https://github.com/ritiek/spotify-downloader/compare/v1.0.0-beta.1...v1.0.0 | ||||
| @@ -1,74 +0,0 @@ | ||||
| # Contributor Covenant Code of Conduct | ||||
|  | ||||
| ## Our Pledge | ||||
|  | ||||
| In the interest of fostering an open and welcoming environment, we as | ||||
| contributors and maintainers pledge to making participation in our project and | ||||
| our community a harassment-free experience for everyone, regardless of age, body | ||||
| size, disability, ethnicity, gender identity and expression, level of experience, | ||||
| education, socio-economic status, nationality, personal appearance, race, | ||||
| religion, or sexual identity and orientation. | ||||
|  | ||||
| ## Our Standards | ||||
|  | ||||
| Examples of behavior that contributes to creating a positive environment | ||||
| include: | ||||
|  | ||||
| * Using welcoming and inclusive language | ||||
| * Being respectful of differing viewpoints and experiences | ||||
| * Gracefully accepting constructive criticism | ||||
| * Focusing on what is best for the community | ||||
| * Showing empathy towards other community members | ||||
|  | ||||
| Examples of unacceptable behavior by participants include: | ||||
|  | ||||
| * The use of sexualized language or imagery and unwelcome sexual attention or | ||||
|   advances | ||||
| * Trolling, insulting/derogatory comments, and personal or political attacks | ||||
| * Public or private harassment | ||||
| * Publishing others' private information, such as a physical or electronic | ||||
|   address, without explicit permission | ||||
| * Other conduct which could reasonably be considered inappropriate in a | ||||
|   professional setting | ||||
|  | ||||
| ## Our Responsibilities | ||||
|  | ||||
| Project maintainers are responsible for clarifying the standards of acceptable | ||||
| behavior and are expected to take appropriate and fair corrective action in | ||||
| response to any instances of unacceptable behavior. | ||||
|  | ||||
| Project maintainers have the right and responsibility to remove, edit, or | ||||
| reject comments, commits, code, wiki edits, issues, and other contributions | ||||
| that are not aligned to this Code of Conduct, or to ban temporarily or | ||||
| permanently any contributor for other behaviors that they deem inappropriate, | ||||
| threatening, offensive, or harmful. | ||||
|  | ||||
| ## Scope | ||||
|  | ||||
| This Code of Conduct applies both within project spaces and in public spaces | ||||
| when an individual is representing the project or its community. Examples of | ||||
| representing a project or community include using an official project e-mail | ||||
| address, posting via an official social media account, or acting as an appointed | ||||
| representative at an online or offline event. Representation of a project may be | ||||
| further defined and clarified by project maintainers. | ||||
|  | ||||
| ## Enforcement | ||||
|  | ||||
| Instances of abusive, harassing, or otherwise unacceptable behavior may be | ||||
| reported by contacting the project owner at ritiekmalhotra123@gmail.com. All | ||||
| complaints will be reviewed and investigated and will result in a response that | ||||
| is deemed necessary and appropriate to the circumstances. The project team is | ||||
| obligated to maintain confidentiality with regard to the reporter of an incident. | ||||
| Further details of specific enforcement policies may be posted separately. | ||||
|  | ||||
| Project maintainers who do not follow or enforce the Code of Conduct in good | ||||
| faith may face temporary or permanent repercussions as determined by other | ||||
| members of the project's leadership. | ||||
|  | ||||
| ## Attribution | ||||
|  | ||||
| This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, | ||||
| available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html | ||||
|  | ||||
| [homepage]: https://www.contributor-covenant.org | ||||
|  | ||||
| @@ -1,38 +0,0 @@ | ||||
| # Contributing | ||||
|  | ||||
| - Want to contribute to [spotify-downloader](https://github.com/ritiek/spotify-downloader)? | ||||
| That's great. We are happy to have you! | ||||
| - Here is a basic outline on opening issues and making PRs: | ||||
|  | ||||
| ## Opening Issues | ||||
|  | ||||
| - Search for your problem in the | ||||
| [issues section](https://github.com/ritiek/spotify-downloader/issues) | ||||
| before opening a new ticket. It might be already answered and save both you and us time. :smile: | ||||
| - Provide as much information as possible when opening your ticket, including any relevant examples (if any). | ||||
| - If your issue is a *bug*, make sure you pass `--log-level=DEBUG` when invoking | ||||
| `spotdl.py` and paste the output in your issue. | ||||
| - If you think your question is naive or something and you can't find anything related, | ||||
| don't feel bad. Open an issue any way! | ||||
|  | ||||
| ## Making Pull Requests | ||||
|  | ||||
| - Look up for open issues and see if you can help out there. | ||||
| - Easy issues for newcomers are usually labelled as | ||||
| [good-first-issue](https://github.com/ritiek/spotify-downloader/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). | ||||
| - When making a PR, point it to the [master branch](https://github.com/ritiek/spotify-downloader/tree/master) | ||||
| unless mentioned otherwise. | ||||
| - 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. | ||||
| - 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) | ||||
| to run the test suite: `$ pytest`. | ||||
| If you don't have pytest, you can install it with `$ pip3 install pytest`. | ||||
| - 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. | ||||
| - If you are planning to work on something big, let us know through an issue. So we can discuss more about it. | ||||
| - Lastly, please don't hesitate to ask if you have any questions! | ||||
| Let us know (through an issue) if you are facing any trouble making a PR, we'd be glad to help you out! | ||||
|  | ||||
| ## Related Resources | ||||
|  | ||||
| - There's also a web-based front-end to operate this tool, which under (major) construction | ||||
| called [spotifube](https://github.com/linusg/spotifube). | ||||
| Check it out if you'd like to contribute to it! | ||||
							
								
								
									
										16
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								Dockerfile
									
									
									
									
									
								
							| @@ -1,16 +0,0 @@ | ||||
| FROM python:3.6-alpine | ||||
|  | ||||
| RUN apk add --no-cache \ | ||||
|     ffmpeg | ||||
|  | ||||
| ADD spotdl/ /spotify-downloader/spotdl | ||||
| ADD setup.py /spotify-downloader/setup.py | ||||
| ADD README.md /spotify-downloader/README.md | ||||
|  | ||||
| WORKDIR /spotify-downloader | ||||
| RUN pip install . | ||||
|  | ||||
| RUN mkdir /music | ||||
| WORKDIR /music | ||||
|  | ||||
| ENTRYPOINT ["spotdl", "-f", "/music"] | ||||
| @@ -1,25 +1,31 @@ | ||||
| <!-- | ||||
| Please follow the guide below | ||||
|  | ||||
| - Before opening your ticket, make sure you either installed the latest release from PyPI | ||||
|   or installed directly from the master branch and have searched through existing issues | ||||
|   including closed ones. | ||||
| --> | ||||
|  | ||||
| <!-- | ||||
| - Put an `x` into the box [ ] below (like [x]) depending on the purpose of your issue | ||||
| - You will be asked some questions and requested to provide some information, please read them CAREFULLY and answer honestly | ||||
| - Put an `x` into all the boxes [ ] relevant to your *issue* (like that [x]) | ||||
| - Use *Preview* tab to see how your issue will actually look like | ||||
| --> | ||||
|  | ||||
| - [ ] Using latest version as provided on the [master branch](https://github.com/ritiek/spotify-downloader/tree/master) | ||||
| - [ ] [Searched](https://github.com/ritiek/spotify-downloader/issues?utf8=%E2%9C%93&q=is%3Aissue) for similar issues including closed ones | ||||
|  | ||||
| #### What is the purpose of your *issue*? | ||||
| - [ ] Bug | ||||
| - [ ] Feature Request | ||||
| - [ ] Script won't run | ||||
| - [ ] Encountered bug | ||||
| - [ ] Feature request | ||||
| - [ ] Question | ||||
| - [ ] Other | ||||
|  | ||||
| #### System information | ||||
| - Your `python` version: `python 3.x` | ||||
| - Your operating system: `Ubuntu 16.04` | ||||
|  | ||||
| ### Description | ||||
| <!-- Provide as much information possible and whatever you have tried below --> | ||||
| <!-- Provide as much information possible with relevant examples and whatever you have tried below --> | ||||
|  | ||||
|  | ||||
| ### Log | ||||
| <!-- Run the script with `--log-level=DEBUG` and paste the output below--> | ||||
|  | ||||
|  | ||||
|  | ||||
|  | ||||
| <!-- Give your issue a relevant title and you are good to go --> | ||||
|   | ||||
							
								
								
									
										2
									
								
								LICENSE → LICENSE.txt
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										2
									
								
								LICENSE → LICENSE.txt
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -1,5 +1,5 @@ | ||||
| The MIT License (MIT) | ||||
| Copyright (c) 2018 Ritiek Malhotra | ||||
| Copyright (c) 2016 Ritiek Malhotra | ||||
| 
 | ||||
| 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: | ||||
| 
 | ||||
							
								
								
									
										205
									
								
								README.md
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										205
									
								
								README.md
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -1,91 +1,196 @@ | ||||
| # Spotify-Downloader | ||||
|  | ||||
| [](https://pypi.org/project/spotdl) | ||||
| [](https://travis-ci.org/ritiek/spotify-downloader) | ||||
| [](https://codecov.io/gh/ritiek/spotify-downloader) | ||||
| [](https://hub.docker.com/r/ritiek/spotify-downloader) | ||||
| [](https://github.com/ambv/black) | ||||
| [](https://gitter.im/spotify-downloader/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) | ||||
|  | ||||
| - Downloads songs from YouTube in an MP3 format by using Spotify's HTTP link. | ||||
| - Downloads songs from YouTube in an MP3 format by using Spotify's HTTP link. | ||||
|  | ||||
| - 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). | ||||
| - Automatically applies metadata to the downloaded song which includes: | ||||
|  | ||||
|   - `Title`, `Artist`, `Album`, `Album art`, `Lyrics` (if found either on [Genius](https://genius.com/) or [LyricsWikia](http://lyrics.wikia.com)), `Album artist`, `Genre`, `Track number`, `Disc number`, `Release date`, and more... | ||||
| - Automatically fixes song's meta-tags which include: | ||||
|  | ||||
| - Works straight out of the box and does not require you to generate or mess with your API keys (already included). | ||||
|   - Title | ||||
|   - Artist | ||||
|   - Album | ||||
|   - Album art | ||||
|   - Album artist | ||||
|   - Genre | ||||
|   - Track number | ||||
|   - Disc number | ||||
|   - Release date | ||||
|   - And some more... | ||||
|  | ||||
| Below is how your music library will look! | ||||
| - Works straight out of the box and does not require to generate or mess with your API keys. | ||||
|  | ||||
| That's how your Music library will look like! | ||||
|  | ||||
| <img src="http://i.imgur.com/Gpch7JI.png" width="290"><img src="http://i.imgur.com/5vhk3HY.png" width="290"><img src="http://i.imgur.com/RDTCCST.png" width="290"> | ||||
|  | ||||
| ## Installation | ||||
| ## Reporting Issues | ||||
|  | ||||
| ❗️ **This tool works only with Python 3.** | ||||
| - Search for your problem in the [issues section](https://github.com/Ritiek/spotify-downloader/issues?utf8=%E2%9C%93&q=) before opening a new ticket. It might be already answered and save us time. :smile: | ||||
|  | ||||
| Python 2 compatibility was dropped because of the way it deals with unicode (2020 is coming soon too). | ||||
| If you still need to use Python 2 - check out the (outdated) | ||||
| [python2](https://github.com/ritiek/spotify-downloader/tree/python2) branch. | ||||
| - Provide as much information possible when opening your ticket. | ||||
|  | ||||
| spotify-downloader works with all major distributions and even on low-powered devices such as a Raspberry Pi. | ||||
| ## Installation & Usage | ||||
|  | ||||
| spotify-downloader can be installed via pip with: | ||||
| ```console | ||||
| $ pip3 install spotdl | ||||
| <img src="http://i.imgur.com/Dg8p9up.png" width="600"> | ||||
|  | ||||
| - **This tool supports only Python 3**, Python 2 compatibility was dropped because of the way it deals with unicode. If you need to use Python 2 though, check out the (old) `python2` branch. | ||||
|  | ||||
| - Note: `play` and `lyrics` commands have been deprecated in the current brach since they were not of much use and created unnecessary clutter. You can still get them back by using `old` branch though. | ||||
|  | ||||
| ### Debian, Ubuntu, Linux & Mac | ||||
|  | ||||
| ``` | ||||
| cd | ||||
| git clone https://github.com/ritiek/spotify-downloader | ||||
| cd spotify-downloader | ||||
| pip install -U -r requirements.txt | ||||
| ``` | ||||
|  | ||||
| but be sure to check out the [Installation](https://github.com/ritiek/spotify-downloader/wiki/Installation) wiki | ||||
| page for detailed OS-specific instructions to get it and other dependencies it relies on working on your system. | ||||
| **Important:** if you have installed both Python 2 and 3, the `pip` command could invoke an installation for Python 2. To see which Python version `pip` refers to, try `pip -V`. If it turns out `pip` is your Python 2 pip, try `pip3 install -U -r requirements.txt` instead. | ||||
|  | ||||
| ## Usage | ||||
| You'll also need to install FFmpeg for conversion (use `--avconv` if you'd like to use that instead): | ||||
|  | ||||
| For the most basic usage, downloading tracks is as easy as | ||||
| Linux: `sudo apt-get install ffmpeg` | ||||
|  | ||||
| ```console | ||||
| $ spotdl --song https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ | ||||
| $ spotdl --song "ncs - spectre" | ||||
| Mac: `brew install ffmpeg --with-libmp3lame --with-libass --with-opus --with-fdk-aac` | ||||
|  | ||||
| If it does not install correctly, you may have to build it from source. For more info see https://trac.ffmpeg.org/wiki/CompilationGuide. | ||||
|  | ||||
| ### Windows | ||||
|  | ||||
| Assuming you have Python 3 ([preferably v3.6 or above to stay away from Unicode errors](https://stackoverflow.com/questions/30539882/whats-the-deal-with-python-3-4-unicode-different-languages-and-windows)) already installed and in PATH. | ||||
|  | ||||
| - Download and extract the [zip file](https://github.com/ritiek/spotify-downloader/archive/master.zip) from master branch. | ||||
|  | ||||
| - Download FFmpeg for Windows from [here](http://ffmpeg.zeranoe.com/builds/). Copy `ffmpeg.exe` from `ffmpeg-xxx-winxx-static\bin\ffmpeg.exe` to PATH (usually C:\Windows\System32\) or just place it in the root directory extracted from the above step. | ||||
|  | ||||
| - Open `cmd` and type `pip install -U -r requirements.txt` to install dependencies. The same note about `pip` as for Debian, Ubuntu, Linux & Mac applies. | ||||
|  | ||||
| ## Instructions for Downloading Songs | ||||
|  | ||||
|  | ||||
| **Important:** as like with `pip`, there might be no `python3` command. This is most likely the case when you have only Python 3 but not 2 installed. In this case try the `python` command instead of `python3`, but make sure `python -V` gives you a `Python 3.x.x`! | ||||
|  | ||||
| - For all available options, run `python3 spotdl.py --help`. | ||||
|  | ||||
| ``` | ||||
| usage: spotdl.py [-h] (-s SONG | -l LIST | -p PLAYLIST | -u USERNAME) [-m] | ||||
|                  [-nm] [-a] [-f FOLDER] [-v] [-i INPUT_EXT] [-o OUTPUT_EXT] | ||||
|  | ||||
| Download and convert songs from Spotify, Youtube etc. | ||||
|  | ||||
| optional arguments: | ||||
|   -h, --help            show this help message and exit | ||||
|   -s SONG, --song SONG  download song by spotify link or name (default: None) | ||||
|   -l LIST, --list LIST  download songs from a file (default: None) | ||||
|   -p PLAYLIST, --playlist PLAYLIST | ||||
|                         load songs from playlist URL into <playlist_name>.txt | ||||
|                         (default: None) | ||||
|   -u USERNAME, --username USERNAME | ||||
|                         load songs from user's playlist into | ||||
|                         <playlist_name>.txt (default: None) | ||||
|   -m, --manual          choose the song to download manually (default: False) | ||||
|   -nm, --no-metadata    do not embed metadata in songs (default: False) | ||||
|   -a, --avconv          Use avconv for conversion otherwise set defaults to | ||||
|                         ffmpeg (default: False) | ||||
|   -f FOLDER, --folder FOLDER | ||||
|                         path to folder where files will be stored in (default: | ||||
|                         Music/) | ||||
|   -v, --verbose         show debug output (default: False) | ||||
|   -i INPUT_EXT, --input_ext INPUT_EXT | ||||
|                         prefered input format .m4a or .webm (Opus) (default: | ||||
|                         .m4a) | ||||
|   -o OUTPUT_EXT, --output_ext OUTPUT_EXT | ||||
|                         prefered output extension .mp3 or .m4a (AAC) (default: | ||||
|                         .mp3) | ||||
| ``` | ||||
|  | ||||
| For downloading playlist and albums, you need to first load all the tracks into text file and then pass | ||||
| this text file to `--list` argument. Here is how you would do it for a playlist | ||||
| #### Download by Name | ||||
|  | ||||
| ```console | ||||
| $ spotdl --playlist https://open.spotify.com/user/nocopyrightsounds/playlist/7sZbq8QGyMnhKPcLJvCUFD | ||||
| INFO: Writing 62 tracks to ncs-releases.txt | ||||
| $ spotdl --list ncs-releases.txt | ||||
| For example | ||||
|  | ||||
| - We want to download Hello by Adele, simply run `python3 spotdl.py --song "adele hello"`. | ||||
|  | ||||
| - The script will automatically look for the best matching song and download it in the folder `Music/` placed in the root directory of the code base. | ||||
|  | ||||
| - It will now convert the song to an mp3 and try to fix meta-tags and album-art by looking up on Spotify. | ||||
|  | ||||
| #### Download by Spotify Link (Recommended) | ||||
|  | ||||
| For example | ||||
|  | ||||
| - We want to download the same song (i.e: Hello by Adele) but using Spotify Link this time that looks like  `http://open.spotify.com/track/1MDoll6jK4rrk2BcFRP5i7`, you can copy it from your Spotify desktop or mobile app by right clicking or long tap on the song and copy HTTP link. | ||||
|  | ||||
| - Run `python3 spotdl.py --song http://open.spotify.com/track/1MDoll6jK4rrk2BcFRP5i7`, it should download Hello by Adele. | ||||
|  | ||||
| - Just like before, it will again convert the song to an mp3 but since we used a Spotify HTTP link, the script is guaranteed to fetch the correct meta-tags and album-art. | ||||
|  | ||||
| #### Download multiple songs at once | ||||
|  | ||||
| For example | ||||
|  | ||||
| - We want to download `Hello by Adele`, `The Nights by Avicci` and `21 Guns by Green Day` just using a single command. | ||||
|  | ||||
| Let's suppose, we have the Spotify link for only `Hello by Adele` and `21 Guns by Green Day`. | ||||
|  | ||||
| No problem! | ||||
|  | ||||
| - Just make a `list.txt` in the same folder as the script and add all the songs you want to download, in our case it is | ||||
|  | ||||
| (if you are on Windows, just edit `list.txt` - i.e `C:\Python36\spotify-downloader-master\list.txt`) | ||||
|  | ||||
| ``` | ||||
| https://open.spotify.com/track/1MDoll6jK4rrk2BcFRP5i7 | ||||
| the nights avicci | ||||
| http://open.spotify.com/track/64yrDBpcdwEdNY9loyEGbX | ||||
| ``` | ||||
|  | ||||
| Run `spotdl --help` to get a list of all available options in spotify-downloader. | ||||
| - Now pass `--list=list.txt` to the script, i.e `python3 spotdl.py --list=list.txt` and it will start downloading songs mentioned in `list.txt`. | ||||
|  | ||||
| Check out the [Available options](https://github.com/ritiek/spotify-downloader/wiki/Available-options) | ||||
| wiki page for the list of currently available options with their description. | ||||
| - You can stop downloading songs by hitting `ctrl+c`, the script will automatically resume from the song where you stopped it the next time you want to download the songs present in `list.txt`. | ||||
|  | ||||
| The wiki page [Instructions for Downloading Songs](https://github.com/ritiek/spotify-downloader/wiki/Instructions-for-Downloading-Songs) | ||||
| contains detailed information about different available ways to download tracks. | ||||
| - Songs that are already downloaded will be skipped and not be downloaded again. | ||||
|  | ||||
| ## FAQ | ||||
| #### Download playlists | ||||
|  | ||||
| All FAQs will be mentioned in our [FAQ wiki page](https://github.com/ritiek/spotify-downloader/wiki/FAQ). | ||||
| - You can copy the Spotify URL of the playlist and pass it in `--playlist` option. | ||||
|  | ||||
| ## Contributing | ||||
| For example | ||||
|  | ||||
| Check out [CONTRIBUTING.md](CONTRIBUTING.md) for more info. | ||||
| - `python3 spotdl.py --playlist https://open.spotify.com/user/camillazi/playlist/71MXqcSOKCxsLNtRvONkhF` | ||||
|  | ||||
| ## Running Tests | ||||
| - The script will load all the tracks from the playlist into `<playlist_name>.txt` | ||||
|  | ||||
| ```console | ||||
| $ pytest | ||||
| - Then you can simply run `python3 spotdl.py --list=<playlist_name>.txt` to download all the tracks. | ||||
|  | ||||
| #### Download playlists by username | ||||
|  | ||||
| - You can also load songs using Spotify username if you don't have the playlist URL. (Open profile in Spotify, click on the three little dots below name, "Share", "Copy to clipboard", paste last numbers into command-line: `https://open.spotify.com/user/0123456790`) | ||||
|  | ||||
| - Try running `python3 spotdl.py -u <your_username>`, it will show all your public playlists. | ||||
|  | ||||
| - Once you select the one you want to download, the script will load all the tracks from the playlist into `<playlist_name>.txt`. | ||||
|  | ||||
| - Run `python3 spotdl.py --list=<playlist_name>.txt` to download all the tracks. | ||||
|  | ||||
| #### Specify the target directory | ||||
|  | ||||
| If you don't want to download all the songs to the `Music/` folder relative to the `spotdl.py` script, you can use the `-f`/`--folder` option. E.g. `python3 spotdl.py -s "adele hello" -f "/home/user/Music/"`. This works with both relative and absolute paths. | ||||
|  | ||||
| ## Running tests | ||||
|  | ||||
| ``` | ||||
| python3 -m pytest test | ||||
| ``` | ||||
|  | ||||
| Obviously this requires the `pytest` module to be installed. | ||||
| Obviously this requires the `pytest` module to be installed.  | ||||
|  | ||||
| ## Disclaimer | ||||
|  | ||||
| Downloading copyright songs may be illegal in your country. | ||||
| This tool is for educational purposes only and was created only to show | ||||
| how Spotify's API can be exploited to download music from YouTube. | ||||
| Please support the artists by buying their music. | ||||
| Downloading copyright songs may be illegal in your country. This tool is for educational purposes only and was created only to show how Spotify's API can be exploited to download music from YouTube. Please support the artists by buying their music. | ||||
|  | ||||
| ## License | ||||
|  | ||||
| [](https://github.com/ritiek/spotify-downloader/blob/master/LICENSE) | ||||
| ```The MIT License``` | ||||
|   | ||||
							
								
								
									
										1
									
								
								core/__init__.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										1
									
								
								core/__init__.py
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1 @@ | ||||
|  | ||||
							
								
								
									
										68
									
								
								core/convert.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								core/convert.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| import subprocess | ||||
| import os | ||||
|  | ||||
|  | ||||
| """ | ||||
| What are the differences and similarities between ffmpeg, libav, and avconv? | ||||
| https://stackoverflow.com/questions/9477115 | ||||
| ffmeg encoders high to lower quality | ||||
| libopus > libvorbis >= libfdk_aac > aac > libmp3lame | ||||
| libfdk_aac due to copyrights needs to be compiled by end user | ||||
| on MacOS brew install ffmpeg --with-fdk-aac will do just that. Other OS? | ||||
| https://trac.ffmpeg.org/wiki/Encode/AAC | ||||
| """ | ||||
|  | ||||
| def song(input_song, output_song, folder, avconv=False, verbose=False): | ||||
|     """Do the audio format conversion.""" | ||||
|     if not input_song == output_song: | ||||
|         print('Converting {0} to {1}'.format( | ||||
|             input_song, output_song.split('.')[-1])) | ||||
|         if avconv: | ||||
|             exit_code = convert_with_avconv(input_song, output_song, folder, verbose) | ||||
|         else: | ||||
|             exit_code = convert_with_ffmpeg(input_song, output_song, folder, verbose) | ||||
|         return exit_code | ||||
|     return 0 | ||||
|  | ||||
|  | ||||
| def convert_with_avconv(input_song, output_song, folder, verbose): | ||||
|     """Convert the audio file using avconv.""" | ||||
|     if verbose: | ||||
|         level = 'debug' | ||||
|     else: | ||||
|         level = '0' | ||||
|  | ||||
|     command = ['avconv', | ||||
|                '-loglevel', level, | ||||
|                '-i',        os.path.join(folder, input_song), | ||||
|                '-ab',       '192k', | ||||
|                os.path.join(folder, output_song)] | ||||
|  | ||||
|     return subprocess.call(command) | ||||
|  | ||||
|  | ||||
| def convert_with_ffmpeg(input_song, output_song, folder, verbose): | ||||
|     """Convert the audio file using FFmpeg.""" | ||||
|     ffmpeg_pre = 'ffmpeg -y ' | ||||
|     if not verbose: | ||||
|         ffmpeg_pre += '-hide_banner -nostats -v panic ' | ||||
|  | ||||
|     input_ext = input_song.split('.')[-1] | ||||
|     output_ext = output_song.split('.')[-1] | ||||
|  | ||||
|     if input_ext == 'm4a': | ||||
|         if output_ext == 'mp3': | ||||
|             ffmpeg_params = '-codec:v copy -codec:a libmp3lame -q:a 2 ' | ||||
|         elif output_ext == 'webm': | ||||
|             ffmpeg_params = '-c:a libopus -vbr on -b:a 192k -vn ' | ||||
|  | ||||
|     elif input_ext == 'webm': | ||||
|         if output_ext == 'mp3': | ||||
|             ffmpeg_params = ' -ab 192k -ar 44100 -vn ' | ||||
|         elif output_ext == 'm4a': | ||||
|             ffmpeg_params = '-cutoff 20000 -c:a libfdk_aac -b:a 192k -vn ' | ||||
|  | ||||
|     command = '{0}-i {1} {2}{3}'.format( | ||||
|         ffmpeg_pre, os.path.join(folder, input_song), ffmpeg_params, os.path.join(folder, output_song)).split(' ') | ||||
|  | ||||
|     return subprocess.call(command) | ||||
							
								
								
									
										125
									
								
								core/metadata.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										125
									
								
								core/metadata.py
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| from mutagen.easyid3 import EasyID3 | ||||
| from mutagen.id3 import ID3, APIC | ||||
| from mutagen.mp4 import MP4, MP4Cover | ||||
|  | ||||
| import urllib.request | ||||
|  | ||||
|  | ||||
| def compare(music_file, metadata): | ||||
|     """Check if the input music file title matches the expected title.""" | ||||
|     already_tagged = False | ||||
|     try: | ||||
|         if music_file.endswith('.mp3'): | ||||
|             audiofile = EasyID3(music_file) | ||||
|             # fetch track title metadata | ||||
|             already_tagged = audiofile['title'][0] == metadata['name'] | ||||
|         elif music_file.endswith('.m4a'): | ||||
|             tags = {'title': '\xa9nam'} | ||||
|             audiofile = MP4(music_file) | ||||
|             # fetch track title metadata | ||||
|             already_tagged = audiofile[tags['title']] == metadata['name'] | ||||
|     except (KeyError, TypeError): | ||||
|         pass | ||||
|     return already_tagged | ||||
|  | ||||
|  | ||||
| def embed(music_file, meta_tags): | ||||
|     """Embed metadata.""" | ||||
|     if meta_tags is None: | ||||
|         print('Could not find meta-tags') | ||||
|         return None | ||||
|     elif music_file.endswith('.m4a'): | ||||
|         print('Fixing meta-tags') | ||||
|         return embed_m4a(music_file, meta_tags) | ||||
|     elif music_file.endswith('.mp3'): | ||||
|         print('Fixing meta-tags') | ||||
|         return embed_mp3(music_file, meta_tags) | ||||
|     else: | ||||
|         print('Cannot embed meta-tags into given output extension') | ||||
|         return False | ||||
|  | ||||
|  | ||||
| def embed_mp3(music_file, meta_tags): | ||||
|     """Embed metadata to MP3 files.""" | ||||
|     # EasyID3 is fun to use ;) | ||||
|     audiofile = EasyID3(music_file) | ||||
|     audiofile['artist'] = meta_tags['artists'][0]['name'] | ||||
|     audiofile['albumartist'] = meta_tags['artists'][0]['name'] | ||||
|     audiofile['album'] = meta_tags['album']['name'] | ||||
|     audiofile['title'] = meta_tags['name'] | ||||
|     audiofile['tracknumber'] = [meta_tags['track_number'], | ||||
|                                 meta_tags['total_tracks']] | ||||
|     audiofile['discnumber'] = [meta_tags['disc_number'], 0] | ||||
|     audiofile['date'] = meta_tags['release_date'] | ||||
|     audiofile['originaldate'] = meta_tags['release_date'] | ||||
|     audiofile['media'] = meta_tags['type'] | ||||
|     audiofile['author'] = meta_tags['artists'][0]['name'] | ||||
|     audiofile['lyricist'] = meta_tags['artists'][0]['name'] | ||||
|     audiofile['arranger'] = meta_tags['artists'][0]['name'] | ||||
|     audiofile['performer'] = meta_tags['artists'][0]['name'] | ||||
|     audiofile['encodedby'] = meta_tags['publisher'] | ||||
|     audiofile['website'] = meta_tags['external_urls']['spotify'] | ||||
|     audiofile['length'] = str(meta_tags['duration_ms'] / 1000) | ||||
|     if meta_tags['genre']: | ||||
|         audiofile['genre'] = meta_tags['genre'] | ||||
|     if meta_tags['copyright']: | ||||
|         audiofile['copyright'] = meta_tags['copyright'] | ||||
|     if meta_tags['isrc']: | ||||
|         audiofile['isrc'] = meta_tags['external_ids']['isrc'] | ||||
|     audiofile.save(v2_version=3) | ||||
|     audiofile = ID3(music_file) | ||||
|     try: | ||||
|         albumart = urllib.request.urlopen(meta_tags['album']['images'][0]['url']) | ||||
|         audiofile["APIC"] = APIC(encoding=3, mime='image/jpeg', type=3, | ||||
|                                  desc=u'Cover', data=albumart.read()) | ||||
|         albumart.close() | ||||
|     except IndexError: | ||||
|         pass | ||||
|     audiofile.save(v2_version=3) | ||||
|     return True | ||||
|  | ||||
|  | ||||
| def embed_m4a(music_file, meta_tags): | ||||
|     """Embed metadata to M4A files.""" | ||||
|     # Apple has specific tags - see mutagen docs - | ||||
|     # http://mutagen.readthedocs.io/en/latest/api/mp4.html | ||||
|     tags = {'album': '\xa9alb', | ||||
|             'artist': '\xa9ART', | ||||
|             'date': '\xa9day', | ||||
|             'title': '\xa9nam', | ||||
|             'originaldate': 'purd', | ||||
|             'comment': '\xa9cmt', | ||||
|             'group': '\xa9grp', | ||||
|             'writer': '\xa9wrt', | ||||
|             'genre': '\xa9gen', | ||||
|             'tracknumber': 'trkn', | ||||
|             'albumartist': 'aART', | ||||
|             'disknumber': 'disk', | ||||
|             'cpil': 'cpil', | ||||
|             'albumart': 'covr', | ||||
|             'copyright': 'cprt', | ||||
|             'tempo': 'tmpo'} | ||||
|  | ||||
|     audiofile = MP4(music_file) | ||||
|     audiofile[tags['artist']] = meta_tags['artists'][0]['name'] | ||||
|     audiofile[tags['albumartist']] = meta_tags['artists'][0]['name'] | ||||
|     audiofile[tags['album']] = meta_tags['album']['name'] | ||||
|     audiofile[tags['title']] = meta_tags['name'] | ||||
|     audiofile[tags['tracknumber']] = [(meta_tags['track_number'], | ||||
|                                        meta_tags['total_tracks'])] | ||||
|     audiofile[tags['disknumber']] = [(meta_tags['disc_number'], 0)] | ||||
|     audiofile[tags['date']] = meta_tags['release_date'] | ||||
|     audiofile[tags['originaldate']] = meta_tags['release_date'] | ||||
|     if meta_tags['genre']: | ||||
|         audiofile[tags['genre']] = meta_tags['genre'] | ||||
|     if meta_tags['copyright']: | ||||
|         audiofile[tags['copyright']] = meta_tags['copyright'] | ||||
|     try: | ||||
|         albumart = urllib.request.urlopen(meta_tags['album']['images'][0]['url']) | ||||
|         audiofile[tags['albumart']] = [MP4Cover( | ||||
|             albumart.read(), imageformat=MP4Cover.FORMAT_JPEG)] | ||||
|         albumart.close() | ||||
|     except IndexError: | ||||
|         pass | ||||
|     audiofile.save() | ||||
|     return True | ||||
							
								
								
									
										141
									
								
								core/misc.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										141
									
								
								core/misc.py
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| import sys | ||||
| import os | ||||
| import argparse | ||||
| import spotipy.oauth2 as oauth2 | ||||
| from urllib.request import quote | ||||
| from slugify import slugify | ||||
|  | ||||
|  | ||||
| def input_link(links): | ||||
|     """Let the user input a number.""" | ||||
|     while True: | ||||
|         try: | ||||
|             the_chosen_one = int(input('>> Choose your number: ')) | ||||
|             if 1 <= the_chosen_one <= len(links): | ||||
|                 return links[the_chosen_one - 1] | ||||
|             elif the_chosen_one == 0: | ||||
|                 return None | ||||
|             else: | ||||
|                 print('Choose a valid number!') | ||||
|         except ValueError: | ||||
|             print('Choose a valid number!') | ||||
|  | ||||
|  | ||||
| def trim_song(file): | ||||
|     """Remove the first song from file.""" | ||||
|     with open(file, 'r') as file_in: | ||||
|         data = file_in.read().splitlines(True) | ||||
|     with open(file, 'w') as file_out: | ||||
|         file_out.writelines(data[1:]) | ||||
|  | ||||
|  | ||||
| def get_arguments(): | ||||
|     parser = argparse.ArgumentParser( | ||||
|         description='Download and convert songs from Spotify, Youtube etc.', | ||||
|         formatter_class=argparse.ArgumentDefaultsHelpFormatter) | ||||
|     group = parser.add_mutually_exclusive_group(required=True) | ||||
|  | ||||
|     group.add_argument( | ||||
|         '-s', '--song', help='download song by spotify link or name') | ||||
|     group.add_argument( | ||||
|         '-l', '--list', help='download songs from a file') | ||||
|     group.add_argument( | ||||
|         '-p', '--playlist', help='load songs from playlist URL into <playlist_name>.txt') | ||||
|     group.add_argument( | ||||
|         '-u', '--username', | ||||
|         help="load songs from user's playlist into <playlist_name>.txt") | ||||
|     parser.add_argument( | ||||
|         '-m', '--manual', default=False, | ||||
|         help='choose the song to download manually', action='store_true') | ||||
|     parser.add_argument( | ||||
|         '-nm', '--no-metadata', default=False, | ||||
|         help='do not embed metadata in songs', action='store_true') | ||||
|     parser.add_argument( | ||||
|         '-a', '--avconv', default=False, | ||||
|         help='Use avconv for conversion otherwise set defaults to ffmpeg', | ||||
|         action='store_true') | ||||
|     parser.add_argument( | ||||
|         '-f', '--folder', default='Music/', | ||||
|         help='path to folder where files will be stored in') | ||||
|     parser.add_argument( | ||||
|         '-v', '--verbose', default=False, help='show debug output', | ||||
|         action='store_true') | ||||
|     parser.add_argument( | ||||
|         '-i', '--input_ext', default='.m4a', | ||||
|         help='prefered input format .m4a or .webm (Opus)') | ||||
|     parser.add_argument( | ||||
|         '-o', '--output_ext', default='.mp3', | ||||
|         help='prefered output extension .mp3 or .m4a (AAC)') | ||||
|  | ||||
|     return parser.parse_args() | ||||
|  | ||||
|  | ||||
| def is_spotify(raw_song): | ||||
|     """Check if the input song is a Spotify link.""" | ||||
|     status = len(raw_song) == 22 and raw_song.replace(" ", "%20") == raw_song | ||||
|     status = status or raw_song.find('spotify') > -1 | ||||
|     return status | ||||
|  | ||||
| def is_youtube(raw_song): | ||||
|     """Check if the input song is a YouTube link.""" | ||||
|     status = len(raw_song) == 11 and raw_song.replace(" ", "%20") == raw_song | ||||
|     status = status and not raw_song.lower() == raw_song | ||||
|     status = status or 'youtube.com/watch?v=' in raw_song | ||||
|     return status | ||||
|  | ||||
|  | ||||
| def sanitize_title(title): | ||||
|     """Generate filename of the song to be downloaded.""" | ||||
|     title = title.replace(' ', '_') | ||||
|     title = title.replace('/', '_') | ||||
|  | ||||
|     # slugify removes any special characters | ||||
|     title = slugify(title, ok='-_()[]{}', lower=False) | ||||
|     return title | ||||
|  | ||||
|  | ||||
| def generate_token(): | ||||
|     """Generate the token. Please respect these credentials :)""" | ||||
|     credentials = oauth2.SpotifyClientCredentials( | ||||
|         client_id='4fe3fecfe5334023a1472516cc99d805', | ||||
|         client_secret='0f02b7c483c04257984695007a4a8d5c') | ||||
|     token = credentials.get_access_token() | ||||
|     return token | ||||
|  | ||||
|  | ||||
| def generate_search_url(song, viewsort=False): | ||||
|     """Generate YouTube search URL for the given song.""" | ||||
|     # urllib.request.quote() encodes URL with special characters | ||||
|     song = quote(song) | ||||
|     if viewsort: | ||||
|         url = u"https://www.youtube.com/results?q={0}".format(song) | ||||
|     else: | ||||
|         url = u"https://www.youtube.com/results?sp=EgIQAQ%253D%253D&q={0}".format(song) | ||||
|  | ||||
|     return url | ||||
|  | ||||
|  | ||||
| def filter_path(path): | ||||
|     os.chdir(sys.path[0]) | ||||
|     if not os.path.exists(path): | ||||
|         os.makedirs(path) | ||||
|     for temp in os.listdir(path): | ||||
|         if temp.endswith('.temp'): | ||||
|             os.remove(os.path.join(path, temp)) | ||||
|  | ||||
|  | ||||
| def grace_quit(): | ||||
|     print('\n\nExiting.') | ||||
|     sys.exit() | ||||
|  | ||||
| def get_sec(time_str): | ||||
|    v = time_str.split(':', 3) | ||||
|    v.reverse() | ||||
|    sec = 0 | ||||
|    if len(v) > 0: #seconds | ||||
|        sec += int(v[0]) | ||||
|    if len(v) > 1: # minutes | ||||
|        sec += int(v[1]) * 60 | ||||
|    if len(v) > 2: # hours | ||||
|        sec += int(v[2]) * 3600 | ||||
|    return sec | ||||
							
								
								
									
										8
									
								
								requirements.txt
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										8
									
								
								requirements.txt
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| pathlib >= 1.0.1 | ||||
| BeautifulSoup4 >= 0.4.13 | ||||
| youtube_dl >= 2017.5.1 | ||||
| pafy >= 0.5.3.1 | ||||
| spotipy >= 2.4.4 | ||||
| mutagen >= 1.37 | ||||
| unicode-slugify >= 0.1.3 | ||||
| titlecase >= 0.10.0 | ||||
							
								
								
									
										63
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										63
									
								
								setup.py
									
									
									
									
									
								
							| @@ -1,63 +0,0 @@ | ||||
| from setuptools import setup | ||||
|  | ||||
| with open("README.md", "r", encoding="utf-8") as f: | ||||
|     long_description = f.read() | ||||
|  | ||||
| import spotdl | ||||
|  | ||||
| setup( | ||||
|     # 'spotify-downloader' was already taken :/ | ||||
|     name="spotdl", | ||||
|     # Tests are included automatically: | ||||
|     # https://docs.python.org/3.6/distutils/sourcedist.html#specifying-the-files-to-distribute | ||||
|     packages=["spotdl", "spotdl.lyrics", "spotdl.lyrics.providers"], | ||||
|     version=spotdl.__version__, | ||||
|     install_requires=[ | ||||
|         "pathlib >= 1.0.1", | ||||
|         "youtube_dl >= 2017.9.26", | ||||
|         "pafy >= 0.5.3.1", | ||||
|         "spotipy >= 2.4.4", | ||||
|         "mutagen >= 1.41.1", | ||||
|         "beautifulsoup4 >= 4.6.3", | ||||
|         "unicode-slugify >= 0.1.3", | ||||
|         "titlecase >= 0.10.0", | ||||
|         "logzero >= 1.3.1", | ||||
|         "lyricwikia >= 0.1.8", | ||||
|         "PyYAML >= 3.13", | ||||
|         "appdirs >= 1.4.3", | ||||
|     ], | ||||
|     description="Download songs from YouTube using Spotify song URLs or playlists with albumart and meta-tags.", | ||||
|     long_description=long_description, | ||||
|     long_description_content_type="text/markdown", | ||||
|     author="Ritiek Malhotra and the spotify-downloader contributors", | ||||
|     author_email="ritiekmalhotra123@gmail.com", | ||||
|     license="MIT", | ||||
|     python_requires=">=3.4", | ||||
|     url="https://github.com/ritiek/spotify-downloader", | ||||
|     download_url="https://pypi.org/project/spotdl/", | ||||
|     keywords=[ | ||||
|         "spotify", | ||||
|         "downloader", | ||||
|         "download", | ||||
|         "music", | ||||
|         "youtube", | ||||
|         "mp3", | ||||
|         "album", | ||||
|         "metadata", | ||||
|     ], | ||||
|     classifiers=[ | ||||
|         "Intended Audience :: End Users/Desktop", | ||||
|         "License :: OSI Approved :: MIT License", | ||||
|         "Programming Language :: Python", | ||||
|         "Programming Language :: Python :: 3", | ||||
|         "Programming Language :: Python :: 3.4", | ||||
|         "Programming Language :: Python :: 3.5", | ||||
|         "Programming Language :: Python :: 3.6", | ||||
|         "Programming Language :: Python :: 3.7", | ||||
|         "Programming Language :: Python :: 3 :: Only", | ||||
|         "Topic :: Multimedia", | ||||
|         "Topic :: Multimedia :: Sound/Audio", | ||||
|         "Topic :: Utilities", | ||||
|     ], | ||||
|     entry_points={"console_scripts": ["spotdl = spotdl.spotdl:main"]}, | ||||
| ) | ||||
							
								
								
									
										428
									
								
								spotdl.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										428
									
								
								spotdl.py
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,428 @@ | ||||
| #!/usr/bin/env python | ||||
| # -*- coding: UTF-8 -*- | ||||
|  | ||||
| from core import metadata | ||||
| from core import convert | ||||
| from core import misc | ||||
| from bs4 import BeautifulSoup | ||||
| from titlecase import titlecase | ||||
| from slugify import slugify | ||||
| import spotipy | ||||
| import pafy | ||||
| import urllib.request | ||||
| import sys | ||||
| import os | ||||
| import time | ||||
| import threading | ||||
|  | ||||
|  | ||||
| def generate_songname(tags): | ||||
|     """Generate a string of the format '[artist] - [song]' for the given spotify song.""" | ||||
|     raw_song = u'{0} - {1}'.format(tags['artists'][0]['name'], tags['name']) | ||||
|     return raw_song | ||||
|  | ||||
|  | ||||
| def generate_metadata(raw_song): | ||||
|     """Fetch a song's metadata from Spotify.""" | ||||
|     if misc.is_spotify(raw_song): | ||||
|         # fetch track information directly if it is spotify link | ||||
|         meta_tags = spotify.track(raw_song) | ||||
|     else: | ||||
|         # otherwise search on spotify and fetch information from first result | ||||
|         try: | ||||
|             meta_tags = spotify.search(raw_song, limit=1)['tracks']['items'][0] | ||||
|         except: | ||||
|             return None | ||||
|     artist = spotify.artist(meta_tags['artists'][0]['id']) | ||||
|     album = spotify.album(meta_tags['album']['id']) | ||||
|  | ||||
|     try: | ||||
|         meta_tags[u'genre'] = titlecase(artist['genres'][0]) | ||||
|     except IndexError: | ||||
|         meta_tags[u'genre'] = None | ||||
|     try: | ||||
|         meta_tags[u'copyright'] = album['copyrights'][0]['text'] | ||||
|     except IndexError: | ||||
|         meta_tags[u'copyright'] = None | ||||
|     try: | ||||
|         meta_tags['isrc'] | ||||
|     except KeyError: | ||||
|         meta_tags['isrc'] = None | ||||
|  | ||||
|     meta_tags[u'release_date'] = album['release_date'] | ||||
|     meta_tags[u'publisher'] = album['label'] | ||||
|     meta_tags[u'total_tracks'] = album['tracks']['total'] | ||||
|  | ||||
|     return meta_tags | ||||
|  | ||||
|  | ||||
| def generate_youtube_url(raw_song, tries_remaining=5): | ||||
|     """Search for the song on YouTube and generate a URL to its video.""" | ||||
|     # prevents an infinite loop but allows for a few retries | ||||
|     if tries_remaining == 0: | ||||
|         return | ||||
|  | ||||
|     meta_tags = generate_metadata(raw_song) | ||||
|     if meta_tags is None: | ||||
|         song = raw_song | ||||
|         search_url = misc.generate_search_url(song, viewsort=False) | ||||
|     else: | ||||
|         song = generate_songname(meta_tags) | ||||
|         search_url = misc.generate_search_url(song, viewsort=True) | ||||
|  | ||||
|     item = urllib.request.urlopen(search_url).read() | ||||
|     # item = unicode(item, 'utf-8') | ||||
|     items_parse = BeautifulSoup(item, "html.parser") | ||||
|  | ||||
|     videos = [] | ||||
|     for x in items_parse.find_all('div', {'class': 'yt-lockup-dismissable yt-uix-tile'}): | ||||
|         # ensure result is not a channel | ||||
|         if x.find('channel') is not None or 'yt-lockup-channel' in x.parent.attrs['class'] or 'yt-lockup-channel' in x.attrs['class']: | ||||
|             continue | ||||
|  | ||||
|         # ensure result is not a mix/playlist | ||||
|         if 'yt-lockup-playlist' in x.parent.attrs['class']: | ||||
|             continue | ||||
|  | ||||
|         # confirm the video result is not an advertisement | ||||
|         if x.find('googleads') is not None: | ||||
|             continue | ||||
|  | ||||
|         y = x.find('div', class_='yt-lockup-content') | ||||
|         link = y.find('a')['href'] | ||||
|         title = y.find('a')['title'] | ||||
|         try: | ||||
|             videotime = x.find('span', class_="video-time").get_text() | ||||
|         except AttributeError: | ||||
|             return generate_youtube_url(raw_song, tries_remaining - 1) | ||||
|  | ||||
|         youtubedetails = {'link': link, 'title': title, 'videotime': videotime, 'seconds':misc.get_sec(videotime)} | ||||
|         videos.append(youtubedetails) | ||||
|         if meta_tags is None: | ||||
|             break | ||||
|  | ||||
|     if not videos: | ||||
|         return None | ||||
|  | ||||
|     if args.manual: | ||||
|         print(song) | ||||
|         print('') | ||||
|         print('0. Skip downloading this song') | ||||
|         # fetch all video links on first page on YouTube | ||||
|         for i, v in enumerate(videos): | ||||
|           print(u'{0}. {1} {2} {3}'.format(i+1, v['title'], v['videotime'], "http://youtube.com"+v['link'])) | ||||
|         print('') | ||||
|         # let user select the song to download | ||||
|         result = misc.input_link(videos) | ||||
|         if result is None: | ||||
|             return None | ||||
|     else: | ||||
|         if meta_tags is not None: | ||||
|             # filter out videos that do not have a similar length to the Spotify song | ||||
|             duration_tolerance = 10 | ||||
|             max_duration_tolerance = 20 | ||||
|             possible_videos_by_duration = list() | ||||
|  | ||||
|             ''' | ||||
|             start with a reasonable duration_tolerance, and increment duration_tolerance | ||||
|             until one of the Youtube results falls within the correct duration or | ||||
|             the duration_tolerance has reached the max_duration_tolerance | ||||
|             ''' | ||||
|             while len(possible_videos_by_duration) == 0: | ||||
|                 possible_videos_by_duration = list(filter(lambda x: abs(x['seconds'] - (int(meta_tags['duration_ms'])/1000)) <= duration_tolerance, videos)) | ||||
|                 duration_tolerance += 1 | ||||
|                 if duration_tolerance > max_duration_tolerance: | ||||
|                     print(meta_tags['name'], 'by', meta_tags['artists'][0]['name'], 'was not found') | ||||
|                     return None | ||||
|  | ||||
|             result = possible_videos_by_duration[0] | ||||
|         else: | ||||
|             # if the metadata could not be acquired, take the first result from Youtube because the proper song length is unknown | ||||
|             result = videos[0] | ||||
|  | ||||
|     full_link = None | ||||
|     if result: | ||||
|         full_link = u'youtube.com{0}'.format(result['link']) | ||||
|  | ||||
|     return full_link | ||||
|  | ||||
|  | ||||
| def go_pafy(raw_song): | ||||
|     """Parse track from YouTube.""" | ||||
|     if misc.is_youtube(raw_song): | ||||
|         track_info = pafy.new(raw_song) | ||||
|     else: | ||||
|         track_url = generate_youtube_url(raw_song) | ||||
|  | ||||
|         if track_url is None: | ||||
|             track_info = None | ||||
|         else: | ||||
|             track_info = pafy.new(track_url) | ||||
|  | ||||
|     return track_info | ||||
|  | ||||
|  | ||||
| def get_youtube_title(content, number=None): | ||||
|     """Get the YouTube video's title.""" | ||||
|     title = content.title | ||||
|     if number is None: | ||||
|         return title | ||||
|     else: | ||||
|         return '{0}. {1}'.format(number, title) | ||||
|  | ||||
|  | ||||
| def feed_playlist(username): | ||||
|     """Fetch user playlists when using the -u option.""" | ||||
|     playlists = spotify.user_playlists(username) | ||||
|     links = [] | ||||
|     check = 1 | ||||
|  | ||||
|     while True: | ||||
|         for playlist in playlists['items']: | ||||
|             # in rare cases, playlists may not be found, so playlists['next'] | ||||
|             # is None. Skip these. Also see Issue #91. | ||||
|             if playlist['name'] is not None: | ||||
|                 print(u'{0:>5}. {1:<30}  ({2} tracks)'.format( | ||||
|                     check, playlist['name'], | ||||
|                     playlist['tracks']['total'])) | ||||
|                 links.append(playlist) | ||||
|                 check += 1 | ||||
|         if playlists['next']: | ||||
|             playlists = spotify.next(playlists) | ||||
|         else: | ||||
|             break | ||||
|  | ||||
|     print('') | ||||
|     playlist = misc.input_link(links) | ||||
|     print('') | ||||
|     write_tracks(playlist) | ||||
|  | ||||
|  | ||||
| def write_tracks(playlist): | ||||
|     results = spotify.user_playlist( | ||||
|         playlist['owner']['id'], playlist['id'], fields='tracks,next') | ||||
|     text_file = u'{0}.txt'.format(slugify(playlist['name'], ok='-_()[]{}')) | ||||
|     print(u'Feeding {0} tracks to {1}'.format(playlist['tracks']['total'], text_file)) | ||||
|  | ||||
|     tracks = results['tracks'] | ||||
|     with open(text_file, 'a') as file_out: | ||||
|         while True: | ||||
|             for item in tracks['items']: | ||||
|                 track = item['track'] | ||||
|                 try: | ||||
|                     file_out.write(track['external_urls']['spotify'] + '\n') | ||||
|                 except KeyError: | ||||
|                     print(u'Skipping track {0} by {1} (local only?)'.format( | ||||
|                         track['name'], track['artists'][0]['name'])) | ||||
|             # 1 page = 50 results | ||||
|             # check if there are more pages | ||||
|             if tracks['next']: | ||||
|                 tracks = spotify.next(tracks) | ||||
|             else: | ||||
|                 break | ||||
|  | ||||
|  | ||||
| def download_song(file_name, content): | ||||
|     """Download the audio file from YouTube.""" | ||||
|     if args.input_ext == '.webm': | ||||
|         link = content.getbestaudio(preftype='webm') | ||||
|     elif args.input_ext == '.m4a': | ||||
|         link = content.getbestaudio(preftype='m4a') | ||||
|     else: | ||||
|         return False | ||||
|  | ||||
|     if link is None: | ||||
|         return False | ||||
|     else: | ||||
|         link.download( | ||||
|             filepath='{0}{1}'.format(os.path.join(args.folder, file_name), args.input_ext)) | ||||
|         return True | ||||
|  | ||||
|  | ||||
| def check_exists(music_file, raw_song, islist=True): | ||||
|     """Check if the input song already exists in the given folder.""" | ||||
|     songs = os.listdir(args.folder) | ||||
|     for song in songs: | ||||
|         if song.endswith('.temp'): | ||||
|             os.remove(os.path.join(args.folder, song)) | ||||
|             continue | ||||
|         # check if any song with similar name is already present in the given folder | ||||
|         file_name = misc.sanitize_title(music_file) | ||||
|         if song.startswith(file_name): | ||||
|             # check if the already downloaded song has correct metadata | ||||
|             already_tagged = metadata.compare(os.path.join(args.folder, song), generate_metadata(raw_song)) | ||||
|  | ||||
|             # if not, remove it and download again without prompt | ||||
|             if misc.is_spotify(raw_song) and not already_tagged: | ||||
|                 os.remove(os.path.join(args.folder, song)) | ||||
|                 return False | ||||
|  | ||||
|             # do not prompt and skip the current song | ||||
|             # if already downloaded when using list | ||||
|             if islist: | ||||
|                 print('Song already exists') | ||||
|                 return True | ||||
|             # if downloading only single song, prompt to re-download | ||||
|             else: | ||||
|                 prompt = input('Song with same name has already been downloaded. ' | ||||
|                                'Re-download? (y/n): ').lower() | ||||
|                 if prompt == 'y': | ||||
|                     os.remove(os.path.join(args.folder, song)) | ||||
|                     return False | ||||
|                 else: | ||||
|                     return True | ||||
|     return False | ||||
|  | ||||
|  | ||||
| def grab_list(text_file): | ||||
|     """Download all songs from the list.""" | ||||
|     with open(text_file, 'r') as listed: | ||||
|         lines = (listed.read()).splitlines() | ||||
|     # ignore blank lines in text_file (if any) | ||||
|     try: | ||||
|         lines.remove('') | ||||
|     except ValueError: | ||||
|         pass | ||||
|     print(u'Total songs in list: {0} songs'.format(len(lines))) | ||||
|     print('') | ||||
|     # nth input song | ||||
|     number = 1 | ||||
|     for raw_song in lines: | ||||
|         try: | ||||
|             grab_single(raw_song, number=number) | ||||
|         # token expires after 1 hour | ||||
|         except spotipy.oauth2.SpotifyOauthError: | ||||
|             # refresh token when it expires | ||||
|             new_token = misc.generate_token() | ||||
|             global spotify | ||||
|             spotify = spotipy.Spotify(auth=new_token) | ||||
|             grab_single(raw_song, number=number) | ||||
|         # detect network problems | ||||
|         except (urllib.request.URLError, TypeError, IOError): | ||||
|             lines.append(raw_song) | ||||
|             # remove the downloaded song from .txt | ||||
|             misc.trim_song(text_file) | ||||
|             # and append it to the last line in .txt | ||||
|             with open(text_file, 'a') as myfile: | ||||
|                 myfile.write(raw_song + '\n') | ||||
|             print('Failed to download song. Will retry after other songs.') | ||||
|             # wait 0.5 sec to avoid infinite looping | ||||
|             time.sleep(0.5) | ||||
|             continue | ||||
|         except KeyboardInterrupt: | ||||
|             misc.grace_quit() | ||||
|         finally: | ||||
|             print('') | ||||
|         misc.trim_song(text_file) | ||||
|         number += 1 | ||||
|  | ||||
|  | ||||
| def grab_playlist(playlist): | ||||
|     if '/' in playlist: | ||||
|         if playlist.endswith('/'): | ||||
|             playlist = playlist[:-1] | ||||
|         splits = playlist.split('/') | ||||
|     else: | ||||
|         splits = playlist.split(':') | ||||
|  | ||||
|     username = splits[-3] | ||||
|     playlist_id = splits[-1] | ||||
|     playlists = spotify.user_playlists(username) | ||||
|  | ||||
|     while True: | ||||
|         for playlist in playlists['items']: | ||||
|             if not playlist['name'] == None: | ||||
|                 if playlist['id'] == playlist_id: | ||||
|                     playlists['next'] = None | ||||
|                     break | ||||
|         if playlists['next']: | ||||
|             playlists = spotify.next(playlists) | ||||
|         else: | ||||
|             break | ||||
|  | ||||
|     write_tracks(playlist) | ||||
|  | ||||
|  | ||||
| def grab_single(raw_song, number=None): | ||||
|     """Logic behind downloading a song.""" | ||||
|     if number: | ||||
|         islist = True | ||||
|     else: | ||||
|         islist = False | ||||
|  | ||||
|     content = go_pafy(raw_song) | ||||
|     if content is None: | ||||
|         return | ||||
|  | ||||
|     if misc.is_youtube(raw_song): | ||||
|         raw_song = slugify(content.title).replace('-', ' ') | ||||
|  | ||||
|     # print '[number]. [artist] - [song]' if downloading from list | ||||
|     # otherwise print '[artist] - [song]' | ||||
|     print(get_youtube_title(content, number)) | ||||
|  | ||||
|     # generate file name of the song to download | ||||
|     meta_tags = generate_metadata(raw_song) | ||||
|     songname = content.title | ||||
|  | ||||
|     if meta_tags is not None: | ||||
|         refined_songname = generate_songname(meta_tags) | ||||
|         if not refined_songname == ' - ': | ||||
|             songname = refined_songname | ||||
|  | ||||
|     file_name = misc.sanitize_title(songname) | ||||
|  | ||||
|     if not check_exists(file_name, raw_song, islist=islist): | ||||
|         if download_song(file_name, content): | ||||
|             print('') | ||||
|             thread = threading.Thread(target=finalize, args=(file_name, meta_tags)) | ||||
|             threads.append(thread) | ||||
|             thread.start() | ||||
|         else: | ||||
|             print('No audio streams available') | ||||
|  | ||||
|  | ||||
| def finalize(file_name, meta_tags): | ||||
|     input_song = file_name + args.input_ext | ||||
|     output_song = file_name + args.output_ext | ||||
|     convert.song(input_song, output_song, args.folder, | ||||
|                  avconv=args.avconv, verbose=args.verbose) | ||||
|     if not args.input_ext == args.output_ext: | ||||
|         os.remove(os.path.join(args.folder, input_song)) | ||||
|  | ||||
|     if not args.no_metadata: | ||||
|         metadata.embed(os.path.join(args.folder, output_song), meta_tags) | ||||
|  | ||||
|  | ||||
| class TestArgs(object): | ||||
|     manual = False | ||||
|     input_ext = '.m4a' | ||||
|     output_ext = '.mp3' | ||||
|     folder = 'Music/' | ||||
|  | ||||
| # token is mandatory when using Spotify's API | ||||
| # https://developer.spotify.com/news-stories/2017/01/27/removing-unauthenticated-calls-to-the-web-api/ | ||||
| token = misc.generate_token() | ||||
| spotify = spotipy.Spotify(auth=token) | ||||
|  | ||||
| if __name__ == '__main__': | ||||
|     os.chdir(sys.path[0]) | ||||
|     args = misc.get_arguments() | ||||
|  | ||||
|     misc.filter_path(args.folder) | ||||
|     threads = [] | ||||
|  | ||||
|     if args.song: | ||||
|         grab_single(raw_song=args.song) | ||||
|     elif args.list: | ||||
|         grab_list(text_file=args.list) | ||||
|     elif args.playlist: | ||||
|         grab_playlist(playlist=args.playlist) | ||||
|     elif args.username: | ||||
|         feed_playlist(username=args.username) | ||||
|  | ||||
|     for thread in threads: | ||||
|         thread.join() | ||||
| else: | ||||
|     misc.filter_path('Music') | ||||
|     args = TestArgs() | ||||
| @@ -1 +0,0 @@ | ||||
| __version__ = "1.2.5" | ||||
| @@ -1,42 +0,0 @@ | ||||
| import logzero | ||||
|  | ||||
| _log_format = "%(color)s%(levelname)s:%(end_color)s %(message)s" | ||||
| _formatter = logzero.LogFormatter(fmt=_log_format) | ||||
| _log_level = 0 | ||||
|  | ||||
| # Set up a temporary logger with default log level so that | ||||
| # it can be used before log level argument is determined | ||||
| logzero.setup_default_logger(formatter=_formatter, level=_log_level) | ||||
|  | ||||
| # Options | ||||
| # Initialize an empty object which can be assigned attributes | ||||
| # (useful when using spotdl as a library) | ||||
| args = type("", (), {})() | ||||
|  | ||||
| # Apple has specific tags - see mutagen docs - | ||||
| # http://mutagen.readthedocs.io/en/latest/api/mp4.html | ||||
| M4A_TAG_PRESET = { | ||||
|     "album": "\xa9alb", | ||||
|     "artist": "\xa9ART", | ||||
|     "date": "\xa9day", | ||||
|     "title": "\xa9nam", | ||||
|     "year": "\xa9day", | ||||
|     "originaldate": "purd", | ||||
|     "comment": "\xa9cmt", | ||||
|     "group": "\xa9grp", | ||||
|     "writer": "\xa9wrt", | ||||
|     "genre": "\xa9gen", | ||||
|     "tracknumber": "trkn", | ||||
|     "albumartist": "aART", | ||||
|     "discnumber": "disk", | ||||
|     "cpil": "cpil", | ||||
|     "albumart": "covr", | ||||
|     "copyright": "cprt", | ||||
|     "tempo": "tmpo", | ||||
|     "lyrics": "\xa9lyr", | ||||
|     "comment": "\xa9cmt", | ||||
| } | ||||
|  | ||||
| TAG_PRESET = {} | ||||
| for key in M4A_TAG_PRESET.keys(): | ||||
|     TAG_PRESET[key] = key | ||||
| @@ -1,164 +0,0 @@ | ||||
| import subprocess | ||||
| import os | ||||
| from logzero import logger as log | ||||
|  | ||||
|  | ||||
| """ | ||||
| What are the differences and similarities between ffmpeg, libav, and avconv? | ||||
| https://stackoverflow.com/questions/9477115 | ||||
|  | ||||
| ffmeg encoders high to lower quality | ||||
| libopus > libvorbis >= libfdk_aac > aac > libmp3lame | ||||
|  | ||||
| libfdk_aac due to copyrights needs to be compiled by end user | ||||
| on MacOS brew install ffmpeg --with-fdk-aac will do just that. Other OS? | ||||
| https://trac.ffmpeg.org/wiki/Encode/AAC | ||||
| """ | ||||
|  | ||||
|  | ||||
| def song( | ||||
|     input_song, | ||||
|     output_song, | ||||
|     folder, | ||||
|     avconv=False, | ||||
|     trim_silence=False, | ||||
|     delete_original=True, | ||||
| ): | ||||
|     """ Do the audio format conversion. """ | ||||
|     if avconv and trim_silence: | ||||
|         raise ValueError("avconv does not support trim_silence") | ||||
|  | ||||
|     if not input_song == output_song: | ||||
|         log.info("Converting {0} to {1}".format(input_song, output_song.split(".")[-1])) | ||||
|     elif input_song.endswith(".m4a"): | ||||
|         log.info('Correcting container in "{}"'.format(input_song)) | ||||
|     else: | ||||
|         return 0 | ||||
|  | ||||
|     convert = Converter( | ||||
|         input_song, output_song, folder, delete_original=delete_original | ||||
|     ) | ||||
|     if avconv: | ||||
|         exit_code, command = convert.with_avconv() | ||||
|     else: | ||||
|         exit_code, command = convert.with_ffmpeg(trim_silence=trim_silence) | ||||
|     return exit_code, command | ||||
|  | ||||
|  | ||||
| class Converter: | ||||
|     def __init__(self, input_song, output_song, folder, delete_original): | ||||
|         _, self.input_ext = os.path.splitext(input_song) | ||||
|         _, self.output_ext = os.path.splitext(output_song) | ||||
|  | ||||
|         self.output_file = os.path.join(folder, output_song) | ||||
|         rename_to_temp = False | ||||
|  | ||||
|         same_file = os.path.abspath(input_song) == os.path.abspath(output_song) | ||||
|         if same_file: | ||||
|             # FFmpeg/avconv cannot have the same file for both input and output | ||||
|             # This would happen when the extensions are same, so rename | ||||
|             # the input track to append ".temp" | ||||
|             log.debug( | ||||
|                 'Input file and output file are going will be same during encoding, will append ".temp" to input file just before starting encoding to avoid conflict' | ||||
|             ) | ||||
|             input_song = output_song + ".temp" | ||||
|             rename_to_temp = True | ||||
|             delete_original = True | ||||
|  | ||||
|         self.input_file = os.path.join(folder, input_song) | ||||
|  | ||||
|         self.rename_to_temp = rename_to_temp | ||||
|         self.delete_original = delete_original | ||||
|  | ||||
|     def with_avconv(self): | ||||
|         if log.level == 10: | ||||
|             level = "debug" | ||||
|         else: | ||||
|             level = "0" | ||||
|  | ||||
|         command = [ | ||||
|             "avconv", | ||||
|             "-loglevel", | ||||
|             level, | ||||
|             "-i", | ||||
|             self.input_file, | ||||
|             "-ab", | ||||
|             "192k", | ||||
|             self.output_file, | ||||
|             "-y", | ||||
|         ] | ||||
|  | ||||
|         if self.rename_to_temp: | ||||
|             os.rename(self.output_file, self.input_file) | ||||
|  | ||||
|         log.debug(command) | ||||
|         try: | ||||
|             code = subprocess.call(command) | ||||
|         except FileNotFoundError: | ||||
|             if self.rename_to_temp: | ||||
|                 os.rename(self.input_file, self.output_file) | ||||
|             raise | ||||
|  | ||||
|         if self.delete_original: | ||||
|             log.debug('Removing original file: "{}"'.format(self.input_file)) | ||||
|             os.remove(self.input_file) | ||||
|  | ||||
|         return code, command | ||||
|  | ||||
|     def with_ffmpeg(self, trim_silence=False): | ||||
|         ffmpeg_pre = ( | ||||
|             "ffmpeg -y -nostdin " | ||||
|         )  # -nostdin is necessary for spotdl to be able to run in the backgroung. | ||||
|  | ||||
|         if not log.level == 10: | ||||
|             ffmpeg_pre += "-hide_banner -nostats -v panic " | ||||
|  | ||||
|         ffmpeg_params = "" | ||||
|  | ||||
|         if self.input_ext == ".m4a": | ||||
|             if self.output_ext == ".mp3": | ||||
|                 ffmpeg_params = "-codec:v copy -codec:a libmp3lame -ar 48000 " | ||||
|             elif self.output_ext == ".webm": | ||||
|                 ffmpeg_params = "-codec:a libopus -vbr on " | ||||
|             elif self.output_ext == ".m4a": | ||||
|                 ffmpeg_params = "-acodec copy " | ||||
|  | ||||
|         elif self.input_ext == ".webm": | ||||
|             if self.output_ext == ".mp3": | ||||
|                 ffmpeg_params = "-codec:a libmp3lame -ar 48000 " | ||||
|             elif self.output_ext == ".m4a": | ||||
|                 ffmpeg_params = "-cutoff 20000 -codec:a aac -ar 48000 " | ||||
|  | ||||
|         if self.output_ext == ".flac": | ||||
|             ffmpeg_params = "-codec:a flac -ar 48000 " | ||||
|  | ||||
|         # add common params for any of the above combination | ||||
|         ffmpeg_params += "-b:a 192k -vn " | ||||
|         ffmpeg_pre += "-i " | ||||
|  | ||||
|         if trim_silence: | ||||
|             ffmpeg_params += "-af silenceremove=start_periods=1 " | ||||
|  | ||||
|         command = ( | ||||
|             ffmpeg_pre.split() | ||||
|             + [self.input_file] | ||||
|             + ffmpeg_params.split() | ||||
|             + [self.output_file] | ||||
|         ) | ||||
|  | ||||
|         if self.rename_to_temp: | ||||
|             os.rename(self.output_file, self.input_file) | ||||
|  | ||||
|         log.debug(command) | ||||
|         try: | ||||
|             code = subprocess.call(command) | ||||
|         except FileNotFoundError: | ||||
|             if self.rename_to_temp: | ||||
|                 os.rename(self.input_file, self.output_file) | ||||
|             raise | ||||
|  | ||||
|         if self.delete_original: | ||||
|             log.debug('Removing original file: "{}"'.format(self.input_file)) | ||||
|             os.remove(self.input_file) | ||||
|  | ||||
|         return code, command | ||||
| @@ -1,256 +0,0 @@ | ||||
| import spotipy | ||||
| import urllib | ||||
| import os | ||||
| import time | ||||
| from logzero import logger as log | ||||
|  | ||||
| from spotdl import const | ||||
| from spotdl import metadata | ||||
| from spotdl import convert | ||||
| from spotdl import internals | ||||
| from spotdl import spotify_tools | ||||
| from spotdl import youtube_tools | ||||
|  | ||||
|  | ||||
| class CheckExists: | ||||
|     def __init__(self, music_file, meta_tags=None): | ||||
|         self.meta_tags = meta_tags | ||||
|         basepath, filename = os.path.split(music_file) | ||||
|         filepath = os.path.join(const.args.folder, basepath) | ||||
|         os.makedirs(filepath, exist_ok=True) | ||||
|         self.filepath = filepath | ||||
|         self.filename = filename | ||||
|  | ||||
|     def already_exists(self, raw_song): | ||||
|         """ Check if the input song already exists in the given folder. """ | ||||
|         log.debug( | ||||
|             "Cleaning any temp files and checking " | ||||
|             'if "{}" already exists'.format(self.filename) | ||||
|         ) | ||||
|         songs = os.listdir(self.filepath) | ||||
|         self._remove_temp_files(songs) | ||||
|  | ||||
|         for song in songs: | ||||
|             # check if a song with the same name is already present in the given folder | ||||
|             if self._match_filenames(song): | ||||
|                 if internals.is_spotify(raw_song) and not self._has_metadata(song): | ||||
|                     return False | ||||
|  | ||||
|                 log.warning('"{}" already exists'.format(song)) | ||||
|                 if const.args.overwrite == "prompt": | ||||
|                     return self._prompt_song(song) | ||||
|                 elif const.args.overwrite == "force": | ||||
|                     return self._force_overwrite_song(song) | ||||
|                 elif const.args.overwrite == "skip": | ||||
|                     return self._skip_song(song) | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     def _remove_temp_files(self, songs): | ||||
|         for song in songs: | ||||
|             if song.endswith(".temp"): | ||||
|                 os.remove(os.path.join(self.filepath, song)) | ||||
|  | ||||
|     def _has_metadata(self, song): | ||||
|         # check if the already downloaded song has correct metadata | ||||
|         # if not, remove it and download again without prompt | ||||
|         already_tagged = metadata.compare( | ||||
|             os.path.join(self.filepath, song), self.meta_tags | ||||
|         ) | ||||
|         log.debug("Checking if it is already tagged correctly? {}", already_tagged) | ||||
|         if not already_tagged: | ||||
|             os.remove(os.path.join(self.filepath, song)) | ||||
|             return False | ||||
|  | ||||
|         return True | ||||
|  | ||||
|     def _prompt_song(self, song): | ||||
|         log.info( | ||||
|             '"{}" has already been downloaded. ' "Re-download? (y/N): ".format(song) | ||||
|         ) | ||||
|         prompt = input("> ") | ||||
|         if prompt.lower() == "y": | ||||
|             return self._force_overwrite_song(song) | ||||
|         else: | ||||
|             return self._skip_song(song) | ||||
|  | ||||
|     def _force_overwrite_song(self, song): | ||||
|         os.remove(os.path.join(const.args.folder, song)) | ||||
|         log.info('Overwriting "{}"'.format(song)) | ||||
|         return False | ||||
|  | ||||
|     def _skip_song(self, song): | ||||
|         log.info('Skipping "{}"'.format(song)) | ||||
|         return True | ||||
|  | ||||
|     def _match_filenames(self, song): | ||||
|         if os.path.splitext(song)[0] == self.filename: | ||||
|             log.debug('Found an already existing song: "{}"'.format(song)) | ||||
|             return True | ||||
|  | ||||
|         return False | ||||
|  | ||||
|  | ||||
| class Downloader: | ||||
|     def __init__(self, raw_song, number=None): | ||||
|         self.raw_song = raw_song | ||||
|         self.number = number | ||||
|         self.content, self.meta_tags = youtube_tools.match_video_and_metadata(raw_song) | ||||
|         self.total_songs = int(self.meta_tags["total_tracks"]) | ||||
|  | ||||
|     def download_single(self): | ||||
|         """ Logic behind downloading a song. """ | ||||
|  | ||||
|         if self._to_skip(): | ||||
|             return | ||||
|  | ||||
|         # "[number]. [artist] - [song]" if downloading from list | ||||
|         # otherwise "[artist] - [song]" | ||||
|         youtube_title = youtube_tools.get_youtube_title(self.content, self.number) | ||||
|         log.info("{} ({})".format(youtube_title, self.content.watchv_url)) | ||||
|  | ||||
|         # generate file name of the song to download | ||||
|         songname = self.refine_songname(self.content.title) | ||||
|  | ||||
|         if const.args.dry_run: | ||||
|             return | ||||
|  | ||||
|         song_existence = CheckExists(songname, self.meta_tags) | ||||
|         if not song_existence.already_exists(self.raw_song): | ||||
|             return self._download_single(songname) | ||||
|  | ||||
|     def _download_single(self, songname): | ||||
|         # deal with file formats containing slashes to non-existent directories | ||||
|         songpath = os.path.join(const.args.folder, os.path.dirname(songname)) | ||||
|         os.makedirs(songpath, exist_ok=True) | ||||
|         input_song = songname + const.args.input_ext | ||||
|         output_song = songname + const.args.output_ext | ||||
|         if youtube_tools.download_song(input_song, self.content): | ||||
|             print("") | ||||
|             try: | ||||
|                 convert.song( | ||||
|                     input_song, | ||||
|                     output_song, | ||||
|                     const.args.folder, | ||||
|                     avconv=const.args.avconv, | ||||
|                     trim_silence=const.args.trim_silence, | ||||
|                     delete_original=not const.args.no_remove_original, | ||||
|                 ) | ||||
|             except FileNotFoundError: | ||||
|                 encoder = "avconv" if const.args.avconv else "ffmpeg" | ||||
|                 log.warning("Could not find {0}, skip encoding".format(encoder)) | ||||
|                 output_song = self.unconverted_filename(songname) | ||||
|  | ||||
|             if not const.args.no_metadata and self.meta_tags is not None: | ||||
|                 metadata.embed( | ||||
|                     os.path.join(const.args.folder, output_song), self.meta_tags | ||||
|                 ) | ||||
|             return True | ||||
|  | ||||
|     def _to_skip(self): | ||||
|         if self.content is None: | ||||
|             log.debug("Found no matching video") | ||||
|             return True | ||||
|  | ||||
|         if const.args.download_only_metadata and self.meta_tags is None: | ||||
|             log.info("Found no metadata. Skipping the download") | ||||
|             return True | ||||
|  | ||||
|     def refine_songname(self, songname): | ||||
|         if self.meta_tags is not None: | ||||
|             refined_songname = internals.format_string( | ||||
|                 const.args.file_format, | ||||
|                 self.meta_tags, | ||||
|                 slugification=True, | ||||
|                 total_songs=self.total_songs, | ||||
|             ) | ||||
|             log.debug( | ||||
|                 'Refining songname from "{0}" to "{1}"'.format( | ||||
|                     songname, refined_songname | ||||
|                 ) | ||||
|             ) | ||||
|             if not refined_songname == " - ": | ||||
|                 songname = refined_songname | ||||
|         else: | ||||
|             songname = internals.sanitize_title(songname) | ||||
|  | ||||
|         return songname | ||||
|  | ||||
|     @staticmethod | ||||
|     def unconverted_filename(songname): | ||||
|         const.args.output_ext = const.args.input_ext | ||||
|         output_song = songname + const.args.output_ext | ||||
|         return output_song | ||||
|  | ||||
|  | ||||
| class ListDownloader: | ||||
|     def __init__(self, tracks_file, skip_file=None, write_successful_file=None): | ||||
|         self.tracks_file = tracks_file | ||||
|         self.skip_file = skip_file | ||||
|         self.write_successful_file = write_successful_file | ||||
|         self.tracks = internals.get_unique_tracks(self.tracks_file) | ||||
|  | ||||
|     def download_list(self): | ||||
|         """ Download all songs from the list. """ | ||||
|         # override file with unique tracks | ||||
|         log.info("Overriding {} with unique tracks".format(self.tracks_file)) | ||||
|         self._override_file() | ||||
|  | ||||
|         # Remove tracks to skip from tracks list | ||||
|         if self.skip_file is not None: | ||||
|             self.tracks = self._filter_tracks_against_skip_file() | ||||
|  | ||||
|         log.info(u"Preparing to download {} songs".format(len(self.tracks))) | ||||
|         return self._download_list() | ||||
|  | ||||
|     def _download_list(self): | ||||
|         downloaded_songs = [] | ||||
|  | ||||
|         for number, raw_song in enumerate(self.tracks, 1): | ||||
|             print("") | ||||
|             try: | ||||
|                 track_dl = Downloader(raw_song, number=number) | ||||
|                 track_dl.download_single() | ||||
|             except (urllib.request.URLError, TypeError, IOError) as e: | ||||
|                 # detect network problems | ||||
|                 self._cleanup(raw_song, e) | ||||
|                 # TODO: remove this sleep once #397 is fixed | ||||
|                 # wait 0.5 sec to avoid infinite looping | ||||
|                 time.sleep(0.5) | ||||
|                 continue | ||||
|  | ||||
|             downloaded_songs.append(raw_song) | ||||
|             # Add track to file of successful downloads | ||||
|             if self.write_successful_file is not None: | ||||
|                 self._write_successful(raw_song) | ||||
|  | ||||
|             log.debug("Removing downloaded song from tracks file") | ||||
|             internals.trim_song(self.tracks_file) | ||||
|  | ||||
|         return downloaded_songs | ||||
|  | ||||
|     def _override_file(self): | ||||
|         with open(self.tracks_file, "w") as f: | ||||
|             f.write("\n".join(self.tracks)) | ||||
|  | ||||
|     def _write_successful(self, raw_song): | ||||
|         log.debug("Adding downloaded song to write successful file") | ||||
|         with open(self.write_successful_file, "a") as f: | ||||
|             f.write("\n" + raw_song) | ||||
|  | ||||
|     def _cleanup(self, raw_song, exception): | ||||
|         self.tracks.append(raw_song) | ||||
|         # remove the downloaded song from file | ||||
|         internals.trim_song(self.tracks_file) | ||||
|         # and append it at the end of file | ||||
|         with open(self.tracks_file, "a") as f: | ||||
|             f.write("\n" + raw_song) | ||||
|         log.exception(exception) | ||||
|         log.warning("Failed to download song. Will retry after other songs\n") | ||||
|  | ||||
|     def _filter_tracks_against_skip_file(self): | ||||
|         skip_tracks = internals.get_unique_tracks(self.skip_file) | ||||
|         len_before = len(self.tracks) | ||||
|         tracks = [track for track in self.tracks if track not in skip_tracks] | ||||
|         log.info("Skipping {} tracks".format(len_before - len(tracks))) | ||||
|         return tracks | ||||
							
								
								
									
										332
									
								
								spotdl/handle.py
									
									
									
									
									
								
							
							
						
						
									
										332
									
								
								spotdl/handle.py
									
									
									
									
									
								
							| @@ -1,332 +0,0 @@ | ||||
| from logzero import logger as log | ||||
| import appdirs | ||||
|  | ||||
| import logging | ||||
| import yaml | ||||
| import argparse | ||||
| import mimetypes | ||||
| import os | ||||
|  | ||||
| import spotdl | ||||
| from spotdl import internals | ||||
|  | ||||
|  | ||||
| _LOG_LEVELS_STR = ["INFO", "WARNING", "ERROR", "DEBUG"] | ||||
|  | ||||
| default_conf = { | ||||
|     "spotify-downloader": { | ||||
|         "no-remove-original": False, | ||||
|         "manual": False, | ||||
|         "no-metadata": False, | ||||
|         "no-fallback-metadata": False, | ||||
|         "avconv": False, | ||||
|         "folder": internals.get_music_dir(), | ||||
|         "overwrite": "prompt", | ||||
|         "input-ext": ".m4a", | ||||
|         "output-ext": ".mp3", | ||||
|         "write-to": None, | ||||
|         "trim-silence": False, | ||||
|         "download-only-metadata": False, | ||||
|         "dry-run": False, | ||||
|         "music-videos-only": False, | ||||
|         "no-spaces": False, | ||||
|         "file-format": "{artist} - {track_name}", | ||||
|         "search-format": "{artist} - {track_name} lyrics", | ||||
|         "youtube-api-key": None, | ||||
|         "skip": None, | ||||
|         "write-successful": None, | ||||
|         "log-level": "INFO", | ||||
|         "spotify_client_id": "4fe3fecfe5334023a1472516cc99d805", | ||||
|         "spotify_client_secret": "0f02b7c483c04257984695007a4a8d5c", | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| def log_leveller(log_level_str): | ||||
|     loggin_levels = [logging.INFO, logging.WARNING, logging.ERROR, logging.DEBUG] | ||||
|     log_level_str_index = _LOG_LEVELS_STR.index(log_level_str) | ||||
|     loggin_level = loggin_levels[log_level_str_index] | ||||
|     return loggin_level | ||||
|  | ||||
|  | ||||
| def merge(default, config): | ||||
|     """ Override default dict with config dict. """ | ||||
|     merged = default.copy() | ||||
|     merged.update(config) | ||||
|     return merged | ||||
|  | ||||
|  | ||||
| def get_config(config_file): | ||||
|     try: | ||||
|         with open(config_file, "r") as ymlfile: | ||||
|             cfg = yaml.safe_load(ymlfile) | ||||
|     except FileNotFoundError: | ||||
|         log.info("Writing default configuration to {0}:".format(config_file)) | ||||
|         with open(config_file, "w") as ymlfile: | ||||
|             yaml.dump(default_conf, ymlfile, default_flow_style=False) | ||||
|             cfg = default_conf | ||||
|  | ||||
|         for line in yaml.dump( | ||||
|             default_conf["spotify-downloader"], default_flow_style=False | ||||
|         ).split("\n"): | ||||
|             if line.strip(): | ||||
|                 log.info(line.strip()) | ||||
|         log.info( | ||||
|             "Please note that command line arguments have higher priority " | ||||
|             "than their equivalents in the configuration file" | ||||
|         ) | ||||
|  | ||||
|     return cfg["spotify-downloader"] | ||||
|  | ||||
|  | ||||
| def override_config(config_file, parser, raw_args=None): | ||||
|     """ Override default dict with config dict passed as comamnd line argument. """ | ||||
|     config_file = os.path.realpath(config_file) | ||||
|     config = merge(default_conf["spotify-downloader"], get_config(config_file)) | ||||
|     parser.set_defaults(**config) | ||||
|     return parser.parse_args(raw_args) | ||||
|  | ||||
|  | ||||
| def get_arguments(raw_args=None, to_group=True, to_merge=True): | ||||
|     parser = argparse.ArgumentParser( | ||||
|         description="Download and convert tracks from Spotify, Youtube etc.", | ||||
|         formatter_class=argparse.ArgumentDefaultsHelpFormatter, | ||||
|     ) | ||||
|  | ||||
|     if to_merge: | ||||
|         config_dir = os.path.join(appdirs.user_config_dir(), "spotdl") | ||||
|         os.makedirs(config_dir, exist_ok=True) | ||||
|         config_file = os.path.join(config_dir, "config.yml") | ||||
|         config = merge(default_conf["spotify-downloader"], get_config(config_file)) | ||||
|     else: | ||||
|         config = default_conf["spotify-downloader"] | ||||
|  | ||||
|     if to_group: | ||||
|         group = parser.add_mutually_exclusive_group(required=True) | ||||
|  | ||||
|         group.add_argument( | ||||
|             "-s", "--song", nargs="+", help="download track by spotify link or name" | ||||
|         ) | ||||
|         group.add_argument("-l", "--list", help="download tracks from a file") | ||||
|         group.add_argument( | ||||
|             "-p", | ||||
|             "--playlist", | ||||
|             help="load tracks from playlist URL into <playlist_name>.txt", | ||||
|         ) | ||||
|         group.add_argument( | ||||
|             "-b", "--album", help="load tracks from album URL into <album_name>.txt" | ||||
|         ) | ||||
|         group.add_argument( | ||||
|             "-ab", | ||||
|             "--all-albums", | ||||
|             help="load all tracks from artist URL into <artist_name>.txt", | ||||
|         ) | ||||
|         group.add_argument( | ||||
|             "-u", | ||||
|             "--username", | ||||
|             help="load tracks from user's playlist into <playlist_name>.txt", | ||||
|         ) | ||||
|  | ||||
|     parser.add_argument( | ||||
|         "--write-m3u", | ||||
|         help="generate an .m3u playlist file with youtube links given " | ||||
|         "a text file containing tracks", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-m", | ||||
|         "--manual", | ||||
|         default=config["manual"], | ||||
|         help="choose the track to download manually from a list of matching tracks", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-nr", | ||||
|         "--no-remove-original", | ||||
|         default=config["no-remove-original"], | ||||
|         help="do not remove the original file after conversion", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-nm", | ||||
|         "--no-metadata", | ||||
|         default=config["no-metadata"], | ||||
|         help="do not embed metadata in tracks", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-nf", | ||||
|         "--no-fallback-metadata", | ||||
|         default=config["no-fallback-metadata"], | ||||
|         help="do not use YouTube as fallback for metadata if track not found on Spotify", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-a", | ||||
|         "--avconv", | ||||
|         default=config["avconv"], | ||||
|         help="use avconv for conversion (otherwise defaults to ffmpeg)", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-f", | ||||
|         "--folder", | ||||
|         default=os.path.abspath(config["folder"]), | ||||
|         help="path to folder where downloaded tracks will be stored in", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--overwrite", | ||||
|         default=config["overwrite"], | ||||
|         help="change the overwrite policy", | ||||
|         choices={"prompt", "force", "skip"}, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-i", | ||||
|         "--input-ext", | ||||
|         default=config["input-ext"], | ||||
|         help="preferred input format .m4a or .webm (Opus)", | ||||
|         choices={".m4a", ".webm"}, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-o", | ||||
|         "--output-ext", | ||||
|         default=config["output-ext"], | ||||
|         help="preferred output format .mp3, .m4a (AAC), .flac, etc.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--write-to", | ||||
|         default=config["write-to"], | ||||
|         help="write tracks from Spotify playlist, album, etc. to this file", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-ff", | ||||
|         "--file-format", | ||||
|         default=config["file-format"], | ||||
|         help="file format to save the downloaded track with, each tag " | ||||
|         "is surrounded by curly braces. Possible formats: " | ||||
|         "{}".format([internals.formats[x] for x in internals.formats]), | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--trim-silence", | ||||
|         default=config["trim-silence"], | ||||
|         help="remove silence from the start of the audio", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-sf", | ||||
|         "--search-format", | ||||
|         default=config["search-format"], | ||||
|         help="search format to search for on YouTube, each tag " | ||||
|         "is surrounded by curly braces. Possible formats: " | ||||
|         "{}".format([internals.formats[x] for x in internals.formats]), | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-dm", | ||||
|         "--download-only-metadata", | ||||
|         default=config["download-only-metadata"], | ||||
|         help="download tracks only whose metadata is found", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-d", | ||||
|         "--dry-run", | ||||
|         default=config["dry-run"], | ||||
|         help="show only track title and YouTube URL, and then skip " | ||||
|         "to the next track (if any)", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-mo", | ||||
|         "--music-videos-only", | ||||
|         default=config["music-videos-only"], | ||||
|         help="search only for music videos on Youtube (works only " | ||||
|         "when YouTube API key is set", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-ns", | ||||
|         "--no-spaces", | ||||
|         default=config["no-spaces"], | ||||
|         help="replace spaces with underscores in file names", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-ll", | ||||
|         "--log-level", | ||||
|         default=config["log-level"], | ||||
|         choices=_LOG_LEVELS_STR, | ||||
|         type=str.upper, | ||||
|         help="set log verbosity", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-yk", | ||||
|         "--youtube-api-key", | ||||
|         default=config["youtube-api-key"], | ||||
|         help=argparse.SUPPRESS, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-sk", | ||||
|         "--skip", | ||||
|         default=config["skip"], | ||||
|         help="path to file containing tracks to skip", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-w", | ||||
|         "--write-successful", | ||||
|         default=config["write-successful"], | ||||
|         help="path to file to write successful tracks to", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-sci", | ||||
|         "--spotify-client-id", | ||||
|         default=config["spotify_client_id"], | ||||
|         help=argparse.SUPPRESS, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-scs", | ||||
|         "--spotify-client-secret", | ||||
|         default=config["spotify_client_secret"], | ||||
|         help=argparse.SUPPRESS, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-c", "--config", default=None, help="path to custom config.yml file" | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-V", | ||||
|         "--version", | ||||
|         action="version", | ||||
|         version="%(prog)s {}".format(spotdl.__version__), | ||||
|     ) | ||||
|  | ||||
|     parsed = parser.parse_args(raw_args) | ||||
|  | ||||
|     if parsed.config is not None and to_merge: | ||||
|         parsed = override_config(parsed.config, parser) | ||||
|  | ||||
|     if ( | ||||
|         to_group | ||||
|         and parsed.list | ||||
|         and not mimetypes.MimeTypes().guess_type(parsed.list)[0] == "text/plain" | ||||
|     ): | ||||
|         parser.error( | ||||
|             "{0} is not of a valid argument to --list, argument must be plain text file".format( | ||||
|                 parsed.list | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     if parsed.write_m3u and not parsed.list: | ||||
|         parser.error("--write-m3u can only be used with --list") | ||||
|  | ||||
|     if parsed.avconv and parsed.trim_silence: | ||||
|         parser.error("--trim-silence can only be used with FFmpeg") | ||||
|  | ||||
|     if parsed.write_to and not ( | ||||
|         parsed.playlist or parsed.album or parsed.all_albums or parsed.username | ||||
|     ): | ||||
|         parser.error( | ||||
|             "--write-to can only be used with --playlist, --album, --all-albums, or --username" | ||||
|         ) | ||||
|  | ||||
|     parsed.log_level = log_leveller(parsed.log_level) | ||||
|  | ||||
|     return parsed | ||||
| @@ -1,280 +0,0 @@ | ||||
| from logzero import logger as log | ||||
| import os | ||||
| import sys | ||||
| import math | ||||
| import urllib.request | ||||
|  | ||||
|  | ||||
| from spotdl import const | ||||
|  | ||||
| try: | ||||
|     import winreg | ||||
| except ImportError: | ||||
|     pass | ||||
|  | ||||
| try: | ||||
|     from slugify import SLUG_OK, slugify | ||||
| except ImportError: | ||||
|     log.error("Oops! `unicode-slugify` was not found.") | ||||
|     log.info("Please remove any other slugify library and install `unicode-slugify`") | ||||
|     sys.exit(5) | ||||
|  | ||||
| formats = { | ||||
|     0: "track_name", | ||||
|     1: "artist", | ||||
|     2: "album", | ||||
|     3: "album_artist", | ||||
|     4: "genre", | ||||
|     5: "disc_number", | ||||
|     6: "duration", | ||||
|     7: "year", | ||||
|     8: "original_date", | ||||
|     9: "track_number", | ||||
|     10: "total_tracks", | ||||
|     11: "isrc", | ||||
|     12: "track_id", | ||||
| } | ||||
|  | ||||
|  | ||||
| def input_link(links): | ||||
|     """ Let the user input a choice. """ | ||||
|     while True: | ||||
|         try: | ||||
|             log.info("Choose your number:") | ||||
|             the_chosen_one = int(input("> ")) | ||||
|             if 1 <= the_chosen_one <= len(links): | ||||
|                 return links[the_chosen_one - 1] | ||||
|             elif the_chosen_one == 0: | ||||
|                 return None | ||||
|             else: | ||||
|                 log.warning("Choose a valid number!") | ||||
|         except ValueError: | ||||
|             log.warning("Choose a valid number!") | ||||
|  | ||||
|  | ||||
| def trim_song(tracks_file): | ||||
|     """ Remove the first song from file. """ | ||||
|     with open(tracks_file, "r") as file_in: | ||||
|         data = file_in.read().splitlines(True) | ||||
|     with open(tracks_file, "w") as file_out: | ||||
|         file_out.writelines(data[1:]) | ||||
|     return data[0] | ||||
|  | ||||
|  | ||||
| def is_spotify(raw_song): | ||||
|     """ Check if the input song is a Spotify link. """ | ||||
|     status = len(raw_song) == 22 and raw_song.replace(" ", "%20") == raw_song | ||||
|     status = status or raw_song.find("spotify") > -1 | ||||
|     return status | ||||
|  | ||||
|  | ||||
| def is_youtube(raw_song): | ||||
|     """ Check if the input song is a YouTube link. """ | ||||
|     status = len(raw_song) == 11 and raw_song.replace(" ", "%20") == raw_song | ||||
|     status = status and not raw_song.lower() == raw_song | ||||
|     status = status or "youtube.com/watch?v=" in raw_song | ||||
|     return status | ||||
|  | ||||
|  | ||||
| def format_string( | ||||
|     string_format, tags, slugification=False, force_spaces=False, total_songs=0 | ||||
| ): | ||||
|     """ Generate a string of the format '[artist] - [song]' for the given spotify song. """ | ||||
|     format_tags = dict(formats) | ||||
|     format_tags[0] = tags["name"] | ||||
|     format_tags[1] = tags["artists"][0]["name"] | ||||
|     format_tags[2] = tags["album"]["name"] | ||||
|     format_tags[3] = tags["artists"][0]["name"] | ||||
|     format_tags[4] = tags["genre"] | ||||
|     format_tags[5] = tags["disc_number"] | ||||
|     format_tags[6] = tags["duration"] | ||||
|     format_tags[7] = tags["year"] | ||||
|     format_tags[8] = tags["release_date"] | ||||
|     format_tags[9] = tags["track_number"] | ||||
|     format_tags[10] = tags["total_tracks"] | ||||
|     format_tags[11] = tags["external_ids"]["isrc"] | ||||
|     try: | ||||
|         format_tags[12] = tags["id"] | ||||
|     except KeyError: | ||||
|         pass | ||||
|  | ||||
|     format_tags_sanitized = { | ||||
|         k: sanitize_title(str(v), ok="'-_()[]{}") if slugification else str(v) | ||||
|         for k, v in format_tags.items() | ||||
|     } | ||||
|     # calculating total digits presnet in total_songs to prepare a zfill. | ||||
|     total_digits = 0 if total_songs == 0 else int(math.log10(total_songs)) + 1 | ||||
|  | ||||
|     for x in formats: | ||||
|         format_tag = "{" + formats[x] + "}" | ||||
|         # Making consistent track number by prepending zero | ||||
|         # on it according to number of digits in total songs | ||||
|         if format_tag == "{track_number}": | ||||
|             format_tags_sanitized[x] = format_tags_sanitized[x].zfill(total_digits) | ||||
|  | ||||
|         string_format = string_format.replace(format_tag, format_tags_sanitized[x]) | ||||
|  | ||||
|     if const.args.no_spaces and not force_spaces: | ||||
|         string_format = string_format.replace(" ", "_") | ||||
|  | ||||
|     return string_format | ||||
|  | ||||
|  | ||||
| def sanitize_title(title, ok="-_()[]{}"): | ||||
|     """ Generate filename of the song to be downloaded. """ | ||||
|  | ||||
|     if const.args.no_spaces: | ||||
|         title = title.replace(" ", "_") | ||||
|  | ||||
|     # replace slashes with "-" to avoid folder creation errors | ||||
|     title = title.replace("/", "-").replace("\\", "-") | ||||
|  | ||||
|     # slugify removes any special characters | ||||
|     title = slugify(title, ok=ok, lower=False, spaces=True) | ||||
|     return title | ||||
|  | ||||
|  | ||||
| def filter_path(path): | ||||
|     if not os.path.exists(path): | ||||
|         os.makedirs(path) | ||||
|     for temp in os.listdir(path): | ||||
|         if temp.endswith(".temp"): | ||||
|             os.remove(os.path.join(path, temp)) | ||||
|  | ||||
|  | ||||
| def videotime_from_seconds(time): | ||||
|     if time < 60: | ||||
|         return str(time) | ||||
|     if time < 3600: | ||||
|         return "{0}:{1:02}".format(time // 60, time % 60) | ||||
|  | ||||
|     return "{0}:{1:02}:{2:02}".format((time // 60) // 60, (time // 60) % 60, time % 60) | ||||
|  | ||||
|  | ||||
| def get_sec(time_str): | ||||
|     if ":" in time_str: | ||||
|         splitter = ":" | ||||
|     elif "." in time_str: | ||||
|         splitter = "." | ||||
|     else: | ||||
|         raise ValueError( | ||||
|             "No expected character found in {} to split" "time values.".format(time_str) | ||||
|         ) | ||||
|     v = time_str.split(splitter, 3) | ||||
|     v.reverse() | ||||
|     sec = 0 | ||||
|     if len(v) > 0:  # seconds | ||||
|         sec += int(v[0]) | ||||
|     if len(v) > 1:  # minutes | ||||
|         sec += int(v[1]) * 60 | ||||
|     if len(v) > 2:  # hours | ||||
|         sec += int(v[2]) * 3600 | ||||
|     return sec | ||||
|  | ||||
|  | ||||
| def extract_spotify_id(raw_string): | ||||
|     """ | ||||
|     Returns a Spotify ID of a playlist, album, etc. after extracting | ||||
|     it from a given HTTP URL or Spotify URI. | ||||
|     """ | ||||
|  | ||||
|     if "/" in raw_string: | ||||
|         # Input string is an HTTP URL | ||||
|         if raw_string.endswith("/"): | ||||
|             raw_string = raw_string[:-1] | ||||
|         # We need to manually trim additional text from HTTP URLs | ||||
|         # We could skip this if https://github.com/plamere/spotipy/pull/324 | ||||
|         # gets merged, | ||||
|         to_trim = raw_string.find("?") | ||||
|         if not to_trim == -1: | ||||
|             raw_string = raw_string[:to_trim] | ||||
|         splits = raw_string.split("/") | ||||
|     else: | ||||
|         # Input string is a Spotify URI | ||||
|         splits = raw_string.split(":") | ||||
|  | ||||
|     spotify_id = splits[-1] | ||||
|  | ||||
|     return spotify_id | ||||
|  | ||||
|  | ||||
| def get_unique_tracks(tracks_file): | ||||
|     """ | ||||
|     Returns a list of unique tracks given a path to a | ||||
|     file containing tracks. | ||||
|     """ | ||||
|  | ||||
|     log.info( | ||||
|         "Checking and removing any duplicate tracks " | ||||
|         "in reading {}".format(tracks_file) | ||||
|     ) | ||||
|     with open(tracks_file, "r") as tracks_in: | ||||
|         # Read tracks into a list and remove any duplicates | ||||
|         lines = tracks_in.read().splitlines() | ||||
|  | ||||
|     # Remove blank and strip whitespaces from lines (if any) | ||||
|     lines = [line.strip() for line in lines if line.strip()] | ||||
|     lines = remove_duplicates(lines) | ||||
|     return lines | ||||
|  | ||||
|  | ||||
| # a hacky way to get user's localized music directory | ||||
| # (thanks @linusg, issue #203) | ||||
| def get_music_dir(): | ||||
|     home = os.path.expanduser("~") | ||||
|  | ||||
|     # On Linux, the localized folder names are the actual ones. | ||||
|     # It's a freedesktop standard though. | ||||
|     if sys.platform.startswith("linux"): | ||||
|         for file_item in (".config/user-dirs.dirs", "user-dirs.dirs"): | ||||
|             path = os.path.join(home, file_item) | ||||
|             if os.path.isfile(path): | ||||
|                 with open(path, "r") as f: | ||||
|                     for line in f: | ||||
|                         if line.startswith("XDG_MUSIC_DIR"): | ||||
|                             return os.path.expandvars( | ||||
|                                 line.strip().split("=")[1].strip('"') | ||||
|                             ) | ||||
|  | ||||
|     # Windows / Cygwin | ||||
|     # Queries registry for 'My Music' folder path (as this can be changed) | ||||
|     if "win" in sys.platform: | ||||
|         try: | ||||
|             key = winreg.OpenKey( | ||||
|                 winreg.HKEY_CURRENT_USER, | ||||
|                 r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders", | ||||
|                 0, | ||||
|                 winreg.KEY_ALL_ACCESS, | ||||
|             ) | ||||
|             return winreg.QueryValueEx(key, "My Music")[0] | ||||
|         except (FileNotFoundError, NameError): | ||||
|             pass | ||||
|  | ||||
|     # On both Windows and macOS, the localized folder names you see in | ||||
|     # Explorer and Finder are actually in English on the file system. | ||||
|     # So, defaulting to C:\Users\<user>\Music or /Users/<user>/Music | ||||
|     # respectively is sufficient. | ||||
|     # On Linux, default to /home/<user>/Music if the above method failed. | ||||
|     return os.path.join(home, "Music") | ||||
|  | ||||
|  | ||||
| def remove_duplicates(tracks): | ||||
|     """ | ||||
|     Removes duplicates from a list whilst preserving order. | ||||
|  | ||||
|     We could directly call `set()` on the list but it changes | ||||
|     the order of elements. | ||||
|     """ | ||||
|  | ||||
|     local_set = set() | ||||
|     local_set_add = local_set.add | ||||
|     return [x for x in tracks if not (x in local_set or local_set_add(x))] | ||||
|  | ||||
|  | ||||
| def content_available(url): | ||||
|     try: | ||||
|         response = urllib.request.urlopen(url) | ||||
|     except urllib.request.HTTPError: | ||||
|         return False | ||||
|     else: | ||||
|         return response.getcode() < 300 | ||||
| @@ -1 +0,0 @@ | ||||
| from spotdl.lyrics.lyric_base import LyricBase | ||||
| @@ -1,5 +0,0 @@ | ||||
| class LyricsNotFound(Exception): | ||||
|     __module__ = Exception.__module__ | ||||
|  | ||||
|     def __init__(self, message=None): | ||||
|         super(LyricsNotFound, self).__init__(message) | ||||
| @@ -1,14 +0,0 @@ | ||||
| import lyricwikia | ||||
|  | ||||
| from abc import ABC | ||||
| from abc import abstractmethod | ||||
|  | ||||
|  | ||||
| class LyricBase(ABC): | ||||
|     @abstractmethod | ||||
|     def __init__(self, artist, song): | ||||
|         pass | ||||
|  | ||||
|     @abstractmethod | ||||
|     def get_lyrics(self, linesep="\n", timeout=None): | ||||
|         pass | ||||
| @@ -1,4 +0,0 @@ | ||||
| from spotdl.lyrics.providers.genius import Genius | ||||
| from spotdl.lyrics.providers.lyricwikia_wrapper import LyricWikia | ||||
|  | ||||
| LyricClasses = (Genius, LyricWikia) | ||||
| @@ -1,49 +0,0 @@ | ||||
| from bs4 import BeautifulSoup | ||||
| import urllib.request | ||||
|  | ||||
| from spotdl.lyrics.lyric_base import LyricBase | ||||
| from spotdl.lyrics.exceptions import LyricsNotFound | ||||
|  | ||||
| BASE_URL = "https://genius.com" | ||||
|  | ||||
|  | ||||
| class Genius(LyricBase): | ||||
|     def __init__(self, artist, song): | ||||
|         self.artist = artist | ||||
|         self.song = song | ||||
|         self.base_url = BASE_URL | ||||
|  | ||||
|     def _guess_lyric_url(self): | ||||
|         query = "/{} {} lyrics".format(self.artist, self.song) | ||||
|         query = query.replace(" ", "-") | ||||
|         encoded_query = urllib.request.quote(query) | ||||
|         lyric_url = self.base_url + encoded_query | ||||
|         return lyric_url | ||||
|  | ||||
|     def _fetch_page(self, url, timeout=None): | ||||
|         request = urllib.request.Request(url) | ||||
|         request.add_header("User-Agent", "urllib") | ||||
|         try: | ||||
|             response = urllib.request.urlopen(request, timeout=timeout) | ||||
|         except urllib.request.HTTPError: | ||||
|             raise LyricsNotFound( | ||||
|                 "Could not find lyrics for {} - {} at URL: {}".format( | ||||
|                     self.artist, self.song, url | ||||
|                 ) | ||||
|             ) | ||||
|         else: | ||||
|             return response.read() | ||||
|  | ||||
|     def _get_lyrics_text(self, html): | ||||
|         soup = BeautifulSoup(html, "html.parser") | ||||
|         lyrics_paragraph = soup.find("p") | ||||
|         if lyrics_paragraph: | ||||
|             return lyrics_paragraph.get_text() | ||||
|         else: | ||||
|             raise LyricsNotFound("The lyrics for this track are yet to be released.") | ||||
|  | ||||
|     def get_lyrics(self, linesep="\n", timeout=None): | ||||
|         url = self._guess_lyric_url() | ||||
|         html_page = self._fetch_page(url, timeout=timeout) | ||||
|         lyrics = self._get_lyrics_text(html_page) | ||||
|         return lyrics.replace("\n", linesep) | ||||
| @@ -1,18 +0,0 @@ | ||||
| import lyricwikia | ||||
|  | ||||
| from spotdl.lyrics.lyric_base import LyricBase | ||||
| from spotdl.lyrics.exceptions import LyricsNotFound | ||||
|  | ||||
|  | ||||
| class LyricWikia(LyricBase): | ||||
|     def __init__(self, artist, song): | ||||
|         self.artist = artist | ||||
|         self.song = song | ||||
|  | ||||
|     def get_lyrics(self, linesep="\n", timeout=None): | ||||
|         try: | ||||
|             lyrics = lyricwikia.get_lyrics(self.artist, self.song, linesep, timeout) | ||||
|         except lyricwikia.LyricsNotFound as e: | ||||
|             raise LyricsNotFound(e.args[0]) | ||||
|         else: | ||||
|             return lyrics | ||||
| @@ -1,37 +0,0 @@ | ||||
| from spotdl.lyrics import LyricBase | ||||
| from spotdl.lyrics import exceptions | ||||
| from spotdl.lyrics.providers import Genius | ||||
|  | ||||
| import urllib.request | ||||
| import pytest | ||||
|  | ||||
|  | ||||
| class TestGenius: | ||||
|     def test_subclass(self): | ||||
|         assert issubclass(Genius, LyricBase) | ||||
|  | ||||
|     @pytest.fixture(scope="module") | ||||
|     def track(self): | ||||
|         return Genius("artist", "song") | ||||
|  | ||||
|     def test_base_url(self, track): | ||||
|         assert track.base_url == "https://genius.com" | ||||
|  | ||||
|     def test_get_lyrics(self, track, monkeypatch): | ||||
|         def mocked_urlopen(url, timeout=None): | ||||
|             class DummyHTTPResponse: | ||||
|                 def read(self): | ||||
|                     return "<p>amazing lyrics!</p>" | ||||
|  | ||||
|             return DummyHTTPResponse() | ||||
|  | ||||
|         monkeypatch.setattr("urllib.request.urlopen", mocked_urlopen) | ||||
|         assert track.get_lyrics() == "amazing lyrics!" | ||||
|  | ||||
|     def test_lyrics_not_found_error(self, track, monkeypatch): | ||||
|         def mocked_urlopen(url, timeout=None): | ||||
|             raise urllib.request.HTTPError("", "", "", "", "") | ||||
|  | ||||
|         monkeypatch.setattr("urllib.request.urlopen", mocked_urlopen) | ||||
|         with pytest.raises(exceptions.LyricsNotFound): | ||||
|             track.get_lyrics() | ||||
| @@ -1,35 +0,0 @@ | ||||
| import lyricwikia | ||||
|  | ||||
| from spotdl.lyrics import LyricBase | ||||
| from spotdl.lyrics import exceptions | ||||
| from spotdl.lyrics.providers import LyricWikia | ||||
|  | ||||
| import pytest | ||||
|  | ||||
|  | ||||
| class TestLyricWikia: | ||||
|     def test_subclass(self): | ||||
|         assert issubclass(LyricWikia, LyricBase) | ||||
|  | ||||
|     def test_get_lyrics(self, monkeypatch): | ||||
|         # `LyricWikia` class uses the 3rd party method `lyricwikia.get_lyrics` | ||||
|         # internally and there is no need to test a 3rd party library as they | ||||
|         # have their own implementation of tests. | ||||
|         monkeypatch.setattr( | ||||
|             "lyricwikia.get_lyrics", lambda a, b, c, d: "awesome lyrics!" | ||||
|         ) | ||||
|         track = LyricWikia("Lyricwikia", "Lyricwikia") | ||||
|         assert track.get_lyrics() == "awesome lyrics!" | ||||
|  | ||||
|     def test_lyrics_not_found_error(self, monkeypatch): | ||||
|         def lyricwikia_lyrics_not_found(msg): | ||||
|             raise lyricwikia.LyricsNotFound(msg) | ||||
|  | ||||
|         # Wrap `lyricwikia.LyricsNotFound` with `exceptions.LyricsNotFound` error. | ||||
|         monkeypatch.setattr( | ||||
|             "lyricwikia.get_lyrics", | ||||
|             lambda a, b, c, d: lyricwikia_lyrics_not_found("Nope, no lyrics."), | ||||
|         ) | ||||
|         track = LyricWikia("Lyricwikia", "Lyricwikia") | ||||
|         with pytest.raises(exceptions.LyricsNotFound): | ||||
|             track.get_lyrics() | ||||
| @@ -1,178 +0,0 @@ | ||||
| from mutagen.easyid3 import EasyID3 | ||||
| from mutagen.id3 import ID3, TORY, TYER, TPUB, APIC, USLT, COMM | ||||
| from mutagen.mp4 import MP4, MP4Cover | ||||
| from mutagen.flac import Picture, FLAC | ||||
|  | ||||
| import urllib.request | ||||
| from logzero import logger as log | ||||
|  | ||||
| from spotdl.const import TAG_PRESET, M4A_TAG_PRESET | ||||
|  | ||||
|  | ||||
| def compare(music_file, metadata): | ||||
|     """Check if the input music file title matches the expected title.""" | ||||
|     already_tagged = False | ||||
|     try: | ||||
|         if music_file.endswith(".mp3"): | ||||
|             audiofile = EasyID3(music_file) | ||||
|             already_tagged = audiofile["title"][0] == metadata["name"] | ||||
|         elif music_file.endswith(".m4a"): | ||||
|             audiofile = MP4(music_file) | ||||
|             already_tagged = audiofile["\xa9nam"][0] == metadata["name"] | ||||
|     except (KeyError, TypeError): | ||||
|         pass | ||||
|  | ||||
|     return already_tagged | ||||
|  | ||||
|  | ||||
| def embed(music_file, meta_tags): | ||||
|     """ Embed metadata. """ | ||||
|     embed = EmbedMetadata(music_file, meta_tags) | ||||
|     if music_file.endswith(".m4a"): | ||||
|         log.info("Applying metadata") | ||||
|         return embed.as_m4a() | ||||
|     elif music_file.endswith(".mp3"): | ||||
|         log.info("Applying metadata") | ||||
|         return embed.as_mp3() | ||||
|     elif music_file.endswith(".flac"): | ||||
|         log.info("Applying metadata") | ||||
|         return embed.as_flac() | ||||
|     else: | ||||
|         log.warning("Cannot embed metadata into given output extension") | ||||
|         return False | ||||
|  | ||||
|  | ||||
| class EmbedMetadata: | ||||
|     def __init__(self, music_file, meta_tags): | ||||
|         self.music_file = music_file | ||||
|         self.meta_tags = meta_tags | ||||
|         self.spotify_metadata = meta_tags["spotify_metadata"] | ||||
|         self.provider = "spotify" if meta_tags["spotify_metadata"] else "youtube" | ||||
|  | ||||
|     def as_mp3(self): | ||||
|         """ Embed metadata to MP3 files. """ | ||||
|         music_file = self.music_file | ||||
|         meta_tags = self.meta_tags | ||||
|         # EasyID3 is fun to use ;) | ||||
|         # For supported easyid3 tags: | ||||
|         # https://github.com/quodlibet/mutagen/blob/master/mutagen/easyid3.py | ||||
|         # Check out somewhere at end of above linked file | ||||
|         audiofile = EasyID3(music_file) | ||||
|         self._embed_basic_metadata(audiofile, preset=TAG_PRESET) | ||||
|         audiofile["media"] = meta_tags["type"] | ||||
|         audiofile["author"] = meta_tags["artists"][0]["name"] | ||||
|         audiofile["lyricist"] = meta_tags["artists"][0]["name"] | ||||
|         audiofile["arranger"] = meta_tags["artists"][0]["name"] | ||||
|         audiofile["performer"] = meta_tags["artists"][0]["name"] | ||||
|         audiofile["website"] = meta_tags["external_urls"][self.provider] | ||||
|         audiofile["length"] = str(meta_tags["duration"]) | ||||
|         if meta_tags["publisher"]: | ||||
|             audiofile["encodedby"] = meta_tags["publisher"] | ||||
|         if meta_tags["external_ids"]["isrc"]: | ||||
|             audiofile["isrc"] = meta_tags["external_ids"]["isrc"] | ||||
|         audiofile.save(v2_version=3) | ||||
|  | ||||
|         # For supported id3 tags: | ||||
|         # https://github.com/quodlibet/mutagen/blob/master/mutagen/id3/_frames.py | ||||
|         # Each class represents an id3 tag | ||||
|         audiofile = ID3(music_file) | ||||
|         audiofile["TORY"] = TORY(encoding=3, text=meta_tags["year"]) | ||||
|         audiofile["TYER"] = TYER(encoding=3, text=meta_tags["year"]) | ||||
|         if meta_tags["publisher"]: | ||||
|             audiofile["TPUB"] = TPUB(encoding=3, text=meta_tags["publisher"]) | ||||
|         audiofile["COMM"] = COMM( | ||||
|             encoding=3, text=meta_tags["external_urls"][self.provider] | ||||
|         ) | ||||
|         if meta_tags["lyrics"]: | ||||
|             audiofile["USLT"] = USLT( | ||||
|                 encoding=3, desc=u"Lyrics", text=meta_tags["lyrics"] | ||||
|             ) | ||||
|         try: | ||||
|             albumart = urllib.request.urlopen(meta_tags["album"]["images"][0]["url"]) | ||||
|             audiofile["APIC"] = APIC( | ||||
|                 encoding=3, | ||||
|                 mime="image/jpeg", | ||||
|                 type=3, | ||||
|                 desc=u"Cover", | ||||
|                 data=albumart.read(), | ||||
|             ) | ||||
|             albumart.close() | ||||
|         except IndexError: | ||||
|             pass | ||||
|  | ||||
|         audiofile.save(v2_version=3) | ||||
|         return True | ||||
|  | ||||
|     def as_m4a(self): | ||||
|         """ Embed metadata to M4A files. """ | ||||
|         music_file = self.music_file | ||||
|         meta_tags = self.meta_tags | ||||
|         audiofile = MP4(music_file) | ||||
|         self._embed_basic_metadata(audiofile, preset=M4A_TAG_PRESET) | ||||
|         audiofile[M4A_TAG_PRESET["year"]] = meta_tags["year"] | ||||
|         audiofile[M4A_TAG_PRESET["comment"]] = meta_tags["external_urls"][self.provider] | ||||
|         if meta_tags["lyrics"]: | ||||
|             audiofile[M4A_TAG_PRESET["lyrics"]] = meta_tags["lyrics"] | ||||
|         try: | ||||
|             albumart = urllib.request.urlopen(meta_tags["album"]["images"][0]["url"]) | ||||
|             audiofile[M4A_TAG_PRESET["albumart"]] = [ | ||||
|                 MP4Cover(albumart.read(), imageformat=MP4Cover.FORMAT_JPEG) | ||||
|             ] | ||||
|             albumart.close() | ||||
|         except IndexError: | ||||
|             pass | ||||
|  | ||||
|         audiofile.save() | ||||
|         return True | ||||
|  | ||||
|     def as_flac(self): | ||||
|         music_file = self.music_file | ||||
|         meta_tags = self.meta_tags | ||||
|         audiofile = FLAC(music_file) | ||||
|         self._embed_basic_metadata(audiofile) | ||||
|         audiofile["year"] = meta_tags["year"] | ||||
|         audiofile["comment"] = meta_tags["external_urls"][self.provider] | ||||
|         if meta_tags["lyrics"]: | ||||
|             audiofile["lyrics"] = meta_tags["lyrics"] | ||||
|  | ||||
|         image = Picture() | ||||
|         image.type = 3 | ||||
|         image.desc = "Cover" | ||||
|         image.mime = "image/jpeg" | ||||
|         albumart = urllib.request.urlopen(meta_tags["album"]["images"][0]["url"]) | ||||
|         image.data = albumart.read() | ||||
|         albumart.close() | ||||
|         audiofile.add_picture(image) | ||||
|  | ||||
|         audiofile.save() | ||||
|         return True | ||||
|  | ||||
|     def _embed_basic_metadata(self, audiofile, preset=TAG_PRESET): | ||||
|         meta_tags = self.meta_tags | ||||
|         audiofile[preset["artist"]] = meta_tags["artists"][0]["name"] | ||||
|         if meta_tags["album"]["artists"][0]["name"]: | ||||
|             audiofile[preset["albumartist"]] = meta_tags["album"]["artists"][0]["name"] | ||||
|         if meta_tags["album"]["name"]: | ||||
|             audiofile[preset["album"]] = meta_tags["album"]["name"] | ||||
|         audiofile[preset["title"]] = meta_tags["name"] | ||||
|         audiofile[preset["date"]] = meta_tags["release_date"] | ||||
|         audiofile[preset["originaldate"]] = meta_tags["release_date"] | ||||
|         if meta_tags["genre"]: | ||||
|             audiofile[preset["genre"]] = meta_tags["genre"] | ||||
|         if meta_tags["copyright"]: | ||||
|             audiofile[preset["copyright"]] = meta_tags["copyright"] | ||||
|         if self.music_file.endswith(".flac"): | ||||
|             audiofile[preset["discnumber"]] = str(meta_tags["disc_number"]) | ||||
|         else: | ||||
|             audiofile[preset["discnumber"]] = [(meta_tags["disc_number"], 0)] | ||||
|         if self.music_file.endswith(".flac"): | ||||
|             audiofile[preset["tracknumber"]] = str(meta_tags["track_number"]) | ||||
|         else: | ||||
|             if preset["tracknumber"] == TAG_PRESET["tracknumber"]: | ||||
|                 audiofile[preset["tracknumber"]] = "{}/{}".format( | ||||
|                     meta_tags["track_number"], meta_tags["total_tracks"] | ||||
|                 ) | ||||
|             else: | ||||
|                 audiofile[preset["tracknumber"]] = [ | ||||
|                     (meta_tags["track_number"], meta_tags["total_tracks"]) | ||||
|                 ] | ||||
| @@ -1,64 +0,0 @@ | ||||
| from pafy import backend_youtube_dl | ||||
| import pafy | ||||
|  | ||||
| from spotdl import internals | ||||
|  | ||||
|  | ||||
| def _getbestthumb(self): | ||||
|     url = self._ydl_info["thumbnails"][0]["url"] | ||||
|     if url: | ||||
|         return url | ||||
|  | ||||
|     part_url = "https://i.ytimg.com/vi/%s/" % self.videoid | ||||
|     # Thumbnail resolution sorted in descending order | ||||
|     thumbs = ( | ||||
|         "maxresdefault.jpg", | ||||
|         "sddefault.jpg", | ||||
|         "hqdefault.jpg", | ||||
|         "mqdefault.jpg", | ||||
|         "default.jpg", | ||||
|     ) | ||||
|     for thumb in thumbs: | ||||
|         url = part_url + thumb | ||||
|         if self._content_available(url): | ||||
|             return url | ||||
|  | ||||
|  | ||||
| def _process_streams(self): | ||||
|     for format_index in range(len(self._ydl_info["formats"])): | ||||
|         try: | ||||
|             self._ydl_info["formats"][format_index]["url"] = self._ydl_info["formats"][ | ||||
|                 format_index | ||||
|             ]["fragment_base_url"] | ||||
|         except KeyError: | ||||
|             pass | ||||
|     return backend_youtube_dl.YtdlPafy._old_process_streams(self) | ||||
|  | ||||
|  | ||||
| @classmethod | ||||
| def _content_available(cls, url): | ||||
|     return internals.content_available(url) | ||||
|  | ||||
|  | ||||
| class PatchPafy: | ||||
|     """ | ||||
|     These patches have not been released by pafy on PyPI yet but | ||||
|     are useful to us. | ||||
|     """ | ||||
|  | ||||
|     def patch_getbestthumb(self): | ||||
|         # https://github.com/mps-youtube/pafy/pull/211 | ||||
|         pafy.backend_shared.BasePafy._bestthumb = None | ||||
|         pafy.backend_shared.BasePafy._content_available = _content_available | ||||
|         pafy.backend_shared.BasePafy.getbestthumb = _getbestthumb | ||||
|  | ||||
|     def patch_process_streams(self): | ||||
|         # https://github.com/mps-youtube/pafy/pull/230 | ||||
|         backend_youtube_dl.YtdlPafy._old_process_streams = ( | ||||
|             backend_youtube_dl.YtdlPafy._process_streams | ||||
|         ) | ||||
|         backend_youtube_dl.YtdlPafy._process_streams = _process_streams | ||||
|  | ||||
|     def patch_insecure_streams(self): | ||||
|         # https://github.com/mps-youtube/pafy/pull/235 | ||||
|         pafy.g.def_ydl_opts["prefer_insecure"] = False | ||||
| @@ -1,79 +0,0 @@ | ||||
| #!/usr/bin/env python3 | ||||
|  | ||||
| import sys | ||||
| import platform | ||||
| import pprint | ||||
| import logzero | ||||
| from logzero import logger as log | ||||
|  | ||||
| from spotdl import __version__ | ||||
| from spotdl import const | ||||
| from spotdl import handle | ||||
| from spotdl import internals | ||||
| from spotdl import spotify_tools | ||||
| from spotdl import youtube_tools | ||||
| from spotdl import downloader | ||||
|  | ||||
|  | ||||
| def debug_sys_info(): | ||||
|     log.debug("Python version: {}".format(sys.version)) | ||||
|     log.debug("Platform: {}".format(platform.platform())) | ||||
|     log.debug(pprint.pformat(const.args.__dict__)) | ||||
|  | ||||
|  | ||||
| def match_args(): | ||||
|     if const.args.song: | ||||
|         for track in const.args.song: | ||||
|             track_dl = downloader.Downloader(raw_song=track) | ||||
|             track_dl.download_single() | ||||
|     elif const.args.list: | ||||
|         if const.args.write_m3u: | ||||
|             youtube_tools.generate_m3u( | ||||
|                 track_file=const.args.list | ||||
|             ) | ||||
|         else: | ||||
|             list_dl = downloader.ListDownloader( | ||||
|                 tracks_file=const.args.list, | ||||
|                 skip_file=const.args.skip, | ||||
|                 write_successful_file=const.args.write_successful, | ||||
|             ) | ||||
|             list_dl.download_list() | ||||
|     elif const.args.playlist: | ||||
|         spotify_tools.write_playlist( | ||||
|             playlist_url=const.args.playlist, text_file=const.args.write_to | ||||
|         ) | ||||
|     elif const.args.album: | ||||
|         spotify_tools.write_album( | ||||
|             album_url=const.args.album, text_file=const.args.write_to | ||||
|         ) | ||||
|     elif const.args.all_albums: | ||||
|         spotify_tools.write_all_albums_from_artist( | ||||
|             artist_url=const.args.all_albums, text_file=const.args.write_to | ||||
|         ) | ||||
|     elif const.args.username: | ||||
|         spotify_tools.write_user_playlist( | ||||
|             username=const.args.username, text_file=const.args.write_to | ||||
|         ) | ||||
|  | ||||
|  | ||||
| def main(): | ||||
|     const.args = handle.get_arguments() | ||||
|  | ||||
|     internals.filter_path(const.args.folder) | ||||
|     youtube_tools.set_api_key() | ||||
|  | ||||
|     logzero.setup_default_logger(formatter=const._formatter, level=const.args.log_level) | ||||
|  | ||||
|     try: | ||||
|         match_args() | ||||
|         # actually we don't necessarily need this, but yeah... | ||||
|         # explicit is better than implicit! | ||||
|         sys.exit(0) | ||||
|  | ||||
|     except KeyboardInterrupt as e: | ||||
|         log.exception(e) | ||||
|         sys.exit(3) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
| @@ -1,265 +0,0 @@ | ||||
| import spotipy | ||||
| import spotipy.oauth2 as oauth2 | ||||
|  | ||||
| from slugify import slugify | ||||
| from titlecase import titlecase | ||||
| from logzero import logger as log | ||||
| import pprint | ||||
| import sys | ||||
| import os | ||||
| import functools | ||||
|  | ||||
| from spotdl import const | ||||
| from spotdl import internals | ||||
| from spotdl.lyrics.providers import LyricClasses | ||||
| from spotdl.lyrics.exceptions import LyricsNotFound | ||||
|  | ||||
| spotify = None | ||||
|  | ||||
|  | ||||
| def generate_token(): | ||||
|     """ Generate the token. """ | ||||
|     credentials = oauth2.SpotifyClientCredentials( | ||||
|         client_id=const.args.spotify_client_id, | ||||
|         client_secret=const.args.spotify_client_secret, | ||||
|     ) | ||||
|     token = credentials.get_access_token() | ||||
|     return token | ||||
|  | ||||
|  | ||||
| def must_be_authorized(func, spotify=spotify): | ||||
|     def wrapper(*args, **kwargs): | ||||
|         global spotify | ||||
|         try: | ||||
|             assert spotify | ||||
|             return func(*args, **kwargs) | ||||
|         except (AssertionError, spotipy.client.SpotifyException): | ||||
|             token = generate_token() | ||||
|             spotify = spotipy.Spotify(auth=token) | ||||
|             return func(*args, **kwargs) | ||||
|  | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| @must_be_authorized | ||||
| def generate_metadata(raw_song): | ||||
|     """ Fetch a song's metadata from Spotify. """ | ||||
|     if internals.is_spotify(raw_song): | ||||
|         # fetch track information directly if it is spotify link | ||||
|         log.debug("Fetching metadata for given track URL") | ||||
|         meta_tags = spotify.track(raw_song) | ||||
|     else: | ||||
|         # otherwise search on spotify and fetch information from first result | ||||
|         log.debug('Searching for "{}" on Spotify'.format(raw_song)) | ||||
|         try: | ||||
|             meta_tags = spotify.search(raw_song, limit=1)["tracks"]["items"][0] | ||||
|         except IndexError: | ||||
|             return None | ||||
|     artist = spotify.artist(meta_tags["artists"][0]["id"]) | ||||
|     album = spotify.album(meta_tags["album"]["id"]) | ||||
|  | ||||
|     try: | ||||
|         meta_tags[u"genre"] = titlecase(artist["genres"][0]) | ||||
|     except IndexError: | ||||
|         meta_tags[u"genre"] = None | ||||
|     try: | ||||
|         meta_tags[u"copyright"] = album["copyrights"][0]["text"] | ||||
|     except IndexError: | ||||
|         meta_tags[u"copyright"] = None | ||||
|     try: | ||||
|         meta_tags[u"external_ids"][u"isrc"] | ||||
|     except KeyError: | ||||
|         meta_tags[u"external_ids"][u"isrc"] = None | ||||
|  | ||||
|     meta_tags[u"release_date"] = album["release_date"] | ||||
|     meta_tags[u"publisher"] = album["label"] | ||||
|     meta_tags[u"total_tracks"] = album["tracks"]["total"] | ||||
|  | ||||
|     log.debug("Fetching lyrics") | ||||
|     meta_tags["lyrics"] = None | ||||
|  | ||||
|     for LyricClass in LyricClasses: | ||||
|         track = LyricClass(meta_tags["artists"][0]["name"], meta_tags["name"]) | ||||
|         try: | ||||
|             meta_tags["lyrics"] = track.get_lyrics() | ||||
|         except LyricsNotFound: | ||||
|             continue | ||||
|         else: | ||||
|             break | ||||
|  | ||||
|     # Some sugar | ||||
|     meta_tags["year"], *_ = meta_tags["release_date"].split("-") | ||||
|     meta_tags["duration"] = meta_tags["duration_ms"] / 1000.0 | ||||
|     meta_tags["spotify_metadata"] = True | ||||
|     # Remove unwanted parameters | ||||
|     del meta_tags["duration_ms"] | ||||
|     del meta_tags["available_markets"] | ||||
|     del meta_tags["album"]["available_markets"] | ||||
|  | ||||
|     log.debug(pprint.pformat(meta_tags)) | ||||
|     return meta_tags | ||||
|  | ||||
|  | ||||
| @must_be_authorized | ||||
| def write_user_playlist(username, text_file=None): | ||||
|     """ Write user playlists to text_file """ | ||||
|     links = get_playlists(username=username) | ||||
|     playlist = internals.input_link(links) | ||||
|     return write_playlist(playlist, text_file) | ||||
|  | ||||
|  | ||||
| @must_be_authorized | ||||
| def get_playlists(username): | ||||
|     """ Fetch user playlists when using the -u option. """ | ||||
|     playlists = spotify.user_playlists(username) | ||||
|     links = [] | ||||
|     check = 1 | ||||
|  | ||||
|     while True: | ||||
|         for playlist in playlists["items"]: | ||||
|             # in rare cases, playlists may not be found, so playlists['next'] | ||||
|             # is None. Skip these. Also see Issue #91. | ||||
|             if playlist["name"] is not None: | ||||
|                 log.info( | ||||
|                     u"{0:>5}. {1:<30}  ({2} tracks)".format( | ||||
|                         check, playlist["name"], playlist["tracks"]["total"] | ||||
|                     ) | ||||
|                 ) | ||||
|                 playlist_url = playlist["external_urls"]["spotify"] | ||||
|                 log.debug(playlist_url) | ||||
|                 links.append(playlist_url) | ||||
|                 check += 1 | ||||
|         if playlists["next"]: | ||||
|             playlists = spotify.next(playlists) | ||||
|         else: | ||||
|             break | ||||
|  | ||||
|     return links | ||||
|  | ||||
|  | ||||
| @must_be_authorized | ||||
| def fetch_playlist(playlist): | ||||
|     try: | ||||
|         playlist_id = internals.extract_spotify_id(playlist) | ||||
|     except IndexError: | ||||
|         # Wrong format, in either case | ||||
|         log.error("The provided playlist URL is not in a recognized format!") | ||||
|         sys.exit(10) | ||||
|     try: | ||||
|         results = spotify.user_playlist( | ||||
|             user=None, playlist_id=playlist_id, fields="tracks,next,name" | ||||
|         ) | ||||
|     except spotipy.client.SpotifyException: | ||||
|         log.error("Unable to find playlist") | ||||
|         log.info("Make sure the playlist is set to publicly visible and then try again") | ||||
|         sys.exit(11) | ||||
|  | ||||
|     return results | ||||
|  | ||||
|  | ||||
| @must_be_authorized | ||||
| def write_playlist(playlist_url, text_file=None): | ||||
|     playlist = fetch_playlist(playlist_url) | ||||
|     tracks = playlist["tracks"] | ||||
|     if not text_file: | ||||
|         text_file = u"{0}.txt".format(slugify(playlist["name"], ok="-_()[]{}")) | ||||
|     return write_tracks(tracks, text_file) | ||||
|  | ||||
|  | ||||
| @must_be_authorized | ||||
| def fetch_album(album): | ||||
|     album_id = internals.extract_spotify_id(album) | ||||
|     album = spotify.album(album_id) | ||||
|     return album | ||||
|  | ||||
|  | ||||
| @must_be_authorized | ||||
| def fetch_albums_from_artist(artist_url, album_type=None): | ||||
|     """ | ||||
|     This funcction returns all the albums from a give artist_url using the US | ||||
|     market | ||||
|     :param artist_url - spotify artist url | ||||
|     :param album_type - the type of album to fetch (ex: single) the default is | ||||
|                         all albums | ||||
|     :param return - the album from the artist | ||||
|     """ | ||||
|  | ||||
|     # fetching artist's albums limitting the results to the US to avoid duplicate | ||||
|     # albums from multiple markets | ||||
|     artist_id = internals.extract_spotify_id(artist_url) | ||||
|     results = spotify.artist_albums(artist_id, album_type=album_type, country="US") | ||||
|  | ||||
|     albums = results["items"] | ||||
|  | ||||
|     # indexing all pages of results | ||||
|     while results["next"]: | ||||
|         results = spotify.next(results) | ||||
|         albums.extend(results["items"]) | ||||
|  | ||||
|     return albums | ||||
|  | ||||
|  | ||||
| @must_be_authorized | ||||
| def write_all_albums_from_artist(artist_url, text_file=None): | ||||
|     """ | ||||
|     This function gets all albums from an artist and writes it to a file in the | ||||
|     current working directory called [ARTIST].txt, where [ARTIST] is the artist | ||||
|     of the album | ||||
|     :param artist_url - spotify artist url | ||||
|     :param text_file - file to write albums to | ||||
|     """ | ||||
|  | ||||
|     album_base_url = "https://open.spotify.com/album/" | ||||
|  | ||||
|     # fetching all default albums | ||||
|     albums = fetch_albums_from_artist(artist_url, album_type=None) | ||||
|  | ||||
|     # if no file if given, the default save file is in the current working | ||||
|     # directory with the name of the artist | ||||
|     if text_file is None: | ||||
|         text_file = albums[0]["artists"][0]["name"] + ".txt" | ||||
|  | ||||
|     for album in albums: | ||||
|         # logging album name | ||||
|         log.info("Fetching album: " + album["name"]) | ||||
|         write_album(album_base_url + album["id"], text_file=text_file) | ||||
|  | ||||
|  | ||||
| @must_be_authorized | ||||
| def write_album(album_url, text_file=None): | ||||
|     album = fetch_album(album_url) | ||||
|     tracks = spotify.album_tracks(album["id"]) | ||||
|     if not text_file: | ||||
|         text_file = u"{0}.txt".format(slugify(album["name"], ok="-_()[]{}")) | ||||
|     return write_tracks(tracks, text_file) | ||||
|  | ||||
|  | ||||
| @must_be_authorized | ||||
| def write_tracks(tracks, text_file): | ||||
|     log.info(u"Writing {0} tracks to {1}".format(tracks["total"], text_file)) | ||||
|     track_urls = [] | ||||
|     with open(text_file, "a") as file_out: | ||||
|         while True: | ||||
|             for item in tracks["items"]: | ||||
|                 if "track" in item: | ||||
|                     track = item["track"] | ||||
|                 else: | ||||
|                     track = item | ||||
|                 try: | ||||
|                     track_url = track["external_urls"]["spotify"] | ||||
|                     log.debug(track_url) | ||||
|                     file_out.write(track_url + "\n") | ||||
|                     track_urls.append(track_url) | ||||
|                 except KeyError: | ||||
|                     log.warning( | ||||
|                         u"Skipping track {0} by {1} (local only?)".format( | ||||
|                             track["name"], track["artists"][0]["name"] | ||||
|                         ) | ||||
|                     ) | ||||
|             # 1 page = 50 results | ||||
|             # check if there are more pages | ||||
|             if tracks["next"]: | ||||
|                 tracks = spotify.next(tracks) | ||||
|             else: | ||||
|                 break | ||||
|     return track_urls | ||||
| @@ -1,412 +0,0 @@ | ||||
| from bs4 import BeautifulSoup | ||||
| import urllib | ||||
| import pafy | ||||
|  | ||||
| from slugify import slugify | ||||
| from logzero import logger as log | ||||
| import os | ||||
|  | ||||
| from spotdl import spotify_tools | ||||
| from spotdl import internals | ||||
| from spotdl import const | ||||
|  | ||||
| # Fix download speed throttle on short duration tracks | ||||
| # Read more on mps-youtube/pafy#199 | ||||
| pafy.g.opener.addheaders.append(("Range", "bytes=0-")) | ||||
|  | ||||
| # Implement unreleased methods on Pafy object | ||||
| # More info: https://github.com/mps-youtube/pafy/pull/211 | ||||
| if pafy.__version__ <= "0.5.5": | ||||
|     from spotdl import patcher | ||||
|  | ||||
|     pafy_patcher = patcher.PatchPafy() | ||||
|     pafy_patcher.patch_getbestthumb() | ||||
|     pafy_patcher.patch_process_streams() | ||||
|     pafy_patcher.patch_insecure_streams() | ||||
|  | ||||
|  | ||||
| def set_api_key(): | ||||
|     if const.args.youtube_api_key: | ||||
|         key = const.args.youtube_api_key | ||||
|     else: | ||||
|         # Please respect this YouTube token :) | ||||
|         key = "AIzaSyC6cEeKlxtOPybk9sEe5ksFN5sB-7wzYp0" | ||||
|     pafy.set_api_key(key) | ||||
|  | ||||
|  | ||||
| def go_pafy(raw_song, meta_tags=None): | ||||
|     """ Parse track from YouTube. """ | ||||
|     if internals.is_youtube(raw_song): | ||||
|         track_info = pafy.new(raw_song) | ||||
|     else: | ||||
|         track_url = generate_youtube_url(raw_song, meta_tags) | ||||
|  | ||||
|         if track_url: | ||||
|             track_info = pafy.new(track_url) | ||||
|         else: | ||||
|             track_info = None | ||||
|  | ||||
|     return track_info | ||||
|  | ||||
|  | ||||
| def match_video_and_metadata(track): | ||||
|     """ Get and match track data from YouTube and Spotify. """ | ||||
|     meta_tags = None | ||||
|  | ||||
|     def fallback_metadata(meta_tags): | ||||
|         fallback_metadata_info = ( | ||||
|             "Track not found on Spotify, falling back on YouTube metadata" | ||||
|         ) | ||||
|         skip_fallback_metadata_warning = ( | ||||
|             "Fallback condition not met, shall not embed metadata" | ||||
|         ) | ||||
|         if meta_tags is None: | ||||
|             if const.args.no_fallback_metadata: | ||||
|                 log.warning(skip_fallback_metadata_warning) | ||||
|             else: | ||||
|                 log.info(fallback_metadata_info) | ||||
|                 meta_tags = generate_metadata(content) | ||||
|         return meta_tags | ||||
|  | ||||
|     if internals.is_youtube(track): | ||||
|         log.debug("Input song is a YouTube URL") | ||||
|         content = go_pafy(track, meta_tags=None) | ||||
|         track = slugify(content.title).replace("-", " ") | ||||
|         if not const.args.no_metadata: | ||||
|             meta_tags = spotify_tools.generate_metadata(track) | ||||
|             meta_tags = fallback_metadata(meta_tags) | ||||
|  | ||||
|     elif internals.is_spotify(track): | ||||
|         log.debug("Input song is a Spotify URL") | ||||
|         # Let it generate metadata, YouTube doesn't know Spotify slang | ||||
|         meta_tags = spotify_tools.generate_metadata(track) | ||||
|         content = go_pafy(track, meta_tags) | ||||
|         if const.args.no_metadata: | ||||
|             meta_tags = None | ||||
|  | ||||
|     else: | ||||
|         log.debug("Input song is plain text based") | ||||
|         if const.args.no_metadata: | ||||
|             content = go_pafy(track, meta_tags=None) | ||||
|         else: | ||||
|             meta_tags = spotify_tools.generate_metadata(track) | ||||
|             content = go_pafy(track, meta_tags=meta_tags) | ||||
|             meta_tags = fallback_metadata(meta_tags) | ||||
|  | ||||
|     return content, meta_tags | ||||
|  | ||||
|  | ||||
| def generate_metadata(content): | ||||
|     """ Fetch a song's metadata from YouTube. """ | ||||
|     meta_tags = { | ||||
|         "spotify_metadata": False, | ||||
|         "name": content.title, | ||||
|         "artists": [{"name": content.author}], | ||||
|         "duration": content.length, | ||||
|         "external_urls": {"youtube": content.watchv_url}, | ||||
|         "album": { | ||||
|             "images": [{"url": content.getbestthumb()}], | ||||
|             "artists": [{"name": None}], | ||||
|             "name": None, | ||||
|         }, | ||||
|         "year": None, | ||||
|         "release_date": None, | ||||
|         "type": "track", | ||||
|         "disc_number": 1, | ||||
|         "track_number": 1, | ||||
|         "total_tracks": 1, | ||||
|         "publisher": None, | ||||
|         "external_ids": {"isrc": None}, | ||||
|         "lyrics": None, | ||||
|         "copyright": None, | ||||
|         "genre": None, | ||||
|     } | ||||
|  | ||||
|     # Workaround for | ||||
|     # https://github.com/ritiek/spotify-downloader/issues/671 | ||||
|     try: | ||||
|         meta_tags["year"] = content.published.split("-")[0] | ||||
|         meta_tags["release_date"] = content.published.split(" ")[0] | ||||
|     except pafy.util.GdataError: | ||||
|         pass | ||||
|  | ||||
|     return meta_tags | ||||
|  | ||||
|  | ||||
| def get_youtube_title(content, number=None): | ||||
|     """ Get the YouTube video's title. """ | ||||
|     title = content.title | ||||
|     if number: | ||||
|         return "{0}. {1}".format(number, title) | ||||
|     else: | ||||
|         return title | ||||
|  | ||||
|  | ||||
| def generate_m3u(track_file): | ||||
|     tracks = internals.get_unique_tracks(track_file) | ||||
|     target_file = "{}.m3u".format(track_file.split(".")[0]) | ||||
|     total_tracks = len(tracks) | ||||
|     log.info("Generating {0} from {1} YouTube URLs".format(target_file, total_tracks)) | ||||
|     with open(target_file, "w") as output_file: | ||||
|         output_file.write("#EXTM3U\n\n") | ||||
|  | ||||
|     videos = [] | ||||
|     for n, track in enumerate(tracks, 1): | ||||
|         content, _ = match_video_and_metadata(track) | ||||
|         if content is None: | ||||
|             log.warning("Skipping {}".format(track)) | ||||
|         else: | ||||
|             log.info( | ||||
|                 "Matched track {0}/{1} ({2})".format( | ||||
|                     n, total_tracks, content.watchv_url | ||||
|                 ) | ||||
|             ) | ||||
|             log.debug(track) | ||||
|             m3u_key = "#EXTINF:{duration},{title}\n{youtube_url}\n".format( | ||||
|                 duration=internals.get_sec(content.duration), | ||||
|                 title=content.title, | ||||
|                 youtube_url=content.watchv_url, | ||||
|             ) | ||||
|             log.debug(m3u_key) | ||||
|             with open(target_file, "a") as output_file: | ||||
|                 output_file.write(m3u_key) | ||||
|             videos.append(content.watchv_url) | ||||
|  | ||||
|     return videos | ||||
|  | ||||
|  | ||||
| def download_song(file_name, content): | ||||
|     """ Download the audio file from YouTube. """ | ||||
|     _, extension = os.path.splitext(file_name) | ||||
|     if extension in (".webm", ".m4a"): | ||||
|         link = content.getbestaudio(preftype=extension[1:]) | ||||
|     else: | ||||
|         log.debug("No audio streams available for {} type".format(extension)) | ||||
|         return False | ||||
|  | ||||
|     if link: | ||||
|         log.debug("Downloading from URL: " + link.url) | ||||
|         filepath = os.path.join(const.args.folder, file_name) | ||||
|         log.debug("Saving to: " + filepath) | ||||
|         link.download(filepath=filepath) | ||||
|         return True | ||||
|     else: | ||||
|         log.debug("No audio streams available") | ||||
|         return False | ||||
|  | ||||
|  | ||||
| def generate_search_url(query): | ||||
|     """ Generate YouTube search URL for the given song. """ | ||||
|     # urllib.request.quote() encodes string with special characters | ||||
|     quoted_query = urllib.request.quote(query) | ||||
|     # Special YouTube URL filter to search only for videos | ||||
|     url = "https://www.youtube.com/results?sp=EgIQAQ%253D%253D&q={0}".format( | ||||
|         quoted_query | ||||
|     ) | ||||
|     return url | ||||
|  | ||||
|  | ||||
| def is_video(result): | ||||
|     # ensure result is not a channel | ||||
|     not_video = ( | ||||
|         result.find("channel") is not None | ||||
|         or "yt-lockup-channel" in result.parent.attrs["class"] | ||||
|         or "yt-lockup-channel" in result.attrs["class"] | ||||
|     ) | ||||
|  | ||||
|     # ensure result is not a mix/playlist | ||||
|     not_video = not_video or "yt-lockup-playlist" in result.parent.attrs["class"] | ||||
|  | ||||
|     # ensure video result is not an advertisement | ||||
|     not_video = not_video or result.find("googleads") is not None | ||||
|  | ||||
|     video = not not_video | ||||
|     return video | ||||
|  | ||||
|  | ||||
| def generate_youtube_url(raw_song, meta_tags): | ||||
|     url_fetch = GenerateYouTubeURL(raw_song, meta_tags) | ||||
|     if const.args.youtube_api_key: | ||||
|         url = url_fetch.api() | ||||
|     else: | ||||
|         url = url_fetch.scrape() | ||||
|     return url | ||||
|  | ||||
|  | ||||
| class GenerateYouTubeURL: | ||||
|     def __init__(self, raw_song, meta_tags): | ||||
|         self.raw_song = raw_song | ||||
|         self.meta_tags = meta_tags | ||||
|  | ||||
|         if meta_tags is None: | ||||
|             self.search_query = raw_song | ||||
|         else: | ||||
|             self.search_query = internals.format_string( | ||||
|                 const.args.search_format, meta_tags, force_spaces=True | ||||
|             ) | ||||
|  | ||||
|     def _best_match(self, videos): | ||||
|         if not videos: | ||||
|             log.error("No videos found on YouTube for a given search") | ||||
|             return None | ||||
|  | ||||
|         """ Select the best matching video from a list of videos. """ | ||||
|         if const.args.manual: | ||||
|             log.info(self.raw_song) | ||||
|             log.info("0. Skip downloading this song.\n") | ||||
|             # fetch all video links on first page on YouTube | ||||
|             for i, v in enumerate(videos): | ||||
|                 log.info( | ||||
|                     u"{0}. {1} {2} {3}".format( | ||||
|                         i + 1, | ||||
|                         v["title"], | ||||
|                         v["videotime"], | ||||
|                         "http://youtube.com/watch?v=" + v["link"], | ||||
|                     ) | ||||
|                 ) | ||||
|             # let user select the song to download | ||||
|             result = internals.input_link(videos) | ||||
|             if result is None: | ||||
|                 return None | ||||
|         else: | ||||
|             if not self.meta_tags: | ||||
|                 # if the metadata could not be acquired, take the first result | ||||
|                 # from Youtube because the proper song length is unknown | ||||
|                 result = videos[0] | ||||
|                 log.debug( | ||||
|                     "Since no metadata found on Spotify, going with the first result" | ||||
|                 ) | ||||
|             else: | ||||
|                 # filter out videos that do not have a similar length to the Spotify song | ||||
|                 duration_tolerance = 10 | ||||
|                 max_duration_tolerance = 20 | ||||
|                 possible_videos_by_duration = [] | ||||
|  | ||||
|                 # start with a reasonable duration_tolerance, and increment duration_tolerance | ||||
|                 # until one of the Youtube results falls within the correct duration or | ||||
|                 # the duration_tolerance has reached the max_duration_tolerance | ||||
|                 while len(possible_videos_by_duration) == 0: | ||||
|                     possible_videos_by_duration = list( | ||||
|                         filter( | ||||
|                             lambda x: abs(x["seconds"] - self.meta_tags["duration"]) | ||||
|                             <= duration_tolerance, | ||||
|                             videos, | ||||
|                         ) | ||||
|                     ) | ||||
|                     duration_tolerance += 1 | ||||
|                     if duration_tolerance > max_duration_tolerance: | ||||
|                         log.error( | ||||
|                             "{0} by {1} was not found.".format( | ||||
|                                 self.meta_tags["name"], | ||||
|                                 self.meta_tags["artists"][0]["name"], | ||||
|                             ) | ||||
|                         ) | ||||
|                         return None | ||||
|  | ||||
|                 result = possible_videos_by_duration[0] | ||||
|  | ||||
|         if result: | ||||
|             url = "http://youtube.com/watch?v={0}".format(result["link"]) | ||||
|         else: | ||||
|             url = None | ||||
|  | ||||
|         return url | ||||
|  | ||||
|     def scrape(self, bestmatch=True, tries_remaining=5): | ||||
|         """ Search and scrape YouTube to return a list of matching videos. """ | ||||
|  | ||||
|         # prevents an infinite loop but allows for a few retries | ||||
|         if tries_remaining == 0: | ||||
|             log.debug("No tries left. I quit.") | ||||
|             return | ||||
|  | ||||
|         search_url = generate_search_url(self.search_query) | ||||
|         log.debug("Opening URL: {0}".format(search_url)) | ||||
|  | ||||
|         item = self._fetch_response(search_url).read() | ||||
|         items_parse = BeautifulSoup(item, "html.parser") | ||||
|  | ||||
|         videos = [] | ||||
|         for x in items_parse.find_all( | ||||
|             "div", {"class": "yt-lockup-dismissable yt-uix-tile"} | ||||
|         ): | ||||
|  | ||||
|             if not is_video(x): | ||||
|                 continue | ||||
|  | ||||
|             y = x.find("div", class_="yt-lockup-content") | ||||
|             link = y.find("a")["href"][-11:] | ||||
|             title = y.find("a")["title"] | ||||
|  | ||||
|             try: | ||||
|                 videotime = x.find("span", class_="video-time").get_text() | ||||
|             except AttributeError: | ||||
|                 log.debug("Could not find video duration on YouTube, retrying..") | ||||
|                 return self.scrape( | ||||
|                     bestmatch=bestmatch, tries_remaining=tries_remaining - 1 | ||||
|                 ) | ||||
|  | ||||
|             youtubedetails = { | ||||
|                 "link": link, | ||||
|                 "title": title, | ||||
|                 "videotime": videotime, | ||||
|                 "seconds": internals.get_sec(videotime), | ||||
|             } | ||||
|             videos.append(youtubedetails) | ||||
|  | ||||
|         if bestmatch: | ||||
|             return self._best_match(videos) | ||||
|  | ||||
|         return videos | ||||
|  | ||||
|     def api(self, bestmatch=True): | ||||
|         """ Use YouTube API to search and return a list of matching videos. """ | ||||
|  | ||||
|         query = {"part": "snippet", "maxResults": 50, "type": "video"} | ||||
|  | ||||
|         if const.args.music_videos_only: | ||||
|             query["videoCategoryId"] = "10" | ||||
|  | ||||
|         if not self.meta_tags: | ||||
|             song = self.raw_song | ||||
|             query["q"] = song | ||||
|         else: | ||||
|             query["q"] = self.search_query | ||||
|         log.debug("query: {0}".format(query)) | ||||
|  | ||||
|         data = pafy.call_gdata("search", query) | ||||
|         data["items"] = list( | ||||
|             filter(lambda x: x["id"].get("videoId") is not None, data["items"]) | ||||
|         ) | ||||
|         query_results = { | ||||
|             "part": "contentDetails,snippet,statistics", | ||||
|             "maxResults": 50, | ||||
|             "id": ",".join(i["id"]["videoId"] for i in data["items"]), | ||||
|         } | ||||
|         log.debug("query_results: {0}".format(query_results)) | ||||
|  | ||||
|         vdata = pafy.call_gdata("videos", query_results) | ||||
|  | ||||
|         videos = [] | ||||
|         for x in vdata["items"]: | ||||
|             duration_s = pafy.playlist.parseISO8591(x["contentDetails"]["duration"]) | ||||
|             youtubedetails = { | ||||
|                 "link": x["id"], | ||||
|                 "title": x["snippet"]["title"], | ||||
|                 "videotime": internals.videotime_from_seconds(duration_s), | ||||
|                 "seconds": duration_s, | ||||
|             } | ||||
|             videos.append(youtubedetails) | ||||
|  | ||||
|         if bestmatch: | ||||
|             return self._best_match(videos) | ||||
|  | ||||
|         return videos | ||||
|  | ||||
|     @staticmethod | ||||
|     def _fetch_response(url): | ||||
|         # XXX: This method exists only because it helps us indirectly | ||||
|         # monkey patch `urllib.request.open`, directly monkey patching | ||||
|         # `urllib.request.open` causes us to end up in an infinite recursion | ||||
|         # during the test since `urllib.request.open` would monkeypatch itself. | ||||
|         return urllib.request.urlopen(url) | ||||
| @@ -1,26 +0,0 @@ | ||||
| from spotdl import const | ||||
| from spotdl import handle | ||||
| from spotdl import spotdl | ||||
| import urllib | ||||
|  | ||||
| import pytest | ||||
|  | ||||
|  | ||||
| def load_defaults(): | ||||
|     const.args = handle.get_arguments(raw_args="", to_group=False, to_merge=False) | ||||
|     const.args.overwrite = "skip" | ||||
|  | ||||
|     spotdl.args = const.args | ||||
|     spotdl.log = const.logzero.setup_logger( | ||||
|         formatter=const._formatter, level=const.args.log_level | ||||
|     ) | ||||
|  | ||||
|  | ||||
| # GIST_URL is the monkeypatched version of: https://www.youtube.com/results?search_query=janji+-+heroes | ||||
| # so that we get same results even if YouTube changes the list/order of videos on their page. | ||||
| GIST_URL = "https://gist.githubusercontent.com/ritiek/e731338e9810e31c2f00f13c249a45f5/raw/c11a27f3b5d11a8d082976f1cdd237bd605ec2c2/search_results.html" | ||||
|  | ||||
|  | ||||
| def monkeypatch_youtube_search_page(*args, **kwargs): | ||||
|     fake_urlopen = urllib.request.urlopen(GIST_URL) | ||||
|     return fake_urlopen | ||||
| @@ -1,244 +0,0 @@ | ||||
| import subprocess | ||||
| import os | ||||
|  | ||||
| from spotdl import const | ||||
| from spotdl import internals | ||||
| from spotdl import spotify_tools | ||||
| from spotdl import youtube_tools | ||||
| from spotdl import convert | ||||
| from spotdl import metadata | ||||
| from spotdl import downloader | ||||
|  | ||||
| import pytest | ||||
| import loader | ||||
|  | ||||
| loader.load_defaults() | ||||
|  | ||||
| SPOTIFY_TRACK_URL = "https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD" | ||||
| EXPECTED_YOUTUBE_TITLE = "Janji - Heroes Tonight (feat. Johnning) [NCS Release]" | ||||
| EXPECTED_SPOTIFY_TITLE = "Janji - Heroes Tonight" | ||||
| EXPECTED_YOUTUBE_URL = "http://youtube.com/watch?v=3nQNiWdeH2Q" | ||||
| # GIST_URL is the monkeypatched version of: https://www.youtube.com/results?search_query=janji+-+heroes | ||||
| # so that we get same results even if YouTube changes the list/order of videos on their page. | ||||
| GIST_URL = "https://gist.githubusercontent.com/ritiek/e731338e9810e31c2f00f13c249a45f5/raw/c11a27f3b5d11a8d082976f1cdd237bd605ec2c2/search_results.html" | ||||
|  | ||||
|  | ||||
| def pytest_namespace(): | ||||
|     # XXX: We override the value of `content_fixture` later in the tests. | ||||
|     # We do not use an acutal @pytest.fixture because it does not accept | ||||
|     # the monkeypatch parameter and we need to monkeypatch the network | ||||
|     # request before creating the Pafy object. | ||||
|     return {"content_fixture": None} | ||||
|  | ||||
|  | ||||
| @pytest.fixture(scope="module") | ||||
| def metadata_fixture(): | ||||
|     meta_tags = spotify_tools.generate_metadata(SPOTIFY_TRACK_URL) | ||||
|     return meta_tags | ||||
|  | ||||
|  | ||||
| def test_metadata(metadata_fixture): | ||||
|     expect_number = 24 | ||||
|     assert len(metadata_fixture) == expect_number | ||||
|  | ||||
|  | ||||
| class TestFileFormat: | ||||
|     def test_with_spaces(self, metadata_fixture): | ||||
|         title = internals.format_string(const.args.file_format, metadata_fixture) | ||||
|         assert title == EXPECTED_SPOTIFY_TITLE | ||||
|  | ||||
|     def test_without_spaces(self, metadata_fixture): | ||||
|         const.args.no_spaces = True | ||||
|         title = internals.format_string(const.args.file_format, metadata_fixture) | ||||
|         assert title == EXPECTED_SPOTIFY_TITLE.replace(" ", "_") | ||||
|  | ||||
|  | ||||
| def test_youtube_url(metadata_fixture, monkeypatch): | ||||
|     monkeypatch.setattr( | ||||
|         youtube_tools.GenerateYouTubeURL, | ||||
|         "_fetch_response", | ||||
|         loader.monkeypatch_youtube_search_page, | ||||
|     ) | ||||
|     url = youtube_tools.generate_youtube_url(SPOTIFY_TRACK_URL, metadata_fixture) | ||||
|     assert url == EXPECTED_YOUTUBE_URL | ||||
|  | ||||
|  | ||||
| def test_youtube_title(metadata_fixture, monkeypatch): | ||||
|     monkeypatch.setattr( | ||||
|         youtube_tools.GenerateYouTubeURL, | ||||
|         "_fetch_response", | ||||
|         loader.monkeypatch_youtube_search_page, | ||||
|     ) | ||||
|     content = youtube_tools.go_pafy(SPOTIFY_TRACK_URL, metadata_fixture) | ||||
|     pytest.content_fixture = content | ||||
|     title = youtube_tools.get_youtube_title(content) | ||||
|     assert title == EXPECTED_YOUTUBE_TITLE | ||||
|  | ||||
|  | ||||
| @pytest.fixture(scope="module") | ||||
| def filename_fixture(metadata_fixture): | ||||
|     songname = internals.format_string(const.args.file_format, metadata_fixture) | ||||
|     filename = internals.sanitize_title(songname) | ||||
|     return filename | ||||
|  | ||||
|  | ||||
| def test_check_track_exists_before_download(tmpdir, metadata_fixture, filename_fixture): | ||||
|     expect_check = False | ||||
|     const.args.folder = str(tmpdir) | ||||
|     # prerequisites for determining filename | ||||
|     track_existence = downloader.CheckExists(filename_fixture, metadata_fixture) | ||||
|     check = track_existence.already_exists(SPOTIFY_TRACK_URL) | ||||
|     assert check == expect_check | ||||
|  | ||||
|  | ||||
| class TestDownload: | ||||
|     def blank_audio_generator(self, filepath): | ||||
|         if filepath.endswith(".m4a"): | ||||
|             cmd = "ffmpeg -f lavfi -i anullsrc -t 1 -c:a aac {}".format(filepath) | ||||
|         elif filepath.endswith(".webm"): | ||||
|             cmd = "ffmpeg -f lavfi -i anullsrc -t 1 -c:a libopus {}".format(filepath) | ||||
|         subprocess.call(cmd.split(" ")) | ||||
|  | ||||
|     def test_m4a(self, monkeypatch, filename_fixture): | ||||
|         expect_download = True | ||||
|         monkeypatch.setattr( | ||||
|             "pafy.backend_shared.BaseStream.download", self.blank_audio_generator | ||||
|         ) | ||||
|         monkeypatch.setattr( | ||||
|             "pafy.backend_youtube_dl.YtdlStream.download", self.blank_audio_generator | ||||
|         ) | ||||
|         download = youtube_tools.download_song( | ||||
|             filename_fixture + ".m4a", pytest.content_fixture | ||||
|         ) | ||||
|         assert download == expect_download | ||||
|  | ||||
|     def test_webm(self, monkeypatch, filename_fixture): | ||||
|         expect_download = True | ||||
|         monkeypatch.setattr( | ||||
|             "pafy.backend_shared.BaseStream.download", self.blank_audio_generator | ||||
|         ) | ||||
|         monkeypatch.setattr( | ||||
|             "pafy.backend_youtube_dl.YtdlStream.download", self.blank_audio_generator | ||||
|         ) | ||||
|         download = youtube_tools.download_song( | ||||
|             filename_fixture + ".webm", pytest.content_fixture | ||||
|         ) | ||||
|         assert download == expect_download | ||||
|  | ||||
|  | ||||
| class TestFFmpeg: | ||||
|     def test_convert_from_webm_to_mp3(self, filename_fixture, monkeypatch): | ||||
|         expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.webm -codec:a libmp3lame -ar 44100 -b:a 192k -vn {0}.mp3".format( | ||||
|             os.path.join(const.args.folder, filename_fixture) | ||||
|         ) | ||||
|         monkeypatch.setattr("os.remove", lambda x: None) | ||||
|         _, command = convert.song( | ||||
|             filename_fixture + ".webm", filename_fixture + ".mp3", const.args.folder | ||||
|         ) | ||||
|         assert " ".join(command) == expect_command | ||||
|  | ||||
|     def test_convert_from_webm_to_m4a(self, filename_fixture, monkeypatch): | ||||
|         expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.webm -cutoff 20000 -codec:a aac -ar 44100 -b:a 192k -vn {0}.m4a".format( | ||||
|             os.path.join(const.args.folder, filename_fixture) | ||||
|         ) | ||||
|         monkeypatch.setattr("os.remove", lambda x: None) | ||||
|         _, command = convert.song( | ||||
|             filename_fixture + ".webm", filename_fixture + ".m4a", const.args.folder | ||||
|         ) | ||||
|         assert " ".join(command) == expect_command | ||||
|  | ||||
|     def test_convert_from_m4a_to_mp3(self, filename_fixture, monkeypatch): | ||||
|         expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.m4a -codec:v copy -codec:a libmp3lame -ar 44100 -b:a 192k -vn {0}.mp3".format( | ||||
|             os.path.join(const.args.folder, filename_fixture) | ||||
|         ) | ||||
|         monkeypatch.setattr("os.remove", lambda x: None) | ||||
|         _, command = convert.song( | ||||
|             filename_fixture + ".m4a", filename_fixture + ".mp3", const.args.folder | ||||
|         ) | ||||
|         assert " ".join(command) == expect_command | ||||
|  | ||||
|     def test_convert_from_m4a_to_webm(self, filename_fixture, monkeypatch): | ||||
|         expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.m4a -codec:a libopus -vbr on -b:a 192k -vn {0}.webm".format( | ||||
|             os.path.join(const.args.folder, filename_fixture) | ||||
|         ) | ||||
|         monkeypatch.setattr("os.remove", lambda x: None) | ||||
|         _, command = convert.song( | ||||
|             filename_fixture + ".m4a", filename_fixture + ".webm", const.args.folder | ||||
|         ) | ||||
|         assert " ".join(command) == expect_command | ||||
|  | ||||
|     def test_convert_from_m4a_to_flac(self, filename_fixture, monkeypatch): | ||||
|         expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.m4a -codec:a flac -ar 44100 -b:a 192k -vn {0}.flac".format( | ||||
|             os.path.join(const.args.folder, filename_fixture) | ||||
|         ) | ||||
|         monkeypatch.setattr("os.remove", lambda x: None) | ||||
|         _, command = convert.song( | ||||
|             filename_fixture + ".m4a", filename_fixture + ".flac", const.args.folder | ||||
|         ) | ||||
|         assert " ".join(command) == expect_command | ||||
|  | ||||
|     def test_correct_container_for_m4a(self, filename_fixture, monkeypatch): | ||||
|         expect_command = "ffmpeg -y -nostdin -hide_banner -nostats -v panic -i {0}.m4a.temp -acodec copy -b:a 192k -vn {0}.m4a".format( | ||||
|             os.path.join(const.args.folder, filename_fixture) | ||||
|         ) | ||||
|         _, command = convert.song( | ||||
|             filename_fixture + ".m4a", filename_fixture + ".m4a", const.args.folder | ||||
|         ) | ||||
|         assert " ".join(command) == expect_command | ||||
|  | ||||
|  | ||||
| class TestAvconv: | ||||
|     @pytest.mark.skip(reason="avconv is no longer provided with FFmpeg") | ||||
|     def test_convert_from_m4a_to_mp3(self, filename_fixture, monkeypatch): | ||||
|         monkeypatch.setattr("os.remove", lambda x: None) | ||||
|         expect_command = "avconv -loglevel 0 -i {0}.m4a -ab 192k {0}.mp3 -y".format( | ||||
|             os.path.join(const.args.folder, filename_fixture) | ||||
|         ) | ||||
|         _, command = convert.song( | ||||
|             filename_fixture + ".m4a", | ||||
|             filename_fixture + ".mp3", | ||||
|             const.args.folder, | ||||
|             avconv=True, | ||||
|         ) | ||||
|         assert " ".join(command) == expect_command | ||||
|  | ||||
|  | ||||
| @pytest.fixture(scope="module") | ||||
| def trackpath_fixture(filename_fixture): | ||||
|     trackpath = os.path.join(const.args.folder, filename_fixture) | ||||
|     return trackpath | ||||
|  | ||||
|  | ||||
| class TestEmbedMetadata: | ||||
|     def test_embed_in_mp3(self, metadata_fixture, trackpath_fixture): | ||||
|         expect_embed = True | ||||
|         embed = metadata.embed(trackpath_fixture + ".mp3", metadata_fixture) | ||||
|         assert embed == expect_embed | ||||
|  | ||||
|     def test_embed_in_m4a(self, metadata_fixture, trackpath_fixture): | ||||
|         expect_embed = True | ||||
|         embed = metadata.embed(trackpath_fixture + ".m4a", metadata_fixture) | ||||
|         os.remove(trackpath_fixture + ".m4a") | ||||
|         assert embed == expect_embed | ||||
|  | ||||
|     def test_embed_in_webm(self, metadata_fixture, trackpath_fixture): | ||||
|         expect_embed = False | ||||
|         embed = metadata.embed(trackpath_fixture + ".webm", metadata_fixture) | ||||
|         os.remove(trackpath_fixture + ".webm") | ||||
|         assert embed == expect_embed | ||||
|  | ||||
|     def test_embed_in_flac(self, metadata_fixture, trackpath_fixture): | ||||
|         expect_embed = True | ||||
|         embed = metadata.embed(trackpath_fixture + ".flac", metadata_fixture) | ||||
|         os.remove(trackpath_fixture + ".flac") | ||||
|         assert embed == expect_embed | ||||
|  | ||||
|  | ||||
| def test_check_track_exists_after_download( | ||||
|     metadata_fixture, filename_fixture, trackpath_fixture | ||||
| ): | ||||
|     expect_check = True | ||||
|     track_existence = downloader.CheckExists(filename_fixture, metadata_fixture) | ||||
|     check = track_existence.already_exists(SPOTIFY_TRACK_URL) | ||||
|     os.remove(trackpath_fixture + ".mp3") | ||||
|     assert check == expect_check | ||||
| @@ -1,21 +0,0 @@ | ||||
| import os | ||||
|  | ||||
| from spotdl import const | ||||
| from spotdl import downloader | ||||
|  | ||||
| import loader | ||||
|  | ||||
| loader.load_defaults() | ||||
|  | ||||
| TRACK_URL = "http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU" | ||||
|  | ||||
|  | ||||
| def test_dry_download_list(tmpdir): | ||||
|     const.args.folder = str(tmpdir) | ||||
|     const.args.dry_run = True | ||||
|     file_path = os.path.join(const.args.folder, "test_list.txt") | ||||
|     with open(file_path, "w") as f: | ||||
|         f.write(TRACK_URL) | ||||
|     list_dl = downloader.ListDownloader(file_path) | ||||
|     downloaded_song, *_ = list_dl.download_list() | ||||
|     assert downloaded_song == TRACK_URL | ||||
| @@ -1,70 +0,0 @@ | ||||
| import os | ||||
| import sys | ||||
| import argparse | ||||
|  | ||||
| from spotdl import handle | ||||
|  | ||||
| import pytest | ||||
| import yaml | ||||
|  | ||||
|  | ||||
| def test_error_m3u_without_list(): | ||||
|     with pytest.raises(SystemExit): | ||||
|         handle.get_arguments(raw_args=("-s cool song", "--write-m3u"), to_group=True) | ||||
|  | ||||
|  | ||||
| def test_m3u_with_list(): | ||||
|     handle.get_arguments(raw_args=("-l cool_list.txt", "--write-m3u"), to_group=True) | ||||
|  | ||||
|  | ||||
| def test_log_str_to_int(): | ||||
|     expect_levels = [20, 30, 40, 10] | ||||
|     levels = [handle.log_leveller(level) for level in handle._LOG_LEVELS_STR] | ||||
|     assert levels == expect_levels | ||||
|  | ||||
|  | ||||
| @pytest.fixture(scope="module") | ||||
| def config_path_fixture(tmpdir_factory): | ||||
|     config_path = os.path.join(str(tmpdir_factory.mktemp("config")), "config.yml") | ||||
|     return config_path | ||||
|  | ||||
|  | ||||
| @pytest.fixture(scope="module") | ||||
| def modified_config_fixture(): | ||||
|     modified_config = dict(handle.default_conf) | ||||
|     return modified_config | ||||
|  | ||||
|  | ||||
| class TestConfig: | ||||
|     def test_default_config(self, config_path_fixture): | ||||
|         expect_config = handle.default_conf["spotify-downloader"] | ||||
|         config = handle.get_config(config_path_fixture) | ||||
|         assert config == expect_config | ||||
|  | ||||
|     def test_modified_config(self, modified_config_fixture): | ||||
|         modified_config_fixture["spotify-downloader"]["file-format"] = "just_a_test" | ||||
|         merged_config = handle.merge(handle.default_conf, modified_config_fixture) | ||||
|         assert merged_config == modified_config_fixture | ||||
|  | ||||
|     def test_custom_config_path(self, config_path_fixture, modified_config_fixture): | ||||
|         parser = argparse.ArgumentParser() | ||||
|         with open(config_path_fixture, "w") as config_file: | ||||
|             yaml.dump(modified_config_fixture, config_file, default_flow_style=False) | ||||
|         overridden_config = handle.override_config( | ||||
|             config_path_fixture, parser, raw_args="" | ||||
|         ) | ||||
|         modified_values = [ | ||||
|             str(value) | ||||
|             for value in modified_config_fixture["spotify-downloader"].values() | ||||
|         ] | ||||
|         overridden_config.folder = os.path.realpath(overridden_config.folder) | ||||
|         overridden_values = [ | ||||
|             str(value) for value in overridden_config.__dict__.values() | ||||
|         ] | ||||
|         assert sorted(overridden_values) == sorted(modified_values) | ||||
|  | ||||
|  | ||||
| def test_grouped_arguments(tmpdir): | ||||
|     sys.path[0] = str(tmpdir) | ||||
|     with pytest.raises(SystemExit): | ||||
|         handle.get_arguments(to_group=True, to_merge=True) | ||||
| @@ -1,180 +0,0 @@ | ||||
| import sys | ||||
| import os | ||||
| import subprocess | ||||
|  | ||||
| from spotdl import internals | ||||
|  | ||||
| import pytest | ||||
|  | ||||
|  | ||||
| DUPLICATE_TRACKS_TEST_TABLE = [ | ||||
|     ( | ||||
|         ( | ||||
|             "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", | ||||
|             "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", | ||||
|         ), | ||||
|         ("https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ",), | ||||
|     ), | ||||
|     ( | ||||
|         ( | ||||
|             "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", | ||||
|             "", | ||||
|             "https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD", | ||||
|         ), | ||||
|         ( | ||||
|             "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", | ||||
|             "https://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD", | ||||
|         ), | ||||
|     ), | ||||
|     ( | ||||
|         ( | ||||
|             "ncs fade", | ||||
|             "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", | ||||
|             "", | ||||
|             "ncs fade", | ||||
|         ), | ||||
|         ("ncs fade", "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ"), | ||||
|     ), | ||||
|     ( | ||||
|         ("ncs spectre ", "  https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ", ""), | ||||
|         ("ncs spectre", "https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ"), | ||||
|     ), | ||||
| ] | ||||
|  | ||||
| STRING_IDS_TEST_TABLE = [ | ||||
|     ( | ||||
|         "https://open.spotify.com/artist/1feoGrmmD8QmNqtK2Gdwy8?si=_cVm-FBRQmi7VWML7E49Ig", | ||||
|         "1feoGrmmD8QmNqtK2Gdwy8", | ||||
|     ), | ||||
|     ( | ||||
|         "https://open.spotify.com/artist/1feoGrmmD8QmNqtK2Gdwy8", | ||||
|         "1feoGrmmD8QmNqtK2Gdwy8", | ||||
|     ), | ||||
|     ("spotify:artist:1feoGrmmD8QmNqtK2Gdwy8", "1feoGrmmD8QmNqtK2Gdwy8"), | ||||
|     ( | ||||
|         "https://open.spotify.com/album/1d1l3UkeAjtM7kVTDyR8yp?si=LkVQLJGGT--Lh8BWM4MGvg", | ||||
|         "1d1l3UkeAjtM7kVTDyR8yp", | ||||
|     ), | ||||
|     ("https://open.spotify.com/album/1d1l3UkeAjtM7kVTDyR8yp", "1d1l3UkeAjtM7kVTDyR8yp"), | ||||
|     ("spotify:album:1d1l3UkeAjtM7kVTDyR8yp", "1d1l3UkeAjtM7kVTDyR8yp"), | ||||
|     ( | ||||
|         "https://open.spotify.com/user/5kkyy50uu8btnagp30pobxz2f/playlist/3SFKRjUXm0IMQJMkEgPHeY?si=8Da4gbE2T9qMkd8Upg22ZA", | ||||
|         "3SFKRjUXm0IMQJMkEgPHeY", | ||||
|     ), | ||||
|     ( | ||||
|         "https://open.spotify.com/playlist/3SFKRjUXm0IMQJMkEgPHeY?si=8Da4gbE2T9qMkd8Upg22ZA", | ||||
|         "3SFKRjUXm0IMQJMkEgPHeY", | ||||
|     ), | ||||
|     ( | ||||
|         "https://open.spotify.com/playlist/3SFKRjUXm0IMQJMkEgPHeY", | ||||
|         "3SFKRjUXm0IMQJMkEgPHeY", | ||||
|     ), | ||||
|     ( | ||||
|         "spotify:user:5kkyy50uu8btnagp30pobxz2f:playlist:3SFKRjUXm0IMQJMkEgPHeY", | ||||
|         "3SFKRjUXm0IMQJMkEgPHeY", | ||||
|     ), | ||||
|     ( | ||||
|         "https://open.spotify.com/user/uqlakumu7wslkoen46s5bulq0", | ||||
|         "uqlakumu7wslkoen46s5bulq0", | ||||
|     ), | ||||
| ] | ||||
|  | ||||
|  | ||||
| FROM_SECONDS_TEST_TABLE = [ | ||||
|     (35, "35"), | ||||
|     (23, "23"), | ||||
|     (158, "2:38"), | ||||
|     (263, "4:23"), | ||||
|     (4562, "1:16:02"), | ||||
|     (26762, "7:26:02"), | ||||
| ] | ||||
|  | ||||
|  | ||||
| TO_SECONDS_TEST_TABLE = [ | ||||
|     ("0:23", 23), | ||||
|     ("0:45", 45), | ||||
|     ("2:19", 139), | ||||
|     ("3:33", 213), | ||||
|     ("7:38", 458), | ||||
|     ("1:30:05", 5405), | ||||
| ] | ||||
|  | ||||
|  | ||||
| def test_default_music_directory(): | ||||
|     if sys.platform.startswith("linux"): | ||||
|         output = subprocess.check_output(["xdg-user-dir", "MUSIC"]) | ||||
|         expect_directory = output.decode("utf-8").rstrip() | ||||
|     else: | ||||
|         home = os.path.expanduser("~") | ||||
|         expect_directory = os.path.join(home, "Music") | ||||
|  | ||||
|     directory = internals.get_music_dir() | ||||
|     assert directory == expect_directory | ||||
|  | ||||
|  | ||||
| @pytest.fixture(scope="module") | ||||
| def directory_fixture(tmpdir_factory): | ||||
|     dir_path = os.path.join(str(tmpdir_factory.mktemp("tmpdir")), "filter_this_folder") | ||||
|     return dir_path | ||||
|  | ||||
|  | ||||
| class TestPathFilterer: | ||||
|     def test_create_directory(self, directory_fixture): | ||||
|         expect_path = True | ||||
|         internals.filter_path(directory_fixture) | ||||
|         is_path = os.path.isdir(directory_fixture) | ||||
|         assert is_path == expect_path | ||||
|  | ||||
|     def test_remove_temp_files(self, directory_fixture): | ||||
|         expect_file = False | ||||
|         file_path = os.path.join(directory_fixture, "pesky_file.temp") | ||||
|         open(file_path, "a") | ||||
|         internals.filter_path(directory_fixture) | ||||
|         is_file = os.path.isfile(file_path) | ||||
|         assert is_file == expect_file | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("sec_duration, str_duration", FROM_SECONDS_TEST_TABLE) | ||||
| def test_video_time_from_seconds(sec_duration, str_duration): | ||||
|     duration = internals.videotime_from_seconds(sec_duration) | ||||
|     assert duration == str_duration | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("str_duration, sec_duration", TO_SECONDS_TEST_TABLE) | ||||
| def test_get_seconds_from_video_time(str_duration, sec_duration): | ||||
|     secs = internals.get_sec(str_duration) | ||||
|     assert secs == sec_duration | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("duplicates, expected", DUPLICATE_TRACKS_TEST_TABLE) | ||||
| def test_get_unique_tracks(tmpdir, duplicates, expected): | ||||
|     file_path = os.path.join(str(tmpdir), "test_duplicates.txt") | ||||
|     with open(file_path, "w") as f: | ||||
|         f.write("\n".join(duplicates)) | ||||
|  | ||||
|     unique_tracks = internals.get_unique_tracks(file_path) | ||||
|     assert tuple(unique_tracks) == expected | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize("input_str, expected_spotify_id", STRING_IDS_TEST_TABLE) | ||||
| def test_extract_spotify_id(input_str, expected_spotify_id): | ||||
|     spotify_id = internals.extract_spotify_id(input_str) | ||||
|     assert spotify_id == expected_spotify_id | ||||
|  | ||||
|  | ||||
| def test_trim(tmpdir): | ||||
|     text_file = os.path.join(str(tmpdir), "test_trim.txt") | ||||
|     with open(text_file, "w") as track_file: | ||||
|         track_file.write("ncs - spectre\nncs - heroes\nncs - hope") | ||||
|  | ||||
|     with open(text_file, "r") as track_file: | ||||
|         tracks = track_file.readlines() | ||||
|  | ||||
|     expect_number = len(tracks) - 1 | ||||
|     expect_track = tracks[0] | ||||
|     track = internals.trim_song(text_file) | ||||
|  | ||||
|     with open(text_file, "r") as track_file: | ||||
|         number = len(track_file.readlines()) | ||||
|  | ||||
|     assert expect_number == number and expect_track == track | ||||
| @@ -1,36 +0,0 @@ | ||||
| from spotdl import patcher | ||||
| import pafy | ||||
|  | ||||
| import pytest | ||||
|  | ||||
| pafy_patcher = patcher.PatchPafy() | ||||
| pafy_patcher.patch_getbestthumb() | ||||
|  | ||||
|  | ||||
| class TestPafyContentAvailable: | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class TestMethodAssignment: | ||||
|     def test_pafy_getbestthumb(self): | ||||
|         pafy.backend_shared.BasePafy.getbestthumb == patcher._getbestthumb | ||||
|  | ||||
|  | ||||
| class TestMethodCalls: | ||||
|     @pytest.fixture(scope="module") | ||||
|     def content_fixture(self): | ||||
|         content = pafy.new("http://youtube.com/watch?v=3nQNiWdeH2Q") | ||||
|         return content | ||||
|  | ||||
|     def test_pafy_getbestthumb(self, content_fixture): | ||||
|         thumbnail = patcher._getbestthumb(content_fixture) | ||||
|         assert thumbnail == "https://i.ytimg.com/vi/3nQNiWdeH2Q/hqdefault.jpg" | ||||
|  | ||||
|     def test_pafy_getbestthumb_without_ytdl(self, content_fixture): | ||||
|         content_fixture._ydl_info["thumbnails"][0]["url"] = None | ||||
|         thumbnail = patcher._getbestthumb(content_fixture) | ||||
|         assert thumbnail == "https://i.ytimg.com/vi/3nQNiWdeH2Q/sddefault.jpg" | ||||
|  | ||||
|     def test_pafy_content_available(self): | ||||
|         TestPafyContentAvailable._content_available = patcher._content_available | ||||
|         assert TestPafyContentAvailable()._content_available("https://youtube.com/") | ||||
							
								
								
									
										72
									
								
								test/test_simple.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								test/test_simple.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| # -*- coding: UTF-8 -*- | ||||
|  | ||||
| import spotdl | ||||
| import os | ||||
|  | ||||
| raw_song = "Tony's Videos VERY SHORT VIDEO 28.10.2016" | ||||
|  | ||||
| for x in os.listdir(spotdl.args.folder): | ||||
|     os.remove(os.path.join(spotdl.args.folder, x)) | ||||
|  | ||||
| def test_youtube_url(): | ||||
|     expect_url = 'youtube.com/watch?v=qOOcy2-tmbk' | ||||
|     url = spotdl.generate_youtube_url(raw_song) | ||||
|     assert url == expect_url | ||||
|  | ||||
|  | ||||
| def test_youtube_title(): | ||||
|     expect_title = "Tony's Videos VERY SHORT VIDEO 28.10.2016" | ||||
|     global content | ||||
|     content = spotdl.go_pafy(raw_song) | ||||
|     global title | ||||
|     title = spotdl.get_youtube_title(content) | ||||
|     assert title == expect_title | ||||
|  | ||||
| def test_check_exists(): | ||||
|     expect_check = False | ||||
|     # prerequisites for determining filename | ||||
|     file_name = spotdl.misc.sanitize_title(title) | ||||
|     check = spotdl.check_exists(file_name, raw_song, islist=True) | ||||
|     assert check == expect_check | ||||
|  | ||||
|  | ||||
| def test_download(): | ||||
|     expect_download = True | ||||
|     # prerequisites for determining filename | ||||
|     file_name = spotdl.misc.sanitize_title(title) | ||||
|     download = spotdl.download_song(file_name, content) | ||||
|     assert download == expect_download | ||||
|  | ||||
|  | ||||
| def test_convert(): | ||||
|     # exit code 0 = success | ||||
|     expect_convert = 0 | ||||
|     # prerequisites for determining filename | ||||
|     file_name = spotdl.misc.sanitize_title(title) | ||||
|     input_song = file_name + spotdl.args.input_ext | ||||
|     output_song = file_name + spotdl.args.output_ext | ||||
|     convert = spotdl.convert.song(input_song, output_song, spotdl.args.folder) | ||||
|     assert convert == expect_convert | ||||
|  | ||||
|  | ||||
| def test_metadata(): | ||||
|     expect_metadata = None | ||||
|     # prerequisites for determining filename | ||||
|     meta_tags = spotdl.generate_metadata(raw_song) | ||||
|     meta_tags = spotdl.generate_metadata(raw_song) | ||||
|     file_name = spotdl.misc.sanitize_title(title) | ||||
|     output_song = file_name + spotdl.args.output_ext | ||||
|     metadata_output = spotdl.metadata.embed(os.path.join(spotdl.args.folder, output_song), meta_tags) | ||||
|     input_song = file_name + spotdl.args.input_ext | ||||
|     metadata_input = spotdl.metadata.embed(os.path.join(spotdl.args.folder, input_song), meta_tags) | ||||
|     assert (metadata_output == expect_metadata) and (metadata_input == expect_metadata) | ||||
|  | ||||
|  | ||||
| def test_check_exists2(): | ||||
|     expect_check = True | ||||
|     # prerequisites for determining filename | ||||
|     file_name = spotdl.misc.sanitize_title(title) | ||||
|     input_song = file_name + spotdl.args.input_ext | ||||
|     os.remove(os.path.join(spotdl.args.folder, input_song)) | ||||
|     check = spotdl.check_exists(file_name, raw_song, islist=True) | ||||
|     assert check == expect_check | ||||
							
								
								
									
										77
									
								
								test/test_spotify.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								test/test_spotify.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| # -*- coding: UTF-8 -*- | ||||
|  | ||||
| import spotdl | ||||
| import os | ||||
|  | ||||
| raw_song = 'http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU' | ||||
|  | ||||
| for x in os.listdir(spotdl.args.folder): | ||||
|     os.remove(os.path.join(spotdl.args.folder, x)) | ||||
|  | ||||
|  | ||||
| def test_spotify_title(): | ||||
|     expect_title = 'David André Østby - Intro' | ||||
|     global meta_tags | ||||
|     meta_tags = spotdl.generate_metadata(raw_song) | ||||
|     title = spotdl.generate_songname(meta_tags) | ||||
|     assert title == expect_title | ||||
|  | ||||
|  | ||||
| def youtube_url(): | ||||
|     expect_url = 'youtube.com/watch?v=rg1wfcty0BA' | ||||
|     url = spotdl.generate_youtube_url(raw_song) | ||||
|     assert url == expect_url | ||||
|  | ||||
|  | ||||
| def youtube_title(): | ||||
|     expect_title = 'Intro - David André Østby' | ||||
|     content = spotdl.go_pafy(raw_song) | ||||
|     title = spotdl.get_youtube_title(content) | ||||
|     assert title == expect_title | ||||
|  | ||||
|  | ||||
| def test_check_exists(): | ||||
|     expect_check = False | ||||
|     # prerequisites for determining filename | ||||
|     songname = spotdl.generate_songname(meta_tags) | ||||
|     global file_name | ||||
|     file_name = spotdl.misc.sanitize_title(songname) | ||||
|     check = spotdl.check_exists(file_name, raw_song, islist=True) | ||||
|     assert check == expect_check | ||||
|  | ||||
|  | ||||
| def test_download(): | ||||
|     expect_download = True | ||||
|     # prerequisites for determining filename | ||||
|     content = spotdl.go_pafy(raw_song) | ||||
|     download = spotdl.download_song(file_name, content) | ||||
|     assert download == expect_download | ||||
|  | ||||
|  | ||||
| def test_convert(): | ||||
|     # exit code 0 = success | ||||
|     expect_convert = 0 | ||||
|     # prerequisites for determining filename | ||||
|     input_song = file_name + spotdl.args.input_ext | ||||
|     output_song = file_name + spotdl.args.output_ext | ||||
|     convert = spotdl.convert.song(input_song, output_song, spotdl.args.folder) | ||||
|     assert convert == expect_convert | ||||
|  | ||||
|  | ||||
| def test_metadata(): | ||||
|     expect_metadata = True | ||||
|     # prerequisites for determining filename | ||||
|     output_song = file_name + spotdl.args.output_ext | ||||
|     metadata_output = spotdl.metadata.embed(os.path.join(spotdl.args.folder, output_song), meta_tags) | ||||
|     input_song = file_name + spotdl.args.input_ext | ||||
|     metadata_input = spotdl.metadata.embed(os.path.join(spotdl.args.folder, input_song), meta_tags) | ||||
|     assert metadata_output == (metadata_input == expect_metadata) | ||||
|  | ||||
|  | ||||
| def test_check_exists2(): | ||||
|     expect_check = True | ||||
|     # prerequisites for determining filename | ||||
|     input_song = file_name + spotdl.args.input_ext | ||||
|     os.remove(os.path.join(spotdl.args.folder, input_song)) | ||||
|     check = spotdl.check_exists(file_name, raw_song, islist=True) | ||||
|     assert check == expect_check | ||||
| @@ -1,178 +0,0 @@ | ||||
| from spotdl import spotify_tools | ||||
| from spotdl import const | ||||
|  | ||||
| import spotipy | ||||
|  | ||||
| import os | ||||
| import pytest | ||||
| import loader | ||||
|  | ||||
| loader.load_defaults() | ||||
|  | ||||
|  | ||||
| def test_generate_token(): | ||||
|     token = spotify_tools.generate_token() | ||||
|     assert len(token) == 83 | ||||
|  | ||||
|  | ||||
| class TestMustBeAuthorizedDecorator: | ||||
|     def test_spotify_instance_is_unset(self): | ||||
|         spotify_tools.spotify = None | ||||
|  | ||||
|         @spotify_tools.must_be_authorized | ||||
|         def sample_func(): | ||||
|             return True | ||||
|  | ||||
|         assert sample_func() | ||||
|  | ||||
|     def test_spotify_instance_forces_assertion_error(self): | ||||
|         @spotify_tools.must_be_authorized | ||||
|         def sample_func(): | ||||
|             raise AssertionError | ||||
|  | ||||
|         with pytest.raises(AssertionError): | ||||
|             sample_func() | ||||
|  | ||||
|     def test_fake_token_generator(self, monkeypatch): | ||||
|         spotify_tools.spotify = None | ||||
|         monkeypatch.setattr(spotify_tools, "generate_token", lambda: 123123) | ||||
|  | ||||
|         with pytest.raises(spotipy.client.SpotifyException): | ||||
|             spotify_tools.generate_metadata("ncs - spectre") | ||||
|  | ||||
|     def test_correct_token(self): | ||||
|         assert spotify_tools.generate_metadata("ncs - spectre") | ||||
|  | ||||
|  | ||||
| class TestGenerateMetadata: | ||||
|     @pytest.fixture(scope="module") | ||||
|     def metadata_fixture(self): | ||||
|         metadata = spotify_tools.generate_metadata("ncs - spectre") | ||||
|         return metadata | ||||
|  | ||||
|     def test_len(self, metadata_fixture): | ||||
|         assert len(metadata_fixture) == 24 | ||||
|  | ||||
|     def test_trackname(self, metadata_fixture): | ||||
|         assert metadata_fixture["name"] == "Spectre" | ||||
|  | ||||
|     def test_artist(self, metadata_fixture): | ||||
|         assert metadata_fixture["artists"][0]["name"] == "Alan Walker" | ||||
|  | ||||
|     def test_duration(self, metadata_fixture): | ||||
|         assert metadata_fixture["duration"] == 230.634 | ||||
|  | ||||
|  | ||||
| def test_get_playlists(): | ||||
|     expect_playlist_ids = [ | ||||
|         "34gWCK8gVeYDPKcctB6BQJ", | ||||
|         "04wTU2c2WNQG9XE5oSLYfj", | ||||
|         "0fWBMhGh38y0wsYWwmM9Kt", | ||||
|     ] | ||||
|  | ||||
|     expect_playlists = [ | ||||
|         "https://open.spotify.com/playlist/" + playlist_id | ||||
|         for playlist_id in expect_playlist_ids | ||||
|     ] | ||||
|  | ||||
|     playlists = spotify_tools.get_playlists("uqlakumu7wslkoen46s5bulq0") | ||||
|     assert playlists == expect_playlists | ||||
|  | ||||
|  | ||||
| def test_write_user_playlist(tmpdir, monkeypatch): | ||||
|     expect_tracks = 17 | ||||
|     text_file = os.path.join(str(tmpdir), "test_us.txt") | ||||
|     monkeypatch.setattr("builtins.input", lambda x: 1) | ||||
|     spotify_tools.write_user_playlist("uqlakumu7wslkoen46s5bulq0", text_file) | ||||
|     with open(text_file, "r") as f: | ||||
|         tracks = len(f.readlines()) | ||||
|     assert tracks == expect_tracks | ||||
|  | ||||
|  | ||||
| class TestFetchPlaylist: | ||||
|     @pytest.fixture(scope="module") | ||||
|     def playlist_fixture(self): | ||||
|         playlist = spotify_tools.fetch_playlist( | ||||
|             "https://open.spotify.com/playlist/0fWBMhGh38y0wsYWwmM9Kt" | ||||
|         ) | ||||
|         return playlist | ||||
|  | ||||
|     def test_name(self, playlist_fixture): | ||||
|         assert playlist_fixture["name"] == "special_test_playlist" | ||||
|  | ||||
|     def test_tracks(self, playlist_fixture): | ||||
|         assert playlist_fixture["tracks"]["total"] == 14 | ||||
|  | ||||
|  | ||||
| def test_write_playlist(tmpdir): | ||||
|     expect_tracks = 14 | ||||
|     text_file = os.path.join(str(tmpdir), "test_pl.txt") | ||||
|     spotify_tools.write_playlist( | ||||
|         "https://open.spotify.com/playlist/0fWBMhGh38y0wsYWwmM9Kt", text_file | ||||
|     ) | ||||
|     with open(text_file, "r") as f: | ||||
|         tracks = len(f.readlines()) | ||||
|     assert tracks == expect_tracks | ||||
|  | ||||
|  | ||||
| # XXX: Monkeypatch these tests if they fail in future | ||||
| class TestFetchAlbum: | ||||
|     @pytest.fixture(scope="module") | ||||
|     def album_fixture(self): | ||||
|         album = spotify_tools.fetch_album( | ||||
|             "https://open.spotify.com/album/499J8bIsEnU7DSrosFDJJg" | ||||
|         ) | ||||
|         return album | ||||
|  | ||||
|     def test_name(self, album_fixture): | ||||
|         assert album_fixture["name"] == "NCS: Infinity" | ||||
|  | ||||
|     def test_tracks(self, album_fixture): | ||||
|         assert album_fixture["tracks"]["total"] == 15 | ||||
|  | ||||
|  | ||||
| # XXX: Monkeypatch these tests if they fail in future | ||||
| class TestFetchAlbumsFromArtist: | ||||
|     @pytest.fixture(scope="module") | ||||
|     def albums_from_artist_fixture(self): | ||||
|         albums = spotify_tools.fetch_albums_from_artist( | ||||
|             "https://open.spotify.com/artist/7oPftvlwr6VrsViSDV7fJY" | ||||
|         ) | ||||
|         return albums | ||||
|  | ||||
|     def test_len(self, albums_from_artist_fixture): | ||||
|         assert len(albums_from_artist_fixture) == 54 | ||||
|  | ||||
|     def test_zeroth_album_name(self, albums_from_artist_fixture): | ||||
|         assert albums_from_artist_fixture[0]["name"] == "Revolution Radio" | ||||
|  | ||||
|     def test_zeroth_album_tracks(self, albums_from_artist_fixture): | ||||
|         assert albums_from_artist_fixture[0]["total_tracks"] == 12 | ||||
|  | ||||
|     def test_fist_album_name(self, albums_from_artist_fixture): | ||||
|         assert albums_from_artist_fixture[1]["name"] == "Demolicious" | ||||
|  | ||||
|     def test_first_album_tracks(self, albums_from_artist_fixture): | ||||
|         assert albums_from_artist_fixture[0]["total_tracks"] == 12 | ||||
|  | ||||
|  | ||||
| def test_write_all_albums_from_artist(tmpdir): | ||||
|     expect_tracks = 282 | ||||
|     text_file = os.path.join(str(tmpdir), "test_ab.txt") | ||||
|     spotify_tools.write_all_albums_from_artist( | ||||
|         "https://open.spotify.com/artist/4dpARuHxo51G3z768sgnrY", text_file | ||||
|     ) | ||||
|     with open(text_file, "r") as f: | ||||
|         tracks = len(f.readlines()) | ||||
|     assert tracks == expect_tracks | ||||
|  | ||||
|  | ||||
| def test_write_album(tmpdir): | ||||
|     expect_tracks = 15 | ||||
|     text_file = os.path.join(str(tmpdir), "test_al.txt") | ||||
|     spotify_tools.write_album( | ||||
|         "https://open.spotify.com/album/499J8bIsEnU7DSrosFDJJg", text_file | ||||
|     ) | ||||
|     with open(text_file, "r") as f: | ||||
|         tracks = len(f.readlines()) | ||||
|     assert tracks == expect_tracks | ||||
							
								
								
									
										57
									
								
								test/test_username.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								test/test_username.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| # -*- coding: UTF-8 -*- | ||||
|  | ||||
| import spotdl | ||||
|  | ||||
| username = 'alex' | ||||
|  | ||||
|  | ||||
| def test_user(): | ||||
|     expect_playlists = 7 | ||||
|     playlists = spotdl.spotify.user_playlists(username) | ||||
|     playlists = len(playlists['items']) | ||||
|     assert playlists == expect_playlists | ||||
|  | ||||
|  | ||||
| def test_playlist(): | ||||
|     expect_tracks = 14 | ||||
|     playlist = spotdl.spotify.user_playlists(username)['items'][0] | ||||
|     tracks = playlist['tracks']['total'] | ||||
|     assert tracks == expect_tracks | ||||
|  | ||||
|  | ||||
| def test_tracks(): | ||||
|     playlist = spotdl.spotify.user_playlists(username)['items'][0] | ||||
|     expect_lines = playlist['tracks']['total'] | ||||
|     result = spotdl.spotify.user_playlist( | ||||
|         playlist['owner']['id'], playlist['id'], fields='tracks,next') | ||||
|     tracks = result['tracks'] | ||||
|  | ||||
|     with open('list.txt', 'w') as fout: | ||||
|         while True: | ||||
|             for item in tracks['items']: | ||||
|                 track = item['track'] | ||||
|                 try: | ||||
|                     fout.write(track['external_urls']['spotify'] + '\n') | ||||
|                 except KeyError: | ||||
|                     title = track['name'] + ' by '+ track['artists'][0]['name'] | ||||
|                     print('Skipping track ' + title + ' (local only?)') | ||||
|             # 1 page = 50 results | ||||
|             # check if there are more pages | ||||
|             if tracks['next']: | ||||
|                 tracks = spotify.next(tracks) | ||||
|             else: | ||||
|                 break | ||||
|  | ||||
|     with open('list.txt', 'r') as listed: | ||||
|         expect_song = (listed.read()).splitlines()[0] | ||||
|  | ||||
|     spotdl.misc.trim_song('list.txt') | ||||
|     with open('list.txt', 'a') as myfile: | ||||
|         myfile.write(expect_song) | ||||
|  | ||||
|     with open('list.txt', 'r') as listed: | ||||
|         songs = (listed.read()).splitlines() | ||||
|  | ||||
|     lines = len(songs) | ||||
|     song = songs[-1] | ||||
|     assert (expect_lines == lines and expect_song == song) | ||||
| @@ -1,243 +0,0 @@ | ||||
| import os | ||||
| import builtins | ||||
|  | ||||
| from spotdl import const | ||||
| from spotdl import internals | ||||
| from spotdl import spotify_tools | ||||
| from spotdl import youtube_tools | ||||
| from spotdl import downloader | ||||
|  | ||||
| import loader | ||||
| import pytest | ||||
|  | ||||
| loader.load_defaults() | ||||
|  | ||||
| YT_API_KEY = "AIzaSyAnItl3udec-Q1d5bkjKJGL-RgrKO_vU90" | ||||
|  | ||||
| TRACK_SEARCH = "Tony's Videos VERY SHORT VIDEO 28.10.2016" | ||||
| EXPECTED_TITLE = TRACK_SEARCH | ||||
| EXPECTED_YT_URL = "http://youtube.com/watch?v=qOOcy2-tmbk" | ||||
|  | ||||
| RESULT_COUNT_SEARCH = "she is still sleeping SAO" | ||||
|  | ||||
| EXPECTED_YT_API_KEY = "AIzaSyC6cEeKlxtOPybk9sEe5ksFN5sB-7wzYp0" | ||||
| EXPECTED_YT_API_KEY_CUSTOM = "some_api_key" | ||||
|  | ||||
|  | ||||
| class TestYouTubeAPIKeys: | ||||
|     def test_custom(self): | ||||
|         const.args.youtube_api_key = EXPECTED_YT_API_KEY_CUSTOM | ||||
|         youtube_tools.set_api_key() | ||||
|         key = youtube_tools.pafy.g.api_key | ||||
|         assert key == EXPECTED_YT_API_KEY_CUSTOM | ||||
|  | ||||
|     def test_default(self): | ||||
|         const.args.youtube_api_key = None | ||||
|         youtube_tools.set_api_key() | ||||
|         key = youtube_tools.pafy.g.api_key | ||||
|         assert key == EXPECTED_YT_API_KEY | ||||
|  | ||||
|  | ||||
| @pytest.fixture(scope="module") | ||||
| def metadata_fixture(): | ||||
|     metadata = spotify_tools.generate_metadata(TRACK_SEARCH) | ||||
|     return metadata | ||||
|  | ||||
|  | ||||
| def test_metadata(metadata_fixture): | ||||
|     expect_metadata = None | ||||
|     assert metadata_fixture == expect_metadata | ||||
|  | ||||
|  | ||||
| class TestArgsManualResultCount: | ||||
|     # Regresson test for issue #264 | ||||
|     def test_scrape(self): | ||||
|         const.args.manual = True | ||||
|         url = youtube_tools.GenerateYouTubeURL(RESULT_COUNT_SEARCH, meta_tags=None) | ||||
|         video_ids = url.scrape(bestmatch=False) | ||||
|         # Web scraping gives us all videos on the 1st page | ||||
|         assert len(video_ids) == 20 | ||||
|  | ||||
|     def test_api(self): | ||||
|         url = youtube_tools.GenerateYouTubeURL(RESULT_COUNT_SEARCH, meta_tags=None) | ||||
|         video_ids = url.api(bestmatch=False) | ||||
|         const.args.manual = False | ||||
|         # API gives us 50 videos (or as requested) | ||||
|         assert len(video_ids) == 50 | ||||
|  | ||||
|  | ||||
| class TestYouTubeURL: | ||||
|     def test_only_music_category(self, metadata_fixture): | ||||
|         const.args.music_videos_only = True | ||||
|         url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata_fixture) | ||||
|         assert url == EXPECTED_YT_URL | ||||
|  | ||||
|     def test_all_categories(self, metadata_fixture): | ||||
|         const.args.music_videos_only = False | ||||
|         url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata_fixture) | ||||
|         assert url == EXPECTED_YT_URL | ||||
|  | ||||
|     def test_args_manual(self, metadata_fixture, monkeypatch): | ||||
|         const.args.manual = True | ||||
|         monkeypatch.setattr("builtins.input", lambda x: "1") | ||||
|         url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata_fixture) | ||||
|         assert url == EXPECTED_YT_URL | ||||
|  | ||||
|     def test_args_manual_none(self, metadata_fixture, monkeypatch): | ||||
|         expect_url = None | ||||
|         monkeypatch.setattr("builtins.input", lambda x: "0") | ||||
|         url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata_fixture) | ||||
|         const.args.manual = False | ||||
|         assert url == expect_url | ||||
|  | ||||
|  | ||||
| @pytest.fixture(scope="module") | ||||
| def content_fixture(metadata_fixture): | ||||
|     content = youtube_tools.go_pafy(TRACK_SEARCH, metadata_fixture) | ||||
|     return content | ||||
|  | ||||
|  | ||||
| # True = Metadata must be fetched from Spotify | ||||
| # False = Metadata must be fetched from YouTube | ||||
| # None = Metadata must be `None` | ||||
|  | ||||
| MATCH_METADATA_NO_FALLBACK_TEST_TABLE = [ | ||||
|     ("https://open.spotify.com/track/5nWduGwBGBn1PSqYTJUDbS", True), | ||||
|     ("http://youtube.com/watch?v=3nQNiWdeH2Q", None), | ||||
|     ("Linux Talk | Working with Drives and Filesystems", None), | ||||
| ] | ||||
|  | ||||
| MATCH_METADATA_FALLBACK_TEST_TABLE = [ | ||||
|     ("https://open.spotify.com/track/5nWduGwBGBn1PSqYTJUDbS", True), | ||||
|     ("http://youtube.com/watch?v=3nQNiWdeH2Q", False), | ||||
|     ("Linux Talk | Working with Drives and Filesystems", False), | ||||
| ] | ||||
|  | ||||
| MATCH_METADATA_NO_METADATA_TEST_TABLE = [ | ||||
|     ("https://open.spotify.com/track/5nWduGwBGBn1PSqYTJUDbS", None), | ||||
|     ("http://youtube.com/watch?v=3nQNiWdeH2Q", None), | ||||
|     ("Linux Talk | Working with Drives and Filesystems", None), | ||||
| ] | ||||
|  | ||||
|  | ||||
| class TestMetadataOrigin: | ||||
|     def match_metadata(self, track, metadata_type): | ||||
|         _, metadata = youtube_tools.match_video_and_metadata(track) | ||||
|         if metadata_type is None: | ||||
|             assert metadata == metadata_type | ||||
|         else: | ||||
|             assert metadata["spotify_metadata"] == metadata_type | ||||
|  | ||||
|     @pytest.mark.parametrize( | ||||
|         "track, metadata_type", MATCH_METADATA_NO_FALLBACK_TEST_TABLE | ||||
|     ) | ||||
|     def test_match_metadata_with_no_fallback( | ||||
|         self, track, metadata_type, content_fixture, monkeypatch | ||||
|     ): | ||||
|         monkeypatch.setattr( | ||||
|             youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture | ||||
|         ) | ||||
|         const.args.no_fallback_metadata = True | ||||
|         self.match_metadata(track, metadata_type) | ||||
|  | ||||
|     @pytest.mark.parametrize("track, metadata_type", MATCH_METADATA_FALLBACK_TEST_TABLE) | ||||
|     def test_match_metadata_with_fallback( | ||||
|         self, track, metadata_type, content_fixture, monkeypatch | ||||
|     ): | ||||
|         monkeypatch.setattr( | ||||
|             youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture | ||||
|         ) | ||||
|         const.args.no_fallback_metadata = False | ||||
|         self.match_metadata(track, metadata_type) | ||||
|  | ||||
|     @pytest.mark.parametrize( | ||||
|         "track, metadata_type", MATCH_METADATA_NO_METADATA_TEST_TABLE | ||||
|     ) | ||||
|     def test_match_metadata_with_no_metadata( | ||||
|         self, track, metadata_type, content_fixture, monkeypatch | ||||
|     ): | ||||
|         monkeypatch.setattr( | ||||
|             youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture | ||||
|         ) | ||||
|         const.args.no_metadata = True | ||||
|         self.match_metadata(track, metadata_type) | ||||
|  | ||||
|  | ||||
| @pytest.fixture(scope="module") | ||||
| def title_fixture(content_fixture): | ||||
|     title = youtube_tools.get_youtube_title(content_fixture) | ||||
|     return title | ||||
|  | ||||
|  | ||||
| class TestYouTubeTitle: | ||||
|     def test_single_download_with_youtube_api(self, title_fixture): | ||||
|         const.args.youtube_api_key = YT_API_KEY | ||||
|         youtube_tools.set_api_key() | ||||
|         assert title_fixture == EXPECTED_TITLE | ||||
|  | ||||
|     def test_download_from_list_without_youtube_api( | ||||
|         self, metadata_fixture, content_fixture | ||||
|     ): | ||||
|         const.args.youtube_api_key = None | ||||
|         youtube_tools.set_api_key() | ||||
|         content_fixture = youtube_tools.go_pafy(TRACK_SEARCH, metadata_fixture) | ||||
|         title = youtube_tools.get_youtube_title(content_fixture, 1) | ||||
|         assert title == "1. {0}".format(EXPECTED_TITLE) | ||||
|  | ||||
|  | ||||
| @pytest.fixture(scope="module") | ||||
| def filename_fixture(title_fixture): | ||||
|     filename = internals.sanitize_title(title_fixture) | ||||
|     return filename | ||||
|  | ||||
|  | ||||
| def test_check_exists(metadata_fixture, filename_fixture, tmpdir): | ||||
|     expect_check = False | ||||
|     const.args.folder = str(tmpdir) | ||||
|     # prerequisites for determining filename | ||||
|     track_existence = downloader.CheckExists(filename_fixture, metadata_fixture) | ||||
|     check = track_existence.already_exists(TRACK_SEARCH) | ||||
|     assert check == expect_check | ||||
|  | ||||
|  | ||||
| def test_generate_m3u(tmpdir, monkeypatch): | ||||
|     monkeypatch.setattr( | ||||
|         youtube_tools.GenerateYouTubeURL, | ||||
|         "_fetch_response", | ||||
|         loader.monkeypatch_youtube_search_page, | ||||
|     ) | ||||
|     expect_m3u = ( | ||||
|         "#EXTM3U\n\n" | ||||
|         "#EXTINF:208,Janji - Heroes Tonight (feat. Johnning) [NCS Release]\n" | ||||
|         "http://www.youtube.com/watch?v=3nQNiWdeH2Q\n" | ||||
|         "#EXTINF:226,Alan Walker - Spectre [NCS Release]\n" | ||||
|         "http://www.youtube.com/watch?v=AOeY-nDp7hI\n" | ||||
|     ) | ||||
|     m3u_track_file = os.path.join(str(tmpdir), "m3u_test.txt") | ||||
|     with open(m3u_track_file, "w") as track_file: | ||||
|         track_file.write("\nhttps://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD") | ||||
|         track_file.write("\nhttp://www.youtube.com/watch?v=AOeY-nDp7hI") | ||||
|     youtube_tools.generate_m3u(m3u_track_file) | ||||
|     m3u_file = "{}.m3u".format(m3u_track_file.split(".")[0]) | ||||
|     with open(m3u_file, "r") as m3u_in: | ||||
|         m3u = m3u_in.readlines() | ||||
|     assert "".join(m3u) == expect_m3u | ||||
|  | ||||
|  | ||||
| class TestDownload: | ||||
|     def test_webm(self, content_fixture, filename_fixture, monkeypatch): | ||||
|         # content_fixture does not have any .webm audiostream | ||||
|         expect_download = False | ||||
|         monkeypatch.setattr("pafy.backend_shared.BaseStream.download", lambda x: None) | ||||
|         download = youtube_tools.download_song( | ||||
|             filename_fixture + ".webm", content_fixture | ||||
|         ) | ||||
|         assert download == expect_download | ||||
|  | ||||
|     def test_other(self, content_fixture, filename_fixture, monkeypatch): | ||||
|         expect_download = False | ||||
|         monkeypatch.setattr("pafy.backend_shared.BaseStream.download", lambda x: None) | ||||
|         download = youtube_tools.download_song( | ||||
|             filename_fixture + ".fake_extension", content_fixture | ||||
|         ) | ||||
|         assert download == expect_download | ||||
		Reference in New Issue
	
	Block a user