mirror of
https://github.com/THIS-IS-NOT-A-BACKUP/zspotify.git
synced 2024-11-26 09:53:17 +01:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
c8e8cad94a
3
.github/workflows/pylint.yml
vendored
3
.github/workflows/pylint.yml
vendored
@ -17,6 +17,7 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pylint
|
||||
pip install -r requirements.txt
|
||||
- name: Analysing the code with pylint
|
||||
run: |
|
||||
pylint `ls -R|grep .py$|xargs`
|
||||
pylint $(git ls-files '*.py')
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -151,3 +151,9 @@ ZSpotify\ Podcasts/
|
||||
|
||||
# Intellij
|
||||
.idea
|
||||
|
||||
# Config file
|
||||
zs_config.json
|
||||
|
||||
# MacOS file
|
||||
.DS_Store
|
||||
|
96
CHANGELOG.md
Normal file
96
CHANGELOG.md
Normal file
@ -0,0 +1,96 @@
|
||||
## **Changelog:**
|
||||
**v2.3 (25 Oct 2021):**
|
||||
- Moved changelog to seperate file.
|
||||
- Added argument parsing in search function (query results limit and query result types).
|
||||
- Fixed spelling errors.
|
||||
- Added mac specific install guide stuff.
|
||||
- Fixed infinite loop.
|
||||
- Fixed issue where zspotify could'nt run on python 3.8/3.9.
|
||||
- Changed it so you can just run zspotify from the root folder again.
|
||||
- Added function to auto generate config file if it doesnt exist.
|
||||
- Fixed issue where if you enabled splitting discs into seperate folders downloading would fail.
|
||||
- Added playlist file(m3u) creation for playlist download.
|
||||
|
||||
**v2.2 (24 Oct 2021):**
|
||||
- Added basic support for downloading an entire podcast series.
|
||||
- Split code into multiple files for easier maintenance.
|
||||
- Changed initial launch script to app.py
|
||||
- Simplified audio formats.
|
||||
- Added prebuild exe for Windows users.
|
||||
- Added Docker file.
|
||||
- Added CONTRIBUTING.md.
|
||||
- Fixed artist names getting cutoff in metadata.
|
||||
- Removed data sanitization of metadata tags.
|
||||
|
||||
**v2.1 (23 Oct 2021):**
|
||||
- Moved configuration from hard-coded values to separate zs_config.json file.
|
||||
- Add subfolders for each disc.
|
||||
- Can now search and download all songs by artist.
|
||||
- Show single progress bar for entire album.
|
||||
- Added song number at start of track name in albums.
|
||||
|
||||
**v2.0 (22 Oct 2021):**
|
||||
- Added progress bar for downloads.
|
||||
- Added multi-select support for all results when searching.
|
||||
- Added GPLv3 Licence.
|
||||
- Changed welcome banner and removed unnecessary debug print statements.
|
||||
|
||||
**v1.9 (22 Oct 2021):**
|
||||
- Added Gitea mirror for when the Spotify Glowies come to DMCA the shit out of this.
|
||||
- Changed the discord server invite to a matrix server so that won't get swatted either.
|
||||
- Added option to select multiple of our saved playlists to download at once.
|
||||
- Added support for downloading an entire show at once.
|
||||
|
||||
**v1.8 (21 Oct 2021):**
|
||||
- Improved podcast downloading a bit.
|
||||
- Simplified the code that catches crashes while downloading.
|
||||
- Cleaned up code using linter again.
|
||||
- Added option to just paste a url in the search bar to download it.
|
||||
- Added a small delay between downloading each track when downloading in bulk to help with downloading issues and potential bans.
|
||||
|
||||
**v1.7 (21 Oct 2021):**
|
||||
- Rewrote README.md to look a lot more professional.
|
||||
- Added patch to fix edge case crash when downloading liked songs.
|
||||
- Made premium account check a lot more reliable.
|
||||
- Added experimental podcast support for specific episodes!
|
||||
|
||||
**v1.6 (20 Oct 2021):**
|
||||
- Added Pillow to requirements.txt.
|
||||
- Removed websocket-client from requirements.txt because librespot-python added it to their dependency list.
|
||||
- Made it hide your password when you type it in.
|
||||
- Added manual override to force premium quality if zspotify cannot auto detect it.
|
||||
- Added option to just download the raw audio with no re-encoding at all.
|
||||
- Added Shebang line so it runs smoother on Linux.
|
||||
- Made it download the entire track at once now so it is more efficient and fixed a bug users encountered.
|
||||
|
||||
**v1.5 (19 Oct 2021):**
|
||||
- Made downloading a lot more efficient and probably faster.
|
||||
- Made the sanitizer more efficient.
|
||||
- Formatted and linted all the code.
|
||||
|
||||
**v1.4 (19 Oct 2021):**
|
||||
- Added option to encode the downloaded tracks in the "ogg" format rather than "mp3".
|
||||
- Added small improvement to sanitation function so it catches another edge case.
|
||||
|
||||
**v1.3 (19 Oct 2021):**
|
||||
- Added auto detection about if the current account is premium or not. If it is a premium account it automatically sets the quality to VERY_HIGH and otherwise HIGH if we are using a free account.
|
||||
- Fixed conversion function so it now exports to the correct bitrate.
|
||||
- Added sanitation to playlist names to help catch an edge case crash.
|
||||
- Added option to download all your liked songs into a sub-folder.
|
||||
|
||||
**v1.2 (18 Oct 2021):**
|
||||
- Added .gitignore.
|
||||
- Replaced dependency list in README.md with a proper requirements.txt file.
|
||||
- Improved the readability of README.md.
|
||||
|
||||
**v1.1 (16 Oct 2021):**
|
||||
- Added try/except to help catch crashes where a very few specific tracks would crash either the downloading or conversion part.
|
||||
|
||||
**v1.0 (14 Oct 2021):**
|
||||
- Adjusted some functions so it runs again with the newer version of librespot-python.
|
||||
- Improved my sanitization function so it catches more edge cases.
|
||||
- Fixed an issue where sometimes spotify wouldn't provide a song id for a track we are trying to download. It will now detect and skip these invalid tracks.
|
||||
- Added additional check for tracks that cannot be "played" due to licence(and similar) issues. These tracks will be skipped.
|
||||
|
||||
**v0.9 (13 Oct 2021):**
|
||||
- Initial upload, needs adjustments to get working again after backend rewrite.
|
@ -14,7 +14,8 @@ ZSpotify is a community-driven project. There are many different ways to contrib
|
||||
|
||||
### What we aren't looking for
|
||||
|
||||
Please don't use the issues section to request help installing or setting up the project. It should be reserved for bugs when running the code, and feature requqests. Instead use the support channel in either our Discord or Matrix server.
|
||||
Please don't use the issues section to request help installing or setting up the project. It should be reserved for bugs when running the code, and feature requests. Instead use the support channel in either our Discord or Matrix server.
|
||||
Please do not make a new pull request just to fix a typo or any small issue like that. We'd rather you just make an issue reporting it and we will fix it in the next commit. This helps to prevent commit spamming.
|
||||
|
||||
# Ground rules
|
||||
|
||||
|
@ -13,7 +13,7 @@ RUN apk add gcc libc-dev zlib zlib-dev jpeg-dev \
|
||||
FROM base
|
||||
|
||||
COPY --from=builder /install /usr/local
|
||||
COPY src /app
|
||||
COPY zs_config.json /
|
||||
COPY zspotify /app
|
||||
COPY *zs_config.json /
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["/usr/local/bin/python", "app.py"]
|
||||
ENTRYPOINT ["/usr/local/bin/python", "__main__.py"]
|
||||
|
112
README.md
112
README.md
@ -24,14 +24,16 @@ Python packages:
|
||||
|
||||
```
|
||||
|
||||
\*ffmpeg can be installed via apt for Debian-based distros or by downloading the binaries from [ffmpeg.org](https://ffmpeg.org) and placing them in your %PATH% in Windows.
|
||||
\*ffmpeg can be installed via apt for Debian-based distros or by downloading the binaries from [ffmpeg.org](https://ffmpeg.org) and placing them in your %PATH% in Windows. Mac users can install it with [Homebrew](https://brew.sh) by running `brew install ffmpeg`.
|
||||
|
||||
\*\*Git can be installed via apt for Debian-based distros or by downloading the binaries from [git-scm.com](https://git-scm.com/download/win) for Windows.
|
||||
|
||||
### Command line usage:
|
||||
```
|
||||
Command line usage:
|
||||
python app.py Loads search prompt to find then download a specific track, album or playlist
|
||||
python app.py <track/album/playlist/episode url> Downloads the track, album, playlist or podcast episode specified as a command line argument
|
||||
python app.py <artist url> Downloads all albums by specified artist
|
||||
Basic usage:
|
||||
python zspotify Loads search prompt to find then download a specific track, album or playlist
|
||||
python zspotify <track/album/playlist/episode url> Downloads the track, album, playlist or podcast episode specified as a command line argument
|
||||
python zspotify <artist url> Downloads all albums by specified artist
|
||||
|
||||
Extra command line options:
|
||||
-p, --playlist Downloads a saved playlist from your account
|
||||
@ -43,13 +45,23 @@ Options that can be configured in zs_config.json:
|
||||
|
||||
SKIP_EXISTING_FILES Set this to false if you want ZSpotify to overwrite files with the same name rather than skipping the song
|
||||
|
||||
MUSIC_FORMAT Can be "mp3" or "ogg", mp3 is required for track metadata however ogg is slightly higer quality as it is not trsnacoded.
|
||||
MUSIC_FORMAT Can be "mp3" or "ogg", mp3 is required for track metadata however ogg is slightly higher quality as it is not transcoded.
|
||||
|
||||
FORCE_PREMIUM Set this to true if ZSpotify isn't automatically detecting that you are using a premium account
|
||||
|
||||
ANTI_BAN_WAIT_TIME Change this setting if the time waited between bulk downloads is too high or low
|
||||
OVERRIDE_AUTO_WAIT Change this to true if you want to completely disable the wait between songs for faster downloads with the risk of instability
|
||||
```
|
||||
|
||||
### Docker Usage
|
||||
|
||||
```
|
||||
Build the docker image from the Dockerfile:
|
||||
docker build -t zspotify .
|
||||
Create and run a container from the image:
|
||||
docker run --rm -v "$PWD/ZSpotify Music:/ZSpotify Music" -v "$PWD/ZSpotify Podcasts:/ZSpotify Podcasts" -it zspotify
|
||||
```
|
||||
|
||||
### Will my account get banned if I use this tool?
|
||||
Currently no user has reported their account getting banned after using ZSpotify.
|
||||
This isn't to say _you_ won't get banned as it is technically against Spotify's TOS.
|
||||
@ -59,89 +71,7 @@ This isn't to say _you_ won't get banned as it is technically against Spotify's
|
||||
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 and continue using Spotify.
|
||||
|
||||
### Contributing
|
||||
Please refer to CONTRIBUTING.md
|
||||
Please refer to [CONTRIBUTING](CONTRIBUTING.md)
|
||||
|
||||
## **Changelog:**
|
||||
**v2.2 (24 Oct 2021):**
|
||||
- Added basic support for downloading an entire podcast series.
|
||||
- Split code into multiple files for easier maintenance.
|
||||
- Changed initial launch script to app.py
|
||||
- Simplified audio formats.
|
||||
- Added prebuild exe for Windows users.
|
||||
- Added Docker file.
|
||||
- Added CONTRIBUTING.md.
|
||||
- Fixed artist names getting cutoff in metadata.
|
||||
- Removed data sanitization of metadata tags.
|
||||
|
||||
**v2.1 (23 Oct 2021):**
|
||||
- Moved configuration from hard-coded values to separate zs_config.json file.
|
||||
- Add subfolders for each disc.
|
||||
- Can now search and download all songs by artist.
|
||||
- Show single progress bar for entire album.
|
||||
- Added song number at start of track name in albums.
|
||||
|
||||
**v2.0 (22 Oct 2021):**
|
||||
- Added progress bar for downloads.
|
||||
- Added multi-select support for all results when searching.
|
||||
- Added GPLv3 Licence.
|
||||
- Changed welcome banner and removed unnecessary debug print statements.
|
||||
|
||||
**v1.9 (22 Oct 2021):**
|
||||
- Added Gitea mirror for when the Spotify Glowies come to DMCA the shit out of this.
|
||||
- Changed the discord server invite to a matrix server so that won't get swatted either.
|
||||
- Added option to select multiple of our saved playlists to download at once.
|
||||
- Added support for downloading an entire show at once.
|
||||
|
||||
**v1.8 (21 Oct 2021):**
|
||||
- Improved podcast downloading a bit.
|
||||
- Simplified the code that catches crashes while downloading.
|
||||
- Cleaned up code using linter again.
|
||||
- Added option to just paste a url in the search bar to download it.
|
||||
- Added a small delay between downloading each track when downloading in bulk to help with downloading issues and potential bans.
|
||||
|
||||
**v1.7 (21 Oct 2021):**
|
||||
- Rewrote README.md to look a lot more professional.
|
||||
- Added patch to fix edge case crash when downloading liked songs.
|
||||
- Made premium account check a lot more reliable.
|
||||
- Added experimental podcast support for specific episodes!
|
||||
|
||||
**v1.6 (20 Oct 2021):**
|
||||
- Added Pillow to requirements.txt.
|
||||
- Removed websocket-client from requirements.txt because librespot-python added it to their dependency list.
|
||||
- Made it hide your password when you type it in.
|
||||
- Added manual override to force premium quality if zspotify cannot auto detect it.
|
||||
- Added option to just download the raw audio with no re-encoding at all.
|
||||
- Added Shebang line so it runs smoother on Linux.
|
||||
- Made it download the entire track at once now so it is more efficient and fixed a bug users encountered.
|
||||
|
||||
**v1.5 (19 Oct 2021):**
|
||||
- Made downloading a lot more efficient and probably faster.
|
||||
- Made the sanitizer more efficient.
|
||||
- Formatted and linted all the code.
|
||||
|
||||
**v1.4 (19 Oct 2021):**
|
||||
- Added option to encode the downloaded tracks in the "ogg" format rather than "mp3".
|
||||
- Added small improvement to sanitation function so it catches another edge case.
|
||||
|
||||
**v1.3 (19 Oct 2021):**
|
||||
- Added auto detection about if the current account is premium or not. If it is a premium account it automatically sets the quality to VERY_HIGH and otherwise HIGH if we are using a free account.
|
||||
- Fixed conversion function so it now exports to the correct bitrate.
|
||||
- Added sanitation to playlist names to help catch an edge case crash.
|
||||
- Added option to download all your liked songs into a sub-folder.
|
||||
|
||||
**v1.2 (18 Oct 2021):**
|
||||
- Added .gitignore.
|
||||
- Replaced dependency list in README.md with a proper requirements.txt file.
|
||||
- Improved the readability of README.md.
|
||||
|
||||
**v1.1 (16 Oct 2021):**
|
||||
- Added try/except to help catch crashes where a very few specific tracks would crash either the downloading or conversion part.
|
||||
|
||||
**v1.0 (14 Oct 2021):**
|
||||
- Adjusted some functions so it runs again with the newer version of librespot-python.
|
||||
- Improved my sanitization function so it catches more edge cases.
|
||||
- Fixed an issue where sometimes spotify wouldn't provide a song id for a track we are trying to download. It will now detect and skip these invalid tracks.
|
||||
- Added additional check for tracks that cannot be "played" due to licence(and similar) issues. These tracks will be skipped.
|
||||
|
||||
**v0.9 (13 Oct 2021):**
|
||||
- Initial upload, needs adjustments to get working again after backend rewrite.
|
||||
### Changelog
|
||||
Please refer to [CHANGELOG](CHANGELOG.md)
|
||||
|
172
src/app.py
172
src/app.py
@ -1,172 +0,0 @@
|
||||
import sys
|
||||
|
||||
from librespot.audio.decoders import AudioQuality
|
||||
from tabulate import tabulate
|
||||
|
||||
from album import download_album, download_artist_albums
|
||||
from const import TRACK, NAME, ID, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUMS, OWNER, \
|
||||
PLAYLISTS, DISPLAY_NAME
|
||||
from playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist
|
||||
from podcast import download_episode, get_show_episodes
|
||||
from track import download_track, get_saved_tracks
|
||||
from utils import sanitize_data, splash, split_input, regex_input_for_urls
|
||||
from zspotify import ZSpotify
|
||||
|
||||
SEARCH_URL = 'https://api.spotify.com/v1/search'
|
||||
|
||||
|
||||
def client() -> None:
|
||||
""" Connects to spotify to perform query's and get songs to download """
|
||||
ZSpotify()
|
||||
splash()
|
||||
|
||||
if ZSpotify.check_premium():
|
||||
print('[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n')
|
||||
ZSpotify.DOWNLOAD_QUALITY = AudioQuality.VERY_HIGH
|
||||
else:
|
||||
print('[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n\n')
|
||||
ZSpotify.DOWNLOAD_QUALITY = AudioQuality.HIGH
|
||||
|
||||
while True:
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == '-p' or sys.argv[1] == '--playlist':
|
||||
download_from_user_playlist()
|
||||
elif sys.argv[1] == '-ls' or sys.argv[1] == '--liked-songs':
|
||||
for song in get_saved_tracks():
|
||||
if not song[TRACK][NAME]:
|
||||
print('### SKIPPING: SONG DOES NOT EXISTS ON SPOTIFY ANYMORE ###')
|
||||
else:
|
||||
download_track(song[TRACK][ID], 'Liked Songs/')
|
||||
print('\n')
|
||||
else:
|
||||
track_id, album_id, playlist_id, episode_id, show_id, artist_id = regex_input_for_urls(sys.argv[1])
|
||||
|
||||
if track_id is not None:
|
||||
download_track(track_id)
|
||||
elif artist_id is not None:
|
||||
download_artist_albums(artist_id)
|
||||
elif album_id is not None:
|
||||
download_album(album_id)
|
||||
elif playlist_id is not None:
|
||||
playlist_songs = get_playlist_songs(playlist_id)
|
||||
name, _ = get_playlist_info(playlist_id)
|
||||
for song in playlist_songs:
|
||||
download_track(song[TRACK][ID],
|
||||
sanitize_data(name) + '/')
|
||||
print('\n')
|
||||
elif episode_id is not None:
|
||||
download_episode(episode_id)
|
||||
elif show_id is not None:
|
||||
for episode in get_show_episodes(show_id):
|
||||
download_episode(episode)
|
||||
|
||||
else:
|
||||
search_text = ''
|
||||
while len(search_text) == 0:
|
||||
search_text = input('Enter search or URL: ')
|
||||
|
||||
track_id, album_id, playlist_id, episode_id, show_id, artist_id = regex_input_for_urls(search_text)
|
||||
|
||||
if track_id is not None:
|
||||
download_track(track_id)
|
||||
elif artist_id is not None:
|
||||
download_artist_albums(artist_id)
|
||||
elif album_id is not None:
|
||||
download_album(album_id)
|
||||
elif playlist_id is not None:
|
||||
playlist_songs = get_playlist_songs(playlist_id)
|
||||
name, _ = get_playlist_info(playlist_id)
|
||||
for song in playlist_songs:
|
||||
download_track(song[TRACK][ID], sanitize_data(name) + '/')
|
||||
print('\n')
|
||||
elif episode_id is not None:
|
||||
download_episode(episode_id)
|
||||
elif show_id is not None:
|
||||
for episode in get_show_episodes(show_id):
|
||||
download_episode(episode)
|
||||
else:
|
||||
search(search_text)
|
||||
# wait()
|
||||
|
||||
|
||||
def search(search_term):
|
||||
""" Searches Spotify's API for relevant data """
|
||||
params = {'limit': '10', 'offset': '0', 'q': search_term, 'type': 'track,album,artist,playlist'}
|
||||
resp = ZSpotify.invoke_url_with_params(SEARCH_URL, **params)
|
||||
|
||||
counter = 1
|
||||
tracks = resp[TRACKS][ITEMS]
|
||||
if len(tracks) > 0:
|
||||
print('### TRACKS ###')
|
||||
track_data = []
|
||||
for track in tracks:
|
||||
if track[EXPLICIT]:
|
||||
explicit = '[E]'
|
||||
else:
|
||||
explicit = ''
|
||||
track_data.append([counter, f'{track[NAME]} {explicit}',
|
||||
','.join([artist[NAME] for artist in track[ARTISTS]])])
|
||||
counter += 1
|
||||
total_tracks = counter - 1
|
||||
print(tabulate(track_data, headers=['S.NO', 'Name', 'Artists'], tablefmt='pretty'))
|
||||
print('\n')
|
||||
else:
|
||||
total_tracks = 0
|
||||
|
||||
albums = resp[ALBUMS][ITEMS]
|
||||
if len(albums) > 0:
|
||||
print('### ALBUMS ###')
|
||||
album_data = []
|
||||
for album in albums:
|
||||
album_data.append([counter, album[NAME], ','.join([artist[NAME] for artist in album[ARTISTS]])])
|
||||
counter += 1
|
||||
total_albums = counter - total_tracks - 1
|
||||
print(tabulate(album_data, headers=['S.NO', 'Album', 'Artists'], tablefmt='pretty'))
|
||||
print('\n')
|
||||
else:
|
||||
total_albums = 0
|
||||
|
||||
artists = resp[ARTISTS][ITEMS]
|
||||
if len(artists) > 0:
|
||||
print('### ARTISTS ###')
|
||||
artist_data = []
|
||||
for artist in artists:
|
||||
artist_data.append([counter, artist[NAME]])
|
||||
counter += 1
|
||||
total_artists = counter - total_tracks - total_albums - 1
|
||||
print(tabulate(artist_data, headers=['S.NO', 'Name'], tablefmt='pretty'))
|
||||
print('\n')
|
||||
else:
|
||||
total_artists = 0
|
||||
|
||||
playlists = resp[PLAYLISTS][ITEMS]
|
||||
print('### PLAYLISTS ###')
|
||||
playlist_data = []
|
||||
for playlist in playlists:
|
||||
playlist_data.append([counter, playlist[NAME], playlist[OWNER][DISPLAY_NAME]])
|
||||
counter += 1
|
||||
print(tabulate(playlist_data, headers=['S.NO', 'Name', 'Owner'], tablefmt='pretty'))
|
||||
print('\n')
|
||||
|
||||
if len(tracks) + len(albums) + len(playlists) == 0:
|
||||
print('NO RESULTS FOUND - EXITING...')
|
||||
else:
|
||||
selection = ''
|
||||
while len(selection) == 0:
|
||||
selection = str(input('SELECT ITEM(S) BY S.NO: '))
|
||||
inputs = split_input(selection)
|
||||
for pos in inputs:
|
||||
position = int(pos)
|
||||
if position <= total_tracks:
|
||||
track_id = tracks[position - 1][ID]
|
||||
download_track(track_id)
|
||||
elif position <= total_albums + total_tracks:
|
||||
download_album(albums[position - total_tracks - 1][ID])
|
||||
elif position <= total_artists + total_tracks + total_albums:
|
||||
download_artist_albums(artists[position - total_tracks - total_albums - 1][ID])
|
||||
else:
|
||||
download_playlist(playlists, position - total_tracks - total_albums - total_artists)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
client()
|
@ -1,11 +0,0 @@
|
||||
{
|
||||
"ROOT_PATH": "../ZSpotify Music/",
|
||||
"ROOT_PODCAST_PATH": "../ZSpotify Podcasts/",
|
||||
"SKIP_EXISTING_FILES": true,
|
||||
"DOWNLOAD_FORMAT": "mp3",
|
||||
"FORCE_PREMIUM": false,
|
||||
"ANTI_BAN_WAIT_TIME": 1,
|
||||
"OVERRIDE_AUTO_WAIT": false,
|
||||
"CHUNK_SIZE": 50000,
|
||||
"SPLIT_ALBUM_DISCS": false
|
||||
}
|
4
zspotify/__main__.py
Normal file
4
zspotify/__main__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from app import client
|
||||
|
||||
if __name__ == '__main__':
|
||||
client()
|
@ -35,7 +35,13 @@ def get_artist_albums(artist_id):
|
||||
""" Returns artist's albums """
|
||||
resp = ZSpotify.invoke_url(f'{ARTIST_URL}/{artist_id}/albums')
|
||||
# Return a list each album's id
|
||||
return [resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))]
|
||||
album_ids = [resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))]
|
||||
# Recursive requests to get all albums including singles an EPs
|
||||
while resp['next'] is not None:
|
||||
resp = ZSpotify.invoke_url(resp['next'])
|
||||
album_ids.extend([resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))])
|
||||
|
||||
return album_ids
|
||||
|
||||
|
||||
def download_album(album):
|
266
zspotify/app.py
Normal file
266
zspotify/app.py
Normal file
@ -0,0 +1,266 @@
|
||||
import sys
|
||||
|
||||
from librespot.audio.decoders import AudioQuality
|
||||
from tabulate import tabulate
|
||||
|
||||
from album import download_album, download_artist_albums
|
||||
from const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \
|
||||
OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME
|
||||
from playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist
|
||||
from podcast import download_episode, get_show_episodes
|
||||
from track import download_track, get_saved_tracks
|
||||
from utils import sanitize_data, splash, split_input, regex_input_for_urls
|
||||
from zspotify import ZSpotify
|
||||
|
||||
SEARCH_URL = 'https://api.spotify.com/v1/search'
|
||||
|
||||
|
||||
def client() -> None:
|
||||
""" Connects to spotify to perform query's and get songs to download """
|
||||
ZSpotify()
|
||||
splash()
|
||||
|
||||
if ZSpotify.check_premium():
|
||||
print('[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n')
|
||||
ZSpotify.DOWNLOAD_QUALITY = AudioQuality.VERY_HIGH
|
||||
else:
|
||||
print('[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n\n')
|
||||
ZSpotify.DOWNLOAD_QUALITY = AudioQuality.HIGH
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == '-p' or sys.argv[1] == '--playlist':
|
||||
download_from_user_playlist()
|
||||
elif sys.argv[1] == '-ls' or sys.argv[1] == '--liked-songs':
|
||||
for song in get_saved_tracks():
|
||||
if not song[TRACK][NAME]:
|
||||
print(
|
||||
'### SKIPPING: SONG DOES NOT EXIST ON SPOTIFY ANYMORE ###')
|
||||
else:
|
||||
download_track(song[TRACK][ID], 'Liked Songs/')
|
||||
print('\n')
|
||||
else:
|
||||
track_id, album_id, playlist_id, episode_id, show_id, artist_id = regex_input_for_urls(
|
||||
sys.argv[1])
|
||||
|
||||
if track_id is not None:
|
||||
download_track(track_id)
|
||||
elif artist_id is not None:
|
||||
download_artist_albums(artist_id)
|
||||
elif album_id is not None:
|
||||
download_album(album_id)
|
||||
elif playlist_id is not None:
|
||||
playlist_songs = get_playlist_songs(playlist_id)
|
||||
name, _ = get_playlist_info(playlist_id)
|
||||
for song in playlist_songs:
|
||||
download_track(song[TRACK][ID],
|
||||
sanitize_data(name) + '/')
|
||||
print('\n')
|
||||
elif episode_id is not None:
|
||||
download_episode(episode_id)
|
||||
elif show_id is not None:
|
||||
for episode in get_show_episodes(show_id):
|
||||
download_episode(episode)
|
||||
|
||||
else:
|
||||
search_text = ''
|
||||
while len(search_text) == 0:
|
||||
search_text = input('Enter search or URL: ')
|
||||
|
||||
track_id, album_id, playlist_id, episode_id, show_id, artist_id = regex_input_for_urls(
|
||||
search_text)
|
||||
|
||||
if track_id is not None:
|
||||
download_track(track_id)
|
||||
elif artist_id is not None:
|
||||
download_artist_albums(artist_id)
|
||||
elif album_id is not None:
|
||||
download_album(album_id)
|
||||
elif playlist_id is not None:
|
||||
playlist_songs = get_playlist_songs(playlist_id)
|
||||
name, _ = get_playlist_info(playlist_id)
|
||||
for song in playlist_songs:
|
||||
download_track(song[TRACK][ID], sanitize_data(name) + '/')
|
||||
print('\n')
|
||||
elif episode_id is not None:
|
||||
download_episode(episode_id)
|
||||
elif show_id is not None:
|
||||
for episode in get_show_episodes(show_id):
|
||||
download_episode(episode)
|
||||
else:
|
||||
search(search_text)
|
||||
|
||||
|
||||
def search(search_term):
|
||||
""" Searches Spotify's API for relevant data """
|
||||
params = {'limit': '10',
|
||||
'offset': '0',
|
||||
'q': search_term,
|
||||
'type': 'track,album,artist,playlist'}
|
||||
|
||||
# Parse args
|
||||
splits = search_term.split()
|
||||
for split in splits:
|
||||
index = splits.index(split)
|
||||
|
||||
if split[0] == '-' and len(split) > 1:
|
||||
if len(splits)-1 == index:
|
||||
raise IndexError('No parameters passed after option: {}\n'.
|
||||
format(split))
|
||||
|
||||
if split == '-l' or split == '-limit':
|
||||
try:
|
||||
int(splits[index+1])
|
||||
except ValueError:
|
||||
raise ValueError('Paramater passed after {} option must be an integer.\n'.
|
||||
format(split))
|
||||
if int(splits[index+1]) > 50:
|
||||
raise ValueError('Invalid limit passed. Max is 50.\n')
|
||||
params['limit'] = splits[index+1]
|
||||
|
||||
if split == '-t' or split == '-type':
|
||||
|
||||
allowed_types = ['track', 'playlist', 'album', 'artist']
|
||||
passed_types = []
|
||||
for i in range(index+1, len(splits)):
|
||||
if splits[i][0] == '-':
|
||||
break
|
||||
|
||||
if splits[i] not in allowed_types:
|
||||
raise ValueError('Parameters passed after {} option must be from this list:\n{}'.
|
||||
format(split, '\n'.join(allowed_types)))
|
||||
|
||||
passed_types.append(splits[i])
|
||||
params['type'] = ','.join(passed_types)
|
||||
|
||||
if len(params['type']) == 0:
|
||||
params['type'] = 'track,album,artist,playlist'
|
||||
|
||||
# Clean search term
|
||||
search_term_list = []
|
||||
for split in splits:
|
||||
if split[0] == "-":
|
||||
break
|
||||
search_term_list.append(split)
|
||||
if not search_term_list:
|
||||
raise ValueError("Invalid query.")
|
||||
params["q"] = ' '.join(search_term_list)
|
||||
|
||||
resp = ZSpotify.invoke_url_with_params(SEARCH_URL, **params)
|
||||
|
||||
counter = 1
|
||||
dics = []
|
||||
|
||||
total_tracks = 0
|
||||
if TRACK in params['type'].split(','):
|
||||
tracks = resp[TRACKS][ITEMS]
|
||||
if len(tracks) > 0:
|
||||
print('### TRACKS ###')
|
||||
track_data = []
|
||||
for track in tracks:
|
||||
if track[EXPLICIT]:
|
||||
explicit = '[E]'
|
||||
else:
|
||||
explicit = ''
|
||||
|
||||
track_data.append([counter, f'{track[NAME]} {explicit}',
|
||||
','.join([artist[NAME] for artist in track[ARTISTS]])])
|
||||
dics.append({
|
||||
ID: track[ID],
|
||||
NAME: track[NAME],
|
||||
'type': TRACK,
|
||||
})
|
||||
|
||||
counter += 1
|
||||
total_tracks = counter - 1
|
||||
print(tabulate(track_data, headers=[
|
||||
'S.NO', 'Name', 'Artists'], tablefmt='pretty'))
|
||||
print('\n')
|
||||
del tracks
|
||||
del track_data
|
||||
|
||||
total_albums = 0
|
||||
if ALBUM in params['type'].split(','):
|
||||
albums = resp[ALBUMS][ITEMS]
|
||||
if len(albums) > 0:
|
||||
print('### ALBUMS ###')
|
||||
album_data = []
|
||||
for album in albums:
|
||||
album_data.append([counter, album[NAME],
|
||||
','.join([artist[NAME] for artist in album[ARTISTS]])])
|
||||
dics.append({
|
||||
ID: album[ID],
|
||||
NAME: album[NAME],
|
||||
'type': ALBUM,
|
||||
})
|
||||
|
||||
counter += 1
|
||||
total_albums = counter - total_tracks - 1
|
||||
print(tabulate(album_data, headers=[
|
||||
'S.NO', 'Album', 'Artists'], tablefmt='pretty'))
|
||||
print('\n')
|
||||
del albums
|
||||
del album_data
|
||||
|
||||
total_artists = 0
|
||||
if ARTIST in params['type'].split(','):
|
||||
artists = resp[ARTISTS][ITEMS]
|
||||
if len(artists) > 0:
|
||||
print('### ARTISTS ###')
|
||||
artist_data = []
|
||||
for artist in artists:
|
||||
artist_data.append([counter, artist[NAME]])
|
||||
dics.append({
|
||||
ID: artist[ID],
|
||||
NAME: artist[NAME],
|
||||
'type': ARTIST,
|
||||
})
|
||||
counter += 1
|
||||
total_artists = counter - total_tracks - total_albums - 1
|
||||
print(tabulate(artist_data, headers=[
|
||||
'S.NO', 'Name'], tablefmt='pretty'))
|
||||
print('\n')
|
||||
del artists
|
||||
del artist_data
|
||||
|
||||
total_playlists = 0
|
||||
if PLAYLIST in params['type'].split(','):
|
||||
playlists = resp[PLAYLISTS][ITEMS]
|
||||
if len(playlists) > 0:
|
||||
print('### PLAYLISTS ###')
|
||||
playlist_data = []
|
||||
for playlist in playlists:
|
||||
playlist_data.append(
|
||||
[counter, playlist[NAME], playlist[OWNER][DISPLAY_NAME]])
|
||||
dics.append({
|
||||
ID: playlist[ID],
|
||||
NAME: playlist[NAME],
|
||||
'type': PLAYLIST,
|
||||
})
|
||||
counter += 1
|
||||
total_playlists = counter - total_artists - total_tracks - total_albums - 1
|
||||
print(tabulate(playlist_data, headers=[
|
||||
'S.NO', 'Name', 'Owner'], tablefmt='pretty'))
|
||||
print('\n')
|
||||
del playlists
|
||||
del playlist_data
|
||||
|
||||
if total_tracks + total_albums + total_artists + total_playlists == 0:
|
||||
print('NO RESULTS FOUND - EXITING...')
|
||||
else:
|
||||
selection = ''
|
||||
while len(selection) == 0:
|
||||
selection = str(input('SELECT ITEM(S) BY S.NO: '))
|
||||
inputs = split_input(selection)
|
||||
for pos in inputs:
|
||||
position = int(pos)
|
||||
for dic in dics:
|
||||
print_pos = dics.index(dic) + 1
|
||||
if print_pos == position:
|
||||
if dic['type'] == TRACK:
|
||||
download_track(dic[ID])
|
||||
elif dic['type'] == ALBUM:
|
||||
download_album(dic[ID])
|
||||
elif dic['type'] == ARTIST:
|
||||
download_artist_albums(dic[ID])
|
||||
else:
|
||||
download_playlist(dic)
|
@ -54,6 +54,8 @@ ERROR = 'error'
|
||||
|
||||
EXPLICIT = 'explicit'
|
||||
|
||||
PLAYLIST = 'playlist'
|
||||
|
||||
PLAYLISTS = 'playlists'
|
||||
|
||||
OWNER = 'owner'
|
||||
@ -93,3 +95,15 @@ OVERRIDE_AUTO_WAIT = 'OVERRIDE_AUTO_WAIT'
|
||||
CHUNK_SIZE = 'CHUNK_SIZE'
|
||||
|
||||
SPLIT_ALBUM_DISCS = 'SPLIT_ALBUM_DISCS'
|
||||
|
||||
CONFIG_DEFAULT_SETTINGS = {
|
||||
'ROOT_PATH': '../ZSpotify Music/',
|
||||
'ROOT_PODCAST_PATH': '../ZSpotify Podcasts/',
|
||||
'SKIP_EXISTING_FILES': True,
|
||||
'DOWNLOAD_FORMAT': 'mp3',
|
||||
'FORCE_PREMIUM': False,
|
||||
'ANTI_BAN_WAIT_TIME': 1,
|
||||
'OVERRIDE_AUTO_WAIT': False,
|
||||
'CHUNK_SIZE': 50000,
|
||||
'SPLIT_ALBUM_DISCS': False
|
||||
}
|
@ -47,13 +47,13 @@ def get_playlist_info(playlist_id):
|
||||
return resp['name'].strip(), resp['owner']['display_name'].strip()
|
||||
|
||||
|
||||
def download_playlist(playlists, playlist_number):
|
||||
def download_playlist(playlist):
|
||||
"""Downloads all the songs from a playlist"""
|
||||
|
||||
playlist_songs = [song for song in get_playlist_songs(playlists[int(playlist_number) - 1][ID]) if song[TRACK][ID]]
|
||||
playlist_songs = [song for song in get_playlist_songs(playlist[ID]) if song[TRACK][ID]]
|
||||
p_bar = tqdm(playlist_songs, unit='song', total=len(playlist_songs), unit_scale=True)
|
||||
for song in p_bar:
|
||||
download_track(song[TRACK][ID], sanitize_data(playlists[int(playlist_number) - 1][NAME].strip()) + '/',
|
||||
download_track(song[TRACK][ID], sanitize_data(playlist[NAME].strip()) + '/',
|
||||
disable_progressbar=True)
|
||||
p_bar.set_description(song[TRACK][NAME])
|
||||
|
@ -1,5 +1,5 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from librespot.audio.decoders import VorbisOnlyAudioQuality
|
||||
from librespot.metadata import EpisodeId
|
||||
@ -14,7 +14,7 @@ EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes'
|
||||
SHOWS_URL = 'https://api.spotify.com/v1/shows'
|
||||
|
||||
|
||||
def get_episode_info(episode_id_str) -> tuple[Optional[str], Optional[str]]:
|
||||
def get_episode_info(episode_id_str) -> Tuple[Optional[str], Optional[str]]:
|
||||
info = ZSpotify.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}')
|
||||
if ERROR in info:
|
||||
return None, None
|
@ -1,6 +1,6 @@
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
from typing import Any, Tuple, List
|
||||
|
||||
from librespot.audio.decoders import AudioQuality
|
||||
from librespot.metadata import TrackId
|
||||
@ -22,7 +22,8 @@ def get_saved_tracks() -> list:
|
||||
limit = 50
|
||||
|
||||
while True:
|
||||
resp = ZSpotify.invoke_url_with_params(SAVED_TRACKS_URL, limit=limit, offset=offset)
|
||||
resp = ZSpotify.invoke_url_with_params(
|
||||
SAVED_TRACKS_URL, limit=limit, offset=offset)
|
||||
offset += limit
|
||||
songs.extend(resp[ITEMS])
|
||||
if len(resp[ITEMS]) < limit:
|
||||
@ -31,7 +32,7 @@ def get_saved_tracks() -> list:
|
||||
return songs
|
||||
|
||||
|
||||
def get_song_info(song_id) -> tuple[list[str], str, str, Any, Any, Any, Any, Any, Any]:
|
||||
def get_song_info(song_id) -> Tuple[List[str], str, str, Any, Any, Any, Any, Any, Any]:
|
||||
""" Retrieves metadata for downloaded songs """
|
||||
info = ZSpotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token')
|
||||
|
||||
@ -53,24 +54,29 @@ def get_song_info(song_id) -> tuple[list[str], str, str, Any, Any, Any, Any, Any
|
||||
# noinspection PyBroadException
|
||||
def download_track(track_id: str, extra_paths='', prefix=False, prefix_value='', disable_progressbar=False) -> None:
|
||||
""" Downloads raw song audio from Spotify """
|
||||
download_directory = os.path.join(os.path.dirname(__file__), ZSpotify.get_config(ROOT_PATH), extra_paths)
|
||||
|
||||
try:
|
||||
(artists, album_name, name, image_url, release_year, disc_number,
|
||||
track_number, scraped_song_id, is_playable) = get_song_info(track_id)
|
||||
|
||||
if ZSpotify.get_config(SPLIT_ALBUM_DISCS):
|
||||
download_directory = os.path.join(os.path.dirname(
|
||||
__file__), ZSpotify.get_config(ROOT_PATH), extra_paths, f'Disc {disc_number}')
|
||||
else:
|
||||
download_directory = os.path.join(os.path.dirname(
|
||||
__file__), ZSpotify.get_config(ROOT_PATH), extra_paths)
|
||||
|
||||
song_name = artists[0] + ' - ' + name
|
||||
if prefix:
|
||||
song_name = f'{prefix_value.zfill(2)} - {song_name}' if prefix_value.isdigit(
|
||||
) else f'{prefix_value} - {song_name}'
|
||||
|
||||
if ZSpotify.get_config(SPLIT_ALBUM_DISCS):
|
||||
filename = os.path.join(download_directory, f'Disc {disc_number}',
|
||||
f'{song_name}.{ZSpotify.get_config(DOWNLOAD_FORMAT)}')
|
||||
else:
|
||||
filename = os.path.join(download_directory,
|
||||
f'{song_name}.{ZSpotify.get_config(DOWNLOAD_FORMAT)}')
|
||||
except Exception:
|
||||
filename = os.path.join(
|
||||
download_directory, f'{song_name}.{ZSpotify.get_config(DOWNLOAD_FORMAT)}')
|
||||
|
||||
except Exception as e:
|
||||
print('### SKIPPING SONG - FAILED TO QUERY METADATA ###')
|
||||
print(e)
|
||||
else:
|
||||
try:
|
||||
if not is_playable:
|
||||
@ -84,7 +90,8 @@ def download_track(track_id: str, extra_paths='', prefix=False, prefix_value='',
|
||||
if track_id != scraped_song_id:
|
||||
track_id = scraped_song_id
|
||||
track_id = TrackId.from_base62(track_id)
|
||||
stream = ZSpotify.get_content_stream(track_id, ZSpotify.DOWNLOAD_QUALITY)
|
||||
stream = ZSpotify.get_content_stream(
|
||||
track_id, ZSpotify.DOWNLOAD_QUALITY)
|
||||
create_download_directory(download_directory)
|
||||
total_size = stream.input_stream.size
|
||||
|
||||
@ -125,4 +132,5 @@ def convert_audio_format(filename) -> None:
|
||||
bitrate = '320k'
|
||||
else:
|
||||
bitrate = '160k'
|
||||
raw_audio.export(filename, format=ZSpotify.get_config(DOWNLOAD_FORMAT), bitrate=bitrate)
|
||||
raw_audio.export(filename, format=ZSpotify.get_config(
|
||||
DOWNLOAD_FORMAT), bitrate=bitrate)
|
@ -3,6 +3,7 @@ import platform
|
||||
import re
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import List, Tuple
|
||||
|
||||
import music_tag
|
||||
import requests
|
||||
@ -27,7 +28,7 @@ def wait(seconds: int = 3) -> None:
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def split_input(selection) -> list[str]:
|
||||
def split_input(selection) -> List[str]:
|
||||
""" Returns a list of inputted strings """
|
||||
inputs = []
|
||||
if '-' in selection:
|
||||
@ -91,7 +92,7 @@ def set_music_thumbnail(filename, image_url) -> None:
|
||||
tags.save()
|
||||
|
||||
|
||||
def regex_input_for_urls(search_input) -> tuple[str, str, str, str, str, str]:
|
||||
def regex_input_for_urls(search_input) -> Tuple[str, str, str, str, str, str]:
|
||||
""" Since many kinds of search may be passed at the command line, process them all here. """
|
||||
track_uri_search = re.search(
|
||||
r'^spotify:track:(?P<TrackID>[0-9a-zA-Z]{22})$', search_input)
|
@ -18,7 +18,7 @@ from librespot.core import Session
|
||||
|
||||
from const import CREDENTIALS_JSON, TYPE, \
|
||||
PREMIUM, USER_READ_EMAIL, AUTHORIZATION, OFFSET, LIMIT, CONFIG_FILE_PATH, FORCE_PREMIUM, \
|
||||
PLAYLIST_READ_PRIVATE
|
||||
PLAYLIST_READ_PRIVATE, CONFIG_DEFAULT_SETTINGS
|
||||
from utils import MusicFormat
|
||||
|
||||
|
||||
@ -55,8 +55,14 @@ class ZSpotify:
|
||||
@classmethod
|
||||
def load_config(cls) -> None:
|
||||
app_dir = os.path.dirname(__file__)
|
||||
with open(os.path.join(app_dir, CONFIG_FILE_PATH), encoding='utf-8') as config_file:
|
||||
cls.CONFIG = json.load(config_file)
|
||||
true_config_file_path = os.path.join(app_dir, CONFIG_FILE_PATH)
|
||||
if not os.path.exists(true_config_file_path):
|
||||
with open(true_config_file_path, 'w', encoding='utf-8') as config_file:
|
||||
json.dump(CONFIG_DEFAULT_SETTINGS, config_file, indent=4)
|
||||
cls.CONFIG = CONFIG_DEFAULT_SETTINGS
|
||||
else:
|
||||
with open(true_config_file_path, encoding='utf-8') as config_file:
|
||||
cls.CONFIG = json.load(config_file)
|
||||
|
||||
@classmethod
|
||||
def get_config(cls, key) -> Any:
|
Loading…
Reference in New Issue
Block a user