├── zotify ├── __init__.py ├── termoutput.py ├── const.py ├── album.py ├── loader.py ├── __main__.py ├── playlist.py ├── zotify.py ├── podcast.py ├── utils.py ├── app.py ├── config.py └── track.py ├── pyproject.toml ├── requirements.txt ├── Dockerfile ├── .github └── workflows │ └── pushmirror.yml ├── LICENSE ├── setup.cfg ├── INSTALLATION.md ├── CONTRIBUTING.md ├── .gitignore ├── CHANGELOG.md └── README.md /zotify/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 40.9.0", 4 | "wheel", 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ffmpy 2 | https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip 3 | music_tag 4 | Pillow 5 | protobuf 6 | pwinput 7 | tabulate[widechars] 8 | tqdm 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-alpine as base 2 | 3 | RUN apk --update add ffmpeg 4 | 5 | FROM base as builder 6 | 7 | WORKDIR /install 8 | COPY requirements.txt /requirements.txt 9 | 10 | RUN apk add gcc libc-dev zlib zlib-dev jpeg-dev 11 | RUN pip install --prefix="/install" -r /requirements.txt 12 | 13 | FROM base 14 | 15 | COPY --from=builder /install /usr/local/lib/python3.9/site-packages 16 | RUN mv /usr/local/lib/python3.9/site-packages/lib/python3.9/site-packages/* /usr/local/lib/python3.9/site-packages/ 17 | 18 | COPY zotify /app/zotify 19 | 20 | WORKDIR /app 21 | CMD ["python3", "-m", "zotify"] 22 | -------------------------------------------------------------------------------- /.github/workflows/pushmirror.yml: -------------------------------------------------------------------------------- 1 | name: Push mirror 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | 7 | jobs: 8 | push: 9 | if: github.event.pull_request.merged == true 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: setup git 15 | run: | 16 | git config user.name "GitHub Actions Bot" 17 | git config user.email "<>" 18 | 19 | - name: set upstream 20 | run: | 21 | git remote set-url origin https://x-access-token:${{ secrets.GITEA_TOKEN }}@zotify.xyz/zotify/zotify 22 | git remote add old https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/zotify-dev/zotify 23 | 24 | - name: push repo 25 | run: | 26 | git fetch --unshallow old 27 | git push 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Zotify Contributors 2 | 3 | This software is provided 'as-is', without any express or implied 4 | warranty. In no event will the authors be held liable for any damages 5 | arising from the use of this software. 6 | 7 | Permission is granted to anyone to use this software for any purpose, 8 | including commercial applications, and to alter it and redistribute it 9 | freely, subject to the following restrictions: 10 | 11 | 1. The origin of this software must not be misrepresented; you must not 12 | claim that you wrote the original software. If you use this software 13 | in a product, an acknowledgment in the product documentation would be 14 | appreciated but is not required. 15 | 2. Altered source versions must be plainly marked as such, and must not be 16 | misrepresented as being the original software. 17 | 3. This notice may not be removed or altered from any source distribution. 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = zotify 3 | version = 0.6.14 4 | author = Zotify Contributors 5 | description = A highly customizable music and podcast downloader 6 | long_description = file: README.md 7 | long_description_content_type = text/markdown 8 | keywords = python, music, podcast, downloader 9 | licence = Unlicence 10 | classifiers = 11 | Programming Language :: Python :: 3 12 | License :: OSI Approved :: The Unlicense (Unlicense) 13 | Operating System :: OS Independent 14 | 15 | [options] 16 | packages = zotify 17 | python_requires = >=3.9 18 | install_requires = 19 | librespot@git+https://github.com/kokarare1212/librespot-python.git 20 | ffmpy 21 | music_tag 22 | Pillow 23 | protobuf==3.20.1 24 | pwinput 25 | tabulate[widechars] 26 | tqdm 27 | 28 | [options.package_data] 29 | file: README.md, LICENSE 30 | 31 | [options.entry_points] 32 | console_scripts = 33 | zotify = zotify.__main__:main 34 | -------------------------------------------------------------------------------- /zotify/termoutput.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import Enum 3 | from tqdm import tqdm 4 | 5 | from zotify.config import * 6 | from zotify.zotify import Zotify 7 | 8 | 9 | class PrintChannel(Enum): 10 | SPLASH = PRINT_SPLASH 11 | SKIPS = PRINT_SKIPS 12 | DOWNLOAD_PROGRESS = PRINT_DOWNLOAD_PROGRESS 13 | ERRORS = PRINT_ERRORS 14 | WARNINGS = PRINT_WARNINGS 15 | DOWNLOADS = PRINT_DOWNLOADS 16 | API_ERRORS = PRINT_API_ERRORS 17 | PROGRESS_INFO = PRINT_PROGRESS_INFO 18 | 19 | 20 | ERROR_CHANNEL = [PrintChannel.ERRORS, PrintChannel.API_ERRORS] 21 | 22 | 23 | class Printer: 24 | @staticmethod 25 | def print(channel: PrintChannel, msg: str) -> None: 26 | if Zotify.CONFIG.get(channel.value): 27 | if channel in ERROR_CHANNEL: 28 | print(msg, file=sys.stderr) 29 | else: 30 | print(msg) 31 | 32 | @staticmethod 33 | def print_loader(channel: PrintChannel, msg: str) -> None: 34 | if Zotify.CONFIG.get(channel.value): 35 | print(msg, flush=True, end="") 36 | 37 | @staticmethod 38 | def progress(iterable=None, desc=None, total=None, unit='it', disable=False, unit_scale=False, unit_divisor=1000): 39 | if not Zotify.CONFIG.get(PrintChannel.DOWNLOAD_PROGRESS.value): 40 | disable = True 41 | return tqdm(iterable=iterable, desc=desc, total=total, disable=disable, unit=unit, unit_scale=unit_scale, unit_divisor=unit_divisor) 42 | -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | ### Installing Zotify 2 | 3 | > **Windows** 4 | 5 | This guide uses *Scoop* (https://scoop.sh) to simplify installing prerequisites and *pipx* to manage Zotify itself. 6 | 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. 7 | 8 | - Open PowerShell (cmd will not work) 9 | - Install Scoop by running: 10 | - `Set-ExecutionPolicy RemoteSigned -Scope CurrentUser` 11 | - `irm get.scoop.sh | iex` 12 | - After installing scoop run: `scoop install python ffmpeg-shared git` 13 | - Install pipx: 14 | - `python3 -m pip install --user pipx` 15 | - `python3 -m pipx ensurepath` 16 | Now close PowerShell and reopen it to ensure the pipx command is available. 17 | - Install Zotify with: `pipx install https://get.zotify.xyz` 18 | - Done! Use `zotify --help` for a basic list of commands or check the *README.md* file in Zotify's code repository for full documentation. 19 | 20 | > **macOS** 21 | - Open the Terminal app 22 | - Install *Homebrew* (https://brew.sh) by running: `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` 23 | - After installing Homebrew run: `brew install python@3.11 pipx ffmpeg git` 24 | - Setup pipx: `pipx ensurepath` 25 | - Install Zotify: `pipx install https://get.zotify.xyz` 26 | - Done! Use `zotify --help` for a basic list of commands or check the README.md file in Zotify's code repository for full documentation. 27 | 28 | > **Linux (Most Popular Distributions)** 29 | - Install `python3`, `pip` (if a separate package), `ffmpeg`, and `git` from your distribution's package manager or software center. 30 | - Then install pipx, either from your package manager or through pip with: `python3 -m pip install --user pipx` 31 | - Install Zotify `pipx install https://get.zotify.xyz` 32 | - Done! Use `zotify --help` for a basic list of commands or check the README.md file in Zotify's code repository for full documentation. -------------------------------------------------------------------------------- /zotify/const.py: -------------------------------------------------------------------------------- 1 | FOLLOWED_ARTISTS_URL = 'https://api.spotify.com/v1/me/following?type=artist' 2 | 3 | SAVED_TRACKS_URL = 'https://api.spotify.com/v1/me/tracks' 4 | 5 | TRACKS_URL = 'https://api.spotify.com/v1/tracks' 6 | 7 | TRACK_STATS_URL = 'https://api.spotify.com/v1/audio-features/' 8 | 9 | TRACKNUMBER = 'tracknumber' 10 | 11 | DISCNUMBER = 'discnumber' 12 | 13 | YEAR = 'year' 14 | 15 | ALBUM = 'album' 16 | 17 | TRACKTITLE = 'tracktitle' 18 | 19 | ARTIST = 'artist' 20 | 21 | ARTISTS = 'artists' 22 | 23 | ALBUMARTIST = 'albumartist' 24 | 25 | GENRES = 'genres' 26 | 27 | GENRE = 'genre' 28 | 29 | ARTWORK = 'artwork' 30 | 31 | TRACKS = 'tracks' 32 | 33 | TRACK = 'track' 34 | 35 | ITEMS = 'items' 36 | 37 | NAME = 'name' 38 | 39 | HREF = 'href' 40 | 41 | ID = 'id' 42 | 43 | URL = 'url' 44 | 45 | RELEASE_DATE = 'release_date' 46 | 47 | IMAGES = 'images' 48 | 49 | LIMIT = 'limit' 50 | 51 | OFFSET = 'offset' 52 | 53 | AUTHORIZATION = 'Authorization' 54 | 55 | IS_PLAYABLE = 'is_playable' 56 | 57 | DURATION_MS = 'duration_ms' 58 | 59 | TRACK_NUMBER = 'track_number' 60 | 61 | DISC_NUMBER = 'disc_number' 62 | 63 | SHOW = 'show' 64 | 65 | ERROR = 'error' 66 | 67 | EXPLICIT = 'explicit' 68 | 69 | PLAYLIST = 'playlist' 70 | 71 | PLAYLISTS = 'playlists' 72 | 73 | OWNER = 'owner' 74 | 75 | DISPLAY_NAME = 'display_name' 76 | 77 | ALBUMS = 'albums' 78 | 79 | TYPE = 'type' 80 | 81 | PREMIUM = 'premium' 82 | 83 | WIDTH = 'width' 84 | 85 | USER_READ_EMAIL = 'user-read-email' 86 | 87 | USER_FOLLOW_READ = 'user-follow-read' 88 | 89 | PLAYLIST_READ_PRIVATE = 'playlist-read-private' 90 | 91 | USER_LIBRARY_READ = 'user-library-read' 92 | 93 | WINDOWS_SYSTEM = 'Windows' 94 | 95 | LINUX_SYSTEM = 'Linux' 96 | 97 | CODEC_MAP = { 98 | 'aac': 'aac', 99 | 'fdk_aac': 'libfdk_aac', 100 | 'm4a': 'aac', 101 | 'mp3': 'libmp3lame', 102 | 'ogg': 'copy', 103 | 'opus': 'libopus', 104 | 'vorbis': 'copy', 105 | } 106 | 107 | EXT_MAP = { 108 | 'aac': 'm4a', 109 | 'fdk_aac': 'm4a', 110 | 'm4a': 'm4a', 111 | 'mp3': 'mp3', 112 | 'ogg': 'ogg', 113 | 'opus': 'ogg', 114 | 'vorbis': 'ogg', 115 | } 116 | -------------------------------------------------------------------------------- /zotify/album.py: -------------------------------------------------------------------------------- 1 | from zotify.const import ITEMS, ARTISTS, NAME, ID 2 | from zotify.termoutput import Printer 3 | from zotify.track import download_track 4 | from zotify.utils import fix_filename 5 | from zotify.zotify import Zotify 6 | 7 | ALBUM_URL = 'https://api.spotify.com/v1/albums' 8 | ARTIST_URL = 'https://api.spotify.com/v1/artists' 9 | 10 | 11 | def get_album_tracks(album_id): 12 | """ Returns album tracklist """ 13 | songs = [] 14 | offset = 0 15 | limit = 50 16 | 17 | while True: 18 | resp = Zotify.invoke_url_with_params(f'{ALBUM_URL}/{album_id}/tracks', limit=limit, offset=offset) 19 | offset += limit 20 | songs.extend(resp[ITEMS]) 21 | if len(resp[ITEMS]) < limit: 22 | break 23 | 24 | return songs 25 | 26 | 27 | def get_album_name(album_id): 28 | """ Returns album name """ 29 | (raw, resp) = Zotify.invoke_url(f'{ALBUM_URL}/{album_id}') 30 | return resp[ARTISTS][0][NAME], fix_filename(resp[NAME]) 31 | 32 | 33 | def get_artist_albums(artist_id): 34 | """ Returns artist's albums """ 35 | (raw, resp) = Zotify.invoke_url(f'{ARTIST_URL}/{artist_id}/albums?include_groups=album%2Csingle') 36 | # Return a list each album's id 37 | album_ids = [resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))] 38 | # Recursive requests to get all albums including singles an EPs 39 | while resp['next'] is not None: 40 | (raw, resp) = Zotify.invoke_url(resp['next']) 41 | album_ids.extend([resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))]) 42 | 43 | return album_ids 44 | 45 | 46 | def download_album(album): 47 | """ Downloads songs from an album """ 48 | artist, album_name = get_album_name(album) 49 | tracks = get_album_tracks(album) 50 | for n, track in Printer.progress(enumerate(tracks, start=1), unit_scale=True, unit='Song', total=len(tracks)): 51 | download_track('album', track[ID], extra_keys={'album_num': str(n).zfill(2), 'artist': artist, 'album': album_name, 'album_id': album}, disable_progressbar=True) 52 | 53 | 54 | def download_artist_albums(artist): 55 | """ Downloads albums of an artist """ 56 | albums = get_artist_albums(artist) 57 | for album_id in albums: 58 | download_album(album_id) 59 | -------------------------------------------------------------------------------- /zotify/loader.py: -------------------------------------------------------------------------------- 1 | # load symbol from: 2 | # https://stackoverflow.com/questions/22029562/python-how-to-make-simple-animated-loading-while-process-is-running 3 | 4 | # imports 5 | from itertools import cycle 6 | from shutil import get_terminal_size 7 | from threading import Thread 8 | from time import sleep 9 | 10 | from zotify.termoutput import Printer 11 | 12 | 13 | class Loader: 14 | """Busy symbol. 15 | 16 | Can be called inside a context: 17 | 18 | with Loader("This take some Time..."): 19 | # do something 20 | pass 21 | """ 22 | def __init__(self, chan, desc="Loading...", end='', timeout=0.1, mode='prog'): 23 | """ 24 | A loader-like context manager 25 | 26 | Args: 27 | desc (str, optional): The loader's description. Defaults to "Loading...". 28 | end (str, optional): Final print. Defaults to "". 29 | timeout (float, optional): Sleep time between prints. Defaults to 0.1. 30 | """ 31 | self.desc = desc 32 | self.end = end 33 | self.timeout = timeout 34 | self.channel = chan 35 | 36 | self._thread = Thread(target=self._animate, daemon=True) 37 | if mode == 'std1': 38 | self.steps = ["⢿", "⣻", "⣽", "⣾", "⣷", "⣯", "⣟", "⡿"] 39 | elif mode == 'std2': 40 | self.steps = ["◜","◝","◞","◟"] 41 | elif mode == 'std3': 42 | self.steps = ["😐 ","😐 ","😮 ","😮 ","😦 ","😦 ","😧 ","😧 ","🤯 ","💥 ","✨ ","\u3000 ","\u3000 ","\u3000 "] 43 | elif mode == 'prog': 44 | self.steps = ["[∙∙∙]","[●∙∙]","[∙●∙]","[∙∙●]","[∙∙∙]"] 45 | 46 | self.done = False 47 | 48 | def start(self): 49 | self._thread.start() 50 | return self 51 | 52 | def _animate(self): 53 | for c in cycle(self.steps): 54 | if self.done: 55 | break 56 | Printer.print_loader(self.channel, f"\r\t{c} {self.desc} ") 57 | sleep(self.timeout) 58 | 59 | def __enter__(self): 60 | self.start() 61 | 62 | def stop(self): 63 | self.done = True 64 | cols = get_terminal_size((80, 20)).columns 65 | Printer.print_loader(self.channel, "\r" + " " * cols) 66 | 67 | if self.end != "": 68 | Printer.print_loader(self.channel, f"\r{self.end}") 69 | 70 | def __exit__(self, exc_type, exc_value, tb): 71 | # handle exceptions with those variables ^ 72 | self.stop() 73 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ### Thank you for contributing 4 | 5 | Without people like you this project wouldn't be anywhere near as polished and feature-rich as it is now. 6 | 7 | ### Guidelines 8 | 9 | Following these guidelines helps show that you respect the the time and effort spent by the developers and your fellow contributors making this project. 10 | 11 | ### What we are looking for 12 | 13 | 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. 14 | 15 | ### What we aren't looking for 16 | 17 | 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. 18 | 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. 19 | 20 | # Ground rules 21 | 22 | ### Expectations 23 | * Ensure all code is linted with pylint before pushing. 24 | * Ensure all code passes the [testing criteria](#testing-criteria) (coming soon). 25 | * If you're planning on contributing a new feature, join the Discord or Matrix and discuss it with the Dev Team. 26 | * Please don't commit multiple new features at once. 27 | * Follow the [Python Community Code of Conduct](https://www.python.org/psf/codeofconduct/) 28 | 29 | # Your first contribution 30 | 31 | 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. 32 | Here are a couple of friendly tutorials on making pull requests: http://makeapullrequest.com/ and http://www.firsttimersonly.com/ 33 | 34 | # Code review process 35 | 36 | 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. 37 | 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. 38 | 39 | # Community 40 | 41 | Come and chat with us on Discord or Matrix. Devs try to respond to mentions at least once per day. -------------------------------------------------------------------------------- /zotify/__main__.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | """ 4 | Zotify 5 | It's like youtube-dl, but for that other music platform. 6 | """ 7 | 8 | import argparse 9 | 10 | from zotify.app import client 11 | from zotify.config import CONFIG_VALUES 12 | 13 | def main(): 14 | parser = argparse.ArgumentParser(prog='zotify', 15 | description='A music and podcast downloader needing only python and ffmpeg.') 16 | parser.add_argument('-ns', '--no-splash', 17 | action='store_true', 18 | help='Suppress the splash screen when loading.') 19 | parser.add_argument('--config-location', 20 | type=str, 21 | help='Specify the zconfig.json location') 22 | parser.add_argument('--username', 23 | type=str, 24 | help='Account username') 25 | parser.add_argument('--password', 26 | type=str, 27 | help='Account password') 28 | group = parser.add_mutually_exclusive_group(required=False) 29 | group.add_argument('urls', 30 | type=str, 31 | # action='extend', 32 | default='', 33 | nargs='*', 34 | help='Downloads the track, album, playlist, podcast episode, or all albums by an artist from a url. Can take multiple urls.') 35 | group.add_argument('-l', '--liked', 36 | dest='liked_songs', 37 | action='store_true', 38 | help='Downloads all the liked songs from your account.') 39 | group.add_argument('-f', '--followed', 40 | dest='followed_artists', 41 | action='store_true', 42 | help='Downloads all the songs from all your followed artists.') 43 | group.add_argument('-p', '--playlist', 44 | action='store_true', 45 | help='Downloads a saved playlist from your account.') 46 | group.add_argument('-s', '--search', 47 | type=str, 48 | nargs='?', 49 | const=' ', 50 | help='Loads search prompt to find then download a specific track, album or playlist') 51 | group.add_argument('-d', '--download', 52 | type=str, 53 | help='Downloads tracks, playlists and albums from the URLs written in the file passed.') 54 | 55 | for configkey in CONFIG_VALUES: 56 | parser.add_argument(CONFIG_VALUES[configkey]['arg'], 57 | type=str, 58 | default=None, 59 | help='Specify the value of the ['+configkey+'] config value') 60 | 61 | parser.set_defaults(func=client) 62 | 63 | args = parser.parse_args() 64 | args.func(args) 65 | 66 | 67 | if __name__ == '__main__': 68 | main() 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | src/__pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # pytype static type analyzer 136 | .pytype/ 137 | 138 | # Cython debug symbols 139 | cython_debug/ 140 | 141 | # MacOS file 142 | .DS_Store 143 | 144 | # IDE settings 145 | .vscode/ 146 | .idea/ 147 | 148 | # Configuration 149 | .zotify/ 150 | 151 | #Download Folder 152 | Zotify\ Music/ 153 | Zotify\ Podcasts/ 154 | -------------------------------------------------------------------------------- /zotify/playlist.py: -------------------------------------------------------------------------------- 1 | from zotify.const import ITEMS, ID, TRACK, NAME 2 | from zotify.termoutput import Printer 3 | from zotify.track import download_track 4 | from zotify.utils import split_input 5 | from zotify.zotify import Zotify 6 | 7 | MY_PLAYLISTS_URL = 'https://api.spotify.com/v1/me/playlists' 8 | PLAYLISTS_URL = 'https://api.spotify.com/v1/playlists' 9 | 10 | 11 | def get_all_playlists(): 12 | """ Returns list of users playlists """ 13 | playlists = [] 14 | limit = 50 15 | offset = 0 16 | 17 | while True: 18 | resp = Zotify.invoke_url_with_params(MY_PLAYLISTS_URL, limit=limit, offset=offset) 19 | offset += limit 20 | playlists.extend(resp[ITEMS]) 21 | if len(resp[ITEMS]) < limit: 22 | break 23 | 24 | return playlists 25 | 26 | 27 | def get_playlist_songs(playlist_id): 28 | """ returns list of songs in a playlist """ 29 | songs = [] 30 | offset = 0 31 | limit = 100 32 | 33 | while True: 34 | resp = Zotify.invoke_url_with_params(f'{PLAYLISTS_URL}/{playlist_id}/tracks', limit=limit, offset=offset) 35 | offset += limit 36 | songs.extend(resp[ITEMS]) 37 | if len(resp[ITEMS]) < limit: 38 | break 39 | 40 | return songs 41 | 42 | 43 | def get_playlist_info(playlist_id): 44 | """ Returns information scraped from playlist """ 45 | (raw, resp) = Zotify.invoke_url(f'{PLAYLISTS_URL}/{playlist_id}?fields=name,owner(display_name)&market=from_token') 46 | return resp['name'].strip(), resp['owner']['display_name'].strip() 47 | 48 | 49 | def download_playlist(playlist): 50 | """Downloads all the songs from a playlist""" 51 | 52 | playlist_songs = [song for song in get_playlist_songs(playlist[ID]) if song[TRACK] is not None and song[TRACK][ID]] 53 | p_bar = Printer.progress(playlist_songs, unit='song', total=len(playlist_songs), unit_scale=True) 54 | enum = 1 55 | for song in p_bar: 56 | download_track('extplaylist', song[TRACK][ID], extra_keys={'playlist': playlist[NAME], 'playlist_num': str(enum).zfill(2)}, disable_progressbar=True) 57 | p_bar.set_description(song[TRACK][NAME]) 58 | enum += 1 59 | 60 | 61 | def download_from_user_playlist(): 62 | """ Select which playlist(s) to download """ 63 | playlists = get_all_playlists() 64 | 65 | count = 1 66 | for playlist in playlists: 67 | print(str(count) + ': ' + playlist[NAME].strip()) 68 | count += 1 69 | 70 | selection = '' 71 | print('\n> SELECT A PLAYLIST BY ID') 72 | print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s') 73 | print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s\n') 74 | while len(selection) == 0: 75 | selection = str(input('ID(s): ')) 76 | playlist_choices = map(int, split_input(selection)) 77 | 78 | for playlist_number in playlist_choices: 79 | playlist = playlists[playlist_number - 1] 80 | print(f'Downloading {playlist[NAME].strip()}') 81 | download_playlist(playlist) 82 | 83 | print('\n**All playlists have been downloaded**\n') 84 | -------------------------------------------------------------------------------- /zotify/zotify.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from pwinput import pwinput 4 | import time 5 | import requests 6 | from librespot.audio.decoders import VorbisOnlyAudioQuality 7 | from librespot.core import Session 8 | 9 | from zotify.const import TYPE, \ 10 | PREMIUM, USER_READ_EMAIL, OFFSET, LIMIT, \ 11 | PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ, USER_FOLLOW_READ 12 | from zotify.config import Config 13 | 14 | class Zotify: 15 | SESSION: Session = None 16 | DOWNLOAD_QUALITY = None 17 | CONFIG: Config = Config() 18 | 19 | def __init__(self, args): 20 | Zotify.CONFIG.load(args) 21 | Zotify.login(args) 22 | 23 | @classmethod 24 | def login(cls, args): 25 | """ Authenticates with Spotify and saves credentials to a file """ 26 | 27 | cred_location = Config.get_credentials_location() 28 | 29 | if Path(cred_location).is_file(): 30 | try: 31 | conf = Session.Configuration.Builder().set_store_credentials(False).build() 32 | cls.SESSION = Session.Builder(conf).stored_file(cred_location).create() 33 | return 34 | except RuntimeError: 35 | pass 36 | while True: 37 | user_name = args.username if args.username else '' 38 | while len(user_name) == 0: 39 | user_name = input('Username: ') 40 | password = args.password if args.password else pwinput(prompt='Password: ', mask='*') 41 | try: 42 | if Config.get_save_credentials(): 43 | conf = Session.Configuration.Builder().set_stored_credential_file(cred_location).build() 44 | else: 45 | conf = Session.Configuration.Builder().set_store_credentials(False).build() 46 | cls.SESSION = Session.Builder(conf).user_pass(user_name, password).create() 47 | return 48 | except RuntimeError: 49 | pass 50 | 51 | @classmethod 52 | def get_content_stream(cls, content_id, quality): 53 | return cls.SESSION.content_feeder().load(content_id, VorbisOnlyAudioQuality(quality), False, None) 54 | 55 | @classmethod 56 | def __get_auth_token(cls): 57 | return cls.SESSION.tokens().get_token( 58 | USER_READ_EMAIL, PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ, USER_FOLLOW_READ 59 | ).access_token 60 | 61 | @classmethod 62 | def get_auth_header(cls): 63 | return { 64 | 'Authorization': f'Bearer {cls.__get_auth_token()}', 65 | 'Accept-Language': f'{cls.CONFIG.get_language()}', 66 | 'Accept': 'application/json', 67 | 'app-platform': 'WebPlayer' 68 | } 69 | 70 | @classmethod 71 | def get_auth_header_and_params(cls, limit, offset): 72 | return { 73 | 'Authorization': f'Bearer {cls.__get_auth_token()}', 74 | 'Accept-Language': f'{cls.CONFIG.get_language()}', 75 | 'Accept': 'application/json', 76 | 'app-platform': 'WebPlayer' 77 | }, {LIMIT: limit, OFFSET: offset} 78 | 79 | @classmethod 80 | def invoke_url_with_params(cls, url, limit, offset, **kwargs): 81 | headers, params = cls.get_auth_header_and_params(limit=limit, offset=offset) 82 | params.update(kwargs) 83 | return requests.get(url, headers=headers, params=params).json() 84 | 85 | @classmethod 86 | def invoke_url(cls, url, tryCount=0): 87 | # we need to import that here, otherwise we will get circular imports! 88 | from zotify.termoutput import Printer, PrintChannel 89 | headers = cls.get_auth_header() 90 | response = requests.get(url, headers=headers) 91 | responsetext = response.text 92 | try: 93 | responsejson = response.json() 94 | except json.decoder.JSONDecodeError: 95 | responsejson = {"error": {"status": "unknown", "message": "received an empty response"}} 96 | 97 | if not responsejson or 'error' in responsejson: 98 | if tryCount < (cls.CONFIG.get_retry_attempts() - 1): 99 | Printer.print(PrintChannel.WARNINGS, f"Spotify API Error (try {tryCount + 1}) ({responsejson['error']['status']}): {responsejson['error']['message']}") 100 | time.sleep(5) 101 | return cls.invoke_url(url, tryCount + 1) 102 | 103 | Printer.print(PrintChannel.API_ERRORS, f"Spotify API Error ({responsejson['error']['status']}): {responsejson['error']['message']}") 104 | 105 | return responsetext, responsejson 106 | 107 | @classmethod 108 | def check_premium(cls) -> bool: 109 | """ If user has spotify premium return true """ 110 | return (cls.SESSION.get_user_attribute(TYPE) == PREMIUM) 111 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.6.13 4 | - Only replace chars with _ when required 5 | - Added defaults to README 6 | 7 | ## 0.6.12 8 | - Dockerfile works again 9 | - Fixed lrc file extension replacement 10 | - Fixed lrc file writes breaking on non-utf8 systems 11 | 12 | ## 0.6.11 13 | - Add new scope for reading followed artists 14 | - Print API errors by default 15 | 16 | ## 0.6.10 17 | - Fix cover art size once and for all 18 | 19 | ## 0.6.9 20 | - Fix low resolution cover art 21 | - Fix crash when missing ffmpeg 22 | 23 | ## 0.6.8 24 | - Improve check for direct download availability of podcasts 25 | 26 | ## 0.6.7 27 | - Temporary fix for upstream protobuf error 28 | 29 | ## v0.6.6 30 | - Added `-f` / `--followed` option to download every song by all of your followed artists 31 | 32 | ## v0.6.5 33 | - Implemented more stable fix for bug still persisting after v0.6.4 34 | 35 | ## v0.6.4 36 | - Fixed upstream bug causing tracks to not download fully 37 | 38 | ## 0.6.3 39 | - Less stupid single format 40 | - Fixed error in json fetching 41 | - Default to search if no other option is provided 42 | 43 | ## v0.6.2 44 | - Won't crash if downloading a song with no lyrics and `DOWNLOAD_LYRICS` is set to True 45 | - Fixed visual glitch when entering login info 46 | - Saving genre metadata is now optional (disabled by default) and configurable with the `MD_SAVE_GENRES`/`--md-save-genres` option 47 | - Switched to new loading animation that hopefully renders a little better in Windows command shells 48 | - Username and password can now be entered as arguments with `--username` and `--password` - does **not** take priority over credentials.json 49 | - Added option to disable saving credentials `SAVE_CREDENTIALS`/`--save-credentials` - will still use credentials.json if already exists 50 | - Default output format for singles is now `{artist}/Single - {song_name}/{artist} - {song_name}.{ext}` 51 | 52 | ## v0.6.1 53 | - Added support for synced lyrics (unsynced is synced unavailable) 54 | - Can be configured with the `DOWNLOAD_LYRICS` option in config.json or `--download-lyrics=True/False` as a command line argument 55 | 56 | ## v0.6 57 | **General changes** 58 | - 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. 59 | - The "FORCE_PREMIUM" option has been removed, the same result can be achieved with `--download-quality="very-high"`. 60 | - The "BITRATE" option has been renamed "TRANSCODE_BITRATE" as it now only effects transcodes 61 | - FFmpeg is now semi-optional, not having it installed means you are limited to saving music as ogg vorbis. 62 | - Zotify can now be installed with `pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip` 63 | - Zotify can be ran from any directory with `zotify [args]`, you no longer need to prefix "python" in the command. 64 | - The -s option now takes search input as a command argument, it will still promt you if no search is given. 65 | - The -ls/--liked-songs option has been shrotened to -l/--liked, 66 | - Singles are now stored in their own folders under the artist folder 67 | - Fixed default config not loading on first run 68 | - Now shows asterisks when entering password 69 | - Switched from os.path to pathlib 70 | - New default config locations: 71 | - Windows: `%AppData%\Roaming\Zotify\config.json` 72 | - Linux: `~/.config/zotify/config.json` 73 | - macOS: `~/Library/Application Support/Zotify/config.json` 74 | - Other/Undetected: `.zotify/config.json` 75 | - You can still use `--config-location` to specify a different location. 76 | - New default credential locations: 77 | - Windows: `%AppData%\Roaming\Zotify\credentials.json` 78 | - Linux: `~/.local/share/zotify/credentials.json` 79 | - macOS: `~/Library/Application Support/Zotify/credentials.json` 80 | - Other/Undetected: `.zotify/credentials.json` 81 | - You can still use `--credentials-location` to specify a different file. 82 | - New default music and podcast locations: 83 | - Windows: `C:\Users\\Music\Zotify Music\` & `C:\Users\\Music\Zotify Podcasts\` 84 | - Linux & macOS: `~/Music/Zotify Music/` & `~/Music/Zotify Podcasts/` 85 | - Other/Undetected: `./Zotify Music/` & `./Zotify Podcasts/` 86 | - You can still use `--root-path` and `--root-podcast-path` respectively to specify a differnt location 87 | 88 | **Docker** 89 | - Dockerfile is currently broken, it will be fixed soon. \ 90 | The Dockerhub image is now discontinued, we will try to switch to GitLab's container registry. 91 | 92 | **Windows installer** 93 | - The Windows installer is unavilable with this release. 94 | - The current installation system will be replaced and a new version will be available with the next release. 95 | 96 | ## v0.5.2 97 | **General changes** 98 | - Fixed filenaming on Windows 99 | - Fixed removal of special characters metadata 100 | - Can now download different songs with the same name 101 | - Real-time downloads now work correctly 102 | - Removed some debug messages 103 | - Added album_artist metadata 104 | - Added global song archive 105 | - Added SONG_ARCHIVE config value 106 | - Added CREDENTIALS_LOCATION config value 107 | - Added `--download` argument 108 | - Added `--config-location` argument 109 | - Added `--output` for output templating 110 | - Save extra data in .song_ids 111 | - Added options to regulate terminal output 112 | - Direct download support for certain podcasts 113 | 114 | **Docker images** 115 | - Remember credentials between container starts 116 | - Use same uid/gid in container as on host 117 | 118 | **Windows installer** 119 | - Now comes with full installer 120 | - Dependencies are installed if not found 121 | -------------------------------------------------------------------------------- /zotify/podcast.py: -------------------------------------------------------------------------------- 1 | # import os 2 | from pathlib import PurePath, Path 3 | import time 4 | from typing import Optional, Tuple 5 | 6 | from librespot.metadata import EpisodeId 7 | 8 | from zotify.const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS 9 | from zotify.termoutput import PrintChannel, Printer 10 | from zotify.utils import create_download_directory, fix_filename 11 | from zotify.zotify import Zotify 12 | from zotify.loader import Loader 13 | 14 | 15 | EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes' 16 | SHOWS_URL = 'https://api.spotify.com/v1/shows' 17 | 18 | 19 | def get_episode_info(episode_id_str) -> Tuple[Optional[str], Optional[str]]: 20 | with Loader(PrintChannel.PROGRESS_INFO, "Fetching episode information..."): 21 | (raw, info) = Zotify.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}') 22 | if not info: 23 | Printer.print(PrintChannel.ERRORS, "### INVALID EPISODE ID ###") 24 | duration_ms = info[DURATION_MS] 25 | if ERROR in info: 26 | return None, None 27 | return fix_filename(info[SHOW][NAME]), duration_ms, fix_filename(info[NAME]) 28 | 29 | 30 | def get_show_episodes(show_id_str) -> list: 31 | episodes = [] 32 | offset = 0 33 | limit = 50 34 | 35 | with Loader(PrintChannel.PROGRESS_INFO, "Fetching episodes..."): 36 | while True: 37 | resp = Zotify.invoke_url_with_params( 38 | f'{SHOWS_URL}/{show_id_str}/episodes', limit=limit, offset=offset) 39 | offset += limit 40 | for episode in resp[ITEMS]: 41 | episodes.append(episode[ID]) 42 | if len(resp[ITEMS]) < limit: 43 | break 44 | 45 | return episodes 46 | 47 | 48 | def download_podcast_directly(url, filename): 49 | import functools 50 | import shutil 51 | import requests 52 | from tqdm.auto import tqdm 53 | 54 | r = requests.get(url, stream=True, allow_redirects=True) 55 | if r.status_code != 200: 56 | r.raise_for_status() # Will only raise for 4xx codes, so... 57 | raise RuntimeError( 58 | f"Request to {url} returned status code {r.status_code}") 59 | file_size = int(r.headers.get('Content-Length', 0)) 60 | 61 | path = Path(filename).expanduser().resolve() 62 | path.parent.mkdir(parents=True, exist_ok=True) 63 | 64 | desc = "(Unknown total file size)" if file_size == 0 else "" 65 | r.raw.read = functools.partial( 66 | r.raw.read, decode_content=True) # Decompress if needed 67 | with tqdm.wrapattr(r.raw, "read", total=file_size, desc=desc) as r_raw: 68 | with path.open("wb") as f: 69 | shutil.copyfileobj(r_raw, f) 70 | 71 | return path 72 | 73 | 74 | def download_episode(episode_id) -> None: 75 | podcast_name, duration_ms, episode_name = get_episode_info(episode_id) 76 | extra_paths = podcast_name + '/' 77 | prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Preparing download...") 78 | prepare_download_loader.start() 79 | 80 | if podcast_name is None: 81 | Printer.print(PrintChannel.SKIPS, '### SKIPPING: (EPISODE NOT FOUND) ###') 82 | prepare_download_loader.stop() 83 | else: 84 | filename = podcast_name + ' - ' + episode_name 85 | 86 | resp = Zotify.invoke_url( 87 | '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"] 88 | direct_download_url = resp["audio"]["items"][-1]["url"] 89 | 90 | download_directory = PurePath(Zotify.CONFIG.get_root_podcast_path()).joinpath(extra_paths) 91 | # download_directory = os.path.realpath(download_directory) 92 | create_download_directory(download_directory) 93 | 94 | if "anon-podcast.scdn.co" in direct_download_url or "audio_preview_url" not in resp: 95 | episode_id = EpisodeId.from_base62(episode_id) 96 | stream = Zotify.get_content_stream( 97 | episode_id, Zotify.DOWNLOAD_QUALITY) 98 | 99 | total_size = stream.input_stream.size 100 | 101 | filepath = PurePath(download_directory).joinpath(f"{filename}.ogg") 102 | if ( 103 | Path(filepath).is_file() 104 | and Path(filepath).stat().st_size == total_size 105 | and Zotify.CONFIG.get_skip_existing() 106 | ): 107 | Printer.print(PrintChannel.SKIPS, "\n### SKIPPING: " + podcast_name + " - " + episode_name + " (EPISODE ALREADY EXISTS) ###") 108 | prepare_download_loader.stop() 109 | return 110 | 111 | prepare_download_loader.stop() 112 | time_start = time.time() 113 | downloaded = 0 114 | with open(filepath, 'wb') as file, Printer.progress( 115 | desc=filename, 116 | total=total_size, 117 | unit='B', 118 | unit_scale=True, 119 | unit_divisor=1024 120 | ) as p_bar: 121 | prepare_download_loader.stop() 122 | while True: 123 | #for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 2): 124 | data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size()) 125 | p_bar.update(file.write(data)) 126 | downloaded += len(data) 127 | if data == b'': 128 | break 129 | if Zotify.CONFIG.get_download_real_time(): 130 | delta_real = time.time() - time_start 131 | delta_want = (downloaded / total_size) * (duration_ms/1000) 132 | if delta_want > delta_real: 133 | time.sleep(delta_want - delta_real) 134 | else: 135 | filepath = PurePath(download_directory).joinpath(f"{filename}.mp3") 136 | download_podcast_directly(direct_download_url, filepath) 137 | 138 | prepare_download_loader.stop() 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zotify 2 | 3 | ### A highly customizable music and podcast downloader. 4 | 5 |

6 | Zotify logo 7 |

8 | 9 | ### Features 10 | - Downloads at up to 320kbps* 11 | - Downloads directly from the source** 12 | - Downloads podcasts, playlists, liked songs, albums, artists, singles. 13 | - Downloads synced lyrics from the source 14 | - Option to download in real time to appear more legitimate*** 15 | - Supports multiple audio formats 16 | - Download directly from URL or use built-in in search 17 | - Bulk downloads from a list of URLs in a text file or parsed directly as arguments 18 | 19 | *Free accounts are limited to 160kbps. \ 20 | **Audio files are NOT substituted with ones from other sources such as YouTube or Deezer, they are sourced directly. \ 21 | ***'real time' refers to downloading at the speed it would normally be streamed at (the duration of the track). 22 | 23 | ### Install 24 | 25 | ``` 26 | Dependencies: 27 | 28 | - Python 3.9 or greater 29 | - FFmpeg 30 | 31 | Installation: 32 | 33 | python -m pip install git+https://zotify.xyz/zotify/zotify.git 34 | ``` 35 | 36 | See [INSTALLATION](INSTALLATION.md) for a more detailed and opinionated installation walkthrough. 37 | 38 | ### Command line usage 39 | 40 | ``` 41 | Basic command line usage: 42 | zotify 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. 43 | 44 | Basic options: 45 | (nothing) Download the tracks/albums/playlists URLs from the parameter 46 | -d, --download Download all tracks/albums/playlists URLs from the specified file 47 | -p, --playlist Downloads a saved playlist from your account 48 | -l, --liked Downloads all the liked songs from your account 49 | -f, --followed Downloads all songs by all artists you follow 50 | -s, --search Searches for specified track, album, artist or playlist, loads search prompt if none are given. 51 | -h, --help See this message. 52 | ``` 53 | 54 | ### Options 55 | 56 | All these options can either be configured in the config or via the commandline, in case of both the commandline-option has higher priority. 57 | Be aware you have to set boolean values in the commandline like this: `--download-real-time=True` 58 | 59 | | Key (config) | Commandline parameter | Defaults | Description 60 | |------------------------------|----------------------------------|----------|---------------------------------------------------------------------| 61 | | CREDENTIALS_LOCATION | --credentials-location | | The location of the credentials.json 62 | | OUTPUT | --output | | The output location/format (see below) 63 | | SONG_ARCHIVE | --song-archive | | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED 64 | | ROOT_PATH | --root-path | | Directory where Zotify saves music 65 | | ROOT_PODCAST_PATH | --root-podcast-path | | Directory where Zotify saves podcasts 66 | | SPLIT_ALBUM_DISCS | --split-album-discs | False | Saves each disk in its own folder 67 | | DOWNLOAD_LYRICS | --download-lyrics | True | Downloads synced lyrics in .lrc format, uses unsynced as fallback. 68 | | MD_ALLGENRES | --md-allgenres | False | Save all relevant genres in metadata 69 | | MD_GENREDELIMITER | --md-genredelimiter | , | Delimiter character used to split genres in metadata 70 | | DOWNLOAD_FORMAT | --download-format | ogg | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis) 71 | | DOWNLOAD_QUALITY | --download-quality | auto | Audio quality of downloaded songs (normal, high, very_high*) 72 | | TRANSCODE_BITRATE | --transcode-bitrate | auto | Overwrite the bitrate for ffmpeg encoding 73 | | SKIP_EXISTING_FILES | --skip-existing | True | Skip songs with the same name 74 | | SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | False | Use a song_archive file to skip previously downloaded songs 75 | | RETRY_ATTEMPTS | --retry-attempts | 1 | Number of times Zotify will retry a failed request 76 | | BULK_WAIT_TIME | --bulk-wait-time | 1 | The wait time between bulk downloads 77 | | OVERRIDE_AUTO_WAIT | --override-auto-wait | False | Totally disable wait time between songs with the risk of instability 78 | | CHUNK_SIZE | --chunk-size | 20000 | Chunk size for downloading 79 | | DOWNLOAD_REAL_TIME | --download-real-time | False | Downloads songs as fast as they would be played, should prevent account bans. 80 | | LANGUAGE | --language | en | Language for spotify metadata 81 | | PRINT_SPLASH | --print-splash | False | Show the Zotify logo at startup 82 | | PRINT_SKIPS | --print-skips | True | Show messages if a song is being skipped 83 | | PRINT_DOWNLOAD_PROGRESS | --print-download-progress | True | Show download/playlist progress bars 84 | | PRINT_ERRORS | --print-errors | True | Show errors 85 | | PRINT_DOWNLOADS | --print-downloads | False | Print messages when a song is finished downloading 86 | | TEMP_DOWNLOAD_DIR | --temp-download-dir | | Download tracks to a temporary directory first 87 | 88 | *very-high is limited to premium only 89 | 90 | ### Configuration 91 | 92 | You can find the configuration file in following locations: 93 | | OS | Location 94 | |-----------------|-------------------------------------------------------------------| 95 | | Windows | `C:\Users\\AppData\Roaming\Zotify\config.json` | 96 | | MacOS | `/Users//Library/ApplicationSupport/Zotify/config.json` | 97 | | Linux | `/home//.config/zotify/config.json` | 98 | 99 | To log out, just remove the configuration file. Uninstalling Zotify does not remove the config file. 100 | 101 | ### Output format 102 | 103 | With the option `OUTPUT` (or the commandline parameter `--output`) you can specify the output location and format. 104 | The value is relative to the `ROOT_PATH`/`ROOT_PODCAST_PATH` directory and can contain the following placeholder: 105 | 106 | | Placeholder | Description 107 | |-----------------|-------------------------------- 108 | | {artist} | The song artist 109 | | {album} | The song album 110 | | {song_name} | The song name 111 | | {release_year} | The song release year 112 | | {disc_number} | The disc number 113 | | {track_number} | The track_number 114 | | {id} | The song id 115 | | {track_id} | The track id 116 | | {ext} | The file extension 117 | | {album_id} | (only when downloading albums) ID of the album 118 | | {album_num} | (only when downloading albums) Incrementing track number 119 | | {playlist} | (only when downloading playlists) Name of the playlist 120 | | {playlist_num} | (only when downloading playlists) Incrementing track number 121 | 122 | Example values could be: 123 | ~~~~ 124 | {playlist}/{artist} - {song_name}.{ext} 125 | {playlist}/{playlist_num} - {artist} - {song_name}.{ext} 126 | {artist} - {song_name}.{ext} 127 | {artist}/{album}/{album_num} - {artist} - {song_name}.{ext} 128 | ~~~~ 129 | 130 | ### Docker Usage 131 | ``` 132 | Build the docker image from the Dockerfile: 133 | docker build -t zotify . 134 | Create and run a container from the image: 135 | docker run --rm -v "$PWD/Zotify Music:/root/Music/Zotify Music" -v "$PWD/Zotify Podcasts:/root/Music/Zotify Podcasts" -it zotify 136 | ``` 137 | 138 | ### What do I do if I see "Your session has been terminated"? 139 | 140 | 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. 141 | 142 | 143 | ### Will my account get banned if I use this tool? 144 | 145 | Currently no user has reported their account getting banned after using Zotify. 146 | 147 | It is recommended you use Zotify with a burner account. 148 | 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. 149 | 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. 150 | 151 | ### Disclaimer 152 | Zotify is intended to be used in compliance with DMCA, Section 1201, for educational, private and fair use. \ 153 | Zotify contributors are not responsible for any misuse of the program or source code. 154 | 155 | ### Contributing 156 | 157 | Please refer to [CONTRIBUTING](CONTRIBUTING.md) 158 | 159 | ### Changelog 160 | 161 | Please refer to [CHANGELOG](CHANGELOG.md) 162 | -------------------------------------------------------------------------------- /zotify/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import math 3 | import os 4 | import platform 5 | import re 6 | import subprocess 7 | from enum import Enum 8 | from pathlib import Path, PurePath 9 | from typing import List, Tuple 10 | 11 | import music_tag 12 | import requests 13 | 14 | from zotify.const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \ 15 | WINDOWS_SYSTEM, ALBUMARTIST 16 | from zotify.zotify import Zotify 17 | 18 | 19 | class MusicFormat(str, Enum): 20 | MP3 = 'mp3', 21 | OGG = 'ogg', 22 | 23 | 24 | def create_download_directory(download_path: str) -> None: 25 | """ Create directory and add a hidden file with song ids """ 26 | Path(download_path).mkdir(parents=True, exist_ok=True) 27 | 28 | # add hidden file with song ids 29 | hidden_file_path = PurePath(download_path).joinpath('.song_ids') 30 | if not Path(hidden_file_path).is_file(): 31 | with open(hidden_file_path, 'w', encoding='utf-8') as f: 32 | pass 33 | 34 | 35 | def get_previously_downloaded() -> List[str]: 36 | """ Returns list of all time downloaded songs """ 37 | 38 | ids = [] 39 | archive_path = Zotify.CONFIG.get_song_archive() 40 | 41 | if Path(archive_path).exists(): 42 | with open(archive_path, 'r', encoding='utf-8') as f: 43 | ids = [line.strip().split('\t')[0] for line in f.readlines()] 44 | 45 | return ids 46 | 47 | 48 | def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str) -> None: 49 | """ Adds song id to all time installed songs archive """ 50 | 51 | archive_path = Zotify.CONFIG.get_song_archive() 52 | 53 | if Path(archive_path).exists(): 54 | with open(archive_path, 'a', encoding='utf-8') as file: 55 | 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') 56 | else: 57 | with open(archive_path, 'w', encoding='utf-8') as file: 58 | 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') 59 | 60 | 61 | def get_directory_song_ids(download_path: str) -> List[str]: 62 | """ Gets song ids of songs in directory """ 63 | 64 | song_ids = [] 65 | 66 | hidden_file_path = PurePath(download_path).joinpath('.song_ids') 67 | if Path(hidden_file_path).is_file(): 68 | with open(hidden_file_path, 'r', encoding='utf-8') as file: 69 | song_ids.extend([line.strip().split('\t')[0] for line in file.readlines()]) 70 | 71 | return song_ids 72 | 73 | 74 | def add_to_directory_song_ids(download_path: str, song_id: str, filename: str, author_name: str, song_name: str) -> None: 75 | """ Appends song_id to .song_ids file in directory """ 76 | 77 | hidden_file_path = PurePath(download_path).joinpath('.song_ids') 78 | # not checking if file exists because we need an exception 79 | # to be raised if something is wrong 80 | with open(hidden_file_path, 'a', encoding='utf-8') as file: 81 | 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') 82 | 83 | 84 | def get_downloaded_song_duration(filename: str) -> float: 85 | """ Returns the downloaded file's duration in seconds """ 86 | 87 | command = ['ffprobe', '-show_entries', 'format=duration', '-i', f'{filename}'] 88 | output = subprocess.run(command, capture_output=True) 89 | 90 | duration = re.search(r'[\D]=([\d\.]*)', str(output.stdout)).groups()[0] 91 | duration = float(duration) 92 | 93 | return duration 94 | 95 | 96 | def split_input(selection) -> List[str]: 97 | """ Returns a list of inputted strings """ 98 | inputs = [] 99 | if '-' in selection: 100 | for number in range(int(selection.split('-')[0]), int(selection.split('-')[1]) + 1): 101 | inputs.append(number) 102 | else: 103 | selections = selection.split(',') 104 | for i in selections: 105 | inputs.append(i.strip()) 106 | return inputs 107 | 108 | 109 | def splash() -> str: 110 | """ Displays splash screen """ 111 | return """ 112 | ███████╗ ██████╗ ████████╗██╗███████╗██╗ ██╗ 113 | ╚══███╔╝██╔═══██╗╚══██╔══╝██║██╔════╝╚██╗ ██╔╝ 114 | ███╔╝ ██║ ██║ ██║ ██║█████╗ ╚████╔╝ 115 | ███╔╝ ██║ ██║ ██║ ██║██╔══╝ ╚██╔╝ 116 | ███████╗╚██████╔╝ ██║ ██║██║ ██║ 117 | ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ 118 | """ 119 | 120 | 121 | def clear() -> None: 122 | """ Clear the console window """ 123 | if platform.system() == WINDOWS_SYSTEM: 124 | os.system('cls') 125 | else: 126 | os.system('clear') 127 | 128 | 129 | def set_audio_tags(filename, artists, genres, name, album_name, release_year, disc_number, track_number) -> None: 130 | """ sets music_tag metadata """ 131 | tags = music_tag.load_file(filename) 132 | tags[ALBUMARTIST] = artists[0] 133 | tags[ARTIST] = conv_artist_format(artists) 134 | tags[GENRE] = genres[0] if not Zotify.CONFIG.get_all_genres() else Zotify.CONFIG.get_all_genres_delimiter().join(genres) 135 | tags[TRACKTITLE] = name 136 | tags[ALBUM] = album_name 137 | tags[YEAR] = release_year 138 | tags[DISCNUMBER] = disc_number 139 | tags[TRACKNUMBER] = track_number 140 | tags.save() 141 | 142 | 143 | def conv_artist_format(artists) -> str: 144 | """ Returns converted artist format """ 145 | return ', '.join(artists) 146 | 147 | 148 | def set_music_thumbnail(filename, image_url) -> None: 149 | """ Downloads cover artwork """ 150 | img = requests.get(image_url).content 151 | tags = music_tag.load_file(filename) 152 | tags[ARTWORK] = img 153 | tags.save() 154 | 155 | 156 | def regex_input_for_urls(search_input) -> Tuple[str, str, str, str, str, str]: 157 | """ Since many kinds of search may be passed at the command line, process them all here. """ 158 | track_uri_search = re.search( 159 | r'^spotify:track:(?P[0-9a-zA-Z]{22})$', search_input) 160 | track_url_search = re.search( 161 | r'^(https?://)?open\.spotify\.com/track/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', 162 | search_input, 163 | ) 164 | 165 | album_uri_search = re.search( 166 | r'^spotify:album:(?P[0-9a-zA-Z]{22})$', search_input) 167 | album_url_search = re.search( 168 | r'^(https?://)?open\.spotify\.com/album/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', 169 | search_input, 170 | ) 171 | 172 | playlist_uri_search = re.search( 173 | r'^spotify:playlist:(?P[0-9a-zA-Z]{22})$', search_input) 174 | playlist_url_search = re.search( 175 | r'^(https?://)?open\.spotify\.com/playlist/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', 176 | search_input, 177 | ) 178 | 179 | episode_uri_search = re.search( 180 | r'^spotify:episode:(?P[0-9a-zA-Z]{22})$', search_input) 181 | episode_url_search = re.search( 182 | r'^(https?://)?open\.spotify\.com/episode/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', 183 | search_input, 184 | ) 185 | 186 | show_uri_search = re.search( 187 | r'^spotify:show:(?P[0-9a-zA-Z]{22})$', search_input) 188 | show_url_search = re.search( 189 | r'^(https?://)?open\.spotify\.com/show/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', 190 | search_input, 191 | ) 192 | 193 | artist_uri_search = re.search( 194 | r'^spotify:artist:(?P[0-9a-zA-Z]{22})$', search_input) 195 | artist_url_search = re.search( 196 | r'^(https?://)?open\.spotify\.com/artist/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', 197 | search_input, 198 | ) 199 | 200 | if track_uri_search is not None or track_url_search is not None: 201 | track_id_str = (track_uri_search 202 | if track_uri_search is not None else 203 | track_url_search).group('TrackID') 204 | else: 205 | track_id_str = None 206 | 207 | if album_uri_search is not None or album_url_search is not None: 208 | album_id_str = (album_uri_search 209 | if album_uri_search is not None else 210 | album_url_search).group('AlbumID') 211 | else: 212 | album_id_str = None 213 | 214 | if playlist_uri_search is not None or playlist_url_search is not None: 215 | playlist_id_str = (playlist_uri_search 216 | if playlist_uri_search is not None else 217 | playlist_url_search).group('PlaylistID') 218 | else: 219 | playlist_id_str = None 220 | 221 | if episode_uri_search is not None or episode_url_search is not None: 222 | episode_id_str = (episode_uri_search 223 | if episode_uri_search is not None else 224 | episode_url_search).group('EpisodeID') 225 | else: 226 | episode_id_str = None 227 | 228 | if show_uri_search is not None or show_url_search is not None: 229 | show_id_str = (show_uri_search 230 | if show_uri_search is not None else 231 | show_url_search).group('ShowID') 232 | else: 233 | show_id_str = None 234 | 235 | if artist_uri_search is not None or artist_url_search is not None: 236 | artist_id_str = (artist_uri_search 237 | if artist_uri_search is not None else 238 | artist_url_search).group('ArtistID') 239 | else: 240 | artist_id_str = None 241 | 242 | return track_id_str, album_id_str, playlist_id_str, episode_id_str, show_id_str, artist_id_str 243 | 244 | 245 | def fix_filename(name): 246 | """ 247 | Replace invalid characters on Linux/Windows/MacOS with underscores. 248 | List from https://stackoverflow.com/a/31976060/819417 249 | Trailing spaces & periods are ignored on Windows. 250 | >>> fix_filename(" COM1 ") 251 | '_ COM1 _' 252 | >>> fix_filename("COM10") 253 | 'COM10' 254 | >>> fix_filename("COM1,") 255 | 'COM1,' 256 | >>> fix_filename("COM1.txt") 257 | '_.txt' 258 | >>> all('_' == fix_filename(chr(i)) for i in list(range(32))) 259 | True 260 | """ 261 | return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE) 262 | 263 | 264 | def fmt_seconds(secs: float) -> str: 265 | val = math.floor(secs) 266 | 267 | s = math.floor(val % 60) 268 | val -= s 269 | val /= 60 270 | 271 | m = math.floor(val % 60) 272 | val -= m 273 | val /= 60 274 | 275 | h = math.floor(val) 276 | 277 | if h == 0 and m == 0 and s == 0: 278 | return "0s" 279 | elif h == 0 and m == 0: 280 | return f'{s}s'.zfill(2) 281 | elif h == 0: 282 | return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2) 283 | else: 284 | return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2) 285 | -------------------------------------------------------------------------------- /zotify/app.py: -------------------------------------------------------------------------------- 1 | from librespot.audio.decoders import AudioQuality 2 | from tabulate import tabulate 3 | from pathlib import Path 4 | 5 | from zotify.album import download_album, download_artist_albums 6 | from zotify.const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \ 7 | OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME, TYPE 8 | from zotify.loader import Loader 9 | from zotify.playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist 10 | from zotify.podcast import download_episode, get_show_episodes 11 | from zotify.termoutput import Printer, PrintChannel 12 | from zotify.track import download_track, get_saved_tracks, get_followed_artists 13 | from zotify.utils import splash, split_input, regex_input_for_urls 14 | from zotify.zotify import Zotify 15 | 16 | SEARCH_URL = 'https://api.spotify.com/v1/search' 17 | 18 | 19 | def client(args) -> None: 20 | """ Connects to download server to perform query's and get songs to download """ 21 | Zotify(args) 22 | 23 | Printer.print(PrintChannel.SPLASH, splash()) 24 | 25 | quality_options = { 26 | 'auto': AudioQuality.VERY_HIGH if Zotify.check_premium() else AudioQuality.HIGH, 27 | 'normal': AudioQuality.NORMAL, 28 | 'high': AudioQuality.HIGH, 29 | 'very_high': AudioQuality.VERY_HIGH 30 | } 31 | Zotify.DOWNLOAD_QUALITY = quality_options[Zotify.CONFIG.get_download_quality()] 32 | 33 | if args.download: 34 | urls = [] 35 | filename = args.download 36 | if Path(filename).exists(): 37 | with open(filename, 'r', encoding='utf-8') as file: 38 | urls.extend([line.strip() for line in file.readlines()]) 39 | 40 | download_from_urls(urls) 41 | 42 | else: 43 | Printer.print(PrintChannel.ERRORS, f'File {filename} not found.\n') 44 | return 45 | 46 | if args.urls: 47 | if len(args.urls) > 0: 48 | download_from_urls(args.urls) 49 | return 50 | 51 | if args.playlist: 52 | download_from_user_playlist() 53 | return 54 | 55 | if args.liked_songs: 56 | for song in get_saved_tracks(): 57 | if not song[TRACK][NAME] or not song[TRACK][ID]: 58 | Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n") 59 | else: 60 | download_track('liked', song[TRACK][ID]) 61 | return 62 | 63 | if args.followed_artists: 64 | for artist in get_followed_artists(): 65 | download_artist_albums(artist) 66 | return 67 | 68 | if args.search: 69 | if args.search == ' ': 70 | search_text = '' 71 | while len(search_text) == 0: 72 | search_text = input('Enter search: ') 73 | search(search_text) 74 | else: 75 | if not download_from_urls([args.search]): 76 | search(args.search) 77 | return 78 | 79 | else: 80 | search_text = '' 81 | while len(search_text) == 0: 82 | search_text = input('Enter search: ') 83 | search(search_text) 84 | 85 | def download_from_urls(urls: list[str]) -> bool: 86 | """ Downloads from a list of urls """ 87 | download = False 88 | 89 | for spotify_url in urls: 90 | track_id, album_id, playlist_id, episode_id, show_id, artist_id = regex_input_for_urls(spotify_url) 91 | 92 | if track_id is not None: 93 | download = True 94 | download_track('single', track_id) 95 | elif artist_id is not None: 96 | download = True 97 | download_artist_albums(artist_id) 98 | elif album_id is not None: 99 | download = True 100 | download_album(album_id) 101 | elif playlist_id is not None: 102 | download = True 103 | playlist_songs = get_playlist_songs(playlist_id) 104 | name, _ = get_playlist_info(playlist_id) 105 | enum = 1 106 | char_num = len(str(len(playlist_songs))) 107 | for song in playlist_songs: 108 | if not song[TRACK][NAME] or not song[TRACK][ID]: 109 | Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n") 110 | else: 111 | if song[TRACK][TYPE] == "episode": # Playlist item is a podcast episode 112 | download_episode(song[TRACK][ID]) 113 | else: 114 | download_track('playlist', song[TRACK][ID], extra_keys= 115 | { 116 | 'playlist_song_name': song[TRACK][NAME], 117 | 'playlist': name, 118 | 'playlist_num': str(enum).zfill(char_num), 119 | 'playlist_id': playlist_id, 120 | 'playlist_track_id': song[TRACK][ID] 121 | }) 122 | enum += 1 123 | elif episode_id is not None: 124 | download = True 125 | download_episode(episode_id) 126 | elif show_id is not None: 127 | download = True 128 | for episode in get_show_episodes(show_id): 129 | download_episode(episode) 130 | 131 | return download 132 | 133 | 134 | def search(search_term): 135 | """ Searches download server's API for relevant data """ 136 | params = {'limit': '10', 137 | 'offset': '0', 138 | 'q': search_term, 139 | 'type': 'track,album,artist,playlist'} 140 | 141 | # Parse args 142 | splits = search_term.split() 143 | for split in splits: 144 | index = splits.index(split) 145 | 146 | if split[0] == '-' and len(split) > 1: 147 | if len(splits)-1 == index: 148 | raise IndexError('No parameters passed after option: {}\n'. 149 | format(split)) 150 | 151 | if split == '-l' or split == '-limit': 152 | try: 153 | int(splits[index+1]) 154 | except ValueError: 155 | raise ValueError('Parameter passed after {} option must be an integer.\n'. 156 | format(split)) 157 | if int(splits[index+1]) > 50: 158 | raise ValueError('Invalid limit passed. Max is 50.\n') 159 | params['limit'] = splits[index+1] 160 | 161 | if split == '-t' or split == '-type': 162 | 163 | allowed_types = ['track', 'playlist', 'album', 'artist'] 164 | passed_types = [] 165 | for i in range(index+1, len(splits)): 166 | if splits[i][0] == '-': 167 | break 168 | 169 | if splits[i] not in allowed_types: 170 | raise ValueError('Parameters passed after {} option must be from this list:\n{}'. 171 | format(split, '\n'.join(allowed_types))) 172 | 173 | passed_types.append(splits[i]) 174 | params['type'] = ','.join(passed_types) 175 | 176 | if len(params['type']) == 0: 177 | params['type'] = 'track,album,artist,playlist' 178 | 179 | # Clean search term 180 | search_term_list = [] 181 | for split in splits: 182 | if split[0] == "-": 183 | break 184 | search_term_list.append(split) 185 | if not search_term_list: 186 | raise ValueError("Invalid query.") 187 | params["q"] = ' '.join(search_term_list) 188 | 189 | resp = Zotify.invoke_url_with_params(SEARCH_URL, **params) 190 | 191 | counter = 1 192 | dics = [] 193 | 194 | total_tracks = 0 195 | if TRACK in params['type'].split(','): 196 | tracks = resp[TRACKS][ITEMS] 197 | if len(tracks) > 0: 198 | print('### TRACKS ###') 199 | track_data = [] 200 | for track in tracks: 201 | if track[EXPLICIT]: 202 | explicit = '[E]' 203 | else: 204 | explicit = '' 205 | 206 | track_data.append([counter, f'{track[NAME]} {explicit}', 207 | ','.join([artist[NAME] for artist in track[ARTISTS]])]) 208 | dics.append({ 209 | ID: track[ID], 210 | NAME: track[NAME], 211 | 'type': TRACK, 212 | }) 213 | 214 | counter += 1 215 | total_tracks = counter - 1 216 | print(tabulate(track_data, headers=[ 217 | 'S.NO', 'Name', 'Artists'], tablefmt='pretty')) 218 | print('\n') 219 | del tracks 220 | del track_data 221 | 222 | total_albums = 0 223 | if ALBUM in params['type'].split(','): 224 | albums = resp[ALBUMS][ITEMS] 225 | if len(albums) > 0: 226 | print('### ALBUMS ###') 227 | album_data = [] 228 | for album in albums: 229 | album_data.append([counter, album[NAME], 230 | ','.join([artist[NAME] for artist in album[ARTISTS]])]) 231 | dics.append({ 232 | ID: album[ID], 233 | NAME: album[NAME], 234 | 'type': ALBUM, 235 | }) 236 | 237 | counter += 1 238 | total_albums = counter - total_tracks - 1 239 | print(tabulate(album_data, headers=[ 240 | 'S.NO', 'Album', 'Artists'], tablefmt='pretty')) 241 | print('\n') 242 | del albums 243 | del album_data 244 | 245 | total_artists = 0 246 | if ARTIST in params['type'].split(','): 247 | artists = resp[ARTISTS][ITEMS] 248 | if len(artists) > 0: 249 | print('### ARTISTS ###') 250 | artist_data = [] 251 | for artist in artists: 252 | artist_data.append([counter, artist[NAME]]) 253 | dics.append({ 254 | ID: artist[ID], 255 | NAME: artist[NAME], 256 | 'type': ARTIST, 257 | }) 258 | counter += 1 259 | total_artists = counter - total_tracks - total_albums - 1 260 | print(tabulate(artist_data, headers=[ 261 | 'S.NO', 'Name'], tablefmt='pretty')) 262 | print('\n') 263 | del artists 264 | del artist_data 265 | 266 | total_playlists = 0 267 | if PLAYLIST in params['type'].split(','): 268 | playlists = resp[PLAYLISTS][ITEMS] 269 | if len(playlists) > 0: 270 | print('### PLAYLISTS ###') 271 | playlist_data = [] 272 | for playlist in playlists: 273 | playlist_data.append( 274 | [counter, playlist[NAME], playlist[OWNER][DISPLAY_NAME]]) 275 | dics.append({ 276 | ID: playlist[ID], 277 | NAME: playlist[NAME], 278 | 'type': PLAYLIST, 279 | }) 280 | counter += 1 281 | total_playlists = counter - total_artists - total_tracks - total_albums - 1 282 | print(tabulate(playlist_data, headers=[ 283 | 'S.NO', 'Name', 'Owner'], tablefmt='pretty')) 284 | print('\n') 285 | del playlists 286 | del playlist_data 287 | 288 | if total_tracks + total_albums + total_artists + total_playlists == 0: 289 | print('NO RESULTS FOUND - EXITING...') 290 | else: 291 | selection = '' 292 | print('> SELECT A DOWNLOAD OPTION BY ID') 293 | print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s') 294 | print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s\n') 295 | while len(selection) == 0: 296 | selection = str(input('ID(s): ')) 297 | inputs = split_input(selection) 298 | for pos in inputs: 299 | position = int(pos) 300 | for dic in dics: 301 | print_pos = dics.index(dic) + 1 302 | if print_pos == position: 303 | if dic['type'] == TRACK: 304 | download_track('single', dic[ID]) 305 | elif dic['type'] == ALBUM: 306 | download_album(dic[ID]) 307 | elif dic['type'] == ARTIST: 308 | download_artist_albums(dic[ID]) 309 | else: 310 | download_playlist(dic) 311 | -------------------------------------------------------------------------------- /zotify/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from pathlib import Path, PurePath 4 | from typing import Any 5 | 6 | 7 | ROOT_PATH = 'ROOT_PATH' 8 | ROOT_PODCAST_PATH = 'ROOT_PODCAST_PATH' 9 | SKIP_EXISTING = 'SKIP_EXISTING' 10 | SKIP_PREVIOUSLY_DOWNLOADED = 'SKIP_PREVIOUSLY_DOWNLOADED' 11 | DOWNLOAD_FORMAT = 'DOWNLOAD_FORMAT' 12 | BULK_WAIT_TIME = 'BULK_WAIT_TIME' 13 | OVERRIDE_AUTO_WAIT = 'OVERRIDE_AUTO_WAIT' 14 | CHUNK_SIZE = 'CHUNK_SIZE' 15 | SPLIT_ALBUM_DISCS = 'SPLIT_ALBUM_DISCS' 16 | DOWNLOAD_REAL_TIME = 'DOWNLOAD_REAL_TIME' 17 | LANGUAGE = 'LANGUAGE' 18 | DOWNLOAD_QUALITY = 'DOWNLOAD_QUALITY' 19 | TRANSCODE_BITRATE = 'TRANSCODE_BITRATE' 20 | SONG_ARCHIVE = 'SONG_ARCHIVE' 21 | SAVE_CREDENTIALS = 'SAVE_CREDENTIALS' 22 | CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION' 23 | OUTPUT = 'OUTPUT' 24 | PRINT_SPLASH = 'PRINT_SPLASH' 25 | PRINT_SKIPS = 'PRINT_SKIPS' 26 | PRINT_DOWNLOAD_PROGRESS = 'PRINT_DOWNLOAD_PROGRESS' 27 | PRINT_ERRORS = 'PRINT_ERRORS' 28 | PRINT_DOWNLOADS = 'PRINT_DOWNLOADS' 29 | PRINT_API_ERRORS = 'PRINT_API_ERRORS' 30 | TEMP_DOWNLOAD_DIR = 'TEMP_DOWNLOAD_DIR' 31 | MD_SAVE_GENRES = 'MD_SAVE_GENRES' 32 | MD_ALLGENRES = 'MD_ALLGENRES' 33 | MD_GENREDELIMITER = 'MD_GENREDELIMITER' 34 | PRINT_PROGRESS_INFO = 'PRINT_PROGRESS_INFO' 35 | PRINT_WARNINGS = 'PRINT_WARNINGS' 36 | RETRY_ATTEMPTS = 'RETRY_ATTEMPTS' 37 | CONFIG_VERSION = 'CONFIG_VERSION' 38 | DOWNLOAD_LYRICS = 'DOWNLOAD_LYRICS' 39 | 40 | CONFIG_VALUES = { 41 | SAVE_CREDENTIALS: { 'default': 'True', 'type': bool, 'arg': '--save-credentials' }, 42 | CREDENTIALS_LOCATION: { 'default': '', 'type': str, 'arg': '--credentials-location' }, 43 | OUTPUT: { 'default': '', 'type': str, 'arg': '--output' }, 44 | SONG_ARCHIVE: { 'default': '', 'type': str, 'arg': '--song-archive' }, 45 | ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' }, 46 | ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' }, 47 | SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' }, 48 | DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' }, 49 | MD_SAVE_GENRES: { 'default': 'False', 'type': bool, 'arg': '--md-save-genres' }, 50 | MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' }, 51 | MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' }, 52 | DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' }, 53 | DOWNLOAD_QUALITY: { 'default': 'auto', 'type': str, 'arg': '--download-quality' }, 54 | TRANSCODE_BITRATE: { 'default': 'auto', 'type': str, 'arg': '--transcode-bitrate' }, 55 | SKIP_EXISTING: { 'default': 'True', 'type': bool, 'arg': '--skip-existing' }, 56 | SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' }, 57 | RETRY_ATTEMPTS: { 'default': '1', 'type': int, 'arg': '--retry-attempts' }, 58 | BULK_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--bulk-wait-time' }, 59 | OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' }, 60 | CHUNK_SIZE: { 'default': '20000', 'type': int, 'arg': '--chunk-size' }, 61 | DOWNLOAD_REAL_TIME: { 'default': 'False', 'type': bool, 'arg': '--download-real-time' }, 62 | LANGUAGE: { 'default': 'en', 'type': str, 'arg': '--language' }, 63 | PRINT_SPLASH: { 'default': 'False', 'type': bool, 'arg': '--print-splash' }, 64 | PRINT_SKIPS: { 'default': 'True', 'type': bool, 'arg': '--print-skips' }, 65 | PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' }, 66 | PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' }, 67 | PRINT_DOWNLOADS: { 'default': 'False', 'type': bool, 'arg': '--print-downloads' }, 68 | PRINT_API_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-api-errors' }, 69 | PRINT_PROGRESS_INFO: { 'default': 'True', 'type': bool, 'arg': '--print-progress-info' }, 70 | PRINT_WARNINGS: { 'default': 'True', 'type': bool, 'arg': '--print-warnings' }, 71 | TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' } 72 | } 73 | 74 | OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}' 75 | OUTPUT_DEFAULT_PLAYLIST_EXT = '{playlist}/{playlist_num} - {artist} - {song_name}.{ext}' 76 | OUTPUT_DEFAULT_LIKED_SONGS = 'Liked Songs/{artist} - {song_name}.{ext}' 77 | OUTPUT_DEFAULT_SINGLE = '{artist}/{album}/{artist} - {song_name}.{ext}' 78 | OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}' 79 | 80 | 81 | class Config: 82 | Values = {} 83 | 84 | @classmethod 85 | def load(cls, args) -> None: 86 | system_paths = { 87 | 'win32': Path.home() / 'AppData/Roaming/Zotify', 88 | 'linux': Path.home() / '.config/zotify', 89 | 'darwin': Path.home() / 'Library/Application Support/Zotify' 90 | } 91 | if sys.platform not in system_paths: 92 | config_fp = Path.cwd() / '.zotify/config.json' 93 | else: 94 | config_fp = system_paths[sys.platform] / 'config.json' 95 | if args.config_location: 96 | config_fp = args.config_location 97 | 98 | true_config_file_path = Path(config_fp).expanduser() 99 | 100 | # Load config from zconfig.json 101 | Path(PurePath(true_config_file_path).parent).mkdir(parents=True, exist_ok=True) 102 | if not Path(true_config_file_path).exists(): 103 | with open(true_config_file_path, 'w', encoding='utf-8') as config_file: 104 | json.dump(cls.get_default_json(), config_file, indent=4) 105 | with open(true_config_file_path, encoding='utf-8') as config_file: 106 | jsonvalues = json.load(config_file) 107 | cls.Values = {} 108 | for key in CONFIG_VALUES: 109 | if key in jsonvalues: 110 | cls.Values[key] = cls.parse_arg_value(key, jsonvalues[key]) 111 | 112 | # Add default values for missing keys 113 | 114 | for key in CONFIG_VALUES: 115 | if key not in cls.Values: 116 | cls.Values[key] = cls.parse_arg_value(key, CONFIG_VALUES[key]['default']) 117 | 118 | # Override config from commandline arguments 119 | 120 | for key in CONFIG_VALUES: 121 | if key.lower() in vars(args) and vars(args)[key.lower()] is not None: 122 | cls.Values[key] = cls.parse_arg_value(key, vars(args)[key.lower()]) 123 | 124 | if args.no_splash: 125 | cls.Values[PRINT_SPLASH] = False 126 | 127 | @classmethod 128 | def get_default_json(cls) -> Any: 129 | r = {} 130 | for key in CONFIG_VALUES: 131 | r[key] = CONFIG_VALUES[key]['default'] 132 | return r 133 | 134 | @classmethod 135 | def parse_arg_value(cls, key: str, value: Any) -> Any: 136 | if type(value) == CONFIG_VALUES[key]['type']: 137 | return value 138 | if CONFIG_VALUES[key]['type'] == str: 139 | return str(value) 140 | if CONFIG_VALUES[key]['type'] == int: 141 | return int(value) 142 | if CONFIG_VALUES[key]['type'] == bool: 143 | if str(value).lower() in ['yes', 'true', '1']: 144 | return True 145 | if str(value).lower() in ['no', 'false', '0']: 146 | return False 147 | raise ValueError("Not a boolean: " + value) 148 | raise ValueError("Unknown Type: " + value) 149 | 150 | @classmethod 151 | def get(cls, key: str) -> Any: 152 | return cls.Values.get(key) 153 | 154 | @classmethod 155 | def get_root_path(cls) -> str: 156 | if cls.get(ROOT_PATH) == '': 157 | root_path = PurePath(Path.home() / 'Music/Zotify Music/') 158 | else: 159 | root_path = PurePath(Path(cls.get(ROOT_PATH)).expanduser()) 160 | Path(root_path).mkdir(parents=True, exist_ok=True) 161 | return root_path 162 | 163 | @classmethod 164 | def get_root_podcast_path(cls) -> str: 165 | if cls.get(ROOT_PODCAST_PATH) == '': 166 | root_podcast_path = PurePath(Path.home() / 'Music/Zotify Podcasts/') 167 | else: 168 | root_podcast_path = PurePath(Path(cls.get(ROOT_PODCAST_PATH)).expanduser()) 169 | Path(root_podcast_path).mkdir(parents=True, exist_ok=True) 170 | return root_podcast_path 171 | 172 | @classmethod 173 | def get_skip_existing(cls) -> bool: 174 | return cls.get(SKIP_EXISTING) 175 | 176 | @classmethod 177 | def get_skip_previously_downloaded(cls) -> bool: 178 | return cls.get(SKIP_PREVIOUSLY_DOWNLOADED) 179 | 180 | @classmethod 181 | def get_split_album_discs(cls) -> bool: 182 | return cls.get(SPLIT_ALBUM_DISCS) 183 | 184 | @classmethod 185 | def get_chunk_size(cls) -> int: 186 | return cls.get(CHUNK_SIZE) 187 | 188 | @classmethod 189 | def get_override_auto_wait(cls) -> bool: 190 | return cls.get(OVERRIDE_AUTO_WAIT) 191 | 192 | @classmethod 193 | def get_download_format(cls) -> str: 194 | return cls.get(DOWNLOAD_FORMAT) 195 | 196 | @classmethod 197 | def get_download_lyrics(cls) -> bool: 198 | return cls.get(DOWNLOAD_LYRICS) 199 | 200 | @classmethod 201 | def get_bulk_wait_time(cls) -> int: 202 | return cls.get(BULK_WAIT_TIME) 203 | 204 | @classmethod 205 | def get_language(cls) -> str: 206 | return cls.get(LANGUAGE) 207 | 208 | @classmethod 209 | def get_download_real_time(cls) -> bool: 210 | return cls.get(DOWNLOAD_REAL_TIME) 211 | 212 | @classmethod 213 | def get_download_quality(cls) -> str: 214 | return cls.get(DOWNLOAD_QUALITY) 215 | 216 | @classmethod 217 | def get_transcode_bitrate(cls) -> str: 218 | return cls.get(TRANSCODE_BITRATE) 219 | 220 | @classmethod 221 | def get_song_archive(cls) -> str: 222 | if cls.get(SONG_ARCHIVE) == '': 223 | system_paths = { 224 | 'win32': Path.home() / 'AppData/Roaming/Zotify', 225 | 'linux': Path.home() / '.local/share/zotify', 226 | 'darwin': Path.home() / 'Library/Application Support/Zotify' 227 | } 228 | if sys.platform not in system_paths: 229 | song_archive = PurePath(Path.cwd() / '.zotify/.song_archive') 230 | else: 231 | song_archive = PurePath(system_paths[sys.platform] / '.song_archive') 232 | else: 233 | song_archive = PurePath(Path(cls.get(SONG_ARCHIVE)).expanduser()) 234 | Path(song_archive.parent).mkdir(parents=True, exist_ok=True) 235 | return song_archive 236 | 237 | @classmethod 238 | def get_save_credentials(cls) -> bool: 239 | return cls.get(SAVE_CREDENTIALS) 240 | 241 | @classmethod 242 | def get_credentials_location(cls) -> str: 243 | if cls.get(CREDENTIALS_LOCATION) == '': 244 | system_paths = { 245 | 'win32': Path.home() / 'AppData/Roaming/Zotify', 246 | 'linux': Path.home() / '.local/share/zotify', 247 | 'darwin': Path.home() / 'Library/Application Support/Zotify' 248 | } 249 | if sys.platform not in system_paths: 250 | credentials_location = PurePath(Path.cwd() / '.zotify/credentials.json') 251 | else: 252 | credentials_location = PurePath(system_paths[sys.platform] / 'credentials.json') 253 | else: 254 | credentials_location = PurePath(Path.cwd()).joinpath(cls.get(CREDENTIALS_LOCATION)) 255 | Path(credentials_location.parent).mkdir(parents=True, exist_ok=True) 256 | return credentials_location 257 | 258 | @classmethod 259 | def get_temp_download_dir(cls) -> str: 260 | if cls.get(TEMP_DOWNLOAD_DIR) == '': 261 | return '' 262 | return PurePath(cls.get_root_path()).joinpath(cls.get(TEMP_DOWNLOAD_DIR)) 263 | 264 | @classmethod 265 | def get_save_genres(cls) -> bool: 266 | return cls.get(MD_SAVE_GENRES) 267 | 268 | @classmethod 269 | def get_all_genres(cls) -> bool: 270 | return cls.get(MD_ALLGENRES) 271 | 272 | @classmethod 273 | def get_all_genres_delimiter(cls) -> bool: 274 | return cls.get(MD_GENREDELIMITER) 275 | 276 | @classmethod 277 | def get_output(cls, mode: str) -> str: 278 | v = cls.get(OUTPUT) 279 | if v: 280 | return v 281 | if mode == 'playlist': 282 | if cls.get_split_album_discs(): 283 | split = PurePath(OUTPUT_DEFAULT_PLAYLIST).parent 284 | return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) 285 | return OUTPUT_DEFAULT_PLAYLIST 286 | if mode == 'extplaylist': 287 | if cls.get_split_album_discs(): 288 | split = PurePath(OUTPUT_DEFAULT_PLAYLIST_EXT).parent 289 | return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) 290 | return OUTPUT_DEFAULT_PLAYLIST_EXT 291 | if mode == 'liked': 292 | if cls.get_split_album_discs(): 293 | split = PurePath(OUTPUT_DEFAULT_LIKED_SONGS).parent 294 | return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) 295 | return OUTPUT_DEFAULT_LIKED_SONGS 296 | if mode == 'single': 297 | if cls.get_split_album_discs(): 298 | split = PurePath(OUTPUT_DEFAULT_SINGLE).parent 299 | return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) 300 | return OUTPUT_DEFAULT_SINGLE 301 | if mode == 'album': 302 | if cls.get_split_album_discs(): 303 | split = PurePath(OUTPUT_DEFAULT_ALBUM).parent 304 | return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) 305 | return OUTPUT_DEFAULT_ALBUM 306 | raise ValueError() 307 | 308 | @classmethod 309 | def get_retry_attempts(cls) -> int: 310 | return cls.get(RETRY_ATTEMPTS) 311 | -------------------------------------------------------------------------------- /zotify/track.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path, PurePath 2 | import math 3 | import re 4 | import time 5 | import uuid 6 | from typing import Any, Tuple, List 7 | 8 | from librespot.metadata import TrackId 9 | import ffmpy 10 | 11 | from zotify.const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \ 12 | RELEASE_DATE, ID, TRACKS_URL, FOLLOWED_ARTISTS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS, \ 13 | HREF, ARTISTS, WIDTH 14 | from zotify.termoutput import Printer, PrintChannel 15 | from zotify.utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \ 16 | get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive, fmt_seconds 17 | from zotify.zotify import Zotify 18 | import traceback 19 | from zotify.loader import Loader 20 | 21 | 22 | def get_saved_tracks() -> list: 23 | """ Returns user's saved tracks """ 24 | songs = [] 25 | offset = 0 26 | limit = 50 27 | 28 | while True: 29 | resp = Zotify.invoke_url_with_params( 30 | SAVED_TRACKS_URL, limit=limit, offset=offset) 31 | offset += limit 32 | songs.extend(resp[ITEMS]) 33 | if len(resp[ITEMS]) < limit: 34 | break 35 | 36 | return songs 37 | 38 | 39 | def get_followed_artists() -> list: 40 | """ Returns user's followed artists """ 41 | artists = [] 42 | resp = Zotify.invoke_url(FOLLOWED_ARTISTS_URL)[1] 43 | for artist in resp[ARTISTS][ITEMS]: 44 | artists.append(artist[ID]) 45 | 46 | return artists 47 | 48 | 49 | def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, Any, Any, Any, Any, int]: 50 | """ Retrieves metadata for downloaded songs """ 51 | with Loader(PrintChannel.PROGRESS_INFO, "Fetching track information..."): 52 | (raw, info) = Zotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token') 53 | 54 | if not TRACKS in info: 55 | raise ValueError(f'Invalid response from TRACKS_URL:\n{raw}') 56 | 57 | try: 58 | artists = [] 59 | for data in info[TRACKS][0][ARTISTS]: 60 | artists.append(data[NAME]) 61 | 62 | album_name = info[TRACKS][0][ALBUM][NAME] 63 | name = info[TRACKS][0][NAME] 64 | release_year = info[TRACKS][0][ALBUM][RELEASE_DATE].split('-')[0] 65 | disc_number = info[TRACKS][0][DISC_NUMBER] 66 | track_number = info[TRACKS][0][TRACK_NUMBER] 67 | scraped_song_id = info[TRACKS][0][ID] 68 | is_playable = info[TRACKS][0][IS_PLAYABLE] 69 | duration_ms = info[TRACKS][0][DURATION_MS] 70 | 71 | image = info[TRACKS][0][ALBUM][IMAGES][0] 72 | for i in info[TRACKS][0][ALBUM][IMAGES]: 73 | if i[WIDTH] > image[WIDTH]: 74 | image = i 75 | image_url = image[URL] 76 | 77 | return artists, info[TRACKS][0][ARTISTS], album_name, name, image_url, release_year, disc_number, track_number, scraped_song_id, is_playable, duration_ms 78 | except Exception as e: 79 | raise ValueError(f'Failed to parse TRACKS_URL response: {str(e)}\n{raw}') 80 | 81 | 82 | def get_song_genres(rawartists: List[str], track_name: str) -> List[str]: 83 | if Zotify.CONFIG.get_save_genres(): 84 | try: 85 | genres = [] 86 | for data in rawartists: 87 | # query artist genres via href, which will be the api url 88 | with Loader(PrintChannel.PROGRESS_INFO, "Fetching artist information..."): 89 | (raw, artistInfo) = Zotify.invoke_url(f'{data[HREF]}') 90 | if Zotify.CONFIG.get_all_genres() and len(artistInfo[GENRES]) > 0: 91 | for genre in artistInfo[GENRES]: 92 | genres.append(genre) 93 | elif len(artistInfo[GENRES]) > 0: 94 | genres.append(artistInfo[GENRES][0]) 95 | 96 | if len(genres) == 0: 97 | Printer.print(PrintChannel.WARNINGS, '### No Genres found for song ' + track_name) 98 | genres.append('') 99 | 100 | return genres 101 | except Exception as e: 102 | raise ValueError(f'Failed to parse GENRES response: {str(e)}\n{raw}') 103 | else: 104 | return [''] 105 | 106 | 107 | def get_song_lyrics(song_id: str, file_save: str) -> None: 108 | raw, lyrics = Zotify.invoke_url(f'https://spclient.wg.spotify.com/color-lyrics/v2/track/{song_id}') 109 | 110 | if lyrics: 111 | try: 112 | formatted_lyrics = lyrics['lyrics']['lines'] 113 | except KeyError: 114 | raise ValueError(f'Failed to fetch lyrics: {song_id}') 115 | if(lyrics['lyrics']['syncType'] == "UNSYNCED"): 116 | with open(file_save, 'w+', encoding='utf-8') as file: 117 | for line in formatted_lyrics: 118 | file.writelines(line['words'] + '\n') 119 | return 120 | elif(lyrics['lyrics']['syncType'] == "LINE_SYNCED"): 121 | with open(file_save, 'w+', encoding='utf-8') as file: 122 | for line in formatted_lyrics: 123 | timestamp = int(line['startTimeMs']) 124 | ts_minutes = str(math.floor(timestamp / 60000)).zfill(2) 125 | ts_seconds = str(math.floor((timestamp % 60000) / 1000)).zfill(2) 126 | ts_millis = str(math.floor(timestamp % 1000))[:2].zfill(2) 127 | file.writelines(f'[{ts_minutes}:{ts_seconds}.{ts_millis}]' + line['words'] + '\n') 128 | return 129 | raise ValueError(f'Failed to fetch lyrics: {song_id}') 130 | 131 | 132 | def get_song_duration(song_id: str) -> float: 133 | """ Retrieves duration of song in second as is on spotify """ 134 | 135 | (raw, resp) = Zotify.invoke_url(f'{TRACK_STATS_URL}{song_id}') 136 | 137 | # get duration in miliseconds 138 | ms_duration = resp['duration_ms'] 139 | # convert to seconds 140 | duration = float(ms_duration)/1000 141 | 142 | return duration 143 | 144 | 145 | def download_track(mode: str, track_id: str, extra_keys=None, disable_progressbar=False) -> None: 146 | """ Downloads raw song audio from Spotify """ 147 | 148 | if extra_keys is None: 149 | extra_keys = {} 150 | 151 | prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Preparing download...") 152 | prepare_download_loader.start() 153 | 154 | try: 155 | output_template = Zotify.CONFIG.get_output(mode) 156 | 157 | (artists, raw_artists, album_name, name, image_url, release_year, disc_number, 158 | track_number, scraped_song_id, is_playable, duration_ms) = get_song_info(track_id) 159 | 160 | song_name = fix_filename(artists[0]) + ' - ' + fix_filename(name) 161 | 162 | for k in extra_keys: 163 | output_template = output_template.replace("{"+k+"}", fix_filename(extra_keys[k])) 164 | 165 | ext = EXT_MAP.get(Zotify.CONFIG.get_download_format().lower()) 166 | 167 | output_template = output_template.replace("{artist}", fix_filename(artists[0])) 168 | output_template = output_template.replace("{album}", fix_filename(album_name)) 169 | output_template = output_template.replace("{song_name}", fix_filename(name)) 170 | output_template = output_template.replace("{release_year}", fix_filename(release_year)) 171 | output_template = output_template.replace("{disc_number}", fix_filename(disc_number)) 172 | output_template = output_template.replace("{track_number}", fix_filename(track_number)) 173 | output_template = output_template.replace("{id}", fix_filename(scraped_song_id)) 174 | output_template = output_template.replace("{track_id}", fix_filename(track_id)) 175 | output_template = output_template.replace("{ext}", ext) 176 | 177 | filename = PurePath(Zotify.CONFIG.get_root_path()).joinpath(output_template) 178 | filedir = PurePath(filename).parent 179 | 180 | filename_temp = filename 181 | if Zotify.CONFIG.get_temp_download_dir() != '': 182 | filename_temp = PurePath(Zotify.CONFIG.get_temp_download_dir()).joinpath(f'zotify_{str(uuid.uuid4())}_{track_id}.{ext}') 183 | 184 | check_name = Path(filename).is_file() and Path(filename).stat().st_size 185 | check_id = scraped_song_id in get_directory_song_ids(filedir) 186 | check_all_time = scraped_song_id in get_previously_downloaded() 187 | 188 | # a song with the same name is installed 189 | if not check_id and check_name: 190 | c = len([file for file in Path(filedir).iterdir() if re.search(f'^{filename}_', str(file))]) + 1 191 | 192 | fname = PurePath(PurePath(filename).name).parent 193 | ext = PurePath(PurePath(filename).name).suffix 194 | 195 | filename = PurePath(filedir).joinpath(f'{fname}_{c}{ext}') 196 | 197 | except Exception as e: 198 | Printer.print(PrintChannel.ERRORS, '### SKIPPING SONG - FAILED TO QUERY METADATA ###') 199 | Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id)) 200 | for k in extra_keys: 201 | Printer.print(PrintChannel.ERRORS, k + ': ' + str(extra_keys[k])) 202 | Printer.print(PrintChannel.ERRORS, "\n") 203 | Printer.print(PrintChannel.ERRORS, str(e) + "\n") 204 | Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") 205 | 206 | else: 207 | try: 208 | if not is_playable: 209 | prepare_download_loader.stop() 210 | Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG IS UNAVAILABLE) ###' + "\n") 211 | else: 212 | if check_id and check_name and Zotify.CONFIG.get_skip_existing(): 213 | prepare_download_loader.stop() 214 | Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS) ###' + "\n") 215 | 216 | elif check_all_time and Zotify.CONFIG.get_skip_previously_downloaded(): 217 | prepare_download_loader.stop() 218 | Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY DOWNLOADED ONCE) ###' + "\n") 219 | 220 | else: 221 | if track_id != scraped_song_id: 222 | track_id = scraped_song_id 223 | track = TrackId.from_base62(track_id) 224 | stream = Zotify.get_content_stream(track, Zotify.DOWNLOAD_QUALITY) 225 | create_download_directory(filedir) 226 | total_size = stream.input_stream.size 227 | 228 | prepare_download_loader.stop() 229 | 230 | time_start = time.time() 231 | downloaded = 0 232 | with open(filename_temp, 'wb') as file, Printer.progress( 233 | desc=song_name, 234 | total=total_size, 235 | unit='B', 236 | unit_scale=True, 237 | unit_divisor=1024, 238 | disable=disable_progressbar 239 | ) as p_bar: 240 | b = 0 241 | while b < 5: 242 | #for _ in range(int(total_size / Zotify.CONFIG.get_chunk_size()) + 2): 243 | data = stream.input_stream.stream().read(Zotify.CONFIG.get_chunk_size()) 244 | p_bar.update(file.write(data)) 245 | downloaded += len(data) 246 | b += 1 if data == b'' else 0 247 | if Zotify.CONFIG.get_download_real_time(): 248 | delta_real = time.time() - time_start 249 | delta_want = (downloaded / total_size) * (duration_ms/1000) 250 | if delta_want > delta_real: 251 | time.sleep(delta_want - delta_real) 252 | 253 | time_downloaded = time.time() 254 | 255 | genres = get_song_genres(raw_artists, name) 256 | 257 | if(Zotify.CONFIG.get_download_lyrics()): 258 | try: 259 | get_song_lyrics(track_id, PurePath(str(filename)[:-3] + "lrc")) 260 | except ValueError: 261 | Printer.print(PrintChannel.SKIPS, f"### Skipping lyrics for {song_name}: lyrics not available ###") 262 | convert_audio_format(filename_temp) 263 | try: 264 | set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number) 265 | set_music_thumbnail(filename_temp, image_url) 266 | except Exception: 267 | Printer.print(PrintChannel.ERRORS, "Unable to write metadata, ensure ffmpeg is installed and added to your PATH.") 268 | 269 | if filename_temp != filename: 270 | Path(filename_temp).rename(filename) 271 | 272 | time_finished = time.time() 273 | 274 | 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") 275 | 276 | # add song id to archive file 277 | if Zotify.CONFIG.get_skip_previously_downloaded(): 278 | add_to_archive(scraped_song_id, PurePath(filename).name, artists[0], name) 279 | # add song id to download directory's .song_ids file 280 | if not check_id: 281 | add_to_directory_song_ids(filedir, scraped_song_id, PurePath(filename).name, artists[0], name) 282 | 283 | if Zotify.CONFIG.get_bulk_wait_time(): 284 | time.sleep(Zotify.CONFIG.get_bulk_wait_time()) 285 | except Exception as e: 286 | Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###') 287 | Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id)) 288 | for k in extra_keys: 289 | Printer.print(PrintChannel.ERRORS, k + ': ' + str(extra_keys[k])) 290 | Printer.print(PrintChannel.ERRORS, "\n") 291 | Printer.print(PrintChannel.ERRORS, str(e) + "\n") 292 | Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") 293 | if Path(filename_temp).exists(): 294 | Path(filename_temp).unlink() 295 | 296 | prepare_download_loader.stop() 297 | 298 | 299 | def convert_audio_format(filename) -> None: 300 | """ Converts raw audio into playable file """ 301 | temp_filename = f'{PurePath(filename).parent}.tmp' 302 | Path(filename).replace(temp_filename) 303 | 304 | download_format = Zotify.CONFIG.get_download_format().lower() 305 | file_codec = CODEC_MAP.get(download_format, 'copy') 306 | if file_codec != 'copy': 307 | bitrate = Zotify.CONFIG.get_transcode_bitrate() 308 | bitrates = { 309 | 'auto': '320k' if Zotify.check_premium() else '160k', 310 | 'normal': '96k', 311 | 'high': '160k', 312 | 'very_high': '320k' 313 | } 314 | bitrate = bitrates[Zotify.CONFIG.get_download_quality()] 315 | else: 316 | bitrate = None 317 | 318 | output_params = ['-c:a', file_codec] 319 | if bitrate: 320 | output_params += ['-b:a', bitrate] 321 | 322 | try: 323 | ff_m = ffmpy.FFmpeg( 324 | global_options=['-y', '-hide_banner', '-loglevel error'], 325 | inputs={temp_filename: None}, 326 | outputs={filename: output_params} 327 | ) 328 | with Loader(PrintChannel.PROGRESS_INFO, "Converting file..."): 329 | ff_m.run() 330 | 331 | if Path(temp_filename).exists(): 332 | Path(temp_filename).unlink() 333 | 334 | except ffmpy.FFExecutableNotFoundError: 335 | Printer.print(PrintChannel.WARNINGS, f'### SKIPPING {file_codec.upper()} CONVERSION - FFMPEG NOT FOUND ###') 336 | --------------------------------------------------------------------------------