ReplayGain

This commit is contained in:
zotify 2023-07-31 18:14:25 +12:00
parent 30721125ef
commit 911c29820a
13 changed files with 171 additions and 141 deletions

View file

@ -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. - 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. - ~~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 ### Changes
- Genre metadata available for tracks downloaded from an album - 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 - 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 - Search result selector now accepts both comma-seperated and hyphen-seperated values at the same time
- Renamed `--liked`/`-l` to `--liked-tracks`/`-lt` - Renamed `--liked`/`-l` to `--liked-tracks`/`-lt`
- Renamed `root_path` and `root_podcast_path` to `music_library` and `podcast_library` - Renamed `root_path` and `root_podcast_path` to `music_library` and `podcast_library`
- `--username` and `--password` arguments now take priority over saved credentials - `--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. - 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 - 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 - 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. - 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` - `--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. - `--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
- Search results can be narrowed down using field 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 can be used while searching albums, artists and tracks. You can filter on a single 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).
- The album filter can be used while searching albums and tracks. - The 'album' filter only shows results from the given album(s)
- The genre filter can be used while searching artists and tracks. - The 'genre' filter only shows results from the given genre(s)
- The isrc and track filters can be used while searching 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. - 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 - Search has been expanded to include podcasts and episodes
- New output placeholders / metadata tags for tracks - New output placeholders / metadata tags for tracks
- `{artists}` - `{artists}`
- `{album_artist}` - `{album_artist}`
- `{album_artists}` - `{album_artists}`
- !!`{duration}` - In milliseconds - `{duration}` (milliseconds)
- `{explicit}`
- `{explicit_symbol}` - For output format, will be \[E] if track is explicit.
- `{isrc}` - `{isrc}`
- `{licensor}` - `{licensor}`
- !!`{popularity}` - `{popularity}`
- `{release_date}` - `{release_date}`
- `{track_number}` - `{track_number}`
- Genre information is now more accurate and is always enabled - 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 - Added support for transcoding to wav and wavpack formats
- Unsynced lyrics are saved to a txt file instead of lrc - 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) - 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 - This option only affects the external lyrics files
- Embedded lyrics are tied to `save_metadata` - Embedded lyrics are controlled with `save_metadata`
### Removals ### Removals
- Removed "Zotify" ASCII banner - Removed "Zotify" ASCII banner
- Removed search prompt - Removed search prompt, searches can only be done as cli arguments now.
- Removed song archive files - Removed song archive files
- Removed `{ext}` option in output formats as file extentions are managed automatically - 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 `split_album_discs` because the same functionality can be achieved by using output formatting
- Removed `print_api_errors` because API errors are now trated like regular errors - Removed `print_api_errors` because API errors are now treated like regular errors
- Removed the following config options due to lack of utility - Removed the following config options due to their corresponding features being removed:
- `bulk_wait_time` - `bulk_wait_time`
- `download_real_time` - `download_real_time`
- `md_allgenres` - `md_allgenres`

View file

@ -93,7 +93,7 @@ Here's a very simple example of downloading a track and its metadata:
```python ```python
import zotify import zotify
session = zotify.Session(username="username", password="password") session = zotify.Session.from_userpass(username="username", password="password")
track = session.get_track("4cOdK2wGLETKBW3PvgPWqT") track = session.get_track("4cOdK2wGLETKBW3PvgPWqT")
output = track.create_output("./Music", "{artist} - {title}") output = track.create_output("./Music", "{artist} - {title}")

View file

@ -1,5 +1,5 @@
librespot>=0.0.9 librespot>=0.0.9
music-tag music-tag@git+https://zotify.xyz/zotify/music-tag
mutagen mutagen
Pillow Pillow
pwinput pwinput

View file

@ -3,3 +3,4 @@ flake8
mypy mypy
pre-commit pre-commit
types-requests types-requests
wheel

View file

@ -20,7 +20,7 @@ packages = zotify
python_requires = >=3.10 python_requires = >=3.10
install_requires = install_requires =
librespot>=0.0.9 librespot>=0.0.9
music-tag music-tag@git+https://zotify.xyz/zotify/music-tag
mutagen mutagen
Pillow Pillow
pwinput pwinput
@ -35,5 +35,3 @@ console_scripts =
# Conflicts with black # Conflicts with black
ignore = E203 ignore = E203
max-line-length = 160 max-line-length = 160
per-file-ignores =
zotify/file.py: E701

View file

@ -70,15 +70,9 @@ class Api(ApiClient):
class Session: class Session:
__api: Api
__country: str
__language: str
__session: LibrespotSession
__session_builder: LibrespotSession.Builder
def __init__( def __init__(
self, self,
session_builder: LibrespotSession.Builder, librespot_session: LibrespotSession,
language: str = "en", language: str = "en",
) -> None: ) -> None:
""" """
@ -87,10 +81,10 @@ class Session:
session_builder: An instance of the Librespot Session.Builder session_builder: An instance of the Librespot Session.Builder
langauge: ISO 639-1 language code langauge: ISO 639-1 language code
""" """
self.__session_builder = session_builder self.__session = librespot_session
self.__session = self.__session_builder.create()
self.__language = language self.__language = language
self.__api = Api(self.__session, language) self.__api = Api(self.__session, language)
self.__country = self.api().invoke_url(API_URL + "me")["country"]
@staticmethod @staticmethod
def from_file(cred_file: Path, langauge: str = "en") -> Session: def from_file(cred_file: Path, langauge: str = "en") -> Session:
@ -98,7 +92,7 @@ class Session:
Creates session using saved credentials file Creates session using saved credentials file
Args: Args:
cred_file: Path to credentials file cred_file: Path to credentials file
langauge: ISO 639-1 language code langauge: ISO 639-1 language code for API responses
Returns: Returns:
Zotify session Zotify session
""" """
@ -107,9 +101,8 @@ class Session:
.set_store_credentials(False) .set_store_credentials(False)
.build() .build()
) )
return Session( session = LibrespotSession.Builder(conf).stored_file(str(cred_file))
LibrespotSession.Builder(conf).stored_file(str(cred_file)), langauge return Session(session.create(), langauge)
)
@staticmethod @staticmethod
def from_userpass( def from_userpass(
@ -124,7 +117,7 @@ class Session:
username: Account username username: Account username
password: Account password password: Account password
save_file: Path to save login credentials to, optional. 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: Returns:
Zotify session Zotify session
""" """
@ -132,22 +125,18 @@ class Session:
password = ( password = (
pwinput(prompt="Password: ", mask="*") if password == "" else password pwinput(prompt="Password: ", mask="*") if password == "" else password
) )
builder = LibrespotSession.Configuration.Builder()
if save_file: if save_file:
save_file.parent.mkdir(parents=True, exist_ok=True) save_file.parent.mkdir(parents=True, exist_ok=True)
conf = ( builder.set_stored_credential_file(str(save_file))
LibrespotSession.Configuration.Builder()
.set_stored_credential_file(str(save_file))
.build()
)
else: else:
conf = ( builder.set_store_credentials(False)
LibrespotSession.Configuration.Builder()
.set_store_credentials(False) session = LibrespotSession.Builder(builder.build()).user_pass(
.build() username, password
)
return Session(
LibrespotSession.Builder(conf).user_pass(username, password), language
) )
return Session(session.create(), language)
def __get_playable( def __get_playable(
self, playable_id: PlayableId, quality: Quality self, playable_id: PlayableId, quality: Quality
@ -188,10 +177,6 @@ class Session:
def country(self) -> str: def country(self) -> str:
"""Returns two letter country code of user's account""" """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: def is_premium(self) -> bool:
@ -200,4 +185,4 @@ class Session:
def clone(self) -> Session: def clone(self) -> Session:
"""Creates a copy of the session for use in a parallel thread""" """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)

View file

@ -137,6 +137,8 @@ def main():
from traceback import format_exc from traceback import format_exc
print(format_exc().splitlines()[-1]) print(format_exc().splitlines()[-1])
except KeyboardInterrupt:
print("goodbye")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -22,7 +22,7 @@ from zotify.printer import PrintChannel, Printer
from zotify.utils import API_URL, AudioFormat, b62_to_hex 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 id: PlayableId
library: Path library: Path
output: str output: str
metadata: dict[str, Any] = {}
class Selection: class Selection:
@ -55,6 +56,7 @@ class Selection:
], ],
) -> list[str]: ) -> list[str]:
categories = ",".join(category) categories = ",".join(category)
with Loader("Searching..."):
resp = self.__session.api().invoke_url( resp = self.__session.api().invoke_url(
API_URL + "search", API_URL + "search",
{ {
@ -79,11 +81,22 @@ class Selection:
count += 1 count += 1
return self.__get_selection(links) return self.__get_selection(links)
def get(self, item: str, suffix: str) -> list[str]: def get(self, category: str, name: str = "", content: str = "") -> list[str]:
resp = self.__session.api().invoke_url(f"{API_URL}me/{item}", limit=50)[suffix] 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)): for i in range(len(resp)):
self.__print(i + 1, resp[i]) try:
return self.__get_selection(resp) item = resp[i][name]
except KeyError:
item = resp[i]
items.append(item)
self.__print(i + 1, item)
return self.__get_selection(items)
@staticmethod @staticmethod
def from_file(file_path: Path) -> list[str]: def from_file(file_path: Path) -> list[str]:
@ -169,8 +182,6 @@ class Selection:
class App: class App:
__config: Config
__session: Session
__playable_list: list[PlayableData] = [] __playable_list: list[PlayableData] = []
def __init__(self, args: Namespace): def __init__(self, args: Namespace):
@ -204,7 +215,7 @@ class App:
with Loader("Parsing input..."): with Loader("Parsing input..."):
try: try:
self.parse(ids) self.parse(ids)
except ParsingError as e: except ParseError as e:
Printer.print(PrintChannel.ERRORS, str(e)) Printer.print(PrintChannel.ERRORS, str(e))
self.download_all() self.download_all()
@ -214,13 +225,13 @@ class App:
if args.search: if args.search:
return selection.search(" ".join(args.search), args.category) return selection.search(" ".join(args.search), args.category)
elif args.playlist: elif args.playlist:
return selection.get("playlists", "items") return selection.get("playlists")
elif args.followed: elif args.followed:
return selection.get("following?type=artist", "artists") return selection.get("following?type=artist", content="artists")
elif args.liked_tracks: elif args.liked_tracks:
return selection.get("tracks", "items") return selection.get("tracks", "track")
elif args.liked_episodes: elif args.liked_episodes:
return selection.get("episodes", "items") return selection.get("episodes")
elif args.download: elif args.download:
ids = [] ids = []
for x in args.download: for x in args.download:
@ -228,9 +239,10 @@ class App:
return ids return ids
elif args.urls: elif args.urls:
return args.urls return args.urls
except (FileNotFoundError, ValueError, KeyboardInterrupt): except (FileNotFoundError, ValueError):
pass
Printer.print(PrintChannel.WARNINGS, "there is nothing to do") Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
except KeyboardInterrupt:
Printer.print(PrintChannel.WARNINGS, "\nthere is nothing to do")
exit() exit()
def parse(self, links: list[str]) -> None: def parse(self, links: list[str]) -> None:
@ -246,7 +258,7 @@ class App:
_id = split[-1] _id = split[-1]
id_type = split[-2] id_type = split[-2]
except IndexError: except IndexError:
raise ParsingError(f'Could not parse "{link}"') raise ParseError(f'Could not parse "{link}"')
match id_type: match id_type:
case "album": case "album":
@ -262,7 +274,7 @@ class App:
case "playlist": case "playlist":
self.__parse_playlist(_id) self.__parse_playlist(_id)
case _: 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: def __parse_album(self, hex_id: str) -> None:
album = self.__session.api().get_metadata_4_album(AlbumId.from_hex(hex_id)) 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: def __parse_artist(self, hex_id: str) -> None:
artist = self.__session.api().get_metadata_4_artist(ArtistId.from_hex(hex_id)) 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( 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 disc in album.disc:
for track in disc.track: for track in disc.track:
@ -373,7 +385,7 @@ class App:
self.__config.chunk_size, 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..."): with Loader("Fetching lyrics..."):
try: try:
track.get_lyrics().save(output) track.get_lyrics().save(output)

View file

@ -1,5 +1,6 @@
from argparse import Namespace from argparse import Namespace
from json import dump, load from json import dump, load
from os import environ
from pathlib import Path from pathlib import Path
from sys import platform as PLATFORM from sys import platform as PLATFORM
from typing import Any from typing import Any
@ -33,7 +34,7 @@ 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 = "save_lyrics" 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"
@ -42,8 +43,10 @@ TRANSCODE_BITRATE = "transcode_bitrate"
SYSTEM_PATHS = { SYSTEM_PATHS = {
"win32": Path.home().joinpath("AppData/Roaming/Zotify"), "win32": Path.home().joinpath("AppData/Roaming/Zotify"),
"linux": Path.home().joinpath(".config/zotify"),
"darwin": Path.home().joinpath("Library/Application Support/Zotify"), "darwin": Path.home().joinpath("Library/Application Support/Zotify"),
"linux": Path(environ.get("XDG_CONFIG_HOME") or "~/.config")
.expanduser()
.joinpath("zotify"),
} }
LIBRARY_PATHS = { LIBRARY_PATHS = {
@ -171,10 +174,10 @@ CONFIG_VALUES = {
"arg": "--language", "arg": "--language",
"help": "Language for metadata", "help": "Language for metadata",
}, },
SAVE_LYRICS: { SAVE_LYRICS_FILE: {
"default": True, "default": True,
"type": bool, "type": bool,
"arg": "--save-lyrics", "arg": "--save-lyrics-file",
"help": "Save lyrics to a file", "help": "Save lyrics to a file",
}, },
LYRICS_ONLY: { LYRICS_ONLY: {
@ -277,7 +280,7 @@ class Config:
playlist_library: Path playlist_library: Path
podcast_library: Path podcast_library: Path
print_progress: bool print_progress: bool
save_lyrics: bool save_lyrics_file: bool
save_metadata: bool save_metadata: bool
transcode_bitrate: int transcode_bitrate: int

View file

@ -1,12 +1,11 @@
from errno import ENOENT from errno import ENOENT
from pathlib import Path from pathlib import Path
from subprocess import PIPE, Popen from subprocess import PIPE, Popen
from typing import Any
from music_tag import load_file from music_tag import load_file
from mutagen.oggvorbis import OggVorbisHeaderError from mutagen.oggvorbis import OggVorbisHeaderError
from zotify.utils import AudioFormat from zotify.utils import AudioFormat, MetadataEntry
class TranscodingError(RuntimeError): class TranscodingError(RuntimeError):
@ -87,7 +86,7 @@ class LocalFile:
self.__audio_format = audio_format self.__audio_format = audio_format
self.__bitrate = bitrate self.__bitrate = bitrate
def write_metadata(self, metadata: dict[str, Any]) -> None: def write_metadata(self, metadata: list[MetadataEntry]) -> None:
""" """
Write metadata to file Write metadata to file
Args: Args:
@ -95,9 +94,9 @@ class LocalFile:
""" """
f = load_file(self.__path) f = load_file(self.__path)
f.save() f.save()
for k, v in metadata.items(): for m in metadata:
try: try:
f[k] = str(v) f[m.name] = m.value
except KeyError: except KeyError:
pass pass
try: try:

View file

@ -14,6 +14,7 @@ from zotify.utils import (
LYRICS_URL, LYRICS_URL,
AudioFormat, AudioFormat,
ImageSize, ImageSize,
MetadataEntry,
bytes_to_base62, bytes_to_base62,
fix_filename, fix_filename,
) )
@ -53,7 +54,7 @@ class Lyrics:
class Playable: class Playable:
cover_images: list[Any] cover_images: list[Any]
metadata: dict[str, Any] metadata: list[MetadataEntry]
name: str name: str
input_stream: GeneralAudioStream input_stream: GeneralAudioStream
@ -67,13 +68,12 @@ class Playable:
Returns: Returns:
File path for the track File path for the track
""" """
for k, v in self.metadata.items(): for m in self.metadata:
output = output.replace( if m.output is not None:
"{" + k + "}", fix_filename(str(v).replace("\0", ", ")) output = output.replace("{" + m.name + "}", fix_filename(m.output))
)
file_path = library.joinpath(output).expanduser() file_path = library.joinpath(output).expanduser()
if file_path.exists() and not replace: if file_path.exists() and not replace:
raise FileExistsError("Output Creation Error: File already downloaded") raise FileExistsError("File already downloaded")
else: else:
file_path.parent.mkdir(parents=True, exist_ok=True) file_path.parent.mkdir(parents=True, exist_ok=True)
return file_path return file_path
@ -140,28 +140,34 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
except AttributeError: except AttributeError:
return super().__getattribute__("track").__getattribute__(name) return super().__getattribute__("track").__getattribute__(name)
def __default_metadata(self) -> dict[str, Any]: def __default_metadata(self) -> list[MetadataEntry]:
date = self.album.date date = self.album.date
return { return [
"album": self.album.name, MetadataEntry("album", self.album.name),
"album_artist": "\0".join([a.name for a in self.album.artist]), MetadataEntry("album_artist", [a.name for a in self.album.artist]),
"artist": self.artist[0].name, MetadataEntry("artist", self.artist[0].name),
"artists": "\0".join([a.name for a in self.artist]), MetadataEntry("artists", [a.name for a in self.artist]),
"date": f"{date.year}-{date.month}-{date.day}", MetadataEntry("date", f"{date.year}-{date.month}-{date.day}"),
"disc_number": self.disc_number, MetadataEntry("disc", self.disc_number),
"duration": self.duration, MetadataEntry("duration", self.duration),
"explicit": self.explicit, MetadataEntry("explicit", self.explicit, "[E]" if self.explicit else ""),
"explicit_symbol": "[E]" if self.explicit else "", MetadataEntry("isrc", self.external_id[0].id),
"isrc": self.external_id[0].id, MetadataEntry("popularity", int(self.popularity * 255) / 100),
"popularity": (self.popularity * 255) / 100, MetadataEntry("track_number", self.number, str(self.number).zfill(2)),
"track_number": str(self.number).zfill(2), MetadataEntry("title", self.name),
# "year": self.album.date.year, MetadataEntry(
"title": self.name, "replaygain_track_gain", self.normalization_data.track_gain_db, ""
"replaygain_track_gain": self.normalization_data.track_gain_db, ),
"replaygain_track_peak": self.normalization_data.track_peak, MetadataEntry(
"replaygain_album_gain": self.normalization_data.album_gain_db, "replaygain_track_peak", self.normalization_data.track_peak, ""
"replaygain_album_peak": self.normalization_data.album_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: def get_lyrics(self) -> Lyrics:
"""Returns track lyrics if available""" """Returns track lyrics if available"""
@ -198,17 +204,17 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
except AttributeError: except AttributeError:
return super().__getattribute__("episode").__getattribute__(name) return super().__getattribute__("episode").__getattribute__(name)
def __default_metadata(self) -> dict[str, Any]: def __default_metadata(self) -> list[MetadataEntry]:
return { return [
"description": self.description, MetadataEntry("description", self.description),
"duration": self.duration, MetadataEntry("duration", self.duration),
"episode_number": self.number, MetadataEntry("episode_number", self.number),
"explicit": self.explicit, MetadataEntry("explicit", self.explicit, "[E]" if self.explicit else ""),
"language": self.language, MetadataEntry("language", self.language),
"podcast": self.show.name, MetadataEntry("podcast", self.show.name),
"date": self.publish_time, MetadataEntry("date", self.publish_time),
"title": self.name, MetadataEntry("title", self.name),
} ]
def write_audio_stream( def write_audio_stream(
self, output: Path, chunk_size: int = 128 * 1024 self, output: Path, chunk_size: int = 128 * 1024
@ -221,7 +227,7 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
Returns: Returns:
LocalFile object LocalFile object
""" """
if bool(self.external_url): if not bool(self.external_url):
return super().write_audio_stream(output, chunk_size) return super().write_audio_stream(output, chunk_size)
file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}" file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}"
with get(self.external_url, stream=True) as r, open( with get(self.external_url, stream=True) as r, open(

View file

@ -71,11 +71,12 @@ class Printer:
unit_divisor=unit_divisor, unit_divisor=unit_divisor,
) )
@staticmethod @classmethod
def print_loader(msg: str) -> None: def print_loader(cls, msg: str) -> None:
""" """
Prints animated loading symbol Prints animated loading symbol
Args: Args:
msg: Message to print msg: Message to print
""" """
if cls.__config.print_progress:
print(msg, flush=True, end="") print(msg, flush=True, end="")

View file

@ -2,7 +2,7 @@ from argparse import Action, ArgumentError
from enum import Enum, IntEnum from enum import Enum, IntEnum
from re import IGNORECASE, sub from re import IGNORECASE, sub
from sys import platform as PLATFORM from sys import platform as PLATFORM
from typing import NamedTuple from typing import Any, NamedTuple
from librespot.audio.decoders import AudioQuality from librespot.audio.decoders import AudioQuality
from librespot.util import Base62, bytes_to_hex 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: 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.