From 911c29820a2d56fa4dcc41308ed2e21cc81acc6e Mon Sep 17 00:00:00 2001 From: zotify Date: Mon, 31 Jul 2023 18:14:25 +1200 Subject: [PATCH] ReplayGain --- CHANGELOG.md | 40 ++++++++++----------- README.md | 2 +- requirements.txt | 2 +- requirements_dev.txt | 1 + setup.cfg | 4 +-- zotify/__init__.py | 49 +++++++++----------------- zotify/__main__.py | 2 ++ zotify/app.py | 74 ++++++++++++++++++++++---------------- zotify/config.py | 13 ++++--- zotify/file.py | 9 +++-- zotify/playable.py | 84 ++++++++++++++++++++++++-------------------- zotify/printer.py | 7 ++-- zotify/utils.py | 25 ++++++++++++- 13 files changed, 171 insertions(+), 141 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 128056e..631fd57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,19 +8,19 @@ An unexpected reboot - Most components have been completely rewritten to address some fundamental design issues with the previous codebase, This update will provide a better base for new features in the future. - ~~Some~~ Most configuration options have been renamed, please check your configuration file. -- There is a new library path for podcasts, existing podcasts will stay where they are. +- There is a new library path for playlists, existing playlists will stay where they are. ### Changes - Genre metadata available for tracks downloaded from an album - Boolean command line options are now set like `--save-metadata` or `--no-save-metadata` for True or False -- Setting `--config` (formerly `--config-location`) can be set to "none" to not use any config file +- Setting `--config` (formerly `--config-location`) can be set to "None" to not use any config file - Search result selector now accepts both comma-seperated and hyphen-seperated values at the same time - Renamed `--liked`/`-l` to `--liked-tracks`/`-lt` - Renamed `root_path` and `root_podcast_path` to `music_library` and `podcast_library` - `--username` and `--password` arguments now take priority over saved credentials - Regex pattern for cleaning filenames is now OS specific, allowing more usable characters on Linux & macOS. -- The default location for credentials.json on Linux is now ~/.config/zotify to keep it in the same place as config.json +- On Linux both `config.json` and `credentials.json` are now kept under `$XDG_CONFIG_HOME/zotify/`, (`~/.config/zotify/` by default). - The output template used is now based on track info rather than search result category - Search queries with spaces no longer need to be in quotes - File metadata no longer uses sanitized file metadata, this will result in more accurate metadata. @@ -32,24 +32,24 @@ An unexpected reboot - `--library`/`-l` overrides both `music_library` and `podcast_library` options similar to `--output` - `--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 -- Search results can be narrowed down using field filters - - Available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre. - - The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1970-1982). - - The album filter can be used while searching albums and tracks. - - The genre filter can be used while searching artists and tracks. - - The isrc and track filters can be used while searching tracks. - - The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to show only albums in the lowest 10% of popularity. +- 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). + - The 'album' filter only shows results from the given album(s) + - The 'genre' filter only shows results from the given genre(s) + - The 'isrc' and 'track' filters can be used while searching tracks + - The 'upc', tag:new and tag:hipster filters can only be used while searching albums + - 'tag:new' filter will show albums released within the past two weeks + - 'tag:hipster' will only show albums in the lowest 10% of popularity - Search has been expanded to include podcasts and episodes - New output placeholders / metadata tags for tracks - `{artists}` - `{album_artist}` - `{album_artists}` - - !!`{duration}` - In milliseconds - - `{explicit}` - - `{explicit_symbol}` - For output format, will be \[E] if track is explicit. + - `{duration}` (milliseconds) - `{isrc}` - `{licensor}` - - !!`{popularity}` + - `{popularity}` - `{release_date}` - `{track_number}` - Genre information is now more accurate and is always enabled @@ -60,19 +60,19 @@ An unexpected reboot - Added support for transcoding to wav and wavpack formats - Unsynced lyrics are saved to a txt file instead of lrc - Unsynced lyrics can now be embedded directly into file metadata (for supported file types) -- Added new option `save_lyrics` +- Added new option `save_lyrics_file` - This option only affects the external lyrics files - - Embedded lyrics are tied to `save_metadata` + - Embedded lyrics are controlled with `save_metadata` ### Removals - Removed "Zotify" ASCII banner -- Removed search prompt +- Removed search prompt, searches can only be done as cli arguments now. - Removed song archive files - Removed `{ext}` option in output formats as file extentions are managed automatically -- Removed `split_album_discs` because the same functionality cna be achieved by using output formatting and it was causing conflicts -- Removed `print_api_errors` because API errors are now trated like regular errors -- Removed the following config options due to lack of utility +- Removed `split_album_discs` because the same functionality can be achieved by using output formatting +- Removed `print_api_errors` because API errors are now treated like regular errors +- Removed the following config options due to their corresponding features being removed: - `bulk_wait_time` - `download_real_time` - `md_allgenres` diff --git a/README.md b/README.md index 524f9f8..923e565 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Here's a very simple example of downloading a track and its metadata: ```python import zotify -session = zotify.Session(username="username", password="password") +session = zotify.Session.from_userpass(username="username", password="password") track = session.get_track("4cOdK2wGLETKBW3PvgPWqT") output = track.create_output("./Music", "{artist} - {title}") diff --git a/requirements.txt b/requirements.txt index 1c6736e..8ae15d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ librespot>=0.0.9 -music-tag +music-tag@git+https://zotify.xyz/zotify/music-tag mutagen Pillow pwinput diff --git a/requirements_dev.txt b/requirements_dev.txt index c7740c9..624c4eb 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -3,3 +3,4 @@ flake8 mypy pre-commit types-requests +wheel diff --git a/setup.cfg b/setup.cfg index e611dcc..27f0c12 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,7 +20,7 @@ packages = zotify python_requires = >=3.10 install_requires = librespot>=0.0.9 - music-tag + music-tag@git+https://zotify.xyz/zotify/music-tag mutagen Pillow pwinput @@ -35,5 +35,3 @@ console_scripts = # Conflicts with black ignore = E203 max-line-length = 160 -per-file-ignores = - zotify/file.py: E701 diff --git a/zotify/__init__.py b/zotify/__init__.py index 237d4ae..981d092 100644 --- a/zotify/__init__.py +++ b/zotify/__init__.py @@ -70,15 +70,9 @@ class Api(ApiClient): class Session: - __api: Api - __country: str - __language: str - __session: LibrespotSession - __session_builder: LibrespotSession.Builder - def __init__( self, - session_builder: LibrespotSession.Builder, + librespot_session: LibrespotSession, language: str = "en", ) -> None: """ @@ -87,10 +81,10 @@ class Session: session_builder: An instance of the Librespot Session.Builder langauge: ISO 639-1 language code """ - self.__session_builder = session_builder - self.__session = self.__session_builder.create() + self.__session = librespot_session self.__language = language self.__api = Api(self.__session, language) + self.__country = self.api().invoke_url(API_URL + "me")["country"] @staticmethod def from_file(cred_file: Path, langauge: str = "en") -> Session: @@ -98,7 +92,7 @@ class Session: Creates session using saved credentials file Args: cred_file: Path to credentials file - langauge: ISO 639-1 language code + langauge: ISO 639-1 language code for API responses Returns: Zotify session """ @@ -107,9 +101,8 @@ class Session: .set_store_credentials(False) .build() ) - return Session( - LibrespotSession.Builder(conf).stored_file(str(cred_file)), langauge - ) + session = LibrespotSession.Builder(conf).stored_file(str(cred_file)) + return Session(session.create(), langauge) @staticmethod def from_userpass( @@ -124,7 +117,7 @@ class Session: username: Account username password: Account password save_file: Path to save login credentials to, optional. - langauge: ISO 639-1 language code + langauge: ISO 639-1 language code for API responses Returns: Zotify session """ @@ -132,22 +125,18 @@ class Session: password = ( pwinput(prompt="Password: ", mask="*") if password == "" else password ) + + builder = LibrespotSession.Configuration.Builder() if save_file: save_file.parent.mkdir(parents=True, exist_ok=True) - conf = ( - LibrespotSession.Configuration.Builder() - .set_stored_credential_file(str(save_file)) - .build() - ) + builder.set_stored_credential_file(str(save_file)) else: - conf = ( - LibrespotSession.Configuration.Builder() - .set_store_credentials(False) - .build() - ) - return Session( - LibrespotSession.Builder(conf).user_pass(username, password), language + builder.set_store_credentials(False) + + session = LibrespotSession.Builder(builder.build()).user_pass( + username, password ) + return Session(session.create(), language) def __get_playable( self, playable_id: PlayableId, quality: Quality @@ -188,11 +177,7 @@ class Session: def country(self) -> str: """Returns two letter country code of user's account""" - try: - return self.__country - except AttributeError: - self.__country = self.api().invoke_url(API_URL + "me")["country"] - return self.__country + return self.__country def is_premium(self) -> bool: """Returns users premium account status""" @@ -200,4 +185,4 @@ class Session: def clone(self) -> Session: """Creates a copy of the session for use in a parallel thread""" - return Session(session_builder=self.__session_builder, language=self.__language) + return Session(self.__session, self.__language) diff --git a/zotify/__main__.py b/zotify/__main__.py index 9df7cea..623082a 100644 --- a/zotify/__main__.py +++ b/zotify/__main__.py @@ -137,6 +137,8 @@ def main(): from traceback import format_exc print(format_exc().splitlines()[-1]) + except KeyboardInterrupt: + print("goodbye") if __name__ == "__main__": diff --git a/zotify/app.py b/zotify/app.py index eb2c270..d9ba61d 100644 --- a/zotify/app.py +++ b/zotify/app.py @@ -22,7 +22,7 @@ from zotify.printer import PrintChannel, Printer from zotify.utils import API_URL, AudioFormat, b62_to_hex -class ParsingError(RuntimeError): +class ParseError(ValueError): ... @@ -36,6 +36,7 @@ class PlayableData(NamedTuple): id: PlayableId library: Path output: str + metadata: dict[str, Any] = {} class Selection: @@ -55,17 +56,18 @@ class Selection: ], ) -> list[str]: categories = ",".join(category) - resp = self.__session.api().invoke_url( - API_URL + "search", - { - "q": search_text, - "type": categories, - "include_external": "audio", - "market": self.__session.country(), - }, - limit=10, - offset=0, - ) + with Loader("Searching..."): + resp = self.__session.api().invoke_url( + API_URL + "search", + { + "q": search_text, + "type": categories, + "include_external": "audio", + "market": self.__session.country(), + }, + limit=10, + offset=0, + ) count = 0 links = [] @@ -79,11 +81,22 @@ class Selection: count += 1 return self.__get_selection(links) - def get(self, item: str, suffix: str) -> list[str]: - resp = self.__session.api().invoke_url(f"{API_URL}me/{item}", limit=50)[suffix] + def get(self, category: str, name: str = "", content: str = "") -> list[str]: + with Loader("Fetching items..."): + r = self.__session.api().invoke_url(f"{API_URL}me/{category}", limit=50) + if content != "": + r = r[content] + resp = r["items"] + + items = [] for i in range(len(resp)): - self.__print(i + 1, resp[i]) - return self.__get_selection(resp) + try: + item = resp[i][name] + except KeyError: + item = resp[i] + items.append(item) + self.__print(i + 1, item) + return self.__get_selection(items) @staticmethod def from_file(file_path: Path) -> list[str]: @@ -169,8 +182,6 @@ class Selection: class App: - __config: Config - __session: Session __playable_list: list[PlayableData] = [] def __init__(self, args: Namespace): @@ -204,7 +215,7 @@ class App: with Loader("Parsing input..."): try: self.parse(ids) - except ParsingError as e: + except ParseError as e: Printer.print(PrintChannel.ERRORS, str(e)) self.download_all() @@ -214,13 +225,13 @@ class App: if args.search: return selection.search(" ".join(args.search), args.category) elif args.playlist: - return selection.get("playlists", "items") + return selection.get("playlists") elif args.followed: - return selection.get("following?type=artist", "artists") + return selection.get("following?type=artist", content="artists") elif args.liked_tracks: - return selection.get("tracks", "items") + return selection.get("tracks", "track") elif args.liked_episodes: - return selection.get("episodes", "items") + return selection.get("episodes") elif args.download: ids = [] for x in args.download: @@ -228,9 +239,10 @@ class App: return ids elif args.urls: return args.urls - except (FileNotFoundError, ValueError, KeyboardInterrupt): - pass - Printer.print(PrintChannel.WARNINGS, "there is nothing to do") + except (FileNotFoundError, ValueError): + Printer.print(PrintChannel.WARNINGS, "there is nothing to do") + except KeyboardInterrupt: + Printer.print(PrintChannel.WARNINGS, "\nthere is nothing to do") exit() def parse(self, links: list[str]) -> None: @@ -246,7 +258,7 @@ class App: _id = split[-1] id_type = split[-2] except IndexError: - raise ParsingError(f'Could not parse "{link}"') + raise ParseError(f'Could not parse "{link}"') match id_type: case "album": @@ -262,7 +274,7 @@ class App: case "playlist": self.__parse_playlist(_id) case _: - raise ParsingError(f'Unknown content type "{id_type}"') + raise ParseError(f'Unknown content type "{id_type}"') def __parse_album(self, hex_id: str) -> None: album = self.__session.api().get_metadata_4_album(AlbumId.from_hex(hex_id)) @@ -279,9 +291,9 @@ class App: def __parse_artist(self, hex_id: str) -> None: artist = self.__session.api().get_metadata_4_artist(ArtistId.from_hex(hex_id)) - for album in artist.album_group + artist.single_group: + for album_group in artist.album_group and artist.single_group: album = self.__session.api().get_metadata_4_album( - AlbumId.from_hex(album.gid) + AlbumId.from_hex(album_group.album[0].gid) ) for disc in album.disc: for track in disc.track: @@ -373,7 +385,7 @@ class App: self.__config.chunk_size, ) - if self.__config.save_lyrics and playable.type == PlayableType.TRACK: + if self.__config.save_lyrics_file and playable.type == PlayableType.TRACK: with Loader("Fetching lyrics..."): try: track.get_lyrics().save(output) diff --git a/zotify/config.py b/zotify/config.py index 971d8e4..8bbf79b 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -1,5 +1,6 @@ from argparse import Namespace from json import dump, load +from os import environ from pathlib import Path from sys import platform as PLATFORM from typing import Any @@ -33,7 +34,7 @@ PRINT_PROGRESS = "print_progress" PRINT_SKIPS = "print_skips" PRINT_WARNINGS = "print_warnings" REPLACE_EXISTING = "replace_existing" -SAVE_LYRICS = "save_lyrics" +SAVE_LYRICS_FILE = "save_lyrics_file" SAVE_METADATA = "save_metadata" SAVE_SUBTITLES = "save_subtitles" SKIP_DUPLICATES = "skip_duplicates" @@ -42,8 +43,10 @@ TRANSCODE_BITRATE = "transcode_bitrate" SYSTEM_PATHS = { "win32": Path.home().joinpath("AppData/Roaming/Zotify"), - "linux": Path.home().joinpath(".config/zotify"), "darwin": Path.home().joinpath("Library/Application Support/Zotify"), + "linux": Path(environ.get("XDG_CONFIG_HOME") or "~/.config") + .expanduser() + .joinpath("zotify"), } LIBRARY_PATHS = { @@ -171,10 +174,10 @@ CONFIG_VALUES = { "arg": "--language", "help": "Language for metadata", }, - SAVE_LYRICS: { + SAVE_LYRICS_FILE: { "default": True, "type": bool, - "arg": "--save-lyrics", + "arg": "--save-lyrics-file", "help": "Save lyrics to a file", }, LYRICS_ONLY: { @@ -277,7 +280,7 @@ class Config: playlist_library: Path podcast_library: Path print_progress: bool - save_lyrics: bool + save_lyrics_file: bool save_metadata: bool transcode_bitrate: int diff --git a/zotify/file.py b/zotify/file.py index 375c781..4cf1bfc 100644 --- a/zotify/file.py +++ b/zotify/file.py @@ -1,12 +1,11 @@ from errno import ENOENT from pathlib import Path from subprocess import PIPE, Popen -from typing import Any from music_tag import load_file from mutagen.oggvorbis import OggVorbisHeaderError -from zotify.utils import AudioFormat +from zotify.utils import AudioFormat, MetadataEntry class TranscodingError(RuntimeError): @@ -87,7 +86,7 @@ class LocalFile: self.__audio_format = audio_format self.__bitrate = bitrate - def write_metadata(self, metadata: dict[str, Any]) -> None: + def write_metadata(self, metadata: list[MetadataEntry]) -> None: """ Write metadata to file Args: @@ -95,9 +94,9 @@ class LocalFile: """ f = load_file(self.__path) f.save() - for k, v in metadata.items(): + for m in metadata: try: - f[k] = str(v) + f[m.name] = m.value except KeyError: pass try: diff --git a/zotify/playable.py b/zotify/playable.py index e5d36d9..dd312db 100644 --- a/zotify/playable.py +++ b/zotify/playable.py @@ -14,6 +14,7 @@ from zotify.utils import ( LYRICS_URL, AudioFormat, ImageSize, + MetadataEntry, bytes_to_base62, fix_filename, ) @@ -53,7 +54,7 @@ class Lyrics: class Playable: cover_images: list[Any] - metadata: dict[str, Any] + metadata: list[MetadataEntry] name: str input_stream: GeneralAudioStream @@ -67,13 +68,12 @@ class Playable: Returns: File path for the track """ - for k, v in self.metadata.items(): - output = output.replace( - "{" + k + "}", fix_filename(str(v).replace("\0", ", ")) - ) + for m in self.metadata: + if m.output is not None: + output = output.replace("{" + m.name + "}", fix_filename(m.output)) file_path = library.joinpath(output).expanduser() if file_path.exists() and not replace: - raise FileExistsError("Output Creation Error: File already downloaded") + raise FileExistsError("File already downloaded") else: file_path.parent.mkdir(parents=True, exist_ok=True) return file_path @@ -140,28 +140,34 @@ class Track(PlayableContentFeeder.LoadedStream, Playable): except AttributeError: return super().__getattribute__("track").__getattribute__(name) - def __default_metadata(self) -> dict[str, Any]: + def __default_metadata(self) -> list[MetadataEntry]: date = self.album.date - return { - "album": self.album.name, - "album_artist": "\0".join([a.name for a in self.album.artist]), - "artist": self.artist[0].name, - "artists": "\0".join([a.name for a in self.artist]), - "date": f"{date.year}-{date.month}-{date.day}", - "disc_number": self.disc_number, - "duration": self.duration, - "explicit": self.explicit, - "explicit_symbol": "[E]" if self.explicit else "", - "isrc": self.external_id[0].id, - "popularity": (self.popularity * 255) / 100, - "track_number": str(self.number).zfill(2), - # "year": self.album.date.year, - "title": self.name, - "replaygain_track_gain": self.normalization_data.track_gain_db, - "replaygain_track_peak": self.normalization_data.track_peak, - "replaygain_album_gain": self.normalization_data.album_gain_db, - "replaygain_album_peak": self.normalization_data.album_peak, - } + return [ + MetadataEntry("album", self.album.name), + MetadataEntry("album_artist", [a.name for a in self.album.artist]), + MetadataEntry("artist", self.artist[0].name), + MetadataEntry("artists", [a.name for a in self.artist]), + MetadataEntry("date", f"{date.year}-{date.month}-{date.day}"), + MetadataEntry("disc", self.disc_number), + MetadataEntry("duration", self.duration), + MetadataEntry("explicit", self.explicit, "[E]" if self.explicit else ""), + MetadataEntry("isrc", self.external_id[0].id), + MetadataEntry("popularity", int(self.popularity * 255) / 100), + MetadataEntry("track_number", self.number, str(self.number).zfill(2)), + MetadataEntry("title", self.name), + MetadataEntry( + "replaygain_track_gain", self.normalization_data.track_gain_db, "" + ), + MetadataEntry( + "replaygain_track_peak", self.normalization_data.track_peak, "" + ), + MetadataEntry( + "replaygain_album_gain", self.normalization_data.album_gain_db, "" + ), + MetadataEntry( + "replaygain_album_peak", self.normalization_data.album_peak, "" + ), + ] def get_lyrics(self) -> Lyrics: """Returns track lyrics if available""" @@ -198,17 +204,17 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable): except AttributeError: return super().__getattribute__("episode").__getattribute__(name) - def __default_metadata(self) -> dict[str, Any]: - return { - "description": self.description, - "duration": self.duration, - "episode_number": self.number, - "explicit": self.explicit, - "language": self.language, - "podcast": self.show.name, - "date": self.publish_time, - "title": self.name, - } + def __default_metadata(self) -> list[MetadataEntry]: + return [ + MetadataEntry("description", self.description), + MetadataEntry("duration", self.duration), + MetadataEntry("episode_number", self.number), + MetadataEntry("explicit", self.explicit, "[E]" if self.explicit else ""), + MetadataEntry("language", self.language), + MetadataEntry("podcast", self.show.name), + MetadataEntry("date", self.publish_time), + MetadataEntry("title", self.name), + ] def write_audio_stream( self, output: Path, chunk_size: int = 128 * 1024 @@ -221,7 +227,7 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable): Returns: LocalFile object """ - if bool(self.external_url): + if not bool(self.external_url): return super().write_audio_stream(output, chunk_size) file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}" with get(self.external_url, stream=True) as r, open( diff --git a/zotify/printer.py b/zotify/printer.py index 3f182f0..9aa7d32 100644 --- a/zotify/printer.py +++ b/zotify/printer.py @@ -71,11 +71,12 @@ class Printer: unit_divisor=unit_divisor, ) - @staticmethod - def print_loader(msg: str) -> None: + @classmethod + def print_loader(cls, msg: str) -> None: """ Prints animated loading symbol Args: msg: Message to print """ - print(msg, flush=True, end="") + if cls.__config.print_progress: + print(msg, flush=True, end="") diff --git a/zotify/utils.py b/zotify/utils.py index ead1cee..869976a 100644 --- a/zotify/utils.py +++ b/zotify/utils.py @@ -2,7 +2,7 @@ from argparse import Action, ArgumentError from enum import Enum, IntEnum from re import IGNORECASE, sub from sys import platform as PLATFORM -from typing import NamedTuple +from typing import Any, NamedTuple from librespot.audio.decoders import AudioQuality from librespot.util import Base62, bytes_to_hex @@ -112,6 +112,29 @@ class OptionalOrFalse(Action): ) +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.