62 Commits

Author SHA1 Message Date
Ritiek Malhotra
5ca4317944 Merge pull request #558 from ritiek/pafy-prefer-secure-by-default
Pafy prefer secure HTTPS by default
2019-06-05 23:36:11 +05:30
Ritiek Malhotra
f4cd70b603 Bump to v1.2.2 2019-06-03 14:18:31 +05:30
Ritiek Malhotra
b6c5c88550 Fix tests for now and rephrase comments for clarity 2019-06-03 14:15:35 +05:30
Ritiek Malhotra
9f1f361dcb Add docs on what this is about 2019-06-03 14:15:23 +05:30
Ritiek Malhotra
fd74adb42f Prefer secure HTTPS by default 2019-06-03 14:04:41 +05:30
Ritiek Malhotra
b808265c38 Merge pull request #540 from ritiek/release-v1.2.1
Bump to v1.2.1
2019-04-28 17:09:33 +05:30
Ritiek Malhotra
21a1f1a150 Bump to v1.2.1 2019-04-28 17:05:44 +05:30
Ritiek Malhotra
951ae02e08 Merge pull request #539 from ritiek/patch-audiostream-urls
Patch bug in Pafy when fetching audiostreams with latest youtube-dl
2019-04-28 17:03:25 +05:30
Ritiek Malhotra
dfd48f75ce Update CHANGES.md 2019-04-28 16:46:30 +05:30
Ritiek Malhotra
bb385a3bfd Skip avconv tests as it is no longer provided in later distros 2019-04-28 15:31:43 +05:30
Ritiek Malhotra
a9477c7873 Fix tests 2019-04-28 15:26:18 +05:30
Ritiek Malhotra
c225e5821b Patch bug in Pafy when fetching audiostreams with latest youtube-dl 2019-04-28 15:09:42 +05:30
Ritiek Malhotra
d61309b0ce Merge pull request #522 from ritiek/hightlight-shell-code-blocks
Use "console" as language to highlight shell code blocks with
2019-03-17 10:02:33 +05:30
Ritiek Malhotra
5b2a073033 Merge pull request #519 from ritiek/remove-duplicate-debuglog-entry
Remove duplicate debuglog entry
2019-03-17 10:02:23 +05:30
Linus Groh
f17e5f58d8 Update README.md 2019-03-16 17:44:49 +00:00
Ritiek Malhotra
d3668f55bb Update CHANGES.md 2019-03-14 20:13:27 +05:30
Ritiek Malhotra
6ca136f039 Remove duplicate debuglog entry 2019-03-14 20:12:53 +05:30
Sumanjay
e2a136d885 Update CHANGES.md (#518)
* Update CHANGES.md

* Update CHANGES.md
2019-03-14 19:58:15 +05:30
Ritiek Malhotra
d10f3e9df0 Merge pull request #517 from cyberboysumanjay/master
Fix YAMLLoadWarning
2019-03-14 18:22:47 +05:30
Sumanjay
46eb2e3e32 Fix YAMLLoadWarning 2019-03-14 13:26:35 +05:30
Ritiek Malhotra
21fd63be6f Bump to v1.2.0 (#508)
* Bump to v1.2.0

* Add v1.2.0 release header
2019-03-01 00:40:15 -08:00
Ritiek Malhotra
703e228345 Write tracks to custom file with --write-to (#507)
* Write tracks to custom file

* Update CHANGES.md
2019-02-28 02:48:02 -08:00
Ritiek Malhotra
2825f6c593 Fix prompt when using '/' to create sub-directories (#503)
* Fix prompt when using '/' to create sub-directories

* Fix and update CHANGES.md
2019-02-27 10:45:05 -08:00
Ritiek Malhotra
ac7d42535f Replace class SpotifyAuthorize with @must_be_authorized (#506)
* @must_be_authorized decorator for functions in spotify_tools.py

* We don't need this

* Add tests

* Update CHANGELOG.md
2019-02-27 09:48:18 -08:00
Ritiek Malhotra
1767899a8a Merge pull request #502 from ritiek/spotify-creds
Spotify Credentials from file
2019-02-26 20:50:12 -08:00
Ritiek Malhotra
e9f046bea1 Rebase fixes 2019-02-26 23:41:37 +05:30
Manveer Basra
4fc23a84dc Refactored to use spotify_tools.SpotifyAuthorize class 2019-02-26 22:02:59 +05:30
Manveer Basra
c886ccf603 Refactored to pass tests 2019-02-26 21:24:54 +05:30
Manveer Basra
cf9b0690fd Set default client id/secret in handle.py to None 2019-02-26 21:23:09 +05:30
Manveer Basra
d215ce685d Exposed Spotify Client ID/Secret in config.yml 2019-02-26 21:22:08 +05:30
Manveer Basra
0492c711cc Refactored for consistency 2019-02-26 20:56:30 +05:30
Manveer Basra
42f33162ea --list flag accepts only text files using mimetypes 2019-02-26 20:56:03 +05:30
Ritiek Malhotra
4a051fee19 Merge pull request #457 from ritiek/youtube-metadata
Use YouTube as fallback for track metadata if not found on Spotify
2019-02-25 22:24:15 -08:00
Ritiek Malhotra
441c75ec64 Load defaults in test_spotify_tools.py 2019-02-26 10:33:05 +05:30
Ritiek Malhotra
72ae2bc0cd Update CHANGES.md 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
548a87e945 Re-add test for m3u files (removed accidently in #448) 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
ed1c068c36 Add tests 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
ec19491f4f Fix tests and monkeypatch Pafy.download method for version on GitHub 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
e56cd3caca Add option for not falling back on YouTube metadata 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
eb77880f9f Use YouTube as fallback for track metadata if not found on Spotify 2019-02-26 09:50:59 +05:30
Ritiek Malhotra
ddb4b01897 Merge pull request #494 from ritiek/release-v1.1.2
Release changes for v1.1.2
2019-02-10 20:52:42 +05:30
Ritiek Malhotra
1d401d26c1 Bump to v1.1.2 2019-02-10 20:30:39 +05:30
Ritiek Malhotra
cfa9f78ce4 Mark section for v1.1.2 2019-02-10 20:28:52 +05:30
Ritiek Malhotra
01c6c11a1d Black format code 2019-02-10 20:26:22 +05:30
Ritiek Malhotra
eb1be87039 Merge pull request #493 from ritiek/fetch-all-album-types
Fetch all album types for an artist by default
2019-02-08 18:03:37 +05:30
Ritiek Malhotra
925521aa3b Fix tests for now 2019-02-04 20:18:01 +05:30
Ritiek Malhotra
1d2b43a5f9 Update CHANGES.md 2019-02-04 15:27:04 +05:30
Ritiek Malhotra
542201091d Fetch all artist albums by default 2019-02-04 15:24:37 +05:30
Ritiek Malhotra
a182fe5eb3 Use argparse special features to handle displaying version info (#486)
* Use argparse special features to handle displaying version info

* Remove version argument check from spotdl.py
2019-01-21 05:56:21 -08:00
Ritiek Malhotra
3dac0125a9 Merge pull request #477 from ritiek/missing-changelogs
Changelog entries for missed PRs
2019-01-14 21:24:16 -08:00
Ritiek Malhotra
fbf930fe43 Changelog entries for missed PRs 2019-01-15 10:53:07 +05:30
Ritiek Malhotra
b72eb773f3 Merge pull request #475 from ritiek/fix-m4a-when-encoder-not-found
Fix renaming files when encoder is not found
2019-01-14 19:55:24 -08:00
Ritiek Malhotra
8944dec8e0 Merge branch 'master' into fix-m4a-when-encoder-not-found 2019-01-14 19:48:33 -08:00
Ritiek Malhotra
76906cfdbc Merge pull request #476 from Silverfeelin/master
Use folder argument as base for album/playlist file exports.
2019-01-13 11:38:24 -08:00
Silverfeelin
a18f888e97 Update CHANGES.md 2019-01-13 20:25:05 +01:00
Silverfeelin
6c07267312 Use folder argument as base for album/playlist file exports. 2019-01-13 17:12:05 +01:00
Ritiek Malhotra
f078875f0e Update CHANGES.md 2019-01-13 18:32:48 +05:30
Ritiek Malhotra
31cd1c5856 Move 'encoder not found' warning to more appropriate place 2019-01-13 18:19:38 +05:30
Ritiek Malhotra
54d3336aa2 Fix renaming files when encoder is not present 2019-01-13 18:19:06 +05:30
Ritiek Malhotra
94500e31a3 Merge pull request #469 from tillhainbach/master
Use first artist from album object for album artist
2019-01-08 20:06:00 -08:00
tillhainbach
bf6e6fb0c5 first artist from album object for album artist 2019-01-08 22:53:22 +01:00
ifduyue
67ae7d5c4c Add missing import time (#465)
This fixes #464
2019-01-04 09:56:21 +00:00
17 changed files with 472 additions and 124 deletions

View File

@@ -5,6 +5,47 @@ 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). and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
-
### Fixed
-
### Changed
-
## [1.2.2] - 2019-06-03
### Fixed
- Patch bug in Pafy to prefer secure HTTPS ([@ritiek](https://github.com/ritiek)) (#558)
## [1.2.1] - 2019-04-28
### Fixed
- Patch bug in Pafy when fetching audiostreams with latest youtube-dl ([@ritiek](https://github.com/ritiek)) (#539)
### Changed
- Removed duplicate debug log entry from `internals.trim_song` ([@ritiek](https://github.com/ritiek)) (#519)
- Fix YAMLLoadWarning ([@cyberboysumanjay](https://github.com/cyberboysumanjay)) (#517)
## [1.2.0] - 2019-03-01
### Added
- `--write-to` parameter for setting custom file to write Spotify track URLs to ([@ritiek](https://github.com/ritiek)) (#507)
- Set custom Spotify Client ID and Client Secret via config.yml ([@ManveerBasra](https://github.com/ManveerBasra)) (#502)
- Use YouTube as fallback metadata if track not found on Spotify. Also added `--no-fallback-metadata`
to preserve old behaviour ([@ritiek](https://github.com/ritiek)) (#457)
### Fixed
- Fix already downloaded prompt when using "/" in `--file-format` to create sub-directories ([@ritiek](https://github.com/ritiek)) (#503)
- Fix writing playlist tracks to file ([@ritiek](https://github.com/ritiek)) (#506)
## [1.1.2] - 2019-02-10
### Changed
- Fetch all artist albums by default instead of only fetching the "album" type ([@ritiek](https://github.com/ritiek)) (#493)
- Option `-f` (`--folder`) is used when exporting text files using `-p` (`--playlist`) for playlists or `-b` (`--album`) for albums ([@Silverfeelin](https://github.com/Silverfeelin)) (#476)
- Use first artist from album object for album artist ([@tillhainbach](https://github.com/tillhainbach))
### Fixed
- Fix renaming files when encoder is not found ([@ritiek](https://github.com/ritiek)) (#475)
- Add missing `import time` ([@ifduyue](https://github.com/ifduyue)) (#465)
## [1.1.1] - 2019-01-03 ## [1.1.1] - 2019-01-03
### Added ### Added

View File

@@ -30,7 +30,7 @@ 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. spotify-downloader works with all major distributions and even on low-powered devices such as a Raspberry Pi.
spotify-downloader can be installed via pip with: spotify-downloader can be installed via pip with:
``` ```console
$ pip3 install spotdl $ pip3 install spotdl
``` ```
@@ -41,7 +41,7 @@ page for detailed OS-specific instructions to get it and other dependencies it r
For the most basic usage, downloading tracks is as easy as For the most basic usage, downloading tracks is as easy as
``` ```console
$ spotdl --song https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ $ spotdl --song https://open.spotify.com/track/2DGa7iaidT5s0qnINlwMjJ
$ spotdl --song "ncs - spectre" $ spotdl --song "ncs - spectre"
``` ```
@@ -49,7 +49,7 @@ $ spotdl --song "ncs - spectre"
For downloading playlist and albums, you need to first load all the tracks into text file and then pass For downloading playlist and albums, you need to first load all the tracks into text file and then pass
this text file to `--list` argument. Here is how you would do it for a playlist this text file to `--list` argument. Here is how you would do it for a playlist
``` ```console
$ spotdl --playlist https://open.spotify.com/user/nocopyrightsounds/playlist/7sZbq8QGyMnhKPcLJvCUFD $ spotdl --playlist https://open.spotify.com/user/nocopyrightsounds/playlist/7sZbq8QGyMnhKPcLJvCUFD
INFO: Writing 62 tracks to ncs-releases.txt INFO: Writing 62 tracks to ncs-releases.txt
$ spotdl --list ncs-releases.txt $ spotdl --list ncs-releases.txt
@@ -73,7 +73,7 @@ Check out [CONTRIBUTING.md](CONTRIBUTING.md) for more info.
## Running Tests ## Running Tests
``` ```console
$ python3 -m pytest test $ python3 -m pytest test
``` ```

View File

@@ -1 +1 @@
__version__ = "1.1.1" __version__ = "1.2.2"

View File

@@ -83,7 +83,12 @@ class Converter:
os.rename(self.output_file, self.input_file) os.rename(self.output_file, self.input_file)
log.debug(command) log.debug(command)
code = 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: if self.delete_original:
log.debug('Removing original file: "{}"'.format(self.input_file)) log.debug('Removing original file: "{}"'.format(self.input_file))
@@ -134,7 +139,12 @@ class Converter:
os.rename(self.output_file, self.input_file) os.rename(self.output_file, self.input_file)
log.debug(command) log.debug(command)
code = 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: if self.delete_original:
log.debug('Removing original file: "{}"'.format(self.input_file)) log.debug('Removing original file: "{}"'.format(self.input_file))

View File

@@ -1,6 +1,7 @@
import spotipy import spotipy
import urllib import urllib
import os import os
import time
from logzero import logger as log from logzero import logger as log
from spotdl import const from spotdl import const
@@ -13,16 +14,20 @@ from spotdl import youtube_tools
class CheckExists: class CheckExists:
def __init__(self, music_file, meta_tags=None): def __init__(self, music_file, meta_tags=None):
self.music_file = music_file
self.meta_tags = meta_tags self.meta_tags = meta_tags
basepath, filename = os.path.split(music_file)
filepath = os.path.join(const.args.folder, basepath)
os.makedirs(filepath, exist_ok=True)
self.filepath = filepath
self.filename = filename
def already_exists(self, raw_song): def already_exists(self, raw_song):
""" Check if the input song already exists in the given folder. """ """ Check if the input song already exists in the given folder. """
log.debug( log.debug(
"Cleaning any temp files and checking " "Cleaning any temp files and checking "
'if "{}" already exists'.format(self.music_file) 'if "{}" already exists'.format(self.filename)
) )
songs = os.listdir(const.args.folder) songs = os.listdir(self.filepath)
self._remove_temp_files(songs) self._remove_temp_files(songs)
for song in songs: for song in songs:
@@ -44,17 +49,17 @@ class CheckExists:
def _remove_temp_files(self, songs): def _remove_temp_files(self, songs):
for song in songs: for song in songs:
if song.endswith(".temp"): if song.endswith(".temp"):
os.remove(os.path.join(const.args.folder, song)) os.remove(os.path.join(self.filepath, song))
def _has_metadata(self, song): def _has_metadata(self, song):
# check if the already downloaded song has correct metadata # check if the already downloaded song has correct metadata
# if not, remove it and download again without prompt # if not, remove it and download again without prompt
already_tagged = metadata.compare( already_tagged = metadata.compare(
os.path.join(const.args.folder, song), self.meta_tags os.path.join(self.filepath, song), self.meta_tags
) )
log.debug("Checking if it is already tagged correctly? {}", already_tagged) log.debug("Checking if it is already tagged correctly? {}", already_tagged)
if not already_tagged: if not already_tagged:
os.remove(os.path.join(const.args.folder, song)) os.remove(os.path.join(self.filepath, song))
return False return False
return True return True
@@ -79,7 +84,7 @@ class CheckExists:
return True return True
def _match_filenames(self, song): def _match_filenames(self, song):
if os.path.splitext(song)[0] == self.music_file: if os.path.splitext(song)[0] == self.filename:
log.debug('Found an already existing song: "{}"'.format(song)) log.debug('Found an already existing song: "{}"'.format(song))
return True return True
@@ -130,6 +135,8 @@ class Downloader:
trim_silence=const.args.trim_silence, trim_silence=const.args.trim_silence,
) )
except FileNotFoundError: 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) output_song = self.unconverted_filename(songname)
if not const.args.no_metadata and self.meta_tags is not None: if not const.args.no_metadata and self.meta_tags is not None:
@@ -160,16 +167,12 @@ class Downloader:
if not refined_songname == " - ": if not refined_songname == " - ":
songname = refined_songname songname = refined_songname
else: else:
if not const.args.no_metadata:
log.warning("Could not find metadata")
songname = internals.sanitize_title(songname) songname = internals.sanitize_title(songname)
return songname return songname
@staticmethod @staticmethod
def unconverted_filename(songname): def unconverted_filename(songname):
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 const.args.output_ext = const.args.input_ext
output_song = songname + const.args.output_ext output_song = songname + const.args.output_ext
return output_song return output_song
@@ -203,12 +206,8 @@ class ListDownloader:
try: try:
track_dl = Downloader(raw_song, number=number) track_dl = Downloader(raw_song, number=number)
track_dl.download_single() 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: except (urllib.request.URLError, TypeError, IOError) as e:
# detect network problems
self._cleanup(raw_song, e) self._cleanup(raw_song, e)
# TODO: remove this sleep once #397 is fixed # TODO: remove this sleep once #397 is fixed
# wait 0.5 sec to avoid infinite looping # wait 0.5 sec to avoid infinite looping
@@ -234,11 +233,6 @@ class ListDownloader:
with open(self.write_successful_file, "a") as f: with open(self.write_successful_file, "a") as f:
f.write("\n" + raw_song) 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): def _cleanup(self, raw_song, exception):
self.tracks.append(raw_song) self.tracks.append(raw_song)
# remove the downloaded song from file # remove the downloaded song from file

View File

@@ -7,6 +7,7 @@ import argparse
import mimetypes import mimetypes
import os import os
import spotdl
from spotdl import internals from spotdl import internals
@@ -16,11 +17,13 @@ default_conf = {
"spotify-downloader": { "spotify-downloader": {
"manual": False, "manual": False,
"no-metadata": False, "no-metadata": False,
"no-fallback-metadata": False,
"avconv": False, "avconv": False,
"folder": internals.get_music_dir(), "folder": internals.get_music_dir(),
"overwrite": "prompt", "overwrite": "prompt",
"input-ext": ".m4a", "input-ext": ".m4a",
"output-ext": ".mp3", "output-ext": ".mp3",
"write-to": None,
"trim-silence": False, "trim-silence": False,
"download-only-metadata": False, "download-only-metadata": False,
"dry-run": False, "dry-run": False,
@@ -32,6 +35,8 @@ default_conf = {
"skip": None, "skip": None,
"write-successful": None, "write-successful": None,
"log-level": "INFO", "log-level": "INFO",
"spotify_client_id": "4fe3fecfe5334023a1472516cc99d805",
"spotify_client_secret": "0f02b7c483c04257984695007a4a8d5c"
} }
} }
@@ -53,7 +58,7 @@ def merge(default, config):
def get_config(config_file): def get_config(config_file):
try: try:
with open(config_file, "r") as ymlfile: with open(config_file, "r") as ymlfile:
cfg = yaml.load(ymlfile) cfg = yaml.safe_load(ymlfile)
except FileNotFoundError: except FileNotFoundError:
log.info("Writing default configuration to {0}:".format(config_file)) log.info("Writing default configuration to {0}:".format(config_file))
with open(config_file, "w") as ymlfile: with open(config_file, "w") as ymlfile:
@@ -120,9 +125,6 @@ def get_arguments(raw_args=None, to_group=True, to_merge=True):
"--username", "--username",
help="load tracks from user's playlist into <playlist_name>.txt", help="load tracks from user's playlist into <playlist_name>.txt",
) )
group.add_argument(
"-V", "--version", help="show version and exit", action="store_true"
)
parser.add_argument( parser.add_argument(
"--write-m3u", "--write-m3u",
@@ -134,7 +136,7 @@ def get_arguments(raw_args=None, to_group=True, to_merge=True):
"-m", "-m",
"--manual", "--manual",
default=config["manual"], default=config["manual"],
help="choose the track to download manually from a list " "of matching tracks", help="choose the track to download manually from a list of matching tracks",
action="store_true", action="store_true",
) )
parser.add_argument( parser.add_argument(
@@ -144,6 +146,13 @@ def get_arguments(raw_args=None, to_group=True, to_merge=True):
help="do not embed metadata in tracks", help="do not embed metadata in tracks",
action="store_true", action="store_true",
) )
parser.add_argument(
"-nf",
"--no-fallback-metadata",
default=config["no-fallback-metadata"],
help="do not use YouTube as fallback for metadata if track not found on Spotify",
action="store_true",
)
parser.add_argument( parser.add_argument(
"-a", "-a",
"--avconv", "--avconv",
@@ -176,6 +185,11 @@ def get_arguments(raw_args=None, to_group=True, to_merge=True):
default=config["output-ext"], default=config["output-ext"],
help="preferred output format .mp3, .m4a (AAC), .flac, etc.", help="preferred output format .mp3, .m4a (AAC), .flac, etc.",
) )
parser.add_argument(
"--write-to",
default=config["write-to"],
help="write tracks from Spotify playlist, album, etc. to this file",
)
parser.add_argument( parser.add_argument(
"-ff", "-ff",
"--file-format", "--file-format",
@@ -254,9 +268,27 @@ def get_arguments(raw_args=None, to_group=True, to_merge=True):
default=config["write-successful"], default=config["write-successful"],
help="path to file to write successful tracks to", help="path to file to write successful tracks to",
) )
parser.add_argument(
"-sci",
"--spotify-client-id",
default=config["spotify_client_id"],
help=argparse.SUPPRESS
)
parser.add_argument(
"-scs",
"--spotify-client-secret",
default=config["spotify_client_secret"],
help=argparse.SUPPRESS
)
parser.add_argument( parser.add_argument(
"-c", "--config", default=None, help="path to custom config.yml file" "-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) parsed = parser.parse_args(raw_args)
@@ -280,6 +312,12 @@ def get_arguments(raw_args=None, to_group=True, to_merge=True):
if parsed.avconv and parsed.trim_silence: if parsed.avconv and parsed.trim_silence:
parser.error("--trim-silence can only be used with FFmpeg") parser.error("--trim-silence can only be used with FFmpeg")
if parsed.write_to and not (parsed.playlist \
or parsed.album \
or parsed.all_albums \
or parsed.username):
parser.error("--write-to can only be used with --playlist, --album, --all-albums, or --username")
parsed.log_level = log_leveller(parsed.log_level) parsed.log_level = log_leveller(parsed.log_level)
return parsed return parsed

View File

@@ -1,6 +1,7 @@
from logzero import logger as log from logzero import logger as log
import os import os
import sys import sys
import urllib.request
from spotdl import const from spotdl import const
@@ -50,7 +51,6 @@ def input_link(links):
def trim_song(tracks_file): def trim_song(tracks_file):
""" Remove the first song from file. """ """ Remove the first song from file. """
log.debug("Removing downloaded song from tracks file")
with open(tracks_file, "r") as file_in: with open(tracks_file, "r") as file_in:
data = file_in.read().splitlines(True) data = file_in.read().splitlines(True)
with open(tracks_file, "w") as file_out: with open(tracks_file, "w") as file_out:
@@ -253,3 +253,12 @@ def remove_duplicates(tracks):
local_set = set() local_set = set()
local_set_add = local_set.add local_set_add = local_set.add
return [x for x in tracks if not (x in local_set or local_set_add(x))] return [x for x in tracks if not (x in local_set or local_set_add(x))]
def content_available(url):
try:
response = urllib.request.urlopen(url)
except HTTPError:
return False
else:
return response.getcode() < 300

View File

@@ -46,6 +46,8 @@ class EmbedMetadata:
def __init__(self, music_file, meta_tags): def __init__(self, music_file, meta_tags):
self.music_file = music_file self.music_file = music_file
self.meta_tags = meta_tags self.meta_tags = meta_tags
self.spotify_metadata = meta_tags["spotify_metadata"]
self.provider = "spotify" if meta_tags["spotify_metadata"] else "youtube"
def as_mp3(self): def as_mp3(self):
""" Embed metadata to MP3 files. """ """ Embed metadata to MP3 files. """
@@ -62,7 +64,7 @@ class EmbedMetadata:
audiofile["lyricist"] = meta_tags["artists"][0]["name"] audiofile["lyricist"] = meta_tags["artists"][0]["name"]
audiofile["arranger"] = meta_tags["artists"][0]["name"] audiofile["arranger"] = meta_tags["artists"][0]["name"]
audiofile["performer"] = meta_tags["artists"][0]["name"] audiofile["performer"] = meta_tags["artists"][0]["name"]
audiofile["website"] = meta_tags["external_urls"]["spotify"] audiofile["website"] = meta_tags["external_urls"][self.provider]
audiofile["length"] = str(meta_tags["duration"]) audiofile["length"] = str(meta_tags["duration"])
if meta_tags["publisher"]: if meta_tags["publisher"]:
audiofile["encodedby"] = meta_tags["publisher"] audiofile["encodedby"] = meta_tags["publisher"]
@@ -78,7 +80,7 @@ class EmbedMetadata:
audiofile["TYER"] = TYER(encoding=3, text=meta_tags["year"]) audiofile["TYER"] = TYER(encoding=3, text=meta_tags["year"])
if meta_tags["publisher"]: if meta_tags["publisher"]:
audiofile["TPUB"] = TPUB(encoding=3, text=meta_tags["publisher"]) audiofile["TPUB"] = TPUB(encoding=3, text=meta_tags["publisher"])
audiofile["COMM"] = COMM(encoding=3, text=meta_tags["external_urls"]["spotify"]) audiofile["COMM"] = COMM(encoding=3, text=meta_tags["external_urls"][self.provider])
if meta_tags["lyrics"]: if meta_tags["lyrics"]:
audiofile["USLT"] = USLT( audiofile["USLT"] = USLT(
encoding=3, desc=u"Lyrics", text=meta_tags["lyrics"] encoding=3, desc=u"Lyrics", text=meta_tags["lyrics"]
@@ -106,7 +108,7 @@ class EmbedMetadata:
audiofile = MP4(music_file) audiofile = MP4(music_file)
self._embed_basic_metadata(audiofile, preset=M4A_TAG_PRESET) self._embed_basic_metadata(audiofile, preset=M4A_TAG_PRESET)
audiofile[M4A_TAG_PRESET["year"]] = meta_tags["year"] audiofile[M4A_TAG_PRESET["year"]] = meta_tags["year"]
audiofile[M4A_TAG_PRESET["comment"]] = meta_tags["external_urls"]["spotify"] audiofile[M4A_TAG_PRESET["comment"]] = meta_tags["external_urls"][self.provider]
if meta_tags["lyrics"]: if meta_tags["lyrics"]:
audiofile[M4A_TAG_PRESET["lyrics"]] = meta_tags["lyrics"] audiofile[M4A_TAG_PRESET["lyrics"]] = meta_tags["lyrics"]
try: try:
@@ -127,7 +129,7 @@ class EmbedMetadata:
audiofile = FLAC(music_file) audiofile = FLAC(music_file)
self._embed_basic_metadata(audiofile) self._embed_basic_metadata(audiofile)
audiofile["year"] = meta_tags["year"] audiofile["year"] = meta_tags["year"]
audiofile["comment"] = meta_tags["external_urls"]["spotify"] audiofile["comment"] = meta_tags["external_urls"][self.provider]
if meta_tags["lyrics"]: if meta_tags["lyrics"]:
audiofile["lyrics"] = meta_tags["lyrics"] audiofile["lyrics"] = meta_tags["lyrics"]
@@ -146,8 +148,10 @@ class EmbedMetadata:
def _embed_basic_metadata(self, audiofile, preset=TAG_PRESET): def _embed_basic_metadata(self, audiofile, preset=TAG_PRESET):
meta_tags = self.meta_tags meta_tags = self.meta_tags
audiofile[preset["artist"]] = meta_tags["artists"][0]["name"] audiofile[preset["artist"]] = meta_tags["artists"][0]["name"]
audiofile[preset["albumartist"]] = meta_tags["artists"][0]["name"] if meta_tags["album"]["artists"][0]["name"]:
audiofile[preset["album"]] = meta_tags["album"]["name"] audiofile[preset["albumartist"]] = meta_tags["album"]["artists"][0]["name"]
if meta_tags["album"]["name"]:
audiofile[preset["album"]] = meta_tags["album"]["name"]
audiofile[preset["title"]] = meta_tags["name"] audiofile[preset["title"]] = meta_tags["name"]
audiofile[preset["date"]] = meta_tags["release_date"] audiofile[preset["date"]] = meta_tags["release_date"]
audiofile[preset["originaldate"]] = meta_tags["release_date"] audiofile[preset["originaldate"]] = meta_tags["release_date"]

55
spotdl/patcher.py Normal file
View File

@@ -0,0 +1,55 @@
from pafy import backend_youtube_dl
import pafy
from spotdl import internals
def _getbestthumb(self):
url = self._ydl_info["thumbnails"][0]["url"]
if url:
return url
part_url = "https://i.ytimg.com/vi/%s/" % self.videoid
# Thumbnail resolution sorted in descending order
thumbs = ("maxresdefault.jpg",
"sddefault.jpg",
"hqdefault.jpg",
"mqdefault.jpg",
"default.jpg")
for thumb in thumbs:
url = part_url + thumb
if self._content_available(url):
return url
def _process_streams(self):
for format_index in range(len(self._ydl_info['formats'])):
try:
self._ydl_info['formats'][format_index]['url'] = self._ydl_info['formats'][format_index]['fragment_base_url']
except KeyError:
pass
return backend_youtube_dl.YtdlPafy._old_process_streams(self)
@classmethod
def _content_available(cls, url):
return internals.content_available(url)
class PatchPafy:
"""
These patches have not been released by pafy on PyPI yet but
are useful to us.
"""
def patch_getbestthumb(self):
# https://github.com/mps-youtube/pafy/pull/211
pafy.backend_shared.BasePafy._bestthumb = None
pafy.backend_shared.BasePafy._content_available = _content_available
pafy.backend_shared.BasePafy.getbestthumb = _getbestthumb
def patch_process_streams(self):
# https://github.com/mps-youtube/pafy/pull/230
backend_youtube_dl.YtdlPafy._old_process_streams = backend_youtube_dl.YtdlPafy._process_streams
backend_youtube_dl.YtdlPafy._process_streams = _process_streams
def patch_insecure_streams(self):
# https://github.com/mps-youtube/pafy/pull/235
pafy.g.def_ydl_opts["prefer_insecure"] = False

View File

@@ -28,7 +28,8 @@ def match_args():
track_dl.download_single() track_dl.download_single()
elif const.args.list: elif const.args.list:
if const.args.write_m3u: if const.args.write_m3u:
youtube_tools.generate_m3u(track_file=const.args.list) youtube_tools.generate_m3u(track_file=const.args.list,
text_file=const.args.write_to)
else: else:
list_dl = downloader.ListDownloader( list_dl = downloader.ListDownloader(
tracks_file=const.args.list, tracks_file=const.args.list,
@@ -37,22 +38,22 @@ def match_args():
) )
list_dl.download_list() list_dl.download_list()
elif const.args.playlist: elif const.args.playlist:
spotify_tools.write_playlist(playlist_url=const.args.playlist) spotify_tools.write_playlist(playlist_url=const.args.playlist,
text_file=const.args.write_to)
elif const.args.album: elif const.args.album:
spotify_tools.write_album(album_url=const.args.album) spotify_tools.write_album(album_url=const.args.album,
text_file=const.args.write_to)
elif const.args.all_albums: elif const.args.all_albums:
spotify_tools.write_all_albums_from_artist(artist_url=const.args.all_albums) spotify_tools.write_all_albums_from_artist(artist_url=const.args.all_albums,
text_file=const.args.write_to)
elif const.args.username: elif const.args.username:
spotify_tools.write_user_playlist(username=const.args.username) spotify_tools.write_user_playlist(username=const.args.username,
text_file=const.args.write_to)
def main(): def main():
const.args = handle.get_arguments() const.args = handle.get_arguments()
if const.args.version:
print("spotdl {version}".format(version=__version__))
sys.exit()
internals.filter_path(const.args.folder) internals.filter_path(const.args.folder)
youtube_tools.set_api_key() youtube_tools.set_api_key()

View File

@@ -7,33 +7,39 @@ from titlecase import titlecase
from logzero import logger as log from logzero import logger as log
import pprint import pprint
import sys import sys
import os
import functools
from spotdl import const
from spotdl import internals from spotdl import internals
spotify = None
def generate_token(): def generate_token():
""" Generate the token. Please respect these credentials :) """ """ Generate the token. """
credentials = oauth2.SpotifyClientCredentials( credentials = oauth2.SpotifyClientCredentials(
client_id="4fe3fecfe5334023a1472516cc99d805", client_id=const.args.spotify_client_id,
client_secret="0f02b7c483c04257984695007a4a8d5c", client_secret=const.args.spotify_client_secret,
) )
token = credentials.get_access_token() token = credentials.get_access_token()
return token return token
def refresh_token(): def must_be_authorized(func, spotify=spotify):
""" Refresh expired token""" def wrapper(*args, **kwargs):
global spotify global spotify
new_token = generate_token() try:
spotify = spotipy.Spotify(auth=new_token) assert spotify
return func(*args, **kwargs)
except (AssertionError, spotipy.client.SpotifyException):
# token is mandatory when using Spotify's API token = generate_token()
# https://developer.spotify.com/news-stories/2017/01/27/removing-unauthenticated-calls-to-the-web-api/ spotify = spotipy.Spotify(auth=token)
_token = generate_token() return func(*args, **kwargs)
spotify = spotipy.Spotify(auth=_token) return wrapper
@must_be_authorized
def generate_metadata(raw_song): def generate_metadata(raw_song):
""" Fetch a song's metadata from Spotify. """ """ Fetch a song's metadata from Spotify. """
if internals.is_spotify(raw_song): if internals.is_spotify(raw_song):
@@ -79,6 +85,7 @@ def generate_metadata(raw_song):
# Some sugar # Some sugar
meta_tags["year"], *_ = meta_tags["release_date"].split("-") meta_tags["year"], *_ = meta_tags["release_date"].split("-")
meta_tags["duration"] = meta_tags["duration_ms"] / 1000.0 meta_tags["duration"] = meta_tags["duration_ms"] / 1000.0
meta_tags["spotify_metadata"] = True
# Remove unwanted parameters # Remove unwanted parameters
del meta_tags["duration_ms"] del meta_tags["duration_ms"]
del meta_tags["available_markets"] del meta_tags["available_markets"]
@@ -88,6 +95,15 @@ def generate_metadata(raw_song):
return meta_tags return meta_tags
@must_be_authorized
def write_user_playlist(username, text_file=None):
""" Write user playlists to text_file """
links = get_playlists(username=username)
playlist = internals.input_link(links)
return write_playlist(playlist, text_file)
@must_be_authorized
def get_playlists(username): def get_playlists(username):
""" Fetch user playlists when using the -u option. """ """ Fetch user playlists when using the -u option. """
playlists = spotify.user_playlists(username) playlists = spotify.user_playlists(username)
@@ -116,12 +132,7 @@ def get_playlists(username):
return links return links
def write_user_playlist(username, text_file=None): @must_be_authorized
links = get_playlists(username=username)
playlist = internals.input_link(links)
return write_playlist(playlist, text_file)
def fetch_playlist(playlist): def fetch_playlist(playlist):
try: try:
playlist_id = internals.extract_spotify_id(playlist) playlist_id = internals.extract_spotify_id(playlist)
@@ -141,6 +152,7 @@ def fetch_playlist(playlist):
return results return results
@must_be_authorized
def write_playlist(playlist_url, text_file=None): def write_playlist(playlist_url, text_file=None):
playlist = fetch_playlist(playlist_url) playlist = fetch_playlist(playlist_url)
tracks = playlist["tracks"] tracks = playlist["tracks"]
@@ -149,19 +161,21 @@ def write_playlist(playlist_url, text_file=None):
return write_tracks(tracks, text_file) return write_tracks(tracks, text_file)
@must_be_authorized
def fetch_album(album): def fetch_album(album):
album_id = internals.extract_spotify_id(album) album_id = internals.extract_spotify_id(album)
album = spotify.album(album_id) album = spotify.album(album_id)
return album return album
def fetch_albums_from_artist(artist_url, album_type="album"): @must_be_authorized
def fetch_albums_from_artist(artist_url, album_type=None):
""" """
This funcction returns all the albums from a give artist_url using the US This funcction returns all the albums from a give artist_url using the US
market market
:param artist_url - spotify artist url :param artist_url - spotify artist url
:param album_type - the type of album to fetch (ex: single) the default is :param album_type - the type of album to fetch (ex: single) the default is
a standard album all albums
:param return - the album from the artist :param return - the album from the artist
""" """
@@ -180,6 +194,7 @@ def fetch_albums_from_artist(artist_url, album_type="album"):
return albums return albums
@must_be_authorized
def write_all_albums_from_artist(artist_url, text_file=None): 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 This function gets all albums from an artist and writes it to a file in the
@@ -192,7 +207,7 @@ def write_all_albums_from_artist(artist_url, text_file=None):
album_base_url = "https://open.spotify.com/album/" album_base_url = "https://open.spotify.com/album/"
# fetching all default albums # fetching all default albums
albums = fetch_albums_from_artist(artist_url) albums = fetch_albums_from_artist(artist_url, album_type=None)
# if no file if given, the default save file is in the current working # if no file if given, the default save file is in the current working
# directory with the name of the artist # directory with the name of the artist
@@ -204,14 +219,8 @@ def write_all_albums_from_artist(artist_url, text_file=None):
log.info("Fetching album: " + album["name"]) log.info("Fetching album: " + album["name"])
write_album(album_base_url + album["id"], text_file=text_file) write_album(album_base_url + album["id"], text_file=text_file)
# fetching all single albums
singles = fetch_albums_from_artist(artist_url, album_type="single")
for single in singles:
log.info("Fetching single: " + single["name"])
write_album(album_base_url + single["id"], text_file=text_file)
@must_be_authorized
def write_album(album_url, text_file=None): def write_album(album_url, text_file=None):
album = fetch_album(album_url) album = fetch_album(album_url)
tracks = spotify.album_tracks(album["id"]) tracks = spotify.album_tracks(album["id"])
@@ -220,6 +229,7 @@ def write_album(album_url, text_file=None):
return write_tracks(tracks, text_file) return write_tracks(tracks, text_file)
@must_be_authorized
def write_tracks(tracks, text_file): 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 = [] track_urls = []

View File

@@ -14,6 +14,15 @@ from spotdl import const
# Read more on mps-youtube/pafy#199 # Read more on mps-youtube/pafy#199
pafy.g.opener.addheaders.append(("Range", "bytes=0-")) pafy.g.opener.addheaders.append(("Range", "bytes=0-"))
# Implement unreleased methods on Pafy object
# More info: https://github.com/mps-youtube/pafy/pull/211
if pafy.__version__ <= "0.5.4":
from spotdl import patcher
pafy_patcher = patcher.PatchPafy()
pafy_patcher.patch_getbestthumb()
pafy_patcher.patch_process_streams()
pafy_patcher.patch_insecure_streams()
def set_api_key(): def set_api_key():
if const.args.youtube_api_key: if const.args.youtube_api_key:
@@ -39,28 +48,76 @@ def go_pafy(raw_song, meta_tags=None):
return track_info return track_info
def match_video_and_metadata(track, force_pafy=True): def match_video_and_metadata(track):
""" Get and match track data from YouTube and Spotify. """ """ Get and match track data from YouTube and Spotify. """
meta_tags = None meta_tags = None
def fallback_metadata(meta_tags):
fallback_metadata_info = "Track not found on Spotify, falling back on YouTube metadata"
skip_fallback_metadata_warning = "Fallback condition not met, shall not embed metadata"
if meta_tags is None:
if const.args.no_fallback_metadata:
log.warning(skip_fallback_metadata_warning)
else:
log.info(fallback_metadata_info)
meta_tags = generate_metadata(content)
return meta_tags
if internals.is_youtube(track): if internals.is_youtube(track):
log.debug("Input song is a YouTube URL") log.debug("Input song is a YouTube URL")
content = go_pafy(track, meta_tags=None) content = go_pafy(track, meta_tags=None)
track = slugify(content.title).replace("-", " ") track = slugify(content.title).replace("-", " ")
if not const.args.no_metadata: if not const.args.no_metadata:
meta_tags = spotify_tools.generate_metadata(track) meta_tags = spotify_tools.generate_metadata(track)
else: meta_tags = fallback_metadata(meta_tags)
# 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: elif internals.is_spotify(track):
content = go_pafy(track, meta_tags) log.debug("Input song is a Spotify URL")
# Let it generate metadata, YouTube doesn't know Spotify slang
meta_tags = spotify_tools.generate_metadata(track)
content = go_pafy(track, meta_tags)
if const.args.no_metadata:
meta_tags = None
else:
log.debug("Input song is plain text based")
if const.args.no_metadata:
content = go_pafy(track, meta_tags=None)
else: else:
content = None meta_tags = spotify_tools.generate_metadata(track)
content = go_pafy(track, meta_tags=meta_tags)
meta_tags = fallback_metadata(meta_tags)
return content, meta_tags return content, meta_tags
def generate_metadata(content):
""" Fetch a song's metadata from YouTube. """
meta_tags = {"spotify_metadata": False,
"name": content.title,
"artists": [{"name": content.author}],
"duration": content.length,
"external_urls": {"youtube": content.watchv_url},
"album": {"images" : [{"url": content.getbestthumb()}],
"artists": [{"name": None}],"name": None},
"year": content.published.split("-")[0],
"release_date": content.published.split(" ")[0],
"type": "track",
"disc_number": 1,
"track_number": 1,
"total_tracks": 1,
"publisher": None,
"external_ids": {"isrc": None},
"lyrics": None,
"copyright": None,
"genre": None,
}
return meta_tags
def get_youtube_title(content, number=None): def get_youtube_title(content, number=None):
""" Get the YouTube video's title. """ """ Get the YouTube video's title. """
title = content.title title = content.title

View File

@@ -1,6 +1,7 @@
from spotdl import const from spotdl import const
from spotdl import handle from spotdl import handle
from spotdl import spotdl from spotdl import spotdl
import urllib
import pytest import pytest
@@ -13,3 +14,13 @@ def load_defaults():
spotdl.log = const.logzero.setup_logger( spotdl.log = const.logzero.setup_logger(
formatter=const._formatter, level=const.args.log_level formatter=const._formatter, level=const.args.log_level
) )
# GIST_URL is the monkeypatched version of: https://www.youtube.com/results?search_query=janji+-+heroes
# so that we get same results even if YouTube changes the list/order of videos on their page.
GIST_URL = "https://gist.githubusercontent.com/ritiek/e731338e9810e31c2f00f13c249a45f5/raw/c11a27f3b5d11a8d082976f1cdd237bd605ec2c2/search_results.html"
def monkeypatch_youtube_search_page(*args, **kwargs):
fake_urlopen = urllib.request.urlopen(GIST_URL)
return fake_urlopen

View File

@@ -1,4 +1,3 @@
import urllib
import subprocess import subprocess
import os import os
@@ -39,7 +38,7 @@ def metadata_fixture():
def test_metadata(metadata_fixture): def test_metadata(metadata_fixture):
expect_number = 23 expect_number = 24
assert len(metadata_fixture) == expect_number assert len(metadata_fixture) == expect_number
@@ -54,16 +53,11 @@ class TestFileFormat:
assert title == EXPECTED_SPOTIFY_TITLE.replace(" ", "_") 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): def test_youtube_url(metadata_fixture, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
youtube_tools.GenerateYouTubeURL, youtube_tools.GenerateYouTubeURL,
"_fetch_response", "_fetch_response",
monkeypatch_youtube_search_page, loader.monkeypatch_youtube_search_page,
) )
url = youtube_tools.generate_youtube_url(SPOTIFY_TRACK_URL, metadata_fixture) url = youtube_tools.generate_youtube_url(SPOTIFY_TRACK_URL, metadata_fixture)
assert url == EXPECTED_YOUTUBE_URL assert url == EXPECTED_YOUTUBE_URL
@@ -73,7 +67,7 @@ def test_youtube_title(metadata_fixture, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
youtube_tools.GenerateYouTubeURL, youtube_tools.GenerateYouTubeURL,
"_fetch_response", "_fetch_response",
monkeypatch_youtube_search_page, loader.monkeypatch_youtube_search_page,
) )
content = youtube_tools.go_pafy(SPOTIFY_TRACK_URL, metadata_fixture) content = youtube_tools.go_pafy(SPOTIFY_TRACK_URL, metadata_fixture)
pytest.content_fixture = content pytest.content_fixture = content
@@ -107,22 +101,16 @@ class TestDownload:
def test_m4a(self, monkeypatch, filename_fixture): def test_m4a(self, monkeypatch, filename_fixture):
expect_download = True expect_download = True
monkeypatch.setattr( monkeypatch.setattr("pafy.backend_shared.BaseStream.download", self.blank_audio_generator)
"pafy.backend_shared.BaseStream.download", self.blank_audio_generator monkeypatch.setattr("pafy.backend_youtube_dl.YtdlStream.download", self.blank_audio_generator)
) download = youtube_tools.download_song(filename_fixture + ".m4a", pytest.content_fixture)
download = youtube_tools.download_song(
filename_fixture + ".m4a", pytest.content_fixture
)
assert download == expect_download assert download == expect_download
def test_webm(self, monkeypatch, filename_fixture): def test_webm(self, monkeypatch, filename_fixture):
expect_download = True expect_download = True
monkeypatch.setattr( monkeypatch.setattr("pafy.backend_shared.BaseStream.download", self.blank_audio_generator)
"pafy.backend_shared.BaseStream.download", self.blank_audio_generator monkeypatch.setattr("pafy.backend_youtube_dl.YtdlStream.download", self.blank_audio_generator)
) download = youtube_tools.download_song(filename_fixture + ".webm", pytest.content_fixture)
download = youtube_tools.download_song(
filename_fixture + ".webm", pytest.content_fixture
)
assert download == expect_download assert download == expect_download
@@ -188,6 +176,7 @@ class TestFFmpeg:
class TestAvconv: class TestAvconv:
@pytest.mark.skip(reason="avconv is no longer provided with FFmpeg")
def test_convert_from_m4a_to_mp3(self, filename_fixture, monkeypatch): def test_convert_from_m4a_to_mp3(self, filename_fixture, monkeypatch):
monkeypatch.setattr("os.remove", lambda x: None) monkeypatch.setattr("os.remove", lambda x: None)
expect_command = "avconv -loglevel 0 -i {0}.m4a -ab 192k {0}.mp3 -y".format( expect_command = "avconv -loglevel 0 -i {0}.m4a -ab 192k {0}.mp3 -y".format(

36
test/test_patcher.py Normal file
View File

@@ -0,0 +1,36 @@
from spotdl import patcher
import pafy
import pytest
pafy_patcher = patcher.PatchPafy()
pafy_patcher.patch_getbestthumb()
class TestPafyContentAvailable:
pass
class TestMethodAssignment:
def test_pafy_getbestthumb(self):
pafy.backend_shared.BasePafy.getbestthumb == patcher._getbestthumb
class TestMethodCalls:
@pytest.fixture(scope="module")
def content_fixture(self):
content = pafy.new("http://youtube.com/watch?v=3nQNiWdeH2Q")
return content
def test_pafy_getbestthumb(self, content_fixture):
thumbnail = patcher._getbestthumb(content_fixture)
assert thumbnail == "https://i.ytimg.com/vi/3nQNiWdeH2Q/maxresdefault.jpg"
def test_pafy_getbestthumb_without_ytdl(self, content_fixture):
content_fixture._ydl_info["thumbnails"][0]["url"] = None
thumbnail = patcher._getbestthumb(content_fixture)
assert thumbnail == "https://i.ytimg.com/vi/3nQNiWdeH2Q/maxresdefault.jpg"
def test_pafy_content_available(self):
TestPafyContentAvailable._content_available = patcher._content_available
assert TestPafyContentAvailable()._content_available("https://youtube.com/")

View File

@@ -1,7 +1,13 @@
from spotdl import spotify_tools from spotdl import spotify_tools
from spotdl import const
import spotipy
import os import os
import pytest import pytest
import loader
loader.load_defaults()
def test_generate_token(): def test_generate_token():
@@ -9,11 +15,33 @@ def test_generate_token():
assert len(token) == 83 assert len(token) == 83
def test_refresh_token(): class TestMustBeAuthorizedDecorator:
old_instance = spotify_tools.spotify def test_spotify_instance_is_unset(self):
spotify_tools.refresh_token() spotify_tools.spotify = None
new_instance = spotify_tools.spotify
assert not old_instance == new_instance @spotify_tools.must_be_authorized
def sample_func():
return True
assert sample_func()
def test_spotify_instance_forces_assertion_error(self):
@spotify_tools.must_be_authorized
def sample_func():
raise AssertionError
with pytest.raises(AssertionError):
sample_func()
def test_fake_token_generator(self, monkeypatch):
spotify_tools.spotify = None
monkeypatch.setattr(spotify_tools, "generate_token", lambda: 123123)
with pytest.raises(spotipy.client.SpotifyException):
spotify_tools.generate_metadata("ncs - spectre")
def test_correct_token(self):
assert spotify_tools.generate_metadata("ncs - spectre")
class TestGenerateMetadata: class TestGenerateMetadata:
@@ -23,7 +51,7 @@ class TestGenerateMetadata:
return metadata return metadata
def test_len(self, metadata_fixture): def test_len(self, metadata_fixture):
assert len(metadata_fixture) == 23 assert len(metadata_fixture) == 24
def test_trackname(self, metadata_fixture): def test_trackname(self, metadata_fixture):
assert metadata_fixture["name"] == "Spectre" assert metadata_fixture["name"] == "Spectre"
@@ -87,7 +115,7 @@ def test_write_playlist(tmpdir):
assert tracks == expect_tracks assert tracks == expect_tracks
# XXX: Mock this test off if it fails in future # XXX: Monkeypatch these tests if they fail in future
class TestFetchAlbum: class TestFetchAlbum:
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def album_fixture(self): def album_fixture(self):
@@ -103,7 +131,7 @@ class TestFetchAlbum:
assert album_fixture["tracks"]["total"] == 15 assert album_fixture["tracks"]["total"] == 15
# XXX: Mock this test off if it fails in future # XXX: Monkeypatch these tests if they fail in future
class TestFetchAlbumsFromArtist: class TestFetchAlbumsFromArtist:
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def albums_from_artist_fixture(self): def albums_from_artist_fixture(self):
@@ -113,7 +141,7 @@ class TestFetchAlbumsFromArtist:
return albums return albums
def test_len(self, albums_from_artist_fixture): def test_len(self, albums_from_artist_fixture):
assert len(albums_from_artist_fixture) == 18 assert len(albums_from_artist_fixture) == 53
def test_zeroth_album_name(self, albums_from_artist_fixture): def test_zeroth_album_name(self, albums_from_artist_fixture):
assert albums_from_artist_fixture[0]["name"] == "Revolution Radio" assert albums_from_artist_fixture[0]["name"] == "Revolution Radio"
@@ -128,11 +156,8 @@ class TestFetchAlbumsFromArtist:
assert albums_from_artist_fixture[0]["total_tracks"] == 12 assert albums_from_artist_fixture[0]["total_tracks"] == 12
# XXX: Mock this test off if it fails in future
def test_write_all_albums_from_artist(tmpdir): def test_write_all_albums_from_artist(tmpdir):
# current number of tracks on spotify since as of 10/10/2018 expect_tracks = 282
# in US market only
expect_tracks = 49
text_file = os.path.join(str(tmpdir), "test_ab.txt") text_file = os.path.join(str(tmpdir), "test_ab.txt")
spotify_tools.write_all_albums_from_artist( spotify_tools.write_all_albums_from_artist(
"https://open.spotify.com/artist/4dpARuHxo51G3z768sgnrY", text_file "https://open.spotify.com/artist/4dpARuHxo51G3z768sgnrY", text_file

View File

@@ -17,7 +17,6 @@ YT_API_KEY = "AIzaSyAnItl3udec-Q1d5bkjKJGL-RgrKO_vU90"
TRACK_SEARCH = "Tony's Videos VERY SHORT VIDEO 28.10.2016" TRACK_SEARCH = "Tony's Videos VERY SHORT VIDEO 28.10.2016"
EXPECTED_TITLE = TRACK_SEARCH EXPECTED_TITLE = TRACK_SEARCH
EXPECTED_YT_URL = "http://youtube.com/watch?v=qOOcy2-tmbk" 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" RESULT_COUNT_SEARCH = "she is still sleeping SAO"
@@ -71,8 +70,7 @@ class TestYouTubeURL:
def test_only_music_category(self, metadata_fixture): def test_only_music_category(self, metadata_fixture):
const.args.music_videos_only = True const.args.music_videos_only = True
url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata_fixture) url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata_fixture)
# YouTube keeps changing its results assert url == EXPECTED_YT_URL
assert url in EXPECTED_YT_URLS
def test_all_categories(self, metadata_fixture): def test_all_categories(self, metadata_fixture):
const.args.music_videos_only = False const.args.music_videos_only = False
@@ -99,6 +97,56 @@ def content_fixture(metadata_fixture):
return content return content
# True = Metadata must be fetched from Spotify
# False = Metadata must be fetched from YouTube
# None = Metadata must be `None`
MATCH_METADATA_NO_FALLBACK_TEST_TABLE = [
("https://open.spotify.com/track/5nWduGwBGBn1PSqYTJUDbS", True),
("http://youtube.com/watch?v=3nQNiWdeH2Q", None),
("Linux Talk | Working with Drives and Filesystems", None)
]
MATCH_METADATA_FALLBACK_TEST_TABLE = [
("https://open.spotify.com/track/5nWduGwBGBn1PSqYTJUDbS", True),
("http://youtube.com/watch?v=3nQNiWdeH2Q", False),
("Linux Talk | Working with Drives and Filesystems", False)
]
MATCH_METADATA_NO_METADATA_TEST_TABLE = [
("https://open.spotify.com/track/5nWduGwBGBn1PSqYTJUDbS", None),
("http://youtube.com/watch?v=3nQNiWdeH2Q", None),
("Linux Talk | Working with Drives and Filesystems", None)
]
class TestMetadataOrigin:
def match_metadata(self, track, metadata_type):
_, metadata = youtube_tools.match_video_and_metadata(track)
if metadata_type is None:
assert metadata == metadata_type
else:
assert metadata["spotify_metadata"] == metadata_type
@pytest.mark.parametrize("track, metadata_type", MATCH_METADATA_NO_FALLBACK_TEST_TABLE)
def test_match_metadata_with_no_fallback(self, track, metadata_type, content_fixture, monkeypatch):
monkeypatch.setattr(youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture)
const.args.no_fallback_metadata = True
self.match_metadata(track, metadata_type)
@pytest.mark.parametrize("track, metadata_type", MATCH_METADATA_FALLBACK_TEST_TABLE)
def test_match_metadata_with_fallback(self, track, metadata_type, content_fixture, monkeypatch):
monkeypatch.setattr(youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture)
const.args.no_fallback_metadata = False
self.match_metadata(track, metadata_type)
@pytest.mark.parametrize("track, metadata_type", MATCH_METADATA_NO_METADATA_TEST_TABLE)
def test_match_metadata_with_no_metadata(self, track, metadata_type, content_fixture, monkeypatch):
monkeypatch.setattr(youtube_tools, "go_pafy", lambda track, meta_tags: content_fixture)
const.args.no_metadata = True
self.match_metadata(track, metadata_type)
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def title_fixture(content_fixture): def title_fixture(content_fixture):
title = youtube_tools.get_youtube_title(content_fixture) title = youtube_tools.get_youtube_title(content_fixture)
@@ -136,6 +184,26 @@ def test_check_exists(metadata_fixture, filename_fixture, tmpdir):
assert check == expect_check assert check == expect_check
def test_generate_m3u(tmpdir, monkeypatch):
monkeypatch.setattr(youtube_tools.GenerateYouTubeURL, "_fetch_response", loader.monkeypatch_youtube_search_page)
expect_m3u = (
"#EXTM3U\n\n"
"#EXTINF:208,Janji - Heroes Tonight (feat. Johnning) [NCS Release]\n"
"http://www.youtube.com/watch?v=3nQNiWdeH2Q\n"
"#EXTINF:226,Alan Walker - Spectre [NCS Release]\n"
"http://www.youtube.com/watch?v=AOeY-nDp7hI\n"
)
m3u_track_file = os.path.join(str(tmpdir), "m3u_test.txt")
with open(m3u_track_file, "w") as track_file:
track_file.write("\nhttps://open.spotify.com/track/3SipFlNddvL0XNZRLXvdZD")
track_file.write("\nhttp://www.youtube.com/watch?v=AOeY-nDp7hI")
youtube_tools.generate_m3u(m3u_track_file)
m3u_file = "{}.m3u".format(m3u_track_file.split(".")[0])
with open(m3u_file, "r") as m3u_in:
m3u = m3u_in.readlines()
assert "".join(m3u) == expect_m3u
class TestDownload: class TestDownload:
def test_webm(self, content_fixture, filename_fixture, monkeypatch): def test_webm(self, content_fixture, filename_fixture, monkeypatch):
# content_fixture does not have any .webm audiostream # content_fixture does not have any .webm audiostream