diff --git a/.gitignore b/.gitignore index 0596b92..14b5977 100644 --- a/.gitignore +++ b/.gitignore @@ -160,4 +160,5 @@ cython_debug/ #.idea/ .vscode/* +!.vscode/extensions.json !.vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..553fc46 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,15 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. + // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp + + // List of extensions which should be recommended for users of this workspace. + "recommendations": [ + "matangover.mypy", + "ms-python.black-formatter", + "ms-python.flake8" + ], + // List of extensions recommended by VS Code that should not be recommended for users of this workspace. + "unwantedRecommendations": [ + + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index bce49fa..13b4248 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,7 @@ { - "python.linting.flake8Enabled": true, - "python.linting.mypyEnabled": true, - "python.linting.enabled": true, - "python.formatting.provider": "black", + "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": true }, - "isort.args": ["--profile", "black"] -} +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 631fd57..788a032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## v1.0.0 -An unexpected reboot +An unexpected reboot. ### BREAKING CHANGES AHEAD @@ -29,9 +29,14 @@ An unexpected reboot ### Additions - Added new command line arguments - - `--library`/`-l` overrides both `music_library` and `podcast_library` options similar to `--output` + - `--library`/`-l` overrides both `music_library` and `podcast_library` options similar to `--output`/`-o` - `--category`/`-c` will limit search results to a certain type, accepted values are "album", "artist", "playlist", "track", "show", "episode". Accepts multiple choices. - `--debug` shows full tracebacks on crash instead of just the final error message + - Added new shorthand aliases to some options: + - `-oa` = `--output-album` + - `-opt` = `--output-playlist-track` + - `-ope` = `--output-playlist-episode` + - `-op` = `--output-podcast` - Search results can be narrowed down using search filters - Available filters are 'album', 'artist', 'track', 'year', 'upc', 'tag:hipster', 'tag:new', 'isrc', and 'genre'. - The 'artist' and 'year' filters only shows results from the given year or a range (e.g. 1970-1982). diff --git a/setup.cfg b/setup.cfg index 27f0c12..8db7ba7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,19 @@ console_scripts = zotify = zotify.__main__:main [flake8] -# Conflicts with black -ignore = E203 max-line-length = 160 + +[mypy] +warn_unused_configs = True + +[mypy-librespot.*] +ignore_missing_imports = True + +[mypy-music_tag] +ignore_missing_imports = True + +[mypy-pwinput] +ignore_missing_imports = True + +[mypy-tqdm] +ignore_missing_imports = True diff --git a/zotify/__main__.py b/zotify/__main__.py index 623082a..adbb088 100644 --- a/zotify/__main__.py +++ b/zotify/__main__.py @@ -5,15 +5,16 @@ from pathlib import Path from zotify.app import App from zotify.config import CONFIG_PATHS, CONFIG_VALUES -from zotify.utils import OptionalOrFalse +from zotify.utils import OptionalOrFalse, SimpleHelpFormatter -VERSION = "0.9.2" +VERSION = "0.9.3" def main(): parser = ArgumentParser( prog="zotify", description="A fast and customizable music and podcast downloader", + formatter_class=SimpleHelpFormatter, ) parser.add_argument( "-v", @@ -39,7 +40,7 @@ def main(): help="Specify a path to the root of a music/playlist/podcast library", ) parser.add_argument( - "-o", "--output", type=str, help="Specify the output location/format" + "-o", "--output", type=str, help="Specify the output file structure/format" ) parser.add_argument( "-c", @@ -101,7 +102,7 @@ def main(): for k, v in CONFIG_VALUES.items(): if v["type"] == bool: parser.add_argument( - v["arg"], + *v["args"], action=OptionalOrFalse, default=v["default"], help=v["help"], @@ -109,7 +110,7 @@ def main(): else: try: parser.add_argument( - v["arg"], + *v["args"], type=v["type"], choices=v["choices"], default=None, @@ -117,7 +118,7 @@ def main(): ) except KeyError: parser.add_argument( - v["arg"], + *v["args"], type=v["type"], default=None, help=v["help"], diff --git a/zotify/app.py b/zotify/app.py index d9ba61d..e3569e0 100644 --- a/zotify/app.py +++ b/zotify/app.py @@ -19,7 +19,7 @@ from zotify.config import Config from zotify.file import TranscodingError from zotify.loader import Loader from zotify.printer import PrintChannel, Printer -from zotify.utils import API_URL, AudioFormat, b62_to_hex +from zotify.utils import API_URL, AudioFormat, MetadataEntry, b62_to_hex class ParseError(ValueError): @@ -36,7 +36,7 @@ class PlayableData(NamedTuple): id: PlayableId library: Path output: str - metadata: dict[str, Any] = {} + metadata: list[MetadataEntry] = [] class Selection: @@ -385,7 +385,7 @@ class App: self.__config.chunk_size, ) - if self.__config.save_lyrics_file and playable.type == PlayableType.TRACK: + if playable.type == PlayableType.TRACK and self.__config.lyrics_file: with Loader("Fetching lyrics..."): try: track.get_lyrics().save(output) diff --git a/zotify/config.py b/zotify/config.py index 8bbf79b..c2d1a68 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -17,6 +17,7 @@ DOWNLOAD_QUALITY = "download_quality" FFMPEG_ARGS = "ffmpeg_args" FFMPEG_PATH = "ffmpeg_path" LANGUAGE = "language" +LYRICS_FILE = "lyrics_file" LYRICS_ONLY = "lyrics_only" MUSIC_LIBRARY = "music_library" OUTPUT = "output" @@ -34,7 +35,6 @@ PRINT_PROGRESS = "print_progress" PRINT_SKIPS = "print_skips" PRINT_WARNINGS = "print_warnings" REPLACE_EXISTING = "replace_existing" -SAVE_LYRICS_FILE = "save_lyrics_file" SAVE_METADATA = "save_metadata" SAVE_SUBTITLES = "save_subtitles" SKIP_DUPLICATES = "skip_duplicates" @@ -72,190 +72,190 @@ CONFIG_VALUES = { CREDENTIALS: { "default": CONFIG_PATHS["creds"], "type": Path, - "arg": "--credentials", + "args": ["--credentials"], "help": "Path to credentials file", }, PATH_ARCHIVE: { "default": CONFIG_PATHS["archive"], "type": Path, - "arg": "--archive", + "args": ["--archive"], "help": "Path to track archive file", }, MUSIC_LIBRARY: { "default": LIBRARY_PATHS["music"], "type": Path, - "arg": "--music-library", + "args": ["--music-library"], "help": "Path to root of music library", }, PODCAST_LIBRARY: { "default": LIBRARY_PATHS["podcast"], "type": Path, - "arg": "--podcast-library", + "args": ["--podcast-library"], "help": "Path to root of podcast library", }, PLAYLIST_LIBRARY: { "default": LIBRARY_PATHS["playlist"], "type": Path, - "arg": "--playlist-library", + "args": ["--playlist-library"], "help": "Path to root of playlist library", }, OUTPUT_ALBUM: { "default": OUTPUT_PATHS["album"], "type": str, - "arg": "--output-album", + "args": ["--output-album", "-oa"], "help": "File layout for saved albums", }, OUTPUT_PLAYLIST_TRACK: { "default": OUTPUT_PATHS["playlist_track"], "type": str, - "arg": "--output-playlist-track", + "args": ["--output-playlist-track", "-opt"], "help": "File layout for tracks in a playlist", }, OUTPUT_PLAYLIST_EPISODE: { "default": OUTPUT_PATHS["playlist_episode"], "type": str, - "arg": "--output-playlist-episode", + "args": ["--output-playlist-episode", "-ope"], "help": "File layout for episodes in a playlist", }, OUTPUT_PODCAST: { "default": OUTPUT_PATHS["podcast"], "type": str, - "arg": "--output-podcast", + "args": ["--output-podcast", "-op"], "help": "File layout for saved podcasts", }, DOWNLOAD_QUALITY: { "default": "auto", "type": Quality.from_string, "choices": list(Quality), - "arg": "--download-quality", + "args": ["--download-quality"], "help": "Audio download quality (auto for highest available)", }, ARTWORK_SIZE: { "default": "large", "type": ImageSize.from_string, "choices": list(ImageSize), - "arg": "--artwork-size", + "args": ["--artwork-size"], "help": "Image size of track's cover art", }, AUDIO_FORMAT: { "default": "vorbis", "type": AudioFormat, "choices": [n.value.name for n in AudioFormat], - "arg": "--audio-format", + "args": ["--audio-format"], "help": "Audio format of final track output", }, TRANSCODE_BITRATE: { "default": -1, "type": int, - "arg": "--bitrate", + "args": ["--bitrate"], "help": "Transcoding bitrate (-1 to use download rate)", }, FFMPEG_PATH: { "default": "", "type": str, - "arg": "--ffmpeg-path", + "args": ["--ffmpeg-path"], "help": "Path to ffmpeg binary", }, FFMPEG_ARGS: { "default": "", "type": str, - "arg": "--ffmpeg-args", + "args": ["--ffmpeg-args"], "help": "Additional ffmpeg arguments when transcoding", }, SAVE_SUBTITLES: { "default": False, "type": bool, - "arg": "--save-subtitles", + "args": ["--save-subtitles"], "help": "Save subtitles from podcasts to a .srt file", }, LANGUAGE: { "default": "en", "type": str, - "arg": "--language", + "args": ["--language"], "help": "Language for metadata", }, - SAVE_LYRICS_FILE: { - "default": True, + LYRICS_FILE: { + "default": False, "type": bool, - "arg": "--save-lyrics-file", + "args": ["--lyrics-file"], "help": "Save lyrics to a file", }, LYRICS_ONLY: { "default": False, "type": bool, - "arg": "--lyrics-only", + "args": ["--lyrics-only"], "help": "Only download lyrics and not actual audio", }, CREATE_PLAYLIST_FILE: { "default": True, "type": bool, - "arg": "--playlist-file", + "args": ["--playlist-file"], "help": "Save playlist information to an m3u8 file", }, SAVE_METADATA: { "default": True, "type": bool, - "arg": "--save-metadata", + "args": ["--save-metadata"], "help": "Save metadata, required for other metadata options", }, ALL_ARTISTS: { "default": True, "type": bool, - "arg": "--all-artists", + "args": ["--all-artists"], "help": "Add all track artists to artist tag in metadata", }, REPLACE_EXISTING: { "default": False, "type": bool, - "arg": "--replace-existing", + "args": ["--replace-existing"], "help": "Overwrite existing files with the same name", }, SKIP_PREVIOUS: { "default": True, "type": bool, - "arg": "--skip-previous", + "args": ["--skip-previous"], "help": "Skip previously downloaded songs", }, SKIP_DUPLICATES: { "default": True, "type": bool, - "arg": "--skip-duplicates", + "args": ["--skip-duplicates"], "help": "Skip downloading existing track to different album", }, CHUNK_SIZE: { - "default": 131072, + "default": 16384, "type": int, - "arg": "--chunk-size", + "args": ["--chunk-size"], "help": "Number of bytes read at a time during download", }, PRINT_DOWNLOADS: { "default": False, "type": bool, - "arg": "--print-downloads", + "args": ["--print-downloads"], "help": "Print messages when a song is finished downloading", }, PRINT_PROGRESS: { "default": True, "type": bool, - "arg": "--print-progress", + "args": ["--print-progress"], "help": "Show progress bars", }, PRINT_SKIPS: { "default": False, "type": bool, - "arg": "--print-skips", + "args": ["--print-skips"], "help": "Show messages if a song is being skipped", }, PRINT_WARNINGS: { "default": True, "type": bool, - "arg": "--print-warnings", + "args": ["--print-warnings"], "help": "Show warnings", }, PRINT_ERRORS: { "default": True, "type": bool, - "arg": "--print-errors", + "args": ["--print-errors"], "help": "Show errors", }, } @@ -272,6 +272,7 @@ class Config: ffmpeg_path: str music_library: Path language: str + lyrics_file: bool output_album: str output_liked: str output_podcast: str @@ -280,7 +281,6 @@ class Config: playlist_library: Path podcast_library: Path print_progress: bool - save_lyrics_file: bool save_metadata: bool transcode_bitrate: int @@ -303,6 +303,8 @@ class Config: jsonvalues[key] = str(CONFIG_VALUES[key]["default"]) with open(self.__config_file, "w+", encoding="utf-8") as conf: dump(jsonvalues, conf, indent=4) + else: + self.__config_file = None for key in CONFIG_VALUES: # Override config with commandline arguments @@ -318,10 +320,14 @@ class Config: key, self.__parse_arg_value(key, CONFIG_VALUES[key]["default"]), ) - else: - self.__config_file = None - # Make "output" arg override all output_* options + # "library" arg overrides all *_library options + if args.library: + self.music_library = args.library + self.playlist_library = args.library + self.podcast_library = args.library + + # "output" arg overrides all output_* options if args.output: self.output_album = args.output self.output_liked = args.output diff --git a/zotify/printer.py b/zotify/printer.py index 9aa7d32..901e1ff 100644 --- a/zotify/printer.py +++ b/zotify/printer.py @@ -63,7 +63,7 @@ class Printer: iterable=iterable, desc=desc, total=total, - disable=False, # cls.__config.print_progress, + disable=not cls.__config.print_progress, leave=leave, position=position, unit=unit, diff --git a/zotify/utils.py b/zotify/utils.py index 869976a..01d5236 100644 --- a/zotify/utils.py +++ b/zotify/utils.py @@ -1,7 +1,9 @@ -from argparse import Action, ArgumentError +from argparse import Action, ArgumentError, HelpFormatter from enum import Enum, IntEnum from re import IGNORECASE, sub +from sys import exit from sys import platform as PLATFORM +from sys import stderr from typing import Any, NamedTuple from librespot.audio.decoders import AudioQuality @@ -15,8 +17,8 @@ BASE62 = Base62.create_instance_with_inverted_character_set() class AudioCodec(NamedTuple): - ext: str name: str + ext: str class AudioFormat(Enum): @@ -69,6 +71,43 @@ class ImageSize(IntEnum): return s +class MetadataEntry: + name: str + value: Any + output: str + + def __init__(self, name: str, value: Any, output_value: str | None = None): + """ + Holds metadata entries + args: + name: name of metadata key + value: Value to use in metadata tags + output_value: Value when used in output formatting, if none is provided + will use value from previous argument. + """ + self.name = name + + if type(value) == list: + value = "\0".join(value) + self.value = value + + if output_value is None: + output_value = self.value + elif output_value == "": + output_value = None + if type(output_value) == list: + output_value = ", ".join(output_value) + self.output = str(output_value) + + +class SimpleHelpFormatter(HelpFormatter): + def _format_usage(self, usage, actions, groups, prefix): + if usage is not None: + super()._format_usage(usage, actions, groups, prefix) + stderr.write('zotify: error: unrecognized arguments - try "zotify -h"\n') + exit(2) + + class OptionalOrFalse(Action): def __init__( self, @@ -103,38 +142,15 @@ class OptionalOrFalse(Action): ) def __call__(self, parser, namespace, values, option_string=None): - if values is None and not option_string.startswith("--no-"): - raise ArgumentError(self, "expected 1 argument") + if values is not None: + raise ArgumentError(self, "expected 0 arguments") setattr( namespace, self.dest, - values if not option_string.startswith("--no-") else False, + True if not option_string.startswith("--no-") else False, ) -class MetadataEntry: - def __init__(self, name: str, value: Any, output_value: str | None = None): - """ - Holds metadata entries - args: - name: name of metadata key - tag_val: Value to use in metadata tags - output_value: Value when used in output formatting - """ - self.name = name - if type(value) == list: - value = "\0".join(value) - self.value = value - - if output_value is None: - output_value = value - if output_value == "": - output_value = None - if type(output_value) == list: - output_value = ", ".join(output_value) - self.output = str(output_value) - - def fix_filename(filename: str, substitute: str = "_", platform: str = PLATFORM) -> str: """ Replace invalid characters on Linux/Windows/MacOS with underscores.