mirror of
https://zotify.xyz/zotify/zotify.git
synced 2024-11-10 09:07:53 +01:00
ReplayGain
This commit is contained in:
parent
30721125ef
commit
911c29820a
13 changed files with 171 additions and 141 deletions
40
CHANGELOG.md
40
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.
|
- 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`
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -3,3 +3,4 @@ flake8
|
||||||
mypy
|
mypy
|
||||||
pre-commit
|
pre-commit
|
||||||
types-requests
|
types-requests
|
||||||
|
wheel
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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__":
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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="")
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in a new issue