More tweaks/fixes

This commit is contained in:
zotify 2023-09-08 17:22:55 +12:00
parent 911c29820a
commit a10b32b5b7
10 changed files with 140 additions and 87 deletions

1
.gitignore vendored
View File

@ -160,4 +160,5 @@ cython_debug/
#.idea/ #.idea/
.vscode/* .vscode/*
!.vscode/extensions.json
!.vscode/settings.json !.vscode/settings.json

15
.vscode/extensions.json vendored Normal file
View File

@ -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": [
]
}

View File

@ -1,11 +1,7 @@
{ {
"python.linting.flake8Enabled": true, "editor.defaultFormatter": "ms-python.black-formatter",
"python.linting.mypyEnabled": true,
"python.linting.enabled": true,
"python.formatting.provider": "black",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports": true "source.organizeImports": true
}, },
"isort.args": ["--profile", "black"]
} }

View File

@ -2,7 +2,7 @@
## v1.0.0 ## v1.0.0
An unexpected reboot An unexpected reboot.
### BREAKING CHANGES AHEAD ### BREAKING CHANGES AHEAD
@ -29,9 +29,14 @@ An unexpected reboot
### Additions ### Additions
- Added new command line arguments - 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. - `--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 - `--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 - Search results can be narrowed down using search filters
- Available filters are 'album', 'artist', 'track', 'year', 'upc', 'tag:hipster', 'tag:new', 'isrc', and 'genre'. - 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). - The 'artist' and 'year' filters only shows results from the given year or a range (e.g. 1970-1982).

View File

@ -32,6 +32,19 @@ console_scripts =
zotify = zotify.__main__:main zotify = zotify.__main__:main
[flake8] [flake8]
# Conflicts with black
ignore = E203
max-line-length = 160 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

View File

@ -5,15 +5,16 @@ from pathlib import Path
from zotify.app import App from zotify.app import App
from zotify.config import CONFIG_PATHS, CONFIG_VALUES 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(): def main():
parser = ArgumentParser( parser = ArgumentParser(
prog="zotify", prog="zotify",
description="A fast and customizable music and podcast downloader", description="A fast and customizable music and podcast downloader",
formatter_class=SimpleHelpFormatter,
) )
parser.add_argument( parser.add_argument(
"-v", "-v",
@ -39,7 +40,7 @@ def main():
help="Specify a path to the root of a music/playlist/podcast library", help="Specify a path to the root of a music/playlist/podcast library",
) )
parser.add_argument( 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( parser.add_argument(
"-c", "-c",
@ -101,7 +102,7 @@ def main():
for k, v in CONFIG_VALUES.items(): for k, v in CONFIG_VALUES.items():
if v["type"] == bool: if v["type"] == bool:
parser.add_argument( parser.add_argument(
v["arg"], *v["args"],
action=OptionalOrFalse, action=OptionalOrFalse,
default=v["default"], default=v["default"],
help=v["help"], help=v["help"],
@ -109,7 +110,7 @@ def main():
else: else:
try: try:
parser.add_argument( parser.add_argument(
v["arg"], *v["args"],
type=v["type"], type=v["type"],
choices=v["choices"], choices=v["choices"],
default=None, default=None,
@ -117,7 +118,7 @@ def main():
) )
except KeyError: except KeyError:
parser.add_argument( parser.add_argument(
v["arg"], *v["args"],
type=v["type"], type=v["type"],
default=None, default=None,
help=v["help"], help=v["help"],

View File

@ -19,7 +19,7 @@ from zotify.config import Config
from zotify.file import TranscodingError from zotify.file import TranscodingError
from zotify.loader import Loader from zotify.loader import Loader
from zotify.printer import PrintChannel, Printer 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): class ParseError(ValueError):
@ -36,7 +36,7 @@ class PlayableData(NamedTuple):
id: PlayableId id: PlayableId
library: Path library: Path
output: str output: str
metadata: dict[str, Any] = {} metadata: list[MetadataEntry] = []
class Selection: class Selection:
@ -385,7 +385,7 @@ class App:
self.__config.chunk_size, 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..."): with Loader("Fetching lyrics..."):
try: try:
track.get_lyrics().save(output) track.get_lyrics().save(output)

View File

@ -17,6 +17,7 @@ DOWNLOAD_QUALITY = "download_quality"
FFMPEG_ARGS = "ffmpeg_args" FFMPEG_ARGS = "ffmpeg_args"
FFMPEG_PATH = "ffmpeg_path" FFMPEG_PATH = "ffmpeg_path"
LANGUAGE = "language" LANGUAGE = "language"
LYRICS_FILE = "lyrics_file"
LYRICS_ONLY = "lyrics_only" LYRICS_ONLY = "lyrics_only"
MUSIC_LIBRARY = "music_library" MUSIC_LIBRARY = "music_library"
OUTPUT = "output" OUTPUT = "output"
@ -34,7 +35,6 @@ PRINT_PROGRESS = "print_progress"
PRINT_SKIPS = "print_skips" PRINT_SKIPS = "print_skips"
PRINT_WARNINGS = "print_warnings" PRINT_WARNINGS = "print_warnings"
REPLACE_EXISTING = "replace_existing" REPLACE_EXISTING = "replace_existing"
SAVE_LYRICS_FILE = "save_lyrics_file"
SAVE_METADATA = "save_metadata" SAVE_METADATA = "save_metadata"
SAVE_SUBTITLES = "save_subtitles" SAVE_SUBTITLES = "save_subtitles"
SKIP_DUPLICATES = "skip_duplicates" SKIP_DUPLICATES = "skip_duplicates"
@ -72,190 +72,190 @@ CONFIG_VALUES = {
CREDENTIALS: { CREDENTIALS: {
"default": CONFIG_PATHS["creds"], "default": CONFIG_PATHS["creds"],
"type": Path, "type": Path,
"arg": "--credentials", "args": ["--credentials"],
"help": "Path to credentials file", "help": "Path to credentials file",
}, },
PATH_ARCHIVE: { PATH_ARCHIVE: {
"default": CONFIG_PATHS["archive"], "default": CONFIG_PATHS["archive"],
"type": Path, "type": Path,
"arg": "--archive", "args": ["--archive"],
"help": "Path to track archive file", "help": "Path to track archive file",
}, },
MUSIC_LIBRARY: { MUSIC_LIBRARY: {
"default": LIBRARY_PATHS["music"], "default": LIBRARY_PATHS["music"],
"type": Path, "type": Path,
"arg": "--music-library", "args": ["--music-library"],
"help": "Path to root of music library", "help": "Path to root of music library",
}, },
PODCAST_LIBRARY: { PODCAST_LIBRARY: {
"default": LIBRARY_PATHS["podcast"], "default": LIBRARY_PATHS["podcast"],
"type": Path, "type": Path,
"arg": "--podcast-library", "args": ["--podcast-library"],
"help": "Path to root of podcast library", "help": "Path to root of podcast library",
}, },
PLAYLIST_LIBRARY: { PLAYLIST_LIBRARY: {
"default": LIBRARY_PATHS["playlist"], "default": LIBRARY_PATHS["playlist"],
"type": Path, "type": Path,
"arg": "--playlist-library", "args": ["--playlist-library"],
"help": "Path to root of playlist library", "help": "Path to root of playlist library",
}, },
OUTPUT_ALBUM: { OUTPUT_ALBUM: {
"default": OUTPUT_PATHS["album"], "default": OUTPUT_PATHS["album"],
"type": str, "type": str,
"arg": "--output-album", "args": ["--output-album", "-oa"],
"help": "File layout for saved albums", "help": "File layout for saved albums",
}, },
OUTPUT_PLAYLIST_TRACK: { OUTPUT_PLAYLIST_TRACK: {
"default": OUTPUT_PATHS["playlist_track"], "default": OUTPUT_PATHS["playlist_track"],
"type": str, "type": str,
"arg": "--output-playlist-track", "args": ["--output-playlist-track", "-opt"],
"help": "File layout for tracks in a playlist", "help": "File layout for tracks in a playlist",
}, },
OUTPUT_PLAYLIST_EPISODE: { OUTPUT_PLAYLIST_EPISODE: {
"default": OUTPUT_PATHS["playlist_episode"], "default": OUTPUT_PATHS["playlist_episode"],
"type": str, "type": str,
"arg": "--output-playlist-episode", "args": ["--output-playlist-episode", "-ope"],
"help": "File layout for episodes in a playlist", "help": "File layout for episodes in a playlist",
}, },
OUTPUT_PODCAST: { OUTPUT_PODCAST: {
"default": OUTPUT_PATHS["podcast"], "default": OUTPUT_PATHS["podcast"],
"type": str, "type": str,
"arg": "--output-podcast", "args": ["--output-podcast", "-op"],
"help": "File layout for saved podcasts", "help": "File layout for saved podcasts",
}, },
DOWNLOAD_QUALITY: { DOWNLOAD_QUALITY: {
"default": "auto", "default": "auto",
"type": Quality.from_string, "type": Quality.from_string,
"choices": list(Quality), "choices": list(Quality),
"arg": "--download-quality", "args": ["--download-quality"],
"help": "Audio download quality (auto for highest available)", "help": "Audio download quality (auto for highest available)",
}, },
ARTWORK_SIZE: { ARTWORK_SIZE: {
"default": "large", "default": "large",
"type": ImageSize.from_string, "type": ImageSize.from_string,
"choices": list(ImageSize), "choices": list(ImageSize),
"arg": "--artwork-size", "args": ["--artwork-size"],
"help": "Image size of track's cover art", "help": "Image size of track's cover art",
}, },
AUDIO_FORMAT: { AUDIO_FORMAT: {
"default": "vorbis", "default": "vorbis",
"type": AudioFormat, "type": AudioFormat,
"choices": [n.value.name for n in AudioFormat], "choices": [n.value.name for n in AudioFormat],
"arg": "--audio-format", "args": ["--audio-format"],
"help": "Audio format of final track output", "help": "Audio format of final track output",
}, },
TRANSCODE_BITRATE: { TRANSCODE_BITRATE: {
"default": -1, "default": -1,
"type": int, "type": int,
"arg": "--bitrate", "args": ["--bitrate"],
"help": "Transcoding bitrate (-1 to use download rate)", "help": "Transcoding bitrate (-1 to use download rate)",
}, },
FFMPEG_PATH: { FFMPEG_PATH: {
"default": "", "default": "",
"type": str, "type": str,
"arg": "--ffmpeg-path", "args": ["--ffmpeg-path"],
"help": "Path to ffmpeg binary", "help": "Path to ffmpeg binary",
}, },
FFMPEG_ARGS: { FFMPEG_ARGS: {
"default": "", "default": "",
"type": str, "type": str,
"arg": "--ffmpeg-args", "args": ["--ffmpeg-args"],
"help": "Additional ffmpeg arguments when transcoding", "help": "Additional ffmpeg arguments when transcoding",
}, },
SAVE_SUBTITLES: { SAVE_SUBTITLES: {
"default": False, "default": False,
"type": bool, "type": bool,
"arg": "--save-subtitles", "args": ["--save-subtitles"],
"help": "Save subtitles from podcasts to a .srt file", "help": "Save subtitles from podcasts to a .srt file",
}, },
LANGUAGE: { LANGUAGE: {
"default": "en", "default": "en",
"type": str, "type": str,
"arg": "--language", "args": ["--language"],
"help": "Language for metadata", "help": "Language for metadata",
}, },
SAVE_LYRICS_FILE: { LYRICS_FILE: {
"default": True, "default": False,
"type": bool, "type": bool,
"arg": "--save-lyrics-file", "args": ["--lyrics-file"],
"help": "Save lyrics to a file", "help": "Save lyrics to a file",
}, },
LYRICS_ONLY: { LYRICS_ONLY: {
"default": False, "default": False,
"type": bool, "type": bool,
"arg": "--lyrics-only", "args": ["--lyrics-only"],
"help": "Only download lyrics and not actual audio", "help": "Only download lyrics and not actual audio",
}, },
CREATE_PLAYLIST_FILE: { CREATE_PLAYLIST_FILE: {
"default": True, "default": True,
"type": bool, "type": bool,
"arg": "--playlist-file", "args": ["--playlist-file"],
"help": "Save playlist information to an m3u8 file", "help": "Save playlist information to an m3u8 file",
}, },
SAVE_METADATA: { SAVE_METADATA: {
"default": True, "default": True,
"type": bool, "type": bool,
"arg": "--save-metadata", "args": ["--save-metadata"],
"help": "Save metadata, required for other metadata options", "help": "Save metadata, required for other metadata options",
}, },
ALL_ARTISTS: { ALL_ARTISTS: {
"default": True, "default": True,
"type": bool, "type": bool,
"arg": "--all-artists", "args": ["--all-artists"],
"help": "Add all track artists to artist tag in metadata", "help": "Add all track artists to artist tag in metadata",
}, },
REPLACE_EXISTING: { REPLACE_EXISTING: {
"default": False, "default": False,
"type": bool, "type": bool,
"arg": "--replace-existing", "args": ["--replace-existing"],
"help": "Overwrite existing files with the same name", "help": "Overwrite existing files with the same name",
}, },
SKIP_PREVIOUS: { SKIP_PREVIOUS: {
"default": True, "default": True,
"type": bool, "type": bool,
"arg": "--skip-previous", "args": ["--skip-previous"],
"help": "Skip previously downloaded songs", "help": "Skip previously downloaded songs",
}, },
SKIP_DUPLICATES: { SKIP_DUPLICATES: {
"default": True, "default": True,
"type": bool, "type": bool,
"arg": "--skip-duplicates", "args": ["--skip-duplicates"],
"help": "Skip downloading existing track to different album", "help": "Skip downloading existing track to different album",
}, },
CHUNK_SIZE: { CHUNK_SIZE: {
"default": 131072, "default": 16384,
"type": int, "type": int,
"arg": "--chunk-size", "args": ["--chunk-size"],
"help": "Number of bytes read at a time during download", "help": "Number of bytes read at a time during download",
}, },
PRINT_DOWNLOADS: { PRINT_DOWNLOADS: {
"default": False, "default": False,
"type": bool, "type": bool,
"arg": "--print-downloads", "args": ["--print-downloads"],
"help": "Print messages when a song is finished downloading", "help": "Print messages when a song is finished downloading",
}, },
PRINT_PROGRESS: { PRINT_PROGRESS: {
"default": True, "default": True,
"type": bool, "type": bool,
"arg": "--print-progress", "args": ["--print-progress"],
"help": "Show progress bars", "help": "Show progress bars",
}, },
PRINT_SKIPS: { PRINT_SKIPS: {
"default": False, "default": False,
"type": bool, "type": bool,
"arg": "--print-skips", "args": ["--print-skips"],
"help": "Show messages if a song is being skipped", "help": "Show messages if a song is being skipped",
}, },
PRINT_WARNINGS: { PRINT_WARNINGS: {
"default": True, "default": True,
"type": bool, "type": bool,
"arg": "--print-warnings", "args": ["--print-warnings"],
"help": "Show warnings", "help": "Show warnings",
}, },
PRINT_ERRORS: { PRINT_ERRORS: {
"default": True, "default": True,
"type": bool, "type": bool,
"arg": "--print-errors", "args": ["--print-errors"],
"help": "Show errors", "help": "Show errors",
}, },
} }
@ -272,6 +272,7 @@ class Config:
ffmpeg_path: str ffmpeg_path: str
music_library: Path music_library: Path
language: str language: str
lyrics_file: bool
output_album: str output_album: str
output_liked: str output_liked: str
output_podcast: str output_podcast: str
@ -280,7 +281,6 @@ class Config:
playlist_library: Path playlist_library: Path
podcast_library: Path podcast_library: Path
print_progress: bool print_progress: bool
save_lyrics_file: bool
save_metadata: bool save_metadata: bool
transcode_bitrate: int transcode_bitrate: int
@ -303,6 +303,8 @@ class Config:
jsonvalues[key] = str(CONFIG_VALUES[key]["default"]) jsonvalues[key] = str(CONFIG_VALUES[key]["default"])
with open(self.__config_file, "w+", encoding="utf-8") as conf: with open(self.__config_file, "w+", encoding="utf-8") as conf:
dump(jsonvalues, conf, indent=4) dump(jsonvalues, conf, indent=4)
else:
self.__config_file = None
for key in CONFIG_VALUES: for key in CONFIG_VALUES:
# Override config with commandline arguments # Override config with commandline arguments
@ -318,10 +320,14 @@ class Config:
key, key,
self.__parse_arg_value(key, CONFIG_VALUES[key]["default"]), 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: if args.output:
self.output_album = args.output self.output_album = args.output
self.output_liked = args.output self.output_liked = args.output

View File

@ -63,7 +63,7 @@ class Printer:
iterable=iterable, iterable=iterable,
desc=desc, desc=desc,
total=total, total=total,
disable=False, # cls.__config.print_progress, disable=not cls.__config.print_progress,
leave=leave, leave=leave,
position=position, position=position,
unit=unit, unit=unit,

View File

@ -1,7 +1,9 @@
from argparse import Action, ArgumentError from argparse import Action, ArgumentError, HelpFormatter
from enum import Enum, IntEnum from enum import Enum, IntEnum
from re import IGNORECASE, sub from re import IGNORECASE, sub
from sys import exit
from sys import platform as PLATFORM from sys import platform as PLATFORM
from sys import stderr
from typing import Any, NamedTuple from typing import Any, NamedTuple
from librespot.audio.decoders import AudioQuality from librespot.audio.decoders import AudioQuality
@ -15,8 +17,8 @@ BASE62 = Base62.create_instance_with_inverted_character_set()
class AudioCodec(NamedTuple): class AudioCodec(NamedTuple):
ext: str
name: str name: str
ext: str
class AudioFormat(Enum): class AudioFormat(Enum):
@ -69,6 +71,43 @@ class ImageSize(IntEnum):
return s 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): class OptionalOrFalse(Action):
def __init__( def __init__(
self, self,
@ -103,38 +142,15 @@ class OptionalOrFalse(Action):
) )
def __call__(self, parser, namespace, values, option_string=None): def __call__(self, parser, namespace, values, option_string=None):
if values is None and not option_string.startswith("--no-"): if values is not None:
raise ArgumentError(self, "expected 1 argument") raise ArgumentError(self, "expected 0 arguments")
setattr( setattr(
namespace, namespace,
self.dest, 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: def fix_filename(filename: str, substitute: str = "_", platform: str = PLATFORM) -> str:
""" """
Replace invalid characters on Linux/Windows/MacOS with underscores. Replace invalid characters on Linux/Windows/MacOS with underscores.