More tweaks/fixes
This commit is contained in:
parent
911c29820a
commit
a10b32b5b7
|
@ -160,4 +160,5 @@ cython_debug/
|
|||
#.idea/
|
||||
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.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": [
|
||||
|
||||
]
|
||||
}
|
|
@ -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"]
|
||||
}
|
||||
}
|
|
@ -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).
|
||||
|
|
17
setup.cfg
17
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
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue