diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 0b88fe7..c742569 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -16,8 +16,8 @@ jobs: - name: Setup Pylint run: | python -m pip install --upgrade pip - pip install pylint + pip install pylint pylint-exit pip install -r requirements.txt - name: Run Pylint run: | - pylint $(git ls-files '*.py') + pylint $(git ls-files '*.py') || pylint-exit $? diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f93e54..c7001d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ ## **Changelog:** +**v2.4 (27 Oct 20212):** +- Added realtime downloading support to avoid account suspensions. +- Fix for downloading by artist. +- Replace audio conversion method for better quality. +- Fix bug when automatically setting audio bitrate. + **v2.3 (25 Oct 2021):** - Moved changelog to seperate file. - Added argument parsing in search function (query results limit and query result types). diff --git a/README.md b/README.md index 979bafc..e1b7dff 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ Create and run a container from the image: **There have been 2-3 reports from users who received account bans from Spotify for using this tool**. -We recommend using ZSpotify with a burner account. +We recommend using ZSpotify with a burner account. + Alternatively, there is a configuration option labled ```DOWNLOAD_REAL_TIME```, this limits the download speed to the duration of the song being downloaded thus not appearing suspicious to Spotify. 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. diff --git a/requirements.txt b/requirements.txt index 4f4b877..b1877ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ +ffmpy git+https://github.com/kokarare1212/librespot-python music_tag -pydub Pillow -tqdm -tabulate \ No newline at end of file +protobuf +pydub +tabulate +tqdm \ No newline at end of file diff --git a/zspotify/album.py b/zspotify/album.py index 19004d1..2070da2 100644 --- a/zspotify/album.py +++ b/zspotify/album.py @@ -33,7 +33,7 @@ def get_album_name(album_id): def get_artist_albums(artist_id): """ Returns artist's albums """ - resp = ZSpotify.invoke_url(f'{ARTIST_URL}/{artist_id}/albums') + resp = ZSpotify.invoke_url(f'{ARTIST_URL}/{artist_id}/albums?include_groups=album%2Csingle') # Return a list each album's id album_ids = [resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))] # Recursive requests to get all albums including singles an EPs diff --git a/zspotify/const.py b/zspotify/const.py index 3e4d9b0..1ec7594 100644 --- a/zspotify/const.py +++ b/zspotify/const.py @@ -96,14 +96,39 @@ CHUNK_SIZE = 'CHUNK_SIZE' SPLIT_ALBUM_DISCS = 'SPLIT_ALBUM_DISCS' +DOWNLOAD_REAL_TIME = 'DOWNLOAD_REAL_TIME' + +BITRATE = 'BITRATE' + +CODEC_MAP = { + 'aac': 'aac', + 'fdk_aac': 'libfdk_aac', + 'm4a': 'aac', + 'mp3': 'libmp3lame', + 'ogg': 'copy', + 'opus': 'libopus', + 'vorbis': 'copy', +} + +EXT_MAP = { + 'aac': 'm4a', + 'fdk_aac': 'm4a', + 'm4a': 'm4a', + 'mp3': 'mp3', + 'ogg': 'ogg', + 'opus': 'ogg', + 'vorbis': 'ogg', +} + CONFIG_DEFAULT_SETTINGS = { 'ROOT_PATH': '../ZSpotify Music/', 'ROOT_PODCAST_PATH': '../ZSpotify Podcasts/', 'SKIP_EXISTING_FILES': True, - 'DOWNLOAD_FORMAT': 'mp3', + 'DOWNLOAD_FORMAT': 'ogg', 'FORCE_PREMIUM': False, 'ANTI_BAN_WAIT_TIME': 1, 'OVERRIDE_AUTO_WAIT': False, 'CHUNK_SIZE': 50000, - 'SPLIT_ALBUM_DISCS': False + 'SPLIT_ALBUM_DISCS': False, + 'DOWNLOAD_REAL_TIME': False } diff --git a/zspotify/playlist.py b/zspotify/playlist.py index 9a1304c..cf333d1 100644 --- a/zspotify/playlist.py +++ b/zspotify/playlist.py @@ -52,10 +52,12 @@ def download_playlist(playlist): 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) + enum = 1 for song in p_bar: download_track(song[TRACK][ID], sanitize_data(playlist[NAME].strip()) + '/', - disable_progressbar=True) + prefix=True, prefix_value=str(enum) ,disable_progressbar=True) p_bar.set_description(song[TRACK][NAME]) + enum += 1 def download_from_user_playlist(): diff --git a/zspotify/podcast.py b/zspotify/podcast.py index eea36c1..03bc7b3 100644 --- a/zspotify/podcast.py +++ b/zspotify/podcast.py @@ -5,11 +5,11 @@ from librespot.audio.decoders import VorbisOnlyAudioQuality from librespot.metadata import EpisodeId from tqdm import tqdm -from const import NAME, ERROR, SHOW, ITEMS, ID, ROOT_PODCAST_PATH, CHUNK_SIZE -from utils import sanitize_data, create_download_directory, MusicFormat +from const import (CHUNK_SIZE, ERROR, ID, ITEMS, NAME, ROOT_PODCAST_PATH, SHOW, + SKIP_EXISTING_FILES) +from utils import create_download_directory, sanitize_data from zspotify import ZSpotify - EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes' SHOWS_URL = 'https://api.spotify.com/v1/shows' @@ -50,21 +50,41 @@ def download_episode(episode_id) -> None: episode_id = EpisodeId.from_base62(episode_id) stream = ZSpotify.get_content_stream(episode_id, ZSpotify.DOWNLOAD_QUALITY) - download_directory = os.path.dirname(__file__) + ZSpotify.get_config(ROOT_PODCAST_PATH) + extra_paths + download_directory = os.path.join( + os.path.dirname(__file__), + ZSpotify.get_config(ROOT_PODCAST_PATH), + extra_paths, + ) + download_directory = os.path.realpath(download_directory) create_download_directory(download_directory) total_size = stream.input_stream.size - with open(download_directory + filename + MusicFormat.OGG.value, - 'wb') as file, tqdm( - desc=filename, - total=total_size, - unit='B', - unit_scale=True, - unit_divisor=1024 + + filepath = os.path.join(download_directory, f"{filename}.ogg") + if ( + os.path.isfile(filepath) + and os.path.getsize(filepath) == total_size + and ZSpotify.get_config(SKIP_EXISTING_FILES) + ): + print( + "\n### SKIPPING:", + podcast_name, + "-", + episode_name, + "(EPISODE ALREADY EXISTS) ###", + ) + return + + with open(filepath, 'wb') as file, tqdm( + desc=filename, + total=total_size, + unit='B', + unit_scale=True, + unit_divisor=1024 ) as bar: for _ in range(int(total_size / ZSpotify.get_config(CHUNK_SIZE)) + 1): bar.update(file.write( stream.input_stream.stream().read(ZSpotify.get_config(CHUNK_SIZE)))) # convert_audio_format(ROOT_PODCAST_PATH + - # extra_paths + filename + '.ogg') \ No newline at end of file + # extra_paths + filename + '.ogg') diff --git a/zspotify/track.py b/zspotify/track.py index 423cce0..5796049 100644 --- a/zspotify/track.py +++ b/zspotify/track.py @@ -4,14 +4,14 @@ from typing import Any, Tuple, List from librespot.audio.decoders import AudioQuality from librespot.metadata import TrackId +from ffmpy import FFmpeg from pydub import AudioSegment from tqdm import tqdm from const import TRACKS, ALBUM, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \ RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, SPLIT_ALBUM_DISCS, ROOT_PATH, DOWNLOAD_FORMAT, CHUNK_SIZE, \ - SKIP_EXISTING_FILES, ANTI_BAN_WAIT_TIME, OVERRIDE_AUTO_WAIT -from utils import sanitize_data, set_audio_tags, set_music_thumbnail, create_download_directory, \ - MusicFormat + SKIP_EXISTING_FILES, ANTI_BAN_WAIT_TIME, OVERRIDE_AUTO_WAIT, BITRATE, CODEC_MAP, EXT_MAP, DOWNLOAD_REAL_TIME +from utils import sanitize_data, set_audio_tags, set_music_thumbnail, create_download_directory from zspotify import ZSpotify @@ -72,7 +72,7 @@ def download_track(track_id: str, extra_paths='', prefix=False, prefix_value='', ) else f'{prefix_value} - {song_name}' filename = os.path.join( - download_directory, f'{song_name}.{ZSpotify.get_config(DOWNLOAD_FORMAT)}') + download_directory, f'{song_name}.{EXT_MAP.get(ZSpotify.get_config(DOWNLOAD_FORMAT))}') except Exception as e: print('### SKIPPING SONG - FAILED TO QUERY METADATA ###') @@ -81,11 +81,11 @@ def download_track(track_id: str, extra_paths='', prefix=False, prefix_value='', try: if not is_playable: print('\n### SKIPPING:', song_name, - '(SONG IS UNAVAILABLE) ###') + '(SONG IS UNAVAILABLE) ###') else: if os.path.isfile(filename) and os.path.getsize(filename) and ZSpotify.get_config(SKIP_EXISTING_FILES): print('\n### SKIPPING:', song_name, - '(SONG ALREADY EXISTS) ###') + '(SONG ALREADY EXISTS) ###') else: if track_id != scraped_song_id: track_id = scraped_song_id @@ -103,15 +103,21 @@ def download_track(track_id: str, extra_paths='', prefix=False, prefix_value='', unit_divisor=1024, disable=disable_progressbar ) as p_bar: - for _ in range(int(total_size / ZSpotify.get_config(CHUNK_SIZE)) + 1): - p_bar.update(file.write( - stream.input_stream.stream().read(ZSpotify.get_config(CHUNK_SIZE)))) + for chunk in range(int(total_size / ZSpotify.get_config(CHUNK_SIZE)) + 1): + data = stream.input_stream.stream().read(ZSpotify.get_config(CHUNK_SIZE)) + if data == b'': + break + p_bar.update(file.write(data)) + if ZSpotify.get_config(DOWNLOAD_REAL_TIME): + if chunk == 0: + pause = get_segment_duration(p_bar) + if pause: + time.sleep(pause) - if ZSpotify.get_config(DOWNLOAD_FORMAT) == 'mp3': - convert_audio_format(filename) - set_audio_tags(filename, artists, name, album_name, - release_year, disc_number, track_number) - set_music_thumbnail(filename, image_url) + convert_audio_format(filename) + set_audio_tags(filename, artists, name, album_name, + release_year, disc_number, track_number) + set_music_thumbnail(filename, image_url) if not ZSpotify.get_config(OVERRIDE_AUTO_WAIT): time.sleep(ZSpotify.get_config(ANTI_BAN_WAIT_TIME)) @@ -123,14 +129,44 @@ def download_track(track_id: str, extra_paths='', prefix=False, prefix_value='', os.remove(filename) +def get_segment_duration(segment): + """ Returns playback duration of given audio segment """ + sound = AudioSegment( + data = segment, + sample_width = 2, + frame_rate = 44100, + channels = 2 + ) + duration = len(sound) / 5000 + return duration + + def convert_audio_format(filename) -> None: - """ Converts raw audio into playable mp3 """ - # print('### CONVERTING TO ' + MUSIC_FORMAT.upper() + ' ###') - raw_audio = AudioSegment.from_file(filename, format=MusicFormat.OGG.value, - frame_rate=44100, channels=2, sample_width=2) - if ZSpotify.DOWNLOAD_QUALITY == AudioQuality.VERY_HIGH: - bitrate = '320k' + """ Converts raw audio into playable file """ + temp_filename = f'{os.path.splitext(filename)[0]}.tmp' + os.replace(filename, temp_filename) + + download_format = ZSpotify.get_config(DOWNLOAD_FORMAT) + file_codec = CODEC_MAP.get(download_format, "copy") + if file_codec != 'copy': + bitrate = ZSpotify.get_config(BITRATE) + if not bitrate: + if ZSpotify.DOWNLOAD_QUALITY == AudioQuality.VERY_HIGH: + bitrate = '320k' + else: + bitrate = '160k' else: - bitrate = '160k' - raw_audio.export(filename, format=ZSpotify.get_config( - DOWNLOAD_FORMAT), bitrate=bitrate) + bitrate = None + + output_params = ['-c:a', file_codec] + if bitrate: + output_params += ['-b:a', bitrate] + + ff_m = FFmpeg( + global_options=['-y', '-hide_banner', '-loglevel error'], + inputs={temp_filename: None}, + outputs={filename: output_params} + ) + ff_m.run() + if os.path.exists(temp_filename): + os.remove(temp_filename)