More accurate search results

This commit is contained in:
zotify 2023-05-29 23:58:06 +12:00
parent 2908dadc5b
commit 30721125ef
11 changed files with 269 additions and 226 deletions

View file

@ -2,5 +2,10 @@
"python.linting.flake8Enabled": true, "python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true, "python.linting.mypyEnabled": true,
"python.linting.enabled": true, "python.linting.enabled": true,
"python.formatting.provider": "black" "python.formatting.provider": "black",
} "editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
},
"isort.args": ["--profile", "black"]
}

View file

@ -1,18 +1,21 @@
# STILL IN DEVELOPMENT, EVERYTHING HERE IS SUBJECT TO CHANGE! # STILL IN DEVELOPMENT, EVERYTHING HERE IS SUBJECT TO CHANGE!
## v1.0.0 ## v1.0.0
An unexpected reboot An unexpected reboot
### BREAKING CHANGES AHEAD ### BREAKING CHANGES AHEAD
- 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 podcasts, existing podcasts 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
@ -24,29 +27,31 @@ An unexpected reboot
- Replaced ffmpy with custom implementation - Replaced ffmpy with custom implementation
### Additions ### Additions
- Added new command line arguments - Added new command line arguments
- `--library`/`-l` overrides both `music_library` and `podcast_library` options similar to `--output` - `--library`/`-l` overrides both `music_library` and `podcast_library` options similar to `--output`
- `--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
- Search results can be narrowed down using field filters - Search results can be narrowed down using field 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 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 album filter can be used while searching albums and tracks.
- The genre filter can be used while searching artists 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 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. 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 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}` - In milliseconds
- `{explicit}` - `{explicit}`
- `{explicit_symbol}` - For output format, will be \[E] if track is 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
- New library location for playlists `playlist_library` - New library location for playlists `playlist_library`
- Added download option for "liked episodes" `--liked-episodes`/`-le` - Added download option for "liked episodes" `--liked-episodes`/`-le`
@ -56,10 +61,11 @@ An unexpected reboot
- 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`
- 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 tied to `save_metadata`
### Removals ### Removals
- Removed "Zotify" ASCII banner - Removed "Zotify" ASCII banner
- Removed search prompt - Removed search prompt
- Removed song archive files - Removed song archive files
@ -67,12 +73,12 @@ An unexpected reboot
- 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 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 `print_api_errors` because API errors are now trated like regular errors
- Removed the following config options due to lack of utility - Removed the following config options due to lack of utility
- `bulk_wait_time` - `bulk_wait_time`
- `download_real_time` - `download_real_time`
- `md_allgenres` - `md_allgenres`
- `md_genredelimiter` - `md_genredelimiter`
- `metadata_delimiter` - `metadata_delimiter`
- `override_auto_wait` - `override_auto_wait`
- `retry_attempts` - `retry_attempts`
- `save_genres` - `save_genres`
- `temp_download_dir` - `temp_download_dir`

View file

@ -10,7 +10,8 @@ Formerly ZSpotify.
Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify). Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify).
## Features ## Features
- Save tracks at up to 320kbps*
- Save tracks at up to 320kbps\*
- Save to most popular audio formats - Save to most popular audio formats
- Built in search - Built in search
- Bulk downloads - Bulk downloads
@ -18,9 +19,10 @@ Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https:/
- Embedded metadata - Embedded metadata
- Downloads all audio, metadata and lyrics directly, no substituting from other services. - Downloads all audio, metadata and lyrics directly, no substituting from other services.
*Non-premium accounts are limited to 160kbps \*Non-premium accounts are limited to 160kbps
## Installation ## Installation
Requires Python 3.10 or greater. \ Requires Python 3.10 or greater. \
Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis. Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis.
@ -30,10 +32,12 @@ Enter the following command in terminal to install Zotify. \
## General Usage ## General Usage
### Simplest usage ### Simplest usage
Downloads specified items. Accepts any combination of track, album, playlist, episode or artists, URLs or URIs. \ Downloads specified items. Accepts any combination of track, album, playlist, episode or artists, URLs or URIs. \
`zotify <items to download>` `zotify <items to download>`
### Basic options ### Basic options
``` ```
-p, --playlist Download selection of user's saved playlists -p, --playlist Download selection of user's saved playlists
-lt, --liked-tracks Download user's liked tracks -lt, --liked-tracks Download user's liked tracks
@ -45,7 +49,7 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
<details><summary>All configuration options</summary> <details><summary>All configuration options</summary>
| Config key | Command line argument | Description | | Config key | Command line argument | Description |
|-------------------------|---------------------------|-----------------------------------------------------| | ----------------------- | ------------------------- | --------------------------------------------------- |
| path_credentials | --path-credentials | Path to credentials file | | path_credentials | --path-credentials | Path to credentials file |
| path_archive | --path-archive | Path to track archive file | | path_archive | --path-archive | Path to track archive file |
| music_library | --music-library | Path to root of music library | | music_library | --music-library | Path to root of music library |
@ -61,27 +65,31 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
| ffmpeg_path | --ffmpeg-path | Path to ffmpeg binary | | ffmpeg_path | --ffmpeg-path | Path to ffmpeg binary |
| ffmpeg_args | --ffmpeg-args | Additional ffmpeg arguments when transcoding | | ffmpeg_args | --ffmpeg-args | Additional ffmpeg arguments when transcoding |
| save_credentials | --save-credentials | Save login credentials to a file | | save_credentials | --save-credentials | Save login credentials to a file |
| save_subtitles | --save-subtitles | | save_subtitles | --save-subtitles |
| save_artist_genres | --save-arist-genres | | save_artist_genres | --save-arist-genres |
</details> </details>
### More about search ### More about search
- `-c` or `--category` can be used to limit search results to certain categories. - `-c` or `--category` can be used to limit search results to certain categories.
- Available categories are "album", "artist", "playlist", "track", "show" and "episode". - Available categories are "album", "artist", "playlist", "track", "show" and "episode".
- You can search in multiple categories at once - You can search in multiple categories at once
- You can also narrow down results by using field filters in search queries - You can also narrow down results by using field filters in search queries
- Currently available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre. - Currently 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. - 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 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 album filter can be used while searching albums and tracks.
- The genre filter can be used while searching artists 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 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. 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.
## Usage as a library ## Usage as a library
Zotify can be used as a user-friendly library for saving music, podcasts, lyrics and metadata. Zotify can be used as a user-friendly library for saving music, podcasts, lyrics and metadata.
Here's a very simple example of downloading a track and its metadata: Here's a very simple example of downloading a track and its metadata:
```python ```python
import zotify import zotify
@ -96,10 +104,11 @@ file.write_cover_art(track.get_cover_art())
``` ```
## Contributing ## Contributing
Pull requests are always welcome, but if adding an entirely new feature we encourage you to create an issue proposing the feature first so we can ensure it's something that fits sthe scope of the project. Pull requests are always welcome, but if adding an entirely new feature we encourage you to create an issue proposing the feature first so we can ensure it's something that fits sthe scope of the project.
Zotify aims to be a comprehensive and user-friendly tool for downloading music and podcasts. Zotify aims to be a comprehensive and user-friendly tool for downloading music and podcasts.
It is designed to be simple by default but offer a high level of configuration for users that want it. It is designed to be simple by default but offer a high level of configuration for users that want it.
All new contributions should follow this principle to keep the program consistent. All new contributions should follow this principle to keep the program consistent.
## Will my account get banned if I use this tool? ## Will my account get banned if I use this tool?
@ -110,12 +119,14 @@ However, it is still a possiblity and it is recommended you use Zotify with a bu
Consider using [Exportify](https://github.com/watsonbox/exportify) to keep backups of your playlists. Consider using [Exportify](https://github.com/watsonbox/exportify) to keep backups of your playlists.
## Disclaimer ## Disclaimer
Using Zotify violates Spotify user guidelines and may get your account suspended. Using Zotify violates Spotify user guidelines and may get your account suspended.
Zotify is intended to be used in compliance with DMCA, Section 1201, for educational, private and fair use, or any simlar laws in other regions. \ Zotify is intended to be used in compliance with DMCA, Section 1201, for educational, private and fair use, or any simlar laws in other regions. \
Zotify contributors cannot be held liable for damages caused by the use of this tool. See the [LICENSE](./LICENCE) file for more details. Zotify contributors cannot be held liable for damages caused by the use of this tool. See the [LICENSE](./LICENCE) file for more details.
## Acknowledgements ## Acknowledgements
- [Librespot-Python](https://github.com/kokarare1212/librespot-python) does most of the heavy lifting, it's used for authentication, fetching track data, and audio streaming. - [Librespot-Python](https://github.com/kokarare1212/librespot-python) does most of the heavy lifting, it's used for authentication, fetching track data, and audio streaming.
- [music-tag](https://github.com/KristoforMaynard/music-tag) is used for writing metadata into the downloaded files. - [music-tag](https://github.com/KristoforMaynard/music-tag) is used for writing metadata into the downloaded files.
- [FFmpeg](https://ffmpeg.org/) is used for transcoding audio. - [FFmpeg](https://ffmpeg.org/) is used for transcoding audio.

View file

@ -1,6 +1,6 @@
[metadata] [metadata]
name = zotify name = zotify
version = 0.9.1 version = 0.9.2
author = Zotify Contributors author = Zotify Contributors
description = A highly customizable music and podcast downloader description = A highly customizable music and podcast downloader
long_description = file: README.md long_description = file: README.md
@ -33,7 +33,7 @@ console_scripts =
[flake8] [flake8]
# Conflicts with black # Conflicts with black
ignore = E203, W503 ignore = E203
max-line-length = 160 max-line-length = 160
per-file-ignores = per-file-ignores =
zotify/file.py: E701 zotify/file.py: E701

View file

@ -1,20 +1,16 @@
from __future__ import annotations
from pathlib import Path from pathlib import Path
from librespot.audio.decoders import VorbisOnlyAudioQuality from librespot.audio.decoders import VorbisOnlyAudioQuality
from librespot.core import ( from librespot.core import ApiClient, PlayableContentFeeder
ApiClient, from librespot.core import Session as LibrespotSession
PlayableContentFeeder,
Session as LibrespotSession,
)
from librespot.metadata import EpisodeId, PlayableId, TrackId from librespot.metadata import EpisodeId, PlayableId, TrackId
from pwinput import pwinput from pwinput import pwinput
from requests import HTTPError, get from requests import HTTPError, get
from zotify.playable import Episode, Track from zotify.playable import Episode, Track
from zotify.utils import ( from zotify.utils import API_URL, Quality
API_URL,
Quality,
)
class Api(ApiClient): class Api(ApiClient):
@ -40,8 +36,8 @@ class Api(ApiClient):
self, self,
url: str, url: str,
params: dict = {}, params: dict = {},
limit: int | None = None, limit: int = 20,
offset: int | None = None, offset: int = 0,
) -> dict: ) -> dict:
""" """
Requests data from api Requests data from api
@ -59,10 +55,8 @@ class Api(ApiClient):
"Accept-Language": self.__language, "Accept-Language": self.__language,
"app-platform": "WebPlayer", "app-platform": "WebPlayer",
} }
if limit: params["limit"] = limit
params["limit"] = limit params["offset"] = offset
if offset:
params["offset"] = offset
response = get(url, headers=headers, params=params) response = get(url, headers=headers, params=params)
data = response.json() data = response.json()
@ -78,61 +72,82 @@ class Api(ApiClient):
class Session: class Session:
__api: Api __api: Api
__country: str __country: str
__is_premium: bool __language: str
__session: LibrespotSession __session: LibrespotSession
__session_builder: LibrespotSession.Builder
def __init__( def __init__(
self, self,
cred_file: Path | None = None, session_builder: LibrespotSession.Builder,
username: str | None = None,
password: str | None = None,
save: bool | None = False,
language: str = "en", language: str = "en",
) -> None: ) -> None:
""" """
Authenticates user, saves credentials to a file Authenticates user, saves credentials to a file and generates api token.
and generates api token Args:
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.__language = language
self.__api = Api(self.__session, language)
@staticmethod
def from_file(cred_file: Path, langauge: str = "en") -> Session:
"""
Creates session using saved credentials file
Args:
cred_file: Path to credentials file
langauge: ISO 639-1 language code
Returns:
Zotify session
"""
conf = (
LibrespotSession.Configuration.Builder()
.set_store_credentials(False)
.build()
)
return Session(
LibrespotSession.Builder(conf).stored_file(str(cred_file)), langauge
)
@staticmethod
def from_userpass(
username: str = "",
password: str = "",
save_file: Path | None = None,
language: str = "en",
) -> Session:
"""
Creates session using username & password
Args: Args:
cred_file: Path to the credentials file
username: Account username username: Account username
password: Account password password: Account password
save: Save given credentials to a file save_file: Path to save login credentials to, optional.
langauge: ISO 639-1 language code
Returns:
Zotify session
""" """
# Find an existing credentials file username = input("Username: ") if username == "" else username
if cred_file is not None and cred_file.is_file(): password = (
pwinput(prompt="Password: ", mask="*") if password == "" else password
)
if save_file:
save_file.parent.mkdir(parents=True, exist_ok=True)
conf = (
LibrespotSession.Configuration.Builder()
.set_stored_credential_file(str(save_file))
.build()
)
else:
conf = ( conf = (
LibrespotSession.Configuration.Builder() LibrespotSession.Configuration.Builder()
.set_store_credentials(False) .set_store_credentials(False)
.build() .build()
) )
self.__session = ( return Session(
LibrespotSession.Builder(conf).stored_file(str(cred_file)).create() LibrespotSession.Builder(conf).user_pass(username, password), language
) )
# Otherwise get new credentials
else:
username = input("Username: ") if username is None else username
password = (
pwinput(prompt="Password: ", mask="*") if password is None else password
)
# Save credentials to file
if save and cred_file:
cred_file.parent.mkdir(parents=True, exist_ok=True)
conf = (
LibrespotSession.Configuration.Builder()
.set_stored_credential_file(str(cred_file))
.build()
)
else:
conf = (
LibrespotSession.Configuration.Builder()
.set_store_credentials(False)
.build()
)
self.__session = (
LibrespotSession.Builder(conf).user_pass(username, password).create()
)
self.__api = Api(self.__session, language)
def __get_playable( def __get_playable(
self, playable_id: PlayableId, quality: Quality self, playable_id: PlayableId, quality: Quality
@ -182,3 +197,7 @@ class Session:
def is_premium(self) -> bool: def is_premium(self) -> bool:
"""Returns users premium account status""" """Returns users premium account status"""
return self.__session.get_user_attribute("type") == "premium" return self.__session.get_user_attribute("type") == "premium"
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)

View file

@ -7,7 +7,7 @@ from zotify.app import App
from zotify.config import CONFIG_PATHS, CONFIG_VALUES from zotify.config import CONFIG_PATHS, CONFIG_VALUES
from zotify.utils import OptionalOrFalse from zotify.utils import OptionalOrFalse
VERSION = "0.9.1" VERSION = "0.9.2"
def main(): def main():
@ -21,6 +21,11 @@ def main():
action="store_true", action="store_true",
help="Print version and exit", help="Print version and exit",
) )
parser.add_argument(
"--debug",
action="store_true",
help="Don't hide tracebacks",
)
parser.add_argument( parser.add_argument(
"--config", "--config",
type=Path, type=Path,
@ -31,7 +36,7 @@ def main():
"-l", "-l",
"--library", "--library",
type=Path, type=Path,
help="Specify a path to the root of a music/podcast library", help="Specify a path to the root of a music/playlist/podcast library",
) )
parser.add_argument( parser.add_argument(
"-o", "--output", type=str, help="Specify the output location/format" "-o", "--output", type=str, help="Specify the output location/format"
@ -45,8 +50,8 @@ def main():
nargs="+", nargs="+",
help="Searches for only this type", help="Searches for only this type",
) )
parser.add_argument("--username", type=str, help="Account username") parser.add_argument("--username", type=str, default="", help="Account username")
parser.add_argument("--password", type=str, help="Account password") parser.add_argument("--password", type=str, default="", help="Account password")
group = parser.add_mutually_exclusive_group(required=False) group = parser.add_mutually_exclusive_group(required=False)
group.add_argument( group.add_argument(
"urls", "urls",
@ -123,12 +128,15 @@ def main():
if args.version: if args.version:
print(VERSION) print(VERSION)
return return
args.func(args) if args.debug:
return
try:
args.func(args) args.func(args)
except Exception as e: else:
print(f"Fatal Error: {e}") try:
args.func(args)
except Exception:
from traceback import format_exc
print(format_exc().splitlines()[-1])
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -18,8 +18,7 @@ from zotify import Session
from zotify.config import Config from zotify.config import Config
from zotify.file import TranscodingError from zotify.file import TranscodingError
from zotify.loader import Loader from zotify.loader import Loader
from zotify.playable import Track from zotify.printer import PrintChannel, Printer
from zotify.printer import Printer, PrintChannel
from zotify.utils import API_URL, AudioFormat, b62_to_hex from zotify.utils import API_URL, AudioFormat, b62_to_hex
@ -174,39 +173,46 @@ class App:
__session: Session __session: Session
__playable_list: list[PlayableData] = [] __playable_list: list[PlayableData] = []
def __init__( def __init__(self, args: Namespace):
self,
args: Namespace,
):
self.__config = Config(args) self.__config = Config(args)
Printer(self.__config) Printer(self.__config)
if self.__config.audio_format == AudioFormat.VORBIS and (self.__config.ffmpeg_args != "" or self.__config.ffmpeg_path != ""): if self.__config.audio_format == AudioFormat.VORBIS and (
Printer.print(PrintChannel.WARNINGS, "FFmpeg options will be ignored since no transcoding is required") self.__config.ffmpeg_args != "" or self.__config.ffmpeg_path != ""
):
Printer.print(
PrintChannel.WARNINGS,
"FFmpeg options will be ignored since no transcoding is required",
)
with Loader("Logging in..."): with Loader("Logging in..."):
if self.__config.credentials is False: if (
self.__session = Session() args.username != "" and args.password != ""
) or not self.__config.credentials.is_file():
self.__session = Session.from_userpass(
args.username,
args.password,
self.__config.credentials,
self.__config.language,
)
else: else:
self.__session = Session( self.__session = Session.from_file(
cred_file=self.__config.credentials, self.__config.credentials, self.__config.language
save=True,
language=self.__config.language,
) )
ids = self.get_selection(args) ids = self.get_selection(args)
with Loader("Parsing input..."): with Loader("Parsing input..."):
try: try:
self.parse(ids) self.parse(ids)
except (IndexError, TypeError) as e: except ParsingError as e:
Printer.print(PrintChannel.ERRORS, str(e)) Printer.print(PrintChannel.ERRORS, str(e))
self.download() self.download_all()
def get_selection(self, args: Namespace) -> list[str]: def get_selection(self, args: Namespace) -> list[str]:
selection = Selection(self.__session) selection = Selection(self.__session)
try: try:
if args.search: if args.search:
return selection.search(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", "items")
elif args.followed: elif args.followed:
@ -222,7 +228,7 @@ class App:
return ids return ids
elif args.urls: elif args.urls:
return args.urls return args.urls
except (FileNotFoundError, ValueError): except (FileNotFoundError, ValueError, KeyboardInterrupt):
pass pass
Printer.print(PrintChannel.WARNINGS, "there is nothing to do") Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
exit() exit()
@ -340,63 +346,56 @@ class App:
"""Returns list of Playable items""" """Returns list of Playable items"""
return self.__playable_list return self.__playable_list
def download(self) -> None: def download_all(self) -> None:
"""Downloads playable to local file""" """Downloads playable to local file"""
for playable in self.__playable_list: for playable in self.__playable_list:
if playable.type == PlayableType.TRACK: self.__download(playable)
with Loader("Fetching track..."):
track = self.__session.get_track( def __download(self, playable: PlayableData) -> None:
playable.id, self.__config.download_quality if playable.type == PlayableType.TRACK:
) with Loader("Fetching track..."):
elif playable.type == PlayableType.EPISODE: track = self.__session.get_track(
with Loader("Fetching episode..."): playable.id, self.__config.download_quality
track = self.__session.get_episode(playable.id)
else:
Printer.print(
PrintChannel.SKIPS,
f'Download Error: Unknown playable content "{playable.type}"',
) )
continue elif playable.type == PlayableType.EPISODE:
with Loader("Fetching episode..."):
try: track = self.__session.get_episode(playable.id)
output = track.create_output(playable.library, playable.output) else:
except FileExistsError as e: Printer.print(
Printer.print(PrintChannel.SKIPS, str(e)) PrintChannel.SKIPS,
continue f'Download Error: Unknown playable content "{playable.type}"',
file = track.write_audio_stream(
output,
self.__config.chunk_size,
) )
if self.__config.save_lyrics and isinstance(track, Track): return
with Loader("Fetching lyrics..."):
try:
track.get_lyrics().save(output)
except FileNotFoundError as e:
Printer.print(PrintChannel.SKIPS, str(e))
Printer.print(PrintChannel.DOWNLOADS, f"\nDownloaded {track.name}") output = track.create_output(playable.library, playable.output)
file = track.write_audio_stream(
output,
self.__config.chunk_size,
)
if self.__config.audio_format != AudioFormat.VORBIS: if self.__config.save_lyrics and playable.type == PlayableType.TRACK:
with Loader("Fetching lyrics..."):
try: try:
with Loader(PrintChannel.PROGRESS, "Converting audio..."): track.get_lyrics().save(output)
file.transcode( except FileNotFoundError as e:
self.__config.audio_format, Printer.print(PrintChannel.SKIPS, str(e))
self.__config.transcode_bitrate
if self.__config.transcode_bitrate > 0
else None,
True,
self.__config.ffmpeg_path
if self.__config.ffmpeg_path != ""
else "ffmpeg",
self.__config.ffmpeg_args.split(),
)
except TranscodingError as e:
Printer.print(PrintChannel.ERRORS, str(e))
if self.__config.save_metadata: Printer.print(PrintChannel.DOWNLOADS, f"\nDownloaded {track.name}")
with Loader("Writing metadata..."):
file.write_metadata(track.metadata) if self.__config.audio_format != AudioFormat.VORBIS:
file.write_cover_art( try:
track.get_cover_art(self.__config.artwork_size) with Loader(PrintChannel.PROGRESS, "Converting audio..."):
file.transcode(
self.__config.audio_format,
self.__config.transcode_bitrate,
True,
self.__config.ffmpeg_path,
self.__config.ffmpeg_args.split(),
) )
except TranscodingError as e:
Printer.print(PrintChannel.ERRORS, str(e))
if self.__config.save_metadata:
with Loader("Writing metadata..."):
file.write_metadata(track.metadata)
file.write_cover_art(track.get_cover_art(self.__config.artwork_size))

View file

@ -6,7 +6,6 @@ from typing import Any
from zotify.utils import AudioFormat, ImageSize, Quality from zotify.utils import AudioFormat, ImageSize, Quality
ALL_ARTISTS = "all_artists" ALL_ARTISTS = "all_artists"
ARTWORK_SIZE = "artwork_size" ARTWORK_SIZE = "artwork_size"
AUDIO_FORMAT = "audio_format" AUDIO_FORMAT = "audio_format"
@ -60,9 +59,9 @@ CONFIG_PATHS = {
} }
OUTPUT_PATHS = { OUTPUT_PATHS = {
"album": "{album_artist}/{album}/{track_number}. {artist} - {title}", "album": "{album_artist}/{album}/{track_number}. {artists} - {title}",
"podcast": "{podcast}/{episode_number} - {title}", "podcast": "{podcast}/{episode_number} - {title}",
"playlist_track": "{playlist}/{playlist_number}. {artist} - {title}", "playlist_track": "{playlist}/{playlist_number}. {artists} - {title}",
"playlist_episode": "{playlist}/{playlist_number}. {episode_number} - {title}", "playlist_episode": "{playlist}/{playlist_number}. {episode_number} - {title}",
} }
@ -170,7 +169,7 @@ CONFIG_VALUES = {
"default": "en", "default": "en",
"type": str, "type": str,
"arg": "--language", "arg": "--language",
"help": "Language for metadata" "help": "Language for metadata",
}, },
SAVE_LYRICS: { SAVE_LYRICS: {
"default": True, "default": True,
@ -239,7 +238,7 @@ CONFIG_VALUES = {
"help": "Show progress bars", "help": "Show progress bars",
}, },
PRINT_SKIPS: { PRINT_SKIPS: {
"default": True, "default": False,
"type": bool, "type": bool,
"arg": "--print-skips", "arg": "--print-skips",
"help": "Show messages if a song is being skipped", "help": "Show messages if a song is being skipped",

View file

@ -1,6 +1,6 @@
from errno import ENOENT from errno import ENOENT
from pathlib import Path from pathlib import Path
from subprocess import Popen, PIPE from subprocess import PIPE, Popen
from typing import Any from typing import Any
from music_tag import load_file from music_tag import load_file
@ -9,12 +9,8 @@ from mutagen.oggvorbis import OggVorbisHeaderError
from zotify.utils import AudioFormat from zotify.utils import AudioFormat
# fmt: off class TranscodingError(RuntimeError):
class TranscodingError(RuntimeError): ... ...
class TargetExistsError(FileExistsError, TranscodingError): ...
class FFmpegNotFoundError(FileNotFoundError, TranscodingError): ...
class FFmpegExecutionError(OSError, TranscodingError): ...
# fmt: on
class LocalFile: class LocalFile:
@ -22,19 +18,18 @@ class LocalFile:
self, self,
path: Path, path: Path,
audio_format: AudioFormat | None = None, audio_format: AudioFormat | None = None,
bitrate: int | None = None, bitrate: int = -1,
): ):
self.__path = path self.__path = path
self.__audio_format = audio_format
self.__bitrate = bitrate self.__bitrate = bitrate
if audio_format:
self.__audio_format = audio_format
def transcode( def transcode(
self, self,
audio_format: AudioFormat | None = None, audio_format: AudioFormat | None = None,
bitrate: int | None = None, bitrate: int = -1,
replace: bool = False, replace: bool = False,
ffmpeg: str = "ffmpeg", ffmpeg: str = "",
opt_args: list[str] = [], opt_args: list[str] = [],
) -> None: ) -> None:
""" """
@ -46,12 +41,15 @@ class LocalFile:
ffmpeg: Location of FFmpeg binary ffmpeg: Location of FFmpeg binary
opt_args: Additional arguments to pass to ffmpeg opt_args: Additional arguments to pass to ffmpeg
""" """
if audio_format is not None: if not audio_format:
new_ext = audio_format.value.ext audio_format = self.__audio_format
if audio_format:
ext = audio_format.value.ext
else: else:
new_ext = self.__audio_format.value.ext ext = self.__path.suffix[1:]
cmd = [ cmd = [
ffmpeg, ffmpeg if ffmpeg != "" else "ffmpeg",
"-y", "-y",
"-hide_banner", "-hide_banner",
"-loglevel", "-loglevel",
@ -59,38 +57,35 @@ class LocalFile:
"-i", "-i",
str(self.__path), str(self.__path),
] ]
newpath = self.__path.parent.joinpath( path = self.__path.parent.joinpath(self.__path.name.rsplit(".", 1)[0] + ext)
self.__path.name.rsplit(".", 1)[0] + new_ext if self.__path == path:
) raise TranscodingError(
if self.__path == newpath: f"Cannot overwrite source, target file {path} already exists."
raise TargetExistsError(
f"Transcoding Error: Cannot overwrite source, target file is already a {self.__audio_format} file."
) )
cmd.extend(["-b:a", str(bitrate) + "k"]) if bitrate else None cmd.extend(["-b:a", str(bitrate) + "k"]) if bitrate > 0 else None
cmd.extend(["-c:a", audio_format.value.name]) if audio_format else None cmd.extend(["-c:a", audio_format.value.name]) if audio_format else None
cmd.extend(opt_args) cmd.extend(opt_args)
cmd.append(str(newpath)) cmd.append(str(path))
try: try:
process = Popen(cmd, stdin=PIPE) process = Popen(cmd, stdin=PIPE)
process.wait() process.wait()
except OSError as e: except OSError as e:
if e.errno == ENOENT: if e.errno == ENOENT:
raise FFmpegNotFoundError("Transcoding Error: FFmpeg was not found") raise TranscodingError("FFmpeg was not found")
else: else:
raise raise
if process.returncode != 0: if process.returncode != 0:
raise FFmpegExecutionError( raise TranscodingError(
f'Transcoding Error: `{" ".join(cmd)}` failed with error code {process.returncode}' f'`{" ".join(cmd)}` failed with error code {process.returncode}'
) )
if replace: if replace:
self.__path.unlink() self.__path.unlink()
self.__path = newpath self.__path = path
self.__audio_format = audio_format
self.__bitrate = bitrate self.__bitrate = bitrate
if audio_format:
self.__audio_format = audio_format
def write_metadata(self, metadata: dict[str, Any]) -> None: def write_metadata(self, metadata: dict[str, Any]) -> None:
""" """
@ -121,4 +116,4 @@ class LocalFile:
try: try:
f.save() f.save()
except OggVorbisHeaderError: except OggVorbisHeaderError:
pass pass # Thrown when using untranscoded file, nothing breaks.

View file

@ -3,8 +3,8 @@ from pathlib import Path
from typing import Any from typing import Any
from librespot.core import PlayableContentFeeder from librespot.core import PlayableContentFeeder
from librespot.util import bytes_to_hex
from librespot.structure import GeneralAudioStream from librespot.structure import GeneralAudioStream
from librespot.util import bytes_to_hex
from requests import get from requests import get
from zotify.file import LocalFile from zotify.file import LocalFile
@ -69,7 +69,7 @@ class Playable:
""" """
for k, v in self.metadata.items(): for k, v in self.metadata.items():
output = output.replace( output = output.replace(
"{" + k + "}", fix_filename(str(v).replace("\0", ",")) "{" + k + "}", fix_filename(str(v).replace("\0", ", "))
) )
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:

View file

@ -1,14 +1,15 @@
from enum import Enum from enum import Enum
from sys import stderr from sys import stderr
from tqdm import tqdm from tqdm import tqdm
from zotify.config import ( from zotify.config import (
Config,
PRINT_SKIPS,
PRINT_PROGRESS,
PRINT_ERRORS,
PRINT_WARNINGS,
PRINT_DOWNLOADS, PRINT_DOWNLOADS,
PRINT_ERRORS,
PRINT_PROGRESS,
PRINT_SKIPS,
PRINT_WARNINGS,
Config,
) )