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! + + + +# Zotify + +A customizable music and podcast downloader. \ +Formerly ZSpotify. + +Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify). + +## Features +- Save tracks at up to 320kbps* +- Save to most popular audio formats +- Built in search +- Bulk downloads +- Downloads synced lyrics +- Embedded metadata +- Downloads all audio, metadata and lyrics directly, no substituting from other services. + +*Non-premium accounts are limited to 160kbps + +## Installation +Requires Python 3.9 or greater. \ +Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis. + +Enter the following command in terminal to install Zotify. \ +`python -m pip install https://get.zotify.xyz` + +## General Usage + +### Simplest usage +Downloads specified items. Accepts any combination of track, album, playlist, episode or artists, URLs or URIs. \ +`zotify <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 Spotify user guidelines and may get your account suspended. + +Zotify is intended to be used in compliance with DMCA, Section 1201, for educational, private and fair use, or any simlar laws in other regions. \ +Zotify contributors cannot be held liable for damages caused by the use of this tool. See the [LICENSE](./LICENCE) file for more details. + +## Acknowledgements +- [Librespot-Python](https://github.com/kokarare1212/librespot-python) does most of the heavy lifting, it's used for authentication, fetching track data, and audio streaming. +- [music-tag](https://github.com/KristoforMaynard/music-tag) is used for writing metadata into the downloaded files. +- [FFmpeg](https://ffmpeg.org/) is used for transcoding audio. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b61373e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e53101d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +librespot@https://github.com/kokarare1212/librespot-python/archive/refs/heads/main.zip +music-tag +mutagen +Pillow +pwinput +requests +tqdm diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..c7740c9 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,5 @@ +black +flake8 +mypy +pre-commit +types-requests diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..85f442c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,39 @@ +[metadata] +name = zotify +version = 0.9.0 +author = Zotify Contributors +description = A highly customizable music and podcast downloader +long_description = file: README.md +long_description_content_type = text/markdown +keywords = python, music, podcast, downloader +licence = Zlib +classifiers = + Programming Language :: Python :: 3 + License :: OSI Approved :: zlib/libpng License + Operating System :: POSIX :: Linux + Operating System :: Microsoft :: Windows + Operating System :: MacOS + Topic :: Multimedia :: Sound/Audio + +[options] +packages = zotify +python_requires = >=3.9 +install_requires = + librespot@https://github.com/kokarare1212/librespot-python/archive/refs/heads/main.zip + music-tag + mutagen + Pillow + pwinput + requests + tqdm + +[options.entry_points] +console_scripts = + zotify = zotify.__main__:main + +[flake8] +# Conflicts with black +ignore = E203, W503 +max-line-length = 160 +per-file-ignores = + zotify/file.py: E701 diff --git a/zotify/__init__.py b/zotify/__init__.py new file mode 100644 index 0000000..fb54b90 --- /dev/null +++ b/zotify/__init__.py @@ -0,0 +1,184 @@ +from pathlib import Path + +from librespot.audio.decoders import VorbisOnlyAudioQuality +from librespot.core import ( + ApiClient, + PlayableContentFeeder, + Session as LibrespotSession, +) +from librespot.metadata import EpisodeId, PlayableId, TrackId +from pwinput import pwinput +from requests import HTTPError, get + +from zotify.playable import Episode, Track +from zotify.utils import ( + API_URL, + Quality, +) + + +class Api(ApiClient): + def __init__(self, session: LibrespotSession, language: str = "en"): + super(Api, self).__init__(session) + self.__session = session + self.__language = language + + def __get_token(self) -> str: + """Returns user's API token""" + return ( + self.__session.tokens() + .get_token( + "playlist-read-private", # Private playlists + "user-follow-read", # Followed artists + "user-library-read", # Liked tracks/episodes/etc. + "user-read-private", # Country + ) + .access_token + ) + + def invoke_url( + self, + url: str, + params: dict = {}, + limit: int | None = None, + offset: int | None = None, + ) -> dict: + """ + Requests data from api + Args: + url: API url and to get data from + params: parameters to be sent in the request + limit: The maximum number of items in the response + offset: The offset of the items returned + Returns: + Dictionary representation of json response + """ + headers = { + "Authorization": f"Bearer {self.__get_token()}", + "Accept": "application/json", + "Accept-Language": self.__language, + "app-platform": "WebPlayer", + } + if limit: + params["limit"] = limit + if offset: + params["offset"] = offset + + response = get(url, headers=headers, params=params) + data = response.json() + + try: + raise HTTPError( + f"{url}\nAPI Error {data['error']['status']}: {data['error']['message']}" + ) + except KeyError: + return data + + +class Session: + __api: Api + __country: str + __is_premium: bool + __session: LibrespotSession + + def __init__( + self, + cred_file: Path | None = None, + username: str | None = None, + password: str | None = None, + save: bool | None = False, + language: str = "en", + ) -> None: + """ + Authenticates user, saves credentials to a file + and generates api token + Args: + cred_file: Path to the credentials file + username: Account username + password: Account password + save: Save given credentials to a file + """ + # Find an existing credentials file + if cred_file is not None and cred_file.is_file(): + conf = ( + LibrespotSession.Configuration.Builder() + .set_store_credentials(False) + .build() + ) + self.__session = ( + LibrespotSession.Builder(conf).stored_file(str(cred_file)).create() + ) + # Otherwise get new credentials + else: + username = input("Username: ") if username is None else username + password = ( + pwinput(prompt="Password: ", mask="*") if password is None else password + ) + + # Save credentials to file + if save and cred_file: + cred_file.parent.mkdir(parents=True, exist_ok=True) + conf = ( + LibrespotSession.Configuration.Builder() + .set_stored_credential_file(str(cred_file)) + .build() + ) + else: + conf = ( + LibrespotSession.Configuration.Builder() + .set_store_credentials(False) + .build() + ) + self.__session = ( + LibrespotSession.Builder(conf).user_pass(username, password).create() + ) + self.__api = Api(self.__session, language) + + def __get_playable( + self, playable_id: PlayableId, quality: Quality + ) -> PlayableContentFeeder.LoadedStream: + if quality.value is None: + quality = Quality.VERY_HIGH if self.is_premium() else Quality.HIGH + return self.__session.content_feeder().load( + playable_id, + VorbisOnlyAudioQuality(quality.value), + False, + None, + ) + + def get_track(self, track_id: TrackId, quality: Quality = Quality.AUTO) -> Track: + """ + Gets track/episode data and audio stream + Args: + track_id: Base62 ID of track + quality: Audio quality of track when downloaded + Returns: + Track object + """ + return Track(self.__get_playable(track_id, quality), self.api()) + + def get_episode(self, episode_id: EpisodeId) -> Episode: + """ + Gets track/episode data and audio stream + Args: + episode: Base62 ID of episode + Returns: + Episode object + """ + return Episode(self.__get_playable(episode_id, Quality.NORMAL), self.api()) + + def api(self) -> ApiClient: + """Returns API Client""" + return self.__api + + def country(self) -> str: + """Returns two letter country code of user's account""" + try: + return self.__country + except AttributeError: + self.__country = self.api().invoke_url(API_URL + "me")["country"] + return self.__country + + def is_premium(self) -> bool: + """Returns users premium account status""" + return self.__session.get_user_attribute("type") == "premium" diff --git a/zotify/__main__.py b/zotify/__main__.py new file mode 100644 index 0000000..0fce19d --- /dev/null +++ b/zotify/__main__.py @@ -0,0 +1,135 @@ +#! /usr/bin/env python3 + +from argparse import ArgumentParser +from pathlib import Path + +from zotify.app import client +from zotify.config import CONFIG_PATHS, CONFIG_VALUES +from zotify.utils import OptionalOrFalse + +VERSION = "0.9.0" + + +def main(): + parser = ArgumentParser( + prog="zotify", + description="A fast and customizable music and podcast downloader", + ) + parser.add_argument( + "-v", + "--version", + action="store_true", + help="Print version and exit", + ) + parser.add_argument( + "--config", + type=Path, + default=CONFIG_PATHS["conf"], + help="Specify the config.json location", + ) + parser.add_argument( + "-l", + "--library", + type=Path, + help="Specify a path to the root of a music/podcast library", + ) + parser.add_argument( + "-o", "--output", type=str, help="Specify the output location/format" + ) + parser.add_argument( + "-c", + "--category", + type=str, + choices=["album", "artist", "playlist", "track", "show", "episode"], + default=["album", "artist", "playlist", "track", "show", "episode"], + nargs="+", + help="Searches for only this type", + ) + parser.add_argument("--username", type=str, help="Account username") + parser.add_argument("--password", type=str, help="Account password") + group = parser.add_mutually_exclusive_group(required=False) + group.add_argument( + "urls", + type=str, + default="", + nargs="*", + help="Downloads the track, album, playlist, podcast, episode or artist from a URL or URI. Accepts multiple options.", + ) + group.add_argument( + "-d", + "--download", + type=str, + help="Downloads tracks, playlists and albums from the URLs written in the file passed.", + ) + group.add_argument( + "-f", + "--followed", + action="store_true", + help="Download all songs from your followed artists.", + ) + group.add_argument( + "-lt", + "--liked-tracks", + action="store_true", + help="Download all of your liked songs.", + ) + group.add_argument( + "-le", + "--liked-episodes", + action="store_true", + help="Download all of your liked episodes.", + ) + group.add_argument( + "-p", + "--playlist", + action="store_true", + help="Download a saved playlists from your account.", + ) + group.add_argument( + "-s", + "--search", + type=str, + nargs="+", + help="Search for a specific track, album, playlist, artist or podcast", + ) + + for k, v in CONFIG_VALUES.items(): + if v["type"] == bool: + parser.add_argument( + v["arg"], + action=OptionalOrFalse, + default=v["default"], + help=v["help"], + ) + else: + try: + parser.add_argument( + v["arg"], + type=v["type"], + choices=v["choices"], + default=None, + help=v["help"], + ) + except KeyError: + parser.add_argument( + v["arg"], + type=v["type"], + default=None, + help=v["help"], + ) + + parser.set_defaults(func=client) + args = parser.parse_args() + if args.version: + print(VERSION) + return + args.func(args) + return + try: + args.func(args) + except Exception as e: + print(f"Fatal Error: {e}") + + +if __name__ == "__main__": + main() diff --git a/zotify/app.py b/zotify/app.py new file mode 100644 index 0000000..431c60d --- /dev/null +++ b/zotify/app.py @@ -0,0 +1,336 @@ +from argparse import Namespace +from enum import Enum +from pathlib import Path +from typing import Any, NamedTuple + +from librespot.metadata import ( + AlbumId, + ArtistId, + EpisodeId, + PlayableId, + PlaylistId, + ShowId, + TrackId, +) +from librespot.util import bytes_to_hex + +from zotify import Session +from zotify.config import Config +from zotify.file import TranscodingError +from zotify.loader import Loader +from zotify.printer import Printer, PrintChannel +from zotify.utils import API_URL, AudioFormat, b62_to_hex + + +def client(args: Namespace) -> None: + config = Config(args) + Printer(config) + with Loader("Logging in..."): + if config.credentials is False: + session = Session() + else: + session = Session( + cred_file=config.credentials, save=True, language=config.language + ) + selection = Selection(session) + + try: + if args.search: + ids = selection.search(args.search, args.category) + elif args.playlist: + ids = selection.get("playlists", "items") + elif args.followed: + ids = selection.get("following?type=artist", "artists") + elif args.liked_tracks: + ids = selection.get("tracks", "items") + elif args.liked_episodes: + ids = selection.get("episodes", "items") + elif args.download: + ids = [] + for x in args.download: + ids.extend(selection.from_file(x)) + elif args.urls: + ids = args.urls + except (FileNotFoundError, ValueError): + Printer.print(PrintChannel.WARNINGS, "there is nothing to do") + return + + app = App(config, session) + with Loader("Parsing input..."): + try: + app.parse(ids) + except (IndexError, TypeError) as e: + Printer.print(PrintChannel.ERRORS, str(e)) + app.download() + + +class Selection: + def __init__(self, session: Session): + self.__session = session + + def search( + self, + search_text: str, + category: list = [ + "track", + "album", + "artist", + "playlist", + "show", + "episode", + ], + ) -> list[str]: + categories = ",".join(category) + resp = self.__session.api().invoke_url( + API_URL + "search", + { + "q": search_text, + "type": categories, + "include_external": "audio", + "market": self.__session.country(), + }, + limit=10, + offset=0, + ) + + count = 0 + links = [] + for c in categories.split(","): + label = c + "s" + if len(resp[label]["items"]) > 0: + print(f"\n### {label.capitalize()} ###") + for item in resp[label]["items"]: + links.append(item) + self.__print(count + 1, item) + count += 1 + return self.__get_selection(links) + + def get(self, item: str, suffix: str) -> list[str]: + resp = self.__session.api().invoke_url(f"{API_URL}me/{item}", limit=50)[suffix] + for i in range(len(resp)): + self.__print(i + 1, resp[i]) + return self.__get_selection(resp) + + @staticmethod + def from_file(file_path: Path) -> list[str]: + with open(file_path, "r", encoding="utf-8") as f: + return [line.strip() for line in f.readlines()] + + @staticmethod + def __get_selection(items: list[dict[str, Any]]) -> list[str]: + print("\nResults to save (eg: 1,2,3 1-3)") + selection = "" + while len(selection) == 0: + selection = input("==> ") + ids = [] + selections = selection.split(",") + for i in selections: + if "-" in i: + split = i.split("-") + for x in range(int(split[0]), int(split[1]) + 1): + ids.append(items[x - 1]["uri"]) + else: + ids.append(items[int(i) - 1]["uri"]) + return ids + + @staticmethod + def __print(i: int, item: dict[str, Any]) -> None: + print("{:<2} {:<77}".format(i, item["name"])) + + +class PlayableType(Enum): + TRACK = "track" + EPISODE = "episode" + + +class PlayableData(NamedTuple): + type: PlayableType + id: PlayableId + library: Path + output: str + + +class App: + __playable_list: list[PlayableData] + + def __init__( + self, + config: Config, + session: Session, + ): + self.__config = config + self.__session = session + self.__playable_list = [] + + def __parse_album(self, hex_id: str) -> None: + album = self.__session.api().get_metadata_4_album(AlbumId.from_hex(hex_id)) + for disc in album.disc: + for track in disc.track: + self.__playable_list.append( + PlayableData( + PlayableType.TRACK, + bytes_to_hex(track.gid), + self.__config.music_library, + self.__config.output_album, + ) + ) + + def __parse_artist(self, hex_id: str) -> None: + artist = self.__session.api().get_metadata_4_artist(ArtistId.from_hex(hex_id)) + for album in artist.album_group + artist.single_group: + album = self.__session.api().get_metadata_4_album( + AlbumId.from_hex(album.gid) + ) + for disc in album.disc: + for track in disc.track: + self.__playable_list.append( + PlayableData( + PlayableType.TRACK, + bytes_to_hex(track.gid), + self.__config.music_library, + self.__config.output_album, + ) + ) + + def __parse_playlist(self, b62_id: str) -> None: + playlist = self.__session.api().get_playlist(PlaylistId(b62_id)) + for item in playlist.contents.items: + split = item.uri.split(":") + playable_type = PlayableType(split[1]) + id_map = {PlayableType.TRACK: TrackId, PlayableType.EPISODE: EpisodeId} + playable_id = id_map[playable_type].from_base62(split[2]) + self.__playable_list.append( + PlayableData( + playable_type, + playable_id, + self.__config.playlist_library, + self.__config.get(f"output_playlist_{playable_type.value}"), + ) + ) + + def __parse_show(self, hex_id: str) -> None: + show = self.__session.api().get_metadata_4_show(ShowId.from_hex(hex_id)) + for episode in show.episode: + self.__playable_list.append( + PlayableData( + PlayableType.EPISODE, + bytes_to_hex(episode.gid), + self.__config.podcast_library, + self.__config.output_podcast, + ) + ) + + def __parse_track(self, hex_id: str) -> None: + self.__playable_list.append( + PlayableData( + PlayableType.TRACK, + TrackId.from_hex(hex_id), + self.__config.music_library, + self.__config.output_album, + ) + ) + + def __parse_episode(self, hex_id: str) -> None: + self.__playable_list.append( + PlayableData( + PlayableType.EPISODE, + EpisodeId.from_hex(hex_id), + self.__config.podcast_library, + self.__config.output_podcast, + ) + ) + + def parse(self, links: list[str]) -> None: + """ + Parses list of selected tracks/playlists/shows/etc... + Args: + links: List of links + """ + for link in links: + link = link.rsplit("?", 1)[0] + try: + split = link.split(link[-23]) + _id = split[-1] + id_type = split[-2] + except IndexError: + raise IndexError(f'Parsing Error: Could not parse "{link}"') + + if id_type == "album": + self.__parse_album(b62_to_hex(_id)) + elif id_type == "artist": + self.__parse_artist(b62_to_hex(_id)) + elif id_type == "playlist": + self.__parse_playlist(_id) + elif id_type == "show": + self.__parse_show(b62_to_hex(_id)) + elif id_type == "track": + self.__parse_track(b62_to_hex(_id)) + elif id_type == "episode": + self.__parse_episode(b62_to_hex(_id)) + else: + raise TypeError(f'Parsing Error: Unknown type "{id_type}"') + + def get_playable_list(self) -> list[PlayableData]: + """Returns list of Playable items""" + return self.__playable_list + + def download(self) -> None: + """Downloads playable to local file""" + for playable in self.__playable_list: + if playable.type == PlayableType.TRACK: + with Loader("Fetching track..."): + track = self.__session.get_track( + playable.id, self.__config.download_quality + ) + elif playable.type == PlayableType.EPISODE: + with Loader("Fetching episode..."): + track = self.__session.get_episode(playable.id) + else: + Printer.print( + PrintChannel.SKIPS, + f'Download Error: Unknown playable content "{playable.type}"', + ) + continue + + try: + output = track.create_output(playable.library, playable.output) + except FileExistsError as e: + Printer.print(PrintChannel.SKIPS, str(e)) + continue + + file = track.write_audio_stream( + output, + self.__config.chunk_size, + ) + if self.__config.save_lyrics: + with Loader("Fetching lyrics..."): + try: + track.get_lyrics().save(output) + except FileNotFoundError as e: + Printer.print(PrintChannel.SKIPS, str(e)) + + Printer.print(PrintChannel.DOWNLOADS, f"\nDownloaded {track.name}") + + if self.__config.audio_format != AudioFormat.VORBIS: + try: + with Loader(PrintChannel.PROGRESS, "Converting audio..."): + file.transcode( + self.__config.audio_format, + self.__config.transcode_bitrate + if self.__config.transcode_bitrate > 0 + else None, + True, + self.__config.ffmpeg_path + if self.__config.ffmpeg_path != "" + else "ffmpeg", + self.__config.ffmpeg_args.split(), + ) + except TranscodingError as e: + Printer.print(PrintChannel.ERRORS, str(e)) + + if self.__config.save_metadata: + with Loader("Writing metadata..."): + file.write_metadata(track.metadata) + file.write_cover_art( + track.get_cover_art(self.__config.artwork_size) + ) diff --git a/zotify/config.py b/zotify/config.py new file mode 100644 index 0000000..295aa2c --- /dev/null +++ b/zotify/config.py @@ -0,0 +1,354 @@ +from argparse import Namespace +from json import dump, load +from pathlib import Path +from sys import platform as PLATFORM +from typing import Any + +from zotify.utils import AudioFormat, ImageSize, Quality + + +ALL_ARTISTS = "all_artists" +ARTWORK_SIZE = "artwork_size" +AUDIO_FORMAT = "audio_format" +CHUNK_SIZE = "chunk_size" +CREATE_PLAYLIST_FILE = "create_playlist_file" +CREDENTIALS = "credentials" +DOWNLOAD_QUALITY = "download_quality" +FFMPEG_ARGS = "ffmpeg_args" +FFMPEG_PATH = "ffmpeg_path" +LANGUAGE = "language" +LYRICS_ONLY = "lyrics_only" +MUSIC_LIBRARY = "music_library" +OUTPUT = "output" +OUTPUT_ALBUM = "output_album" +OUTPUT_PLAYLIST_TRACK = "output_playlist_track" +OUTPUT_PLAYLIST_EPISODE = "output_playlist_episode" +OUTPUT_PODCAST = "output_podcast" +OUTPUT_SINGLE = "output_single" +PATH_ARCHIVE = "path_archive" +PLAYLIST_LIBRARY = "playlist_library" +PODCAST_LIBRARY = "podcast_library" +PRINT_DOWNLOADS = "print_downloads" +PRINT_ERRORS = "print_errors" +PRINT_PROGRESS = "print_progress" +PRINT_SKIPS = "print_skips" +PRINT_WARNINGS = "print_warnings" +REPLACE_EXISTING = "replace_existing" +SAVE_LYRICS = "save_lyrics" +SAVE_METADATA = "save_metadata" +SAVE_SUBTITLES = "save_subtitles" +SKIP_DUPLICATES = "skip_duplicates" +SKIP_PREVIOUS = "skip_previous" +TRANSCODE_BITRATE = "transcode_bitrate" + +SYSTEM_PATHS = { + "win32": Path.home().joinpath("AppData/Roaming/Zotify"), + "linux": Path.home().joinpath(".config/zotify"), + "darwin": Path.home().joinpath("Library/Application Support/Zotify"), +} + +LIBRARY_PATHS = { + "music": Path.home().joinpath("Music/Zotify Music"), + "podcast": Path.home().joinpath("Music/Zotify Podcasts"), + "playlist": Path.home().joinpath("Music/Zotify Playlists"), +} + +CONFIG_PATHS = { + "conf": SYSTEM_PATHS[PLATFORM].joinpath("config.json"), + "creds": SYSTEM_PATHS[PLATFORM].joinpath("credentials.json"), + "archive": SYSTEM_PATHS[PLATFORM].joinpath("track_archive"), +} + +OUTPUT_PATHS = { + "album": "{album_artist}/{album}/{track_number}. {artist} - {title}", + "podcast": "{podcast}/{episode_number} - {title}", + "playlist_track": "{playlist}/{playlist_number}. {artist} - {title}", + "playlist_episode": "{playlist}/{playlist_number}. {episode_number} - {title}", +} + +CONFIG_VALUES = { + CREDENTIALS: { + "default": CONFIG_PATHS["creds"], + "type": Path, + "arg": "--credentials", + "help": "Path to credentials file", + }, + PATH_ARCHIVE: { + "default": CONFIG_PATHS["archive"], + "type": Path, + "arg": "--archive", + "help": "Path to track archive file", + }, + MUSIC_LIBRARY: { + "default": LIBRARY_PATHS["music"], + "type": Path, + "arg": "--music-library", + "help": "Path to root of music library", + }, + PODCAST_LIBRARY: { + "default": LIBRARY_PATHS["podcast"], + "type": Path, + "arg": "--podcast-library", + "help": "Path to root of podcast library", + }, + PLAYLIST_LIBRARY: { + "default": LIBRARY_PATHS["playlist"], + "type": Path, + "arg": "--playlist-library", + "help": "Path to root of playlist library", + }, + OUTPUT_ALBUM: { + "default": OUTPUT_PATHS["album"], + "type": str, + "arg": "--output-album", + "help": "File layout for saved albums", + }, + OUTPUT_PLAYLIST_TRACK: { + "default": OUTPUT_PATHS["playlist_track"], + "type": str, + "arg": "--output-playlist-track", + "help": "File layout for tracks in a playlist", + }, + OUTPUT_PLAYLIST_EPISODE: { + "default": OUTPUT_PATHS["playlist_episode"], + "type": str, + "arg": "--output-playlist-episode", + "help": "File layout for episodes in a playlist", + }, + OUTPUT_PODCAST: { + "default": OUTPUT_PATHS["podcast"], + "type": str, + "arg": "--output-podcast", + "help": "File layout for saved podcasts", + }, + DOWNLOAD_QUALITY: { + "default": "auto", + "type": Quality.from_string, + "choices": list(Quality), + "arg": "--download-quality", + "help": "Audio download quality (auto for highest available)", + }, + ARTWORK_SIZE: { + "default": "large", + "type": ImageSize.from_string, + "choices": list(ImageSize), + "arg": "--artwork-size", + "help": "Image size of track's cover art", + }, + AUDIO_FORMAT: { + "default": "vorbis", + "type": AudioFormat, + "choices": [n.value for n in AudioFormat], + "arg": "--audio-format", + "help": "Audio format of final track output", + }, + TRANSCODE_BITRATE: { + "default": -1, + "type": int, + "arg": "--bitrate", + "help": "Transcoding bitrate (-1 to use download rate)", + }, + FFMPEG_PATH: { + "default": "", + "type": str, + "arg": "--ffmpeg-path", + "help": "Path to ffmpeg binary", + }, + FFMPEG_ARGS: { + "default": "", + "type": str, + "arg": "--ffmpeg-args", + "help": "Additional ffmpeg arguments when transcoding", + }, + SAVE_SUBTITLES: { + "default": False, + "type": bool, + "arg": "--save-subtitles", + "help": "Save subtitles from podcasts to a .srt file", + }, + LANGUAGE: { + "default": "en", + "type": str, + "arg": "--language", + "help": "Language for metadata" + }, + SAVE_LYRICS: { + "default": True, + "type": bool, + "arg": "--save-lyrics", + "help": "Save lyrics to a file", + }, + LYRICS_ONLY: { + "default": False, + "type": bool, + "arg": "--lyrics-only", + "help": "Only download lyrics and not actual audio", + }, + CREATE_PLAYLIST_FILE: { + "default": True, + "type": bool, + "arg": "--playlist-file", + "help": "Save playlist information to an m3u8 file", + }, + SAVE_METADATA: { + "default": True, + "type": bool, + "arg": "--save-metadata", + "help": "Save metadata, required for other metadata options", + }, + ALL_ARTISTS: { + "default": True, + "type": bool, + "arg": "--all-artists", + "help": "Add all track artists to artist tag in metadata", + }, + REPLACE_EXISTING: { + "default": False, + "type": bool, + "arg": "--replace-existing", + "help": "Overwrite existing files with the same name", + }, + SKIP_PREVIOUS: { + "default": True, + "type": bool, + "arg": "--skip-previous", + "help": "Skip previously downloaded songs", + }, + SKIP_DUPLICATES: { + "default": True, + "type": bool, + "arg": "--skip-duplicates", + "help": "Skip downloading existing track to different album", + }, + CHUNK_SIZE: { + "default": 131072, + "type": int, + "arg": "--chunk-size", + "help": "Number of bytes read at a time during download", + }, + PRINT_DOWNLOADS: { + "default": False, + "type": bool, + "arg": "--print-downloads", + "help": "Print messages when a song is finished downloading", + }, + PRINT_PROGRESS: { + "default": True, + "type": bool, + "arg": "--print-progress", + "help": "Show progress bars", + }, + PRINT_SKIPS: { + "default": True, + "type": bool, + "arg": "--print-skips", + "help": "Show messages if a song is being skipped", + }, + PRINT_WARNINGS: { + "default": True, + "type": bool, + "arg": "--print-warnings", + "help": "Show warnings", + }, + PRINT_ERRORS: { + "default": True, + "type": bool, + "arg": "--print-errors", + "help": "Show errors", + }, +} + + +class Config: + __config_file: Path | None + artwork_size: ImageSize + audio_format: AudioFormat + chunk_size: int + credentials: Path + download_quality: Quality + ffmpeg_args: str + ffmpeg_path: str + music_library: Path + language: str + output_album: str + output_liked: str + output_podcast: str + output_playlist_track: str + output_playlist_episode: str + playlist_library: Path + podcast_library: Path + print_progress: bool + save_lyrics: bool + save_metadata: bool + transcode_bitrate: int + + def __init__(self, args: Namespace = Namespace()): + jsonvalues = {} + if args.config: + self.__config_file = Path(args.config) + # Valid config file found + if self.__config_file.exists(): + with open(self.__config_file, "r", encoding="utf-8") as conf: + jsonvalues = load(conf) + # Remove config file and make a new one + else: + self.__config_file.parent.mkdir(parents=True, exist_ok=True) + jsonvalues = {} + for key in CONFIG_VALUES: + if CONFIG_VALUES[key]["type"] in [str, int, bool]: + jsonvalues[key] = CONFIG_VALUES[key]["default"] + else: + jsonvalues[key] = str(CONFIG_VALUES[key]["default"]) + with open(self.__config_file, "w+", encoding="utf-8") as conf: + dump(jsonvalues, conf, indent=4) + + for key in CONFIG_VALUES: + # Override config with commandline arguments + if key in vars(args) and vars(args)[key] is not None: + setattr(self, key, self.__parse_arg_value(key, vars(args)[key])) + # If no command option specified use config + elif key in jsonvalues: + setattr(self, key, self.__parse_arg_value(key, jsonvalues[key])) + # Use default values for missing keys + else: + setattr( + self, + key, + self.__parse_arg_value(key, CONFIG_VALUES[key]["default"]), + ) + else: + self.__config_file = None + + # Make "output" arg override all output_* options + if args.output: + self.output_album = args.output + self.output_liked = args.output + self.output_podcast = args.output + self.output_playlist_track = args.output + self.output_playlist_episode = args.output + + @staticmethod + def __parse_arg_value(key: str, value: Any) -> Any: + config_type = CONFIG_VALUES[key]["type"] + if type(value) == config_type: + return value + elif config_type == Path: + return Path(value).expanduser() + elif config_type == AudioFormat: + return AudioFormat(value) + elif config_type == ImageSize.from_string: + return ImageSize.from_string(value) + elif config_type == Quality.from_string: + return Quality.from_string(value) + else: + raise TypeError("Invalid Type: " + value) + + def get(self, key: str) -> Any: + """ + Gets a value from config + Args: + key: config attribute to return value of + Returns: + Value of key + """ + return getattr(self, key) diff --git a/zotify/file.py b/zotify/file.py new file mode 100644 index 0000000..0cf7885 --- /dev/null +++ b/zotify/file.py @@ -0,0 +1,126 @@ +from errno import ENOENT +from pathlib import Path +from subprocess import Popen, PIPE +from typing import Any + +from music_tag import load_file +from mutagen.oggvorbis import OggVorbisHeaderError + +from zotify.utils import AudioFormat, ExtMap + + +# fmt: off +class TranscodingError(RuntimeError): ... +class TargetExistsError(FileExistsError, TranscodingError): ... +class FFmpegNotFoundError(FileNotFoundError, TranscodingError): ... +class FFmpegExecutionError(OSError, TranscodingError): ... +# fmt: on + + +class LocalFile: + audio_format: AudioFormat + + def __init__( + self, + path: Path, + audio_format: AudioFormat | None = None, + bitrate: int | None = None, + ): + self.path = path + self.bitrate = bitrate + if audio_format: + self.audio_format = audio_format + + def transcode( + self, + audio_format: AudioFormat | None = None, + bitrate: int | None = None, + replace: bool = False, + ffmpeg: str = "ffmpeg", + opt_args: list[str] = [], + ) -> None: + """ + Use ffmpeg to transcode a saved audio file + Args: + audio_format: Audio format to transcode file to + bitrate: Bitrate to transcode file to in kbps + replace: Replace existing file + ffmpeg: Location of FFmpeg binary + opt_args: Additional arguments to pass to ffmpeg + """ + if audio_format: + new_ext = ExtMap[audio_format.value] + else: + new_ext = ExtMap[self.audio_format.value] + cmd = [ + ffmpeg, + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + str(self.path), + ] + newpath = self.path.parent.joinpath( + self.path.name.rsplit(".", 1)[0] + new_ext.value + ) + if self.path == newpath: + raise TargetExistsError( + f"Transcoding Error: Cannot overwrite source, target file is already a {self.audio_format} file." + ) + + cmd.extend(["-b:a", str(bitrate) + "k"]) if bitrate else None + cmd.extend(["-c:a", audio_format.value]) if audio_format else None + cmd.extend(opt_args) + cmd.append(str(newpath)) + + try: + process = Popen(cmd, stdin=PIPE) + process.wait() + except OSError as e: + if e.errno == ENOENT: + raise FFmpegNotFoundError("Transcoding Error: FFmpeg was not found") + else: + raise + if process.returncode != 0: + raise FFmpegExecutionError( + f'Transcoding Error: `{" ".join(cmd)}` failed with error code {process.returncode}' + ) + + if replace: + Path(self.path).unlink() + self.path = newpath + self.bitrate = bitrate + if audio_format: + self.audio_format = audio_format + + def write_metadata(self, metadata: dict[str, Any]) -> None: + """ + Write metadata to file + Args: + metadata: key-value metadata dictionary + """ + f = load_file(self.path) + f.save() + for k, v in metadata.items(): + try: + f[k] = str(v) + except KeyError: + pass + try: + f.save() + except OggVorbisHeaderError: + pass # Thrown when using untranscoded file, nothing breaks. + + def write_cover_art(self, image: bytes) -> None: + """ + Write cover artwork to file + Args: + image: raw image data + """ + f = load_file(self.path) + f["artwork"] = image + try: + f.save() + except OggVorbisHeaderError: + pass diff --git a/zotify/loader.py b/zotify/loader.py new file mode 100644 index 0000000..9eb3885 --- /dev/null +++ b/zotify/loader.py @@ -0,0 +1,69 @@ +# load symbol from: +# https://stackoverflow.com/questions/22029562/python-how-to-make-simple-animated-loading-while-process-is-running +from __future__ import annotations + +from itertools import cycle +from shutil import get_terminal_size +from sys import platform +from threading import Thread +from time import sleep + +from zotify.printer import Printer + + +class Loader: + """ + Busy symbol. + + Can be called inside a context: + + with Loader("This take some Time..."): + # do something + pass + """ + + def __init__(self, desc="Loading...", end="", timeout=0.1, mode="std3") -> None: + """ + A loader-like context manager + Args: + desc (str, optional): The loader's description. Defaults to "Loading...". + end (str, optional): Final print. Defaults to "". + timeout (float, optional): Sleep time between prints. Defaults to 0.1. + """ + self.desc = desc + self.end = end + self.timeout = timeout + + self.__thread = Thread(target=self.__animate, daemon=True) + if platform == "win32": + self.steps = ["/", "-", "\\", "|"] + else: + self.steps = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"] + + self.done = False + + def start(self) -> Loader: + self.__thread.start() + return self + + def __animate(self) -> None: + for c in cycle(self.steps): + if self.done: + break + Printer.print_loader(f"\r {c} {self.desc} ") + sleep(self.timeout) + + def __enter__(self) -> None: + self.start() + + def stop(self) -> None: + self.done = True + cols = get_terminal_size((80, 20)).columns + Printer.print_loader("\r" + " " * cols) + + if self.end != "": + Printer.print_loader(f"\r{self.end}") + + def __exit__(self, exc_type, exc_value, tb) -> None: + # handle exceptions with those variables ^ + self.stop() diff --git a/zotify/playable.py b/zotify/playable.py new file mode 100644 index 0000000..ce3ab3c --- /dev/null +++ b/zotify/playable.py @@ -0,0 +1,235 @@ +from math import floor +from pathlib import Path +from typing import Any + +from librespot.core import PlayableContentFeeder +from librespot.metadata import AlbumId +from librespot.util import bytes_to_hex +from librespot.structure import GeneralAudioStream +from requests import get + +from zotify.file import LocalFile +from zotify.printer import Printer +from zotify.utils import ( + IMG_URL, + LYRICS_URL, + AudioFormat, + ImageSize, + bytes_to_base62, + fix_filename, +) + + +class Lyrics: + def __init__(self, lyrics: dict, **kwargs): + self.lines = [] + self.sync_type = lyrics["syncType"] + for line in lyrics["lines"]: + self.lines.append(line["words"] + "\n") + if self.sync_type == "line_synced": + self.lines_synced = [] + for line in lyrics["lines"]: + timestamp = int(line["start_time_ms"]) + ts_minutes = str(floor(timestamp / 60000)).zfill(2) + ts_seconds = str(floor((timestamp % 60000) / 1000)).zfill(2) + ts_millis = str(floor(timestamp % 1000))[:2].zfill(2) + self.lines_synced.append( + f"[{ts_minutes}:{ts_seconds}.{ts_millis}]{line.words}\n" + ) + + def save(self, path: Path, prefer_synced: bool = True) -> None: + """ + Saves lyrics to file + Args: + location: path to target lyrics file + prefer_synced: Use line synced lyrics if available + """ + if self.sync_type == "line_synced" and prefer_synced: + with open(f"{path}.lrc", "w+", encoding="utf-8") as f: + f.writelines(self.lines_synced) + else: + with open(f"{path}.txt", "w+", encoding="utf-8") as f: + f.writelines(self.lines[:-1]) + + +class Playable: + cover_images: list[Any] + metadata: dict[str, Any] + name: str + input_stream: GeneralAudioStream + + def create_output(self, library: Path, output: str, replace: bool = False) -> Path: + """ + Creates save directory for the output file + Args: + library: Path to root content library + output: Template for the output filepath + replace: Replace existing files with same output + Returns: + File path for the track + """ + for k, v in self.metadata.items(): + output = output.replace( + "{" + k + "}", fix_filename(str(v).replace("\0", ",")) + ) + file_path = library.joinpath(output).expanduser() + if file_path.exists() and not replace: + raise FileExistsError("Output Creation Error: File already downloaded") + else: + file_path.parent.mkdir(parents=True, exist_ok=True) + return file_path + + def write_audio_stream( + self, + output: Path, + chunk_size: int = 128 * 1024, + ) -> LocalFile: + """ + Writes audio stream to file + Args: + output: File path of saved audio stream + chunk_size: maximum number of bytes to read at a time + Returns: + LocalFile object + """ + file = f"{output}.ogg" + with open(file, "wb") as f, Printer.progress( + desc=self.name, + total=self.input_stream.size, + unit="B", + unit_scale=True, + unit_divisor=1024, + position=0, + leave=False, + ) as p_bar: + chunk = None + while chunk != b"": + chunk = self.input_stream.stream().read(chunk_size) + p_bar.update(f.write(chunk)) + return LocalFile(Path(file), AudioFormat.VORBIS) + + def get_cover_art(self, size: ImageSize = ImageSize.LARGE) -> bytes: + """ + Returns image data of cover art + Args: + size: Size of cover art + Returns: + Image data of cover art + """ + return get( + IMG_URL + bytes_to_hex(self.cover_images[size.value].file_id) + ).content + + +class Track(PlayableContentFeeder.LoadedStream, Playable): + lyrics: Lyrics + + def __init__(self, track: PlayableContentFeeder.LoadedStream, api): + super(Track, self).__init__( + track.track, + track.input_stream, + track.normalization_data, + track.metrics, + ) + self.__api = api + try: + isinstance(self.track.album.genre, str) + except AttributeError: + self.album = self.__api.get_metadata_4_album( + AlbumId.from_hex(bytes_to_hex(self.track.album.gid)) + ) + self.cover_images = self.album.cover_group.image + self.metadata = self.__default_metadata() + + def __getattr__(self, name): + try: + return super().__getattribute__(name) + except AttributeError: + return super().__getattribute__("track").__getattribute__(name) + + def __default_metadata(self) -> dict[str, Any]: + date = self.album.date + return { + "album": self.album.name, + "album_artist": "\0".join([a.name for a in self.album.artist]), + "artist": self.artist[0].name, + "artists": "\0".join([a.name for a in self.artist]), + "date": f"{date.year}-{date.month}-{date.day}", + "release_date": f"{date.year}-{date.month}-{date.day}", + "disc_number": self.disc_number, + "duration": self.duration, + "explicit": self.explicit, + "genre": self.album.genre, + "isrc": self.external_id[0].id, + "licensor": self.licensor, + "popularity": self.popularity, + "track_number": self.number, + "replaygain_track_gain": self.normalization_data.track_gain_db, + "replaygain_track_peak": self.normalization_data.track_peak, + "replaygain_album_gain": self.normalization_data.album_gain_db, + "replaygain_album_prak": self.normalization_data.album_peak, + "title": self.name, + "track_title": self.name, + # "year": self.album.date.year, + } + + def get_lyrics(self) -> Lyrics: + """ + Fetch lyrics from track if available + Returns: + Instance of track lyrics + """ + if not self.track.has_lyrics: + raise FileNotFoundError( + f"No lyrics available for {self.track.artist[0].name} - {self.track.name}" + ) + try: + return self.lyrics + except AttributeError: + self.lyrics = Lyrics( + self.__api.invoke_url(LYRICS_URL + bytes_to_base62(self.track.gid))[ + "lyrics" + ] + ) + return self.lyrics + + +class Episode(PlayableContentFeeder.LoadedStream, Playable): + def __init__(self, episode: PlayableContentFeeder.LoadedStream, api): + super(Episode, self).__init__( + episode.episode, + episode.input_stream, + episode.normalization_data, + episode.metrics, + ) + self.__api = api + self.cover_images = self.episode.cover_image.image + self.metadata = self.__default_metadata() + + def __getattr__(self, name): + try: + return super().__getattribute__(name) + except AttributeError: + return super().__getattribute__("episode").__getattribute__(name) + + def __default_metadata(self) -> dict[str, Any]: + return { + "description": self.description, + "duration": self.duration, + "episode_number": self.number, + "explicit": self.explicit, + "language": self.language, + "podcast": self.show.name, + "date": self.publish_time, + "title": self.name, + } + + def can_download_direct(self) -> bool: + """Returns true if episode can be downloaded from its original external source""" + return bool(self.episode.is_externally_hosted) + + def download_direct(self) -> LocalFile: + """Downloads episode from original source""" + if not self.can_download_directly(): + raise RuntimeError("Podcast cannot be downloaded direct") + raise NotImplementedError() diff --git a/zotify/printer.py b/zotify/printer.py new file mode 100644 index 0000000..63ec468 --- /dev/null +++ b/zotify/printer.py @@ -0,0 +1,80 @@ +from enum import Enum +from sys import stderr +from tqdm import tqdm + +from zotify.config import ( + Config, + PRINT_SKIPS, + PRINT_PROGRESS, + PRINT_ERRORS, + PRINT_WARNINGS, + PRINT_DOWNLOADS, +) + + +class PrintChannel(Enum): + SKIPS = PRINT_SKIPS + PROGRESS = PRINT_PROGRESS + ERRORS = PRINT_ERRORS + WARNINGS = PRINT_WARNINGS + DOWNLOADS = PRINT_DOWNLOADS + + +class Printer: + __config: Config + + @classmethod + def __init__(cls, config: Config): + cls.__config = config + + @classmethod + def print(cls, channel: PrintChannel, msg: str) -> None: + """ + Prints a message to console if the print channel is enabled + Args: + channel: PrintChannel to print to + msg: Message to print + """ + if cls.__config.get(channel.value): + if channel == PrintChannel.ERRORS: + print(msg, file=stderr) + else: + print(msg) + + @classmethod + def progress( + cls, + iterable=None, + desc=None, + total=None, + leave=False, + position=0, + unit="it", + unit_scale=False, + unit_divisor=1000, + ) -> tqdm: + """ + Prints progress bar + Returns: + tqdm decorated iterable + """ + return tqdm( + iterable=iterable, + desc=desc, + total=total, + disable=False, # cls.__config.print_progress, + leave=leave, + position=position, + unit=unit, + unit_scale=unit_scale, + unit_divisor=unit_divisor, + ) + + @staticmethod + def print_loader(msg: str) -> None: + """ + Prints animated loading symbol + Args: + msg: Message to print + """ + print(msg, flush=True, end="") diff --git a/zotify/utils.py b/zotify/utils.py new file mode 100644 index 0000000..bcb5456 --- /dev/null +++ b/zotify/utils.py @@ -0,0 +1,166 @@ +from argparse import Action, ArgumentError +from enum import Enum, IntEnum +from re import IGNORECASE, sub +from sys import platform as PLATFORM + +from librespot.audio.decoders import AudioQuality +from librespot.util import Base62, bytes_to_hex +from requests import get + +API_URL = "https://api.sp" + "otify.com/v1/" +IMG_URL = "https://i.s" + "cdn.co/image/" +LYRICS_URL = "https://sp" + "client.wg.sp" + "otify.com/color-lyrics/v2/track/" +BASE62 = Base62.create_instance_with_inverted_character_set() + + +class AudioFormat(Enum): + AAC = "aac" + FDK_AAC = "fdk_aac" + FLAC = "flac" + MP3 = "mp3" + OPUS = "opus" + VORBIS = "vorbis" + WAV = "wav" + WV = "wavpack" + + +class ExtMap(Enum): + AAC = "m4a" + FDK_AAC = "m4a" + FLAC = "flac" + MP3 = "mp3" + OPUS = "ogg" + VORBIS = "ogg" + WAV = "wav" + WAVPACK = "wv" + + +class Quality(Enum): + NORMAL = AudioQuality.NORMAL # ~96kbps + HIGH = AudioQuality.HIGH # ~160kbps + VERY_HIGH = AudioQuality.VERY_HIGH # ~320kbps + AUTO = None # Highest quality available for account + + def __str__(self): + return self.name.lower() + + def __repr__(self): + return str(self) + + @staticmethod + def from_string(s): + try: + return Quality[s.upper()] + except Exception: + return s + + +class ImageSize(IntEnum): + SMALL = 0 # 64px + MEDIUM = 1 # 300px + LARGE = 2 # 640px + + def __str__(self): + return self.name.lower() + + def __repr__(self): + return str(self) + + @staticmethod + def from_string(s): + try: + return ImageSize[s.upper()] + except Exception: + return s + + +class OptionalOrFalse(Action): + def __init__( + self, + option_strings, + dest, + nargs="?", + default=None, + type=None, + choices=None, + required=False, + help=None, + metavar=None, + ): + _option_strings = [] + for option_string in option_strings: + _option_strings.append(option_string) + + if option_string.startswith("--"): + option_string = "--no-" + option_string[2:] + _option_strings.append(option_string) + + super().__init__( + option_strings=_option_strings, + dest=dest, + nargs=nargs, + default=default, + type=type, + choices=choices, + required=required, + help=help, + metavar=metavar, + ) + + def __call__(self, parser, namespace, values, option_string=None): + if values is None and not option_string.startswith("--no-"): + raise ArgumentError(self, "expected 1 argument") + setattr( + namespace, + self.dest, + values if not option_string.startswith("--no-") else False, + ) + + +def fix_filename(filename: str, substitute: str = "_", platform: str = PLATFORM) -> str: + """ + Replace invalid characters on Linux/Windows/MacOS with underscores. + Original list from https://stackoverflow.com/a/31976060/819417 + Trailing spaces & periods are ignored on Windows. + Args: + filename: The name of the file to repair + platform: Host operating system + substitute: Replacement character for disallowed characters + Returns: + Filename with replaced characters + """ + if platform == "linux": + regex = r"[/\0]|^(?![^.])|[\s]$" + elif platform == "darwin": + regex = r"[/\0:]|^(?![^.])|[\s]$" + else: + regex = r"[/\\:|<>\"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$" + return sub(regex, substitute, str(filename), flags=IGNORECASE) + + +def download_cover_art(images: list, size: ImageSize) -> bytes: + """ + Returns image data of cover art + Args: + images: list of retrievable images + size: Desired size in pixels of cover art, can be 640, 300, or 64 + Returns: + Image data of cover art + """ + return get(images[size.value]["url"]).content + + +def str_to_bool(value: str) -> bool: + if value.lower() in ["yes", "y", "true"]: + return True + if value.lower() in ["no", "n", "false"]: + return False + raise TypeError("Not a boolean: " + value) + + +def bytes_to_base62(id: bytes) -> str: + return BASE62.encode(id, 22).decode() + + +def b62_to_hex(base62: str) -> str: + return bytes_to_hex(BASE62.decode(base62.encode(), 16)) From dbf05e3b50558a961219617a1d30aaac6f4a52ed Mon Sep 17 00:00:00 2001 From: zotify <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 ZSpotify. Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify). ## Features -- Save tracks at up to 320kbps* + +- Save tracks at up to 320kbps\* - Save to most popular audio formats - Built in search - Bulk downloads @@ -18,9 +19,10 @@ Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https:/ - Embedded metadata - Downloads all audio, metadata and lyrics directly, no substituting from other services. -*Non-premium accounts are limited to 160kbps +\*Non-premium accounts are limited to 160kbps ## Installation + Requires Python 3.10 or greater. \ Optionally requires FFmpeg to save tracks as anything other than Ogg Vorbis. @@ -30,10 +32,12 @@ Enter the following command in terminal to install Zotify. \ ## General Usage ### Simplest usage + Downloads specified items. Accepts any combination of track, album, playlist, episode or artists, URLs or URIs. \ `zotify <items to download>` ### Basic options + ``` -p, --playlist Download selection of user's saved playlists -lt, --liked-tracks Download user's liked tracks @@ -45,7 +49,7 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep <details><summary>All configuration options</summary> | Config key | Command line argument | Description | -|-------------------------|---------------------------|-----------------------------------------------------| +| ----------------------- | ------------------------- | --------------------------------------------------- | | path_credentials | --path-credentials | Path to credentials file | | path_archive | --path-archive | Path to track archive file | | music_library | --music-library | Path to root of music library | @@ -61,27 +65,31 @@ Downloads specified items. Accepts any combination of track, album, playlist, ep | ffmpeg_path | --ffmpeg-path | Path to ffmpeg binary | | ffmpeg_args | --ffmpeg-args | Additional ffmpeg arguments when transcoding | | save_credentials | --save-credentials | Save login credentials to a file | -| save_subtitles | --save-subtitles | -| save_artist_genres | --save-arist-genres | +| save_subtitles | --save-subtitles | +| save_artist_genres | --save-arist-genres | + </details> ### More about search + - `-c` or `--category` can be used to limit search results to certain categories. - - Available categories are "album", "artist", "playlist", "track", "show" and "episode". - - You can search in multiple categories at once + - Available categories are "album", "artist", "playlist", "track", "show" and "episode". + - You can search in multiple categories at once - You can also narrow down results by using field filters in search queries - - Currently available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre. - - Available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre. - - The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1970-1982). - - The album filter can be used while searching albums and tracks. - - The genre filter can be used while searching artists and tracks. - - The isrc and track filters can be used while searching tracks. - - The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to show only albums in the lowest 10% of popularity. + - Currently available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre. + - Available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre. + - The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1970-1982). + - The album filter can be used while searching albums and tracks. + - The genre filter can be used while searching artists and tracks. + - The isrc and track filters can be used while searching tracks. + - The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to show only albums in the lowest 10% of popularity. ## Usage as a library + Zotify can be used as a user-friendly library for saving music, podcasts, lyrics and metadata. Here's a very simple example of downloading a track and its metadata: + ```python import zotify @@ -96,10 +104,11 @@ file.write_cover_art(track.get_cover_art()) ``` ## Contributing + Pull requests are always welcome, but if adding an entirely new feature we encourage you to create an issue proposing the feature first so we can ensure it's something that fits sthe scope of the project. -Zotify aims to be a comprehensive and user-friendly tool for downloading music and podcasts. -It is designed to be simple by default but offer a high level of configuration for users that want it. +Zotify aims to be a comprehensive and user-friendly tool for downloading music and podcasts. +It is designed to be simple by default but offer a high level of configuration for users that want it. All new contributions should follow this principle to keep the program consistent. ## Will my account get banned if I use this tool? @@ -110,12 +119,14 @@ However, it is still a possiblity and it is recommended you use Zotify with a bu Consider using [Exportify](https://github.com/watsonbox/exportify) to keep backups of your playlists. ## Disclaimer + Using Zotify violates Spotify user guidelines and may get your account suspended. Zotify is intended to be used in compliance with DMCA, Section 1201, for educational, private and fair use, or any simlar laws in other regions. \ Zotify contributors cannot be held liable for damages caused by the use of this tool. See the [LICENSE](./LICENCE) file for more details. ## Acknowledgements + - [Librespot-Python](https://github.com/kokarare1212/librespot-python) does most of the heavy lifting, it's used for authentication, fetching track data, and audio streaming. - [music-tag](https://github.com/KristoforMaynard/music-tag) is used for writing metadata into the downloaded files. - [FFmpeg](https://ffmpeg.org/) is used for transcoding audio. 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! - - + # Zotify A customizable music and podcast downloader. \ Formerly ZSpotify. -Available on [zotify.xyz](https://zotify.xyz/zotify/zotify) and [GitHub](https://github.com/zotify-dev/zotify). +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 Spotify user guidelines and may get your account suspended. -Zotify is intended to be used in compliance with DMCA, Section 1201, for educational, private and fair use, or any simlar laws in other regions. \ -Zotify contributors cannot be held liable for damages caused by the use of this tool. See the [LICENSE](./LICENCE) file for more details. - -## Acknowledgements - -- [Librespot-Python](https://github.com/kokarare1212/librespot-python) does most of the heavy lifting, it's used for authentication, fetching track data, and audio streaming. -- [music-tag](https://github.com/KristoforMaynard/music-tag) is used for writing metadata into the downloaded files. -- [FFmpeg](https://ffmpeg.org/) is used for transcoding audio. +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 zcmeEthg(v8AGb{_QyZq{$ka;Bm3wPxYUM0*uhiUI#62ogOG7Jj4^mTefSMZzrKve_ z<HkKe!G#EjywrW)&-1+Rzwq9N>s<Ue*Et;cuFvN?&Rau$ZN_sP=jiC@7#}{k|AdZ? znYKJWaOO1aO>`)Fh>q@9l&gk@;X@6L>pp(oPOct~baa9r!at}yXuHeRZfm6ZAuj65 zeJ(x|fyS?I93bi!Z@;$4HKtF}fq#Ce@o`~6u%4Cc(5y_cULJ#<yM5*r&-H#L{W!5F z3)GM5@$AeA<ZbNiB9{C5NRMunqWz1)DW}^P*`y|nD=}A&tFwPUqvhyy74Y%XYuo`$ z`$c)+<DBJ4EMl;9Mdb9!`wQ$+x|u;yK~BT6^0>>f=8sL@BHjw<!me-gBd;W)<k_rP zgPg{7i&oyY{j|>oCCfT#8u91cO8A<gPgL)4NZw_2hFCfFh3>G$_>sCLk*-YqFEvH( zy*hKs_>*A_ND!hZ#QLO~hp^4u5b@UPT*IShOJ;XZaXd#dAJM}bYj%v1Y@P@*`M1dT zcd`%EFi5*4?1pd1gDzh`IB&Hs8d<>98}ZS`-Sq?iXK&8>dN0@RYd#Gp&Z}PiCe--k zec^k=37qqup801g>zes)h&y(1@7Mz)(=N`rxeM&#hWju?W8+jPnV?$*=gC>}t9L)A zaFeE$)Al;r_vtRsUYCz4^&8QSob`EN4xpnG<Nv)M3o1|xpdF<5f2gNPzjplm6@Cue z-8*cwLs$JZ&HOdIJv|-0{OL6O9PRuapI;Aj^>?|h{ZP-)^d%GRr0aAK@85eGG`TTj z`OX>{%(La)7p=i4&Zoc!%zggolSt>svUt%cKfVcl^ms{jp}rmROa`mwE7dQ8hSh@a zQ&|NKKi|FhnOW2J_?PfAPo!NxTz{LSSUY9b@~hUPGeyK)4x{z<V-Sx`Y}<TqnQCvk zLMX0^px_+SHZO}OA7fQ;MvaliEJ6j-sphWKBZ>m;XaCRk!i4?t@&EZuTe>hGd-nev zx%lsq|4PK=UupbT7<B(i<9~$luQdKw82?J+Uupa;fq$dK|C++T()d>ze@Wn9OZe9k z{=c(?4-&t#u0NYg;^137iQrw3ZyzYs-k$JOS9kNx+v02qi5jV)@gZA0JF4ZTmcT7O zKKt#3_B5qnr}c#_2kcM(-fJos0Rs9R8{6ykK704G!ttOqHM#uyzrI$Df9Qxpu9E!w zE_7B`GXp#lvk5a-rm*8|mIS!)E$<eIp<lmXYR>y>+wy^Pee%1*$b}^-L}vpM^(5@i zg)dSr(f!Wae{bW@kkkI*y5U<b<M{70m7DwFre7G@^t0Y28K|&zMs6+26MGpxu(2_o z|8x3XoilVN{;cTeJYGo4au_ksa>^a;@K;j$DG;}*>Ahn4L%hJk7P5t#_ASz8f1PWZ zMtA4$tG+u*D@t6HQzZ@7ucz|7-FC05fkA>LI`<ND-kyluDM$-m^o*kabFxqLe{W`W z+SUIClzO1OU=80d9;1&e-LX$tM1@M)P>10OyWTt!X@A{-Q<sjNwwk*wg`cyoT#O3r z{5h-gp`-J34w#P*c^HwlTMA|bP~33g#hoU*8|*u(qpMfQJf&sM9)^vv+*9m6@O!%j z>k6DQwQZ`A&Dc<(O>0kxiIg_k+K9VNC%sBa9DXtO2yH1r-mm->AD>F0)Go(h778N| zy3)cBkPjW&I)H!z{&2+)N^D=O(lNz>jQ4F6)r?s+6Ig_nXZf`5_g^gDQUBF2{N?Fx z|1TK^aZ&5bgAJyApI2jKGH;77T5TYAkPV?Is|LH<vqksBs-=v(ZP0;W#3RC2u0*s# z_WsCBdC-rA>Gq)AMEIp)ROl^Mv0h>u9o@jWDO9NT^tPq#n?9s1p=#zsJMA2u#CFa| z60%Z0aPa$IDY3`<zbv&Lrb}ImmZ$Dl)?+mYKNDhk-fS1U^J{5^M7?OcL6znE0ZTAZ z4`|3`C|yHjKrP*yPg~cGJ>S1=P+n?_IHhsRm7FtH1S#Ii<O=T>etkpSv$bpiQ~wT7 z<cIy<!!c>06r+(a=%&<gM~ZXU;P8eRmue;NJnGYv4dPx~B|c}$VJYKnuF+yR*}QBj z1UiAwh)vCSimUT-5DLr0u+D|tAd|~*_@mm7Y;23YfjJ3A2F-$p40P44Yd;Ie3DLH1 zIaRlPs9@+$mHqP@`w=Ut_UIp>iv=iZzC7iy=k(P-Ch_dVJM47BE=u^f;eH?GU*%#$ zWW^gH;2gt=6i<<?%(ox(7A0Vwl0ye;%R%<5$P7=B{1wB;=s<+dq0W$NnnSXu2tNVW znSn_{dncXVQ-V~2*ZoFew&5Arq}VlGJvdT|H*G9P#Kfpd=G{#4C$Y`xXelY*wZxW7 z!v@V2yh<-XUb8H6hv-gw^an!c+R{paY(Y~?w7$F5Zn539FQa8uIx4C^v-H+4tq`tc zglgDClE`Xr>);1wy?S6$Iw|)G?T5Q!JnMeWz;~xA7h4E6-1Decm5`*$)ep=aYb$T% z@f!xn`69_<fBy17;vaweZtP)a$J^f=cBfyN{K!f<Lb4;!E0K292Q97J&iiK}1y6q& zEkDS<3^Nt6ecw$eZnh<KW7GBQs*WA&d2`fBj=}l00njAF300|rmk6&7OEe1Wr&kJe zd*7|{i1NrR+&<2Dq<S(Hec8xgBW9EXD$_Mx^hhOlQ#Bc#&X78Q>u?|T+5Lv_xY~b# z!W{zHV~|{(Bz{oBy||1zxW`2qOSC`Q=#6RWDZJLC6cD+?FVW&K<&zY)1{(nZDP+4} zS0;*TqdG)o${;J-ZY`Ex5dx{RxXN!%joyTj$eJ$;4HuVve6dzZZj;IGsLR1ymxWRJ z-KM?VnF&#md&_AGLGn9!bm!=N9QDEvj)HJ-5`-NdFf}^W#ZtLBQ`fPt<Q=~*0G@fz zbLQk9!ENc^r28G|wwNktHoIyQER_(xN$<OT(8Ye06IAJ5cb|i^w5v)i_D2f8Rm0_Z z-5GgvG-Jt_cVn7j<+o)(BfRj*fcrXOna%=wvMtW89#-zKd`*~u>&h{iOoxdI)fREu zVN`hwMgZa1laEVfbPFPo(R)t{cp41ncU<v6EF?5;t=PIwv>m;Y99ft%X`GXqk+}RI zIihGXz__zQ&dXei`N?h2^E%aLnbI*ywvd#N<FTU?M%-lk#*o!}#@=UI$a^bhuK1wv zxoWQb4aS1h`~%7)tIBTk6b>Dh&??n~H!uF#YM&Lc6D@xLhjXwM8F>F9yOSZI4mh<V zU0o}<FdyiCxQklIZPsX+aa{IerPgB7&M(HrQH6}WJGhKlP_eXHp1;>jqmWxi@pSBD zO;!jwnG<O+pE{x-p6ra9W1Q|$Te@B_+c3JDvqo$<qo?+U*A$)d2=}Uif7o}6nQD*~ zzI}IzcnY;~?1eBo#g_bZgDOD1J~Y0jln2iw=(dl<;%F|~!op?%mif!3>BLU_sn)#+ zOiM>Hx2HSXzGd*{iQk4KNd#<;J#gR`{xSQ8^wk`ZQxD2~mJ(RS-DK+}@xDH+>DGQG z`gn4&?4?G3>)SOqN!2%RhnMa*lYzHipEoKl0FvjmMl|VqYAoKGh~Zk(^LVSrxhy>< z>--gP<_Rk)C;Xcoo_H9|1Zf)72i1_tDV<+AzKF(r!eGps6p{TYPtW=j_T`KNEcF;g zZHUe#I3sIOzml@#<P7bC)a5cK{XQAtL?eOo>9@&ch4#27eXzBd_)-MUx_tC1d=Jh* zKO0v^U81&c<St$$?%--+!3b#mJOKQr%hEfJozs)6$V#g7^d@hPd&gH^zNuX%bX~0x z*qIe&skdkqt_Hn-U_`|~Mdgf)_jC2BnedXD+(V`e-&Gg!uKMh@bm)P_QQ^zY)%EP! z)CG(LrES%IzBS7mOZWXslGc;yZA65MSIAyI<w%oQB3<h!2@PF)DUYAlk&p}Te*VWP z=**mtfB(J0-Hm5kSvcg3T|AJBWMP|?=la0k5#Ry(u)UoOudrehgfMiDo@#y6`2-N; zp<rmrbGwxKnL|QnFszCFq3~%bhc}qr+f$ECt6&~Z0QP$7wY10UI-U?xP2^q6(onQk zTm9IkH^-pb>uLF;iy9kZlVR`(LPAVHL53~HjY}fPYmA$71YF&rxX0kVk8vO7>JDGI zKdjoYTh#yahuT4p{O%Cv2R1RPSE)l%=3)x!z~2Lo>wl2hkvf@{ablgaq6fbo0VnNQ z>J;1cmsJbPAm+{5l^>p1(d4ifiO0=LKZlr1POYlc?f~r^Pz%n%-{sGTX%~=IMQySY zFDFv2GezPXB_j8mywQJ4?}!GqDm;_{rP|Z{nH`HTW)x2mI8tGSMty|-lHJ&cze2In z^hU0O@5+R!{XR%TLQ$4u(MX9O1TyJI-!Fd>K3&$7@9L}8H>y)AuPRuN^bd!>cOTXi zBIJPG_a$xgY%|DXTxUJek?<Di%6P@G<saee{V^_Urr{SV8`a#_gE*#V3oQz|HJ&&# zPe0x5jQ|M8l;{l~6##2+(Bx^u#E4xLf`a(z-oAUg`O;28?XAQK`JDpq&OK&uVmkwy zI62hS-(Pw^Y~#l`af>1$+0YQ63pmf#)bDQX?YhNbF?H=%+h*nrwP7?oLVVeLwBbpe zw*RVGNhCoA+UN!u@}X=Ha&#{GuB4|fZo&_>db!nVc$FTXpc|q75NJO_9foZX$77T2 z!$khr7iC&U{f|k}(P19^A#5+qn&f$^Xbydu#%G*z!f)<Ox3i-vWwZojHQ$ZkyeGJX zIB$8>hS|(S<hNlr&E?fbHxnmEQX)c&WjC{7d+wCkx~QB9@fWG|Be})5ihZiX5}&R+ zbJUxzSuFFq$sZh&<b#dg)Yg?Z+-g#?I<G9(KlSvDsYzl{?rH$8zMv&k%k;!AJ+g4o z*O_<)JT&u%=ruom5s{8T_@N0GITP1;*Z`XF1fuhqf8rx?AK0fVfj56-o1oblkBxeX zh6XBnP4YB&fuunXt!s7+z1!zk9Lp9+(p%O>*&4-8pn}#=QT)cSS$Id=rq0aaV-7wM zl;tZfwpry%-iHLW_+4*qkO+<2SluJHY^B$4CnXC0+yQTQXgMJ1v2QP<$iBUZEYk~r zjOqpO`X7|K+ZDY&?7Kf<x^P(Pt=Ws{EL2f)@E<mlj{Tmu6D)HEMw1p&&#p0I75Pvj z%T&2Yh|U6}TbSd}2RA8qWlBqNmsx8f`B=MoXbjI*0Ovbbd^XW&TT{&R>aN}X@MfJi z=JW3Gi=&k_cpdh0izfI;Cc7@<PRMWudo46-?7q>9=9R3h#f_@+j(PZjR&Taq6)*0y zXx%KkT=-Fvgf9h{&2x7^`Y5U+&sOhr-&A!R!TdZsVcNN$>1*UBITyu&Hchfy4fG1~ zK3^q9>f61CqyE5U5tE!3w!mtv&Nhnd@w*Hyb|>gedq*?~oe=o8dGuHIiE5#M>0PWm z>42FZfe=3PM<(BIJ|Yuv1pb}hXyGCqOnJI-Kn!(Q)xgE;g$8JGa6_^N#lM&oHF~=T zn|9xHpC}3l-><dS8-R#CYs{ghdqO<TH}O%VWMnbK)$u2?h*j_2vW}<eu8cd_q4a8e z#LF$6OxsfTNJ9Fmjq(M0>T0UPN=+S~xQNX@p>M1?l$HVP1v>N~W;k3u{C@=$0c|3q z&}MbaKET+mn!XRCiAF8GyWaWksKaX(S(%A))Xvm}pMg1X(Gv=&EE)<r^|Unm65wPu z3B}5X5*)f}L@Q+~m}+@{ANjbxNvosNKCE;9&6~9G8h*Y4_9?}y`I!PFo4O!hRWXx@ zU!&2B!xE7Jw^;iTw?+O41R_!Rhtupy0S<z)s8yW{BZ)R|xS3nz0#tVHzS|sU6)Mm- zgJ8w?RwfOe%$vEL0WqF~Lk!02u&TjVOj+UG8a1J55l`K#GipB(jzO3&?~`G+7iw+$ z;*m5n*x&Lt67^e0k}Q~Zd#3}LMmCcgXC2I2WRV+Zn_!cSlhhCy8QZjUkcRD{Q!kf% z^&jGIMJP`Exx_73&Ue_Df%jX-eJGUBOWk|d<mctKX3p^q)z*SUPo*GT!)?%+!9oHb zpUh+0+vV{GNxnPY;44R`=%8N*-@Xk(zz>UB;U_klsLj3<aaERB>WWg_j|`AEisLtX z_?c5Y-V|!8yO9%D>6s+a;_2=&S*aqsIeF35|M?6!lMT?z4Nf2eRvjW%!hSE~o%`E2 z<{czLmx8h)F%NpP<y~L=k>dD;zo75@CqM$Ay!pziO{j1}82;@zae!=(X2Zt6%?!|< zly33YAHnq~y2PSZ23(LkIY02B-b@!c#FW2aCuXh#0KHym*HKT*06sruM@L*57#ngE z3mTW-WOd{98G-RRiUDMc<`$vqjN2+@r4H6Qsi9I<o81VZga=07Wl3SPxGcL)c}9tS zl};s|k-AbP7#>)TJvHU|(CcfkOjx#?yLph`>a&>;Q7sZ%e^xwy<YDCE21B`K4j8_H zOB?LF>`N(%<gJa|f<DL;a3fhlt*udHke-(npc!9A4j-O{Hxv2>S=#hu07@|~2Dmgi zME6KVME9;FTTbd}XdB5`rT3gyM0rsQcv=6TZYpya6)DRUzCJ}WxemGKbaQdAWdJzt z?TnHGCGtY|s57dZ`Y0=m6iusPytW@e9_&LZm&ckV8VE6IP0PRc!tt<0poF4?#1g!( zyj=Q{L}jS6GX!b*8w6q#+oSnA7Y}K8xj!mrzfvPdTj!r<`K^!_z`qFk_Yx~*plm`I zQOS2@J=MOGjD2Grxf)gJih#cP@FCg7Oy+euN2}fFmK6&WH)C5bhefhXmOje9?AKjD zPDbd}{OBq)v~1{WHt1KeLyQ3Gp4MA#2YHW61h!^pCwzge8k3q1&27#NC+DOX2|P3P zbZz#6V?sF!az<=z%Z#uM&@e6Okzit6p|w8X{+BGiqBkpoAvOx-K}m&MzSeh}RG(S4 z7`d@07dUGRy|!d)fnwLDl`KOs+3uy**H-Q28*J-|OM&2o%ZMfx1~xITU!jhxCvb1G zd4g@<>3KoVV#hXRazdR9fz_qjev2EP5rOxYOZ>861JI!tz}2AOv7#a5=#@bhh4O2C zpnFTRfs5Uo;-i%7uezs2l@-}+q-KIGl<i#kRx{)*_sTN8mV$!AS+%``{bBC;O|X>& z-Ol;)Faf8;oHtE^E+^=$6sEjTY;s{ccG2uZw0yFjBB#cKI4VLJorh7OcVt;Dy=OQr zapP=L*e03UnHF)`P1wFOl;`1XtkplW0Q`#%w2~L&@BQ(+vj{|1BA}e>9iW{1os>Aw z8$kKBcMP4ItbYng-zN(wWCEE}>bFkICdOql9XiRV%SMUH7c#x4IQcW>t{g9ExYVs9 z{G)s&+`4BFzL1bqAZRY|)1q$C(`y{%zn*0^Tp}250ou^KuNQR3RQ)9|6ttfM7XGlB z3;^7JRLm<m$)YM&+X5@{#=7b~g<`O4=3JQz;qfZB>4|wO899hYb}f%o`~)_#`9k;- zqA_vzy6g)u0WU@SQ|%vA$*T(axoWQof=py;E$d=8cEoxcsu%#3r)9{UDJ!nwd(BS= z^>@A<R?n)uEOqC1v;1&-(PUPyv94WVM?GQKpoP7h`h#t8VK1FAIMxFW^q$_C0>|sg zZNz;lc{~+?shn<u;ok#nh-c}F?M7E%4O3EbA-D@Fs|4r(i8}^^IyopMoOkQGZ4R+` zRr}&nM#Lvm^%*z4<j@g(%lL<7p4%6a<qXP6;c2DG9+H7eK~2QiNbaJ@9X}D9ECM4N zUA_I#gE#_(b}$JCdn{qHPDD{C^2Ei}D+}Bnzp-XhC9tE0Fnx7u7GU}A9lUD)_MeJl z2&rA3xVP(zP6v0iZk$T`Q!;vyJ8|rHS?z_6WMqVFg38g;tnj0n@qpEqw2(diN@5Bp z9|)v%U&aYJlltutv!jrVP~`3Tdfy1Jy5}bHjmxH#S|j%e$(Uw>*Z^&hMW>|Zdqk60 zt=681WeswE5U!I)M%a*G8Tq1{J{dYnXI3ZYTpAJ^cDlMBKs=63Dn02rHe+=;&&n0i zjZao_Qwr|QQbc@pHQt6LhsahJaGVs4$tWEal5Um+D|X&S2=Rb+X5u+>p;^X8>kH6f z1z@ebD51Ez#~00=zkHP3DQMbJ(Qu_90D_N!0^-tCwoJGvrQRKf>ojde>TXP2K~gA* z?c*c+{TWbf*OZbr0Nj$iuu!lq(u>r$)hPu8`xZ=IH4hFQfyRBEN>V0l_}Uye_hD{` zBa&JzzDBkg^DhhK88l`@o^uI=q@b+B7rKgDuy}7M4E9CUyRen;iUw=j$pQ--@$|1F zY4Gobq{m%3vd*_urMmo%oLI0-1G=EHvVK1&uyZ693*sg;%XQRwhifAp><Q5ET^;83 z9NQ2<dtZtECjaaDsucUI->M_;9J~Cdxcj1I;}1#v4&&hF25ko75{nZfuiwH=v*k}g zg*J{Wm90>2@aK5}X0VQJze?o^ezOjJ1TI5x1c76J_nvKepc?&5WZL4zZ0_grxl=NG z4k}Rdsjt@Q!AOb79o?qw&`6FVTlot#hL%dPmXK{wn8jzpIi_%MsiDGcQ21krDL2vw zJQ;jv>F`>;m)PQAy%ic=DKEz}Y)ie@gm*^0Axf==InW?|18hpmM9Dl<J*z50NV{1+ z8?}e$=UW`rj2F5BlvKQ=goO(is``6xgi&WCV8QB-WA23R1#v|l#`^AU^K@+F=AZjx zrgxc@{*<RHpscK{_M}cV5TLx}Y7*HiZU#&wrZp|5u&0|b9WW;8TRQ6QJUTfWQb&*V zj6e@J>$n2?^lQSW<aFm`@x`wFQ5w^2F(H{G)vq~Gs<sn@G6XOuWpbFMQ1=9fv~IR( zK(_TPZv(dB#H|iD^`X)%2L`Ds-z8y#pXsKk$@g2hwKbQGh=ZeX5}*z$c9@y}jl7e= zY8V(hl@-(UaJU}QawRwoFXzLwpN?wlFPaRNS<16^-_8tBfyxu6g?;zj)!sw{R>O^G z(y6!RX%UA`GzyH(cM_W%W)9Zcj(;~T?y~fHH^UQtCxF^YZ|^acax8MF$ZZ#ET}h|{ zzi9?jXuSg>UVeX*r+pizdX-j3G3NRmZ0Yz~nZEyiw{j9H822Oh`Ob((T(6z3QKQuc z<E14p>C88polRNEQfjO-VT89k5*|=*JeNaQF!}{BeNvBUcn{K<d1jqZkn{wrVxos) zC7Cn6W}UL^zbvMra3!S9UvTu~;ZAom;8C<j=`^g?A0>!tG($Xb`Q{qtx0H1bh@Ey= z_7VG3EM#Q{u(l?bZmuiH;rkPodrZ(QlvQ)=yT`6+0O#(ftA;aSZ@R74<Ye`B>*P(^ zSEZw6^OefarKW~v@{5afd{ComaS_p%CE)j7_Fz=^kLr)AcoOv*Yqf)eW$=w&%KWDV zw&*>xEz{VNvqF70#Ubqx^P4akOL*Ann1Ot+Ec*kXRf*~CKGY9PZOD|m8O&@rSsbHO z`=oL51rT7bN~;qyU3+_iijQn3YIO2DQwgW&e3obM-qc`^NQCD(Um?GQqKMVN#lxup z`R3b4N{De6-SC5!`fa1cpD9ybChze!Vhr)Vhi?TIA@C3pC!0vid%q=5H#`5gYdR(a z5~{?TFRn6Qd+Y8sdpY!4xM1yUvznHGTxzR>>i}y7D0di(bGOe~1}Iv32WUhTb8YEm z&si8;Rt(gSzGLyIz(D~BM|Yt7cwdbN_wT4ZF?i}D^epYREnCYY1#NwumrYN{F#!dH zVjsPd81n7#;*;6{yYn!+&X_ay8hn-h0b&Unfv+)Dt;wVUbd=xnT|j*em!TDU@sW?B z8(&6pwUbzH!4poSz@w!H!pnL<&+bvnKn&QZWC`frQAi5zpLrITdu@5cAKDkL<rqOI zOXl*$Hkdk)wu|iToB}jv&susl3eiftx$LNh=1(e#t%sOwDt=dVxpi6)`)(%K)_>LS zX+S@AhOd#s#DEHJCZGSd2g4~*m9lT)bq$W6zIy-khc|cm-rMV6(tPY^p694jyF9aF z(qOeLI#q+)k1pMBFWiZ_jqGmRPic$$bk~wk^aI_oS9B-&R<k6&(@CXGD+Hp*C^o`b zDj;Wf@BG{Jj@~^&8($%LRv>FvZM7llUSB!YOJ!lQ(ID-;YV^ZjN_`aadq7QHoPvN_ z<mZb^iDPn<A1AyvTvwDo@dDMxKBq7y5zosI%-18#?o|YN3xL0l@{e=aGe10-9k~h~ zns2-|*t&LO-j<He=aE_9K9gs|&SjQ`{B!r5!?#VEsm^M-tVNYC=RuSBWTk2h20S`2 zCd#t(Tt$o$K1rO&tl6!Sb3cqm;^z~229_|5LVxw%Xhi9;|KE+f?<G?{v-+Ed-Okm- zm>wDbv4H3nRr5FeRjzD$I+B@>aZ_5W{e?Rvs-HOI_R3DYR(;~pWhR2&fT;)S3u9N+ z+ftS?7X=GB4P?LzN3Kb;wS~>RIgww{V~s9&M_{DdYMy$8Ag`Bn=9rB!=qde2$OKEZ zoD<KAXjD$ut5cA1s?1Z&#qxM_ikAx9zW?Ez6w_;1xT9~<%4~u9mHxfm9>yWu<n{Qm ziiX(5%DU4-`M$z4qJ8>R51rRba)DKqz?#HD)O0>Gb<d^`J0XtoHy~B1h5ZoACI_|@ z>zUe?-5CIfdRTeJl@w<7$0<fj+e5W3G|eVGyj#D$!P1x~e{06U)k(F^%M)zSC)NtG z9ymB(iu@IL6&bf^+JKwBJ7h3w2)u*{H+g;gyT`r9*A+;;w-Z3BC#<bmcEFEK-c><P z*VcatN0Ey=u7y6#CTd3H)3W8F*9_DZIp7g445Xe#7w?c1vJXCFW?7Jyt_V)*r|zc$ z_f2tzR2c8M9f`DA%FBJqnkq<zaJ&7FThr)~Okw0ytta)>-yPa#GNJ~3ZYy|#G-WdM zob~YBz9`yK?deIe(9ZkEI(F_OpGu)WrN>0Z@5ZfUfCfB)GgfjIf$vyi&GU7LFS5MV zZ?g@ZSG`jtIG15ncr+`w>Ori*-mu78<d`Kj+<)2HO9`(U*{g}E&vkuT+ELESbRpII zBsBy}iqn`;ePBj^W*6wL+Y2rV$_^Kltw#<~9C&SG=k~2fie)BJSr?k$$0+BAd!)k( zLFGvb^;2UqjOmpzVJ`JjO6AP(K$?n<o*B2oc~52<yL4@TsNVGtUm!Zsrbmv~2A% z_E{L~<OCO1t-=s7{?wwEgpw4mclavqy#6`fUGqzjq_9|&jVbN|I_Ae9U^C~YV`R_^ z>OjTXZoY5!p2LmH@?JKkpp~gXBx||B)a0kml$Qs4S5t3b{AG^f+5DG|imH{R5Z)hY z&D>AEU9`$82m9;;Ui}wGj2LOsPX>#ej9m7jw=_3Wqz1MW3LkCrxa<mu_Z(diC`-uo z<e&8I)SC=z7m%(ZpAa=bJo%G8()47;|4&zW&cQAlnsTd9W_!UhBW&*`kS%2d9`S(E z#yNsSu?6<v;j%*>4<+@5>2)bc-azI+kcW~5Pg!4cN`v<q-|xfpTAYNf9}aBNWygzn znFLOw%xfLRp7_n|-9fF46j@lR_dIHhvS?}P14u$LG*&{6t_@n$A>|*%6uD0afG*H; z<a69e?e7yk7bsjjdXB*0mTuqxHy@M$MmJ{(M>XEF9Iuj+$=8V72Y}!Qg|PS@0ZK5= z7OuZ~xX)INM|wS_b^!ZGu$o^n&6YXwYnY{Rk6Zx4vRW(sbu(G_Ox#XwACK7QlxLob z=Ls6eLKa;MWAf*tlQEnY$8jJYruVG9ws2C|Lkq3CQ)rDIXWgD4&Uuf(>@ESDv6t|$ z2RaT$hby$=n*LRdsXBBkihL_t{^iO(&sZ;(S@5Tj*dAeh8g=jrdMFTSz>l=Pw?$z2 z7J+2SrYuj5!hM?yQ4zMa)476wyM|8;{~tO^bCSfFoz^_3S<>C%a=3tAubU5<74L0P zF6cXU#+B99WK%^Hech@qsHXmk=NfRvqjtFO1}Tru3P<WZ|HZ_$N4}E%mAf;h9JBoD zO9Nqen@E`KSYEkE@3q+Vy0-SS*md?A<%EtY-Lt2`LHN~!_od$WOq-%}F&P$@V_kcU ziYNFK>QY|`Ro@gA4Gfb++^EjBEl>Fp_^X=Wd9p!63J!ZBFupO{tES<=tXj&Ss9L_f zKDu5Dqtv?B?7G*v`CxniQEWA5c!w7h9@rJ^g=Dnu3%R6*F}l)?(KjdY+`2FnduJx3 zMX#Frz|ZxtBd4#%R}6cxBw(Jkj>}Xh-cM*Z>bMA;p93tFPRR+c-XX(PRVUyp^aTgR zH(r)Fs^OPnHfE43l^Huc9UJRty(o8|1|;3%0X;I4N9!2DpJ$(ELlabwl%wUNJt-rj zFf^F*Az<1!pLCjD@RyL_9^Q5L3V5JjJj^1gyg9$XH{$)Jzm<^Hbx@A~Yb}4%Ux~YK zQ!TGqE#d)v1nFySB+&^JMoOCW=^~;n<(|^J_hr~UD^8V@YG^88&VYR`zmkiTypvix z{!TsH#8&>b;_6k<kDGTzpIdZTd@!gEYI60HcC+JHz<w@v(@!_~!h5xDgoCbn2OM!O zB}Q}+V@b?u0E})-TgLD}2DPO1`bf2K)jQ^JZ^^*=_LrXUmZM7zOoJ&<TLo1$CZ5xE z^`f3NqUutH*~zDqhNUa7a9e0po93DPdN>E0)YfDaG1Wn_D*l9cL52rM0N&{D)`?{n zb)%xGS>@I!;;WecS=PF>n)*|K8lF;83L*iew7%>1Fn;s-)R<|Xa=<!if3|vUf)Nk9 zFPb$P)Y5yZQ8fJY@ajN~q^!A4Jqi~)u;IcLw<>ePooA-(=2uQX@KwLfvLr;p`(Dky z%Afb_-MxrwyETMhn2*a-o}(@UnMP-L+3>QQ`fu2u@atP<nLo+dvUEJoCyrxMHP7I1 z5H(Pn3H=?keu%bNWL&3J`~TXU75^$S($UE;i-?P3w!#fu7bfJS{o1Zf?iXa%9JN}X zJ3(=KjUE!omHc6bN|sxCRhN!UcRYZezmPGSKCUG<Ao*eqdKp+}ET!z64lNvY*Ss@; zk`g#aEwDqS+<kuKw(cy0ubvboC<*4Mc(c)=+r>e-KWMu7s4+!aR_4^MJ4>CS%&Am8 zrmir_AJ2G#dIPSDoybYQP+8z!>>>gponA)PPsX;Nx4u?o3H+kJx=8rKkb64SKasTy zp`zztArl)-*vD*HC%Ogu1sLZn1D(B*{kSljD5ct!#yOL|PuAiq9oK*e$a0Q(k811D z=Ek+ROb^B+mOAW-_*(yFQKIVAe3Z5m9`Lz$_5N0NHvm<76xMxeQ#9gM{z}h^;>{wY z=coDu-ZM_ve8_Sv`*Sco)#!8xutwi>k)RAsmqVEbx~8KJXd@m%&IdybLak?I>w#6I zF~;J?iU$(<yv~v9Cf0j(aL5q|Bh=ygd}p+-Mq2hE@83F@JFoqx4@LV}bVF*mb?_{A z$eb&8#MV_$_@bg`Gv30p37xkfUJxTV#hKTil0J|lgW8S#bg@Beamq?#`fHN27+tDA zomZ2%-0Aqtv!>peh?#afHLzn!W}VPwNsojEw!5OoYaX^ug;=o4*c(>}dRqlNVr-Zz z?di2Jl=^8U!&p<#HHsHKEB7e(;yZkN{&Nr}p@$a|+Ax$Wr+pKYH*@+O^z!IbEQHqO zQ|lD0a_bXKdY7A%s5w&@m1*Q6ZCB&=fg^YG0=+|OXJIv!UJbd|$kVNuopf}b*~6lI z_h<|$9Y%2+GK7oFBzPfWsvSJ|-?%*_Ser_rSQLYyL#I)ogr<k`&Hc|TcRzT!?{iBg zhVPX*suRR6a5aM?#G?0hf8>Q47e&KD1Gf<GB4@u9A1z5;mu_nZAxfrKvueWM3uLy` zI|urj(_&|Sn?Y0Aazk)L8*T8WC~JVN8oRM6d>GFZQPcVegP16uP~mwJZ}fd&|DrXC z`45KpzKg8vEMEJ?k9b03V}CZY6aRthPtHz?q&1z4u;4mb+t9bQ;YV7YFtk0M@qBf_ zFUawbhwlgI31Jn`-bWeklbqHDz+dAc(zwhoc0E>h!Eg_=vDkx;1J+kw8^2Niu6k%G zL+E{Hx<1X>R$YJEM1ZU3SFX2J79$B3CGuKJwemw_Ulxy?mmb4s*zQ*oi_0=?xuP=} zrEMCLUk28MII~k_Ul?P_JBlV?haY+I+Kl-#hN2URAaRJom^1^odXq~;M0P_k_tC`V zjM=J48jGS(D3e5DX7xHcepHTy>b$1)+1jFE7`SIXx8L%OxGz{{sS@=t_8GvA_`Fo6 z(}n1XqaSb)%npOT9<ai7uqOq}vJP6EQGN}(=>HXrm;*2CR-lHOKS84=)x;?+I+15y zMtkHalAA2<=)r3(wXcO~WnXQD)7aC9bai-X>-W2RyAD3~7wA-FJes$YV0&;P^mL5e zH-v#kcC;o)6)ze6x5!_}y!dBaPoGmKNs1$U!x5yq|4j=D*PX)F+hjr;b)zh0@vW|o zXN==Cf*b>NQ%Uc3UZ}3E=!-;!uGj{gRR`FO8h$w_J9lA!mieKx0p11@m8XfE|ElDs z!(`$&6VyXE?8092<DFeQoD0q0?`QtTC*^XE^&_3_#92AFPRrL+^lcLd!m(vt^@i;y z8Lgl({v*p@CefeceM!8@-0|@;f*NrvA5(a5Y#PVoO7A!fGS-;ohBO&}ts*2eJaZng zHg-&1n*D{Ta2i^l2q6f@+Zk^uLhliwA7z9Z8&ejm{WhQveiX<w(y(+IpO!F9eWzGe zdbxgjpJ?Dpmh(n_FJ#206n9)*xKI|ElgEhJH@?_ps(`E41-+=5h9&>7LY-9pgm@pV z+afoy>*g_H<Lq?P^$-mxw4#?gucPWQ-DZfL@*{?jE5afm2Uc${%}NUIS7nA?>1E_S zhwA~#hy`j-?YeB|?R6xFu_l8r4>Ife5V~bJ#0Zt?uJchEid)TK*Q2??5_bt&P%Cz# zC@2ZFK9rPiddnB~PCXJVc8IO>?=PoLZc}gE%JS;~2W!qmYo7aiT87S@?$5XkO{ZBY zw`@YRhrKsPS4zc(Y7w)a=Q+y_QKZ}w%Q6F!MRsQC@z<&emcU6-@oOIwAWw$Qj(kMM z<1~DJ7_ph`EehclUjZE1M`TtYk;aE0^Iw6BWzY?ac@j3?(bP=1?Imyv8Ah01urWTU z<Z`&^A)UI#a8C0@Ih#NJ<*$-FN5RP&Q%23}vYYQRYSlrlS<6ITFL#S*XX^twi-rp! z^UrR+T;jXP0aQxZ%?4ITq%quWyx{>aQc7s!HEpi~OaZujuWgQcM<wjHE>^Nji=N zTIUI&_427JHaSpc|5tn|>(NY5SMVeeG$mc{SyfiflwvY(RKOhunQ)c+S!xyQq`_Sc z>@wo%NVIV38SSa5+vL84ejjjkG6nAOZ8h&cgfj{b_BJRxNJnR{=ft}3jH$i2j=jWH z7thopYjU*p!f>;CXF`^s94lREv)L{=kW;NKcA_PBHgp|uL7n@BY$^Ch&@sqS?`@Gp zgY{oD6twgBxFt@*^8&^MkHRwo4^bo_%e=!BvJJKXr;s^X4*BAIGn7bc0G5CBHFoAd zB)}86!X|yxYRM5fFY4K_#^p(wX5?sDc4Nej^WpKv+}8Qf@{jJPGOADMslWQj0a6pz zR2T0*)%eV3WPa3Iq-%0i?k)V=^rq-l%LzhFmWgyH>6S0S0qHyTQ6gEQ)n)++Y%ylJ zAlCL))yx`<WSHKEW_?k#ZZ6q+HliwhHFD&VIWS{_J`iH%!1lasCyy5zd2wGFP!5B( zJ_eQ2l^7(JV!vfBDVN^K2Y=Iqyq^i}!Z>bf)VehXhSR&sMa0Kg<1!=x3cDTGHmULS z)pukYZX=eqq8t6Of$+jy>#6)n6Tms&Z?i+q?h&miBO>8eB3^@B(+=Fx5g10t6%90$ zR=<0%*z2~=XVw!@6B7G$H3b>a#+VZJ&g{yWfQz%YSvI9Q&#k16hkr7EdYVTbx}Q8s zu0CgPNg;lKf{gLsV2kyktelIF0w9|16J%Ip#g`LmVTKNf7X6~zzu7d#Qttjm{f|eC zCts*Baf&M%m>vzo%X7IcY1F{WH|)t}ak%C`)ZiKDuW<`n>LS&;M5dRn!d1wsQZj01 zLm*nJq1mKZx&7H*+E7{ASRPwuCu_NJpXH*idyh)lRWALupR88OqtWb1cK44>KXSS1 z;_z|%uAXp~GqES;`MB4iCdPJj)5v;F(J0DKEalc!N}os-lj`(_5(hc*%e)&;1^(W$ z;1}0U!R5;RH#H(}H5cEFq5KFnU#kEP(^$o^AyK)U#*y@FWG+19eie@A6A&gqxw?VD z3@3QcLh-3N<#z^nHsiIN7vBfpQm?+3M;iBqK4y8XXHfgRyfAmsMP>L!EvLtbo&43% zUmGC*4QRXfuCMOC?}KYuH|}mk^xxerqJO?BfP5)mb?qpERSiD2AC0@iCFy><OVL_S zdFRzFaH!Et@Lx!Sgh#B&A2mhWt|J&VqFC&bJ)4Of)<=mE@H4el<pzQIi7Mil0HJ@t zz3k%27k}BmPsf_SVnzt&gkCfLB@=45zBU3I;@M15WXx`EW_ut)ylVp;E=pzio{V~W z4>oZAKqg@qXD+p8c6v=@U6tb#^Vo80^m-PD-OmewN?zxmh;qkCng1$b^l35a98ttN z^p-;UyPH~uCJT|e7|RIm$;`XXo#~u)YFM*H#HgBeNs3ho74|#b4xBBgk;zoiKz&jb z?eApwAMHEW5K~G=>g+1QZ5o_=B*|_CKtgA<&qnim|9MDD#T64-Hp4{NW6MQVN2b~X z?Fb$O|bc+@xRE<Mp>=qvjXVrz|0J~w%W4Tfjp@u1P0uIMgP|21r9wX#f)_fr~1 zSKp<dOX^hQj;o04KiYx74sg^2bz7cxc@3`cJ%zZxk(ti@%zuLVhn2*4m^oY3vbU?K zp|MQpNUiwnmmi*%PYcHKK095!QJqpzBy-{$uwVSfJ$2N*YC^O5S~Dkl4kC1)Q87Oh zcKA%0mFM(%U%Ccg+ndMKQ5|wqh`Q(o-e?D~6MoPnXO#7Yl`H$GKiWPm*|;k2qPw~2 z%^&wMqOJ|96Rg!;;U#l<SK3P0uGiyhsxPJcS_bQnN=_OeqfI#7zi2H^czl*NeZ-@0 zJaCXN?sm9bH)?KWk-X}O5jCaFF(Zp3+h8jkWCfFKdyNSG`?<ELA9bpb9JgO;%EqGc z<@r9Pk^1Na*r-j(m|#STNSeAAbA#(^T><n))eP*c#wQ8rY7X&6#h2HQR2haUWnuQQ z2Nn$^M^&mwz4L%NMma2Msa+#qF$|StA+LB1_>I;P`#w36Zu|L6>C&`!_&v5G2<i>> zeBAKl^lVhd#4hiDxzFo=xKF>CQ(=P&y>#8`3mQA9*79mLnNkak8ngCEA;rY7MC0R@ ztScnzGfvuIO2=>qK9G?4fdQR&TK9&W%%oY44I0K;Sl{V)bh#<4@zOWbT=@9M+=KWg z;`_?Dfjovm*7=e856*=Y&)`Q;a!Ke9^{*$st+cf^SK>2HT3Sbcb^qE}&(LF1BVwy_ z`IF{3>8CQ{$SFB_(OJBL72AErZ}uD8StT*wx?m%WFO%lnt~?MOGje<<1#|tR1fjo~ zDp6A+@&n8n5-1aofcRBo)t&C4{iX4TnTkQkMGFovO&1<L1#D|&sG}xYs%){k1hH}A z8Ivt#{+f!6UP=z2ZbI&Hr-}C)2`+vk8kTkg6U0{bjkx)6){r`$Zue8d$T5iH8fX~T z`M$QF(NP|`U?>WX_55cRz?Wq#l|wYJXMl7L5~OD`T7{&@2}Z!wYXs1%t>O<J<d8{g zl9lc)8V&fI%@9x?{g3d76)ckMI6wC|{6K)<5`eg8Uex3L=(leSTAQ(`!Io}}1|&y3 z7_<mH!bKRZhOhQ)EK2pxYv&aFH33FPSJ83dpZa)4g|tE`KS8BnJvGpMX;P<p(;YlE zprFJ;BB7%`cEF4u^at|m!Fo+zUCHg0|Haw87+2vs%`#eaW6|=dsB|d?hJ+*>CUeX5 zRCsW^>_r&^=jD%o<*RUMckBaRVHA5?Cja27`W3BrQC(h#qpx3~zd}FWsWz_rIOJRU zi_OBnPrhTV!-A!_`@jTQa-!g#*mLuf>=@lz%#u~KE`~V5t=H$M5AkBf>>GSLfi^7C z;j?7vO;|N!uD_-!zROiYG}#rF)ApS{3?&1X1QK0?-JFbbN`Z~F?wx_EMY)@nC2mBq za#qRg%9*jU){-9!`qiWRI%5*`H6pL7cqa|+v5wwIpiAY>#;m}-R77Vnebb?p+M-yq z>Ch&PQ6Ox#>qz_T4KRNTU#@a@81u|+A1?fRrWF61P&GC59GoFd8wfzS7!LI*!vHen zZkfnL-{uzHBf+<gRyt#wZihJnGUPFOw3z5_CT%8B9^%i+D%9FlvZ-97GSN@XL=6O^ zh?3FrZDvSm()cdRpYALzh>fxTLvqZFR+za)4`1dh?)=i3+$rv<S&Wx+qZF(bd$%bX zX9qJFKa;MX^ZM|n^qaTl_45AZX!$c%j(U)Db7Q>5qud$=^5vNUIY3@bo-t9*^$ccm zG4?iR^GE+a{KP%tRLp?|2-hv~$suLoy<f1|o7H)Nr4#9o+0B~oe(6zwy?4CW>(wjc zR?aC047fM6LzbLi2aRSN>$5R6ll$hBqnKg2#a&P!|J=jRx)4*<eaCI`U<~&XX6z+( z=h}2|kAsooRutHs)J!QI@&rB`4#_5vX$5j%>Z7O=z-LIu;>Ax*;J{|Fb(5_r@!?Fx z5(r1#8{~KTUp)gM=D0QKR0y7YL-6bl*?CrmN0yC)`60LZuTMYppdm~ehoA2wd>-rQ zBr>#I4CaoJ)xi!1^)FJegbK$F_HX2>i{((J4KWe-jUUebOI@bb%5~qpm4AE5aJH?- zVfq<?j-j77H09sYp2rejZ$ceOk_qRf@gyY+uI$f$jgS7{jzWuy1LK#=UG^b_8sZQ` z^`>Q=JEeW~!HE+O1x%3awIRG`t4rKkCe~KmR2$e&T{aHngtj(shXOHuy+KduW23mK zwwpC6wo#gWAWo$F$va65W-Hg+&Z>;P>^PKgHLFw{$tc&9(~D|L2UI_SY{;)ZBi4xo zW-+@sVNo1Mo*SG!A{Hue>~vGY;l|m%rbxrF^{l<sqDY^+N^qYfSsULEt#fy$&yM*q zeA24bXerB`@Mx^O=6eEDUFUPlDyO0UT<0g$^PK~{J8xCp5HD;i((ve7guzLreVZMG z?yr%_Aw*7Zl*d?09p~!(numNx5s>bRD*RXgX*3YH^m271J$+S<lST?HEHW}^T)nIH zM?TB^jR`ymfqwO;>aD>Yd|>`x48HIW2A8=q<QrMAhwC{Ynv45Pt;-;`F!M}k%L9s~ zY{!Hp3R}B$IcT{&cLQo(TZ$iqlMd0FwJ&89Uv}5<LCdGE9YS!X&ZID<q4)6YA=Osi ztD5<-TM{^&tyE7I8}JxYm%HfIX{-7{zkBUp)o{LUdy*#8X!&Xp6R*hoj0vV@t4pda zqW&(HUuU<+*3q=I1G9DlQ7qM{__D_n9uQR<xhRF^u>srOa8LJ85U}K(%T)DCDJ#-B z$Uw8s6ef3G`1qOfXt8WRaN^v!ns-X5s$X6I$!x$wlG`C`QQ)vMUxT;hdpW4%QM%jA zn-o+VvD(L~a>v1Eo)V>aP(*N@pBtQYrm^Jjt}YSOi*Y`e%#hVSLGRSFPEL6@(l9pL z+vttd(?Gk&4hC@b|FCIBzP}p*bPv$}iX4%^I;Z^&WK4XZ>uMc#phH;f35s-2CJCS_ zvKm#^B2)U2urLRyIdvod`!mDB_6i+%q1~jF_z2&PV*4^D(aw@(3+!}9{SfT+7qXjT z#|1D?_O*-x7lyHvQ|EKjx%y7CPF<=d))Xf&xMm`y2eQqJQeZy`Mmk@I>S}?m#tfVD zdKkC;<Qb00y#iwfsuyfYdGmneEM4Il?|v`5?0OJ@$5K|=hJGDGM|l(;p&|P|b3#}1 zz)DzI*bNIbT2atR9hXFCVkg&g8wiP&fs;9-9zEOBR{mVzt5~v@l4DP~_V(t^+Na-T zhLz4+XQ;>NUJOqz4Ej#^Y1@4U+h^U?SxiEUi9LREnc`gCBR+JKx)peZbezWhKq}-C z*e=6=!{_yX!sm-BX{n+DQ`GQ?dua9c<y9AQs`nr*0J-=~ITe?etKMOo>#dQ1*qoC* z#rC0((mpMJn2MQi+)+^dV%jp|agQ^xE%2A9jrTN^=~C>C4USGeJ<yMG7WrD3ERoK$ zO4|b)G3cIB?#OjP-_6OS?~aXByn9G*Kwnq6Yv<SwxX=L_UW@U(dM;poFkNmIdBmgo zHM+9PI9N4Ce7XS8LcYS7T{Q#T^{mbFoMC&cjo~});RXdxRXyEYt-S!NDT@ro+0@Hd zUrWIjTbb2vnv1WnmbFAU6`iCK=QUO)RyEVZGh=+GIv14!Vh6@PDF5Pc@M8TM2nq@% zA9c<|-{d}+w{<_9kQ=eSK0?bN^u1}IbTshTQywQ0=X<=b1rFxRdWPwrgbGvFQO2l& z@8_s9bVZH%iOSj+|0dJVK>sns=V|FqvoqyH3c-y+baq`us^-sgiWyN0@lVb`nzg1f z98T+yvtie5{q?s&?Z+Mgyk$-liRG<I<(2Gy^rg;P8A`ELfI2xphRP<sf5j|a8=#N? ze-u^c=xt^>pR+t&-5P|!zlqB9#hD8;F&InBR9kDiReM3(%9?{$7m^)Mpq>@#g6FTG zGZ=0lLqAJNmA9!yJO$u*KK{<;(${`xbGC3jPB9gOTsT}otB#;PWwSGH$p8sec+Mku z<z7hXKFp%A*FwtAW?HEqXi44`W}9JBgu-=fWyJa;S(a2?{J>`(57a7`VV7rU4?nnN zlgnHt$R^FE&M?5s1;wUo8DQlZ=&frJkCPDa0&FWE{N<l~w>OkrokO>ERtDeENNZ@I zZRNi7P$y7E)D^w^k%(tHmyI&Qh@StgI<fo{9G{({b=CWtstlxX8^a{)sTxX;oa-)Q zs{2;frr5~|kDw`ro@FvY_Eq!a97No(B;^%=@xidW){Mepy{$u;n~ivmMhn%orOquF zD$|i{!+*n}zZuMsxZ;*Z*6GjP{F0(T=wCO%*UoYD!J;&DC_}-P0q6UCD!CWQ)00oh zer{T^M;j4+O~jWT4S*qV)Da%zzh9?Ts#j+v1mWpU7}}6uvG>e=xjST7OI$1F4ox)c zQT(bRH*Hnqr}lh@G#Wk~Dvaw3^R^Q@i*e2YmPX3nmF)Z`RaYAR?V$#C!X;RgbnN&9 zg3kTta?!O&<Jd@2y`oXoVIj>P``L}kH^&kXzZ=${*X3sO&j<`?Qs)Az=MSrdeqQz3 zt7LPW5&Lh_QIhyii>}l8XznZ1Au)<HXG=A_Oi(RoAAD#o#~3hnQNg(RQTUyT2tBue zBvGk2S5ve2qoSppF5)P`b)>!}lw$>NZMi75aKQ<v`<47+<#_x08<An6Db~Vw(<;h@ zK((rnEUU0dDSoK`p!_+pp*lJiKrs_lXn8#Ro|$UV!yz~!vzXZQWji&nw5q=Pc4#D~ zsC?|#CbA@wVwmqx%>$SmMoM;?IXM>@yoTGjn0F?tZ#0W%1oj(CA4N7fN1@IOKaa&Q zq1MlfqywOJm5#4!@Gc=T6<Y6=d5%CQhEj^lg^+;?*^w0L`;Fz#JDr#JRFAPTC7^#d zyyn;LFaCM%JFCCYW=ij({^$PRUE&!XUj6q~Eg_u8@G=W){D{Z@)DFzr*8ahf!%Hw7 z?!K1D2UR#!5s>CNQ!QahvZ%0vG3$CAyNV-(=Z~86kQ0Eu@7_}9xIXqbn>4Y0+E4S$ z;rUo$6AoHY^7TOGHFom9!(A$2>t3_tSldIsiOOBUV#|&D1#ST1OC1N3IVxw+*-qs% zPVyspilp=rZK9I7WX%3|fMGK_ZXxUOOkW<cL-;*IBP$X^%d|E!75xdnGcDD1vdZhv zeW});t6Fvy7_nTB&*Ww}a-9RYU&al+-$+z6;oxWvUwl+R9#G>+*`WASLb5%Ev#*uM zbC9)Z>0LfU7a*hGvP<g1NB8LG^rMUC9))rjk*7Xlv=NW)oGJwuUr=%F<58L;nC+dN z8>V0<9^`i%)wG|;pQJX2$`Fq3na3vlAGY2E8tV4_AJ<dbZ1p5+kf#kL6vn=U<dN+A zGAO&T#2EWh36+K-WY4|~#x@v?v4m`69b?}aX6!p-`M*7%r=IWsoZo#;op(;odEeK4 z-PiVdUDx}h#L!+00F|Li&EQfC-bS%gTxOn;0X9-{BBs7c{QH69>6f6jBQ*+fqyIM< z{`4yuUgb6VmPANNmdae?HtX$=NA>(<b?TZZ;JA1w2fy7uQMnvFDaC0>k9hd9MDmr8 ze|Gfam&?y)uO7HOlQ<<B4h`laRJgNWa>G80gw8Z^cBXhe@gChw5I)0U<h^+oZ1MA^ zwj&hsCaUU`D9MVT(lPdP&(lqc&Wczlire$pp#Z@R)XUyocYC*MaJcjk2o!3!JFp|J z!pEQ6RRAlPolkM#KbzbyEHhk;j2NeMq=jcGIQlTn&DjnWK>5id^qIzGf+<BSCDLVq zIsvucC<YvYl7qz?&@kC29Krc;wPlLa36hvISiYTGRod<hB$X4Ngtvc$4sGRkYQMiS zF`wr4Ncu7Wvb!zF?n?)~IhsD$U{Irwf|mi1<)2kJxAkl2+=M=H$sHW<O6T0N|8wW# zonyUoM>AP*URLFm8==p9rVH4G5N3|WW(!dV@VrOGQ7iM|YB;?)>9%&f5o+YfUgqtv zOo0WVqghV9&+IEcFWD~0W$>ei6I*Ocsgy`Y;PJw#jz#gp`-vrIgNc-p@#BxdC~P0R zysX`}P1>p%l3`Yv$!+QAKbYni1n|-omL3^FN4J(bwOtc9R`3^aGmA?VlyQhj&pcLr zVO|?$=jHTf9N2R0b;{3%cPnb|W)}=qt9Jp)$0<B@6a_u>veAAIuC2`MR!YwLFO9hT zZ@x{V_E{|}EsaSk3wylXEU|i+Y`2`~S3HZqKkBz7ahzZ;9lV&H;K)61?=Igo22?A* z_$2qWQ_oo@M&h#e&z&pX*bud!j$*w>znwgzTM`a;PQ*x;sExH-n|kRh9qoI;t><<p z0P|o|iou=(Q^!J>r*4CM)@OAOO4eeb8-y5JqNuFW&Hncb#BsvZ{F(nuYr+4IkwwF; zX5%CW^I^2zL?sh@>NEk~9D~DPdQIJ_d%Fb*T!v!SX=;(o9|JN`NKXQ8SgLqg5%e*Q zVj=Guvbi`HrQk*yIiFvt?JvUu$*3t98PJO>N~MPM$8m&fAb1Lz1cs&2jt^cm3dwIL z?n+VI+%{1+2;JwBv~AqsmHErNQ;4CX{9$mRm3FaBR9@kD0|pz}ewPaQslyA)@2A=Z zt<Aby2=9}#=i&xk@+a38#Z{7e_dk5tnNS*TYc5gfy~>l^XpJbY>U|#S!)SMm!x__^ z114YkPMij+(@C+yuDt~2DgAu2a-_%c?zw~2Z{a-j^z$E&m%{-L+wDe!+cF0NOtwy) z{{Ojbc8-6Gu&*O+7eDBucg9CEn2sAp<NOym;H)vsv~<YFX*Tx*3%QPK!rQmU_!Qp3 z^F;k|GHM9u?nDa4r(eHt-hOSb4)IXO;-1o#)JMjfofBdDa9_WriKiq~ca_b1(>1d_ zGX*U<D~j!UU0Y#2m@hX3bX!QDVshwvHJ-vuBq%9#RL1!&KoGLqA0<jQki|Blrrt?q z0f#IMPHgT}p(G#X+e~XR*~rT%{YvPALFC76hxhmOWqDh~`+fa;Xgt_OP|aUh1;<HM zS3_*0_^#-cN82=mG>4-r@`u|n*qp1$f8eHFsYM5Z1@6V69jV9%;i?NiN5k#>|A*BW z2!+WU<gtW@X}>qy>inTzlNS`Ibnk~!Da9C<39oY3cn?mkD$HKXGR^#T0ca$CO+(=} z_Ha?&poZBV6zP|)Zkq;$<pfwp0_+354qZQAt$t{w(e_tW$&g%6?2*mkQ0rRbB|py8 zfM6a^=pd}^n>py?*SA5b+72UxJ~O0$hPZ#gQtoX#!Y24m(d_PsFZax6Hmlpav1bKJ zwn{YeLsnAQQ*+-fN~Dj*Dzy1c5y@XVYmRD8!iG3mu_36mdjx}7sS4@fh*-Lu3egHM zOADSgS-LjOWbZD9-wIRPv8CRizi3^2iPPq$u)BxtlWsnYx~-SYK~n3d>Va_H#2Dl= z+joJ7Y9BpxA;$WbBjs&n8l!cjgzH=OR$;<f8nS$Pvw4uafs)=YG|#P6n@x9Cr!3e1 z$+<{FW6p5+2Hyvm7N97d%7VK?i5sI%bJ}{j2EX(@1BB$6D#vT%TJt2E1Ju=NbzE+D zv+3``#D4-s+O4Cdhjy^Z3k?Aq7OFDq#g!#=@W%qma?vie&&SnfeS-V_^a<W-8=p#X zjxrBz3q+xyw66~;YEqQ-`%N^;8B6+Io|f#(xNzh#Mj9O+wjTOT^JmR|YyZ0Ncv!|c zG`FwXX@nDf!+klfgK!b?qTeG@qIW~$U|I>hQ+Lxa%cp0@Iv@b&ez2c!6tEx;5O4<L zbfjN~zD<7v8Q&-y`H*QEX$~EY;r}ZMW33z2RjrQ+mzStXkA7A_5D6<Kx~)<;^~h;U z$D9!>hyHYgZ%COIJ@+Bq8ViqA7wZ5s*ySl-)GGP8cH74MQ`Un>(`1f2a7hkTse=f> zHLCznuFTI<G_`TCP1a8P$<=E?yLSN){_WiMjr2>L-LwI#d|M0*TJO=Du^T`CLlOT{ z27UiGLaHT`A3U&Mj~b2hwJHx@kuf%w@yHhmm^*<HQ@h4j<lj5K7)iR%zR0v4tCf6N zZQOWa=xD$MUom@7Hr;!vSXpzROZ9JFh8&Mi;eT;9uPwk8j2bF*=A10}KY&8KLp?2j znq>{Tlx672$i;uM@Ot?)1W<$$=cT9<>?COsU@F@9Q5hYp*DeO7q_ms15&HzG_p`4@ zmrXD_40Fap6^U(F&Is><SEla#nC<S_A<-uL!OFa9svvJNUbfWaCN_X@O8i1RcSgtA zLAGQj-ecR>pQ}X=caajVJ6FihJJwDy(uie-9?eS*y&5xQzE!k?_YvN}n_hH@E1nIL z***5pJIPftt~J|vdF%NcZ#rFEyX;uAe`UyfgZthOP_gi%{0}i>|6OP0UvSjxaKa`K z3sE=H;ymY65x#Tw2;XgqE$2a=psqi&`FcLc{A4wO>Np2YK($pK3$dv^k7#7cHFt6o zN7LRLc1~6k@_C(MfgFc2sKz6;g(EksZ<$w58LT&`H{0XdpRV=__5QdmU^f-z0pKF0 zQGR0Uu}EQ8DwsT8fk#g<@NK}R5n+6Di4NhHyy1+l@Vu~`9he2vUBY5wl{`pV95=+p zOFItp!B*p479mjS+Myds-eY`k<||+M+XVNwq5U{Vjtb_iQewS3KVdFjiMLw=vWnva zJHAh9KihY^&eE{=HidTfX14Kvzx3e<!NyZ>=QOn^3KVPUhWwkec$!=WJV??;NeL-{ zvH_iAT#5!3O-Y{0<p03C$AA7x^(HKc3vPhmWlqvPr4_$t#xg#LvN8X}vz>zlHFf@A zt&f_>q51fkR+$9jnq=|*2Nz_^Opt4SAgOH>+BeZ8jXt=42M%+%V&wCq74*WgO<l>( zK=@f{lXJ*~eP8s&EjS(DPEk8(ca7FCD=o)ntS`kUsc(IEQ+eFy<{8JDgFh0tjFWpO z@cD2t@VoZvTpp)2_v_}WbCey+^NQyQQ#EM9%{tnby;ur40^x4#bWaFM3E!eNoO_u@ z8{RbTYklAsiGAU?#Y>9o6FP(5QZH!kXL+bsb9BV3k`&WVlEnHtV_BF!k!yGSGxv6w zS|ko=-GKa<>1c-E5p#5rf!;a&8t*snB?yPtydj`vh!J132*m$pkbwo@RoE5(jSy*c zezBQovOWiOD{~yJO~-jpav(-LgPzuSrUkn@eA(Y;-eT)0t3*H5>y|Iz&OMg6bISA> zO;=^apKTW_kS0^9tgb0qI7KygebciK!kWKwJ)AIeKFd8#G5EStntCN4g!qU&U#v&} zwT$*Xa=9Ff;``RdDN2d9(_w?aUhMdDU@0!}Oly5B9DuH>4%hfqoh@UkdXwSklD~YH z4KdoM6=mrcG2L40z^{<_uF5CGsP90$bfgNyKg+vyF$N~gyYtN0d7^M?cKTFh0S5EX zSC>wu#tEZ;RSRF*sAjt4h??8bkmgQVe#+tN$Hq~V-(5ab2|<i*e6=1Q%UbRc<iyjw zp0;j0el66!-uG$jaQZmC?P!nl{^pkE=TD7A%2;Z^=pfZY?SL%bwTGj^Wz>n^Fn%Gz zCxPEDEJ5Suulz6J?VPMw-X0SpWj?UeZl)K27)efDm7#d8@ye93Q~l3rcK+~9`N?E- zl{HVH*5zTbOk3hg{MmD?7ah(#9L=dCTNfhM`pk5Q>*MEOq`o&ppO5a6ZfUSZXxM>_ zo|E*!rMeIiqJS8qVx>iEXov<8?Xg5|Cnwj0I)7$@h*DTmAG-LCX8^~!ug+w@eHi!; zD&QH2KO8pe&V<UQJQULiZ^;jzDXEm3Q|NQ5oA&e=9?EWgLPdX)UqWD`#_(~{Q3{zP z=hAZ#(#wHUu?fz?%Wf-~gSG@LqNG!?h?~OhIrK%M_fT}#&$h$n{;<jLl+s`6Oy+(W z<m>8l*G5bnU6iMHWRXQ#X1(hpe{lW;6b?V5;%s*uaD>5Amx=eD?yvN;abT&a<-VRD z!i~G{%f{TI`psU604mya!|%<lUn+X*;BTY<Jx?%~NrAI~s2=5aJcn}IrOFcP&xHUm z=cN!8$|b86Nlt@yIE@IH`uSO|;Cq(16+>?)vF+VMaVwTbm8h*hLb))j1sM!Mx;6I8 zpaoS>;+Nh}rH#^t3T*~~$RN}=#S{Z5dn6~UMiaJgWn*ocwj-4ht9$Wyn~Bku#44M? z5R)vxz_{s}V+B#rI;Vf!rIp-ebQ;<&&0}bR-rQhlfyc+>R!JhqlrnuwirhNW6+V@z zS_u8{A@-2)qr{J&Vc;YU_sW7~UKK9q{*h~S>#_6q(}%aKF{w}1rfxZ`RK1O7a;f@j ztDSd+Jg*>-EhN+#@{oq+anl=4+4LfAv&>5$u;W$6Le5xqpz#2L+w=NZ*aSdY&wf!w zX+D2H4gA8%dIkC>E0(zzXaUgWt~z2S1sx9$wu}M>_sGMgYI1%q=H=v>>j%JPL~7)% zl(Lck`mUlIJ^HSL%#xBKCc;cI{(JvupORgpepl!EtZB$(?rrCUZF)`CK8ZqT0iW~W zO@hfSG@~5R&rG2&L&V9LEtS8Zg~DY>ND&j380$~eIV|PN_2W4kcQ&^TW)%UKknko> zgZ$=c;d(j4<o?d_@A+obQh=*c(d7&&Pp@-+WE~l#;UN*tv=J=vj_q#9s1f*&X+>u% zGh*>+H%wVUZT|3mW?*a}pF!VR+0c_^@$3TqyS<~>gQT1x(fGG2otdMB3R+4*-ZI@| zbS-=ey4aUBdJb#ZqGcfIC_X2V#pJ7P(h-`8p((pI8N1B~KIh=8oj1CsQi`SqflTf( zKIxbwAY9=U87%8{q49I+vA{BMGFes*kn|*yzdw_gKlKu{y*=Ki)YI*){Qrauv@ia4 z(Y=4$yim4K#}*8V?%4O<CD~ERoe29Xc-?FFkF}HUz0)|ubeEIkD_O(&i`oPEY%BZX z^JbN5)o{rHK^_MeYL|v-`A+B4to{k=Ws=cGa4r=Uv};Q6)b%)DAo(qhPB@wfd(_7B zcid(?pBnwq)~$i-jT~v)NWE%i&~=;8<<DVvE&t4RDzJ|8aVh7z;is55NVovN2M4~6 z)FD3dc;&t2S0;-e?DOT{QzC!(`&Ed4{V;z^XBqmUM$rlVddokzp+K1jBBv=a;OA&{ z4NHHSTW;0BaK&4eFf6(Nb%=Ht*BC`IjsT=jBvIs@w?K$3cWzM@r4}1+<E6UO-fvMN zreTV&bF3%FW}Did?bmHArMxaQ%$Bdc*t7oS9%Ah99ZPfO3EUv_w#;;^kJcu7c`9OT zZ~W*<%;AP?-zp*5pcgH+8);^H9Dc)GJK?V?|7n=7b)K9Or+?UQa{$`s0ScNY>(##D zw{QLP8_s0x@d6d7PaZvv49Di;2TJ?UcMhP6*}b%$Ds_iO?)T9W=P7m8zqkNT41%5R zDnbI=V&W73&OiUgh%0bmIO@Jr;(bmr{$W;Q7l`%<xsNu6jl)Xe>f8f`+S%h&H|OpS zwCG(<ezZ5E?y0xO#h`G-n+w_IEwvvDiUu*yN>EGPN1W1mjUN_t2Spo_#gwZ2S5!bl zXx*E;dZqqey<2q3e^rYHloX#X&AqShvQixAj2V~{G3gU2XRM9hDzP7qLYH`BBYwJK z{8@BIQb#ney6O*h4CR7RJC1%U`{^2^w;?NnwZ=;zI+sfTMo!JHpnGqeovex^nIH(A zdEPf79#;7nO?$pp1S3q*Q#bVb&|;ZDf~X+7AJmi_pfcKcSH=<$U6_K+3r*+aHreiW zW6r!m5PK!*(NfihjYIGiOQ7`yaQ$}y6I>ap0tz5s{hgKD{^Fd`Bs5iy7G#=^1Ko1d z(nD0#acE|8fYRt5FujeHmcLCPe^BG6VBP4tZ$MIjw0$5&zN}21BnsV=pr+EzM|0RI zxMtt2YZY8%AE{{FNL4W&O(g1^5uCLr{n2t$`wAz1lfg9-pTqJ%vJu~x$2h)KF}K4J z4d4!bsLh=nC{<J@Z7X>o1866pe57O=1j#8BGg>>THJeoPpq5VO+jY)F?I>UNWazJv zeSr0^e5&j@;>t#pWlOpK9JT$S^I=HzBSSw8gB0T!q=#G1`cTSslJ4Ima~H93qZ^6l z9q8vM);q?_w(BXZ;H`sUGXEuD$P3MG#?kY#!tC;+m;e-za6_~=%AE9laL3w9hhw7- zZ?6FE!THPnE>a5G@dM_ZEmZ#a=KpjQ_&Ukyum3)Z9m1_i;KO}tod2PpW5ue2Behi( z1hkm|2~ZEUt-(&Ir|i4)P;Hr5wYTh@=1N$bKWfWwWa};TR$e;WE3Or`AiLhjxe-v6 z4s%zKpeiZapRe)71kZ@W(zIJg5F1RrdBK0_`E$x@BH;D&u|T;?d7Sf*t$!iUc|1i` z6jD&=56q)`E}hq26ntR%5VNCD-9Ok?PPpqQTYe5*pVUq;+*|vam=a%S+$gtvbp^Wj zxB|V{yZMJ4B5VkLnD<D@j14wCY_#5*52o)>O7(TzUn9D0W~t{2NjHbwqnWtfb|A&f zK0SWv)joy)f!z+SQk!&yK+Pwt_}<(Qz}XBW%d)bdN#VyOTuDBOjk0g#&63@H*H~l} zh~LwgWYvE`VKj!nt*TJ_$hnZxU7H=~K}zD#S`<K7Z)zjpyaFGbi8=NB&$pU1kG!xh z&jkDo{Nnejmezdio|UNy=uJK)HWHb=Z%$syCGuf(ywiB7cHP$rE>_<O@-7QgYJ^Zn z5Q9bJW=4+mZUR*|^MjIU{|xU+ZdHZEsGgDxEUIk89bF$>=Xv9%Wnf1>G~S89tSU;r zh&SlHU(16MVh>#m)Cv30x#**^XSJ6pEPVHo0rja`V5L9P&!rdR?<z-HZd$%2H;xr` z<tfjt(L2%C5%}YY&S!}6XqDuS#zPB<^$i>JLy5b$?Ev*yMGEyE;dU`|+Z=rWE+a`E zFFg8`Fn16Vf+m?A+W<YOopH(L@hF^0;BXmG__6kKeHtl%RsF4H|3_dNfEy}VStc$X zSD8^f!fYuq&V8#{{Jj&HVS;EEN|=3^;%l$@teBBCT27wxD|#xUi%z2QVS27wZ6j21 zU8DkP;DXX%=kWC|d9D*J_iSUDC;Z;ac$o@^`^%c@?egM(s{Hvq<^6S57~L#;J4EIV z%%ueB+OXx7sc}ZDjo$u!6$fwj@Ph4rtSEeUEDJ652-dSBcU~?YRq<HTpHc!E!GM(! z!S;^5bLf<8$k#f)IpgH(WE!A#CMDPBdM-K}>4>#Xp^p#Np!GtoI$i(F;BOX1<$q#a zEY)mMFZsjyY0CQgRb3d5Kf~O%(fc7bC2{i-p66z>2uf9-bKfJF=*Rt*`#T!UKluMx z)MLK?m0~eFl7F$}9PazgA$x+*g3=3J8r;tQR7l!MWf-~~s8H+_!=y%2dm*AM^IU)Q z8qjeF*v9#(<Nb2<US%8pyV9`RZVmU=B$I!cz3flh{6Tl>mal(-oP~Pzoi!4`hyQ0i zruzkl*Isuz`uUFWaP@_%)OK#!s5>HfWjFn-$EUyj6e|RoU$ked*cI48IX>sV45`aa zu;h9B0e-(+N{BgDf%v7Ao>KYB*;haGXb8pu(jDb0t0>o%vpn|aD0~n#s3eP$_+~g# zvRF)>)Ao4Jw_;V`)Uu~_wTHgWM08+mi^Y9~*j<&ZV*{WQ{r>Zdjzil^K!Mj$+u3*P zJ2I@8C695R<rKlSnt#EBl&4V5+q%P?N0jTgX;Y|-dC=<8Os)47K@Z&T#v_sJI<!WC zKHS2U2F87c1MM7~Q~mhHO(fx~@y_l~!RPE)1Nakn<Mi`jDo4cfS}MHI@SvjNp|@~q zx-o3Ds9Y2{gEIO%`Zh6g7UO<i801>AOa`WWyCph$@1!au_9pr%_vZHTJ|}vEH@V+y z{4jW5G*2?0RO0FReC4<b*Vo5eWjGR<?rf`J?2L(%&x=^DPmMM{GLfn9dy*oMH)QaX z)9(%KzwrP~>2Cl%@6_mQ7*Lw(9~r~-S0+oPd_nm>YY;U{fwQh?jZZwjoc`RKfp$mb zYiizU3_P-*XTiv=i*G7yuD;sZMYwmo+`feDT^?=MTC8k8sKIZKGa5%S<`|W^XpPbg z(1P3c-c^b^Tz6yeuc>KpcJYFZMo{ims+AP<uC33fGagUKbzlMBkoexHAJEb=pMcxA z>R3HDl}afw&MJ<~CwoVu`JtBn5I-ABeKt<v{QbME=M3MMRIrd-tz>SSnvO$=@<V8G z`w4d1qBc>AiebqW=T%6^=w2*#vK$s6<8;OjNgTCm3$7uIG4W?pl1l_1ugpzKGK;e5 z8^f+Eu)k6x=8jPO3r2&i5XT3G2<eUBXcFu0>l2|XpRh-pl6`eaz9hP#J%aJo+yDVt z=?ChaWPrY#-XI&GRW|ZMPCfMt8%k#!=q-2o<HT=O`6SPc$$lS{rUGqhIgM#ojHCqp z+hNq;v*3q<c_|#XK|?i)6oG+^EmC3z18ZU$@FQ-APaB*!`WKh|9<cm-N4{S@W&XcM zbA;iyWa1Y<B|c=0b^TP;w{2rBAWnFla3WJ5r=^;w-W@ed^zAR(!t&@`_uVDV3PutS zy)*}kY`=p>QC}?8-U<JmD>jk0KQ@Ol3l13dLQtMWX5-9~l8D_t$^DCC4ah%_mN-Fu zVFG^7*!U&YUlf`}yNyPf^g_y6@+mjL^%dQ23S8gG#F|+XtNzxwHr*x3l6IHq1oo-v zo?7oxR7u@b`ys4%6&}fHhU#^_{)dy)pEqkX;1_T!FtqNRzrVk|OqC-z(Q6NaIGR!f z#I*PLM8qx8SvKe$sw^SJhm(j2m~I;a>_wLErISYX+e}jVfwD)m$M}k80Ars|QS6kp z$4c*y4QJb_K_XD=tuxz`0Ae-18#j8VCX42h%R@wM+rTmQyOAe<lDqa{9!IS^*L@c6 zvM~HB)WTc84G*Yq$sPU5(<Efs@0rrF&r{=oC2z-?t!IuE2fkbRW~?Es`t*)*&z0|s z*}utsx+^53r}<uV`66YR6_F-(ud*yOZC|g%PORbpd;!}kIIh3Y-bH_lb;tIs5Tzv= zUNjrlwCpW!!Gz7mkC5GrNB8$A!s}?`KGABRy;eXw@g4dzz9C-;x;~t*WY{%QiL_Ex zRW>%a8d}SwwwdgZE`^JGm`OIFj)uvV{2$mpQma4)mC1bKN;k*a>M|k}1Pd=QFAkg< zZ!%@NIke7ub}TZMzuM<<$g=O_(2ypIG_c{<kxpwrE4B~fIqk=p{4*2HDhwrY>vjxI zoZ=ZxVz`~pKwa?yLT3GKC7sHHq5BtrAr`))vj7NnxWcv(DzP6*a@)00w*|;zk^VUR zW<pbvucC!|%`Ii4fHA`%_hI<SVHtVg!=`bcWB;))z>)s5^4|Y~p9`DdRS<;C=G#nD zU!?}3pS&?{_g+2@Ak)?z-7)wE1(SFCvsutgYVvg~<r-Tzho63XbcJ8{v*C3*22CRI z5{G`+qbsIcEv=ga_YJO3nZIIwp!#Q<|3SB3<^R$*AKm|@Z`DBhc1Z33T^pQl`c`24 z#f_hsYLd)@h<h$^@el5si%-cS$}_<)JLI|S66gv=w&i=m#8p3L$vHxmIc)+L7LwL3 zjA$fh?R#qTNbqDW<px%zU2WcV@9xlRv#YHb(?0TA?>+Bwaj?8eExFUOrcEhJ*n20^ zh&`SQ=mr+cd43>sy3{b~8_<C7{r9<m^+FM|0L)V-jFGWZwN~Km2F^B57RGS9b)Y$T ztsNTYZ^1;j`DnmxR*^jX@P`dafI2S7674q6%|z*v4$qOq?G_;{jv5+<jvLOE9)~d5 zPG^vyB%*U~nK4S*I%@@Bru0sIabfvIKKW0tY3?YiO!tC(cC8T72gSe}Xc;?E(?v{S zU-pBEDk<u9@;z^ac?@Dd>n6@dnXvSg-_wPg+}^U|Z)CG8QvlPlg7X7wtz^ec#l)Bs z?R=DUy*e*Vy@|U!YSOER<Mm5CqlHvqy-nY<h+Lsegx~0%`mvdtH@SM3G@rNPuc=_; z93`LJ6J4%1^8~pPDtB&Naga9I$7@Mq#^@+$Qv;6OLjO%HsexnQz7IurbbS$B)&AGB z^ZUE+dPQQjml!zf!Q*w9F{G~N$f(3uj)2g}w```X=+AfMY|CMXP~HAff)I1(=e_+5 zpHd9b&$ANb<Bf2oX>2>8mK{wB2(Q&klKFc&fD1n{Z4L0E%sjOt1*-2U4|(p<f-sZ7 z_1^S#tX%ZnayALjS{{x~B5WFS+B)sXs7mbw&&i1WeCgX>Hu8#A5kPHZ*NV3f7qCX4 zc&Uu06T3ckj60U69xs|M9Sr3U?QISB;a)(t2a1%;Uf%rWTbrN!sxduo_#xzcGDHLo zw2lg|Ly30NXjT=4K+QR!i!zm5*k`bmsOKL=vRUs$V_v7p4vuus{gDRmC60;C38uou z1aCMCu;+cY4B!(T>ADsUp8NJLs>7_IFE*j~3*e~t{FE#cmzB**j*k2tsZBPe%TutA z%N;C@nt`nkQK_{s4jqPK^Fg?7!Ih}CxabrLd0@S|c+46;fc}WzFPf2rG~S$)ShUx3 zn^64&Kg)liF3724iC}Lv?KmyDwoOha>*54*c`%f(BXpw8qmjJ>KBXIw0kK`i=9^AO z#sZ0Se~F43ndILOchWS+0AcLZNg`P91ZO>|T+k$Z1S-bJ!SWrwanvwcfsJ2AiP8y_ zYytN-B9nUJ1xJb$4=KTV)p{QOnxQ9NQX(Z|>KJf?-HSe)JuaKfrL#@E_v&59{15C` z0(L(<<@B2TdV|#SqjdXt?@+?6b6SEVnM}l^#z62h%lv%MgBE=Qjejjr?W5lehtFs! zA=C!|dN?_ajjOD-Ghxuuwam6YS-}TecP9;@B7F>+*Lu!9g#1-ZCbNa&*BJB^B9y_b zWt$v&e2m9jSlu*~-`_&ZI>xvg%;QA)k;@{|WkNFfw>^%~z$UqkmlHP8Kq8_h89;G& z-0#Z(rZ9B<G>@mtdIpD$UINFVpLWQ@KDvmAS?crE6cE5*E0$EL;i>1)tF>k6NT)}u z_i2*og`ebS_tKYGk$Q&pqlNj)vK3x=Ifj;(wD-}Ys8X-^X%ljg0X(UgJ!uM?nrQqP zeF|$t8(wNZw`tkSC~-ktW&qf_+7-HU;r4*Vz8b#32I{MgR>jKVRPotWKbd^QN9jM& zLk}^M_kOq-NC{_-9#eO~$CH!2*6oQ4hD+2VX$jwzE0q&W0Sd-WmCquC_eR*r?HwiA z|BCdr#qX$b;#=m}5OQ#b(RMo08IY&CeXmV{&Un+7=41Vj4ex=0IDcAy8fy=_19@9a zuQhb>W~;=3@eOAed9bB?exoSiQiL}7FW2|=9Y+;{Qb-Mt+C<M?E7h9)vD`!2gD=Ac zJ#)q=y@7|!SGgeJzG9MB?(kJ{lOlQ>uj|ebT8H`-9~q+96}5&^e|Vs-6zaMRnVy^f z-c{4&#McJihUy>P)`3p2<a&05__5U)sqExa#2ufB*qPAcJ{$`7ey+-mEt5L!greo$ z`N}+E64l=w{y<w&*=Q(0M`<NM4Pb_2839=)xFN=ux(>?CC*IBT^LKae2#fkQ2JI#D z2mL%ay6sI0I0^?0g9KkQ&ePJQ>Ns4M&~J<)>;>#nKt7v_ELZ=P+AEXaz>ye#VuqC} zwnj-CwbI>G1(?ZB)3nLqg8m6SU-6NQ;yqL1ektQHOQKikB}*Rp^qR<538H+*cCoK4 zUL`szbn}6i8$b^#UT0Da*n5`q?O1h@%aHA({3g<HWn3fVRY$s-exy1s2k4$joBa?Q zS;KV#v915%c-%1zu7IWIrZC+-ryp~Fh8(I{ZBcM>ZkSJ>tl_{+-fpprDMmD8?)gLk z^|eRx!adgSMSkXu2wAma#@J_}Te|Y#&V3`N7|e$s*GYx0qZv&jzSk=7H)AXqoX#i* zSdVKB=y*IUv%i{lt|5<J-x%x>D*6)K6vU(iU#0FqomO5LmbZnR%%ObsanOy>$UJ#% zZilmb_;@;?YyHQjoooz1PU{A87($|%`HSC{H0W0p@!D)?@l9IXarf;w-y=sy%<MDA zy%98-b5N)JRupqNB#KfA^3YeU%GNujxl#AbD$IrW>CYd^o~85F(7BZx;l1ZVrb{+p zJQjtzuh11^2g`>bEcG5db575S18Abkbi{eZpDklha&%}rZ(Bp>I2tR;a7snFJ2%E} zhG1f2*;uDd*E0Jc2R_nTmTUGmDxYT{{$u>X+_t)4+5L1A6MdEp9G^YOfp0p@+SCj; zWCC=(_1DksKnX2kz1+hiNzZ(Y^&xLU;rZKDgbihhV0cVb+`&R`T!2rqLrz1;NzDK1 zBB+LZ>to?t{K(res_cr??Rp#`!FtZX;9pIub@?@jhh{>*2Oz0c`QU(nm^km(Y2W6k z51oM#Z2^kz#lXe@@6xD(&y-8X?(itO=-~9xJju>-Ca0<wgqvXLnQNpRuPV>YHMTY- zZnqgct{<oy0d%f$pdV)A?9zUqfL#L^;B#$?HeV-X7C^7R3nO2W|8oF6<L!Pxv2}Dd zR(TV}v>#{HUuVQ}w-+L+_MU_?zWT!`*mmu(*Q{3}a~d7h6o=;>)n5B%oRypd@pz#e zxg(ubWHa8kesGIZkLI<mRr9|7W181@a-2uM_nTFOcD9#|t5;@9AMY@MamVKy|J^c8 z-tTe^jjv@W(7m(Xn<ad>UNz)<co($Sg8)fEpOEPWr;#;Tn%^E>u*;NF8gE1&99;|0 zmRN2ZIx?c5^JYx~d_MyW5l|J$u47rU`2OVhQHq@7@l;mnWbua2<oH!&LhNIeF0gTY z^iV9Bs}2L>+ud=TeRKoLm*!`_WH@EE?~E^RmlOtFAq6-h)d%SZ!!(IyYrz&@+p)=2 z+ly^0cALN_G-4A;4Htkn7gNl~naA`Im2Mh+lHN=J+H3>me{HB=_*Vi#qxNkNt0auu zdEa!yTRGktV{$vW%9O${W9&x>PW}4wwn6M(S-V%T;FPVhhdIpf=cN!${YrIcEttWN zYx-jD={D<34R*UW4#d&Sg_BrM0!D|_BIPIJ)NY$qDf_Vz6{kbvw~Zd<Um|ixCNB|& zI&w1fZu*p8;xH!M$5^jmcd6vvt8c69rbo+6N*-{S6$kX)_`$F+s&}3k{bj66N~Nyj zp@D=A43ZyyxvcE@_NZB#QImCMqX!eY3n-jUc8PKR4Ge|4ApQE(gr`G=Eo*WNUo^M% z<|NZ{+ApHjYw-WRXans3^%W;CYf~U2${Qrgbgf3Q+Rwy`LRYI30fvrCjDaCDCt{p8 zK=oD{rmL{g<)`4T9m2q#{rnd9H=HLIy+dQDVs*)mQ8^Mr@_??d5ec!7#MPB1UOcfM zHJ1J6i>lUqM^8)!<631G@}788glq6jm9;RKu{YE4+w{hvuCKX><y2|M<5yC_pFc@F zX%4!xmq14i2HL329d9qPuTxvha3EM&AYAIKSz3kn>`|$$)GS_C3tO6cvjmiQI}%hy zRpvu<uHHP!auTLQOxze1ZP$R!YTH<c{dd4p{~d5#P{S1?F{u>5xDi+3$z4Bzw0)l! zuRWf*r6>sjr)FziE~3{1A0no!^n&Cmmn;R&Ugy+TQVa||EjnG?PyfcqTahy|KRhYG zF`V%*MhPt}gT22s;?^QALEVu-)s>WA?Xc{9pEDDfu_cQYlUwTC(i|4<XY)wW?cp0J z{_-LK0v(aOGaVI*nZp;_KA7D<qf%c(2z#aZ5?QsnFP<??cd?+V#Ht|rU6g8U?jER7 zlG}2+f8hwM&w39bMQz;!1J!BrDiG$+gjNwCCQ}V3L~pUie4H(-;?I*HVok{`_19U` z{4eV}3fNv2{=0zkeB}TpSh9^RJpDudGtK!w>GVJUsr;GduIAS~y*AZif>!ycwBz8q z*vm?bmm^E7Vq8r^kFIQ2P<wy;@V%zgUcMZ?WQPD7<#9DCe*K|(hbH2$ipak%ygs9m z?T2djkePwtF+0{*|NipVeadm<_txq*45?aFs3<$h8*m`6RlZB$zIWetw%teck}F&T z_i?TzL*>q&q4}QO&0ob;D*Mk4(@ob;4%(q~?>>4?SD&yq{WUhI4RVi@!}MBm4~A13 z8QZH+Y_z0}dVw+NuZ?liqx5fSWqi$1@jrZFDL%X-Vy4P_t<!Z&{be}@OY0XUblax0 z`0Xncw4xxD`t`=Npa*xr6ESEErn!~<3Ld=%V%8B4W2hQockK3o(!xvYv5hYF#@Tj@ zYpme4dgI)VKTi-8n&`l71oEEz8>WEGHkEY`2jEBltmxCfHH$wMvv#ioyPDZ#=53s) z@2D}Ja!4M*D$dsM=gb7j=JWU(ZC1Dx_JG*E9!(GYnWn46CA=vLj1^+bvZ5oXW1VKh zMDq9Zhx;(c&oE`rUYtkUj`dIQP_Myk*$WAZBZ$!9<fV0=wc8Ik3LV*H_-`zvnY8d1 z*Az_hcJSMST?)kpoHJX@YVv@hR2KOMJ*Ob>hP8qk3ky1!l0<n+9qvrwgHVg#jE~p9 zyWeSIO!D=5e^3A`e&mIAHqEc|an@>H&c6>Y%^BMl9Y%>F=fAnd(+-#Z6yxk15HRi8 z{ScYkVHW3v^ebQ1T6qHj{^wa_&n?2e0;1BWb+YFYf3%<dw3VbpRjAakUpq1rrER<k z#`ICJo&_5=!ETe?X>lAkVq(`O|Df4zH{9f;d2EqSIlGtl;NijMn##ID*}w9oG2s6l zs_nMxWZ8iChVH4>xIbIc&(k{w1Uyut%7>(;yS7Lc^UV7^DwftS!J3zZv*Mz4k)VJ% zFoU$)6S}m>WN*I&7<mwFc-l<*du#64-7u%pFkxS^E80#N-*~1l6sotG?Sd`g2q<Z` zA=#<mu`>NHCx<eY1G;?$=Ggi6y!n!h*$q7yrilH8{IIN$%}F!q+y!ltB^t!Z^N*mr z%bY&nV!n1n2>m^Ughu_tuVO$2dwX?&sSR$YLhU$Nwr{u7Ob70!RV>!`jbZ&+<EdU@ z4SUQ4DoCA9@;r5TWAvN#xK!HYKL@#aH%FOLd1@<QmPL2JijDn6EG9TbyG;?Azbe&+ z&>Z0yaD}f)9uRggBL~GETsQ$A(v+9M!}2E5J{9u9@uR7%i(*1K%dDyR3u(6eI{qo| zg^%?^$T~`21TPDrCs!A7I75u{oBrgG#AtZ36W5+f_t&>9+Y>nneeqd5Czkb}b>lZb zx5A-sjz#zoA0n}ZSFk^>(<V!onn8%p`jlX~oDB9S;KUv9Ql$Nue#{f`k`x`-dVQ3C zVb8@W)K)$nNeO=BBTH<rvJp@&Cp=RR>G!8N!A+_R3$*mEk@?8d@k`rK<saZr=Zv{h zL1$GO7)$jArrj5Aw9j#TOGLFPI#6IO_@9EvR;Qx3P8h`8(#`<i*qo9tWRg9vkO>9C zHi6o?eFS<423*x9zMRh^I`GiN$=e|D^<b70-a9a?Ir!6`G_|*k&Q|W_wSV*6<7>%2 z2@(ETGXLFY6`_XdIBKW5{bJ4)vTUIWq<lrsN;lc0B|M_(;F68V{rGa7Z16A+E%7L0 z66d3)oz-a;vFeP;R}A!6(WmONr9T^tH*+x%rAP9!%tO2n^)7wVlY>l6Lzaz9YX`1E zco%;xCPaAIkm9kNlLsH?Y6gZb;)Qt=(!dq3!JdAWq(TGi=q+N=Syb=h0hbw2tHX&E z(9eTD|Kb9W`{+5Df!D4T)LB?bIX{RaC5S2ih9Q6c2E8YWb&+~tnD^hD2RNJ-B|IR1 zOu>f$?`1?gLb!=~7ygJIWJLP^)qB9Ou277ZY$hMW`RVQSkf6Qp5|H0ig?(>K=CJ*7 zqEDYsr+<{h@YSbKKh1N)=8(OIc~^hDEUD{DjZ;Ph77ccx8c!LnXdP`qGRa4e5pPS^ zn;?0^g;uHEh^aPzoB4Epe#8UG(56?kr_A(C&IOcPl~ujP>?Q_51j7$%d^;y?mjMO{ zMIrHctP||x^o|K=qM$L4wdeSYCIRd}#qqms`0?3ag?O5_`jL@@;G<<PV0LV!_qJKa zSk-p6Rj)m!xb4hcO8Xrci{ib+o^*$6d3nj&<JNefR|&4wAq<=0Lr5Qf%<V0Su}n;q z6wO&#^^c-TU>`cl#8FZW<5l!I&Z_aTUUOnTg9qFOvGh5-<H7lTt_3sJr-6L$N4+ho z7JZA~EY2I~voEXKK!ZqGl+_h9E&Kv;{%!B!jaL3a%|3PUD}SjkBLw}vQ9?ynYo^wv z^QH~6h@X<<6LLvP;Pq)UsK47=rjsjXew}v*u6d^OV6}oJxcT=`7n<w8z+{>SY`_SO z4GYlz;AM#CO!mIhcz%+xd#+#eRlxjaa#*RFdJyZs2zHuGe(4p!rNLx9wKi@?##s76 zhKbOvLyz3zGBekV_JsOchik3qx6QOv*v-+WQTM_wom1FXPXJfAOJpnN!zmCyY;;|L zOMscNWl~FrJ7PJ1_ATZ<J=jOdusYi9%bs}p8&gc`8kPogOo&Zrp>v?_D4Ony4LPZ~ z@?-oRt<^|1$GiyKW|=*V&KJlp`Nw5ZeCt&^&ro#02dtbXbe0!pV0A(;@_$6m?==4% zXGE;s466WisxgvW=^nbjJOt2$M1G=mQXMk9CN)CkqY;1PM$<*;HD?KLPTQPlwI0%s zOt}}+Mn3LQwI8n3D~Mn?zOO1$)i`I`r%PmORt{}aXF+O;T^3M*PF5)2=<l&eV0GUj z8F?nHZ#W4vkp%L1oMw7_k|6<we2hmdXZ+amvEMme;-=8`5^=D`Y0*}}=C$#?+@>1= znpXsB(uNkLoc;fIX6W+PsAtUez;2{|(^CKMZiedT)1LyB!ESE#?4A83GG+?|Hz`-l zNMaO6g+lp#7GyimJVZ8J0qw_g3m-O}D-Ke*bhKpKn>${qn@BBdL4$W~jP<49vMa{U z=1@d9U+cMMCP=3EtFoe3+jm%<l-1y|Cd-Z6Xd_|zMb*)IXkWP~+1f$5hG_W$W=5Pi zcnc*p!4dAmf0B&WxNxzBp%UjMRmxP4$nTy8ct1gZxIVT@^V;h5jat@25)BQn7A6hR z`R>zT@2}z5G&JA;3lvl>7UzNKzDr*#-Az+!hCPrLog$bpJpo)Cm-*piJL%Vk;h_>j z2w4}CE8J?iA^~=l`P-wFD$?bPIyq@z+j^+!=e|9;?#IF_o*`|^j-O}w#6$*6`yxcH z)F9$lMLv#;knJeN;l<E4;mwY-P9>C<u{MW}w`qy`Xr4V2b|d#7m}9AM?-XYIuXoF` zJ0Wm8k-=1P{)8Jnt7*-`l7csd<o`Iahpzg|4r%AFZ!}E*_QkLN_<fOtqsV=EO<^@T zMQP5tHdJr;c$smcC?vx*pj_uZZQ^U#sm`F>rX(*_1zE}5prRagzXH7YmPrW^JYW^0 z2+}PMWQaNCkp?$+Y>NrV2R>Krgj;wUaeHcs#iJI!UUP@|zS9-+&K~p>j58|G`~jwK z&n!{rO*oT>r8D^21usZ?P?jE8u+dZ1J)pS@E_mh*p`}J{Wy?IpGH+3g+&EZK9gWZ( zg<4waAneO;CEj+kGY>e`)Se08c#DH{-!q*raO@_eU5yj=u>afUc>j?qF2Ga|xi)w5 zsfxJAIf*o{`VW4d`G4u7D)b<qNqB2n94-YIOXtxH{Ao#D^73kBUL4ViD7-Ou9dWQJ zuG^d?&~jizWXfLQ6-D-qUxgp#m-it8i(N7a5u0|KQp*Mj49Trb5zk!Ci;O|H>ShyI zn-Eu!a&)7@pNx`2?ouPWpv>3Gcq|vsnOzNvo(+oTi_0^VrhzWVr!qFNn26g3<%#*Q zm#?5L?dEzewish9i+L8=bnaZx&q|po<t?n%K(R9Iu=21<mrmrij7-yexE=D?u?JOR zV?9w-!Yqkl`pACa?P-$mM$`2{`6$toZ{Kv?Y2`Tcx+K>k^8b12XtKQS6;uuCgZ{Wa zHNeYMI=SlUX!){E+mK9-mRh)^XfEBjkG#+d1c+;+k{I*%`sL6oHSz@}z=jx{`Rsl| zTi==UUF7H98xj*{UkPm}9dj38X0w4!zj9=ELYyvRal2Y}=N&ugMdYAoopDL&uv3qh zXoXV+OX%EW`5+iK>-4p2`*dBMr;9m}iO3?%?Y?#pxmtMlrrkz;ga5L4wP{6gg#{`! zumv9<g{ddut58Q4ji;PqE*Plfo<uP#VYV|tqPmHRB-N8C_QIF-`8CfZ$IdA&{337D zJih!pfYJLDThb7BSf?E)wjb>cRF9=SOtW0gVsax(Kt~IgRTHmQ-kBVCH4Z(?1cWP5 zY&yAL8$LT85Q4h313U`HnQETi)Z(qowhGg|Hm9139Mo`l=)Q(&`6Hp6yEksYq|ROT z%pyqLYi-M*;Zgfub1=u(l4kEJ?Aim_%laB-ksp_%W45;5;>H>u1Td-g%!IDk&gcsu zMZj(GEHs6S-wY2!jA>{<2SJ5DNa@GaCVAalO(tMh|E!O{W)IO6rhbdfRQJzbh~y;K zHz*N&DEzuv*5IQ9wp`UtNQC$%=SW(T0#%1>uYUTw+(bWfX(_|oyl3@k%8v=^bfhM& z7rUYZtel@2W{kEg+D(c#cq=Df-ZN!I)q$&n!51%7ouzCA)~(?y(u$xDRf!K9fN7-n z%t!RZ7}q6YP_Q!N;;RJwN|_0#$8rag+mUlp*T|mcSRU|}kFU7S3M#Jl&9U;F7@dC> z!+-DnlJAr1LcM|#{{mRj!%3@i^6>tS`#VY3$#EWbP>5=Y*`bErC4O)$tnO8-4FAO* zqwSHm%tbIcTfOM({Zo&TZDxI5tk1(rRtp{4M(#Y1cl^NKQNlBjP*u7iu~+$Br5J`7 zqN$qFDBEZ%bl?&55x6Mdg^3Fxnu&?#e&IYeqO*vs7C*_lxn`e*-P#Rsj7R;R14{$? z9UIcb{7u|uFgmQ|us7`Gjw0K2r+-0(T9m8tUr05QcD9douX3RwD0WgpxH=)F0;6BK zwDbLGou%kH5%uLPvfC)O5c6q@qWastyEmt7*=4h#5#lPaA9Br*NJGD+Ems4h@EPy9 z4xt2}xFYz%wxz8;uAut0bI;Sks$kA%B5gsTkv@_6Azm47V~3l|Ut+I{TnszijgA#( z@H<vUaLVQkv2zsC(6q(gJ3w5cxx^zW!+kgoo+I7+f5;ptFqrTokH^^himed9N4UK7 zlnwd}uU&$7KMnn((C(UuME8U9xH!&daJ#?2jD-Hq^1?|cH|utjOf-s_HYDx(ofrKN zU$co`2rG|obKix#cE2;Z#KkyFYLXijE>2L(G`&v;VlXb#cK#F3ca-T(XUwGp^=JY0 zdWUob9tuTTM2W1!BZyIA(%qJ(XC*H_;YJD$%Bxz0<I+FG0RFH|6nFC3Yy<K3^9m;q zC#&l}@4EXnN^)3ucgvVi<Q#8eyx&<I-8tB=knUdyzS!E-;okv$Oi5*|b0H0Whvtvx z5Sccg))}&TAQ5VzOeKps{^euyZ#L$Z>zQ4K=!sr_?vYYX65*xVPQFg;`)IeL!C<Uu z(Szs8KH&G2=cwp)b-3MogVr<O4;KqJ$WsI^ta7X?74kli8W$W8$!iUIsD3zQg*e)L zZ+}Io+VFMT7OWZx0g4ppyk%`nnra5#9i2zk#gwQ<E#qe@S|692U=sW@W1i*GZ{O)e zrd`SH(4Rei`cPIldO0~x6K?+$MpDv^Ptt|EK7}b*<f?Y$N{DwL{wIZh{x+xm-GoAu zTwF8ko<o{L&JGzd&$vNk9>$%~I%LlyGL+YJ5Xv!cLudc2wlnBw+H0~Pa!*Gz98TnI zG;0pgijI8|rf$tm789f1)*`*8&qChwm7EQDyz;osN#7c)2<ORGk(OF|%h-hPF%pNq z;#dLU75!fG#i*-g&88L>!3Hz_j+j$VB(7RC3yVs~k$5MBbQYF$C_|^v1#B9WN;z~s z8}KiGUGJ*C;@q9csxETP`26PHq>FBIrZ9Dqwj+_Fe`N4-hyTO$y^`B;oUs#6f9oo6 z-cNoPG-|Vch-W2@#uEa@JMJ@WKb6xK_+TGPy6CQG)_ce$n@zdv9OzziQ6aZZTXxf4 z@HoNYy$EBuHD|m^)h5>$%K(1GH>S8@r$b*GwYi|k!@pb~df<4Sr2@Q~eOO`mj5ZHb z6?4q18=BWdb0Av!8~xu3+IrT#-Yiu_FUa@4LeoK6Lf%1qqxQG=c0$~iQ2_<9+N_q~ zdR%RW2C(5~-T1y5c;b*~zOp<nW4k|?WTLi$Z+#qo|Fkp^W%HM#L?p8?zy>8H^9Hp6 zYVh{;j_Qp!b0N)`wThWt2m=kv@uwMB#P~m-r8_y`ifg|^unFy+O8>{$ozZNqfS4HT zd?6D0aBFyOZfL}M7@wPThmZUX8hccXSB%e_Fo@yOJ9IBsem@WdbKi?@i1yLAq=v^2 zuUI(@>eWSGZ}U1eDkeq=ceF!9yjYi;`o2``rEmt##HdE12u@ndjwX}}g%M{$5WO(h zt3oX-xuD~2Se)14Cx?{Qc-md&uT_$LURzvfM^hwziLI~35o>hT8^n%1NaM~ZV*%=6 zY1<vp^muo!Vb&%t@YbANN5X-I5VvgKW_%VfjkS^PO!BQ%$BupR(<|zqG9o10P33N0 ze2(2WUER5{8vB@!U#%_9AGj(Rml*oEY=k$)3@^S34%mTKy<`Z|I809A+QXipa6q7) z^V@s8R+r1jQh?ULHI}MSpOIg6z71?$DOE1)V>&Qew)6g~5?JN(UUb--SWIk@{vcn^ zMM@a27pX4bj0UT?7OmBI3z^cb%gj9h;WMreT4s51gs@VBe=NpTKXh#{+Ml)|UsD$| zMw%zmg2NSR=V*N)b3bOPg?$rooLdS|7%Xmg;WDCnH98lz0o;6bGOQe>GD?Kx6i~`? zphRoH0;KUo+{Z%5*cTz*&I4Z@jIZ4w{%GR{udPeQdG7%b^Qnp3uK8CXdjd6zH!oHn zgdcIpPMqDnHq^fjrN|z01=)U=kVX=ZHjgzN-NRn~Wz(VW+?z{v%D5Df2@EN4K90xz zDq*Yso*_x&Ti@!)2a+mPcN`|R=(cjMIkd@cfld>tqnU5|Q@P6glfc7|#R9?<!#gB% zJQpj@p*G}e){DzpbRZI^Y@$0`D&Jb!kQP8OPwEotAG*G{{PvJ#F0W+TvL1i*+H~dR zUWb3ApMTQ8gP&KZuvm%hu;75bkM_MKfDFbT=j7`qR*4{~m5$`G5>?0e`CYdn;3|-D ze&PGh<F}RLq@~k8Oglr@Gs?kxtyQ@l>;C{x{23pnS8TWYywHwDs*f&yl^p8Z!9c+W z1DYS-yoTmvA5ZvSynNkz{?}O2F3bPy19#ra1e`2Wt)7A3p3;CaOQCr#fke~c{Nt}h zobFJV&w5W!C$N?wnA~DtLqF?o&1qCXT$aHftGuVhQKy}1^j-QC4Pwt?W9KUHOsA-W z<|=^=c;?{XtLK~{pS9Hr5_2qkUnlP%xR<QlHx9(TcIpiMv94UC4kcP5!wsdNM4#!? z42L0Rw#x_CoS7#qY$2mrW5ymxpK)DswRfZm=82}vDMvQ_9LMSV`TJ8H#rCSb&PhGH zMWW#C%?llgoEeV+wT`LnYUs3^maUGF7k=ou`}B|6p9_+&5Ur3UX8E3ju5V4@)oM3S z^dCrG=uesd^8hq0oB=*=mY&oAM0QI*SDyDE`577|B4g>;@J(;@2s@282llVET-v(w zK%%Y(Uji@!ulLa{#l`$7!U_kNf?cUa<PBG4*5UnC{-LzL20-s3tX$W8`4$Box_`Q` zP742w=ZZ*FJcJ+KL1h#fYx_C2EQ`gmo84Ac;pY2z*n7C1QMX`w%jFv}T=d*2JCxSe z7RFL$0hFk};+eQ`krf|iH`Cl{_rmR?=*88`rGQ6V;Ui}6L_^2}HIEJqGNh8LU6nZ` zMUfUH>wLcnCsiEWXu7i|x7$X|*8l%w>${_x+MaN2fP#QtDN40nP-#jR1QY~B1f<tc zL~7{0M?^qHKtVu43DRpq3lK^|5s(r(gc^D%p@-h`4%hN~Z>@LMa=||?&OUqZnQy+C z`Q|;R@tcfmtJo0t(>n|kTXK9%Li>5s2EJs_yKAi0BgH!Gx{f_STHp@lKW9xUK6HNh z8LXHb*Khlqx=(5Ozsv*x-nI<3YUlck%gQ-6umLo$601Zo2?cFCec`0MI#*_o8WB=i z#<~FnmiRBQpL7iYh2jgjtW=(9d6eu<eAOQr9K<pTd_niiy^c3JKOPgCZKpx}^BrKk zPc@+_O1~#&aEhof$1U=}5pDUTe9}t`)8@>g?Sf~bsYj!R&y~0K7<ldUR0n~3OIY@g zqoLvf8pu5gJa6qk20B~0JF(zCR}#Owam|OfejyTNj#|yz;RXj)p~6Q5n{X*t*|g5; z)Zj_yj{qK8y8uKvHoJ4prTb{Zy9#H6c1E)`&%L=!2F<q80{Nn|!JoAf47=8*n}3ti zkFWf#emMnu<ATaR_H}$oj1|$y>07I~ZMw+2;6POLjy5-9Ne{nLKIBlgaM$Lk==txK z64VKva909_R!x_aCA`&0(8eX<himVxDThXsM`O1+d3W<ZvuZnMiRsw-!@8fb9#ih| z#J(LXnpnPch)NurV;%Y3-0gBMC`|szw9~?t6%s`ONNoUG13tRQhxM4pw*WSK6)P#? zG*9WiGHkSF^RCmNS(~-bxu3G&pr!%($XTNylB0jn6``bE`NE&+xjo<TG3IdHLJSdm z?0_$)5cQwS<5}!H4RC1yQR7|RqT53Bd#JbL8`|&M+tEf`jaV+I4giXAr7HIY&{b0d zQsl`()#Iww*|Nw-E>H7TYV;kxKy>w5d>n@|=#LkzawF8#FIrC|v+h0$|0H!(oO`AB z&w0A=w=C<;_)7rK1({&9?S0Fi7sQmxJLs_SsuZUOXo)<+^FoZJ4-;sc36gXNb>r!g z1#N!5um52vJKRypTn^|}K%rW^Mg+f4%UdkIHak1xru!A6BG?*?M23`Tz+3S?he9mj z1*y~31?$u4K)y;{6C?h;8zY%HJaf^JDdjN{8bHfdX?*0lqK(Rme#?6WR8^h}-q8WN z>}_|~%~=9A?|=dYkWre%Md`)D7g|15Is>rCk&%jpibuv_W9)u98wW!cEd&Bhd1n%9 z_m6I|e%EO_V^(?cr<ZrZ6BE)&<Zev{^9%^u6PdSD>p*=Owbq6B9Y;Kl{@+o;d`EXK zYv8cDIemDI@vb{^(A!QjfnmcMlAx8Vq+V3n<~lRu6linUS$FD%qgh-hzExh4uP1RQ z73}$dR2FH_`*zv|*Fu#&r$%eD3nUW7B))jKQC`=*m*Y#;W)XL*soXKvU!4lK$mzjl zh+ww73yyuuZKehdE7yLKpneNU@&PF)kdeuC^84KRQv#{vt}T}Rmfm6(2;Qh05fpTw zWjkxg2?$?rX+phsc!Pi0Olyrpo?l4f)G5CgCzMr{{t3itHI0m7LUqWixC(Xal3;VM zisJ1Trf1X}YIAt5)~&h_S3S4#?JJ_DC;+fbL(nv0Ts^Prm(Q4wGiqSb^9bg@c=cce z`=Y}L9`Ia2<7L=B1X~p#b!T}B$q-UDJxrLp#yLMDymS1ygS>SGD&-#N_=56g#<mVE z8~RBe+AA9Hk__qb0cyN4eXb4ti>OIkER4OoOAa#tVC>g`A|sH%p1Ac5xDzE=9eMV> zwjb3~+}&Z7JD`m9G{={p7!N$@v+5eeG~S;dgZwH)f*StKtkpL60#I_y1g$=chFdF3 zZQd&}8Wgs4ex~PaG#I70eu?Sa^IoBb+xfu<Q4=|wx#4p&tDVW_sB28*k8#%V?s}K} zwlGjqf{(!y_WGf8n$q@Zr*Ofx{HE$k=5n$-g@}9czDqkyTe{Rf(~q53T7vp)+uPw0 zfJ>4IFcxz3yKb1hVOjSPU>tE!#5kNCvJNWnU2~|%sK71kF$0hTn&EZk@%~(a@l*{% z8hPOTch85M-{iBMSfgbyqaY5uW*7`?s-IT^*h&`P6_MXfi(NndDzcs8l*=dwz@UHD zu!sHEysxS7qJU<7W(wJc`?hQ^k*^`S_#`pb^evcvPmLIBq<ft@LEfBndc2~lz(Te` zr?3;|%_h4&XOHjZ@4bMFXY3Hb+`}{!$&`8fzso3WbCABC*xrbmA70;^gdPfkxGI+3 z4p|^pO`5AJVUe*pqLPSp7)Sp+jbomuSZ4wSj{&MmE@%gzUnek{t)XZb)cLRQ^3f`> zZ`hHeCl`Uw{Z8J<4}o|*1%h~L(uJv`cWpb!mIL}xo(ZwNL^j_Ii-H<*O6wRqL*C=L zWP5;i$l7yFV=$*+shFdb9=Qb=#+cmy?`Kd))o2HW?&P?SNT8k%jhN%bt}95nfXd<= zq@<y=-OmF)i*h=~)H~T84x*ecf}IjO6JNufua@r`ekqtxSC7Qs-&}g@e{VFIeNfOm z1Qglu#%Xe$pqO%G4sw>#Vk?iO-oIY8B-IxAz<miQ;aOzt&$RL`F>TD<^srf8#TVp} zH(=IVIO(`|kRn;g_|ih(Fx5&=KV%P7Fm*BstT26esahf6_-3$mZA>C1T{nf-zbDIs z*NohKciS-AcBbf0{ynATe?~Vld$j}H@4mwkQ#s`jyFd<^ia>G1g<ohk2|HVcw~0@& z-)xH=gk5Wpxz1Wo3!9Ux9x{(zL~6Kw!WbJ)4bB)4Lc_uoz;0WqKMO4gHxSBe|F~;* zrHDiF`-KmNuYq<WF+HQ9w~ATDtP3~34jUo~o-2L8TdS2WqR-w#A~hIp17OU66(f!I zyH1i5239h`8ZhInAKsB14)AL{IF0X@c-ki>S!bR{$UbB7B`^Zqmc~hL?u1@y#=QL< z-_|O2d1JZUIq_*4j*4}7J2Ku4y!*Onx>(Au!G9nn)P8+C^+A2f6nHi*7w%-oQ|-{5 z$k#h3E=gZL!^YZq*WUp(e1Ox<<R4grnw8JWub2c~PE2I#2)Ehf?gak_wshEZT^6g% z5d()E?75rnpv@{O9!<b!fid23`}C{0P{>g>S48y_b!kz&)daGIrD)TC%Gk_1Afz3} z+56)Q4sf5Ut=gMbtt%c|9D^C%CLCE;d1hCW_!zg?N89FnUhi5d{^s*)sUQC-8$ZRb zfs3GJsV0+7STdB~ZeNrmzvn%=y^zQPQ^SDj{M6};)T`iZYnb6{^bo#c=;1Tlt8n-D zy{a&A6WS&X!eFX9KND96hlvptSWnC_J!FBjxS7v?xU32F+5`0yn7v9&<rk_gO9uRj zg~R3f8CQ3o8Q`|TB$%I^CO5u66teZ+5-+K!4t(-Q%p5*|is(&BI*{5aFNy`JLgiVw z2lWk`{Q@Cq07>|_JJ37*zeqwpTkGE9!PJ)RQ1$bB%)!rCx8Srvc}(tMSPqkMqN7QE zf>PScT#<Rs>OQYMXBJqAmLjP9sZ_D6i*oz&D}pw|#@1Z-F8<t6c+eE1rwG4~14&!u zk%v)q^SGvc#F~Ik!~pWh-~cPPh+4P(7&!iHZiR1n8$k{PZbNFvk&IflgtW@ZyWdu+ z*+>Q!k_E)ncN796^2Boei4s6W{rOe?DFE$$x%qVd_}3`6LYJ?9n&nUN!bgWP1F2-P z%A^uz4SHdD6?*5y$6gtG>W96`jg`d98}f^8gK6Q5Ob>@xBCM5-hGZICvJ#3fzXrdI zY$O!+G1`9+;&pw;CsF3TbL%0H>u|kHyl=DkL05RYizZj4<HUqd7~C^Bs0FGJO~^d; znB5s;-Ha&86+9x3T)t|(MkDsTL;WP%uGDbS?924PY1ijt;Tbn0lrg(5JnK>v6Tl8^ zZ}R*`Ri?pz#Vk(oe%~Qg^?CPt#0(oo*@ydd?MIf3d}P>y^Ra0)P8yRR@$5nq=a^(X z7rto^ripX(GCjmG^49ZdoaIx;yjH<SWbAM9OP5a(Eqv*!<O!1GWAE@s$58~%R#)JC zuAfqR`b0~C`?Y1Ye%Gc19G^`K@q-zNUF6>%qVzL@r>TG;WDa-E!3-htF*j63QdT{7 zckJ4!RT<s@ScR_M3hV~ZL6k2-kI!(?l?>+TavgC<I>MXhs;<kSp!#Gfu{CdwYu-x~ zK%PYw_<O*Uf9Y>}@C_wUSTk0lU321q>o{6aDUx=gqY1RrO-U9>o_0PF0Cha(d8As} zBETGR#iW5(N-Zk!?gf|u;i63SVqdnH!|+1cJnIY}FcPk_kyPr`?!>0tp)5&}Wyn`D ztN`+#=BaWkz2mhMX%SEN!_29o!qr8OnS37nQmCa`BmwB^Ly#aO8f-H9dfvKywzUJm z*%TZNnAz{ejXwvttgIZ@ImXHj$gvzP-nYc)gF`KlYoE=<@>!o4<5yoWd7F52sH||2 zi*A(<osry)+c^fkYiWu(zX;k|YFR4NlO+R>b&7q%8y~b^O{@R1#y`Et6uEo`if7oU zT8BY-(Zg}!WERB$&_CI<!BT-i5$F!iX2A;sg;a&!b92*(No11evK{l}(&a5;r5mRI zY#aYWG4>%rQX6G<OuW65gC!Gx`58<-+;pH(Dl|HvDYm0Cz9q43H1I{2WNr;sS7bdc z4eC8DR|!v9a5pZ|XFc9+18h;;p5Ga1P)6Cufn)sG;=@8s&cg$k?RNVmc_5IYI8{yV z+Ld-j6duaRDR4Z@)!rGqxgMlWP0NNyOoqY`<a9ydq~jo8ZCgH)H^0px3RimnQC-+C zb8brU{f`{WDTeG#58E|E&un)bWzIij-o{cGLWtRY44H0-nDP8X8&t)Q3l$wX5?wY+ zkQ!(|U{WMC)Z0Amr<lG+5?P8Ou5L6JzYsGfMbbv_=!KS;J_@s(WjW^*G(^e%skgbc zAL|VYI(p`c9(OF0an(58rq$%_>e4}UT?)5Ya8JCQ5Rco9OvVj{{e~bad42fYE%I8} z@Wvdy&!ovj53#~Ft?Wn*l2V5ly{S7z#sf7KJe?=(0?ao_R=BIu>}67wnC5$~ndhoU zp@^(YUugc<vafUXnA7Fg9-sPor@Y*Dg2uPM(!Rjx-(IuZbVrpwb7R-5!PJr@AFF_` zT*4?hP_+mx+Oo4S^)w4eh@*Z;-q<%M=wnvw2X+DJyPgk_g@@)~r}b3xAQ4H5CnlPc zb`7Zh9W_p0#Wb~=+{H~cM<(HdZ9Xt^*@PUpPrn?;25MRxa^+(z@L%J%KQ5utS|Fxg z2hM5ZHLl(A>_A_Kl=CXtuAMQ5k8^)6RauD~4d49@6ASDAM+SU44#MRUCy@8tkr}K< zqw^Xcd(hz>Jgv<JLCYFfhe3i92THn4J^A*TNrk8kHINIM(@}vo%&a&JqsaV#DleX3 zES2`1y!n+N!xm%Plt6*qyXJ+Hw%^ko*8nQeJ_fd_&WR-a_tFxG?+t*;zn*@kvA>BB zcc;dCN*{*`gP1@E>h9N+E8h<smw;s5UqAY{{p!@CB%GZFZjuPFI-g+%v}fV821}K9 zTozOL>rg`ZX6C~)-@`gODK%)|j&RfKh&202wtn$Y@DtoCn&#+!xe`=LK8fF=?%Lk| zJDlw|R}YG7ixs^H`8oLmRxvISRQh8I5xtpe1!PV#YyY5Hvab6HLj%>wDUfGHyTS&@ z-EmsB1uridmEAiU8{V*@x9!I{q{)c@OHrRcQ$1*6ow&b>S7V1hBSNL!musSApXI7{ zSRdwG`D4~8rmDn#AC>DpNb?}d6^u$ue@&FDpnl)dz~TpW=4QDhBepmZnqtKcUWq>W z?m@T2%6%d37NbE`T(vXE<+WezV&K4T;%<tYl>~h1(O2&(J_)`g-8)i}7dj+X4`08Q z@lF_!FP_O8zdqQ0d+x1vCh|Gr3+ugX<j$bJma8M94k9C(^WwIZx*Eel+w)a@jUu0# z>7w=w(wEnot}J`ULQwH_v@MAS@CR-NV(^|!$hHX5!AKi2ekM+myWsesJa2k2T!I8S zdfNYCfl;3lk2vuL&VV<tF7EtXlx5rvrJ~Cn?R|2YW|Mt1jRceu0keY?uFT80`ERO8 z_(eoM@_2!Kcq~Qz-={GBH}$WjcxG#3qrS?#lUMAG)QUJ>=&M#ORo69{bH}-N3zXd1 zUgTD&p{rD4=<m4_<%HM^)T7y7*_T7zo*_EE5Ac3s8dhHy8$MFGnNlHBwCC?7H1_^z zrJDc!X66&)67lrJjsXGPQEWN>p?Hm|;FY-o9Q{b;{L?VoS4{07^)aJjEX}@z%;8eh z&>lMko3Ya_xO11C8xJntqfmJiZ~|AR6ZpEn?n^@o%8u#Bb%;&c3}@R^acCao7Ua<1 z-3S@@3c$3PRwNo0??Vde?#CGzLn*P9luIhLNUc8KId=xxsD4`(@0qKAP}cXqC`)g> zHOYR_cTESmjFN?C<cn)9R!5X-eC-d9s<tWAX?YUBpsl{vCn-hSU+G5%-KEZfGG~OJ z`N;&jC8^3H9nnmCwk|+#pt_^}SYXdwPpZ$*@e9$UEy_EAag(lW(>B0-Y=(s*%sn1^ zY%pajv3h;3=ZyoDUUe>JH0Fg=;-0!?uxsgy@>jv2kJEIWnvs~)B8;y(vPB=yYt7$b zv$Z<<eQO-01~|yYK#M#JZG<R!FK7{E!QBY#qKf0pmo$y=i0tr-qpni&)@P)sJaR@= zvcO8vNBgma(5By<TP?Nrcfhv(_vlpOM?7;#P0KF!d}dt(U|!aelq$Pwb<|VvaG=<* z%~nV0arwM~<<bjPk&ER_*hYxMy=R1wgAyD)o#(<jzf6o99sjbWQAM&B>=0hXMKV}} zex<<^xM$o|BG6Epa!G{f<W~LiP#Ba2E5$BsOsw~nV>#M$#zw06B)t!u?bL1^UW;7O z?kvCwdo`bJTM`-57zkMn(B=bpv9;bgovv1I@>?7iIGta)Gt@Vj;Dg}3sAj!{L*(w% z^0(@xn9nI79oYIt7ibvFud#|(!#Y=guE}+3{#xN1#!qy$f7Z%7m*}%&uiaU5pv|!q zQ4hn5F;fO=@c8L%*1T6$7c#!9lHpUQ-<aePJy1Wnl|NtXTwBqNjP|GS4-LBH?1LS3 zKb(F`)Vq)Ip)w{7M8lLvvEsU%%sUJnAL&GClADNSc3lrE(RWM68QX869oU>aY&=a$ z{7z5NHIP=5a9!hv8YNaC7H+z-SMGWuiGT>PXX=_j*Ha84E^)w*#9wf5)Zp^3;uD}S zp_j#b_l~^vZm4Y5vD`sUgWy2GtPj%BMD50HY_bF*M;HI67lh~PY17{e<k1-kIqg1P zWs-qb?GGxBe++L?-e_7j=Nq>Xd&s@K5AKj0IJ^%Z@6oXt7Ck@bGsk4igI~>ZoSrlO zN0ilcTduSv=q>TK+4TKgB0`NoEy<a68b9Sqqz8$IrXvfLNA@XqZeF2_pc1uM)NxWf z9B6s6D{e?X2WoolO2a~)R0oC&eE%;cr;=u&0s~mZajiKx51_%OLzcpUr)3WKVulUw z05wk5&wCNDo~4!&hSWgx2qI>>^KF|>&4Iven{S0O|E;F><KOwiwI2az+E-h?`jQwy z>k{Kqd5i9=0at#)Ig6wh#=eGgRMsHz2u+`4i%NWDJHiXWW4jKfaBaO9%Vu!tj%q!8 zJzdbuiX{7^Wj`k5&2+89=m5@0!k9rp)Z$EMhxciVxJTt7f%vz3RqZ}Wo^TVg7cJ^> zsji}sqj&rR(4YuP6zFs1ltO%%=GIOyoWQ=#>_+?7lpW@cYXZg69|JRS{?po(Q?PM_ zi{r0*2-J*pYQCd<&ThYWH0nTI#I_33t*WG7O1%`7t69PV+4v*Jtv7k^k2q89b4>?F zZ8GzAt~-$h^;`)xP^9i8Z1ajJ<;lOmnYSAl_O^h3MUW^q6{Ae~T(E>cx;e(Prs9GF zLxqS{u*#d>Xd`j*uFWGgQd9KX2}BDfRxG{8eIU!x3_av+X5cNdv4Q4-;El0BvF^Y3 z?+Pgd6fE={`nh){qEc3lz4#?P%9Klluxuv)2LoU1I{`T19b*Z|b+rIPz}{S&t)=b` z=%+jZ<$b<nf{eA<>(slrp9j~fMfGn1F$fCLww`6u52d2tIIG4oFH>rg7FqN7M(l{c zWQ8IeS#MRI-py^SVegUrCjxQ$n@i3YKk@`+!Go%1D-B5F?nJ@JnnD{k7kBuCm(1qk z;itU*vlt-7nNf%W(t&lyXmM*+3aum~mSGh->^ZBL3x};>FV@b6V9gz&AI^~JU6lNX zq3d46YWLgBPR8u@?z^}!)(chbHa1S;LS-AYzR|r#pWRi1M!J<cEC7WBRa~R%2e5_^ z7PBA7f0&geA|u?vN}-ZA4dp3aS3|=!4CSAi7WS0p6MG4e^*8u~a<V#21NQ0}f)uTv z$Ep+_0uW;*M^d9eV%q;uo;G0l)GTjTnFUh9!>(_Wn02>FaPqmTf9Uo)im$~s^9zi9 z_Jl+{d1C&=$8TcNNl=x=N2P}FEU61~P2>M0Z|2Y%j3}7!m4IxRjlv`k6@t>SulcSG zzE;1FOa0yh&NA<(r<>E{#97Ezb?c*+ZFX^LSA9XB(!Bq@VNxReI05C5SWH_*R&b?n zjmiwba{idbnDqwBc{hF}{L?Ox7^Z2Y82ykYLW$+H;V$hlKME5dYcQ!2nXKCxVSC7} zsKuTz6%L$sXDs9JX4X~*ruKP%G2BG=aOfy^(^DO&U+dCR;E>|d1_QVFxZ&V#y<iW@ zD&ZFuswq%iaKZ`x3Peu*Z>!lrRif4tfg@VRV^@OZL6>@BQk~#oH{s4u5o%P^6OAyR zqy6t1R_b^9Gg>rU)v3m^{R<nHt)sV*-Py5CKG^!t`Cn(;()Q%Pn31mJtH(`RAG^b) z?Y<r&-9D$MITh1cskiF6@YCK($ha|gTauXJ5WUi1d}pwcO45<P8_9R!nc^e}QRP%4 z<Rr{Yu3TvBk)UM!*x})<V#KDex=5HRYY6y~25qb}Pe|_nIWAqWuB)0OYBCGl`^US? z&yaA=E=I`qsT-yz%V!GV7Ct88?mj&`Cbs$#RUR$Nd*xi2=B-G!v|rrXsn36bGjM-; zdQ$NME4porK^jB<CV58FRd)y$ID)=;XtPAo)~Byu4$#%#GXX`VxI2%4Xi^Joti@MS zSv||KZYA1fGVq7-7G)^m)TL1KXav8$GmBtE^Lx3<CqD+F=9spRRj!m}mKw)1VQji| zjT=E)lyMy`yll!Sk5*&+L!r=g9exaUm4S__xIr>&*iU;p5}luOCCkF~1Y<G);sV|U ztf`lrIR9JNqYzGM*~=_WF~$3IE8j1*J#GWA^vak5;C+&=4a_KW|EX@j{g(qRJoB|` zxPND&z_!LCx=(}7bANB>b~Ov73#r=FmQ4>!aErcVC!rH%r~+5N5BQ7*&IWI13z<zK zg?aR)VzH-}zF}uR@KxzJ;<pO(WxfVa69~3So+cFRo<|ZsX2u9jmj;Og*N;HrD$ufR zU<46eZmH{(lubg|W-c_Voya6;Cw-d)cB!i<3~;K?h#{vz>vKE37wCX8%kX%y4r@XZ zJ+M*!p35;j52>Bwi87^;2MpKthPo$0seT<(Q;Wa7z$pt`c(f+F80ap}ILFa?Q^hTI zPn#h-rl;1%Ot0KDKh@v%zC(8atj!2v9e|qd_o@LV*DfCLfe1xa-|A|n3nq$K`bxTA z#*W*-Q({|gFmZ4cxK56dSPu3sU?bJmfl?tLPaLu)Q21_uHt1vuGUe(O;7AE8a7aAA z%wnxSmIwFO)p+0b4X7~K=^);Dmp%+=#RPaK%AVK_fHPA63*w;KIa(T>c<dtD6V`eJ z!gDaYe{kO-6h<w6?=6f*<e=|f{A_7Rsc51yX)e>w0*j_CmSCg`Ke{0NYzErss=PxL zX(Wt$aeZKlP(?vhnai7i?tOBo?r0x9-1<T6po|f+$W!C8S?7r+u^bw>`G_+!cY@T# zUXxZe3ByV&IC)PgmhpJPID1d<s#L}hy~1k|i9FN2yo+w&X;=UVUh|K~GUeyqO84*q z$c}W<-Z<n=UVrSegcoAkCBR*jOzlnPi_^WiL(NmSD}=D!TE%}qaiw><JiTCC5kUC~ zzvdW~M|R&>#~d=f$fPEJxc!m9uejyZ?Y}ve6dR<&yzX4wc23{OW&5HT)>_waa8h)< z{NC8SAsruegM_jxCVsn?AUHC0n6JOdGp-;mEzyx<F#1J&fx&K4ObQ@pGDD%BQG2eg z$NlwG{MSrSnH39{`AeiY#Au&1Uz;pV-P+;;?_6o7##s>{SI}W}&Pw89#$jQ^1qMPc zA4eHlC(iD@6_oO0$@t1@>MdJWZI#2dY|HWemt`*j3u1xj!>5Kr_T9B9EALV|eW`?x z!}~FwpMfoXW6^oyJ(lEwfTh{pP+PxO8CdpF=#J(&CBfdGo%4iWVoPuSPTM=B0^}ZZ zjvdG014+c$*4gxHaMH0D?aDoSC^(kj6cZ8{t$^gGOAonm5|K}5IlmXJjACAN@0h73 zO?QF+IT!H{f(cosBG*R14$ZVPkegdlGb{Kn{Dg{~b>#|++|j6uJ?Gn7bXeJkn-Sc4 zznw5s?eD^$qwG>pp<c+$;Nk4vp6X+$B}U#iE=aqN40}7sB+Oox{+j;-+2EyboWk3G zm5}k?D#s8-Vdq1segYFfkniz4EFdE=xn+FdY>KtZTaZbgt#RL5Cy`hiem+g$CzCBh z>KmSiTbwsv`#iKADN|h@!D*gXiWW%f8;v*nO_{uT9Q>y%&suagLTAh03}H~7AIlT> zn*ZXPR{ddp(S-VNv^X>S1g}(gH6GJJvr<{{5H7D`(~w+=a;S<gt5*7{ZH6(3^G@Do z?+{vrOtCo$y6ZZH$h^!{clMIs!-hvYxeYgp)4oX1pZsyCjCw5|=k`ifV0MQbgHsY8 zw%j^+6<{^qUb9YQ!1rcXl_&`*;=2ch9J3{)niD8g%$5NOZg`C$tDnRvz1Bsl)vkJ! zy2>LmK`>Ej)%VQ~XDnK6lqdgF=jY%rN-Br@B3q+nVH-^Il3oQNXmYw;GUqvuhYJ`h zPFU}kZpDZ{W1eQZPXEpiKh|{a$RWdteeO<iMpoc|&LC4yIq=6P2<CZW`G(nHggGzQ zgXtM?kmD=2c!aa38KV)muj=IE=PX-X>@HZCp9c%U!Xb%?Y^)p?F-bpL4{9WC5HG)T zYv5?#qQlarVHdm!!M@Bn!H~!(^}NhI-45$rM_B*ikb285r6w;V2(rqwHHHk8J%wq3 zu|HQO9<SHfVqDlyLeaM2`G#}nS?{_d^A2k1sGYCf^(+T|Qw1^{$D`Sq#$Lex`tP|O z`d5r1vj^>)HcY=9MRbUp5&2^O%?YTN8)8^XRb#$Z5};nv*s&=&=7V912TM1D>;8Eg zg3@uytjd1k?%Y-?uZE;_y{PQCcf}~JC4*ERDctF1wY8Mp1aZ;oFS?7rV@(Y@D6?5( zg{57p<xc4vNTnrXaX=eW3h*^ehk%sJCrkYP8-QhmSaZr(eA>i&Cp|A<1YUR3O5msM zVT#1B*PZ$6?z)5}Jng$`kEpb+Ekz7Z`q+b`U)!Ydm*_ak5qU>rCAsN(_Y1sBni-AS za!ie85ns4s!{)9g5>s^=*Zp{-@9<wuEetOdxeCc4&cO`%?Uu4UN<J+5!6t-7>}|p( z(-ZM*?H`G_iQ|Dxg78Lpr|ndKFHnvP<T8iyE_#5EWYD_snLCtgN;Qc6s?rt7Y6Q?U zvmq+WJP|UCFhG>qgYW*Z08*Iybjv_Ro^1&U=mHe~O?W)M5%7-V*Mi<;b`1~b;sus9 zEqYV5_$|zfqZZfueE0_$LflHsCjDTbxMiD!*G2``T@4t}$ab%F-MEotcJ47_;W@YH z77EhEISqy%;Z06k*PYQ95SYe(qOR9bBaYRGr)1wRMZ5%Ijjo%%Q<`MMm58@$A>=~+ zHs58cCB|a;)-3BbbX^R{YM7h2e*XQlr(yXWHHQ7<m;{l-2CKM6zrBm65Gn)jGghwv zo?%(&H6TwXOs?|G?!n1_2S}~qtP=801H+Wifb*D+_Q2-)H9i|at4J#Wn(@1J{?7lf zSF!Gjt!}57g9dRWTtiu3gR7AEf}eT`$~juV`R$zvi2t*J3_q)+HbIXRQT5GnTBX^V zVE~zqa&)PVZs$EeXE|NmF_LL&d3;z;>`&k@j7n4Kbj9m2wO4TzBGY5T<b8tH+>0O3 zZd0nn)qJd|xK&cG+GeA|%Z{txk`VZ6&!qy4@oD1pQSWcL25>plc`M}VVT7^36mM;o z`5J3Epl8(!4cO`v{Kf}6`@f{8g@4H4N%c88yKWpEBgj0Db`R}<tX?=12P;DRZm;k0 zDp|TMpEL~tyPx{}Z!qbCb8V(`AN9&GGE`=H?2a$LY_MxT{-jf1lYbr>v)azu?3nf` zxBj?ni1^xf^m$l2_c3EII7u01g_3UqZ`!ZOV?UOtM5@HsI#}Ax*%wkGUGA;y0i$_> zUL#w)ameh}ct6Mc0_8u3^SC>04nJHmxC$cxu|k*oQ4GuOMmWQnT?Ukf5)pG?5#uXy z67lQBHgWhWmAy>6L;+MvE!LQe1@B%&<^%oL_yVAaa5FCpXoY(J+bX2oI8*!Int-<b z0*#z>Jh43IAk0^LarK2PCLqfML*>BsJe*?8vgTIr{TzzOS>A>41h;CWpZK6flhgX1 zQmN?DCt{~Owd6ANZts?uq{i_h-L7RZPNoP2XX*j_cw#7zI$VKY7n^#@MZD8a{GoA{ z$lz*qo}}aPI;7`-MLHR>Qyqfd`D!V{_T?JLqVN!?{_P)@IevA-efHF@R?NZ4oimBz zSq}h`Ol_S&iy&9BcQ{}_Jgqq)i2WBtOsI^Nq5?<ifxxeYF#V6tDDJQPZjxj&oCl=X z`6r;%XY<XtNA7PDsdV_}4O!{5S2e+PUs{ui_3P6l1|#)W#i;3*;H%Gd98?<9?&;Z2 zLo0TNDNuSe_tY8Uf$3eSfI6Kuo`QGat0e5jB~WMegZ3)lb;My!v>iP611CrCsNvka z&%4Zt=l>QQWbNdRj;TI)qnz%#s<YMW3}gU!HPh&?@Y<zY_+unIO`hD44#SL;{U%CJ ziaRqS(0_D(3-A1IUoxJXWbLDm9wJxtc(k9>?(c}cLXoj6y{U7S4doOM3`^e9Rp{Ig z@;lTs$hU$Uz7`om*UC>HC?1f?-=dhdu5Fha)_Gt$T@DAM3}(T`U9LkP=cf5zC3Cf8 z&IxY?0g^;}{{JP_Q|@k#`vwgoh9BQ}qinZU?x+nw`x77#n-pIz)7PG0bq7H8J4<Qj zVSy`WCAFrg43(mqIK0($rb7pQh1sUgf8$SH$=MFkEq~|#@IvdljH{(w#*qCwN#TIS z-q6lh)L}__GDd=r<K=NSj%V@TrBDx<{r#{=zLgCyr?UgI(7{c>&kqci{PFYOE$m^G zkOLj+(|(jj%JN|HJO?T&?LV(_&HFunZMYi<13F2nIDgn;=knrH5-m^ZZjRLa9Uk)L z$o^?u=j8_Ky~f=OBvwan;D+ug!JcoHNyGhrI4_&CFIUg4+zPkiBvxWts>9~yrHc-_ ztUraZR9QkH%V%lH%dQ3$_v?Uk&&KyG5r|u4pMh~H;^7-LwUG$gMX74FG<uVO(=5`= zr|^&o<3QZ(E3SZ}5TMTgLM78(r5exG?%-7!c|QJrSYCO7_Q*EUk6)R^cVD}o%gXB{ z<qtfjO8@(WJj+#Lxy$e*m$BurbCzMZa)%K5W?zAV((dD*%*<{e-m3`?>*ZDQk<}F< zgmT7$VL@G_bwQYkc}^a`@pKpsb*Ond$t6rpIZEruRBKvBB6D$2*pWdv9AAadY!*ql z^Fz>7q_;kJeSdl{*VbG!Dt#E3VKdzeWW{nuhq#pPxqJRZ_LChepF_%xGM=6ma1Fwc z9;$G7E7QnLGXNKom$M&wVs3)QE5Z#J<{x?$@E=ZG%P~m18uxfy%8eWjijV;W#QuBY zxc_&i!#hB%eV$~#dx*u@M4t0dCPnL@OEy7|^Su?*tt=WaL+^oBKeI4&<`aWBw%$7| zL|9J!H3LfmmsM0^wBVQ5%oN^4!zue#tS(A_*EIq$@1j0wO2H+6MM=lDTc6)M-Spks z{?PC{-1)poia?Nom4&4}(8_moTmp3Dmfr1stNW)G;1`SfU2w2Kn|@UF2EUMNR){df zQjav(Bn<J;0=nXaJw2WRKLXNx$BN6;_=x-3K*$)2^tT1cq{Q;*w8XH<V=e-vLZ#dE z^u$e*W2=uRS0<*Y*|?_`=kO)(>s^IyprZ}6h+ep-@`xnS2=p#hjr9AQQU!k&Chgl` zYkdDObrY$7Gn9qArATDX4&Fc4j^%T!%^<SF*C7h!1(s^hHF}2=>^6otvgjaR=o*ny z^FAtyMjJ_FQk1k9%Osm0CAJT=dw0uDO#=zBRF%D#1)ND+=Edu=hrFqI@iRs&huW6? z>mRWgsAi_QlW3t`YqK|FUk?Sz6D1k1;D?QrvFZp@NXWC@=++h9YP~k}XwpPDQUv0q zml2^uy?(7Y7;34O+}{q)5+UthQ^EK=N9GKv9ZJwJbCo)o$@U@=smtPq4g1jMX~Uj& z*^n``Dm;elyIRsyWEEB^0}1B}*rNUqyDGTb8ANwh!&f>@<Q`COatte<+Wlu9MKU<% zzjDogm*TgC!JDuqOzv+QuVN{tYPj!c$5EqAgg9%^x~FFYa0N5STx`g@k~)0>S%c02 zrp+v@amz-hQAKn7zo%bA9GFzoUy7bLq9<g|Ropqps%Uw{U-LwFJVn{5jFP&(lM9pP zxPC7TZvN!bFvZUG^G~b{1OYzY@AgBJ<taXA-U<#=QS2iR*(>y%y7n&HWUJK<*zCR4 zN1YIKyytjn;d47#>$9gR&DSV{-^JxFT5t^`+qJeFuaI=zHtEN%2wm>QY`*Z^e5&}O z|HX?&Efvo}Z4bVtevpFcr}ekt`tRPII}`Zn()GafDuMXDB+d=qPSQ+EUfvNtVKq(; z9nanA-QkL!OPrzJh&FLx%4an<8O`}ZwUT-X_XO;qE(GWoESOXSa@*2h1w`TFMua$F z-!*?MhacvsV@S8RD}s;3*DkJ!W9<9z;3e)Qma2tFSl1|VHRqv}+5-U1K%G}Y5tmP` zyz<)7p&c#L595|eyQ&)&FS>+*Nj$SnPUzl5M5zD1#QFbAg5==`r>lf_*F=X*A@aMr z&J&`3H#FIUY4wI>cBU9_=qnRGXIkmj#)0sBysU?%9V<=74?M1#uJ{-!`Ag;4s6QYU za}qu50$OlEbD%JgEr(1&4s%5A1Ci$Nx9$!5i^GQpfI`o~Ip#fd@nBawc-3Z~*%7>r z+?fgQA5s}*<-@W+%t77acHb9B#{I-g3U4=pSo~jC*#;dY{sgCpN@S~$H`SB&)CBTl z^WH|EaUxH?o%`TQftO>#A3P;BtJ+vM7koue3z+u&I6iPEbV6S6!K1z$tZI#`T&d<% zmLQKl$&qLOWxNVq{#xaH1Ehh<G^|R~e7xEO8GcK>8J|7vSj6jFH^nHhVD6rbq)=t{ z^e(tHXy7)fLeO`=2<oY8>yR8xpGSj}Mm#QX9JrjVazE!27WkHv`OZYJr`>|AVO>$| zfn%b0TFJP#QWz&JPLebE$Nq!$EvkOchIbYD2y|1Hro@g3pfX6~*vcF`FffzAheaQw zElnUM+gAQ82OAFdhhv?2WCCsCV4j^H`K$zF!ky3G<+!plc;~`|#F+0W1UzT-aE>}} zXSjNx<7iEL%%yYn<sr4X2_8sAkV=p{o^O#Go}*bD6E%8KVo>N|_l-Y1K$z^G^1j+! zkwW*h5VSMKc3KW}25rwD7k_tEnOMvp?wR*ClktPrj)lau&+O0c!>=-`J;h9Rv8r|T z-dV7Z`QJWy53cAgr<7QM4H^@pB5EC;Sni%VZFD!m@E>8-NE9(K=w&JIP-bPEbCHbN zK&NNTVAp0Z!e)J}cS9j8fr6-DZ}%+WxP|%75A8Cq#rzKPgoVn{Azuz^dtA=1=zGo_ zz0H2Gn8^M-o&8~Rr1iu$t9ZBWhpqn7J$iI%Bsmg;fs?GUe2Zh`Ntyi0{n2d!^5dWp z$4ObG#Qkze9!gC0<@pH~$)l{!`AwK~NiIQnJ7%nM4{}J!&HdnD*mr+^+#e=uBrk;V zQN+3m<vOxuTwNG%2%!=_o*e@|7c0XwH4$SY+dw}mf8(|apz8sRF8RaEGyhKxQai_S z`n~AkdYo_}i7UrgldYPMQ+u--yOEkat64Iuu7fMtDqgd@EQrM|GhOP4{r0bau+;e- zpROY@R-wgniSG=83XA@IGAh_**w6JZ=dBKgxOIJZkMne{r8?S*^wd{XGlcZm(S(ip z`(bMirJAerQ+W}tnsD&O$2ZQtM+2j&1T?wQk{+f7&S}BEb+-V@!m@2|osF`NbQ9rC z6fbav?C@X@_jfDpsOAe|Ga>8${ZrtqG-M?&FbjL_U=w%qOgFzQvkar;Nsh48t4X>y zSIy?XgV`tczjJ|~wTbMFKC$1oGD&A*<k}urJGE=u=RST^%(7E)TSeOUGtHS__lmhd z;SVg!7v1{uM8(e)vA<fWo{MXZJg?)g(<SNK;&XXjJNA=EhWFSu*z9L%5(Rj!?vY<l zkz-i>Ph+^HMfAw^h<w5G(Uf+1&8z1--qE@Ry{cef%Q3m{I6YKdoyL@Du?@fZa*0E$ z+=wS1VvKpEqDJ4*25)%6{dz~RdWl1=jpT3H#vE{j&J5UsLtT|edG&Pn9fwjr9PMbn z&r1=hN>L+EHSe!M_5pp&08dZ|L{kXCfomb}3ApcfCTqG{28K=CtC++b*luLp09KYa zG80MHvUjuK`B~4=l<!G~cYZLwK5_@tJ_G=#PhM#T`hd*;-Ytdy-K|%a!xgL2v=ECp zy9&(o)}uJX8MkOc<?@4jHw$zWbb9k6s8oFUz^z0bO3RGvk680zGW?R`xwleS<=vz{ z)Kg;ZrP?lpc27LwW)1OZSmu+oq&ZU(D?-^(Txsz_?BabxHl;EeH?t4*eG7sDXS)bb zmTUZyEu+(RofHSlNrzv<KY^4T1F~Jmef5>IvV3mVhT@BX%M<1Ps5wS>t0Zk7i`gZ+ z9oIhb&TGbtjRKQ%%>35`j%9mgq*@!Df@vKv^5&7tCu3DjvDT_soBfRPyHA;~s7XD( z3{UA+^vo6&eG1$R_LM*28b2-U=5OC#n0s-oT>Grr%I9$U=hV|B`p-m_gABS*Vm?xk z{8oHbBe*+=x5yxd8wn>`$?#t5tvAZ`j8LzhX{oGp&S7ueTKiUDbmLLxqwIX^c+bAf zTh)f{OB~vg$?)onOrc4Ud~nDEJJHUY>7b!Tad9h9rDW*$c|r~e>b>tS!aavlFI>5} zQlXs&8&|ynPsxbvSOg}(N+lgfTlFO-E-x4eXAw4AVA8-iB<xP<CIB-dc&vL@{Xa}d z15vkT2YF58V^`z+tr2b5)yzw}KPk`jFJmHAI&Amj&aM7hqLa-oqWjmuKcy4?Mar;r zeFu}WPk7M6OOsL7n`ZNJpx_0}XB#8<8oHgJJt`{BmNu^kmXDiop@^Bb3>>?!k?1jt zwKA7~JUI2zeyCNau_Hgx@$$TVWRyxu;+J6i&{@W2t?kX2{EIjPFSc1z^)wJE-M&~p zDU`%K!XCiKQGTAy7YNYvtS%q(c{*&Q5S_|KTX9mpW7xKipF%XyeS90#`{0&$+KFAT z>Xq3$BVX}0)8tWVG<jbVDzks1@q9by(MxXfmZ$KRP~~ZpyGLTG)`!YfCp9m6LQHCj z-G?Jtcb-xZ^T1Q(fRLtc?L=9ENW~Eo!0|2aH2{YEH}^0RwfSdg`Ki3RiHVdZj5h|5 zp0-HZtWFZ;ldX+^^q$Cc&P9lD&%A(ZN4O9A?k%IoRR?Yr()W?Dl7Xs$Ya9{HMb`Nn zS&Yr5doDB~{QuMr(UFca$9!u*VG-AhkR~P7CWXyyZR_>n#j(|v?EPzg<H<|6AY(lg z_d3611cJke=1JN_-n5vcFF~$TVJg^12bq?U$x#BAj~n-eoDOJN0wyA|ryuf%3y5q1 zC@Uq0lQZ)ws1(q;-&afSt}lu#-HYUi+gkUpivv%2a`yD8MdnVAJonj>ZpVD1m=FAI z5BvA5jByhkhIgL^7jdulqByH>I$j>E&L95*tArgGL_zsfj^}9?e;tiC%1w~&6QN4r zaT@Daq!QT=jm&oJEYN@yEPYP!h|}(J!PV1MXneW9CH$V{5nV%V40~`xf5E8L)%UdJ zB_B^Wh@$o5@E+VFPAij-C!M{kff^M@RB}!U%Gt|ruTuS)IIHC92KWipWG=mVW;07c z?10F%TNfV~if5U!DmS&_THbc3pEiK+&Efktg+lwG`V*FQm8FxO9M9qYMjx9Nl>B!w zhm3&qEYxp>Hfi4klhtI^%P=iSYd4nvCA91WEf!j~S0B=0s64to8!AF%Jeh+lE^Zaw z2$wDkl6?jR25E?NbiDkHDvbpHhKBH}B5n_x`AJEOvq#Gm-dBYg+)E0)s*PTX#Y7}o zej<IggRX8}A7NeoF`YGla@C#Fn=C@{l|OkI<ab0<;CsWYpl3bL)?pGJHLMpmO?|eN zgDuSyA;<E@0qtqfE8}elu<Z^E`O5xcpTJ9DfR?xgRMJb9o7`WH^qRWZSaF_aH6TBl zyf^}t^oe?9lu1jTH#><#1|nj?iw`TKJcNoM0`e6X%_GNoSm)C&MRCErlWzXESEnY5 z|Auc)V|}(FDt-Y83K+C*ne)?JZ!0VO{o7E_BR=R+EXY1Avqr-zTbQPZ$voaY#Jb@# z_$nn`P%*(mPmd8blKTd{dmYdymXO%9l=JLpnchmxY`zyMML(1o9wkN3$31C4zrD(+ z@}9?~x^D6|drX>=_YhmfU?`vyJ!B-6YonEC_mA=_tb53j{5%Yn=m-OwvJbC~&?~tg zyJBa*c`tmE-uG0?6Dfc8fd2{D$C#7ss0Loy+PT|gakX+zhjtYm4&2f=BIX;wjcue1 z&fXteK}+^|_MQu_V6Z%wEfmJxVSPNP_y^m>1*`p`L@OL99Y(UhBgVN|Ie8`5Lh1$| zO+bU6N-6L8^xYnqdXq~v(o!;E?Q6~xRc1`^Y3L}`_v+7o-xtdhe_JfRnms$bzRWpF z%+?8PF=FZ&hhp|^ZElgPi(Rfdf-1V&TG>gpjS`+%g9U_IMqBNR=~{K#B}W|b=)szc z4}3kUht9MS9G!bA&M!k-U1yT}?jTGG$F~#tuC{)BFq(Qx>s#4&aEN8HbawD`)@(SF z7|lS&@O(;B@}S%e{)RqMsQW(o@O$5s@)`b60nyEM$gVFIIL3_?7g>)VfZhOev4^{% z$+%4cV0bfQzQX%04cc)vFA2}&70j<QP;usOjbjD>BCDeCdb5cZIU*tB*g*1}t;N;8 z2G!zZ3Y!9U7rwXEg8EFBNZv2RSPF1Sp<V1qOhQbcp+$n#$ZP%nn|e#s^*PCr4DUcC z*YhnToyxa;_O3puK>F)(1RJ*nRJ6Xu7%%k(>HOfJj3Fr!o=k%TAcPwWH?<Oj9i-?c znB0+qZiXCH%z*@tJE)6l<&h1oTLpG`OGB5L4hsDoVi|Mn*1<z$Ur}|>==hd`i%JIo zEIi)V#<EM_HL-O8`gx(d`$4(1+nVo{-HtKUsX~zLTMsA4dFomDl8z$*O^411jZezL z)jM>wc<=u*i{PdiA*#Ew%+qS4+(d-t`>-S56-^+qiafh5bZ+Ul52W~?*~c09zkbPz zM(O%{I>atAxkIsx;cxh!&9=6M4yAcG^<`Xl%G)$6E3mj>%9s?Wea@-i{9&%$Q!sud zpXdz$E1V<#fhPs3q*xi=x>;mcHR8}6IM{Z}ld(>p#<+y{wUK3L8%o}{O|w(}27vdN zmTu;_Nq*vq{5U5cUQE~2$TfS&*jM~&aB9=*(kr@7<$?B!Z@q_zaa<o_T+=n+0}yiP zVug!VrM1zgr`~o1?EIT_aNNc)MNQICwmL>w&4$s2%Xo7F95Gk83{sbd+)3(tyCZ5_ z|B!;;Y1!$>)LriodkxDjl`}a8bZPJFmUW*744_=Ezy`%bo_<L0yAaNbpdE3gXc_7D zxZz(PXyW5v!J!GLppd=aqBL!QdzPK`_4W@6>|SY9`kF?hiV}xrg|g9zFb?$k+$G7T zO~GoYqxrocc+0WWm{o=Gu7Ui^@_SXRE8{&NLK{gHqs~zIDse4GJD-~|DTvezUmNUy zfY!B-i-cWV(Jy~h{#sNUcGg{q57*Nj2?(5+sFO;r0b~RC7SQT49CBHj$^SwJIZpQi z*;pR$;=fNm)<Xiu{84ezTcf3#qj|di<6gCXgZVLmqU!@8#+3u7@!Y7afuI&EI@|ZB zoHEvrcCMU4pEp0)evV|>acu*#u&hSEvqu#-ZT}dLTBLBPXWl++ke`+PGs8R0UY*L= z!_DhBXo!E9`@Og2_sSj1M`+6zmiR}D#)StL>x@*^v&tp^DM|LLl%(aM>a6i;&Gl+& zILd5h9QYAs5bV0BF&*I@${rBiBF_%h_Lr^<nD1ps;)%=iUaUSb0|PUU#Ha^qikf#` zzEi$W0!Bry2tWY*C+LV$CAXaBt3J5USn&)wc{<!La6%Y>IFbZoZ?$l>EWnl5EsKFo zM^qSIQRHLb>)gA(@Y`6lOQP@ZiV5BSDKy~Pf7Te5P8vpLqiuK>BJ$E>;8aQeRqqUA z>|mIQBw~g4C9N&f!;}n%qT=QQ-pUOwXCq|~4!7>`%Ja<vTw@_Y+}`IUR`XfIVd<YF zE7=X%-qO)na!@5UbhOx1;CiU=`nr*ll*~3WPKi@n4)&q_-77;@Ou}0=aU#A;ztLQQ z^n?5_on!H%<A<m&Sg<+gLOYD@bNM{IC*STXIoRO=XSTB?(0_81{7DL3g+p*0YaO4) zN#G<Z0xAM@7WxNmZ5B~MMi9{Pi>2{4`Qj+Z+|ImIZaA$A_NT~R`x8KZ_`k>K!C$V# z=t_`W5X%w4xZiQ)dVj-qsmA&ZR5cGg=VBy*clSPs$z>}SCvh;_`dX=0d|XwD+f0DT z2hB+`?OocsDNY>fb#j>ePSJdIIqwyEviDU<hj|Ocr}7JT!Iv}#jIjrQge=;)$ie%O z!xQ_0d;082!4p^WI^_n<1}$d2jjXK+L$?5lRP()mCQb4o0L}4l|DKvKbJ}`Ky7ci5 zq|^6@$UtIZZ!`ITz7GmYxyo#2jbqvxFAn_W`xf`a`VFjKzNfwwx%|@J?X#>=?GMd! z8UNa>2XzWVHs$`rZ#*Gtalcl6s$y}`T#jY0p)(JH<I^yysky(LZFo+Wk-|=jW{^oh zAD!EhKQ<<2!)55Uz9np$xSFYlZA-N8f{IZiGil!jUAOCI6KB;mgAYwThFiG`?rdDu zBD|A2Pw=_F6Vdc@$V=z-7e49jDe)S+0alNxtqvcC2l)K6&d%r6i=s@Ow%^Ln{TP9K zJhGU(F)zC``?FDk)XCo69(c<Zd+ENM<5k~R^zs$w3x+8CHytsEfz3ya6?C!EkMerS z>!EJO=&P12zKI8HK-1dyaTIWTNMS(q7CdYUYeH8h$nu&4hn;0KfCdO*z2E$Gc*Xy7 zc#8@Ua8?KhT2pk;g#RM6|K9npgvdwddzr#5O6K@m^4UdcnV4n52vvJtc#clK!2%0F zbIMVw{l~q9URDaT5&HbpK!=LtdvA*!mi>LMvhgc^XlUN6s4M!`GvU_E)c7DyC3HHV zuDD~JlvBz5#MWJuOLQk9pv_`+W}7(a{Rzt3ETm2v6N2uwjM)COBo}#gG@xX^+#&T6 z^JEET_X7*;g~ofL^-32N^e_~6=ZE~&gGGM*a54kXML7_WV?{?v);Rlp-G3*>1Rg?n zMQu#&avxn!=W*iolX+yo1Lr-?`>oORCNEP5VKYTD0*poTwYVauejXSdgEUJKrYCs3 z^U-#guM1<Zc^76`Zy`Qlq;86ne2eT}Yb?Icn;06!x4_5SXg=%j&^!H{{<e{haLe+x zcTb9WAr-9$X)A5da8D>v@nO|RAE?CJAKiQBiJu2}vAl+CQL4nx2=wOhwWNbK6PxwT z*Ov*D1CkPopv3sN2%CK?zc)T2;yYs|whJ>>46lL#_#sld<8g9M2R=2@OZOfc0+Ctp zv7wmlE_&q7)Be>O;I#TLah!x^!MAMrgnu_KoPuXdKmWDjQxmq=1}l~1Ofb60_3tib zv3Is^2eMKsgOcc9MEPn<4E$Si{V}o5XExh7J(w>!?2#{@p;jrGwg<@$@+X=9Y-r-J zw|fG^`=!nA)^oSsLMc^>Cg@&!!*5<r&=#Q{xI27t&OAiPER2Bo=-g_nA4mE!^R+-K zVDMo@_LFS`VXJ7r@89EBG2eryt!7T}nom%C2q%6Z!%<8Fpy2kGIw7(*bS^jwZ?(YK zx?&-FeEOW@532uZfQ@IOg1CKF4ep|;DHgZ$m<F&&I;W#GRTTck@mdO{r{niaj7_lN zJd_;g%lAL#r>imoeL67+@Bh~~P3<B`zrO_h<9_Dla571;zx)Vb?Lt&lY^^+^ldBr3 zDEL;c5X~sXo+nZAxsHW>bM)o5LFn@s4sz94w=Hj&wQbk)cW!gGcb1siad7YxQc|%_ zY{z_&g+fzH@{h~xz-$oH9?~>#bW5Gy!tRqm(ssQ~Yq@nZ>yPB#N(C}!W`LA;<Nsmm zI>Xs~-?mOIMHf}GOSNXJHleDjD2f`1O;IcMj9EoX(Na|Gy|-AASU*(l8Zi>FR}iGJ z$NT8|zxStn;5eRpT<g5f`@B0+9QwXjgHm)Kgmc4^1})5G0F*}4$<F8AakiFXaRG`{ zBZu9-YQljfFCHD#!TU)ACiGzO_1x|wTnBI*o=LfB@3a7oXbS5FY(6pH!93*2E5<{t zex4m$`o)`%!ie<|;?Lo*gLh2XHEzErZ9n^uh-2S!T#BxDp_Y)|P7?dpitt5GmxhXT z-RGB$$j<R0FKyA^@>&;vgk=cCM@7uHD|U3&=nUq!am`)6u(%{uOzo_?c!6mzWJsmw zi5fGBdLV7=VK6Qdm6lj8-}4)MJ4k|%0Pt<$elwk2`Z;bv^0ILkYMq&?luB9ZAddlf zSo}%B%vNgHZKk?C^IymMf*k_*tD976ndVG*H-49MN8zxel_jL*TcF6CL;udG8Z`fh zUelYIkFE`%n0CK|$|esto6?MJM_e%J-DQmw9p(lBDV9}1o-yw<3!QM`?oj!X>M=Aj zFdAhd#2>%u%G{GbKdULTkS6OmSeI8`Ge}kw;3vWd!?w_RIbemC$c{UZqD)!#rG3Nt ztk$^2Rl*6vz}oz3+Bxptgd|K&{UPw+c4pn+kCCH&jqzq}FFgKOg1iAPaL{xK!N7Di zt!elQEJK{Vssl(TV{bi&h`Xpb1xp>nX~(0QYg;7p_fNn=Q}?R!j@DPo`Wj8ZOgCsA z)mWYyF^S77FGzAuFErqE*DHO?LHMdQWbFoYQ(d~pv_6G}%o*#?mD@bteL}xb5X>Rh zzGX3PX0W&eG4ELyi%1rdmduXJrppx)>Ir+B)DP6{XQB8PH?@p5D2wxi${*j<Dh09z z>(#-n`~OjW?A-y5Qo+N*xHDac`?geSsc7S;w<EC1H}8;ff~l#Lz(ug{wMQFg6n=kk zwc@|`-2J%Cw$X)5M?=lP$?LkDzCu?IzIanPAX6*?GCV3U`D26ZK423EtL*%qIevW| zVf0aQoG?;uzvmeEAQNkVd25VAig0Y&jFp@;W~l|CQnX@%9m>5u#WW()sx4XhSZpdM zj+$@hjY=gH0s#@30Dv-tmdjD9J=VKO;JqLudk3>Jl;P+2-n6XSGh%4Ykm#e#T#NCP zi`ESz_4{&2q;1!|Rsle&ECbElHYoaNP*#4W4&zctEt*Y1jKXSIHv@H!(55c3!;VYV z*y{)d_g-XODj^zxyF3raZM%3ams--XfB1vdR{xl@6CsQ&*MAI)5H-9B&pg&oG)Tgi zz~_V`0u+twu>!)7vRhJ+O}foH@0VJ>nGK70b#V>dugV{VVHYu9C>`=4Yqi+v?p8A$ zVQlcVHUa}*yJ;VvS*jVyr0bSoL3l_eDJ-I_pp>%wy&-p7M8q)n?p_u<IZS$VvGD?E z)bx6(t7U3x&3yQq)nU#Jj;8tr<>q8$=&gc=8scFj|9PF;F)E4u1Jv^O{!?5%3a&l^ zs$Hf%ec}wbi(O3aCf9|P(X_+Dc+c9iLxz~)E65tgJ6-!fd3W|N|Dn4zXv$I={W+hp zg;K&9pY1>eyWR0--iRA-m(+{|y<*zlDml1~RZA3K4bqnIXn8~|dqrQ~ZdhvMAqvwq zk9Sm^v{qvjdwv>LQe1u6EZB(jU{jB)RfO*DWHch&!bm@Fie8?c-JipvEy`&=nl(Nk z#9fI*84kQboo>~|1GquX!Lxfp)fUvCE<U6610WMPlUOkn*<OEuc!sXfK*$_MO*;Z; zl!dBGWmYkV=c1&y&+Wfl00?EZPfmO7p*}H~QplaCMfF*re^_H{aXGnuxSWI0!x?E~ z^L}qgUxRXdBI)-$8#LwqH)Y$ORlv>f$uYA4Z`MQ?WT1V1jNz)h4}S&11&oh8*b^Xa zggCq^)vCzkuX5FSXecZ=txsE#H#8`NhmsfA@;2!cGglu$(N2(Zel`tjbARSDAZw#N z<ubH*V6`96qbZwtDm@h0I&!^h|0`d^HG%4Btz7eC45r{dsW|!N^v^qiIS+Pc^~+KZ zlR$^1*_)1+p2H=6`o~C#tKR(m!S<T#F4#YN#0M@k=wFy84y<8ZJMIQWX-{|8;7rSL zg*>jX`_r1d#iR0MAMX*+uw3VyRRMCts=5funCr0r@$S(E1|kXK;2a2M6SnW+P0JoR zGtRG~YI#V$a@B+e?z39bTJMwm`m`Rhw6XT=>Tp5Jmh{T!+gX$k24pef4deh9ujop> z{vOt$R_Xm)Ezbvn_5^ol6M=uUCmtMZ7BK^W@!j#ZJIc~d@8f?vaSCl#RsetbHT25- z$Am2xtYNR1*<V@s>S(we4bSR9IgRSS@$$R+`uNYgUNW5_uhVz7@YT{@3%PGN`t@3# zes7n1YU&7kU24mgRJ4dL5NIrAk<iR@*B%_CH*YYo-!KdXRw{c(L^|W*wcSKZN+LjU zF`_w4I-CZQBObN)RMUIJm9FVSCY&!vu7jsK$!<Rgs1=UKMBcv}4HJ<AB4Ka#BkEJo zD<aPkkV|6Z9D<!hq%PkUpRYQDO`NoLsn5kn%9SGE*vD3yF9$;r%7ZxH!G}I|om}%o zg%V7tM>gsF=AstJkS=000NR4?T+dB6SUFoa+gIyWpr)4I&E&4mtopW-FSduz>af^g z5F4L8qjc=k1oPw0Rsv2tW-^pU`DpuIpiZ-ADQTpIX;k63%Txd=9FglfTxJZ+>Gj<+ zr`a5i1r4?Z32qF#s-QD+PkDX#^YC90%|A$%5900o@=85Qn>;3>K!(5ecvs1qOTM9Q z#bwBy>90$CPp)jPqk0x<nq_=W<U)z3KoV!_kf5YZbmPQwobDySL_Uh>&#|VOMX<SS zV#yntgv)n?BPz>n;=2*NBfk3~aq`B7<pME@&njn=I_nZ5<Xoy|BrAC9_R(2}FvG%~ zvq08ofR72w3%l_0y;<kshCafa;og4`so>(e;<=IQRHYvqS*u>220-V#${^CS9bS|7 zABpwF+2L+K*Y(T%(Mi(8!a$C${dfGOfUy9h0U`6R=O*{?k?r;<ifMlX2V?|A2e9Vk znhaH&ih@U0@MmDqhYR!5*(XLGG%yz%h9`FCJ~Cfcm$+j+IW&pRo44q@<h)6>84lk* z10&YL+8+M5S9&MLCVCp&vTeBl{tmxH*`_mSEqdY9wSzCZMXk))f;%F`K;{)hckWc= z*~MKC84bz>Hsl-vFb4Ki+KwDMHcdRuFXf{GF(vk-e&F?E_4icG5!k}r|LpBp3gTKf z@f`}Wf_vqrR$Y95goP;du6S{<|C$+vU#Nid5kS<Rg<3klRCm$V<xi^_6hBqpSau_q zDbOSKI=AoZk)W1@7xV}2N?H#q`O77}rW$X={A^X;y5w?1b~kYN0v<FrCkWI}DU242 ztaRzJN@>N8LT1NnliT(k2s2_`hZCP58YJw!$L98JymIf^6PdERE!R=~S4~*I?u?AV zx9y}=Z^k^d@9DDg631O>`@Y9k-3_bZ8!+O)&|>Je1|W~foy<&iZrp*-2bI(75%N)Z zUo#y0Qt$ZKt9pk7*r2{f56jf(?<Pot(Eq{;x3CwUA<Ua_a}dh-IU`5HX7s*R3zKHv z?zp+XdMI+CQd?oK$2A-Z&a1TIQ<FBPn|}#{yM0V#`J73;a<VNh*bGE_RO@C#Xdjwo zsdF07GOJW>TX#(EbMS2Q>Hue}Ei5sXDsAL?DEn^FccDlh>iVY4PY9eEv&%Zy@zs0Q zJETNK5O8#hGvHsR&iOR(jn@5G>w2~6pH~6zyEy{|+aCCywp-5K6CH_U2v7R)BX}S6 zr=5eLJz?~Fn<%&iL<KVX?8dWqW5U!6vsNb!?yrL4dAQiJ?~iU|zL}|TfePx>R4O@k zBmse=3<}F%xRekWff`#kGxd<~nAqOt4H@<HvWe+z$>*Mm27S!0Bz3#COs<2goURUu zEKe-26WH2P_4y3nyjiVDhx^V~)b-7npV*MFOi}d~iO>Kn38hH!l{6v&Bsx!z^U%?m zqD6~EPe>-(<Ap5Ucxh2YDY4ao24pY6sr!##TO{^B$B=Uz<E7o7lM_W0yusU;vd6h? zDAzF_oupv1fcd+co3ySjQ0+m?5R#{dUS>jYBHze^D*~ytbus)%hrAzp0I9(9ft)1R zVNz}!PBG%AdiBG_ys?+yT3-9n^ToBvPR<{9ta&j_bP-l_4Z6P~JcU0{zrH3$=llSm zn<-@W{r|)Twnoo~lzc?%iv02c=3RgsuUD$fO|Flt^=|s?_+c~%)Hl2Rgeh6Y`VfJ) zZv@;rFeczr_|88Or8=dL(=F#H^*DCRJQiAgJJuIincfdgD^n`g7CN&!+?mhdF1Ixv zB3AvXG{RArAOIq=WJ=_(zQmFle_(!GbY6e3@@)CqS4Hw+M?jrDs{cGJvJuKAQCO4w zCzL<`X*=@#Qq04n(GPrv-~13(0IkJJxl8Rm&?b2HikUUzxM{9z)x~Qy*HQ(P>656c zT;kjZ7Y`dC-CDSU6l;&nN(S-RA^sKVx&Mmvlz0d#tFH&2J9&Tvun329f;NuKk~F6( zEQPCe+6MZtL%eoht{28hb_i2%u4sKGb!olG28&g#xzJTA(Y8jOS{9YkHQ(!YlpA9( zg}`o<XT*#Q8z6i`BYK*5w3d6yF(JuROai(V*QeWCr-jEQ(sPOEw3bKx;5XMF=lFJ< z)nB7N<vEPacMjEe!wLcW5hjHWg*f-Q5zg|WN$sBX7u|`n#dV8nC+!bjMkPHuST+t@ z!T0a*vw9{XkTXepn@WV$-k^H>Dl5|fkZ;7UiR>eOmRZPO4<Juz^lQ&3DD(l28URO( z@rG3)2Wu1@`QO&ceZrKjSzUcbE)%mW4ZrG|LB8RkTV%(DS+Wt;^TIx0?w{F6xm2Vp z1r;+Vx2c1ml8Za*XQqSqsF7EY+7&nXrukxeZtoacuus(8-ElR8*KiH6Xti>QpOlxG z$hhfrsi{)nISYy}7N&J2Vjj*<viBt^!M%4sG2r)JjA!hff0;?t5~pw*w6Q!~Q&=jY z?W%9|&{vU8Y&_1OE1j1T=4jXV45Itm+}eTMnLoTVdv$h35q{7j0UYUnO;D$QLQyYs z(H>9JkM=JOu8#8zO6X3ug26rF-(zHk%Q&DC!;q))%4R&6I*|HKB=-9!%K^=yJ3V6u zo)?9?k9rC(FzSj1+c-ruHzy$$$vpnLEUH)cUE*{lunA$8Tp#icl?otqS#ottu~BTO zn9SH|3aHxU8V_vYa9N1F@ucfY=kyH+srP1-i;V_R`etUG-Tq(?zEJtd8laWrMU+D2 z>=03@K#~;6FH{5e<J+yY$%p`73+3$jevSF62`Fi7OT`JtzlSa{#<AZ%@a^NS?rtOp z7zlY!lmxBTCqB7j<NUHYV!UwV8s0#pZNL{3#x(5nhzJIbw>Kay`fE(o{*wgrqa~Nh zgdKGQ7&89OpcQy)*fmFx$iw{9dpf*Kff8r0me*NOc`66WR&Ty8y7JgzSP2tiOTvT< zhJau@M=kEgk(khzWwRhC>VT8(D8?8df--6~hPt+zk+2!2P;^*TK$mgPglq6?{wMA0 z3zojd(PJ}h3>?lK(_N>!*anJNbv8HLdEYa^t{;ZYGbH!6#HzlTn>dePI7d8z(<#hC z>vG<EdM&g~RpXsYo`Yvdw@$|bzV-t!6+a={!wE_&F98L>8Lb!^;v5$b$12x`<ROyl z_y1N+E;9cP_h(39)`#rPHxr>_8L3kAysx8OX2Nuj7#cBp!}lw(m0Twi&Hx(g^_tVK z?hVmY(j+mA1vz~V>27RETO&Vc&~qLo*VWh$#Z`IX^5^bEoa$I|T#o5(30*<mdW?Jc zQInTYmK;(l{<b9s%FxqH606h;Yi76HAkyY}xW>ev(Rp^z`VefW{L?MJ^5^*Vkdkm- zpldD*vrE57?+GYef&60gk85BI-B9x^p!#F~ocU-$Y2emF`>imh>b+;YY=(Qj_}nmB zK)n&^Q6DKNyJh%$KlSAQ9qx<Qu}C>@!;!mTw9aT9`tQokIoEdejF?{QFMb_2+q1_) zr(NO$v?VK-yxpt3i)P&W*XyR-LAf8?aziyGi{bKBHN4mEbhmEs&1aDt#k!=tpBJ_R zJ^hK3yHKX=a~(Q&C^~mm2DgHkTkkyj1rk-nCD-vL0}p-DKievdD5^=1AV$*k>SlOA zwaWR~=bQdq>v56&n-e}Z-ah1IIzaWb3rCnMdBr_0tdf|SYl4;+mF3%BF5#{1o|D!G zZ$apuscf>kRFU1mqcl1rXwoVw>7YQIL8-Me#Di0|&FOi#2=4Ck1~+ken7MO|cR@XY zrQ9v!3&;T4xVd9BHz;G3FUms-zi7W>qE(;k(dD%z)Wt4++pe-2>_?qr$Cz~vceq$O zFz^G=?=WFaZzc&?WsMHsZ~@lTdJnkw&Cylo{t>|NJeHw@&oJ9Wm^l-!J~-;W<!+5g zCWHQ`-@(oP;2$mFh5{1+KztL~%<&<-D}Xwb2zMslO>_mFk5=3vj8J4PSinlp$Y5;Z zB{SkVRm;7r%Ig^3`4^r;Kl?nH5pOWwB8=Cs!H)G5Vk5FoOu2uV$fcxY$*}4w_sG3) z@*4U4$riQeqiMBq8=uPP`<5i@Y}n`~Mn>;)H+3^~i!kF!pLwL-*(a}@te97)Z;p9^ z?(M}o*;yWLTORX`P8RqnU!+(yUld7J@cP_P`;2m8c2MNwub4ik%r44uq;$68h)=XP zyRUdvufOfVajOu@MBH&&49hZ4lKw9D=jJXU<nLWaiov#E!TsQ54cmF;4Ky6vv5HP| zi6ZYu)Um$MOoDYGSI$b~0)nc|EXH!O3+W@i&sBTaSHB6jE|WCU{SS<?IgDQ^Pvi{W zn_-Z+SGzlx3P!Yjp`X<0sGVh)obbYWjWaCEH=>)0>Q@CiFzXMr#|-MfnzDW8C46Ns zi8JdcU|sE+TiDqTsink)m8akD)_j@0S-bNUyb|d&oMgdp%k>Podv46eaxz$6Xm9yX zhQsY`n8=uRPM^oyJ^WUIQ&*4q3^ff^-^zhM?Ie&Ru)gu~G9cL{WE`L8mBsC}S|OpM zgDuC)99?K<y~13Nzl|wEUc}$qyNn)=!MoCK@ir;@+Ceb~$(n+wFsUX+!8#YS9UKoM z%UMU>3;SnHzjA_cIfSD8yv7pUkp>aGKZhw2A^WYx;f{Vl7pR9R))ZV9A@H8XCYJq8 z$qP$uoaEEx_EKHR`85CHXp)GM)oWw^@e~I~rj$zey8U<6(X0ufPBUC5+b2E9Yp=~a zblZRoti|p8(aCGMmBykT(y~P!uJBtw{W4Xv2kKpJ2QF+JmLvrlK7G5bEnQF#u&2Bu zVR;y3R?&h+N@)7U54*h6Otp6Tb999$KWBWHDU;Wl@T!8sqaL<7lfP#CFIPwj9$XR% z^L2<1+9ZBazCd5oBl#RY`z)XWy3<d(Xc@!9d$L@?L}W>Y`Sg3-^x4`Km;B4Ejh-GU zB42gU^=aw$CD8SBn-@IPO-Q-^DWg46M8f|0t$5YeIuiM{?$-8p=wyI$vne8liLJXT zt%$+Zl(sEIePnjigKao3a!}!MjR%00)bGI=q~mF8=}+l=*bTZA(kn;mzaG*{J-@>< zUqc`oI-diQ9KwH1%$$SU+mgos&Oj1EjOYz}EhMF0y}fG5%O>3%c>IVJ5Kjo<zPf*E ziJv9@V<MIlxrt@#{@phUBrPL!?^=hxwBNkZ^*JcfDSZYSvrmJZs>E!SA#Bl!O>3ps z+K2B2S+x_WHtA`v7U4^61?ch@9H!W!Z^Df(+wd8LTpnOs>EIGpWn$9)30{A|s>dgg z|5Xt?B7)7;?L9vNqB<wf5F@zv)0h`^6jwI_R7QN4Pt>o%llx9oi*!FaF#%m7)E zE!<p%ORe2?$$79nu>yd+`WjA^=-Ov&`1bZF!Dg2~cB=XIubuX$FU89XZnV+m`)rx# zCLQEAJt$$Stcd>px&(ZcC3<7ow%0%;q{LG$ZE)<wh}Xe`kIZNNd1=nVYOqbNbz0r{ zx26x2=B_$lFH*!pO7JDh4ros_{!=vR=bN||mvbG)AxKK(6vXXLPI)`Hgoo-*FoJ4H zeV|>!`gP-1<M`K6f~b^0VqH<-ZT5~>IF_d&v#w3$;u;}qXM{&?A&~SUd%no(_eOnN z4sDwuDP6r~jDSB&#-g(3B=?7Ld0`|otuB%i{9x9B&*ax$Q4~=9?SuQVy<%q`*d}80 zcWs$7A-v~0&o+*RCzW_q50n>`8#s6J%4Rd1_K0Gr(CWhsO{b<)*a&CE=3<vK@7#;; zsX2$>I=?nI2Km7udam>PSfddgWYUwd79nb{$M)JR7Uv$`zB^ghba4ON1*8jfv@cJ@ z2DF@*%?&YkIFJ~MZhEjb#SdgGWV!*!8~bvK*fevXX|Ep4wQA+1NI78uyp&E+O&}V- z;{s|x&s}<7AB9JNtln{4G11=1gjqK%9D))A70RWhxP)~7Qoiz^uyg#LCxxdH@Fwy$ zW-I3?bnJEWf!W;w@%S-@kZ$C6fdXUg@i#5E>aC221;5qLiD7SHtnz3x=5}<qOzKP| zl;&eUnl3G$P2gADoG;tdQ+B8_zE3Mlxue@rdjL^C<<{P08{*-9nMcJ?)p{|pBXatl z07_xIijgldCAf{9UAwey(TYVpGZ*Muc*M{qHe>>54^JEM>g_%__!Ixe>`7G0&Z6^P zhfwbh+rw(~(Urn^!-A6qPI~jlJoS4(R4M2yUTHs^J?V&`k!A9k;p;+G>>}GY@h$<o zRrqhHVH?XidHEXm-=Va`>YpsY!oz?qJGBEPxAtde)7FY5Et~Q;5NWE=i_X08r67X2 zH1F~Pv+#HqaG9;LMcylPzWp)yIpWPwzI*bdSAT0mOHV}{^hbsW9hdI#m#Vb$j_x*^ zZ3z)X`Y!ioJ%?c|rg`_yslYbf1WH1uXz={=nAhpZGx^bil-&#!K28Qss_a_Uj!DdC zsxJ%~W}nj+<eb0vB;t)S|I2q!_XuEP2rFCrs#$`*YsZ*yneP_->->U-^HFq0O)OrU z#9|oo+l}}Q2K+NZ*&z1eGyt>D*`50=>PIj*E#EcuDUTy}JgpSU01;Ap+ict*v@l9Y zBDRIjMn+E>xYc+Fs=)LB#jKXvk!oYil-H{94PiRrSpV5-ZvD@imDOiw^H*$nq}dS2 zeRz{k-Q?F1Yc>74PFa{*oG8hr@i~}t@&RK)a9)2=bKi7MkVhhl5qVB5_)*XNBcbdO zB06hltNU9#FO*?I;39>=vw*Ew1DR}#aR9P^Y!?SwyDOLTpqijX##K6K_LKrwekR*i z=UywoJ%R{-x-a?Ee-W1y`3vDRmNNv{E2PVlJeSHb7mrUD$@i)uuH9j~9SkQl&KPTL zNp>#Dnd0x+bJ6n#H{S$VaVETT#!Z`}8H%P`(oFb_btfdn2!k{Z9f=j`?JcFcFcme9 zjgkW02hs$_DxP<35~Vjj>VCNiJ+hW-`OXxWYDUNPiY4vs9NJ?75)($BT<k^cqc7?0 z-7mMZKHR=8HYqu=L0(TbtX&V}2<hdXhf~;VAA5}kPeI=WSs|6g?BgI`yt-O@JA(Ly z<|ldI10ud2$AI5?N6qlt{PL-iAOAryJRf^|J(sONO0j99QTH9`7)rO#GrI|<dy=C* ziW8<8H)n3UWJuS9^bkk9M?uy^=`QA<`##qPA$biv*o}`cRKc2oF=IUTdheXZ6m+&+ zFAha{w6tC7ZhXB;&l@Ho+uvuZ)Kqgf4qVx@d7-oLtL(Uk*zzgts?qYg5vvQeHL+c+ zG_Av-l;_-aU3<os(kcjKdr}wfz4{eW9twC>`p4D95{5SF@7VgL^xWEFPXiInWv2se z4G?t(Qk%ZT5<k<Bi+|KNz=6bO?hdeza}VTx&$1z}S$pfxG(ko)?XZEPi6K{!h>9yv zHg$4J6qNBjIeNJf$xm|%tw2%Lc``PN3rXZU-xw697hy>Hjwr)<3>a4JojAkl6W~x_ zg3ZA}y-M2cjqd<0-T;?6T~PietNh!3$SYe&jQH@>0w=_My&Cf9ov%N4CM$(2l^Qv` z>Z$f{Qt7~q)WR-XA8@X^K|+%WC-tNk<jVQch3j^pjL|iUmUF&G%uD^iT>3hl^7(Io zs@+1DL?X!hd+OGnGEV%g^CoA0O$nG0M(H;aMM2OhG{9`f0>T+UIRB8E^1=2iRhaa| zyneFYUBk&TuNwaH?=9UOd<TAMeC>@7d=$>Ct&&LeY_F51Q2+H`f%bcC0Zo~?6d=*K zFY)kHwat(`<wOxtCiJu)Y)+3~NW!P>aS&|-kUXLeX{&pCm#NzTgsJZUsl8aYY43Qa zOX^5#j|c?r<I%wN4{tSMWv(_&-`yg93JXfqmL%-v({nu6H}k2)wGB-@)T%ZcoSssH zi&vJNDY>sO0l$`u-Q1iU)}Nc0tW_K;{;FDLYw=h_|Be3UY9lv;jp=L}biT1k_?vMT zX}VY(O2?&Dn%uh|X`bw(p+rpl)sksAz9-{Z>jKj@m^n3waw^j=%*?X!J7O70^*!)a zpdk7$e5evrq$pyydaDSeeSHfetF}iH7p^W<*$y-m=~iqHwdGr=h=f~CG>%mtL5kwo zS!ZwkUGe|^x8kq7N$-P>7H$q!*~Va+>GXpd_4yAoy#4y$ShGZF4h`}jT&ZIVv!jQR zcs|7!C7bDmqF|*ydvc)|<tHWM>!orL;kT2t>v+V`Yu|984js2Q-h}T+qSDzk5#QKg zTXC_<R|<z!VR2{L*ofH|#9%LxAx=V|;ugF5qL%dJZ_Mx3{kX$dll0e<`mN{V`M>6v zAm9b9dw@ykB*U|_1&>B40X~2g;iP1lok)GGBHVZO9Xz?q-v8vUO)}D);;#B_4}LB= zQc^jk_~QnRup$}4c!pPeJ8ONQf>bEih!cGcFHYVh^R`*p8H3wKbDZo;!_G{5wuovT z$Tt}|YEl}g*3t*x_AtZax3lG8Wf{J9;=cLkc3s(m&-6_?vl;Zs4ol=&s|^glO->p> zNy2u9hc2zeFxBsenU9O+4mDjCp|%$igro(7g>JLpOy|d{ujTo~TOwu($t>Zn-4dR& z(5%zPc|4VVC!4#5EFDhe=4IIkZ0>H5u~1J>;amJ50T7U7TLmdRq8y)@0#@Mo5azXH z^Zfb+xQHyRe9L_TVDiI>KVBw(r>(U$#*a!mLpDdquL}k0;#+Mc_BJco1B*4o-q7oU z+tgn9Zuds*aE(T|{d`1=BtM(hsY!H+a}zzvV{1$L&yyaNl`)<;IlUC&1ru=f)cP^f zf{I3Md_28pRRgTnj{!Q`{Phu@<n_}<mkrZ8Y(<0{-1;}W+l9ftiQ;Q)z_NSxHpR2& zIxI;74Ge20ACvjKuiw{9$AEt1(vHTD?TG@02%k>G@2hQonxTI~Y)SV8$r3pPKkk0m zh3D$>FNA2uw9de$8!VY_u8!|i1>~F7Yffqtg<~}9E3s0_n*LKW!;)+rQKq#z*G9*= z4669%AL|d*e9H54sK(wws3i1b`|a|CY8t{$>dB4Yz+w*A)lWZx1(Gowj{r_gUm8#o z86_TNeiI^H(AUiWC|RSSg*;>*VIHpvjk;+$*KjH1&l+on{rkr73!oCY+xS{TgmN=@ zqx=yZz7;~_8&uqqXOio{KKfILSnD&t@t{CgY*fxQ2T_z5fFd0B$L*H286GSJHuYYk zdgzAl=FVG)Ch*kT#j18&uOcZCYlqnzcN=d{SfwH71&IC5?^ak7tbYY^)%c?k2ZZ^x z`%lx*9<NT(jk|jLYSt6Z(qNr5dXhtjI^WLu_#}k>DP{AS{swfTL`D{N0L*aBckQTt zMZJd(LfJJxUQ_fO&+;}y@={~yri(g=%`X15P5PLEQGp9W*=_kD$ua*~BR6jriN?6= z6U8ynER&9X`UKB{L1|<q*UuY&xc~ze=-eF({Lnru`TL^yIX#zmdoerN-ygHfEyOt~ zQ&e*wf`O9E^RczDLOFk(G63&MA@*uleP_gpzKE{}?GJZM0-pR{TO-YBjX$xvLHs2< z`;T6tbj~O!J`-JAOWp|Eqp&_h2=q5l8mi)(&LCv^Pj`=3@o`U8uONOLWRZ&4vRRkN zCVSnb-;*`d-NLm|So+prRGLah7Xqf=KRRDtK|LgjGrY(GtzkHv<n5Azee9NKK>T>~ zoxd{BBjU!@QQdj?UJAt8*gN<FHqpuVQoX*(N|IWUlV11U35@Ej^y8dHaltLDTA0@X zz+k!e%xGeqHxDmRG}oqq@xmkePSAinwR`I60BRiYSA5TYPpiQ5AEY<r|Ji$NaI1j< zXf5HU&S<hD>el2Bn%HUv{WXY8PYWShXZ5Fj{<EYQ(iTjjJn$jS4?T=@i<7fZ_8O_- z*Y0?b7tb0U+=J|@zp#^qitI4W9WUFvPF;IcJ0q`RJ(=AG00x+gtbVb5@)XtD0}Gqj zUtEC4#rva9o{-Su<cy2=qn;&UUG!Z+`-7|kKH8Y3#we?Vq&di6Z}LwSMyJ_|T-%DS zQ$E;p{dV{4EZ9Wwz2CFZ#Gt&krJ;IZujLLNI(ALDx6=)FHOUWVY9l{6iU;Sdj1k-i zsI`BTc?Tl5IBB;Qqe)y<GzKDzPuAa1KS5V^Rx4KK!UMHC(|5YBD<6<$coP$6%}*2o z`Z^u^PSz&xZ~Z*S%$#u!Hx6*u<bkwj{2{3`{%UH|nl_O0=uPuK7qEPd=L_MtT>YO- z3i<!D$sihRjoK;Ti(Aj$i9RtTh-EsX#(SZ$mDO3+j8i{Cy-G_gDu-y(b_jenLyvOM z>hdL)?oumgVo$y}RknU`zYCK5gB;?i)gzQ^*Y<V&5D9XvkyNnIo!uQOO%}XpBd`O+ zVPE^FN}S!_<DHpK7jYQc6lJt{s^*2!{;<uH9}tO9e+MOA0xDm?{r&vdavN}e8!Ul2 z+sMA&^s%BvDauW#=E2;0QnB~+fCq1#M@Z-&+M4yre-;}CU;%w=?4UbHSk$ZHcF={r z)>8b^<Gc>g%4dQZBf{OL%tlCQh+DR4MShNf5)$>W<9b}D<QPwQDWtOJ&C$|@F$P@^ zF~bfB%e9-}o>S$Mb^W4+lmxC^U9ZzaYRa+l`d%P0S_NpMRMbLou#ix6|K_~}Tejqy zlJG=OAV)GB^TXFiUrAp2vk0KXiP7r-?zW}SY8D*y@c(Qg-aqY!M9aPCI@YxA)i7VE zgjl{lT6ICy4_a;3q<dLj`+f3<E}%{R=mO{jEKLIhcN{3sHt~}cczbB$o)sIFvUEGc zSr>h_K)YrYWa1bipef0PPDQq%F-GgGd%ux`fXPJyZ`E2(u)UZgre}s@B^<YL)ec)U zT=vOcqP@V?YPjk8*<#JrjDzN`w{ILrKZwxCal>V2zs=zyIC8ZI>%LQ+wXPm;Ulf6Z zex^b~X@L4j+%hp4!1ueuXOfNM<p;OlL_R5SLMY#!(jX7Qqnrr)=MtSF*cx4Ek1uAF zsFS3ni`MZl9faCp_|+wRCHP<r+WP~RY;aH?j1$djq^#WBN6i6kuDwMGJ+I&M)_L@g z$yV?X5J51-W77}YRzC&pzH)xY`?grK(eQo<5>j#xoDX3;T+u6ij|$Sa0>uSuhcO1b zNFcAewHn~2G37D!*9yQ6buF%WjQA)I&JS2GW<#`zruSQJ!u7&0?4g>;_2CyCsw2vR zOW;Lwgls|bmi&OIMB99}KjA%XGGI`E);P#trKh7#IwAR305&BXxWV8gE%xjtmtKBR z-EGy?FM|d11klzLa>E1tfbRGATT}jJ5cvt2Pc-u8NxLEi1yVAMSI_7@V;YETlMxms zJ{~I#kMTfX_BlW8KDaq@3i*EeLKQi0wxer=zp&_<OSoi_H~Qy)VqGBbr&5UHz-!S( zBE4Qi0@RJnbD)$;k1wzk!d~+(Ek!@(GU_dXMNfL*^fimknkvElFxdIjyJy)^N(j69 zf}zgAJvXr-K%!$>qPhnwL|Rtk`y+o>aQ}b4^;0Tk<(uu~m1tWgKG}K(*8<I!=?M=2 zGssJ{o7L)Y%Rsz7@M5OhoTk@s*6ak?P}y{Jeb0{&ERer68a4*#Ow&x2;+m(GZt-a8 z$9eHqmC4#`%1=){zy;8LYeaLqF(b6XTltu8Af2HqBwunZ1ODj6xQ#<O1?D=d6fz(8 zn4<dHfLIUCw;nzd3Vh#VqGeLt(3Ca6ntn$!Ahf>?(sp5&jeu&{dX>yuqudw1z}ZzD z`+n!9M#}#lMk#rAA)L;7fmy;#Bti*M!eNU&S3R+^oZmjX!_6>bs_ZB-A`qrsuzgg9 zDe$PsVZ=2TZd@qz5ca7#+N=u+<Dh<9JSPDDJQBu4G#>-6>FtngPCcSCwCU-3yCZ+X zAPNlIRMRjG9XqJp8gb~AahohL0Q*av>|EhYVoi<<aZ;xpOi~>!98N!ozHU*q?9O3{ zP6Z---~NYkpvcoq6BFay{i>Od7niv-R5O}&X~ZZImJ&Mjn%_%?Vm=9OuX3V@J*B_% zk=-%H@BUMh?RHl9PH?mKN}=%^yvn%Soh~-frk39zw4YG$akX_k8kmkl{sd7vWp}fy z>=3@eP`Ywdm3F-(xTAc{Zlpv6#!FpPD@l#_T$g0gTy6Ye<iVz5%V{?TbK|!79H^0Q zWH;<7((&z?@mLPh*5}pi$1h!j-<#jKjYYSkSl=OxR=i$@Mph)PjHj=*so6%h%|;f~ z$_R+<55EQ<*=REqePUHm%fIo7b@O6(E#-WoKk7~5GWhOrhm+0D&gZ&W$ab-v^{PBE zM+o^J(1N+_OtD&Y6t3KO4Q!;JG;JFXh)u)mq=AUVhH(CQMMz3i{^n(Je49|U8$t6r zl33IQJVp1+hw|vPBDI~@xBt!mU+6k}fjCyQd#dVrfcJ*v_Vv@Sj<3)W3Jf-#55>{b z)HoR_tdy1;6KpFn#M}Izv0pVPx0>4ljnb7EfOOY?9G*KdD#^$qgmF0o>r9$U{0KUK zhnsE+zR}G8q)zKuM!FX?miC@jmd6<4mBgFnRo1cSOxjZ@uD7i_4jva9gk8cl*pzhM z3+OFR=6TT2`lbaYql<@@Z;4Qqglx}9$}KdVT>=R0w*z(B0Nn}yTh1`MH#HCq>SLJm zXTgXC5cQ~<_goF49ppEOY1Kfj>K%Ib+wZN_iTZc-CI7g9U(l}1^~HnAJ~WC}Gb0kZ z80c{h0n&}`EtLv6+A_UWZzQcfIPpfy_HL6T8w0Jr-o9oJ$Uf({;b$lCk`D?dEIcnl z*Gp$6hb4{T-R|*aFD*_v8n#nXfbNG=hK2Mmy4&+%C1h*mwnwI%CS-6}iBtXSi?%J~ zZXe-@#J-<+7WHW?j|g?icfsz?m+#Cj*rcBt^!$YcK$5$i52pZ=?3JDK_0f}Rj{WMg z&$`$+_6jfQDc4pIM*C)=6zPesJnqPa674_#wcdX(eHNIUa<l!>fCl*HhxbI`q>L~- z##_8To;z67EqypBOnmX#eN0XSl>D^$Vv!q~!LIhjLd(<+-&kC;b_tJ|3OM#AcZlx0 z1k#$_C8o`LFIfWPqr5#^uv{Ic9V{WRPi%Z{3&+uBFCDVmCFW7y0-QyGu6~WnTjtuQ zpJH~1{9_9gSTbsITH&~4@r3gW3++vt0x-N#|5}tp(ZW|*LzKL5FNTCj1j>mW?B#3M zfA7-JokN;G+_)0OL6L|z3-!K_D{a$XBY6BEeP=7rE#I{?3gopYpP%G1rqm}1d|)af zHo9tMyyqwvy_ir{iwI$>Y4K?~PZ@zSN!)iYxK+s0vf_+N&<OVO7>yB;ZY}XH2EWWs zNi5-}mAlqvrJyuxgtg~Uz1#$d+&Enc|C|K$ida;6bJkHJh@V5(Jp%l0mT)2T!Xh{| z@wNVtS2rl?!hL;f99}Rn-$V1AD$S(pDc&>vIYChOBl{GdI*uQ)=Bq&b*cgLlz|}rB z^j_I|vuj6Z5XT=SVCjyhUFD56r)8O(0?sTj8sPsp$wu^f5p;(~%ebAW*BFoSo%xw0 z&B495<6F>_KAjN?8J^SUE>E0{oZ^b28Yz3>!P7Nt9Km{J^IMtZnD2v!53iKnmb@Lo zDH+)s*;3Nda=KbkkK!$b9L4G0y&%L^W}YK#KDZM`6q_IK%p0e|Y2qVJyK2sLIrVrD zYd+*X1Cg!vb9FT#RZkr`G7HW7ALqE)7tz>Xg(Gqjp}V<(worYCJ$)x1N~2hs#kA4{ zGyjp-3DpEUY@8@q0~<a}^`w5$*k>E_<5LOEoblmKK=N%~NIIzr#qdIek@LF9N{Wpo z(o`v+pP`6br-WKMy6jV_TUh6PxEqVts_(eRY7XDQRJCWnGye(b>qL=-pJFe}4%BNP zaZfsBHJfm^UGOvBz(G%eEv04ox_*4}R56s<Y8mIQ+?q!OXKyUYQKPd84bi!L`1n7J zvAh4ITB;krC~_!!{nXN2#orV9evtA;P4>&3>ZP=jT#`w8cazuJL@7_~Qs6sY>c!pb zTJs{atj=jJmJO~CK2%H=W=>nMzU8$JVh;9>ddLw6j$wE8F&d_xJBzb#P60GZQbZi8 zFWQBH=S`{OPsjsT{QC(%Adub)%o!4CDUo^);>wFkDPzxab?^flN12dc;I64GH2L~y zCTc{b$y~NKCDb{@+`6FQnzv#g%Q~03l!UnIzXhORe%FDUG+c7`dcC~ng|25igtgWz zZjC%D{wsTo9r)+cM81ydThETy8whJfzZ!_b><wFsTlZa2$ikQ@+mxF(D7+CmKc@&@ zaQQSJCfjBXHXUEaHm2-&xSIy~>&T@Vc-$O*JKt%ov#i-P^{J558t&Bk9nR4f_Q8qp zvnRPz#9El8$Z72siT~+<9#{?3KMO&37rpUmcaiec>NoV&clCh--Hr3Ti%vnlcpNI} zR;P$c9v%@c{;^l=7kl?{B%~Ns*xlEE^_DuW1`<u9RBZKe<NNu+)fvLZD)QQZ=elS- z)o&*tB6PCh_pb(;3<nI8kW`g!rkS!>>}ea+g<Q-{;csEPVP1)ttHXh*;Mmgvs4SFX z{Es=$xlM@;+Q>W~(pbfw{SO4D%)YSv!AqMnB!Th@C#YkA66bU<jFQ!bGBG@{j+Be{ z8$K&DBPtj#nVr*I!~;pMn`;Tca3H7*Bys}rl43OU$O40Ik3OT=f`{kzbakYUZi(|3 zCh8$&f=!}kx|J<=Ree(zWjW1oYyhfuWuZ=n!u`vxJ|R>BJDsJ8t5whBP^NnLl(_$8 z+2ogw%7qtF%5Foqy6E)vx5np8C1w=8%7u0kF=ilB+21~VB1<dvcM(j{5SEIr&TWm7 zn1#lD5-T1oQhJhpz>ak?KbY@2+c<Jq8dJH-nxScC@O+RaNGUS!;9(gQIer8|8{6&L z!1HL@6r)<Zf7m-5<sNc&Q+IDR*YnZ*rwF~6wvzp>zo@#%mo~P0Fw=PEwKo$BToO9o z8RdCra@?q!ig3rN(V@L2YJQj1FG^moNb3B&S<pdut3t!5-cZu;%&xI9vmUz#|HhL6 z6&2yZzJW)3LLLiW3S_shRfC=JlCfy*+r<?XsY^rAR_yMvCY@Ih$RU~?kvn&Q7nPnY z9$GJ-;z>c#u1U3n0`nh|r)A;WQ#i*fE+LbaUPhsO`@a0&vPNSMAlmnH*S}Ss@l^%q z=jRgHjUN47qJNZuiL);a241dE!)7XZqqs6<E)S~ypgrU~A9UAWz)8jpY%xR!9Ab~r z3Jtrv*4J$K2-Wdt20Dpd`8@aHy;#DcDu^x&uD4Wl4<YqPdq=7IEjy-t#2Xf7X98Jb z9)2yO!^&8}dVI#Ao?KGj{WD>_uKaBlL0G$F_0G>_?2S(=gKbmaYYUu+6Xdxr4pH&* zLY$=YKEh5Vtp=)zM`f!0c(HrPfjRRN&|AVdwJjX=Rg-t4IKsUqN%eWp@Bxrd%q`%Q zZ{r^!khMQi2Ul-RG0_XLje43HM&awhnR2J|vVFXsqeA;_?*6t0?9YEC9hQPA!cKFi zNH=42qIh{<t4_UFd8lXhY|nkdyLg{vcFr?5aZkFb5xb4X<@;h9<9;%PF=NgR+1j}( zYT8zz7J}2x&Md=1p^RFISYix!QaQNMo*}4&*Aw?55!4leNj8t*t#|dsCS_>j2Y=E< z<hbPual%T`mbqLKS~=v6v86h1nW1#*EA~Q=J-nvr;ORidG}kRp{r&P}jrSfYbtZbV zG#v{#k)|h$uF8f=JXe>U)}+qsF-PG&^iA3EywNNzYl6}hvVf$d>qxP+*BeUW!~ZYO zVr1}-Q@F;)72Uz)>mHgHVH*|C=sf>Eo_zOc`=-%-LQ=es<P4&@+TNyoL1-?Fko+hq z9e)Rt##v~j(oR`)<CV=Brx!;R`=wf+N_inwM_<eLz0za~at^r|%kKfjUh=|2n(G$d zk^!=z%X*K2`rbf`&$U@XL8<EpxM8&hqTV6<?qSO`<R@V(2MvqXLv(gg>7{lby4`O& z2e>fJk&2H`I!}@5Y_1;=C2~*^TR`aX(;*IUC`<c(dGho-AtuL_u+*`4Xw@&ESyenk zSjCKM%`aFA0}SrtF?U9s;!jtJn7TV>mM2xI2~A^g-H1*`2#Ljl_?#Te3R>(~zPpS& zwO{ErW3}mzh;~UMGmm89EXz~hnj?L-wIZ26F1{_@rEIMkuJ~LmZAjDo@YZW<pGSrr z^C`h)_BWUZx;TW|Lzu!|3)JW-JCZv=*OIkcCM`R7AF^~P2#*X6U<@?(7OsP<+;CfI zPAUk_st#M-yZK*cxHzJvtL04Hiq%#EMFw{dZz94I6QgP<ND8ncWLn$Un=rbS-ay|y zp1|r4HIW*m(v@ZMB~OdHgWmH^C&9cZUW@Ift9lxK2dAFiP4-bH?+L5Fe@l#p6r(|e z>3H_>_Px-*kK+361pX_+U>S8LFA)6rn^hjU1<Pk|YD+J|y%x%h3Ok<E$7C7vTGtb( z#HaGuExK9}%jAH7X|Md(0c||gv~&g--*2zGl^Wnj;!1`qGqW}US4*Y&nNNrDvT}jG zo5jsexSJ8454~n661mQwn9e*Pz$$0Zr4LbcIuMueMbGi}1uEE*n6D!bPsM0S?qGGl zL{)WX7z7+C(Mn1ej<eUNZRT^DB@)=Hd4;%`_I}!Ot>|-FzdGB`HehEd8{JlEwd}^; zKUEK_XWA;#&IC`NJ!_r|TmER1CwueR8!w}B#p$|YY^Rl5p@!uSV<4mduZ#J&(ORAd ztS;dRsTV)*?g{5rWIW6_RhkwW#MfIkVhY?mBg23EJ}+Oj|3!SF|3+F+t5m5{CL+{~ z!y{L}o>+_r@!F8Ag%|4LIgFfaUn_&xFmafLAw4E3W!YlIy=mHR$#ZMsfC3g7InK2> z9N*Qqv5u9=8LaEB42Wqm_@<-Ibhlgl65ZQUVbRtN)VqFioNu?y<^gHDalz-_aHj0n zGQ~Ud3-zsMQ}sy!LuEV@)*DOK<6im~?NpFAIQe^T=vcSsr}MQ3IJx*bzKHd4&_ij# zR5ndXMTk79615`m`OUba&(TVbFih@F6>)I57@xk5v)0UusfqKyuG2Fh^?1$hs&;<P z`kFA6!xg|SaLRp^sb>}irXLjNwW}F&ky}EDEe^M(7#3XBKFCFleyxGWagrC_{^4Bh z{9{NwXj9CLyen0yG`lCFNF)7tF&Lr`j}wY@DM#FS%FS;?2z=YMKU^G`=#5&(r4XP@ z7gP+Uc|?Vm4Xi^v$DZA`NS?i8tgG?BIx0#GBW-*6-eyzP<N{cG>dr3K4in!gv1Goy zuTszOEJr!g3Cti?bywt^js?y)>Qtv)Pl6;%*mPa@e_YE3EJJy|)ln|6CG=VrpVx|z zi;5~k%m&073Mu-YZ5O30#OI{2rVEap%8)33!KEN<pt<8u&O4Z<te9b!$|w!4%BjtW zbIAstib*BSe`I1Gy}2&zsKHeXe`(vpd-d&cpWQEC;2`SuQetw}#m~R;ibr1MGiUJ{ zy!JzjmK(^u5kL#&c>KTP<5b|f1z9{q{Lta(bjwOK-}8lRd{vNnG;vaaR(Q+(r^RkH z{=EpOs*ItI@2Iw#vRE3GYLWD?(@f@@C}YpFo}0O`ac5L@8a;3+&n78i>`GWLc;E4` zoV>vKpJmOBPK%k&eb~rHY*P6{7{l1EKDxRX?XdifTzyAw_(($Cgq^rN=`LK-(6MYx zD{pgFVsO6;Cba3V%q6rSc<L%JQ=ah%E!CV#3!1>{^W^5Sj4F8_ME_Eb_srEmU?z-Y zAq`}HI}y;V`}||$p(fElWaH~-5`wEZNoNgV1%f?Aku+?We$ASqlY3wAPfzDTFcaX- z4$7<Ad2MOg)-PwtDU4Q3+x<9nv$x@MfV-fSYsbQu*$7`Mu521gI<wVKlosG+L{zGG z`>A6r1ehIPOnRg}Bq`vUFxkzJ?cKDtjdl|vOLx<kBEx(=+k;m&8(xBj%m%&AGvyla z!w7!s-L?C_9IHtUcyRmLW{z{4RW5`UvvjpnIyafQpnrz9R84I$T=!M&G=pXYRGSbi zKj5>j>@tqJN4PEmx{-@IdVPfEyAsKgxw8neI+#)h4C6j;UjF*9Yn*#M`wpA^TE>9> zb0LAvcjkwlOeE9nQ_DcV-P(FdVoz}M1I~+h)lw2ITbRtszAkAPrSu~++t9T9_sSTJ z|3my9$S*0u^Q22<S7XlS&TRFD`R4T5Z4rW%qJj%1bc$zfio&4#5gWFy!5ogbl2>JQ zAE8$V5N|P%if7D2KZeU1%9+)Zj2N`G!NVU)Zb(YnC<prMM{%T1q%pK?7%pvK+T^m` zb}(bFbPFvut(SW5gDq&A>SxQ~bhc`i7)Abl>mA3Z)E9~&GMqDEO0V{?D^@<U-b^sZ zU`10+sF#s_MAa5b_NMtu99o@>V!rpEat5#-j?TWxYJ_>aW8SeJcp)+F6=4U!8Pvy6 z&t`QQ=%us`#EO;@2@GCgqd=N6-+N#9_Z#dKbFBYr43-=oXwGIJ$Wb>oTmMDmw><hY z&Y3<Kz57&OcK3VpV!tROM_L;(hYz(LyyckG5^)#Y>8D0Q_zo=tprz|h3XCIi#%daj z11^iEGG3VEdip}?^XJ=60CMEfQgc*!b6supns@fGmc5OlIr4&N{E1-?%$>v#GvD3$ z<;{VeIUnHq+NWjpV?Rl54CC^3ju%v8F3S!nLedvV)Xqh|;?A!yt;wtR3}!ewoMt+T z#^phEZfV~qJ@Y$LNNzeG@_^bjxjqzXDDvS)-gzPRF_oL<E=TOy0Mt%V?!2h$?9k<) zJ}ui~2oqr&<I|pk<>Xpyma6_f0T1~9X-3RFK3M1F!q+I~ar$z^p1GU2RezrOHFx>^ zREeE^h2!L;Q72Ev2+_>i^5Y|rfVT(srf8_2II&K6!PWZ5Cur&SL~c-Es7WKA^tT0v zQ0W-vO&gzs^qxmwi$h^J$Xa&jlAS<YROg0%Y=9>$w!bq&TQ!fIZo1T^wp|4zmR9T| zcX_QZY_uCcfW2aYP{nfx)3fn(zAz6!6tZZLbM>tbhYZ4e=Y%z5!`sDt?$J|g5adq! zm8+r6KEsb?Ohx=Z^x!$7cT~e3EutM%a8=zJ=hlRPzE=6b$Wf7au&T!U=Le~Uj);ZW zd`(3w$uiqUOqQFgB5<?WEb5Ov7);fF|C5km)N<8_wr}gc^0o;TREzx*$TN+ukm(su zDJ{s90drs2&C5B0^0~)LTcCrjhklAIla|uf6J{gS1k16*RLmE)nyU{=L&9l>*k0X- zHSAbdO|Ba<kA}Lt(*&$17zW#;8xzv!+8yZ3nI%7W8{-x9cO&?7y_cG9h<bSo4e_c? zQ^Q_C9(@jasb`F9uAwAv)N&(@ytPUU6wElQj7z_><8#-2cfK<C?ywD36~D~Ynxr{N zt+=&9y_5jJzUHaN)<H9V8~Qq$w?h+YcRTQF;84#sgDs-icI4dLD_#v?p5~U3d<|lm z>+mHao<<-Zu)Hw)?iYYrt(w3C9-yrxYb!mInPUz9{tM$;utCe4NW|3Je&TT>9D? zNiyzDXD8J~&UM!|J+>28Il3*~0RI?A=XLk?9D7ugPgV0tw9odVMTX@qn()0n$i+-n zhC^09S~vr%G*1SBYDmx~!TIAIw*<o%laj%MJO@~XRdrnrLzw<bx`i2zko*OB(P{HP zSIaR0FbDggi|o*9Tix_ad$k*$uRykH`kr1<@c0bm;a@I*TfwW9SYjTj@D$vWkWe76 zevVppuVp%~K=&!&JRq+$Nc~lWAIbX(CfN50{oAptDn88T%zHhJPq-efZklf^LjA|Z z0RVzTx5uJg?lvrq)nGK+9l6bPG>Ci!!KN@KwN8%-kpoVR%(IVnqxyxEz3rs`Ku#1S zhJQ^fLPnjV-k{;w?JAECUk>YhrKsXh^K@}UTs>GYs6Vn{Sw<Lju9e^TI*n*2BPVws z_CDZ=SFJV28IYVRDx{0px5G?R7|tk0jp$S;9wi7ff|mIRX>I#x^!mP@j^Bjlq09TW zbKK(FZCASt`V@Jo8s_$BB;v3&mM%xz^aw+k_E60stD<692)y1gA3H9jo_<R@jdk_E z>Ii3H>v?_6&?WQ0gP5*}6pNn!W9qxZ*=*nUwS84f(S_FT(iWjDp(v_-QG4%Qd+$xs zQnVB;wO4B=A+chUqNqJ1_9kXx2NC(DpSJJ!_x$x7hXY6M+}AqK^SUmH4&7MD-Y5?m zjK2an3dHLL_W^jLb#_1am}jPdw*Qe$&RdSJg4TL6g;ifc^vL^oINNjxR1A-SJ>~%E zW*Ztm2Ppfu-i)p936mPm4d2ont5;4a?jmxz8s@JZFa>3ihH}<2Qmj1x2A%)5YP)fK zVG$iZ`Jq|^jLOwiaNiK-GEGRT;$LeSCzpRc<1C<=jwAvUwU!ss$`jUkpJj8o9r6}b zXKj?6)-xB2rP;HW0!)L0oIugC4WYZcrIV%G`ySLehQblq)72zwt+ZG^#H{|CDNN`- zvDBYns566-87W#`wll4m$g?m_f|XM@#AmR03#>I18OwUKCwHtd+2dJ(m4Z6nGY`G9 z#0nvy1<QA@)7P_z6V;%;E`}}|8Lzv4h(&{!KpCX0#)oNZ>alG9T5rAvN`nhN24Rbo z$tOQTHauK2I0JK6oSlp0t}IWO+Gd54Je?h1T)doe(hCAgH5&2mPBaHaxBIR%H)R!` z9{NlxQATZ(#ASaU(f4{e8uQ<;v#fkcU999$)dakmAh476+*kMHVcR>LUClX>wdiFt zHedIQ)x)upTi|6Zr%w++e5A+iR0I1UAcM^#y~o2OKU*EYRT_b=w#yu#%wO3D`MgMK zJgpVC<-soAPfttJD?EXil<6#lE?t_SqyDz{Rm1`{n?lIQ4wn8HpD3c;=$hd`UOZ>G z6U(I;j2BN}#@uUXJbIn^SSw24J7w3@YUjmh@R2F4d(MMjr*ft?*))7)x1D9Vd6mjI zhz?-?D>MsWl_G`W9Y}i*!rl)bxM<t&zoU4Zidn}2u^__?(*3dPzdvj;=0AkP*b-fb zc||BrNR<VX5?d`JWZC4~?_tH&Es`XfJ}(?TUhaa0_Bc5C0@qXbE%iL6&c~$dt+NrW zu|}RA`<<{hozr248m1n;Y8#BlO!S)3X0Y+YE}vp`OVVu}#!L-DU<8mv!7)^ZNY{4T z_slr7NuL4NUnj0Hx@xS4EjTnhcROwvIazekspMxO9oZE4f((mMreBGbL)KZ>-IOj; z^+|7dmVn=^JFlf+bOOn8v~yq;4L<t;X+M3quIU#3o|YP_E&T5n`hTbutHWfu9I|hc zx2ocCu9CxJ@`d{y{GbG;13hHdiHvkqZ8^hwyyG1?(SoiO^P;YFGm%KTHsK|S;qdY- zXS>=$z3ruuKJDQ<RbNe)N$x(1#nQnF#V-r2r>4DUqLxabZH<0)oq@Uvp)S#~n7}p@ z2R#oa%IF}Gz1mkIrdmfaXz1yIDd2dD5qh*`qmvZdm9TQ*W13muOx;>z@Y@&0;U8wI z)hKu??KUUW$>>|D+B9X=O3Pj>gVI#k&7UhO$T2Eev$WzHzqov396^m@7@jNF!``E$ z%<BA_FyDLCVEdDgMgD}*Wy${%Euf~^xc26UO^>RNU5cu3t|g9VX<@g^OYGQh^>DhY zPQ!@gb=tG>0<1ETft$=>f-QE%^><n{B4gI2*F1#qg#8|ZH+@d0yS$m+BTK!@+$wE- zz3UlW{8f?J4@y0<YMLK889%0jr$srKH~GyOpJ!C?h<8S2NqvRfv0dglF%p0s_1h%) zz(479O`qJeb)T(Av6#SJNCBx)IrT>wPn-%2YbSOCTc{DSh(Yt*viM@NqVXiW_AY81 zRvIA!d16X|UPVRY^T|7xy{j*|XqZHu&e1Hf>O^jHK0vTzvcX#=+RI6gjAO!rvL>t| z=+(Uhi;bSGiH=Z`(_8tO)ACn7&9KZ!b36L}-4y%(pN_i0r26JA1$!~zO;CzFSE%4N zNUfnPJJ7}d#L3j_$?Exy*f}eY#Cnkr?KXWZ)W9VUj(>rG^p`grfJ=8WnbU_14*e^6 z>0bZr4XpRuXv$e0tVuaeF_cbEMVq@(#wQq@6uNuJMa}m!o20m%7pPVpdko6k{f22; z5s<KD-NF6IMe!vYgOgrAU{cjGxDMyRe<erXDxPa#Mz^69O@Ycy@IBu!z6<bnVs2qn zIf}7oar4G*_VirCH#vpzQthRgYWW2exsWGw5;9)f>S28v$tGH`%EcMC|M7)!FUnSw zf8Yl`=y~?F!*mZz9l)<)*%j~28r{UiY1ANrCmSIqD3zfz)mtD3Z5>0P%q_|wtx0@V zrX+NF>>=%2<v!v4jB~?(k`cPI(j33V!c@lRkc_UM>pT;*1!)>wcDG;AbN0#el(*-6 z&CBwBk`$(%QUA$#rbPb>^&Q5u#Wway<JEMfvUl1^??Q7-PM;|}8_olVm%e^TnN?dn zA*<s_t)WTBXZ!N_rjo%MpoE&pP*B!lHn5E&jZ|AVTj&@31hUsf@n<W^wJb)S<t(Ct zI-7TlmJ&zqv6yxkS4|ZzmCEkB_tKx8Z>4mCsx}^-cC6akv|_rLqS>R(6T_RAlq+8= z=k-lNmuIG6rT42~wXc<9o^!iAX&8!Mt3P25tDYoONjeIjiq6R2T2>shAHY8=+!=2j zo`0f3nw<?XmF9R80dq1|qyUN4##5R3DEZNADI<L`FyG_<COn0x|3@Yy@ac3qJge47 zkQY_DhAgbYb91KNqb&CIjI_A?<l@SCguBI;A^fmIuN>oMwh(=a5_3{dAAmkEV8LMS zhDg1rQ+fiRFsesAjRFI4eNPKIUYT`gk@JpPyYg{i2aDVTz6>eqXrA}32Whg9Pnf)y zS0b*mttU~+s);<{wNRHItx03^bU4i2>Pluwy^^-VeE7<-|AMJc-;f{Z3B)kY**JHn zZHkpR>f?<9YZtT3s1twQG^lH5yXHcx1@>1@kZ}2SISF#wRJ_&@s1Xm|R*H1bh$bHs zwZ|H2w^&lle<T?f#~Ai;Qg+iPJC(bJ7j76rM$}?8-XE1=#<10q4K1fBNBDo=%n}Rm zIK2gkQQngV0dAGxK(4<rH>7DM@;50<rT4AT-$43ALRX1PNDICI%{93DIH$k{Jv<Cw zTV3Q9?Mk}tO#;|4O5BxtX0^!YGR+#MWzsY@MG>B-EI%1~wty8x0!hf=HFin9byG5z zl}|6^_=kx7x+X-E^=5x>-&kEfeD;QD)KQ%ixlccfyGANaN%Dj{tq`&|P)sRwD3v>3 z6{ObZ3ddV_ExQJ{7b5Z*P1v+$Tf91Hp!oWHz{sJeZ{ewoLy#=tV7J{mev{fHbo%I_ zNb_a1#K^CZ3twE1qCgO!7_;m%6bQ0t9MSlaVokrE67&ChjN$73OUa#PjV9w2uXyhR zREwWJPpSOGabmAKssap_5))ZvXaAv3H;)uxm0$_n=OUcmtT-EUu6FEl`31ZDIU_nO zOGWtpd7qrY=GK9^AX2vValD9Vt!@}L#h&vHXJv?IdX_Fe)=UGtA79qHkAG$4pgnxj zEl@)!{>+--#d*ZgeTpO?lqRb%ku2-sOf56zLg>kQ(tI<#Q^X1E_%GE&u7^UUgYr@= zUVWFqi9VdEH9|_lw4r)_InB|qp`krS2F(b*x!i43jb2aelp+8gS}HhIysTBF@t#kZ zv`M0CJl|0M&zlGkxg_94Ucv%JEb@l$NPOMCwlM_pN0nc)^?xVw+~3`#r@#kVVPnjj z;31<F)siS+ueDAc=*i&-=<KwKm5?UgcxQ19&JDMZ^E|y(gE{ovi|`g;7-~Dx-(9w- zIal<^kFD~J3I-d_ZYlc>%Wt(-k2yB^(9>ET-3)JeG0+?7SZL9H_||n7UZ;g`zc0K$ zXtahD_blULBNXW@oziQTvlmHDh$iCh@{Ke0p1;S+JnOS_uGNM|h{c3Wbd<&Y>!d%n z4<C9u&C%U0ODuczWi!@#y5@X4ri)jyLZ%1h8JlWWYqDS5wZwa(^VCIIYi9g@Pm|!q z<fQcBllcr*;s(d?d@6d_{={`tXW>J}@H{QjELc9{Tmf?s$49EISNk*~zgTAYbL{Lx z?*octmrfu5T4_jH{Kelp^mR+?4}|dDnwOHH5)V}qa7Ophd<NCSeLvdljSy)rmo0^r zBu);@mcvBj%^^`Ci3+_>ZuFH2)tW0RzJwm7!@K<vAJ2Vt*{Uz+|7vi<{NQZ$^7*Fz z)AjiWB{S)wCT5dUq8EK?o8(PxgjX2TM%oucAWZzj>raimqLp3xKh<z4<G`8kOqh(# zurwAq4Rf{m)4Q%KgQBK-*zU#dsd&WO<q;-mkV8R*hw^edSoX`i^q#c3uCO8iKtBCQ zC1<0FQ&cSJ-H#j<-1)$2?)pvN1QGw6J6<#ao@z1P%tbtP5&ZQP9Vv0qv*}I>FP9iy zbT_jk3uVEs2v!P&{X{p(Ge-3B<Rc2_0VK6h)~@mjOZlg@fj6hGm^43TgD(o7CJ?p3 zUVN?9v|481gxjyyGVV}4QzXp+>TS<ZMs1t94v@TM(oUOX#@I<^e+Jji%Kf*+{QDAQ zx<PKdPR?PE900%-rh0u<@q|(P5AV4Ze7mKA!b77tl}wYY?#6rawA9@FXQO^LU#pR; z=-#)E4PdYI2!EEfs!+#rF(wMb!1H;LKZAuQdVMyRdvAmA(HlfRzx*AvTJM>c)zuYS zJ8#%|0;-s+kAGgy^^s#lSu7<jKKBlG__*!N*wIM(M(YmLJwdug#c<@4qG`@*cCj_} z)Mu0AB~_tXfja(P>gca6sR3^fxb@a|Fdbs#fc<mR7B6Qv0-lCp08?#nY1PS>Pkr%z zfqe-|Wq~6UVvJVDOdK-J7;C?Su0lI{`wO+uTdc7mpr=2VS9qG23c-G9!~E-mWRj3U zS|HJ*CO~!p<&AP6z=zULmLvV4zq}gCFaE`=GDwW@6pozDzW7Bq8sf3e>kT@htN}Rk zmAFSmj7T+)L1s!or&;jY_V%H#`73<iXF@1x#NXyvI)?v>_OAYF+h}Ok)I)&tCGi+u zIxs`^Q=#>H{6kyW)>F=*<}Us7A~P&{AWw8tHM1{4rLMq6kfDx&Pok#lMT`9?@FGjv z(vF=0_sAeJLP#eyPld~8%o0>BR4g-iMMON-A0Zgonm4D;;&Wa#G{4v5tvQ&Ubx>9W z#Gm2fWztReQ!%br8A9SR)gW%!|3;1di}Fharv-e84hF?vaPqrVZ?X!#=k~W>j!gT! zf_-}D9Qx5^{EE2q;j@+V&qlivt=ks9R~=1(8$KjgF%BNx4Q!j}5~;o*OKdgM0%h&d zl-cOuPJz6n&<4Z3SkC^@oHQMikuQ8#YJUX6h$Ga<Olg^0a7|KEkKsvOEUdyK{WT?P zQySqPoClvCa-T%O9xD)Lwi-64qOM!KD^<hg|0)6oSZ?O8;;)`Hc|R@<4n;ivou8F( zZ#yRaetlyA6=c)er=*i>+mAY6AiPP^-gj?B=|p8410`L$kTL<4#8eDrQ0oDt_o(z0 z%R770DN<&B<j69)`)C_u{${p1D9VV%YnuLC()3f!>x13CvOcnwRsj2)=PtrCR`m9g zWn#G;&OLedm|6B-iGrl}J>tha3pHDZO{E<=Dh?Um90!3kX_Hp+n+1#xYbz-=7s<Mv z4pQ|B<2A}qxdA=IZuydU<N>0b-p46u3gEz(VXBr?Y-X=gR(y@pnm1pqp#Su#fXf-D zxtBH1eO+Ado^i#ReVEm~@M6)FZ+;rS#OrFBy)%tmlk$C`)t8~}TGo=rJ}sm*<EMgd zSbz9Khu7ISyA?&gVsav#r9%u8eem;=L!hYLhA5#E$h$XDr#R&+2;JX!Ogo2J%uwPj zHhXa^JQ(0R!K>p_tc^wbK5SF@NKh>S7harZ8=8?QY`gCo&%4*4uZ|(5kbcShe?4?p z!PowXXu4lmE4)PZ&G0xI+)hvoePcCxRpdnYBH5w;>Zk;_qYSLb8!|)dcNy`q1*It- zPHp*9NjxAc4FkeRw;ef00Rs8$rJXbcr@f|`D<i!H==zQU=JESh*KXGHzmK;0pj;3n zq33B|C`x}ME~uRG4`@iFK9Ov1K^MS=9kHs(5@5*wZc#8^@oqrOS`#yT)Fz_>rc@Sx zP;Pn;HK)&Q>!F4QKEdfI#&Gflq``=es7ZC!8MU?bXqLU1eRx59Sm|s<gSQW9x5Jcf z!lK~5ZY6`QK~K*NAM-|N(VcSZTg1iVr>?k;h6nf2U4xr^I;YWf!0kRxja|zLod)X{ zaAkS(?uOnbt+o1NhOp}MllnT>$UQF|K-;Px7-m`(ReQ`Dv=&giZ;V4{n1TSx;9YNs zyi*4z7<-n&I_^gKhNIq48n3$*@snRD)4UXKF%M!AUDLIQNv{8Gs(tm}*Z_mVV83#& zc14ITPTE`+;2iG*VV3$5^vUP>eh9>U3(dy!>}^<|gy0uGz6;;mWF|x0L%#u>hEk0} zxuDw2&3mDl0^+)fS+Qilf$?~2@u-j@+CIGRzXwwEArJv6R9HKY%u4Px_(`5oqpAQ- ze<L?jJK)F!3HDNM5ThlJR0`-U-7(6|^M+U%d=jCLBYJzuTo%31SL<i?F1FyNr)Mjs zhi)xZ`d^J3Cix+uJ4A!Xkj5sJFOo?DqjbH*qm-wI?AY%)pX!RU)R)Supr;9e!zZH* z2|Nz<s}{W~q*mDVIYSDMU|CnOk|mYDb6)(@aVJ}%-GFqka&#;%`v&F}e&dTF*#G^D zAKw1h+msWuUiScIj{dKPJL?r{VvZqB(AZsL_}@)^Lmg$d{(H0p)kk&c7qp*UPAAtS zzshjEKyB!`xD6Ory>gcNXpi=^C&+R}_>*|lMQRQ$0LvR7qRI8xZ)+TMU)y%z$T*H= zAM1Q{WH1oaoR?=g_8uq;RAAiSX%|Q}gPUr+7ycQ}AZ8o)p+|MTK>!lHgY1xEsGF+| zF7R4~A)ubigXtUXZQ*%PkV}<U*CF>L8^-EYw1>rv9r@DnvN)e%)kE;N>{#HU;k|Z) zMo^e#3bm(F^Jp&@W`?*e^(iSZF$o!qr(v1S_K4%XS5vH{GhL~DE!J6=Am5G-z`d(= zHC9IqE%jW+m=Kq$hCxRF9al4z$MDac2TO*OB@QFQLqy;{I>}lqBh2VyfI*KMUU#7* zBeeJD$=(U_$*-RPP9?9bH+W|Ti=-horRt0}vryk~MqbK|Vx{d(r{8$|5MGQKy0&Bm zol3!i8@r?oi?ZTXl=#1Y6E*W+;OzgN8{bN;Yj|T_;@Y4CLWy0v=c5(rPWRYy484Mc ztuklyN7agWLhnV}H$2ZZ82%USn3zh3<e~Q<wKZ$V&l|rSDTFz|?MwKc@dMuZ$BH8^ zB96Z4;!C$qNcWxl-#)!)VN3rJz@}#La&orW?%#N$@<~}?{EO-+XUgS~@{Y-f_ePKz zd6N|H>12-Hb~d@qmsIA+Lem6r;5NAq0*w0(?CK8Z);{=B44QH-L3yaWiQk?-KL&a~ zJ(0zn)gp@lxhZSJn}Gv!Tk7VCkizS$L5ZT;$D<-Z&^jkj+85@~)wYzrKz{`@wU~9{ z4f0&zoq3kQs)p;2BQNDZr>ZPu62AuY83K;7va~7J`23uiG2hezCBJF9Mf*5cBsm49 zBYaAb=Ef9hQp5xTLwiHS2hC^uk70KPg;8!4NAT;RV>x5~zZWX=_nnk{s4)l@z{b*{ zXbKw@?zc0QLzjJmV)Xm&ebY!W{$$s4yJWfSaXL%2@H&k_yk+@$h=7uQ{uzVa<^C*l z5goeNsv@J(d)Hox!)s4-GpgW=IZl9cp_ZX;#dL|If+^NLCPSlmZsjq?H42PJL@Ks? z4R!f0^GaV;<dsm`B=_Fxd7$dqpL3HW?sDo|n!BtWhR6Pz?WgWMo~t@>5~38v3R!b1 zxWFpo7c`hBCmY?j@ud#SY>>kqZr&g{OVbtF!1ND<M#esA^JPc#I=23ie?T-KIM^>b z!}w|>=Y~a+ixyq2q-`{)fwXql)|7iPt^x0iYKorAn(<E?MM;;H_=yxujwfsacg<GH zf?*WQ%;+v*z!^&Jia;J>@VdcR+JyClG4J*B@aKflu~SCT1<KAwHdgW`!Erw8S$1a( z6Wzjka&Z@MJC<|g#|&6KZ^_arWPO%G(IAWD?NGnG%EpN>g}tJ{AHROBt0(jg$lm^C zbS!>8`z+P+zw1W}WkOolUrPxMQM8DVKe!`v1?|1xUli3&rxGhv8fj8%ZR|lZw`b-3 zD1l35DN#8G;qer3kVx~o!@>Ue)$Q85=BwvRhxXk4S9_W+rL%d}sfaI)b00$8Kp(86 z5_^<7+&j*!9(H=OsGds;{bZB+Y42=Cnan>JgX5jk&jR!(;=2>y8P0xnar;;<{C>UF zcZfBAP8F<b@21@(KG@sZOaCHIK!y80R#RLa+n_t(Uw=FB8~(1<Byqr91$B}bP~1y# zM#l|~+Ku1_r=s~ypy0f-yPc6tiG+b|rEA>FvVeCc@>8=<S#TFw@R_-7+t-#i>zc%j zEBglLJTii4Fr9G{z4ypjQ=T1Y57Ux~nH%;hC-kqU>0#VX>Hnmco0rky1a0G@F~h-L z4Qo|`di&`3H7QSht^2(lwUX2P?mhqGAOmIsYQ)JGu>plR)8aK8dTm1j0zvudY@$G( z)5WD_feEQu)hY>7u<BWi1~IQ{*nbs3`}hTR)Ii4<k$<_|-*wzGstrs$V{(X*{wywv z#jv0QM;uOE@d<1&3gi2W3tDVGO9e;3?I{`s^vMRN!(&&}GRUZ*O7QQ1MSi;_>#jsn z?3X{8&Cf4g*3qQS;z1%tA)5cq(igl(niW@Z$-P3B=O16xfZmKUTGc*4t<wma^cy}+ zi`W%k3ZP->;aRzC^z+E`r}#x%PoKHh#@ogbQp?#GVuW}{jloAQ0cmnY6r;x-jKuuV z$rDQTNat$514BS0XiRL<Jsn((zI7(KB<V)eXC!-W%c@H4`>04c8yO?~(qxH}zDLG> z^qzsGvoGojh1{!A^Rqkej~HMPOI)g$t2`e<YYKEc6$&NOW24czDp5KCZVjwPi3(aA ztFM(tz7g-hzd>TO0kAShYGiecVpkeevP1(Ni0Kfh91h%VE&)qUv8QV-Wg=OLoxu*z zykUy4mBT<ugFdfS^5J6+7u)01fQ73GRe}YV%Y)4f>s}>ncA*1K-wikTPk&NWVs$t@ zo^k+zVzv1QD4W_x#l4O3yT<`FiVFJ$D?Y}DdEMXu<ROv4iqO;63Z1TtdGT+ptBE@C ze*1%e1YBq8{beYCYJ6wR>+Y9Hr=+O0hTzXH{`fta1x))NBjd!TI}nOKH{h8e2HO*3 z45XXOpz1+9|GK2mZT^(TQF`fT?43&10W|q%=Dpwp#wW`~`Nj?+3>-%N{!i^*6ir?N zNFd5X)C*)CD?@<)^jPNH9>0X#iRlST>mhy*^8`RGH7dm*vZ=SEJXAMb*}r7C_H=;w zi&?c+uNhf)v9|h;-Xz#)fByzq4RO5ZIwdeJ;Vx-X!LqcO<mX~@RF<~GG8w)xkR8~W z9q<%CH+h|3+_nPTy?%EyYX)4TtBc3!K%GsBI!}kzK-l;Bgp4?3UHymo)a2u17&O?e zoN|uW7sUBojkC9BK&Xu%rDALhgtX!AZ3-qVr;rn^p$*_rC;_2oZy!%5E<X<T)~7_2 z2R|-R&}^9MO#vlqf&<{Vq1}7cOd5t8C>K3sQ&-r(UkTfWOf0g_#d2tKjpsrT4Q<ra zRK~tOAj3^;4D4W*C-QOM-gM*FUM1JRyS(}R{wA7KYMYrx+Na$N_V$uoBm2z-Z=aSw z-KY+E?j%qpRs@64A3eR9%S|QjB_QKBbnO00dUiEDeJH#eASTN$pz<;W6t%GOOr&<* z{n1E?r&!n3tX7?$zGr*}92cInayY1X6ch{}4VgiM7e5wF)Zqe4pP6b=RtqUm2&Dwg zMhLF|Q-wd>jYd7&`Zjp=!I5X+zHgFy<6>UULA7X*i!ocU^!WQgOyt>a*>(2xNm{&o zJOJJ`RVSBFrwj8rOwUJoKhMBWp#H{t`zrzAut!tqJv}gFd8RhPu=`3?VgB*Lgf=)R zEucUELW;MfD7lQM?%29i*Cb*DH1|*Ge_2T$dcn9}%tnhtg>=Ms{WWtfQBC6=Uw!+1 z5aF?k;3)Oizghs&YF8@Ah8#@*%I4)B-$~OUj|wE{GW)U#A`EgVk!QD&?h)1O`J;nX zBO-;*YhOR^etQM=@Q*lG|LK2X%X7#jOQ=*0g^>^tOIl)Gc%Ci1QtRNM5Z7Nb_x8tK zD~GU)E%Q{C7Tg1BFOF~Z$=G5RcT21h$HC?%V2+yI-rEUI13v~W0Lq$%P0i_D%jrf+ zD3*0|9|My-CpgI0fPQT=!iNOB#Hl2HvF`5Ywfi4Q>K>1(+?+dxI?^ZeD#iQQ2ZukG zdtN&&{(=s&i+5_Ltx<$LEo6=ON>lu%C*9aWV#Y~w49Pd`MPhd!JR_=zWjJU|_vPdp z<&$Z*W|?LT#r!iAi_4jmy1`S^*z1o$6NN_g2S^qJ4Q#QnTTR;)%%S4Cifhhh(6w<1 z9WgK620YgQWpkp2E3}1;4?em_V1ypOrH5`9D?DD-w?9f=<C@7kZuf=wmgY^b-$(nC za>a?NP|w4P;f$877rvVQ3z|AUaCK`+{P!*TDzq2qwAN>;i|@WpiR9A|rLv)TkFFKf z;IS$YvS>L8r%f6Fq@LuUbcyFzKzfG8dYf}SP|4Dld96YOAGl<F3Z+HST-7T!UBh-p zLC2@)>y6!Iwt2WA*!$g|He9Oz+TUKqK`BzUV4R5k&}#_)y>Euth&M}1&O@JGGvD3d zile@3a@7Tyy_}thUtCLPL1|yM+K|qqeoPb_y<3r*r(3=dH%9-uB+SGDK>M$v$c4aK z8F`G==|fx6Lxt(xQ13Pwdu%;>AtnI*ke;Eg@SH1H(ZbIlbi3sd{?kDv<!syf;Bu>D zxfPmz7BuU$%}5h10vipGM7mSfaWJ{j9wG8d%_WWv5#Z<(Dwf6)=e<hiZ8%O`GU>7E zwYHf4daax#;h@vf#Z6_(T(C8;EhLFRNKwLl2r2P3T(@F8E=PLNgLdbgxE^q6`?$D) zkAUtL7xY~&YpUY(v`XmWwoOEA5)akR5~oRts$KqF<udefX8&+Q3_edZyjU_yay;3L zT02Q<S!?vr+|)|4YT$IOsD8#{a@>+-KS18HsBuUMsFzn<04CrJV4aeV#H2j524VK5 zw_199L!iP`m0%$Och7sKhFirKarg{wm}msd-+^9C(2arA2fT3Eucq_}`~)8M`HMzU zqE71R!5Lcjo8y$*;X^)8>z;>~ab&SSK?T3c2rU4T%nl%=lWj55y?@|_x=T19RI*wD zE#dN6(dEFbZ@S79M<&%crJB7zOI3Yp*vHOI^(<DIjGhxCO8gQ9Q=VD5cD3K)Un;6N zUasl8n+CVgygMy5iRaJJnJ$L3!AJ|CmMW&##`gU6`c<mQPXu4tDcmjNGQE`g#ZXGw zX*sLC9#m{<?yIk-FZd}fPrSz{zN<O;bdHt{yUfaBRIR@IB11-KT=*<2fnRsTc?7JX zJ@7Rg<R+|7{K{)jFp{<_{u*DB;_Du$xw=I8yxSM|1$ufIIe*pUM=fdAN~|9DT)7Od zCim<>dmWP|7&lU@<jY9$BO}%sNkjVn*P%Y4cU=;1R(+k<7&x{}X30lx8Kw+zKh2zx z2bqhF3JKtBHqD%+zc@C@sfG|b#7+9G4Gr2|WP8d<?)CTM8a~PP1MGMwXH-=!l1()` zI!2TWPx2<T#e8JYT%3CA1+Gv}ckzk7@BDkywO=8K#_VO51NDz=?_dc!HQr&oRm&~Z z4W2_~QGl_d)l-R1ZF!$>d530Ad8MhsBV;u%mwFyiQyu%&LIy~^>*R%^DfA+WWPn>| z`#3V?t62+{467c;>-+*B@KXiTV-myu+bT$Kz>7`2nHm9$*`4rvP@F;W4kgxZ7~j(4 zGaDd&cKo6<Rp*lFi(ep7{Zgy4$*vc)qn|a%yBNg3?hdUw3tIh+6HtA+MfFG8O<k=0 z5VpN_zD8c8lLtyW-mYl+bdabqoSS2x$|TIR->m5Q7W5tsHnp&MLVreHMH@h?ef-vv zB`kj7Jwmpfk9NW)!$<{mP7hoQvcrTOEjO_OoKs=9{OzMK*OW^R^#x%mBahhb(_STV zjeZh;caMFzRWP6Wt-4T014uC*d<qq}6`-UaPEN+qPONLZZfqWMA0cD-TKXT{ru<Ts zqJC(_nDg~G0lPgT{TeTDa#C95<WYA1z$#ji?KzQ%!MAOAI~|-`I{ogxB2|YuF>O8_ zZ;-?z7krM2Jk*Cu&I?=^ilEN{*ugVK29ITACMy4RS-%R*9Ooe(Xdpq`mE*3NN!}E( z##qYfvJ@I;gxFcmyS^YtPJQieVrZN+B9MJ!ciZ$t4FMsx`O~_mP|$IsA8znDVs~7) zL#&I&<^W*OOE06P04*6LH+8N6E#a@wpcTKCk&)`oE@~QYq^aj`pp~byz*}sROZnSS zC;i)KgbC(oyd`bfpE7XStz{)8ALaQfx^FX|S+u5Ca7H5*QTGSqz5cf{=C_M3?$SA) zl^gTZk>&7c;dZ)l(zT>N^_6HhE~ZoS_4NSOy>0Nss)PkY9|%7$snmiguw-1;64q<& z(P>bSN3e~B-iL?vxE1nA3F!IT+NaIsUMEq8rTT$RPCcoEW9TAtLjLu9+Beu*ncrAx zma%}P2nA<86l(KAc0L2j08x304<`htkC)K>^_v<c(L1q6a~lr|om(Pp1{#rDS8G=| zQ`{MtaTa9GUBQUmzSU;8a{7rqfuBJ6x50wqxG?Yn$?hdiLHd)i{%t&K@={d|pF)E* zeT~@|{Fr70y0dW+2@ZNe>EA5lG=V<~&0HPiI#s@gBHV&(6w+*Qu5gOMAPAJ)E%+i} zgCfXm3{=QVu)Mxt2usqLMrtmZ@a51?XyLt>&sO^A!BfKmrgrj;J9-ZDc981RHJW+o zYm|K3Vme{wUxcPHiSsP5iNYo~ghGQI^tSMllR^2JV`_hr6e_h3e`YP2f8#~4CK1`{ ztk{?pIvAT6uLjwRYO0NT`h52%C$o7$ko>vHO40#SHYTVXyQ|`tvTH+W8ZJ0B?!vP# zTL!)Gq^N&axgEoH@zUbS-@i6MWnqc+F-@o9*YD*r_1zOkgk4l8KjT@a2?DNkvptLd z2Vml1HNFZIGh+T+ISZ7P{+Hc2r=oDI9XDA~3*Lj)5>@*Y3Pv<@vX5<je&P%sqRVF{ zR(!#gPJ!VMaoH!~e4_}NL$M+=m(aY0QSLX6riJM2H|za={-^BIxF;+&HAC<|0otSS zPwO`j1KRc2yZ4-tJ^YQsrZVO3#$Mq2zG3wKTy6t~3kyze46wvuBk5a&lnf|2Dgt=W zItAS{Y+}4`DL0j43nT%XBfbY_+oqnhVDEt}TA0>P<^hq93T76+Xd8+po@$xaJva=~ zKP1$;X>QI%8(*XPo?b!+pnS(4UrctJ1)u^IGFXnr{~Y<&U&ZwI5N%UUJrcxuFm=0W zCN6Bs2cU9M&R&V7AcUrGqU4eFPVJ(lo}2Pj#l4pJ(C=TFTJ-^3#@-@}iy{{u9mIbW z$g~<CL6?<w)4A3<)d%HL2A~^h@S@dT1+FuWKaBXc6m)9=)GiP(on^Kcpy#D=@>p56 z;mfA{ePL4P#BaiQ9dDE~NLtUe0j9;XF$?pK-Veh7dEb;js5j|f6Jdxq^6BaENBShr zD9PGeVXsGcs0eJ-Z^U{2kTC_D$!|u;^L))|uwmw4j(FEVTFnkf^(Tb#>DzkgRUd*l zMyia;N}+p{L@ano04QChgx-%@>O4u13{N{&LqL5`*u$unNB1yhdW4?_wC+sef(<HH zm`lb()0RcNE>Y?C8_q~Qzv8ltXh3;#lYNVR$LsHB_5R9I^zAw+4FFlAw*qHaAvoXZ ziV$g1e^z8m{4y9v+vXmMX+>;HZJqmU|J=hZ=yGB-R{U@j>^xT!FQ4-~dv^lH{O<Ka zRt;V6HTc5~j*W=?1y)FP28?T~V!*_D_sZF*wjC}3=-ekRo{fWCmg&JoQN}`FpW|A* zEH<5WA_=zFu{qdj({xbU{AB6nP-}3^f?9EjNlA2d<4d?szsxA(9>YWuuOFp?6zt;C zdy2V*@%UkUqB_8X^=@NuT%-+M7Lbm;JDYBNjxRMLyr?}l&Z>)z!fa@4ma0HWdn(AR z;FlQ~Rfy=+Tn}pvp+E_|Rn!K?Yf#V?f12d@2^aFi|5Y&ayxyh(MyU=;^wV|Bx2uZ2 z?QFt^k6ME+g<MlVOW`kP!->b6KMlN11N9P2_#U+N95D2+K}n%^{xX>F`&ECREfrlP zrGGEb^>c&k{?`eInJq3M<gM6CN^Q(u@fi=jQUiHuX-!A^zB7B#C5GS%5+z~@M?c}^ z)1Crcof-bl)a}>3y)wQHMX+tZqxM90LBA<@MD#&OJul1XHpRwb^K>kEgI`BgRLu44 zdPw9l;Wa#S;5pF(iUU$5C(~8U!Cps^xWwit7d`D5yBl=PYl(re7bMJLIiJ?+Ifu8% zN86#}_P!gULA$Cz(54U2r|1)GOYiW4_smlSgp9>rKbw5cr?i-IPU*vUK6;^%PQGYL zFAQzz8#L$iudO$p`TLhl-nhzba&g6*IQ^}SxtrA(f5#wm8OT&%lw#J{ElSaeZ)6iS z<v^<eH_kZ-$v)h5R~3C0F2)N=OLrahykqbb>`AQ>8bPmn`7Sb~t)jfwP8`uI5@eia z!H`31@zO@qt2nM*@qg7YW?0HYcVyFq(4fzWfE>Z96sw9iBBY0a2icBN8^@!cB%jpL zS>!OVK6y3FXw<tzz?W1Lx89!=68R=k?uGuNM^53yv1om*gAYsDy)~W_MO9Yt2)DV~ zjCcUFL$bnjiHrlOoL~ac_235RHE%KOKd&??jX-Eh`!4MnV>KR?(=;qvLQ(+$ryxY0 z?GKyJ>NeOGPx>WLFq!3liMFnVzLAIpms$kNMVCL&Oc?LqJ>7J&&aKIv6vC~TJzi0O zu82W1tfkrC{^{ucIk3PA*V>{-sWD6a;9|2|(Y;&7UH4c`f>e*@#<y<L#UzUICDE}N z-XJFkB`vdOw6@oT7+PWZX^)SE+(Ut|StcHqyMPS6oNS@I^(~3pbtk~6zD;RIIBMFj zZ5eZ*X_QgG=y?2_ZS8W9DpRPmOwsnVUs$N-EB4Q-E0k-kab_!vpXc~8ELy1T)tccO z`~~MD8#p#2dRo&=%(crDs46>|nx_@&YptuV7CDlqnD`;52Px3ft9mAc)g}w;2w%}> zn#^j-6cpRX8AEn@@A0BM4AEQ--ylgdx~{b?X=1|rc(UOVKuO0CmHe6tBeR}HM4yUr zI3n_JO87$2u|&tMtOAzYGOhi$^bV4qZl-{)j|_9w^F1x61D{ZD&cMScdV$SQC>SJ) ztXA+cm4M(%J^$L?gXc8nzm1Ro0y<AcheAYD*q3Vim~sooGfv87ylB|6xVq#ivnPCX z|I(RoY}uZr?J0JQycCNx9v#ISY{=j3vA#0srVPee+!&!frlrA(=B@AMWOhc+dcXA1 z-Ew-O7uEYHIkuep{aU+pk6BfXIgrwIsnMW9`sD4y$)cw`vI!+dzf3)C(imHue8&f4 zbXLdTVaqFZV7<^sS;IX6wqPF_oU$H3mOtoqsNuUNbu~8CgpQp0&b!O(C3re6dz??a z*MrC>ktNF!SRVM`(tT{ZTa(}N)SV*^*BYI=mFknmZL_8N!dkkJf$iY5nROuogKrai zTuwlAWB>sk2f_+OlNKmQZa+m&EV`uV^^fV#A~ZTr+D|q0_GJcR$0<?)aKAuM>%AkX z5sicHUaO@NT*0^hXVj#ZWx7c(Chv~VNWJ<KW#8QTi@uo46bAXlJN0=$bmOx1otPh0 zgnZBSkdb7mZ>Iln#pp6Q>SdKXW+H`{e0*#C<nwAd$Y><wb5uy=*rBd)_WT8eG#krs zE1M9;y~<B#J6^=gdG3xZtb-<h_nU^SUaK-M%FA#nEB%v+Ltlq!k+TVOaDgrF9x$a# z{(Azk-K@C&5sd95i_){T+&&gzg3mS_>&G13Y4;Rhh=a|hzLQ^+xLo(mGYT)yH)+XC zVBmbxJ`ZnqSz+G4(`;Zh5f0}MvaYtwm>2jdvgRNTsdE0HXjZR-M0pmZ#yV?443>I= z‚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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 ZSpotify. -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 ZSpotify. - 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