diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ae8c23..d842ecd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,7 @@ 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). - 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`. diff --git a/README.md b/README.md index 5181f0c..2b32823 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Spotify-Downloader [![PyPi](https://img.shields.io/pypi/v/spotdl.svg)](https://pypi.org/project/spotdl) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) [![Build Status](https://travis-ci.org/ritiek/spotify-downloader.svg?branch=master)](https://travis-ci.org/ritiek/spotify-downloader) [![Coverage Status](https://codecov.io/gh/ritiek/spotify-downloader/branch/master/graph/badge.svg)](https://codecov.io/gh/ritiek/spotify-downloader) [![Docker Build Status](https://img.shields.io/docker/build/ritiek/spotify-downloader.svg)](https://hub.docker.com/r/ritiek/spotify-downloader) diff --git a/setup.py b/setup.py index 54e3a23..f747675 100644 --- a/setup.py +++ b/setup.py @@ -1,59 +1,64 @@ from setuptools import setup -with open('README.md', 'r') as f: +with open("README.md", "r") 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"]}, ) diff --git a/spotdl/__init__.py b/spotdl/__init__.py index 1f356cc..5becc17 100755 --- a/spotdl/__init__.py +++ b/spotdl/__init__.py @@ -1 +1 @@ -__version__ = '1.0.0' +__version__ = "1.0.0" diff --git a/spotdl/const.py b/spotdl/const.py index c9eaf59..635f389 100644 --- a/spotdl/const.py +++ b/spotdl/const.py @@ -1,6 +1,6 @@ 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) # Set up a temporary logger with default log level so that @@ -12,25 +12,27 @@ args = None # Apple has specific tags - see mutagen docs - # http://mutagen.readthedocs.io/en/latest/api/mp4.html -M4A_TAG_PRESET = { 'album' : '\xa9alb', - 'artist' : '\xa9ART', - 'date' : '\xa9day', - 'title' : '\xa9nam', - 'year' : '\xa9day', - 'originaldate' : 'purd', - 'comment' : '\xa9cmt', - 'group' : '\xa9grp', - 'writer' : '\xa9wrt', - 'genre' : '\xa9gen', - 'tracknumber' : 'trkn', - 'albumartist' : 'aART', - 'discnumber' : 'disk', - 'cpil' : 'cpil', - 'albumart' : 'covr', - 'copyright' : 'cprt', - 'tempo' : 'tmpo', - 'lyrics' : '\xa9lyr', - 'comment' : '\xa9cmt' } +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(): diff --git a/spotdl/convert.py b/spotdl/convert.py index 8964ecc..c898e81 100644 --- a/spotdl/convert.py +++ b/spotdl/convert.py @@ -20,8 +20,7 @@ def song(input_song, output_song, folder, avconv=False, trim_silence=False): 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])) + log.info("Converting {0} to {1}".format(input_song, output_song.split(".")[-1])) if avconv: exit_code = convert.with_avconv() else: @@ -37,54 +36,67 @@ class Converter: def with_avconv(self): if log.level == 10: - level = 'debug' + level = "debug" else: - level = '0' + level = "0" + + command = [ + "avconv", + "-loglevel", + level, + "-i", + self.input_file, + "-ab", + "192k", + self.output_file, + "-y", + ] - command = ['avconv', '-loglevel', level, '-i', - self.input_file, '-ab', '192k', - self.output_file, '-y'] - if self.trim_silence: - log.warning('--trim-silence not supported with avconv') - + log.warning("--trim-silence not supported with avconv") + log.debug(command) return subprocess.call(command) def with_ffmpeg(self): - ffmpeg_pre = 'ffmpeg -y ' + 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 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 ' + 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 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 ' + 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 output_ext == ".flac": + ffmpeg_params = "-codec:a flac -ar 44100 " # add common params for any of the above combination - ffmpeg_params += '-b:a 192k -vn ' - ffmpeg_pre += ' -i' - + ffmpeg_params += "-b:a 192k -vn " + ffmpeg_pre += " -i" + if self.trim_silence: - ffmpeg_params += '-af silenceremove=start_periods=1 ' - - command = ffmpeg_pre.split() + [self.input_file] + ffmpeg_params.split() + [self.output_file] + ffmpeg_params += "-af silenceremove=start_periods=1 " + + command = ( + ffmpeg_pre.split() + + [self.input_file] + + ffmpeg_params.split() + + [self.output_file] + ) log.debug(command) return subprocess.call(command) diff --git a/spotdl/handle.py b/spotdl/handle.py index 0eb028e..9a1408f 100644 --- a/spotdl/handle.py +++ b/spotdl/handle.py @@ -10,26 +10,30 @@ import os import sys -_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): @@ -48,136 +52,200 @@ 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", 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 .txt", + ) group.add_argument( - '-p', '--playlist', - help='load tracks from playlist URL into .txt') + "-b", "--album", help="load tracks from album URL into .txt" + ) group.add_argument( - '-b', '--album', - help='load tracks from album URL into .txt') + "-u", + "--username", + help="load tracks from user's playlist into .txt", + ) group.add_argument( - '-u', '--username', - help="load tracks from user's playlist into .txt") - group.add_argument( - '-V', '--version', - help="show version and exit", - action='store_true') + "-V", "--version", help="show version and exit", action="store_true" + ) parser.add_argument( - '-m', '--manual', default=config['manual'], - help='choose the track to download manually from a list ' - 'of matching tracks', - action='store_true') + "-m", + "--manual", + default=config["manual"], + help="choose the track to download manually from a list " "of matching tracks", + action="store_true", + ) parser.add_argument( - '-nm', '--no-metadata', default=config['no-metadata'], - help='do not embed metadata in tracks', action='store_true') + "-nm", + "--no-metadata", + default=config["no-metadata"], + help="do not embed metadata in tracks", + action="store_true", + ) parser.add_argument( - '-a', '--avconv', default=config['avconv'], - help='use avconv for conversion (otherwise defaults to ffmpeg)', - action='store_true') + "-a", + "--avconv", + default=config["avconv"], + help="use avconv for conversion (otherwise defaults to ffmpeg)", + action="store_true", + ) parser.add_argument( - '-f', '--folder', default=os.path.abspath(config['folder']), - help='path to folder where downloaded tracks will be stored in') + "-f", + "--folder", + default=os.path.abspath(config["folder"]), + help="path to folder where downloaded tracks will be stored in", + ) parser.add_argument( - '--overwrite', default=config['overwrite'], - help='change the overwrite policy', - choices={'prompt', 'force', 'skip'}) + "--overwrite", + default=config["overwrite"], + help="change the overwrite policy", + choices={"prompt", "force", "skip"}, + ) parser.add_argument( - '-i', '--input-ext', default=config['input-ext'], - help='preferred input format .m4a or .webm (Opus)', - choices={'.m4a', '.webm'}) + "-i", + "--input-ext", + default=config["input-ext"], + help="preferred input format .m4a or .webm (Opus)", + choices={".m4a", ".webm"}, + ) parser.add_argument( - '-o', '--output-ext', default=config['output-ext'], - help='preferred output format .mp3, .m4a (AAC), .flac, etc.') + "-o", + "--output-ext", + default=config["output-ext"], + help="preferred output format .mp3, .m4a (AAC), .flac, etc.", + ) 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])) + "-ff", + "--file-format", + default=config["file-format"], + help="file format to save the downloaded track with, each tag " + "is surrounded by curly braces. Possible formats: " + "{}".format([internals.formats[x] for x in internals.formats]), + ) parser.add_argument( - '--trim-silence', default=config['trim-silence'], - help='remove silence from the start of the audio', - action='store_true') + "--trim-silence", + default=config["trim-silence"], + help="remove silence from the start of the audio", + action="store_true", + ) parser.add_argument( - '-sf', '--search-format', default=config['search-format'], - help='search format to search for on YouTube, each tag ' - 'is surrounded by curly braces. Possible formats: ' - '{}'.format([internals.formats[x] for x in internals.formats])) + "-sf", + "--search-format", + default=config["search-format"], + help="search format to search for on YouTube, each tag " + "is surrounded by curly braces. Possible formats: " + "{}".format([internals.formats[x] for x in internals.formats]), + ) parser.add_argument( - '-dm', '--download-only-metadata', default=config['download-only-metadata'], - help='download tracks only whose metadata is found', - action='store_true') + "-dm", + "--download-only-metadata", + default=config["download-only-metadata"], + help="download tracks only whose metadata is found", + action="store_true", + ) parser.add_argument( - '-d', '--dry-run', default=config['dry-run'], - help='show only track title and YouTube URL, and then skip ' - 'to the next track (if any)', - action='store_true') + "-d", + "--dry-run", + default=config["dry-run"], + help="show only track title and YouTube URL, and then skip " + "to the next track (if any)", + action="store_true", + ) parser.add_argument( - '-mo', '--music-videos-only', default=config['music-videos-only'], - help='search only for music videos on Youtube (works only ' - 'when YouTube API key is set', - action='store_true') + "-mo", + "--music-videos-only", + default=config["music-videos-only"], + help="search only for music videos on Youtube (works only " + "when YouTube API key is set", + action="store_true", + ) parser.add_argument( - '-ns', '--no-spaces', default=config['no-spaces'], - help='replace spaces with underscores in file names', - action='store_true') + "-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'], + "-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" + ) parsed = parser.parse_args(raw_args) diff --git a/spotdl/internals.py b/spotdl/internals.py index 2461901..64d2ac0 100755 --- a/spotdl/internals.py +++ b/spotdl/internals.py @@ -4,49 +4,55 @@ from logzero import logger as log from spotdl import const +try: + import winreg +except ImportError: + pass try: from slugify import SLUG_OK, slugify except ImportError: - log.error('Oops! `unicode-slugify` was not found.') - log.info('Please remove any other slugify library and install `unicode-slugify`') + 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): """ Remove the first song from file. """ - with open(text_file, 'r') as file_in: + with open(text_file, "r") as file_in: data = file_in.read().splitlines(True) - with open(text_file, 'w') as file_out: + with open(text_file, "w") as file_out: file_out.writelines(data[1:]) return data[0] @@ -54,7 +60,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 +68,47 @@ 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="'-_()[]{}") + format_tags[tag] = sanitize_title(format_tags[tag], ok="'-_()[]{}") else: format_tags[tag] = str(format_tags[tag]) 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[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(" ", "_") # slugify removes any special characters title = slugify(title, ok=ok, lower=False, spaces=True) @@ -115,7 +119,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 +127,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 @@ -149,34 +154,74 @@ def get_sec(time_str): def get_splits(url): - if '/' in url: - if url.endswith('/'): + if "/" in url: + if url.endswith("/"): url = url[:-1] - splits = url.split('/') + splits = url.split("/") else: - splits = url.split(':') + splits = url.split(":") return splits -# a hacky way to user's localized music directory +def get_unique_tracks(text_file): + """ + Returns a list of unique tracks given a path to a + file containing tracks. + """ + + with open(text_file, "r") as listed: + # Read tracks into a list and remove any duplicates + lines = listed.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\\Music or /Users//Music # respectively is sufficient. # On Linux, default to /home//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))] diff --git a/spotdl/metadata.py b/spotdl/metadata.py index 089016b..2dd3bd3 100755 --- a/spotdl/metadata.py +++ b/spotdl/metadata.py @@ -12,12 +12,12 @@ 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 @@ -27,17 +27,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 @@ -56,34 +56,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']) - 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']) + 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 @@ -97,14 +104,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'] - audiofile[M4A_TAG_PRESET['comment']] = meta_tags['external_urls']['spotify'] - if meta_tags['lyrics']: - audiofile[M4A_TAG_PRESET['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 @@ -117,16 +125,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) @@ -136,27 +144,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["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"]) ] diff --git a/spotdl/spotdl.py b/spotdl/spotdl.py index d8cf061..7b33f9c 100755 --- a/spotdl/spotdl.py +++ b/spotdl/spotdl.py @@ -22,11 +22,13 @@ import pprint 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)) + 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'): + 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 @@ -35,76 +37,90 @@ def check_exists(music_file, raw_song, meta_tags): 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) + 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': + 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': + 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': + elif const.args.overwrite == "skip": log.info('Skipping "{}"'.format(song)) return True return False -def download_list(text_file): +def download_list(tracks_file, skip_file=None, write_successful_file=None): """ 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))) + log.info("Checking and removing any duplicate tracks") + tracks = internals.get_unique_tracks(tracks_file) + + # override file with unique tracks + with open(tracks_file, "w") as f: + f.write("\n".join(tracks)) + + # Remove tracks to skip from tracks list + if skip_file is not None: + skip_tracks = internals.get_unique_tracks(skip_file) + len_before = len(tracks) + tracks = [track for track in tracks if track not in skip_tracks] + log.info("Skipping {} tracks".format(len_before - len(tracks))) + + log.info(u"Preparing to download {} songs".format(len(tracks))) downloaded_songs = [] - for number, raw_song in enumerate(lines, 1): - print('') + for number, raw_song in enumerate(tracks, 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') + 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) + tracks.append(raw_song) # remove the downloaded song from file - internals.trim_song(text_file) + internals.trim_song(tracks_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') + with open(tracks_file, "a") as f: + f.write("\n" + raw_song) + 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) + # Add track to file of successful downloads + log.debug("Adding downloaded song to write successful file") + if write_successful_file is not None: + with open(write_successful_file, "a") as f: + f.write("\n" + raw_song) + log.debug("Removing downloaded song from tracks file") + internals.trim_song(tracks_file) return downloaded_songs @@ -113,39 +129,41 @@ 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') + log.debug("Input song is a YouTube URL") content = youtube_tools.go_pafy(raw_song, meta_tags=None) - raw_song = slugify(content.title).replace('-', ' ') + 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') + 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') + 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)) + 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 == ' - ': + 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') + log.warning("Could not find metadata") songname = internals.sanitize_title(songname) if const.args.dry_run: @@ -158,13 +176,18 @@ def download_single(raw_song, number=None): input_song = songname + const.args.input_ext output_song = songname + const.args.output_ext if youtube_tools.download_song(input_song, content): - print('') + print("") try: - convert.song(input_song, output_song, const.args.folder, - avconv=const.args.avconv, trim_silence=const.args.trim_silence) + 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)) + 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 @@ -179,7 +202,7 @@ def main(): const.args = handle.get_arguments() if const.args.version: - print('spotdl {version}'.format(version=__version__)) + print("spotdl {version}".format(version=__version__)) sys.exit() internals.filter_path(const.args.folder) @@ -187,15 +210,19 @@ def main(): logzero.setup_default_logger(formatter=const._formatter, level=const.args.log_level) - log.debug('Python version: {}'.format(sys.version)) - log.debug('Platform: {}'.format(platform.platform())) + log.debug("Python version: {}".format(sys.version)) + log.debug("Platform: {}".format(platform.platform())) log.debug(pprint.pformat(const.args.__dict__)) try: if const.args.song: download_single(raw_song=const.args.song) elif const.args.list: - download_list(text_file=const.args.list) + download_list( + tracks_file=const.args.list, + skip_file=const.args.skip, + write_successful_file=const.args.write_successful, + ) elif const.args.playlist: spotify_tools.write_playlist(playlist_url=const.args.playlist) elif const.args.album: @@ -212,5 +239,5 @@ def main(): sys.exit(3) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/spotdl/spotify_tools.py b/spotdl/spotify_tools.py index ef90764..75fdbc2 100644 --- a/spotdl/spotify_tools.py +++ b/spotdl/spotify_tools.py @@ -14,11 +14,13 @@ import sys 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 + # 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() @@ -29,51 +31,51 @@ 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 @@ -92,18 +94,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 @@ -117,15 +121,16 @@ def fetch_playlist(playlist): username = splits[-3] 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( + username, 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,9 +138,9 @@ 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='-_()[]{}')) + text_file = u"{0}.txt".format(slugify(playlist["name"], ok="-_()[]{}")) return write_tracks(tracks, text_file) @@ -148,34 +153,36 @@ def fetch_album(album): 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='-_()[]{}')) + text_file = u"{0}.txt".format(slugify(album["name"], ok="-_()[]{}")) return 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 = [] - 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 diff --git a/spotdl/youtube_tools.py b/spotdl/youtube_tools.py index 55264e4..da7379d 100644 --- a/spotdl/youtube_tools.py +++ b/spotdl/youtube_tools.py @@ -11,7 +11,7 @@ import pprint # 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(): @@ -19,7 +19,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) @@ -42,7 +42,7 @@ 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 @@ -50,20 +50,20 @@ def get_youtube_title(content, number=None): 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 @@ -72,23 +72,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 @@ -111,18 +113,25 @@ 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): """ 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: @@ -132,7 +141,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 @@ -143,16 +154,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.\n".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 @@ -163,33 +185,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() 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: @@ -197,40 +227,43 @@ 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: diff --git a/test/loader.py b/test/loader.py index c00f7ce..f176cee 100644 --- a/test/loader.py +++ b/test/loader.py @@ -1,14 +1,16 @@ 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 = handle.get_arguments(raw_args="", to_group=False, to_merge=False) + const.args.overwrite = "skip" const.args.log_level = 10 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 + ) diff --git a/test/test_dry_run.py b/test/test_dry_run.py index 31c9d0a..d9dc42e 100644 --- a/test/test_dry_run.py +++ b/test/test_dry_run.py @@ -1,18 +1,20 @@ -from spotdl import const - -from spotdl import spotdl -import loader import os +from spotdl import const +from spotdl import spotdl + +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) + file_path = os.path.join(const.args.folder, "test_list.txt") + with open(file_path, "w") as f: + f.write(TRACK_URL) downloaded_song, *_ = spotdl.download_list(file_path) - assert downloaded_song == song + assert downloaded_song == TRACK_URL diff --git a/test/test_handle.py b/test/test_handle.py index 6230a71..0cbfdfa 100644 --- a/test/test_handle.py +++ b/test/test_handle.py @@ -1,46 +1,47 @@ -import yaml +import os +import sys +import argparse from spotdl import handle from spotdl import const import pytest -import os -import sys -import argparse +import yaml 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 class TestConfig: def test_default_config(self, tmpdir): - expect_config = handle.default_conf['spotify-downloader'] + expect_config = handle.default_conf["spotify-downloader"] global config_path - config_path = os.path.join(str(tmpdir), 'config.yml') + config_path = os.path.join(str(tmpdir), "config.yml") config = handle.get_config(config_path) 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' + 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_custom_config_path(self, tmpdir): parser = argparse.ArgumentParser() - with open(config_path, 'w') as config_file: + 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() ] + overridden_config = handle.override_config(config_path, parser, raw_args="") + modified_values = [ + str(value) for value in modified_config["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) diff --git a/test/test_internals.py b/test/test_internals.py index 89e6380..35315dd 100644 --- a/test/test_internals.py +++ b/test/test_internals.py @@ -1,18 +1,54 @@ -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"), + ), +] + + 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 @@ -22,15 +58,15 @@ class TestPathFilterer: def test_create_directory(self, tmpdir): expect_path = True global folder_path - folder_path = os.path.join(str(tmpdir), 'filter_this_folder') + folder_path = os.path.join(str(tmpdir), "filter_this_folder") internals.filter_path(folder_path) is_path = os.path.isdir(folder_path) assert is_path == expect_path def test_remove_temp_files(self, tmpdir): expect_file = False - file_path = os.path.join(folder_path, 'pesky_file.temp') - open(file_path, 'a') + file_path = os.path.join(folder_path, "pesky_file.temp") + open(file_path, "a") internals.filter_path(folder_path) is_file = os.path.isfile(file_path) assert is_file == expect_file @@ -38,17 +74,17 @@ class TestPathFilterer: class TestVideoTimeFromSeconds: def test_from_seconds(self): - expect_duration = '35' + expect_duration = "35" duration = internals.videotime_from_seconds(35) assert duration == expect_duration def test_from_minutes(self): - expect_duration = '2:38' + expect_duration = "2:38" duration = internals.videotime_from_seconds(158) assert duration == expect_duration def test_from_hours(self): - expect_duration = '1:16:02' + expect_duration = "1:16:02" duration = internals.videotime_from_seconds(4562) assert duration == expect_duration @@ -56,27 +92,37 @@ class TestVideoTimeFromSeconds: class TestGetSeconds: def test_from_seconds(self): expect_secs = 45 - secs = internals.get_sec('0:45') + secs = internals.get_sec("0:45") assert secs == expect_secs - secs = internals.get_sec('0.45') + secs = internals.get_sec("0.45") assert secs == expect_secs def test_from_minutes(self): expect_secs = 213 - secs = internals.get_sec('3.33') + secs = internals.get_sec("3.33") assert secs == expect_secs - secs = internals.get_sec('3:33') + 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') + secs = internals.get_sec("1.30.05") assert secs == expect_secs - secs = internals.get_sec('1:30:05') + secs = internals.get_sec("1:30:05") assert secs == expect_secs def test_raise_error(self): with pytest.raises(ValueError): - internals.get_sec('10*05') + internals.get_sec("10*05") with pytest.raises(ValueError): - internals.get_sec('02,28,46') + internals.get_sec("02,28,46") + + +@pytest.mark.parametrize("duplicates, expected", DUPLICATE_TRACKS_TEST_TABLE) +def test_get_unique_tracks(tmpdir, duplicates, expected): + file_path = os.path.join(str(tmpdir), "test_duplicates.txt") + with open(file_path, "w") as f: + f.write("\n".join(duplicates)) + + unique_tracks = internals.get_unique_tracks(file_path) + assert tuple(unique_tracks) == expected diff --git a/test/test_list.py b/test/test_list.py index 19e7bb5..a44f86f 100644 --- a/test/test_list.py +++ b/test/test_list.py @@ -1,50 +1,52 @@ -from spotdl import spotify_tools -from spotdl import const - -from spotdl import spotdl - import builtins import os +from spotdl import spotify_tools +from spotdl import const +from spotdl import spotdl + +PLAYLIST_URL = "https://open.spotify.com/user/alex/playlist/0iWOVoumWlkXIrrBTSJmN8" +ALBUM_URL = "https://open.spotify.com/album/499J8bIsEnU7DSrosFDJJg" + 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()) + 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 f: + tracks = len(f.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()) + text_file = os.path.join(str(tmpdir), "test_pl.txt") + spotify_tools.write_playlist(PLAYLIST_URL, text_file) + with open(text_file, "r") as f: + tracks = len(f.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()) + text_file = os.path.join(str(tmpdir), "test_al.txt") + spotify_tools.write_album(ALBUM_URL, text_file) + with open(text_file, "r") as f: + tracks = len(f.readlines()) assert tracks == expect_tracks def test_trim(): - with open(text_file, 'r') as track_file: + 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: + with open(text_file, "r") as track_file: number = len(track_file.readlines()) - assert (expect_number == number and expect_track == track) + assert expect_number == number and expect_track == track diff --git a/test/test_with_metadata.py b/test/test_with_metadata.py index 86e914a..e90d419 100644 --- a/test/test_with_metadata.py +++ b/test/test_with_metadata.py @@ -1,3 +1,4 @@ +import os from spotdl import const from spotdl import internals @@ -5,48 +6,46 @@ 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' + +TRACK_URL = "http://open.spotify.com/track/0JlS7BXXD07hRmevDnbPDU" +EXPECTED_TITLE = "David André Østby - Intro" +EXPECTED_YT_TITLE = "Intro - David André Østby" +EXPECTED_YT_URL = "http://youtube.com/watch?v=rg1wfcty0BA" def test_metadata(): expect_number = 23 global meta_tags - meta_tags = spotify_tools.generate_metadata(raw_song) + meta_tags = spotify_tools.generate_metadata(TRACK_URL) 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 + assert title == EXPECTED_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 + assert title == EXPECTED_TITLE.replace(" ", "_") 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 + url = youtube_tools.generate_youtube_url(TRACK_URL, meta_tags) + assert url == EXPECTED_YT_URL def test_youtube_title(): - expect_title = 'Intro - David André Østby' global content - content = youtube_tools.go_pafy(raw_song, meta_tags) + content = youtube_tools.go_pafy(TRACK_URL, meta_tags) title = youtube_tools.get_youtube_title(content) - assert title == expect_title + assert title == EXPECTED_YT_TITLE def test_check_track_exists_before_download(tmpdir): @@ -56,67 +55,65 @@ def test_check_track_exists_before_download(tmpdir): 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) + check = spotdl.check_exists(file_name, TRACK_URL, meta_tags) assert check == expect_check class TestDownload: def test_m4a(self): expect_download = True - download = youtube_tools.download_song(file_name + '.m4a', content) + 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) + download = youtube_tools.download_song(file_name + ".webm", content) assert download == expect_download -class TestFFmpeg(): +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) + 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) + 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) + 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) + 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) + 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) + return_code = convert.song( + file_name + ".m4a", file_name + ".mp3", const.args.folder, avconv=True + ) assert return_code == expect_return_code @@ -125,30 +122,30 @@ class TestEmbedMetadata: expect_embed = True global track_path track_path = os.path.join(const.args.folder, file_name) - embed = metadata.embed(track_path + '.mp3', meta_tags) + 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') + 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') + 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') + 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') + check = spotdl.check_exists(file_name, TRACK_URL, meta_tags) + os.remove(track_path + ".mp3") assert check == expect_check diff --git a/test/test_without_metadata.py b/test/test_without_metadata.py index 5628ebf..ec95626 100644 --- a/test/test_without_metadata.py +++ b/test/test_without_metadata.py @@ -1,38 +1,47 @@ +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 spotdl + import loader -import os -import builtins - loader.load_defaults() -raw_song = "Tony's Videos VERY SHORT VIDEO 28.10.2016" + +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): - expect_key = 'some_api_key' - const.args.youtube_api_key = expect_key + const.args.youtube_api_key = EXPECTED_YT_API_KEY_CUSTOM youtube_tools.set_api_key() key = youtube_tools.pafy.g.api_key - assert key == expect_key + assert key == EXPECTED_YT_API_KEY_CUSTOM 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 + assert key == EXPECTED_YT_API_KEY def test_metadata(): expect_metadata = None global metadata - metadata = spotify_tools.generate_metadata(raw_song) + metadata = spotify_tools.generate_metadata(TRACK_SEARCH) assert metadata == expect_metadata @@ -40,15 +49,13 @@ 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) + 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("she is still sleeping SAO", - meta_tags=None) + 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) @@ -57,30 +64,26 @@ class TestArgsManualResultCount: 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 + url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata) + # YouTube keeps changing its results + assert url in EXPECTED_YT_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 + url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata) + assert url == EXPECTED_YT_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 + monkeypatch.setattr("builtins.input", lambda x: "1") + url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata) + assert url == EXPECTED_YT_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) + monkeypatch.setattr("builtins.input", lambda x: "0") + url = youtube_tools.generate_youtube_url(TRACK_SEARCH, metadata) const.args.manual = False assert url == expect_url @@ -89,21 +92,18 @@ 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 + const.args.youtube_api_key = YT_API_KEY youtube_tools.set_api_key() - content = youtube_tools.go_pafy(raw_song, metadata) + content = youtube_tools.go_pafy(TRACK_SEARCH, metadata) title = youtube_tools.get_youtube_title(content) - assert title == expect_title + assert title == EXPECTED_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) + content = youtube_tools.go_pafy(TRACK_SEARCH, metadata) title = youtube_tools.get_youtube_title(content, 1) - assert title == expect_title + assert title == "1. {0}".format(EXPECTED_TITLE) def test_check_exists(tmpdir): @@ -112,7 +112,7 @@ def test_check_exists(tmpdir): # prerequisites for determining filename global file_name file_name = internals.sanitize_title(title) - check = spotdl.check_exists(file_name, raw_song, metadata) + check = spotdl.check_exists(file_name, TRACK_SEARCH, metadata) assert check == expect_check @@ -120,10 +120,10 @@ 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) + 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) + download = youtube_tools.download_song(file_name + ".fake_extension", content) assert download == expect_download