More accurate search results
This commit is contained in:
parent
2908dadc5b
commit
30721125ef
|
@ -2,5 +2,10 @@
|
|||
"python.linting.flake8Enabled": true,
|
||||
"python.linting.mypyEnabled": 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"]
|
||||
}
|
||||
|
|
68
CHANGELOG.md
68
CHANGELOG.md
|
@ -1,18 +1,21 @@
|
|||
# STILL IN DEVELOPMENT, EVERYTHING HERE IS SUBJECT TO CHANGE!
|
||||
|
||||
## v1.0.0
|
||||
|
||||
An unexpected reboot
|
||||
|
||||
### 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.
|
||||
- ~~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.
|
||||
|
||||
### Changes
|
||||
|
||||
- Genre metadata available for tracks downloaded from an album
|
||||
- Boolean command line options are now set like `--save-metadata` or `--no-save-metadata` for True or False
|
||||
- Setting `--config` (formerly `--config-location`) can be set to "none" to not use any config file
|
||||
- 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 `root_path` and `root_podcast_path` to `music_library` and `podcast_library`
|
||||
- `--username` and `--password` arguments now take priority over saved credentials
|
||||
|
@ -24,29 +27,31 @@ An unexpected reboot
|
|||
- Replaced ffmpy with custom implementation
|
||||
|
||||
### Additions
|
||||
|
||||
- Added new command line arguments
|
||||
- `--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.
|
||||
- `--library`/`-l` overrides both `music_library` and `podcast_library` options similar to `--output`
|
||||
- `--category`/`-c` will limit search results to a certain type, accepted values are "album", "artist", "playlist", "track", "show", "episode". Accepts multiple choices.
|
||||
- `--debug` shows full tracebacks on crash instead of just the final error message
|
||||
- Search results can be narrowed down using field filters
|
||||
- Available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre.
|
||||
- The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1970-1982).
|
||||
- The album filter can be used while searching albums and tracks.
|
||||
- The genre filter can be used while searching artists and tracks.
|
||||
- The isrc and track filters can be used while searching tracks.
|
||||
- The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to show only albums in the lowest 10% of popularity.
|
||||
- Available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre.
|
||||
- The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1970-1982).
|
||||
- The album filter can be used while searching albums and tracks.
|
||||
- The genre filter can be used while searching artists and tracks.
|
||||
- The isrc and track filters can be used while searching tracks.
|
||||
- The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to show only albums in the lowest 10% of popularity.
|
||||
- Search has been expanded to include podcasts and episodes
|
||||
- New output placeholders / metadata tags for tracks
|
||||
- `{artists}`
|
||||
- `{album_artist}`
|
||||
- `{album_artists}`
|
||||
- !!`{duration}` - In milliseconds
|
||||
- `{explicit}`
|
||||
- `{explicit_symbol}` - For output format, will be \[E] if track is explicit.
|
||||
- `{isrc}`
|
||||
- `{licensor}`
|
||||
- !!`{popularity}`
|
||||
- `{release_date}`
|
||||
- `{track_number}`
|
||||
- `{artists}`
|
||||
- `{album_artist}`
|
||||
- `{album_artists}`
|
||||
- !!`{duration}` - In milliseconds
|
||||
- `{explicit}`
|
||||
- `{explicit_symbol}` - For output format, will be \[E] if track is explicit.
|
||||
- `{isrc}`
|
||||
- `{licensor}`
|
||||
- !!`{popularity}`
|
||||
- `{release_date}`
|
||||
- `{track_number}`
|
||||
- Genre information is now more accurate and is always enabled
|
||||
- New library location for playlists `playlist_library`
|
||||
- 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 can now be embedded directly into file metadata (for supported file types)
|
||||
- Added new option `save_lyrics`
|
||||
- This option only affects the external lyrics files
|
||||
- Embedded lyrics are tied to `save_metadata`
|
||||
- This option only affects the external lyrics files
|
||||
- Embedded lyrics are tied to `save_metadata`
|
||||
|
||||
### Removals
|
||||
|
||||
- Removed "Zotify" ASCII banner
|
||||
- Removed search prompt
|
||||
- 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 `print_api_errors` because API errors are now trated like regular errors
|
||||
- Removed the following config options due to lack of utility
|
||||
- `bulk_wait_time`
|
||||
- `download_real_time`
|
||||
- `md_allgenres`
|
||||
- `md_genredelimiter`
|
||||
- `metadata_delimiter`
|
||||
- `override_auto_wait`
|
||||
- `retry_attempts`
|
||||
- `save_genres`
|
||||
- `temp_download_dir`
|
||||
- `bulk_wait_time`
|
||||
- `download_real_time`
|
||||
- `md_allgenres`
|
||||
- `md_genredelimiter`
|
||||
- `metadata_delimiter`
|
||||
- `override_auto_wait`
|
||||
- `retry_attempts`
|
||||
- `save_genres`
|
||||
- `temp_download_dir`
|
||||
|
|
43
README.md
43
README.md
|
@ -10,7 +10,8 @@ Formerly ZSpotify.
|
|||
Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify).
|
||||
|
||||
## Features
|
||||
- Save tracks at up to 320kbps*
|
||||
|
||||
- Save tracks at up to 320kbps\*
|
||||
- Save to most popular audio formats
|
||||
- Built in search
|
||||
- Bulk downloads
|
||||
|
@ -18,9 +19,10 @@ Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https:/
|
|||
- Embedded metadata
|
||||
- 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
|
||||
|
||||
Requires Python 3.10 or greater. \
|
||||
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
|
||||
|
||||
### Simplest usage
|
||||
|
||||
Downloads specified items. Accepts any combination of track, album, playlist, episode or artists, URLs or URIs. \
|
||||
`zotify <items to download>`
|
||||
|
||||
### Basic options
|
||||
|
||||
```
|
||||
-p, --playlist Download selection of user's saved playlists
|
||||
-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>
|
||||
|
||||
| Config key | Command line argument | Description |
|
||||
|-------------------------|---------------------------|-----------------------------------------------------|
|
||||
| ----------------------- | ------------------------- | --------------------------------------------------- |
|
||||
| path_credentials | --path-credentials | Path to credentials file |
|
||||
| path_archive | --path-archive | Path to track archive file |
|
||||
| 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_args | --ffmpeg-args | Additional ffmpeg arguments when transcoding |
|
||||
| save_credentials | --save-credentials | Save login credentials to a file |
|
||||
| save_subtitles | --save-subtitles |
|
||||
| save_artist_genres | --save-arist-genres |
|
||||
| save_subtitles | --save-subtitles |
|
||||
| save_artist_genres | --save-arist-genres |
|
||||
|
||||
</details>
|
||||
|
||||
### More about search
|
||||
|
||||
- `-c` or `--category` can be used to limit search results to certain categories.
|
||||
- Available categories are "album", "artist", "playlist", "track", "show" and "episode".
|
||||
- You can search in multiple categories at once
|
||||
- Available categories are "album", "artist", "playlist", "track", "show" and "episode".
|
||||
- You can search in multiple categories at once
|
||||
- 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.
|
||||
- Available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre.
|
||||
- The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1970-1982).
|
||||
- The album filter can be used while searching albums and tracks.
|
||||
- The genre filter can be used while searching artists and tracks.
|
||||
- The isrc and track filters can be used while searching tracks.
|
||||
- The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to show only albums in the lowest 10% of popularity.
|
||||
- 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.
|
||||
- The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1970-1982).
|
||||
- The album filter can be used while searching albums and tracks.
|
||||
- The genre filter can be used while searching artists and tracks.
|
||||
- The isrc and track filters can be used while searching tracks.
|
||||
- The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to show only albums in the lowest 10% of popularity.
|
||||
|
||||
## Usage as a library
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
import zotify
|
||||
|
||||
|
@ -96,10 +104,11 @@ file.write_cover_art(track.get_cover_art())
|
|||
```
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
All new contributions should follow this principle to keep the program consistent.
|
||||
|
||||
## 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.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
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 contributors cannot be held liable for damages caused by the use of this tool. See the [LICENSE](./LICENCE) file for more details.
|
||||
|
||||
## 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.
|
||||
- [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.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[metadata]
|
||||
name = zotify
|
||||
version = 0.9.1
|
||||
version = 0.9.2
|
||||
author = Zotify Contributors
|
||||
description = A highly customizable music and podcast downloader
|
||||
long_description = file: README.md
|
||||
|
@ -33,7 +33,7 @@ console_scripts =
|
|||
|
||||
[flake8]
|
||||
# Conflicts with black
|
||||
ignore = E203, W503
|
||||
ignore = E203
|
||||
max-line-length = 160
|
||||
per-file-ignores =
|
||||
zotify/file.py: E701
|
||||
|
|
|
@ -1,20 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from librespot.audio.decoders import VorbisOnlyAudioQuality
|
||||
from librespot.core import (
|
||||
ApiClient,
|
||||
PlayableContentFeeder,
|
||||
Session as LibrespotSession,
|
||||
)
|
||||
from librespot.core import ApiClient, PlayableContentFeeder
|
||||
from librespot.core import Session as LibrespotSession
|
||||
from librespot.metadata import EpisodeId, PlayableId, TrackId
|
||||
from pwinput import pwinput
|
||||
from requests import HTTPError, get
|
||||
|
||||
from zotify.playable import Episode, Track
|
||||
from zotify.utils import (
|
||||
API_URL,
|
||||
Quality,
|
||||
)
|
||||
from zotify.utils import API_URL, Quality
|
||||
|
||||
|
||||
class Api(ApiClient):
|
||||
|
@ -40,8 +36,8 @@ class Api(ApiClient):
|
|||
self,
|
||||
url: str,
|
||||
params: dict = {},
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
"""
|
||||
Requests data from api
|
||||
|
@ -59,10 +55,8 @@ class Api(ApiClient):
|
|||
"Accept-Language": self.__language,
|
||||
"app-platform": "WebPlayer",
|
||||
}
|
||||
if limit:
|
||||
params["limit"] = limit
|
||||
if offset:
|
||||
params["offset"] = offset
|
||||
params["limit"] = limit
|
||||
params["offset"] = offset
|
||||
|
||||
response = get(url, headers=headers, params=params)
|
||||
data = response.json()
|
||||
|
@ -78,61 +72,82 @@ class Api(ApiClient):
|
|||
class Session:
|
||||
__api: Api
|
||||
__country: str
|
||||
__is_premium: bool
|
||||
__language: str
|
||||
__session: LibrespotSession
|
||||
__session_builder: LibrespotSession.Builder
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cred_file: Path | None = None,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
save: bool | None = False,
|
||||
session_builder: LibrespotSession.Builder,
|
||||
language: str = "en",
|
||||
) -> None:
|
||||
"""
|
||||
Authenticates user, saves credentials to a file
|
||||
and generates api token
|
||||
Authenticates user, saves credentials to a file 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:
|
||||
cred_file: Path to the credentials file
|
||||
username: Account username
|
||||
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
|
||||
if cred_file is not None and cred_file.is_file():
|
||||
username = input("Username: ") if username == "" else username
|
||||
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 = (
|
||||
LibrespotSession.Configuration.Builder()
|
||||
.set_store_credentials(False)
|
||||
.build()
|
||||
)
|
||||
self.__session = (
|
||||
LibrespotSession.Builder(conf).stored_file(str(cred_file)).create()
|
||||
)
|
||||
# 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)
|
||||
return Session(
|
||||
LibrespotSession.Builder(conf).user_pass(username, password), language
|
||||
)
|
||||
|
||||
def __get_playable(
|
||||
self, playable_id: PlayableId, quality: Quality
|
||||
|
@ -182,3 +197,7 @@ class Session:
|
|||
def is_premium(self) -> bool:
|
||||
"""Returns users premium account status"""
|
||||
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)
|
||||
|
|
|
@ -7,7 +7,7 @@ from zotify.app import App
|
|||
from zotify.config import CONFIG_PATHS, CONFIG_VALUES
|
||||
from zotify.utils import OptionalOrFalse
|
||||
|
||||
VERSION = "0.9.1"
|
||||
VERSION = "0.9.2"
|
||||
|
||||
|
||||
def main():
|
||||
|
@ -21,6 +21,11 @@ def main():
|
|||
action="store_true",
|
||||
help="Print version and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Don't hide tracebacks",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
|
@ -31,7 +36,7 @@ def main():
|
|||
"-l",
|
||||
"--library",
|
||||
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(
|
||||
"-o", "--output", type=str, help="Specify the output location/format"
|
||||
|
@ -45,8 +50,8 @@ def main():
|
|||
nargs="+",
|
||||
help="Searches for only this type",
|
||||
)
|
||||
parser.add_argument("--username", type=str, help="Account username")
|
||||
parser.add_argument("--password", type=str, help="Account password")
|
||||
parser.add_argument("--username", type=str, default="", help="Account username")
|
||||
parser.add_argument("--password", type=str, default="", help="Account password")
|
||||
group = parser.add_mutually_exclusive_group(required=False)
|
||||
group.add_argument(
|
||||
"urls",
|
||||
|
@ -123,12 +128,15 @@ def main():
|
|||
if args.version:
|
||||
print(VERSION)
|
||||
return
|
||||
args.func(args)
|
||||
return
|
||||
try:
|
||||
if args.debug:
|
||||
args.func(args)
|
||||
except Exception as e:
|
||||
print(f"Fatal Error: {e}")
|
||||
else:
|
||||
try:
|
||||
args.func(args)
|
||||
except Exception:
|
||||
from traceback import format_exc
|
||||
|
||||
print(format_exc().splitlines()[-1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
137
zotify/app.py
137
zotify/app.py
|
@ -18,8 +18,7 @@ from zotify import Session
|
|||
from zotify.config import Config
|
||||
from zotify.file import TranscodingError
|
||||
from zotify.loader import Loader
|
||||
from zotify.playable import Track
|
||||
from zotify.printer import Printer, PrintChannel
|
||||
from zotify.printer import PrintChannel, Printer
|
||||
from zotify.utils import API_URL, AudioFormat, b62_to_hex
|
||||
|
||||
|
||||
|
@ -174,39 +173,46 @@ class App:
|
|||
__session: Session
|
||||
__playable_list: list[PlayableData] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
args: Namespace,
|
||||
):
|
||||
def __init__(self, args: Namespace):
|
||||
self.__config = Config(args)
|
||||
Printer(self.__config)
|
||||
|
||||
if self.__config.audio_format == AudioFormat.VORBIS and (self.__config.ffmpeg_args != "" or self.__config.ffmpeg_path != ""):
|
||||
Printer.print(PrintChannel.WARNINGS, "FFmpeg options will be ignored since no transcoding is required")
|
||||
if self.__config.audio_format == AudioFormat.VORBIS and (
|
||||
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..."):
|
||||
if self.__config.credentials is False:
|
||||
self.__session = Session()
|
||||
if (
|
||||
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:
|
||||
self.__session = Session(
|
||||
cred_file=self.__config.credentials,
|
||||
save=True,
|
||||
language=self.__config.language,
|
||||
self.__session = Session.from_file(
|
||||
self.__config.credentials, self.__config.language
|
||||
)
|
||||
|
||||
ids = self.get_selection(args)
|
||||
with Loader("Parsing input..."):
|
||||
try:
|
||||
self.parse(ids)
|
||||
except (IndexError, TypeError) as e:
|
||||
except ParsingError as e:
|
||||
Printer.print(PrintChannel.ERRORS, str(e))
|
||||
self.download()
|
||||
self.download_all()
|
||||
|
||||
def get_selection(self, args: Namespace) -> list[str]:
|
||||
selection = Selection(self.__session)
|
||||
try:
|
||||
if args.search:
|
||||
return selection.search(args.search, args.category)
|
||||
return selection.search(" ".join(args.search), args.category)
|
||||
elif args.playlist:
|
||||
return selection.get("playlists", "items")
|
||||
elif args.followed:
|
||||
|
@ -222,7 +228,7 @@ class App:
|
|||
return ids
|
||||
elif args.urls:
|
||||
return args.urls
|
||||
except (FileNotFoundError, ValueError):
|
||||
except (FileNotFoundError, ValueError, KeyboardInterrupt):
|
||||
pass
|
||||
Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
|
||||
exit()
|
||||
|
@ -340,63 +346,56 @@ class App:
|
|||
"""Returns list of Playable items"""
|
||||
return self.__playable_list
|
||||
|
||||
def download(self) -> None:
|
||||
def download_all(self) -> None:
|
||||
"""Downloads playable to local file"""
|
||||
for playable in self.__playable_list:
|
||||
if playable.type == PlayableType.TRACK:
|
||||
with Loader("Fetching track..."):
|
||||
track = self.__session.get_track(
|
||||
playable.id, self.__config.download_quality
|
||||
)
|
||||
elif playable.type == PlayableType.EPISODE:
|
||||
with Loader("Fetching episode..."):
|
||||
track = self.__session.get_episode(playable.id)
|
||||
else:
|
||||
Printer.print(
|
||||
PrintChannel.SKIPS,
|
||||
f'Download Error: Unknown playable content "{playable.type}"',
|
||||
self.__download(playable)
|
||||
|
||||
def __download(self, playable: PlayableData) -> None:
|
||||
if playable.type == PlayableType.TRACK:
|
||||
with Loader("Fetching track..."):
|
||||
track = self.__session.get_track(
|
||||
playable.id, self.__config.download_quality
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
output = track.create_output(playable.library, playable.output)
|
||||
except FileExistsError as e:
|
||||
Printer.print(PrintChannel.SKIPS, str(e))
|
||||
continue
|
||||
|
||||
file = track.write_audio_stream(
|
||||
output,
|
||||
self.__config.chunk_size,
|
||||
elif playable.type == PlayableType.EPISODE:
|
||||
with Loader("Fetching episode..."):
|
||||
track = self.__session.get_episode(playable.id)
|
||||
else:
|
||||
Printer.print(
|
||||
PrintChannel.SKIPS,
|
||||
f'Download Error: Unknown playable content "{playable.type}"',
|
||||
)
|
||||
if self.__config.save_lyrics and isinstance(track, Track):
|
||||
with Loader("Fetching lyrics..."):
|
||||
try:
|
||||
track.get_lyrics().save(output)
|
||||
except FileNotFoundError as e:
|
||||
Printer.print(PrintChannel.SKIPS, str(e))
|
||||
return
|
||||
|
||||
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:
|
||||
with Loader(PrintChannel.PROGRESS, "Converting audio..."):
|
||||
file.transcode(
|
||||
self.__config.audio_format,
|
||||
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))
|
||||
track.get_lyrics().save(output)
|
||||
except FileNotFoundError as e:
|
||||
Printer.print(PrintChannel.SKIPS, 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)
|
||||
Printer.print(PrintChannel.DOWNLOADS, f"\nDownloaded {track.name}")
|
||||
|
||||
if self.__config.audio_format != AudioFormat.VORBIS:
|
||||
try:
|
||||
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))
|
||||
|
|
|
@ -6,7 +6,6 @@ from typing import Any
|
|||
|
||||
from zotify.utils import AudioFormat, ImageSize, Quality
|
||||
|
||||
|
||||
ALL_ARTISTS = "all_artists"
|
||||
ARTWORK_SIZE = "artwork_size"
|
||||
AUDIO_FORMAT = "audio_format"
|
||||
|
@ -60,9 +59,9 @@ CONFIG_PATHS = {
|
|||
}
|
||||
|
||||
OUTPUT_PATHS = {
|
||||
"album": "{album_artist}/{album}/{track_number}. {artist} - {title}",
|
||||
"album": "{album_artist}/{album}/{track_number}. {artists} - {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}",
|
||||
}
|
||||
|
||||
|
@ -170,7 +169,7 @@ CONFIG_VALUES = {
|
|||
"default": "en",
|
||||
"type": str,
|
||||
"arg": "--language",
|
||||
"help": "Language for metadata"
|
||||
"help": "Language for metadata",
|
||||
},
|
||||
SAVE_LYRICS: {
|
||||
"default": True,
|
||||
|
@ -239,7 +238,7 @@ CONFIG_VALUES = {
|
|||
"help": "Show progress bars",
|
||||
},
|
||||
PRINT_SKIPS: {
|
||||
"default": True,
|
||||
"default": False,
|
||||
"type": bool,
|
||||
"arg": "--print-skips",
|
||||
"help": "Show messages if a song is being skipped",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from errno import ENOENT
|
||||
from pathlib import Path
|
||||
from subprocess import Popen, PIPE
|
||||
from subprocess import PIPE, Popen
|
||||
from typing import Any
|
||||
|
||||
from music_tag import load_file
|
||||
|
@ -9,12 +9,8 @@ from mutagen.oggvorbis import OggVorbisHeaderError
|
|||
from zotify.utils import AudioFormat
|
||||
|
||||
|
||||
# fmt: off
|
||||
class TranscodingError(RuntimeError): ...
|
||||
class TargetExistsError(FileExistsError, TranscodingError): ...
|
||||
class FFmpegNotFoundError(FileNotFoundError, TranscodingError): ...
|
||||
class FFmpegExecutionError(OSError, TranscodingError): ...
|
||||
# fmt: on
|
||||
class TranscodingError(RuntimeError):
|
||||
...
|
||||
|
||||
|
||||
class LocalFile:
|
||||
|
@ -22,19 +18,18 @@ class LocalFile:
|
|||
self,
|
||||
path: Path,
|
||||
audio_format: AudioFormat | None = None,
|
||||
bitrate: int | None = None,
|
||||
bitrate: int = -1,
|
||||
):
|
||||
self.__path = path
|
||||
self.__audio_format = audio_format
|
||||
self.__bitrate = bitrate
|
||||
if audio_format:
|
||||
self.__audio_format = audio_format
|
||||
|
||||
def transcode(
|
||||
self,
|
||||
audio_format: AudioFormat | None = None,
|
||||
bitrate: int | None = None,
|
||||
bitrate: int = -1,
|
||||
replace: bool = False,
|
||||
ffmpeg: str = "ffmpeg",
|
||||
ffmpeg: str = "",
|
||||
opt_args: list[str] = [],
|
||||
) -> None:
|
||||
"""
|
||||
|
@ -46,12 +41,15 @@ class LocalFile:
|
|||
ffmpeg: Location of FFmpeg binary
|
||||
opt_args: Additional arguments to pass to ffmpeg
|
||||
"""
|
||||
if audio_format is not None:
|
||||
new_ext = audio_format.value.ext
|
||||
if not audio_format:
|
||||
audio_format = self.__audio_format
|
||||
if audio_format:
|
||||
ext = audio_format.value.ext
|
||||
else:
|
||||
new_ext = self.__audio_format.value.ext
|
||||
ext = self.__path.suffix[1:]
|
||||
|
||||
cmd = [
|
||||
ffmpeg,
|
||||
ffmpeg if ffmpeg != "" else "ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
|
@ -59,38 +57,35 @@ class LocalFile:
|
|||
"-i",
|
||||
str(self.__path),
|
||||
]
|
||||
newpath = self.__path.parent.joinpath(
|
||||
self.__path.name.rsplit(".", 1)[0] + new_ext
|
||||
)
|
||||
if self.__path == newpath:
|
||||
raise TargetExistsError(
|
||||
f"Transcoding Error: Cannot overwrite source, target file is already a {self.__audio_format} file."
|
||||
path = self.__path.parent.joinpath(self.__path.name.rsplit(".", 1)[0] + ext)
|
||||
if self.__path == path:
|
||||
raise TranscodingError(
|
||||
f"Cannot overwrite source, target file {path} already exists."
|
||||
)
|
||||
|
||||
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(opt_args)
|
||||
cmd.append(str(newpath))
|
||||
cmd.append(str(path))
|
||||
|
||||
try:
|
||||
process = Popen(cmd, stdin=PIPE)
|
||||
process.wait()
|
||||
except OSError as e:
|
||||
if e.errno == ENOENT:
|
||||
raise FFmpegNotFoundError("Transcoding Error: FFmpeg was not found")
|
||||
raise TranscodingError("FFmpeg was not found")
|
||||
else:
|
||||
raise
|
||||
if process.returncode != 0:
|
||||
raise FFmpegExecutionError(
|
||||
f'Transcoding Error: `{" ".join(cmd)}` failed with error code {process.returncode}'
|
||||
raise TranscodingError(
|
||||
f'`{" ".join(cmd)}` failed with error code {process.returncode}'
|
||||
)
|
||||
|
||||
if replace:
|
||||
self.__path.unlink()
|
||||
self.__path = newpath
|
||||
self.__path = path
|
||||
self.__audio_format = audio_format
|
||||
self.__bitrate = bitrate
|
||||
if audio_format:
|
||||
self.__audio_format = audio_format
|
||||
|
||||
def write_metadata(self, metadata: dict[str, Any]) -> None:
|
||||
"""
|
||||
|
@ -121,4 +116,4 @@ class LocalFile:
|
|||
try:
|
||||
f.save()
|
||||
except OggVorbisHeaderError:
|
||||
pass
|
||||
pass # Thrown when using untranscoded file, nothing breaks.
|
||||
|
|
|
@ -3,8 +3,8 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
from librespot.core import PlayableContentFeeder
|
||||
from librespot.util import bytes_to_hex
|
||||
from librespot.structure import GeneralAudioStream
|
||||
from librespot.util import bytes_to_hex
|
||||
from requests import get
|
||||
|
||||
from zotify.file import LocalFile
|
||||
|
@ -69,7 +69,7 @@ class Playable:
|
|||
"""
|
||||
for k, v in self.metadata.items():
|
||||
output = output.replace(
|
||||
"{" + k + "}", fix_filename(str(v).replace("\0", ","))
|
||||
"{" + k + "}", fix_filename(str(v).replace("\0", ", "))
|
||||
)
|
||||
file_path = library.joinpath(output).expanduser()
|
||||
if file_path.exists() and not replace:
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
from enum import Enum
|
||||
from sys import stderr
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from zotify.config import (
|
||||
Config,
|
||||
PRINT_SKIPS,
|
||||
PRINT_PROGRESS,
|
||||
PRINT_ERRORS,
|
||||
PRINT_WARNINGS,
|
||||
PRINT_DOWNLOADS,
|
||||
PRINT_ERRORS,
|
||||
PRINT_PROGRESS,
|
||||
PRINT_SKIPS,
|
||||
PRINT_WARNINGS,
|
||||
Config,
|
||||
)
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue