From 53f6cf29467a9ae42079be950ba5b187004bfd10 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 04:53:43 +0000
Subject: [PATCH 001/169] Upload New File

---
 README.md | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 141 insertions(+)
 create mode 100644 README.md

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..42bc870
--- /dev/null
+++ b/README.md
@@ -0,0 +1,141 @@
+# Zotify
+
+### A music and podcast downloader needing only a python interpreter and ffmpeg.
+
+<p align="center">
+  <img src="https://i.imgur.com/hGXQWSl.png">
+</p>
+
+
+
+```
+Requirements:
+
+Binaries
+
+- Python 3.9 or greater
+- ffmpeg*
+- Git**
+
+Python packages:
+
+- pip install -r requirements.txt
+
+```
+
+\*ffmpeg can be installed via apt for Debian-based distros or by downloading the binaries from [ffmpeg.org](https://ffmpeg.org) and placing them in your %PATH% in Windows. Mac users can install it with [Homebrew](https://brew.sh) by running `brew install ffmpeg`.
+
+\*\*Git can be installed via apt for Debian-based distros or by downloading the binaries from [git-scm.com](https://git-scm.com/download/win) for Windows.
+
+### Command line usage:
+
+```
+Basic command line usage:
+  python zotify <track/album/playlist/episode/artist url>   Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls.
+
+Different usage modes:
+  (nothing)            Download the tracks/alumbs/playlists URLs from the parameter
+  -d,  --download      Download all tracks/alumbs/playlists URLs from the specified file
+  -p,  --playlist      Downloads a saved playlist from your account
+  -ls, --liked-songs   Downloads all the liked songs from your account
+  -s,  --search        Loads search prompt to find then download a specific track, album or playlist
+  
+Extra command line options:
+  -ns, --no-splash     Suppress the splash screen when loading.
+  --config-location    Use a different zs_config.json, defaults to the one in the program directory
+```
+
+### Options:
+
+All these options can either be configured in the zs_config or via the commandline, in case of both the commandline-option has higher priority.  
+Be aware you have to set boolean values in the commandline like this: `--download-real-time=True`
+
+| Key (zs-config)              | commandline parameter            | Description
+|------------------------------|----------------------------------|---------------------------------------------------------------------|
+| ROOT_PATH                    | --root-path                      | directory where Zotify saves the music
+| ROOT_PODCAST_PATH            | --root-podcast-path              | directory where Zotify saves the podcasts
+| SKIP_EXISTING_FILES          | --skip-existing-files            | Skip songs with the same name
+| SKIP_PREVIOUSLY_DOWNLOADED   | --skip-previously-downloaded     | Create a .song_archive file and 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              | split downloaded albums by disc
+| DOWNLOAD_REAL_TIME           | --download-real-time             | only downloads songs as fast as they would be played, can 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
+| 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
+
+### Output format:
+
+With the option `OUTPUT` (or the commandline parameter `--output`) you can specify the output location and format.  
+The value is relative to the `ROOT_PATH`/`ROOT_PODCAST_PATH` directory and can contain the following placeholder:
+
+| Placeholder     | Description
+|-----------------|--------------------------------
+| {artist}        | The song artist
+| {album}         | The song album
+| {song_name}     | The song name
+| {release_year}  | The song release year
+| {disc_number}   | The disc number
+| {track_number}  | The track_number
+| {id}            | The song id
+| {track_id}      | The track id
+| {ext}           | The file extension
+| {album_id}      | (only when downloading albums) ID of the album
+| {album_num}     | (only when downloading albums) Incrementing track number
+| {playlist}      | (only when downloading playlists) Name of the playlist 
+| {playlist_num}  | (only when downloading playlists) Incrementing track number
+
+Example values could be:
+~~~~
+{playlist}/{artist} - {song_name}.{ext}
+{playlist}/{playlist_num} - {artist} - {song_name}.{ext}
+Liked Songs/{artist} - {song_name}.{ext}
+{artist} - {song_name}.{ext}
+{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}
+/home/user/downloads/{artist} - {song_name} [{id}].{ext}
+~~~~
+
+### 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/zconfig.json:/zconfig.json" -v "$PWD/Zotify Music:/Zotify Music" -v "$PWD/Zotify Podcasts:/Zotify Podcasts" -it zotify
+```
+
+### Will my account get banned if I use this tool?
+
+Currently no user has reported their account getting banned after using Zotify.
+
+We highly recommend using 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 not appearing 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.
+
+### Contributing
+
+Please refer to [CONTRIBUTING](CONTRIBUTING.md)
+
+### Changelog
+
+Please refer to [CHANGELOG](CHANGELOG.md)
+
+### Common Errors
+
+Please refer to [COMMON_ERRORS](COMMON_ERRORS.md)

From 70b836a91fb4f6ec0e19490e62e151d911283b6b Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:21:10 +0000
Subject: [PATCH 002/169] Upload New File

---
 setup.py | 29 +++++++++++++++++++++++++++++
 1 file changed, 29 insertions(+)
 create mode 100644 setup.py

diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..3b730af
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,29 @@
+import pathlib
+from setuptools import setup
+import setuptools
+
+
+
+# The directory containing this file
+HERE = pathlib.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.5.3",
+    description="A music and podcast downloader.",
+    long_description=README,
+    long_description_content_type="text/markdown",
+    url="https://gitlab.com/zotify/zotify.git",
+    author="zotify",
+    classifiers=[
+        "Programming Language :: Python :: 3.9",
+    ],
+    packages=['zotify'],
+    install_requires=['ffmpy', 'music_tag', 'Pillow', 'protobuf', 'tabulate', 'tqdm',
+                      'librespot @ https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip'],
+    include_package_data=True,
+)

From 598809751d76fd8e94f21b5fd3d691f4f50b88b7 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:21:36 +0000
Subject: [PATCH 003/169] Upload New File

---
 requirements.txt | 7 +++++++
 1 file changed, 7 insertions(+)
 create mode 100644 requirements.txt

diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..dbb530a
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+ffmpy
+https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip
+music_tag
+Pillow
+protobuf
+tabulate
+tqdm

From 12d6cc46294913851af2b589fe220730239fa028 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:22:26 +0000
Subject: [PATCH 004/169] Upload New File

---
 LICENSE | 20 ++++++++++++++++++++
 1 file changed, 20 insertions(+)
 create mode 100644 LICENSE

diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3aab9fc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+Anonymous Licence v1.0
+
+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.
+
+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.
+
+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.

From aa05e7644142e659b8085c1c9ad14663e9ac24ed Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:25:14 +0000
Subject: [PATCH 005/169] Upload New File

---
 Dockerfile | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)
 create mode 100644 Dockerfile

diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..79ee2ba
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,18 @@
+FROM python:3.9-alpine as base
+
+RUN apk --update add git ffmpeg
+
+FROM base as builder
+RUN mkdir /install
+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
+
+
+FROM base
+
+COPY --from=builder /install /usr/local
+COPY zotify /app
+WORKDIR /app
+ENTRYPOINT ["/usr/local/bin/python", "__main__.py"]

From 00e705e877e6cad37bd99ad07c47dde6809bbbbf Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:25:40 +0000
Subject: [PATCH 006/169] Upload New File

---
 CONTRIBUTING.md | 41 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 41 insertions(+)
 create mode 100644 CONTRIBUTING.md

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..1a7b05f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,41 @@
+# Introduction
+
+### Thank you for contributing
+
+Without people like you this project wouldn't be anywhere near as polished and feature-rich as it is now.
+
+### Guidelines
+
+Following these guidelines helps show that you respect the the time and effort spent by the developers and your fellow contributors making this project.
+
+### What we are looking for
+
+Zotify is a community-driven project. There are many different ways to contribute. From providing tutorials and examples to help new users, reporting bugs, requesting new features, writing new code that can be added to the project, or even writing documentation.
+
+### What we aren't looking for
+
+Please don't use the issues section to request help installing or setting up the project. It should be reserved for bugs when running the code, and feature requests. Instead use the support channel in either our Discord or Matrix server.
+Please do not make a new pull request just to fix a typo or any small issue like that. We'd rather you just make an issue reporting it and we will fix it in the next commit. This helps to prevent commit spamming.
+
+# Ground rules
+
+### Expectations
+* Ensure all code is linted with pylint before pushing.
+* Ensure all code passes the [testing criteria](#testing-criteria) (coming soon).
+* If you're planning on contributing a new feature, join the Discord or Matrix and discuss it with the Dev Team.
+* Please don't commit multiple new features at once.
+* Follow the [Python Community Code of Conduct](https://www.python.org/psf/codeofconduct/) 
+
+# Your first contribution
+
+Unsure where to start? Have a look for any issues tagged "good first issue". They should be minor bugs that only require a few lines to fix. 
+Here are a couple of friendly tutorials on making pull requests: http://makeapullrequest.com/ and http://www.firsttimersonly.com/
+
+# Code review process
+
+The dev team looks at Pull Requests around once per day. After feedback has been given we expect responses within one week. After a week we may close the pull request if it isn't showing any activity.
+You may be asked by a maintainer to "rebase" your PR, they're saying that a lot of code has changed, and that you need to update your branch so it's easier to merge.
+
+# Community
+
+Come and chat with us on Discord or Matrix. Devs try to respond to mentions at least once per day.
\ No newline at end of file

From 9103b164addeb542152ed1b63527dfb5be25cd33 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:25:55 +0000
Subject: [PATCH 007/169] Upload New File

---
 COMMON_ERRORS.md | 9 +++++++++
 1 file changed, 9 insertions(+)
 create mode 100644 COMMON_ERRORS.md

diff --git a/COMMON_ERRORS.md b/COMMON_ERRORS.md
new file mode 100644
index 0000000..52bce29
--- /dev/null
+++ b/COMMON_ERRORS.md
@@ -0,0 +1,9 @@
+# Introduction
+
+Below will contain sets of errors that you might get running zotify. Below will also contain possible fixes to these errors. It is advisable that you read this before posting your error in any support channel.
+
+## AttributeError: module 'google.protobuf.descriptor' has no attribute '\_internal_create_key
+
+_Answer(s):_
+
+`pip install --upgrade protobuf`

From 20e5649d67a5bdbf2f62a6d052b07871ffff94bd Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:26:12 +0000
Subject: [PATCH 008/169] Upload New File

---
 CHANGELOG.md | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)
 create mode 100644 CHANGELOG.md

diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0acb2e9
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,26 @@
+# Changelog:
+### v0.5.2:
+**General changes:**
+- Fixed filenaming on Windows
+- Fixed removal of special characters metadata
+- Can now download different songs with the same name
+- Real-time downloads now work correctly
+- Removed some debug messages
+- Added album_artist metadata
+- Added global song archive
+- Added SONG_ARCHIVE config value
+- Added CREDENTIALS_LOCATION config value
+- Added `--download` argument
+- Added `--config-location` argument
+- Added `--output` for output templating
+- Save extra data in .song_ids
+- Added options to regulate terminal output
+- Direct download support for certain podcasts  
+  
+**Docker images:**
+- Remember credentials between container starts
+- Use same uid/gid in container as on host  
+  
+**Windows installer:**
+- Now comes with full installer
+- Dependencies are installed if not found

From 888b09da51daa2f0683175a321b020eea9e3dcbb Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:26:42 +0000
Subject: [PATCH 009/169] Add new directory

---
 zotify/.gitkeep | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 zotify/.gitkeep

diff --git a/zotify/.gitkeep b/zotify/.gitkeep
new file mode 100644
index 0000000..e69de29

From c17b3c8c85af985b00384b897bf322017277fb2f Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:26:53 +0000
Subject: [PATCH 010/169] Delete .gitkeep

---
 zotify/.gitkeep | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 zotify/.gitkeep

diff --git a/zotify/.gitkeep b/zotify/.gitkeep
deleted file mode 100644
index e69de29..0000000

From ac58315c75718e6a5ed2f2b580e5b57341f780c8 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:27:09 +0000
Subject: [PATCH 011/169] Add new directory

---
 zotify/.gitkeep | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 zotify/.gitkeep

diff --git a/zotify/.gitkeep b/zotify/.gitkeep
new file mode 100644
index 0000000..e69de29

From 5f9fb14ec6a4a5e3b67043932b0badc7de682a6a Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:27:27 +0000
Subject: [PATCH 012/169] Upload New File

---
 zotify/__main__.py | 53 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 53 insertions(+)
 create mode 100644 zotify/__main__.py

diff --git a/zotify/__main__.py b/zotify/__main__.py
new file mode 100644
index 0000000..091a395
--- /dev/null
+++ b/zotify/__main__.py
@@ -0,0 +1,53 @@
+#! /usr/bin/env python3
+
+"""
+Zotify
+It's like youtube-dl, but for that other music platform.
+"""
+
+import argparse
+
+from app import client
+from config import CONFIG_VALUES
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(prog='zotify',
+        description='A music and podcast downloader needing only a python interpreter and ffmpeg.')
+    parser.add_argument('-ns', '--no-splash',
+                        action='store_true',
+                        help='Suppress the splash screen when loading.')
+    parser.add_argument('--config-location',
+                        type=str,
+                        help='Specify the zconfig.json location')
+    group = parser.add_mutually_exclusive_group(required=True)
+    group.add_argument('urls',
+                       type=str,
+                       # action='extend',
+                       default='',
+                       nargs='*',
+                       help='Downloads the track, album, playlist, podcast episode, or all albums by an artist from a url. Can take multiple urls.')
+    group.add_argument('-ls', '--liked-songs',
+                       dest='liked_songs',
+                       action='store_true',
+                       help='Downloads all the liked songs from your account.')
+    group.add_argument('-p', '--playlist',
+                       action='store_true',
+                       help='Downloads a saved playlist from your account.')
+    group.add_argument('-s', '--search',
+                       dest='search_spotify',
+                       action='store_true',
+                       help='Loads search prompt to find then download a specific track, album or playlist')
+    group.add_argument('-d', '--download',
+                       type=str,
+                       help='Downloads tracks, playlists and albums from the URLs written in the file passed.')
+
+    for configkey in CONFIG_VALUES:
+        parser.add_argument(CONFIG_VALUES[configkey]['arg'],
+                            type=str,
+                            default=None,
+                            help='Specify the value of the ['+configkey+'] config value')
+
+    parser.set_defaults(func=client)
+
+    args = parser.parse_args()
+    args.func(args)

From d3788fdca805f4b163edb72d2cf54be8acbd0097 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:27:54 +0000
Subject: [PATCH 013/169] Upload New File

---
 zotify/album.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 58 insertions(+)
 create mode 100644 zotify/album.py

diff --git a/zotify/album.py b/zotify/album.py
new file mode 100644
index 0000000..8920af3
--- /dev/null
+++ b/zotify/album.py
@@ -0,0 +1,58 @@
+from const import ITEMS, ARTISTS, NAME, ID
+from termoutput import Printer
+from track import download_track
+from utils import fix_filename
+from zotify import Zotify
+
+ALBUM_URL = 'https://api.spotify.com/v1/albums'
+ARTIST_URL = 'https://api.spotify.com/v1/artists'
+
+
+def get_album_tracks(album_id):
+    """ Returns album tracklist """
+    songs = []
+    offset = 0
+    limit = 50
+
+    while True:
+        resp = Zotify.invoke_url_with_params(f'{ALBUM_URL}/{album_id}/tracks', limit=limit, offset=offset)
+        offset += limit
+        songs.extend(resp[ITEMS])
+        if len(resp[ITEMS]) < limit:
+            break
+
+    return songs
+
+
+def get_album_name(album_id):
+    """ Returns album name """
+    (raw, resp) = Zotify.invoke_url(f'{ALBUM_URL}/{album_id}')
+    return resp[ARTISTS][0][NAME], fix_filename(resp[NAME])
+
+
+def get_artist_albums(artist_id):
+    """ Returns artist's albums """
+    (raw, resp) = Zotify.invoke_url(f'{ARTIST_URL}/{artist_id}/albums?include_groups=album%2Csingle')
+    # Return a list each album's id
+    album_ids = [resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))]
+    # Recursive requests to get all albums including singles an EPs
+    while resp['next'] is not None:
+        (raw, resp) = Zotify.invoke_url(resp['next'])
+        album_ids.extend([resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))])
+
+    return album_ids
+
+
+def download_album(album):
+    """ Downloads songs from an album """
+    artist, album_name = get_album_name(album)
+    tracks = get_album_tracks(album)
+    for n, track in Printer.progress(enumerate(tracks, start=1), unit_scale=True, unit='Song', total=len(tracks)):
+        download_track('album', track[ID], extra_keys={'album_num': str(n).zfill(2), 'artist': artist, 'album': album_name, 'album_id': album}, disable_progressbar=True)
+
+
+def download_artist_albums(artist):
+    """ Downloads albums of an artist """
+    albums = get_artist_albums(artist)
+    for album_id in albums:
+        download_album(album_id)

From 65cc9498ed7db38b305250ff34acbfe9cbf1f23e Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:28:08 +0000
Subject: [PATCH 014/169] Upload New File

---
 zotify/app.py | 287 ++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 287 insertions(+)
 create mode 100644 zotify/app.py

diff --git a/zotify/app.py b/zotify/app.py
new file mode 100644
index 0000000..c30bceb
--- /dev/null
+++ b/zotify/app.py
@@ -0,0 +1,287 @@
+from librespot.audio.decoders import AudioQuality
+from tabulate import tabulate
+import os
+
+from album import download_album, download_artist_albums
+from const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \
+    OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME
+from playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist
+from podcast import download_episode, get_show_episodes
+from termoutput import Printer, PrintChannel
+from track import download_track, get_saved_tracks
+from utils import splash, split_input, regex_input_for_urls
+from zspotify import ZSpotify
+
+SEARCH_URL = 'https://api.spotify.com/v1/search'
+
+
+def client(args) -> None:
+    """ Connects to spotify to perform query's and get songs to download """
+    ZSpotify(args)
+
+    Printer.print(PrintChannel.SPLASH, splash())
+
+    if ZSpotify.check_premium():
+        Printer.print(PrintChannel.SPLASH, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n')
+        ZSpotify.DOWNLOAD_QUALITY = AudioQuality.VERY_HIGH
+    else:
+        Printer.print(PrintChannel.SPLASH, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n\n')
+        ZSpotify.DOWNLOAD_QUALITY = AudioQuality.HIGH
+
+    if args.download:
+        urls = []
+        filename = args.download
+        if os.path.exists(filename):
+            with open(filename, 'r', encoding='utf-8') as file:
+                urls.extend([line.strip() for line in file.readlines()])
+
+            download_from_urls(urls)
+
+        else:
+            Printer.print(PrintChannel.ERRORS, f'File {filename} not found.\n')
+
+    if args.urls:
+        download_from_urls(args.urls)
+
+    if args.playlist:
+        download_from_user_playlist()
+
+    if args.liked_songs:
+        for song in get_saved_tracks():
+            if not song[TRACK][NAME] or not song[TRACK][ID]:
+                Printer.print(PrintChannel.SKIPS, '###   SKIPPING:  SONG DOES NOT EXIST ON SPOTIFY ANYMORE   ###' + "\n")
+            else:
+                download_track('liked', song[TRACK][ID])
+
+    if args.search_spotify:
+        search_text = ''
+        while len(search_text) == 0:
+            search_text = input('Enter search or URL: ')
+
+        if not download_from_urls([search_text]):
+            search(search_text)
+
+def download_from_urls(urls: list[str]) -> bool:
+    """ Downloads from a list of spotify urls """
+    download = False
+
+    for spotify_url in urls:
+        track_id, album_id, playlist_id, episode_id, show_id, artist_id = regex_input_for_urls(
+            spotify_url)
+
+        if track_id is not None:
+            download = True
+            download_track('single', track_id)
+        elif artist_id is not None:
+            download = True
+            download_artist_albums(artist_id)
+        elif album_id is not None:
+            download = True
+            download_album(album_id)
+        elif playlist_id is not None:
+            download = True
+            playlist_songs = get_playlist_songs(playlist_id)
+            name, _ = get_playlist_info(playlist_id)
+            enum = 1
+            char_num = len(str(len(playlist_songs)))
+            for song in playlist_songs:
+                if not song[TRACK][NAME] or not song[TRACK][ID]:
+                    Printer.print(PrintChannel.SKIPS, '###   SKIPPING:  SONG DOES NOT EXIST ON SPOTIFY ANYMORE   ###' + "\n")
+                else:
+                    download_track('playlist', song[TRACK][ID], extra_keys=
+                    {
+                        'playlist_song_name': song[TRACK][NAME],
+                        'playlist': name,
+                        'playlist_num': str(enum).zfill(char_num),
+                        'playlist_id': playlist_id,
+                        'playlist_track_id': song[TRACK][ID]
+                    })
+                    enum += 1
+        elif episode_id is not None:
+            download = True
+            download_episode(episode_id)
+        elif show_id is not None:
+            download = True
+            for episode in get_show_episodes(show_id):
+                download_episode(episode)
+
+    return download
+
+
+def search(search_term):
+    """ Searches Spotify's API for relevant data """
+    params = {'limit': '10',
+              'offset': '0',
+              'q': search_term,
+              'type': 'track,album,artist,playlist'}
+
+    # Parse args
+    splits = search_term.split()
+    for split in splits:
+        index = splits.index(split)
+
+        if split[0] == '-' and len(split) > 1:
+            if len(splits)-1 == index:
+                raise IndexError('No parameters passed after option: {}\n'.
+                                 format(split))
+
+        if split == '-l' or split == '-limit':
+            try:
+                int(splits[index+1])
+            except ValueError:
+                raise ValueError('Paramater 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')
+            params['limit'] = splits[index+1]
+
+        if split == '-t' or split == '-type':
+
+            allowed_types = ['track', 'playlist', 'album', 'artist']
+            passed_types = []
+            for i in range(index+1, len(splits)):
+                if splits[i][0] == '-':
+                    break
+
+                if splits[i] not in allowed_types:
+                    raise ValueError('Parameters passed after {} option must be from this list:\n{}'.
+                                     format(split, '\n'.join(allowed_types)))
+
+                passed_types.append(splits[i])
+            params['type'] = ','.join(passed_types)
+
+    if len(params['type']) == 0:
+        params['type'] = 'track,album,artist,playlist'
+
+    # Clean search term
+    search_term_list = []
+    for split in splits:
+        if split[0] == "-":
+            break
+        search_term_list.append(split)
+    if not search_term_list:
+        raise ValueError("Invalid query.")
+    params["q"] = ' '.join(search_term_list)
+
+    resp = ZSpotify.invoke_url_with_params(SEARCH_URL, **params)
+
+    counter = 1
+    dics = []
+
+    total_tracks = 0
+    if TRACK in params['type'].split(','):
+        tracks = resp[TRACKS][ITEMS]
+        if len(tracks) > 0:
+            print('###  TRACKS  ###')
+            track_data = []
+            for track in tracks:
+                if track[EXPLICIT]:
+                    explicit = '[E]'
+                else:
+                    explicit = ''
+
+                track_data.append([counter, f'{track[NAME]} {explicit}',
+                                  ','.join([artist[NAME] for artist in track[ARTISTS]])])
+                dics.append({
+                    ID: track[ID],
+                    NAME: track[NAME],
+                    'type': TRACK,
+                })
+
+                counter += 1
+            total_tracks = counter - 1
+            print(tabulate(track_data, headers=[
+                  'S.NO', 'Name', 'Artists'], tablefmt='pretty'))
+            print('\n')
+            del tracks
+            del track_data
+
+    total_albums = 0
+    if ALBUM in params['type'].split(','):
+        albums = resp[ALBUMS][ITEMS]
+        if len(albums) > 0:
+            print('###  ALBUMS  ###')
+            album_data = []
+            for album in albums:
+                album_data.append([counter, album[NAME],
+                                  ','.join([artist[NAME] for artist in album[ARTISTS]])])
+                dics.append({
+                    ID: album[ID],
+                    NAME: album[NAME],
+                    'type': ALBUM,
+                })
+
+                counter += 1
+            total_albums = counter - total_tracks - 1
+            print(tabulate(album_data, headers=[
+                  'S.NO', 'Album', 'Artists'], tablefmt='pretty'))
+            print('\n')
+            del albums
+            del album_data
+
+    total_artists = 0
+    if ARTIST in params['type'].split(','):
+        artists = resp[ARTISTS][ITEMS]
+        if len(artists) > 0:
+            print('###  ARTISTS  ###')
+            artist_data = []
+            for artist in artists:
+                artist_data.append([counter, artist[NAME]])
+                dics.append({
+                    ID: artist[ID],
+                    NAME: artist[NAME],
+                    'type': ARTIST,
+                })
+                counter += 1
+            total_artists = counter - total_tracks - total_albums - 1
+            print(tabulate(artist_data, headers=[
+                  'S.NO', 'Name'], tablefmt='pretty'))
+            print('\n')
+            del artists
+            del artist_data
+
+    total_playlists = 0
+    if PLAYLIST in params['type'].split(','):
+        playlists = resp[PLAYLISTS][ITEMS]
+        if len(playlists) > 0:
+            print('###  PLAYLISTS  ###')
+            playlist_data = []
+            for playlist in playlists:
+                playlist_data.append(
+                    [counter, playlist[NAME], playlist[OWNER][DISPLAY_NAME]])
+                dics.append({
+                    ID: playlist[ID],
+                    NAME: playlist[NAME],
+                    'type': PLAYLIST,
+                })
+                counter += 1
+            total_playlists = counter - total_artists - total_tracks - total_albums - 1
+            print(tabulate(playlist_data, headers=[
+                  'S.NO', 'Name', 'Owner'], tablefmt='pretty'))
+            print('\n')
+            del playlists
+            del playlist_data
+
+    if total_tracks + total_albums + total_artists + total_playlists == 0:
+        print('NO RESULTS FOUND - EXITING...')
+    else:
+        selection = ''
+        print('> SELECT A DOWNLOAD OPTION BY ID')
+        print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s')
+        print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s\n')
+        while len(selection) == 0:
+            selection = str(input('ID(s): '))
+        inputs = split_input(selection)
+        for pos in inputs:
+            position = int(pos)
+            for dic in dics:
+                print_pos = dics.index(dic) + 1
+                if print_pos == position:
+                    if dic['type'] == TRACK:
+                        download_track('single', dic[ID])
+                    elif dic['type'] == ALBUM:
+                        download_album(dic[ID])
+                    elif dic['type'] == ARTIST:
+                        download_artist_albums(dic[ID])
+                    else:
+                        download_playlist(dic)

From f949030e70feb8a37a52c7d86145511aafed303e Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:28:25 +0000
Subject: [PATCH 015/169] Upload New File

---
 zotify/config.py | 251 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 251 insertions(+)
 create mode 100644 zotify/config.py

diff --git a/zotify/config.py b/zotify/config.py
new file mode 100644
index 0000000..adee713
--- /dev/null
+++ b/zotify/config.py
@@ -0,0 +1,251 @@
+import json
+import os
+from typing import Any
+
+CONFIG_FILE_PATH = '../zconfig.json'
+
+ROOT_PATH = 'ROOT_PATH'
+ROOT_PODCAST_PATH = 'ROOT_PODCAST_PATH'
+SKIP_EXISTING_FILES = 'SKIP_EXISTING_FILES'
+SKIP_PREVIOUSLY_DOWNLOADED = 'SKIP_PREVIOUSLY_DOWNLOADED'
+DOWNLOAD_FORMAT = 'DOWNLOAD_FORMAT'
+FORCE_PREMIUM = 'FORCE_PREMIUM'
+ANTI_BAN_WAIT_TIME = 'ANTI_BAN_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'
+SONG_ARCHIVE = 'SONG_ARCHIVE'
+CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION'
+OUTPUT = 'OUTPUT'
+PRINT_SPLASH = 'PRINT_SPLASH'
+PRINT_SKIPS = 'PRINT_SKIPS'
+PRINT_DOWNLOAD_PROGRESS = 'PRINT_DOWNLOAD_PROGRESS'
+PRINT_ERRORS = 'PRINT_ERRORS'
+PRINT_DOWNLOADS = 'PRINT_DOWNLOADS'
+PRINT_API_ERRORS = 'PRINT_API_ERRORS'
+TEMP_DOWNLOAD_DIR = 'TEMP_DOWNLOAD_DIR'
+MD_ALLGENRES = 'MD_ALLGENRES'
+MD_GENREDELIMITER = 'MD_GENREDELIMITER'
+PRINT_PROGRESS_INFO = 'PRINT_PROGRESS_INFO'
+PRINT_WARNINGS = 'PRINT_WARNINGS'
+RETRY_ATTEMPTS = 'RETRY_ATTEMPTS'
+
+CONFIG_VALUES = {
+    ROOT_PATH:                  { 'default': '../Zotify Music/',    'type': str,  'arg': '--root-path'                  },
+    ROOT_PODCAST_PATH:          { 'default': '../Zotify Podcasts/', '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'                    },
+    SONG_ARCHIVE:               { 'default': '.song_archive',       'type': str,  'arg': '--song-archive'               },
+    CREDENTIALS_LOCATION:       { 'default': 'credentials.json',    'type': str,  'arg': '--credentials-location'       },
+    OUTPUT:                     { 'default': '',                    'type': str,  'arg': '--output'                     },
+    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'    },
+    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_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}.{ext}'
+OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}'
+
+
+class Config:
+    Values = {}
+
+    @classmethod
+    def load(cls, args) -> None:
+        app_dir = os.path.dirname(__file__)
+
+        config_fp = CONFIG_FILE_PATH
+        if args.config_location:
+            config_fp = args.config_location
+
+        true_config_file_path = os.path.join(app_dir, config_fp)
+
+        # Load config from zconfig.json
+
+        if not os.path.exists(true_config_file_path):
+            with open(true_config_file_path, 'w', encoding='utf-8') as config_file:
+                json.dump(cls.get_default_json(), config_file, indent=4)
+            cls.Values = cls.get_default_json()
+        else:
+            with open(true_config_file_path, encoding='utf-8') as config_file:
+                jsonvalues = json.load(config_file)
+                cls.Values = {}
+                for key in CONFIG_VALUES:
+                    if key in jsonvalues:
+                        cls.Values[key] = cls.parse_arg_value(key, jsonvalues[key])
+
+        # Add default values for missing keys
+
+        for key in CONFIG_VALUES:
+            if key not in cls.Values:
+                cls.Values[key] = cls.parse_arg_value(key, CONFIG_VALUES[key]['default'])
+
+        # Override config from commandline arguments
+
+        for key in CONFIG_VALUES:
+            if key.lower() in vars(args) and vars(args)[key.lower()] is not None:
+                cls.Values[key] = cls.parse_arg_value(key, vars(args)[key.lower()])
+
+        if args.no_splash:
+            cls.Values[PRINT_SPLASH] = False
+
+    @classmethod
+    def get_default_json(cls) -> Any:
+        r = {}
+        for key in CONFIG_VALUES:
+            r[key] = CONFIG_VALUES[key]['default']
+        return r
+
+    @classmethod
+    def parse_arg_value(cls, key: str, value: Any) -> Any:
+        if type(value) == CONFIG_VALUES[key]['type']:
+            return value
+        if CONFIG_VALUES[key]['type'] == str:
+            return str(value)
+        if CONFIG_VALUES[key]['type'] == int:
+            return int(value)
+        if CONFIG_VALUES[key]['type'] == bool:
+            if str(value).lower() in ['yes', 'true', '1']:
+                return True
+            if str(value).lower() in ['no', 'false', '0']:
+                return False
+            raise ValueError("Not a boolean: " + value)
+        raise ValueError("Unknown Type: " + value)
+
+    @classmethod
+    def get(cls, key: str) -> Any:
+        return cls.Values.get(key)
+
+    @classmethod
+    def get_root_path(cls) -> str:
+        return os.path.join(os.path.dirname(__file__), cls.get(ROOT_PATH))
+
+    @classmethod
+    def get_root_podcast_path(cls) -> str:
+        return os.path.join(os.path.dirname(__file__), cls.get(ROOT_PODCAST_PATH))
+
+    @classmethod
+    def get_skip_existing_files(cls) -> bool:
+        return cls.get(SKIP_EXISTING_FILES)
+
+    @classmethod
+    def get_skip_previously_downloaded(cls) -> bool:
+        return cls.get(SKIP_PREVIOUSLY_DOWNLOADED)
+
+    @classmethod
+    def get_split_album_discs(cls) -> bool:
+        return cls.get(SPLIT_ALBUM_DISCS)
+
+    @classmethod
+    def get_chunk_size(cls) -> int:
+        return cls.get(CHUNK_SIZE)
+
+    @classmethod
+    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)
+
+    @classmethod
+    def get_language(cls) -> str:
+        return cls.get(LANGUAGE)
+
+    @classmethod
+    def get_download_real_time(cls) -> bool:
+        return cls.get(DOWNLOAD_REAL_TIME)
+
+    @classmethod
+    def get_bitrate(cls) -> str:
+        return cls.get(BITRATE)
+
+    @classmethod
+    def get_song_archive(cls) -> str:
+        return os.path.join(cls.get_root_path(), cls.get(SONG_ARCHIVE))
+
+    @classmethod
+    def get_credentials_location(cls) -> str:
+        return os.path.join(os.getcwd(), cls.get(CREDENTIALS_LOCATION))
+
+    @classmethod
+    def get_temp_download_dir(cls) -> str:
+        if cls.get(TEMP_DOWNLOAD_DIR) == '':
+            return ''
+        return os.path.join(cls.get_root_path(), cls.get(TEMP_DOWNLOAD_DIR))
+    
+    @classmethod
+    def get_all_genres(cls) -> bool:
+        return cls.get(MD_ALLGENRES)
+
+    @classmethod
+    def get_all_genres_delimiter(cls) -> bool:
+        return cls.get(MD_GENREDELIMITER)
+    
+    @classmethod
+    def get_output(cls, mode: str) -> str:
+        v = cls.get(OUTPUT)
+        if v:
+            return v
+        if mode == 'playlist':
+            if cls.get_split_album_discs():
+                split = os.path.split(OUTPUT_DEFAULT_PLAYLIST)
+                return os.path.join(split[0], 'Disc {disc_number}', split[0])
+            return OUTPUT_DEFAULT_PLAYLIST
+        if mode == 'extplaylist':
+            if cls.get_split_album_discs():
+                split = os.path.split(OUTPUT_DEFAULT_PLAYLIST_EXT)
+                return os.path.join(split[0], 'Disc {disc_number}', split[0])
+            return OUTPUT_DEFAULT_PLAYLIST_EXT
+        if mode == 'liked':
+            if cls.get_split_album_discs():
+                split = os.path.split(OUTPUT_DEFAULT_LIKED_SONGS)
+                return os.path.join(split[0], 'Disc {disc_number}', split[0])
+            return OUTPUT_DEFAULT_LIKED_SONGS
+        if mode == 'single':
+            if cls.get_split_album_discs():
+                split = os.path.split(OUTPUT_DEFAULT_SINGLE)
+                return os.path.join(split[0], 'Disc {disc_number}', split[0])
+            return OUTPUT_DEFAULT_SINGLE
+        if mode == 'album':
+            if cls.get_split_album_discs():
+                split = os.path.split(OUTPUT_DEFAULT_ALBUM)
+                return os.path.join(split[0], 'Disc {disc_number}', split[0])
+            return OUTPUT_DEFAULT_ALBUM
+        raise ValueError()
+
+    @classmethod
+    def get_retry_attempts(cls) -> int:
+        return cls.get(RETRY_ATTEMPTS)
\ No newline at end of file

From 6aec3148491953b71ed9e9aaacced69883e98027 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:28:41 +0000
Subject: [PATCH 016/169] Upload New File

---
 zotify/const.py | 107 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 107 insertions(+)
 create mode 100644 zotify/const.py

diff --git a/zotify/const.py b/zotify/const.py
new file mode 100644
index 0000000..59c7d43
--- /dev/null
+++ b/zotify/const.py
@@ -0,0 +1,107 @@
+SAVED_TRACKS_URL = 'https://api.spotify.com/v1/me/tracks'
+
+TRACKS_URL = 'https://api.spotify.com/v1/tracks'
+
+TRACK_STATS_URL = 'https://api.spotify.com/v1/audio-features/'
+
+TRACKNUMBER = 'tracknumber'
+
+DISCNUMBER = 'discnumber'
+
+YEAR = 'year'
+
+ALBUM = 'album'
+
+TRACKTITLE = 'tracktitle'
+
+ARTIST = 'artist'
+
+ARTISTS = 'artists'
+
+ALBUMARTIST = 'albumartist'
+
+GENRES = 'genres'
+
+GENRE = 'genre'
+
+ARTWORK = 'artwork'
+
+TRACKS = 'tracks'
+
+TRACK = 'track'
+
+ITEMS = 'items'
+
+NAME = 'name'
+
+HREF = 'href'
+
+ID = 'id'
+
+URL = 'url'
+
+RELEASE_DATE = 'release_date'
+
+IMAGES = 'images'
+
+LIMIT = 'limit'
+
+OFFSET = 'offset'
+
+AUTHORIZATION = 'Authorization'
+
+IS_PLAYABLE = 'is_playable'
+
+DURATION_MS = 'duration_ms'
+
+TRACK_NUMBER = 'track_number'
+
+DISC_NUMBER = 'disc_number'
+
+SHOW = 'show'
+
+ERROR = 'error'
+
+EXPLICIT = 'explicit'
+
+PLAYLIST = 'playlist'
+
+PLAYLISTS = 'playlists'
+
+OWNER = 'owner'
+
+DISPLAY_NAME = 'display_name'
+
+ALBUMS = 'albums'
+
+TYPE = 'type'
+
+PREMIUM = 'premium'
+
+USER_READ_EMAIL = 'user-read-email'
+
+PLAYLIST_READ_PRIVATE = 'playlist-read-private'
+
+USER_LIBRARY_READ = 'user-library-read'
+
+WINDOWS_SYSTEM = 'Windows'
+
+CODEC_MAP = {
+    'aac': 'aac',
+    'fdk_aac': 'libfdk_aac',
+    'm4a': 'aac',
+    'mp3': 'libmp3lame',
+    'ogg': 'copy',
+    'opus': 'libopus',
+    'vorbis': 'copy',
+}
+
+EXT_MAP = {
+    'aac': 'm4a',
+    'fdk_aac': 'm4a',
+    'm4a': 'm4a',
+    'mp3': 'mp3',
+    'ogg': 'ogg',
+    'opus': 'ogg',
+    'vorbis': 'ogg',
+}

From 17876e428b23115ae0aceef9400d989e13a8e82e Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:29:06 +0000
Subject: [PATCH 017/169] Upload New File

---
 zotify/loader.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 72 insertions(+)
 create mode 100644 zotify/loader.py

diff --git a/zotify/loader.py b/zotify/loader.py
new file mode 100644
index 0000000..faa3cb6
--- /dev/null
+++ b/zotify/loader.py
@@ -0,0 +1,72 @@
+# load symbol from:
+# https://stackoverflow.com/questions/22029562/python-how-to-make-simple-animated-loading-while-process-is-running
+
+# imports
+from itertools import cycle
+from shutil import get_terminal_size
+from threading import Thread
+from time import sleep
+
+from termoutput import Printer
+
+
+class Loader:
+    """Busy symbol.
+
+    Can be called inside a context:
+
+    with Loader("This take some Time..."):
+        # do something
+        pass
+    """
+    def __init__(self, chan, desc="Loading...", end='', timeout=0.1, mode='std1'):
+        """
+        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.channel = chan
+
+        self._thread = Thread(target=self._animate, daemon=True)
+        if mode == 'std1':
+            self.steps = ["⢿", "⣻", "⣽", "⣾", "⣷", "⣯", "⣟", "⡿"]
+        elif mode == 'std2':
+            self.steps = ["◜","◝","◞","◟"]
+        elif mode == 'std3':
+            self.steps = ["😐 ","😐 ","😮 ","😮 ","😦 ","😦 ","😧 ","😧 ","🤯 ","💥 ","✨ ","\u3000 ","\u3000 ","\u3000 "]
+        elif mode == 'prog':
+            self.steps = ["[∙∙∙]","[●∙∙]","[∙●∙]","[∙∙●]","[∙∙∙]"]
+
+        self.done = False
+
+    def start(self):
+        self._thread.start()
+        return self
+
+    def _animate(self):
+        for c in cycle(self.steps):
+            if self.done:
+                break
+            Printer.print_loader(self.channel, f"\r\t{c} {self.desc} ")
+            sleep(self.timeout)
+
+    def __enter__(self):
+        self.start()
+
+    def stop(self):
+        self.done = True
+        cols = get_terminal_size((80, 20)).columns
+        Printer.print_loader(self.channel, "\r" + " " * cols)
+
+        if self.end != "":
+            Printer.print_loader(self.channel, f"\r{self.end}")
+
+    def __exit__(self, exc_type, exc_value, tb):
+        # handle exceptions with those variables ^
+        self.stop()

From d075784d60124d22aebe9e54b29b5828065f5d86 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:29:22 +0000
Subject: [PATCH 018/169] Upload New File

---
 zotify/playlist.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 83 insertions(+)
 create mode 100644 zotify/playlist.py

diff --git a/zotify/playlist.py b/zotify/playlist.py
new file mode 100644
index 0000000..e00c745
--- /dev/null
+++ b/zotify/playlist.py
@@ -0,0 +1,83 @@
+from const import ITEMS, ID, TRACK, NAME
+from termoutput import Printer
+from track import download_track
+from utils import split_input
+from zotify import Zotify
+
+MY_PLAYLISTS_URL = 'https://api.spotify.com/v1/me/playlists'
+PLAYLISTS_URL = 'https://api.spotify.com/v1/playlists'
+
+
+def get_all_playlists():
+    """ Returns list of users playlists """
+    playlists = []
+    limit = 50
+    offset = 0
+
+    while True:
+        resp = Zotify.invoke_url_with_params(MY_PLAYLISTS_URL, limit=limit, offset=offset)
+        offset += limit
+        playlists.extend(resp[ITEMS])
+        if len(resp[ITEMS]) < limit:
+            break
+
+    return playlists
+
+
+def get_playlist_songs(playlist_id):
+    """ returns list of songs in a playlist """
+    songs = []
+    offset = 0
+    limit = 100
+
+    while True:
+        resp = Zotify.invoke_url_with_params(f'{PLAYLISTS_URL}/{playlist_id}/tracks', limit=limit, offset=offset)
+        offset += limit
+        songs.extend(resp[ITEMS])
+        if len(resp[ITEMS]) < limit:
+            break
+
+    return songs
+
+
+def get_playlist_info(playlist_id):
+    """ Returns information scraped from playlist """
+    (raw, resp) = Zotify.invoke_url(f'{PLAYLISTS_URL}/{playlist_id}?fields=name,owner(display_name)&market=from_token')
+    return resp['name'].strip(), resp['owner']['display_name'].strip()
+
+
+def download_playlist(playlist):
+    """Downloads all the songs from a playlist"""
+
+    playlist_songs = [song for song in get_playlist_songs(playlist[ID]) if song[TRACK][ID]]
+    p_bar = Printer.progress(playlist_songs, unit='song', total=len(playlist_songs), unit_scale=True)
+    enum = 1
+    for song in p_bar:
+        download_track('extplaylist', song[TRACK][ID], extra_keys={'playlist': playlist[NAME], 'playlist_num': str(enum).zfill(2)}, disable_progressbar=True)
+        p_bar.set_description(song[TRACK][NAME])
+        enum += 1
+
+
+def download_from_user_playlist():
+    """ Select which playlist(s) to download """
+    playlists = get_all_playlists()
+
+    count = 1
+    for playlist in playlists:
+        print(str(count) + ': ' + playlist[NAME].strip())
+        count += 1
+
+    selection = ''
+    print('\n> SELECT A PLAYLIST BY ID')
+    print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s')
+    print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s\n')
+    while len(selection) == 0:
+        selection = str(input('ID(s): '))
+    playlist_choices = map(int, split_input(selection))
+
+    for playlist_number in playlist_choices:
+        playlist = playlists[playlist_number - 1]
+        print(f'Downloading {playlist[NAME].strip()}')
+        download_playlist(playlist)
+
+    print('\n**All playlists have been downloaded**\n')

From a35808e0730cf866ea39d96c85a67dfb4de41095 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:29:41 +0000
Subject: [PATCH 019/169] Upload New File

---
 zotify/podcast.py | 134 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 134 insertions(+)
 create mode 100644 zotify/podcast.py

diff --git a/zotify/podcast.py b/zotify/podcast.py
new file mode 100644
index 0000000..1c5b493
--- /dev/null
+++ b/zotify/podcast.py
@@ -0,0 +1,134 @@
+import os
+import time
+from typing import Optional, Tuple
+
+from librespot.metadata import EpisodeId
+
+from const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS
+from termoutput import PrintChannel, Printer
+from utils import create_download_directory, fix_filename
+from zotify import Zotify
+from loader import Loader
+
+
+EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes'
+SHOWS_URL = 'https://api.spotify.com/v1/shows'
+
+
+def get_episode_info(episode_id_str) -> Tuple[Optional[str], Optional[str]]:
+    with Loader(PrintChannel.PROGRESS_INFO, "Fetching episode information..."):
+        (raw, info) = Zotify.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}')
+    if not info:
+        Printer.print(PrintChannel.ERRORS, "###   INVALID EPISODE ID   ###")
+    duration_ms = info[DURATION_MS]
+    if ERROR in info:
+        return None, None
+    return fix_filename(info[SHOW][NAME]), duration_ms,  fix_filename(info[NAME])
+
+
+def get_show_episodes(show_id_str) -> list:
+    episodes = []
+    offset = 0
+    limit = 50
+
+    with Loader(PrintChannel.PROGRESS_INFO, "Fetching episodes..."):
+        while True:
+            resp = Zotify.invoke_url_with_params(
+                f'{SHOWS_URL}/{show_id_str}/episodes', limit=limit, offset=offset)
+            offset += limit
+            for episode in resp[ITEMS]:
+                episodes.append(episode[ID])
+            if len(resp[ITEMS]) < limit:
+                break
+
+    return episodes
+
+
+def download_podcast_directly(url, filename):
+    import functools
+    import pathlib
+    import shutil
+    import requests
+    from tqdm.auto import tqdm
+
+    r = requests.get(url, stream=True, allow_redirects=True)
+    if r.status_code != 200:
+        r.raise_for_status()  # Will only raise for 4xx codes, so...
+        raise RuntimeError(
+            f"Request to {url} returned status code {r.status_code}")
+    file_size = int(r.headers.get('Content-Length', 0))
+
+    path = pathlib.Path(filename).expanduser().resolve()
+    path.parent.mkdir(parents=True, exist_ok=True)
+
+    desc = "(Unknown total file size)" if file_size == 0 else ""
+    r.raw.read = functools.partial(
+        r.raw.read, decode_content=True)  # Decompress if needed
+    with tqdm.wrapattr(r.raw, "read", total=file_size, desc=desc) as r_raw:
+        with path.open("wb") as f:
+            shutil.copyfileobj(r_raw, f)
+
+    return path
+
+
+def download_episode(episode_id) -> None:
+    podcast_name, duration_ms, episode_name = get_episode_info(episode_id)
+    extra_paths = podcast_name + '/'
+    prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Preparing download...")
+    prepare_download_loader.start()
+
+    if podcast_name is None:
+        Printer.print(PrintChannel.SKIPS, '###   SKIPPING: (EPISODE NOT FOUND)   ###')
+        prepare_download_loader.stop()
+    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"]
+
+        download_directory = os.path.join(Zotify.CONFIG.get_root_podcast_path(), extra_paths)
+        download_directory = os.path.realpath(download_directory)
+        create_download_directory(download_directory)
+
+        if "anon-podcast.scdn.co" in direct_download_url:
+            episode_id = EpisodeId.from_base62(episode_id)
+            stream = Zotify.get_content_stream(
+                episode_id, Zotify.DOWNLOAD_QUALITY)
+
+            total_size = stream.input_stream.size
+
+            filepath = os.path.join(download_directory, f"{filename}.ogg")
+            if (
+                os.path.isfile(filepath)
+                and os.path.getsize(filepath) == total_size
+                and Zotify.CONFIG.get_skip_existing_files()
+            ):
+                Printer.print(PrintChannel.SKIPS, "\n###   SKIPPING: " + podcast_name + " - " + episode_name + " (EPISODE ALREADY EXISTS)   ###")
+                prepare_download_loader.stop()
+                return
+
+            prepare_download_loader.stop()
+            time_start = time.time()
+            downloaded = 0
+            with open(filepath, 'wb') as file, Printer.progress(
+                desc=filename,
+                total=total_size,
+                unit='B',
+                unit_scale=True,
+                unit_divisor=1024
+            ) as p_bar:
+                prepare_download_loader.stop()
+                for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 1):
+                    data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size())
+                    p_bar.update(file.write(data))
+                    downloaded += len(data)
+                    if Zotify.CONFIG.get_download_real_time():
+                        delta_real = time.time() - time_start
+                        delta_want = (downloaded / total_size) * (duration_ms/1000)
+                        if delta_want > delta_real:
+                            time.sleep(delta_want - delta_real)
+        else:
+            filepath = os.path.join(download_directory, f"{filename}.mp3")
+            download_podcast_directly(direct_download_url, filepath)
+
+    prepare_download_loader.stop()

From 96258ab7c7ce92d8b25be04a7f6bb18138307668 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:30:01 +0000
Subject: [PATCH 020/169] Upload New File

---
 zotify/termoutput.py | 41 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 41 insertions(+)
 create mode 100644 zotify/termoutput.py

diff --git a/zotify/termoutput.py b/zotify/termoutput.py
new file mode 100644
index 0000000..7a7ad96
--- /dev/null
+++ b/zotify/termoutput.py
@@ -0,0 +1,41 @@
+import sys
+from enum import Enum
+from tqdm import tqdm
+
+from config import *
+from zotify import Zotify
+
+
+class PrintChannel(Enum):
+    SPLASH = PRINT_SPLASH
+    SKIPS = PRINT_SKIPS
+    DOWNLOAD_PROGRESS = PRINT_DOWNLOAD_PROGRESS
+    ERRORS = PRINT_ERRORS
+    WARNINGS = PRINT_WARNINGS
+    DOWNLOADS = PRINT_DOWNLOADS
+    API_ERRORS = PRINT_API_ERRORS
+    PROGRESS_INFO = PRINT_PROGRESS_INFO
+
+
+ERROR_CHANNEL = [PrintChannel.ERRORS, PrintChannel.API_ERRORS]
+
+
+class Printer:
+    @staticmethod
+    def print(channel: PrintChannel, msg: str) -> None:
+        if Zotify.CONFIG.get(channel.value):
+            if channel in ERROR_CHANNEL:
+                print(msg, file=sys.stderr)
+            else:
+                print(msg)
+
+    @staticmethod
+    def print_loader(channel: PrintChannel, msg: str) -> None:
+        if Zotify.CONFIG.get(channel.value):
+            print(msg, flush=True, end="")
+
+    @staticmethod
+    def progress(iterable=None, desc=None, total=None, unit='it', disable=False, unit_scale=False, unit_divisor=1000):
+        if not Zotify.CONFIG.get(PrintChannel.DOWNLOAD_PROGRESS.value):
+            disable = True
+        return tqdm(iterable=iterable, desc=desc, total=total, disable=disable, unit=unit, unit_scale=unit_scale, unit_divisor=unit_divisor)

From 0101c32a374dbd7b8eefd59cb8ef777a768c4b66 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:30:42 +0000
Subject: [PATCH 021/169] Upload New File

---
 zotify/track.py | 281 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 281 insertions(+)
 create mode 100644 zotify/track.py

diff --git a/zotify/track.py b/zotify/track.py
new file mode 100644
index 0000000..af4ef38
--- /dev/null
+++ b/zotify/track.py
@@ -0,0 +1,281 @@
+import os
+import re
+import time
+import uuid
+from typing import Any, Tuple, List
+
+from librespot.audio.decoders import AudioQuality
+from librespot.metadata import TrackId
+from ffmpy import FFmpeg
+
+from 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
+from termoutput import Printer, PrintChannel
+from 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
+from zotify import Zotify
+import traceback
+from loader import Loader
+
+
+def get_saved_tracks() -> list:
+    """ Returns user's saved tracks """
+    songs = []
+    offset = 0
+    limit = 50
+
+    while True:
+        resp = Zotify.invoke_url_with_params(
+            SAVED_TRACKS_URL, limit=limit, offset=offset)
+        offset += limit
+        songs.extend(resp[ITEMS])
+        if len(resp[ITEMS]) < limit:
+            break
+
+    return songs
+
+
+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..."):
+        (raw, info) = Zotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token')
+
+    if not TRACKS in info:
+        raise ValueError(f'Invalid response from TRACKS_URL:\n{raw}')
+
+    try:
+        artists = []
+        for data in info[TRACKS][0][ARTISTS]:
+            artists.append(data[NAME])
+
+        album_name = info[TRACKS][0][ALBUM][NAME]
+        name = info[TRACKS][0][NAME]
+        image_url = info[TRACKS][0][ALBUM][IMAGES][0][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]
+        scraped_song_id = info[TRACKS][0][ID]
+        is_playable = info[TRACKS][0][IS_PLAYABLE]
+        duration_ms = info[TRACKS][0][DURATION_MS]
+
+        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}')
+
+
+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 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}')
+
+
+def get_song_duration(song_id: str) -> float:
+    """ Retrieves duration of song in second as is on spotify """
+
+    (raw, resp) = Zotify.invoke_url(f'{TRACK_STATS_URL}{song_id}')
+
+    # get duration in miliseconds
+    ms_duration = resp['duration_ms']
+    # 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 """
+
+    if extra_keys is None:
+        extra_keys = {}
+
+    prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Preparing download...")
+    prepare_download_loader.start()
+
+    try:
+        output_template = Zotify.CONFIG.get_output(mode)
+
+        (artists, raw_artists, album_name, name, image_url, release_year, disc_number,
+         track_number, scraped_song_id, is_playable, duration_ms) = get_song_info(track_id)
+
+        song_name = fix_filename(artists[0]) + ' - ' + fix_filename(name)
+
+        for k in extra_keys:
+            output_template = output_template.replace("{"+k+"}", fix_filename(extra_keys[k]))
+
+        ext = EXT_MAP.get(Zotify.CONFIG.get_download_format().lower())
+
+        output_template = output_template.replace("{artist}", fix_filename(artists[0]))
+        output_template = output_template.replace("{album}", fix_filename(album_name))
+        output_template = output_template.replace("{song_name}", fix_filename(name))
+        output_template = output_template.replace("{release_year}", fix_filename(release_year))
+        output_template = output_template.replace("{disc_number}", fix_filename(disc_number))
+        output_template = output_template.replace("{track_number}", fix_filename(track_number))
+        output_template = output_template.replace("{id}", fix_filename(scraped_song_id))
+        output_template = output_template.replace("{track_id}", fix_filename(track_id))
+        output_template = output_template.replace("{ext}", ext)
+
+        filename = os.path.join(Zotify.CONFIG.get_root_path(), output_template)
+        filedir = os.path.dirname(filename)
+
+        filename_temp = filename
+        if Zotify.CONFIG.get_temp_download_dir() != '':
+            filename_temp = os.path.join(Zotify.CONFIG.get_temp_download_dir(), f'zotify_{str(uuid.uuid4())}_{track_id}.{ext}')
+
+        check_name = os.path.isfile(filename) and os.path.getsize(filename)
+        check_id = scraped_song_id in get_directory_song_ids(filedir)
+        check_all_time = scraped_song_id in get_previously_downloaded()
+
+        # a song with the same name is installed
+        if not check_id and check_name:
+            c = len([file for file in os.listdir(filedir) if re.search(f'^{filename}_', str(file))]) + 1
+
+            fname = os.path.splitext(os.path.basename(filename))[0]
+            ext = os.path.splitext(os.path.basename(filename))[1]
+
+            filename = os.path.join(filedir, f'{fname}_{c}{ext}')
+
+    except Exception as e:
+        Printer.print(PrintChannel.ERRORS, '###   SKIPPING SONG - FAILED TO QUERY METADATA   ###')
+        Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id))
+        for k in extra_keys:
+            Printer.print(PrintChannel.ERRORS, k + ': ' + str(extra_keys[k]))
+        Printer.print(PrintChannel.ERRORS, "\n")
+        Printer.print(PrintChannel.ERRORS, str(e) + "\n")
+        Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n")
+
+    else:
+        try:
+            if not is_playable:
+                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():
+                    prepare_download_loader.stop()
+                    Printer.print(PrintChannel.SKIPS, '\n###   SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS)   ###' + "\n")
+
+                elif check_all_time and Zotify.CONFIG.get_skip_previously_downloaded():
+                    prepare_download_loader.stop()
+                    Printer.print(PrintChannel.SKIPS, '\n###   SKIPPING: ' + song_name + ' (SONG ALREADY DOWNLOADED ONCE)   ###' + "\n")
+
+                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)
+                    create_download_directory(filedir)
+                    total_size = stream.input_stream.size
+
+                    prepare_download_loader.stop()
+
+                    time_start = time.time()
+                    downloaded = 0
+                    with open(filename_temp, 'wb') as file, Printer.progress(
+                            desc=song_name,
+                            total=total_size,
+                            unit='B',
+                            unit_scale=True,
+                            unit_divisor=1024,
+                            disable=disable_progressbar
+                    ) as p_bar:
+                        for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 1):
+                            data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size())
+                            p_bar.update(file.write(data))
+                            downloaded += len(data)
+                            if Zotify.CONFIG.get_download_real_time():
+                                delta_real = time.time() - time_start
+                                delta_want = (downloaded / total_size) * (duration_ms/1000)
+                                if delta_want > delta_real:
+                                    time.sleep(delta_want - delta_real)
+
+                    time_downloaded = time.time()
+
+                    genres = get_song_genres(raw_artists, name)
+
+                    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)
+
+                    if filename_temp != filename:
+                        os.rename(filename_temp, filename)
+
+                    time_finished = time.time()
+
+                    Printer.print(PrintChannel.DOWNLOADS, f'###   Downloaded "{song_name}" to "{os.path.relpath(filename, Zotify.CONFIG.get_root_path())}" in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting)   ###' + "\n")
+
+                    # add song id to archive file
+                    if Zotify.CONFIG.get_skip_previously_downloaded():
+                        add_to_archive(scraped_song_id, os.path.basename(filename), artists[0], name)
+                    # add song id to download directory's .song_ids file
+                    if not check_id:
+                        add_to_directory_song_ids(filedir, scraped_song_id, os.path.basename(filename), artists[0], name)
+
+                    if not Zotify.CONFIG.get_anti_ban_wait_time():
+                        time.sleep(Zotify.CONFIG.get_anti_ban_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))
+            for k in extra_keys:
+                Printer.print(PrintChannel.ERRORS, k + ': ' + str(extra_keys[k]))
+            Printer.print(PrintChannel.ERRORS, "\n")
+            Printer.print(PrintChannel.ERRORS, str(e) + "\n")
+            Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n")
+            if os.path.exists(filename_temp):
+                os.remove(filename_temp)
+
+    prepare_download_loader.stop()
+
+
+def convert_audio_format(filename) -> None:
+    """ Converts raw audio into playable file """
+    temp_filename = f'{os.path.splitext(filename)[0]}.tmp'
+    os.replace(filename, temp_filename)
+
+    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'
+    else:
+        bitrate = None
+
+    output_params = ['-c:a', file_codec]
+    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}
+    )
+
+    with Loader(PrintChannel.PROGRESS_INFO, "Converting file..."):
+        ff_m.run()
+
+    if os.path.exists(temp_filename):
+        os.remove(temp_filename)

From ea4486d8eb729ae67f477267f12732ae1ed3276f Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:30:54 +0000
Subject: [PATCH 022/169] Upload New File

---
 zotify/utils.py | 284 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 284 insertions(+)
 create mode 100644 zotify/utils.py

diff --git a/zotify/utils.py b/zotify/utils.py
new file mode 100644
index 0000000..31c6298
--- /dev/null
+++ b/zotify/utils.py
@@ -0,0 +1,284 @@
+import datetime
+import math
+import os
+import platform
+import re
+import subprocess
+from enum import Enum
+from typing import List, Tuple
+
+import music_tag
+import requests
+
+from const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \
+    WINDOWS_SYSTEM, ALBUMARTIST
+from zotify import Zotify
+
+
+class MusicFormat(str, Enum):
+    MP3 = 'mp3',
+    OGG = 'ogg',
+
+
+def create_download_directory(download_path: str) -> None:
+    """ Create directory and add a hidden file with song ids """
+    os.makedirs(download_path, exist_ok=True)
+
+    # add hidden file with song ids
+    hidden_file_path = os.path.join(download_path, '.song_ids')
+    if not os.path.isfile(hidden_file_path):
+        with open(hidden_file_path, 'w', encoding='utf-8') as f:
+            pass
+
+
+def get_previously_downloaded() -> List[str]:
+    """ Returns list of all time downloaded songs """
+
+    ids = []
+    archive_path = Zotify.CONFIG.get_song_archive()
+
+    if os.path.exists(archive_path):
+        with open(archive_path, 'r', encoding='utf-8') as f:
+            ids = [line.strip().split('\t')[0] for line in f.readlines()]
+
+    return ids
+
+
+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_song_archive()
+
+    if os.path.exists(archive_path):
+        with open(archive_path, 'a', encoding='utf-8') as file:
+            file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n')
+    else:
+        with open(archive_path, 'w', encoding='utf-8') as file:
+            file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n')
+
+
+def get_directory_song_ids(download_path: str) -> List[str]:
+    """ Gets song ids of songs in directory """
+
+    song_ids = []
+
+    hidden_file_path = os.path.join(download_path, '.song_ids')
+    if os.path.isfile(hidden_file_path):
+        with open(hidden_file_path, 'r', encoding='utf-8') as file:
+            song_ids.extend([line.strip().split('\t')[0] for line in file.readlines()])
+
+    return song_ids
+
+
+def add_to_directory_song_ids(download_path: str, song_id: str, filename: str, author_name: str, song_name: str) -> None:
+    """ Appends song_id to .song_ids file in directory """
+
+    hidden_file_path = os.path.join(download_path, '.song_ids')
+    # not checking if file exists because we need an exception
+    # to be raised if something is wrong
+    with open(hidden_file_path, 'a', encoding='utf-8') as file:
+        file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n')
+
+
+def get_downloaded_song_duration(filename: str) -> float:
+    """ Returns the downloaded file's duration in seconds """
+
+    command = ['ffprobe', '-show_entries', 'format=duration', '-i', f'{filename}']
+    output = subprocess.run(command, capture_output=True)
+
+    duration = re.search(r'[\D]=([\d\.]*)', str(output.stdout)).groups()[0]
+    duration = float(duration)
+
+    return duration
+
+
+def split_input(selection) -> List[str]:
+    """ Returns a list of inputted strings """
+    inputs = []
+    if '-' in selection:
+        for number in range(int(selection.split('-')[0]), int(selection.split('-')[1]) + 1):
+            inputs.append(number)
+    else:
+        selections = selection.split(',')
+        for i in selections:
+            inputs.append(i.strip())
+    return inputs
+
+
+def splash() -> str:
+    """ Displays splash screen """
+    return """
+███████  ██████  ████████ ██ ███████ ██    ██
+   ███  ██    ██    ██    ██ ██       ██  ██
+  ███   ██    ██    ██    ██ █████     ████
+ ███    ██    ██    ██    ██ ██         ██
+███████  ██████     ██    ██ ██         ██
+    """
+
+
+def clear() -> None:
+    """ Clear the console window """
+    if platform.system() == WINDOWS_SYSTEM:
+        os.system('cls')
+    else:
+        os.system('clear')
+
+
+def set_audio_tags(filename, artists, genres, name, album_name, release_year, disc_number, track_number) -> None:
+    """ sets music_tag metadata """
+    tags = music_tag.load_file(filename)
+    tags[ALBUMARTIST] = artists[0]
+    tags[ARTIST] = conv_artist_format(artists)
+    tags[GENRE] = genres[0] if not Zotify.CONFIG.get_all_genres() else Zotify.CONFIG.get_all_genres_delimiter().join(genres)
+    tags[TRACKTITLE] = name
+    tags[ALBUM] = album_name
+    tags[YEAR] = release_year
+    tags[DISCNUMBER] = disc_number
+    tags[TRACKNUMBER] = track_number
+    tags.save()
+
+
+def conv_artist_format(artists) -> str:
+    """ Returns converted artist format """
+    return ', '.join(artists)
+
+
+def set_music_thumbnail(filename, image_url) -> None:
+    """ Downloads cover artwork """
+    img = requests.get(image_url).content
+    tags = music_tag.load_file(filename)
+    tags[ARTWORK] = img
+    tags.save()
+
+
+def regex_input_for_urls(search_input) -> Tuple[str, str, str, str, str, str]:
+    """ Since many kinds of search may be passed at the command line, process them all here. """
+    track_uri_search = re.search(
+        r'^spotify:track:(?P<TrackID>[0-9a-zA-Z]{22})$', search_input)
+    track_url_search = re.search(
+        r'^(https?://)?open\.spotify\.com/track/(?P<TrackID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
+        search_input,
+    )
+
+    album_uri_search = re.search(
+        r'^spotify:album:(?P<AlbumID>[0-9a-zA-Z]{22})$', search_input)
+    album_url_search = re.search(
+        r'^(https?://)?open\.spotify\.com/album/(?P<AlbumID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
+        search_input,
+    )
+
+    playlist_uri_search = re.search(
+        r'^spotify:playlist:(?P<PlaylistID>[0-9a-zA-Z]{22})$', search_input)
+    playlist_url_search = re.search(
+        r'^(https?://)?open\.spotify\.com/playlist/(?P<PlaylistID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
+        search_input,
+    )
+
+    episode_uri_search = re.search(
+        r'^spotify:episode:(?P<EpisodeID>[0-9a-zA-Z]{22})$', search_input)
+    episode_url_search = re.search(
+        r'^(https?://)?open\.spotify\.com/episode/(?P<EpisodeID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
+        search_input,
+    )
+
+    show_uri_search = re.search(
+        r'^spotify:show:(?P<ShowID>[0-9a-zA-Z]{22})$', search_input)
+    show_url_search = re.search(
+        r'^(https?://)?open\.spotify\.com/show/(?P<ShowID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
+        search_input,
+    )
+
+    artist_uri_search = re.search(
+        r'^spotify:artist:(?P<ArtistID>[0-9a-zA-Z]{22})$', search_input)
+    artist_url_search = re.search(
+        r'^(https?://)?open\.spotify\.com/artist/(?P<ArtistID>[0-9a-zA-Z]{22})(\?si=.+?)?$',
+        search_input,
+    )
+
+    if track_uri_search is not None or track_url_search is not None:
+        track_id_str = (track_uri_search
+                        if track_uri_search is not None else
+                        track_url_search).group('TrackID')
+    else:
+        track_id_str = None
+
+    if album_uri_search is not None or album_url_search is not None:
+        album_id_str = (album_uri_search
+                        if album_uri_search is not None else
+                        album_url_search).group('AlbumID')
+    else:
+        album_id_str = None
+
+    if playlist_uri_search is not None or playlist_url_search is not None:
+        playlist_id_str = (playlist_uri_search
+                           if playlist_uri_search is not None else
+                           playlist_url_search).group('PlaylistID')
+    else:
+        playlist_id_str = None
+
+    if episode_uri_search is not None or episode_url_search is not None:
+        episode_id_str = (episode_uri_search
+                          if episode_uri_search is not None else
+                          episode_url_search).group('EpisodeID')
+    else:
+        episode_id_str = None
+
+    if show_uri_search is not None or show_url_search is not None:
+        show_id_str = (show_uri_search
+                       if show_uri_search is not None else
+                       show_url_search).group('ShowID')
+    else:
+        show_id_str = None
+
+    if artist_uri_search is not None or artist_url_search is not None:
+        artist_id_str = (artist_uri_search
+                         if artist_uri_search is not None else
+                         artist_url_search).group('ArtistID')
+    else:
+        artist_id_str = None
+
+    return track_id_str, album_id_str, playlist_id_str, episode_id_str, show_id_str, artist_id_str
+
+
+def fix_filename(name):
+    """
+    Replace invalid characters on Linux/Windows/MacOS with underscores.
+    List from https://stackoverflow.com/a/31976060/819417
+    Trailing spaces & periods are ignored on Windows.
+    >>> fix_filename("  COM1  ")
+    '_ COM1 _'
+    >>> fix_filename("COM10")
+    'COM10'
+    >>> fix_filename("COM1,")
+    'COM1,'
+    >>> fix_filename("COM1.txt")
+    '_.txt'
+    >>> all('_' == fix_filename(chr(i)) for i in list(range(32)))
+    True
+    """
+    return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE)
+
+
+def fmt_seconds(secs: float) -> str:
+    val = math.floor(secs)
+
+    s = math.floor(val % 60)
+    val -= s
+    val /= 60
+
+    m = math.floor(val % 60)
+    val -= m
+    val /= 60
+
+    h = math.floor(val)
+
+    if h == 0 and m == 0 and s == 0:
+        return "0s"
+    elif h == 0 and m == 0:
+        return f'{s}s'.zfill(2)
+    elif h == 0:
+        return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)
+    else:
+        return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)
+
+

From 7b9ba16c8ecf5a898ed2a41be31e0b2085650204 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:31:10 +0000
Subject: [PATCH 023/169] Upload New File

---
 zotify/zotify.py | 97 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 97 insertions(+)
 create mode 100644 zotify/zotify.py

diff --git a/zotify/zotify.py b/zotify/zotify.py
new file mode 100644
index 0000000..f0b3329
--- /dev/null
+++ b/zotify/zotify.py
@@ -0,0 +1,97 @@
+import os
+import os.path
+from getpass import getpass
+import time
+import requests
+from librespot.audio.decoders import VorbisOnlyAudioQuality
+from librespot.core import Session
+
+from const import TYPE, \
+    PREMIUM, USER_READ_EMAIL, OFFSET, LIMIT, \
+    PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ
+from config import Config
+
+class Zotify:    
+    SESSION: Session = None
+    DOWNLOAD_QUALITY = None
+    CONFIG: Config = Config()
+
+    def __init__(self, args):
+        Zotify.CONFIG.load(args)
+        Zotify.login()
+
+    @classmethod
+    def login(cls):
+        """ Authenticates with Spotify and saves credentials to a file """
+
+        cred_location = Config.get_credentials_location()
+
+        if os.path.isfile(cred_location):
+            try:
+                cls.SESSION = Session.Builder().stored_file(cred_location).create()
+                return
+            except RuntimeError:
+                pass
+        while True:
+            user_name = ''
+            while len(user_name) == 0:
+                user_name = input('Username: ')
+            password = getpass()
+            try:
+                conf = Session.Configuration.Builder().set_stored_credential_file(cred_location).build()
+                cls.SESSION = Session.Builder(conf).user_pass(user_name, password).create()
+                return
+            except RuntimeError:
+                pass
+
+    @classmethod
+    def get_content_stream(cls, content_id, quality):
+        return cls.SESSION.content_feeder().load(content_id, VorbisOnlyAudioQuality(quality), False, None)
+
+    @classmethod
+    def __get_auth_token(cls):
+        return cls.SESSION.tokens().get_token(USER_READ_EMAIL, PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ).access_token
+
+    @classmethod
+    def get_auth_header(cls):
+        return {
+            'Authorization': f'Bearer {cls.__get_auth_token()}',
+            'Accept-Language': f'{cls.CONFIG.get_language()}'
+        }
+
+    @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()}'
+        }, {LIMIT: limit, OFFSET: offset}
+
+    @classmethod
+    def invoke_url_with_params(cls, url, limit, offset, **kwargs):
+        headers, params = cls.get_auth_header_and_params(limit=limit, offset=offset)
+        params.update(kwargs)
+        return requests.get(url, headers=headers, params=params).json()
+
+    @classmethod
+    def invoke_url(cls, url, tryCount=0):
+        # we need to import that here, otherwise we will get circular imports!
+        from termoutput import Printer, PrintChannel
+        headers = cls.get_auth_header()
+        response = requests.get(url, headers=headers)
+        responsetext = response.text
+        responsejson = response.json()
+
+        if '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)
+                return cls.invoke_url(url, tryCount + 1)
+
+            Printer.print(PrintChannel.API_ERRORS, f"Spotify API Error ({responsejson['error']['status']}): {responsejson['error']['message']}")
+
+        return responsetext, responsejson
+
+    @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()

From bed7057f9f710281feee12fd5a910b7fdb672c49 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:32:06 +0000
Subject: [PATCH 024/169] Delete .gitkeep

---
 zotify/.gitkeep | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 zotify/.gitkeep

diff --git a/zotify/.gitkeep b/zotify/.gitkeep
deleted file mode 100644
index e69de29..0000000

From f6f70020e543eb9f7727b836816001aeb58a74b9 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 26 Dec 2021 05:36:08 +0000
Subject: [PATCH 025/169] Update README.md

---
 README.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index 42bc870..5c78eda 100644
--- a/README.md
+++ b/README.md
@@ -42,15 +42,15 @@ Different usage modes:
   
 Extra command line options:
   -ns, --no-splash     Suppress the splash screen when loading.
-  --config-location    Use a different zs_config.json, defaults to the one in the program directory
+  --config-location    Use a different zconfig.json, defaults to the one in the program directory
 ```
 
 ### Options:
 
-All these options can either be configured in the zs_config or via the commandline, in case of both the commandline-option has higher priority.  
+All these options can either be configured in the zconfig or via the commandline, in case of both the commandline-option has higher priority.  
 Be aware you have to set boolean values in the commandline like this: `--download-real-time=True`
 
-| Key (zs-config)              | commandline parameter            | Description
+| Key (zconfig)                | commandline parameter            | Description
 |------------------------------|----------------------------------|---------------------------------------------------------------------|
 | ROOT_PATH                    | --root-path                      | directory where Zotify saves the music
 | ROOT_PODCAST_PATH            | --root-podcast-path              | directory where Zotify saves the podcasts

From 06bca0dbcb66bcceedb36c6a8676b16e86986267 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Mon, 27 Dec 2021 00:38:29 +0000
Subject: [PATCH 026/169] Add new file

---
 .gitignore | 156 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 156 insertions(+)
 create mode 100644 .gitignore

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9c6d2d3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,156 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+src/__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
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__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/
+
+# MacOS file
+.DS_Store
+
+# IDE settings
+.vscode/
+.idea/
+
+# Credentials
+credentials.json
+
+# Config file
+zconfig.json
+
+#Download Folder
+Zotify\ Music/
+Zotify\ Podcasts/

From 1f348ee82c7ee90c2ea5bd5e8c437f06db6194ee Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Mon, 27 Dec 2021 08:42:30 +0000
Subject: [PATCH 027/169] Update README.md

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 5c78eda..11bfd6d 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
   <img src="https://i.imgur.com/hGXQWSl.png">
 </p>
 
-
+[Discord Server](https://discord.gg/Vwr4R2UUvD) - [Matrix Server](https://matrix.to/#/#zotify-community:matrix.org) - [NotABug Mirror](https://notabug.org/Zotify/zotify)
 
 ```
 Requirements:

From 2aef2f31715de640219c71503f12648edc216782 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Mon, 27 Dec 2021 23:15:23 +0000
Subject: [PATCH 028/169] Update zotify/app.py

---
 zotify/app.py | 22 +++++++++++-----------
 1 file changed, 11 insertions(+), 11 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index c30bceb..fca6d42 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -10,23 +10,23 @@ from podcast import download_episode, get_show_episodes
 from termoutput import Printer, PrintChannel
 from track import download_track, get_saved_tracks
 from utils import splash, split_input, regex_input_for_urls
-from zspotify import ZSpotify
+from zotify import Zotify
 
 SEARCH_URL = 'https://api.spotify.com/v1/search'
 
 
 def client(args) -> None:
-    """ Connects to spotify to perform query's and get songs to download """
-    ZSpotify(args)
+    """ Connects to download server to perform query's and get songs to download """
+    Zotify(args)
 
     Printer.print(PrintChannel.SPLASH, splash())
 
-    if ZSpotify.check_premium():
+    if Zotify.check_premium():
         Printer.print(PrintChannel.SPLASH, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n')
-        ZSpotify.DOWNLOAD_QUALITY = AudioQuality.VERY_HIGH
+        Zotify.DOWNLOAD_QUALITY = AudioQuality.VERY_HIGH
     else:
         Printer.print(PrintChannel.SPLASH, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n\n')
-        ZSpotify.DOWNLOAD_QUALITY = AudioQuality.HIGH
+        Zotify.DOWNLOAD_QUALITY = AudioQuality.HIGH
 
     if args.download:
         urls = []
@@ -49,7 +49,7 @@ def client(args) -> None:
     if args.liked_songs:
         for song in get_saved_tracks():
             if not song[TRACK][NAME] or not song[TRACK][ID]:
-                Printer.print(PrintChannel.SKIPS, '###   SKIPPING:  SONG DOES NOT EXIST ON SPOTIFY ANYMORE   ###' + "\n")
+                Printer.print(PrintChannel.SKIPS, '###   SKIPPING:  SONG DOES NOT EXIST ANYMORE   ###' + "\n")
             else:
                 download_track('liked', song[TRACK][ID])
 
@@ -62,7 +62,7 @@ def client(args) -> None:
             search(search_text)
 
 def download_from_urls(urls: list[str]) -> bool:
-    """ Downloads from a list of spotify urls """
+    """ Downloads from a list of urls """
     download = False
 
     for spotify_url in urls:
@@ -86,7 +86,7 @@ def download_from_urls(urls: list[str]) -> bool:
             char_num = len(str(len(playlist_songs)))
             for song in playlist_songs:
                 if not song[TRACK][NAME] or not song[TRACK][ID]:
-                    Printer.print(PrintChannel.SKIPS, '###   SKIPPING:  SONG DOES NOT EXIST ON SPOTIFY ANYMORE   ###' + "\n")
+                    Printer.print(PrintChannel.SKIPS, '###   SKIPPING:  SONG DOES NOT EXIST ANYMORE   ###' + "\n")
                 else:
                     download_track('playlist', song[TRACK][ID], extra_keys=
                     {
@@ -109,7 +109,7 @@ def download_from_urls(urls: list[str]) -> bool:
 
 
 def search(search_term):
-    """ Searches Spotify's API for relevant data """
+    """ Searches download server's API for relevant data """
     params = {'limit': '10',
               'offset': '0',
               'q': search_term,
@@ -163,7 +163,7 @@ def search(search_term):
         raise ValueError("Invalid query.")
     params["q"] = ' '.join(search_term_list)
 
-    resp = ZSpotify.invoke_url_with_params(SEARCH_URL, **params)
+    resp = Zotify.invoke_url_with_params(SEARCH_URL, **params)
 
     counter = 1
     dics = []

From 1eef9756fdb16bea3e4c440a85b545a023fd89b2 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Tue, 28 Dec 2021 00:36:47 +0000
Subject: [PATCH 029/169] Update README.md

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 11bfd6d..81e8122 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
   <img src="https://i.imgur.com/hGXQWSl.png">
 </p>
 
-[Discord Server](https://discord.gg/Vwr4R2UUvD) - [Matrix Server](https://matrix.to/#/#zotify-community:matrix.org) - [NotABug Mirror](https://notabug.org/Zotify/zotify)
+[Discord Server](https://discord.gg/XDYsFRTUjE) - [NotABug Mirror](https://notabug.org/Zotify/zotify)
 
 ```
 Requirements:

From 6dd0ad968b464d38a3d4a70ffa073d8700330065 Mon Sep 17 00:00:00 2001
From: Zotify <10505468-zotify@users.noreply.gitlab.com>
Date: Sun, 16 Jan 2022 02:08:22 +0000
Subject: [PATCH 030/169] Check for podcast episode in playlists

---
 zotify/app.py | 21 ++++++++++++---------
 1 file changed, 12 insertions(+), 9 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index fca6d42..9c56cc9 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -4,7 +4,7 @@ import os
 
 from album import download_album, download_artist_albums
 from const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \
-    OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME
+    OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME, TYPE
 from playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist
 from podcast import download_episode, get_show_episodes
 from termoutput import Printer, PrintChannel
@@ -88,14 +88,17 @@ def download_from_urls(urls: list[str]) -> bool:
                 if not song[TRACK][NAME] or not song[TRACK][ID]:
                     Printer.print(PrintChannel.SKIPS, '###   SKIPPING:  SONG DOES NOT EXIST ANYMORE   ###' + "\n")
                 else:
-                    download_track('playlist', song[TRACK][ID], extra_keys=
-                    {
-                        'playlist_song_name': song[TRACK][NAME],
-                        'playlist': name,
-                        'playlist_num': str(enum).zfill(char_num),
-                        'playlist_id': playlist_id,
-                        'playlist_track_id': song[TRACK][ID]
-                    })
+                    if song[TRACK][TYPE] == "episode": # Playlist item is a podcast episode
+                        download_episode(song[TRACK][ID])
+                    else:
+                        download_track('playlist', song[TRACK][ID], extra_keys=
+                        {
+                            'playlist_song_name': song[TRACK][NAME],
+                            'playlist': name,
+                            'playlist_num': str(enum).zfill(char_num),
+                            'playlist_id': playlist_id,
+                            'playlist_track_id': song[TRACK][ID]
+                        })
                     enum += 1
         elif episode_id is not None:
             download = True

From 70da4264633d0e24098d5584f290a089c0e7691b Mon Sep 17 00:00:00 2001
From: logykk <joshbelcher00@gmail.com>
Date: Wed, 2 Feb 2022 21:56:57 +1300
Subject: [PATCH 031/169] todo: add global config support

---
 CHANGELOG.md         |  25 ++++++++--
 COMMON_ERRORS.md     |   9 ----
 setup.py             |  22 +++++----
 zotify/__init__.py   |   0
 zotify/__main__.py   |   9 ++--
 zotify/album.py      |  10 ++--
 zotify/app.py        |  42 +++++++++--------
 zotify/config.py     | 108 ++++++++++++++++++++++++-------------------
 zotify/loader.py     |   2 +-
 zotify/playlist.py   |  10 ++--
 zotify/podcast.py    |  30 ++++++------
 zotify/termoutput.py |   4 +-
 zotify/track.py      |  52 +++++++++++----------
 zotify/utils.py      |  22 +++++----
 zotify/zotify.py     |   9 ++--
 15 files changed, 197 insertions(+), 157 deletions(-)
 delete mode 100644 COMMON_ERRORS.md
 create mode 100644 zotify/__init__.py

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0acb2e9..d2b6055 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,21 @@
-# Changelog:
-### v0.5.2:
-**General changes:**
+# Changelog
+## v0.6
+**General changes**
+- Switched from os.path to pathlib
+- Zotify can now be installed with pip \
+`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.
+
+**Docker**
+- Dockerfile is currently broken, it will be fixed soon. \
+The Dockerhub image is now discontinued, we will try to switch to GitLab's container registry.
+
+**Windows installer**
+- The Windows installer is unavilable with this release.
+- The current installation system will be replaced and a new version will be available with the next release.
+
+## v0.5.2
+**General changes**
 - Fixed filenaming on Windows
 - Fixed removal of special characters metadata
 - Can now download different songs with the same name
@@ -17,10 +32,10 @@
 - Added options to regulate terminal output
 - Direct download support for certain podcasts  
   
-**Docker images:**
+**Docker images**
 - Remember credentials between container starts
 - Use same uid/gid in container as on host  
   
-**Windows installer:**
+**Windows installer**
 - Now comes with full installer
 - Dependencies are installed if not found
diff --git a/COMMON_ERRORS.md b/COMMON_ERRORS.md
deleted file mode 100644
index 52bce29..0000000
--- a/COMMON_ERRORS.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Introduction
-
-Below will contain sets of errors that you might get running zotify. Below will also contain possible fixes to these errors. It is advisable that you read this before posting your error in any support channel.
-
-## AttributeError: module 'google.protobuf.descriptor' has no attribute '\_internal_create_key
-
-_Answer(s):_
-
-`pip install --upgrade protobuf`
diff --git a/setup.py b/setup.py
index 3b730af..e3abc2e 100644
--- a/setup.py
+++ b/setup.py
@@ -1,7 +1,6 @@
 import pathlib
-from setuptools import setup
-import setuptools
-
+from distutils.core import setup
+from setuptools import setup, find_packages
 
 
 # The directory containing this file
@@ -13,17 +12,24 @@ README = (HERE / "README.md").read_text()
 # This call to setup() does all the work
 setup(
     name="zotify",
-    version="0.5.3",
+    version="0.6.0",
+    author="Zotify",
     description="A music and podcast downloader.",
     long_description=README,
     long_description_content_type="text/markdown",
-    url="https://gitlab.com/zotify/zotify.git",
-    author="zotify",
+    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",
     ],
-    packages=['zotify'],
     install_requires=['ffmpy', 'music_tag', 'Pillow', 'protobuf', 'tabulate', 'tqdm',
                       'librespot @ https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip'],
-    include_package_data=True,
 )
diff --git a/zotify/__init__.py b/zotify/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 091a395..de8e0c8 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -7,10 +7,10 @@ It's like youtube-dl, but for that other music platform.
 
 import argparse
 
-from app import client
-from config import CONFIG_VALUES
+from zotify.app import client
+from zotify.config import CONFIG_VALUES
 
-if __name__ == '__main__':
+def main():
     parser = argparse.ArgumentParser(prog='zotify',
         description='A music and podcast downloader needing only a python interpreter and ffmpeg.')
     parser.add_argument('-ns', '--no-splash',
@@ -51,3 +51,6 @@ if __name__ == '__main__':
 
     args = parser.parse_args()
     args.func(args)
+
+if __name__ == '__main__':
+    main()
diff --git a/zotify/album.py b/zotify/album.py
index 8920af3..53c7992 100644
--- a/zotify/album.py
+++ b/zotify/album.py
@@ -1,8 +1,8 @@
-from const import ITEMS, ARTISTS, NAME, ID
-from termoutput import Printer
-from track import download_track
-from utils import fix_filename
-from zotify import Zotify
+from zotify.const import ITEMS, ARTISTS, NAME, ID
+from zotify.termoutput import Printer
+from zotify.track import download_track
+from zotify.utils import fix_filename
+from zotify.zotify import Zotify
 
 ALBUM_URL = 'https://api.spotify.com/v1/albums'
 ARTIST_URL = 'https://api.spotify.com/v1/artists'
diff --git a/zotify/app.py b/zotify/app.py
index fca6d42..7482e43 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -1,16 +1,17 @@
 from librespot.audio.decoders import AudioQuality
 from tabulate import tabulate
-import os
+#import os
+from pathlib import Path
 
-from album import download_album, download_artist_albums
-from const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \
-    OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME
-from playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist
-from podcast import download_episode, get_show_episodes
-from termoutput import Printer, PrintChannel
-from track import download_track, get_saved_tracks
-from utils import splash, split_input, regex_input_for_urls
-from zotify import Zotify
+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.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.utils import splash, split_input, regex_input_for_urls
+from zotify.zotify import Zotify
 
 SEARCH_URL = 'https://api.spotify.com/v1/search'
 
@@ -31,7 +32,7 @@ def client(args) -> None:
     if args.download:
         urls = []
         filename = args.download
-        if os.path.exists(filename):
+        if Path(filename).exists():
             with open(filename, 'r', encoding='utf-8') as file:
                 urls.extend([line.strip() for line in file.readlines()])
 
@@ -88,14 +89,17 @@ def download_from_urls(urls: list[str]) -> bool:
                 if not song[TRACK][NAME] or not song[TRACK][ID]:
                     Printer.print(PrintChannel.SKIPS, '###   SKIPPING:  SONG DOES NOT EXIST ANYMORE   ###' + "\n")
                 else:
-                    download_track('playlist', song[TRACK][ID], extra_keys=
-                    {
-                        'playlist_song_name': song[TRACK][NAME],
-                        'playlist': name,
-                        'playlist_num': str(enum).zfill(char_num),
-                        'playlist_id': playlist_id,
-                        'playlist_track_id': song[TRACK][ID]
-                    })
+                    if song[TRACK][TYPE] == "episode": # Playlist item is a podcast episode
+                        download_episode(song[TRACK][ID])
+                    else:
+                        download_track('playlist', song[TRACK][ID], extra_keys=
+                        {
+                            'playlist_song_name': song[TRACK][NAME],
+                            'playlist': name,
+                            'playlist_num': str(enum).zfill(char_num),
+                            'playlist_id': playlist_id,
+                            'playlist_track_id': song[TRACK][ID]
+                        })
                     enum += 1
         elif episode_id is not None:
             download = True
diff --git a/zotify/config.py b/zotify/config.py
index adee713..fe290ee 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -1,8 +1,9 @@
 import json
-import os
+# import os
+from pathlib import Path, PurePath
 from typing import Any
 
-CONFIG_FILE_PATH = '../zconfig.json'
+CONFIG_FILE_PATH = './zconfig.json'
 
 ROOT_PATH = 'ROOT_PATH'
 ROOT_PODCAST_PATH = 'ROOT_PODCAST_PATH'
@@ -34,34 +35,34 @@ PRINT_WARNINGS = 'PRINT_WARNINGS'
 RETRY_ATTEMPTS = 'RETRY_ATTEMPTS'
 
 CONFIG_VALUES = {
-    ROOT_PATH:                  { 'default': '../Zotify Music/',    'type': str,  'arg': '--root-path'                  },
-    ROOT_PODCAST_PATH:          { 'default': '../Zotify Podcasts/', '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'                    },
-    SONG_ARCHIVE:               { 'default': '.song_archive',       'type': str,  'arg': '--song-archive'               },
-    CREDENTIALS_LOCATION:       { 'default': 'credentials.json',    'type': str,  'arg': '--credentials-location'       },
-    OUTPUT:                     { 'default': '',                    'type': str,  'arg': '--output'                     },
-    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'    },
-    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_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'          }
+    ROOT_PATH:                  { 'default': './Zotify Music/',    'type': str,  'arg': '--root-path'                  },
+    ROOT_PODCAST_PATH:          { 'default': './Zotify Podcasts/', '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'                    },
+    SONG_ARCHIVE:               { 'default': '.song_archive',      'type': str,  'arg': '--song-archive'               },
+    CREDENTIALS_LOCATION:       { 'default': 'credentials.json',   'type': str,  'arg': '--credentials-location'       },
+    OUTPUT:                     { 'default': '',                   'type': str,  'arg': '--output'                     },
+    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'    },
+    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_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}'
@@ -76,17 +77,18 @@ class Config:
 
     @classmethod
     def load(cls, args) -> None:
-        app_dir = os.path.dirname(__file__)
+        #app_dir = PurePath(__file__).parent
+        app_dir = Path.cwd()
 
         config_fp = CONFIG_FILE_PATH
         if args.config_location:
             config_fp = args.config_location
 
-        true_config_file_path = os.path.join(app_dir, config_fp)
+        true_config_file_path = PurePath(app_dir).joinpath(config_fp)
 
         # Load config from zconfig.json
 
-        if not os.path.exists(true_config_file_path):
+        if not Path(true_config_file_path).exists():
             with open(true_config_file_path, 'w', encoding='utf-8') as config_file:
                 json.dump(cls.get_default_json(), config_file, indent=4)
             cls.Values = cls.get_default_json()
@@ -142,11 +144,11 @@ class Config:
 
     @classmethod
     def get_root_path(cls) -> str:
-        return os.path.join(os.path.dirname(__file__), cls.get(ROOT_PATH))
+        return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PATH))
 
     @classmethod
     def get_root_podcast_path(cls) -> str:
-        return os.path.join(os.path.dirname(__file__), cls.get(ROOT_PODCAST_PATH))
+        return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PODCAST_PATH))
 
     @classmethod
     def get_skip_existing_files(cls) -> bool:
@@ -194,17 +196,17 @@ class Config:
 
     @classmethod
     def get_song_archive(cls) -> str:
-        return os.path.join(cls.get_root_path(), cls.get(SONG_ARCHIVE))
+        return PurePath(cls.get_root_path()).joinpath(cls.get(SONG_ARCHIVE))
 
     @classmethod
     def get_credentials_location(cls) -> str:
-        return os.path.join(os.getcwd(), cls.get(CREDENTIALS_LOCATION))
+        return PurePath(Path.cwd()).joinpath(cls.get(CREDENTIALS_LOCATION))
 
     @classmethod
     def get_temp_download_dir(cls) -> str:
         if cls.get(TEMP_DOWNLOAD_DIR) == '':
             return ''
-        return os.path.join(cls.get_root_path(), cls.get(TEMP_DOWNLOAD_DIR))
+        return PurePath(cls.get_root_path()).joinpath(cls.get(TEMP_DOWNLOAD_DIR))
     
     @classmethod
     def get_all_genres(cls) -> bool:
@@ -221,28 +223,38 @@ class Config:
             return v
         if mode == 'playlist':
             if cls.get_split_album_discs():
-                split = os.path.split(OUTPUT_DEFAULT_PLAYLIST)
-                return os.path.join(split[0], 'Disc {disc_number}', split[0])
+                # split = os.path.split(OUTPUT_DEFAULT_PLAYLIST)
+                # return os.path.join(split[0], 'Disc {disc_number}', split[0])
+                split = PurePath(OUTPUT_DEFAULT_PLAYLIST).parent
+                return PurePath(split).joinpath('Disc {disc_number}').joinpath(split)
             return OUTPUT_DEFAULT_PLAYLIST
         if mode == 'extplaylist':
             if cls.get_split_album_discs():
-                split = os.path.split(OUTPUT_DEFAULT_PLAYLIST_EXT)
-                return os.path.join(split[0], 'Disc {disc_number}', split[0])
+                # split = os.path.split(OUTPUT_DEFAULT_PLAYLIST_EXT)
+                # return os.path.join(split[0], 'Disc {disc_number}', split[0])
+                split = PurePath(OUTPUT_DEFAULT_PLAYLIST_EXT).parent
+                return PurePath(split).joinpath('Disc {disc_number}').joinpath(split)
             return OUTPUT_DEFAULT_PLAYLIST_EXT
         if mode == 'liked':
             if cls.get_split_album_discs():
-                split = os.path.split(OUTPUT_DEFAULT_LIKED_SONGS)
-                return os.path.join(split[0], 'Disc {disc_number}', split[0])
+                # split = os.path.split(OUTPUT_DEFAULT_LIKED_SONGS)
+                # return os.path.join(split[0], 'Disc {disc_number}', split[0])
+                split = PurePath(OUTPUT_DEFAULT_LIKED_SONGS).parent
+                return PurePath(split).joinpath('Disc {disc_number}').joinpath(split)
             return OUTPUT_DEFAULT_LIKED_SONGS
         if mode == 'single':
             if cls.get_split_album_discs():
-                split = os.path.split(OUTPUT_DEFAULT_SINGLE)
-                return os.path.join(split[0], 'Disc {disc_number}', split[0])
+                # split = os.path.split(OUTPUT_DEFAULT_SINGLE)
+                # return os.path.join(split[0], 'Disc {disc_number}', split[0])
+                split = PurePath(OUTPUT_DEFAULT_SINGLE).parent
+                return PurePath(split).joinpath('Disc {disc_number}').joinpath(split)
             return OUTPUT_DEFAULT_SINGLE
         if mode == 'album':
             if cls.get_split_album_discs():
-                split = os.path.split(OUTPUT_DEFAULT_ALBUM)
-                return os.path.join(split[0], 'Disc {disc_number}', split[0])
+                # split = os.path.split(OUTPUT_DEFAULT_ALBUM)
+                # return os.path.join(split[0], 'Disc {disc_number}', split[0])
+                split = PurePath(OUTPUT_DEFAULT_ALBUM).parent
+                return PurePath(split).joinpath('Disc {disc_number}').joinpath(split)
             return OUTPUT_DEFAULT_ALBUM
         raise ValueError()
 
diff --git a/zotify/loader.py b/zotify/loader.py
index faa3cb6..ca894fe 100644
--- a/zotify/loader.py
+++ b/zotify/loader.py
@@ -7,7 +7,7 @@ from shutil import get_terminal_size
 from threading import Thread
 from time import sleep
 
-from termoutput import Printer
+from zotify.termoutput import Printer
 
 
 class Loader:
diff --git a/zotify/playlist.py b/zotify/playlist.py
index e00c745..53c1941 100644
--- a/zotify/playlist.py
+++ b/zotify/playlist.py
@@ -1,8 +1,8 @@
-from const import ITEMS, ID, TRACK, NAME
-from termoutput import Printer
-from track import download_track
-from utils import split_input
-from zotify import Zotify
+from zotify.const import ITEMS, ID, TRACK, NAME
+from zotify.termoutput import Printer
+from zotify.track import download_track
+from zotify.utils import split_input
+from zotify.zotify import Zotify
 
 MY_PLAYLISTS_URL = 'https://api.spotify.com/v1/me/playlists'
 PLAYLISTS_URL = 'https://api.spotify.com/v1/playlists'
diff --git a/zotify/podcast.py b/zotify/podcast.py
index 1c5b493..39e778c 100644
--- a/zotify/podcast.py
+++ b/zotify/podcast.py
@@ -1,14 +1,15 @@
-import os
+# import os
+from pathlib import PurePath, Path
 import time
 from typing import Optional, Tuple
 
 from librespot.metadata import EpisodeId
 
-from const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS
-from termoutput import PrintChannel, Printer
-from utils import create_download_directory, fix_filename
-from zotify import Zotify
-from loader import Loader
+from zotify.const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS
+from zotify.termoutput import PrintChannel, Printer
+from zotify.utils import create_download_directory, fix_filename
+from zotify.zotify import Zotify
+from zotify.loader import Loader
 
 
 EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes'
@@ -46,7 +47,7 @@ def get_show_episodes(show_id_str) -> list:
 
 def download_podcast_directly(url, filename):
     import functools
-    import pathlib
+    # import pathlib
     import shutil
     import requests
     from tqdm.auto import tqdm
@@ -58,7 +59,8 @@ def download_podcast_directly(url, filename):
             f"Request to {url} returned status code {r.status_code}")
     file_size = int(r.headers.get('Content-Length', 0))
 
-    path = pathlib.Path(filename).expanduser().resolve()
+    # path = pathlib.Path(filename).expanduser().resolve()
+    path = Path(filename).expanduser().resolve()
     path.parent.mkdir(parents=True, exist_ok=True)
 
     desc = "(Unknown total file size)" if file_size == 0 else ""
@@ -86,8 +88,8 @@ def download_episode(episode_id) -> None:
         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"]
 
-        download_directory = os.path.join(Zotify.CONFIG.get_root_podcast_path(), extra_paths)
-        download_directory = os.path.realpath(download_directory)
+        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:
@@ -97,10 +99,10 @@ def download_episode(episode_id) -> None:
 
             total_size = stream.input_stream.size
 
-            filepath = os.path.join(download_directory, f"{filename}.ogg")
+            filepath = PurePath(download_directory).joinpath(f"{filename}.ogg")
             if (
-                os.path.isfile(filepath)
-                and os.path.getsize(filepath) == total_size
+                Path(filepath).isfile()
+                and Path(filepath).stat().st_size == total_size
                 and Zotify.CONFIG.get_skip_existing_files()
             ):
                 Printer.print(PrintChannel.SKIPS, "\n###   SKIPPING: " + podcast_name + " - " + episode_name + " (EPISODE ALREADY EXISTS)   ###")
@@ -128,7 +130,7 @@ def download_episode(episode_id) -> None:
                         if delta_want > delta_real:
                             time.sleep(delta_want - delta_real)
         else:
-            filepath = os.path.join(download_directory, f"{filename}.mp3")
+            filepath = PurePath(download_directory).joinpath(f"{filename}.mp3")
             download_podcast_directly(direct_download_url, filepath)
 
     prepare_download_loader.stop()
diff --git a/zotify/termoutput.py b/zotify/termoutput.py
index 7a7ad96..2539d1f 100644
--- a/zotify/termoutput.py
+++ b/zotify/termoutput.py
@@ -2,8 +2,8 @@ import sys
 from enum import Enum
 from tqdm import tqdm
 
-from config import *
-from zotify import Zotify
+from zotify.config import *
+from zotify.zotify import Zotify
 
 
 class PrintChannel(Enum):
diff --git a/zotify/track.py b/zotify/track.py
index af4ef38..b6b0019 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -1,4 +1,5 @@
-import os
+# import os
+from pathlib import Path, PurePath
 import re
 import time
 import uuid
@@ -8,14 +9,14 @@ from librespot.audio.decoders import AudioQuality
 from librespot.metadata import TrackId
 from ffmpy import FFmpeg
 
-from const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \
+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
-from termoutput import Printer, PrintChannel
-from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \
+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
-from zotify import Zotify
+from zotify.zotify import Zotify
 import traceback
-from loader import Loader
+from zotify.loader import Loader
 
 
 def get_saved_tracks() -> list:
@@ -136,25 +137,27 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
         output_template = output_template.replace("{track_id}", fix_filename(track_id))
         output_template = output_template.replace("{ext}", ext)
 
-        filename = os.path.join(Zotify.CONFIG.get_root_path(), output_template)
-        filedir = os.path.dirname(filename)
+        filename = PurePath(Zotify.CONFIG.get_root_path()).joinpath(output_template)
+        filedir = PurePath(filename).parent
 
         filename_temp = filename
         if Zotify.CONFIG.get_temp_download_dir() != '':
-            filename_temp = os.path.join(Zotify.CONFIG.get_temp_download_dir(), f'zotify_{str(uuid.uuid4())}_{track_id}.{ext}')
+            filename_temp = PurePath(Zotify.CONFIG.get_temp_download_dir()).joinpath(f'zotify_{str(uuid.uuid4())}_{track_id}.{ext}')
 
-        check_name = os.path.isfile(filename) and os.path.getsize(filename)
+        check_name = Path(filename).is_file() and Path(filename).stat().st_size
         check_id = scraped_song_id in get_directory_song_ids(filedir)
         check_all_time = scraped_song_id in get_previously_downloaded()
 
         # a song with the same name is installed
         if not check_id and check_name:
-            c = len([file for file in os.listdir(filedir) if re.search(f'^{filename}_', str(file))]) + 1
+            c = len([file for file in Path(filedir).iterdir() if re.search(f'^{filename}_', str(file))]) + 1
 
-            fname = os.path.splitext(os.path.basename(filename))[0]
-            ext = os.path.splitext(os.path.basename(filename))[1]
+            # fname = os.path.splitext(os.path.basename(filename))[0]
+            # ext = os.path.splitext(os.path.basename(filename))[1]
+            fname = PurePath(PurePath(filename).name).parent
+            ext = PurePath(PurePath(filename).name).suffix
 
-            filename = os.path.join(filedir, f'{fname}_{c}{ext}')
+            filename = PurePath(filedir).joinpath(f'{fname}_{c}{ext}')
 
     except Exception as e:
         Printer.print(PrintChannel.ERRORS, '###   SKIPPING SONG - FAILED TO QUERY METADATA   ###')
@@ -218,18 +221,18 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
                     set_music_thumbnail(filename_temp, image_url)
 
                     if filename_temp != filename:
-                        os.rename(filename_temp, filename)
+                        Path(filename_temp).rename(filename)
 
                     time_finished = time.time()
 
-                    Printer.print(PrintChannel.DOWNLOADS, f'###   Downloaded "{song_name}" to "{os.path.relpath(filename, Zotify.CONFIG.get_root_path())}" in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting)   ###' + "\n")
+                    Printer.print(PrintChannel.DOWNLOADS, f'###   Downloaded "{song_name}" to "{Path(filename).relative_to(Zotify.CONFIG.get_root_path())}" in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting)   ###' + "\n")
 
                     # add song id to archive file
                     if Zotify.CONFIG.get_skip_previously_downloaded():
-                        add_to_archive(scraped_song_id, os.path.basename(filename), artists[0], name)
+                        add_to_archive(scraped_song_id, PurePath(filename).name, artists[0], name)
                     # add song id to download directory's .song_ids file
                     if not check_id:
-                        add_to_directory_song_ids(filedir, scraped_song_id, os.path.basename(filename), artists[0], name)
+                        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())
@@ -241,16 +244,17 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
             Printer.print(PrintChannel.ERRORS, "\n")
             Printer.print(PrintChannel.ERRORS, str(e) + "\n")
             Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n")
-            if os.path.exists(filename_temp):
-                os.remove(filename_temp)
+            if Path(filename_temp).exists():
+                Path(filename_temp).unlink()
 
     prepare_download_loader.stop()
 
 
 def convert_audio_format(filename) -> None:
     """ Converts raw audio into playable file """
-    temp_filename = f'{os.path.splitext(filename)[0]}.tmp'
-    os.replace(filename, temp_filename)
+    # temp_filename = f'{os.path.splitext(filename)[0]}.tmp'
+    temp_filename = f'{PurePath(filename).parent}.tmp'
+    Path(filename).replace(temp_filename)
 
     download_format = Zotify.CONFIG.get_download_format().lower()
     file_codec = CODEC_MAP.get(download_format, 'copy')
@@ -277,5 +281,5 @@ def convert_audio_format(filename) -> None:
     with Loader(PrintChannel.PROGRESS_INFO, "Converting file..."):
         ff_m.run()
 
-    if os.path.exists(temp_filename):
-        os.remove(temp_filename)
+    if Path(temp_filename).exists():
+        Path(temp_filename).unlink()
diff --git a/zotify/utils.py b/zotify/utils.py
index 31c6298..359e500 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -5,14 +5,15 @@ import platform
 import re
 import subprocess
 from enum import Enum
+from pathlib import Path, PurePath
 from typing import List, Tuple
 
 import music_tag
 import requests
 
-from const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \
+from zotify.const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \
     WINDOWS_SYSTEM, ALBUMARTIST
-from zotify import Zotify
+from zotify.zotify import Zotify
 
 
 class MusicFormat(str, Enum):
@@ -22,11 +23,12 @@ class MusicFormat(str, Enum):
 
 def create_download_directory(download_path: str) -> None:
     """ Create directory and add a hidden file with song ids """
-    os.makedirs(download_path, exist_ok=True)
+    # os.makedirs(download_path, exist_ok=True)
+    Path(download_path).mkdir(parents=True, exist_ok=True)
 
     # add hidden file with song ids
-    hidden_file_path = os.path.join(download_path, '.song_ids')
-    if not os.path.isfile(hidden_file_path):
+    hidden_file_path = PurePath(download_path).joinpath('.song_ids')
+    if not Path(hidden_file_path).is_file():
         with open(hidden_file_path, 'w', encoding='utf-8') as f:
             pass
 
@@ -37,7 +39,7 @@ def get_previously_downloaded() -> List[str]:
     ids = []
     archive_path = Zotify.CONFIG.get_song_archive()
 
-    if os.path.exists(archive_path):
+    if Path(archive_path).exists():
         with open(archive_path, 'r', encoding='utf-8') as f:
             ids = [line.strip().split('\t')[0] for line in f.readlines()]
 
@@ -49,7 +51,7 @@ def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str
 
     archive_path = Zotify.CONFIG.get_song_archive()
 
-    if os.path.exists(archive_path):
+    if Path(archive_path).exists():
         with open(archive_path, 'a', encoding='utf-8') as file:
             file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n')
     else:
@@ -62,8 +64,8 @@ def get_directory_song_ids(download_path: str) -> List[str]:
 
     song_ids = []
 
-    hidden_file_path = os.path.join(download_path, '.song_ids')
-    if os.path.isfile(hidden_file_path):
+    hidden_file_path = PurePath(download_path).joinpath('.song_ids')
+    if Path(hidden_file_path).is_file():
         with open(hidden_file_path, 'r', encoding='utf-8') as file:
             song_ids.extend([line.strip().split('\t')[0] for line in file.readlines()])
 
@@ -73,7 +75,7 @@ def get_directory_song_ids(download_path: str) -> List[str]:
 def add_to_directory_song_ids(download_path: str, song_id: str, filename: str, author_name: str, song_name: str) -> None:
     """ Appends song_id to .song_ids file in directory """
 
-    hidden_file_path = os.path.join(download_path, '.song_ids')
+    hidden_file_path = PurePath(download_path).joinpath('.song_ids')
     # not checking if file exists because we need an exception
     # to be raised if something is wrong
     with open(hidden_file_path, 'a', encoding='utf-8') as file:
diff --git a/zotify/zotify.py b/zotify/zotify.py
index f0b3329..d963359 100644
--- a/zotify/zotify.py
+++ b/zotify/zotify.py
@@ -1,15 +1,16 @@
 import os
 import os.path
+from pathlib import Path
 from getpass import getpass
 import time
 import requests
 from librespot.audio.decoders import VorbisOnlyAudioQuality
 from librespot.core import Session
 
-from const import TYPE, \
+from zotify.const import TYPE, \
     PREMIUM, USER_READ_EMAIL, OFFSET, LIMIT, \
     PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ
-from config import Config
+from zotify.config import Config
 
 class Zotify:    
     SESSION: Session = None
@@ -26,7 +27,7 @@ class Zotify:
 
         cred_location = Config.get_credentials_location()
 
-        if os.path.isfile(cred_location):
+        if Path(cred_location).is_file():
             try:
                 cls.SESSION = Session.Builder().stored_file(cred_location).create()
                 return
@@ -75,7 +76,7 @@ class Zotify:
     @classmethod
     def invoke_url(cls, url, tryCount=0):
         # we need to import that here, otherwise we will get circular imports!
-        from termoutput import Printer, PrintChannel
+        from zotify.termoutput import Printer, PrintChannel
         headers = cls.get_auth_header()
         response = requests.get(url, headers=headers)
         responsetext = response.text

From 3d50d8f1419bec7ecb883c5f7f4619cccd55c5de Mon Sep 17 00:00:00 2001
From: logykk <joshbelcher00@gmail.com>
Date: Fri, 4 Feb 2022 22:11:49 +1300
Subject: [PATCH 032/169] Global config location

---
 CHANGELOG.md       |  9 +++++++--
 README.md          | 16 ++++++++--------
 zotify/__main__.py |  2 +-
 zotify/app.py      |  7 +++----
 zotify/config.py   | 47 +++++++++++++++++++---------------------------
 zotify/podcast.py  |  2 +-
 zotify/utils.py    |  3 ---
 7 files changed, 39 insertions(+), 47 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index d2b6055..2fc66d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,9 +2,14 @@
 ## v0.6
 **General changes**
 - Switched from os.path to pathlib
-- Zotify can now be installed with pip \
+- Zotify can now be installed with pip - 
 `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.
+- Zotify can be ran from any directory with `zotify [args]`, you no longer need to prefix `python` in the command.
+- New default config locations:
+  - Windows: `%AppData%\Roaming\Zotify\config.json`
+  - Linux: `~/.config/zotify/config.json`
+  - macOS: `~/Library/Application Support/Zotify/config.json`
+  - You can still use `--config-location` to specify a local config file.
 
 **Docker**
 - Dockerfile is currently broken, it will be fixed soon. \
diff --git a/README.md b/README.md
index 81e8122..9f7cd95 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ Python packages:
 
 ```
 Basic command line usage:
-  python zotify <track/album/playlist/episode/artist url>   Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls.
+  zotify <track/album/playlist/episode/artist url>   Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls.
 
 Different usage modes:
   (nothing)            Download the tracks/alumbs/playlists URLs from the parameter
@@ -42,15 +42,15 @@ Different usage modes:
   
 Extra command line options:
   -ns, --no-splash     Suppress the splash screen when loading.
-  --config-location    Use a different zconfig.json, defaults to the one in the program directory
+  --config-location    Use a different config.json.
 ```
 
 ### Options:
 
-All these options can either be configured in the zconfig or via the commandline, in case of both the commandline-option has higher priority.  
+All these options can either be configured in the config or via the commandline, in case of both the commandline-option has higher priority.  
 Be aware you have to set boolean values in the commandline like this: `--download-real-time=True`
 
-| Key (zconfig)                | commandline parameter            | Description
+| Key (config)                 | commandline parameter            | Description
 |------------------------------|----------------------------------|---------------------------------------------------------------------|
 | ROOT_PATH                    | --root-path                      | directory where Zotify saves the music
 | ROOT_PODCAST_PATH            | --root-podcast-path              | directory where Zotify saves the podcasts
@@ -106,20 +106,20 @@ Liked Songs/{artist} - {song_name}.{ext}
 /home/user/downloads/{artist} - {song_name} [{id}].{ext}
 ~~~~
 
-### Docker Usage
+### Docker Usage - CURRENTLY BROKEN
 ```
 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/zconfig.json:/zconfig.json" -v "$PWD/Zotify Music:/Zotify Music" -v "$PWD/Zotify Podcasts:/Zotify Podcasts" -it zotify
+  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
 ```
 
 ### Will my account get banned if I use this tool?
 
 Currently no user has reported their account getting banned after using Zotify.
 
-We highly recommend using 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 not appearing suspicious.
+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.
diff --git a/zotify/__main__.py b/zotify/__main__.py
index de8e0c8..1837bd8 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -18,7 +18,7 @@ def main():
                         help='Suppress the splash screen when loading.')
     parser.add_argument('--config-location',
                         type=str,
-                        help='Specify the zconfig.json location')
+                        help='Specify the json config location')
     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 7482e43..d32f30c 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -23,10 +23,10 @@ def client(args) -> None:
     Printer.print(PrintChannel.SPLASH, splash())
 
     if Zotify.check_premium():
-        Printer.print(PrintChannel.SPLASH, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n')
+        Printer.print(PrintChannel.WARNINGS, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n')
         Zotify.DOWNLOAD_QUALITY = AudioQuality.VERY_HIGH
     else:
-        Printer.print(PrintChannel.SPLASH, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n\n')
+        Printer.print(PrintChannel.WARNINGS, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n\n')
         Zotify.DOWNLOAD_QUALITY = AudioQuality.HIGH
 
     if args.download:
@@ -67,8 +67,7 @@ def download_from_urls(urls: list[str]) -> bool:
     download = False
 
     for spotify_url in urls:
-        track_id, album_id, playlist_id, episode_id, show_id, artist_id = regex_input_for_urls(
-            spotify_url)
+        track_id, album_id, playlist_id, episode_id, show_id, artist_id = regex_input_for_urls(spotify_url)
 
         if track_id is not None:
             download = True
diff --git a/zotify/config.py b/zotify/config.py
index fe290ee..c51f700 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -1,9 +1,8 @@
 import json
-# import os
+import sys
 from pathlib import Path, PurePath
 from typing import Any
 
-CONFIG_FILE_PATH = './zconfig.json'
 
 ROOT_PATH = 'ROOT_PATH'
 ROOT_PODCAST_PATH = 'ROOT_PODCAST_PATH'
@@ -77,28 +76,28 @@ class Config:
 
     @classmethod
     def load(cls, args) -> None:
-        #app_dir = PurePath(__file__).parent
-        app_dir = Path.cwd()
-
-        config_fp = CONFIG_FILE_PATH
+        system_paths = {
+            'win32': Path.home() / 'AppData/Roaming/Zotify',
+            'linux': Path.home() / '.config/zotify',
+            'darwin': Path.home() / 'Library/Application Support/Zotify'
+        }
+        config_fp = system_paths[sys.platform] / 'config.json'
         if args.config_location:
             config_fp = args.config_location
 
-        true_config_file_path = PurePath(app_dir).joinpath(config_fp)
+        true_config_file_path = Path(config_fp).expanduser()
 
         # Load config from zconfig.json
-
+        Path(PurePath(true_config_file_path).parent).mkdir(parents=True, exist_ok=True)
         if not Path(true_config_file_path).exists():
             with open(true_config_file_path, 'w', encoding='utf-8') as config_file:
                 json.dump(cls.get_default_json(), config_file, indent=4)
-            cls.Values = cls.get_default_json()
-        else:
-            with open(true_config_file_path, encoding='utf-8') as config_file:
-                jsonvalues = json.load(config_file)
-                cls.Values = {}
-                for key in CONFIG_VALUES:
-                    if key in jsonvalues:
-                        cls.Values[key] = cls.parse_arg_value(key, jsonvalues[key])
+        with open(true_config_file_path, encoding='utf-8') as config_file:
+            jsonvalues = json.load(config_file)
+            cls.Values = {}
+            for key in CONFIG_VALUES:
+                if key in jsonvalues:
+                    cls.Values[key] = cls.parse_arg_value(key, jsonvalues[key])
 
         # Add default values for missing keys
 
@@ -144,11 +143,13 @@ class Config:
 
     @classmethod
     def get_root_path(cls) -> str:
-        return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PATH))
+        # return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PATH))
+        return PurePath(Path(cls.get(ROOT_PATH)).expanduser())
 
     @classmethod
     def get_root_podcast_path(cls) -> str:
-        return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PODCAST_PATH))
+        # return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PODCAST_PATH))
+        return PurePath(Path(cls.get(ROOT_PODCAST_PATH)).expanduser())
 
     @classmethod
     def get_skip_existing_files(cls) -> bool:
@@ -223,36 +224,26 @@ class Config:
             return v
         if mode == 'playlist':
             if cls.get_split_album_discs():
-                # split = os.path.split(OUTPUT_DEFAULT_PLAYLIST)
-                # return os.path.join(split[0], 'Disc {disc_number}', split[0])
                 split = PurePath(OUTPUT_DEFAULT_PLAYLIST).parent
                 return PurePath(split).joinpath('Disc {disc_number}').joinpath(split)
             return OUTPUT_DEFAULT_PLAYLIST
         if mode == 'extplaylist':
             if cls.get_split_album_discs():
-                # split = os.path.split(OUTPUT_DEFAULT_PLAYLIST_EXT)
-                # return os.path.join(split[0], 'Disc {disc_number}', split[0])
                 split = PurePath(OUTPUT_DEFAULT_PLAYLIST_EXT).parent
                 return PurePath(split).joinpath('Disc {disc_number}').joinpath(split)
             return OUTPUT_DEFAULT_PLAYLIST_EXT
         if mode == 'liked':
             if cls.get_split_album_discs():
-                # split = os.path.split(OUTPUT_DEFAULT_LIKED_SONGS)
-                # return os.path.join(split[0], 'Disc {disc_number}', split[0])
                 split = PurePath(OUTPUT_DEFAULT_LIKED_SONGS).parent
                 return PurePath(split).joinpath('Disc {disc_number}').joinpath(split)
             return OUTPUT_DEFAULT_LIKED_SONGS
         if mode == 'single':
             if cls.get_split_album_discs():
-                # split = os.path.split(OUTPUT_DEFAULT_SINGLE)
-                # return os.path.join(split[0], 'Disc {disc_number}', split[0])
                 split = PurePath(OUTPUT_DEFAULT_SINGLE).parent
                 return PurePath(split).joinpath('Disc {disc_number}').joinpath(split)
             return OUTPUT_DEFAULT_SINGLE
         if mode == 'album':
             if cls.get_split_album_discs():
-                # split = os.path.split(OUTPUT_DEFAULT_ALBUM)
-                # return os.path.join(split[0], 'Disc {disc_number}', split[0])
                 split = PurePath(OUTPUT_DEFAULT_ALBUM).parent
                 return PurePath(split).joinpath('Disc {disc_number}').joinpath(split)
             return OUTPUT_DEFAULT_ALBUM
diff --git a/zotify/podcast.py b/zotify/podcast.py
index 39e778c..63863cf 100644
--- a/zotify/podcast.py
+++ b/zotify/podcast.py
@@ -7,7 +7,7 @@ from librespot.metadata import EpisodeId
 
 from zotify.const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS
 from zotify.termoutput import PrintChannel, Printer
-from zotify.utils import create_download_directory, fix_filename
+from zotify.utils import create_download_directory, fix_filename, convert_audio_format
 from zotify.zotify import Zotify
 from zotify.loader import Loader
 
diff --git a/zotify/utils.py b/zotify/utils.py
index 359e500..0d051d6 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -23,7 +23,6 @@ class MusicFormat(str, Enum):
 
 def create_download_directory(download_path: str) -> None:
     """ Create directory and add a hidden file with song ids """
-    # os.makedirs(download_path, exist_ok=True)
     Path(download_path).mkdir(parents=True, exist_ok=True)
 
     # add hidden file with song ids
@@ -282,5 +281,3 @@ def fmt_seconds(secs: float) -> str:
         return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)
     else:
         return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)
-
-

From d8c17e2ce9cc57b500915fcbe76722fc690c7011 Mon Sep 17 00:00:00 2001
From: logykk <joshbelcher00@gmail.com>
Date: Sat, 12 Feb 2022 20:48:27 +1300
Subject: [PATCH 033/169] Zotify 0.6 RC1

---
 CHANGELOG.md       |  25 ++++++++--
 README.md          |  60 ++++++++++--------------
 requirements.txt   |   1 +
 setup.py           |   8 ++--
 zotify/__main__.py |  10 ++--
 zotify/app.py      |  21 +++++----
 zotify/config.py   | 114 ++++++++++++++++++++++++++++++---------------
 zotify/podcast.py  |   6 +--
 zotify/track.py    |   4 --
 zotify/utils.py    |  15 +++---
 zotify/zotify.py   |  14 +++---
 11 files changed, 161 insertions(+), 117 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fc66d6..d6ffec3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,14 +2,31 @@
 ## v0.6
 **General changes**
 - Switched from os.path to pathlib
-- Zotify can now be installed with pip - 
-`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.
+- Renamed .song_archive to track_archive
+- 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,
 - New default config locations:
   - Windows: `%AppData%\Roaming\Zotify\config.json`
   - Linux: `~/.config/zotify/config.json`
   - macOS: `~/Library/Application Support/Zotify/config.json`
-  - You can still use `--config-location` to specify a local config file.
+  - Other/Undetected: `.zotify/config.json` 
+  - You can still use `--config-location` to specify a different location.
+- New default config locations:
+  - Windows: `%AppData%\Roaming\Zotify\credentials.json`
+  - Linux: `~/.local/share/zotify/credentials.json`
+  - macOS: `~/Library/Application Support/Zotify/credentials.json`
+  - Other/Undetected: `.zotify/credentials.json` 
+  - You can still use `--credentials-location` to specify a different file.
+- New default music and podcast locations:
+  - Windows: `C:\Users\<user>\Music\Zotify Music\` & `C:\Users\<user>\Music\Zotify Podcasts\`
+  - 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 9f7cd95..297f179 100644
--- a/README.md
+++ b/README.md
@@ -3,66 +3,58 @@
 ### A music and podcast downloader needing only a python interpreter and ffmpeg.
 
 <p align="center">
-  <img src="https://i.imgur.com/hGXQWSl.png">
+  <img src="https://i.imgur.com/hGXQWSl.png" width="50%">
 </p>
 
-[Discord Server](https://discord.gg/XDYsFRTUjE) - [NotABug Mirror](https://notabug.org/Zotify/zotify)
+[Discord Server](https://discord.gg/XDYsFRTUjE)
+
+### Install
 
 ```
-Requirements:
-
-Binaries
+Dependencies:
 
 - Python 3.9 or greater
 - ffmpeg*
-- Git**
 
-Python packages:
-
-- pip install -r requirements.txt
+Installation:
 
+python -m pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip
 ```
 
-\*ffmpeg can be installed via apt for Debian-based distros or by downloading the binaries from [ffmpeg.org](https://ffmpeg.org) and placing them in your %PATH% in Windows. Mac users can install it with [Homebrew](https://brew.sh) by running `brew install ffmpeg`.
+\*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.
 
-\*\*Git can be installed via apt for Debian-based distros or by downloading the binaries from [git-scm.com](https://git-scm.com/download/win) for Windows.
-
-### Command line usage:
+### Command line usage
 
 ```
 Basic command line usage:
   zotify <track/album/playlist/episode/artist url>   Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls.
 
-Different usage modes:
-  (nothing)            Download the tracks/alumbs/playlists URLs from the parameter
-  -d,  --download      Download all tracks/alumbs/playlists URLs from the specified file
-  -p,  --playlist      Downloads a saved playlist from your account
-  -ls, --liked-songs   Downloads all the liked songs from your account
-  -s,  --search        Loads search prompt to find then download a specific track, album or playlist
-  
-Extra command line options:
-  -ns, --no-splash     Suppress the splash screen when loading.
-  --config-location    Use a different config.json.
+Basic options:
+  (nothing)        Download the tracks/alumbs/playlists URLs from the parameter
+  -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
+  -s, --search     Searches for specified track, album, artist or playlist, loads search prompt if none are given.
 ```
 
-### Options:
+### Options
 
 All these options can either be configured in the config or via the commandline, in case of both the commandline-option has higher priority.  
 Be aware you have to set boolean values in the commandline like this: `--download-real-time=True`
 
 | Key (config)                 | commandline parameter            | Description
 |------------------------------|----------------------------------|---------------------------------------------------------------------|
-| ROOT_PATH                    | --root-path                      | directory where Zotify saves the music
-| ROOT_PODCAST_PATH            | --root-podcast-path              | directory where Zotify saves the podcasts
+| 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     | Create a .song_archive file and skip previously downloaded songs
+| 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              | split downloaded albums by disc
-| DOWNLOAD_REAL_TIME           | --download-real-time             | only downloads songs as fast as they would be played, can prevent account bans
+| 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
@@ -75,7 +67,7 @@ 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
 
-### Output format:
+### Output format
 
 With the option `OUTPUT` (or the commandline parameter `--output`) you can specify the output location and format.  
 The value is relative to the `ROOT_PATH`/`ROOT_PODCAST_PATH` directory and can contain the following placeholder:
@@ -100,7 +92,7 @@ Example values could be:
 ~~~~
 {playlist}/{artist} - {song_name}.{ext}
 {playlist}/{playlist_num} - {artist} - {song_name}.{ext}
-Liked Songs/{artist} - {song_name}.{ext}
+Bangers/{artist} - {song_name}.{ext}
 {artist} - {song_name}.{ext}
 {artist}/{album}/{album_num} - {artist} - {song_name}.{ext}
 /home/user/downloads/{artist} - {song_name} [{id}].{ext}
@@ -135,7 +127,3 @@ Please refer to [CONTRIBUTING](CONTRIBUTING.md)
 ### Changelog
 
 Please refer to [CHANGELOG](CHANGELOG.md)
-
-### Common Errors
-
-Please refer to [COMMON_ERRORS](COMMON_ERRORS.md)
diff --git a/requirements.txt b/requirements.txt
index dbb530a..8bd5229 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,5 +3,6 @@ https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip
 music_tag
 Pillow
 protobuf
+pwinput
 tabulate
 tqdm
diff --git a/setup.py b/setup.py
index e3abc2e..fbcda8b 100644
--- a/setup.py
+++ b/setup.py
@@ -1,10 +1,10 @@
-import pathlib
+from pathlib import Path
 from distutils.core import setup
 from setuptools import setup, find_packages
 
 
 # The directory containing this file
-HERE = pathlib.Path(__file__).parent
+HERE = Path(__file__).parent
 
 # The text of the README file
 README = (HERE / "README.md").read_text()
@@ -13,7 +13,7 @@ README = (HERE / "README.md").read_text()
 setup(
     name="zotify",
     version="0.6.0",
-    author="Zotify",
+    author="Zotify Contributors",
     description="A music and podcast downloader.",
     long_description=README,
     long_description_content_type="text/markdown",
@@ -30,6 +30,6 @@ setup(
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
     ],
-    install_requires=['ffmpy', 'music_tag', 'Pillow', 'protobuf', 'tabulate', 'tqdm',
+    install_requires=['ffmpy', 'music_tag', 'Pillow', 'protobuf', 'pwinput', 'tabulate', 'tqdm',
                       'librespot @ https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip'],
 )
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 1837bd8..e92c906 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -18,7 +18,7 @@ def main():
                         help='Suppress the splash screen when loading.')
     parser.add_argument('--config-location',
                         type=str,
-                        help='Specify the json config location')
+                        help='Specify the zconfig.json location')
     group = parser.add_mutually_exclusive_group(required=True)
     group.add_argument('urls',
                        type=str,
@@ -26,7 +26,7 @@ def main():
                        default='',
                        nargs='*',
                        help='Downloads the track, album, playlist, podcast episode, or all albums by an artist from a url. Can take multiple urls.')
-    group.add_argument('-ls', '--liked-songs',
+    group.add_argument('-l', '--liked',
                        dest='liked_songs',
                        action='store_true',
                        help='Downloads all the liked songs from your account.')
@@ -34,8 +34,9 @@ def main():
                        action='store_true',
                        help='Downloads a saved playlist from your account.')
     group.add_argument('-s', '--search',
-                       dest='search_spotify',
-                       action='store_true',
+                       type=str,
+                       nargs='?',
+                       const=' ',
                        help='Loads search prompt to find then download a specific track, album or playlist')
     group.add_argument('-d', '--download',
                        type=str,
@@ -52,5 +53,6 @@ def main():
     args = parser.parse_args()
     args.func(args)
 
+
 if __name__ == '__main__':
     main()
diff --git a/zotify/app.py b/zotify/app.py
index d32f30c..7ba1931 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -1,6 +1,5 @@
 from librespot.audio.decoders import AudioQuality
 from tabulate import tabulate
-#import os
 from pathlib import Path
 
 from zotify.album import download_album, download_artist_albums
@@ -23,10 +22,10 @@ def client(args) -> None:
     Printer.print(PrintChannel.SPLASH, splash())
 
     if Zotify.check_premium():
-        Printer.print(PrintChannel.WARNINGS, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n')
+        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\n')
+        Printer.print(PrintChannel.WARNINGS, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n')
         Zotify.DOWNLOAD_QUALITY = AudioQuality.HIGH
 
     if args.download:
@@ -42,7 +41,8 @@ def client(args) -> None:
             Printer.print(PrintChannel.ERRORS, f'File {filename} not found.\n')
 
     if args.urls:
-        download_from_urls(args.urls)
+        if len(args.urls) > 0:
+            download_from_urls(args.urls)
 
     if args.playlist:
         download_from_user_playlist()
@@ -54,13 +54,14 @@ def client(args) -> None:
             else:
                 download_track('liked', song[TRACK][ID])
 
-    if args.search_spotify:
-        search_text = ''
-        while len(search_text) == 0:
-            search_text = input('Enter search or URL: ')
+    if args.search:
+        if args.search == ' ':
+            search_text = ''
+            while len(search_text) == 0:
+                search_text = input('Enter search or URL: ')
 
-        if not download_from_urls([search_text]):
-            search(search_text)
+        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 c51f700..fec8d90 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -17,7 +17,7 @@ SPLIT_ALBUM_DISCS = 'SPLIT_ALBUM_DISCS'
 DOWNLOAD_REAL_TIME = 'DOWNLOAD_REAL_TIME'
 LANGUAGE = 'LANGUAGE'
 BITRATE = 'BITRATE'
-SONG_ARCHIVE = 'SONG_ARCHIVE'
+TRACK_ARCHIVE = 'TRACK_ARCHIVE'
 CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION'
 OUTPUT = 'OUTPUT'
 PRINT_SPLASH = 'PRINT_SPLASH'
@@ -32,42 +32,43 @@ MD_GENREDELIMITER = 'MD_GENREDELIMITER'
 PRINT_PROGRESS_INFO = 'PRINT_PROGRESS_INFO'
 PRINT_WARNINGS = 'PRINT_WARNINGS'
 RETRY_ATTEMPTS = 'RETRY_ATTEMPTS'
+CONFIG_VERSION = 'CONFIG_VERSION'
 
 CONFIG_VALUES = {
-    ROOT_PATH:                  { 'default': './Zotify Music/',    'type': str,  'arg': '--root-path'                  },
-    ROOT_PODCAST_PATH:          { 'default': './Zotify Podcasts/', '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'                    },
-    SONG_ARCHIVE:               { 'default': '.song_archive',      'type': str,  'arg': '--song-archive'               },
-    CREDENTIALS_LOCATION:       { 'default': 'credentials.json',   'type': str,  'arg': '--credentials-location'       },
-    OUTPUT:                     { 'default': '',                   'type': str,  'arg': '--output'                     },
-    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'    },
-    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_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'          }
+    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'                     },
+    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'    },
+    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_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}.{ext}'
+OUTPUT_DEFAULT_SINGLE = '{artist} - {song_name}/{artist} - {song_name}.{ext}'
 OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}'
 
 
@@ -81,7 +82,10 @@ class Config:
             'linux': Path.home() / '.config/zotify',
             'darwin': Path.home() / 'Library/Application Support/Zotify'
         }
-        config_fp = system_paths[sys.platform] / 'config.json'
+        if sys.platform not in system_paths:
+            config_fp = Path.cwd() / '.zotify/config.json'
+        else:
+            config_fp = system_paths[sys.platform] / 'config.json'
         if args.config_location:
             config_fp = args.config_location
 
@@ -143,13 +147,21 @@ class Config:
 
     @classmethod
     def get_root_path(cls) -> str:
-        # return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PATH))
-        return PurePath(Path(cls.get(ROOT_PATH)).expanduser())
+        if cls.get(ROOT_PATH) == '':
+            root_path = PurePath(Path.home() / 'Music/Zotify Music/')
+        else:
+            root_path = PurePath(Path(cls.get(ROOT_PATH)).expanduser())
+        Path(root_path).mkdir(parents=True, exist_ok=True)
+        return root_path
 
     @classmethod
     def get_root_podcast_path(cls) -> str:
-        # return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PODCAST_PATH))
-        return PurePath(Path(cls.get(ROOT_PODCAST_PATH)).expanduser())
+        if cls.get(ROOT_PODCAST_PATH) == '':
+            root_podcast_path = PurePath(Path.home() / 'Music/Zotify Podcasts/')
+        else:
+            root_podcast_path = PurePath(Path(cls.get(ROOT_PODCAST_PATH)).expanduser())
+        Path(root_podcast_path).mkdir(parents=True, exist_ok=True)
+        return root_podcast_path
 
     @classmethod
     def get_skip_existing_files(cls) -> bool:
@@ -196,12 +208,38 @@ class Config:
         return cls.get(BITRATE)
 
     @classmethod
-    def get_song_archive(cls) -> str:
-        return PurePath(cls.get_root_path()).joinpath(cls.get(SONG_ARCHIVE))
+    def get_track_archive(cls) -> str:
+        if cls.get(TRACK_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')
+            else:
+                track_archive = PurePath(system_paths[sys.platform] / 'track_archive')
+        else:
+            track_archive = PurePath(Path(cls.get(TRACK_ARCHIVE)).expanduser())
+        Path(track_archive.parent).mkdir(parents=True, exist_ok=True)
+        return track_archive
 
     @classmethod
     def get_credentials_location(cls) -> str:
-        return PurePath(Path.cwd()).joinpath(cls.get(CREDENTIALS_LOCATION))
+        if cls.get(CREDENTIALS_LOCATION) == '':
+            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:
+                credentials_location = PurePath(Path.cwd() / '.zotify/credentials.json')
+            else:
+                credentials_location = PurePath(system_paths[sys.platform] / 'credentials.json')
+        else:
+            credentials_location = PurePath(Path.cwd()).joinpath(cls.get(CREDENTIALS_LOCATION))
+        Path(credentials_location.parent).mkdir(parents=True, exist_ok=True)
+        return credentials_location
 
     @classmethod
     def get_temp_download_dir(cls) -> str:
diff --git a/zotify/podcast.py b/zotify/podcast.py
index 63863cf..67f1b41 100644
--- a/zotify/podcast.py
+++ b/zotify/podcast.py
@@ -7,7 +7,7 @@ from librespot.metadata import EpisodeId
 
 from zotify.const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS
 from zotify.termoutput import PrintChannel, Printer
-from zotify.utils import create_download_directory, fix_filename, convert_audio_format
+from zotify.utils import create_download_directory, fix_filename
 from zotify.zotify import Zotify
 from zotify.loader import Loader
 
@@ -24,7 +24,7 @@ def get_episode_info(episode_id_str) -> Tuple[Optional[str], Optional[str]]:
     duration_ms = info[DURATION_MS]
     if ERROR in info:
         return None, None
-    return fix_filename(info[SHOW][NAME]), duration_ms,  fix_filename(info[NAME])
+    return fix_filename(info[SHOW][NAME]), duration_ms, fix_filename(info[NAME])
 
 
 def get_show_episodes(show_id_str) -> list:
@@ -47,7 +47,6 @@ def get_show_episodes(show_id_str) -> list:
 
 def download_podcast_directly(url, filename):
     import functools
-    # import pathlib
     import shutil
     import requests
     from tqdm.auto import tqdm
@@ -59,7 +58,6 @@ def download_podcast_directly(url, filename):
             f"Request to {url} returned status code {r.status_code}")
     file_size = int(r.headers.get('Content-Length', 0))
 
-    # path = pathlib.Path(filename).expanduser().resolve()
     path = Path(filename).expanduser().resolve()
     path.parent.mkdir(parents=True, exist_ok=True)
 
diff --git a/zotify/track.py b/zotify/track.py
index b6b0019..a3fcef4 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -1,4 +1,3 @@
-# import os
 from pathlib import Path, PurePath
 import re
 import time
@@ -152,8 +151,6 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
         if not check_id and check_name:
             c = len([file for file in Path(filedir).iterdir() if re.search(f'^{filename}_', str(file))]) + 1
 
-            # fname = os.path.splitext(os.path.basename(filename))[0]
-            # ext = os.path.splitext(os.path.basename(filename))[1]
             fname = PurePath(PurePath(filename).name).parent
             ext = PurePath(PurePath(filename).name).suffix
 
@@ -252,7 +249,6 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
 
 def convert_audio_format(filename) -> None:
     """ Converts raw audio into playable file """
-    # temp_filename = f'{os.path.splitext(filename)[0]}.tmp'
     temp_filename = f'{PurePath(filename).parent}.tmp'
     Path(filename).replace(temp_filename)
 
diff --git a/zotify/utils.py b/zotify/utils.py
index 0d051d6..05084c2 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_song_archive()
+    archive_path = Zotify.CONFIG.get_track_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_song_archive()
+    archive_path = Zotify.CONFIG.get_track_archive()
 
     if Path(archive_path).exists():
         with open(archive_path, 'a', encoding='utf-8') as file:
@@ -109,11 +109,12 @@ def split_input(selection) -> List[str]:
 def splash() -> str:
     """ Displays splash screen """
     return """
-███████  ██████  ████████ ██ ███████ ██    ██
-   ███  ██    ██    ██    ██ ██       ██  ██
-  ███   ██    ██    ██    ██ █████     ████
- ███    ██    ██    ██    ██ ██         ██
-███████  ██████     ██    ██ ██         ██
+███████╗ ██████╗ ████████╗██╗███████╗██╗   ██╗
+╚══███╔╝██╔═══██╗╚══██╔══╝██║██╔════╝╚██╗ ██╔╝
+  ███╔╝ ██║   ██║   ██║   ██║█████╗   ╚████╔╝ 
+ ███╔╝  ██║   ██║   ██║   ██║██╔══╝    ╚██╔╝  
+███████╗╚██████╔╝   ██║   ██║██║        ██║   
+╚══════╝ ╚═════╝    ╚═╝   ╚═╝╚═╝        ╚═╝   
     """
 
 
diff --git a/zotify/zotify.py b/zotify/zotify.py
index d963359..dd40d6d 100644
--- a/zotify/zotify.py
+++ b/zotify/zotify.py
@@ -1,7 +1,5 @@
-import os
-import os.path
 from pathlib import Path
-from getpass import getpass
+from pwinput import pwinput
 import time
 import requests
 from librespot.audio.decoders import VorbisOnlyAudioQuality
@@ -29,7 +27,8 @@ class Zotify:
 
         if Path(cred_location).is_file():
             try:
-                cls.SESSION = Session.Builder().stored_file(cred_location).create()
+                conf = Session.Configuration.Builder().set_store_credentials(False).build()
+                cls.SESSION = Session.Builder(conf).stored_file(cred_location).create()
                 return
             except RuntimeError:
                 pass
@@ -37,7 +36,7 @@ class Zotify:
             user_name = ''
             while len(user_name) == 0:
                 user_name = input('Username: ')
-            password = getpass()
+            password = pwinput(prompt='Password: ', mask='*')
             try:
                 conf = Session.Configuration.Builder().set_stored_credential_file(cred_location).build()
                 cls.SESSION = Session.Builder(conf).user_pass(user_name, password).create()
@@ -80,7 +79,10 @@ class Zotify:
         headers = cls.get_auth_header()
         response = requests.get(url, headers=headers)
         responsetext = response.text
-        responsejson = response.json()
+        try:
+            responsejson = response.json()
+        except requests.exceptions.JSONDecodeError:
+            responsejson = {}
 
         if 'error' in responsejson:
             if tryCount < (cls.CONFIG.get_retry_attempts() - 1):

From 1e48861df366683e3021a735e70ab4a7499e90c8 Mon Sep 17 00:00:00 2001
From: logykk <joshbelcher00@gmail.com>
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.
 
 <p align="center">
   <img src="https://i.imgur.com/hGXQWSl.png" width="50%">
@@ -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 <joshbelcher00@gmail.com>
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 <joshbelcher00@gmail.com>
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 <logyx1@protonmail.com>
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 <joshbelcher00@gmail.com>
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 <logyx1@protonmail.com>
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 <logyx1@protonmail.com>
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 <joshbelcher00@gmail.com>
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 <joshbelcher00@gmail.com>
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 <joshbelcher00@gmail.com>
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 <joshbelcher00@gmail.com>
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 <joshbelcher00@gmail.com>
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 <joshbelcher00@gmail.com>
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 <joshbelcher00@gmail.com>
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 <logyx1@protonmail.com>
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 @@
   <img src="https://i.imgur.com/hGXQWSl.png" width="50%">
 </p>
 
-[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 <joshbelcher00@gmail.com>
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 <joshbelcher00@gmail.com>
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 <logyx1@protonmail.com>
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 <joshbelcher00@gmail.com>
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 <joshbelcher00@gmail.com>
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 <zspotsaver@protonmail.com>
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 <zotify@noreply.zotify.xyz>
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 <zspotsaver@protonmail.com>
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 <zotify@localhost>
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 <logykk@localhost>
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 <noreply@zotify.xyz>
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!
+
+![Logo banner](https://s1.fileditch.ch/hOwJhfeCFEsYFRWUWaz.png)
+
+# Zotify
+
+A customizable music and podcast downloader. \
+Formerly ZSp‌otify.
+
+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 <items to download>`
+
+### 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 <search>  Searches for items to download
+```
+
+<details><summary>All configuration options</summary>
+
+| Config key              | Command line argument     | Description                                         |
+|-------------------------|---------------------------|-----------------------------------------------------|
+| path_credentials        | --path-credentials        | Path to credentials file                            |
+| path_archive            | --path-archive            | Path to track archive file                          |
+| music_library           | --music-library           | Path to root of music library                       |
+| 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       | 
+</details>
+
+### More about search
+- `-c` or `--category` can be used to limit search results to certain categories.
+    - Available categories are "album", "artist", "playlist", "track", "show" and "episode".
+    - You can search in multiple categories at once
+- 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 Sp‌otify 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 <noreply@zotify.xyz>
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 <noreply@zotify.xyz>
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 <gabriel@selinschek.com>
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 <gabriel@selinschek.com>
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 <zotify@noreply.zotify.xyz>
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 <noreply@zotify.xyz>
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 <noreply@zotify.xyz>
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 ZSp‌otify.
 Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify).
 
 ## Features
-- Save tracks at up to 320kbps*
+
+- Save tracks at up to 320kbps\*
 - Save to most popular audio formats
 - Built in search
 - Bulk downloads
@@ -18,9 +19,10 @@ Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https:/
 - Embedded metadata
 - Downloads all audio, metadata and lyrics directly, no substituting from other services.
 
-*Non-premium accounts are limited to 160kbps
+\*Non-premium accounts are limited to 160kbps
 
 ## Installation
+
 Requires Python 3.10 or greater. \
 Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis.
 
@@ -30,10 +32,12 @@ Enter the following command in terminal to install Zotify. \
 ## General Usage
 
 ### Simplest usage
+
 Downloads specified items. Accepts any combination of track, album, playlist, episode or artists, URLs or URIs. \
 `zotify <items to download>`
 
 ### Basic options
+
 ```
     -p,  --playlist         Download selection of user's saved playlists
     -lt, --liked-tracks     Download user's liked tracks
@@ -45,7 +49,7 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
 <details><summary>All configuration options</summary>
 
 | Config key              | Command line argument     | Description                                         |
-|-------------------------|---------------------------|-----------------------------------------------------|
+| ----------------------- | ------------------------- | --------------------------------------------------- |
 | path_credentials        | --path-credentials        | Path to credentials file                            |
 | path_archive            | --path-archive            | Path to track archive file                          |
 | music_library           | --music-library           | Path to root of music library                       |
@@ -61,27 +65,31 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
 | ffmpeg_path             | --ffmpeg-path             | Path to ffmpeg binary                               |
 | ffmpeg_args             | --ffmpeg-args             | Additional ffmpeg arguments when transcoding        |
 | save_credentials        | --save-credentials        | Save login credentials to a file                    |
-| save_subtitles          | --save-subtitles          | 
-| save_artist_genres      | --save-arist-genres       | 
+| save_subtitles          | --save-subtitles          |
+| save_artist_genres      | --save-arist-genres       |
+
 </details>
 
 ### More about search
+
 - `-c` or `--category` can be used to limit search results to certain categories.
-    - Available categories are "album", "artist", "playlist", "track", "show" and "episode".
-    - You can search in multiple categories at once
+  - Available categories are "album", "artist", "playlist", "track", "show" and "episode".
+  - You can search in multiple categories at once
 - You can also narrow down results by using field filters in search queries
-    - Currently available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre.
-    - Available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre.
-    - The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1970-1982).
-    - The album filter can be used while searching albums and tracks.
-    - The genre filter can be used while searching artists and tracks.
-    - The isrc and track filters can be used while searching tracks.
-    - The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to show only albums in the lowest 10% of popularity. 
+  - Currently available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre.
+  - Available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre.
+  - The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1970-1982).
+  - The album filter can be used while searching albums and tracks.
+  - The genre filter can be used while searching artists and tracks.
+  - The isrc and track filters can be used while searching tracks.
+  - The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to show only albums in the lowest 10% of popularity.
 
 ## Usage as a library
+
 Zotify can be used as a user-friendly library for saving music, podcasts, lyrics and metadata.
 
 Here's a very simple example of downloading a track and its metadata:
+
 ```python
 import zotify
 
@@ -96,10 +104,11 @@ file.write_cover_art(track.get_cover_art())
 ```
 
 ## Contributing
+
 Pull requests are always welcome, but if adding an entirely new feature we encourage you to create an issue proposing the feature first so we can ensure it's something that fits sthe scope of the project.
 
-Zotify aims to be a comprehensive and user-friendly tool for downloading music and podcasts. 
-It is designed to be simple by default but offer a high level of configuration for users that want it. 
+Zotify aims to be a comprehensive and user-friendly tool for downloading music and podcasts.
+It is designed to be simple by default but offer a high level of configuration for users that want it.
 All new contributions should follow this principle to keep the program consistent.
 
 ## Will my account get banned if I use this tool?
@@ -110,12 +119,14 @@ However, it is still a possiblity and it is recommended you use Zotify with a bu
 Consider using [Exportify](https://github.com/watsonbox/exportify) to keep backups of your playlists.
 
 ## Disclaimer
+
 Using Zotify violates Sp‌otify 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 <zotify@noreply.zotify.xyz>
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 <noreply@zotify.xyz>
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 <logykk@localhost>
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 <noreply@zotify.xyz>
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 <octoshrimpy@noreply.zotify.xyz>
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.
 
 <p align="center">
-  <img src="https://i.imgur.com/hGXQWSl.png" width="50%">
+  <img src="https://i.imgur.com/hGXQWSl.png" width="50%" alt="Zotify logo">
 </p>
 
-### 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\<USERNAME>\AppData\Roaming\Zotify\config.json`|
+| MacOS           | `/Users/<USERNAME>/.config/zotify/config.json`          |
+| Linux           | `/home/<USERNAME>/.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 <octoshrimpy@noreply.zotify.xyz>
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 <octoshrimpy@noreply.zotify.xyz>
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/<USERNAME>/.config/zotify/config.json`          |
 | Linux           | `/home/<USERNAME>/.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 <octoshrimpy@noreply.zotify.xyz>
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 <octoshrimpy@noreply.zotify.xyz>
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/<USERNAME>/.config/zotify/config.json`          |
 | Linux           | `/home/<USERNAME>/.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 <octoshrimpy@noreply.zotify.xyz>
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 <octoshrimpy@noreply.zotify.xyz>
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 <octoshrimpy@noreply.zotify.xyz>
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 <track/album/playlist/episode/artist url>   Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls.
 
 Basic options:
-  (nothing)        Download the tracks/alumbs/playlists URLs from the parameter
-  -d, --download   Download all tracks/alumbs/playlists URLs from the specified file
+  (nothing)        Download the tracks/albums/playlists URLs from the parameter
+  -d, --download   Download all tracks/albums/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

From ac4fff4e1f46207fc4043755851c6bd8cb4b5e9a Mon Sep 17 00:00:00 2001
From: Zotify <zotify@noreply.zotify.xyz>
Date: Tue, 12 Sep 2023 23:16:24 +0200
Subject: [PATCH 084/169] Reference INSTALLATION.md in README

---
 README.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index 7e18f23..0da8bed 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,8 @@ Installation:
 python -m pip install git+https://zotify.xyz/zotify/zotify.git
 ```
 
+See [INSTALLATION](INSTALLATION.md) for a more detailed and opinionated installation walkthrough.
+
 ### Command line usage
 
 ```
@@ -121,10 +123,8 @@ Example values could be:
 ~~~~
 {playlist}/{artist} - {song_name}.{ext}
 {playlist}/{playlist_num} - {artist} - {song_name}.{ext}
-Bangers/{artist} - {song_name}.{ext}
 {artist} - {song_name}.{ext}
 {artist}/{album}/{album_num} - {artist} - {song_name}.{ext}
-/home/user/downloads/{artist} - {song_name} [{id}].{ext}
 ~~~~
 
 ### Docker Usage

From e236b4d38c65c4586a3ef711cb28fdc65a9eb2c1 Mon Sep 17 00:00:00 2001
From: Zotify <zotify@noreply.zotify.xyz>
Date: Thu, 5 Oct 2023 23:39:09 +0200
Subject: [PATCH 085/169] Correct macos config path in README

---
 README.md | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/README.md b/README.md
index 0da8bed..a4d624c 100644
--- a/README.md
+++ b/README.md
@@ -91,10 +91,10 @@ Be aware you have to set boolean values in the commandline like this: `--downloa
 
 You can find the configuration file in following locations:  
 | OS              | Location          
-|-----------------|---------------------------------------------------------|
-| Windows         | `C:\Users\<USERNAME>\AppData\Roaming\Zotify\config.json`|
-| MacOS           | `/Users/<USERNAME>/.config/zotify/config.json`          |
-| Linux           | `/home/<USERNAME>/.config/zotify/config.json`           |
+|-----------------|-------------------------------------------------------------------|
+| Windows         | `C:\Users\<USERNAME>\AppData\Roaming\Zotify\config.json`          |
+| MacOS           | `/Users/<USERNAME>/Library/ApplicationSupport/Zotify/config.json` |
+| Linux           | `/home/<USERNAME>/.config/zotify/config.json`                     |
 
 To log out, just remove the configuration file. Uninstalling Zotify does not remove the config file.
 

From 5b705a1d7d0ee0949f30e19d35f030577c2e05c8 Mon Sep 17 00:00:00 2001
From: Simon <103917755+SimsesLab@users.noreply.github.com>
Date: Wed, 15 Nov 2023 17:50:10 +0100
Subject: [PATCH 086/169] Update README.md

Added 'Defaults' column to Options table
---
 README.md | 56 +++++++++++++++++++++++++++----------------------------
 1 file changed, 28 insertions(+), 28 deletions(-)

diff --git a/README.md b/README.md
index a4d624c..e54b822 100644
--- a/README.md
+++ b/README.md
@@ -56,34 +56,34 @@ Basic options:
 All these options can either be configured in the config or via the commandline, in case of both the commandline-option has higher priority.  
 Be aware you have to set boolean values in the commandline like this: `--download-real-time=True`
 
-| Key (config)                 | commandline parameter            | Description
-|------------------------------|----------------------------------|---------------------------------------------------------------------|
-| CREDENTIALS_LOCATION         | --credentials-location           | The location of the credentials.json
-| OUTPUT                       | --output                         | The output location/format (see below)
-| 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
-| 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)
-| 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                  | 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
+| Key (config)                 | Commandline parameter            | Defaults | Description
+|------------------------------|----------------------------------|----------|---------------------------------------------------------------------|
+| CREDENTIALS_LOCATION         | --credentials-location           |          | The location of the credentials.json
+| OUTPUT                       | --output                         |          | The output location/format (see below)
+| 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              | False    | Saves each disk in its own folder
+| DOWNLOAD_LYRICS              | --download-lyrics                | True     | Downloads synced lyrics in .lrc format, uses unsynced as fallback.
+| MD_ALLGENRES                 | --md-allgenres                   | False    | Save all relevant genres in metadata
+| MD_GENREDELIMITER            | --md-genredelimiter              | ,        | Delimiter character used to split genres in metadata
+| DOWNLOAD_FORMAT              | --download-format                | ogg      | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis)
+| DOWNLOAD_QUALITY             | --download-quality               | auto     | Audio quality of downloaded songs (normal, high, very_high*)
+| TRANSCODE_BITRATE            | --transcode-bitrate              | auto     | Overwrite the bitrate for ffmpeg encoding
+| SKIP_EXISTING_FILES          | --skip-existing                  | True     | Skip songs with the same name
+| SKIP_PREVIOUSLY_DOWNLOADED   | --skip-previously-downloaded     | False    | Use a song_archive file to skip previously downloaded songs
+| RETRY_ATTEMPTS               | --retry-attempts                 | 1        | Number of times Zotify will retry a failed request
+| BULK_WAIT_TIME               | --bulk-wait-time                 | 1        | The wait time between bulk downloads
+| OVERRIDE_AUTO_WAIT           | --override-auto-wait             | False    | Totally disable wait time between songs with the risk of instability
+| CHUNK_SIZE                   | --chunk-size                     | 20000    | Chunk size for downloading
+| DOWNLOAD_REAL_TIME           | --download-real-time             | False    | Downloads songs as fast as they would be played, should prevent account bans.
+| LANGUAGE                     | --language                       | en       | Language for spotify metadata
+| PRINT_SPLASH                 | --print-splash                   | False    | Show the Zotify logo at startup
+| PRINT_SKIPS                  | --print-skips                    | True     | Show messages if a song is being skipped
+| PRINT_DOWNLOAD_PROGRESS      | --print-download-progress        | True     | Show download/playlist progress bars
+| PRINT_ERRORS                 | --print-errors                   | True     | Show errors
+| PRINT_DOWNLOADS              | --print-downloads                | False    | 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  
 

From 1aa74e0197e0b96d0f8cde49aa98e92ebaa4d289 Mon Sep 17 00:00:00 2001
From: Krzysztof Nasuta <krzysztof.nasuta@wp.pl>
Date: Wed, 24 Jan 2024 18:49:16 +0100
Subject: [PATCH 087/169] fix: bulk download wait

---
 zotify/track.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/zotify/track.py b/zotify/track.py
index bb2af89..a739a3d 100644
--- a/zotify/track.py
+++ b/zotify/track.py
@@ -280,7 +280,7 @@ 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_bulk_wait_time():
+                    if 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)   ###')

From 5da27d32a1f522e80a3129c61f939b1934a0824a Mon Sep 17 00:00:00 2001
From: Logykk <logykk@logykk.stream>
Date: Wed, 7 Feb 2024 18:10:08 +1300
Subject: [PATCH 088/169] version bump

---
 CHANGELOG.md    | 4 ++++
 setup.cfg       | 2 +-
 zotify/const.py | 2 ++
 zotify/utils.py | 9 +++++++--
 4 files changed, 14 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95e6ff1..17f30b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## 0.6.13
+- Only replace chars with _ when required
+- Added defaults to README
+
 ## 0.6.12
 - Dockerfile works again
 - Fixed lrc file extension replacement
diff --git a/setup.cfg b/setup.cfg
index af73781..1706618 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = zotify
-version = 0.6.12
+version = 0.6.13
 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 770f6be..e269a90 100644
--- a/zotify/const.py
+++ b/zotify/const.py
@@ -92,6 +92,8 @@ USER_LIBRARY_READ = 'user-library-read'
 
 WINDOWS_SYSTEM = 'Windows'
 
+LINUX_SYSTEM = 'Linux'
+
 CODEC_MAP = {
     'aac': 'aac',
     'fdk_aac': 'libfdk_aac',
diff --git a/zotify/utils.py b/zotify/utils.py
index df8a661..dce9fd2 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -12,7 +12,7 @@ import music_tag
 import requests
 
 from zotify.const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \
-    WINDOWS_SYSTEM, ALBUMARTIST
+    WINDOWS_SYSTEM, LINUX_SYSTEM, ALBUMARTIST
 from zotify.zotify import Zotify
 
 
@@ -258,7 +258,12 @@ def fix_filename(name):
     >>> all('_' == fix_filename(chr(i)) for i in list(range(32)))
     True
     """
-    return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE)
+    if platform.system() == WINDOWS_SYSTEM:
+        return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE)
+    elif platform.system() == LINUX_SYSTEM:
+        return re.sub(r'[/\0]', "_", str(name))
+    else: # MacOS
+        return re.sub(r'[/:\0]', "_", str(name))
 
 
 def fmt_seconds(secs: float) -> str:

From 360e342bc2b13d672e76bc8fafe27f000b499d94 Mon Sep 17 00:00:00 2001
From: Zotify <zotify@localhost>
Date: Sat, 17 Feb 2024 17:59:23 +1300
Subject: [PATCH 089/169] changes

---
 CHANGELOG.md                     |  11 +-
 LICENCE                          |   2 +-
 Pipfile                          |  18 ++
 Pipfile.lock                     | 414 ++++++++++++++++++++++++++++
 README.md                        |  59 ++--
 assets/banner.png                | Bin 0 -> 112613 bytes
 requirements_dev.txt             |   1 +
 setup.cfg                        |   9 +-
 zotify/__init__.py               | 110 ++++----
 zotify/__main__.py               |   7 +-
 zotify/app.py                    | 444 ++++++++++++-------------------
 zotify/collections.py            |  95 +++++++
 zotify/config.py                 |  26 +-
 zotify/file.py                   |   3 +-
 zotify/loader.py                 |   8 +-
 zotify/{printer.py => logger.py} |  14 +-
 zotify/playable.py               |  80 +++---
 zotify/utils.py                  |  85 +++---
 18 files changed, 923 insertions(+), 463 deletions(-)
 create mode 100644 Pipfile
 create mode 100644 Pipfile.lock
 create mode 100644 assets/banner.png
 create mode 100644 zotify/collections.py
 rename zotify/{printer.py => logger.py} (85%)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 788a032..b1830b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,8 +2,6 @@
 
 ## 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.
@@ -12,7 +10,7 @@ An unexpected reboot.
 
 ### Changes
 
-- Genre metadata available for tracks downloaded from an album
+- Genre metadata available for all tracks
 - 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
@@ -24,10 +22,12 @@ An unexpected reboot.
 - 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
+- Replaced ffmpy with custom implementation providing more tags
+- Fixed artist download missing some tracks
 
 ### Additions
 
+- New library location for playlists `playlist_library`
 - Added new command line arguments
   - `--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.
@@ -52,13 +52,13 @@ An unexpected reboot.
   - `{album_artist}`
   - `{album_artists}`
   - `{duration}` (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
@@ -79,6 +79,7 @@ An unexpected reboot.
 - 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`
+  - `chunk_size`
   - `download_real_time`
   - `md_allgenres`
   - `md_genredelimiter`
diff --git a/LICENCE b/LICENCE
index d3ba069..c012b87 100644
--- a/LICENCE
+++ b/LICENCE
@@ -1,4 +1,4 @@
-Copyright (c) 2022 Zotify Contributors
+Copyright (c) 2024 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
diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..2fc6f0d
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,18 @@
+[[source]]
+url = "https://pypi.org/simple"
+verify_ssl = true
+name = "pypi"
+
+[packages]
+librespot = {git = "git+https://github.com/kokarare1212/librespot-python"}
+music-tag = {git = "git+https://zotify.xyz/zotify/music-tag"}
+mutagen = "*"
+pillow = "*"
+pwinput = "*"
+requests = "*"
+tqdm = "*"
+
+[dev-packages]
+
+[requires]
+python_version = "3.11"
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 0000000..4eb010d
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,414 @@
+{
+    "_meta": {
+        "hash": {
+            "sha256": "dfbc5e27f802eeeddf2967a8d8d280346f8e3b4e4759b4bea10f59dbee08a0ee"
+        },
+        "pipfile-spec": 6,
+        "requires": {
+            "python_version": "3.11"
+        },
+        "sources": [
+            {
+                "name": "pypi",
+                "url": "https://pypi.org/simple",
+                "verify_ssl": true
+            }
+        ]
+    },
+    "default": {
+        "certifi": {
+            "hashes": [
+                "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f",
+                "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==2024.2.2"
+        },
+        "charset-normalizer": {
+            "hashes": [
+                "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027",
+                "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087",
+                "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786",
+                "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8",
+                "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09",
+                "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185",
+                "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574",
+                "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e",
+                "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519",
+                "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898",
+                "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269",
+                "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3",
+                "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f",
+                "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6",
+                "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8",
+                "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a",
+                "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73",
+                "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc",
+                "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714",
+                "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2",
+                "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc",
+                "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce",
+                "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d",
+                "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e",
+                "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6",
+                "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269",
+                "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96",
+                "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d",
+                "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a",
+                "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4",
+                "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77",
+                "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d",
+                "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0",
+                "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed",
+                "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068",
+                "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac",
+                "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25",
+                "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8",
+                "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab",
+                "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26",
+                "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2",
+                "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db",
+                "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f",
+                "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5",
+                "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99",
+                "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c",
+                "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d",
+                "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811",
+                "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa",
+                "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a",
+                "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03",
+                "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b",
+                "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04",
+                "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c",
+                "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001",
+                "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458",
+                "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389",
+                "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99",
+                "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985",
+                "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537",
+                "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238",
+                "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f",
+                "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d",
+                "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796",
+                "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a",
+                "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143",
+                "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8",
+                "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c",
+                "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5",
+                "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5",
+                "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711",
+                "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4",
+                "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6",
+                "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c",
+                "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7",
+                "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4",
+                "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b",
+                "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae",
+                "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12",
+                "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c",
+                "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae",
+                "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8",
+                "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887",
+                "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b",
+                "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4",
+                "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f",
+                "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5",
+                "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33",
+                "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519",
+                "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"
+            ],
+            "markers": "python_full_version >= '3.7.0'",
+            "version": "==3.3.2"
+        },
+        "defusedxml": {
+            "hashes": [
+                "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69",
+                "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==0.7.1"
+        },
+        "idna": {
+            "hashes": [
+                "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca",
+                "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==3.6"
+        },
+        "ifaddr": {
+            "hashes": [
+                "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748",
+                "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4"
+            ],
+            "version": "==0.2.0"
+        },
+        "librespot": {
+            "git": "git+https://github.com/kokarare1212/librespot-python",
+            "ref": "f56533f9b56e62b28bac6c57d0710620aeb6a5dd"
+        },
+        "music-tag": {
+            "git": "git+https://zotify.xyz/zotify/music-tag",
+            "ref": "5c73ddf11a6d65d6575c0e1bb8cce8413f46a433"
+        },
+        "mutagen": {
+            "hashes": [
+                "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99",
+                "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.7'",
+            "version": "==1.47.0"
+        },
+        "pillow": {
+            "hashes": [
+                "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8",
+                "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39",
+                "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac",
+                "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869",
+                "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e",
+                "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04",
+                "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9",
+                "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e",
+                "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe",
+                "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef",
+                "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56",
+                "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa",
+                "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f",
+                "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f",
+                "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e",
+                "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a",
+                "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2",
+                "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2",
+                "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5",
+                "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a",
+                "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2",
+                "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213",
+                "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563",
+                "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591",
+                "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c",
+                "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2",
+                "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb",
+                "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757",
+                "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0",
+                "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452",
+                "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad",
+                "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01",
+                "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f",
+                "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5",
+                "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61",
+                "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e",
+                "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b",
+                "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068",
+                "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9",
+                "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588",
+                "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483",
+                "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f",
+                "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67",
+                "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7",
+                "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311",
+                "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6",
+                "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72",
+                "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6",
+                "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129",
+                "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13",
+                "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67",
+                "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c",
+                "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516",
+                "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e",
+                "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e",
+                "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364",
+                "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023",
+                "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1",
+                "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04",
+                "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d",
+                "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a",
+                "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7",
+                "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb",
+                "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4",
+                "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e",
+                "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1",
+                "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48",
+                "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==10.2.0"
+        },
+        "protobuf": {
+            "hashes": [
+                "sha256:06059eb6953ff01e56a25cd02cca1a9649a75a7e65397b5b9b4e929ed71d10cf",
+                "sha256:097c5d8a9808302fb0da7e20edf0b8d4703274d140fd25c5edabddcde43e081f",
+                "sha256:284f86a6207c897542d7e956eb243a36bb8f9564c1742b253462386e96c6b78f",
+                "sha256:32ca378605b41fd180dfe4e14d3226386d8d1b002ab31c969c366549e66a2bb7",
+                "sha256:3cc797c9d15d7689ed507b165cd05913acb992d78b379f6014e013f9ecb20996",
+                "sha256:62f1b5c4cd6c5402b4e2d63804ba49a327e0c386c99b1675c8a0fefda23b2067",
+                "sha256:69ccfdf3657ba59569c64295b7d51325f91af586f8d5793b734260dfe2e94e2c",
+                "sha256:6f50601512a3d23625d8a85b1638d914a0970f17920ff39cec63aaef80a93fb7",
+                "sha256:7403941f6d0992d40161aa8bb23e12575637008a5a02283a930addc0508982f9",
+                "sha256:755f3aee41354ae395e104d62119cb223339a8f3276a0cd009ffabfcdd46bb0c",
+                "sha256:77053d28427a29987ca9caf7b72ccafee011257561259faba8dd308fda9a8739",
+                "sha256:7e371f10abe57cee5021797126c93479f59fccc9693dafd6bd5633ab67808a91",
+                "sha256:9016d01c91e8e625141d24ec1b20fed584703e527d28512aa8c8707f105a683c",
+                "sha256:9be73ad47579abc26c12024239d3540e6b765182a91dbc88e23658ab71767153",
+                "sha256:adc31566d027f45efe3f44eeb5b1f329da43891634d61c75a5944e9be6dd42c9",
+                "sha256:adfc6cf69c7f8c50fd24c793964eef18f0ac321315439d94945820612849c388",
+                "sha256:af0ebadc74e281a517141daad9d0f2c5d93ab78e9d455113719a45a49da9db4e",
+                "sha256:cb29edb9eab15742d791e1025dd7b6a8f6fcb53802ad2f6e3adcb102051063ab",
+                "sha256:cd68be2559e2a3b84f517fb029ee611546f7812b1fdd0aa2ecc9bc6ec0e4fdde",
+                "sha256:cdee09140e1cd184ba9324ec1df410e7147242b94b5f8b0c64fc89e38a8ba531",
+                "sha256:db977c4ca738dd9ce508557d4fce0f5aebd105e158c725beec86feb1f6bc20d8",
+                "sha256:dd5789b2948ca702c17027c84c2accb552fc30f4622a98ab5c51fcfe8c50d3e7",
+                "sha256:e250a42f15bf9d5b09fe1b293bdba2801cd520a9f5ea2d7fb7536d4441811d20",
+                "sha256:ff8d8fa42675249bb456f5db06c00de6c2f4c27a065955917b28c4f15978b9c3"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==3.20.1"
+        },
+        "pwinput": {
+            "hashes": [
+                "sha256:ca1a8bd06e28872d751dbd4132d8637127c25b408ea3a349377314a5491426f3"
+            ],
+            "index": "pypi",
+            "version": "==1.0.3"
+        },
+        "pycryptodomex": {
+            "hashes": [
+                "sha256:0daad007b685db36d977f9de73f61f8da2a7104e20aca3effd30752fd56f73e1",
+                "sha256:108e5f1c1cd70ffce0b68739c75734437c919d2eaec8e85bffc2c8b4d2794305",
+                "sha256:19764605feea0df966445d46533729b645033f134baeb3ea26ad518c9fdf212c",
+                "sha256:1be97461c439a6af4fe1cf8bf6ca5936d3db252737d2f379cc6b2e394e12a458",
+                "sha256:25cd61e846aaab76d5791d006497134602a9e451e954833018161befc3b5b9ed",
+                "sha256:2a47bcc478741b71273b917232f521fd5704ab4b25d301669879e7273d3586cc",
+                "sha256:59af01efb011b0e8b686ba7758d59cf4a8263f9ad35911bfe3f416cee4f5c08c",
+                "sha256:5dcac11031a71348faaed1f403a0debd56bf5404232284cf8c761ff918886ebc",
+                "sha256:62a5ec91388984909bb5398ea49ee61b68ecb579123694bffa172c3b0a107079",
+                "sha256:645bd4ca6f543685d643dadf6a856cc382b654cc923460e3a10a49c1b3832aeb",
+                "sha256:653b29b0819605fe0898829c8ad6400a6ccde096146730c2da54eede9b7b8baa",
+                "sha256:69138068268127cd605e03438312d8f271135a33140e2742b417d027a0539427",
+                "sha256:6e186342cfcc3aafaad565cbd496060e5a614b441cacc3995ef0091115c1f6c5",
+                "sha256:76bd15bb65c14900d98835fcd10f59e5e0435077431d3a394b60b15864fddd64",
+                "sha256:7805830e0c56d88f4d491fa5ac640dfc894c5ec570d1ece6ed1546e9df2e98d6",
+                "sha256:7a710b79baddd65b806402e14766c721aee8fb83381769c27920f26476276c1e",
+                "sha256:7a7a8f33a1f1fb762ede6cc9cbab8f2a9ba13b196bfaf7bc6f0b39d2ba315a43",
+                "sha256:82ee7696ed8eb9a82c7037f32ba9b7c59e51dda6f105b39f043b6ef293989cb3",
+                "sha256:88afd7a3af7ddddd42c2deda43d53d3dfc016c11327d0915f90ca34ebda91499",
+                "sha256:8af1a451ff9e123d0d8bd5d5e60f8e3315c3a64f3cdd6bc853e26090e195cdc8",
+                "sha256:8ee606964553c1a0bc74057dd8782a37d1c2bc0f01b83193b6f8bb14523b877b",
+                "sha256:91852d4480a4537d169c29a9d104dda44094c78f1f5b67bca76c29a91042b623",
+                "sha256:9c682436c359b5ada67e882fec34689726a09c461efd75b6ea77b2403d5665b7",
+                "sha256:bc3ee1b4d97081260d92ae813a83de4d2653206967c4a0a017580f8b9548ddbc",
+                "sha256:bca649483d5ed251d06daf25957f802e44e6bb6df2e8f218ae71968ff8f8edc4",
+                "sha256:c39778fd0548d78917b61f03c1fa8bfda6cfcf98c767decf360945fe6f97461e",
+                "sha256:cbe71b6712429650e3883dc81286edb94c328ffcd24849accac0a4dbcc76958a",
+                "sha256:d00fe8596e1cc46b44bf3907354e9377aa030ec4cd04afbbf6e899fc1e2a7781",
+                "sha256:d3584623e68a5064a04748fb6d76117a21a7cb5eaba20608a41c7d0c61721794",
+                "sha256:e48217c7901edd95f9f097feaa0388da215ed14ce2ece803d3f300b4e694abea",
+                "sha256:f2e497413560e03421484189a6b65e33fe800d3bd75590e6d78d4dfdb7accf3b",
+                "sha256:ff5c9a67f8a4fba4aed887216e32cbc48f2a6fb2673bb10a99e43be463e15913"
+            ],
+            "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+            "version": "==3.20.0"
+        },
+        "pyogg": {
+            "hashes": [
+                "sha256:40f79b288b3a667309890885f4cf53371163b7dae17eb17567fb24ab467eca26",
+                "sha256:794db340fb5833afb4f493b40f91e3e0f594606fd4b31aea0ebf5be2de9da964",
+                "sha256:8294b34aa59c90200c4630c2cc4a5b84407209141e8e5d069d7a5be358e94262"
+            ],
+            "version": "==0.6.14a1"
+        },
+        "requests": {
+            "hashes": [
+                "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f",
+                "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.7'",
+            "version": "==2.31.0"
+        },
+        "tqdm": {
+            "hashes": [
+                "sha256:d302b3c5b53d47bce91fea46679d9c3c6508cf6332229aa1e7d8653723793386",
+                "sha256:d88e651f9db8d8551a62556d3cff9e3034274ca5d66e93197cf2490e2dcb69c7"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.7'",
+            "version": "==4.66.1"
+        },
+        "urllib3": {
+            "hashes": [
+                "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20",
+                "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==2.2.0"
+        },
+        "websocket-client": {
+            "hashes": [
+                "sha256:10e511ea3a8c744631d3bd77e61eb17ed09304c413ad42cf6ddfa4c7787e8fe6",
+                "sha256:f4c3d22fec12a2461427a29957ff07d35098ee2d976d3ba244e688b8b4057588"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==1.7.0"
+        },
+        "zeroconf": {
+            "hashes": [
+                "sha256:0251034ed1d57eeb4e08782b22cc51e2455da7552b592bfad69a5761e69241c7",
+                "sha256:02e3b6d1c1df87e8bc450de3f973ab9f4cfd1b4c0a3fb9e933d84580a1d61263",
+                "sha256:08eb87b0500ddc7c148fe3db3913e9d07d5495d756d7d75683f2dee8d7a09dc5",
+                "sha256:10e8d23cee434077a10ceec4b419b9de8c84ede7f42b64e735d0f0b7708b0c66",
+                "sha256:14f0bef6b4f7bd0caf80f207acd1e399e8d8a37e12266d80871a2ed6c9ee3b16",
+                "sha256:18ff5b28e8935e5399fe47ece323e15816bc2ea4111417c41fc09726ff056cd2",
+                "sha256:194cf1465a756c3090e23ef2a5bd3341caa8d36eef486054daa8e532a4e24ac8",
+                "sha256:1a57e0c4a94276ec690d2ecf1edeea158aaa3a7f38721af6fa572776dda6c8ad",
+                "sha256:2389e3a61e99bf74796da7ebc3001b90ecd4e6286f392892b1211748e5b19853",
+                "sha256:24b0a46c5f697cd6a0b27678ea65a3222b95f1804be6b38c6f5f1a7ce8b5cded",
+                "sha256:28d906fc0779badb2183f5b20dbcc7e508cce53a13e63ba4d9477381c9f77463",
+                "sha256:2907784c8c88795bf1b74cc9b6a4051e37a519ae2caaa7307787d466bc57884c",
+                "sha256:34c3379d899361cd9d6b573ea9ac1eba53e2306eb28f94353b58c4703f0e74ae",
+                "sha256:3768ab13a8d7f0df85e40e766edd9e2aef28710a350dc4b15e1f2c5dd1326f00",
+                "sha256:38bfd08c9191716d65e6ac52741442ee918bfe2db43993aa4d3b365966c0ab48",
+                "sha256:3a49aaff22bc576680b4bcb3c7de896587f6ab4adaa788bedbc468dd0ad28cce",
+                "sha256:3b167b9e47f3fec8cc28a8f73a9e47c563ceb6681c16dcbe2c7d41e084cee755",
+                "sha256:3bc16228495e67ec990668970e815b341160258178c21b7716400c5e7a78976a",
+                "sha256:3f49ec4e8d5bd860e9958e88e8b312e31828f5cb2203039390c551f3fb0b45dd",
+                "sha256:434344df3037df08bad7422d5d36a415f30ddcc29ac1ad0cc0160b4976b782b5",
+                "sha256:4713e5cd986f9467494e5b47b0149ac0ffd7ad630d78cd6f6d2555b199e5a653",
+                "sha256:4865ef65b7eb7eee1a38c05bf7e91dd8182ef2afb1add65440f99e8dd43836d2",
+                "sha256:52b65e5eeacae121695bcea347cc9ad7da5556afcd3765c461e652ca3e8a84e9",
+                "sha256:551c04799325c890f2baa347e82cd2c3fb1d01b14940d7695f27c49cd2413b0c",
+                "sha256:5d777b177cb472f7996b9d696b81337bfb846dbe454b8a34a8e33704d3a435b0",
+                "sha256:6a041468c428622798193f0006831237aa749ee23e26b5b79e457618484457ef",
+                "sha256:6c55a1627290ba0718022fb63cf5a25d773c52b00319ef474dd443ebe92efab1",
+                "sha256:7c4235f45defd43bb2402ff8d3c7ff5d740e671bfd926852541c282ebef992bc",
+                "sha256:8642d374481d8cc7be9e364b82bcd11bda4a095c24c5f9f5754017a118496b77",
+                "sha256:90c431e99192a044a5e0217afd7ca0ca9824af93190332e6f7baf4da5375f331",
+                "sha256:9a7f3b9a580af6bf74a7c435b80925dfeb065c987dffaf4d957d578366a80b2c",
+                "sha256:9dfa3d8827efffebec61b108162eeb76b0fe170a8379f9838be441f61b4557fd",
+                "sha256:a3f1d959e3a57afa6b383eb880048929473507b1cc0e8b5e1a72ddf0fc1bbb77",
+                "sha256:a613827f97ca49e2b4b6d6eb7e61a0485afe23447978a60f42b981a45c2b25fd",
+                "sha256:a984c93aa413a594f048ef7166f0d9be73b0cd16dfab1395771b7c0607e07817",
+                "sha256:b843d5e2d2e576efeab59e382907bca1302f20eb33ee1a0a485e90d017b1088a",
+                "sha256:bdb1a2a67e34059e69aaead600525e91c126c46502ada1c7fc3d2c082cc8ad27",
+                "sha256:bf9ec50ffdf4e179c035f96a106a5c510d5295c5fb7e2e69dd4cda7b7f42f8bf",
+                "sha256:c10158396d6875f790bfb5600391d44edcbf52ac4d148e19baab3e8bb7825f76",
+                "sha256:c3f0f87e47e4d5a9bcfcfc1ce29d0e9127a5cab63e839cc6f845c563f29d765c",
+                "sha256:c75bb2c1e472723067c7ec986ea510350c335bf8e73ad12617fc6a9ec765dc4b",
+                "sha256:cb2879708357cac9805d20944973f3d50b472c703b8eaadd9bf136024c5539b4",
+                "sha256:cc7a76103b03f47d2aa02206f74cc8b2120f4bac02936ccee5d6f29290f5bde5",
+                "sha256:ce67d8dab4d88bcd1e5975d08235590fc5b9f31b2e2b7993ee1680810e67e56d",
+                "sha256:d08170123f5c04480bd7a82122b46c5afdb91553a9cef7d686d3fb9c369a9204",
+                "sha256:d4baa0450b9b0f1bd8acc25c2970d4e49e54726cbc437b81ffb65e5ffb6bd321",
+                "sha256:d5d92987c3669edbfa9f911a8ef1c46cfd2c3e51971fc80c215f99212b81d4b1",
+                "sha256:e0d1357940b590466bc72ac605e6ad3f7f05b2e1475b6896ec8e4c61e4d23034",
+                "sha256:e7d51df61579862414ac544f2892ea3c91a6b45dd728d4fb6260d65bf6f1ef0f",
+                "sha256:f74149a22a6a27e4c039f6477188dcbcb910acd60529dab5c114ff6265d40ba7",
+                "sha256:fdcb9cb0555c7947f29a4d5c05c98e260a04f37d6af31aede1e981bf1bdf8691"
+            ],
+            "markers": "python_version >= '3.7' and python_version < '4.0'",
+            "version": "==0.131.0"
+        }
+    },
+    "develop": {}
+}
diff --git a/README.md b/README.md
index 923e565..a50d527 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,12 @@
-# STILL IN DEVELOPMENT, NOT RECOMMENDED FOR GENERAL USE!
-
-![Logo banner](https://s1.fileditch.ch/hOwJhfeCFEsYFRWUWaz.png)
+![Logo banner](./assets/banner.png)
 
 # Zotify
 
 A customizable music and podcast downloader. \
 Formerly ZSp‌otify.
 
-Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify).
+Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify). \
+Built on [Librespot](https://github.com/kokarare1212/librespot-python).
 
 ## Features
 
@@ -48,23 +47,23 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
 
 <details><summary>All configuration options</summary>
 
-| Config key              | Command line argument     | Description                                         |
-| ----------------------- | ------------------------- | --------------------------------------------------- |
-| path_credentials        | --path-credentials        | Path to credentials file                            |
-| path_archive            | --path-archive            | Path to track archive file                          |
-| music_library           | --music-library           | Path to root of music library                       |
-| 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                    |
+| Config key              | Command line argument     | Description                                         | Default                                                    |
+| ----------------------- | ------------------------- | --------------------------------------------------- | ---------------------------------------------------------- |
+| 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                        | {album_artist}/{album}/{track_number}. {artists} - {title} |
+| output_playlist_track   | --output-playlist-track   | File layout for tracks in a playlist                | {playlist}/{playlist_number}. {artists} - {title}          |
+| output_playlist_episode | --output-playlist-episode | File layout for episodes in a playlist              | {playlist}/{playlist_number}. {episode_number} - {title}   |
+| output_podcast          | --output-podcast          | File layout for saved podcasts                      | {podcast}/{episode_number} - {title}                       |
+| 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       |
 
@@ -91,9 +90,9 @@ Zotify can be used as a user-friendly library for saving music, podcasts, lyrics
 Here's a very simple example of downloading a track and its metadata:
 
 ```python
-import zotify
+from zotify import Session
 
-session = zotify.Session.from_userpass(username="username", password="password")
+session = Session.from_userpass(username="username", password="password")
 track = session.get_track("4cOdK2wGLETKBW3PvgPWqT")
 output = track.create_output("./Music", "{artist} - {title}")
 
@@ -113,20 +112,14 @@ All new contributions should follow this principle to keep the program consisten
 
 ## Will my account get banned if I use this tool?
 
-No user has reported their account getting banned after using Zotify
+There have been no confirmed cases of accounts getting banned as a result of 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.
+Consider using [Exportify](https://watsonbox.github.io/exportify/) to keep backups of your playlists.
 
 ## Disclaimer
 
 Using Zotify violates Sp‌otify 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.
+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 are not liable for damages caused by the use of this tool. See the [LICENSE](./LICENCE) file for more details.
diff --git a/assets/banner.png b/assets/banner.png
new file mode 100644
index 0000000000000000000000000000000000000000..c1d63dbbedd240a7a01e499820c6f077068f3ae1
GIT binary patch
literal 112613
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
A3;+NC

literal 0
HcmV?d00001

diff --git a/requirements_dev.txt b/requirements_dev.txt
index 624c4eb..7caa5e4 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -2,5 +2,6 @@ black
 flake8
 mypy
 pre-commit
+types-protobuf
 types-requests
 wheel
diff --git a/setup.cfg b/setup.cfg
index 8db7ba7..94867d4 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = zotify
-version = 0.9.2
+version = 0.9.4
 author = Zotify Contributors
 description = A highly customizable music and podcast downloader
 long_description = file: README.md
@@ -33,6 +33,10 @@ console_scripts =
 
 [flake8]
 max-line-length = 160
+ignore =
+    E701
+    E704
+    W503
 
 [mypy]
 warn_unused_configs = True
@@ -43,6 +47,9 @@ ignore_missing_imports = True
 [mypy-music_tag]
 ignore_missing_imports = True
 
+[mypy-mutagen.*]
+ignore_missing_imports = True
+
 [mypy-pwinput]
 ignore_missing_imports = True
 
diff --git a/zotify/__init__.py b/zotify/__init__.py
index 981d092..01148a3 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -3,24 +3,25 @@ from __future__ import annotations
 from pathlib import Path
 
 from librespot.audio.decoders import VorbisOnlyAudioQuality
-from librespot.core import ApiClient, PlayableContentFeeder
+from librespot.core import ApiClient, ApResolver, 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.loader import Loader
 from zotify.playable import Episode, Track
-from zotify.utils import API_URL, Quality
+from zotify.utils import Quality
+
+API_URL = "https://api.sp" + "otify.com/v1/"
 
 
 class Api(ApiClient):
-    def __init__(self, session: LibrespotSession, language: str = "en"):
+    def __init__(self, session: Session):
         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(
@@ -40,25 +41,25 @@ class Api(ApiClient):
         offset: int = 0,
     ) -> dict:
         """
-        Requests data from api
+        Requests data from API
         Args:
-            url: API url and to get data from
+            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
+            Dictionary representation of JSON response
         """
         headers = {
             "Authorization": f"Bearer {self.__get_token()}",
             "Accept": "application/json",
-            "Accept-Language": self.__language,
+            "Accept-Language": self.__session.language(),
             "app-platform": "WebPlayer",
         }
         params["limit"] = limit
         params["offset"] = offset
 
-        response = get(url, headers=headers, params=params)
+        response = get(API_URL + url, headers=headers, params=params)
         data = response.json()
 
         try:
@@ -69,30 +70,39 @@ class Api(ApiClient):
             return data
 
 
-class Session:
+class Session(LibrespotSession):
     def __init__(
-        self,
-        librespot_session: LibrespotSession,
-        language: str = "en",
+        self, session_builder: LibrespotSession.Builder, language: str = "en"
     ) -> None:
         """
         Authenticates user, saves credentials to a file and generates api token.
         Args:
-            session_builder: An instance of the Librespot Session.Builder
+            session_builder: An instance of the Librespot Session builder
             langauge: ISO 639-1 language code
         """
-        self.__session = librespot_session
-        self.__language = language
-        self.__api = Api(self.__session, language)
-        self.__country = self.api().invoke_url(API_URL + "me")["country"]
+        with Loader("Logging in..."):
+            super(Session, self).__init__(
+                LibrespotSession.Inner(
+                    session_builder.device_type,
+                    session_builder.device_name,
+                    session_builder.preferred_locale,
+                    session_builder.conf,
+                    session_builder.device_id,
+                ),
+                ApResolver.get_random_accesspoint(),
+            )
+            self.connect()
+            self.authenticate(session_builder.login_credentials)
+            self.__api = Api(self)
+            self.__language = language
 
     @staticmethod
-    def from_file(cred_file: Path, langauge: str = "en") -> Session:
+    def from_file(cred_file: Path, language: str = "en") -> Session:
         """
         Creates session using saved credentials file
         Args:
             cred_file: Path to credentials file
-            langauge: ISO 639-1 language code for API responses
+            language: ISO 639-1 language code for API responses
         Returns:
             Zotify session
         """
@@ -102,12 +112,12 @@ class Session:
             .build()
         )
         session = LibrespotSession.Builder(conf).stored_file(str(cred_file))
-        return Session(session.create(), langauge)
+        return Session(session, language)
 
     @staticmethod
     def from_userpass(
-        username: str = "",
-        password: str = "",
+        username: str,
+        password: str,
         save_file: Path | None = None,
         language: str = "en",
     ) -> Session:
@@ -117,15 +127,10 @@ class Session:
             username: Account username
             password: Account password
             save_file: Path to save login credentials to, optional.
-            langauge: ISO 639-1 language code for API responses
+            language: ISO 639-1 language code for API responses
         Returns:
             Zotify session
         """
-        username = input("Username: ") if username == "" else username
-        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)
@@ -136,21 +141,35 @@ class Session:
         session = LibrespotSession.Builder(builder.build()).user_pass(
             username, password
         )
-        return Session(session.create(), language)
+        return Session(session, language)
+
+    @staticmethod
+    def from_prompt(save_file: Path | None = None, language: str = "en") -> Session:
+        """
+        Creates a session with username + password supplied from CLI prompt
+        Args:
+            save_file: Path to save login credentials to, optional.
+            language: ISO 639-1 language code for API responses
+        Returns:
+            Zotify session
+        """
+        username = input("Username: ")
+        password = pwinput(prompt="Password: ", mask="*")
+        return Session.from_userpass(username, password, save_file, 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(
+        return self.content_feeder().load(
             playable_id,
             VorbisOnlyAudioQuality(quality.value),
             False,
             None,
         )
 
-    def get_track(self, track_id: TrackId, quality: Quality = Quality.AUTO) -> Track:
+    def get_track(self, track_id: str, quality: Quality = Quality.AUTO) -> Track:
         """
         Gets track/episode data and audio stream
         Args:
@@ -159,9 +178,11 @@ class Session:
         Returns:
             Track object
         """
-        return Track(self.__get_playable(track_id, quality), self.api())
+        return Track(
+            self.__get_playable(TrackId.from_base62(track_id), quality), self.api()
+        )
 
-    def get_episode(self, episode_id: EpisodeId) -> Episode:
+    def get_episode(self, episode_id: str) -> Episode:
         """
         Gets track/episode data and audio stream
         Args:
@@ -169,20 +190,19 @@ class Session:
         Returns:
             Episode object
         """
-        return Episode(self.__get_playable(episode_id, Quality.NORMAL), self.api())
+        return Episode(
+            self.__get_playable(EpisodeId.from_base62(episode_id), Quality.NORMAL),
+            self.api(),
+        )
 
-    def api(self) -> ApiClient:
+    def api(self) -> Api:
         """Returns API Client"""
         return self.__api
 
-    def country(self) -> str:
-        """Returns two letter country code of user's account"""
-        return self.__country
+    def language(self) -> str:
+        """Returns session language"""
+        return self.__language
 
     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(self.__session, self.__language)
+        return self.get_user_attribute("type") == "premium"
diff --git a/zotify/__main__.py b/zotify/__main__.py
index adbb088..6250003 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, SimpleHelpFormatter
 
-VERSION = "0.9.3"
+VERSION = "0.9.4"
 
 
 def main():
@@ -25,7 +25,7 @@ def main():
     parser.add_argument(
         "--debug",
         action="store_true",
-        help="Don't hide tracebacks",
+        help="Display full tracebacks",
     )
     parser.add_argument(
         "--config",
@@ -138,8 +138,9 @@ def main():
             from traceback import format_exc
 
             print(format_exc().splitlines()[-1])
+            exit(1)
         except KeyboardInterrupt:
-            print("goodbye")
+            exit(130)
 
 
 if __name__ == "__main__":
diff --git a/zotify/app.py b/zotify/app.py
index e3569e0..691dc91 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -1,47 +1,33 @@
 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 typing import Any
 
 from zotify import Session
+from zotify.collections import Album, Artist, Collection, Episode, Playlist, Show, Track
 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, MetadataEntry, b62_to_hex
+from zotify.logger import LogChannel, Logger
+from zotify.utils import (
+    AudioFormat,
+    CollectionType,
+    PlayableType,
+)
 
 
-class ParseError(ValueError):
-    ...
-
-
-class PlayableType(Enum):
-    TRACK = "track"
-    EPISODE = "episode"
-
-
-class PlayableData(NamedTuple):
-    type: PlayableType
-    id: PlayableId
-    library: Path
-    output: str
-    metadata: list[MetadataEntry] = []
+class ParseError(ValueError): ...
 
 
 class Selection:
     def __init__(self, session: Session):
         self.__session = session
+        self.__items: list[dict[str, Any]] = []
+        self.__print_labels = {
+            "album": ("name", "artists"),
+            "playlist": ("name", "owner"),
+            "track": ("title", "artists", "album"),
+            "show": ("title", "creator"),
+        }
 
     def search(
         self,
@@ -57,54 +43,55 @@ class Selection:
     ) -> list[str]:
         categories = ",".join(category)
         with Loader("Searching..."):
+            country = self.__session.api().invoke_url("me")["country"]
             resp = self.__session.api().invoke_url(
-                API_URL + "search",
+                "search",
                 {
                     "q": search_text,
                     "type": categories,
                     "include_external": "audio",
-                    "market": self.__session.country(),
+                    "market": country,
                 },
                 limit=10,
                 offset=0,
             )
 
         count = 0
-        links = []
-        for c in categories.split(","):
-            label = c + "s"
-            if len(resp[label]["items"]) > 0:
+        for cat in categories.split(","):
+            label = cat + "s"
+            items = resp[label]["items"]
+            if len(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)
+                try:
+                    self.__print(count, items, *self.__print_labels[cat])
+                except KeyError:
+                    self.__print(count, items, "name")
+                count += len(items)
+                self.__items.extend(items)
+        return self.__get_selection()
 
     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)
+            r = self.__session.api().invoke_url(f"me/{category}", limit=50)
             if content != "":
                 r = r[content]
             resp = r["items"]
 
-        items = []
         for i in range(len(resp)):
             try:
                 item = resp[i][name]
             except KeyError:
                 item = resp[i]
-            items.append(item)
+            self.__items.append(item)
             self.__print(i + 1, item)
-        return self.__get_selection(items)
+        return self.__get_selection()
 
     @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]:
+    def __get_selection(self) -> list[str]:
         print("\nResults to save (eg: 1,2,5 1-3)")
         selection = ""
         while len(selection) == 0:
@@ -115,64 +102,40 @@ class Selection:
             if "-" in i:
                 split = i.split("-")
                 for x in range(int(split[0]), int(split[1]) + 1):
-                    ids.append(items[x - 1]["uri"])
+                    ids.append(self.__items[x - 1]["uri"])
             else:
-                ids.append(items[int(i) - 1]["uri"])
+                ids.append(self.__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(self, count: int, items: list[dict[str, Any]], *args: str) -> None:
+        arg_range = range(len(args))
+        category_str = "   " + " ".join("{:<38}" for _ in arg_range)
+        print(category_str.format(*[s.upper() for s in list(args)]))
+        for item in items:
+            count += 1
+            fmt_str = "{:<2} ".format(count) + " ".join("{:<38}" for _ in arg_range)
+            fmt_vals: list[str] = []
+            for arg in args:
+                match arg:
+                    case "artists":
+                        fmt_vals.append(
+                            ", ".join([artist["name"] for artist in item["artists"]])
+                        )
+                    case "owner":
+                        fmt_vals.append(item["owner"]["display_name"])
+                    case "album":
+                        fmt_vals.append(item["album"]["name"])
+                    case "creator":
+                        fmt_vals.append(item["publisher"])
+                    case "title":
+                        fmt_vals.append(item["name"])
+                    case _:
+                        fmt_vals.append(item[arg])
+            print(
+                fmt_str.format(
+                    *(self.__fix_string_length(fmt_vals[x], 38) for x in arg_range),
                 )
-
-    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 __fix_string_length(text: str, max_length: int) -> str:
@@ -182,42 +145,48 @@ class Selection:
 
 
 class App:
-    __playable_list: list[PlayableData] = []
-
     def __init__(self, args: Namespace):
         self.__config = Config(args)
-        Printer(self.__config)
+        Logger(self.__config)
 
+        # Check options
         if self.__config.audio_format == AudioFormat.VORBIS and (
             self.__config.ffmpeg_args != "" or self.__config.ffmpeg_path != ""
         ):
-            Printer.print(
-                PrintChannel.WARNINGS,
+            Logger.log(
+                LogChannel.WARNINGS,
                 "FFmpeg options will be ignored since no transcoding is required",
             )
 
-        with Loader("Logging in..."):
-            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.from_file(
-                    self.__config.credentials, self.__config.language
-                )
+        # Create session
+        if args.username != "" and args.password != "":
+            self.__session = Session.from_userpass(
+                args.username,
+                args.password,
+                self.__config.credentials,
+                self.__config.language,
+            )
+        elif self.__config.credentials.is_file():
+            self.__session = Session.from_file(
+                self.__config.credentials, self.__config.language
+            )
+        else:
+            self.__session = Session.from_prompt(
+                self.__config.credentials, self.__config.language
+            )
 
+        # Get items to download
         ids = self.get_selection(args)
         with Loader("Parsing input..."):
             try:
-                self.parse(ids)
+                collections = self.parse(ids)
             except ParseError as e:
-                Printer.print(PrintChannel.ERRORS, str(e))
-        self.download_all()
+                Logger.log(LogChannel.ERRORS, str(e))
+        if len(collections) > 0:
+            self.download_all(collections)
+        else:
+            Logger.log(LogChannel.WARNINGS, "there is nothing to do")
+        exit(0)
 
     def get_selection(self, args: Namespace) -> list[str]:
         selection = Selection(self.__session)
@@ -240,17 +209,14 @@ class App:
             elif args.urls:
                 return args.urls
         except (FileNotFoundError, ValueError):
-            Printer.print(PrintChannel.WARNINGS, "there is nothing to do")
+            Logger.log(LogChannel.WARNINGS, "there is nothing to do")
         except KeyboardInterrupt:
-            Printer.print(PrintChannel.WARNINGS, "\nthere is nothing to do")
-        exit()
+            Logger.log(LogChannel.WARNINGS, "\nthere is nothing to do")
+            exit(130)
+        exit(0)
 
-    def parse(self, links: list[str]) -> None:
-        """
-        Parses list of selected tracks/playlists/shows/etc...
-        Args:
-            links: List of links
-        """
+    def parse(self, links: list[str]) -> list[Collection]:
+        collections: list[Collection] = []
         for link in links:
             link = link.rsplit("?", 1)[0]
             try:
@@ -262,152 +228,92 @@ class App:
 
             match id_type:
                 case "album":
-                    self.__parse_album(b62_to_hex(_id))
+                    collections.append(Album(self.__session, _id))
                 case "artist":
-                    self.__parse_artist(b62_to_hex(_id))
+                    collections.append(Artist(self.__session, _id))
                 case "show":
-                    self.__parse_show(b62_to_hex(_id))
+                    collections.append(Show(self.__session, _id))
                 case "track":
-                    self.__parse_track(b62_to_hex(_id))
+                    collections.append(Track(self.__session, _id))
                 case "episode":
-                    self.__parse_episode(b62_to_hex(_id))
+                    collections.append(Episode(self.__session, _id))
                 case "playlist":
-                    self.__parse_playlist(_id)
+                    collections.append(Playlist(self.__session, _id))
                 case _:
-                    raise ParseError(f'Unknown content type "{id_type}"')
+                    raise ParseError(f'Unsupported content type "{id_type}"')
+        return collections
 
-    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,
-                        TrackId.from_hex(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_group in artist.album_group and artist.single_group:
-            album = self.__session.api().get_metadata_4_album(
-                AlbumId.from_hex(album_group.album[0].gid)
-            )
-            for disc in album.disc:
-                for track in disc.track:
-                    self.__playable_list.append(
-                        PlayableData(
-                            PlayableType.TRACK,
-                            TrackId.from_hex(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,
-                    EpisodeId.from_hex(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 get_playable_list(self) -> list[PlayableData]:
-        """Returns list of Playable items"""
-        return self.__playable_list
-
-    def download_all(self) -> None:
+    def download_all(self, collections: list[Collection]) -> None:
         """Downloads playable to local file"""
-        for playable in self.__playable_list:
-            self.__download(playable)
+        for collection in collections:
+            for i in range(len(collection.playables)):
+                playable = collection.playables[i]
 
-    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
-                )
-        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}"',
-            )
-            return
-
-        output = track.create_output(playable.library, playable.output)
-        file = track.write_audio_stream(
-            output,
-            self.__config.chunk_size,
-        )
-
-        if playable.type == PlayableType.TRACK and self.__config.lyrics_file:
-            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,
-                        True,
-                        self.__config.ffmpeg_path,
-                        self.__config.ffmpeg_args.split(),
+                # Get track data
+                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:
+                    Logger.log(
+                        LogChannel.SKIPS,
+                        f'Download Error: Unknown playable content "{playable.type}"',
                     )
-            except TranscodingError as e:
-                Printer.print(PrintChannel.ERRORS, str(e))
+                    return
 
-        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))
+                # Create download location and generate file name
+                match collection.type():
+                    case CollectionType.PLAYLIST:
+                        # TODO: add playlist name to track metadata
+                        library = self.__config.playlist_library
+                        template = (
+                            self.__config.output_playlist_track
+                            if playable.type == PlayableType.TRACK
+                            else self.__config.output_playlist_episode
+                        )
+                    case CollectionType.SHOW | CollectionType.EPISODE:
+                        library = self.__config.podcast_library
+                        template = self.__config.output_podcast
+                    case _:
+                        library = self.__config.music_library
+                        template = self.__config.output_album
+                output = track.create_output(
+                    library, template, self.__config.replace_existing
+                )
+
+                file = track.write_audio_stream(output)
+
+                # Download lyrics
+                if playable.type == PlayableType.TRACK and self.__config.lyrics_file:
+                    with Loader("Fetching lyrics..."):
+                        try:
+                            track.get_lyrics().save(output)
+                        except FileNotFoundError as e:
+                            Logger.log(LogChannel.SKIPS, str(e))
+                Logger.log(LogChannel.DOWNLOADS, f"\nDownloaded {track.name}")
+
+                # Transcode audio
+                if self.__config.audio_format != AudioFormat.VORBIS:
+                    try:
+                        with Loader(LogChannel.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:
+                        Logger.log(LogChannel.ERRORS, str(e))
+
+                # Write metadata
+                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/collections.py b/zotify/collections.py
new file mode 100644
index 0000000..d43a3ed
--- /dev/null
+++ b/zotify/collections.py
@@ -0,0 +1,95 @@
+from librespot.metadata import (
+    AlbumId,
+    ArtistId,
+    PlaylistId,
+    ShowId,
+)
+
+from zotify import Session
+from zotify.utils import CollectionType, PlayableData, PlayableType, bytes_to_base62
+
+
+class Collection:
+    playables: list[PlayableData] = []
+
+    def type(self) -> CollectionType:
+        return CollectionType(self.__class__.__name__.lower())
+
+
+class Album(Collection):
+    def __init__(self, session: Session, b62_id: str):
+        album = session.api().get_metadata_4_album(AlbumId.from_base62(b62_id))
+        for disc in album.disc:
+            for track in disc.track:
+                self.playables.append(
+                    PlayableData(
+                        PlayableType.TRACK,
+                        bytes_to_base62(track.gid),
+                    )
+                )
+
+
+class Artist(Collection):
+    def __init__(self, session: Session, b62_id: str):
+        artist = session.api().get_metadata_4_artist(ArtistId.from_base62(b62_id))
+        for album_group in (
+            artist.album_group
+            and artist.single_group
+            and artist.compilation_group
+            and artist.appears_on_group
+        ):
+            album = session.api().get_metadata_4_album(
+                AlbumId.from_hex(album_group.album[0].gid)
+            )
+            for disc in album.disc:
+                for track in disc.track:
+                    self.playables.append(
+                        PlayableData(
+                            PlayableType.TRACK,
+                            bytes_to_base62(track.gid),
+                        )
+                    )
+
+
+class Show(Collection):
+    def __init__(self, session: Session, b62_id: str):
+        show = session.api().get_metadata_4_show(ShowId.from_base62(b62_id))
+        for episode in show.episode:
+            self.playables.append(
+                PlayableData(PlayableType.EPISODE, bytes_to_base62(episode.gid))
+            )
+
+
+class Playlist(Collection):
+    def __init__(self, session: Session, b62_id: str):
+        playlist = session.api().get_playlist(PlaylistId(b62_id))
+        # self.name = playlist.title
+        for item in playlist.contents.items:
+            split = item.uri.split(":")
+            playable_type = split[1]
+            if playable_type == "track":
+                self.playables.append(
+                    PlayableData(
+                        PlayableType.TRACK,
+                        split[2],
+                    )
+                )
+            elif playable_type == "episode":
+                self.playables.append(
+                    PlayableData(
+                        PlayableType.EPISODE,
+                        split[2],
+                    )
+                )
+            else:
+                raise ValueError("Unknown playable content", playable_type)
+
+
+class Track(Collection):
+    def __init__(self, session: Session, b62_id: str):
+        self.playables.append(PlayableData(PlayableType.TRACK, b62_id))
+
+
+class Episode(Collection):
+    def __init__(self, session: Session, b62_id: str):
+        self.playables.append(PlayableData(PlayableType.EPISODE, b62_id))
diff --git a/zotify/config.py b/zotify/config.py
index c2d1a68..b6dcf53 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -10,7 +10,6 @@ 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"
@@ -64,8 +63,8 @@ CONFIG_PATHS = {
 OUTPUT_PATHS = {
     "album": "{album_artist}/{album}/{track_number}. {artists} - {title}",
     "podcast": "{podcast}/{episode_number} - {title}",
-    "playlist_track": "{playlist}/{playlist_number}. {artists} - {title}",
-    "playlist_episode": "{playlist}/{playlist_number}. {episode_number} - {title}",
+    "playlist_track": "{playlist}/{artists} - {title}",
+    "playlist_episode": "{playlist}/{episode_number} - {title}",
 }
 
 CONFIG_VALUES = {
@@ -222,12 +221,6 @@ CONFIG_VALUES = {
         "args": ["--skip-duplicates"],
         "help": "Skip downloading existing track to different album",
     },
-    CHUNK_SIZE: {
-        "default": 16384,
-        "type": int,
-        "args": ["--chunk-size"],
-        "help": "Number of bytes read at a time during download",
-    },
     PRINT_DOWNLOADS: {
         "default": False,
         "type": bool,
@@ -265,7 +258,6 @@ class Config:
     __config_file: Path | None
     artwork_size: ImageSize
     audio_format: AudioFormat
-    chunk_size: int
     credentials: Path
     download_quality: Quality
     ffmpeg_args: str
@@ -274,13 +266,13 @@ class Config:
     language: str
     lyrics_file: bool
     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
+    replace_existing: bool
     save_metadata: bool
     transcode_bitrate: int
 
@@ -323,14 +315,14 @@ class Config:
 
         # "library" arg overrides all *_library options
         if args.library:
-            self.music_library = args.library
-            self.playlist_library = args.library
-            self.podcast_library = args.library
+            print("args.library")
+            self.music_library = Path(args.library).expanduser().resolve()
+            self.playlist_library = Path(args.library).expanduser().resolve()
+            self.podcast_library = Path(args.library).expanduser().resolve()
 
         # "output" arg overrides 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
@@ -338,10 +330,10 @@ class Config:
     @staticmethod
     def __parse_arg_value(key: str, value: Any) -> Any:
         config_type = CONFIG_VALUES[key]["type"]
-        if type(value) == config_type:
+        if type(value) is config_type:
             return value
         elif config_type == Path:
-            return Path(value).expanduser()
+            return Path(value).expanduser().resolve()
         elif config_type == AudioFormat:
             return AudioFormat[value.upper()]
         elif config_type == ImageSize.from_string:
diff --git a/zotify/file.py b/zotify/file.py
index 4cf1bfc..960f376 100644
--- a/zotify/file.py
+++ b/zotify/file.py
@@ -8,8 +8,7 @@ from mutagen.oggvorbis import OggVorbisHeaderError
 from zotify.utils import AudioFormat, MetadataEntry
 
 
-class TranscodingError(RuntimeError):
-    ...
+class TranscodingError(RuntimeError): ...
 
 
 class LocalFile:
diff --git a/zotify/loader.py b/zotify/loader.py
index 9eb3885..364a147 100644
--- a/zotify/loader.py
+++ b/zotify/loader.py
@@ -8,7 +8,7 @@ from sys import platform
 from threading import Thread
 from time import sleep
 
-from zotify.printer import Printer
+from zotify.logger import Logger
 
 
 class Loader:
@@ -50,7 +50,7 @@ class Loader:
         for c in cycle(self.steps):
             if self.done:
                 break
-            Printer.print_loader(f"\r {c} {self.desc} ")
+            Logger.print_loader(f"\r {c} {self.desc} ")
             sleep(self.timeout)
 
     def __enter__(self) -> None:
@@ -59,10 +59,10 @@ class Loader:
     def stop(self) -> None:
         self.done = True
         cols = get_terminal_size((80, 20)).columns
-        Printer.print_loader("\r" + " " * cols)
+        Logger.print_loader("\r" + " " * cols)
 
         if self.end != "":
-            Printer.print_loader(f"\r{self.end}")
+            Logger.print_loader(f"\r{self.end}")
 
     def __exit__(self, exc_type, exc_value, tb) -> None:
         # handle exceptions with those variables ^
diff --git a/zotify/printer.py b/zotify/logger.py
similarity index 85%
rename from zotify/printer.py
rename to zotify/logger.py
index 901e1ff..46c9112 100644
--- a/zotify/printer.py
+++ b/zotify/logger.py
@@ -13,7 +13,7 @@ from zotify.config import (
 )
 
 
-class PrintChannel(Enum):
+class LogChannel(Enum):
     SKIPS = PRINT_SKIPS
     PROGRESS = PRINT_PROGRESS
     ERRORS = PRINT_ERRORS
@@ -21,7 +21,7 @@ class PrintChannel(Enum):
     DOWNLOADS = PRINT_DOWNLOADS
 
 
-class Printer:
+class Logger:
     __config: Config
 
     @classmethod
@@ -29,15 +29,15 @@ class Printer:
         cls.__config = config
 
     @classmethod
-    def print(cls, channel: PrintChannel, msg: str) -> None:
+    def log(cls, channel: LogChannel, msg: str) -> None:
         """
         Prints a message to console if the print channel is enabled
         Args:
-            channel: PrintChannel to print to
-            msg: Message to print
+            channel: LogChannel to print to
+            msg: Message to log
         """
         if cls.__config.get(channel.value):
-            if channel == PrintChannel.ERRORS:
+            if channel == LogChannel.ERRORS:
                 print(msg, file=stderr)
             else:
                 print(msg)
@@ -76,7 +76,7 @@ class Printer:
         """
         Prints animated loading symbol
         Args:
-            msg: Message to print
+            msg: Message to display
         """
         if cls.__config.print_progress:
             print(msg, flush=True, end="")
diff --git a/zotify/playable.py b/zotify/playable.py
index dd312db..e2da87a 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -3,37 +3,40 @@ from pathlib import Path
 from typing import Any
 
 from librespot.core import PlayableContentFeeder
+from librespot.metadata import AlbumId
 from librespot.structure import GeneralAudioStream
 from librespot.util import bytes_to_hex
 from requests import get
 
 from zotify.file import LocalFile
-from zotify.printer import Printer
+from zotify.logger import Logger
 from zotify.utils import (
-    IMG_URL,
-    LYRICS_URL,
     AudioFormat,
     ImageSize,
     MetadataEntry,
+    PlayableType,
     bytes_to_base62,
     fix_filename,
 )
 
+IMG_URL = "https://i.s" + "cdn.co/image/"
+LYRICS_URL = "https://sp" + "client.wg.sp" + "otify.com/color-lyrics/v2/track/"
+
 
 class Lyrics:
     def __init__(self, lyrics: dict, **kwargs):
-        self.lines = []
-        self.sync_type = lyrics["syncType"]
+        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 = []
+            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(
+                self.__lines_synced.append(
                     f"[{ts_minutes}:{ts_seconds}.{ts_millis}]{line.words}\n"
                 )
 
@@ -44,21 +47,24 @@ class Lyrics:
             location: path to target lyrics file
             prefer_synced: Use line synced lyrics if available
         """
-        if self.sync_type == "line_synced" and prefer_synced:
+        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)
+                f.writelines(self.__lines_synced)
         else:
             with open(f"{path}.txt", "w+", encoding="utf-8") as f:
-                f.writelines(self.lines[:-1])
+                f.writelines(self.__lines[:-1])
 
 
 class Playable:
     cover_images: list[Any]
+    input_stream: GeneralAudioStream
     metadata: list[MetadataEntry]
     name: str
-    input_stream: GeneralAudioStream
+    type: PlayableType
 
-    def create_output(self, library: Path, output: str, replace: bool = False) -> Path:
+    def create_output(
+        self, library: Path = Path("./"), output: str = "{title}", replace: bool = False
+    ) -> Path:
         """
         Creates save directory for the output file
         Args:
@@ -68,9 +74,11 @@ class Playable:
         Returns:
             File path for the track
         """
-        for m in self.metadata:
-            if m.output is not None:
-                output = output.replace("{" + m.name + "}", fix_filename(m.output))
+        for meta in self.metadata:
+            if meta.string is not None:
+                output = output.replace(
+                    "{" + meta.name + "}", fix_filename(meta.string)
+                )
         file_path = library.joinpath(output).expanduser()
         if file_path.exists() and not replace:
             raise FileExistsError("File already downloaded")
@@ -81,18 +89,16 @@ class Playable:
     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(
+        with open(file, "wb") as f, Logger.progress(
             desc=self.name,
             total=self.input_stream.size,
             unit="B",
@@ -103,7 +109,7 @@ class Playable:
         ) as p_bar:
             chunk = None
             while chunk != b"":
-                chunk = self.input_stream.stream().read(chunk_size)
+                chunk = self.input_stream.stream().read(1024)
                 p_bar.update(f.write(chunk))
         return LocalFile(Path(file), AudioFormat.VORBIS)
 
@@ -121,8 +127,6 @@ class Playable:
 
 
 class Track(PlayableContentFeeder.LoadedStream, Playable):
-    lyrics: Lyrics
-
     def __init__(self, track: PlayableContentFeeder.LoadedStream, api):
         super(Track, self).__init__(
             track.track,
@@ -131,8 +135,10 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             track.metrics,
         )
         self.__api = api
+        self.__lyrics: Lyrics
         self.cover_images = self.album.cover_group.image
         self.metadata = self.__default_metadata()
+        self.type = PlayableType.TRACK
 
     def __getattr__(self, name):
         try:
@@ -142,6 +148,10 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
 
     def __default_metadata(self) -> list[MetadataEntry]:
         date = self.album.date
+        if not hasattr(self.album, "genre"):
+            self.track.album = self.__api().get_metadata_4_album(
+                AlbumId.from_hex(bytes_to_hex(self.album.gid))
+            )
         return [
             MetadataEntry("album", self.album.name),
             MetadataEntry("album_artist", [a.name for a in self.album.artist]),
@@ -155,6 +165,7 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             MetadataEntry("popularity", int(self.popularity * 255) / 100),
             MetadataEntry("track_number", self.number, str(self.number).zfill(2)),
             MetadataEntry("title", self.name),
+            MetadataEntry("year", date.year),
             MetadataEntry(
                 "replaygain_track_gain", self.normalization_data.track_gain_db, ""
             ),
@@ -169,21 +180,21 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             ),
         ]
 
-    def get_lyrics(self) -> Lyrics:
+    def lyrics(self) -> 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}"
             )
         try:
-            return self.lyrics
+            return self.__lyrics
         except AttributeError:
-            self.lyrics = Lyrics(
+            self.__lyrics = Lyrics(
                 self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.gid))[
                     "lyrics"
                 ]
             )
-            return self.lyrics
+            return self.__lyrics
 
 
 class Episode(PlayableContentFeeder.LoadedStream, Playable):
@@ -197,6 +208,7 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
         self.__api = api
         self.cover_images = self.episode.cover_image.image
         self.metadata = self.__default_metadata()
+        self.type = PlayableType.EPISODE
 
     def __getattr__(self, name):
         try:
@@ -216,23 +228,21 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
             MetadataEntry("title", self.name),
         ]
 
-    def write_audio_stream(
-        self, output: Path, chunk_size: int = 128 * 1024
-    ) -> LocalFile:
+    def write_audio_stream(self, output: Path) -> LocalFile:
         """
-        Writes audio stream to file
+        Writes audio stream to file.
+        Uses external source if available for faster download.
         Args:
             output: File path of saved audio stream
-            chunk_size: maximum number of bytes to read at a time
         Returns:
             LocalFile object
         """
         if not bool(self.external_url):
-            return super().write_audio_stream(output, chunk_size)
+            return super().write_audio_stream(output)
         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(
+        ) as f, Logger.progress(
             desc=self.name,
             total=self.input_stream.size,
             unit="B",
@@ -241,6 +251,6 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
             position=0,
             leave=False,
         ) as p_bar:
-            for chunk in r.iter_content(chunk_size=chunk_size):
+            for chunk in r.iter_content(chunk_size=1024):
                 p_bar.update(f.write(chunk))
         return LocalFile(Path(file))
diff --git a/zotify/utils.py b/zotify/utils.py
index 01d5236..62dfc22 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -7,12 +7,8 @@ from sys import stderr
 from typing import Any, NamedTuple
 
 from librespot.audio.decoders import AudioQuality
-from librespot.util import Base62, bytes_to_hex
-from requests import get
+from librespot.util import Base62
 
-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()
 
 
@@ -74,30 +70,47 @@ class ImageSize(IntEnum):
 class MetadataEntry:
     name: str
     value: Any
-    output: str
+    string: str
 
-    def __init__(self, name: str, value: Any, output_value: str | None = None):
+    def __init__(self, name: str, value: Any, string_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
+            string_value: Value when used in output formatting, if none is provided
             will use value from previous argument.
         """
         self.name = name
 
-        if type(value) == list:
+        if isinstance(value, tuple):
             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)
+        if string_value is None:
+            string_value = self.value
+        if isinstance(string_value, list):
+            string_value = ", ".join(string_value)
+        self.string = str(string_value)
+
+
+class CollectionType(Enum):
+    ALBUM = "album"
+    ARTIST = "artist"
+    SHOW = "show"
+    PLAYLIST = "playlist"
+    TRACK = "track"
+    EPISODE = "episode"
+
+
+class PlayableType(Enum):
+    TRACK = "track"
+    EPISODE = "episode"
+
+
+class PlayableData(NamedTuple):
+    type: PlayableType
+    id: str
 
 
 class SimpleHelpFormatter(HelpFormatter):
@@ -147,7 +160,14 @@ class OptionalOrFalse(Action):
         setattr(
             namespace,
             self.dest,
-            True if not option_string.startswith("--no-") else False,
+            (
+                True
+                if not (
+                    option_string.startswith("--no-")
+                    or option_string.startswith("--dont-")
+                )
+                else False
+            ),
         )
 
 
@@ -172,29 +192,12 @@ def fix_filename(filename: str, substitute: str = "_", platform: str = PLATFORM)
     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:
+    """
+    Converts bytes to base62
+    Args:
+        id: bytes
+    Returns:
+        base62
+    """
     return BASE62.encode(id, 22).decode()
-
-
-def b62_to_hex(base62: str) -> str:
-    return bytes_to_hex(BASE62.decode(base62.encode(), 16))

From 1659fe667e22fc552f0c2f657e6fa3169c61e325 Mon Sep 17 00:00:00 2001
From: PhYrE <phyre@noreply.zotify.xyz>
Date: Wed, 24 Apr 2024 01:38:05 +0200
Subject: [PATCH 090/169] Don't crash out if a user includes an episode/podcast
 in a playlist.

Previously, "'NoneType' object is not subscriptable" happened on certain playlists where the playlist includes a podcast episode or other non-track content.  This checks it and skips over in such a case.
---
 zotify/playlist.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/zotify/playlist.py b/zotify/playlist.py
index 53c1941..919c47b 100644
--- a/zotify/playlist.py
+++ b/zotify/playlist.py
@@ -49,7 +49,7 @@ def get_playlist_info(playlist_id):
 def download_playlist(playlist):
     """Downloads all the songs from a playlist"""
 
-    playlist_songs = [song for song in get_playlist_songs(playlist[ID]) if song[TRACK][ID]]
+    playlist_songs = [song for song in get_playlist_songs(playlist[ID]) if song[TRACK] is not None and song[TRACK][ID]]
     p_bar = Printer.progress(playlist_songs, unit='song', total=len(playlist_songs), unit_scale=True)
     enum = 1
     for song in p_bar:

From fa2156b2a17627578860ee7ec2732e41d4f25afc Mon Sep 17 00:00:00 2001
From: Zotify <zotify@noreply.zotify.xyz>
Date: Sat, 1 Jun 2024 04:03:59 +0200
Subject: [PATCH 091/169] version bump

---
 setup.cfg | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/setup.cfg b/setup.cfg
index 1706618..2989fb5 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = zotify
-version = 0.6.13
+version = 0.6.14
 author = Zotify Contributors
 description = A highly customizable music and podcast downloader
 long_description = file: README.md

From 83f50f9347ec2bf0ec6d330bed2077f8c73c46b7 Mon Sep 17 00:00:00 2001
From: azumukupoe <31339007+azumukupoe@users.noreply.github.com>
Date: Wed, 10 Jul 2024 16:21:46 +0900
Subject: [PATCH 092/169] Install tabulate with support for wide characters

---
 requirements.txt | 2 +-
 setup.cfg        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index 8bd5229..eadfc62 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,5 +4,5 @@ music_tag
 Pillow
 protobuf
 pwinput
-tabulate
+tabulate[widechars]
 tqdm
diff --git a/setup.cfg b/setup.cfg
index 2989fb5..d62b766 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -22,7 +22,7 @@ install_requires =
     Pillow
     protobuf==3.20.1
     pwinput
-    tabulate
+    tabulate[widechars]
     tqdm
 
 [options.package_data]

From b361976504ced5316fcb9ad2ffe21a388361be35 Mon Sep 17 00:00:00 2001
From: Zotify <zotify@localhost>
Date: Thu, 1 Aug 2024 23:44:52 +1200
Subject: [PATCH 093/169] various changes

---
 .vscode/settings.json |   6 +-
 CHANGELOG.md          |   8 +-
 Pipfile               |   5 +
 Pipfile.lock          | 471 +++++++++++++++++++++++++++++-------------
 README.md             |  15 +-
 requirements.txt      |   2 +-
 requirements_dev.txt  |   1 -
 setup.cfg             |   8 +-
 zotify/__init__.py    |  12 +-
 zotify/__main__.py    |   7 +-
 zotify/app.py         | 134 ++++++------
 zotify/collections.py |  84 +++++---
 zotify/config.py      |  41 ++--
 zotify/loader.py      |   7 +-
 zotify/logger.py      |   8 +-
 zotify/playable.py    |  58 +++---
 zotify/utils.py       |  59 +++---
 17 files changed, 573 insertions(+), 353 deletions(-)

diff --git a/.vscode/settings.json b/.vscode/settings.json
index 13b4248..fd2ec86 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,7 +1,9 @@
 {
-    "editor.defaultFormatter": "ms-python.black-formatter",
     "editor.formatOnSave": true,
     "editor.codeActionsOnSave": {
-        "source.organizeImports": true
+        "source.organizeImports": "explicit"
+    },
+    "[python]": {
+        "editor.defaultFormatter": "ms-python.black-formatter"
     },
 }
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b1830b7..c961176 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,9 +15,8 @@
 - 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`
+- Renamed `root_path` and `root_podcast_path` to `album_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.
 - 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
@@ -29,7 +28,7 @@
 
 - New library location for playlists `playlist_library`
 - Added new command line arguments
-  - `--library`/`-l` overrides both `music_library` and `podcast_library` options similar to `--output`/`-o`
+  - `--library`/`-l` overrides both `album_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:
@@ -55,6 +54,9 @@
   - `{explicit}`
   - `{isrc}`
   - `{licensor}`
+  - `{playlist}`
+  - `{playlist_number}`
+  - `{playlist_owner}`
   - `{popularity}`
   - `{release_date}`
   - `{track_number}`
diff --git a/Pipfile b/Pipfile
index 2fc6f0d..030f469 100644
--- a/Pipfile
+++ b/Pipfile
@@ -13,6 +13,11 @@ requests = "*"
 tqdm = "*"
 
 [dev-packages]
+black = "*"
+flake8 = "*"
+mypy = "*"
+types-protobuf = "*"
+types-requests = "*"
 
 [requires]
 python_version = "3.11"
diff --git a/Pipfile.lock b/Pipfile.lock
index 4eb010d..b5a9ee8 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "dfbc5e27f802eeeddf2967a8d8d280346f8e3b4e4759b4bea10f59dbee08a0ee"
+            "sha256": "9cf0a0fbfd691c64820035a5c12805f868ae1d2401630b9f68b67b936f5e7892"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -18,11 +18,11 @@
     "default": {
         "certifi": {
             "hashes": [
-                "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f",
-                "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"
+                "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b",
+                "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"
             ],
             "markers": "python_version >= '3.6'",
-            "version": "==2024.2.2"
+            "version": "==2024.7.4"
         },
         "charset-normalizer": {
             "hashes": [
@@ -130,11 +130,11 @@
         },
         "idna": {
             "hashes": [
-                "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca",
-                "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"
+                "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc",
+                "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"
             ],
             "markers": "python_version >= '3.5'",
-            "version": "==3.6"
+            "version": "==3.7"
         },
         "ifaddr": {
             "hashes": [
@@ -145,7 +145,7 @@
         },
         "librespot": {
             "git": "git+https://github.com/kokarare1212/librespot-python",
-            "ref": "f56533f9b56e62b28bac6c57d0710620aeb6a5dd"
+            "ref": "3b46fe560ad829b976ce63e85012cff95b1e0bf3"
         },
         "music-tag": {
             "git": "git+https://zotify.xyz/zotify/music-tag",
@@ -162,78 +162,90 @@
         },
         "pillow": {
             "hashes": [
-                "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8",
-                "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39",
-                "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac",
-                "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869",
-                "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e",
-                "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04",
-                "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9",
-                "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e",
-                "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe",
-                "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef",
-                "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56",
-                "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa",
-                "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f",
-                "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f",
-                "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e",
-                "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a",
-                "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2",
-                "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2",
-                "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5",
-                "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a",
-                "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2",
-                "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213",
-                "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563",
-                "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591",
-                "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c",
-                "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2",
-                "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb",
-                "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757",
-                "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0",
-                "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452",
-                "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad",
-                "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01",
-                "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f",
-                "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5",
-                "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61",
-                "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e",
-                "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b",
-                "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068",
-                "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9",
-                "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588",
-                "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483",
-                "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f",
-                "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67",
-                "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7",
-                "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311",
-                "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6",
-                "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72",
-                "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6",
-                "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129",
-                "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13",
-                "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67",
-                "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c",
-                "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516",
-                "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e",
-                "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e",
-                "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364",
-                "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023",
-                "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1",
-                "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04",
-                "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d",
-                "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a",
-                "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7",
-                "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb",
-                "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4",
-                "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e",
-                "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1",
-                "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48",
-                "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868"
+                "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885",
+                "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea",
+                "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df",
+                "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5",
+                "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c",
+                "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d",
+                "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd",
+                "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06",
+                "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908",
+                "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a",
+                "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be",
+                "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0",
+                "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b",
+                "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80",
+                "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a",
+                "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e",
+                "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9",
+                "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696",
+                "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b",
+                "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309",
+                "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e",
+                "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab",
+                "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d",
+                "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060",
+                "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d",
+                "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d",
+                "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4",
+                "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3",
+                "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6",
+                "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb",
+                "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94",
+                "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b",
+                "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496",
+                "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0",
+                "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319",
+                "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b",
+                "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856",
+                "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef",
+                "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680",
+                "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b",
+                "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42",
+                "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e",
+                "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597",
+                "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a",
+                "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8",
+                "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3",
+                "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736",
+                "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da",
+                "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126",
+                "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd",
+                "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5",
+                "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b",
+                "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026",
+                "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b",
+                "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc",
+                "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46",
+                "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2",
+                "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c",
+                "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe",
+                "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984",
+                "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a",
+                "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70",
+                "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca",
+                "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b",
+                "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91",
+                "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3",
+                "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84",
+                "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1",
+                "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5",
+                "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be",
+                "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f",
+                "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc",
+                "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9",
+                "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e",
+                "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141",
+                "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef",
+                "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22",
+                "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27",
+                "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e",
+                "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"
             ],
             "index": "pypi",
             "markers": "python_version >= '3.8'",
-            "version": "==10.2.0"
+            "version": "==10.4.0"
         },
         "protobuf": {
             "hashes": [
@@ -320,95 +332,266 @@
         },
         "requests": {
             "hashes": [
-                "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f",
-                "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"
+                "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760",
+                "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"
             ],
             "index": "pypi",
-            "markers": "python_version >= '3.7'",
-            "version": "==2.31.0"
+            "markers": "python_version >= '3.8'",
+            "version": "==2.32.3"
         },
         "tqdm": {
             "hashes": [
-                "sha256:d302b3c5b53d47bce91fea46679d9c3c6508cf6332229aa1e7d8653723793386",
-                "sha256:d88e651f9db8d8551a62556d3cff9e3034274ca5d66e93197cf2490e2dcb69c7"
+                "sha256:b75ca56b413b030bc3f00af51fd2c1a1a5eac6a0c1cca83cbb37a5c52abce644",
+                "sha256:e4d936c9de8727928f3be6079590e97d9abfe8d39a590be678eb5919ffc186bb"
             ],
             "index": "pypi",
             "markers": "python_version >= '3.7'",
-            "version": "==4.66.1"
+            "version": "==4.66.4"
         },
         "urllib3": {
             "hashes": [
-                "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20",
-                "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"
+                "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472",
+                "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"
             ],
             "markers": "python_version >= '3.8'",
-            "version": "==2.2.0"
+            "version": "==2.2.2"
         },
         "websocket-client": {
             "hashes": [
-                "sha256:10e511ea3a8c744631d3bd77e61eb17ed09304c413ad42cf6ddfa4c7787e8fe6",
-                "sha256:f4c3d22fec12a2461427a29957ff07d35098ee2d976d3ba244e688b8b4057588"
+                "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526",
+                "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"
             ],
             "markers": "python_version >= '3.8'",
-            "version": "==1.7.0"
+            "version": "==1.8.0"
         },
         "zeroconf": {
             "hashes": [
-                "sha256:0251034ed1d57eeb4e08782b22cc51e2455da7552b592bfad69a5761e69241c7",
-                "sha256:02e3b6d1c1df87e8bc450de3f973ab9f4cfd1b4c0a3fb9e933d84580a1d61263",
-                "sha256:08eb87b0500ddc7c148fe3db3913e9d07d5495d756d7d75683f2dee8d7a09dc5",
-                "sha256:10e8d23cee434077a10ceec4b419b9de8c84ede7f42b64e735d0f0b7708b0c66",
-                "sha256:14f0bef6b4f7bd0caf80f207acd1e399e8d8a37e12266d80871a2ed6c9ee3b16",
-                "sha256:18ff5b28e8935e5399fe47ece323e15816bc2ea4111417c41fc09726ff056cd2",
-                "sha256:194cf1465a756c3090e23ef2a5bd3341caa8d36eef486054daa8e532a4e24ac8",
-                "sha256:1a57e0c4a94276ec690d2ecf1edeea158aaa3a7f38721af6fa572776dda6c8ad",
-                "sha256:2389e3a61e99bf74796da7ebc3001b90ecd4e6286f392892b1211748e5b19853",
-                "sha256:24b0a46c5f697cd6a0b27678ea65a3222b95f1804be6b38c6f5f1a7ce8b5cded",
-                "sha256:28d906fc0779badb2183f5b20dbcc7e508cce53a13e63ba4d9477381c9f77463",
-                "sha256:2907784c8c88795bf1b74cc9b6a4051e37a519ae2caaa7307787d466bc57884c",
-                "sha256:34c3379d899361cd9d6b573ea9ac1eba53e2306eb28f94353b58c4703f0e74ae",
-                "sha256:3768ab13a8d7f0df85e40e766edd9e2aef28710a350dc4b15e1f2c5dd1326f00",
-                "sha256:38bfd08c9191716d65e6ac52741442ee918bfe2db43993aa4d3b365966c0ab48",
-                "sha256:3a49aaff22bc576680b4bcb3c7de896587f6ab4adaa788bedbc468dd0ad28cce",
-                "sha256:3b167b9e47f3fec8cc28a8f73a9e47c563ceb6681c16dcbe2c7d41e084cee755",
-                "sha256:3bc16228495e67ec990668970e815b341160258178c21b7716400c5e7a78976a",
-                "sha256:3f49ec4e8d5bd860e9958e88e8b312e31828f5cb2203039390c551f3fb0b45dd",
-                "sha256:434344df3037df08bad7422d5d36a415f30ddcc29ac1ad0cc0160b4976b782b5",
-                "sha256:4713e5cd986f9467494e5b47b0149ac0ffd7ad630d78cd6f6d2555b199e5a653",
-                "sha256:4865ef65b7eb7eee1a38c05bf7e91dd8182ef2afb1add65440f99e8dd43836d2",
-                "sha256:52b65e5eeacae121695bcea347cc9ad7da5556afcd3765c461e652ca3e8a84e9",
-                "sha256:551c04799325c890f2baa347e82cd2c3fb1d01b14940d7695f27c49cd2413b0c",
-                "sha256:5d777b177cb472f7996b9d696b81337bfb846dbe454b8a34a8e33704d3a435b0",
-                "sha256:6a041468c428622798193f0006831237aa749ee23e26b5b79e457618484457ef",
-                "sha256:6c55a1627290ba0718022fb63cf5a25d773c52b00319ef474dd443ebe92efab1",
-                "sha256:7c4235f45defd43bb2402ff8d3c7ff5d740e671bfd926852541c282ebef992bc",
-                "sha256:8642d374481d8cc7be9e364b82bcd11bda4a095c24c5f9f5754017a118496b77",
-                "sha256:90c431e99192a044a5e0217afd7ca0ca9824af93190332e6f7baf4da5375f331",
-                "sha256:9a7f3b9a580af6bf74a7c435b80925dfeb065c987dffaf4d957d578366a80b2c",
-                "sha256:9dfa3d8827efffebec61b108162eeb76b0fe170a8379f9838be441f61b4557fd",
-                "sha256:a3f1d959e3a57afa6b383eb880048929473507b1cc0e8b5e1a72ddf0fc1bbb77",
-                "sha256:a613827f97ca49e2b4b6d6eb7e61a0485afe23447978a60f42b981a45c2b25fd",
-                "sha256:a984c93aa413a594f048ef7166f0d9be73b0cd16dfab1395771b7c0607e07817",
-                "sha256:b843d5e2d2e576efeab59e382907bca1302f20eb33ee1a0a485e90d017b1088a",
-                "sha256:bdb1a2a67e34059e69aaead600525e91c126c46502ada1c7fc3d2c082cc8ad27",
-                "sha256:bf9ec50ffdf4e179c035f96a106a5c510d5295c5fb7e2e69dd4cda7b7f42f8bf",
-                "sha256:c10158396d6875f790bfb5600391d44edcbf52ac4d148e19baab3e8bb7825f76",
-                "sha256:c3f0f87e47e4d5a9bcfcfc1ce29d0e9127a5cab63e839cc6f845c563f29d765c",
-                "sha256:c75bb2c1e472723067c7ec986ea510350c335bf8e73ad12617fc6a9ec765dc4b",
-                "sha256:cb2879708357cac9805d20944973f3d50b472c703b8eaadd9bf136024c5539b4",
-                "sha256:cc7a76103b03f47d2aa02206f74cc8b2120f4bac02936ccee5d6f29290f5bde5",
-                "sha256:ce67d8dab4d88bcd1e5975d08235590fc5b9f31b2e2b7993ee1680810e67e56d",
-                "sha256:d08170123f5c04480bd7a82122b46c5afdb91553a9cef7d686d3fb9c369a9204",
-                "sha256:d4baa0450b9b0f1bd8acc25c2970d4e49e54726cbc437b81ffb65e5ffb6bd321",
-                "sha256:d5d92987c3669edbfa9f911a8ef1c46cfd2c3e51971fc80c215f99212b81d4b1",
-                "sha256:e0d1357940b590466bc72ac605e6ad3f7f05b2e1475b6896ec8e4c61e4d23034",
-                "sha256:e7d51df61579862414ac544f2892ea3c91a6b45dd728d4fb6260d65bf6f1ef0f",
-                "sha256:f74149a22a6a27e4c039f6477188dcbcb910acd60529dab5c114ff6265d40ba7",
-                "sha256:fdcb9cb0555c7947f29a4d5c05c98e260a04f37d6af31aede1e981bf1bdf8691"
+                "sha256:06203c23a82b69aca9e961da675600dff19026bb22b5d042f18f9e0ff1139ed3",
+                "sha256:0b0d2ffc4bafbcc4152067bfbc1a67074d23e6100e356424bd985ca8067a2bfd",
+                "sha256:13beed15eed7e569fd56dbe16c7cb758f81c661d53ec253fbf9cfe7a20e28b7c",
+                "sha256:1a95025f0949ed0e873e141d482fbbefa223ef90646443e4a1d6d47f50eb89d7",
+                "sha256:1c932b15848ae6b8e4b2b50c65368e396d000fea95acd473611693dbe5a00096",
+                "sha256:1f09b692219abf9b1ca28364d6f4eb283a4c676e30c905933d1694cbd321bc4b",
+                "sha256:28b1721617ddc9bf3d2ba3e2b96234f7539e1dbdcacaf6e94ec31ff7b5ebe620",
+                "sha256:31c8406f62251aa62f5b67d865007ffd1dd929eae9027166ffa6bccca69253bd",
+                "sha256:390feb3e7fccdffbf66c9bcd895b1db92e501aa2789d6a8b44e6e027ab80ec14",
+                "sha256:3ad2fe0cbfebe20612c9a5390075a2b3a258a78928f5b7b5163be1699cc528f0",
+                "sha256:3bd0cd9435dced8c31491b3ed7c15707acedd11f00451f7fbb57ba3868dd5724",
+                "sha256:3eb0e57654e139c3ef5b6421053236be4a0add9f0301b01545b11a0552c7c123",
+                "sha256:4754dfba1af63545dfd0ab26c834c907e1dd3f94c8ee190c3041a6444313aaed",
+                "sha256:48275e3db89a8d90ff983c3f7b0c6eee2ede3c4e5e75eaf2aa571ea8cb956d95",
+                "sha256:4dd7d8fdee36cc6bde0bcb08b79375009de7a76d935d1401b6ae4b62505b9ee0",
+                "sha256:4e83e18722d0bdc2e603f7ca104adf276d5728a664b9e94c99e2d8c02001429c",
+                "sha256:5354c1cf83d36b2d03ee5774923d30fe838f9371963b42ca46ecba45d3507ff4",
+                "sha256:5586bc773d6cee4f9a14692f5e6bc6387ddb54b2bfae0db01c0695aac20c420a",
+                "sha256:56146e66774c30e238088f67be47740ffd4f669c08e76f2e470bd611d7bdae46",
+                "sha256:59953e8445e69e5fee53381c437d3494f7fac8d7b51f0169d59b69eba8f95063",
+                "sha256:5b6cfc2b62e6282eabbcb6c7223b0a8c05ed3a326e7b467d06b85a3eeda1bfc8",
+                "sha256:5c8c2eeb838538fffaa421f9b3f9c671778886595b5aa0d4ef4d000531e721d2",
+                "sha256:6732b224be7e69f7c77798e50205f8e92646ab59724151d66d8dc97f92e99a77",
+                "sha256:700bae69eb7c45037deef4a729586f32205d391de38802e2ab89151a7a87d1fc",
+                "sha256:76d12185c335c14b04b8706b4dd0badc16f4185caeb635419c84e575cef7c980",
+                "sha256:779d81aac693e57090343ce5b18f477fec993f969aa87660a33e7ce81880ccdf",
+                "sha256:82678a77e471dd3b0ad5ed47a4a42474af3150819718eff7e36dca32ae591949",
+                "sha256:87b6e92a869932f4aac3076816a1b987c581b01e49a08e495bef7165be049dfd",
+                "sha256:9228c512334905338f65825102e47778e5ce034bb4249c3deb22991826ed061f",
+                "sha256:9ad8bc6e3f168fe8c164634c762d3265c775643defff10e26273623a12d73ae1",
+                "sha256:9c295b424a271ce5022da83a1274b4cd0f696c5b8e0c190e6a28efde8b36e82d",
+                "sha256:9d364a929121df5b96af53ac62abdd61fa3a931e74c7a4c80204c961c01a8667",
+                "sha256:a2fa3a89f6a0cf03a56141dad158634a009a22fbe645c7c01e85edc12a0a239f",
+                "sha256:a37fe4f302edb8d931a4c386d0944f996e3f54717495636113880c4492ab479f",
+                "sha256:a49b13ec79edff347b1e7af65f5843719ca151ef071ac6b2ff564bb69d164331",
+                "sha256:b20036ab22df2fb663f797b110fa82d4798084fcc56c8a264af50989581062be",
+                "sha256:b3dd7143dfc37a20f7d1ccf32f916ac78c11d3c8bae61438ee06376b1bc535fc",
+                "sha256:b60b260c70bb77d7f3b666bdd2a2a74cead5e36814f8b4295778bcdd08f65c7e",
+                "sha256:c50ee0df6b0b06f1dad6261670b5be53c909b9a2b1985bcf65ea5b0d766fd10e",
+                "sha256:ca46637fcc0386fdbe6bde447184ed981499c8c1b5b5fcaa0f35c3b15528162a",
+                "sha256:d4bc5e43d02e0848c3174914595dfcebed9b74e65cbdfb1011c5082db7916605",
+                "sha256:d6c05af8b49c442422ce49565ab41a094b23e0f5692abe1533428cbe35a78f8e",
+                "sha256:d80bde641349198c8c17684692a8cc40a36a93c0cebd8f1d7c42db7ceeaa17be",
+                "sha256:db8607a32347da1fd4519cfea441d8b36b44df0c53198ae0471c76fc932a86e0",
+                "sha256:ddae9592604fe04ec065cc53a321844c3592c812988346136d8ee548127f3d12",
+                "sha256:e1031c7c5f8516108e7c24190179e6a522183de218a954681a341ee818f8079a",
+                "sha256:e36f50a963d149bb7152543db9bdbd73f7997e66b57b7956fc17751f55e59625",
+                "sha256:e7e2c398679c863e810a9af2c5d14542a32d438e3bf5ba0b9d8e119326c33303",
+                "sha256:f2b26c23efeded0e7fcfd0fb4d638ec4a83d120e1d455267d353090e36479528",
+                "sha256:f56ec955f43f944985f857c9d23030362df52e14a7c53c64bf8b29cfadebd601",
+                "sha256:f9a28b0416a36ec32273ee1ac80cc72ff9b06d1cb15a9481dcd5c92bd2bc8f03"
             ],
-            "markers": "python_version >= '3.7' and python_version < '4.0'",
-            "version": "==0.131.0"
+            "markers": "python_version >= '3.8' and python_version < '4.0'",
+            "version": "==0.132.2"
         }
     },
-    "develop": {}
+    "develop": {
+        "black": {
+            "hashes": [
+                "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474",
+                "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1",
+                "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0",
+                "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8",
+                "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96",
+                "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1",
+                "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04",
+                "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021",
+                "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94",
+                "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d",
+                "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c",
+                "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7",
+                "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c",
+                "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc",
+                "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7",
+                "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d",
+                "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c",
+                "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741",
+                "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce",
+                "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb",
+                "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063",
+                "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==24.4.2"
+        },
+        "click": {
+            "hashes": [
+                "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28",
+                "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==8.1.7"
+        },
+        "flake8": {
+            "hashes": [
+                "sha256:2e416edcc62471a64cea09353f4e7bdba32aeb079b6e360554c659a122b1bc6a",
+                "sha256:48a07b626b55236e0fb4784ee69a465fbf59d79eec1f5b4785c3d3bc57d17aa5"
+            ],
+            "index": "pypi",
+            "markers": "python_full_version >= '3.8.1'",
+            "version": "==7.1.0"
+        },
+        "mccabe": {
+            "hashes": [
+                "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325",
+                "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==0.7.0"
+        },
+        "mypy": {
+            "hashes": [
+                "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54",
+                "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a",
+                "sha256:0bd53faf56de9643336aeea1c925012837432b5faf1701ccca7fde70166ccf72",
+                "sha256:11965c2f571ded6239977b14deebd3f4c3abd9a92398712d6da3a772974fad69",
+                "sha256:1a81cf05975fd61aec5ae16501a091cfb9f605dc3e3c878c0da32f250b74760b",
+                "sha256:2684d3f693073ab89d76da8e3921883019ea8a3ec20fa5d8ecca6a2db4c54bbe",
+                "sha256:2c63350af88f43a66d3dfeeeb8d77af34a4f07d760b9eb3a8697f0386c7590b4",
+                "sha256:45df906e8b6804ef4b666af29a87ad9f5921aad091c79cc38e12198e220beabd",
+                "sha256:4c956b49c5d865394d62941b109728c5c596a415e9c5b2be663dd26a1ff07bc0",
+                "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525",
+                "sha256:749fd3213916f1751fff995fccf20c6195cae941dc968f3aaadf9bb4e430e5a2",
+                "sha256:79c07eb282cb457473add5052b63925e5cc97dfab9812ee65a7c7ab5e3cb551c",
+                "sha256:7b6343d338390bb946d449677726edf60102a1c96079b4f002dedff375953fc5",
+                "sha256:886c9dbecc87b9516eff294541bf7f3655722bf22bb898ee06985cd7269898de",
+                "sha256:a2b43895a0f8154df6519706d9bca8280cda52d3d9d1514b2d9c3e26792a0b74",
+                "sha256:a32fc80b63de4b5b3e65f4be82b4cfa362a46702672aa6a0f443b4689af7008c",
+                "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e",
+                "sha256:a831671bad47186603872a3abc19634f3011d7f83b083762c942442d51c58d58",
+                "sha256:b639dce63a0b19085213ec5fdd8cffd1d81988f47a2dec7100e93564f3e8fb3b",
+                "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417",
+                "sha256:c1952f5ea8a5a959b05ed5f16452fddadbaae48b5d39235ab4c3fc444d5fd411",
+                "sha256:d44be7551689d9d47b7abc27c71257adfdb53f03880841a5db15ddb22dc63edb",
+                "sha256:e1e30dc3bfa4e157e53c1d17a0dad20f89dc433393e7702b813c10e200843b03",
+                "sha256:e4fe9f4e5e521b458d8feb52547f4bade7ef8c93238dfb5bbc790d9ff2d770ca",
+                "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8",
+                "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08",
+                "sha256:fca4a60e1dd9fd0193ae0067eaeeb962f2d79e0d9f0f66223a0682f26ffcc809"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==1.11.1"
+        },
+        "mypy-extensions": {
+            "hashes": [
+                "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d",
+                "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==1.0.0"
+        },
+        "packaging": {
+            "hashes": [
+                "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002",
+                "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==24.1"
+        },
+        "pathspec": {
+            "hashes": [
+                "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08",
+                "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==0.12.1"
+        },
+        "platformdirs": {
+            "hashes": [
+                "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee",
+                "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==4.2.2"
+        },
+        "pycodestyle": {
+            "hashes": [
+                "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c",
+                "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==2.12.0"
+        },
+        "pyflakes": {
+            "hashes": [
+                "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f",
+                "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==3.2.0"
+        },
+        "types-protobuf": {
+            "hashes": [
+                "sha256:683ba14043bade6785e3f937a7498f243b37881a91ac8d81b9202ecf8b191e9c",
+                "sha256:688e8f7e8d9295db26bc560df01fb731b27a25b77cbe4c1ce945647f7024f5c1"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==5.27.0.20240626"
+        },
+        "types-requests": {
+            "hashes": [
+                "sha256:90c079ff05e549f6bf50e02e910210b98b8ff1ebdd18e19c873cd237737c1358",
+                "sha256:f754283e152c752e46e70942fa2a146b5bc70393522257bb85bd1ef7e019dcc3"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.8'",
+            "version": "==2.32.0.20240712"
+        },
+        "typing-extensions": {
+            "hashes": [
+                "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d",
+                "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==4.12.2"
+        },
+        "urllib3": {
+            "hashes": [
+                "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472",
+                "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==2.2.2"
+        }
+    }
 }
diff --git a/README.md b/README.md
index a50d527..aef8c68 100644
--- a/README.md
+++ b/README.md
@@ -10,19 +10,20 @@ Built on [Librespot](https://github.com/kokarare1212/librespot-python).
 
 ## Features
 
-- Save tracks at up to 320kbps\*
+- Save tracks at up to 320kbps<sup>**1**</sup>
 - Save to most popular audio formats
 - Built in search
 - Bulk downloads
-- Downloads synced lyrics
+- Downloads synced lyrics<sup>**2**</sup>
 - Embedded metadata
 - Downloads all audio, metadata and lyrics directly, no substituting from other services.
 
-\*Non-premium accounts are limited to 160kbps
+**1**: Non-premium accounts are limited to 160kbps \
+**2**: Requires premium
 
 ## Installation
 
-Requires Python 3.10 or greater. \
+Requires Python 3.11 or greater. \
 Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis.
 
 Enter the following command in terminal to install Zotify. \
@@ -64,8 +65,6 @@ 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       |
 
 </details>
 
@@ -104,7 +103,7 @@ 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.
+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 the 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.
@@ -112,7 +111,7 @@ All new contributions should follow this principle to keep the program consisten
 
 ## Will my account get banned if I use this tool?
 
-There have been no confirmed cases of accounts getting banned as a result of using Zotify.
+There have been no *confirmed* cases of accounts getting banned as a result of 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://watsonbox.github.io/exportify/) to keep backups of your playlists.
diff --git a/requirements.txt b/requirements.txt
index 8ae15d7..4d4febf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-librespot>=0.0.9
+librespot@git+https://github.com/kokarare1212/librespot-python
 music-tag@git+https://zotify.xyz/zotify/music-tag
 mutagen
 Pillow
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 7caa5e4..3d7dcff 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -1,7 +1,6 @@
 black
 flake8
 mypy
-pre-commit
 types-protobuf
 types-requests
 wheel
diff --git a/setup.cfg b/setup.cfg
index 94867d4..743a7b8 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,11 +1,11 @@
 [metadata]
 name = zotify
-version = 0.9.4
+version = 0.9.5
 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
+keywords = music, podcast, downloader
 licence = Zlib
 classifiers =
     Programming Language :: Python :: 3
@@ -17,9 +17,9 @@ classifiers =
 
 [options]
 packages = zotify
-python_requires = >=3.10
+python_requires = >=3.11
 install_requires =
-    librespot>=0.0.9
+    librespot@git+https://github.com/kokarare1212/librespot-python
     music-tag@git+https://zotify.xyz/zotify/music-tag
     mutagen
     Pillow
diff --git a/zotify/__init__.py b/zotify/__init__.py
index 01148a3..185c9a1 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -97,7 +97,7 @@ class Session(LibrespotSession):
             self.__language = language
 
     @staticmethod
-    def from_file(cred_file: Path, language: str = "en") -> Session:
+    def from_file(cred_file: Path | str, language: str = "en") -> Session:
         """
         Creates session using saved credentials file
         Args:
@@ -106,6 +106,8 @@ class Session(LibrespotSession):
         Returns:
             Zotify session
         """
+        if not isinstance(cred_file, Path):
+            cred_file = Path(cred_file).expanduser()
         conf = (
             LibrespotSession.Configuration.Builder()
             .set_store_credentials(False)
@@ -118,7 +120,7 @@ class Session(LibrespotSession):
     def from_userpass(
         username: str,
         password: str,
-        save_file: Path | None = None,
+        save_file: Path | str | None = None,
         language: str = "en",
     ) -> Session:
         """
@@ -133,6 +135,8 @@ class Session(LibrespotSession):
         """
         builder = LibrespotSession.Configuration.Builder()
         if save_file:
+            if not isinstance(save_file, Path):
+                save_file = Path(save_file).expanduser()
             save_file.parent.mkdir(parents=True, exist_ok=True)
             builder.set_stored_credential_file(str(save_file))
         else:
@@ -144,7 +148,9 @@ class Session(LibrespotSession):
         return Session(session, language)
 
     @staticmethod
-    def from_prompt(save_file: Path | None = None, language: str = "en") -> Session:
+    def from_prompt(
+        save_file: Path | str | None = None, language: str = "en"
+    ) -> Session:
         """
         Creates a session with username + password supplied from CLI prompt
         Args:
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 6250003..28d17bc 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -5,16 +5,15 @@ from pathlib import Path
 
 from zotify.app import App
 from zotify.config import CONFIG_PATHS, CONFIG_VALUES
-from zotify.utils import OptionalOrFalse, SimpleHelpFormatter
+from zotify.utils import OptionalOrFalse
 
-VERSION = "0.9.4"
+VERSION = "0.9.5"
 
 
 def main():
     parser = ArgumentParser(
         prog="zotify",
         description="A fast and customizable music and podcast downloader",
-        formatter_class=SimpleHelpFormatter,
     )
     parser.add_argument(
         "-v",
@@ -53,7 +52,7 @@ def main():
     )
     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 = parser.add_mutually_exclusive_group(required=True)
     group.add_argument(
         "urls",
         type=str,
diff --git a/zotify/app.py b/zotify/app.py
index 691dc91..5772765 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -8,11 +8,7 @@ from zotify.config import Config
 from zotify.file import TranscodingError
 from zotify.loader import Loader
 from zotify.logger import LogChannel, Logger
-from zotify.utils import (
-    AudioFormat,
-    CollectionType,
-    PlayableType,
-)
+from zotify.utils import AudioFormat, PlayableType
 
 
 class ParseError(ValueError): ...
@@ -32,7 +28,7 @@ class Selection:
     def search(
         self,
         search_text: str,
-        category: list = [
+        category: list[str] = [
             "track",
             "album",
             "artist",
@@ -56,12 +52,13 @@ class Selection:
                 offset=0,
             )
 
+        print(f'Search results for "{search_text}"')
         count = 0
         for cat in categories.split(","):
             label = cat + "s"
             items = resp[label]["items"]
             if len(items) > 0:
-                print(f"\n### {label.capitalize()} ###")
+                print(f"\n{label.capitalize()}:")
                 try:
                     self.__print(count, items, *self.__print_labels[cat])
                 except KeyError:
@@ -109,7 +106,7 @@ class Selection:
 
     def __print(self, count: int, items: list[dict[str, Any]], *args: str) -> None:
         arg_range = range(len(args))
-        category_str = "   " + " ".join("{:<38}" for _ in arg_range)
+        category_str = " # " + " ".join("{:<38}" for _ in arg_range)
         print(category_str.format(*[s.upper() for s in list(args)]))
         for item in items:
             count += 1
@@ -149,30 +146,21 @@ class App:
         self.__config = Config(args)
         Logger(self.__config)
 
-        # Check options
-        if self.__config.audio_format == AudioFormat.VORBIS and (
-            self.__config.ffmpeg_args != "" or self.__config.ffmpeg_path != ""
-        ):
-            Logger.log(
-                LogChannel.WARNINGS,
-                "FFmpeg options will be ignored since no transcoding is required",
-            )
-
         # Create session
         if args.username != "" and args.password != "":
             self.__session = Session.from_userpass(
                 args.username,
                 args.password,
-                self.__config.credentials,
+                self.__config.credentials_path,
                 self.__config.language,
             )
-        elif self.__config.credentials.is_file():
+        elif self.__config.credentials_path.is_file():
             self.__session = Session.from_file(
-                self.__config.credentials, self.__config.language
+                self.__config.credentials_path, self.__config.language
             )
         else:
             self.__session = Session.from_prompt(
-                self.__config.credentials, self.__config.language
+                self.__config.credentials_path, self.__config.language
             )
 
         # Get items to download
@@ -182,6 +170,7 @@ class App:
                 collections = self.parse(ids)
             except ParseError as e:
                 Logger.log(LogChannel.ERRORS, str(e))
+                exit(1)
         if len(collections) > 0:
             self.download_all(collections)
         else:
@@ -208,11 +197,12 @@ class App:
                 return ids
             elif args.urls:
                 return args.urls
-        except (FileNotFoundError, ValueError):
-            Logger.log(LogChannel.WARNINGS, "there is nothing to do")
         except KeyboardInterrupt:
             Logger.log(LogChannel.WARNINGS, "\nthere is nothing to do")
             exit(130)
+        except (FileNotFoundError, ValueError):
+            pass
+        Logger.log(LogChannel.WARNINGS, "there is nothing to do")
         exit(0)
 
     def parse(self, links: list[str]) -> list[Collection]:
@@ -226,28 +216,28 @@ class App:
             except IndexError:
                 raise ParseError(f'Could not parse "{link}"')
 
-            match id_type:
-                case "album":
-                    collections.append(Album(self.__session, _id))
-                case "artist":
-                    collections.append(Artist(self.__session, _id))
-                case "show":
-                    collections.append(Show(self.__session, _id))
-                case "track":
-                    collections.append(Track(self.__session, _id))
-                case "episode":
-                    collections.append(Episode(self.__session, _id))
-                case "playlist":
-                    collections.append(Playlist(self.__session, _id))
-                case _:
-                    raise ParseError(f'Unsupported content type "{id_type}"')
+            collection_types = {
+                "album": Album,
+                "artist": Artist,
+                "show": Show,
+                "track": Track,
+                "episode": Episode,
+                "playlist": Playlist,
+            }
+            try:
+                collections.append(
+                    collection_types[id_type](_id, self.__session.api(), self.__config)
+                )
+            except ValueError:
+                raise ParseError(f'Unsupported content type "{id_type}"')
         return collections
 
     def download_all(self, collections: list[Collection]) -> None:
-        """Downloads playable to local file"""
+        count = 0
+        total = sum(len(c.playables) for c in collections)
         for collection in collections:
-            for i in range(len(collection.playables)):
-                playable = collection.playables[i]
+            for playable in collection.playables:
+                count += 1
 
                 # Get track data
                 if playable.type == PlayableType.TRACK:
@@ -263,43 +253,51 @@ class App:
                         LogChannel.SKIPS,
                         f'Download Error: Unknown playable content "{playable.type}"',
                     )
-                    return
+                    continue
 
                 # Create download location and generate file name
-                match collection.type():
-                    case CollectionType.PLAYLIST:
-                        # TODO: add playlist name to track metadata
-                        library = self.__config.playlist_library
-                        template = (
-                            self.__config.output_playlist_track
-                            if playable.type == PlayableType.TRACK
-                            else self.__config.output_playlist_episode
-                        )
-                    case CollectionType.SHOW | CollectionType.EPISODE:
-                        library = self.__config.podcast_library
-                        template = self.__config.output_podcast
-                    case _:
-                        library = self.__config.music_library
-                        template = self.__config.output_album
-                output = track.create_output(
-                    library, template, self.__config.replace_existing
-                )
+                track.metadata.extend(playable.metadata)
+                try:
+                    output = track.create_output(
+                        playable.library,
+                        playable.output_template,
+                        self.__config.replace_existing,
+                    )
+                except FileExistsError:
+                    Logger.log(
+                        LogChannel.SKIPS,
+                        f'Skipping "{track.name}": Already exists at specified output',
+                    )
 
-                file = track.write_audio_stream(output)
+                # Download track
+                with Logger.progress(
+                    desc=f"({count}/{total}) {track.name}",
+                    total=track.input_stream.size,
+                ) as p_bar:
+                    file = track.write_audio_stream(output, p_bar)
 
                 # Download lyrics
                 if playable.type == PlayableType.TRACK and self.__config.lyrics_file:
-                    with Loader("Fetching lyrics..."):
-                        try:
-                            track.get_lyrics().save(output)
-                        except FileNotFoundError as e:
-                            Logger.log(LogChannel.SKIPS, str(e))
+                    if not self.__session.is_premium():
+                        Logger.log(
+                            LogChannel.SKIPS,
+                            f'Failed to save lyrics for "{track.name}": Lyrics are only available to premium users',
+                        )
+                    else:
+                        with Loader("Fetching lyrics..."):
+                            try:
+                                track.lyrics().save(output)
+                            except FileNotFoundError as e:
+                                Logger.log(LogChannel.SKIPS, str(e))
                 Logger.log(LogChannel.DOWNLOADS, f"\nDownloaded {track.name}")
 
                 # Transcode audio
-                if self.__config.audio_format != AudioFormat.VORBIS:
+                if (
+                    self.__config.audio_format != AudioFormat.VORBIS
+                    or self.__config.ffmpeg_args != ""
+                ):
                     try:
-                        with Loader(LogChannel.PROGRESS, "Converting audio..."):
+                        with Loader("Converting audio..."):
                             file.transcode(
                                 self.__config.audio_format,
                                 self.__config.transcode_bitrate,
diff --git a/zotify/collections.py b/zotify/collections.py
index d43a3ed..40ab149 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -5,80 +5,105 @@ from librespot.metadata import (
     ShowId,
 )
 
-from zotify import Session
-from zotify.utils import CollectionType, PlayableData, PlayableType, bytes_to_base62
+from zotify import Api
+from zotify.config import Config
+from zotify.utils import MetadataEntry, PlayableData, PlayableType, bytes_to_base62
 
 
 class Collection:
     playables: list[PlayableData] = []
 
-    def type(self) -> CollectionType:
-        return CollectionType(self.__class__.__name__.lower())
+    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+        raise NotImplementedError
 
 
 class Album(Collection):
-    def __init__(self, session: Session, b62_id: str):
-        album = session.api().get_metadata_4_album(AlbumId.from_base62(b62_id))
+    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+        album = api.get_metadata_4_album(AlbumId.from_base62(b62_id))
         for disc in album.disc:
             for track in disc.track:
                 self.playables.append(
                     PlayableData(
                         PlayableType.TRACK,
                         bytes_to_base62(track.gid),
+                        config.album_library,
+                        config.output_album,
                     )
                 )
 
 
 class Artist(Collection):
-    def __init__(self, session: Session, b62_id: str):
-        artist = session.api().get_metadata_4_artist(ArtistId.from_base62(b62_id))
+    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+        artist = api.get_metadata_4_artist(ArtistId.from_base62(b62_id))
         for album_group in (
             artist.album_group
             and artist.single_group
             and artist.compilation_group
             and artist.appears_on_group
         ):
-            album = session.api().get_metadata_4_album(
-                AlbumId.from_hex(album_group.album[0].gid)
-            )
+            album = api.get_metadata_4_album(AlbumId.from_hex(album_group.album[0].gid))
             for disc in album.disc:
                 for track in disc.track:
                     self.playables.append(
                         PlayableData(
                             PlayableType.TRACK,
                             bytes_to_base62(track.gid),
+                            config.album_library,
+                            config.output_album,
                         )
                     )
 
 
 class Show(Collection):
-    def __init__(self, session: Session, b62_id: str):
-        show = session.api().get_metadata_4_show(ShowId.from_base62(b62_id))
+    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+        show = api.get_metadata_4_show(ShowId.from_base62(b62_id))
         for episode in show.episode:
             self.playables.append(
-                PlayableData(PlayableType.EPISODE, bytes_to_base62(episode.gid))
+                PlayableData(
+                    PlayableType.EPISODE,
+                    bytes_to_base62(episode.gid),
+                    config.podcast_library,
+                    config.output_podcast,
+                )
             )
 
 
 class Playlist(Collection):
-    def __init__(self, session: Session, b62_id: str):
-        playlist = session.api().get_playlist(PlaylistId(b62_id))
-        # self.name = playlist.title
-        for item in playlist.contents.items:
+    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+        playlist = api.get_playlist(PlaylistId(b62_id))
+        for i in range(len(playlist.contents.items)):
+            item = playlist.contents.items[i]
             split = item.uri.split(":")
             playable_type = split[1]
+            playable_id = split[2]
+            metadata = [
+                MetadataEntry("playlist", playlist.attributes.name),
+                MetadataEntry("playlist_length", playlist.length),
+                MetadataEntry("playlist_owner", playlist.owner_username),
+                MetadataEntry(
+                    "playlist_number",
+                    i + 1,
+                    str(i + 1).zfill(len(str(playlist.length + 1))),
+                ),
+            ]
             if playable_type == "track":
                 self.playables.append(
                     PlayableData(
                         PlayableType.TRACK,
-                        split[2],
+                        playable_id,
+                        config.playlist_library,
+                        config.output_playlist_track,
+                        metadata,
                     )
                 )
             elif playable_type == "episode":
                 self.playables.append(
                     PlayableData(
                         PlayableType.EPISODE,
-                        split[2],
+                        playable_id,
+                        config.playlist_library,
+                        config.output_playlist_episode,
+                        metadata,
                     )
                 )
             else:
@@ -86,10 +111,21 @@ class Playlist(Collection):
 
 
 class Track(Collection):
-    def __init__(self, session: Session, b62_id: str):
-        self.playables.append(PlayableData(PlayableType.TRACK, b62_id))
+    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+        self.playables.append(
+            PlayableData(
+                PlayableType.TRACK, b62_id, config.album_library, config.output_album
+            )
+        )
 
 
 class Episode(Collection):
-    def __init__(self, session: Session, b62_id: str):
-        self.playables.append(PlayableData(PlayableType.EPISODE, b62_id))
+    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+        self.playables.append(
+            PlayableData(
+                PlayableType.EPISODE,
+                b62_id,
+                config.podcast_library,
+                config.output_podcast,
+            )
+        )
diff --git a/zotify/config.py b/zotify/config.py
index b6dcf53..8961989 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -7,18 +7,18 @@ from typing import Any
 
 from zotify.utils import AudioFormat, ImageSize, Quality
 
+ALBUM_LIBRARY = "album_library"
 ALL_ARTISTS = "all_artists"
 ARTWORK_SIZE = "artwork_size"
 AUDIO_FORMAT = "audio_format"
 CREATE_PLAYLIST_FILE = "create_playlist_file"
-CREDENTIALS = "credentials"
+CREDENTIALS_PATH = "credentials_path"
 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"
 OUTPUT_ALBUM = "output_album"
 OUTPUT_PLAYLIST_TRACK = "output_playlist_track"
@@ -49,7 +49,7 @@ SYSTEM_PATHS = {
 }
 
 LIBRARY_PATHS = {
-    "music": Path.home().joinpath("Music/Zotify Music"),
+    "album": Path.home().joinpath("Music/Zotify Albums"),
     "podcast": Path.home().joinpath("Music/Zotify Podcasts"),
     "playlist": Path.home().joinpath("Music/Zotify Playlists"),
 }
@@ -68,7 +68,7 @@ OUTPUT_PATHS = {
 }
 
 CONFIG_VALUES = {
-    CREDENTIALS: {
+    CREDENTIALS_PATH: {
         "default": CONFIG_PATHS["creds"],
         "type": Path,
         "args": ["--credentials"],
@@ -80,11 +80,11 @@ CONFIG_VALUES = {
         "args": ["--archive"],
         "help": "Path to track archive file",
     },
-    MUSIC_LIBRARY: {
-        "default": LIBRARY_PATHS["music"],
+    ALBUM_LIBRARY: {
+        "default": LIBRARY_PATHS["album"],
         "type": Path,
-        "args": ["--music-library"],
-        "help": "Path to root of music library",
+        "args": ["--album-library"],
+        "help": "Path to root of album library",
     },
     PODCAST_LIBRARY: {
         "default": LIBRARY_PATHS["podcast"],
@@ -138,8 +138,8 @@ CONFIG_VALUES = {
     },
     AUDIO_FORMAT: {
         "default": "vorbis",
-        "type": AudioFormat,
-        "choices": [n.value.name for n in AudioFormat],
+        "type": AudioFormat.from_string,
+        "choices": list(AudioFormat),
         "args": ["--audio-format"],
         "help": "Audio format of final track output",
     },
@@ -256,13 +256,13 @@ CONFIG_VALUES = {
 
 class Config:
     __config_file: Path | None
+    album_library: Path
     artwork_size: ImageSize
     audio_format: AudioFormat
-    credentials: Path
+    credentials_path: Path
     download_quality: Quality
     ffmpeg_args: str
     ffmpeg_path: str
-    music_library: Path
     language: str
     lyrics_file: bool
     output_album: str
@@ -276,9 +276,9 @@ class Config:
     save_metadata: bool
     transcode_bitrate: int
 
-    def __init__(self, args: Namespace = Namespace()):
+    def __init__(self, args: Namespace | None = None):
         jsonvalues = {}
-        if args.config:
+        if args is not None and args.config:
             self.__config_file = Path(args.config)
             # Valid config file found
             if self.__config_file.exists():
@@ -300,7 +300,7 @@ class Config:
 
         for key in CONFIG_VALUES:
             # Override config with commandline arguments
-            if key in vars(args) and vars(args)[key] is not None:
+            if args is not None and 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:
@@ -314,14 +314,13 @@ class Config:
                 )
 
         # "library" arg overrides all *_library options
-        if args.library:
-            print("args.library")
-            self.music_library = Path(args.library).expanduser().resolve()
+        if args is not None and args.library:
+            self.album_library = Path(args.library).expanduser().resolve()
             self.playlist_library = Path(args.library).expanduser().resolve()
             self.podcast_library = Path(args.library).expanduser().resolve()
 
         # "output" arg overrides all output_* options
-        if args.output:
+        if args is not None and args.output:
             self.output_album = args.output
             self.output_podcast = args.output
             self.output_playlist_track = args.output
@@ -334,8 +333,8 @@ class Config:
             return value
         elif config_type == Path:
             return Path(value).expanduser().resolve()
-        elif config_type == AudioFormat:
-            return AudioFormat[value.upper()]
+        elif config_type == AudioFormat.from_string:
+            return AudioFormat.from_string(value)
         elif config_type == ImageSize.from_string:
             return ImageSize.from_string(value)
         elif config_type == Quality.from_string:
diff --git a/zotify/loader.py b/zotify/loader.py
index 364a147..57c8bf3 100644
--- a/zotify/loader.py
+++ b/zotify/loader.py
@@ -4,7 +4,7 @@ from __future__ import annotations
 
 from itertools import cycle
 from shutil import get_terminal_size
-from sys import platform
+from sys import platform as PLATFORM
 from threading import Thread
 from time import sleep
 
@@ -22,7 +22,7 @@ class Loader:
         pass
     """
 
-    def __init__(self, desc="Loading...", end="", timeout=0.1, mode="std3") -> None:
+    def __init__(self, desc: str = "Loading...", end: str = "", timeout: float = 0.1):
         """
         A loader-like context manager
         Args:
@@ -35,7 +35,8 @@ class Loader:
         self.timeout = timeout
 
         self.__thread = Thread(target=self.__animate, daemon=True)
-        if platform == "win32":
+        # Cool loader looks awful in cmd
+        if PLATFORM == "win32":
             self.steps = ["/", "-", "\\", "|"]
         else:
             self.steps = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]
diff --git a/zotify/logger.py b/zotify/logger.py
index 46c9112..1034bfc 100644
--- a/zotify/logger.py
+++ b/zotify/logger.py
@@ -22,7 +22,7 @@ class LogChannel(Enum):
 
 
 class Logger:
-    __config: Config
+    __config: Config = Config()
 
     @classmethod
     def __init__(cls, config: Config):
@@ -50,9 +50,9 @@ class Logger:
         total=None,
         leave=False,
         position=0,
-        unit="it",
-        unit_scale=False,
-        unit_divisor=1000,
+        unit="B",
+        unit_scale=True,
+        unit_divisor=1024,
     ) -> tqdm:
         """
         Prints progress bar
diff --git a/zotify/playable.py b/zotify/playable.py
index e2da87a..ff81f99 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -7,14 +7,13 @@ from librespot.metadata import AlbumId
 from librespot.structure import GeneralAudioStream
 from librespot.util import bytes_to_hex
 from requests import get
+from tqdm import tqdm
 
 from zotify.file import LocalFile
-from zotify.logger import Logger
 from zotify.utils import (
     AudioFormat,
     ImageSize,
     MetadataEntry,
-    PlayableType,
     bytes_to_base62,
     fix_filename,
 )
@@ -40,13 +39,15 @@ class Lyrics:
                     f"[{ts_minutes}:{ts_seconds}.{ts_millis}]{line.words}\n"
                 )
 
-    def save(self, path: Path, prefer_synced: bool = True) -> None:
+    def save(self, path: Path | str, 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 not isinstance(path, Path):
+            path = Path(path).expanduser()
         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)
@@ -60,10 +61,12 @@ class Playable:
     input_stream: GeneralAudioStream
     metadata: list[MetadataEntry]
     name: str
-    type: PlayableType
 
     def create_output(
-        self, library: Path = Path("./"), output: str = "{title}", replace: bool = False
+        self,
+        library: Path | str = Path("./"),
+        output: str = "{title}",
+        replace: bool = False,
     ) -> Path:
         """
         Creates save directory for the output file
@@ -74,6 +77,8 @@ class Playable:
         Returns:
             File path for the track
         """
+        if not isinstance(library, Path):
+            library = Path(library)
         for meta in self.metadata:
             if meta.string is not None:
                 output = output.replace(
@@ -87,26 +92,20 @@ class Playable:
             return file_path
 
     def write_audio_stream(
-        self,
-        output: Path,
+        self, output: Path | str, p_bar: tqdm = tqdm(disable=True)
     ) -> LocalFile:
         """
         Writes audio stream to file
         Args:
             output: File path of saved audio stream
+            p_bar: tqdm progress bar
         Returns:
             LocalFile object
         """
+        if not isinstance(output, Path):
+            output = Path(output).expanduser()
         file = f"{output}.ogg"
-        with open(file, "wb") as f, Logger.progress(
-            desc=self.name,
-            total=self.input_stream.size,
-            unit="B",
-            unit_scale=True,
-            unit_divisor=1024,
-            position=0,
-            leave=False,
-        ) as p_bar:
+        with open(file, "wb") as f, p_bar as p_bar:
             chunk = None
             while chunk != b"":
                 chunk = self.input_stream.stream().read(1024)
@@ -127,6 +126,8 @@ class Playable:
 
 
 class Track(PlayableContentFeeder.LoadedStream, Playable):
+    __lyrics: Lyrics
+
     def __init__(self, track: PlayableContentFeeder.LoadedStream, api):
         super(Track, self).__init__(
             track.track,
@@ -135,10 +136,8 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             track.metrics,
         )
         self.__api = api
-        self.__lyrics: Lyrics
         self.cover_images = self.album.cover_group.image
         self.metadata = self.__default_metadata()
-        self.type = PlayableType.TRACK
 
     def __getattr__(self, name):
         try:
@@ -154,7 +153,8 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             )
         return [
             MetadataEntry("album", self.album.name),
-            MetadataEntry("album_artist", [a.name for a in self.album.artist]),
+            MetadataEntry("album_artist", self.album.artist[0].name),
+            MetadataEntry("album_artists", [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}"),
@@ -180,7 +180,7 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             ),
         ]
 
-    def lyrics(self) -> Lyrics:
+    def get_lyrics(self) -> Lyrics:
         """Returns track lyrics if available"""
         if not self.track.has_lyrics:
             raise FileNotFoundError(
@@ -208,7 +208,6 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
         self.__api = api
         self.cover_images = self.episode.cover_image.image
         self.metadata = self.__default_metadata()
-        self.type = PlayableType.EPISODE
 
     def __getattr__(self, name):
         try:
@@ -228,29 +227,26 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
             MetadataEntry("title", self.name),
         ]
 
-    def write_audio_stream(self, output: Path) -> LocalFile:
+    def write_audio_stream(
+        self, output: Path | str, p_bar: tqdm = tqdm(disable=True)
+    ) -> LocalFile:
         """
         Writes audio stream to file.
         Uses external source if available for faster download.
         Args:
             output: File path of saved audio stream
+            p_bar: tqdm progress bar
         Returns:
             LocalFile object
         """
+        if not isinstance(output, Path):
+            output = Path(output).expanduser()
         if not bool(self.external_url):
             return super().write_audio_stream(output)
         file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}"
         with get(self.external_url, stream=True) as r, open(
             file, "wb"
-        ) as f, Logger.progress(
-            desc=self.name,
-            total=self.input_stream.size,
-            unit="B",
-            unit_scale=True,
-            unit_divisor=1024,
-            position=0,
-            leave=False,
-        ) as p_bar:
+        ) as f, p_bar as p_bar:
             for chunk in r.iter_content(chunk_size=1024):
                 p_bar.update(f.write(chunk))
         return LocalFile(Path(file))
diff --git a/zotify/utils.py b/zotify/utils.py
index 62dfc22..3710c81 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -1,9 +1,7 @@
-from argparse import Action, ArgumentError, HelpFormatter
+from argparse import Action, ArgumentError
 from enum import Enum, IntEnum
+from pathlib import Path
 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
@@ -25,7 +23,20 @@ class AudioFormat(Enum):
     OPUS = AudioCodec("opus", "ogg")
     VORBIS = AudioCodec("vorbis", "ogg")
     WAV = AudioCodec("wav", "wav")
-    WV = AudioCodec("wavpack", "wv")
+    WAVPACK = AudioCodec("wavpack", "wv")
+
+    def __str__(self):
+        return self.name.lower()
+
+    def __repr__(self):
+        return str(self)
+
+    @staticmethod
+    def from_string(s):
+        try:
+            return AudioFormat[s.upper()]
+        except Exception:
+            return s
 
 
 class Quality(Enum):
@@ -94,15 +105,6 @@ class MetadataEntry:
         self.string = str(string_value)
 
 
-class CollectionType(Enum):
-    ALBUM = "album"
-    ARTIST = "artist"
-    SHOW = "show"
-    PLAYLIST = "playlist"
-    TRACK = "track"
-    EPISODE = "episode"
-
-
 class PlayableType(Enum):
     TRACK = "track"
     EPISODE = "episode"
@@ -111,14 +113,9 @@ class PlayableType(Enum):
 class PlayableData(NamedTuple):
     type: PlayableType
     id: str
-
-
-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)
+    library: Path
+    output_template: str
+    metadata: list[MetadataEntry] = []
 
 
 class OptionalOrFalse(Action):
@@ -171,24 +168,22 @@ class OptionalOrFalse(Action):
         )
 
 
-def fix_filename(filename: str, substitute: str = "_", platform: str = PLATFORM) -> str:
+def fix_filename(
+    filename: str,
+    substitute: str = "_",
+) -> str:
     """
-    Replace invalid characters on Linux/Windows/MacOS with underscores.
+    Replace invalid characters. Trailing spaces & periods are ignored.
     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.]$"
+    regex = (
+        r"[/\\:|<>\"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$"
+    )
     return sub(regex, substitute, str(filename), flags=IGNORECASE)
 
 

From 446c8c2a52bb9b1656cd311fa5cd8cd51d0c50ff Mon Sep 17 00:00:00 2001
From: Zotify <zotify@localhost>
Date: Thu, 15 Aug 2024 02:05:26 +1200
Subject: [PATCH 094/169] Experimental OAuth login

---
 Pipfile               |   2 +-
 Pipfile.lock          |  18 ++-
 zotify/__init__.py    | 357 +++++++++++++++++++++++++++++++-----------
 zotify/app.py         |  34 ++--
 zotify/collections.py |  16 +-
 zotify/file.py        |   6 +-
 zotify/playable.py    |   5 +-
 7 files changed, 310 insertions(+), 128 deletions(-)

diff --git a/Pipfile b/Pipfile
index 030f469..7efd3d1 100644
--- a/Pipfile
+++ b/Pipfile
@@ -8,7 +8,7 @@ librespot = {git = "git+https://github.com/kokarare1212/librespot-python"}
 music-tag = {git = "git+https://zotify.xyz/zotify/music-tag"}
 mutagen = "*"
 pillow = "*"
-pwinput = "*"
+pkce = "*"
 requests = "*"
 tqdm = "*"
 
diff --git a/Pipfile.lock b/Pipfile.lock
index b5a9ee8..937e73b 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "9cf0a0fbfd691c64820035a5c12805f868ae1d2401630b9f68b67b936f5e7892"
+            "sha256": "9a41882e9856db99151e4f1a3712d4b1562f2997e9a51cfcaf473335cd2db74c"
         },
         "pipfile-spec": 6,
         "requires": {
@@ -247,6 +247,15 @@
             "markers": "python_version >= '3.8'",
             "version": "==10.4.0"
         },
+        "pkce": {
+            "hashes": [
+                "sha256:55927e24c7d403b2491ebe182b95d9dcb1807643243d47e3879fbda5aad4471d",
+                "sha256:9775fd76d8a743d39b87df38af1cd04a58c9b5a5242d5a6350ef343d06814ab6"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3'",
+            "version": "==1.0.3"
+        },
         "protobuf": {
             "hashes": [
                 "sha256:06059eb6953ff01e56a25cd02cca1a9649a75a7e65397b5b9b4e929ed71d10cf",
@@ -277,13 +286,6 @@
             "markers": "python_version >= '3.7'",
             "version": "==3.20.1"
         },
-        "pwinput": {
-            "hashes": [
-                "sha256:ca1a8bd06e28872d751dbd4132d8637127c25b408ea3a349377314a5491426f3"
-            ],
-            "index": "pypi",
-            "version": "==1.0.3"
-        },
         "pycryptodomex": {
             "hashes": [
                 "sha256:0daad007b685db36d977f9de73f61f8da2a7104e20aca3effd30752fd56f73e1",
diff --git a/zotify/__init__.py b/zotify/__init__.py
index 185c9a1..52a06df 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -1,78 +1,76 @@
 from __future__ import annotations
 
+from http.server import BaseHTTPRequestHandler, HTTPServer
 from pathlib import Path
+from threading import Thread
+from typing import Any
+from time import time_ns
+from urllib.parse import urlencode, urlparse, parse_qs
 
+from librespot.audio import AudioKeyManager, CdnManager
 from librespot.audio.decoders import VorbisOnlyAudioQuality
-from librespot.core import ApiClient, ApResolver, PlayableContentFeeder
-from librespot.core import Session as LibrespotSession
+from librespot.audio.storage import ChannelManager
+from librespot.cache import CacheManager
+from librespot.core import (
+    ApResolver,
+    DealerClient,
+    EventService,
+    PlayableContentFeeder,
+    SearchManager,
+    ApiClient as LibrespotApiClient,
+    Session as LibrespotSession,
+    TokenProvider as LibrespotTokenProvider,
+)
+from librespot.mercury import MercuryClient
 from librespot.metadata import EpisodeId, PlayableId, TrackId
-from pwinput import pwinput
-from requests import HTTPError, get
+from librespot.proto import Authentication_pb2 as Authentication
+from pkce import generate_code_verifier, get_code_challenge
+from requests import HTTPError, get, post
 
 from zotify.loader import Loader
 from zotify.playable import Episode, Track
 from zotify.utils import Quality
 
 API_URL = "https://api.sp" + "otify.com/v1/"
-
-
-class Api(ApiClient):
-    def __init__(self, session: Session):
-        super(Api, self).__init__(session)
-        self.__session = session
-
-    def __get_token(self) -> str:
-        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 = 20,
-        offset: int = 0,
-    ) -> 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.__session.language(),
-            "app-platform": "WebPlayer",
-        }
-        params["limit"] = limit
-        params["offset"] = offset
-
-        response = get(API_URL + 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
+AUTH_URL = "https://accounts.sp" + "otify.com/"
+REDIRECT_URI = "http://127.0.0.1:4381/login"
+CLIENT_ID = "65b70807" + "3fc0480e" + "a92a0772" + "33ca87bd"
+SCOPES = [
+    "app-remote-control",
+    "playlist-modify",
+    "playlist-modify-private",
+    "playlist-modify-public",
+    "playlist-read",
+    "playlist-read-collaborative",
+    "playlist-read-private",
+    "streaming",
+    "ugc-image-upload",
+    "user-follow-modify",
+    "user-follow-read",
+    "user-library-modify",
+    "user-library-read",
+    "user-modify",
+    "user-modify-playback-state",
+    "user-modify-private",
+    "user-personalized",
+    "user-read-birthdate",
+    "user-read-currently-playing",
+    "user-read-email",
+    "user-read-play-history",
+    "user-read-playback-position",
+    "user-read-playback-state",
+    "user-read-private",
+    "user-read-recently-played",
+    "user-top-read",
+]
 
 
 class Session(LibrespotSession):
     def __init__(
-        self, session_builder: LibrespotSession.Builder, language: str = "en"
+        self,
+        session_builder: LibrespotSession.Builder,
+        token: TokenProvider.StoredToken,
+        language: str = "en",
     ) -> None:
         """
         Authenticates user, saves credentials to a file and generates api token.
@@ -91,10 +89,10 @@ class Session(LibrespotSession):
                 ),
                 ApResolver.get_random_accesspoint(),
             )
+            self.__token = token
+            self.__language = language
             self.connect()
             self.authenticate(session_builder.login_credentials)
-            self.__api = Api(self)
-            self.__language = language
 
     @staticmethod
     def from_file(cred_file: Path | str, language: str = "en") -> Session:
@@ -114,20 +112,16 @@ class Session(LibrespotSession):
             .build()
         )
         session = LibrespotSession.Builder(conf).stored_file(str(cred_file))
-        return Session(session, language)
+        token = session.login_credentials.auth_data  # TODO: this is wrong
+        return Session(session, token, language)
 
     @staticmethod
-    def from_userpass(
-        username: str,
-        password: str,
-        save_file: Path | str | None = None,
-        language: str = "en",
+    def from_oauth(
+        save_file: Path | str | None = None, language: str = "en"
     ) -> Session:
         """
-        Creates session using username & password
+        Creates a session using OAuth2
         Args:
-            username: Account username
-            password: Account password
             save_file: Path to save login credentials to, optional.
             language: ISO 639-1 language code for API responses
         Returns:
@@ -142,26 +136,19 @@ class Session(LibrespotSession):
         else:
             builder.set_store_credentials(False)
 
-        session = LibrespotSession.Builder(builder.build()).user_pass(
-            username, password
-        )
-        return Session(session, language)
-
-    @staticmethod
-    def from_prompt(
-        save_file: Path | str | None = None, language: str = "en"
-    ) -> Session:
-        """
-        Creates a session with username + password supplied from CLI prompt
-        Args:
-            save_file: Path to save login credentials to, optional.
-            language: ISO 639-1 language code for API responses
-        Returns:
-            Zotify session
-        """
+        # TODO: this should be done in App()
         username = input("Username: ")
-        password = pwinput(prompt="Password: ", mask="*")
-        return Session.from_userpass(username, password, save_file, language)
+        auth = OAuth()
+        print(f"Click on the following link to login:\n{auth.get_authorization_url()}")
+        token = auth.await_token()
+
+        session = LibrespotSession.Builder(builder.build())
+        session.login_credentials = Authentication.LoginCredentials(
+            username=username,
+            typ=Authentication.AuthenticationType.values()[3],
+            auth_data=token.access_token.encode(),
+        )
+        return Session(session, token, language)
 
     def __get_playable(
         self, playable_id: PlayableId, quality: Quality
@@ -201,9 +188,9 @@ class Session(LibrespotSession):
             self.api(),
         )
 
-    def api(self) -> Api:
-        """Returns API Client"""
-        return self.__api
+    def token(self) -> TokenProvider.StoredToken:
+        """Returns API token"""
+        return self.__token
 
     def language(self) -> str:
         """Returns session language"""
@@ -212,3 +199,189 @@ class Session(LibrespotSession):
     def is_premium(self) -> bool:
         """Returns users premium account status"""
         return self.get_user_attribute("type") == "premium"
+
+    def authenticate(self, credential: Authentication.LoginCredentials) -> None:
+        """
+        Log in to the thing
+        Args:
+            credential: Account login information
+        """
+        self.__authenticate_partial(credential, False)
+        with self.__auth_lock:
+            self.__mercury_client = MercuryClient(self)
+            self.__token_provider = TokenProvider(self)
+            self.__audio_key_manager = AudioKeyManager(self)
+            self.__channel_manager = ChannelManager(self)
+            self.__api = ApiClient(self)
+            self.__cdn_manager = CdnManager(self)
+            self.__content_feeder = PlayableContentFeeder(self)
+            self.__cache_manager = CacheManager(self)
+            self.__dealer_client = DealerClient(self)
+            self.__search = SearchManager(self)
+            self.__event_service = EventService(self)
+            self.__auth_lock_bool = False
+            self.__auth_lock.notify_all()
+        self.dealer().connect()
+        self.mercury().interested_in("sp" + "otify:user:attributes:update", self)
+        self.dealer().add_message_listener(
+            self, ["hm://connect-state/v1/connect/logout"]
+        )
+
+
+class ApiClient(LibrespotApiClient):
+    def __init__(self, session: Session):
+        super(ApiClient, self).__init__(session)
+        self.__session = session
+
+    def invoke_url(
+        self,
+        url: str,
+        params: dict[str, Any] = {},
+        limit: int = 20,
+        offset: int = 0,
+    ) -> dict[str, Any]:
+        """
+        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.__session.language(),
+            "app-platform": "WebPlayer",
+        }
+        params["limit"] = limit
+        params["offset"] = offset
+
+        response = get(API_URL + 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
+
+    def __get_token(self) -> str:
+        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
+        )
+
+
+class TokenProvider(LibrespotTokenProvider):
+    def __init__(self, session: Session):
+        super(TokenProvider, self).__init__(session)
+        self._session = session
+
+    def get_token(self, *scopes) -> TokenProvider.StoredToken:
+        return self._session.token()
+
+    class StoredToken(LibrespotTokenProvider.StoredToken):
+        def __init__(self, obj):
+            self.timestamp = int(time_ns() / 1000)
+            self.expires_in = int(obj["expires_in"])
+            self.access_token = obj["access_token"]
+            self.scopes = obj["scope"].split()
+            self.refresh_token = obj["refresh_token"]
+
+
+class OAuth:
+    __code_verifier: str
+    __server_thread: Thread
+    __token: TokenProvider.StoredToken
+
+    def __init__(self):
+        self.__server_thread = Thread(target=self.__run_server)
+        self.__server_thread.start()
+
+    def get_authorization_url(self) -> str:
+        self.__code_verifier = generate_code_verifier()
+        code_challenge = get_code_challenge(self.__code_verifier)
+        params = {
+            "client_id": CLIENT_ID,
+            "response_type": "code",
+            "redirect_uri": REDIRECT_URI,
+            "scope": ",".join(SCOPES),
+            "code_challenge_method": "S256",
+            "code_challenge": code_challenge,
+        }
+        return f"{AUTH_URL}authorize?{urlencode(params)}"
+
+    def await_token(self) -> TokenProvider.StoredToken:
+        self.__server_thread.join()
+        return self.__token
+
+    def set_token(self, code: str) -> None:
+        token_url = f"{AUTH_URL}api/token"
+        headers = {"Content-Type": "application/x-www-form-urlencoded"}
+        body = {
+            "grant_type": "authorization_code",
+            "code": code,
+            "redirect_uri": REDIRECT_URI,
+            "client_id": CLIENT_ID,
+            "code_verifier": self.__code_verifier,
+        }
+        response = post(token_url, headers=headers, data=body)
+        if response.status_code != 200:
+            raise IOError(
+                f"Error fetching token: {response.status_code}, {response.text}"
+            )
+        self.__token = TokenProvider.StoredToken(response.json())
+
+    def __run_server(self) -> None:
+        server_address = ("127.0.0.1", 4381)
+        httpd = self.OAuthHTTPServer(server_address, self.RequestHandler, self)
+        httpd.authenticator = self
+        httpd.serve_forever()
+
+    class OAuthHTTPServer(HTTPServer):
+        authenticator: OAuth
+
+        def __init__(
+            self,
+            server_address: tuple[str, int],
+            RequestHandlerClass: type[BaseHTTPRequestHandler],
+            authenticator: OAuth,
+        ):
+            super().__init__(server_address, RequestHandlerClass)
+            self.authenticator = authenticator
+
+    class RequestHandler(BaseHTTPRequestHandler):
+        def log_message(self, format: str, *args):
+            return
+
+        def do_GET(self) -> None:
+            parsed_path = urlparse(self.path)
+            query_params = parse_qs(parsed_path.query)
+            code = query_params.get("code")
+
+            if code:
+                if isinstance(self.server, OAuth.OAuthHTTPServer):
+                    self.server.authenticator.set_token(code[0])
+                self.send_response(200)
+                self.send_header("Content-type", "text/html")
+                self.end_headers()
+                self.wfile.write(
+                    b"Authorization successful. You can close this window."
+                )
+                Thread(target=self.server.shutdown).start()
+            else:
+                self.send_response(400)
+                self.send_header("Content-type", "text/html")
+                self.end_headers()
+                self.wfile.write(b"Authorization code not found.")
+                Thread(target=self.server.shutdown).start()
diff --git a/zotify/app.py b/zotify/app.py
index 5772765..0f4780b 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -147,21 +147,24 @@ class App:
         Logger(self.__config)
 
         # Create session
-        if args.username != "" and args.password != "":
-            self.__session = Session.from_userpass(
-                args.username,
-                args.password,
-                self.__config.credentials_path,
-                self.__config.language,
-            )
-        elif self.__config.credentials_path.is_file():
-            self.__session = Session.from_file(
-                self.__config.credentials_path, self.__config.language
-            )
-        else:
-            self.__session = Session.from_prompt(
-                self.__config.credentials_path, self.__config.language
-            )
+        # if args.username != "" and args.password != "":
+        #     self.__session = Session.from_userpass(
+        #         args.username,
+        #         args.password,
+        #         self.__config.credentials_path,
+        #         self.__config.language,
+        #     )
+        # elif self.__config.credentials_path.is_file():
+        #     self.__session = Session.from_file(
+        #         self.__config.credentials_path, self.__config.language
+        #     )
+        # else:
+        #     self.__session = Session.from_prompt(
+        #         self.__config.credentials_path, self.__config.language
+        #     )
+        self.__session = Session.from_oauth(
+            self.__config.credentials_path, self.__config.language
+        )
 
         # Get items to download
         ids = self.get_selection(args)
@@ -268,6 +271,7 @@ class App:
                         LogChannel.SKIPS,
                         f'Skipping "{track.name}": Already exists at specified output',
                     )
+                    continue
 
                 # Download track
                 with Logger.progress(
diff --git a/zotify/collections.py b/zotify/collections.py
index 40ab149..2b16fb1 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -5,7 +5,7 @@ from librespot.metadata import (
     ShowId,
 )
 
-from zotify import Api
+from zotify import ApiClient
 from zotify.config import Config
 from zotify.utils import MetadataEntry, PlayableData, PlayableType, bytes_to_base62
 
@@ -13,12 +13,12 @@ from zotify.utils import MetadataEntry, PlayableData, PlayableType, bytes_to_bas
 class Collection:
     playables: list[PlayableData] = []
 
-    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+    def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
         raise NotImplementedError
 
 
 class Album(Collection):
-    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+    def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
         album = api.get_metadata_4_album(AlbumId.from_base62(b62_id))
         for disc in album.disc:
             for track in disc.track:
@@ -33,7 +33,7 @@ class Album(Collection):
 
 
 class Artist(Collection):
-    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+    def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
         artist = api.get_metadata_4_artist(ArtistId.from_base62(b62_id))
         for album_group in (
             artist.album_group
@@ -55,7 +55,7 @@ class Artist(Collection):
 
 
 class Show(Collection):
-    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+    def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
         show = api.get_metadata_4_show(ShowId.from_base62(b62_id))
         for episode in show.episode:
             self.playables.append(
@@ -69,7 +69,7 @@ class Show(Collection):
 
 
 class Playlist(Collection):
-    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+    def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
         playlist = api.get_playlist(PlaylistId(b62_id))
         for i in range(len(playlist.contents.items)):
             item = playlist.contents.items[i]
@@ -111,7 +111,7 @@ class Playlist(Collection):
 
 
 class Track(Collection):
-    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+    def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
         self.playables.append(
             PlayableData(
                 PlayableType.TRACK, b62_id, config.album_library, config.output_album
@@ -120,7 +120,7 @@ class Track(Collection):
 
 
 class Episode(Collection):
-    def __init__(self, b62_id: str, api: Api, config: Config = Config()):
+    def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
         self.playables.append(
             PlayableData(
                 PlayableType.EPISODE,
diff --git a/zotify/file.py b/zotify/file.py
index 960f376..a533b0a 100644
--- a/zotify/file.py
+++ b/zotify/file.py
@@ -55,7 +55,9 @@ class LocalFile:
             "-i",
             str(self.__path),
         ]
-        path = self.__path.parent.joinpath(self.__path.name.rsplit(".", 1)[0] + ext)
+        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."
@@ -97,7 +99,7 @@ class LocalFile:
             try:
                 f[m.name] = m.value
             except KeyError:
-                pass
+                pass  # TODO
         try:
             f.save()
         except OggVorbisHeaderError:
diff --git a/zotify/playable.py b/zotify/playable.py
index ff81f99..e2b5687 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -1,9 +1,9 @@
 from math import floor
 from pathlib import Path
-from typing import Any
 
 from librespot.core import PlayableContentFeeder
 from librespot.metadata import AlbumId
+from librespot.proto import Metadata_pb2 as Metadata
 from librespot.structure import GeneralAudioStream
 from librespot.util import bytes_to_hex
 from requests import get
@@ -57,7 +57,7 @@ class Lyrics:
 
 
 class Playable:
-    cover_images: list[Any]
+    cover_images: list[Metadata.Image]
     input_stream: GeneralAudioStream
     metadata: list[MetadataEntry]
     name: str
@@ -165,6 +165,7 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             MetadataEntry("popularity", int(self.popularity * 255) / 100),
             MetadataEntry("track_number", self.number, str(self.number).zfill(2)),
             MetadataEntry("title", self.name),
+            MetadataEntry("track", self.name),
             MetadataEntry("year", date.year),
             MetadataEntry(
                 "replaygain_track_gain", self.normalization_data.track_gain_db, ""

From faca12783e7d349ff609dd3e3c60e636f62f8c60 Mon Sep 17 00:00:00 2001
From: Zotify <zotify@localhost>
Date: Thu, 15 Aug 2024 16:16:50 +1200
Subject: [PATCH 095/169] OAuth refresh

---
 zotify/__init__.py | 76 +++++++++++++++++++++++++++++++++++-----------
 zotify/app.py      |  2 +-
 2 files changed, 59 insertions(+), 19 deletions(-)

diff --git a/zotify/__init__.py b/zotify/__init__.py
index 52a06df..7355f1d 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from enum import IntEnum
 from http.server import BaseHTTPRequestHandler, HTTPServer
 from pathlib import Path
 from threading import Thread
@@ -69,7 +70,7 @@ class Session(LibrespotSession):
     def __init__(
         self,
         session_builder: LibrespotSession.Builder,
-        token: TokenProvider.StoredToken,
+        oauth: OAuth,
         language: str = "en",
     ) -> None:
         """
@@ -89,7 +90,7 @@ class Session(LibrespotSession):
                 ),
                 ApResolver.get_random_accesspoint(),
             )
-            self.__token = token
+            self.__oauth = oauth
             self.__language = language
             self.connect()
             self.authenticate(session_builder.login_credentials)
@@ -112,8 +113,7 @@ class Session(LibrespotSession):
             .build()
         )
         session = LibrespotSession.Builder(conf).stored_file(str(cred_file))
-        token = session.login_credentials.auth_data  # TODO: this is wrong
-        return Session(session, token, language)
+        return Session(session, OAuth(), language)  # TODO
 
     @staticmethod
     def from_oauth(
@@ -148,7 +148,7 @@ class Session(LibrespotSession):
             typ=Authentication.AuthenticationType.values()[3],
             auth_data=token.access_token.encode(),
         )
-        return Session(session, token, language)
+        return Session(session, auth, language)
 
     def __get_playable(
         self, playable_id: PlayableId, quality: Quality
@@ -188,9 +188,9 @@ class Session(LibrespotSession):
             self.api(),
         )
 
-    def token(self) -> TokenProvider.StoredToken:
-        """Returns API token"""
-        return self.__token
+    def oauth(self) -> OAuth:
+        """Returns OAuth service"""
+        return self.__oauth
 
     def language(self) -> str:
         """Returns session language"""
@@ -288,7 +288,7 @@ class TokenProvider(LibrespotTokenProvider):
         self._session = session
 
     def get_token(self, *scopes) -> TokenProvider.StoredToken:
-        return self._session.token()
+        return self._session.oauth().get_token()
 
     class StoredToken(LibrespotTokenProvider.StoredToken):
         def __init__(self, obj):
@@ -309,6 +309,11 @@ class OAuth:
         self.__server_thread.start()
 
     def get_authorization_url(self) -> str:
+        """
+        Generate OAuth URL
+        Returns:
+            OAuth URL
+        """
         self.__code_verifier = generate_code_verifier()
         code_challenge = get_code_challenge(self.__code_verifier)
         params = {
@@ -322,19 +327,48 @@ class OAuth:
         return f"{AUTH_URL}authorize?{urlencode(params)}"
 
     def await_token(self) -> TokenProvider.StoredToken:
+        """
+        Blocks until server thread gets token
+        Returns:
+            StoredToken
+        """
         self.__server_thread.join()
         return self.__token
 
-    def set_token(self, code: str) -> None:
+    def get_token(self) -> TokenProvider.StoredToken:
+        """
+        Gets a valid token
+        Returns:
+            StoredToken
+        """
+        if self.__token is None:
+            raise RuntimeError("Session isn't authenticated!")
+        elif self.__token.expired():
+            self.set_token(self.__token.refresh_token, OAuth.RequestType.REFRESH)
+        return self.__token
+
+    def set_token(self, code: str, request_type: RequestType) -> None:
+        """
+        Fetches and sets stored token
+        Returns:
+            StoredToken
+        """
         token_url = f"{AUTH_URL}api/token"
         headers = {"Content-Type": "application/x-www-form-urlencoded"}
-        body = {
-            "grant_type": "authorization_code",
-            "code": code,
-            "redirect_uri": REDIRECT_URI,
-            "client_id": CLIENT_ID,
-            "code_verifier": self.__code_verifier,
-        }
+        if request_type == OAuth.RequestType.LOGIN:
+            body = {
+                "grant_type": "authorization_code",
+                "code": code,
+                "redirect_uri": REDIRECT_URI,
+                "client_id": CLIENT_ID,
+                "code_verifier": self.__code_verifier,
+            }
+        elif request_type == OAuth.RequestType.REFRESH:
+            body = {
+                "grant_type": "refresh_token",
+                "refresh_token": code,
+                "client_id": CLIENT_ID,
+            }
         response = post(token_url, headers=headers, data=body)
         if response.status_code != 200:
             raise IOError(
@@ -348,6 +382,10 @@ class OAuth:
         httpd.authenticator = self
         httpd.serve_forever()
 
+    class RequestType(IntEnum):
+        LOGIN = 0
+        REFRESH = 1
+
     class OAuthHTTPServer(HTTPServer):
         authenticator: OAuth
 
@@ -371,7 +409,9 @@ class OAuth:
 
             if code:
                 if isinstance(self.server, OAuth.OAuthHTTPServer):
-                    self.server.authenticator.set_token(code[0])
+                    self.server.authenticator.set_token(
+                        code[0], OAuth.RequestType.LOGIN
+                    )
                 self.send_response(200)
                 self.send_header("Content-type", "text/html")
                 self.end_headers()
diff --git a/zotify/app.py b/zotify/app.py
index 0f4780b..57cfb04 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -106,7 +106,7 @@ class Selection:
 
     def __print(self, count: int, items: list[dict[str, Any]], *args: str) -> None:
         arg_range = range(len(args))
-        category_str = " # " + " ".join("{:<38}" for _ in arg_range)
+        category_str = "#  " + " ".join("{:<38}" for _ in arg_range)
         print(category_str.format(*[s.upper() for s in list(args)]))
         for item in items:
             count += 1

From 77ba1cba9cff7c31fc06a53001915767615b573b Mon Sep 17 00:00:00 2001
From: Zotify <zotify@localhost>
Date: Thu, 15 Aug 2024 16:58:23 +1200
Subject: [PATCH 096/169] fix dependencies + version bump

---
 requirements.txt   | 2 +-
 setup.cfg          | 4 ++--
 zotify/__main__.py | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index 4d4febf..182fe14 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,6 +2,6 @@ librespot@git+https://github.com/kokarare1212/librespot-python
 music-tag@git+https://zotify.xyz/zotify/music-tag
 mutagen
 Pillow
-pwinput
+pkce
 requests
 tqdm
diff --git a/setup.cfg b/setup.cfg
index 743a7b8..f0277eb 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = zotify
-version = 0.9.5
+version = 0.9.6
 author = Zotify Contributors
 description = A highly customizable music and podcast downloader
 long_description = file: README.md
@@ -23,7 +23,7 @@ install_requires =
     music-tag@git+https://zotify.xyz/zotify/music-tag
     mutagen
     Pillow
-    pwinput
+    pkce
     requests
     tqdm
 
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 28d17bc..2d6c5b6 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.5"
+VERSION = "0.9.6"
 
 
 def main():

From 66e98dc98d2ddeceb83bcccb55f4fde831c4a226 Mon Sep 17 00:00:00 2001
From: Zotify <zotify@localhost>
Date: Sat, 17 Aug 2024 01:59:24 +1200
Subject: [PATCH 097/169] fix selection printing

---
 zotify/__init__.py | 22 +++++++++-------------
 zotify/app.py      | 12 +++++++++---
 2 files changed, 18 insertions(+), 16 deletions(-)

diff --git a/zotify/__init__.py b/zotify/__init__.py
index 7355f1d..6e46395 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -96,7 +96,7 @@ class Session(LibrespotSession):
             self.authenticate(session_builder.login_credentials)
 
     @staticmethod
-    def from_file(cred_file: Path | str, language: str = "en") -> Session:
+    def from_file(auth: OAuth, cred_file: Path | str, language: str = "en") -> Session:
         """
         Creates session using saved credentials file
         Args:
@@ -113,11 +113,13 @@ class Session(LibrespotSession):
             .build()
         )
         session = LibrespotSession.Builder(conf).stored_file(str(cred_file))
-        return Session(session, OAuth(), language)  # TODO
+        return Session(session, auth, language)  # TODO
 
     @staticmethod
     def from_oauth(
-        save_file: Path | str | None = None, language: str = "en"
+        auth: OAuth,
+        save_file: Path | str | None = None,
+        language: str = "en",
     ) -> Session:
         """
         Creates a session using OAuth2
@@ -136,15 +138,11 @@ class Session(LibrespotSession):
         else:
             builder.set_store_credentials(False)
 
-        # TODO: this should be done in App()
-        username = input("Username: ")
-        auth = OAuth()
-        print(f"Click on the following link to login:\n{auth.get_authorization_url()}")
         token = auth.await_token()
 
         session = LibrespotSession.Builder(builder.build())
         session.login_credentials = Authentication.LoginCredentials(
-            username=username,
+            username=auth.username,
             typ=Authentication.AuthenticationType.values()[3],
             auth_data=token.access_token.encode(),
         )
@@ -221,11 +219,7 @@ class Session(LibrespotSession):
             self.__event_service = EventService(self)
             self.__auth_lock_bool = False
             self.__auth_lock.notify_all()
-        self.dealer().connect()
         self.mercury().interested_in("sp" + "otify:user:attributes:update", self)
-        self.dealer().add_message_listener(
-            self, ["hm://connect-state/v1/connect/logout"]
-        )
 
 
 class ApiClient(LibrespotApiClient):
@@ -303,8 +297,10 @@ class OAuth:
     __code_verifier: str
     __server_thread: Thread
     __token: TokenProvider.StoredToken
+    username: str
 
-    def __init__(self):
+    def __init__(self, username: str):
+        self.username = username
         self.__server_thread = Thread(target=self.__run_server)
         self.__server_thread.start()
 
diff --git a/zotify/app.py b/zotify/app.py
index 57cfb04..e4b4785 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -2,7 +2,7 @@ from argparse import Namespace
 from pathlib import Path
 from typing import Any
 
-from zotify import Session
+from zotify import OAuth, Session
 from zotify.collections import Album, Artist, Collection, Episode, Playlist, Show, Track
 from zotify.config import Config
 from zotify.file import TranscodingError
@@ -80,7 +80,9 @@ class Selection:
             except KeyError:
                 item = resp[i]
             self.__items.append(item)
-            self.__print(i + 1, item)
+            print(
+                "{:<2} {:<38}".format(i + 1, self.__fix_string_length(item["name"], 38))
+            )
         return self.__get_selection()
 
     @staticmethod
@@ -162,8 +164,12 @@ class App:
         #     self.__session = Session.from_prompt(
         #         self.__config.credentials_path, self.__config.language
         #     )
+        username = input("Username: ")
+        auth = OAuth(username)
+        auth_url = auth.get_authorization_url()
+        print(f"\nClick on the following link to login:\n{auth_url}")
         self.__session = Session.from_oauth(
-            self.__config.credentials_path, self.__config.language
+            auth, self.__config.credentials_path, self.__config.language
         )
 
         # Get items to download

From 08d844fe3e644ae6cd9bea34a3b4982e61129f33 Mon Sep 17 00:00:00 2001
From: Zotify <zotify@localhost>
Date: Sun, 1 Sep 2024 18:47:51 +1200
Subject: [PATCH 098/169] platform agnostic special char stripping

---
 zotify/utils.py | 9 ++-------
 1 file changed, 2 insertions(+), 7 deletions(-)

diff --git a/zotify/utils.py b/zotify/utils.py
index dce9fd2..df8a661 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -12,7 +12,7 @@ import music_tag
 import requests
 
 from zotify.const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \
-    WINDOWS_SYSTEM, LINUX_SYSTEM, ALBUMARTIST
+    WINDOWS_SYSTEM, ALBUMARTIST
 from zotify.zotify import Zotify
 
 
@@ -258,12 +258,7 @@ def fix_filename(name):
     >>> all('_' == fix_filename(chr(i)) for i in list(range(32)))
     True
     """
-    if platform.system() == WINDOWS_SYSTEM:
-        return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE)
-    elif platform.system() == LINUX_SYSTEM:
-        return re.sub(r'[/\0]', "_", str(name))
-    else: # MacOS
-        return re.sub(r'[/:\0]', "_", str(name))
+    return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE)
 
 
 def fmt_seconds(secs: float) -> str:

From b11ecfa61a73de27e043b14a3db65752e922690c Mon Sep 17 00:00:00 2001
From: Zotify <zotify@localhost>
Date: Wed, 18 Sep 2024 18:08:18 +1200
Subject: [PATCH 099/169] save login

---
 CHANGELOG.md       |  1 +
 setup.cfg          |  2 +-
 zotify/__init__.py | 43 +++++++++++++++++++++++--------------------
 zotify/__main__.py |  7 +++----
 zotify/app.py      | 43 +++++++++++++++++++++----------------------
 5 files changed, 49 insertions(+), 47 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c961176..e1c1c80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
 
 ### Changes
 
+- Username and password login has been replaced with username and token
 - Genre metadata available for all tracks
 - 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
diff --git a/setup.cfg b/setup.cfg
index f0277eb..69e182e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = zotify
-version = 0.9.6
+version = 0.9.7
 author = Zotify Contributors
 description = A highly customizable music and podcast downloader
 long_description = file: README.md
diff --git a/zotify/__init__.py b/zotify/__init__.py
index 6e46395..9f95e53 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -70,8 +70,8 @@ class Session(LibrespotSession):
     def __init__(
         self,
         session_builder: LibrespotSession.Builder,
-        oauth: OAuth,
         language: str = "en",
+        oauth: OAuth | None = None,
     ) -> None:
         """
         Authenticates user, saves credentials to a file and generates api token.
@@ -96,7 +96,7 @@ class Session(LibrespotSession):
             self.authenticate(session_builder.login_credentials)
 
     @staticmethod
-    def from_file(auth: OAuth, cred_file: Path | str, language: str = "en") -> Session:
+    def from_file(cred_file: Path | str, language: str = "en") -> Session:
         """
         Creates session using saved credentials file
         Args:
@@ -107,17 +107,17 @@ class Session(LibrespotSession):
         """
         if not isinstance(cred_file, Path):
             cred_file = Path(cred_file).expanduser()
-        conf = (
+        config = (
             LibrespotSession.Configuration.Builder()
             .set_store_credentials(False)
             .build()
         )
-        session = LibrespotSession.Builder(conf).stored_file(str(cred_file))
-        return Session(session, auth, language)  # TODO
+        session = LibrespotSession.Builder(config).stored_file(str(cred_file))
+        return Session(session, language)
 
     @staticmethod
     def from_oauth(
-        auth: OAuth,
+        oauth: OAuth,
         save_file: Path | str | None = None,
         language: str = "en",
     ) -> Session:
@@ -129,24 +129,24 @@ class Session(LibrespotSession):
         Returns:
             Zotify session
         """
-        builder = LibrespotSession.Configuration.Builder()
+        config = LibrespotSession.Configuration.Builder()
         if save_file:
             if not isinstance(save_file, Path):
                 save_file = Path(save_file).expanduser()
             save_file.parent.mkdir(parents=True, exist_ok=True)
-            builder.set_stored_credential_file(str(save_file))
+            config.set_stored_credential_file(str(save_file))
         else:
-            builder.set_store_credentials(False)
+            config.set_store_credentials(False)
 
-        token = auth.await_token()
+        token = oauth.await_token()
 
-        session = LibrespotSession.Builder(builder.build())
-        session.login_credentials = Authentication.LoginCredentials(
-            username=auth.username,
+        builder = LibrespotSession.Builder(config.build())
+        builder.login_credentials = Authentication.LoginCredentials(
+            username=oauth.username,
             typ=Authentication.AuthenticationType.values()[3],
             auth_data=token.access_token.encode(),
         )
-        return Session(session, auth, language)
+        return Session(builder, language, oauth)
 
     def __get_playable(
         self, playable_id: PlayableId, quality: Quality
@@ -186,7 +186,7 @@ class Session(LibrespotSession):
             self.api(),
         )
 
-    def oauth(self) -> OAuth:
+    def oauth(self) -> OAuth | None:
         """Returns OAuth service"""
         return self.__oauth
 
@@ -282,7 +282,10 @@ class TokenProvider(LibrespotTokenProvider):
         self._session = session
 
     def get_token(self, *scopes) -> TokenProvider.StoredToken:
-        return self._session.oauth().get_token()
+        oauth = self._session.oauth()
+        if oauth is None:
+            return super().get_token(*scopes)
+        return oauth.get_token()
 
     class StoredToken(LibrespotTokenProvider.StoredToken):
         def __init__(self, obj):
@@ -301,15 +304,15 @@ class OAuth:
 
     def __init__(self, username: str):
         self.username = username
-        self.__server_thread = Thread(target=self.__run_server)
-        self.__server_thread.start()
 
-    def get_authorization_url(self) -> str:
+    def auth_interactive(self) -> str:
         """
-        Generate OAuth URL
+        Starts local server for token callback
         Returns:
             OAuth URL
         """
+        self.__server_thread = Thread(target=self.__run_server)
+        self.__server_thread.start()
         self.__code_verifier = generate_code_verifier()
         code_challenge = get_code_challenge(self.__code_verifier)
         params = {
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 2d6c5b6..1829942 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.6"
+VERSION = "0.9.7"
 
 
 def main():
@@ -51,7 +51,7 @@ def main():
         help="Searches for only this type",
     )
     parser.add_argument("--username", type=str, default="", help="Account username")
-    parser.add_argument("--password", type=str, default="", help="Account password")
+    parser.add_argument("--token", type=str, default="", help="Account token")
     group = parser.add_mutually_exclusive_group(required=True)
     group.add_argument(
         "urls",
@@ -127,8 +127,7 @@ def main():
     args = parser.parse_args()
     if args.version:
         print(VERSION)
-        return
-    if args.debug:
+    elif args.debug:
         args.func(args)
     else:
         try:
diff --git a/zotify/app.py b/zotify/app.py
index e4b4785..d91fda1 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -149,28 +149,27 @@ class App:
         Logger(self.__config)
 
         # Create session
-        # if args.username != "" and args.password != "":
-        #     self.__session = Session.from_userpass(
-        #         args.username,
-        #         args.password,
-        #         self.__config.credentials_path,
-        #         self.__config.language,
-        #     )
-        # elif self.__config.credentials_path.is_file():
-        #     self.__session = Session.from_file(
-        #         self.__config.credentials_path, self.__config.language
-        #     )
-        # else:
-        #     self.__session = Session.from_prompt(
-        #         self.__config.credentials_path, self.__config.language
-        #     )
-        username = input("Username: ")
-        auth = OAuth(username)
-        auth_url = auth.get_authorization_url()
-        print(f"\nClick on the following link to login:\n{auth_url}")
-        self.__session = Session.from_oauth(
-            auth, self.__config.credentials_path, self.__config.language
-        )
+        if args.username != "" and args.token != "":
+            oauth = OAuth(args.username)
+            oauth.set_token(args.token, OAuth.RequestType.REFRESH)
+            self.__session = Session.from_oauth(
+                oauth, self.__config.credentials_path, self.__config.language
+            )
+        elif self.__config.credentials_path.is_file():
+            self.__session = Session.from_file(
+                self.__config.credentials_path,
+                self.__config.language,
+            )
+        else:
+            username = args.username
+            while username == "":
+                username = input("Username: ")
+            oauth = OAuth(username)
+            auth_url = oauth.auth_interactive()
+            print(f"\nClick on the following link to login:\n{auth_url}")
+            self.__session = Session.from_oauth(
+                oauth, self.__config.credentials_path, self.__config.language
+            )
 
         # Get items to download
         ids = self.get_selection(args)

From 7d8b3ddb39abba7ea2f07dd7ec4e799f88b63f89 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Tue, 21 Jan 2025 15:43:16 -0500
Subject: [PATCH 100/169] Added DOWNLOAD_REAL_TIME back as config variable.

---
 zotify/config.py | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/zotify/config.py b/zotify/config.py
index 8961989..f2b9e1c 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -14,6 +14,7 @@ AUDIO_FORMAT = "audio_format"
 CREATE_PLAYLIST_FILE = "create_playlist_file"
 CREDENTIALS_PATH = "credentials_path"
 DOWNLOAD_QUALITY = "download_quality"
+DOWNLOAD_REAL_TIME = "download_real_time"
 FFMPEG_ARGS = "ffmpeg_args"
 FFMPEG_PATH = "ffmpeg_path"
 LANGUAGE = "language"
@@ -129,6 +130,12 @@ CONFIG_VALUES = {
         "args": ["--download-quality"],
         "help": "Audio download quality (auto for highest available)",
     },
+    DOWNLOAD_REAL_TIME: {
+        "default": False,
+        "type": bool,
+        "args": ["--download-real-time"],
+        "help": "Download at the same rate as the track being played",
+    },
     ARTWORK_SIZE: {
         "default": "large",
         "type": ImageSize.from_string,
@@ -261,6 +268,7 @@ class Config:
     audio_format: AudioFormat
     credentials_path: Path
     download_quality: Quality
+    download_real_time: bool
     ffmpeg_args: str
     ffmpeg_path: str
     language: str

From 8d80719f6bf28bada10762e2e1559dfc672100b1 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Tue, 21 Jan 2025 21:34:05 -0500
Subject: [PATCH 101/169] Added download real time feature back.

---
 zotify/app.py      |  2 +-
 zotify/playable.py | 34 ++++++++++++++++++++++++++++++++++
 2 files changed, 35 insertions(+), 1 deletion(-)

diff --git a/zotify/app.py b/zotify/app.py
index d91fda1..3ef8e2d 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -283,7 +283,7 @@ class App:
                     desc=f"({count}/{total}) {track.name}",
                     total=track.input_stream.size,
                 ) as p_bar:
-                    file = track.write_audio_stream(output, p_bar)
+                    file = track.write_audio_stream(output, p_bar, self.__config.download_real_time)
 
                 # Download lyrics
                 if playable.type == PlayableType.TRACK and self.__config.lyrics_file:
diff --git a/zotify/playable.py b/zotify/playable.py
index e2b5687..425baa3 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -1,5 +1,6 @@
 from math import floor
 from pathlib import Path
+from time import time, sleep
 
 from librespot.core import PlayableContentFeeder
 from librespot.metadata import AlbumId
@@ -197,6 +198,39 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             )
             return self.__lyrics
 
+    def write_audio_stream(
+        self,
+        output: Path | str,
+        p_bar: tqdm = tqdm(disable=True),
+        real_time: bool = False,
+    ) -> LocalFile:
+        """
+        Writes audio stream to file
+        Args:
+            output: File path of saved audio stream
+            p_bar: tqdm progress bar
+            real_time: Enable delay to emulate real time streaming
+        Returns:
+            LocalFile object
+        """
+        if not isinstance(output, Path):
+            output = Path(output).expanduser()
+        file = f"{output}.ogg"
+        time_start = time()
+        downloaded = 0
+        with open(file, "wb") as f, p_bar as p_bar:
+            chunk = None
+            while chunk != b"":
+                chunk = self.input_stream.stream().read(1024)
+                p_bar.update(f.write(chunk))
+                if real_time:
+                    downloaded += len(chunk)
+                    delta_current = time() - time_start
+                    delta_required = (downloaded / self.input_stream.size) * (self.duration/1000)
+                    if delta_required > delta_current:
+                        sleep(delta_required - delta_current)
+        return LocalFile(Path(file), AudioFormat.VORBIS)
+
 
 class Episode(PlayableContentFeeder.LoadedStream, Playable):
     def __init__(self, episode: PlayableContentFeeder.LoadedStream, api):

From bf45889e6ae2a9291241d28bf1e118d7ffd7295b Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Wed, 22 Jan 2025 11:32:29 -0500
Subject: [PATCH 102/169] Cleaned up download real time feature implementation.

---
 zotify/playable.py | 13 +++++++------
 1 file changed, 7 insertions(+), 6 deletions(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index 425baa3..083ba3d 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -215,6 +215,8 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
         """
         if not isinstance(output, Path):
             output = Path(output).expanduser()
+        if not real_time:
+            return super().write_audio_stream(output)
         file = f"{output}.ogg"
         time_start = time()
         downloaded = 0
@@ -223,12 +225,11 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             while chunk != b"":
                 chunk = self.input_stream.stream().read(1024)
                 p_bar.update(f.write(chunk))
-                if real_time:
-                    downloaded += len(chunk)
-                    delta_current = time() - time_start
-                    delta_required = (downloaded / self.input_stream.size) * (self.duration/1000)
-                    if delta_required > delta_current:
-                        sleep(delta_required - delta_current)
+                downloaded += len(chunk)
+                delta_current = time() - time_start
+                delta_required = (downloaded / self.input_stream.size) * (self.duration/1000)
+                if delta_required > delta_current:
+                    sleep(delta_required - delta_current)
         return LocalFile(Path(file), AudioFormat.VORBIS)
 
 

From 93a8bd761c25f285dd18985037d898ff4940e787 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Wed, 22 Jan 2025 12:01:07 -0500
Subject: [PATCH 103/169] Removed download real time to the list of removed
 features

---
 CHANGELOG.md | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e1c1c80..db93063 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,4 @@
-# STILL IN DEVELOPMENT, EVERYTHING HERE IS SUBJECT TO CHANGE!
+# STILL IN DEVELOPMENT, EVERYTHING HERE IS SUBJECT TO CHANGE
 
 ## v1.0.0
 
@@ -83,7 +83,6 @@
 - Removed the following config options due to their corresponding features being removed:
   - `bulk_wait_time`
   - `chunk_size`
-  - `download_real_time`
   - `md_allgenres`
   - `md_genredelimiter`
   - `metadata_delimiter`

From 8a4c5e8dbb28c37dadcd0e689d9efa1d61530f54 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Fri, 24 Jan 2025 16:03:42 -0500
Subject: [PATCH 104/169] Bug fix for config.json values being ignored/being
 overriden to default unless specified in CLI

---
 zotify/__main__.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/zotify/__main__.py b/zotify/__main__.py
index 1829942..cf6d981 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -103,7 +103,6 @@ def main():
             parser.add_argument(
                 *v["args"],
                 action=OptionalOrFalse,
-                default=v["default"],
                 help=v["help"],
             )
         else:

From b446845911bc92b767c4a14faf44d73283ac9b07 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Sat, 25 Jan 2025 14:27:18 -0500
Subject: [PATCH 105/169] Added download_real_time to the list of config
 options

---
 README.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/README.md b/README.md
index aef8c68..44c3820 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,7 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
 | output_playlist_episode | --output-playlist-episode | File layout for episodes in a playlist              | {playlist}/{playlist_number}. {episode_number} - {title}   |
 | output_podcast          | --output-podcast          | File layout for saved podcasts                      | {podcast}/{episode_number} - {title}                       |
 | download_quality        | --download-quality        | Audio download quality (auto for highest available) |                                                            |
+| download_real_time      | --download-real-time      | Downloads songs as fast as they would be played     |                                                            |
 | 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                               |                                                            |

From 10326b227c8ea57c89e404b5de96cc15a6fad9f4 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Sat, 25 Jan 2025 16:51:22 -0500
Subject: [PATCH 106/169] Added text as language to Basic options code block to
 suppress lint warnings

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 44c3820..6cba4af 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
 
 ### Basic options
 
-```
+```text
     -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

From c43dfcbe41eca95cf6c440c9ad9009f5c822da19 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Sun, 26 Jan 2025 14:37:38 -0500
Subject: [PATCH 107/169] Fix replace_existing feature where already existing
 songs are always redownloaded

---
 zotify/app.py      | 3 ++-
 zotify/playable.py | 3 ++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index 3ef8e2d..4c25cb1 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -267,9 +267,10 @@ class App:
                 track.metadata.extend(playable.metadata)
                 try:
                     output = track.create_output(
+                        self.__config.audio_format.value.ext,
                         playable.library,
                         playable.output_template,
-                        self.__config.replace_existing,
+                        self.__config.replace_existing
                     )
                 except FileExistsError:
                     Logger.log(
diff --git a/zotify/playable.py b/zotify/playable.py
index 083ba3d..aa7fa7c 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -65,6 +65,7 @@ class Playable:
 
     def create_output(
         self,
+        ext: str,
         library: Path | str = Path("./"),
         output: str = "{title}",
         replace: bool = False,
@@ -86,7 +87,7 @@ class Playable:
                     "{" + meta.name + "}", fix_filename(meta.string)
                 )
         file_path = library.joinpath(output).expanduser()
-        if file_path.exists() and not replace:
+        if file_path.with_suffix("." + ext).exists() and not replace:
             raise FileExistsError("File already downloaded")
         else:
             file_path.parent.mkdir(parents=True, exist_ok=True)

From 5d9a9a71a2a744726818cdc72b91009a983c67a4 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Sun, 26 Jan 2025 14:46:10 -0500
Subject: [PATCH 108/169] Add replace_existing to list of configurations

---
 README.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/README.md b/README.md
index 6cba4af..c02d176 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,7 @@ 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                    |                                                            |
+| replace_existing        | --replace-existing        | Redownload and replace songs if they already exist  |                                                            |
 
 </details>
 

From a6c0089557abab227af3eb94f3fb358d3c8ab881 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Sun, 26 Jan 2025 15:23:47 -0500
Subject: [PATCH 109/169] Updated installation instruction using fork link

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index c02d176..db5a9ad 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Requires Python 3.11 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`
+`python -m pip install git+https://github.com/KDalu/zotify.git`
 
 ## General Usage
 

From daf424a0b87a73fc836112093c7858f19f17fa98 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Sun, 26 Jan 2025 15:49:32 -0500
Subject: [PATCH 110/169] Added link for ffmpeg installation instructions

---
 README.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/README.md b/README.md
index db5a9ad..e2fe81b 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ Built on [Librespot](https://github.com/kokarare1212/librespot-python).
 
 Requires Python 3.11 or greater. \
 Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis.
+(FFmpeg installation instructions available [here](https://github.com/KDalu/zotify/blob/main/INSTALLATION.md))
 
 Enter the following command in terminal to install Zotify. \
 `python -m pip install git+https://github.com/KDalu/zotify.git`

From d0640476c0f421c5e64d3fd6e7df5c89640b5f27 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Tue, 28 Jan 2025 00:06:41 -0500
Subject: [PATCH 111/169] Fixed issue #2

---
 zotify/collections.py | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/zotify/collections.py b/zotify/collections.py
index 2b16fb1..a4d2486 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -106,6 +106,9 @@ class Playlist(Collection):
                         metadata,
                     )
                 )
+            elif playable_type == "local":
+                # Ignore local files
+                pass
             else:
                 raise ValueError("Unknown playable content", playable_type)
 

From b701d9307c36fdd7f4df37272057bea43c9a59d6 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Tue, 28 Jan 2025 00:07:38 -0500
Subject: [PATCH 112/169] Fixed issue #3

---
 zotify/app.py | 13 ++++++++++---
 1 file changed, 10 insertions(+), 3 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index 4c25cb1..be8b600 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -250,9 +250,16 @@ class App:
                 # Get track data
                 if playable.type == PlayableType.TRACK:
                     with Loader("Fetching track..."):
-                        track = self.__session.get_track(
-                            playable.id, self.__config.download_quality
-                        )
+                        try:
+                            track = self.__session.get_track(
+                                playable.id, self.__config.download_quality
+                            )
+                        except RuntimeError as err:
+                            Logger.log(
+                                LogChannel.SKIPS,
+                                f'Skipping song id = {playable.id}: {err}',
+                            )
+                            continue
                 elif playable.type == PlayableType.EPISODE:
                     with Loader("Fetching episode..."):
                         track = self.__session.get_episode(playable.id)

From ab76749ad3cc67b066ee1ea70a27ba8ce19f5c85 Mon Sep 17 00:00:00 2001
From: KDalu <71458929+KDalu@users.noreply.github.com>
Date: Tue, 28 Jan 2025 10:31:27 -0500
Subject: [PATCH 113/169] Added summary of this fork

---
 README.md | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index e2fe81b..8163c6f 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,11 @@
 
 # Zotify
 
+This is a fork of Zotify's [dev branch](https://github.com/zotify-dev/zotify/tree/v1.0-dev) which hasn't seen any activity for months. This fork will be updated to include missing/unimplemented features and maintained by yours truly until the original developers decide to come home with the milk.
+
 A customizable music and podcast downloader. \
 Formerly ZSp‌otify.
 
-Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify). \
 Built on [Librespot](https://github.com/kokarare1212/librespot-python).
 
 ## Features

From 339a675ae026fa1d6c19109b017c017d89c9139d Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Tue, 28 Jan 2025 20:54:30 -0500
Subject: [PATCH 114/169] Updated links

---
 README.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index 8163c6f..9ea39ca 100644
--- a/README.md
+++ b/README.md
@@ -26,10 +26,10 @@ Built on [Librespot](https://github.com/kokarare1212/librespot-python).
 
 Requires Python 3.11 or greater. \
 Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis.
-(FFmpeg installation instructions available [here](https://github.com/KDalu/zotify/blob/main/INSTALLATION.md))
+(FFmpeg installation instructions available [here](https://github.com/DraftKinner/zotify/blob/main/INSTALLATION.md))
 
 Enter the following command in terminal to install Zotify. \
-`python -m pip install git+https://github.com/KDalu/zotify.git`
+`python -m pip install git+https://github.com/DraftKinner/zotify.git`
 
 ## General Usage
 

From b67763c2df2608353ff0adf3d631c7de1b51484c Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Thu, 30 Jan 2025 16:36:44 -0500
Subject: [PATCH 115/169] Added track id as metadata to be written to track
 file itself using key tag

---
 zotify/collections.py | 16 +++++++++++++++-
 1 file changed, 15 insertions(+), 1 deletion(-)

diff --git a/zotify/collections.py b/zotify/collections.py
index a4d2486..1bf6236 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -22,12 +22,14 @@ class Album(Collection):
         album = api.get_metadata_4_album(AlbumId.from_base62(b62_id))
         for disc in album.disc:
             for track in disc.track:
+                metadata = [MetadataEntry("key", bytes_to_base62(track.gid))]
                 self.playables.append(
                     PlayableData(
                         PlayableType.TRACK,
                         bytes_to_base62(track.gid),
                         config.album_library,
                         config.output_album,
+                        metadata,
                     )
                 )
 
@@ -44,12 +46,14 @@ class Artist(Collection):
             album = api.get_metadata_4_album(AlbumId.from_hex(album_group.album[0].gid))
             for disc in album.disc:
                 for track in disc.track:
+                    metadata = [MetadataEntry("key", bytes_to_base62(track.gid))]
                     self.playables.append(
                         PlayableData(
                             PlayableType.TRACK,
                             bytes_to_base62(track.gid),
                             config.album_library,
                             config.output_album,
+                            metadata,
                         )
                     )
 
@@ -58,12 +62,14 @@ class Show(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
         show = api.get_metadata_4_show(ShowId.from_base62(b62_id))
         for episode in show.episode:
+            metadata = [MetadataEntry("key", bytes_to_base62(episode.gid))]
             self.playables.append(
                 PlayableData(
                     PlayableType.EPISODE,
                     bytes_to_base62(episode.gid),
                     config.podcast_library,
                     config.output_podcast,
+                    metadata,
                 )
             )
 
@@ -77,6 +83,7 @@ class Playlist(Collection):
             playable_type = split[1]
             playable_id = split[2]
             metadata = [
+                MetadataEntry("key", playable_id),
                 MetadataEntry("playlist", playlist.attributes.name),
                 MetadataEntry("playlist_length", playlist.length),
                 MetadataEntry("playlist_owner", playlist.owner_username),
@@ -115,20 +122,27 @@ class Playlist(Collection):
 
 class Track(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
+        metadata = [MetadataEntry("key", b62_id)]
         self.playables.append(
             PlayableData(
-                PlayableType.TRACK, b62_id, config.album_library, config.output_album
+                PlayableType.TRACK,
+                b62_id,
+                config.album_library,
+                config.output_album,
+                metadata,
             )
         )
 
 
 class Episode(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
+        metadata = [MetadataEntry("key", b62_id)]
         self.playables.append(
             PlayableData(
                 PlayableType.EPISODE,
                 b62_id,
                 config.podcast_library,
                 config.output_podcast,
+                metadata,
             )
         )

From b678eda8628c308de30c9fda8109ce8b197cf170 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 31 Jan 2025 17:03:52 -0500
Subject: [PATCH 116/169] Fixed issue #6

---
 zotify/collections.py | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/zotify/collections.py b/zotify/collections.py
index 1bf6236..6437b26 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -11,14 +11,13 @@ from zotify.utils import MetadataEntry, PlayableData, PlayableType, bytes_to_bas
 
 
 class Collection:
-    playables: list[PlayableData] = []
-
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        raise NotImplementedError
+        self.playables: list[PlayableData] = []
 
 
 class Album(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
+        super().__init__(b62_id, api, config)
         album = api.get_metadata_4_album(AlbumId.from_base62(b62_id))
         for disc in album.disc:
             for track in disc.track:
@@ -36,6 +35,7 @@ class Album(Collection):
 
 class Artist(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
+        super().__init__(b62_id, api, config)
         artist = api.get_metadata_4_artist(ArtistId.from_base62(b62_id))
         for album_group in (
             artist.album_group
@@ -60,6 +60,7 @@ class Artist(Collection):
 
 class Show(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
+        super().__init__(b62_id, api, config)
         show = api.get_metadata_4_show(ShowId.from_base62(b62_id))
         for episode in show.episode:
             metadata = [MetadataEntry("key", bytes_to_base62(episode.gid))]
@@ -76,6 +77,7 @@ class Show(Collection):
 
 class Playlist(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
+        super().__init__(b62_id, api, config)
         playlist = api.get_playlist(PlaylistId(b62_id))
         for i in range(len(playlist.contents.items)):
             item = playlist.contents.items[i]
@@ -122,6 +124,7 @@ class Playlist(Collection):
 
 class Track(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
+        super().__init__(b62_id, api, config)
         metadata = [MetadataEntry("key", b62_id)]
         self.playables.append(
             PlayableData(
@@ -136,6 +139,7 @@ class Track(Collection):
 
 class Episode(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
+        super().__init__(b62_id, api, config)
         metadata = [MetadataEntry("key", b62_id)]
         self.playables.append(
             PlayableData(

From 096be4faedaa3e77ec27e60e6f992ac340fcf794 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 31 Jan 2025 17:09:17 -0500
Subject: [PATCH 117/169] Fixed issue #7

---
 zotify/app.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index be8b600..434626d 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -200,8 +200,8 @@ class App:
                 return selection.get("episodes")
             elif args.download:
                 ids = []
-                for x in args.download:
-                    ids.extend(selection.from_file(x))
+                for x in args.download.split(", "):
+                    ids.extend(selection.from_file(x.strip()))
                 return ids
             elif args.urls:
                 return args.urls

From a0dcd2aadd7261da8ae9a780414100a103286ed5 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sat, 1 Feb 2025 16:36:40 -0500
Subject: [PATCH 118/169] Changed music-tag package used to official

---
 requirements.txt | 2 +-
 setup.cfg        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index 182fe14..90cc439 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 librespot@git+https://github.com/kokarare1212/librespot-python
-music-tag@git+https://zotify.xyz/zotify/music-tag
+music-tag
 mutagen
 Pillow
 pkce
diff --git a/setup.cfg b/setup.cfg
index 69e182e..5dc3c7f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,7 +20,7 @@ packages = zotify
 python_requires = >=3.11
 install_requires =
     librespot@git+https://github.com/kokarare1212/librespot-python
-    music-tag@git+https://zotify.xyz/zotify/music-tag
+    music-tag
     mutagen
     Pillow
     pkce

From b8e0881f439bcaa341f848cdb7c5765a07f2287e Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sat, 1 Feb 2025 23:02:07 -0500
Subject: [PATCH 119/169] Changed music-tag package used to a fork more similar
 to the one hosted in zotify.xyz

---
 requirements.txt | 2 +-
 setup.cfg        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index 90cc439..1707768 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 librespot@git+https://github.com/kokarare1212/librespot-python
-music-tag
+music-tag@git+https://github.com/koldinger/music-tag.git
 mutagen
 Pillow
 pkce
diff --git a/setup.cfg b/setup.cfg
index 5dc3c7f..9162292 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,7 +20,7 @@ packages = zotify
 python_requires = >=3.11
 install_requires =
     librespot@git+https://github.com/kokarare1212/librespot-python
-    music-tag
+    music-tag@git+https://github.com/koldinger/music-tag.git
     mutagen
     Pillow
     pkce

From bf4336978158ed3fefc8899f977b585e675de9ab Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 2 Feb 2025 14:12:23 -0500
Subject: [PATCH 120/169] Changed music-tag package link to mirror

---
 requirements.txt | 2 +-
 setup.cfg        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index 1707768..4634246 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 librespot@git+https://github.com/kokarare1212/librespot-python
-music-tag@git+https://github.com/koldinger/music-tag.git
+music-tag@git+https://github.com/DraftKinner/music-tag.git
 mutagen
 Pillow
 pkce
diff --git a/setup.cfg b/setup.cfg
index 9162292..8c4c198 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,7 +20,7 @@ packages = zotify
 python_requires = >=3.11
 install_requires =
     librespot@git+https://github.com/kokarare1212/librespot-python
-    music-tag@git+https://github.com/koldinger/music-tag.git
+    music-tag@git+https://github.com/DraftKinner/music-tag.git
     mutagen
     Pillow
     pkce

From e829b39683f8a87274f8da2777b743ae44b8c319 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 2 Feb 2025 21:56:43 -0500
Subject: [PATCH 121/169] Changed method of adding extension for duplicate file
 check

---
 zotify/playable.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index aa7fa7c..fa6e718 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -87,7 +87,8 @@ class Playable:
                     "{" + meta.name + "}", fix_filename(meta.string)
                 )
         file_path = library.joinpath(output).expanduser()
-        if file_path.with_suffix("." + ext).exists() and not replace:
+        file_path = Path(f'{file_path}.{ext}')
+        if file_path.exists() and not replace:
             raise FileExistsError("File already downloaded")
         else:
             file_path.parent.mkdir(parents=True, exist_ok=True)

From 9d3441ffd75fc33f8aca01dc54a68912232c6d69 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 7 Feb 2025 18:19:00 -0500
Subject: [PATCH 122/169] Added --skip-previous implementation

---
 zotify/app.py         | 30 +++++++++++++++++++++-
 zotify/collections.py | 59 ++++++++++++++++++++++++++++++++++++-------
 zotify/file.py        | 10 ++++++++
 zotify/playable.py    |  2 ++
 zotify/utils.py       |  7 +++--
 5 files changed, 96 insertions(+), 12 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index 434626d..ded39e5 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -146,6 +146,7 @@ class Selection:
 class App:
     def __init__(self, args: Namespace):
         self.__config = Config(args)
+        self.__existing = {}
         Logger(self.__config)
 
         # Create session
@@ -180,6 +181,11 @@ class App:
                 Logger.log(LogChannel.ERRORS, str(e))
                 exit(1)
         if len(collections) > 0:
+            self.scan(
+                collections,
+                self.__config.skip_previous,
+                self.__config.skip_duplicates,
+            )
             self.download_all(collections)
         else:
             Logger.log(LogChannel.WARNINGS, "there is nothing to do")
@@ -240,6 +246,21 @@ class App:
                 raise ParseError(f'Unsupported content type "{id_type}"')
         return collections
 
+    def scan(
+        self,
+        collections: list[Collection],
+        skip_previous: bool,
+        skip_duplicate: bool,
+    ):
+        if skip_previous:
+            for collection in collections:
+                existing = collection.get_existing(
+                    self.__config.audio_format.value.ext
+                )
+                self.__existing.update(existing)
+        if skip_duplicate:
+            pass
+
     def download_all(self, collections: list[Collection]) -> None:
         count = 0
         total = sum(len(c.playables) for c in collections)
@@ -247,6 +268,13 @@ class App:
             for playable in collection.playables:
                 count += 1
 
+                if playable.existing:
+                    Logger.log(
+                        LogChannel.SKIPS,
+                        f'Skipping "{self.__existing[playable.id]}": Previously downloaded',
+                    )
+                    continue
+
                 # Get track data
                 if playable.type == PlayableType.TRACK:
                     with Loader("Fetching track..."):
@@ -257,7 +285,7 @@ class App:
                         except RuntimeError as err:
                             Logger.log(
                                 LogChannel.SKIPS,
-                                f'Skipping song id = {playable.id}: {err}',
+                                f'Skipping track id = {playable.id}: {err}',
                             )
                             continue
                 elif playable.type == PlayableType.EPISODE:
diff --git a/zotify/collections.py b/zotify/collections.py
index 6437b26..6f6b74b 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -1,3 +1,6 @@
+from pathlib import Path
+from glob import iglob
+
 from librespot.metadata import (
     AlbumId,
     ArtistId,
@@ -7,17 +10,55 @@ from librespot.metadata import (
 
 from zotify import ApiClient
 from zotify.config import Config
-from zotify.utils import MetadataEntry, PlayableData, PlayableType, bytes_to_base62
-
+from zotify.file import LocalFile
+from zotify.utils import (
+    MetadataEntry,
+    PlayableData,
+    PlayableType,
+    bytes_to_base62,
+    fix_filename,
+)
 
 class Collection:
-    def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
+    def __init__(self):
         self.playables: list[PlayableData] = []
+    
+    def get_existing(self, ext: str) -> dict[str, str]:
+        existing: dict[str, str] = {}
+
+        meta_tags = ["album_artist", "album", "podcast", "playlist"]
+        library = Path(self.playables[0].library)
+        output = self.playables[0].output_template
+        metadata = self.playables[0].metadata
+        id_type = self.playables[0].type
+        
+        for meta in metadata:
+            if meta.name in meta_tags:
+                output = output.replace(
+                    "{" + meta.name + "}", fix_filename(meta.string)
+                )
+        
+        collection_path = library.joinpath(output).expanduser()
+        if collection_path.parent.exists():
+            file_path = "*.{}".format(ext)
+            scan_path = str(collection_path.parent.joinpath(file_path))
+
+            # Check contents of path
+            for file in iglob(scan_path):
+                f_path = Path(file)
+                f = LocalFile(f_path)
+                existing[f.get_metadata("key")] = f_path.stem
+        
+            for playable in self.playables:
+                if playable.id in existing.keys():
+                    playable.existing = True
+        
+        return existing
 
 
 class Album(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__(b62_id, api, config)
+        super().__init__()
         album = api.get_metadata_4_album(AlbumId.from_base62(b62_id))
         for disc in album.disc:
             for track in disc.track:
@@ -35,7 +76,7 @@ class Album(Collection):
 
 class Artist(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__(b62_id, api, config)
+        super().__init__()
         artist = api.get_metadata_4_artist(ArtistId.from_base62(b62_id))
         for album_group in (
             artist.album_group
@@ -60,7 +101,7 @@ class Artist(Collection):
 
 class Show(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__(b62_id, api, config)
+        super().__init__()
         show = api.get_metadata_4_show(ShowId.from_base62(b62_id))
         for episode in show.episode:
             metadata = [MetadataEntry("key", bytes_to_base62(episode.gid))]
@@ -77,7 +118,7 @@ class Show(Collection):
 
 class Playlist(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__(b62_id, api, config)
+        super().__init__()
         playlist = api.get_playlist(PlaylistId(b62_id))
         for i in range(len(playlist.contents.items)):
             item = playlist.contents.items[i]
@@ -124,7 +165,7 @@ class Playlist(Collection):
 
 class Track(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__(b62_id, api, config)
+        super().__init__()
         metadata = [MetadataEntry("key", b62_id)]
         self.playables.append(
             PlayableData(
@@ -139,7 +180,7 @@ class Track(Collection):
 
 class Episode(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__(b62_id, api, config)
+        super().__init__()
         metadata = [MetadataEntry("key", b62_id)]
         self.playables.append(
             PlayableData(
diff --git a/zotify/file.py b/zotify/file.py
index a533b0a..dbc6ca2 100644
--- a/zotify/file.py
+++ b/zotify/file.py
@@ -117,3 +117,13 @@ class LocalFile:
             f.save()
         except OggVorbisHeaderError:
             pass  # Thrown when using untranscoded file, nothing breaks.
+
+    def get_metadata(self, tag: str) -> str:
+        """
+        Gets metadata from file
+        Args:
+            tag: metadata tag to be retrieved
+        """
+        f = load_file(self.__path)
+
+        return f[tag].value
\ No newline at end of file
diff --git a/zotify/playable.py b/zotify/playable.py
index fa6e718..5101abd 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -89,6 +89,8 @@ class Playable:
         file_path = library.joinpath(output).expanduser()
         file_path = Path(f'{file_path}.{ext}')
         if file_path.exists() and not replace:
+            f = LocalFile(file_path)
+            f.write_metadata(self.metadata)
             raise FileExistsError("File already downloaded")
         else:
             file_path.parent.mkdir(parents=True, exist_ok=True)
diff --git a/zotify/utils.py b/zotify/utils.py
index 3710c81..924ebf1 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -3,6 +3,7 @@ from enum import Enum, IntEnum
 from pathlib import Path
 from re import IGNORECASE, sub
 from typing import Any, NamedTuple
+from dataclasses import dataclass, field
 
 from librespot.audio.decoders import AudioQuality
 from librespot.util import Base62
@@ -110,12 +111,14 @@ class PlayableType(Enum):
     EPISODE = "episode"
 
 
-class PlayableData(NamedTuple):
+@dataclass
+class PlayableData():
     type: PlayableType
     id: str
     library: Path
     output_template: str
-    metadata: list[MetadataEntry] = []
+    metadata: list[MetadataEntry] = field(default_factory=list)
+    existing: bool = False
 
 
 class OptionalOrFalse(Action):

From 9204034f02769c6e58b690b51bfeb81ef1d4f4cd Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 7 Feb 2025 18:19:41 -0500
Subject: [PATCH 123/169] Added unreleased changes

---
 CHANGELOG.md | 25 +++++++++++++++++++++++++
 1 file changed, 25 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index db93063..1505043 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,29 @@
 # STILL IN DEVELOPMENT, EVERYTHING HERE IS SUBJECT TO CHANGE
 
+## UNRELEASED
+
+### Changes
+
+- Changed music-tag package used to a mirror of the repository hosted at zotify.xyz, in case it goes down again.
+
+### Additions
+
+- Added `--download-real-time` feature back.
+- Added implementation for `--skip-previous` as it was included as a config parameter but was unimplemented.
+
+### Removals
+
+- None
+
+### Fixes
+
+- Fixed config.json being ignored.
+- Fixed redownloading songs that are already present in destination folder. They are no longer redownloaded.
+- Fixed `Unsupported content type "playlist"` error when local files are included in the playlist. Local files are skipped.
+- Fixed `RuntimeError: Cannot get alternative track` from terminating the program.
+- Fixed downloading multiple collections from downloading everything twice.
+- Fixed `-d` or `--download` option not detecting the file.
+
 ## v1.0.0
 
 ### BREAKING CHANGES AHEAD
@@ -83,6 +107,7 @@
 - Removed the following config options due to their corresponding features being removed:
   - `bulk_wait_time`
   - `chunk_size`
+  - `download_real_time`
   - `md_allgenres`
   - `md_genredelimiter`
   - `metadata_delimiter`

From 5ff25967ca38645cc5cd06ddecafd03107125c29 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 7 Feb 2025 19:16:20 -0500
Subject: [PATCH 124/169] Version bump

---
 setup.cfg          | 2 +-
 zotify/__main__.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 8c4c198..c9cc896 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = zotify
-version = 0.9.7
+version = 0.9.8
 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 cf6d981..6afd13b 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.7"
+VERSION = "0.9.8"
 
 
 def main():

From ec5a26b603ac3b1d78f652e1a0c095942b96fa27 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 7 Feb 2025 22:49:44 -0500
Subject: [PATCH 125/169] Formatted to conventions

---
 zotify/app.py      | 2 +-
 zotify/playable.py | 6 ++++--
 2 files changed, 5 insertions(+), 3 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index ded39e5..6b61d91 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -305,7 +305,7 @@ class App:
                         self.__config.audio_format.value.ext,
                         playable.library,
                         playable.output_template,
-                        self.__config.replace_existing
+                        self.__config.replace_existing,
                     )
                 except FileExistsError:
                     Logger.log(
diff --git a/zotify/playable.py b/zotify/playable.py
index 5101abd..bb57e36 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -87,7 +87,7 @@ class Playable:
                     "{" + meta.name + "}", fix_filename(meta.string)
                 )
         file_path = library.joinpath(output).expanduser()
-        file_path = Path(f'{file_path}.{ext}')
+        file_path = Path(f"{file_path}.{ext}")
         if file_path.exists() and not replace:
             f = LocalFile(file_path)
             f.write_metadata(self.metadata)
@@ -231,7 +231,9 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
                 p_bar.update(f.write(chunk))
                 downloaded += len(chunk)
                 delta_current = time() - time_start
-                delta_required = (downloaded / self.input_stream.size) * (self.duration/1000)
+                delta_required = (downloaded / self.input_stream.size) * (
+                    self.duration / 1000
+                )
                 if delta_required > delta_current:
                     sleep(delta_required - delta_current)
         return LocalFile(Path(file), AudioFormat.VORBIS)

From 802b5b5928f84f06250e212eface9ffc1ba59eee Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 7 Feb 2025 23:10:56 -0500
Subject: [PATCH 126/169] Formatted to conventions

---
 zotify/collections.py | 12 ++++++------
 zotify/file.py        |  3 +--
 2 files changed, 7 insertions(+), 8 deletions(-)

diff --git a/zotify/collections.py b/zotify/collections.py
index 6f6b74b..0b472dd 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -19,10 +19,11 @@ from zotify.utils import (
     fix_filename,
 )
 
+
 class Collection:
     def __init__(self):
         self.playables: list[PlayableData] = []
-    
+
     def get_existing(self, ext: str) -> dict[str, str]:
         existing: dict[str, str] = {}
 
@@ -30,14 +31,13 @@ class Collection:
         library = Path(self.playables[0].library)
         output = self.playables[0].output_template
         metadata = self.playables[0].metadata
-        id_type = self.playables[0].type
-        
+
         for meta in metadata:
             if meta.name in meta_tags:
                 output = output.replace(
                     "{" + meta.name + "}", fix_filename(meta.string)
                 )
-        
+
         collection_path = library.joinpath(output).expanduser()
         if collection_path.parent.exists():
             file_path = "*.{}".format(ext)
@@ -48,11 +48,11 @@ class Collection:
                 f_path = Path(file)
                 f = LocalFile(f_path)
                 existing[f.get_metadata("key")] = f_path.stem
-        
+
             for playable in self.playables:
                 if playable.id in existing.keys():
                     playable.existing = True
-        
+
         return existing
 
 
diff --git a/zotify/file.py b/zotify/file.py
index dbc6ca2..e0f58c9 100644
--- a/zotify/file.py
+++ b/zotify/file.py
@@ -125,5 +125,4 @@ class LocalFile:
             tag: metadata tag to be retrieved
         """
         f = load_file(self.__path)
-
-        return f[tag].value
\ No newline at end of file
+        return f[tag].value

From 08c6fb911e663f267ec255d979b1e73339af173b Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 9 Feb 2025 16:41:14 -0500
Subject: [PATCH 127/169] Added --skip-duplicate implementation

---
 zotify/app.py         | 45 ++++++++++++++++++++---------------
 zotify/collections.py | 55 ++++++++++++++++++++++++++++++++++++++-----
 zotify/utils.py       |  3 ++-
 3 files changed, 77 insertions(+), 26 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index 6b61d91..29f0043 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -147,6 +147,7 @@ class App:
     def __init__(self, args: Namespace):
         self.__config = Config(args)
         self.__existing = {}
+        self.__duplicates = {}
         Logger(self.__config)
 
         # Create session
@@ -181,11 +182,8 @@ class App:
                 Logger.log(LogChannel.ERRORS, str(e))
                 exit(1)
         if len(collections) > 0:
-            self.scan(
-                collections,
-                self.__config.skip_previous,
-                self.__config.skip_duplicates,
-            )
+            with Loader("Scanning collections..."):
+                self.scan(collections)
             self.download_all(collections)
         else:
             Logger.log(LogChannel.WARNINGS, "there is nothing to do")
@@ -246,20 +244,20 @@ class App:
                 raise ParseError(f'Unsupported content type "{id_type}"')
         return collections
 
-    def scan(
-        self,
-        collections: list[Collection],
-        skip_previous: bool,
-        skip_duplicate: bool,
-    ):
-        if skip_previous:
+    def scan(self, collections: list[Collection]):
+        if self.__config.skip_previous:
             for collection in collections:
-                existing = collection.get_existing(
-                    self.__config.audio_format.value.ext
-                )
+                existing = collection.get_existing(self.__config.audio_format.value.ext)
                 self.__existing.update(existing)
-        if skip_duplicate:
-            pass
+        if self.__config.skip_duplicates:
+            for collection in collections:
+                duplicates = collection.get_duplicates(
+                    self.__config.audio_format.value.ext,
+                    self.__config.album_library,
+                    self.__config.playlist_library,
+                    self.__config.podcast_library,
+                )
+                self.__duplicates.update(duplicates)
 
     def download_all(self, collections: list[Collection]) -> None:
         count = 0
@@ -268,6 +266,13 @@ class App:
             for playable in collection.playables:
                 count += 1
 
+                # Skip duplicates and previously downloaded
+                if playable.duplicate:
+                    Logger.log(
+                        LogChannel.SKIPS,
+                        f'Skipping "{self.__duplicates[playable.id]}": Duplicated from another collection',
+                    )
+                    continue
                 if playable.existing:
                     Logger.log(
                         LogChannel.SKIPS,
@@ -285,7 +290,7 @@ class App:
                         except RuntimeError as err:
                             Logger.log(
                                 LogChannel.SKIPS,
-                                f'Skipping track id = {playable.id}: {err}',
+                                f"Skipping track id = {playable.id}: {err}",
                             )
                             continue
                 elif playable.type == PlayableType.EPISODE:
@@ -319,7 +324,9 @@ class App:
                     desc=f"({count}/{total}) {track.name}",
                     total=track.input_stream.size,
                 ) as p_bar:
-                    file = track.write_audio_stream(output, p_bar, self.__config.download_real_time)
+                    file = track.write_audio_stream(
+                        output, p_bar, self.__config.download_real_time
+                    )
 
                 # Download lyrics
                 if playable.type == PlayableType.TRACK and self.__config.lyrics_file:
diff --git a/zotify/collections.py b/zotify/collections.py
index 0b472dd..d63bb14 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -23,10 +23,9 @@ from zotify.utils import (
 class Collection:
     def __init__(self):
         self.playables: list[PlayableData] = []
+        self.path: Path = None
 
-    def get_existing(self, ext: str) -> dict[str, str]:
-        existing: dict[str, str] = {}
-
+    def set_path(self):
         meta_tags = ["album_artist", "album", "podcast", "playlist"]
         library = Path(self.playables[0].library)
         output = self.playables[0].output_template
@@ -38,10 +37,16 @@ class Collection:
                     "{" + meta.name + "}", fix_filename(meta.string)
                 )
 
-        collection_path = library.joinpath(output).expanduser()
-        if collection_path.parent.exists():
+        self.path = library.joinpath(output).expanduser().parent
+
+    def get_existing(self, ext: str) -> dict[str, str]:
+        existing: dict[str, str] = {}
+
+        if self.path is None:
+            self.set_path()
+        if self.path.exists():
             file_path = "*.{}".format(ext)
-            scan_path = str(collection_path.parent.joinpath(file_path))
+            scan_path = str(self.path.joinpath(file_path))
 
             # Check contents of path
             for file in iglob(scan_path):
@@ -55,6 +60,44 @@ class Collection:
 
         return existing
 
+    def get_duplicates(
+        self, ext: str, album_lib: Path, playlist_lib: Path, podcast_lib: Path
+    ) -> dict[str, str]:
+        existing: dict[str, str] = {}
+        duplicates: dict[str, str] = {}
+        scan_paths = []
+
+        if self.path is None:
+            self.set_path()
+        if self.path.exists():
+            file_path = "*.{}".format(ext)
+            collection_path = str(self.path.joinpath(file_path))
+
+        file_path = "**/*.{}".format(ext)
+        # Scan album library path
+        scan_paths.append(str(album_lib.joinpath(file_path)))
+        # Scan playlist library path
+        scan_paths.append(str(playlist_lib.joinpath(file_path)))
+        # Scan podcast library path
+        scan_paths.append(str(podcast_lib.joinpath(file_path)))
+
+        for scan_path in scan_paths:
+            for file in iglob(scan_path, recursive=True):
+                f_path = Path(file)
+                if self.path.exists() and f_path.match(collection_path):
+                    continue
+                f = LocalFile(f_path)
+                existing[f.get_metadata("key")] = f_path.stem
+
+            for playable in self.playables:
+                if playable.id in existing.keys():
+                    playable.duplicate = True
+                    duplicates[playable.id] = existing[playable.id]
+
+            existing = {}
+
+        return duplicates
+
 
 class Album(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
diff --git a/zotify/utils.py b/zotify/utils.py
index 924ebf1..a606c47 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -112,13 +112,14 @@ class PlayableType(Enum):
 
 
 @dataclass
-class PlayableData():
+class PlayableData:
     type: PlayableType
     id: str
     library: Path
     output_template: str
     metadata: list[MetadataEntry] = field(default_factory=list)
     existing: bool = False
+    duplicate: bool = False
 
 
 class OptionalOrFalse(Action):

From 3ae34dae600cf0515f5f0c3b50ca8b1a975fdfbd Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 9 Feb 2025 17:23:15 -0500
Subject: [PATCH 128/169] Updated to --skip-previous details

---
 CHANGELOG.md | 3 ++-
 README.md    | 8 ++++----
 2 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1505043..b36af28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,10 +10,11 @@
 
 - Added `--download-real-time` feature back.
 - Added implementation for `--skip-previous` as it was included as a config parameter but was unimplemented.
+- Added implementation for `--skip-duplicates` as it was included as a config parameter but was unimplemented. Note that tracks must have the key metadata for this to work. Tracks downloaded using version 0.9.8 will automatically have this. TBA: Procedure to add key metadata for tracks without it
 
 ### Removals
 
-- None
+- `--archive` to be removed as it is not used for `--skip-previous` and `--skip-duplicates`
 
 ### Fixes
 
diff --git a/README.md b/README.md
index 9ea39ca..7ccec9b 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,6 @@
 This is a fork of Zotify's [dev branch](https://github.com/zotify-dev/zotify/tree/v1.0-dev) which hasn't seen any activity for months. This fork will be updated to include missing/unimplemented features and maintained by yours truly until the original developers decide to come home with the milk.
 
 A customizable music and podcast downloader. \
-Formerly ZSp‌otify.
-
 Built on [Librespot](https://github.com/kokarare1212/librespot-python).
 
 ## Features
@@ -52,8 +50,8 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
 
 | Config key              | Command line argument     | Description                                         | Default                                                    |
 | ----------------------- | ------------------------- | --------------------------------------------------- | ---------------------------------------------------------- |
-| path_credentials        | --path-credentials        | Path to credentials file                            |                                                            |
-| path_archive            | --path-archive            | Path to track archive file                          |                                                            |
+| path_credentials        | --credentials             | Path to credentials file                            |                                                            |
+| path_archive            | --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      |                                                            |
@@ -69,6 +67,8 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
 | ffmpeg_args             | --ffmpeg-args             | Additional ffmpeg arguments when transcoding        |                                                            |
 | save_credentials        | --save-credentials        | Save login credentials to a file                    |                                                            |
 | replace_existing        | --replace-existing        | Redownload and replace songs if they already exist  |                                                            |
+| skip_previous           | --skip-previous           | Skip previously downloaded songs in the playlist    |                                                            |
+| skip_duplicates         | --skip-duplicates         | Skip downloading existing track to different album  |                                                            |
 
 </details>
 

From 0789b63f1b534dc7d17894ff3d3a01466c5400a0 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 9 Feb 2025 21:05:48 -0500
Subject: [PATCH 129/169] Changed metadata tag used for spotify track id

---
 zotify/collections.py | 22 ++++++++++++++--------
 1 file changed, 14 insertions(+), 8 deletions(-)

diff --git a/zotify/collections.py b/zotify/collections.py
index d63bb14..17321f5 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -52,7 +52,7 @@ class Collection:
             for file in iglob(scan_path):
                 f_path = Path(file)
                 f = LocalFile(f_path)
-                existing[f.get_metadata("key")] = f_path.stem
+                existing[f.get_metadata("musicbrainztrackid")] = f_path.stem
 
             for playable in self.playables:
                 if playable.id in existing.keys():
@@ -87,7 +87,7 @@ class Collection:
                 if self.path.exists() and f_path.match(collection_path):
                     continue
                 f = LocalFile(f_path)
-                existing[f.get_metadata("key")] = f_path.stem
+                existing[f.get_metadata("musicbrainztrackid")] = f_path.stem
 
             for playable in self.playables:
                 if playable.id in existing.keys():
@@ -105,7 +105,9 @@ class Album(Collection):
         album = api.get_metadata_4_album(AlbumId.from_base62(b62_id))
         for disc in album.disc:
             for track in disc.track:
-                metadata = [MetadataEntry("key", bytes_to_base62(track.gid))]
+                metadata = [
+                    MetadataEntry("musicbrainztrackid", bytes_to_base62(track.gid))
+                ]
                 self.playables.append(
                     PlayableData(
                         PlayableType.TRACK,
@@ -130,7 +132,9 @@ class Artist(Collection):
             album = api.get_metadata_4_album(AlbumId.from_hex(album_group.album[0].gid))
             for disc in album.disc:
                 for track in disc.track:
-                    metadata = [MetadataEntry("key", bytes_to_base62(track.gid))]
+                    metadata = [
+                        MetadataEntry("musicbrainztrackid", bytes_to_base62(track.gid))
+                    ]
                     self.playables.append(
                         PlayableData(
                             PlayableType.TRACK,
@@ -147,7 +151,9 @@ class Show(Collection):
         super().__init__()
         show = api.get_metadata_4_show(ShowId.from_base62(b62_id))
         for episode in show.episode:
-            metadata = [MetadataEntry("key", bytes_to_base62(episode.gid))]
+            metadata = [
+                MetadataEntry("musicbrainztrackid", bytes_to_base62(episode.gid))
+            ]
             self.playables.append(
                 PlayableData(
                     PlayableType.EPISODE,
@@ -169,7 +175,7 @@ class Playlist(Collection):
             playable_type = split[1]
             playable_id = split[2]
             metadata = [
-                MetadataEntry("key", playable_id),
+                MetadataEntry("musicbrainztrackid", playable_id),
                 MetadataEntry("playlist", playlist.attributes.name),
                 MetadataEntry("playlist_length", playlist.length),
                 MetadataEntry("playlist_owner", playlist.owner_username),
@@ -209,7 +215,7 @@ class Playlist(Collection):
 class Track(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
         super().__init__()
-        metadata = [MetadataEntry("key", b62_id)]
+        metadata = [MetadataEntry("musicbrainztrackid", b62_id)]
         self.playables.append(
             PlayableData(
                 PlayableType.TRACK,
@@ -224,7 +230,7 @@ class Track(Collection):
 class Episode(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
         super().__init__()
-        metadata = [MetadataEntry("key", b62_id)]
+        metadata = [MetadataEntry("musicbrainztrackid", b62_id)]
         self.playables.append(
             PlayableData(
                 PlayableType.EPISODE,

From 813e322f760cae1d7c233b5fbf3341eb4d44393a Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 9 Feb 2025 21:12:33 -0500
Subject: [PATCH 130/169] Updated --skip-duplicates changes

---
 CHANGELOG.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b36af28..fcbe701 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,7 @@
 
 - Added `--download-real-time` feature back.
 - Added implementation for `--skip-previous` as it was included as a config parameter but was unimplemented.
-- Added implementation for `--skip-duplicates` as it was included as a config parameter but was unimplemented. Note that tracks must have the key metadata for this to work. Tracks downloaded using version 0.9.8 will automatically have this. TBA: Procedure to add key metadata for tracks without it
+- Added implementation for `--skip-duplicates` as it was included as a config parameter but was unimplemented. Note that tracks must have the trackid metadata for this to work. TBA: Procedure to add trackid metadata for tracks without it
 
 ### Removals
 

From afc696de5d2d3711c05d6d5972bba102cfea76ec Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Mon, 10 Feb 2025 00:02:36 -0500
Subject: [PATCH 131/169] Fixed track extension being appended twice

---
 zotify/playable.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index bb57e36..a009514 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -87,9 +87,9 @@ class Playable:
                     "{" + meta.name + "}", fix_filename(meta.string)
                 )
         file_path = library.joinpath(output).expanduser()
-        file_path = Path(f"{file_path}.{ext}")
-        if file_path.exists() and not replace:
-            f = LocalFile(file_path)
+        check_path = Path(f"{file_path}.{ext}")
+        if check_path.exists() and not replace:
+            f = LocalFile(check_path)
             f.write_metadata(self.metadata)
             raise FileExistsError("File already downloaded")
         else:

From 645be0def3c14745585fdcb3afa1171f3daffeaf Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Mon, 10 Feb 2025 00:27:17 -0500
Subject: [PATCH 132/169] Fixed issue #4 (Audio key error, code: 2)

---
 requirements.txt   | 1 +
 setup.cfg          | 1 +
 zotify/__init__.py | 8 ++++++++
 3 files changed, 10 insertions(+)

diff --git a/requirements.txt b/requirements.txt
index 4634246..67f7dfa 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,3 +5,4 @@ Pillow
 pkce
 requests
 tqdm
+ratelimit
diff --git a/setup.cfg b/setup.cfg
index c9cc896..5393bf2 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -26,6 +26,7 @@ install_requires =
     pkce
     requests
     tqdm
+    ratelimit
 
 [options.entry_points]
 console_scripts =
diff --git a/zotify/__init__.py b/zotify/__init__.py
index 9f95e53..673155b 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -7,6 +7,7 @@ from threading import Thread
 from typing import Any
 from time import time_ns
 from urllib.parse import urlencode, urlparse, parse_qs
+from ratelimit import limits, sleep_and_retry
 
 from librespot.audio import AudioKeyManager, CdnManager
 from librespot.audio.decoders import VorbisOnlyAudioQuality
@@ -36,6 +37,8 @@ API_URL = "https://api.sp" + "otify.com/v1/"
 AUTH_URL = "https://accounts.sp" + "otify.com/"
 REDIRECT_URI = "http://127.0.0.1:4381/login"
 CLIENT_ID = "65b70807" + "3fc0480e" + "a92a0772" + "33ca87bd"
+RATE_LIMIT_INTERVAL_SECS = 30
+RATE_LIMIT_CALLS_PER_INTERVAL = 9
 SCOPES = [
     "app-remote-control",
     "playlist-modify",
@@ -221,6 +224,11 @@ class Session(LibrespotSession):
             self.__auth_lock.notify_all()
         self.mercury().interested_in("sp" + "otify:user:attributes:update", self)
 
+    @sleep_and_retry
+    @limits(calls=RATE_LIMIT_CALLS_PER_INTERVAL, period=RATE_LIMIT_INTERVAL_SECS)
+    def api(self) -> ApiClient:
+        return super().api()
+
 
 class ApiClient(LibrespotApiClient):
     def __init__(self, session: Session):

From 3b2dfe8c2f108f13022984bb0013a1e448488a6f Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Mon, 10 Feb 2025 16:42:02 -0500
Subject: [PATCH 133/169] Fixed issue #4: Added increasing delay on consecutive
 rate limit hits

---
 zotify/__init__.py |  5 +++--
 zotify/app.py      | 31 +++++++++++++++++++++----------
 2 files changed, 24 insertions(+), 12 deletions(-)

diff --git a/zotify/__init__.py b/zotify/__init__.py
index 673155b..ead7c66 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -37,8 +37,6 @@ API_URL = "https://api.sp" + "otify.com/v1/"
 AUTH_URL = "https://accounts.sp" + "otify.com/"
 REDIRECT_URI = "http://127.0.0.1:4381/login"
 CLIENT_ID = "65b70807" + "3fc0480e" + "a92a0772" + "33ca87bd"
-RATE_LIMIT_INTERVAL_SECS = 30
-RATE_LIMIT_CALLS_PER_INTERVAL = 9
 SCOPES = [
     "app-remote-control",
     "playlist-modify",
@@ -68,6 +66,9 @@ SCOPES = [
     "user-top-read",
 ]
 
+RATE_LIMIT_INTERVAL_SECS = 30
+RATE_LIMIT_CALLS_PER_INTERVAL = 9
+
 
 class Session(LibrespotSession):
     def __init__(
diff --git a/zotify/app.py b/zotify/app.py
index 29f0043..33929c0 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -1,8 +1,9 @@
 from argparse import Namespace
 from pathlib import Path
 from typing import Any
+from time import sleep
 
-from zotify import OAuth, Session
+from zotify import OAuth, Session, RATE_LIMIT_INTERVAL_SECS
 from zotify.collections import Album, Artist, Collection, Episode, Playlist, Show, Track
 from zotify.config import Config
 from zotify.file import TranscodingError
@@ -262,6 +263,7 @@ class App:
     def download_all(self, collections: list[Collection]) -> None:
         count = 0
         total = sum(len(c.playables) for c in collections)
+        rate_limit_hits = 0
         for collection in collections:
             for playable in collection.playables:
                 count += 1
@@ -282,17 +284,21 @@ class App:
 
                 # Get track data
                 if playable.type == PlayableType.TRACK:
-                    with Loader("Fetching track..."):
-                        try:
+                    try:
+                        with Loader("Fetching track..."):
                             track = self.__session.get_track(
                                 playable.id, self.__config.download_quality
                             )
-                        except RuntimeError as err:
-                            Logger.log(
-                                LogChannel.SKIPS,
-                                f"Skipping track id = {playable.id}: {err}",
-                            )
-                            continue
+                    except RuntimeError as err:
+                        Logger.log(LogChannel.SKIPS, f"Skipping track #{count}: {err}")
+                        if "audio key" in str(err).lower():
+                            rate_limit_hits += 1
+                            sleep_time = RATE_LIMIT_INTERVAL_SECS * rate_limit_hits
+                            with Loader(
+                                f"Rate limit hit! Sleeping for {sleep_time}s..."
+                            ):
+                                sleep(sleep_time)
+                        continue
                 elif playable.type == PlayableType.EPISODE:
                     with Loader("Fetching episode..."):
                         track = self.__session.get_episode(playable.id)
@@ -341,7 +347,9 @@ class App:
                                 track.lyrics().save(output)
                             except FileNotFoundError as e:
                                 Logger.log(LogChannel.SKIPS, str(e))
-                Logger.log(LogChannel.DOWNLOADS, f"\nDownloaded {track.name}")
+                Logger.log(
+                    LogChannel.DOWNLOADS, f"\nDownloaded {track.name} ({count}/{total})"
+                )
 
                 # Transcode audio
                 if (
@@ -367,3 +375,6 @@ class App:
                         file.write_cover_art(
                             track.get_cover_art(self.__config.artwork_size)
                         )
+
+                # Reset rate limit counter for every successful download
+                rate_limit_hits = 0

From ce8d095de72f3e831769d364c9c2328841ad80a5 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Mon, 10 Feb 2025 16:58:50 -0500
Subject: [PATCH 134/169] Fixed issue #9: Moved -v argument to mutually
 exclusive group

---
 zotify/__main__.py | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/zotify/__main__.py b/zotify/__main__.py
index 6afd13b..a260715 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -15,7 +15,9 @@ def main():
         prog="zotify",
         description="A fast and customizable music and podcast downloader",
     )
-    parser.add_argument(
+    group = parser.add_mutually_exclusive_group(required=True)
+
+    group.add_argument(
         "-v",
         "--version",
         action="store_true",
@@ -52,7 +54,6 @@ def main():
     )
     parser.add_argument("--username", type=str, default="", help="Account username")
     parser.add_argument("--token", type=str, default="", help="Account token")
-    group = parser.add_mutually_exclusive_group(required=True)
     group.add_argument(
         "urls",
         type=str,

From caae869d48e7aa6039004db16d1e67d8966a0ad8 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Mon, 10 Feb 2025 17:00:25 -0500
Subject: [PATCH 135/169] Version bump

---
 setup.cfg          | 2 +-
 zotify/__main__.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 5393bf2..7ebf836 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = zotify
-version = 0.9.8
+version = 0.9.9
 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 a260715..1a17338 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.8"
+VERSION = "0.9.9"
 
 
 def main():

From ba9590d8f8b5fe95666626e53f6601632c9e4126 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Thu, 13 Feb 2025 15:08:56 -0500
Subject: [PATCH 136/169] Changed internal rate limiter library used to
 implement adjustable rate limit  modes

---
 requirements.txt   |  2 +-
 setup.cfg          |  2 +-
 zotify/__init__.py | 47 +++++++++++++++++++++++++++----
 zotify/app.py      | 70 ++++++++++++++++++++++++++++++++++++----------
 zotify/utils.py    |  5 ++++
 5 files changed, 104 insertions(+), 22 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index 67f7dfa..037cdae 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,4 +5,4 @@ Pillow
 pkce
 requests
 tqdm
-ratelimit
+limits
diff --git a/setup.cfg b/setup.cfg
index 7ebf836..a375514 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -26,7 +26,7 @@ install_requires =
     pkce
     requests
     tqdm
-    ratelimit
+    limits
 
 [options.entry_points]
 console_scripts =
diff --git a/zotify/__init__.py b/zotify/__init__.py
index ead7c66..0e975d0 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -5,9 +5,9 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
 from pathlib import Path
 from threading import Thread
 from typing import Any
-from time import time_ns
+from time import time_ns, sleep
 from urllib.parse import urlencode, urlparse, parse_qs
-from ratelimit import limits, sleep_and_retry
+from limits import storage, strategies, RateLimitItemPerSecond
 
 from librespot.audio import AudioKeyManager, CdnManager
 from librespot.audio.decoders import VorbisOnlyAudioQuality
@@ -31,7 +31,7 @@ from requests import HTTPError, get, post
 
 from zotify.loader import Loader
 from zotify.playable import Episode, Track
-from zotify.utils import Quality
+from zotify.utils import Quality, RateLimitMode
 
 API_URL = "https://api.sp" + "otify.com/v1/"
 AUTH_URL = "https://accounts.sp" + "otify.com/"
@@ -66,8 +66,12 @@ SCOPES = [
     "user-top-read",
 ]
 
+RATE_LIMIT_API = "rate_limit_api"
+RATE_LIMIT_MAX_CONSECUTIVE_HITS = 10
+RATE_LIMIT_RESTORE_CONDITION = 15
 RATE_LIMIT_INTERVAL_SECS = 30
-RATE_LIMIT_CALLS_PER_INTERVAL = 9
+RATE_LIMIT_CALLS_NORMAL = 9
+RATE_LIMIT_CALLS_REDUCED = 3
 
 
 class Session(LibrespotSession):
@@ -98,6 +102,7 @@ class Session(LibrespotSession):
             self.__language = language
             self.connect()
             self.authenticate(session_builder.login_credentials)
+        self.rate_limiter = RateLimiter()
 
     @staticmethod
     def from_file(cred_file: Path | str, language: str = "en") -> Session:
@@ -225,9 +230,12 @@ class Session(LibrespotSession):
             self.__auth_lock.notify_all()
         self.mercury().interested_in("sp" + "otify:user:attributes:update", self)
 
-    @sleep_and_retry
-    @limits(calls=RATE_LIMIT_CALLS_PER_INTERVAL, period=RATE_LIMIT_INTERVAL_SECS)
     def api(self) -> ApiClient:
+        # Check rate limiter before making calls to api
+        while not self.rate_limiter.check():
+            sleep(1)
+
+        self.rate_limiter.hit()
         return super().api()
 
 
@@ -433,3 +441,30 @@ class OAuth:
                 self.end_headers()
                 self.wfile.write(b"Authorization code not found.")
                 Thread(target=self.server.shutdown).start()
+
+
+class RateLimiter:
+    rate_limits = {
+        RateLimitMode.NORMAL: RateLimitItemPerSecond(
+            RATE_LIMIT_CALLS_NORMAL, RATE_LIMIT_INTERVAL_SECS
+        ),
+        RateLimitMode.REDUCED: RateLimitItemPerSecond(
+            RATE_LIMIT_CALLS_REDUCED, RATE_LIMIT_INTERVAL_SECS
+        ),
+    }
+
+    def __init__(self):
+        self.storage = storage.MemoryStorage()
+        self.moving_window = strategies.MovingWindowRateLimiter(self.storage)
+        self.mode = RateLimitMode.NORMAL
+        self.rate_limit = RateLimiter.rate_limits[self.mode]
+
+    def check(self):
+        return self.moving_window.test(self.rate_limit, RATE_LIMIT_API)
+
+    def hit(self):
+        self.moving_window.hit(self.rate_limit, RATE_LIMIT_API)
+
+    def set_mode(self, mode: RateLimitMode):
+        self.mode = mode
+        self.rate_limit = RateLimiter.rate_limits[self.mode]
diff --git a/zotify/app.py b/zotify/app.py
index 33929c0..0f8bdc0 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -3,13 +3,19 @@ from pathlib import Path
 from typing import Any
 from time import sleep
 
-from zotify import OAuth, Session, RATE_LIMIT_INTERVAL_SECS
+from zotify import (
+    OAuth,
+    Session,
+    RATE_LIMIT_INTERVAL_SECS,
+    RATE_LIMIT_MAX_CONSECUTIVE_HITS,
+    RATE_LIMIT_RESTORE_CONDITION,
+)
 from zotify.collections import Album, Artist, Collection, Episode, Playlist, Show, Track
 from zotify.config import Config
 from zotify.file import TranscodingError
 from zotify.loader import Loader
 from zotify.logger import LogChannel, Logger
-from zotify.utils import AudioFormat, PlayableType
+from zotify.utils import AudioFormat, PlayableType, RateLimitMode
 
 
 class ParseError(ValueError): ...
@@ -261,9 +267,11 @@ class App:
                 self.__duplicates.update(duplicates)
 
     def download_all(self, collections: list[Collection]) -> None:
+        self.rate_limit_hits = 0
+        self.last_rate_limit_hit = 0
+
         count = 0
         total = sum(len(c.playables) for c in collections)
-        rate_limit_hits = 0
         for collection in collections:
             for playable in collection.playables:
                 count += 1
@@ -285,23 +293,22 @@ class App:
                 # Get track data
                 if playable.type == PlayableType.TRACK:
                     try:
+                        self.restore_rate_limit(count)
                         with Loader("Fetching track..."):
                             track = self.__session.get_track(
                                 playable.id, self.__config.download_quality
                             )
                     except RuntimeError as err:
-                        Logger.log(LogChannel.SKIPS, f"Skipping track #{count}: {err}")
-                        if "audio key" in str(err).lower():
-                            rate_limit_hits += 1
-                            sleep_time = RATE_LIMIT_INTERVAL_SECS * rate_limit_hits
-                            with Loader(
-                                f"Rate limit hit! Sleeping for {sleep_time}s..."
-                            ):
-                                sleep(sleep_time)
+                        self.handle_runtime_error(err, playable.type, count)
                         continue
                 elif playable.type == PlayableType.EPISODE:
-                    with Loader("Fetching episode..."):
-                        track = self.__session.get_episode(playable.id)
+                    try:
+                        self.restore_rate_limit(count)
+                        with Loader("Fetching episode..."):
+                            track = self.__session.get_episode(playable.id)
+                    except RuntimeError as err:
+                        self.handle_runtime_error(err, playable.type, count)
+                        continue
                 else:
                     Logger.log(
                         LogChannel.SKIPS,
@@ -377,4 +384,39 @@ class App:
                         )
 
                 # Reset rate limit counter for every successful download
-                rate_limit_hits = 0
+                self.rate_limit_hits = 0
+
+    def restore_rate_limit(self, count: int) -> None:
+        if (
+            self.__session.rate_limiter.mode == RateLimitMode.REDUCED
+            and (count - self.last_rate_limit_hit) > RATE_LIMIT_RESTORE_CONDITION
+        ):
+            with Loader("Restoring rate limit to normal..."):
+                self.__session.rate_limiter.set_mode(RateLimitMode.NORMAL)
+                sleep(RATE_LIMIT_INTERVAL_SECS)
+
+    def handle_runtime_error(
+        self, err: str, playable_type: PlayableType, count: int
+    ) -> None:
+        Logger.log(LogChannel.SKIPS, f"Skipping {playable_type.value} #{count}: {err}")
+        if "audio key" in str(err).lower():
+            self.handle_rate_limit_hit(count)
+
+    def handle_rate_limit_hit(self, count: int) -> None:
+        self.rate_limit_hits += 1
+        self.last_rate_limit_hit = count
+
+        # Exit program if rate limit hit cutoff is reached
+        if self.rate_limit_hits > RATE_LIMIT_MAX_CONSECUTIVE_HITS:
+            Logger.log(LogChannel.ERRORS, "Server too busy or down. Try again later")
+            exit(1)
+
+        # Reduce internal rate limiter
+        if self.__session.rate_limiter.mode == RateLimitMode.NORMAL:
+            self.__session.rate_limiter.set_mode(RateLimitMode.REDUCED)
+
+        # Sleep for one interval
+        with Loader(
+            f"Server rate limit hit! Sleeping for {RATE_LIMIT_INTERVAL_SECS}s..."
+        ):
+            sleep(RATE_LIMIT_INTERVAL_SECS)
diff --git a/zotify/utils.py b/zotify/utils.py
index a606c47..3e27590 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -122,6 +122,11 @@ class PlayableData:
     duplicate: bool = False
 
 
+class RateLimitMode(Enum):
+    NORMAL = "normal"
+    REDUCED = "reduced"
+
+
 class OptionalOrFalse(Action):
     def __init__(
         self,

From af988457c7e9e288a633a3735d9007242acb9c0f Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Thu, 13 Feb 2025 15:12:02 -0500
Subject: [PATCH 137/169] Added rate limiter changes

---
 CHANGELOG.md | 1 +
 1 file changed, 1 insertion(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index fcbe701..b75a066 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@
 - Fixed `RuntimeError: Cannot get alternative track` from terminating the program.
 - Fixed downloading multiple collections from downloading everything twice.
 - Fixed `-d` or `--download` option not detecting the file.
+- Fixed `Failed fetching audio key!` error from continuously hitting API rate limits. Implemented a flat rate limiter on calls to API. When usage traffic is high and rate limits are hit on the server end, the current track is skipped and the program will continue at a reduced internal rate limit.
 
 ## v1.0.0
 

From 4b0cc594041d1da9c31cf1d434cee32f09d10904 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 21 Feb 2025 11:47:31 -0500
Subject: [PATCH 138/169] Fixed issue #14

---
 zotify/collections.py | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/zotify/collections.py b/zotify/collections.py
index 17321f5..7aa208d 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -125,11 +125,13 @@ class Artist(Collection):
         artist = api.get_metadata_4_artist(ArtistId.from_base62(b62_id))
         for album_group in (
             artist.album_group
-            and artist.single_group
-            and artist.compilation_group
-            and artist.appears_on_group
+            or artist.single_group
+            or artist.compilation_group
+            or artist.appears_on_group
         ):
-            album = api.get_metadata_4_album(AlbumId.from_hex(album_group.album[0].gid))
+            album = api.get_metadata_4_album(
+                AlbumId.from_base62(bytes_to_base62(album_group.album[0].gid))
+            )
             for disc in album.disc:
                 for track in disc.track:
                     metadata = [

From 1d552c6dc47ad5007d7c6cbdc197e5c0b67d5a69 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 21 Feb 2025 12:03:49 -0500
Subject: [PATCH 139/169] Add exception handling for empty collections when
 scanning libraries

---
 zotify/app.py | 30 +++++++++++++++++++++---------
 1 file changed, 21 insertions(+), 9 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index 0f8bdc0..90eac9a 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -254,17 +254,29 @@ class App:
     def scan(self, collections: list[Collection]):
         if self.__config.skip_previous:
             for collection in collections:
-                existing = collection.get_existing(self.__config.audio_format.value.ext)
-                self.__existing.update(existing)
+                try:
+                    existing = collection.get_existing(
+                        self.__config.audio_format.value.ext
+                    )
+                    self.__existing.update(existing)
+                except IndexError as err:
+                    Logger.log(
+                        LogChannel.WARNINGS, f"{err} Cannot scan for existing tracks"
+                    )
         if self.__config.skip_duplicates:
             for collection in collections:
-                duplicates = collection.get_duplicates(
-                    self.__config.audio_format.value.ext,
-                    self.__config.album_library,
-                    self.__config.playlist_library,
-                    self.__config.podcast_library,
-                )
-                self.__duplicates.update(duplicates)
+                try:
+                    duplicates = collection.get_duplicates(
+                        self.__config.audio_format.value.ext,
+                        self.__config.album_library,
+                        self.__config.playlist_library,
+                        self.__config.podcast_library,
+                    )
+                    self.__duplicates.update(duplicates)
+                except IndexError as err:
+                    Logger.log(
+                        LogChannel.WARNINGS, f"{err} Cannot scan for duplicate tracks"
+                    )
 
     def download_all(self, collections: list[Collection]) -> None:
         self.rate_limit_hits = 0

From 18fcb370f22ee0b6c06ac72ad745bc73ce0182b7 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Tue, 25 Feb 2025 11:15:31 -0500
Subject: [PATCH 140/169] Update music-tag version that is needed in the fix
 for issue #15

---
 requirements.txt | 2 +-
 setup.cfg        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index 037cdae..b2a40dd 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 librespot@git+https://github.com/kokarare1212/librespot-python
-music-tag@git+https://github.com/DraftKinner/music-tag.git
+https://github.com/DraftKinner/music-tag/archive/refs/tags/v0.4.5.zip
 mutagen
 Pillow
 pkce
diff --git a/setup.cfg b/setup.cfg
index a375514..024f14c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,7 +20,7 @@ packages = zotify
 python_requires = >=3.11
 install_requires =
     librespot@git+https://github.com/kokarare1212/librespot-python
-    music-tag@git+https://github.com/DraftKinner/music-tag.git
+    music-tag@https://github.com/DraftKinner/music-tag/archive/refs/tags/v0.4.5.zip
     mutagen
     Pillow
     pkce

From 82dc7471ac45d2c2a12480642672ad3976720c29 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Tue, 25 Feb 2025 11:20:21 -0500
Subject: [PATCH 141/169] Fixed issues #10 and #15

---
 zotify/__init__.py    |   2 +
 zotify/__main__.py    |   6 +++
 zotify/app.py         |   9 +++-
 zotify/collections.py | 117 ++++++++++++++++++++++++++++++++++--------
 4 files changed, 110 insertions(+), 24 deletions(-)

diff --git a/zotify/__init__.py b/zotify/__init__.py
index 0e975d0..2a71991 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -73,6 +73,8 @@ RATE_LIMIT_INTERVAL_SECS = 30
 RATE_LIMIT_CALLS_NORMAL = 9
 RATE_LIMIT_CALLS_REDUCED = 3
 
+API_MAX_REQUEST_LIMIT = 50
+
 
 class Session(LibrespotSession):
     def __init__(
diff --git a/zotify/__main__.py b/zotify/__main__.py
index 1a17338..1d0cb11 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -54,6 +54,12 @@ def main():
     )
     parser.add_argument("--username", type=str, default="", help="Account username")
     parser.add_argument("--token", type=str, default="", help="Account token")
+    parser.add_argument(
+        "-m",
+        "--match",
+        action="store_true",
+        help="Match downloaded track filenames to corresponding tracks in collection",
+    )
     group.add_argument(
         "urls",
         type=str,
diff --git a/zotify/app.py b/zotify/app.py
index 90eac9a..613644c 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -190,7 +190,7 @@ class App:
                 exit(1)
         if len(collections) > 0:
             with Loader("Scanning collections..."):
-                self.scan(collections)
+                self.scan(collections, args.match)
             self.download_all(collections)
         else:
             Logger.log(LogChannel.WARNINGS, "there is nothing to do")
@@ -251,7 +251,11 @@ class App:
                 raise ParseError(f'Unsupported content type "{id_type}"')
         return collections
 
-    def scan(self, collections: list[Collection]):
+    def scan(self, collections: list[Collection], match: bool):
+        if match:
+            for collection in collections:
+                collection.get_match()
+
         if self.__config.skip_previous:
             for collection in collections:
                 try:
@@ -263,6 +267,7 @@ class App:
                     Logger.log(
                         LogChannel.WARNINGS, f"{err} Cannot scan for existing tracks"
                     )
+
         if self.__config.skip_duplicates:
             for collection in collections:
                 try:
diff --git a/zotify/collections.py b/zotify/collections.py
index 7aa208d..2abc269 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -8,7 +8,7 @@ from librespot.metadata import (
     ShowId,
 )
 
-from zotify import ApiClient
+from zotify import ApiClient, API_MAX_REQUEST_LIMIT
 from zotify.config import Config
 from zotify.file import LocalFile
 from zotify.utils import (
@@ -21,11 +21,16 @@ from zotify.utils import (
 
 
 class Collection:
-    def __init__(self):
+    def __init__(self, api: ApiClient):
         self.playables: list[PlayableData] = []
         self.path: Path = None
+        self.api = api
+        self.offset = 0
 
     def set_path(self):
+        if len(self.playables) == 0:
+            raise IndexError("Collection is empty!")
+
         meta_tags = ["album_artist", "album", "podcast", "playlist"]
         library = Path(self.playables[0].library)
         output = self.playables[0].output_template
@@ -52,7 +57,7 @@ class Collection:
             for file in iglob(scan_path):
                 f_path = Path(file)
                 f = LocalFile(f_path)
-                existing[f.get_metadata("musicbrainztrackid")] = f_path.stem
+                existing[f.get_metadata("spotid")] = f_path.stem
 
             for playable in self.playables:
                 if playable.id in existing.keys():
@@ -87,7 +92,7 @@ class Collection:
                 if self.path.exists() and f_path.match(collection_path):
                     continue
                 f = LocalFile(f_path)
-                existing[f.get_metadata("musicbrainztrackid")] = f_path.stem
+                existing[f.get_metadata("spotid")] = f_path.stem
 
             for playable in self.playables:
                 if playable.id in existing.keys():
@@ -98,16 +103,88 @@ class Collection:
 
         return duplicates
 
+    def get_metadata(self):
+        params = {}
+        ids = ""
+        offset_start = self.offset
+
+        for playable in self.playables[self.offset :]:
+            if (
+                self.offset == offset_start
+                or (self.offset % API_MAX_REQUEST_LIMIT) != 0
+            ):
+                ids = f"{ids},{playable.id}"
+                self.offset += 1
+            else:
+                break
+
+        metadata = []
+        params = {"ids": ids.strip(",")}
+        if isinstance(self, (Album, Artist, Playlist, Track)):
+            r = self.api.invoke_url(
+                "tracks", params, limit=API_MAX_REQUEST_LIMIT, offset=offset_start
+            )
+
+            for track in r["tracks"]:
+                # Get title, artist, and id
+                track_metadata = [
+                    MetadataEntry("spotid", track["id"]),
+                    MetadataEntry("title", track["name"]),
+                    MetadataEntry("artists", [a["name"] for a in track["artists"]]),
+                ]
+                metadata.append(track_metadata)
+        else:
+            r = self.api.invoke_url(
+                "episodes", params, limit=API_MAX_REQUEST_LIMIT, offset=offset_start
+            )
+
+            for episode in r["episodes"]:
+                # Get title and id
+                episode_metadata = [
+                    MetadataEntry("spotid", episode["id"]),
+                    MetadataEntry("title", episode["name"]),
+                ]
+                metadata.append(episode_metadata)
+
+        return metadata
+
+    def get_match(self):
+        count = 0
+
+        # Output format of existing tracks must match
+        # with the current download command
+        if self.path is None:
+            self.set_path()
+        if self.path.exists():
+            for playable in self.playables:
+                if count == self.offset:
+                    # Get new batch of metadata
+                    metadata = self.get_metadata()
+
+                # Create file path, include all extensions
+                filename = Path(self.playables[0].output_template).name
+                filename = filename.replace("{episode_number}", "*")
+                filename = filename.replace("{track_number}", "*")
+                for meta in metadata[count % API_MAX_REQUEST_LIMIT]:
+                    filename = filename.replace(
+                        "{" + meta.name + "}", fix_filename(meta.string)
+                    )
+                scan_path = f"{self.path.joinpath(filename)}.*"
+
+                for file in iglob(scan_path):
+                    f = LocalFile(Path(file))
+                    f.write_metadata(metadata[count % API_MAX_REQUEST_LIMIT])
+
+                count += 1
+
 
 class Album(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__()
+        super().__init__(api)
         album = api.get_metadata_4_album(AlbumId.from_base62(b62_id))
         for disc in album.disc:
             for track in disc.track:
-                metadata = [
-                    MetadataEntry("musicbrainztrackid", bytes_to_base62(track.gid))
-                ]
+                metadata = [MetadataEntry("spotid", bytes_to_base62(track.gid))]
                 self.playables.append(
                     PlayableData(
                         PlayableType.TRACK,
@@ -121,7 +198,7 @@ class Album(Collection):
 
 class Artist(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__()
+        super().__init__(api)
         artist = api.get_metadata_4_artist(ArtistId.from_base62(b62_id))
         for album_group in (
             artist.album_group
@@ -134,9 +211,7 @@ class Artist(Collection):
             )
             for disc in album.disc:
                 for track in disc.track:
-                    metadata = [
-                        MetadataEntry("musicbrainztrackid", bytes_to_base62(track.gid))
-                    ]
+                    metadata = [MetadataEntry("spotid", bytes_to_base62(track.gid))]
                     self.playables.append(
                         PlayableData(
                             PlayableType.TRACK,
@@ -150,12 +225,10 @@ class Artist(Collection):
 
 class Show(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__()
+        super().__init__(api)
         show = api.get_metadata_4_show(ShowId.from_base62(b62_id))
         for episode in show.episode:
-            metadata = [
-                MetadataEntry("musicbrainztrackid", bytes_to_base62(episode.gid))
-            ]
+            metadata = [MetadataEntry("spotid", bytes_to_base62(episode.gid))]
             self.playables.append(
                 PlayableData(
                     PlayableType.EPISODE,
@@ -169,7 +242,7 @@ class Show(Collection):
 
 class Playlist(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__()
+        super().__init__(api)
         playlist = api.get_playlist(PlaylistId(b62_id))
         for i in range(len(playlist.contents.items)):
             item = playlist.contents.items[i]
@@ -177,7 +250,7 @@ class Playlist(Collection):
             playable_type = split[1]
             playable_id = split[2]
             metadata = [
-                MetadataEntry("musicbrainztrackid", playable_id),
+                MetadataEntry("spotid", playable_id),
                 MetadataEntry("playlist", playlist.attributes.name),
                 MetadataEntry("playlist_length", playlist.length),
                 MetadataEntry("playlist_owner", playlist.owner_username),
@@ -216,8 +289,8 @@ class Playlist(Collection):
 
 class Track(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__()
-        metadata = [MetadataEntry("musicbrainztrackid", b62_id)]
+        super().__init__(api)
+        metadata = [MetadataEntry("spotid", b62_id)]
         self.playables.append(
             PlayableData(
                 PlayableType.TRACK,
@@ -231,8 +304,8 @@ class Track(Collection):
 
 class Episode(Collection):
     def __init__(self, b62_id: str, api: ApiClient, config: Config = Config()):
-        super().__init__()
-        metadata = [MetadataEntry("musicbrainztrackid", b62_id)]
+        super().__init__(api)
+        metadata = [MetadataEntry("spotid", b62_id)]
         self.playables.append(
             PlayableData(
                 PlayableType.EPISODE,

From eed421041f83e2e17c5da46ab06963fdcafa982b Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Tue, 25 Feb 2025 12:17:34 -0500
Subject: [PATCH 142/169] Use nargs for -d parameter

---
 zotify/__main__.py | 1 +
 zotify/app.py      | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/zotify/__main__.py b/zotify/__main__.py
index 1d0cb11..0e1d155 100644
--- a/zotify/__main__.py
+++ b/zotify/__main__.py
@@ -71,6 +71,7 @@ def main():
         "-d",
         "--download",
         type=str,
+        nargs="*",
         help="Downloads tracks, playlists and albums from the URLs written in the file passed.",
     )
     group.add_argument(
diff --git a/zotify/app.py b/zotify/app.py
index 613644c..aacc26d 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -211,7 +211,7 @@ class App:
                 return selection.get("episodes")
             elif args.download:
                 ids = []
-                for x in args.download.split(", "):
+                for x in args.download:
                     ids.extend(selection.from_file(x.strip()))
                 return ids
             elif args.urls:

From e9e68c8dc6f910922078ed78a97598b1994540db Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Mon, 3 Mar 2025 19:07:31 -0500
Subject: [PATCH 143/169] Updated with -m flag usage

---
 README.md | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/README.md b/README.md
index 7ccec9b..0069ee3 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,19 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
 
 </details>
 
+### Compatibility with official version
+
+Do note that `--skip-previous` and `--skip-duplicates` won't immediately work with playlists and albums downloaded using the official version (both dev and main branches). To make the playlist/album compatible with this fork such that `--skip-previous` and `--skip-duplicates` will both work, simply add the `-m` or `--match` flag to the download command.
+
+For example:
+```
+zotify -m <playlist/album_url>
+zotify -m -p
+zotify -m -d <text_file_with_urls_to_download>
+```
+This only needs to be done once per existing album or playlist.
+
+
 ### More about search
 
 - `-c` or `--category` can be used to limit search results to certain categories.

From 215446cf5d73ffe7959d30dfaf56e324783185d8 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sat, 8 Mar 2025 12:41:04 -0500
Subject: [PATCH 144/169] Updated music-tag package to support aac and fdk_aac
 for issue #10

---
 requirements.txt | 2 +-
 setup.cfg        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index b2a40dd..7a79694 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 librespot@git+https://github.com/kokarare1212/librespot-python
-https://github.com/DraftKinner/music-tag/archive/refs/tags/v0.4.5.zip
+https://github.com/DraftKinner/music-tag/archive/refs/tags/v0.4.6.zip
 mutagen
 Pillow
 pkce
diff --git a/setup.cfg b/setup.cfg
index 024f14c..53a39c2 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,7 +20,7 @@ packages = zotify
 python_requires = >=3.11
 install_requires =
     librespot@git+https://github.com/kokarare1212/librespot-python
-    music-tag@https://github.com/DraftKinner/music-tag/archive/refs/tags/v0.4.5.zip
+    music-tag@https://github.com/DraftKinner/music-tag/archive/refs/tags/v0.4.6.zip
     mutagen
     Pillow
     pkce

From ea3be10ee063d1922eeb2aefc8262a0f70afbea8 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 9 Mar 2025 13:00:02 -0400
Subject: [PATCH 145/169] Updated music-tag package to fix mp4 tag not properly
 being stored

---
 requirements.txt | 2 +-
 setup.cfg        | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index 7a79694..09100d4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 librespot@git+https://github.com/kokarare1212/librespot-python
-https://github.com/DraftKinner/music-tag/archive/refs/tags/v0.4.6.zip
+https://github.com/DraftKinner/music-tag/archive/refs/tags/v0.4.7.zip
 mutagen
 Pillow
 pkce
diff --git a/setup.cfg b/setup.cfg
index 53a39c2..c75463a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,7 +20,7 @@ packages = zotify
 python_requires = >=3.11
 install_requires =
     librespot@git+https://github.com/kokarare1212/librespot-python
-    music-tag@https://github.com/DraftKinner/music-tag/archive/refs/tags/v0.4.6.zip
+    music-tag@https://github.com/DraftKinner/music-tag/archive/refs/tags/v0.4.7.zip
     mutagen
     Pillow
     pkce

From f56c133273d8bf2dff234b88b3075b0e2e76ec0d Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 9 Mar 2025 17:32:39 -0400
Subject: [PATCH 146/169] Fixed issue #18

---
 zotify/app.py   |  1 +
 zotify/file.py  |  8 ++++++--
 zotify/utils.py | 14 ++++++++++++++
 3 files changed, 21 insertions(+), 2 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index aacc26d..0cf1da1 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -384,6 +384,7 @@ class App:
                         with Loader("Converting audio..."):
                             file.transcode(
                                 self.__config.audio_format,
+                                self.__config.download_quality,
                                 self.__config.transcode_bitrate,
                                 True,
                                 self.__config.ffmpeg_path,
diff --git a/zotify/file.py b/zotify/file.py
index e0f58c9..f40b47b 100644
--- a/zotify/file.py
+++ b/zotify/file.py
@@ -5,7 +5,7 @@ from subprocess import PIPE, Popen
 from music_tag import load_file
 from mutagen.oggvorbis import OggVorbisHeaderError
 
-from zotify.utils import AudioFormat, MetadataEntry
+from zotify.utils import AudioFormat, MetadataEntry, Quality
 
 
 class TranscodingError(RuntimeError): ...
@@ -25,6 +25,7 @@ class LocalFile:
     def transcode(
         self,
         audio_format: AudioFormat | None = None,
+        download_quality: Quality | None = None,
         bitrate: int = -1,
         replace: bool = False,
         ffmpeg: str = "",
@@ -63,7 +64,10 @@ class LocalFile:
                 f"Cannot overwrite source, target file {path} already exists."
             )
 
-        cmd.extend(["-b:a", str(bitrate) + "k"]) if bitrate > 0 else None
+        if bitrate > 0:
+            cmd.extend(["-b:a", str(bitrate) + "k"])
+        else:
+            cmd.extend(["-b:a", str(Quality.get_bitrate(download_quality)) + "k"])
         cmd.extend(["-c:a", audio_format.value.name]) if audio_format else None
         cmd.extend(opt_args)
         cmd.append(str(path))
diff --git a/zotify/utils.py b/zotify/utils.py
index 3e27590..b263f54 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -59,6 +59,20 @@ class Quality(Enum):
         except Exception:
             return s
 
+    @staticmethod
+    def get_bitrate(quality):
+        match quality:
+            case Quality.NORMAL:
+                bitrate = 96
+            case Quality.HIGH:
+                bitrate = 160
+            case Quality.VERY_HIGH:
+                bitrate = 320
+            case Quality.AUTO:
+                bitrate = 160
+
+        return bitrate
+
 
 class ImageSize(IntEnum):
     SMALL = 0  # 64px

From 638e9f10c60fa6e0a3f4cb502ad18dbb772ec236 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 9 Mar 2025 17:33:30 -0400
Subject: [PATCH 147/169] Version bump

---
 zotify/__main__.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/zotify/__main__.py b/zotify/__main__.py
index 0e1d155..aa99775 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.9"
+VERSION = "0.9.10"
 
 
 def main():

From 0799f3b81b715b651bf67250bdc5507424dc074b Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 9 Mar 2025 17:41:53 -0400
Subject: [PATCH 148/169] Removed writing spotid as metadata when an already
 existing file is detected during output file creation

---
 zotify/playable.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index a009514..d3250bc 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -89,8 +89,6 @@ class Playable:
         file_path = library.joinpath(output).expanduser()
         check_path = Path(f"{file_path}.{ext}")
         if check_path.exists() and not replace:
-            f = LocalFile(check_path)
-            f.write_metadata(self.metadata)
             raise FileExistsError("File already downloaded")
         else:
             file_path.parent.mkdir(parents=True, exist_ok=True)

From a5a649c07304b7c399bf81b81de188379dca8cf4 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 9 Mar 2025 20:13:04 -0400
Subject: [PATCH 149/169] Version bump setup.cfg to 0.9.10

---
 setup.cfg | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/setup.cfg b/setup.cfg
index c75463a..2a95814 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = zotify
-version = 0.9.9
+version = 0.9.10
 author = Zotify Contributors
 description = A highly customizable music and podcast downloader
 long_description = file: README.md

From 8a848d69f95ba6d5ff49cae2645ca289f06e0d00 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 21 Mar 2025 23:32:22 -0400
Subject: [PATCH 150/169] Fixed issue #26

---
 zotify/collections.py | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/zotify/collections.py b/zotify/collections.py
index 2abc269..aade82a 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -57,7 +57,10 @@ class Collection:
             for file in iglob(scan_path):
                 f_path = Path(file)
                 f = LocalFile(f_path)
-                existing[f.get_metadata("spotid")] = f_path.stem
+                try:
+                    existing[f.get_metadata("spotid")] = f_path.stem
+                except IndexError:
+                    pass
 
             for playable in self.playables:
                 if playable.id in existing.keys():
@@ -92,7 +95,10 @@ class Collection:
                 if self.path.exists() and f_path.match(collection_path):
                     continue
                 f = LocalFile(f_path)
-                existing[f.get_metadata("spotid")] = f_path.stem
+                try:
+                    existing[f.get_metadata("spotid")] = f_path.stem
+                except IndexError:
+                    pass
 
             for playable in self.playables:
                 if playable.id in existing.keys():

From 85c1b9c1e49a58cb5444a48b28d761aab46c08d9 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Fri, 21 Mar 2025 23:39:57 -0400
Subject: [PATCH 151/169] Fixed issue #23

---
 zotify/playable.py | 77 ++++++++++------------------------------------
 1 file changed, 16 insertions(+), 61 deletions(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index d3250bc..dfd46ff 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -95,7 +95,10 @@ class Playable:
             return file_path
 
     def write_audio_stream(
-        self, output: Path | str, p_bar: tqdm = tqdm(disable=True)
+        self,
+        output: Path | str,
+        p_bar: tqdm = tqdm(disable=True),
+        real_time: bool = False,
     ) -> LocalFile:
         """
         Writes audio stream to file
@@ -107,12 +110,24 @@ class Playable:
         """
         if not isinstance(output, Path):
             output = Path(output).expanduser()
+
         file = f"{output}.ogg"
+        time_start = time()
+        downloaded = 0
+
         with open(file, "wb") as f, p_bar as p_bar:
             chunk = None
             while chunk != b"":
                 chunk = self.input_stream.stream().read(1024)
                 p_bar.update(f.write(chunk))
+                if real_time:
+                    downloaded += len(chunk)
+                    delta_current = time() - time_start
+                    delta_required = (downloaded / self.input_stream.size) * (
+                        self.duration / 1000
+                    )
+                    if delta_required > delta_current:
+                        sleep(delta_required - delta_current)
         return LocalFile(Path(file), AudioFormat.VORBIS)
 
     def get_cover_art(self, size: ImageSize = ImageSize.LARGE) -> bytes:
@@ -200,42 +215,6 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             )
             return self.__lyrics
 
-    def write_audio_stream(
-        self,
-        output: Path | str,
-        p_bar: tqdm = tqdm(disable=True),
-        real_time: bool = False,
-    ) -> LocalFile:
-        """
-        Writes audio stream to file
-        Args:
-            output: File path of saved audio stream
-            p_bar: tqdm progress bar
-            real_time: Enable delay to emulate real time streaming
-        Returns:
-            LocalFile object
-        """
-        if not isinstance(output, Path):
-            output = Path(output).expanduser()
-        if not real_time:
-            return super().write_audio_stream(output)
-        file = f"{output}.ogg"
-        time_start = time()
-        downloaded = 0
-        with open(file, "wb") as f, p_bar as p_bar:
-            chunk = None
-            while chunk != b"":
-                chunk = self.input_stream.stream().read(1024)
-                p_bar.update(f.write(chunk))
-                downloaded += len(chunk)
-                delta_current = time() - time_start
-                delta_required = (downloaded / self.input_stream.size) * (
-                    self.duration / 1000
-                )
-                if delta_required > delta_current:
-                    sleep(delta_required - delta_current)
-        return LocalFile(Path(file), AudioFormat.VORBIS)
-
 
 class Episode(PlayableContentFeeder.LoadedStream, Playable):
     def __init__(self, episode: PlayableContentFeeder.LoadedStream, api):
@@ -266,27 +245,3 @@ class Episode(PlayableContentFeeder.LoadedStream, Playable):
             MetadataEntry("date", self.publish_time),
             MetadataEntry("title", self.name),
         ]
-
-    def write_audio_stream(
-        self, output: Path | str, p_bar: tqdm = tqdm(disable=True)
-    ) -> LocalFile:
-        """
-        Writes audio stream to file.
-        Uses external source if available for faster download.
-        Args:
-            output: File path of saved audio stream
-            p_bar: tqdm progress bar
-        Returns:
-            LocalFile object
-        """
-        if not isinstance(output, Path):
-            output = Path(output).expanduser()
-        if not bool(self.external_url):
-            return super().write_audio_stream(output)
-        file = f"{output}.{self.external_url.rsplit('.', 1)[-1]}"
-        with get(self.external_url, stream=True) as r, open(
-            file, "wb"
-        ) as f, p_bar as p_bar:
-            for chunk in r.iter_content(chunk_size=1024):
-                p_bar.update(f.write(chunk))
-        return LocalFile(Path(file))

From c5032d8ec066408f6529508e2781acefd66cc7da Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sat, 22 Mar 2025 11:13:45 -0400
Subject: [PATCH 152/169] Fixed issue #28

---
 zotify/app.py   | 3 +++
 zotify/utils.py | 6 ++----
 2 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/zotify/app.py b/zotify/app.py
index 0cf1da1..441cee8 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -252,6 +252,9 @@ class App:
         return collections
 
     def scan(self, collections: list[Collection], match: bool):
+        if self.__config.replace_existing:
+            return
+
         if match:
             for collection in collections:
                 collection.get_match()
diff --git a/zotify/utils.py b/zotify/utils.py
index b263f54..0de959d 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -1,4 +1,4 @@
-from argparse import Action, ArgumentError
+from argparse import Action
 from enum import Enum, IntEnum
 from pathlib import Path
 from re import IGNORECASE, sub
@@ -146,7 +146,7 @@ class OptionalOrFalse(Action):
         self,
         option_strings,
         dest,
-        nargs="?",
+        nargs=0,
         default=None,
         type=None,
         choices=None,
@@ -175,8 +175,6 @@ class OptionalOrFalse(Action):
         )
 
     def __call__(self, parser, namespace, values, option_string=None):
-        if values is not None:
-            raise ArgumentError(self, "expected 0 arguments")
         setattr(
             namespace,
             self.dest,

From 2d04278ed7b2ce9ad563816357e5a8db439c8a95 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sat, 22 Mar 2025 12:02:39 -0400
Subject: [PATCH 153/169] Fixed issue #29

---
 zotify/collections.py | 29 +++++++++++++++++++++++------
 1 file changed, 23 insertions(+), 6 deletions(-)

diff --git a/zotify/collections.py b/zotify/collections.py
index aade82a..5875aae 100644
--- a/zotify/collections.py
+++ b/zotify/collections.py
@@ -42,7 +42,10 @@ class Collection:
                     "{" + meta.name + "}", fix_filename(meta.string)
                 )
 
-        self.path = library.joinpath(output).expanduser().parent
+        if type(self) is Track or type(self) is Episode:
+            self.path = library
+        else:
+            self.path = library.joinpath(output).expanduser().parent
 
     def get_existing(self, ext: str) -> dict[str, str]:
         existing: dict[str, str] = {}
@@ -50,11 +53,14 @@ class Collection:
         if self.path is None:
             self.set_path()
         if self.path.exists():
-            file_path = "*.{}".format(ext)
+            if type(self) is Track or type(self) is Episode:
+                file_path = "**/*.{}".format(ext)
+            else:
+                file_path = "*.{}".format(ext)
             scan_path = str(self.path.joinpath(file_path))
 
             # Check contents of path
-            for file in iglob(scan_path):
+            for file in iglob(scan_path, recursive=True):
                 f_path = Path(file)
                 f = LocalFile(f_path)
                 try:
@@ -190,7 +196,11 @@ class Album(Collection):
         album = api.get_metadata_4_album(AlbumId.from_base62(b62_id))
         for disc in album.disc:
             for track in disc.track:
-                metadata = [MetadataEntry("spotid", bytes_to_base62(track.gid))]
+                metadata = [
+                    MetadataEntry("spotid", bytes_to_base62(track.gid)),
+                    MetadataEntry("album_artist", album.artist[0].name),
+                    MetadataEntry("album", album.name),
+                ]
                 self.playables.append(
                     PlayableData(
                         PlayableType.TRACK,
@@ -217,7 +227,11 @@ class Artist(Collection):
             )
             for disc in album.disc:
                 for track in disc.track:
-                    metadata = [MetadataEntry("spotid", bytes_to_base62(track.gid))]
+                    metadata = [
+                        MetadataEntry("spotid", bytes_to_base62(track.gid)),
+                        MetadataEntry("album_artist", album.artist[0].name),
+                        MetadataEntry("album", album.name),
+                    ]
                     self.playables.append(
                         PlayableData(
                             PlayableType.TRACK,
@@ -234,7 +248,10 @@ class Show(Collection):
         super().__init__(api)
         show = api.get_metadata_4_show(ShowId.from_base62(b62_id))
         for episode in show.episode:
-            metadata = [MetadataEntry("spotid", bytes_to_base62(episode.gid))]
+            metadata = [
+                MetadataEntry("spotid", bytes_to_base62(episode.gid)),
+                MetadataEntry("podcast", show.name),
+            ]
             self.playables.append(
                 PlayableData(
                     PlayableType.EPISODE,

From 0b6e46a9d5749585d7d3d570860a041948225c5d Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sat, 22 Mar 2025 12:37:32 -0400
Subject: [PATCH 154/169] Fixed issue #19

---
 zotify/playable.py | 15 ++++++++++++---
 1 file changed, 12 insertions(+), 3 deletions(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index dfd46ff..d1dfc80 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -86,13 +86,22 @@ class Playable:
                 output = output.replace(
                     "{" + meta.name + "}", fix_filename(meta.string)
                 )
+            if meta.name == "spotid":
+                spotid = meta.string
+
         file_path = library.joinpath(output).expanduser()
         check_path = Path(f"{file_path}.{ext}")
-        if check_path.exists() and not replace:
-            raise FileExistsError("File already downloaded")
+        if check_path.exists():
+            f = LocalFile(check_path)
+            if f.get_metadata("spotid") != spotid:
+                file_path = Path(f"{file_path} (SpotId:{spotid[-5:]})")
+            else:
+                if not replace:
+                    raise FileExistsError("File already downloaded")
         else:
             file_path.parent.mkdir(parents=True, exist_ok=True)
-            return file_path
+
+        return file_path
 
     def write_audio_stream(
         self,

From a9cdd7d9a1b00465984fad833b0371750a785e08 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sat, 22 Mar 2025 13:08:42 -0400
Subject: [PATCH 155/169] Fixed issue #21

---
 zotify/playable.py | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index d1dfc80..b85f1e3 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -82,10 +82,17 @@ class Playable:
         if not isinstance(library, Path):
             library = Path(library)
         for meta in self.metadata:
-            if meta.string is not None:
+            if meta.string is not None and len(meta.string) < 100:
                 output = output.replace(
                     "{" + meta.name + "}", fix_filename(meta.string)
                 )
+            else:
+                if "," in meta.string:
+                    shortened = ",".join(meta.string.split(",", 3)[:3])
+                else:
+                    shortened = f"{meta.string[:50]}..."
+                output = output.replace("{" + meta.name + "}", fix_filename(shortened))
+
             if meta.name == "spotid":
                 spotid = meta.string
 

From 4cd4b35f63f35b2456948ac879dbdbce12826a84 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sat, 22 Mar 2025 13:12:42 -0400
Subject: [PATCH 156/169] Version bump to 0.9.11

---
 setup.cfg          | 2 +-
 zotify/__main__.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/setup.cfg b/setup.cfg
index 2a95814..ad656ad 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = zotify
-version = 0.9.10
+version = 0.9.11
 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 aa99775..88f4a87 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.10"
+VERSION = "0.9.11"
 
 
 def main():

From 16f3e6c38edf4a93f6166ca53368d14f3bc8f69a Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sat, 22 Mar 2025 13:38:09 -0400
Subject: [PATCH 157/169] Update -m flag details

---
 CHANGELOG.md | 3 ++-
 README.md    | 2 +-
 2 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b75a066..3653af9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,8 @@
 
 - Added `--download-real-time` feature back.
 - Added implementation for `--skip-previous` as it was included as a config parameter but was unimplemented.
-- Added implementation for `--skip-duplicates` as it was included as a config parameter but was unimplemented. Note that tracks must have the trackid metadata for this to work. TBA: Procedure to add trackid metadata for tracks without it
+- Added implementation for `--skip-duplicates` as it was included as a config parameter but was unimplemented. Note that tracks must have the trackid metadata for this to work.
+- Added `-m` or `--match` flag to match output track filenames to files already existing in the same playlist/album folder and write their corresponding trackid metadata
 
 ### Removals
 
diff --git a/README.md b/README.md
index 0069ee3..fadd0eb 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep
 
 ### Compatibility with official version
 
-Do note that `--skip-previous` and `--skip-duplicates` won't immediately work with playlists and albums downloaded using the official version (both dev and main branches). To make the playlist/album compatible with this fork such that `--skip-previous` and `--skip-duplicates` will both work, simply add the `-m` or `--match` flag to the download command.
+Do note that `--skip-previous` and `--skip-duplicates` won't immediately work with playlists and albums downloaded using the official version (both dev and main branches). To make the playlist/album compatible with this fork such that `--skip-previous` and `--skip-duplicates` will both work, simply add the `-m` or `--match` flag to the download command. This will try to match filenames present in the library to ones that are to be downloaded. Note that output formats should match between the current download command and the existing files.
 
 For example:
 ```

From cf06b40afc75a1ae611168ecb23cb3c51a97dbf1 Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sat, 22 Mar 2025 16:11:37 -0400
Subject: [PATCH 158/169] Fixed issue #19: Added try/except clause on previous
 fix when getting track id metadata

---
 zotify/playable.py | 10 +++++++++-
 1 file changed, 9 insertions(+), 1 deletion(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index b85f1e3..338662a 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -100,11 +100,19 @@ class Playable:
         check_path = Path(f"{file_path}.{ext}")
         if check_path.exists():
             f = LocalFile(check_path)
-            if f.get_metadata("spotid") != spotid:
+            f_spotid = None
+
+            try:
+                f_spotid = f.get_metadata("spotid")
+            except IndexError:
+                pass
+
+            if f_spotid != spotid:
                 file_path = Path(f"{file_path} (SpotId:{spotid[-5:]})")
             else:
                 if not replace:
                     raise FileExistsError("File already downloaded")
+
         else:
             file_path.parent.mkdir(parents=True, exist_ok=True)
 

From 83f56b0cacbbd6274d3c9abdf10d42bfd4a49dcd Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 23 Mar 2025 14:16:14 -0400
Subject: [PATCH 159/169] Fixed issue #13

---
 zotify/__init__.py |  10 +++-
 zotify/app.py      | 134 ++++++++++++++++++++++++++++++++-------------
 2 files changed, 103 insertions(+), 41 deletions(-)

diff --git a/zotify/__init__.py b/zotify/__init__.py
index 2a71991..98941dc 100644
--- a/zotify/__init__.py
+++ b/zotify/__init__.py
@@ -252,6 +252,7 @@ class ApiClient(LibrespotApiClient):
         params: dict[str, Any] = {},
         limit: int = 20,
         offset: int = 0,
+        raw_url: bool = False,
     ) -> dict[str, Any]:
         """
         Requests data from API
@@ -269,10 +270,13 @@ class ApiClient(LibrespotApiClient):
             "Accept-Language": self.__session.language(),
             "app-platform": "WebPlayer",
         }
-        params["limit"] = limit
-        params["offset"] = offset
+        if not raw_url:
+            params["limit"] = limit
+            params["offset"] = offset
 
-        response = get(API_URL + url, headers=headers, params=params)
+            response = get(API_URL + url, headers=headers, params=params)
+        else:
+            response = get(url, headers=headers)
         data = response.json()
 
         try:
diff --git a/zotify/app.py b/zotify/app.py
index 441cee8..1a3cd56 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -44,64 +44,112 @@ class Selection:
             "episode",
         ],
     ) -> list[str]:
+        offset = 0
         categories = ",".join(category)
-        with Loader("Searching..."):
-            country = self.__session.api().invoke_url("me")["country"]
-            resp = self.__session.api().invoke_url(
-                "search",
-                {
-                    "q": search_text,
-                    "type": categories,
-                    "include_external": "audio",
-                    "market": country,
-                },
-                limit=10,
-                offset=0,
-            )
+        ids = []
+        while True:
+            with Loader("Searching..."):
+                country = self.__session.api().invoke_url("me")["country"]
+                resp = self.__session.api().invoke_url(
+                    "search",
+                    {
+                        "q": search_text,
+                        "type": categories,
+                        "include_external": "audio",
+                        "market": country,
+                    },
+                    limit=10,
+                    offset=offset,
+                )
 
-        print(f'Search results for "{search_text}"')
-        count = 0
-        for cat in categories.split(","):
-            label = cat + "s"
-            items = resp[label]["items"]
-            if len(items) > 0:
-                print(f"\n{label.capitalize()}:")
-                try:
-                    self.__print(count, items, *self.__print_labels[cat])
-                except KeyError:
-                    self.__print(count, items, "name")
-                count += len(items)
-                self.__items.extend(items)
-        return self.__get_selection()
+            print(f'Search results for "{search_text}"')
+            count = 0
+            next_page = {}
+            self.__items = []
+            for cat in categories.split(","):
+                label = cat + "s"
+                items = resp[label]["items"]
+                next_page[label] = resp[label]["next"]
+                if len(items) > 0:
+                    print(f"\n{label.capitalize()}:")
+                    try:
+                        self.__print(count, items, *self.__print_labels[cat])
+                    except KeyError:
+                        self.__print(count, items, "name")
+                    count += len(items)
+                    self.__items.extend(items)
+
+            for id in self.__get_selection(allow_empty=True):
+                ids.append(id)
+
+            next_flag = False
+            for page in next_page.values():
+                if page is not None and next_flag is False:
+                    next_flag = True
+                    params = page.split("?", 1)[1]
+                    page_offset = int(params.split("&")[0].split("=")[1])
+                    offset = page_offset
+                    break
+
+            if not next_flag:
+                break
+
+            get_next = self.__get_next_prompt()
+            if get_next.lower() == "n":
+                break
+
+        return ids
 
     def get(self, category: str, name: str = "", content: str = "") -> list[str]:
         with Loader("Fetching items..."):
             r = self.__session.api().invoke_url(f"me/{category}", limit=50)
+
+        ids = []
+        while True:
             if content != "":
                 r = r[content]
             resp = r["items"]
 
-        for i in range(len(resp)):
-            try:
-                item = resp[i][name]
-            except KeyError:
-                item = resp[i]
-            self.__items.append(item)
-            print(
-                "{:<2} {:<38}".format(i + 1, self.__fix_string_length(item["name"], 38))
-            )
-        return self.__get_selection()
+            self.__items = []
+            for i in range(len(resp)):
+                try:
+                    item = resp[i][name]
+                except KeyError:
+                    item = resp[i]
+                self.__items.append(item)
+                print(
+                    "{:<2} {:<38}".format(
+                        i + 1, self.__fix_string_length(item["name"], 38)
+                    )
+                )
+
+            for id in self.__get_selection():
+                ids.append(id)
+
+            if r["next"] is None:
+                break
+
+            get_next = self.__get_next_prompt()
+            if get_next.lower() == "n":
+                break
+
+            with Loader("Fetching items..."):
+                r = self.__session.api().invoke_url(r["next"], raw_url=True)
+
+        return ids
 
     @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()]
 
-    def __get_selection(self) -> list[str]:
+    def __get_selection(self, allow_empty: bool = False) -> list[str]:
         print("\nResults to save (eg: 1,2,5 1-3)")
         selection = ""
         while len(selection) == 0:
             selection = input("==> ")
+            if len(selection) == 0 and allow_empty:
+                return []
         ids = []
         selections = selection.split(",")
         for i in selections:
@@ -149,6 +197,16 @@ class Selection:
             return text[: max_length - 3] + "..."
         return text
 
+    def __get_next_prompt(self) -> str:
+        print("\nGet next page? Y/n")
+        get_next = None
+        while get_next not in ["Y", "y", "N", "n"]:
+            get_next = input("==> ")
+            if len(get_next) == 0:
+                get_next = "y"
+
+        return get_next
+
 
 class App:
     def __init__(self, args: Namespace):

From 908904cffc6550711fbc799594ddcc0c33bf8a1b Mon Sep 17 00:00:00 2001
From: DraftKinner <196864209+DraftKinner@users.noreply.github.com>
Date: Sun, 23 Mar 2025 15:43:19 -0400
Subject: [PATCH 160/169] Fixed issue #1

---
 zotify/app.py      |  3 +++
 zotify/file.py     | 10 ++++++++++
 zotify/playable.py |  2 +-
 3 files changed, 14 insertions(+), 1 deletion(-)

diff --git a/zotify/app.py b/zotify/app.py
index 1a3cd56..674e999 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -462,6 +462,9 @@ class App:
                             track.get_cover_art(self.__config.artwork_size)
                         )
 
+                # Remove temp filename
+                file.clean_filename()
+
                 # Reset rate limit counter for every successful download
                 self.rate_limit_hits = 0
 
diff --git a/zotify/file.py b/zotify/file.py
index f40b47b..9a4f96e 100644
--- a/zotify/file.py
+++ b/zotify/file.py
@@ -130,3 +130,13 @@ class LocalFile:
         """
         f = load_file(self.__path)
         return f[tag].value
+
+    def clean_filename(self) -> None:
+        """
+        Removes tmp suffix on filename
+        Args:
+            None
+        """
+        path = self.__path
+        clean = path.name.replace("_tmp", "")
+        path.rename(path.parent.joinpath(clean))
diff --git a/zotify/playable.py b/zotify/playable.py
index 338662a..94d2030 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -135,7 +135,7 @@ class Playable:
         if not isinstance(output, Path):
             output = Path(output).expanduser()
 
-        file = f"{output}.ogg"
+        file = f"{output}_tmp.ogg"
         time_start = time()
         downloaded = 0
 

From e5d6546d504db79e3eeb17589efbd05bd0921cb9 Mon Sep 17 00:00:00 2001
From: Diamond75 <diamond75@noreply.localhost>
Date: Fri, 4 Apr 2025 20:57:03 +1300
Subject: [PATCH 161/169] Update zotify/config.py

---
 zotify/config.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/zotify/config.py b/zotify/config.py
index f2b9e1c..3a5757f 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -181,7 +181,7 @@ CONFIG_VALUES = {
         "help": "Language for metadata",
     },
     LYRICS_FILE: {
-        "default": False,
+        "default": True,
         "type": bool,
         "args": ["--lyrics-file"],
         "help": "Save lyrics to a file",

From 2ce4537d9b53cf82ef97369405d0c11e0e77bab5 Mon Sep 17 00:00:00 2001
From: Diamond75 <diamond75@noreply.localhost>
Date: Fri, 4 Apr 2025 21:02:48 +1300
Subject: [PATCH 162/169] revert e5d6546d504db79e3eeb17589efbd05bd0921cb9

revert Update zotify/config.py
---
 zotify/config.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/zotify/config.py b/zotify/config.py
index 3a5757f..f2b9e1c 100644
--- a/zotify/config.py
+++ b/zotify/config.py
@@ -181,7 +181,7 @@ CONFIG_VALUES = {
         "help": "Language for metadata",
     },
     LYRICS_FILE: {
-        "default": True,
+        "default": False,
         "type": bool,
         "args": ["--lyrics-file"],
         "help": "Save lyrics to a file",

From fe1ee92caa3ffbe96d50e1ad081ed6defb7aa4ff Mon Sep 17 00:00:00 2001
From: diamond75 <diamondcreeper@diamondcreeper.org>
Date: Fri, 4 Apr 2025 21:28:07 +1300
Subject: [PATCH 163/169] Fix lyrics function

---
 zotify/app.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/zotify/app.py b/zotify/app.py
index 674e999..4dcea04 100644
--- a/zotify/app.py
+++ b/zotify/app.py
@@ -429,7 +429,7 @@ class App:
                     else:
                         with Loader("Fetching lyrics..."):
                             try:
-                                track.lyrics().save(output)
+                                track.get_lyrics().save(output)
                             except FileNotFoundError as e:
                                 Logger.log(LogChannel.SKIPS, str(e))
                 Logger.log(

From 1db1e8b602d8e760a5af162c68b19e54b27aab2a Mon Sep 17 00:00:00 2001
From: diamond75 <diamondcreeper@diamondcreeper.org>
Date: Fri, 4 Apr 2025 21:53:48 +1300
Subject: [PATCH 164/169] Change links in playable.py and edited the
 bytes_to_base62 function in utils.py

---
 zotify/playable.py | 4 ++--
 zotify/utils.py    | 5 +++--
 2 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index 94d2030..eae531a 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -19,8 +19,8 @@ from zotify.utils import (
     fix_filename,
 )
 
-IMG_URL = "https://i.s" + "cdn.co/image/"
-LYRICS_URL = "https://sp" + "client.wg.sp" + "otify.com/color-lyrics/v2/track/"
+IMG_URL = "https://i.scdn.co/image/"
+LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"
 
 
 class Lyrics:
diff --git a/zotify/utils.py b/zotify/utils.py
index 0de959d..5780704 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -208,7 +208,7 @@ def fix_filename(
     return sub(regex, substitute, str(filename), flags=IGNORECASE)
 
 
-def bytes_to_base62(id: bytes) -> str:
+def bytes_to_base62(idstr: bytes) -> str:
     """
     Converts bytes to base62
     Args:
@@ -216,4 +216,5 @@ def bytes_to_base62(id: bytes) -> str:
     Returns:
         base62
     """
-    return BASE62.encode(id, 22).decode()
+    print (idstr)
+    return BASE62.encode(idstr, 22).decode()

From eeb340554e64172eef1805dc7235810eb753b392 Mon Sep 17 00:00:00 2001
From: diamond75 <diamondcreeper@diamondcreeper.org>
Date: Fri, 4 Apr 2025 22:22:21 +1300
Subject: [PATCH 165/169] Updated get_lyrics function in playable.py

---
 zotify/playable.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index eae531a..c13627d 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -233,7 +233,7 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
             return self.__lyrics
         except AttributeError:
             self.__lyrics = Lyrics(
-                self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.gid))[
+                self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.hex_id()))[
                     "lyrics"
                 ]
             )

From 3520939452b0c1b4bdd916c2d6c39cdaefbe3b57 Mon Sep 17 00:00:00 2001
From: diamond75 <diamondcreeper@diamondcreeper.org>
Date: Fri, 4 Apr 2025 22:26:22 +1300
Subject: [PATCH 166/169] Update.

---
 zotify/playable.py | 3 ++-
 zotify/utils.py    | 4 +++-
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index c13627d..d7ce046 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -232,8 +232,9 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
         try:
             return self.__lyrics
         except AttributeError:
+            print(self.track)
             self.__lyrics = Lyrics(
-                self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.hex_id()))[
+                self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.gid))[
                     "lyrics"
                 ]
             )
diff --git a/zotify/utils.py b/zotify/utils.py
index 5780704..4d4fe9b 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -1,3 +1,5 @@
+import binascii
+
 from argparse import Action
 from enum import Enum, IntEnum
 from pathlib import Path
@@ -216,5 +218,5 @@ def bytes_to_base62(idstr: bytes) -> str:
     Returns:
         base62
     """
-    print (idstr)
+    print (binascii.hexlify(idstr))
     return BASE62.encode(idstr, 22).decode()

From 8251d8bc913486b93baa47bf0d89e22c525a7567 Mon Sep 17 00:00:00 2001
From: diamond75 <diamondcreeper@diamondcreeper.org>
Date: Fri, 4 Apr 2025 22:31:25 +1300
Subject: [PATCH 167/169] update utils.py

---
 zotify/utils.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/zotify/utils.py b/zotify/utils.py
index 4d4fe9b..d9fb226 100644
--- a/zotify/utils.py
+++ b/zotify/utils.py
@@ -218,5 +218,7 @@ def bytes_to_base62(idstr: bytes) -> str:
     Returns:
         base62
     """
-    print (binascii.hexlify(idstr))
-    return BASE62.encode(idstr, 22).decode()
+    print(binascii.hexlify(idstr))
+    res = BASE62.encode(idstr, 22).decode()
+    print(res)
+    return res

From 6488ab2f7e982d256afb8353fb4dfa07fa95d2fb Mon Sep 17 00:00:00 2001
From: diamond75 <diamondcreeper@diamondcreeper.org>
Date: Fri, 4 Apr 2025 22:38:07 +1300
Subject: [PATCH 168/169] Update playable.py

---
 zotify/playable.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/zotify/playable.py b/zotify/playable.py
index d7ce046..4980caf 100644
--- a/zotify/playable.py
+++ b/zotify/playable.py
@@ -234,7 +234,7 @@ class Track(PlayableContentFeeder.LoadedStream, Playable):
         except AttributeError:
             print(self.track)
             self.__lyrics = Lyrics(
-                self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.gid))[
+                self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.gid), raw_url=True)[
                     "lyrics"
                 ]
             )

From 8432994bedc529894677f8bc9f281cd3fb065278 Mon Sep 17 00:00:00 2001
From: Diamond75 <diamond75@noreply.localhost>
Date: Fri, 11 Apr 2025 13:51:27 +1200
Subject: [PATCH 169/169] Update README.md

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index fadd0eb..8ec98b0 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis.
 (FFmpeg installation instructions available [here](https://github.com/DraftKinner/zotify/blob/main/INSTALLATION.md))
 
 Enter the following command in terminal to install Zotify. \
-`python -m pip install git+https://github.com/DraftKinner/zotify.git`
+`python -m pip install git+https://git.diamondbyte.org/Diamond75/zotify.git`
 
 ## General Usage