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.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"]
}

View File

@ -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`

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).
## 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.

View File

@ -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

View File

@ -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)

View File

@ -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__":

View File

@ -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))

View File

@ -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",

View File

@ -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.

View File

@ -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:

View File

@ -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,
)