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
|
||||
|
||||
|
||||
91
setup.py
Normal file → Executable file
91
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'],
|
||||
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'
|
||||
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",
|
||||
],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'spotdl = spotdl.spotdl:main',
|
||||
],
|
||||
}
|
||||
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.7",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Topic :: Multimedia",
|
||||
"Topic :: Multimedia :: Sound/Audio",
|
||||
"Topic :: Utilities",
|
||||
],
|
||||
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",
|
||||
]
|
||||
|
||||
if self.rename_to_temp:
|
||||
os.rename(self.output_file, self.input_file)
|
||||
|
||||
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')
|
||||
|
||||
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'
|
||||
|
||||
if self.trim_silence:
|
||||
ffmpeg_params += '-af silenceremove=start_periods=1 '
|
||||
|
||||
command = ffmpeg_pre.split() + [self.input_file] + ffmpeg_params.split() + [self.output_file]
|
||||
ffmpeg_params += "-b:a 192k -vn "
|
||||
ffmpeg_pre += "-i "
|
||||
|
||||
if trim_silence:
|
||||
ffmpeg_params += "-af silenceremove=start_periods=1 "
|
||||
|
||||
command = (
|
||||
ffmpeg_pre.split()
|
||||
+ [self.input_file]
|
||||
+ ffmpeg_params.split()
|
||||
+ [self.output_file]
|
||||
)
|
||||
|
||||
if self.rename_to_temp:
|
||||
os.rename(self.output_file, self.input_file)
|
||||
|
||||
log.debug(command)
|
||||
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
|
||||
303
spotdl/handle.py
303
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
|
||||
|
||||
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
|
||||
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 debug_sys_info():
|
||||
log.debug("Python version: {}".format(sys.version))
|
||||
log.debug("Platform: {}".format(platform.platform()))
|
||||
log.debug(pprint.pformat(const.args.__dict__))
|
||||
|
||||
|
||||
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
|
||||
def match_args():
|
||||
if const.args.song:
|
||||
for track in const.args.song:
|
||||
track_dl = downloader.Downloader(raw_song=track)
|
||||
track_dl.download_single()
|
||||
elif const.args.list:
|
||||
if const.args.write_m3u:
|
||||
youtube_tools.generate_m3u(track_file=const.args.list)
|
||||
else:
|
||||
list_dl = downloader.ListDownloader(
|
||||
tracks_file=const.args.list,
|
||||
skip_file=const.args.skip,
|
||||
write_successful_file=const.args.write_successful,
|
||||
)
|
||||
list_dl.download_list()
|
||||
elif const.args.playlist:
|
||||
spotify_tools.write_playlist(playlist_url=const.args.playlist)
|
||||
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