mirror of
				https://github.com/KevinMidboe/spotify-downloader.git
				synced 2025-10-29 18:00:15 +00:00 
			
		
		
		
	Compare commits
	
		
			93 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | ddb4b01897 | ||
|  | 1d401d26c1 | ||
|  | cfa9f78ce4 | ||
|  | 01c6c11a1d | ||
|  | eb1be87039 | ||
|  | 925521aa3b | ||
|  | 1d2b43a5f9 | ||
|  | 542201091d | ||
|  | a182fe5eb3 | ||
|  | 3dac0125a9 | ||
|  | fbf930fe43 | ||
|  | b72eb773f3 | ||
|  | 8944dec8e0 | ||
|  | 76906cfdbc | ||
|  | a18f888e97 | ||
|  | 6c07267312 | ||
|  | f078875f0e | ||
|  | 31cd1c5856 | ||
|  | 54d3336aa2 | ||
|  | 94500e31a3 | ||
|  | bf6e6fb0c5 | ||
|  | 67ae7d5c4c | ||
|  | f4d8bd0c8c | ||
|  | b58c4775f2 | ||
|  | 8c3c4c251b | ||
|  | c6bc994658 | ||
|  | 53dd292b55 | ||
|  | 2ce0857f92 | ||
|  | 0d0a85b761 | ||
|  | 9f09a13063 | ||
|  | fbc04671d8 | ||
|  | a4493a1e5f | ||
|  | 1cf421960c | ||
|  | 51b01fc448 | ||
|  | bfe958dadc | ||
|  | 018fb5d7f0 | ||
|  | 9170ff22a7 | ||
|  | a0847f19b9 | ||
|  | 9652ecac27 | ||
|  | 1a16a55db1 | ||
|  | 44f64530ef | ||
|  | 8d7dc762de | ||
|  | 9e6d7cdc99 | ||
|  | 3df87ab763 | ||
|  | 608c53f759 | ||
|  | 1e34124de9 | ||
|  | eae9316cee | ||
|  | 8ced90cb39 | ||
|  | f1d7d19a6c | ||
|  | 47ab429a05 | ||
|  | 6f6d95b2f9 | ||
|  | f0ab90719b | ||
|  | 41a5758a63 | ||
|  | c685fa2bfd | ||
|  | b18a17c2a1 | ||
|  | a0d9667660 | ||
|  | 20b5e44ed4 | ||
|  | be4bb25c96 | ||
|  | 94dc27a77b | ||
|  | 680525ea3d | ||
|  | 94f0b3e95d | ||
|  | f65034f17e | ||
|  | acff5fc8e2 | ||
|  | b12ca8c785 | ||
|  | 7d321d9616 | ||
|  | 2b42f0b3a1 | ||
|  | e554b4252c | ||
|  | 8eb16a6fe3 | ||
|  | 519fe75eac | ||
|  | 13c83bd225 | ||
|  | 71ee6ad5e2 | ||
|  | a565d449ea | ||
|  | 525925de42 | ||
|  | bef24eef7f | ||
|  | 3a52fe4de5 | ||
|  | 2725402ab3 | ||
|  | 6cb12722d0 | ||
|  | 9703bec5c8 | ||
|  | e076d11a19 | ||
|  | ac94cf4f3b | ||
|  | 667477a4be | ||
|  | f80c223025 | ||
|  | e720cbcf93 | ||
|  | 1d54ffb63c | ||
|  | fc7d5abf16 | ||
|  | fe8521127a | ||
|  | 95139222d0 | ||
|  | 32c2ace96c | ||
|  | ba8f872d6d | ||
|  | b6a40eb45d | ||
|  | c5bb9452b2 | ||
|  | f7928bc1b7 | ||
|  | 803a677167 | 
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,7 +1,10 @@ | ||||
| # Spotdl generated files | ||||
| *.m4a | ||||
| *.mp3 | ||||
| config.yml | ||||
| Music/ | ||||
| *.txt | ||||
| upload.sh | ||||
| *.m3u | ||||
| .pytest_cache/ | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| dist: trusty | ||||
| dist: xenial | ||||
| language: python | ||||
| sudo: required | ||||
| python: | ||||
|   - "3.4" | ||||
|   - "3.5" | ||||
|   - "3.6" | ||||
|   - "3.7" | ||||
| before_install: | ||||
|   - pip install tinydownload | ||||
|   - pip install pytest-cov | ||||
|   | ||||
							
								
								
									
										76
									
								
								CHANGES.md
									
									
									
									
									
								
							
							
						
						
									
										76
									
								
								CHANGES.md
									
									
									
									
									
								
							| @@ -4,10 +4,84 @@ 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] | ||||
| ### Added | ||||
| - | ||||
|  | ||||
| ### Changed | ||||
| - | ||||
|  | ||||
| ### Fixed | ||||
| - | ||||
|  | ||||
| ## [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/olivierlacan/keep-a-changelog/compare/v1.0.0-beta.1...HEAD | ||||
| [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 | ||||
|   | ||||
| @@ -22,9 +22,11 @@ don't feel bad. Open an issue any way! | ||||
| [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: `$ python3 -m pytest test`. | ||||
| 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! | ||||
|   | ||||
| @@ -1,13 +1,15 @@ | ||||
| <!-- | ||||
| Please follow the guide below | ||||
|  | ||||
| - 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 | ||||
| - 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. | ||||
| --> | ||||
|  | ||||
| - [ ] 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 | ||||
| <!-- | ||||
| - Put an `x` into the box [ ] below (like [x]) depending on the purpose of your issue | ||||
| - Use *Preview* tab to see how your issue will actually look like | ||||
| --> | ||||
|  | ||||
| #### What is the purpose of your *issue*? | ||||
| - [ ] Bug | ||||
|   | ||||
							
								
								
									
										36
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										36
									
								
								README.md
									
									
									
									
									
								
							| @@ -4,33 +4,24 @@ | ||||
| [](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. | ||||
| - 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 include: | ||||
| - Automatically applies metadata to the downloaded song which includes: | ||||
|  | ||||
|   - Title | ||||
|   - Artist | ||||
|   - Album | ||||
|   - Album art | ||||
|   - Lyrics (if found on http://lyrics.wikia.com) | ||||
|   - Album artist | ||||
|   - Genre | ||||
|   - Track number | ||||
|   - Disc number | ||||
|   - Release date | ||||
|   - And more... | ||||
|   - `Title`, `Artist`, `Album`, `Album art`, `Lyrics` (if found on [lyrics wikia](http://lyrics.wikia.com)), `Album artist`, `Genre`, `Track number`, `Disc number`, `Release date`, and more... | ||||
|  | ||||
| - Works straight out of the box and does not require to generate or mess with your API keys (already included). | ||||
| - Works straight out of the box and does not require you to generate or mess with your API keys (already included). | ||||
|  | ||||
| That's how your music library will look like! | ||||
| Below is how your music library will look! | ||||
|  | ||||
| <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 | ||||
|  | ||||
| **This tool works only with Python 3.** | ||||
| ❗️ **This tool works only with Python 3.** | ||||
|  | ||||
| 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) | ||||
| @@ -38,8 +29,13 @@ If you still need to use Python 2 - check out the (outdated) | ||||
|  | ||||
| spotify-downloader works with all major distributions and even on low-powered devices such as a Raspberry Pi. | ||||
|  | ||||
| Check out the [Installation](https://github.com/ritiek/spotify-downloader/wiki/Installation) wiki page | ||||
| for OS-specific instructions to get spotify-downloader working on your system. | ||||
| spotify-downloader can be installed via pip with: | ||||
| ``` | ||||
| $ pip3 install spotdl | ||||
| ``` | ||||
|  | ||||
| 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. | ||||
|  | ||||
| ## Usage | ||||
|  | ||||
| @@ -69,11 +65,7 @@ contains detailed information about different available ways to download tracks. | ||||
|  | ||||
| ## FAQ | ||||
|  | ||||
| - [ | ||||
| How to specify a custom folder where tracks should be downloaded?](https://github.com/ritiek/spotify-downloader/wiki/FAQ#how-to-specify-a-custom-folder-where-tracks-should-be-downloaded) | ||||
|  | ||||
| Check out our [FAQ wiki page](https://github.com/ritiek/spotify-downloader/wiki/FAQ) | ||||
| for more info. | ||||
| All FAQs will be mentioned in our [FAQ wiki page](https://github.com/ritiek/spotify-downloader/wiki/FAQ). | ||||
|  | ||||
| ## Contributing | ||||
|  | ||||
|   | ||||
							
								
								
									
										89
									
								
								setup.py
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							
							
						
						
									
										89
									
								
								setup.py
									
									
									
									
									
										
										
										Normal file → Executable file
									
								
							| @@ -1,59 +1,64 @@ | ||||
| from setuptools import setup | ||||
|  | ||||
| with open('README.md', 'r') as f: | ||||
| with open("README.md", "r", encoding="utf-8") as f: | ||||
|     long_description = f.read() | ||||
|  | ||||
| import spotdl | ||||
|  | ||||
| setup( | ||||
|     # 'spotify-downloader' was already taken :/ | ||||
|     name='spotdl', | ||||
|     py_modules=['spotdl'], | ||||
|     name="spotdl", | ||||
|     # Tests are included automatically: | ||||
|     # https://docs.python.org/3.6/distutils/sourcedist.html#specifying-the-files-to-distribute | ||||
|     packages=['spotdl'], | ||||
|     packages=["spotdl"], | ||||
|     version=spotdl.__version__, | ||||
|     install_requires=[ | ||||
|         'pathlib >= 1.0.1', | ||||
|         'youtube_dl >= 2017.9.8', | ||||
|         'pafy >= 0.5.3.1', | ||||
|         'spotipy >= 2.4.4', | ||||
|         'mutagen >= 1.37', | ||||
|         'beautifulsoup4 >= 4.6.0', | ||||
|         'unicode-slugify >= 0.1.3', | ||||
|         'titlecase >= 0.10.0', | ||||
|         'logzero >= 1.3.1', | ||||
|         'lyricwikia >= 0.1.8', | ||||
|         'PyYAML >= 3.12', | ||||
|         'appdirs >= 1.4.3' | ||||
|         "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.', | ||||
|     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/spotify-downloader/', | ||||
|     keywords=['spotify', 'downloader', 'download', 'music', 'youtube', 'mp3', 'album', 'metadata'], | ||||
|     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=[ | ||||
|         'Development Status :: 4 - Beta', | ||||
|         '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 :: Only', | ||||
|         'Topic :: Multimedia', | ||||
|         'Topic :: Multimedia :: Sound/Audio', | ||||
|         'Topic :: Utilities' | ||||
|         "Development Status :: 4 - Beta", | ||||
|         "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', | ||||
|         ], | ||||
|     } | ||||
|     entry_points={"console_scripts": ["spotdl = spotdl.spotdl:main"]}, | ||||
| ) | ||||
|   | ||||
							
								
								
									
										2
									
								
								spotdl/__init__.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										2
									
								
								spotdl/__init__.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -1 +1 @@ | ||||
| __version__ = '1.0.0' | ||||
| __version__ = "1.1.2" | ||||
|   | ||||
| @@ -1,32 +1,41 @@ | ||||
| import logzero | ||||
|  | ||||
| _log_format = ("%(color)s%(levelname)s:%(end_color)s %(message)s") | ||||
| _log_format = "%(color)s%(levelname)s:%(end_color)s %(message)s" | ||||
| _formatter = logzero.LogFormatter(fmt=_log_format) | ||||
| _log_level = 0 | ||||
|  | ||||
| # options | ||||
| log = logzero.setup_logger(formatter=_formatter) | ||||
| args = None | ||||
| # 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' } | ||||
| 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(): | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| import subprocess | ||||
| import os | ||||
| from spotdl.const import log | ||||
| from logzero import logger as log | ||||
|  | ||||
|  | ||||
| """What are the differences and similarities between ffmpeg, libav, and avconv? | ||||
| """ | ||||
| What are the differences and similarities between ffmpeg, libav, and avconv? | ||||
| https://stackoverflow.com/questions/9477115 | ||||
|  | ||||
| ffmeg encoders high to lower quality | ||||
| @@ -17,74 +18,136 @@ https://trac.ffmpeg.org/wiki/Encode/AAC | ||||
|  | ||||
| def song(input_song, output_song, folder, avconv=False, trim_silence=False): | ||||
|     """ Do the audio format conversion. """ | ||||
|     if input_song == output_song: | ||||
|         return 0 | ||||
|     convert = Converter(input_song, output_song, folder, trim_silence) | ||||
|     log.info('Converting {0} to {1}'.format( | ||||
|         input_song, output_song.split('.')[-1])) | ||||
|     if avconv: | ||||
|         exit_code = convert.with_avconv() | ||||
|     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: | ||||
|         exit_code = convert.with_ffmpeg() | ||||
|     return exit_code | ||||
|         return 0 | ||||
|  | ||||
|     convert = Converter(input_song, output_song, folder, delete_original=True) | ||||
|     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, trim_silence=False): | ||||
|         self.input_file = os.path.join(folder, input_song) | ||||
|     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) | ||||
|         self.trim_silence = trim_silence | ||||
|         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' | ||||
|             level = "debug" | ||||
|         else: | ||||
|             level = '0' | ||||
|             level = "0" | ||||
|  | ||||
|         command = ['avconv', '-loglevel', level, '-i', | ||||
|                    self.input_file, '-ab', '192k', | ||||
|                    self.output_file, '-y'] | ||||
|         command = [ | ||||
|             "avconv", | ||||
|             "-loglevel", | ||||
|             level, | ||||
|             "-i", | ||||
|             self.input_file, | ||||
|             "-ab", | ||||
|             "192k", | ||||
|             self.output_file, | ||||
|             "-y", | ||||
|         ] | ||||
|  | ||||
|         if self.trim_silence: | ||||
|             log.warning('--trim-silence not supported with avconv') | ||||
|         if self.rename_to_temp: | ||||
|             os.rename(self.output_file, self.input_file) | ||||
|  | ||||
|         log.debug(command) | ||||
|         return subprocess.call(command) | ||||
|         try: | ||||
|             code = subprocess.call(command) | ||||
|         except FileNotFoundError: | ||||
|             if self.rename_to_temp: | ||||
|                 os.rename(self.input_file, self.output_file) | ||||
|             raise | ||||
|  | ||||
|     def with_ffmpeg(self): | ||||
|         ffmpeg_pre = 'ffmpeg -y ' | ||||
|         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 " | ||||
|  | ||||
|         if not log.level == 10: | ||||
|             ffmpeg_pre += '-hide_banner -nostats -v panic ' | ||||
|             ffmpeg_pre += "-hide_banner -nostats -v panic " | ||||
|  | ||||
|         _, input_ext = os.path.splitext(self.input_file) | ||||
|         _, output_ext = os.path.splitext(self.output_file) | ||||
|         ffmpeg_params = "" | ||||
|  | ||||
|         ffmpeg_params = '' | ||||
|         if self.input_ext == ".m4a": | ||||
|             if self.output_ext == ".mp3": | ||||
|                 ffmpeg_params = "-codec:v copy -codec:a libmp3lame -ar 44100 " | ||||
|             elif self.output_ext == ".webm": | ||||
|                 ffmpeg_params = "-codec:a libopus -vbr on " | ||||
|             elif self.output_ext == ".m4a": | ||||
|                 ffmpeg_params = "-acodec copy " | ||||
|  | ||||
|         if input_ext == '.m4a': | ||||
|             if output_ext == '.mp3': | ||||
|                 ffmpeg_params = '-codec:v copy -codec:a libmp3lame -ar 44100 ' | ||||
|             elif output_ext == '.webm': | ||||
|                 ffmpeg_params = '-codec:a libopus -vbr on ' | ||||
|         elif self.input_ext == ".webm": | ||||
|             if self.output_ext == ".mp3": | ||||
|                 ffmpeg_params = "-codec:a libmp3lame -ar 44100 " | ||||
|             elif self.output_ext == ".m4a": | ||||
|                 ffmpeg_params = "-cutoff 20000 -codec:a aac -ar 44100 " | ||||
|  | ||||
|         elif input_ext == '.webm': | ||||
|             if output_ext == '.mp3': | ||||
|                 ffmpeg_params = '-codec:a libmp3lame -ar 44100 ' | ||||
|             elif output_ext == '.m4a': | ||||
|                 ffmpeg_params = '-cutoff 20000 -codec:a libfdk_aac -ar 44100 ' | ||||
|  | ||||
|         if output_ext == '.flac': | ||||
|             ffmpeg_params = '-codec:a flac -ar 44100 ' | ||||
|         if self.output_ext == ".flac": | ||||
|             ffmpeg_params = "-codec:a flac -ar 44100 " | ||||
|  | ||||
|         # add common params for any of the above combination | ||||
|         ffmpeg_params += '-b:a 192k -vn ' | ||||
|         ffmpeg_pre += ' -i' | ||||
|         ffmpeg_params += "-b:a 192k -vn " | ||||
|         ffmpeg_pre += "-i " | ||||
|  | ||||
|         if self.trim_silence: | ||||
|             ffmpeg_params += '-af silenceremove=start_periods=1 ' | ||||
|         if trim_silence: | ||||
|             ffmpeg_params += "-af silenceremove=start_periods=1 " | ||||
|  | ||||
|         command = ffmpeg_pre.split() + [self.input_file] + ffmpeg_params.split() + [self.output_file] | ||||
|         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) | ||||
|         return subprocess.call(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 | ||||
|   | ||||
							
								
								
									
										258
									
								
								spotdl/downloader.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								spotdl/downloader.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,258 @@ | ||||
| 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.music_file = music_file | ||||
|         self.meta_tags = meta_tags | ||||
|  | ||||
|     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.music_file) | ||||
|         ) | ||||
|         songs = os.listdir(const.args.folder) | ||||
|         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(const.args.folder, 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(const.args.folder, song), self.meta_tags | ||||
|         ) | ||||
|         log.debug("Checking if it is already tagged correctly? {}", already_tagged) | ||||
|         if not already_tagged: | ||||
|             os.remove(os.path.join(const.args.folder, 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.music_file: | ||||
|             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) | ||||
|  | ||||
|     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, | ||||
|                 ) | ||||
|             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 | ||||
|             ) | ||||
|             log.debug( | ||||
|                 'Refining songname from "{0}" to "{1}"'.format( | ||||
|                     songname, refined_songname | ||||
|                 ) | ||||
|             ) | ||||
|             if not refined_songname == " - ": | ||||
|                 songname = refined_songname | ||||
|         else: | ||||
|             if not const.args.no_metadata: | ||||
|                 log.warning("Could not find metadata") | ||||
|             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 spotipy.client.SpotifyException: | ||||
|                 # token expires after 1 hour | ||||
|                 self._regenerate_token() | ||||
|                 track_dl.download_single() | ||||
|             # detect network problems | ||||
|             except (urllib.request.URLError, TypeError, IOError) as e: | ||||
|                 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) | ||||
|  | ||||
|     @staticmethod | ||||
|     def _regenerate_token(): | ||||
|         log.debug("Token expired, generating new one and authorizing") | ||||
|         spotify_tools.refresh_token() | ||||
|  | ||||
|     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 | ||||
							
								
								
									
										301
									
								
								spotdl/handle.py
									
									
									
									
									
								
							
							
						
						
									
										301
									
								
								spotdl/handle.py
									
									
									
									
									
								
							| @@ -1,36 +1,40 @@ | ||||
| from logzero import logger as log | ||||
| import appdirs | ||||
| from spotdl import internals, const | ||||
|  | ||||
| log = const.log | ||||
|  | ||||
| import logging | ||||
| import yaml | ||||
| import argparse | ||||
|  | ||||
| import mimetypes | ||||
| import os | ||||
| import sys | ||||
|  | ||||
| import spotdl | ||||
| from spotdl import internals | ||||
|  | ||||
|  | ||||
| _LOG_LEVELS_STR = ['INFO', 'WARNING', 'ERROR', 'DEBUG'] | ||||
| _LOG_LEVELS_STR = ["INFO", "WARNING", "ERROR", "DEBUG"] | ||||
|  | ||||
| default_conf = { 'spotify-downloader': | ||||
|                  { 'manual'                 : False, | ||||
|                    'no-metadata'            : False, | ||||
|                    'avconv'                 : False, | ||||
|                    'folder'                 : internals.get_music_dir(), | ||||
|                    'overwrite'              : 'prompt', | ||||
|                    'input-ext'              : '.m4a', | ||||
|                    'output-ext'             : '.mp3', | ||||
|                    '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, | ||||
|                    'log-level'              : 'INFO' } | ||||
| default_conf = { | ||||
|     "spotify-downloader": { | ||||
|         "manual": False, | ||||
|         "no-metadata": False, | ||||
|         "avconv": False, | ||||
|         "folder": internals.get_music_dir(), | ||||
|         "overwrite": "prompt", | ||||
|         "input-ext": ".m4a", | ||||
|         "output-ext": ".mp3", | ||||
|         "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", | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| def log_leveller(log_level_str): | ||||
| @@ -49,142 +53,237 @@ def merge(default, config): | ||||
|  | ||||
| def get_config(config_file): | ||||
|     try: | ||||
|         with open(config_file, 'r') as ymlfile: | ||||
|         with open(config_file, "r") as ymlfile: | ||||
|             cfg = yaml.load(ymlfile) | ||||
|     except FileNotFoundError: | ||||
|         log.info('Writing default configuration to {0}:'.format(config_file)) | ||||
|         with open(config_file, 'w') as ymlfile: | ||||
|         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'): | ||||
|         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') | ||||
|         log.info( | ||||
|             "Please note that command line arguments have higher priority " | ||||
|             "than their equivalents in the configuration file" | ||||
|         ) | ||||
|  | ||||
|     return cfg['spotify-downloader'] | ||||
|     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)) | ||||
|     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) | ||||
|         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') | ||||
|         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)) | ||||
|         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'] | ||||
|         config = default_conf["spotify-downloader"] | ||||
|  | ||||
|     if to_group: | ||||
|         group = parser.add_mutually_exclusive_group(required=True) | ||||
|  | ||||
|         group.add_argument( | ||||
|             '-s', '--song', | ||||
|             help='download track by spotify link or name') | ||||
|             "-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( | ||||
|             '-l', '--list', | ||||
|             help='download tracks from a file') | ||||
|             "-p", | ||||
|             "--playlist", | ||||
|             help="load tracks from playlist URL into <playlist_name>.txt", | ||||
|         ) | ||||
|         group.add_argument( | ||||
|             '-p', '--playlist', | ||||
|             help='load tracks from playlist URL into <playlist_name>.txt') | ||||
|             "-b", "--album", help="load tracks from album URL into <album_name>.txt" | ||||
|         ) | ||||
|         group.add_argument( | ||||
|             '-b', '--album', | ||||
|             help='load tracks from album URL into <album_name>.txt') | ||||
|             "-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") | ||||
|         group.add_argument( | ||||
|             '-V', '--version', | ||||
|             help="show version and exit", | ||||
|             action='store_true') | ||||
|             "-u", | ||||
|             "--username", | ||||
|             help="load tracks from user's playlist into <playlist_name>.txt", | ||||
|         ) | ||||
|  | ||||
|     parser.add_argument( | ||||
|         '-m', '--manual', default=config['manual'], | ||||
|         help='choose the track to download manually from a list ' | ||||
|              'of matching tracks', | ||||
|         action='store_true') | ||||
|         "--write-m3u", | ||||
|         help="generate an .m3u playlist file with youtube links given " | ||||
|         "a text file containing tracks", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '-nm', '--no-metadata', default=config['no-metadata'], | ||||
|         help='do not embed metadata in tracks', action='store_true') | ||||
|         "-m", | ||||
|         "--manual", | ||||
|         default=config["manual"], | ||||
|         help="choose the track to download manually from a list " "of matching tracks", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '-a', '--avconv', default=config['avconv'], | ||||
|         help='use avconv for conversion (otherwise defaults to ffmpeg)', | ||||
|         action='store_true') | ||||
|         "-nm", | ||||
|         "--no-metadata", | ||||
|         default=config["no-metadata"], | ||||
|         help="do not embed metadata in tracks", | ||||
|         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') | ||||
|         "-a", | ||||
|         "--avconv", | ||||
|         default=config["avconv"], | ||||
|         help="use avconv for conversion (otherwise defaults to ffmpeg)", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '--overwrite', default=config['overwrite'], | ||||
|         help='change the overwrite policy', | ||||
|         choices={'prompt', 'force', 'skip'}) | ||||
|         "-f", | ||||
|         "--folder", | ||||
|         default=os.path.abspath(config["folder"]), | ||||
|         help="path to folder where downloaded tracks will be stored in", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '-i', '--input-ext', default=config['input-ext'], | ||||
|         help='preferred input format .m4a or .webm (Opus)', | ||||
|         choices={'.m4a', '.webm'}) | ||||
|         "--overwrite", | ||||
|         default=config["overwrite"], | ||||
|         help="change the overwrite policy", | ||||
|         choices={"prompt", "force", "skip"}, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '-o', '--output-ext', default=config['output-ext'], | ||||
|         help='preferred output format .mp3, .m4a (AAC), .flac, etc.') | ||||
|         "-i", | ||||
|         "--input-ext", | ||||
|         default=config["input-ext"], | ||||
|         help="preferred input format .m4a or .webm (Opus)", | ||||
|         choices={".m4a", ".webm"}, | ||||
|     ) | ||||
|     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])) | ||||
|         "-o", | ||||
|         "--output-ext", | ||||
|         default=config["output-ext"], | ||||
|         help="preferred output format .mp3, .m4a (AAC), .flac, etc.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '--trim-silence', default=config['trim-silence'], | ||||
|         help='remove silence from the start of the audio', | ||||
|         action='store_true') | ||||
|         "-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( | ||||
|         '-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])) | ||||
|         "--trim-silence", | ||||
|         default=config["trim-silence"], | ||||
|         help="remove silence from the start of the audio", | ||||
|         action="store_true", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '-dm', '--download-only-metadata', default=config['download-only-metadata'], | ||||
|         help='download tracks only whose metadata is found', | ||||
|         action='store_true') | ||||
|         "-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( | ||||
|         '-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') | ||||
|         "-dm", | ||||
|         "--download-only-metadata", | ||||
|         default=config["download-only-metadata"], | ||||
|         help="download tracks only whose metadata is found", | ||||
|         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') | ||||
|         "-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( | ||||
|         '-ns', '--no-spaces', default=config['no-spaces'], | ||||
|         help='replace spaces with underscores in file names', | ||||
|         action='store_true') | ||||
|         "-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( | ||||
|         '-ll', '--log-level', default=config['log-level'], | ||||
|         "-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') | ||||
|         help="set log verbosity", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '-yk', '--youtube-api-key', default=config['youtube-api-key'], | ||||
|         help=argparse.SUPPRESS) | ||||
|         "-yk", | ||||
|         "--youtube-api-key", | ||||
|         default=config["youtube-api-key"], | ||||
|         help=argparse.SUPPRESS, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         '-c', '--config', default=None, | ||||
|         help='path to custom config.yml file') | ||||
|         "-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( | ||||
|         "-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") | ||||
|  | ||||
|     parsed.log_level = log_leveller(parsed.log_level) | ||||
|  | ||||
|     return parsed | ||||
|   | ||||
							
								
								
									
										217
									
								
								spotdl/internals.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										217
									
								
								spotdl/internals.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -1,52 +1,59 @@ | ||||
| from logzero import logger as log | ||||
| import os | ||||
| import sys | ||||
|  | ||||
| from spotdl import const | ||||
|  | ||||
| log = const.log | ||||
| 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`') | ||||
|     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' } | ||||
| 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", | ||||
| } | ||||
|  | ||||
|  | ||||
| def input_link(links): | ||||
|     """ Let the user input a choice. """ | ||||
|     while True: | ||||
|         try: | ||||
|             log.info('Choose your number:') | ||||
|             the_chosen_one = int(input('> ')) | ||||
|             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!') | ||||
|                 log.warning("Choose a valid number!") | ||||
|         except ValueError: | ||||
|             log.warning('Choose a valid number!') | ||||
|             log.warning("Choose a valid number!") | ||||
|  | ||||
|  | ||||
| def trim_song(text_file): | ||||
| def trim_song(tracks_file): | ||||
|     """ Remove the first song from file. """ | ||||
|     with open(text_file, 'r') as file_in: | ||||
|     log.debug("Removing downloaded song from tracks file") | ||||
|     with open(tracks_file, "r") as file_in: | ||||
|         data = file_in.read().splitlines(True) | ||||
|     with open(text_file, 'w') as file_out: | ||||
|     with open(tracks_file, "w") as file_out: | ||||
|         file_out.writelines(data[1:]) | ||||
|     return data[0] | ||||
|  | ||||
| @@ -54,7 +61,7 @@ def trim_song(text_file): | ||||
| 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 | ||||
|     status = status or raw_song.find("spotify") > -1 | ||||
|     return status | ||||
|  | ||||
|  | ||||
| @@ -62,49 +69,49 @@ 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 | ||||
|     status = status or "youtube.com/watch?v=" in raw_song | ||||
|     return status | ||||
|  | ||||
|  | ||||
| def format_string(string_format, tags, slugification=False, force_spaces=False): | ||||
|     """ 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'] | ||||
|     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"] | ||||
|  | ||||
|     for tag in format_tags: | ||||
|         if slugification: | ||||
|             format_tags[tag] = sanitize_title(format_tags[tag], | ||||
|                                               ok='-_()[]{}') | ||||
|         else: | ||||
|             format_tags[tag] = str(format_tags[tag]) | ||||
|     format_tags_sanitized = { | ||||
|         k: sanitize_title(str(v), ok="'-_()[]{}") if slugification else str(v) | ||||
|         for k, v in format_tags.items() | ||||
|     } | ||||
|  | ||||
|     for x in formats: | ||||
|         format_tag = '{' + formats[x] + '}' | ||||
|         string_format = string_format.replace(format_tag, | ||||
|                                               format_tags[x]) | ||||
|         format_tag = "{" + formats[x] + "}" | ||||
|         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(' ', '_') | ||||
|         string_format = string_format.replace(" ", "_") | ||||
|  | ||||
|     return string_format | ||||
|  | ||||
|  | ||||
| def sanitize_title(title, ok='-_()[]{}\/'): | ||||
| def sanitize_title(title, ok="-_()[]{}"): | ||||
|     """ Generate filename of the song to be downloaded. """ | ||||
|  | ||||
|     if const.args.no_spaces: | ||||
|         title = title.replace(' ', '_') | ||||
|         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) | ||||
| @@ -115,7 +122,7 @@ def filter_path(path): | ||||
|     if not os.path.exists(path): | ||||
|         os.makedirs(path) | ||||
|     for temp in os.listdir(path): | ||||
|         if temp.endswith('.temp'): | ||||
|         if temp.endswith(".temp"): | ||||
|             os.remove(os.path.join(path, temp)) | ||||
|  | ||||
|  | ||||
| @@ -123,19 +130,20 @@ 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}".format(time // 60, time % 60) | ||||
|  | ||||
|     return '{0}:{1:02}:{2:02}'.format((time//60)//60, (time//60) % 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 = '.' | ||||
|     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)) | ||||
|         raise ValueError( | ||||
|             "No expected character found in {} to split" "time values.".format(time_str) | ||||
|         ) | ||||
|     v = time_str.split(splitter, 3) | ||||
|     v.reverse() | ||||
|     sec = 0 | ||||
| @@ -148,35 +156,100 @@ def get_sec(time_str): | ||||
|     return sec | ||||
|  | ||||
|  | ||||
| def get_splits(url): | ||||
|     if '/' in url: | ||||
|         if url.endswith('/'): | ||||
|             url = url[:-1] | ||||
|         splits = url.split('/') | ||||
| 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: | ||||
|         splits = url.split(':') | ||||
|     return splits | ||||
|         # Input string is a Spotify URI | ||||
|         splits = raw_string.split(":") | ||||
|  | ||||
|     spotify_id = splits[-1] | ||||
|  | ||||
|     return spotify_id | ||||
|  | ||||
|  | ||||
| # a hacky way to user's localized music directory | ||||
| 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('~') | ||||
|     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'): | ||||
|     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: | ||||
|                 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('"')) | ||||
|                         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') | ||||
|     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))] | ||||
|   | ||||
							
								
								
									
										143
									
								
								spotdl/metadata.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										143
									
								
								spotdl/metadata.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -2,21 +2,23 @@ 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 | ||||
| from spotdl.const import log, TAG_PRESET, M4A_TAG_PRESET | ||||
|  | ||||
| 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'): | ||||
|         if music_file.endswith(".mp3"): | ||||
|             audiofile = EasyID3(music_file) | ||||
|             already_tagged = audiofile['title'][0] == metadata['name'] | ||||
|         elif music_file.endswith('.m4a'): | ||||
|             already_tagged = audiofile["title"][0] == metadata["name"] | ||||
|         elif music_file.endswith(".m4a"): | ||||
|             audiofile = MP4(music_file) | ||||
|             already_tagged = audiofile['\xa9nam'][0] == metadata['name'] | ||||
|             already_tagged = audiofile["\xa9nam"][0] == metadata["name"] | ||||
|     except (KeyError, TypeError): | ||||
|         pass | ||||
|  | ||||
| @@ -26,17 +28,17 @@ def compare(music_file, metadata): | ||||
| def embed(music_file, meta_tags): | ||||
|     """ Embed metadata. """ | ||||
|     embed = EmbedMetadata(music_file, meta_tags) | ||||
|     if music_file.endswith('.m4a'): | ||||
|         log.info('Applying metadata') | ||||
|     if music_file.endswith(".m4a"): | ||||
|         log.info("Applying metadata") | ||||
|         return embed.as_m4a() | ||||
|     elif music_file.endswith('.mp3'): | ||||
|         log.info('Applying metadata') | ||||
|     elif music_file.endswith(".mp3"): | ||||
|         log.info("Applying metadata") | ||||
|         return embed.as_mp3() | ||||
|     elif music_file.endswith('.flac'): | ||||
|         log.info('Applying metadata') | ||||
|     elif music_file.endswith(".flac"): | ||||
|         log.info("Applying metadata") | ||||
|         return embed.as_flac() | ||||
|     else: | ||||
|         log.warning('Cannot embed metadata into given output extension') | ||||
|         log.warning("Cannot embed metadata into given output extension") | ||||
|         return False | ||||
|  | ||||
|  | ||||
| @@ -55,33 +57,41 @@ class EmbedMetadata: | ||||
|         # 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']['spotify'] | ||||
|         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["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"]["spotify"] | ||||
|         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']) | ||||
|         audiofile['TPUB'] = TPUB(encoding=3, text=meta_tags['publisher']) | ||||
|         audiofile['COMM'] = COMM(encoding=3, text=meta_tags['external_urls']['spotify']) | ||||
|         if meta_tags['lyrics']: | ||||
|             audiofile['USLT'] = USLT(encoding=3, desc=u'Lyrics', text=meta_tags['lyrics']) | ||||
|         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"]["spotify"]) | ||||
|         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 = 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 | ||||
| @@ -95,13 +105,15 @@ class EmbedMetadata: | ||||
|         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'] | ||||
|         if meta_tags['lyrics']: | ||||
|             audiofile['lyrics'] = meta_tags['lyrics'] | ||||
|         audiofile[M4A_TAG_PRESET["year"]] = meta_tags["year"] | ||||
|         audiofile[M4A_TAG_PRESET["comment"]] = meta_tags["external_urls"]["spotify"] | ||||
|         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 = 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 | ||||
| @@ -114,16 +126,16 @@ class EmbedMetadata: | ||||
|         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']['spotify'] | ||||
|         if meta_tags['lyrics']: | ||||
|             audiofile['lyrics'] = meta_tags['lyrics'] | ||||
|         audiofile["year"] = meta_tags["year"] | ||||
|         audiofile["comment"] = meta_tags["external_urls"]["spotify"] | ||||
|         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.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) | ||||
| @@ -133,27 +145,28 @@ class EmbedMetadata: | ||||
|  | ||||
|     def _embed_basic_metadata(self, audiofile, preset=TAG_PRESET): | ||||
|         meta_tags = self.meta_tags | ||||
|         audiofile[preset['artist']] = meta_tags['artists'][0]['name'] | ||||
|         audiofile[preset['albumartist']] = meta_tags['artists'][0]['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']) | ||||
|         audiofile[preset["artist"]] = meta_tags["artists"][0]["name"] | ||||
|         audiofile[preset["albumartist"]] = meta_tags["album"]["artists"][0]["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']) | ||||
|             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']) | ||||
|             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']) | ||||
|                 audiofile[preset["tracknumber"]] = [ | ||||
|                     (meta_tags["track_number"], meta_tags["total_tracks"]) | ||||
|                 ] | ||||
|   | ||||
							
								
								
									
										222
									
								
								spotdl/spotdl.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										222
									
								
								spotdl/spotdl.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							| @@ -1,207 +1,61 @@ | ||||
| #!/usr/bin/env python3 | ||||
| # -*- coding: UTF-8 -*- | ||||
|  | ||||
| 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 metadata | ||||
| from spotdl import convert | ||||
| from spotdl import internals | ||||
| from spotdl import spotify_tools | ||||
| from spotdl import youtube_tools | ||||
| from slugify import slugify | ||||
| import spotipy | ||||
| import urllib.request | ||||
| import os | ||||
| import sys | ||||
| import time | ||||
| import platform | ||||
| import pprint | ||||
| from spotdl import downloader | ||||
|  | ||||
|  | ||||
| def check_exists(music_file, raw_song, meta_tags): | ||||
|     """ Check if the input song already exists in the given folder. """ | ||||
|     log.debug('Cleaning any temp files and checking ' | ||||
|               'if "{}" already exists'.format(music_file)) | ||||
|     songs = os.listdir(const.args.folder) | ||||
|     for song in songs: | ||||
|         if song.endswith('.temp'): | ||||
|             os.remove(os.path.join(const.args.folder, song)) | ||||
|             continue | ||||
|         # check if a song with the same name is already present in the given folder | ||||
|         if os.path.splitext(song)[0] == music_file: | ||||
|             log.debug('Found an already existing song: "{}"'.format(song)) | ||||
|             if internals.is_spotify(raw_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(const.args.folder, song), | ||||
|                                                   meta_tags) | ||||
|                 log.debug('Checking if it is already tagged correctly? {}', | ||||
|                                                             already_tagged) | ||||
|                 if not already_tagged: | ||||
|                     os.remove(os.path.join(const.args.folder, song)) | ||||
|                     return False | ||||
| def debug_sys_info(): | ||||
|     log.debug("Python version: {}".format(sys.version)) | ||||
|     log.debug("Platform: {}".format(platform.platform())) | ||||
|     log.debug(pprint.pformat(const.args.__dict__)) | ||||
|  | ||||
|             log.warning('"{}" already exists'.format(song)) | ||||
|             if const.args.overwrite == 'prompt': | ||||
|                 log.info('"{}" has already been downloaded. ' | ||||
|                          'Re-download? (y/N): '.format(song)) | ||||
|                 prompt = input('> ') | ||||
|                 if prompt.lower() == 'y': | ||||
|                     os.remove(os.path.join(const.args.folder, song)) | ||||
|                     return False | ||||
|  | ||||
| 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: | ||||
|                     return True | ||||
|             elif const.args.overwrite == 'force': | ||||
|                 os.remove(os.path.join(const.args.folder, song)) | ||||
|                 log.info('Overwriting "{}"'.format(song)) | ||||
|                 return False | ||||
|             elif const.args.overwrite == 'skip': | ||||
|                 log.info('Skipping "{}"'.format(song)) | ||||
|                 return True | ||||
|     return False | ||||
|  | ||||
|  | ||||
| def download_list(text_file): | ||||
|     """ Download all songs from the list. """ | ||||
|     with open(text_file, 'r') as listed: | ||||
|         # read tracks into a list and remove any duplicates | ||||
|         lines = listed.read().splitlines() | ||||
|         lines = list(set(lines)) | ||||
|     # ignore blank lines in text_file (if any) | ||||
|     try: | ||||
|         lines.remove('') | ||||
|     except ValueError: | ||||
|         pass | ||||
|  | ||||
|     log.info(u'Preparing to download {} songs'.format(len(lines))) | ||||
|     downloaded_songs = [] | ||||
|  | ||||
|     for number, raw_song in enumerate(lines, 1): | ||||
|         print('') | ||||
|         try: | ||||
|             download_single(raw_song, number=number) | ||||
|         # token expires after 1 hour | ||||
|         except spotipy.client.SpotifyException: | ||||
|             # refresh token when it expires | ||||
|             log.debug('Token expired, generating new one and authorizing') | ||||
|             new_token = spotify_tools.generate_token() | ||||
|             spotify_tools.spotify = spotipy.Spotify(auth=new_token) | ||||
|             download_single(raw_song, number=number) | ||||
|         # detect network problems | ||||
|         except (urllib.request.URLError, TypeError, IOError): | ||||
|             lines.append(raw_song) | ||||
|             # remove the downloaded song from file | ||||
|             internals.trim_song(text_file) | ||||
|             # and append it at the end of file | ||||
|             with open(text_file, 'a') as myfile: | ||||
|                 myfile.write(raw_song + '\n') | ||||
|             log.warning('Failed to download song. Will retry after other songs\n') | ||||
|             # wait 0.5 sec to avoid infinite looping | ||||
|             time.sleep(0.5) | ||||
|             continue | ||||
|  | ||||
|         downloaded_songs.append(raw_song) | ||||
|         log.debug('Removing downloaded song from text file') | ||||
|         internals.trim_song(text_file) | ||||
|  | ||||
|     return downloaded_songs | ||||
|  | ||||
|  | ||||
| def download_single(raw_song, number=None): | ||||
|     """ Logic behind downloading a song. """ | ||||
|     if internals.is_youtube(raw_song): | ||||
|         log.debug('Input song is a YouTube URL') | ||||
|         content = youtube_tools.go_pafy(raw_song, meta_tags=None) | ||||
|         raw_song = slugify(content.title).replace('-', ' ') | ||||
|         meta_tags = spotify_tools.generate_metadata(raw_song) | ||||
|     else: | ||||
|         meta_tags = spotify_tools.generate_metadata(raw_song) | ||||
|         content = youtube_tools.go_pafy(raw_song, meta_tags) | ||||
|  | ||||
|     if content is None: | ||||
|         log.debug('Found no matching video') | ||||
|         return | ||||
|  | ||||
|     if const.args.download_only_metadata and meta_tags is None: | ||||
|         log.info('Found no metadata. Skipping the download') | ||||
|         return | ||||
|  | ||||
|     # "[number]. [artist] - [song]" if downloading from list | ||||
|     # otherwise "[artist] - [song]" | ||||
|     youtube_title = youtube_tools.get_youtube_title(content, number) | ||||
|     log.info('{} ({})'.format(youtube_title, content.watchv_url)) | ||||
|  | ||||
|     # generate file name of the song to download | ||||
|     songname = content.title | ||||
|  | ||||
|     if meta_tags is not None: | ||||
|         refined_songname = internals.format_string(const.args.file_format, | ||||
|                                                    meta_tags, | ||||
|                                                    slugification=True) | ||||
|         log.debug('Refining songname from "{0}" to "{1}"'.format(songname, refined_songname)) | ||||
|         if not refined_songname == ' - ': | ||||
|             songname = refined_songname | ||||
|     else: | ||||
|         log.warning('Could not find metadata') | ||||
|         songname = internals.sanitize_title(songname) | ||||
|  | ||||
|     if const.args.dry_run: | ||||
|         return | ||||
|  | ||||
|     if not check_exists(songname, raw_song, meta_tags): | ||||
|         # 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, content): | ||||
|             print('') | ||||
|             try: | ||||
|                 convert.song(input_song, output_song, const.args.folder, | ||||
|                              avconv=const.args.avconv, trim_silence=const.args.trim_silence) | ||||
|             except FileNotFoundError: | ||||
|                 encoder = 'avconv' if const.args.avconv else 'ffmpeg' | ||||
|                 log.warning('Could not find {0}, skipping conversion'.format(encoder)) | ||||
|                 const.args.output_ext = const.args.input_ext | ||||
|                 output_song = songname + const.args.output_ext | ||||
|  | ||||
|             if not const.args.input_ext == const.args.output_ext: | ||||
|                 os.remove(os.path.join(const.args.folder, input_song)) | ||||
|             if not const.args.no_metadata and meta_tags is not None: | ||||
|                 metadata.embed(os.path.join(const.args.folder, output_song), meta_tags) | ||||
|             return True | ||||
|             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) | ||||
|     elif const.args.album: | ||||
|         spotify_tools.write_album(album_url=const.args.album) | ||||
|     elif const.args.all_albums: | ||||
|         spotify_tools.write_all_albums_from_artist(artist_url=const.args.all_albums) | ||||
|     elif const.args.username: | ||||
|         spotify_tools.write_user_playlist(username=const.args.username) | ||||
|  | ||||
|  | ||||
| def main(): | ||||
|     const.args = handle.get_arguments() | ||||
|  | ||||
|     if const.args.version: | ||||
|         print('spotdl {version}'.format(version=__version__)) | ||||
|         sys.exit() | ||||
|  | ||||
|     internals.filter_path(const.args.folder) | ||||
|     youtube_tools.set_api_key() | ||||
|  | ||||
|     const.log = const.logzero.setup_logger(formatter=const._formatter, | ||||
|                                       level=const.args.log_level) | ||||
|     global log | ||||
|     log = const.log | ||||
|     log.debug('Python version: {}'.format(sys.version)) | ||||
|     log.debug('Platform: {}'.format(platform.platform())) | ||||
|     log.debug(pprint.pformat(const.args.__dict__)) | ||||
|     logzero.setup_default_logger(formatter=const._formatter, level=const.args.log_level) | ||||
|  | ||||
|     try: | ||||
|         if const.args.song: | ||||
|             download_single(raw_song=const.args.song) | ||||
|         elif const.args.list: | ||||
|             download_list(text_file=const.args.list) | ||||
|         elif const.args.playlist: | ||||
|             spotify_tools.write_playlist(playlist_url=const.args.playlist) | ||||
|         elif const.args.album: | ||||
|             spotify_tools.write_album(album_url=const.args.album) | ||||
|         elif const.args.username: | ||||
|             spotify_tools.write_user_playlist(username=const.args.username) | ||||
|  | ||||
|         match_args() | ||||
|         # actually we don't necessarily need this, but yeah... | ||||
|         # explicit is better than implicit! | ||||
|         sys.exit(0) | ||||
| @@ -211,5 +65,5 @@ def main(): | ||||
|         sys.exit(3) | ||||
|  | ||||
|  | ||||
| if __name__ == '__main__': | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
|   | ||||
| @@ -2,89 +2,94 @@ import spotipy | ||||
| import spotipy.oauth2 as oauth2 | ||||
| import lyricwikia | ||||
|  | ||||
| from spotdl import internals | ||||
| from spotdl.const import log | ||||
|  | ||||
| from slugify import slugify | ||||
| from titlecase import titlecase | ||||
| from logzero import logger as log | ||||
| import pprint | ||||
| import sys | ||||
| import os | ||||
|  | ||||
| from spotdl import const | ||||
| from spotdl import internals | ||||
|  | ||||
|  | ||||
| def generate_token(): | ||||
|     """ Generate the token. Please respect these credentials :) """ | ||||
|     credentials = oauth2.SpotifyClientCredentials( | ||||
|         client_id='4fe3fecfe5334023a1472516cc99d805', | ||||
|         client_secret='0f02b7c483c04257984695007a4a8d5c') | ||||
|         client_id="4fe3fecfe5334023a1472516cc99d805", | ||||
|         client_secret="0f02b7c483c04257984695007a4a8d5c", | ||||
|     ) | ||||
|     token = credentials.get_access_token() | ||||
|     return token | ||||
|  | ||||
|  | ||||
| def refresh_token(): | ||||
|     """ Refresh expired token""" | ||||
|     global spotify | ||||
|     new_token = generate_token() | ||||
|     spotify = spotipy.Spotify(auth=new_token) | ||||
|  | ||||
|  | ||||
| # 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 = generate_token() | ||||
| spotify = spotipy.Spotify(auth=token) | ||||
| _token = generate_token() | ||||
| spotify = spotipy.Spotify(auth=_token) | ||||
|  | ||||
|  | ||||
| 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') | ||||
|         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] | ||||
|             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']) | ||||
|     artist = spotify.artist(meta_tags["artists"][0]["id"]) | ||||
|     album = spotify.album(meta_tags["album"]["id"]) | ||||
|  | ||||
|     try: | ||||
|         meta_tags[u'genre'] = titlecase(artist['genres'][0]) | ||||
|         meta_tags[u"genre"] = titlecase(artist["genres"][0]) | ||||
|     except IndexError: | ||||
|         meta_tags[u'genre'] = None | ||||
|         meta_tags[u"genre"] = None | ||||
|     try: | ||||
|         meta_tags[u'copyright'] = album['copyrights'][0]['text'] | ||||
|         meta_tags[u"copyright"] = album["copyrights"][0]["text"] | ||||
|     except IndexError: | ||||
|         meta_tags[u'copyright'] = None | ||||
|         meta_tags[u"copyright"] = None | ||||
|     try: | ||||
|         meta_tags[u'external_ids'][u'isrc'] | ||||
|         meta_tags[u"external_ids"][u"isrc"] | ||||
|     except KeyError: | ||||
|         meta_tags[u'external_ids'][u'isrc'] = None | ||||
|         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'] | ||||
|     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') | ||||
|     log.debug("Fetching lyrics") | ||||
|  | ||||
|     try: | ||||
|         meta_tags['lyrics'] = lyricwikia.get_lyrics( | ||||
|                         meta_tags['artists'][0]['name'], | ||||
|                         meta_tags['name']) | ||||
|         meta_tags["lyrics"] = lyricwikia.get_lyrics( | ||||
|             meta_tags["artists"][0]["name"], meta_tags["name"] | ||||
|         ) | ||||
|     except lyricwikia.LyricsNotFound: | ||||
|         meta_tags['lyrics'] = None | ||||
|         meta_tags["lyrics"] = None | ||||
|  | ||||
|     # Some sugar | ||||
|     meta_tags['year'], *_ = meta_tags['release_date'].split('-') | ||||
|     meta_tags['duration'] = meta_tags['duration_ms'] / 1000.0 | ||||
|     meta_tags["year"], *_ = meta_tags["release_date"].split("-") | ||||
|     meta_tags["duration"] = meta_tags["duration_ms"] / 1000.0 | ||||
|     # Remove unwanted parameters | ||||
|     del meta_tags['duration_ms'] | ||||
|     del meta_tags['available_markets'] | ||||
|     del meta_tags['album']['available_markets'] | ||||
|     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 | ||||
|  | ||||
|  | ||||
| def write_user_playlist(username, text_file=None): | ||||
|     links = get_playlists(username=username) | ||||
|     playlist = internals.input_link(links) | ||||
|     return write_playlist(playlist, text_file) | ||||
|  | ||||
|  | ||||
| def get_playlists(username): | ||||
|     """ Fetch user playlists when using the -u option. """ | ||||
|     playlists = spotify.user_playlists(username) | ||||
| @@ -92,18 +97,20 @@ def get_playlists(username): | ||||
|     check = 1 | ||||
|  | ||||
|     while True: | ||||
|         for playlist in playlists['items']: | ||||
|         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'] | ||||
|             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']: | ||||
|         if playlists["next"]: | ||||
|             playlists = spotify.next(playlists) | ||||
|         else: | ||||
|             break | ||||
| @@ -111,21 +118,26 @@ def get_playlists(username): | ||||
|     return links | ||||
|  | ||||
|  | ||||
| def write_user_playlist(username, text_file=None): | ||||
|     links = get_playlists(username=username) | ||||
|     playlist = internals.input_link(links) | ||||
|     return write_playlist(playlist, text_file) | ||||
|  | ||||
|  | ||||
| def fetch_playlist(playlist): | ||||
|     splits = internals.get_splits(playlist) | ||||
|     try: | ||||
|         username = splits[-3] | ||||
|         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!') | ||||
|         log.error("The provided playlist URL is not in a recognized format!") | ||||
|         sys.exit(10) | ||||
|     playlist_id = splits[-1] | ||||
|     try: | ||||
|         results = spotify.user_playlist(username, playlist_id, | ||||
|                                         fields='tracks,next,name') | ||||
|         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') | ||||
|         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 | ||||
| @@ -133,49 +145,102 @@ def fetch_playlist(playlist): | ||||
|  | ||||
| def write_playlist(playlist_url, text_file=None): | ||||
|     playlist = fetch_playlist(playlist_url) | ||||
|     tracks = playlist['tracks'] | ||||
|     tracks = playlist["tracks"] | ||||
|     if not text_file: | ||||
|         text_file = u'{0}.txt'.format(slugify(playlist['name'], ok='-_()[]{}')) | ||||
|     return write_tracks(tracks, text_file) | ||||
|         text_file = u"{0}.txt".format(slugify(playlist["name"], ok="-_()[]{}")) | ||||
|     filepath = os.path.join(const.args.folder if const.args.folder else "", text_file) | ||||
|     return write_tracks(tracks, filepath) | ||||
|  | ||||
|  | ||||
| def fetch_album(album): | ||||
|     splits = internals.get_splits(album) | ||||
|     album_id = splits[-1] | ||||
|     album_id = internals.extract_spotify_id(album) | ||||
|     album = spotify.album(album_id) | ||||
|     return album | ||||
|  | ||||
|  | ||||
| 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 | ||||
|  | ||||
|  | ||||
| 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) | ||||
|  | ||||
|  | ||||
| def write_album(album_url, text_file=None): | ||||
|     album = fetch_album(album_url) | ||||
|     tracks = spotify.album_tracks(album['id']) | ||||
|     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) | ||||
|         text_file = u"{0}.txt".format(slugify(album["name"], ok="-_()[]{}")) | ||||
|     filepath = os.path.join(const.args.folder if const.args.folder else "", text_file) | ||||
|     return write_tracks(tracks, filepath) | ||||
|  | ||||
|  | ||||
| def write_tracks(tracks, text_file): | ||||
|     log.info(u'Writing {0} tracks to {1}'.format( | ||||
|                tracks['total'], 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: | ||||
|     with open(text_file, "a") as file_out: | ||||
|         while True: | ||||
|             for item in tracks['items']: | ||||
|                 if 'track' in item: | ||||
|                     track = item['track'] | ||||
|             for item in tracks["items"]: | ||||
|                 if "track" in item: | ||||
|                     track = item["track"] | ||||
|                 else: | ||||
|                     track = item | ||||
|                 try: | ||||
|                     track_url = track['external_urls']['spotify'] | ||||
|                     track_url = track["external_urls"]["spotify"] | ||||
|                     log.debug(track_url) | ||||
|                     file_out.write(track_url + '\n') | ||||
|                     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'])) | ||||
|                     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']: | ||||
|             if tracks["next"]: | ||||
|                 tracks = spotify.next(tracks) | ||||
|             else: | ||||
|                 break | ||||
|   | ||||
| @@ -2,17 +2,17 @@ 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 | ||||
|  | ||||
| import os | ||||
| import pprint | ||||
|  | ||||
| log = const.log | ||||
|  | ||||
| # Fix download speed throttle on short duration tracks | ||||
| # Read more on mps-youtube/pafy#199 | ||||
| pafy.g.opener.addheaders.append(('Range', 'bytes=0-')) | ||||
| pafy.g.opener.addheaders.append(("Range", "bytes=0-")) | ||||
|  | ||||
|  | ||||
| def set_api_key(): | ||||
| @@ -20,7 +20,7 @@ def set_api_key(): | ||||
|         key = const.args.youtube_api_key | ||||
|     else: | ||||
|         # Please respect this YouTube token :) | ||||
|         key = 'AIzaSyC6cEeKlxtOPybk9sEe5ksFN5sB-7wzYp0' | ||||
|         key = "AIzaSyC6cEeKlxtOPybk9sEe5ksFN5sB-7wzYp0" | ||||
|     pafy.set_api_key(key) | ||||
|  | ||||
|  | ||||
| @@ -39,32 +39,87 @@ def go_pafy(raw_song, meta_tags=None): | ||||
|     return track_info | ||||
|  | ||||
|  | ||||
| def match_video_and_metadata(track, force_pafy=True): | ||||
|     """ Get and match track data from YouTube and Spotify. """ | ||||
|     meta_tags = None | ||||
|  | ||||
|     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) | ||||
|     else: | ||||
|         # Let it generate metadata, youtube doesn't know spotify slang | ||||
|         if not const.args.no_metadata or internals.is_spotify(track): | ||||
|             meta_tags = spotify_tools.generate_metadata(track) | ||||
|  | ||||
|         if force_pafy: | ||||
|             content = go_pafy(track, meta_tags) | ||||
|         else: | ||||
|             content = None | ||||
|     return content, 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) | ||||
|         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'): | ||||
|     if extension in (".webm", ".m4a"): | ||||
|         link = content.getbestaudio(preftype=extension[1:]) | ||||
|     else: | ||||
|         log.debug('No audio streams available for {} type'.format(extension)) | ||||
|         log.debug("No audio streams available for {} type".format(extension)) | ||||
|         return False | ||||
|  | ||||
|     if link: | ||||
|         log.debug('Downloading from URL: ' + link.url) | ||||
|         log.debug("Downloading from URL: " + link.url) | ||||
|         filepath = os.path.join(const.args.folder, file_name) | ||||
|         log.debug('Saving to: ' + filepath) | ||||
|         log.debug("Saving to: " + filepath) | ||||
|         link.download(filepath=filepath) | ||||
|         return True | ||||
|     else: | ||||
|         log.debug('No audio streams available') | ||||
|         log.debug("No audio streams available") | ||||
|         return False | ||||
|  | ||||
|  | ||||
| @@ -73,23 +128,25 @@ def generate_search_url(query): | ||||
|     # 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) | ||||
|     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'] | ||||
|     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'] | ||||
|     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 | ||||
|     not_video = not_video or result.find("googleads") is not None | ||||
|  | ||||
|     video = not not_video | ||||
|     return video | ||||
| @@ -112,18 +169,29 @@ class GenerateYouTubeURL: | ||||
|         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) | ||||
|             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') | ||||
|             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'])) | ||||
|                 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: | ||||
| @@ -133,7 +201,9 @@ class GenerateYouTubeURL: | ||||
|                 # 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') | ||||
|                 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 | ||||
| @@ -144,16 +214,27 @@ class GenerateYouTubeURL: | ||||
|                 # 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)) | ||||
|                     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.\n".format(self.meta_tags['name'], self.meta_tags['artists'][0]['name'])) | ||||
|                         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']) | ||||
|             url = "http://youtube.com/watch?v={0}".format(result["link"]) | ||||
|         else: | ||||
|             url = None | ||||
|  | ||||
| @@ -164,33 +245,41 @@ class GenerateYouTubeURL: | ||||
|  | ||||
|         # prevents an infinite loop but allows for a few retries | ||||
|         if tries_remaining == 0: | ||||
|             log.debug('No tries left. I quit.') | ||||
|             log.debug("No tries left. I quit.") | ||||
|             return | ||||
|  | ||||
|         search_url = generate_search_url(self.search_query) | ||||
|         log.debug('Opening URL: {0}'.format(search_url)) | ||||
|         log.debug("Opening URL: {0}".format(search_url)) | ||||
|  | ||||
|         item = urllib.request.urlopen(search_url).read() | ||||
|         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'}): | ||||
|         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'] | ||||
|             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() | ||||
|                 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) | ||||
|                 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)} | ||||
|             youtubedetails = { | ||||
|                 "link": link, | ||||
|                 "title": title, | ||||
|                 "videotime": videotime, | ||||
|                 "seconds": internals.get_sec(videotime), | ||||
|             } | ||||
|             videos.append(youtubedetails) | ||||
|  | ||||
|         if bestmatch: | ||||
| @@ -198,43 +287,54 @@ class GenerateYouTubeURL: | ||||
|  | ||||
|         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' } | ||||
|         query = {"part": "snippet", "maxResults": 50, "type": "video"} | ||||
|  | ||||
|         if const.args.music_videos_only: | ||||
|             query['videoCategoryId'] = '10' | ||||
|             query["videoCategoryId"] = "10" | ||||
|  | ||||
|         if not self.meta_tags: | ||||
|             song = self.raw_song | ||||
|             query['q'] = song | ||||
|             query["q"] = song | ||||
|         else: | ||||
|             query['q'] = self.search_query | ||||
|         log.debug('query: {0}'.format(query)) | ||||
|             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)) | ||||
|         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) | ||||
|         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} | ||||
|         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,14 +1,15 @@ | ||||
| from spotdl import const | ||||
| from spotdl import handle | ||||
| from spotdl import spotdl | ||||
|  | ||||
| import pytest | ||||
|  | ||||
|  | ||||
| def load_defaults(): | ||||
|     const.args = handle.get_arguments(raw_args='', to_group=False, to_merge=False) | ||||
|     const.args.overwrite = 'skip' | ||||
|     const.args.log_level = 10 | ||||
|     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) | ||||
|     spotdl.log = const.logzero.setup_logger( | ||||
|         formatter=const._formatter, level=const.args.log_level | ||||
|     ) | ||||
|   | ||||
							
								
								
									
										243
									
								
								test/test_download_with_metadata.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										243
									
								
								test/test_download_with_metadata.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,243 @@ | ||||
| import urllib | ||||
| 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 = 23 | ||||
|     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 monkeypatch_youtube_search_page(*args, **kwargs): | ||||
|     fake_urlopen = urllib.request.urlopen(GIST_URL) | ||||
|     return fake_urlopen | ||||
|  | ||||
|  | ||||
| def test_youtube_url(metadata_fixture, monkeypatch): | ||||
|     monkeypatch.setattr( | ||||
|         youtube_tools.GenerateYouTubeURL, | ||||
|         "_fetch_response", | ||||
|         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", | ||||
|         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 | ||||
|         ) | ||||
|         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 | ||||
|         ) | ||||
|         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 -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 -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 -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 -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 -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 -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: | ||||
|     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,18 +1,21 @@ | ||||
| from spotdl import const | ||||
|  | ||||
| from spotdl import spotdl | ||||
| import loader | ||||
| 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): | ||||
|     song = 'http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU' | ||||
|     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 tin: | ||||
|         tin.write(song) | ||||
|     downloaded_song, *_ = spotdl.download_list(file_path) | ||||
|     assert downloaded_song == song | ||||
|     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,46 +1,66 @@ | ||||
| import yaml | ||||
|  | ||||
| from spotdl import handle | ||||
| from spotdl import const | ||||
|  | ||||
| import pytest | ||||
| 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] | ||||
|     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, tmpdir): | ||||
|         expect_config = handle.default_conf['spotify-downloader'] | ||||
|         global config_path | ||||
|         config_path = os.path.join(str(tmpdir), 'config.yml') | ||||
|         config = handle.get_config(config_path) | ||||
|     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): | ||||
|         global modified_config | ||||
|         modified_config = dict(handle.default_conf) | ||||
|         modified_config['spotify-downloader']['file-format'] = 'just_a_test' | ||||
|         merged_config = handle.merge(handle.default_conf, modified_config) | ||||
|         assert merged_config == modified_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, tmpdir): | ||||
|     def test_custom_config_path(self, config_path_fixture, modified_config_fixture): | ||||
|         parser = argparse.ArgumentParser() | ||||
|         with open(config_path, 'w') as config_file: | ||||
|             yaml.dump(modified_config, config_file, default_flow_style=False) | ||||
|         overridden_config = handle.override_config(config_path, | ||||
|                                                    parser, | ||||
|                                                    raw_args='') | ||||
|         modified_values = [ str(value) for value in modified_config['spotify-downloader'].values() ] | ||||
|         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() ] | ||||
|         overridden_values = [ | ||||
|             str(value) for value in overridden_config.__dict__.values() | ||||
|         ] | ||||
|         assert sorted(overridden_values) == sorted(modified_values) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,82 +1,180 @@ | ||||
| from spotdl import internals | ||||
|  | ||||
| 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() | ||||
|     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') | ||||
|         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, tmpdir): | ||||
|     def test_create_directory(self, directory_fixture): | ||||
|         expect_path = True | ||||
|         global folder_path | ||||
|         folder_path = os.path.join(str(tmpdir), 'filter_this_folder') | ||||
|         internals.filter_path(folder_path) | ||||
|         is_path = os.path.isdir(folder_path) | ||||
|         internals.filter_path(directory_fixture) | ||||
|         is_path = os.path.isdir(directory_fixture) | ||||
|         assert is_path == expect_path | ||||
|  | ||||
|     def test_remove_temp_files(self, tmpdir): | ||||
|     def test_remove_temp_files(self, directory_fixture): | ||||
|         expect_file = False | ||||
|         file_path = os.path.join(folder_path, 'pesky_file.temp') | ||||
|         open(file_path, 'a') | ||||
|         internals.filter_path(folder_path) | ||||
|         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 | ||||
|  | ||||
|  | ||||
| class TestVideoTimeFromSeconds: | ||||
|     def test_from_seconds(self): | ||||
|         expect_duration = '35' | ||||
|         duration = internals.videotime_from_seconds(35) | ||||
|         assert duration == expect_duration | ||||
|  | ||||
|     def test_from_minutes(self): | ||||
|         expect_duration = '2:38' | ||||
|         duration = internals.videotime_from_seconds(158) | ||||
|         assert duration == expect_duration | ||||
|  | ||||
|     def test_from_hours(self): | ||||
|         expect_duration = '1:16:02' | ||||
|         duration = internals.videotime_from_seconds(4562) | ||||
|         assert duration == expect_duration | ||||
| @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 | ||||
|  | ||||
|  | ||||
| class TestGetSeconds: | ||||
|     def test_from_seconds(self): | ||||
|         expect_secs = 45 | ||||
|         secs = internals.get_sec('0:45') | ||||
|         assert secs == expect_secs | ||||
|         secs = internals.get_sec('0.45') | ||||
|         assert secs == expect_secs | ||||
| @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 | ||||
|  | ||||
|     def test_from_minutes(self): | ||||
|         expect_secs = 213 | ||||
|         secs = internals.get_sec('3.33') | ||||
|         assert secs == expect_secs | ||||
|         secs = internals.get_sec('3:33') | ||||
|         assert secs == expect_secs | ||||
|  | ||||
|     def test_from_hours(self): | ||||
|         expect_secs = 5405 | ||||
|         secs = internals.get_sec('1.30.05') | ||||
|         assert secs == expect_secs | ||||
|         secs = internals.get_sec('1:30:05') | ||||
|         assert secs == expect_secs | ||||
| @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)) | ||||
|  | ||||
|     def test_raise_error(self): | ||||
|         with pytest.raises(ValueError): | ||||
|             internals.get_sec('10*05') | ||||
|         with pytest.raises(ValueError): | ||||
|             internals.get_sec('02,28,46') | ||||
|     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,50 +0,0 @@ | ||||
| from spotdl import spotify_tools | ||||
| from spotdl import const | ||||
|  | ||||
| from spotdl import spotdl | ||||
|  | ||||
| import builtins | ||||
| import os | ||||
|  | ||||
|  | ||||
| def test_user_playlists(tmpdir, monkeypatch): | ||||
|     expect_tracks = 14 | ||||
|     text_file = os.path.join(str(tmpdir), 'test_us.txt') | ||||
|     monkeypatch.setattr('builtins.input', lambda x: 1) | ||||
|     spotify_tools.write_user_playlist('alex', text_file) | ||||
|     with open(text_file, 'r') as tin: | ||||
|         tracks = len(tin.readlines()) | ||||
|     assert tracks == expect_tracks | ||||
|  | ||||
|  | ||||
| def test_playlist(tmpdir): | ||||
|     expect_tracks = 14 | ||||
|     text_file = os.path.join(str(tmpdir), 'test_pl.txt') | ||||
|     spotify_tools.write_playlist('https://open.spotify.com/user/alex/playlist/0iWOVoumWlkXIrrBTSJmN8', text_file) | ||||
|     with open(text_file, 'r') as tin: | ||||
|         tracks = len(tin.readlines()) | ||||
|     assert tracks == expect_tracks | ||||
|  | ||||
|  | ||||
| def test_album(tmpdir): | ||||
|     expect_tracks = 15 | ||||
|     global text_file | ||||
|     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 tin: | ||||
|         tracks = len(tin.readlines()) | ||||
|     assert tracks == expect_tracks | ||||
|  | ||||
|  | ||||
| def test_trim(): | ||||
|     with open(text_file, 'r') as track_file: | ||||
|         tracks = track_file.readlines() | ||||
|  | ||||
|     expect_number = len(tracks) - 1 | ||||
|     expect_track = tracks[0] | ||||
|     track = spotdl.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) | ||||
							
								
								
									
										152
									
								
								test/test_spotify_tools.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								test/test_spotify_tools.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,152 @@ | ||||
| from spotdl import spotify_tools | ||||
|  | ||||
| import os | ||||
| import pytest | ||||
|  | ||||
|  | ||||
| def test_generate_token(): | ||||
|     token = spotify_tools.generate_token() | ||||
|     assert len(token) == 83 | ||||
|  | ||||
|  | ||||
| def test_refresh_token(): | ||||
|     old_instance = spotify_tools.spotify | ||||
|     spotify_tools.refresh_token() | ||||
|     new_instance = spotify_tools.spotify | ||||
|     assert not old_instance == new_instance | ||||
|  | ||||
|  | ||||
| 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) == 23 | ||||
|  | ||||
|     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: Mock this test off if it fails 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: Mock this test off if it fails 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): | ||||
|         # TODO: Mock this test (failed in #493) | ||||
|         assert len(albums_from_artist_fixture) == 52 | ||||
|  | ||||
|     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 | ||||
|  | ||||
|  | ||||
| # TODO: Mock this test (failed in #493) | ||||
| 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 | ||||
| @@ -1,154 +0,0 @@ | ||||
|  | ||||
| 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 spotdl | ||||
|  | ||||
| import loader | ||||
| import os | ||||
|  | ||||
| loader.load_defaults() | ||||
| raw_song = 'http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU' | ||||
|  | ||||
|  | ||||
| def test_metadata(): | ||||
|     expect_number = 23 | ||||
|     global meta_tags | ||||
|     meta_tags = spotify_tools.generate_metadata(raw_song) | ||||
|     assert len(meta_tags) == expect_number | ||||
|  | ||||
|  | ||||
| class TestFileFormat: | ||||
|     def test_with_spaces(self): | ||||
|         expect_title = 'David André Østby - Intro' | ||||
|         title = internals.format_string(const.args.file_format, meta_tags) | ||||
|         assert title == expect_title | ||||
|  | ||||
|     def test_without_spaces(self): | ||||
|         expect_title = 'David_André_Østby_-_Intro' | ||||
|         const.args.no_spaces = True | ||||
|         title = internals.format_string(const.args.file_format, meta_tags) | ||||
|         assert title == expect_title | ||||
|  | ||||
|  | ||||
| def test_youtube_url(): | ||||
|     expect_url = 'http://youtube.com/watch?v=rg1wfcty0BA' | ||||
|     url = youtube_tools.generate_youtube_url(raw_song, meta_tags) | ||||
|     assert url == expect_url | ||||
|  | ||||
|  | ||||
| def test_youtube_title(): | ||||
|     expect_title = 'Intro - David André Østby' | ||||
|     global content | ||||
|     content = youtube_tools.go_pafy(raw_song, meta_tags) | ||||
|     title = youtube_tools.get_youtube_title(content) | ||||
|     assert title == expect_title | ||||
|  | ||||
|  | ||||
| def test_check_track_exists_before_download(tmpdir): | ||||
|     expect_check = False | ||||
|     const.args.folder = str(tmpdir) | ||||
|     # prerequisites for determining filename | ||||
|     songname = internals.format_string(const.args.file_format, meta_tags) | ||||
|     global file_name | ||||
|     file_name = internals.sanitize_title(songname) | ||||
|     check = spotdl.check_exists(file_name, raw_song, meta_tags) | ||||
|     assert check == expect_check | ||||
|  | ||||
|  | ||||
| class TestDownload: | ||||
|     def test_m4a(self): | ||||
|         expect_download = True | ||||
|         download = youtube_tools.download_song(file_name + '.m4a', content) | ||||
|         assert download == expect_download | ||||
|  | ||||
|     def test_webm(self): | ||||
|         expect_download = True | ||||
|         download = youtube_tools.download_song(file_name + '.webm', content) | ||||
|         assert download == expect_download | ||||
|  | ||||
|  | ||||
| class TestFFmpeg(): | ||||
|     def test_convert_from_webm_to_mp3(self): | ||||
|         expect_return_code = 0 | ||||
|         return_code = convert.song(file_name + '.webm', | ||||
|                                    file_name + '.mp3', | ||||
|                                    const.args.folder) | ||||
|         assert return_code == expect_return_code | ||||
|  | ||||
|     def test_convert_from_webm_to_m4a(self): | ||||
|         expect_return_code = 0 | ||||
|         return_code = convert.song(file_name + '.webm', | ||||
|                                    file_name + '.m4a', | ||||
|                                    const.args.folder) | ||||
|         assert return_code == expect_return_code | ||||
|  | ||||
|  | ||||
|     def test_convert_from_m4a_to_mp3(self): | ||||
|         expect_return_code = 0 | ||||
|         return_code = convert.song(file_name + '.m4a', | ||||
|                                    file_name + '.mp3', | ||||
|                                    const.args.folder) | ||||
|         assert return_code == expect_return_code | ||||
|  | ||||
|     def test_convert_from_m4a_to_webm(self): | ||||
|         expect_return_code = 0 | ||||
|         return_code = convert.song(file_name + '.m4a', | ||||
|                                    file_name + '.webm', | ||||
|                                    const.args.folder) | ||||
|         assert return_code == expect_return_code | ||||
|  | ||||
|     def test_convert_from_m4a_to_flac(self): | ||||
|         expect_return_code = 0 | ||||
|         return_code = convert.song(file_name + '.m4a', | ||||
|                                    file_name + '.flac', | ||||
|                                    const.args.folder) | ||||
|         assert return_code == expect_return_code | ||||
|  | ||||
|  | ||||
| class TestAvconv: | ||||
|     def test_convert_from_m4a_to_mp3(self): | ||||
|         expect_return_code = 0 | ||||
|         return_code = convert.song(file_name + '.m4a', | ||||
|                                    file_name + '.mp3', | ||||
|                                    const.args.folder, | ||||
|                                    avconv=True) | ||||
|         assert return_code == expect_return_code | ||||
|  | ||||
|  | ||||
| class TestEmbedMetadata: | ||||
|     def test_embed_in_mp3(self): | ||||
|         expect_embed = True | ||||
|         global track_path | ||||
|         track_path = os.path.join(const.args.folder, file_name) | ||||
|         embed = metadata.embed(track_path + '.mp3', meta_tags) | ||||
|         assert embed == expect_embed | ||||
|  | ||||
|     def test_embed_in_m4a(self): | ||||
|         expect_embed = True | ||||
|         embed = metadata.embed(track_path + '.m4a', meta_tags) | ||||
|         os.remove(track_path + '.m4a') | ||||
|         assert embed == expect_embed | ||||
|  | ||||
|     def test_embed_in_webm(self): | ||||
|         expect_embed = False | ||||
|         embed = metadata.embed(track_path + '.webm', meta_tags) | ||||
|         os.remove(track_path + '.webm') | ||||
|         assert embed == expect_embed | ||||
|  | ||||
|     def test_embed_in_flac(self): | ||||
|         expect_embed = True | ||||
|         embed = metadata.embed(track_path + '.flac', meta_tags) | ||||
|         os.remove(track_path + '.flac') | ||||
|         assert embed == expect_embed | ||||
|  | ||||
|  | ||||
| def test_check_track_exists_after_download(): | ||||
|     expect_check = True | ||||
|     check = spotdl.check_exists(file_name, raw_song, meta_tags) | ||||
|     os.remove(track_path + '.mp3') | ||||
|     assert check == expect_check | ||||
| @@ -1,129 +0,0 @@ | ||||
| from spotdl import const | ||||
| from spotdl import internals | ||||
| from spotdl import spotify_tools | ||||
| from spotdl import youtube_tools | ||||
|  | ||||
| from spotdl import spotdl | ||||
| import loader | ||||
|  | ||||
| import os | ||||
| import builtins | ||||
|  | ||||
| loader.load_defaults() | ||||
| raw_song = "Tony's Videos VERY SHORT VIDEO 28.10.2016" | ||||
|  | ||||
|  | ||||
| class TestYouTubeAPIKeys: | ||||
|     def test_custom(self): | ||||
|         expect_key = 'some_api_key' | ||||
|         const.args.youtube_api_key = expect_key | ||||
|         youtube_tools.set_api_key() | ||||
|         key = youtube_tools.pafy.g.api_key | ||||
|         assert key == expect_key | ||||
|  | ||||
|     def test_default(self): | ||||
|         expect_key = 'AIzaSyC6cEeKlxtOPybk9sEe5ksFN5sB-7wzYp0' | ||||
|         const.args.youtube_api_key = None | ||||
|         youtube_tools.set_api_key() | ||||
|         key = youtube_tools.pafy.g.api_key | ||||
|         assert key == expect_key | ||||
|  | ||||
|  | ||||
| def test_metadata(): | ||||
|     expect_metadata = None | ||||
|     global metadata | ||||
|     metadata = spotify_tools.generate_metadata(raw_song) | ||||
|     assert metadata == expect_metadata | ||||
|  | ||||
|  | ||||
| class TestArgsManualResultCount: | ||||
|     # Regresson test for issue #264 | ||||
|     def test_scrape(self): | ||||
|         const.args.manual = True | ||||
|         url = youtube_tools.GenerateYouTubeURL("she is still sleeping SAO", | ||||
|                                                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("she is still sleeping SAO", | ||||
|                                                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): | ||||
|         # YouTube keeps changing its results | ||||
|         expect_urls = ('http://youtube.com/watch?v=qOOcy2-tmbk', | ||||
|                        'http://youtube.com/watch?v=5USR1Omo7f0') | ||||
|         const.args.music_videos_only = True | ||||
|         url = youtube_tools.generate_youtube_url(raw_song, metadata) | ||||
|         assert url in expect_urls | ||||
|  | ||||
|     def test_all_categories(self): | ||||
|         expect_url = 'http://youtube.com/watch?v=qOOcy2-tmbk' | ||||
|         const.args.music_videos_only = False | ||||
|         url = youtube_tools.generate_youtube_url(raw_song, metadata) | ||||
|         assert url == expect_url | ||||
|  | ||||
|     def test_args_manual(self, monkeypatch): | ||||
|         expect_url = 'http://youtube.com/watch?v=qOOcy2-tmbk' | ||||
|         const.args.manual = True | ||||
|         monkeypatch.setattr('builtins.input', lambda x: '1') | ||||
|         url = youtube_tools.generate_youtube_url(raw_song, metadata) | ||||
|         assert url == expect_url | ||||
|  | ||||
|     def test_args_manual_none(self, monkeypatch): | ||||
|         expect_url = None | ||||
|         monkeypatch.setattr('builtins.input', lambda x: '0') | ||||
|         url = youtube_tools.generate_youtube_url(raw_song, metadata) | ||||
|         const.args.manual = False | ||||
|         assert url == expect_url | ||||
|  | ||||
|  | ||||
| class TestYouTubeTitle: | ||||
|     def test_single_download_with_youtube_api(self): | ||||
|         global content | ||||
|         global title | ||||
|         expect_title = "Tony's Videos VERY SHORT VIDEO 28.10.2016" | ||||
|         key = 'AIzaSyAnItl3udec-Q1d5bkjKJGL-RgrKO_vU90' | ||||
|         const.args.youtube_api_key = key | ||||
|         youtube_tools.set_api_key() | ||||
|         content = youtube_tools.go_pafy(raw_song, metadata) | ||||
|         title = youtube_tools.get_youtube_title(content) | ||||
|         assert title == expect_title | ||||
|  | ||||
|     def test_download_from_list_without_youtube_api(self): | ||||
|         expect_title = "1. Tony's Videos VERY SHORT VIDEO 28.10.2016" | ||||
|         const.args.youtube_api_key = None | ||||
|         youtube_tools.set_api_key() | ||||
|         content = youtube_tools.go_pafy(raw_song, metadata) | ||||
|         title = youtube_tools.get_youtube_title(content, 1) | ||||
|         assert title == expect_title | ||||
|  | ||||
|  | ||||
| def test_check_exists(tmpdir): | ||||
|     expect_check = False | ||||
|     const.args.folder = str(tmpdir) | ||||
|     # prerequisites for determining filename | ||||
|     global file_name | ||||
|     file_name = internals.sanitize_title(title) | ||||
|     check = spotdl.check_exists(file_name, raw_song, metadata) | ||||
|     assert check == expect_check | ||||
|  | ||||
|  | ||||
| class TestDownload: | ||||
|     def test_webm(self): | ||||
|         # content does not have any .webm audiostream | ||||
|         expect_download = False | ||||
|         download = youtube_tools.download_song(file_name + '.webm', content) | ||||
|         assert download == expect_download | ||||
|  | ||||
|     def test_other(self): | ||||
|         expect_download = False | ||||
|         download = youtube_tools.download_song(file_name + '.fake_extension', content) | ||||
|         assert download == expect_download | ||||
							
								
								
									
										155
									
								
								test/test_youtube_tools.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								test/test_youtube_tools.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | ||||
| 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" | ||||
| EXPECTED_YT_URLS = (EXPECTED_YT_URL, "http://youtube.com/watch?v=5USR1Omo7f0") | ||||
|  | ||||
| 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) | ||||
|         # YouTube keeps changing its results | ||||
|         assert url in EXPECTED_YT_URLS | ||||
|  | ||||
|     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 | ||||
|  | ||||
|  | ||||
| @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 | ||||
|  | ||||
|  | ||||
| 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