Date: Tue, 15 Feb 2022 19:07:45 +1300
Subject: [PATCH 034/169] Zotify 0.6
---
CHANGELOG.md | 15 +++++----
README.md | 73 +++++++++++++++++++++++++++---------------
zotify/__main__.py | 2 +-
zotify/app.py | 17 ++++++----
zotify/config.py | 80 +++++++++++++++++++++++-----------------------
zotify/podcast.py | 2 +-
zotify/track.py | 43 ++++++++++++++-----------
zotify/utils.py | 4 +--
zotify/zotify.py | 2 +-
9 files changed, 136 insertions(+), 102 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6ffec3..7fa855c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,19 +1,25 @@
# Changelog
## v0.6
**General changes**
-- Switched from os.path to pathlib
-- Renamed .song_archive to track_archive
+- Added "DOWNLOAD_QUALITY" config option. This can be "normal" (96kbks), "high" (160kpbs), "very-high" (320kpbs, premium only) or "auto" which selects the highest format available for your account automatically.
+- The "FORCE_PREMIUM" option has been removed, the same result can be achieved with `--download-quality="very-high"`.
+- The "BITRATE" option has been renamed "TRANSCODE_BITRATE" as it now only effects transcodes
+- FFmpeg is now semi-optional, not having it installed means you are limited to saving music as ogg vorbis.
- Zotify can now be installed with `pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip`
- Zotify can be ran from any directory with `zotify [args]`, you no longer need to prefix "python" in the command.
- The -s option now takes search input as a command argument, it will still promt you if no search is given.
- The -ls/--liked-songs option has been shrotened to -l/--liked,
+- Singles are now stored in their own folders under the artist folder
+- Fixed default config not loading on first run
+- Now shows asterisks when entering password
+- Switched from os.path to pathlib
- New default config locations:
- Windows: `%AppData%\Roaming\Zotify\config.json`
- Linux: `~/.config/zotify/config.json`
- macOS: `~/Library/Application Support/Zotify/config.json`
- Other/Undetected: `.zotify/config.json`
- You can still use `--config-location` to specify a different location.
-- New default config locations:
+- New default credential locations:
- Windows: `%AppData%\Roaming\Zotify\credentials.json`
- Linux: `~/.local/share/zotify/credentials.json`
- macOS: `~/Library/Application Support/Zotify/credentials.json`
@@ -24,9 +30,6 @@
- Linux & macOS: `~/Music/Zotify Music/` & `~/Music/Zotify Podcasts/`
- Other/Undetected: `./Zotify Music/` & `./Zotify Podcasts/`
- You can still use `--root-path` and `--root-podcast-path` respectively to specify a differnt location
-- Singles are now stored in their own folders
-- Fixed default config not loading on first run
-- Now shows asterisks when entering password
**Docker**
- Dockerfile is currently broken, it will be fixed soon. \
diff --git a/README.md b/README.md
index 297f179..a81d268 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Zotify
-### A music and podcast downloader needing only a python interpreter and ffmpeg.
+### A highly customizable music and podcast downloader.
@@ -8,20 +8,33 @@
[Discord Server](https://discord.gg/XDYsFRTUjE)
+### Featues
+ - Downloads at up to 320kbps*
+ - Downloads directly from the source**
+ - Downloads podcasts, playlists, liked songs, albums, artists, singles.
+ - Option to download in real time to appear more legitimate***
+ - Supports multiple audio formats
+ - Download directly from URL or use built-in in search
+ - Bulk downloads from a list of URLs in a text file or parsed directly as arguments
+
+*Free accounts are limited to 160kbps. \
+**Audio files are NOT substituted with ones from other sources such as YouTube or Deezer, they are sourced directly. \
+***'real time' refers to downloading at the speed it would normally be streamed at (the duration of the track).
+
### Install
```
Dependencies:
- Python 3.9 or greater
-- ffmpeg*
+- FFmpeg*
Installation:
python -m pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip
```
-\*Windows users can download the binaries from [ffmpeg.org](https://ffmpeg.org) and add them to %PATH%. Mac users can install it via [Homebrew](https://brew.sh) by running `brew install ffmpeg`. Linux users should already know how to install ffmpeg, I don't want to add instructions for every package manager.
+\*Zotify will work without FFmpeg but transcoding will be unavailable.
### Command line usage
@@ -44,29 +57,34 @@ Be aware you have to set boolean values in the commandline like this: `--downloa
| Key (config) | commandline parameter | Description
|------------------------------|----------------------------------|---------------------------------------------------------------------|
-| ROOT_PATH | --root-path | directory where Zotify saves music
-| ROOT_PODCAST_PATH | --root-podcast-path | directory where Zotify saves podcasts
-| SKIP_EXISTING_FILES | --skip-existing-files | Skip songs with the same name
-| SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | Use a song_archive file to skip previously downloaded songs
-| DOWNLOAD_FORMAT | --download-format | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis)
-| FORCE_PREMIUM | --force-premium | Force the use of high quality downloads (only with premium accounts)
-| ANTI_BAN_WAIT_TIME | --anti-ban-wait-time | The wait time between bulk downloads
-| OVERRIDE_AUTO_WAIT | --override-auto-wait | Totally disable wait time between songs with the risk of instability
-| CHUNK_SIZE | --chunk-size | Chunk size for downloading
-| SPLIT_ALBUM_DISCS | --split-album-discs | Saves each disk in its own folder
-| DOWNLOAD_REAL_TIME | --download-real-time | Downloads songs as fast as they would be played, should prevent account bans.
-| LANGUAGE | --language | Language for spotify metadata
-| BITRATE | --bitrate | Overwrite the bitrate for ffmpeg encoding
-| SONG_ARCHIVE | --song-archive | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED
| CREDENTIALS_LOCATION | --credentials-location | The location of the credentials.json
| OUTPUT | --output | The output location/format (see below)
-| PRINT_SPLASH | --print-splash | Print the splash message
-| PRINT_SKIPS | --print-skips | Print messages if a song is being skipped
-| PRINT_DOWNLOAD_PROGRESS | --print-download-progress | Print the download/playlist progress bars
-| PRINT_ERRORS | --print-errors | Print errors
+| SONG_ARCHIVE | --song-archive | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED
+| ROOT_PATH | --root-path | Directory where Zotify saves music
+| ROOT_PODCAST_PATH | --root-podcast-path | Directory where Zotify saves podcasts
+| SPLIT_ALBUM_DISCS | --split-album-discs | Saves each disk in its own folder
+| MD_ALLGENRES | --md-allgenres | Save all relevant genres in metadata
+| MD_GENREDELIMITER | --md-genredelimiter | Delimiter character used to split genres in metadata
+| DOWNLOAD_FORMAT | --download-format | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis)
+| DOWNLOAD_QUALITY | --download-quality | Audio quality of downloaded songs (normal, high, very-high*)
+| TRANSCODE_BITRATE | --transcode-bitrate | Overwrite the bitrate for ffmpeg encoding
+| SKIP_EXISTING_FILES | --skip-existing-files | Skip songs with the same name
+| SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | Use a song_archive file to skip previously downloaded songs
+| RETRY_ATTEMPTS | --retry-attempts | Number of times Zotify will retry a failed request
+| BULK_WAIT_TIME | --bulk-wait-time | The wait time between bulk downloads
+| OVERRIDE_AUTO_WAIT | --override-auto-wait | Totally disable wait time between songs with the risk of instability
+| CHUNK_SIZE | --chunk-size | Chunk size for downloading
+| DOWNLOAD_REAL_TIME | --download-real-time | Downloads songs as fast as they would be played, should prevent account bans.
+| LANGUAGE | --language | Language for spotify metadata
+| PRINT_SPLASH | --print-splash | Show the Zotify logo at startup
+| PRINT_SKIPS | --print-skips | Show messages if a song is being skipped
+| PRINT_DOWNLOAD_PROGRESS | --print-download-progress | Show download/playlist progress bars
+| PRINT_ERRORS | --print-errors | Show errors
| PRINT_DOWNLOADS | --print-downloads | Print messages when a song is finished downloading
| TEMP_DOWNLOAD_DIR | --temp-download-dir | Download tracks to a temporary directory first
+*very-high is limited to premium only
+
### Output format
With the option `OUTPUT` (or the commandline parameter `--output`) you can specify the output location and format.
@@ -106,6 +124,11 @@ Create and run a container from the image:
docker run --rm -u $(id -u):$(id -g) -v "$PWD/zotify:/app" -v "$PWD/config.json:/config.json" -v "$PWD/Zotify Music:/Zotify Music" -v "$PWD/Zotify Podcasts:/Zotify Podcasts" -it zotify
```
+### What do I do if I see "Your session has been terminated"?
+
+If you see this, don't worry! Just try logging back in. If you see the incorrect username or password error, reset your password and you should be able to log back in.
+
+
### Will my account get banned if I use this tool?
Currently no user has reported their account getting banned after using Zotify.
@@ -114,11 +137,9 @@ It is recommended you use Zotify with a burner account.
Alternatively, there is a configuration option labled ```DOWNLOAD_REAL_TIME```, this limits the download speed to the duration of the song being downloaded thus appearing less suspicious.
This option is much slower and is only recommended for premium users who wish to download songs in 320kbps without buying premium on a burner account.
-**Use Zotify at your own risk**, the developers of Zotify are not responsible if your account gets banned.
-
-### What do I do if I see "Your session has been terminated"?
-
-If you see this, don't worry! Just try logging back in. If you see the incorrect username or password error, reset your password and you should be able to log back in.
+### Disclaimer
+Zotify is intended to be used in compliance with DMCA, Section 1201, for educational, private and fair use. \
+Zotify contributors are not responsible for any misuse of the program or source code.
### Contributing
diff --git a/zotify/__main__.py b/zotify/__main__.py
index e92c906..22539dd 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -12,7 +12,7 @@ from zotify.config import CONFIG_VALUES
def main():
parser = argparse.ArgumentParser(prog='zotify',
- description='A music and podcast downloader needing only a python interpreter and ffmpeg.')
+ description='A music and podcast downloader needing only python and ffmpeg.')
parser.add_argument('-ns', '--no-splash',
action='store_true',
help='Suppress the splash screen when loading.')
diff --git a/zotify/app.py b/zotify/app.py
index 7ba1931..b8659c4 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -5,6 +5,7 @@ from pathlib import Path
from zotify.album import download_album, download_artist_albums
from zotify.const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \
OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME, TYPE
+from zotify.loader import Loader
from zotify.playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist
from zotify.podcast import download_episode, get_show_episodes
from zotify.termoutput import Printer, PrintChannel
@@ -17,16 +18,20 @@ SEARCH_URL = 'https://api.spotify.com/v1/search'
def client(args) -> None:
""" Connects to download server to perform query's and get songs to download """
+ prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Signing in...")
+ prepare_download_loader.start()
Zotify(args)
+ prepare_download_loader.stop()
Printer.print(PrintChannel.SPLASH, splash())
- if Zotify.check_premium():
- Printer.print(PrintChannel.WARNINGS, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n')
- Zotify.DOWNLOAD_QUALITY = AudioQuality.VERY_HIGH
- else:
- Printer.print(PrintChannel.WARNINGS, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n')
- Zotify.DOWNLOAD_QUALITY = AudioQuality.HIGH
+ quality_options = {
+ 'auto': AudioQuality.VERY_HIGH if Zotify.check_premium() else AudioQuality.HIGH,
+ 'normal': AudioQuality.NORMAL,
+ 'high': AudioQuality.HIGH,
+ 'very_high': AudioQuality.VERY_HIGH
+ }
+ Zotify.DOWNLOAD_QUALITY = quality_options[Zotify.CONFIG.get_download_quality()]
if args.download:
urls = []
diff --git a/zotify/config.py b/zotify/config.py
index fec8d90..8b55597 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -6,18 +6,18 @@ from typing import Any
ROOT_PATH = 'ROOT_PATH'
ROOT_PODCAST_PATH = 'ROOT_PODCAST_PATH'
-SKIP_EXISTING_FILES = 'SKIP_EXISTING_FILES'
+SKIP_EXISTING = 'SKIP_EXISTING'
SKIP_PREVIOUSLY_DOWNLOADED = 'SKIP_PREVIOUSLY_DOWNLOADED'
DOWNLOAD_FORMAT = 'DOWNLOAD_FORMAT'
-FORCE_PREMIUM = 'FORCE_PREMIUM'
-ANTI_BAN_WAIT_TIME = 'ANTI_BAN_WAIT_TIME'
+BULK_WAIT_TIME = 'BULK_WAIT_TIME'
OVERRIDE_AUTO_WAIT = 'OVERRIDE_AUTO_WAIT'
CHUNK_SIZE = 'CHUNK_SIZE'
SPLIT_ALBUM_DISCS = 'SPLIT_ALBUM_DISCS'
DOWNLOAD_REAL_TIME = 'DOWNLOAD_REAL_TIME'
LANGUAGE = 'LANGUAGE'
-BITRATE = 'BITRATE'
-TRACK_ARCHIVE = 'TRACK_ARCHIVE'
+DOWNLOAD_QUALITY = 'DOWNLOAD_QUALITY'
+TRANSCODE_BITRATE = 'TRANSCODE_BITRATE'
+SONG_ARCHIVE = 'SONG_ARCHIVE'
CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION'
OUTPUT = 'OUTPUT'
PRINT_SPLASH = 'PRINT_SPLASH'
@@ -35,23 +35,25 @@ RETRY_ATTEMPTS = 'RETRY_ATTEMPTS'
CONFIG_VERSION = 'CONFIG_VERSION'
CONFIG_VALUES = {
- ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' },
- ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' },
- SKIP_EXISTING_FILES: { 'default': 'True', 'type': bool, 'arg': '--skip-existing-files' },
- SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' },
- RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' },
- DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' },
- FORCE_PREMIUM: { 'default': 'False', 'type': bool, 'arg': '--force-premium' },
- ANTI_BAN_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--anti-ban-wait-time' },
- OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' },
- CHUNK_SIZE: { 'default': '50000', 'type': int, 'arg': '--chunk-size' },
- SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' },
- DOWNLOAD_REAL_TIME: { 'default': 'False', 'type': bool, 'arg': '--download-real-time' },
- LANGUAGE: { 'default': 'en', 'type': str, 'arg': '--language' },
- BITRATE: { 'default': '', 'type': str, 'arg': '--bitrate' },
- TRACK_ARCHIVE: { 'default': '', 'type': str, 'arg': '--track-archive' },
CREDENTIALS_LOCATION: { 'default': '', 'type': str, 'arg': '--credentials-location' },
OUTPUT: { 'default': '', 'type': str, 'arg': '--output' },
+ SONG_ARCHIVE: { 'default': '', 'type': str, 'arg': '--song-archive' },
+ ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' },
+ ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' },
+ SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' },
+ MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' },
+ MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' },
+ DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' },
+ DOWNLOAD_QUALITY: { 'default': 'auto', 'type': str, 'arg': '--download-quality' },
+ TRANSCODE_BITRATE: { 'default': 'auto', 'type': str, 'arg': '--transcode-bitrate' },
+ SKIP_EXISTING: { 'default': 'True', 'type': bool, 'arg': '--skip-existing' },
+ SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' },
+ RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' },
+ BULK_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--bulk-wait-time' },
+ OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' },
+ CHUNK_SIZE: { 'default': '50000', 'type': int, 'arg': '--chunk-size' },
+ DOWNLOAD_REAL_TIME: { 'default': 'False', 'type': bool, 'arg': '--download-real-time' },
+ LANGUAGE: { 'default': 'en', 'type': str, 'arg': '--language' },
PRINT_SPLASH: { 'default': 'False', 'type': bool, 'arg': '--print-splash' },
PRINT_SKIPS: { 'default': 'True', 'type': bool, 'arg': '--print-skips' },
PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' },
@@ -60,15 +62,13 @@ CONFIG_VALUES = {
PRINT_API_ERRORS: { 'default': 'False', 'type': bool, 'arg': '--print-api-errors' },
PRINT_PROGRESS_INFO: { 'default': 'True', 'type': bool, 'arg': '--print-progress-info' },
PRINT_WARNINGS: { 'default': 'True', 'type': bool, 'arg': '--print-warnings' },
- MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' },
- MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' },
TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' }
}
OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_PLAYLIST_EXT = '{playlist}/{playlist_num} - {artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_LIKED_SONGS = 'Liked Songs/{artist} - {song_name}.{ext}'
-OUTPUT_DEFAULT_SINGLE = '{artist} - {song_name}/{artist} - {song_name}.{ext}'
+OUTPUT_DEFAULT_SINGLE = '{artist}/{song_name}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}'
@@ -164,8 +164,8 @@ class Config:
return root_podcast_path
@classmethod
- def get_skip_existing_files(cls) -> bool:
- return cls.get(SKIP_EXISTING_FILES)
+ def get_skip_existing(cls) -> bool:
+ return cls.get(SKIP_EXISTING)
@classmethod
def get_skip_previously_downloaded(cls) -> bool:
@@ -183,17 +183,13 @@ class Config:
def get_override_auto_wait(cls) -> bool:
return cls.get(OVERRIDE_AUTO_WAIT)
- @classmethod
- def get_force_premium(cls) -> bool:
- return cls.get(FORCE_PREMIUM)
-
@classmethod
def get_download_format(cls) -> str:
return cls.get(DOWNLOAD_FORMAT)
@classmethod
- def get_anti_ban_wait_time(cls) -> int:
- return cls.get(ANTI_BAN_WAIT_TIME)
+ def get_bulk_wait_time(cls) -> int:
+ return cls.get(BULK_WAIT_TIME)
@classmethod
def get_language(cls) -> str:
@@ -204,25 +200,29 @@ class Config:
return cls.get(DOWNLOAD_REAL_TIME)
@classmethod
- def get_bitrate(cls) -> str:
- return cls.get(BITRATE)
+ def get_download_quality(cls) -> str:
+ return cls.get(DOWNLOAD_QUALITY)
@classmethod
- def get_track_archive(cls) -> str:
- if cls.get(TRACK_ARCHIVE) == '':
+ def get_transcode_bitrate(cls) -> str:
+ return cls.get(TRANSCODE_BITRATE)
+
+ @classmethod
+ def get_song_archive(cls) -> str:
+ if cls.get(SONG_ARCHIVE) == '':
system_paths = {
'win32': Path.home() / 'AppData/Roaming/Zotify',
'linux': Path.home() / '.local/share/zotify',
'darwin': Path.home() / 'Library/Application Support/Zotify'
}
if sys.platform not in system_paths:
- track_archive = PurePath(Path.cwd() / '.zotify/track_archive')
+ song_archive = PurePath(Path.cwd() / '.zotify/.song_archive')
else:
- track_archive = PurePath(system_paths[sys.platform] / 'track_archive')
+ song_archive = PurePath(system_paths[sys.platform] / '.song_archive')
else:
- track_archive = PurePath(Path(cls.get(TRACK_ARCHIVE)).expanduser())
- Path(track_archive.parent).mkdir(parents=True, exist_ok=True)
- return track_archive
+ song_archive = PurePath(Path(cls.get(SONG_ARCHIVE)).expanduser())
+ Path(song_archive.parent).mkdir(parents=True, exist_ok=True)
+ return song_archive
@classmethod
def get_credentials_location(cls) -> str:
diff --git a/zotify/podcast.py b/zotify/podcast.py
index 67f1b41..cf7a37e 100644
--- a/zotify/podcast.py
+++ b/zotify/podcast.py
@@ -101,7 +101,7 @@ def download_episode(episode_id) -> None:
if (
Path(filepath).isfile()
and Path(filepath).stat().st_size == total_size
- and Zotify.CONFIG.get_skip_existing_files()
+ and Zotify.CONFIG.get_skip_existing()
):
Printer.print(PrintChannel.SKIPS, "\n### SKIPPING: " + podcast_name + " - " + episode_name + " (EPISODE ALREADY EXISTS) ###")
prepare_download_loader.stop()
diff --git a/zotify/track.py b/zotify/track.py
index a3fcef4..779ebb9 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -6,7 +6,7 @@ from typing import Any, Tuple, List
from librespot.audio.decoders import AudioQuality
from librespot.metadata import TrackId
-from ffmpy import FFmpeg
+import ffmpy
from zotify.const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \
RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS, HREF
@@ -171,7 +171,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
prepare_download_loader.stop()
Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG IS UNAVAILABLE) ###' + "\n")
else:
- if check_id and check_name and Zotify.CONFIG.get_skip_existing_files():
+ if check_id and check_name and Zotify.CONFIG.get_skip_existing():
prepare_download_loader.stop()
Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS) ###' + "\n")
@@ -231,8 +231,8 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
if not check_id:
add_to_directory_song_ids(filedir, scraped_song_id, PurePath(filename).name, artists[0], name)
- if not Zotify.CONFIG.get_anti_ban_wait_time():
- time.sleep(Zotify.CONFIG.get_anti_ban_wait_time())
+ if not Zotify.CONFIG.get_bulk_wait_time():
+ time.sleep(Zotify.CONFIG.get_bulk_wait_time())
except Exception as e:
Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###')
Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id))
@@ -255,12 +255,14 @@ def convert_audio_format(filename) -> None:
download_format = Zotify.CONFIG.get_download_format().lower()
file_codec = CODEC_MAP.get(download_format, 'copy')
if file_codec != 'copy':
- bitrate = Zotify.CONFIG.get_bitrate()
- if not bitrate:
- if Zotify.DOWNLOAD_QUALITY == AudioQuality.VERY_HIGH:
- bitrate = '320k'
- else:
- bitrate = '160k'
+ bitrate = Zotify.CONFIG.get_transcode_bitrate()
+ bitrates = {
+ 'auto': '320k' if Zotify.check_premium() else '160k',
+ 'normal': '96k',
+ 'high': '160k',
+ 'very_high': '320k'
+ }
+ bitrate = bitrates[Zotify.CONFIG.get_download_quality()]
else:
bitrate = None
@@ -268,14 +270,17 @@ def convert_audio_format(filename) -> None:
if bitrate:
output_params += ['-b:a', bitrate]
- ff_m = FFmpeg(
- global_options=['-y', '-hide_banner', '-loglevel error'],
- inputs={temp_filename: None},
- outputs={filename: output_params}
- )
+ try:
+ ff_m = ffmpy.FFmpeg(
+ global_options=['-y', '-hide_banner', '-loglevel error'],
+ inputs={temp_filename: None},
+ outputs={filename: output_params}
+ )
+ with Loader(PrintChannel.PROGRESS_INFO, "Converting file..."):
+ ff_m.run()
- with Loader(PrintChannel.PROGRESS_INFO, "Converting file..."):
- ff_m.run()
+ if Path(temp_filename).exists():
+ Path(temp_filename).unlink()
- if Path(temp_filename).exists():
- Path(temp_filename).unlink()
+ except ffmpy.FFExecutableNotFoundError:
+ Printer.print(PrintChannel.ERRORS, f'### SKIPPING {file_codec.upper()} CONVERSION - FFMPEG NOT FOUND ###')
\ No newline at end of file
diff --git a/zotify/utils.py b/zotify/utils.py
index 05084c2..df8a661 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -36,7 +36,7 @@ def get_previously_downloaded() -> List[str]:
""" Returns list of all time downloaded songs """
ids = []
- archive_path = Zotify.CONFIG.get_track_archive()
+ archive_path = Zotify.CONFIG.get_song_archive()
if Path(archive_path).exists():
with open(archive_path, 'r', encoding='utf-8') as f:
@@ -48,7 +48,7 @@ def get_previously_downloaded() -> List[str]:
def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str) -> None:
""" Adds song id to all time installed songs archive """
- archive_path = Zotify.CONFIG.get_track_archive()
+ archive_path = Zotify.CONFIG.get_song_archive()
if Path(archive_path).exists():
with open(archive_path, 'a', encoding='utf-8') as file:
diff --git a/zotify/zotify.py b/zotify/zotify.py
index dd40d6d..1712d22 100644
--- a/zotify/zotify.py
+++ b/zotify/zotify.py
@@ -97,4 +97,4 @@ class Zotify:
@classmethod
def check_premium(cls) -> bool:
""" If user has spotify premium return true """
- return (cls.SESSION.get_user_attribute(TYPE) == PREMIUM) or cls.CONFIG.get_force_premium()
+ return (cls.SESSION.get_user_attribute(TYPE) == PREMIUM)
From b4491264952ee74e8b8fd9560e2c92da83a30ca2 Mon Sep 17 00:00:00 2001
From: logykk
Date: Wed, 16 Feb 2022 21:56:09 +1300
Subject: [PATCH 035/169] lyrics support
---
zotify/app.py | 7 ++++---
zotify/config.py | 6 ++++++
zotify/track.py | 34 +++++++++++++++++++++++++---------
zotify/zotify.py | 8 ++++++--
4 files changed, 41 insertions(+), 14 deletions(-)
diff --git a/zotify/app.py b/zotify/app.py
index b8659c4..8d1144d 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -64,9 +64,10 @@ def client(args) -> None:
search_text = ''
while len(search_text) == 0:
search_text = input('Enter search or URL: ')
-
- if not download_from_urls([args.search]):
- search(args.search)
+ search(search_text)
+ else:
+ if not download_from_urls([args.search]):
+ search(args.search)
def download_from_urls(urls: list[str]) -> bool:
""" Downloads from a list of urls """
diff --git a/zotify/config.py b/zotify/config.py
index 8b55597..5585d29 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -33,6 +33,7 @@ PRINT_PROGRESS_INFO = 'PRINT_PROGRESS_INFO'
PRINT_WARNINGS = 'PRINT_WARNINGS'
RETRY_ATTEMPTS = 'RETRY_ATTEMPTS'
CONFIG_VERSION = 'CONFIG_VERSION'
+DOWNLOAD_LYRICS = 'DOWNLOAD_LYRICS'
CONFIG_VALUES = {
CREDENTIALS_LOCATION: { 'default': '', 'type': str, 'arg': '--credentials-location' },
@@ -41,6 +42,7 @@ CONFIG_VALUES = {
ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' },
ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' },
SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' },
+ DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' },
MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' },
MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' },
DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' },
@@ -187,6 +189,10 @@ class Config:
def get_download_format(cls) -> str:
return cls.get(DOWNLOAD_FORMAT)
+ @classmethod
+ def get_download_lyrics(cls) -> bool:
+ return cls.get(DOWNLOAD_LYRICS)
+
@classmethod
def get_bulk_wait_time(cls) -> int:
return cls.get(BULK_WAIT_TIME)
diff --git a/zotify/track.py b/zotify/track.py
index 779ebb9..bb56b4f 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -1,10 +1,10 @@
from pathlib import Path, PurePath
+import math
import re
import time
import uuid
from typing import Any, Tuple, List
-from librespot.audio.decoders import AudioQuality
from librespot.metadata import TrackId
import ffmpy
@@ -64,7 +64,6 @@ def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, An
def get_song_genres(rawartists: List[str], track_name: str) -> List[str]:
-
try:
genres = []
for data in rawartists:
@@ -86,6 +85,26 @@ def get_song_genres(rawartists: List[str], track_name: str) -> List[str]:
raise ValueError(f'Failed to parse GENRES response: {str(e)}\n{raw}')
+def get_song_lyrics(song_id: str, file_save: str) -> None:
+ raw, lyrics = Zotify.invoke_url(f'https://spclient.wg.spotify.com/color-lyrics/v2/track/{song_id}')
+
+ formatted_lyrics = lyrics['lyrics']['lines']
+ if(lyrics['lyrics']['syncType'] == "UNSYNCED"):
+ with open(file_save, 'w') as file:
+ for line in formatted_lyrics:
+ file.writelines(line['words'] + '\n')
+ elif(lyrics['lyrics']['syncType'] == "LINE_SYNCED"):
+ with open(file_save, 'w') as file:
+ for line in formatted_lyrics:
+ timestamp = int(line['startTimeMs'])
+ ts_minutes = str(math.floor(timestamp / 60000)).zfill(2)
+ ts_seconds = str(math.floor((timestamp % 60000) / 1000)).zfill(2)
+ ts_millis = str(math.floor(timestamp % 1000))[:2].zfill(2)
+ file.writelines(f'[{ts_minutes}:{ts_seconds}.{ts_millis}]' + line['words'] + '\n')
+ else:
+ raise ValueError(f'Filed to fetch lyrics: {song_id}')
+
+
def get_song_duration(song_id: str) -> float:
""" Retrieves duration of song in second as is on spotify """
@@ -96,14 +115,9 @@ def get_song_duration(song_id: str) -> float:
# convert to seconds
duration = float(ms_duration)/1000
- # debug
- # print(duration)
- # print(type(duration))
-
return duration
-# noinspection PyBroadException
def download_track(mode: str, track_id: str, extra_keys=None, disable_progressbar=False) -> None:
""" Downloads raw song audio from Spotify """
@@ -182,8 +196,8 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
else:
if track_id != scraped_song_id:
track_id = scraped_song_id
- track_id = TrackId.from_base62(track_id)
- stream = Zotify.get_content_stream(track_id, Zotify.DOWNLOAD_QUALITY)
+ track = TrackId.from_base62(track_id)
+ stream = Zotify.get_content_stream(track, Zotify.DOWNLOAD_QUALITY)
create_download_directory(filedir)
total_size = stream.input_stream.size
@@ -213,6 +227,8 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
genres = get_song_genres(raw_artists, name)
+ if(Zotify.CONFIG.get_download_lyrics()):
+ get_song_lyrics(track_id, PurePath(filedir / str(song_name + '.lrc')))
convert_audio_format(filename_temp)
set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number)
set_music_thumbnail(filename_temp, image_url)
diff --git a/zotify/zotify.py b/zotify/zotify.py
index 1712d22..e63ff88 100644
--- a/zotify/zotify.py
+++ b/zotify/zotify.py
@@ -56,14 +56,18 @@ class Zotify:
def get_auth_header(cls):
return {
'Authorization': f'Bearer {cls.__get_auth_token()}',
- 'Accept-Language': f'{cls.CONFIG.get_language()}'
+ 'Accept-Language': f'{cls.CONFIG.get_language()}',
+ 'Accept': 'application/json',
+ 'app-platform': 'WebPlayer'
}
@classmethod
def get_auth_header_and_params(cls, limit, offset):
return {
'Authorization': f'Bearer {cls.__get_auth_token()}',
- 'Accept-Language': f'{cls.CONFIG.get_language()}'
+ 'Accept-Language': f'{cls.CONFIG.get_language()}',
+ 'Accept': 'application/json',
+ 'app-platform': 'WebPlayer'
}, {LIMIT: limit, OFFSET: offset}
@classmethod
From 3af57ed8994ddd556d4a37d6397233b57e971444 Mon Sep 17 00:00:00 2001
From: logykk
Date: Wed, 16 Feb 2022 22:00:43 +1300
Subject: [PATCH 036/169] updated docs for lyrics support
---
CHANGELOG.md | 5 +++++
README.md | 1 +
2 files changed, 6 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7fa855c..b31e732 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,9 @@
# Changelog
+
+## v0.6.1
+- Added support for synced lyrics (unsynced is synced unavailable)
+- Can be configured with the `DOWNLOAD_LYRICS` option in config.json or `--download-lyrics=True/False` as a command line argument
+
## v0.6
**General changes**
- Added "DOWNLOAD_QUALITY" config option. This can be "normal" (96kbks), "high" (160kpbs), "very-high" (320kpbs, premium only) or "auto" which selects the highest format available for your account automatically.
diff --git a/README.md b/README.md
index a81d268..6b11d58 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,7 @@ Be aware you have to set boolean values in the commandline like this: `--downloa
| ROOT_PATH | --root-path | Directory where Zotify saves music
| ROOT_PODCAST_PATH | --root-podcast-path | Directory where Zotify saves podcasts
| SPLIT_ALBUM_DISCS | --split-album-discs | Saves each disk in its own folder
+| DOWNLOAD_LYRICS | --download-lyrics | Downloads synced lyrics in .lrc format, uses unsynced as fallback.
| MD_ALLGENRES | --md-allgenres | Save all relevant genres in metadata
| MD_GENREDELIMITER | --md-genredelimiter | Delimiter character used to split genres in metadata
| DOWNLOAD_FORMAT | --download-format | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis)
From 2e348b90d17106e7f3b5169b0dfeef6d238b92d9 Mon Sep 17 00:00:00 2001
From: Not Logykk
Date: Wed, 16 Feb 2022 09:10:37 +0000
Subject: [PATCH 037/169] Fixed version number
---
setup.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/setup.py b/setup.py
index fbcda8b..51d72e4 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@ README = (HERE / "README.md").read_text()
# This call to setup() does all the work
setup(
name="zotify",
- version="0.6.0",
+ version="0.6.1",
author="Zotify Contributors",
description="A music and podcast downloader.",
long_description=README,
From 0ab438d23dea5c341d1d05b6473eaf2ab813e86d Mon Sep 17 00:00:00 2001
From: DS <1269929-dsalmon@users.noreply.gitlab.com>
Date: Wed, 16 Feb 2022 10:39:53 +0000
Subject: [PATCH 038/169] Fix podcast downloading
---
zotify/podcast.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/zotify/podcast.py b/zotify/podcast.py
index cf7a37e..e630184 100644
--- a/zotify/podcast.py
+++ b/zotify/podcast.py
@@ -99,7 +99,7 @@ def download_episode(episode_id) -> None:
filepath = PurePath(download_directory).joinpath(f"{filename}.ogg")
if (
- Path(filepath).isfile()
+ Path(filepath).is_file()
and Path(filepath).stat().st_size == total_size
and Zotify.CONFIG.get_skip_existing()
):
From 7baa1605b468dbf3cfeeb3a3203060a852526493 Mon Sep 17 00:00:00 2001
From: DS <1269929-dsalmon@users.noreply.gitlab.com>
Date: Wed, 16 Feb 2022 17:19:01 +0000
Subject: [PATCH 039/169] Fix Dockerfile
---
Dockerfile | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 79ee2ba..f95bd03 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,6 @@
FROM python:3.9-alpine as base
-RUN apk --update add git ffmpeg
+RUN apk --update add ffmpeg
FROM base as builder
RUN mkdir /install
@@ -13,6 +13,6 @@ RUN apk add gcc libc-dev zlib zlib-dev jpeg-dev \
FROM base
COPY --from=builder /install /usr/local
-COPY zotify /app
+COPY zotify /app/zotify
WORKDIR /app
-ENTRYPOINT ["/usr/local/bin/python", "__main__.py"]
+ENTRYPOINT ["python3", "-m", "zotify"]
From 73ca05bf41dfcad86fa789432b9f236e6a852326 Mon Sep 17 00:00:00 2001
From: logykk
Date: Sat, 19 Feb 2022 16:25:36 +1300
Subject: [PATCH 040/169] Minor fixes - 0.6.2
---
CHANGELOG.md | 9 ++++++
README.md | 1 +
setup.py | 2 +-
zotify/__main__.py | 6 ++++
zotify/app.py | 3 --
zotify/config.py | 14 ++++++++-
zotify/loader.py | 2 +-
zotify/track.py | 76 +++++++++++++++++++++++++---------------------
zotify/zotify.py | 13 +++++---
9 files changed, 81 insertions(+), 45 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b31e732..75aa172 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## v0.6.2
+- Won't crash if downloading a song with no lyrics and `DOWNLOAD_LYRICS` is set to True
+- Fixed visual glitch when entering login info
+- Saving genre metadata is now optional (disabled by default) and configurable with the `MD_SAVE_GENRES`/`--md-save-genres` option
+- Switched to new loading animation that hopefully renders a little better in Windows command shells
+- Username and password can now be entered as arguments with `--username` and `--password` - does **not** take priority over credentials.json
+- Added option to disable saving credentials `SAVE_CREDENTIALS`/`--save-credentials` - will still use credentials.json if already exists
+- Default output format for singles is now `{artist}/Single - {song_name}/{artist} - {song_name}.{ext}`
+
## v0.6.1
- Added support for synced lyrics (unsynced is synced unavailable)
- Can be configured with the `DOWNLOAD_LYRICS` option in config.json or `--download-lyrics=True/False` as a command line argument
diff --git a/README.md b/README.md
index 6b11d58..fd3f196 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@
- Downloads at up to 320kbps*
- Downloads directly from the source**
- Downloads podcasts, playlists, liked songs, albums, artists, singles.
+ - Downloads synced lyrics from the source
- Option to download in real time to appear more legitimate***
- Supports multiple audio formats
- Download directly from URL or use built-in in search
diff --git a/setup.py b/setup.py
index 51d72e4..f3db076 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@ README = (HERE / "README.md").read_text()
# This call to setup() does all the work
setup(
name="zotify",
- version="0.6.1",
+ version="0.6.2",
author="Zotify Contributors",
description="A music and podcast downloader.",
long_description=README,
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 22539dd..7a44638 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -19,6 +19,12 @@ def main():
parser.add_argument('--config-location',
type=str,
help='Specify the zconfig.json location')
+ parser.add_argument('--username',
+ type=str,
+ help='Account username')
+ parser.add_argument('--password',
+ type=str,
+ help='Account password')
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('urls',
type=str,
diff --git a/zotify/app.py b/zotify/app.py
index 8d1144d..58424e0 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -18,10 +18,7 @@ SEARCH_URL = 'https://api.spotify.com/v1/search'
def client(args) -> None:
""" Connects to download server to perform query's and get songs to download """
- prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Signing in...")
- prepare_download_loader.start()
Zotify(args)
- prepare_download_loader.stop()
Printer.print(PrintChannel.SPLASH, splash())
diff --git a/zotify/config.py b/zotify/config.py
index 5585d29..73f4a95 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -18,6 +18,7 @@ LANGUAGE = 'LANGUAGE'
DOWNLOAD_QUALITY = 'DOWNLOAD_QUALITY'
TRANSCODE_BITRATE = 'TRANSCODE_BITRATE'
SONG_ARCHIVE = 'SONG_ARCHIVE'
+SAVE_CREDENTIALS = 'SAVE_CREDENTIALS'
CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION'
OUTPUT = 'OUTPUT'
PRINT_SPLASH = 'PRINT_SPLASH'
@@ -27,6 +28,7 @@ PRINT_ERRORS = 'PRINT_ERRORS'
PRINT_DOWNLOADS = 'PRINT_DOWNLOADS'
PRINT_API_ERRORS = 'PRINT_API_ERRORS'
TEMP_DOWNLOAD_DIR = 'TEMP_DOWNLOAD_DIR'
+MD_SAVE_GENRES = 'MD_SAVE_GENRES'
MD_ALLGENRES = 'MD_ALLGENRES'
MD_GENREDELIMITER = 'MD_GENREDELIMITER'
PRINT_PROGRESS_INFO = 'PRINT_PROGRESS_INFO'
@@ -36,6 +38,7 @@ CONFIG_VERSION = 'CONFIG_VERSION'
DOWNLOAD_LYRICS = 'DOWNLOAD_LYRICS'
CONFIG_VALUES = {
+ SAVE_CREDENTIALS: { 'default': 'True', 'type': bool, 'arg': '--save-credentials' },
CREDENTIALS_LOCATION: { 'default': '', 'type': str, 'arg': '--credentials-location' },
OUTPUT: { 'default': '', 'type': str, 'arg': '--output' },
SONG_ARCHIVE: { 'default': '', 'type': str, 'arg': '--song-archive' },
@@ -43,6 +46,7 @@ CONFIG_VALUES = {
ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' },
SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' },
DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' },
+ MD_SAVE_GENRES: { 'default': 'False', 'type': bool, 'arg': '--md-save-genres' },
MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' },
MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' },
DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' },
@@ -70,7 +74,7 @@ CONFIG_VALUES = {
OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_PLAYLIST_EXT = '{playlist}/{playlist_num} - {artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_LIKED_SONGS = 'Liked Songs/{artist} - {song_name}.{ext}'
-OUTPUT_DEFAULT_SINGLE = '{artist}/{song_name}/{artist} - {song_name}.{ext}'
+OUTPUT_DEFAULT_SINGLE = '{artist}/Single - {song_name}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}'
@@ -230,6 +234,10 @@ class Config:
Path(song_archive.parent).mkdir(parents=True, exist_ok=True)
return song_archive
+ @classmethod
+ def get_save_credentials(cls) -> bool:
+ return cls.get(SAVE_CREDENTIALS)
+
@classmethod
def get_credentials_location(cls) -> str:
if cls.get(CREDENTIALS_LOCATION) == '':
@@ -252,6 +260,10 @@ class Config:
if cls.get(TEMP_DOWNLOAD_DIR) == '':
return ''
return PurePath(cls.get_root_path()).joinpath(cls.get(TEMP_DOWNLOAD_DIR))
+
+ @classmethod
+ def get_save_genres(cls) -> bool:
+ return cls.get(MD_SAVE_GENRES)
@classmethod
def get_all_genres(cls) -> bool:
diff --git a/zotify/loader.py b/zotify/loader.py
index ca894fe..42c48df 100644
--- a/zotify/loader.py
+++ b/zotify/loader.py
@@ -19,7 +19,7 @@ class Loader:
# do something
pass
"""
- def __init__(self, chan, desc="Loading...", end='', timeout=0.1, mode='std1'):
+ def __init__(self, chan, desc="Loading...", end='', timeout=0.1, mode='prog'):
"""
A loader-like context manager
diff --git a/zotify/track.py b/zotify/track.py
index bb56b4f..57a539c 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -64,45 +64,50 @@ def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, An
def get_song_genres(rawartists: List[str], track_name: str) -> List[str]:
- try:
- genres = []
- for data in rawartists:
- # query artist genres via href, which will be the api url
- with Loader(PrintChannel.PROGRESS_INFO, "Fetching artist information..."):
- (raw, artistInfo) = Zotify.invoke_url(f'{data[HREF]}')
- if Zotify.CONFIG.get_all_genres() and len(artistInfo[GENRES]) > 0:
- for genre in artistInfo[GENRES]:
- genres.append(genre)
- elif len(artistInfo[GENRES]) > 0:
- genres.append(artistInfo[GENRES][0])
+ if Zotify.CONFIG.get_save_genres():
+ try:
+ genres = []
+ for data in rawartists:
+ # query artist genres via href, which will be the api url
+ with Loader(PrintChannel.PROGRESS_INFO, "Fetching artist information..."):
+ (raw, artistInfo) = Zotify.invoke_url(f'{data[HREF]}')
+ if Zotify.CONFIG.get_all_genres() and len(artistInfo[GENRES]) > 0:
+ for genre in artistInfo[GENRES]:
+ genres.append(genre)
+ elif len(artistInfo[GENRES]) > 0:
+ genres.append(artistInfo[GENRES][0])
- if len(genres) == 0:
- Printer.print(PrintChannel.WARNINGS, '### No Genres found for song ' + track_name)
- genres.append('')
+ if len(genres) == 0:
+ Printer.print(PrintChannel.WARNINGS, '### No Genres found for song ' + track_name)
+ genres.append('')
- return genres
- except Exception as e:
- raise ValueError(f'Failed to parse GENRES response: {str(e)}\n{raw}')
+ return genres
+ except Exception as e:
+ raise ValueError(f'Failed to parse GENRES response: {str(e)}\n{raw}')
+ else:
+ return ['']
def get_song_lyrics(song_id: str, file_save: str) -> None:
raw, lyrics = Zotify.invoke_url(f'https://spclient.wg.spotify.com/color-lyrics/v2/track/{song_id}')
- formatted_lyrics = lyrics['lyrics']['lines']
- if(lyrics['lyrics']['syncType'] == "UNSYNCED"):
- with open(file_save, 'w') as file:
- for line in formatted_lyrics:
- file.writelines(line['words'] + '\n')
- elif(lyrics['lyrics']['syncType'] == "LINE_SYNCED"):
- with open(file_save, 'w') as file:
- for line in formatted_lyrics:
- timestamp = int(line['startTimeMs'])
- ts_minutes = str(math.floor(timestamp / 60000)).zfill(2)
- ts_seconds = str(math.floor((timestamp % 60000) / 1000)).zfill(2)
- ts_millis = str(math.floor(timestamp % 1000))[:2].zfill(2)
- file.writelines(f'[{ts_minutes}:{ts_seconds}.{ts_millis}]' + line['words'] + '\n')
- else:
- raise ValueError(f'Filed to fetch lyrics: {song_id}')
+ if lyrics:
+ formatted_lyrics = lyrics['lyrics']['lines']
+ if(lyrics['lyrics']['syncType'] == "UNSYNCED"):
+ with open(file_save, 'w') as file:
+ for line in formatted_lyrics:
+ file.writelines(line['words'] + '\n')
+ return
+ elif(lyrics['lyrics']['syncType'] == "LINE_SYNCED"):
+ with open(file_save, 'w') as file:
+ for line in formatted_lyrics:
+ timestamp = int(line['startTimeMs'])
+ ts_minutes = str(math.floor(timestamp / 60000)).zfill(2)
+ ts_seconds = str(math.floor((timestamp % 60000) / 1000)).zfill(2)
+ ts_millis = str(math.floor(timestamp % 1000))[:2].zfill(2)
+ file.writelines(f'[{ts_minutes}:{ts_seconds}.{ts_millis}]' + line['words'] + '\n')
+ return
+ raise ValueError(f'Filed to fetch lyrics: {song_id}')
def get_song_duration(song_id: str) -> float:
@@ -228,7 +233,10 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
genres = get_song_genres(raw_artists, name)
if(Zotify.CONFIG.get_download_lyrics()):
- get_song_lyrics(track_id, PurePath(filedir / str(song_name + '.lrc')))
+ try:
+ get_song_lyrics(track_id, PurePath(filedir / str(song_name + '.lrc')))
+ except ValueError:
+ Printer.print(PrintChannel.SKIPS, f"### Skipping lyrics for {song_name}: lyrics not available ###")
convert_audio_format(filename_temp)
set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number)
set_music_thumbnail(filename_temp, image_url)
@@ -299,4 +307,4 @@ def convert_audio_format(filename) -> None:
Path(temp_filename).unlink()
except ffmpy.FFExecutableNotFoundError:
- Printer.print(PrintChannel.ERRORS, f'### SKIPPING {file_codec.upper()} CONVERSION - FFMPEG NOT FOUND ###')
\ No newline at end of file
+ Printer.print(PrintChannel.WARNINGS, f'### SKIPPING {file_codec.upper()} CONVERSION - FFMPEG NOT FOUND ###')
\ No newline at end of file
diff --git a/zotify/zotify.py b/zotify/zotify.py
index e63ff88..5a304e8 100644
--- a/zotify/zotify.py
+++ b/zotify/zotify.py
@@ -17,10 +17,10 @@ class Zotify:
def __init__(self, args):
Zotify.CONFIG.load(args)
- Zotify.login()
+ Zotify.login(args)
@classmethod
- def login(cls):
+ def login(cls, args):
""" Authenticates with Spotify and saves credentials to a file """
cred_location = Config.get_credentials_location()
@@ -33,12 +33,15 @@ class Zotify:
except RuntimeError:
pass
while True:
- user_name = ''
+ user_name = args.username if args.username else ''
while len(user_name) == 0:
user_name = input('Username: ')
- password = pwinput(prompt='Password: ', mask='*')
+ password = args.password if args.password else pwinput(prompt='Password: ', mask='*')
try:
- conf = Session.Configuration.Builder().set_stored_credential_file(cred_location).build()
+ if Config.get_save_credentials():
+ conf = Session.Configuration.Builder().set_stored_credential_file(cred_location).build()
+ else:
+ conf = Session.Configuration.Builder().set_store_credentials(False).build()
cls.SESSION = Session.Builder(conf).user_pass(user_name, password).create()
return
except RuntimeError:
From 4e7d468ade22fdb3f0a91e37f0c0c1962c2ea7cf Mon Sep 17 00:00:00 2001
From: Not Logykk
Date: Sun, 20 Feb 2022 03:34:37 +0000
Subject: [PATCH 041/169] Less stupid single format
---
zotify/config.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/zotify/config.py b/zotify/config.py
index 73f4a95..f629224 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -74,7 +74,7 @@ CONFIG_VALUES = {
OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_PLAYLIST_EXT = '{playlist}/{playlist_num} - {artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_LIKED_SONGS = 'Liked Songs/{artist} - {song_name}.{ext}'
-OUTPUT_DEFAULT_SINGLE = '{artist}/Single - {song_name}/{artist} - {song_name}.{ext}'
+OUTPUT_DEFAULT_SINGLE = '{artist}/{album}}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}'
@@ -307,4 +307,4 @@ class Config:
@classmethod
def get_retry_attempts(cls) -> int:
- return cls.get(RETRY_ATTEMPTS)
\ No newline at end of file
+ return cls.get(RETRY_ATTEMPTS)
From fcb823a474266a29145fdb4056fe46d4ece68a6d Mon Sep 17 00:00:00 2001
From: Not Logykk
Date: Mon, 21 Feb 2022 23:13:58 +0000
Subject: [PATCH 042/169] Fix typo in output format
---
zotify/config.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/zotify/config.py b/zotify/config.py
index f629224..d8256b5 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -74,7 +74,7 @@ CONFIG_VALUES = {
OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_PLAYLIST_EXT = '{playlist}/{playlist_num} - {artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_LIKED_SONGS = 'Liked Songs/{artist} - {song_name}.{ext}'
-OUTPUT_DEFAULT_SINGLE = '{artist}/{album}}/{artist} - {song_name}.{ext}'
+OUTPUT_DEFAULT_SINGLE = '{artist}/{album}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}'
From fb1dae5400c5311e692ce6aa018f2f624185e550 Mon Sep 17 00:00:00 2001
From: logykk
Date: Fri, 25 Feb 2022 13:40:28 +1300
Subject: [PATCH 043/169] fix json fetching
---
zotify/track.py | 7 +++++--
zotify/zotify.py | 7 ++++---
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/zotify/track.py b/zotify/track.py
index 57a539c..777b9cb 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -92,7 +92,10 @@ def get_song_lyrics(song_id: str, file_save: str) -> None:
raw, lyrics = Zotify.invoke_url(f'https://spclient.wg.spotify.com/color-lyrics/v2/track/{song_id}')
if lyrics:
- formatted_lyrics = lyrics['lyrics']['lines']
+ try:
+ formatted_lyrics = lyrics['lyrics']['lines']
+ except KeyError:
+ raise ValueError(f'Failed to fetch lyrics: {song_id}')
if(lyrics['lyrics']['syncType'] == "UNSYNCED"):
with open(file_save, 'w') as file:
for line in formatted_lyrics:
@@ -107,7 +110,7 @@ def get_song_lyrics(song_id: str, file_save: str) -> None:
ts_millis = str(math.floor(timestamp % 1000))[:2].zfill(2)
file.writelines(f'[{ts_minutes}:{ts_seconds}.{ts_millis}]' + line['words'] + '\n')
return
- raise ValueError(f'Filed to fetch lyrics: {song_id}')
+ raise ValueError(f'Failed to fetch lyrics: {song_id}')
def get_song_duration(song_id: str) -> float:
diff --git a/zotify/zotify.py b/zotify/zotify.py
index 5a304e8..27fc5d0 100644
--- a/zotify/zotify.py
+++ b/zotify/zotify.py
@@ -1,3 +1,4 @@
+import json
from pathlib import Path
from pwinput import pwinput
import time
@@ -88,10 +89,10 @@ class Zotify:
responsetext = response.text
try:
responsejson = response.json()
- except requests.exceptions.JSONDecodeError:
- responsejson = {}
+ except json.decoder.JSONDecodeError:
+ responsejson = {"error": {"status": "unknown", "message": "received an empty response"}}
- if 'error' in responsejson:
+ if not responsejson or 'error' in responsejson:
if tryCount < (cls.CONFIG.get_retry_attempts() - 1):
Printer.print(PrintChannel.WARNINGS, f"Spotify API Error (try {tryCount + 1}) ({responsejson['error']['status']}): {responsejson['error']['message']}")
time.sleep(5)
From fd4e93df8b9f07684106de0199a8a36187af94ef Mon Sep 17 00:00:00 2001
From: logykk
Date: Fri, 25 Feb 2022 13:42:05 +1300
Subject: [PATCH 044/169] bump version
---
CHANGELOG.md | 4 ++++
setup.py | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75aa172..ea4a3ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 0.6.3
+- Less stupid single format
+- Fixed error in json fetching
+
## v0.6.2
- Won't crash if downloading a song with no lyrics and `DOWNLOAD_LYRICS` is set to True
- Fixed visual glitch when entering login info
diff --git a/setup.py b/setup.py
index f3db076..1261432 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@ README = (HERE / "README.md").read_text()
# This call to setup() does all the work
setup(
name="zotify",
- version="0.6.2",
+ version="0.6.3",
author="Zotify Contributors",
description="A music and podcast downloader.",
long_description=README,
From e74dba6344e78f587f5fcedd3433ec8d81355461 Mon Sep 17 00:00:00 2001
From: logykk
Date: Fri, 25 Feb 2022 14:52:30 +1300
Subject: [PATCH 045/169] default to -s
---
CHANGELOG.md | 1 +
zotify/__main__.py | 2 +-
zotify/app.py | 11 +++++++++++
zotify/config.py | 2 +-
4 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea4a3ea..65266e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## 0.6.3
- Less stupid single format
- Fixed error in json fetching
+- Default to search if no other option is provided
## v0.6.2
- Won't crash if downloading a song with no lyrics and `DOWNLOAD_LYRICS` is set to True
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 7a44638..11a2c3f 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -25,7 +25,7 @@ def main():
parser.add_argument('--password',
type=str,
help='Account password')
- group = parser.add_mutually_exclusive_group(required=True)
+ group = parser.add_mutually_exclusive_group(required=False)
group.add_argument('urls',
type=str,
# action='extend',
diff --git a/zotify/app.py b/zotify/app.py
index 58424e0..131256d 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -41,13 +41,16 @@ def client(args) -> None:
else:
Printer.print(PrintChannel.ERRORS, f'File {filename} not found.\n')
+ return
if args.urls:
if len(args.urls) > 0:
download_from_urls(args.urls)
+ return
if args.playlist:
download_from_user_playlist()
+ return
if args.liked_songs:
for song in get_saved_tracks():
@@ -55,6 +58,7 @@ def client(args) -> None:
Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n")
else:
download_track('liked', song[TRACK][ID])
+ return
if args.search:
if args.search == ' ':
@@ -65,6 +69,13 @@ def client(args) -> None:
else:
if not download_from_urls([args.search]):
search(args.search)
+ return
+
+ else:
+ search_text = ''
+ while len(search_text) == 0:
+ search_text = input('Enter search or URL: ')
+ search(search_text)
def download_from_urls(urls: list[str]) -> bool:
""" Downloads from a list of urls """
diff --git a/zotify/config.py b/zotify/config.py
index d8256b5..7385580 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -54,7 +54,7 @@ CONFIG_VALUES = {
TRANSCODE_BITRATE: { 'default': 'auto', 'type': str, 'arg': '--transcode-bitrate' },
SKIP_EXISTING: { 'default': 'True', 'type': bool, 'arg': '--skip-existing' },
SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' },
- RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' },
+ RETRY_ATTEMPTS: { 'default': '1', 'type': int, 'arg': '--retry-attemps' },
BULK_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--bulk-wait-time' },
OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' },
CHUNK_SIZE: { 'default': '50000', 'type': int, 'arg': '--chunk-size' },
From 08ece45ae9edfe16c40403428a2375f78fa860a1 Mon Sep 17 00:00:00 2001
From: logykk
Date: Tue, 22 Mar 2022 21:35:38 +1300
Subject: [PATCH 046/169] Fix tracks getting cutoff
---
CHANGELOG.md | 3 +++
zotify/config.py | 2 +-
zotify/podcast.py | 2 +-
zotify/track.py | 2 +-
4 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65266e0..70294d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## v0.6.4
+- Fixed upstream bug causing tracks to not download fully
+
## 0.6.3
- Less stupid single format
- Fixed error in json fetching
diff --git a/zotify/config.py b/zotify/config.py
index 7385580..b56581d 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -57,7 +57,7 @@ CONFIG_VALUES = {
RETRY_ATTEMPTS: { 'default': '1', 'type': int, 'arg': '--retry-attemps' },
BULK_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--bulk-wait-time' },
OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' },
- CHUNK_SIZE: { 'default': '50000', 'type': int, 'arg': '--chunk-size' },
+ CHUNK_SIZE: { 'default': '20000', 'type': int, 'arg': '--chunk-size' },
DOWNLOAD_REAL_TIME: { 'default': 'False', 'type': bool, 'arg': '--download-real-time' },
LANGUAGE: { 'default': 'en', 'type': str, 'arg': '--language' },
PRINT_SPLASH: { 'default': 'False', 'type': bool, 'arg': '--print-splash' },
diff --git a/zotify/podcast.py b/zotify/podcast.py
index e630184..adabc4a 100644
--- a/zotify/podcast.py
+++ b/zotify/podcast.py
@@ -118,7 +118,7 @@ def download_episode(episode_id) -> None:
unit_divisor=1024
) as p_bar:
prepare_download_loader.stop()
- for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 1):
+ for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 2):
data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size())
p_bar.update(file.write(data))
downloaded += len(data)
diff --git a/zotify/track.py b/zotify/track.py
index 777b9cb..e031378 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -221,7 +221,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
unit_divisor=1024,
disable=disable_progressbar
) as p_bar:
- for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 1):
+ for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 2):
data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size())
p_bar.update(file.write(data))
downloaded += len(data)
From 7bc02bd3d0e43d724f29319d26dc572838d7f155 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Wed, 23 Mar 2022 05:11:24 +0000
Subject: [PATCH 047/169] bump version
---
setup.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/setup.py b/setup.py
index 1261432..b5a8292 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@ README = (HERE / "README.md").read_text()
# This call to setup() does all the work
setup(
name="zotify",
- version="0.6.3",
+ version="0.6.4",
author="Zotify Contributors",
description="A music and podcast downloader.",
long_description=README,
From 7c315da6f4509b48cc1e35f5e3092420a43982b4 Mon Sep 17 00:00:00 2001
From: logykk
Date: Wed, 23 Mar 2022 19:05:40 +1300
Subject: [PATCH 048/169] Proper fix for incomplete downloads
---
CHANGELOG.md | 3 +++
setup.py | 2 +-
zotify/podcast.py | 5 ++++-
zotify/track.py | 5 ++++-
4 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70294d6..c7c3875 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## v0.6.5
+- Implemented more stable fix for bug still persisting after v0.6.4
+
## v0.6.4
- Fixed upstream bug causing tracks to not download fully
diff --git a/setup.py b/setup.py
index b5a8292..45a0937 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@ README = (HERE / "README.md").read_text()
# This call to setup() does all the work
setup(
name="zotify",
- version="0.6.4",
+ version="0.6.5",
author="Zotify Contributors",
description="A music and podcast downloader.",
long_description=README,
diff --git a/zotify/podcast.py b/zotify/podcast.py
index adabc4a..f50d117 100644
--- a/zotify/podcast.py
+++ b/zotify/podcast.py
@@ -118,10 +118,13 @@ def download_episode(episode_id) -> None:
unit_divisor=1024
) as p_bar:
prepare_download_loader.stop()
- for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 2):
+ while True:
+ #for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 2):
data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size())
p_bar.update(file.write(data))
downloaded += len(data)
+ if data == b'':
+ break
if Zotify.CONFIG.get_download_real_time():
delta_real = time.time() - time_start
delta_want = (downloaded / total_size) * (duration_ms/1000)
diff --git a/zotify/track.py b/zotify/track.py
index e031378..81cfccd 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -221,10 +221,13 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
unit_divisor=1024,
disable=disable_progressbar
) as p_bar:
- for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 2):
+ while True:
+ #for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 2):
data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size())
p_bar.update(file.write(data))
downloaded += len(data)
+ if data == b'':
+ break
if Zotify.CONFIG.get_download_real_time():
delta_real = time.time() - time_start
delta_want = (downloaded / total_size) * (duration_ms/1000)
From e052e13584b9d065e397fbefc5f69026982d6930 Mon Sep 17 00:00:00 2001
From: logykk
Date: Wed, 23 Mar 2022 20:01:26 +1300
Subject: [PATCH 049/169] Basic fault tolerance
---
zotify/track.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/zotify/track.py b/zotify/track.py
index 81cfccd..20633ae 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -221,13 +221,13 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
unit_divisor=1024,
disable=disable_progressbar
) as p_bar:
- while True:
+ b = 0
+ while b < 5:
#for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 2):
data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size())
p_bar.update(file.write(data))
downloaded += len(data)
- if data == b'':
- break
+ b += 1 if data == b'' else 0
if Zotify.CONFIG.get_download_real_time():
delta_real = time.time() - time_start
delta_want = (downloaded / total_size) * (duration_ms/1000)
From 59543504ae14d0be9a2d8ebbe51c0440182cf9f9 Mon Sep 17 00:00:00 2001
From: logykk
Date: Thu, 24 Mar 2022 22:42:06 +1300
Subject: [PATCH 050/169] option to download by followed artists
---
CHANGELOG.md | 3 +++
README.md | 1 +
setup.py | 2 +-
zotify/__main__.py | 4 ++++
zotify/app.py | 11 ++++++++---
zotify/const.py | 2 ++
zotify/track.py | 13 ++++++++++++-
7 files changed, 31 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7c3875..20ba0e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## v0.6.6
+- Added `-f` / `--followed` option to download every song by all of your followed artists
+
## v0.6.5
- Implemented more stable fix for bug still persisting after v0.6.4
diff --git a/README.md b/README.md
index fd3f196..dcb75b1 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ Basic options:
-d, --download Download all tracks/alumbs/playlists URLs from the specified file
-p, --playlist Downloads a saved playlist from your account
-l, --liked Downloads all the liked songs from your account
+ -f, --followed Downloads all songs by all artists you follow
-s, --search Searches for specified track, album, artist or playlist, loads search prompt if none are given.
```
diff --git a/setup.py b/setup.py
index 45a0937..2f60251 100644
--- a/setup.py
+++ b/setup.py
@@ -12,7 +12,7 @@ README = (HERE / "README.md").read_text()
# This call to setup() does all the work
setup(
name="zotify",
- version="0.6.5",
+ version="0.6.6",
author="Zotify Contributors",
description="A music and podcast downloader.",
long_description=README,
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 11a2c3f..81374e7 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -36,6 +36,10 @@ def main():
dest='liked_songs',
action='store_true',
help='Downloads all the liked songs from your account.')
+ group.add_argument('-f', '--followed',
+ dest='followed_artists',
+ action='store_true',
+ help='Downloads all the songs from all your followed artists.')
group.add_argument('-p', '--playlist',
action='store_true',
help='Downloads a saved playlist from your account.')
diff --git a/zotify/app.py b/zotify/app.py
index 131256d..9f61b57 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -9,7 +9,7 @@ from zotify.loader import Loader
from zotify.playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist
from zotify.podcast import download_episode, get_show_episodes
from zotify.termoutput import Printer, PrintChannel
-from zotify.track import download_track, get_saved_tracks
+from zotify.track import download_track, get_saved_tracks, get_followed_artists
from zotify.utils import splash, split_input, regex_input_for_urls
from zotify.zotify import Zotify
@@ -59,12 +59,17 @@ def client(args) -> None:
else:
download_track('liked', song[TRACK][ID])
return
+
+ if args.followed_artists:
+ for artist in get_followed_artists():
+ download_artist_albums(artist)
+ return
if args.search:
if args.search == ' ':
search_text = ''
while len(search_text) == 0:
- search_text = input('Enter search or URL: ')
+ search_text = input('Enter search: ')
search(search_text)
else:
if not download_from_urls([args.search]):
@@ -74,7 +79,7 @@ def client(args) -> None:
else:
search_text = ''
while len(search_text) == 0:
- search_text = input('Enter search or URL: ')
+ search_text = input('Enter search: ')
search(search_text)
def download_from_urls(urls: list[str]) -> bool:
diff --git a/zotify/const.py b/zotify/const.py
index 59c7d43..ae63567 100644
--- a/zotify/const.py
+++ b/zotify/const.py
@@ -1,3 +1,5 @@
+FOLLOWED_ARTISTS_URL = 'https://api.spotify.com/v1/me/following?type=artist'
+
SAVED_TRACKS_URL = 'https://api.spotify.com/v1/me/tracks'
TRACKS_URL = 'https://api.spotify.com/v1/tracks'
diff --git a/zotify/track.py b/zotify/track.py
index 20633ae..a9f9f42 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -9,7 +9,8 @@ from librespot.metadata import TrackId
import ffmpy
from zotify.const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \
- RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS, HREF
+ RELEASE_DATE, ID, TRACKS_URL, FOLLOWED_ARTISTS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS, \
+ HREF, ARTISTS
from zotify.termoutput import Printer, PrintChannel
from zotify.utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \
get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive, fmt_seconds
@@ -35,6 +36,16 @@ def get_saved_tracks() -> list:
return songs
+def get_followed_artists() -> list:
+ """ Returns user's followed artists """
+ artists = []
+ resp = Zotify.invoke_url(FOLLOWED_ARTISTS_URL)[1]
+ for artist in resp[ARTISTS][ITEMS]:
+ artists.append(artist[ID])
+
+ return artists
+
+
def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, Any, Any, Any, Any, int]:
""" Retrieves metadata for downloaded songs """
with Loader(PrintChannel.PROGRESS_INFO, "Fetching track information..."):
From 1e7ad14dae90194bd914f7707bede8b7335d57f3 Mon Sep 17 00:00:00 2001
From: Not Logykk
Date: Sun, 3 Apr 2022 02:27:54 +0000
Subject: [PATCH 051/169] Update README.md
---
README.md | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/README.md b/README.md
index dcb75b1..c900960 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,6 @@
-[Discord Server](https://discord.gg/XDYsFRTUjE)
-
### Featues
- Downloads at up to 320kbps*
- Downloads directly from the source**
@@ -69,7 +67,7 @@ Be aware you have to set boolean values in the commandline like this: `--downloa
| MD_ALLGENRES | --md-allgenres | Save all relevant genres in metadata
| MD_GENREDELIMITER | --md-genredelimiter | Delimiter character used to split genres in metadata
| DOWNLOAD_FORMAT | --download-format | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis)
-| DOWNLOAD_QUALITY | --download-quality | Audio quality of downloaded songs (normal, high, very-high*)
+| DOWNLOAD_QUALITY | --download-quality | Audio quality of downloaded songs (normal, high, very_high*)
| TRANSCODE_BITRATE | --transcode-bitrate | Overwrite the bitrate for ffmpeg encoding
| SKIP_EXISTING_FILES | --skip-existing-files | Skip songs with the same name
| SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | Use a song_archive file to skip previously downloaded songs
From 5c4a317d8940e5bddc4930a4ab9ad4b1ac630c07 Mon Sep 17 00:00:00 2001
From: logykk
Date: Fri, 15 Apr 2022 14:59:08 +1200
Subject: [PATCH 052/169] switch to static setup file
---
pyproject.toml | 6 ++++++
setup.cfg | 33 +++++++++++++++++++++++++++++++++
setup.py | 35 -----------------------------------
3 files changed, 39 insertions(+), 35 deletions(-)
create mode 100644 pyproject.toml
create mode 100644 setup.cfg
delete mode 100644 setup.py
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..9ac9b91
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,6 @@
+[build-system]
+requires = [
+ "setuptools >= 40.9.0",
+ "wheel",
+]
+build-backend = "setuptools.build_meta"
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..adbc1a3
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,33 @@
+[metadata]
+name = zotify
+version = 0.6.6
+author = Zotify Contributors
+description = A highly customizable music and podcast downloader
+long_description = file: README.md
+long_description_content_type = text/markdown
+keywords = python, music, podcast, downloader
+licence = Unlicence
+classifiers =
+ Programming Language :: Python :: 3
+ License :: OSI Approved :: The Unlicense (Unlicense)
+ Operating System :: OS Independent
+
+[options]
+packages = zotify
+python_requires = >=3.9
+install_requires =
+ librespot@git+https://github.com/kokarare1212/librespot-python.git
+ ffmpy
+ music_tag
+ Pillow
+ protobuf
+ pwinput
+ tabulate
+ tqdm
+
+[options.package_data]
+ file: README.md, LICENSE
+
+[options.entry_points]
+console_scripts =
+ zotify = zotify.__main__:main
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 2f60251..0000000
--- a/setup.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from pathlib import Path
-from distutils.core import setup
-from setuptools import setup, find_packages
-
-
-# The directory containing this file
-HERE = Path(__file__).parent
-
-# The text of the README file
-README = (HERE / "README.md").read_text()
-
-# This call to setup() does all the work
-setup(
- name="zotify",
- version="0.6.6",
- author="Zotify Contributors",
- description="A music and podcast downloader.",
- long_description=README,
- long_description_content_type="text/markdown",
- url="https://gitlab.com/team-zotify/zotify.git",
- package_data={'': ['README.md', 'LICENSE']},
- packages=['zotify'],
- include_package_data=True,
- entry_points={
- 'console_scripts': [
- 'zotify=zotify.__main__:main',
- ],
- },
- classifiers=[
- "Programming Language :: Python :: 3.9",
- "Programming Language :: Python :: 3.10",
- ],
- install_requires=['ffmpy', 'music_tag', 'Pillow', 'protobuf', 'pwinput', 'tabulate', 'tqdm',
- 'librespot @ https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip'],
-)
From 99c59a581227e66f8eeb9a5d55e4d78e48fd4b4b Mon Sep 17 00:00:00 2001
From: logykk
Date: Sun, 24 Apr 2022 17:56:17 +1200
Subject: [PATCH 053/169] use same naming for lyrics as tracks
---
.gitignore | 7 ++-----
zotify/track.py | 2 +-
2 files changed, 3 insertions(+), 6 deletions(-)
diff --git a/.gitignore b/.gitignore
index 9c6d2d3..0485682 100644
--- a/.gitignore
+++ b/.gitignore
@@ -145,11 +145,8 @@ cython_debug/
.vscode/
.idea/
-# Credentials
-credentials.json
-
-# Config file
-zconfig.json
+# Configuration
+.zotify/
#Download Folder
Zotify\ Music/
diff --git a/zotify/track.py b/zotify/track.py
index a9f9f42..028b41c 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -251,7 +251,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
if(Zotify.CONFIG.get_download_lyrics()):
try:
- get_song_lyrics(track_id, PurePath(filedir / str(song_name + '.lrc')))
+ get_song_lyrics(track_id, PurePath(str(filename).replace(ext, 'lrc')))
except ValueError:
Printer.print(PrintChannel.SKIPS, f"### Skipping lyrics for {song_name}: lyrics not available ###")
convert_audio_format(filename_temp)
From 2bcef5e65e6e2679debc93f40327104f6a6aa4c0 Mon Sep 17 00:00:00 2001
From: Not Logykk
Date: Sun, 29 May 2022 00:27:32 +0000
Subject: [PATCH 054/169] Temporary fix for protobuf error
---
setup.cfg | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/setup.cfg b/setup.cfg
index adbc1a3..93aa162 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
[metadata]
name = zotify
-version = 0.6.6
+version = 0.6.7
author = Zotify Contributors
description = A highly customizable music and podcast downloader
long_description = file: README.md
@@ -20,7 +20,7 @@ install_requires =
ffmpy
music_tag
Pillow
- protobuf
+ protobuf==3.20.1
pwinput
tabulate
tqdm
From 7844f776cfe30bd56fac3daf2d7e388fac215c78 Mon Sep 17 00:00:00 2001
From: logykk
Date: Sat, 2 Jul 2022 12:57:12 +1200
Subject: [PATCH 055/169] check for "audio_preview_url"
---
zotify/podcast.py | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/zotify/podcast.py b/zotify/podcast.py
index f50d117..2a05db1 100644
--- a/zotify/podcast.py
+++ b/zotify/podcast.py
@@ -83,14 +83,15 @@ def download_episode(episode_id) -> None:
else:
filename = podcast_name + ' - ' + episode_name
- direct_download_url = Zotify.invoke_url(
- 'https://api-partner.spotify.com/pathfinder/v1/query?operationName=getEpisode&variables={"uri":"spotify:episode:' + episode_id + '"}&extensions={"persistedQuery":{"version":1,"sha256Hash":"224ba0fd89fcfdfb3a15fa2d82a6112d3f4e2ac88fba5c6713de04d1b72cf482"}}')[1]["data"]["episode"]["audio"]["items"][-1]["url"]
+ resp = Zotify.invoke_url(
+ 'https://api-partner.spotify.com/pathfinder/v1/query?operationName=getEpisode&variables={"uri":"spotify:episode:' + episode_id + '"}&extensions={"persistedQuery":{"version":1,"sha256Hash":"224ba0fd89fcfdfb3a15fa2d82a6112d3f4e2ac88fba5c6713de04d1b72cf482"}}')[1]["data"]["episode"]
+ direct_download_url = resp["audio"]["items"][-1]["url"]
download_directory = PurePath(Zotify.CONFIG.get_root_podcast_path()).joinpath(extra_paths)
# download_directory = os.path.realpath(download_directory)
create_download_directory(download_directory)
- if "anon-podcast.scdn.co" in direct_download_url:
+ if "anon-podcast.scdn.co" in direct_download_url or "audio_preview_url" not in resp:
episode_id = EpisodeId.from_base62(episode_id)
stream = Zotify.get_content_stream(
episode_id, Zotify.DOWNLOAD_QUALITY)
From 76bc317a6afbff3dfc56928ada29d627b237ca0f Mon Sep 17 00:00:00 2001
From: logykk
Date: Sat, 2 Jul 2022 13:00:47 +1200
Subject: [PATCH 056/169] version bump
---
CHANGELOG.md | 6 ++++++
setup.cfg | 2 +-
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20ba0e1..f067e3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 0.6.8
+- Improve check for direct download availability of podcasts
+
+## 0.6.7
+- Temporary fix for upstream protobuf error
+
## v0.6.6
- Added `-f` / `--followed` option to download every song by all of your followed artists
diff --git a/setup.cfg b/setup.cfg
index 93aa162..e758cd6 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
[metadata]
name = zotify
-version = 0.6.7
+version = 0.6.8
author = Zotify Contributors
description = A highly customizable music and podcast downloader
long_description = file: README.md
From 04207597e50dae5013bb6b1f1d09e2ef00ed1dc5 Mon Sep 17 00:00:00 2001
From: zotify
Date: Fri, 7 Oct 2022 17:50:40 +1300
Subject: [PATCH 057/169] Update License
---
LICENSE | 31 ++++++++++++++-----------------
1 file changed, 14 insertions(+), 17 deletions(-)
diff --git a/LICENSE b/LICENSE
index 3aab9fc..d3ba069 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,20 +1,17 @@
-Anonymous Licence v1.0
+Copyright (c) 2022 Zotify Contributors
-This work is to be considered Public Domain.
-No individual or organization can take credit for, or claim ownership
-of this work. To all effects and purposes, this work is not owned or
-authored by any entity.
+This software is provided 'as-is', without any express or implied
+warranty. In no event will the authors be held liable for any damages
+arising from the use of this software.
-All content is attributed by anonymous volunteers who dedicate any and
-all intellectual property interest in this software to the public
-domain. Volunteers intend this dedication to be an overt act of
-relinquishment in perpetuity of all present and future rights to this
-software under intellectual property law.
+Permission is granted to anyone to use this software for any purpose,
+including commercial applications, and to alter it and redistribute it
+freely, subject to the following restrictions:
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-IN NO EVENT SHALL THE VOLUNTEERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
-OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
-ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
-OTHER DEALINGS IN THE SOFTWARE.
+1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+3. This notice may not be removed or altered from any source distribution.
From 08d8e229bbc97dfa0068187c506c187c6c71225c Mon Sep 17 00:00:00 2001
From: Zotify <115511604+zotify-dev@users.noreply.github.com>
Date: Thu, 13 Oct 2022 09:18:08 +0000
Subject: [PATCH 058/169] Create pushmirror.yml
---
.github/workflows/pushmirror.yml | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
create mode 100644 .github/workflows/pushmirror.yml
diff --git a/.github/workflows/pushmirror.yml b/.github/workflows/pushmirror.yml
new file mode 100644
index 0000000..661d243
--- /dev/null
+++ b/.github/workflows/pushmirror.yml
@@ -0,0 +1,27 @@
+name: Push mirror
+
+on:
+ pull_request:
+ types: [closed]
+
+jobs:
+ push:
+ if: github.event.pull_request.merged == true
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: setup git
+ run: |
+ git config user.name "GitHub Actions Bot"
+ git config user.email "<>"
+
+ - name: set upstream
+ run: |
+ git remote set-url origin https://x-access-token:${{ secrets.GITEA_TOKEN }}@zotify.xyz/zotify/zotify
+ git remote add old https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/zotify-dev/zotify
+
+ - name: push repo
+ run: |
+ git fetch --unshallow old
+ git push
From 52be1e20a4a34178fb3c5cb01b2c2b1a8c85f3fc Mon Sep 17 00:00:00 2001
From: Zotify <115511604+zotify-dev@users.noreply.github.com>
Date: Thu, 13 Oct 2022 09:19:41 +0000
Subject: [PATCH 059/169] Update repo location
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index c900960..a6c0de7 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Dependencies:
Installation:
-python -m pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip
+python -m pip install git+https://zotify.xyz/zotify/zotify.git
```
\*Zotify will work without FFmpeg but transcoding will be unavailable.
From b416ca9853c8ced918c136e64e852579abce5a14 Mon Sep 17 00:00:00 2001
From: Zotify
Date: Tue, 18 Oct 2022 21:25:07 +0000
Subject: [PATCH 060/169] Switch cover art to 640px image
Fixes recent API change that switched the order of cover art URLs, causing zotify to fetch 64px images.
---
zotify/track.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/zotify/track.py b/zotify/track.py
index 028b41c..2bee539 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -61,7 +61,7 @@ def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, An
album_name = info[TRACKS][0][ALBUM][NAME]
name = info[TRACKS][0][NAME]
- image_url = info[TRACKS][0][ALBUM][IMAGES][0][URL]
+ image_url = info[TRACKS][0][ALBUM][IMAGES][2][URL]
release_year = info[TRACKS][0][ALBUM][RELEASE_DATE].split('-')[0]
disc_number = info[TRACKS][0][DISC_NUMBER]
track_number = info[TRACKS][0][TRACK_NUMBER]
From c8d0b0eb590ffae79f64d72c6b122759fbea38fc Mon Sep 17 00:00:00 2001
From: zotify
Date: Wed, 19 Oct 2022 16:07:48 +1300
Subject: [PATCH 061/169] release 0.6.9
---
CHANGELOG.md | 4 ++++
README.md | 4 +---
setup.cfg | 2 +-
zotify/track.py | 7 +++++--
4 files changed, 11 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f067e3e..b83f52e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 0.6.9
+- Fix low resolution cover art
+- Fix crash when missing ffmpeg
+
## 0.6.8
- Improve check for direct download availability of podcasts
diff --git a/README.md b/README.md
index a6c0de7..1e78065 100644
--- a/README.md
+++ b/README.md
@@ -26,15 +26,13 @@
Dependencies:
- Python 3.9 or greater
-- FFmpeg*
+- FFmpeg
Installation:
python -m pip install git+https://zotify.xyz/zotify/zotify.git
```
-\*Zotify will work without FFmpeg but transcoding will be unavailable.
-
### Command line usage
```
diff --git a/setup.cfg b/setup.cfg
index e758cd6..3393bd5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
[metadata]
name = zotify
-version = 0.6.8
+version = 0.6.9
author = Zotify Contributors
description = A highly customizable music and podcast downloader
long_description = file: README.md
diff --git a/zotify/track.py b/zotify/track.py
index 2bee539..f82ceee 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -255,8 +255,11 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
except ValueError:
Printer.print(PrintChannel.SKIPS, f"### Skipping lyrics for {song_name}: lyrics not available ###")
convert_audio_format(filename_temp)
- set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number)
- set_music_thumbnail(filename_temp, image_url)
+ try:
+ set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number)
+ set_music_thumbnail(filename_temp, image_url)
+ except Exception:
+ Printer.print(PrintChannel.ERRORS, "Unable to write metadata, ensure ffmpeg is installed and added to your PATH.")
if filename_temp != filename:
Path(filename_temp).rename(filename)
From 4bf43efe785e8ae0b83fea0ac0ec38b3e2a2d5b4 Mon Sep 17 00:00:00 2001
From: zotify
Date: Sat, 12 Nov 2022 14:17:48 +1300
Subject: [PATCH 062/169] fix cover art resolution again
---
CHANGELOG.md | 3 +++
setup.cfg | 2 +-
zotify/const.py | 2 ++
zotify/track.py | 9 +++++++--
4 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b83f52e..ee6a80a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## 0.6.10
+- Fix cover art size once and for all
+
## 0.6.9
- Fix low resolution cover art
- Fix crash when missing ffmpeg
diff --git a/setup.cfg b/setup.cfg
index 3393bd5..f4d68cd 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
[metadata]
name = zotify
-version = 0.6.9
+version = 0.6.10
author = Zotify Contributors
description = A highly customizable music and podcast downloader
long_description = file: README.md
diff --git a/zotify/const.py b/zotify/const.py
index ae63567..98f42c5 100644
--- a/zotify/const.py
+++ b/zotify/const.py
@@ -80,6 +80,8 @@ TYPE = 'type'
PREMIUM = 'premium'
+WIDTH = 'width'
+
USER_READ_EMAIL = 'user-read-email'
PLAYLIST_READ_PRIVATE = 'playlist-read-private'
diff --git a/zotify/track.py b/zotify/track.py
index f82ceee..d1ef537 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -10,7 +10,7 @@ import ffmpy
from zotify.const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \
RELEASE_DATE, ID, TRACKS_URL, FOLLOWED_ARTISTS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS, \
- HREF, ARTISTS
+ HREF, ARTISTS, WIDTH
from zotify.termoutput import Printer, PrintChannel
from zotify.utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \
get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive, fmt_seconds
@@ -61,7 +61,6 @@ def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, An
album_name = info[TRACKS][0][ALBUM][NAME]
name = info[TRACKS][0][NAME]
- image_url = info[TRACKS][0][ALBUM][IMAGES][2][URL]
release_year = info[TRACKS][0][ALBUM][RELEASE_DATE].split('-')[0]
disc_number = info[TRACKS][0][DISC_NUMBER]
track_number = info[TRACKS][0][TRACK_NUMBER]
@@ -69,6 +68,12 @@ def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, An
is_playable = info[TRACKS][0][IS_PLAYABLE]
duration_ms = info[TRACKS][0][DURATION_MS]
+ image = info[TRACKS][0][ALBUM][IMAGES][0]
+ for i in info[TRACKS][0][ALBUM][IMAGES]:
+ if i[WIDTH] > image[WIDTH]:
+ image = i
+ image_url = image[URL]
+
return artists, info[TRACKS][0][ARTISTS], album_name, name, image_url, release_year, disc_number, track_number, scraped_song_id, is_playable, duration_ms
except Exception as e:
raise ValueError(f'Failed to parse TRACKS_URL response: {str(e)}\n{raw}')
From 91f24a81f2812cc3195f17a5308119cee5b2c435 Mon Sep 17 00:00:00 2001
From: logykk
Date: Wed, 28 Dec 2022 23:49:18 +1300
Subject: [PATCH 063/169] add scope for followed artists
---
CHANGELOG.md | 4 ++++
setup.cfg | 2 +-
zotify/config.py | 2 +-
zotify/const.py | 2 ++
zotify/zotify.py | 6 ++++--
5 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee6a80a..8b5aedb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 0.6.11
+- Add new scope for reading followed artists
+- Print API errors by default
+
## 0.6.10
- Fix cover art size once and for all
diff --git a/setup.cfg b/setup.cfg
index f4d68cd..32946e0 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
[metadata]
name = zotify
-version = 0.6.10
+version = 0.6.11
author = Zotify Contributors
description = A highly customizable music and podcast downloader
long_description = file: README.md
diff --git a/zotify/config.py b/zotify/config.py
index b56581d..ff7e929 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -65,7 +65,7 @@ CONFIG_VALUES = {
PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' },
PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' },
PRINT_DOWNLOADS: { 'default': 'False', 'type': bool, 'arg': '--print-downloads' },
- PRINT_API_ERRORS: { 'default': 'False', 'type': bool, 'arg': '--print-api-errors' },
+ PRINT_API_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-api-errors' },
PRINT_PROGRESS_INFO: { 'default': 'True', 'type': bool, 'arg': '--print-progress-info' },
PRINT_WARNINGS: { 'default': 'True', 'type': bool, 'arg': '--print-warnings' },
TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' }
diff --git a/zotify/const.py b/zotify/const.py
index 98f42c5..770f6be 100644
--- a/zotify/const.py
+++ b/zotify/const.py
@@ -84,6 +84,8 @@ WIDTH = 'width'
USER_READ_EMAIL = 'user-read-email'
+USER_FOLLOW_READ = 'user-follow-read'
+
PLAYLIST_READ_PRIVATE = 'playlist-read-private'
USER_LIBRARY_READ = 'user-library-read'
diff --git a/zotify/zotify.py b/zotify/zotify.py
index 27fc5d0..943b4e7 100644
--- a/zotify/zotify.py
+++ b/zotify/zotify.py
@@ -8,7 +8,7 @@ from librespot.core import Session
from zotify.const import TYPE, \
PREMIUM, USER_READ_EMAIL, OFFSET, LIMIT, \
- PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ
+ PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ, USER_FOLLOW_READ
from zotify.config import Config
class Zotify:
@@ -54,7 +54,9 @@ class Zotify:
@classmethod
def __get_auth_token(cls):
- return cls.SESSION.tokens().get_token(USER_READ_EMAIL, PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ).access_token
+ return cls.SESSION.tokens().get_token(
+ USER_READ_EMAIL, PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ, USER_FOLLOW_READ
+ ).access_token
@classmethod
def get_auth_header(cls):
From 2a96a12a2a56f58d05ac8f6098008bdf55bf418e Mon Sep 17 00:00:00 2001
From: zotify
Date: Fri, 24 Mar 2023 23:57:10 +1300
Subject: [PATCH 064/169] v1.0 first upload
---
CHANGELOG.md | 77 ++++++++++
LICENCE | 17 +++
README.md | 121 +++++++++++++++
pyproject.toml | 3 +
requirements.txt | 7 +
requirements_dev.txt | 5 +
setup.cfg | 39 +++++
zotify/__init__.py | 184 ++++++++++++++++++++++
zotify/__main__.py | 135 +++++++++++++++++
zotify/app.py | 336 ++++++++++++++++++++++++++++++++++++++++
zotify/config.py | 354 +++++++++++++++++++++++++++++++++++++++++++
zotify/file.py | 126 +++++++++++++++
zotify/loader.py | 69 +++++++++
zotify/playable.py | 235 ++++++++++++++++++++++++++++
zotify/printer.py | 80 ++++++++++
zotify/utils.py | 166 ++++++++++++++++++++
16 files changed, 1954 insertions(+)
create mode 100644 CHANGELOG.md
create mode 100644 LICENCE
create mode 100644 README.md
create mode 100644 pyproject.toml
create mode 100644 requirements.txt
create mode 100644 requirements_dev.txt
create mode 100644 setup.cfg
create mode 100644 zotify/__init__.py
create mode 100644 zotify/__main__.py
create mode 100644 zotify/app.py
create mode 100644 zotify/config.py
create mode 100644 zotify/file.py
create mode 100644 zotify/loader.py
create mode 100644 zotify/playable.py
create mode 100644 zotify/printer.py
create mode 100644 zotify/utils.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..43f32b4
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,77 @@
+# STILL IN DEVELOPMENT, SOME CHANGES AREN'T IMPLEMENTED AND SOME AREN'T FINAL!
+
+## 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
+- 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
+- Regex pattern for cleaning filenames is now OS specific, allowing more usable characters on Linux & macOS.
+- The default location for credentials.json on Linux is now ~/.config/zotify to keep it in the same place as config.json
+- The output template used is now based on track info rather than search result category
+- Search queries with spaces no longer need to be in quotes
+- File metadata no longer uses sanitized file metadata, this will result in more accurate metadata.
+- 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.
+- 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.
+- 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}`
+ - `{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`
+- Added `save_metadata` option to fully disable writing track metadata
+- Added support for ReplayGain
+- Added support for transcoding to wav and wavpack formats
+- 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`
+
+### Removals
+- Removed "Zotify" ASCII banner
+- Removed search prompt
+- Removed song archive files
+- Removed `{ext}` option in output formats as file extentions are managed automatically
+- Removed `split_album_discs` because the same functionality cna be achieved by using output formatting and it was causing conflicts
+- Removed `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`
diff --git a/LICENCE b/LICENCE
new file mode 100644
index 0000000..d3ba069
--- /dev/null
+++ b/LICENCE
@@ -0,0 +1,17 @@
+Copyright (c) 2022 Zotify Contributors
+
+This software is provided 'as-is', without any express or implied
+warranty. In no event will the authors be held liable for any damages
+arising from the use of this software.
+
+Permission is granted to anyone to use this software for any purpose,
+including commercial applications, and to alter it and redistribute it
+freely, subject to the following restrictions:
+
+1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+3. This notice may not be removed or altered from any source distribution.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f26c4c8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,121 @@
+# STILL IN DEVELOPMENT, NOT RECOMMENDED FOR GENERAL USE!
+
+
+
+# Zotify
+
+A customizable music and podcast downloader. \
+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 to most popular audio formats
+- Built in search
+- Bulk downloads
+- Downloads synced lyrics
+- Embedded metadata
+- Downloads all audio, metadata and lyrics directly, no substituting from other services.
+
+*Non-premium accounts are limited to 160kbps
+
+## Installation
+Requires Python 3.9 or greater. \
+Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis.
+
+Enter the following command in terminal to install Zotify. \
+`python -m pip install https://get.zotify.xyz`
+
+## General Usage
+
+### Simplest usage
+Downloads specified items. Accepts any combination of track, album, playlist, episode or artists, URLs or URIs. \
+`zotify `
+
+### Basic options
+```
+ -p, --playlist Download selection of user's saved playlists
+ -lt, --liked-tracks Download user's liked tracks
+ -le, --liked-episodes Download user's liked episodes
+ -f, --followed Download selection of users followed artists
+ -s, --search Searches for items to download
+```
+
+All configuration options
+
+| 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 |
+| podcast_library | --podcast-library | Path to root of podcast library |
+| mixed_playlist_library | --mixed-playlist-library | Path to root of mixed content playlist library |
+| output_album | --output-album | File layout for saved albums |
+| output_playlist_track | --output-playlist-track | File layout for tracks in a playlist |
+| output_playlist_episode | --output-playlist-episode | File layout for episodes in a playlist |
+| output_podcast | --output-podcast | File layout for saved podcasts |
+| download_quality | --download-quality | Audio download quality (auto for highest available) |
+| audio_format | --audio-format | Audio format of final track output |
+| transcode_bitrate | --transcode-bitrate | Transcoding bitrate (-1 to use download rate) |
+| 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 |
+
+
+### 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
+- 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.
+
+## 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
+
+session = zotify.Session(username="username", password="password")
+track = session.get_track("4cOdK2wGLETKBW3PvgPWqT")
+output = track.create_output("./Music", "{artist} - {title}")
+
+file = track.write_audio_stream(output)
+
+file.write_metadata(track.metadata)
+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.
+All new contributions should follow this principle to keep the program consistent.
+
+## Will my account get banned if I use this tool?
+
+No user has reported their account getting banned after using Zotify
+However, it is still a possiblity and it is recommended you use Zotify with a burner account where possible.
+
+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.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..b61373e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..e53101d
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+librespot@https://github.com/kokarare1212/librespot-python/archive/refs/heads/main.zip
+music-tag
+mutagen
+Pillow
+pwinput
+requests
+tqdm
diff --git a/requirements_dev.txt b/requirements_dev.txt
new file mode 100644
index 0000000..c7740c9
--- /dev/null
+++ b/requirements_dev.txt
@@ -0,0 +1,5 @@
+black
+flake8
+mypy
+pre-commit
+types-requests
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..85f442c
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,39 @@
+[metadata]
+name = zotify
+version = 0.9.0
+author = Zotify Contributors
+description = A highly customizable music and podcast downloader
+long_description = file: README.md
+long_description_content_type = text/markdown
+keywords = python, music, podcast, downloader
+licence = Zlib
+classifiers =
+ Programming Language :: Python :: 3
+ License :: OSI Approved :: zlib/libpng License
+ Operating System :: POSIX :: Linux
+ Operating System :: Microsoft :: Windows
+ Operating System :: MacOS
+ Topic :: Multimedia :: Sound/Audio
+
+[options]
+packages = zotify
+python_requires = >=3.9
+install_requires =
+ librespot@https://github.com/kokarare1212/librespot-python/archive/refs/heads/main.zip
+ music-tag
+ mutagen
+ Pillow
+ pwinput
+ requests
+ tqdm
+
+[options.entry_points]
+console_scripts =
+ zotify = zotify.__main__:main
+
+[flake8]
+# Conflicts with black
+ignore = E203, W503
+max-line-length = 160
+per-file-ignores =
+ zotify/file.py: E701
diff --git a/zotify/__init__.py b/zotify/__init__.py
new file mode 100644
index 0000000..fb54b90
--- /dev/null
+++ b/zotify/__init__.py
@@ -0,0 +1,184 @@
+from pathlib import Path
+
+from librespot.audio.decoders import VorbisOnlyAudioQuality
+from librespot.core import (
+ ApiClient,
+ PlayableContentFeeder,
+ 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,
+)
+
+
+class Api(ApiClient):
+ def __init__(self, session: LibrespotSession, language: str = "en"):
+ super(Api, self).__init__(session)
+ self.__session = session
+ self.__language = language
+
+ def __get_token(self) -> str:
+ """Returns user's API token"""
+ return (
+ self.__session.tokens()
+ .get_token(
+ "playlist-read-private", # Private playlists
+ "user-follow-read", # Followed artists
+ "user-library-read", # Liked tracks/episodes/etc.
+ "user-read-private", # Country
+ )
+ .access_token
+ )
+
+ def invoke_url(
+ self,
+ url: str,
+ params: dict = {},
+ limit: int | None = None,
+ offset: int | None = None,
+ ) -> dict:
+ """
+ Requests data from api
+ Args:
+ url: API url and to get data from
+ params: parameters to be sent in the request
+ limit: The maximum number of items in the response
+ offset: The offset of the items returned
+ Returns:
+ Dictionary representation of json response
+ """
+ headers = {
+ "Authorization": f"Bearer {self.__get_token()}",
+ "Accept": "application/json",
+ "Accept-Language": self.__language,
+ "app-platform": "WebPlayer",
+ }
+ if limit:
+ params["limit"] = limit
+ if offset:
+ params["offset"] = offset
+
+ response = get(url, headers=headers, params=params)
+ data = response.json()
+
+ try:
+ raise HTTPError(
+ f"{url}\nAPI Error {data['error']['status']}: {data['error']['message']}"
+ )
+ except KeyError:
+ return data
+
+
+class Session:
+ __api: Api
+ __country: str
+ __is_premium: bool
+ __session: LibrespotSession
+
+ def __init__(
+ self,
+ cred_file: Path | None = None,
+ username: str | None = None,
+ password: str | None = None,
+ save: bool | None = False,
+ language: str = "en",
+ ) -> None:
+ """
+ Authenticates user, saves credentials to a file
+ and generates api token
+ Args:
+ cred_file: Path to the credentials file
+ username: Account username
+ password: Account password
+ save: Save given credentials to a file
+ """
+ # Find an existing credentials file
+ if cred_file is not None and cred_file.is_file():
+ 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)
+
+ def __get_playable(
+ self, playable_id: PlayableId, quality: Quality
+ ) -> PlayableContentFeeder.LoadedStream:
+ if quality.value is None:
+ quality = Quality.VERY_HIGH if self.is_premium() else Quality.HIGH
+ return self.__session.content_feeder().load(
+ playable_id,
+ VorbisOnlyAudioQuality(quality.value),
+ False,
+ None,
+ )
+
+ def get_track(self, track_id: TrackId, quality: Quality = Quality.AUTO) -> Track:
+ """
+ Gets track/episode data and audio stream
+ Args:
+ track_id: Base62 ID of track
+ quality: Audio quality of track when downloaded
+ Returns:
+ Track object
+ """
+ return Track(self.__get_playable(track_id, quality), self.api())
+
+ def get_episode(self, episode_id: EpisodeId) -> Episode:
+ """
+ Gets track/episode data and audio stream
+ Args:
+ episode: Base62 ID of episode
+ Returns:
+ Episode object
+ """
+ return Episode(self.__get_playable(episode_id, Quality.NORMAL), self.api())
+
+ def api(self) -> ApiClient:
+ """Returns API Client"""
+ return self.__api
+
+ def country(self) -> str:
+ """Returns two letter country code of user's account"""
+ try:
+ return self.__country
+ except AttributeError:
+ self.__country = self.api().invoke_url(API_URL + "me")["country"]
+ return self.__country
+
+ def is_premium(self) -> bool:
+ """Returns users premium account status"""
+ return self.__session.get_user_attribute("type") == "premium"
diff --git a/zotify/__main__.py b/zotify/__main__.py
new file mode 100644
index 0000000..0fce19d
--- /dev/null
+++ b/zotify/__main__.py
@@ -0,0 +1,135 @@
+#! /usr/bin/env python3
+
+from argparse import ArgumentParser
+from pathlib import Path
+
+from zotify.app import client
+from zotify.config import CONFIG_PATHS, CONFIG_VALUES
+from zotify.utils import OptionalOrFalse
+
+VERSION = "0.9.0"
+
+
+def main():
+ parser = ArgumentParser(
+ prog="zotify",
+ description="A fast and customizable music and podcast downloader",
+ )
+ parser.add_argument(
+ "-v",
+ "--version",
+ action="store_true",
+ help="Print version and exit",
+ )
+ parser.add_argument(
+ "--config",
+ type=Path,
+ default=CONFIG_PATHS["conf"],
+ help="Specify the config.json location",
+ )
+ parser.add_argument(
+ "-l",
+ "--library",
+ type=Path,
+ help="Specify a path to the root of a music/podcast library",
+ )
+ parser.add_argument(
+ "-o", "--output", type=str, help="Specify the output location/format"
+ )
+ parser.add_argument(
+ "-c",
+ "--category",
+ type=str,
+ choices=["album", "artist", "playlist", "track", "show", "episode"],
+ default=["album", "artist", "playlist", "track", "show", "episode"],
+ 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")
+ group = parser.add_mutually_exclusive_group(required=False)
+ group.add_argument(
+ "urls",
+ type=str,
+ default="",
+ nargs="*",
+ help="Downloads the track, album, playlist, podcast, episode or artist from a URL or URI. Accepts multiple options.",
+ )
+ group.add_argument(
+ "-d",
+ "--download",
+ type=str,
+ help="Downloads tracks, playlists and albums from the URLs written in the file passed.",
+ )
+ group.add_argument(
+ "-f",
+ "--followed",
+ action="store_true",
+ help="Download all songs from your followed artists.",
+ )
+ group.add_argument(
+ "-lt",
+ "--liked-tracks",
+ action="store_true",
+ help="Download all of your liked songs.",
+ )
+ group.add_argument(
+ "-le",
+ "--liked-episodes",
+ action="store_true",
+ help="Download all of your liked episodes.",
+ )
+ group.add_argument(
+ "-p",
+ "--playlist",
+ action="store_true",
+ help="Download a saved playlists from your account.",
+ )
+ group.add_argument(
+ "-s",
+ "--search",
+ type=str,
+ nargs="+",
+ help="Search for a specific track, album, playlist, artist or podcast",
+ )
+
+ for k, v in CONFIG_VALUES.items():
+ if v["type"] == bool:
+ parser.add_argument(
+ v["arg"],
+ action=OptionalOrFalse,
+ default=v["default"],
+ help=v["help"],
+ )
+ else:
+ try:
+ parser.add_argument(
+ v["arg"],
+ type=v["type"],
+ choices=v["choices"],
+ default=None,
+ help=v["help"],
+ )
+ except KeyError:
+ parser.add_argument(
+ v["arg"],
+ type=v["type"],
+ default=None,
+ help=v["help"],
+ )
+
+ parser.set_defaults(func=client)
+ args = parser.parse_args()
+ if args.version:
+ print(VERSION)
+ return
+ args.func(args)
+ return
+ try:
+ args.func(args)
+ except Exception as e:
+ print(f"Fatal Error: {e}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/zotify/app.py b/zotify/app.py
new file mode 100644
index 0000000..431c60d
--- /dev/null
+++ b/zotify/app.py
@@ -0,0 +1,336 @@
+from argparse import Namespace
+from enum import Enum
+from pathlib import Path
+from typing import Any, NamedTuple
+
+from librespot.metadata import (
+ AlbumId,
+ ArtistId,
+ EpisodeId,
+ PlayableId,
+ PlaylistId,
+ ShowId,
+ TrackId,
+)
+from librespot.util import bytes_to_hex
+
+from zotify import Session
+from zotify.config import Config
+from zotify.file import TranscodingError
+from zotify.loader import Loader
+from zotify.printer import Printer, PrintChannel
+from zotify.utils import API_URL, AudioFormat, b62_to_hex
+
+
+def client(args: Namespace) -> None:
+ config = Config(args)
+ Printer(config)
+ with Loader("Logging in..."):
+ if config.credentials is False:
+ session = Session()
+ else:
+ session = Session(
+ cred_file=config.credentials, save=True, language=config.language
+ )
+ selection = Selection(session)
+
+ try:
+ if args.search:
+ ids = selection.search(args.search, args.category)
+ elif args.playlist:
+ ids = selection.get("playlists", "items")
+ elif args.followed:
+ ids = selection.get("following?type=artist", "artists")
+ elif args.liked_tracks:
+ ids = selection.get("tracks", "items")
+ elif args.liked_episodes:
+ ids = selection.get("episodes", "items")
+ elif args.download:
+ ids = []
+ for x in args.download:
+ ids.extend(selection.from_file(x))
+ elif args.urls:
+ ids = args.urls
+ except (FileNotFoundError, ValueError):
+ Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
+ return
+
+ app = App(config, session)
+ with Loader("Parsing input..."):
+ try:
+ app.parse(ids)
+ except (IndexError, TypeError) as e:
+ Printer.print(PrintChannel.ERRORS, str(e))
+ app.download()
+
+
+class Selection:
+ def __init__(self, session: Session):
+ self.__session = session
+
+ def search(
+ self,
+ search_text: str,
+ category: list = [
+ "track",
+ "album",
+ "artist",
+ "playlist",
+ "show",
+ "episode",
+ ],
+ ) -> list[str]:
+ categories = ",".join(category)
+ resp = self.__session.api().invoke_url(
+ API_URL + "search",
+ {
+ "q": search_text,
+ "type": categories,
+ "include_external": "audio",
+ "market": self.__session.country(),
+ },
+ limit=10,
+ offset=0,
+ )
+
+ count = 0
+ links = []
+ for c in categories.split(","):
+ label = c + "s"
+ if len(resp[label]["items"]) > 0:
+ print(f"\n### {label.capitalize()} ###")
+ for item in resp[label]["items"]:
+ links.append(item)
+ self.__print(count + 1, item)
+ count += 1
+ return self.__get_selection(links)
+
+ def get(self, item: str, suffix: str) -> list[str]:
+ resp = self.__session.api().invoke_url(f"{API_URL}me/{item}", limit=50)[suffix]
+ for i in range(len(resp)):
+ self.__print(i + 1, resp[i])
+ return self.__get_selection(resp)
+
+ @staticmethod
+ def from_file(file_path: Path) -> list[str]:
+ with open(file_path, "r", encoding="utf-8") as f:
+ return [line.strip() for line in f.readlines()]
+
+ @staticmethod
+ def __get_selection(items: list[dict[str, Any]]) -> list[str]:
+ print("\nResults to save (eg: 1,2,3 1-3)")
+ selection = ""
+ while len(selection) == 0:
+ selection = input("==> ")
+ ids = []
+ selections = selection.split(",")
+ for i in selections:
+ if "-" in i:
+ split = i.split("-")
+ for x in range(int(split[0]), int(split[1]) + 1):
+ ids.append(items[x - 1]["uri"])
+ else:
+ ids.append(items[int(i) - 1]["uri"])
+ return ids
+
+ @staticmethod
+ def __print(i: int, item: dict[str, Any]) -> None:
+ print("{:<2} {:<77}".format(i, item["name"]))
+
+
+class PlayableType(Enum):
+ TRACK = "track"
+ EPISODE = "episode"
+
+
+class PlayableData(NamedTuple):
+ type: PlayableType
+ id: PlayableId
+ library: Path
+ output: str
+
+
+class App:
+ __playable_list: list[PlayableData]
+
+ def __init__(
+ self,
+ config: Config,
+ session: Session,
+ ):
+ self.__config = config
+ self.__session = session
+ self.__playable_list = []
+
+ def __parse_album(self, hex_id: str) -> None:
+ album = self.__session.api().get_metadata_4_album(AlbumId.from_hex(hex_id))
+ for disc in album.disc:
+ for track in disc.track:
+ self.__playable_list.append(
+ PlayableData(
+ PlayableType.TRACK,
+ bytes_to_hex(track.gid),
+ self.__config.music_library,
+ self.__config.output_album,
+ )
+ )
+
+ def __parse_artist(self, hex_id: str) -> None:
+ artist = self.__session.api().get_metadata_4_artist(ArtistId.from_hex(hex_id))
+ for album in artist.album_group + artist.single_group:
+ album = self.__session.api().get_metadata_4_album(
+ AlbumId.from_hex(album.gid)
+ )
+ for disc in album.disc:
+ for track in disc.track:
+ self.__playable_list.append(
+ PlayableData(
+ PlayableType.TRACK,
+ bytes_to_hex(track.gid),
+ self.__config.music_library,
+ self.__config.output_album,
+ )
+ )
+
+ def __parse_playlist(self, b62_id: str) -> None:
+ playlist = self.__session.api().get_playlist(PlaylistId(b62_id))
+ for item in playlist.contents.items:
+ split = item.uri.split(":")
+ playable_type = PlayableType(split[1])
+ id_map = {PlayableType.TRACK: TrackId, PlayableType.EPISODE: EpisodeId}
+ playable_id = id_map[playable_type].from_base62(split[2])
+ self.__playable_list.append(
+ PlayableData(
+ playable_type,
+ playable_id,
+ self.__config.playlist_library,
+ self.__config.get(f"output_playlist_{playable_type.value}"),
+ )
+ )
+
+ def __parse_show(self, hex_id: str) -> None:
+ show = self.__session.api().get_metadata_4_show(ShowId.from_hex(hex_id))
+ for episode in show.episode:
+ self.__playable_list.append(
+ PlayableData(
+ PlayableType.EPISODE,
+ bytes_to_hex(episode.gid),
+ self.__config.podcast_library,
+ self.__config.output_podcast,
+ )
+ )
+
+ def __parse_track(self, hex_id: str) -> None:
+ self.__playable_list.append(
+ PlayableData(
+ PlayableType.TRACK,
+ TrackId.from_hex(hex_id),
+ self.__config.music_library,
+ self.__config.output_album,
+ )
+ )
+
+ def __parse_episode(self, hex_id: str) -> None:
+ self.__playable_list.append(
+ PlayableData(
+ PlayableType.EPISODE,
+ EpisodeId.from_hex(hex_id),
+ self.__config.podcast_library,
+ self.__config.output_podcast,
+ )
+ )
+
+ def parse(self, links: list[str]) -> None:
+ """
+ Parses list of selected tracks/playlists/shows/etc...
+ Args:
+ links: List of links
+ """
+ for link in links:
+ link = link.rsplit("?", 1)[0]
+ try:
+ split = link.split(link[-23])
+ _id = split[-1]
+ id_type = split[-2]
+ except IndexError:
+ raise IndexError(f'Parsing Error: Could not parse "{link}"')
+
+ if id_type == "album":
+ self.__parse_album(b62_to_hex(_id))
+ elif id_type == "artist":
+ self.__parse_artist(b62_to_hex(_id))
+ elif id_type == "playlist":
+ self.__parse_playlist(_id)
+ elif id_type == "show":
+ self.__parse_show(b62_to_hex(_id))
+ elif id_type == "track":
+ self.__parse_track(b62_to_hex(_id))
+ elif id_type == "episode":
+ self.__parse_episode(b62_to_hex(_id))
+ else:
+ raise TypeError(f'Parsing Error: Unknown type "{id_type}"')
+
+ def get_playable_list(self) -> list[PlayableData]:
+ """Returns list of Playable items"""
+ return self.__playable_list
+
+ def download(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}"',
+ )
+ 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,
+ )
+ if self.__config.save_lyrics:
+ 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}")
+
+ if self.__config.audio_format != AudioFormat.VORBIS:
+ 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))
+
+ 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)
+ )
diff --git a/zotify/config.py b/zotify/config.py
new file mode 100644
index 0000000..295aa2c
--- /dev/null
+++ b/zotify/config.py
@@ -0,0 +1,354 @@
+from argparse import Namespace
+from json import dump, load
+from pathlib import Path
+from sys import platform as PLATFORM
+from typing import Any
+
+from zotify.utils import AudioFormat, ImageSize, Quality
+
+
+ALL_ARTISTS = "all_artists"
+ARTWORK_SIZE = "artwork_size"
+AUDIO_FORMAT = "audio_format"
+CHUNK_SIZE = "chunk_size"
+CREATE_PLAYLIST_FILE = "create_playlist_file"
+CREDENTIALS = "credentials"
+DOWNLOAD_QUALITY = "download_quality"
+FFMPEG_ARGS = "ffmpeg_args"
+FFMPEG_PATH = "ffmpeg_path"
+LANGUAGE = "language"
+LYRICS_ONLY = "lyrics_only"
+MUSIC_LIBRARY = "music_library"
+OUTPUT = "output"
+OUTPUT_ALBUM = "output_album"
+OUTPUT_PLAYLIST_TRACK = "output_playlist_track"
+OUTPUT_PLAYLIST_EPISODE = "output_playlist_episode"
+OUTPUT_PODCAST = "output_podcast"
+OUTPUT_SINGLE = "output_single"
+PATH_ARCHIVE = "path_archive"
+PLAYLIST_LIBRARY = "playlist_library"
+PODCAST_LIBRARY = "podcast_library"
+PRINT_DOWNLOADS = "print_downloads"
+PRINT_ERRORS = "print_errors"
+PRINT_PROGRESS = "print_progress"
+PRINT_SKIPS = "print_skips"
+PRINT_WARNINGS = "print_warnings"
+REPLACE_EXISTING = "replace_existing"
+SAVE_LYRICS = "save_lyrics"
+SAVE_METADATA = "save_metadata"
+SAVE_SUBTITLES = "save_subtitles"
+SKIP_DUPLICATES = "skip_duplicates"
+SKIP_PREVIOUS = "skip_previous"
+TRANSCODE_BITRATE = "transcode_bitrate"
+
+SYSTEM_PATHS = {
+ "win32": Path.home().joinpath("AppData/Roaming/Zotify"),
+ "linux": Path.home().joinpath(".config/zotify"),
+ "darwin": Path.home().joinpath("Library/Application Support/Zotify"),
+}
+
+LIBRARY_PATHS = {
+ "music": Path.home().joinpath("Music/Zotify Music"),
+ "podcast": Path.home().joinpath("Music/Zotify Podcasts"),
+ "playlist": Path.home().joinpath("Music/Zotify Playlists"),
+}
+
+CONFIG_PATHS = {
+ "conf": SYSTEM_PATHS[PLATFORM].joinpath("config.json"),
+ "creds": SYSTEM_PATHS[PLATFORM].joinpath("credentials.json"),
+ "archive": SYSTEM_PATHS[PLATFORM].joinpath("track_archive"),
+}
+
+OUTPUT_PATHS = {
+ "album": "{album_artist}/{album}/{track_number}. {artist} - {title}",
+ "podcast": "{podcast}/{episode_number} - {title}",
+ "playlist_track": "{playlist}/{playlist_number}. {artist} - {title}",
+ "playlist_episode": "{playlist}/{playlist_number}. {episode_number} - {title}",
+}
+
+CONFIG_VALUES = {
+ CREDENTIALS: {
+ "default": CONFIG_PATHS["creds"],
+ "type": Path,
+ "arg": "--credentials",
+ "help": "Path to credentials file",
+ },
+ PATH_ARCHIVE: {
+ "default": CONFIG_PATHS["archive"],
+ "type": Path,
+ "arg": "--archive",
+ "help": "Path to track archive file",
+ },
+ MUSIC_LIBRARY: {
+ "default": LIBRARY_PATHS["music"],
+ "type": Path,
+ "arg": "--music-library",
+ "help": "Path to root of music library",
+ },
+ PODCAST_LIBRARY: {
+ "default": LIBRARY_PATHS["podcast"],
+ "type": Path,
+ "arg": "--podcast-library",
+ "help": "Path to root of podcast library",
+ },
+ PLAYLIST_LIBRARY: {
+ "default": LIBRARY_PATHS["playlist"],
+ "type": Path,
+ "arg": "--playlist-library",
+ "help": "Path to root of playlist library",
+ },
+ OUTPUT_ALBUM: {
+ "default": OUTPUT_PATHS["album"],
+ "type": str,
+ "arg": "--output-album",
+ "help": "File layout for saved albums",
+ },
+ OUTPUT_PLAYLIST_TRACK: {
+ "default": OUTPUT_PATHS["playlist_track"],
+ "type": str,
+ "arg": "--output-playlist-track",
+ "help": "File layout for tracks in a playlist",
+ },
+ OUTPUT_PLAYLIST_EPISODE: {
+ "default": OUTPUT_PATHS["playlist_episode"],
+ "type": str,
+ "arg": "--output-playlist-episode",
+ "help": "File layout for episodes in a playlist",
+ },
+ OUTPUT_PODCAST: {
+ "default": OUTPUT_PATHS["podcast"],
+ "type": str,
+ "arg": "--output-podcast",
+ "help": "File layout for saved podcasts",
+ },
+ DOWNLOAD_QUALITY: {
+ "default": "auto",
+ "type": Quality.from_string,
+ "choices": list(Quality),
+ "arg": "--download-quality",
+ "help": "Audio download quality (auto for highest available)",
+ },
+ ARTWORK_SIZE: {
+ "default": "large",
+ "type": ImageSize.from_string,
+ "choices": list(ImageSize),
+ "arg": "--artwork-size",
+ "help": "Image size of track's cover art",
+ },
+ AUDIO_FORMAT: {
+ "default": "vorbis",
+ "type": AudioFormat,
+ "choices": [n.value for n in AudioFormat],
+ "arg": "--audio-format",
+ "help": "Audio format of final track output",
+ },
+ TRANSCODE_BITRATE: {
+ "default": -1,
+ "type": int,
+ "arg": "--bitrate",
+ "help": "Transcoding bitrate (-1 to use download rate)",
+ },
+ FFMPEG_PATH: {
+ "default": "",
+ "type": str,
+ "arg": "--ffmpeg-path",
+ "help": "Path to ffmpeg binary",
+ },
+ FFMPEG_ARGS: {
+ "default": "",
+ "type": str,
+ "arg": "--ffmpeg-args",
+ "help": "Additional ffmpeg arguments when transcoding",
+ },
+ SAVE_SUBTITLES: {
+ "default": False,
+ "type": bool,
+ "arg": "--save-subtitles",
+ "help": "Save subtitles from podcasts to a .srt file",
+ },
+ LANGUAGE: {
+ "default": "en",
+ "type": str,
+ "arg": "--language",
+ "help": "Language for metadata"
+ },
+ SAVE_LYRICS: {
+ "default": True,
+ "type": bool,
+ "arg": "--save-lyrics",
+ "help": "Save lyrics to a file",
+ },
+ LYRICS_ONLY: {
+ "default": False,
+ "type": bool,
+ "arg": "--lyrics-only",
+ "help": "Only download lyrics and not actual audio",
+ },
+ CREATE_PLAYLIST_FILE: {
+ "default": True,
+ "type": bool,
+ "arg": "--playlist-file",
+ "help": "Save playlist information to an m3u8 file",
+ },
+ SAVE_METADATA: {
+ "default": True,
+ "type": bool,
+ "arg": "--save-metadata",
+ "help": "Save metadata, required for other metadata options",
+ },
+ ALL_ARTISTS: {
+ "default": True,
+ "type": bool,
+ "arg": "--all-artists",
+ "help": "Add all track artists to artist tag in metadata",
+ },
+ REPLACE_EXISTING: {
+ "default": False,
+ "type": bool,
+ "arg": "--replace-existing",
+ "help": "Overwrite existing files with the same name",
+ },
+ SKIP_PREVIOUS: {
+ "default": True,
+ "type": bool,
+ "arg": "--skip-previous",
+ "help": "Skip previously downloaded songs",
+ },
+ SKIP_DUPLICATES: {
+ "default": True,
+ "type": bool,
+ "arg": "--skip-duplicates",
+ "help": "Skip downloading existing track to different album",
+ },
+ CHUNK_SIZE: {
+ "default": 131072,
+ "type": int,
+ "arg": "--chunk-size",
+ "help": "Number of bytes read at a time during download",
+ },
+ PRINT_DOWNLOADS: {
+ "default": False,
+ "type": bool,
+ "arg": "--print-downloads",
+ "help": "Print messages when a song is finished downloading",
+ },
+ PRINT_PROGRESS: {
+ "default": True,
+ "type": bool,
+ "arg": "--print-progress",
+ "help": "Show progress bars",
+ },
+ PRINT_SKIPS: {
+ "default": True,
+ "type": bool,
+ "arg": "--print-skips",
+ "help": "Show messages if a song is being skipped",
+ },
+ PRINT_WARNINGS: {
+ "default": True,
+ "type": bool,
+ "arg": "--print-warnings",
+ "help": "Show warnings",
+ },
+ PRINT_ERRORS: {
+ "default": True,
+ "type": bool,
+ "arg": "--print-errors",
+ "help": "Show errors",
+ },
+}
+
+
+class Config:
+ __config_file: Path | None
+ artwork_size: ImageSize
+ audio_format: AudioFormat
+ chunk_size: int
+ credentials: Path
+ download_quality: Quality
+ ffmpeg_args: str
+ ffmpeg_path: str
+ music_library: Path
+ language: str
+ output_album: str
+ output_liked: str
+ output_podcast: str
+ output_playlist_track: str
+ output_playlist_episode: str
+ playlist_library: Path
+ podcast_library: Path
+ print_progress: bool
+ save_lyrics: bool
+ save_metadata: bool
+ transcode_bitrate: int
+
+ def __init__(self, args: Namespace = Namespace()):
+ jsonvalues = {}
+ if args.config:
+ self.__config_file = Path(args.config)
+ # Valid config file found
+ if self.__config_file.exists():
+ with open(self.__config_file, "r", encoding="utf-8") as conf:
+ jsonvalues = load(conf)
+ # Remove config file and make a new one
+ else:
+ self.__config_file.parent.mkdir(parents=True, exist_ok=True)
+ jsonvalues = {}
+ for key in CONFIG_VALUES:
+ if CONFIG_VALUES[key]["type"] in [str, int, bool]:
+ jsonvalues[key] = CONFIG_VALUES[key]["default"]
+ else:
+ jsonvalues[key] = str(CONFIG_VALUES[key]["default"])
+ with open(self.__config_file, "w+", encoding="utf-8") as conf:
+ dump(jsonvalues, conf, indent=4)
+
+ for key in CONFIG_VALUES:
+ # Override config with commandline arguments
+ if key in vars(args) and vars(args)[key] is not None:
+ setattr(self, key, self.__parse_arg_value(key, vars(args)[key]))
+ # If no command option specified use config
+ elif key in jsonvalues:
+ setattr(self, key, self.__parse_arg_value(key, jsonvalues[key]))
+ # Use default values for missing keys
+ else:
+ setattr(
+ self,
+ key,
+ self.__parse_arg_value(key, CONFIG_VALUES[key]["default"]),
+ )
+ else:
+ self.__config_file = None
+
+ # Make "output" arg override all output_* options
+ if args.output:
+ self.output_album = args.output
+ self.output_liked = args.output
+ self.output_podcast = args.output
+ self.output_playlist_track = args.output
+ self.output_playlist_episode = args.output
+
+ @staticmethod
+ def __parse_arg_value(key: str, value: Any) -> Any:
+ config_type = CONFIG_VALUES[key]["type"]
+ if type(value) == config_type:
+ return value
+ elif config_type == Path:
+ return Path(value).expanduser()
+ elif config_type == AudioFormat:
+ return AudioFormat(value)
+ elif config_type == ImageSize.from_string:
+ return ImageSize.from_string(value)
+ elif config_type == Quality.from_string:
+ return Quality.from_string(value)
+ else:
+ raise TypeError("Invalid Type: " + value)
+
+ def get(self, key: str) -> Any:
+ """
+ Gets a value from config
+ Args:
+ key: config attribute to return value of
+ Returns:
+ Value of key
+ """
+ return getattr(self, key)
diff --git a/zotify/file.py b/zotify/file.py
new file mode 100644
index 0000000..0cf7885
--- /dev/null
+++ b/zotify/file.py
@@ -0,0 +1,126 @@
+from errno import ENOENT
+from pathlib import Path
+from subprocess import Popen, PIPE
+from typing import Any
+
+from music_tag import load_file
+from mutagen.oggvorbis import OggVorbisHeaderError
+
+from zotify.utils import AudioFormat, ExtMap
+
+
+# fmt: off
+class TranscodingError(RuntimeError): ...
+class TargetExistsError(FileExistsError, TranscodingError): ...
+class FFmpegNotFoundError(FileNotFoundError, TranscodingError): ...
+class FFmpegExecutionError(OSError, TranscodingError): ...
+# fmt: on
+
+
+class LocalFile:
+ audio_format: AudioFormat
+
+ def __init__(
+ self,
+ path: Path,
+ audio_format: AudioFormat | None = None,
+ bitrate: int | None = None,
+ ):
+ self.path = path
+ self.bitrate = bitrate
+ if audio_format:
+ self.audio_format = audio_format
+
+ def transcode(
+ self,
+ audio_format: AudioFormat | None = None,
+ bitrate: int | None = None,
+ replace: bool = False,
+ ffmpeg: str = "ffmpeg",
+ opt_args: list[str] = [],
+ ) -> None:
+ """
+ Use ffmpeg to transcode a saved audio file
+ Args:
+ audio_format: Audio format to transcode file to
+ bitrate: Bitrate to transcode file to in kbps
+ replace: Replace existing file
+ ffmpeg: Location of FFmpeg binary
+ opt_args: Additional arguments to pass to ffmpeg
+ """
+ if audio_format:
+ new_ext = ExtMap[audio_format.value]
+ else:
+ new_ext = ExtMap[self.audio_format.value]
+ cmd = [
+ ffmpeg,
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-i",
+ str(self.path),
+ ]
+ newpath = self.path.parent.joinpath(
+ self.path.name.rsplit(".", 1)[0] + new_ext.value
+ )
+ if self.path == newpath:
+ 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(["-c:a", audio_format.value]) if audio_format else None
+ cmd.extend(opt_args)
+ cmd.append(str(newpath))
+
+ try:
+ process = Popen(cmd, stdin=PIPE)
+ process.wait()
+ except OSError as e:
+ if e.errno == ENOENT:
+ raise FFmpegNotFoundError("Transcoding Error: FFmpeg was not found")
+ else:
+ raise
+ if process.returncode != 0:
+ raise FFmpegExecutionError(
+ f'Transcoding Error: `{" ".join(cmd)}` failed with error code {process.returncode}'
+ )
+
+ if replace:
+ Path(self.path).unlink()
+ self.path = newpath
+ self.bitrate = bitrate
+ if audio_format:
+ self.audio_format = audio_format
+
+ def write_metadata(self, metadata: dict[str, Any]) -> None:
+ """
+ Write metadata to file
+ Args:
+ metadata: key-value metadata dictionary
+ """
+ f = load_file(self.path)
+ f.save()
+ for k, v in metadata.items():
+ try:
+ f[k] = str(v)
+ except KeyError:
+ pass
+ try:
+ f.save()
+ except OggVorbisHeaderError:
+ pass # Thrown when using untranscoded file, nothing breaks.
+
+ def write_cover_art(self, image: bytes) -> None:
+ """
+ Write cover artwork to file
+ Args:
+ image: raw image data
+ """
+ f = load_file(self.path)
+ f["artwork"] = image
+ try:
+ f.save()
+ except OggVorbisHeaderError:
+ pass
diff --git a/zotify/loader.py b/zotify/loader.py
new file mode 100644
index 0000000..9eb3885
--- /dev/null
+++ b/zotify/loader.py
@@ -0,0 +1,69 @@
+# load symbol from:
+# https://stackoverflow.com/questions/22029562/python-how-to-make-simple-animated-loading-while-process-is-running
+from __future__ import annotations
+
+from itertools import cycle
+from shutil import get_terminal_size
+from sys import platform
+from threading import Thread
+from time import sleep
+
+from zotify.printer import Printer
+
+
+class Loader:
+ """
+ Busy symbol.
+
+ Can be called inside a context:
+
+ with Loader("This take some Time..."):
+ # do something
+ pass
+ """
+
+ def __init__(self, desc="Loading...", end="", timeout=0.1, mode="std3") -> None:
+ """
+ A loader-like context manager
+ Args:
+ desc (str, optional): The loader's description. Defaults to "Loading...".
+ end (str, optional): Final print. Defaults to "".
+ timeout (float, optional): Sleep time between prints. Defaults to 0.1.
+ """
+ self.desc = desc
+ self.end = end
+ self.timeout = timeout
+
+ self.__thread = Thread(target=self.__animate, daemon=True)
+ if platform == "win32":
+ self.steps = ["/", "-", "\\", "|"]
+ else:
+ self.steps = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]
+
+ self.done = False
+
+ def start(self) -> Loader:
+ self.__thread.start()
+ return self
+
+ def __animate(self) -> None:
+ for c in cycle(self.steps):
+ if self.done:
+ break
+ Printer.print_loader(f"\r {c} {self.desc} ")
+ sleep(self.timeout)
+
+ def __enter__(self) -> None:
+ self.start()
+
+ def stop(self) -> None:
+ self.done = True
+ cols = get_terminal_size((80, 20)).columns
+ Printer.print_loader("\r" + " " * cols)
+
+ if self.end != "":
+ Printer.print_loader(f"\r{self.end}")
+
+ def __exit__(self, exc_type, exc_value, tb) -> None:
+ # handle exceptions with those variables ^
+ self.stop()
diff --git a/zotify/playable.py b/zotify/playable.py
new file mode 100644
index 0000000..ce3ab3c
--- /dev/null
+++ b/zotify/playable.py
@@ -0,0 +1,235 @@
+from math import floor
+from pathlib import Path
+from typing import Any
+
+from librespot.core import PlayableContentFeeder
+from librespot.metadata import AlbumId
+from librespot.util import bytes_to_hex
+from librespot.structure import GeneralAudioStream
+from requests import get
+
+from zotify.file import LocalFile
+from zotify.printer import Printer
+from zotify.utils import (
+ IMG_URL,
+ LYRICS_URL,
+ AudioFormat,
+ ImageSize,
+ bytes_to_base62,
+ fix_filename,
+)
+
+
+class Lyrics:
+ def __init__(self, lyrics: dict, **kwargs):
+ self.lines = []
+ self.sync_type = lyrics["syncType"]
+ for line in lyrics["lines"]:
+ self.lines.append(line["words"] + "\n")
+ if self.sync_type == "line_synced":
+ self.lines_synced = []
+ for line in lyrics["lines"]:
+ timestamp = int(line["start_time_ms"])
+ ts_minutes = str(floor(timestamp / 60000)).zfill(2)
+ ts_seconds = str(floor((timestamp % 60000) / 1000)).zfill(2)
+ ts_millis = str(floor(timestamp % 1000))[:2].zfill(2)
+ self.lines_synced.append(
+ f"[{ts_minutes}:{ts_seconds}.{ts_millis}]{line.words}\n"
+ )
+
+ def save(self, path: Path, prefer_synced: bool = True) -> None:
+ """
+ Saves lyrics to file
+ Args:
+ location: path to target lyrics file
+ prefer_synced: Use line synced lyrics if available
+ """
+ if self.sync_type == "line_synced" and prefer_synced:
+ with open(f"{path}.lrc", "w+", encoding="utf-8") as f:
+ f.writelines(self.lines_synced)
+ else:
+ with open(f"{path}.txt", "w+", encoding="utf-8") as f:
+ f.writelines(self.lines[:-1])
+
+
+class Playable:
+ cover_images: list[Any]
+ metadata: dict[str, Any]
+ name: str
+ input_stream: GeneralAudioStream
+
+ def create_output(self, library: Path, output: str, replace: bool = False) -> Path:
+ """
+ Creates save directory for the output file
+ Args:
+ library: Path to root content library
+ output: Template for the output filepath
+ replace: Replace existing files with same output
+ Returns:
+ File path for the track
+ """
+ for k, v in self.metadata.items():
+ output = output.replace(
+ "{" + k + "}", fix_filename(str(v).replace("\0", ","))
+ )
+ file_path = library.joinpath(output).expanduser()
+ if file_path.exists() and not replace:
+ raise FileExistsError("Output Creation Error: File already downloaded")
+ else:
+ file_path.parent.mkdir(parents=True, exist_ok=True)
+ return file_path
+
+ def write_audio_stream(
+ self,
+ output: Path,
+ chunk_size: int = 128 * 1024,
+ ) -> LocalFile:
+ """
+ Writes audio stream to file
+ Args:
+ output: File path of saved audio stream
+ chunk_size: maximum number of bytes to read at a time
+ Returns:
+ LocalFile object
+ """
+ file = f"{output}.ogg"
+ with open(file, "wb") as f, Printer.progress(
+ desc=self.name,
+ total=self.input_stream.size,
+ unit="B",
+ unit_scale=True,
+ unit_divisor=1024,
+ position=0,
+ leave=False,
+ ) as p_bar:
+ chunk = None
+ while chunk != b"":
+ chunk = self.input_stream.stream().read(chunk_size)
+ p_bar.update(f.write(chunk))
+ return LocalFile(Path(file), AudioFormat.VORBIS)
+
+ def get_cover_art(self, size: ImageSize = ImageSize.LARGE) -> bytes:
+ """
+ Returns image data of cover art
+ Args:
+ size: Size of cover art
+ Returns:
+ Image data of cover art
+ """
+ return get(
+ IMG_URL + bytes_to_hex(self.cover_images[size.value].file_id)
+ ).content
+
+
+class Track(PlayableContentFeeder.LoadedStream, Playable):
+ lyrics: Lyrics
+
+ def __init__(self, track: PlayableContentFeeder.LoadedStream, api):
+ super(Track, self).__init__(
+ track.track,
+ track.input_stream,
+ track.normalization_data,
+ track.metrics,
+ )
+ self.__api = api
+ try:
+ isinstance(self.track.album.genre, str)
+ except AttributeError:
+ self.album = self.__api.get_metadata_4_album(
+ AlbumId.from_hex(bytes_to_hex(self.track.album.gid))
+ )
+ self.cover_images = self.album.cover_group.image
+ self.metadata = self.__default_metadata()
+
+ def __getattr__(self, name):
+ try:
+ return super().__getattribute__(name)
+ except AttributeError:
+ return super().__getattribute__("track").__getattribute__(name)
+
+ def __default_metadata(self) -> dict[str, Any]:
+ date = self.album.date
+ return {
+ "album": self.album.name,
+ "album_artist": "\0".join([a.name for a in self.album.artist]),
+ "artist": self.artist[0].name,
+ "artists": "\0".join([a.name for a in self.artist]),
+ "date": f"{date.year}-{date.month}-{date.day}",
+ "release_date": f"{date.year}-{date.month}-{date.day}",
+ "disc_number": self.disc_number,
+ "duration": self.duration,
+ "explicit": self.explicit,
+ "genre": self.album.genre,
+ "isrc": self.external_id[0].id,
+ "licensor": self.licensor,
+ "popularity": self.popularity,
+ "track_number": self.number,
+ "replaygain_track_gain": self.normalization_data.track_gain_db,
+ "replaygain_track_peak": self.normalization_data.track_peak,
+ "replaygain_album_gain": self.normalization_data.album_gain_db,
+ "replaygain_album_prak": self.normalization_data.album_peak,
+ "title": self.name,
+ "track_title": self.name,
+ # "year": self.album.date.year,
+ }
+
+ def get_lyrics(self) -> Lyrics:
+ """
+ Fetch lyrics from track if available
+ Returns:
+ Instance of track lyrics
+ """
+ if not self.track.has_lyrics:
+ raise FileNotFoundError(
+ f"No lyrics available for {self.track.artist[0].name} - {self.track.name}"
+ )
+ try:
+ return self.lyrics
+ except AttributeError:
+ self.lyrics = Lyrics(
+ self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.gid))[
+ "lyrics"
+ ]
+ )
+ return self.lyrics
+
+
+class Episode(PlayableContentFeeder.LoadedStream, Playable):
+ def __init__(self, episode: PlayableContentFeeder.LoadedStream, api):
+ super(Episode, self).__init__(
+ episode.episode,
+ episode.input_stream,
+ episode.normalization_data,
+ episode.metrics,
+ )
+ self.__api = api
+ self.cover_images = self.episode.cover_image.image
+ self.metadata = self.__default_metadata()
+
+ def __getattr__(self, name):
+ try:
+ return super().__getattribute__(name)
+ except AttributeError:
+ return super().__getattribute__("episode").__getattribute__(name)
+
+ def __default_metadata(self) -> dict[str, Any]:
+ return {
+ "description": self.description,
+ "duration": self.duration,
+ "episode_number": self.number,
+ "explicit": self.explicit,
+ "language": self.language,
+ "podcast": self.show.name,
+ "date": self.publish_time,
+ "title": self.name,
+ }
+
+ def can_download_direct(self) -> bool:
+ """Returns true if episode can be downloaded from its original external source"""
+ return bool(self.episode.is_externally_hosted)
+
+ def download_direct(self) -> LocalFile:
+ """Downloads episode from original source"""
+ if not self.can_download_directly():
+ raise RuntimeError("Podcast cannot be downloaded direct")
+ raise NotImplementedError()
diff --git a/zotify/printer.py b/zotify/printer.py
new file mode 100644
index 0000000..63ec468
--- /dev/null
+++ b/zotify/printer.py
@@ -0,0 +1,80 @@
+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,
+)
+
+
+class PrintChannel(Enum):
+ SKIPS = PRINT_SKIPS
+ PROGRESS = PRINT_PROGRESS
+ ERRORS = PRINT_ERRORS
+ WARNINGS = PRINT_WARNINGS
+ DOWNLOADS = PRINT_DOWNLOADS
+
+
+class Printer:
+ __config: Config
+
+ @classmethod
+ def __init__(cls, config: Config):
+ cls.__config = config
+
+ @classmethod
+ def print(cls, channel: PrintChannel, msg: str) -> None:
+ """
+ Prints a message to console if the print channel is enabled
+ Args:
+ channel: PrintChannel to print to
+ msg: Message to print
+ """
+ if cls.__config.get(channel.value):
+ if channel == PrintChannel.ERRORS:
+ print(msg, file=stderr)
+ else:
+ print(msg)
+
+ @classmethod
+ def progress(
+ cls,
+ iterable=None,
+ desc=None,
+ total=None,
+ leave=False,
+ position=0,
+ unit="it",
+ unit_scale=False,
+ unit_divisor=1000,
+ ) -> tqdm:
+ """
+ Prints progress bar
+ Returns:
+ tqdm decorated iterable
+ """
+ return tqdm(
+ iterable=iterable,
+ desc=desc,
+ total=total,
+ disable=False, # cls.__config.print_progress,
+ leave=leave,
+ position=position,
+ unit=unit,
+ unit_scale=unit_scale,
+ unit_divisor=unit_divisor,
+ )
+
+ @staticmethod
+ def print_loader(msg: str) -> None:
+ """
+ Prints animated loading symbol
+ Args:
+ msg: Message to print
+ """
+ print(msg, flush=True, end="")
diff --git a/zotify/utils.py b/zotify/utils.py
new file mode 100644
index 0000000..bcb5456
--- /dev/null
+++ b/zotify/utils.py
@@ -0,0 +1,166 @@
+from argparse import Action, ArgumentError
+from enum import Enum, IntEnum
+from re import IGNORECASE, sub
+from sys import platform as PLATFORM
+
+from librespot.audio.decoders import AudioQuality
+from librespot.util import Base62, bytes_to_hex
+from requests import get
+
+API_URL = "https://api.sp" + "otify.com/v1/"
+IMG_URL = "https://i.s" + "cdn.co/image/"
+LYRICS_URL = "https://sp" + "client.wg.sp" + "otify.com/color-lyrics/v2/track/"
+BASE62 = Base62.create_instance_with_inverted_character_set()
+
+
+class AudioFormat(Enum):
+ AAC = "aac"
+ FDK_AAC = "fdk_aac"
+ FLAC = "flac"
+ MP3 = "mp3"
+ OPUS = "opus"
+ VORBIS = "vorbis"
+ WAV = "wav"
+ WV = "wavpack"
+
+
+class ExtMap(Enum):
+ AAC = "m4a"
+ FDK_AAC = "m4a"
+ FLAC = "flac"
+ MP3 = "mp3"
+ OPUS = "ogg"
+ VORBIS = "ogg"
+ WAV = "wav"
+ WAVPACK = "wv"
+
+
+class Quality(Enum):
+ NORMAL = AudioQuality.NORMAL # ~96kbps
+ HIGH = AudioQuality.HIGH # ~160kbps
+ VERY_HIGH = AudioQuality.VERY_HIGH # ~320kbps
+ AUTO = None # Highest quality available for account
+
+ def __str__(self):
+ return self.name.lower()
+
+ def __repr__(self):
+ return str(self)
+
+ @staticmethod
+ def from_string(s):
+ try:
+ return Quality[s.upper()]
+ except Exception:
+ return s
+
+
+class ImageSize(IntEnum):
+ SMALL = 0 # 64px
+ MEDIUM = 1 # 300px
+ LARGE = 2 # 640px
+
+ def __str__(self):
+ return self.name.lower()
+
+ def __repr__(self):
+ return str(self)
+
+ @staticmethod
+ def from_string(s):
+ try:
+ return ImageSize[s.upper()]
+ except Exception:
+ return s
+
+
+class OptionalOrFalse(Action):
+ def __init__(
+ self,
+ option_strings,
+ dest,
+ nargs="?",
+ default=None,
+ type=None,
+ choices=None,
+ required=False,
+ help=None,
+ metavar=None,
+ ):
+ _option_strings = []
+ for option_string in option_strings:
+ _option_strings.append(option_string)
+
+ if option_string.startswith("--"):
+ option_string = "--no-" + option_string[2:]
+ _option_strings.append(option_string)
+
+ super().__init__(
+ option_strings=_option_strings,
+ dest=dest,
+ nargs=nargs,
+ default=default,
+ type=type,
+ choices=choices,
+ required=required,
+ help=help,
+ metavar=metavar,
+ )
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ if values is None and not option_string.startswith("--no-"):
+ raise ArgumentError(self, "expected 1 argument")
+ setattr(
+ namespace,
+ self.dest,
+ values if not option_string.startswith("--no-") else False,
+ )
+
+
+def fix_filename(filename: str, substitute: str = "_", platform: str = PLATFORM) -> str:
+ """
+ Replace invalid characters on Linux/Windows/MacOS with underscores.
+ Original list from https://stackoverflow.com/a/31976060/819417
+ Trailing spaces & periods are ignored on Windows.
+ Args:
+ filename: The name of the file to repair
+ platform: Host operating system
+ substitute: Replacement character for disallowed characters
+ Returns:
+ Filename with replaced characters
+ """
+ if platform == "linux":
+ regex = r"[/\0]|^(?![^.])|[\s]$"
+ elif platform == "darwin":
+ regex = r"[/\0:]|^(?![^.])|[\s]$"
+ else:
+ regex = r"[/\\:|<>\"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$"
+ return sub(regex, substitute, str(filename), flags=IGNORECASE)
+
+
+def download_cover_art(images: list, size: ImageSize) -> bytes:
+ """
+ Returns image data of cover art
+ Args:
+ images: list of retrievable images
+ size: Desired size in pixels of cover art, can be 640, 300, or 64
+ Returns:
+ Image data of cover art
+ """
+ return get(images[size.value]["url"]).content
+
+
+def str_to_bool(value: str) -> bool:
+ if value.lower() in ["yes", "y", "true"]:
+ return True
+ if value.lower() in ["no", "n", "false"]:
+ return False
+ raise TypeError("Not a boolean: " + value)
+
+
+def bytes_to_base62(id: bytes) -> str:
+ return BASE62.encode(id, 22).decode()
+
+
+def b62_to_hex(base62: str) -> str:
+ return bytes_to_hex(BASE62.decode(base62.encode(), 16))
From dbf05e3b50558a961219617a1d30aaac6f4a52ed Mon Sep 17 00:00:00 2001
From: zotify
Date: Sat, 25 Mar 2023 14:35:36 +1300
Subject: [PATCH 065/169] podcast direct downloading
---
.gitignore | 163 ++++++++++++++++++++++++++++++++++++++++++
.vscode/settings.json | 6 ++
zotify/app.py | 3 +-
zotify/playable.py | 41 ++++++++---
4 files changed, 201 insertions(+), 12 deletions(-)
create mode 100644 .gitignore
create mode 100644 .vscode/settings.json
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0596b92
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,163 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+.vscode/*
+!.vscode/settings.json
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..811348a
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,6 @@
+{
+ "python.linting.flake8Enabled": true,
+ "python.linting.mypyEnabled": true,
+ "python.linting.enabled": true,
+ "python.formatting.provider": "black"
+}
\ No newline at end of file
diff --git a/zotify/app.py b/zotify/app.py
index 431c60d..b017c1a 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -18,6 +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.utils import API_URL, AudioFormat, b62_to_hex
@@ -302,7 +303,7 @@ class App:
output,
self.__config.chunk_size,
)
- if self.__config.save_lyrics:
+ if self.__config.save_lyrics and isinstance(track, Track):
with Loader("Fetching lyrics..."):
try:
track.get_lyrics().save(output)
diff --git a/zotify/playable.py b/zotify/playable.py
index ce3ab3c..8513a33 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -174,11 +174,7 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
}
def get_lyrics(self) -> Lyrics:
- """
- Fetch lyrics from track if available
- Returns:
- Instance of track lyrics
- """
+ """Returns track lyrics if available"""
if not self.track.has_lyrics:
raise FileNotFoundError(
f"No lyrics available for {self.track.artist[0].name} - {self.track.name}"
@@ -226,10 +222,33 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
def can_download_direct(self) -> bool:
"""Returns true if episode can be downloaded from its original external source"""
- return bool(self.episode.is_externally_hosted)
+ return bool(self.external_url)
- def download_direct(self) -> LocalFile:
- """Downloads episode from original source"""
- if not self.can_download_directly():
- raise RuntimeError("Podcast cannot be downloaded direct")
- raise NotImplementedError()
+ def write_audio_stream(
+ self, output: Path, chunk_size: int = 128 * 1024
+ ) -> LocalFile:
+ """
+ Writes audio stream to file
+ Args:
+ output: File path of saved audio stream
+ chunk_size: maximum number of bytes to read at a time
+ Returns:
+ LocalFile object
+ """
+ if not self.can_download_direct():
+ return super().write_audio_stream(output, chunk_size)
+ file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}"
+ with get(self.external_url, stream=True) as r, open(
+ file, "wb"
+ ) as f, Printer.progress(
+ desc=self.name,
+ total=self.input_stream.size,
+ unit="B",
+ unit_scale=True,
+ unit_divisor=1024,
+ position=0,
+ leave=False,
+ ) as p_bar:
+ for chunk in r.iter_content(chunk_size=chunk_size):
+ p_bar.update(f.write(chunk))
+ return LocalFile(Path(file))
From 8d8d173a782bbc2fe1842e7f31d95a884a48e7eb Mon Sep 17 00:00:00 2001
From: zotify
Date: Sat, 8 Apr 2023 22:31:29 +1200
Subject: [PATCH 066/169] fix album, artist, and show parsing
---
CHANGELOG.md | 3 ++-
setup.cfg | 2 +-
zotify/__main__.py | 2 +-
zotify/app.py | 6 +++---
zotify/config.py | 4 ++--
zotify/file.py | 40 +++++++++++++++++++---------------------
zotify/playable.py | 22 ++++++----------------
zotify/utils.py | 33 ++++++++++++++-------------------
8 files changed, 48 insertions(+), 64 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 43f32b4..fdcc35a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,4 @@
-# STILL IN DEVELOPMENT, SOME CHANGES AREN'T IMPLEMENTED AND SOME AREN'T FINAL!
+# STILL IN DEVELOPMENT, EVERYTHING HERE IS SUBJECT TO CHANGE!
## v1.0.0
An unexpected reboot
@@ -41,6 +41,7 @@ An unexpected reboot
- `{album_artists}`
- !!`{duration}` - In milliseconds
- `{explicit}`
+ - `{explicit_symbol}` - For output format, will be \[E] if track is explicit.
- `{isrc}`
- `{licensor}`
- !!`{popularity}`
diff --git a/setup.cfg b/setup.cfg
index 85f442c..dc16e10 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
[metadata]
name = zotify
-version = 0.9.0
+version = 0.9.1
author = Zotify Contributors
description = A highly customizable music and podcast downloader
long_description = file: README.md
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 0fce19d..ed2d449 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -7,7 +7,7 @@ from zotify.app import client
from zotify.config import CONFIG_PATHS, CONFIG_VALUES
from zotify.utils import OptionalOrFalse
-VERSION = "0.9.0"
+VERSION = "0.9.1"
def main():
diff --git a/zotify/app.py b/zotify/app.py
index b017c1a..14f56e8 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -170,7 +170,7 @@ class App:
self.__playable_list.append(
PlayableData(
PlayableType.TRACK,
- bytes_to_hex(track.gid),
+ TrackId.from_hex(bytes_to_hex(track.gid)),
self.__config.music_library,
self.__config.output_album,
)
@@ -187,7 +187,7 @@ class App:
self.__playable_list.append(
PlayableData(
PlayableType.TRACK,
- bytes_to_hex(track.gid),
+ TrackId.from_hex(bytes_to_hex(track.gid)),
self.__config.music_library,
self.__config.output_album,
)
@@ -215,7 +215,7 @@ class App:
self.__playable_list.append(
PlayableData(
PlayableType.EPISODE,
- bytes_to_hex(episode.gid),
+ EpisodeId.from_hex(bytes_to_hex(episode.gid)),
self.__config.podcast_library,
self.__config.output_podcast,
)
diff --git a/zotify/config.py b/zotify/config.py
index 295aa2c..087185a 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -138,7 +138,7 @@ CONFIG_VALUES = {
AUDIO_FORMAT: {
"default": "vorbis",
"type": AudioFormat,
- "choices": [n.value for n in AudioFormat],
+ "choices": [n.value.name for n in AudioFormat],
"arg": "--audio-format",
"help": "Audio format of final track output",
},
@@ -335,7 +335,7 @@ class Config:
elif config_type == Path:
return Path(value).expanduser()
elif config_type == AudioFormat:
- return AudioFormat(value)
+ return AudioFormat[value.upper()]
elif config_type == ImageSize.from_string:
return ImageSize.from_string(value)
elif config_type == Quality.from_string:
diff --git a/zotify/file.py b/zotify/file.py
index 0cf7885..c50acd6 100644
--- a/zotify/file.py
+++ b/zotify/file.py
@@ -6,7 +6,7 @@ from typing import Any
from music_tag import load_file
from mutagen.oggvorbis import OggVorbisHeaderError
-from zotify.utils import AudioFormat, ExtMap
+from zotify.utils import AudioFormat
# fmt: off
@@ -18,18 +18,16 @@ class FFmpegExecutionError(OSError, TranscodingError): ...
class LocalFile:
- audio_format: AudioFormat
-
def __init__(
self,
path: Path,
audio_format: AudioFormat | None = None,
bitrate: int | None = None,
):
- self.path = path
- self.bitrate = bitrate
+ self.__path = path
+ self.__bitrate = bitrate
if audio_format:
- self.audio_format = audio_format
+ self.__audio_format = audio_format
def transcode(
self,
@@ -48,10 +46,10 @@ class LocalFile:
ffmpeg: Location of FFmpeg binary
opt_args: Additional arguments to pass to ffmpeg
"""
- if audio_format:
- new_ext = ExtMap[audio_format.value]
+ if audio_format is not None:
+ new_ext = audio_format.value.ext
else:
- new_ext = ExtMap[self.audio_format.value]
+ new_ext = self.__audio_format.value.ext
cmd = [
ffmpeg,
"-y",
@@ -59,18 +57,18 @@ class LocalFile:
"-loglevel",
"error",
"-i",
- str(self.path),
+ str(self.__path),
]
- newpath = self.path.parent.joinpath(
- self.path.name.rsplit(".", 1)[0] + new_ext.value
+ newpath = self.__path.parent.joinpath(
+ self.__path.name.rsplit(".", 1)[0] + new_ext
)
- if self.path == newpath:
+ if self.__path == newpath:
raise TargetExistsError(
- f"Transcoding Error: Cannot overwrite source, target file is already a {self.audio_format} file."
+ 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(["-c:a", audio_format.value]) if audio_format else None
+ cmd.extend(["-c:a", audio_format.value.name]) if audio_format else None
cmd.extend(opt_args)
cmd.append(str(newpath))
@@ -88,11 +86,11 @@ class LocalFile:
)
if replace:
- Path(self.path).unlink()
- self.path = newpath
- self.bitrate = bitrate
+ self.__path.unlink()
+ self.__path = newpath
+ self.__bitrate = bitrate
if audio_format:
- self.audio_format = audio_format
+ self.__audio_format = audio_format
def write_metadata(self, metadata: dict[str, Any]) -> None:
"""
@@ -100,7 +98,7 @@ class LocalFile:
Args:
metadata: key-value metadata dictionary
"""
- f = load_file(self.path)
+ f = load_file(self.__path)
f.save()
for k, v in metadata.items():
try:
@@ -118,7 +116,7 @@ class LocalFile:
Args:
image: raw image data
"""
- f = load_file(self.path)
+ f = load_file(self.__path)
f["artwork"] = image
try:
f.save()
diff --git a/zotify/playable.py b/zotify/playable.py
index 8513a33..7653ecb 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -3,7 +3,6 @@ from pathlib import Path
from typing import Any
from librespot.core import PlayableContentFeeder
-from librespot.metadata import AlbumId
from librespot.util import bytes_to_hex
from librespot.structure import GeneralAudioStream
from requests import get
@@ -132,12 +131,6 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
track.metrics,
)
self.__api = api
- try:
- isinstance(self.track.album.genre, str)
- except AttributeError:
- self.album = self.__api.get_metadata_4_album(
- AlbumId.from_hex(bytes_to_hex(self.track.album.gid))
- )
self.cover_images = self.album.cover_group.image
self.metadata = self.__default_metadata()
@@ -155,22 +148,19 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
"artist": self.artist[0].name,
"artists": "\0".join([a.name for a in self.artist]),
"date": f"{date.year}-{date.month}-{date.day}",
- "release_date": f"{date.year}-{date.month}-{date.day}",
"disc_number": self.disc_number,
"duration": self.duration,
"explicit": self.explicit,
- "genre": self.album.genre,
+ "explicit_symbol": "[E]" if self.explicit else "",
"isrc": self.external_id[0].id,
- "licensor": self.licensor,
- "popularity": self.popularity,
- "track_number": self.number,
+ "popularity": (self.popularity * 255) / 100,
+ "track_number": str(self.number).zfill(2),
+ # "year": self.album.date.year,
+ "title": self.name,
"replaygain_track_gain": self.normalization_data.track_gain_db,
"replaygain_track_peak": self.normalization_data.track_peak,
"replaygain_album_gain": self.normalization_data.album_gain_db,
- "replaygain_album_prak": self.normalization_data.album_peak,
- "title": self.name,
- "track_title": self.name,
- # "year": self.album.date.year,
+ "replaygain_album_peak": self.normalization_data.album_peak,
}
def get_lyrics(self) -> Lyrics:
diff --git a/zotify/utils.py b/zotify/utils.py
index bcb5456..ead1cee 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -2,6 +2,7 @@ from argparse import Action, ArgumentError
from enum import Enum, IntEnum
from re import IGNORECASE, sub
from sys import platform as PLATFORM
+from typing import NamedTuple
from librespot.audio.decoders import AudioQuality
from librespot.util import Base62, bytes_to_hex
@@ -13,26 +14,20 @@ LYRICS_URL = "https://sp" + "client.wg.sp" + "otify.com/color-lyrics/v2/track/"
BASE62 = Base62.create_instance_with_inverted_character_set()
+class AudioCodec(NamedTuple):
+ ext: str
+ name: str
+
+
class AudioFormat(Enum):
- AAC = "aac"
- FDK_AAC = "fdk_aac"
- FLAC = "flac"
- MP3 = "mp3"
- OPUS = "opus"
- VORBIS = "vorbis"
- WAV = "wav"
- WV = "wavpack"
-
-
-class ExtMap(Enum):
- AAC = "m4a"
- FDK_AAC = "m4a"
- FLAC = "flac"
- MP3 = "mp3"
- OPUS = "ogg"
- VORBIS = "ogg"
- WAV = "wav"
- WAVPACK = "wv"
+ AAC = AudioCodec("aac", "m4a")
+ FDK_AAC = AudioCodec("fdk_aac", "m4a")
+ FLAC = AudioCodec("flac", "flac")
+ MP3 = AudioCodec("mp3", "mp3")
+ OPUS = AudioCodec("opus", "ogg")
+ VORBIS = AudioCodec("vorbis", "ogg")
+ WAV = AudioCodec("wav", "wav")
+ WV = AudioCodec("wavpack", "wv")
class Quality(Enum):
From 52a263aa201b4c7ea234905d6b7eae7343265535 Mon Sep 17 00:00:00 2001
From: Gabe
Date: Tue, 25 Apr 2023 09:57:54 +0000
Subject: [PATCH 067/169] Updated Dockerfile
---
Dockerfile | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index f95bd03..644397e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,16 +3,21 @@ FROM python:3.9-alpine as base
RUN apk --update add ffmpeg
FROM base as builder
-RUN mkdir /install
+
+RUN apk --update add git
+
WORKDIR /install
COPY requirements.txt /requirements.txt
-RUN apk add gcc libc-dev zlib zlib-dev jpeg-dev \
- && pip install --prefix="/install" -r /requirements.txt
+RUN apk add gcc libc-dev zlib zlib-dev jpeg-dev
+RUN pip install --prefix="/install" -r /requirements.txt
FROM base
-COPY --from=builder /install /usr/local
+COPY --from=builder /install /usr/local/lib/python3.9/site-packages
+RUN mv /usr/local/lib/python3.9/site-packages/lib/python3.9/site-packages/* /usr/local/lib/python3.9/site-packages/
+
COPY zotify /app/zotify
+
WORKDIR /app
-ENTRYPOINT ["python3", "-m", "zotify"]
+CMD ["python3", "-m", "zotify"]
From c6485a3ae50e60a7a9d68f166b2899eea028f931 Mon Sep 17 00:00:00 2001
From: Gabe
Date: Tue, 25 Apr 2023 10:03:26 +0000
Subject: [PATCH 068/169] Removed git dependency in build stage
---
Dockerfile | 2 --
1 file changed, 2 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 644397e..1afe483 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,8 +4,6 @@ RUN apk --update add ffmpeg
FROM base as builder
-RUN apk --update add git
-
WORKDIR /install
COPY requirements.txt /requirements.txt
From e72fb18eb15ab3c5a988d772284bce1a7f44b86d Mon Sep 17 00:00:00 2001
From: Zotify
Date: Sun, 30 Apr 2023 13:06:09 +0200
Subject: [PATCH 069/169] Update 'README.md'
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 1e78065..ec861e7 100644
--- a/README.md
+++ b/README.md
@@ -115,12 +115,12 @@ Bangers/{artist} - {song_name}.{ext}
/home/user/downloads/{artist} - {song_name} [{id}].{ext}
~~~~
-### Docker Usage - CURRENTLY BROKEN
+### Docker Usage
```
Build the docker image from the Dockerfile:
docker build -t zotify .
Create and run a container from the image:
- docker run --rm -u $(id -u):$(id -g) -v "$PWD/zotify:/app" -v "$PWD/config.json:/config.json" -v "$PWD/Zotify Music:/Zotify Music" -v "$PWD/Zotify Podcasts:/Zotify Podcasts" -it zotify
+ docker run --rm -v "$PWD/Zotify Music:/root/Music/Zotify Music" -v "$PWD/Zotify Podcasts:/root/Music/Zotify Podcasts" -it zotify
```
### What do I do if I see "Your session has been terminated"?
From 2908dadc5bec9f275ab50d35ab609c7096409273 Mon Sep 17 00:00:00 2001
From: zotify
Date: Mon, 29 May 2023 01:09:27 +1200
Subject: [PATCH 070/169] Better search
---
README.md | 2 +-
requirements.txt | 2 +-
setup.cfg | 4 +-
zotify/__main__.py | 4 +-
zotify/app.py | 243 ++++++++++++++++++++++++++++-----------------
zotify/playable.py | 6 +-
6 files changed, 161 insertions(+), 100 deletions(-)
diff --git a/README.md b/README.md
index f26c4c8..0dbc38e 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https:/
*Non-premium accounts are limited to 160kbps
## Installation
-Requires Python 3.9 or greater. \
+Requires Python 3.10 or greater. \
Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis.
Enter the following command in terminal to install Zotify. \
diff --git a/requirements.txt b/requirements.txt
index e53101d..1c6736e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-librespot@https://github.com/kokarare1212/librespot-python/archive/refs/heads/main.zip
+librespot>=0.0.9
music-tag
mutagen
Pillow
diff --git a/setup.cfg b/setup.cfg
index dc16e10..af99003 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -17,9 +17,9 @@ classifiers =
[options]
packages = zotify
-python_requires = >=3.9
+python_requires = >=3.10
install_requires =
- librespot@https://github.com/kokarare1212/librespot-python/archive/refs/heads/main.zip
+ librespot>=0.0.9
music-tag
mutagen
Pillow
diff --git a/zotify/__main__.py b/zotify/__main__.py
index ed2d449..7dc6861 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -3,7 +3,7 @@
from argparse import ArgumentParser
from pathlib import Path
-from zotify.app import client
+from zotify.app import App
from zotify.config import CONFIG_PATHS, CONFIG_VALUES
from zotify.utils import OptionalOrFalse
@@ -118,7 +118,7 @@ def main():
help=v["help"],
)
- parser.set_defaults(func=client)
+ parser.set_defaults(func=App)
args = parser.parse_args()
if args.version:
print(VERSION)
diff --git a/zotify/app.py b/zotify/app.py
index 14f56e8..1c2e788 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -23,46 +23,20 @@ from zotify.printer import Printer, PrintChannel
from zotify.utils import API_URL, AudioFormat, b62_to_hex
-def client(args: Namespace) -> None:
- config = Config(args)
- Printer(config)
- with Loader("Logging in..."):
- if config.credentials is False:
- session = Session()
- else:
- session = Session(
- cred_file=config.credentials, save=True, language=config.language
- )
- selection = Selection(session)
+class ParsingError(RuntimeError):
+ ...
- try:
- if args.search:
- ids = selection.search(args.search, args.category)
- elif args.playlist:
- ids = selection.get("playlists", "items")
- elif args.followed:
- ids = selection.get("following?type=artist", "artists")
- elif args.liked_tracks:
- ids = selection.get("tracks", "items")
- elif args.liked_episodes:
- ids = selection.get("episodes", "items")
- elif args.download:
- ids = []
- for x in args.download:
- ids.extend(selection.from_file(x))
- elif args.urls:
- ids = args.urls
- except (FileNotFoundError, ValueError):
- Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
- return
- app = App(config, session)
- with Loader("Parsing input..."):
- try:
- app.parse(ids)
- except (IndexError, TypeError) as e:
- Printer.print(PrintChannel.ERRORS, str(e))
- app.download()
+class PlayableType(Enum):
+ TRACK = "track"
+ EPISODE = "episode"
+
+
+class PlayableData(NamedTuple):
+ type: PlayableType
+ id: PlayableId
+ library: Path
+ output: str
class Selection:
@@ -119,7 +93,7 @@ class Selection:
@staticmethod
def __get_selection(items: list[dict[str, Any]]) -> list[str]:
- print("\nResults to save (eg: 1,2,3 1-3)")
+ print("\nResults to save (eg: 1,2,5 1-3)")
selection = ""
while len(selection) == 0:
selection = input("==> ")
@@ -134,34 +108,155 @@ class Selection:
ids.append(items[int(i) - 1]["uri"])
return ids
+ def __print(self, i: int, item: dict[str, Any]) -> None:
+ match item["type"]:
+ case "album":
+ self.__print_album(i, item)
+ case "playlist":
+ self.__print_playlist(i, item)
+ case "track":
+ self.__print_track(i, item)
+ case "show":
+ self.__print_show(i, item)
+ case _:
+ print(
+ "{:<2} {:<77}".format(i, self.__fix_string_length(item["name"], 77))
+ )
+
+ def __print_album(self, i: int, item: dict[str, Any]) -> None:
+ artists = ", ".join([artist["name"] for artist in item["artists"]])
+ print(
+ "{:<2} {:<38} {:<38}".format(
+ i,
+ self.__fix_string_length(item["name"], 38),
+ self.__fix_string_length(artists, 38),
+ )
+ )
+
+ def __print_playlist(self, i: int, item: dict[str, Any]) -> None:
+ print(
+ "{:<2} {:<38} {:<38}".format(
+ i,
+ self.__fix_string_length(item["name"], 38),
+ self.__fix_string_length(item["owner"]["display_name"], 38),
+ )
+ )
+
+ def __print_track(self, i: int, item: dict[str, Any]) -> None:
+ artists = ", ".join([artist["name"] for artist in item["artists"]])
+ print(
+ "{:<2} {:<38} {:<38} {:<38}".format(
+ i,
+ self.__fix_string_length(item["name"], 38),
+ self.__fix_string_length(artists, 38),
+ self.__fix_string_length(item["album"]["name"], 38),
+ )
+ )
+
+ def __print_show(self, i: int, item: dict[str, Any]) -> None:
+ print(
+ "{:<2} {:<38} {:<38}".format(
+ i,
+ self.__fix_string_length(item["name"], 38),
+ self.__fix_string_length(item["publisher"], 38),
+ )
+ )
+
@staticmethod
- def __print(i: int, item: dict[str, Any]) -> None:
- print("{:<2} {:<77}".format(i, item["name"]))
-
-
-class PlayableType(Enum):
- TRACK = "track"
- EPISODE = "episode"
-
-
-class PlayableData(NamedTuple):
- type: PlayableType
- id: PlayableId
- library: Path
- output: str
+ def __fix_string_length(text: str, max_length: int) -> str:
+ if len(text) > max_length:
+ return text[: max_length - 3] + "..."
+ return text
class App:
- __playable_list: list[PlayableData]
+ __config: Config
+ __session: Session
+ __playable_list: list[PlayableData] = []
def __init__(
self,
- config: Config,
- session: Session,
+ args: Namespace,
):
- self.__config = config
- self.__session = session
- self.__playable_list = []
+ 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")
+
+ with Loader("Logging in..."):
+ if self.__config.credentials is False:
+ self.__session = Session()
+ else:
+ self.__session = Session(
+ cred_file=self.__config.credentials,
+ save=True,
+ language=self.__config.language,
+ )
+
+ ids = self.get_selection(args)
+ with Loader("Parsing input..."):
+ try:
+ self.parse(ids)
+ except (IndexError, TypeError) as e:
+ Printer.print(PrintChannel.ERRORS, str(e))
+ self.download()
+
+ def get_selection(self, args: Namespace) -> list[str]:
+ selection = Selection(self.__session)
+ try:
+ if args.search:
+ return selection.search(args.search, args.category)
+ elif args.playlist:
+ return selection.get("playlists", "items")
+ elif args.followed:
+ return selection.get("following?type=artist", "artists")
+ elif args.liked_tracks:
+ return selection.get("tracks", "items")
+ elif args.liked_episodes:
+ return selection.get("episodes", "items")
+ elif args.download:
+ ids = []
+ for x in args.download:
+ ids.extend(selection.from_file(x))
+ return ids
+ elif args.urls:
+ return args.urls
+ except (FileNotFoundError, ValueError):
+ pass
+ Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
+ exit()
+
+ def parse(self, links: list[str]) -> None:
+ """
+ Parses list of selected tracks/playlists/shows/etc...
+ Args:
+ links: List of links
+ """
+ for link in links:
+ link = link.rsplit("?", 1)[0]
+ try:
+ split = link.split(link[-23])
+ _id = split[-1]
+ id_type = split[-2]
+ except IndexError:
+ raise ParsingError(f'Could not parse "{link}"')
+
+ match id_type:
+ case "album":
+ self.__parse_album(b62_to_hex(_id))
+ case "artist":
+ self.__parse_artist(b62_to_hex(_id))
+ case "show":
+ self.__parse_show(b62_to_hex(_id))
+ case "track":
+ self.__parse_track(b62_to_hex(_id))
+ case "episode":
+ self.__parse_episode(b62_to_hex(_id))
+ case "playlist":
+ self.__parse_playlist(_id)
+ case _:
+ raise ParsingError(f'Unknown content type "{id_type}"')
def __parse_album(self, hex_id: str) -> None:
album = self.__session.api().get_metadata_4_album(AlbumId.from_hex(hex_id))
@@ -241,36 +336,6 @@ class App:
)
)
- def parse(self, links: list[str]) -> None:
- """
- Parses list of selected tracks/playlists/shows/etc...
- Args:
- links: List of links
- """
- for link in links:
- link = link.rsplit("?", 1)[0]
- try:
- split = link.split(link[-23])
- _id = split[-1]
- id_type = split[-2]
- except IndexError:
- raise IndexError(f'Parsing Error: Could not parse "{link}"')
-
- if id_type == "album":
- self.__parse_album(b62_to_hex(_id))
- elif id_type == "artist":
- self.__parse_artist(b62_to_hex(_id))
- elif id_type == "playlist":
- self.__parse_playlist(_id)
- elif id_type == "show":
- self.__parse_show(b62_to_hex(_id))
- elif id_type == "track":
- self.__parse_track(b62_to_hex(_id))
- elif id_type == "episode":
- self.__parse_episode(b62_to_hex(_id))
- else:
- raise TypeError(f'Parsing Error: Unknown type "{id_type}"')
-
def get_playable_list(self) -> list[PlayableData]:
"""Returns list of Playable items"""
return self.__playable_list
diff --git a/zotify/playable.py b/zotify/playable.py
index 7653ecb..65195d1 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -210,10 +210,6 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
"title": self.name,
}
- def can_download_direct(self) -> bool:
- """Returns true if episode can be downloaded from its original external source"""
- return bool(self.external_url)
-
def write_audio_stream(
self, output: Path, chunk_size: int = 128 * 1024
) -> LocalFile:
@@ -225,7 +221,7 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
Returns:
LocalFile object
"""
- if not self.can_download_direct():
+ if bool(self.external_url):
return super().write_audio_stream(output, chunk_size)
file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}"
with get(self.external_url, stream=True) as r, open(
From 30721125efe68f9c8824d1c5da6f3e24c0282208 Mon Sep 17 00:00:00 2001
From: zotify
Date: Mon, 29 May 2023 23:58:06 +1200
Subject: [PATCH 071/169] More accurate search results
---
.vscode/settings.json | 9 ++-
CHANGELOG.md | 68 +++++++++++----------
README.md | 43 ++++++++-----
setup.cfg | 4 +-
zotify/__init__.py | 127 ++++++++++++++++++++++-----------------
zotify/__main__.py | 26 +++++---
zotify/app.py | 137 +++++++++++++++++++++---------------------
zotify/config.py | 9 ++-
zotify/file.py | 57 ++++++++----------
zotify/playable.py | 4 +-
zotify/printer.py | 11 ++--
11 files changed, 269 insertions(+), 226 deletions(-)
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 811348a..bce49fa 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -2,5 +2,10 @@
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"python.linting.enabled": true,
- "python.formatting.provider": "black"
-}
\ No newline at end of file
+ "python.formatting.provider": "black",
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": true
+ },
+ "isort.args": ["--profile", "black"]
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fdcc35a..128056e 100644
--- a/CHANGELOG.md
+++ b/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`
diff --git a/README.md b/README.md
index 0dbc38e..524f9f8 100644
--- a/README.md
+++ b/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 `
### 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
All configuration options
| 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 |
+
### 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.
diff --git a/setup.cfg b/setup.cfg
index af99003..e611dcc 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -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
diff --git a/zotify/__init__.py b/zotify/__init__.py
index fb54b90..237d4ae 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -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)
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 7dc6861..9df7cea 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -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__":
diff --git a/zotify/app.py b/zotify/app.py
index 1c2e788..eb2c270 100644
--- a/zotify/app.py
+++ b/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))
diff --git a/zotify/config.py b/zotify/config.py
index 087185a..971d8e4 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -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",
diff --git a/zotify/file.py b/zotify/file.py
index c50acd6..375c781 100644
--- a/zotify/file.py
+++ b/zotify/file.py
@@ -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.
diff --git a/zotify/playable.py b/zotify/playable.py
index 65195d1..e5d36d9 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -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:
diff --git a/zotify/printer.py b/zotify/printer.py
index 63ec468..3f182f0 100644
--- a/zotify/printer.py
+++ b/zotify/printer.py
@@ -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,
)
From ff527fe8342c28cc59ccd32dcdb2d8df12ff438b Mon Sep 17 00:00:00 2001
From: Zotify
Date: Sun, 2 Jul 2023 05:58:40 +0200
Subject: [PATCH 072/169] Fix lyrics extension replacement
---
zotify/track.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/zotify/track.py b/zotify/track.py
index d1ef537..3f6d35c 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -256,7 +256,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
if(Zotify.CONFIG.get_download_lyrics()):
try:
- get_song_lyrics(track_id, PurePath(str(filename).replace(ext, 'lrc')))
+ get_song_lyrics(track_id, PurePath(str(filename)[:-3] + "lrc"))
except ValueError:
Printer.print(PrintChannel.SKIPS, f"### Skipping lyrics for {song_name}: lyrics not available ###")
convert_audio_format(filename_temp)
From 911c29820a2d56fa4dcc41308ed2e21cc81acc6e Mon Sep 17 00:00:00 2001
From: zotify
Date: Mon, 31 Jul 2023 18:14:25 +1200
Subject: [PATCH 073/169] ReplayGain
---
CHANGELOG.md | 40 ++++++++++-----------
README.md | 2 +-
requirements.txt | 2 +-
requirements_dev.txt | 1 +
setup.cfg | 4 +--
zotify/__init__.py | 49 +++++++++-----------------
zotify/__main__.py | 2 ++
zotify/app.py | 74 ++++++++++++++++++++++----------------
zotify/config.py | 13 ++++---
zotify/file.py | 9 +++--
zotify/playable.py | 84 ++++++++++++++++++++++++--------------------
zotify/printer.py | 7 ++--
zotify/utils.py | 25 ++++++++++++-
13 files changed, 171 insertions(+), 141 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 128056e..631fd57 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,19 +8,19 @@ An unexpected reboot
- Most components have been completely rewritten to address some fundamental design issues with the previous codebase, This update will provide a better base for new features in the future.
- ~~Some~~ Most configuration options have been renamed, please check your configuration file.
-- There is a new library path for podcasts, existing podcasts will stay where they are.
+- There is a new library path for playlists, existing playlists will stay where they are.
### Changes
- 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
+- 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
- 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
- Regex pattern for cleaning filenames is now OS specific, allowing more usable characters on Linux & macOS.
-- The default location for credentials.json on Linux is now ~/.config/zotify to keep it in the same place as config.json
+- On Linux both `config.json` and `credentials.json` are now kept under `$XDG_CONFIG_HOME/zotify/`, (`~/.config/zotify/` by default).
- The output template used is now based on track info rather than search result category
- Search queries with spaces no longer need to be in quotes
- File metadata no longer uses sanitized file metadata, this will result in more accurate metadata.
@@ -32,24 +32,24 @@ An unexpected reboot
- `--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.
+- Search results can be narrowed down using search filters
+ - Available filters are 'album', 'artist', 'track', 'year', 'upc', 'tag:hipster', 'tag:new', 'isrc', and 'genre'.
+ - The 'artist' and 'year' filters only shows results from the given year or a range (e.g. 1970-1982).
+ - The 'album' filter only shows results from the given album(s)
+ - The 'genre' filter only shows results from the given genre(s)
+ - 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
+ - 'tag:new' filter will show albums released within the past two weeks
+ - 'tag:hipster' will only show albums in the lowest 10% of popularity
- Search has been expanded to include podcasts and episodes
- 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.
+ - `{duration}` (milliseconds)
- `{isrc}`
- `{licensor}`
- - !!`{popularity}`
+ - `{popularity}`
- `{release_date}`
- `{track_number}`
- Genre information is now more accurate and is always enabled
@@ -60,19 +60,19 @@ An unexpected reboot
- Added support for transcoding to wav and wavpack formats
- 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`
+- Added new option `save_lyrics_file`
- This option only affects the external lyrics files
- - Embedded lyrics are tied to `save_metadata`
+ - Embedded lyrics are controlled with `save_metadata`
### Removals
- Removed "Zotify" ASCII banner
-- Removed search prompt
+- Removed search prompt, searches can only be done as cli arguments now.
- Removed song archive files
- Removed `{ext}` option in output formats as file extentions are managed automatically
-- Removed `split_album_discs` because the same functionality cna be achieved by using output formatting and it was causing conflicts
-- Removed `print_api_errors` because API errors are now trated like regular errors
-- Removed the following config options due to lack of utility
+- Removed `split_album_discs` because the same functionality can be achieved by using output formatting
+- Removed `print_api_errors` because API errors are now treated like regular errors
+- Removed the following config options due to their corresponding features being removed:
- `bulk_wait_time`
- `download_real_time`
- `md_allgenres`
diff --git a/README.md b/README.md
index 524f9f8..923e565 100644
--- a/README.md
+++ b/README.md
@@ -93,7 +93,7 @@ Here's a very simple example of downloading a track and its metadata:
```python
import zotify
-session = zotify.Session(username="username", password="password")
+session = zotify.Session.from_userpass(username="username", password="password")
track = session.get_track("4cOdK2wGLETKBW3PvgPWqT")
output = track.create_output("./Music", "{artist} - {title}")
diff --git a/requirements.txt b/requirements.txt
index 1c6736e..8ae15d7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
librespot>=0.0.9
-music-tag
+music-tag@git+https://zotify.xyz/zotify/music-tag
mutagen
Pillow
pwinput
diff --git a/requirements_dev.txt b/requirements_dev.txt
index c7740c9..624c4eb 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -3,3 +3,4 @@ flake8
mypy
pre-commit
types-requests
+wheel
diff --git a/setup.cfg b/setup.cfg
index e611dcc..27f0c12 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,7 +20,7 @@ packages = zotify
python_requires = >=3.10
install_requires =
librespot>=0.0.9
- music-tag
+ music-tag@git+https://zotify.xyz/zotify/music-tag
mutagen
Pillow
pwinput
@@ -35,5 +35,3 @@ console_scripts =
# Conflicts with black
ignore = E203
max-line-length = 160
-per-file-ignores =
- zotify/file.py: E701
diff --git a/zotify/__init__.py b/zotify/__init__.py
index 237d4ae..981d092 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -70,15 +70,9 @@ class Api(ApiClient):
class Session:
- __api: Api
- __country: str
- __language: str
- __session: LibrespotSession
- __session_builder: LibrespotSession.Builder
-
def __init__(
self,
- session_builder: LibrespotSession.Builder,
+ librespot_session: LibrespotSession,
language: str = "en",
) -> None:
"""
@@ -87,10 +81,10 @@ class Session:
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.__session = librespot_session
self.__language = language
self.__api = Api(self.__session, language)
+ self.__country = self.api().invoke_url(API_URL + "me")["country"]
@staticmethod
def from_file(cred_file: Path, langauge: str = "en") -> Session:
@@ -98,7 +92,7 @@ class Session:
Creates session using saved credentials file
Args:
cred_file: Path to credentials file
- langauge: ISO 639-1 language code
+ langauge: ISO 639-1 language code for API responses
Returns:
Zotify session
"""
@@ -107,9 +101,8 @@ class Session:
.set_store_credentials(False)
.build()
)
- return Session(
- LibrespotSession.Builder(conf).stored_file(str(cred_file)), langauge
- )
+ session = LibrespotSession.Builder(conf).stored_file(str(cred_file))
+ return Session(session.create(), langauge)
@staticmethod
def from_userpass(
@@ -124,7 +117,7 @@ class Session:
username: Account username
password: Account password
save_file: Path to save login credentials to, optional.
- langauge: ISO 639-1 language code
+ langauge: ISO 639-1 language code for API responses
Returns:
Zotify session
"""
@@ -132,22 +125,18 @@ class Session:
password = (
pwinput(prompt="Password: ", mask="*") if password == "" else password
)
+
+ builder = LibrespotSession.Configuration.Builder()
if save_file:
save_file.parent.mkdir(parents=True, exist_ok=True)
- conf = (
- LibrespotSession.Configuration.Builder()
- .set_stored_credential_file(str(save_file))
- .build()
- )
+ builder.set_stored_credential_file(str(save_file))
else:
- conf = (
- LibrespotSession.Configuration.Builder()
- .set_store_credentials(False)
- .build()
- )
- return Session(
- LibrespotSession.Builder(conf).user_pass(username, password), language
+ builder.set_store_credentials(False)
+
+ session = LibrespotSession.Builder(builder.build()).user_pass(
+ username, password
)
+ return Session(session.create(), language)
def __get_playable(
self, playable_id: PlayableId, quality: Quality
@@ -188,11 +177,7 @@ class Session:
def country(self) -> str:
"""Returns two letter country code of user's account"""
- try:
- return self.__country
- except AttributeError:
- self.__country = self.api().invoke_url(API_URL + "me")["country"]
- return self.__country
+ return self.__country
def is_premium(self) -> bool:
"""Returns users premium account status"""
@@ -200,4 +185,4 @@ class Session:
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)
+ return Session(self.__session, self.__language)
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 9df7cea..623082a 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -137,6 +137,8 @@ def main():
from traceback import format_exc
print(format_exc().splitlines()[-1])
+ except KeyboardInterrupt:
+ print("goodbye")
if __name__ == "__main__":
diff --git a/zotify/app.py b/zotify/app.py
index eb2c270..d9ba61d 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -22,7 +22,7 @@ from zotify.printer import PrintChannel, Printer
from zotify.utils import API_URL, AudioFormat, b62_to_hex
-class ParsingError(RuntimeError):
+class ParseError(ValueError):
...
@@ -36,6 +36,7 @@ class PlayableData(NamedTuple):
id: PlayableId
library: Path
output: str
+ metadata: dict[str, Any] = {}
class Selection:
@@ -55,17 +56,18 @@ class Selection:
],
) -> list[str]:
categories = ",".join(category)
- resp = self.__session.api().invoke_url(
- API_URL + "search",
- {
- "q": search_text,
- "type": categories,
- "include_external": "audio",
- "market": self.__session.country(),
- },
- limit=10,
- offset=0,
- )
+ with Loader("Searching..."):
+ resp = self.__session.api().invoke_url(
+ API_URL + "search",
+ {
+ "q": search_text,
+ "type": categories,
+ "include_external": "audio",
+ "market": self.__session.country(),
+ },
+ limit=10,
+ offset=0,
+ )
count = 0
links = []
@@ -79,11 +81,22 @@ class Selection:
count += 1
return self.__get_selection(links)
- def get(self, item: str, suffix: str) -> list[str]:
- resp = self.__session.api().invoke_url(f"{API_URL}me/{item}", limit=50)[suffix]
+ def get(self, category: str, name: str = "", content: str = "") -> list[str]:
+ with Loader("Fetching items..."):
+ r = self.__session.api().invoke_url(f"{API_URL}me/{category}", limit=50)
+ if content != "":
+ r = r[content]
+ resp = r["items"]
+
+ items = []
for i in range(len(resp)):
- self.__print(i + 1, resp[i])
- return self.__get_selection(resp)
+ try:
+ item = resp[i][name]
+ except KeyError:
+ item = resp[i]
+ items.append(item)
+ self.__print(i + 1, item)
+ return self.__get_selection(items)
@staticmethod
def from_file(file_path: Path) -> list[str]:
@@ -169,8 +182,6 @@ class Selection:
class App:
- __config: Config
- __session: Session
__playable_list: list[PlayableData] = []
def __init__(self, args: Namespace):
@@ -204,7 +215,7 @@ class App:
with Loader("Parsing input..."):
try:
self.parse(ids)
- except ParsingError as e:
+ except ParseError as e:
Printer.print(PrintChannel.ERRORS, str(e))
self.download_all()
@@ -214,13 +225,13 @@ class App:
if args.search:
return selection.search(" ".join(args.search), args.category)
elif args.playlist:
- return selection.get("playlists", "items")
+ return selection.get("playlists")
elif args.followed:
- return selection.get("following?type=artist", "artists")
+ return selection.get("following?type=artist", content="artists")
elif args.liked_tracks:
- return selection.get("tracks", "items")
+ return selection.get("tracks", "track")
elif args.liked_episodes:
- return selection.get("episodes", "items")
+ return selection.get("episodes")
elif args.download:
ids = []
for x in args.download:
@@ -228,9 +239,10 @@ class App:
return ids
elif args.urls:
return args.urls
- except (FileNotFoundError, ValueError, KeyboardInterrupt):
- pass
- Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
+ except (FileNotFoundError, ValueError):
+ Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
+ except KeyboardInterrupt:
+ Printer.print(PrintChannel.WARNINGS, "\nthere is nothing to do")
exit()
def parse(self, links: list[str]) -> None:
@@ -246,7 +258,7 @@ class App:
_id = split[-1]
id_type = split[-2]
except IndexError:
- raise ParsingError(f'Could not parse "{link}"')
+ raise ParseError(f'Could not parse "{link}"')
match id_type:
case "album":
@@ -262,7 +274,7 @@ class App:
case "playlist":
self.__parse_playlist(_id)
case _:
- raise ParsingError(f'Unknown content type "{id_type}"')
+ raise ParseError(f'Unknown content type "{id_type}"')
def __parse_album(self, hex_id: str) -> None:
album = self.__session.api().get_metadata_4_album(AlbumId.from_hex(hex_id))
@@ -279,9 +291,9 @@ class App:
def __parse_artist(self, hex_id: str) -> None:
artist = self.__session.api().get_metadata_4_artist(ArtistId.from_hex(hex_id))
- for album in artist.album_group + artist.single_group:
+ for album_group in artist.album_group and artist.single_group:
album = self.__session.api().get_metadata_4_album(
- AlbumId.from_hex(album.gid)
+ AlbumId.from_hex(album_group.album[0].gid)
)
for disc in album.disc:
for track in disc.track:
@@ -373,7 +385,7 @@ class App:
self.__config.chunk_size,
)
- if self.__config.save_lyrics and playable.type == PlayableType.TRACK:
+ if self.__config.save_lyrics_file and playable.type == PlayableType.TRACK:
with Loader("Fetching lyrics..."):
try:
track.get_lyrics().save(output)
diff --git a/zotify/config.py b/zotify/config.py
index 971d8e4..8bbf79b 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -1,5 +1,6 @@
from argparse import Namespace
from json import dump, load
+from os import environ
from pathlib import Path
from sys import platform as PLATFORM
from typing import Any
@@ -33,7 +34,7 @@ PRINT_PROGRESS = "print_progress"
PRINT_SKIPS = "print_skips"
PRINT_WARNINGS = "print_warnings"
REPLACE_EXISTING = "replace_existing"
-SAVE_LYRICS = "save_lyrics"
+SAVE_LYRICS_FILE = "save_lyrics_file"
SAVE_METADATA = "save_metadata"
SAVE_SUBTITLES = "save_subtitles"
SKIP_DUPLICATES = "skip_duplicates"
@@ -42,8 +43,10 @@ TRANSCODE_BITRATE = "transcode_bitrate"
SYSTEM_PATHS = {
"win32": Path.home().joinpath("AppData/Roaming/Zotify"),
- "linux": Path.home().joinpath(".config/zotify"),
"darwin": Path.home().joinpath("Library/Application Support/Zotify"),
+ "linux": Path(environ.get("XDG_CONFIG_HOME") or "~/.config")
+ .expanduser()
+ .joinpath("zotify"),
}
LIBRARY_PATHS = {
@@ -171,10 +174,10 @@ CONFIG_VALUES = {
"arg": "--language",
"help": "Language for metadata",
},
- SAVE_LYRICS: {
+ SAVE_LYRICS_FILE: {
"default": True,
"type": bool,
- "arg": "--save-lyrics",
+ "arg": "--save-lyrics-file",
"help": "Save lyrics to a file",
},
LYRICS_ONLY: {
@@ -277,7 +280,7 @@ class Config:
playlist_library: Path
podcast_library: Path
print_progress: bool
- save_lyrics: bool
+ save_lyrics_file: bool
save_metadata: bool
transcode_bitrate: int
diff --git a/zotify/file.py b/zotify/file.py
index 375c781..4cf1bfc 100644
--- a/zotify/file.py
+++ b/zotify/file.py
@@ -1,12 +1,11 @@
from errno import ENOENT
from pathlib import Path
from subprocess import PIPE, Popen
-from typing import Any
from music_tag import load_file
from mutagen.oggvorbis import OggVorbisHeaderError
-from zotify.utils import AudioFormat
+from zotify.utils import AudioFormat, MetadataEntry
class TranscodingError(RuntimeError):
@@ -87,7 +86,7 @@ class LocalFile:
self.__audio_format = audio_format
self.__bitrate = bitrate
- def write_metadata(self, metadata: dict[str, Any]) -> None:
+ def write_metadata(self, metadata: list[MetadataEntry]) -> None:
"""
Write metadata to file
Args:
@@ -95,9 +94,9 @@ class LocalFile:
"""
f = load_file(self.__path)
f.save()
- for k, v in metadata.items():
+ for m in metadata:
try:
- f[k] = str(v)
+ f[m.name] = m.value
except KeyError:
pass
try:
diff --git a/zotify/playable.py b/zotify/playable.py
index e5d36d9..dd312db 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -14,6 +14,7 @@ from zotify.utils import (
LYRICS_URL,
AudioFormat,
ImageSize,
+ MetadataEntry,
bytes_to_base62,
fix_filename,
)
@@ -53,7 +54,7 @@ class Lyrics:
class Playable:
cover_images: list[Any]
- metadata: dict[str, Any]
+ metadata: list[MetadataEntry]
name: str
input_stream: GeneralAudioStream
@@ -67,13 +68,12 @@ class Playable:
Returns:
File path for the track
"""
- for k, v in self.metadata.items():
- output = output.replace(
- "{" + k + "}", fix_filename(str(v).replace("\0", ", "))
- )
+ for m in self.metadata:
+ if m.output is not None:
+ output = output.replace("{" + m.name + "}", fix_filename(m.output))
file_path = library.joinpath(output).expanduser()
if file_path.exists() and not replace:
- raise FileExistsError("Output Creation Error: File already downloaded")
+ raise FileExistsError("File already downloaded")
else:
file_path.parent.mkdir(parents=True, exist_ok=True)
return file_path
@@ -140,28 +140,34 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
except AttributeError:
return super().__getattribute__("track").__getattribute__(name)
- def __default_metadata(self) -> dict[str, Any]:
+ def __default_metadata(self) -> list[MetadataEntry]:
date = self.album.date
- return {
- "album": self.album.name,
- "album_artist": "\0".join([a.name for a in self.album.artist]),
- "artist": self.artist[0].name,
- "artists": "\0".join([a.name for a in self.artist]),
- "date": f"{date.year}-{date.month}-{date.day}",
- "disc_number": self.disc_number,
- "duration": self.duration,
- "explicit": self.explicit,
- "explicit_symbol": "[E]" if self.explicit else "",
- "isrc": self.external_id[0].id,
- "popularity": (self.popularity * 255) / 100,
- "track_number": str(self.number).zfill(2),
- # "year": self.album.date.year,
- "title": self.name,
- "replaygain_track_gain": self.normalization_data.track_gain_db,
- "replaygain_track_peak": self.normalization_data.track_peak,
- "replaygain_album_gain": self.normalization_data.album_gain_db,
- "replaygain_album_peak": self.normalization_data.album_peak,
- }
+ return [
+ MetadataEntry("album", self.album.name),
+ MetadataEntry("album_artist", [a.name for a in self.album.artist]),
+ MetadataEntry("artist", self.artist[0].name),
+ MetadataEntry("artists", [a.name for a in self.artist]),
+ MetadataEntry("date", f"{date.year}-{date.month}-{date.day}"),
+ MetadataEntry("disc", self.disc_number),
+ MetadataEntry("duration", self.duration),
+ MetadataEntry("explicit", self.explicit, "[E]" if self.explicit else ""),
+ MetadataEntry("isrc", self.external_id[0].id),
+ MetadataEntry("popularity", int(self.popularity * 255) / 100),
+ MetadataEntry("track_number", self.number, str(self.number).zfill(2)),
+ MetadataEntry("title", self.name),
+ MetadataEntry(
+ "replaygain_track_gain", self.normalization_data.track_gain_db, ""
+ ),
+ MetadataEntry(
+ "replaygain_track_peak", self.normalization_data.track_peak, ""
+ ),
+ MetadataEntry(
+ "replaygain_album_gain", self.normalization_data.album_gain_db, ""
+ ),
+ MetadataEntry(
+ "replaygain_album_peak", self.normalization_data.album_peak, ""
+ ),
+ ]
def get_lyrics(self) -> Lyrics:
"""Returns track lyrics if available"""
@@ -198,17 +204,17 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
except AttributeError:
return super().__getattribute__("episode").__getattribute__(name)
- def __default_metadata(self) -> dict[str, Any]:
- return {
- "description": self.description,
- "duration": self.duration,
- "episode_number": self.number,
- "explicit": self.explicit,
- "language": self.language,
- "podcast": self.show.name,
- "date": self.publish_time,
- "title": self.name,
- }
+ def __default_metadata(self) -> list[MetadataEntry]:
+ return [
+ MetadataEntry("description", self.description),
+ MetadataEntry("duration", self.duration),
+ MetadataEntry("episode_number", self.number),
+ MetadataEntry("explicit", self.explicit, "[E]" if self.explicit else ""),
+ MetadataEntry("language", self.language),
+ MetadataEntry("podcast", self.show.name),
+ MetadataEntry("date", self.publish_time),
+ MetadataEntry("title", self.name),
+ ]
def write_audio_stream(
self, output: Path, chunk_size: int = 128 * 1024
@@ -221,7 +227,7 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
Returns:
LocalFile object
"""
- if bool(self.external_url):
+ if not bool(self.external_url):
return super().write_audio_stream(output, chunk_size)
file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}"
with get(self.external_url, stream=True) as r, open(
diff --git a/zotify/printer.py b/zotify/printer.py
index 3f182f0..9aa7d32 100644
--- a/zotify/printer.py
+++ b/zotify/printer.py
@@ -71,11 +71,12 @@ class Printer:
unit_divisor=unit_divisor,
)
- @staticmethod
- def print_loader(msg: str) -> None:
+ @classmethod
+ def print_loader(cls, msg: str) -> None:
"""
Prints animated loading symbol
Args:
msg: Message to print
"""
- print(msg, flush=True, end="")
+ if cls.__config.print_progress:
+ print(msg, flush=True, end="")
diff --git a/zotify/utils.py b/zotify/utils.py
index ead1cee..869976a 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -2,7 +2,7 @@ from argparse import Action, ArgumentError
from enum import Enum, IntEnum
from re import IGNORECASE, sub
from sys import platform as PLATFORM
-from typing import NamedTuple
+from typing import Any, NamedTuple
from librespot.audio.decoders import AudioQuality
from librespot.util import Base62, bytes_to_hex
@@ -112,6 +112,29 @@ class OptionalOrFalse(Action):
)
+class MetadataEntry:
+ def __init__(self, name: str, value: Any, output_value: str | None = None):
+ """
+ Holds metadata entries
+ args:
+ name: name of metadata key
+ tag_val: Value to use in metadata tags
+ output_value: Value when used in output formatting
+ """
+ self.name = name
+ if type(value) == list:
+ value = "\0".join(value)
+ self.value = value
+
+ if output_value is None:
+ output_value = value
+ if output_value == "":
+ output_value = None
+ if type(output_value) == list:
+ output_value = ", ".join(output_value)
+ self.output = str(output_value)
+
+
def fix_filename(filename: str, substitute: str = "_", platform: str = PLATFORM) -> str:
"""
Replace invalid characters on Linux/Windows/MacOS with underscores.
From 8cfd27407e8bacc50d43c8983a97443b29a2b7cf Mon Sep 17 00:00:00 2001
From: logykk
Date: Thu, 10 Aug 2023 01:01:00 +1200
Subject: [PATCH 074/169] force lrc file to utf-8
---
CHANGELOG.md | 5 +++++
setup.cfg | 2 +-
zotify/track.py | 6 +++---
3 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b5aedb..95e6ff1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 0.6.12
+- Dockerfile works again
+- Fixed lrc file extension replacement
+- Fixed lrc file writes breaking on non-utf8 systems
+
## 0.6.11
- Add new scope for reading followed artists
- Print API errors by default
diff --git a/setup.cfg b/setup.cfg
index 32946e0..af73781 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
[metadata]
name = zotify
-version = 0.6.11
+version = 0.6.12
author = Zotify Contributors
description = A highly customizable music and podcast downloader
long_description = file: README.md
diff --git a/zotify/track.py b/zotify/track.py
index 3f6d35c..bb2af89 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -113,12 +113,12 @@ def get_song_lyrics(song_id: str, file_save: str) -> None:
except KeyError:
raise ValueError(f'Failed to fetch lyrics: {song_id}')
if(lyrics['lyrics']['syncType'] == "UNSYNCED"):
- with open(file_save, 'w') as file:
+ with open(file_save, 'w+', encoding='utf-8') as file:
for line in formatted_lyrics:
file.writelines(line['words'] + '\n')
return
elif(lyrics['lyrics']['syncType'] == "LINE_SYNCED"):
- with open(file_save, 'w') as file:
+ with open(file_save, 'w+', encoding='utf-8') as file:
for line in formatted_lyrics:
timestamp = int(line['startTimeMs'])
ts_minutes = str(math.floor(timestamp / 60000)).zfill(2)
@@ -332,4 +332,4 @@ def convert_audio_format(filename) -> None:
Path(temp_filename).unlink()
except ffmpy.FFExecutableNotFoundError:
- Printer.print(PrintChannel.WARNINGS, f'### SKIPPING {file_codec.upper()} CONVERSION - FFMPEG NOT FOUND ###')
\ No newline at end of file
+ Printer.print(PrintChannel.WARNINGS, f'### SKIPPING {file_codec.upper()} CONVERSION - FFMPEG NOT FOUND ###')
From a10b32b5b7b3576676a27680d86076183d962392 Mon Sep 17 00:00:00 2001
From: zotify
Date: Fri, 8 Sep 2023 17:22:55 +1200
Subject: [PATCH 075/169] More tweaks/fixes
---
.gitignore | 1 +
.vscode/extensions.json | 15 ++++++++
.vscode/settings.json | 8 +---
CHANGELOG.md | 9 ++++-
setup.cfg | 17 ++++++++-
zotify/__main__.py | 13 ++++---
zotify/app.py | 6 +--
zotify/config.py | 84 ++++++++++++++++++++++-------------------
zotify/printer.py | 2 +-
zotify/utils.py | 72 +++++++++++++++++++++--------------
10 files changed, 140 insertions(+), 87 deletions(-)
create mode 100644 .vscode/extensions.json
diff --git a/.gitignore b/.gitignore
index 0596b92..14b5977 100644
--- a/.gitignore
+++ b/.gitignore
@@ -160,4 +160,5 @@ cython_debug/
#.idea/
.vscode/*
+!.vscode/extensions.json
!.vscode/settings.json
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..553fc46
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,15 @@
+{
+ // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
+ // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
+
+ // List of extensions which should be recommended for users of this workspace.
+ "recommendations": [
+ "matangover.mypy",
+ "ms-python.black-formatter",
+ "ms-python.flake8"
+ ],
+ // List of extensions recommended by VS Code that should not be recommended for users of this workspace.
+ "unwantedRecommendations": [
+
+ ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index bce49fa..13b4248 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,11 +1,7 @@
{
- "python.linting.flake8Enabled": true,
- "python.linting.mypyEnabled": true,
- "python.linting.enabled": true,
- "python.formatting.provider": "black",
+ "editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
},
- "isort.args": ["--profile", "black"]
-}
+}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 631fd57..788a032 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
## v1.0.0
-An unexpected reboot
+An unexpected reboot.
### BREAKING CHANGES AHEAD
@@ -29,9 +29,14 @@ An unexpected reboot
### Additions
- 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`/`-o`
- `--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
+ - Added new shorthand aliases to some options:
+ - `-oa` = `--output-album`
+ - `-opt` = `--output-playlist-track`
+ - `-ope` = `--output-playlist-episode`
+ - `-op` = `--output-podcast`
- Search results can be narrowed down using search filters
- Available filters are 'album', 'artist', 'track', 'year', 'upc', 'tag:hipster', 'tag:new', 'isrc', and 'genre'.
- The 'artist' and 'year' filters only shows results from the given year or a range (e.g. 1970-1982).
diff --git a/setup.cfg b/setup.cfg
index 27f0c12..8db7ba7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -32,6 +32,19 @@ console_scripts =
zotify = zotify.__main__:main
[flake8]
-# Conflicts with black
-ignore = E203
max-line-length = 160
+
+[mypy]
+warn_unused_configs = True
+
+[mypy-librespot.*]
+ignore_missing_imports = True
+
+[mypy-music_tag]
+ignore_missing_imports = True
+
+[mypy-pwinput]
+ignore_missing_imports = True
+
+[mypy-tqdm]
+ignore_missing_imports = True
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 623082a..adbb088 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -5,15 +5,16 @@ from pathlib import Path
from zotify.app import App
from zotify.config import CONFIG_PATHS, CONFIG_VALUES
-from zotify.utils import OptionalOrFalse
+from zotify.utils import OptionalOrFalse, SimpleHelpFormatter
-VERSION = "0.9.2"
+VERSION = "0.9.3"
def main():
parser = ArgumentParser(
prog="zotify",
description="A fast and customizable music and podcast downloader",
+ formatter_class=SimpleHelpFormatter,
)
parser.add_argument(
"-v",
@@ -39,7 +40,7 @@ def main():
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"
+ "-o", "--output", type=str, help="Specify the output file structure/format"
)
parser.add_argument(
"-c",
@@ -101,7 +102,7 @@ def main():
for k, v in CONFIG_VALUES.items():
if v["type"] == bool:
parser.add_argument(
- v["arg"],
+ *v["args"],
action=OptionalOrFalse,
default=v["default"],
help=v["help"],
@@ -109,7 +110,7 @@ def main():
else:
try:
parser.add_argument(
- v["arg"],
+ *v["args"],
type=v["type"],
choices=v["choices"],
default=None,
@@ -117,7 +118,7 @@ def main():
)
except KeyError:
parser.add_argument(
- v["arg"],
+ *v["args"],
type=v["type"],
default=None,
help=v["help"],
diff --git a/zotify/app.py b/zotify/app.py
index d9ba61d..e3569e0 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -19,7 +19,7 @@ from zotify.config import Config
from zotify.file import TranscodingError
from zotify.loader import Loader
from zotify.printer import PrintChannel, Printer
-from zotify.utils import API_URL, AudioFormat, b62_to_hex
+from zotify.utils import API_URL, AudioFormat, MetadataEntry, b62_to_hex
class ParseError(ValueError):
@@ -36,7 +36,7 @@ class PlayableData(NamedTuple):
id: PlayableId
library: Path
output: str
- metadata: dict[str, Any] = {}
+ metadata: list[MetadataEntry] = []
class Selection:
@@ -385,7 +385,7 @@ class App:
self.__config.chunk_size,
)
- if self.__config.save_lyrics_file and playable.type == PlayableType.TRACK:
+ if playable.type == PlayableType.TRACK and self.__config.lyrics_file:
with Loader("Fetching lyrics..."):
try:
track.get_lyrics().save(output)
diff --git a/zotify/config.py b/zotify/config.py
index 8bbf79b..c2d1a68 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -17,6 +17,7 @@ DOWNLOAD_QUALITY = "download_quality"
FFMPEG_ARGS = "ffmpeg_args"
FFMPEG_PATH = "ffmpeg_path"
LANGUAGE = "language"
+LYRICS_FILE = "lyrics_file"
LYRICS_ONLY = "lyrics_only"
MUSIC_LIBRARY = "music_library"
OUTPUT = "output"
@@ -34,7 +35,6 @@ PRINT_PROGRESS = "print_progress"
PRINT_SKIPS = "print_skips"
PRINT_WARNINGS = "print_warnings"
REPLACE_EXISTING = "replace_existing"
-SAVE_LYRICS_FILE = "save_lyrics_file"
SAVE_METADATA = "save_metadata"
SAVE_SUBTITLES = "save_subtitles"
SKIP_DUPLICATES = "skip_duplicates"
@@ -72,190 +72,190 @@ CONFIG_VALUES = {
CREDENTIALS: {
"default": CONFIG_PATHS["creds"],
"type": Path,
- "arg": "--credentials",
+ "args": ["--credentials"],
"help": "Path to credentials file",
},
PATH_ARCHIVE: {
"default": CONFIG_PATHS["archive"],
"type": Path,
- "arg": "--archive",
+ "args": ["--archive"],
"help": "Path to track archive file",
},
MUSIC_LIBRARY: {
"default": LIBRARY_PATHS["music"],
"type": Path,
- "arg": "--music-library",
+ "args": ["--music-library"],
"help": "Path to root of music library",
},
PODCAST_LIBRARY: {
"default": LIBRARY_PATHS["podcast"],
"type": Path,
- "arg": "--podcast-library",
+ "args": ["--podcast-library"],
"help": "Path to root of podcast library",
},
PLAYLIST_LIBRARY: {
"default": LIBRARY_PATHS["playlist"],
"type": Path,
- "arg": "--playlist-library",
+ "args": ["--playlist-library"],
"help": "Path to root of playlist library",
},
OUTPUT_ALBUM: {
"default": OUTPUT_PATHS["album"],
"type": str,
- "arg": "--output-album",
+ "args": ["--output-album", "-oa"],
"help": "File layout for saved albums",
},
OUTPUT_PLAYLIST_TRACK: {
"default": OUTPUT_PATHS["playlist_track"],
"type": str,
- "arg": "--output-playlist-track",
+ "args": ["--output-playlist-track", "-opt"],
"help": "File layout for tracks in a playlist",
},
OUTPUT_PLAYLIST_EPISODE: {
"default": OUTPUT_PATHS["playlist_episode"],
"type": str,
- "arg": "--output-playlist-episode",
+ "args": ["--output-playlist-episode", "-ope"],
"help": "File layout for episodes in a playlist",
},
OUTPUT_PODCAST: {
"default": OUTPUT_PATHS["podcast"],
"type": str,
- "arg": "--output-podcast",
+ "args": ["--output-podcast", "-op"],
"help": "File layout for saved podcasts",
},
DOWNLOAD_QUALITY: {
"default": "auto",
"type": Quality.from_string,
"choices": list(Quality),
- "arg": "--download-quality",
+ "args": ["--download-quality"],
"help": "Audio download quality (auto for highest available)",
},
ARTWORK_SIZE: {
"default": "large",
"type": ImageSize.from_string,
"choices": list(ImageSize),
- "arg": "--artwork-size",
+ "args": ["--artwork-size"],
"help": "Image size of track's cover art",
},
AUDIO_FORMAT: {
"default": "vorbis",
"type": AudioFormat,
"choices": [n.value.name for n in AudioFormat],
- "arg": "--audio-format",
+ "args": ["--audio-format"],
"help": "Audio format of final track output",
},
TRANSCODE_BITRATE: {
"default": -1,
"type": int,
- "arg": "--bitrate",
+ "args": ["--bitrate"],
"help": "Transcoding bitrate (-1 to use download rate)",
},
FFMPEG_PATH: {
"default": "",
"type": str,
- "arg": "--ffmpeg-path",
+ "args": ["--ffmpeg-path"],
"help": "Path to ffmpeg binary",
},
FFMPEG_ARGS: {
"default": "",
"type": str,
- "arg": "--ffmpeg-args",
+ "args": ["--ffmpeg-args"],
"help": "Additional ffmpeg arguments when transcoding",
},
SAVE_SUBTITLES: {
"default": False,
"type": bool,
- "arg": "--save-subtitles",
+ "args": ["--save-subtitles"],
"help": "Save subtitles from podcasts to a .srt file",
},
LANGUAGE: {
"default": "en",
"type": str,
- "arg": "--language",
+ "args": ["--language"],
"help": "Language for metadata",
},
- SAVE_LYRICS_FILE: {
- "default": True,
+ LYRICS_FILE: {
+ "default": False,
"type": bool,
- "arg": "--save-lyrics-file",
+ "args": ["--lyrics-file"],
"help": "Save lyrics to a file",
},
LYRICS_ONLY: {
"default": False,
"type": bool,
- "arg": "--lyrics-only",
+ "args": ["--lyrics-only"],
"help": "Only download lyrics and not actual audio",
},
CREATE_PLAYLIST_FILE: {
"default": True,
"type": bool,
- "arg": "--playlist-file",
+ "args": ["--playlist-file"],
"help": "Save playlist information to an m3u8 file",
},
SAVE_METADATA: {
"default": True,
"type": bool,
- "arg": "--save-metadata",
+ "args": ["--save-metadata"],
"help": "Save metadata, required for other metadata options",
},
ALL_ARTISTS: {
"default": True,
"type": bool,
- "arg": "--all-artists",
+ "args": ["--all-artists"],
"help": "Add all track artists to artist tag in metadata",
},
REPLACE_EXISTING: {
"default": False,
"type": bool,
- "arg": "--replace-existing",
+ "args": ["--replace-existing"],
"help": "Overwrite existing files with the same name",
},
SKIP_PREVIOUS: {
"default": True,
"type": bool,
- "arg": "--skip-previous",
+ "args": ["--skip-previous"],
"help": "Skip previously downloaded songs",
},
SKIP_DUPLICATES: {
"default": True,
"type": bool,
- "arg": "--skip-duplicates",
+ "args": ["--skip-duplicates"],
"help": "Skip downloading existing track to different album",
},
CHUNK_SIZE: {
- "default": 131072,
+ "default": 16384,
"type": int,
- "arg": "--chunk-size",
+ "args": ["--chunk-size"],
"help": "Number of bytes read at a time during download",
},
PRINT_DOWNLOADS: {
"default": False,
"type": bool,
- "arg": "--print-downloads",
+ "args": ["--print-downloads"],
"help": "Print messages when a song is finished downloading",
},
PRINT_PROGRESS: {
"default": True,
"type": bool,
- "arg": "--print-progress",
+ "args": ["--print-progress"],
"help": "Show progress bars",
},
PRINT_SKIPS: {
"default": False,
"type": bool,
- "arg": "--print-skips",
+ "args": ["--print-skips"],
"help": "Show messages if a song is being skipped",
},
PRINT_WARNINGS: {
"default": True,
"type": bool,
- "arg": "--print-warnings",
+ "args": ["--print-warnings"],
"help": "Show warnings",
},
PRINT_ERRORS: {
"default": True,
"type": bool,
- "arg": "--print-errors",
+ "args": ["--print-errors"],
"help": "Show errors",
},
}
@@ -272,6 +272,7 @@ class Config:
ffmpeg_path: str
music_library: Path
language: str
+ lyrics_file: bool
output_album: str
output_liked: str
output_podcast: str
@@ -280,7 +281,6 @@ class Config:
playlist_library: Path
podcast_library: Path
print_progress: bool
- save_lyrics_file: bool
save_metadata: bool
transcode_bitrate: int
@@ -303,6 +303,8 @@ class Config:
jsonvalues[key] = str(CONFIG_VALUES[key]["default"])
with open(self.__config_file, "w+", encoding="utf-8") as conf:
dump(jsonvalues, conf, indent=4)
+ else:
+ self.__config_file = None
for key in CONFIG_VALUES:
# Override config with commandline arguments
@@ -318,10 +320,14 @@ class Config:
key,
self.__parse_arg_value(key, CONFIG_VALUES[key]["default"]),
)
- else:
- self.__config_file = None
- # Make "output" arg override all output_* options
+ # "library" arg overrides all *_library options
+ if args.library:
+ self.music_library = args.library
+ self.playlist_library = args.library
+ self.podcast_library = args.library
+
+ # "output" arg overrides all output_* options
if args.output:
self.output_album = args.output
self.output_liked = args.output
diff --git a/zotify/printer.py b/zotify/printer.py
index 9aa7d32..901e1ff 100644
--- a/zotify/printer.py
+++ b/zotify/printer.py
@@ -63,7 +63,7 @@ class Printer:
iterable=iterable,
desc=desc,
total=total,
- disable=False, # cls.__config.print_progress,
+ disable=not cls.__config.print_progress,
leave=leave,
position=position,
unit=unit,
diff --git a/zotify/utils.py b/zotify/utils.py
index 869976a..01d5236 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -1,7 +1,9 @@
-from argparse import Action, ArgumentError
+from argparse import Action, ArgumentError, HelpFormatter
from enum import Enum, IntEnum
from re import IGNORECASE, sub
+from sys import exit
from sys import platform as PLATFORM
+from sys import stderr
from typing import Any, NamedTuple
from librespot.audio.decoders import AudioQuality
@@ -15,8 +17,8 @@ BASE62 = Base62.create_instance_with_inverted_character_set()
class AudioCodec(NamedTuple):
- ext: str
name: str
+ ext: str
class AudioFormat(Enum):
@@ -69,6 +71,43 @@ class ImageSize(IntEnum):
return s
+class MetadataEntry:
+ name: str
+ value: Any
+ output: str
+
+ def __init__(self, name: str, value: Any, output_value: str | None = None):
+ """
+ Holds metadata entries
+ args:
+ name: name of metadata key
+ value: Value to use in metadata tags
+ output_value: Value when used in output formatting, if none is provided
+ will use value from previous argument.
+ """
+ self.name = name
+
+ if type(value) == list:
+ value = "\0".join(value)
+ self.value = value
+
+ if output_value is None:
+ output_value = self.value
+ elif output_value == "":
+ output_value = None
+ if type(output_value) == list:
+ output_value = ", ".join(output_value)
+ self.output = str(output_value)
+
+
+class SimpleHelpFormatter(HelpFormatter):
+ def _format_usage(self, usage, actions, groups, prefix):
+ if usage is not None:
+ super()._format_usage(usage, actions, groups, prefix)
+ stderr.write('zotify: error: unrecognized arguments - try "zotify -h"\n')
+ exit(2)
+
+
class OptionalOrFalse(Action):
def __init__(
self,
@@ -103,38 +142,15 @@ class OptionalOrFalse(Action):
)
def __call__(self, parser, namespace, values, option_string=None):
- if values is None and not option_string.startswith("--no-"):
- raise ArgumentError(self, "expected 1 argument")
+ if values is not None:
+ raise ArgumentError(self, "expected 0 arguments")
setattr(
namespace,
self.dest,
- values if not option_string.startswith("--no-") else False,
+ True if not option_string.startswith("--no-") else False,
)
-class MetadataEntry:
- def __init__(self, name: str, value: Any, output_value: str | None = None):
- """
- Holds metadata entries
- args:
- name: name of metadata key
- tag_val: Value to use in metadata tags
- output_value: Value when used in output formatting
- """
- self.name = name
- if type(value) == list:
- value = "\0".join(value)
- self.value = value
-
- if output_value is None:
- output_value = value
- if output_value == "":
- output_value = None
- if type(output_value) == list:
- output_value = ", ".join(output_value)
- self.output = str(output_value)
-
-
def fix_filename(filename: str, substitute: str = "_", platform: str = PLATFORM) -> str:
"""
Replace invalid characters on Linux/Windows/MacOS with underscores.
From 33ffd38d4670badc7d03d493d145256ec4f3aa27 Mon Sep 17 00:00:00 2001
From: octoshrimpy
Date: Tue, 12 Sep 2023 18:46:08 +0200
Subject: [PATCH 076/169] updates readme with changes from GH PRs
---
README.md | 20 +++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index ec861e7..08fc101 100644
--- a/README.md
+++ b/README.md
@@ -3,10 +3,10 @@
### A highly customizable music and podcast downloader.
-
+
-### Featues
+### Features
- Downloads at up to 320kbps*
- Downloads directly from the source**
- Downloads podcasts, playlists, liked songs, albums, artists, singles.
@@ -45,7 +45,8 @@ Basic options:
-p, --playlist Downloads a saved playlist from your account
-l, --liked Downloads all the liked songs from your account
-f, --followed Downloads all songs by all artists you follow
- -s, --search Searches for specified track, album, artist or playlist, loads search prompt if none are given.
+ -s, --search Searches for specified track, album, artist or playlist, loads search prompt if none are given.
+ -h, --help See this message.
```
### Options
@@ -82,7 +83,16 @@ Be aware you have to set boolean values in the commandline like this: `--downloa
| PRINT_DOWNLOADS | --print-downloads | Print messages when a song is finished downloading
| TEMP_DOWNLOAD_DIR | --temp-download-dir | Download tracks to a temporary directory first
-*very-high is limited to premium only
+*very-high is limited to premium only
+
+### Configuration
+
+You can find the configuration file in following locations:
+| OS | Location
+|-----------------|---------------------------------------------------------|
+| Windows | `C:\Users\\AppData\Roaming\Zotify\config.json`|
+| MacOS | `/Users//.config/zotify/config.json` |
+| Linux | `/home//.config/zotify/config.json` |
### Output format
@@ -133,7 +143,7 @@ If you see this, don't worry! Just try logging back in. If you see the incorrect
Currently no user has reported their account getting banned after using Zotify.
It is recommended you use Zotify with a burner account.
-Alternatively, there is a configuration option labled ```DOWNLOAD_REAL_TIME```, this limits the download speed to the duration of the song being downloaded thus appearing less suspicious.
+Alternatively, there is a configuration option labeled ```DOWNLOAD_REAL_TIME```, this limits the download speed to the duration of the song being downloaded thus appearing less suspicious.
This option is much slower and is only recommended for premium users who wish to download songs in 320kbps without buying premium on a burner account.
### Disclaimer
From 0e6ec3804fea47d7d6f3ac778b4c15958d09b382 Mon Sep 17 00:00:00 2001
From: octoshrimpy
Date: Tue, 12 Sep 2023 18:47:53 +0200
Subject: [PATCH 077/169] updates app.py with changes from GH PRs
---
zotify/app.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/zotify/app.py b/zotify/app.py
index 9f61b57..f15821b 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -152,7 +152,7 @@ def search(search_term):
try:
int(splits[index+1])
except ValueError:
- raise ValueError('Paramater passed after {} option must be an integer.\n'.
+ raise ValueError('Parameter passed after {} option must be an integer.\n'.
format(split))
if int(splits[index+1]) > 50:
raise ValueError('Invalid limit passed. Max is 50.\n')
From fbc020687a90bb4b2e651d0a241a361e0019bb16 Mon Sep 17 00:00:00 2001
From: octoshrimpy
Date: Tue, 12 Sep 2023 19:09:20 +0200
Subject: [PATCH 078/169] add logout instructions
---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index 08fc101..30ab0eb 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,8 @@ You can find the configuration file in following locations:
| MacOS | `/Users//.config/zotify/config.json` |
| Linux | `/home//.config/zotify/config.json` |
+To log out, just remove the configuration file.
+
### Output format
With the option `OUTPUT` (or the commandline parameter `--output`) you can specify the output location and format.
From 901582948eb3b2710b9611187ed0ea16e6553837 Mon Sep 17 00:00:00 2001
From: octoshrimpy
Date: Tue, 12 Sep 2023 19:10:51 +0200
Subject: [PATCH 079/169] update skip-existing flag
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 30ab0eb..ecaf879 100644
--- a/README.md
+++ b/README.md
@@ -68,7 +68,7 @@ Be aware you have to set boolean values in the commandline like this: `--downloa
| DOWNLOAD_FORMAT | --download-format | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis)
| DOWNLOAD_QUALITY | --download-quality | Audio quality of downloaded songs (normal, high, very_high*)
| TRANSCODE_BITRATE | --transcode-bitrate | Overwrite the bitrate for ffmpeg encoding
-| SKIP_EXISTING_FILES | --skip-existing-files | Skip songs with the same name
+| SKIP_EXISTING_FILES | --skip-existing | Skip songs with the same name
| SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | Use a song_archive file to skip previously downloaded songs
| RETRY_ATTEMPTS | --retry-attempts | Number of times Zotify will retry a failed request
| BULK_WAIT_TIME | --bulk-wait-time | The wait time between bulk downloads
From 0ba8ae0f01787bc66f00a7cdb7e9bbc1cdf23c7b Mon Sep 17 00:00:00 2001
From: octoshrimpy
Date: Tue, 12 Sep 2023 19:12:03 +0200
Subject: [PATCH 080/169] uninstall does not remove config file
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index ecaf879..664d8b0 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ You can find the configuration file in following locations:
| MacOS | `/Users//.config/zotify/config.json` |
| Linux | `/home//.config/zotify/config.json` |
-To log out, just remove the configuration file.
+To log out, just remove the configuration file. Uninstalling Zotify does not remove the config file.
### Output format
From fe6de7698aeaacffdb609345c1b5361949ea115d Mon Sep 17 00:00:00 2001
From: octoshrimpy
Date: Tue, 12 Sep 2023 19:13:30 +0200
Subject: [PATCH 081/169] attemps -> attempts
---
zotify/config.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/zotify/config.py b/zotify/config.py
index ff7e929..802d5ce 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -54,7 +54,7 @@ CONFIG_VALUES = {
TRANSCODE_BITRATE: { 'default': 'auto', 'type': str, 'arg': '--transcode-bitrate' },
SKIP_EXISTING: { 'default': 'True', 'type': bool, 'arg': '--skip-existing' },
SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' },
- RETRY_ATTEMPTS: { 'default': '1', 'type': int, 'arg': '--retry-attemps' },
+ RETRY_ATTEMPTS: { 'default': '1', 'type': int, 'arg': '--retry-attempts' },
BULK_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--bulk-wait-time' },
OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' },
CHUNK_SIZE: { 'default': '20000', 'type': int, 'arg': '--chunk-size' },
From 4e6c425afad09e5282daba59f52bbd049d3e0a88 Mon Sep 17 00:00:00 2001
From: octoshrimpy
Date: Tue, 12 Sep 2023 19:24:23 +0200
Subject: [PATCH 082/169] adds INSTALLATION.md
---
INSTALLATION.md | 32 ++++++++++++++++++++++++++++++++
1 file changed, 32 insertions(+)
create mode 100644 INSTALLATION.md
diff --git a/INSTALLATION.md b/INSTALLATION.md
new file mode 100644
index 0000000..12a9e94
--- /dev/null
+++ b/INSTALLATION.md
@@ -0,0 +1,32 @@
+### Installing Zotify
+
+> **Windows**
+
+This guide uses *Scoop* (https://scoop.sh) to simplify installing prerequisites and *pipx* to manage Zotify itself.
+There are other ways to install and run Zotify on Windows but this is the official recommendation, other methods of installation will not receive support.
+
+- Open PowerShell (cmd will not work)
+- Install Scoop by running:
+ - `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser`
+ - `irm get.scoop.sh | iex`
+- After installing scoop run: `scoop install python ffmpeg-shared git`
+- Install pipx:
+ - `python3 -m pip install --user pipx`
+ - `python3 -m pipx ensurepath`
+Now close PowerShell and reopen it to ensure the pipx command is available.
+- Install Zotify with: `pipx install https://get.zotify.xyz`
+- Done! Use `zotify --help` for a basic list of commands or check the *README.md* file in Zotify's code repository for full documentation.
+
+> **macOS**
+- Open the Terminal app
+- Install *Homebrew* (https://brew.sh) by running: `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
+- After installing Homebrew run: `brew install python@3.11 pipx ffmpeg git`
+- Setup pipx: `pipx ensurepath`
+- Install Zotify: `pipx install https://get.zotify.xyz`
+- Done! Use `zotify --help` for a basic list of commands or check the README.md file in Zotify's code repository for full documentation.
+
+> **Linux (Most Popular Distributions)**
+- Install `python3`, `pip` (if a separate package), `ffmpeg`, and `git` from your distribution's package manager or software center.
+- Then install pipx, either from your package manager or through pip with: `python3 -m pip install --user pipx`
+- Install Zotify `pipx install https://get.zotify.xyz`
+- Done! Use `zotify --help` for a basic list of commands or check the README.md file in Zotify's code repository for full documentation.
\ No newline at end of file
From 2c8ed634c738a9ec2558d2572fd18467d9ec9be5 Mon Sep 17 00:00:00 2001
From: octoshrimpy
Date: Tue, 12 Sep 2023 19:30:08 +0200
Subject: [PATCH 083/169] alumbs -> albums
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 664d8b0..7e18f23 100644
--- a/README.md
+++ b/README.md
@@ -40,8 +40,8 @@ Basic command line usage:
zotify