Merge remote-tracking branch 'upstream/main'

This commit is contained in:
dabreadman 2021-11-24 18:07:26 +00:00
commit 0f2e57aa21
7 changed files with 117 additions and 30 deletions

View File

@ -37,26 +37,78 @@ Python packages:
Basic command line usage: Basic command line usage:
python zspotify <track/album/playlist/episode/artist url> Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls. python zspotify <track/album/playlist/episode/artist url> Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls.
Extra command line options: Different usage modes:
(nothing) Download the tracks/alumbs/playlists URLs from the parameter
-d, --download Download all tracks/alumbs/playlists URLs from the specified file
-p, --playlist Downloads a saved playlist from your account -p, --playlist Downloads a saved playlist from your account
-ls, --liked-songs Downloads all the liked songs from your account -ls, --liked-songs Downloads all the liked songs from your account
-s, --search Loads search prompt to find then download a specific track, album or playlist -s, --search Loads search prompt to find then download a specific track, album or playlist
Extra command line options:
-ns, --no-splash Suppress the splash screen when loading. -ns, --no-splash Suppress the splash screen when loading.
--config-location Use a different zs_config.json, defaults to the one in the program directory
Options that can be configured in zs_config.json:
ROOT_PATH Change this path if you don't like the default directory where ZSpotify saves the music
ROOT_PODCAST_PATH Change this path if you don't like the default directory where ZSpotify saves the podcasts
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 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
``` ```
### Options:
All these options can either be configured in the zs_config or via the commandline, in case of both the commandline-option has higher priority.
Be aware you have to set boolean values in the commandline like this: `--download-real-time=True`
| Key (zs-config) | commandline parameter | Description
|------------------------------|----------------------------------|---------------------------------------------------------------------|
| ROOT_PATH | --root-path | directory where ZSpotify saves the music
| ROOT_PODCAST_PATH | --root-podcast-path | directory where ZSpotify saves the podcasts
| SKIP_EXISTING_FILES | --skip-existing-files | Skip songs with the same name
| SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | Create a .song_archive file and skip previously downloaded songs
| DOWNLOAD_FORMAT | --download-format | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis)
| FORCE_PREMIUM | --force-premium | Force the use of high quality downloads (only with premium accounts)
| ANTI_BAN_WAIT_TIME | --anti-ban-wait-time | The wait time between bulk downloads
| OVERRIDE_AUTO_WAIT | --override-auto-wait | Totally disable wait time between songs with the risk of instability
| CHUNK_SIZE | --chunk-size | chunk size for downloading
| SPLIT_ALBUM_DISCS | --split-album-discs | split downloaded albums by disc
| DOWNLOAD_REAL_TIME | --download-real-time | only downloads songs as fast as they would be played, can prevent account bans
| LANGUAGE | --language | Language for spotify metadata
| BITRATE | --bitrate | Overwrite the bitrate for ffmpeg encoding
| SONG_ARCHIVE | --song-archive | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED
| CREDENTIALS_LOCATION | --credentials-location | The location of the credentials.json
| OUTPUT | --output | The output location/format (see below)
| PRINT_SPLASH | --print-splash | Print the splash message
| PRINT_SKIPS | --print-skips | Print messages if a song is being skipped
| PRINT_DOWNLOAD_PROGRESS | --print-download-progress | Print the download/playlist progress bars
| PRINT_ERRORS | --print-errors | Print errors
| PRINT_DOWNLOADS | --print-downloads | Print messages when a song is finished downloading
### Output format:
With the option `OUTPUT` (or the commandline parameter `--output`) you can specify the output location and format.
The value is relative to the `ROOT_PATH`/`ROOT_PODCAST_PATH` directory and can contain the following placeholder:
| Placeholder | Description
|-----------------|--------------------------------
| {artist} | The song artist
| {album} | The song album
| {song_name} | The song name
| {release_year} | The song release year
| {disc_number} | The disc number
| {track_number} | The track_number
| {id} | The song id
| {track_id} | The track id
| {ext} | The file extension
| {album_id} | (only when downloading albums) ID of the album
| {album_num} | (only when downloading albums) Incrementing track number
| {playlist} | (only when downloading playlists) Name of the playlist
| {playlist_num} | (only when downloading playlists) Incrementing track number
Example values could be:
~~~~
{playlist}/{artist} - {song_name}.{ext}
{playlist}/{playlist_num} - {artist} - {song_name}.{ext}
Liked Songs/{artist} - {song_name}.{ext}
{artist} - {song_name}.{ext}
{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}
/home/user/downloads/{artist} - {song_name} [{id}].{ext}
~~~~
### Docker Usage ### Docker Usage
``` ```

View File

@ -3,6 +3,5 @@ git+https://github.com/kokarare1212/librespot-python
music_tag music_tag
Pillow Pillow
protobuf protobuf
pydub
tabulate tabulate
tqdm tqdm

View File

@ -255,9 +255,7 @@ def search(search_term):
selection = '' selection = ''
print('\n> SELECT A DOWNLOAD OPTION BY ID') print('\n> SELECT A DOWNLOAD OPTION BY ID')
print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s') print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s')
print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s') print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s\n')
print('> For example, typing 5 to get option 5 or 10-20 to get\nevery option from 10-20 (inclusive)\n')
print('> Or type 10,12,15,18 to get those options in particular')
while len(selection) == 0: while len(selection) == 0:
selection = str(input('ID(s): ')) selection = str(input('ID(s): '))
inputs = split_input(selection) inputs = split_input(selection)

View File

@ -70,9 +70,7 @@ def download_from_user_playlist():
selection = '' selection = ''
print('\n> SELECT A PLAYLIST BY ID') print('\n> SELECT A PLAYLIST BY ID')
print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s') print('> SELECT A RANGE BY ADDING A DASH BETWEEN BOTH ID\'s')
print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s') print('> OR PARTICULAR OPTIONS BY ADDING A COMMA BETWEEN ID\'s\n')
print('> For example, typing 10 to get one playlist or 10-20 to get\nevery playlist from 10-20 (inclusive)\n')
print('> Or type 10,12,15,18 to get those playlists in particular')
while len(selection) == 0: while len(selection) == 0:
selection = str(input('ID(s): ')) selection = str(input('ID(s): '))
playlist_choices = map(int, split_input(selection)) playlist_choices = map(int, split_input(selection))

View File

@ -1,3 +1,4 @@
import math
import os import os
import re import re
import time import time
@ -6,15 +7,14 @@ from typing import Any, Tuple, List
from librespot.audio.decoders import AudioQuality from librespot.audio.decoders import AudioQuality
from librespot.metadata import TrackId from librespot.metadata import TrackId
from ffmpy import FFmpeg from ffmpy import FFmpeg
from pydub import AudioSegment
from const import TRACKS, ALBUM, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \ from const import TRACKS, ALBUM, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \
RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS
from termoutput import Printer, PrintChannel from termoutput import Printer, PrintChannel
from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \ from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \
get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive, fmt_seconds
from zspotify import ZSpotify from zspotify import ZSpotify
import traceback
def get_saved_tracks() -> list: def get_saved_tracks() -> list:
""" Returns user's saved tracks """ """ Returns user's saved tracks """
@ -133,6 +133,8 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
create_download_directory(filedir) create_download_directory(filedir)
total_size = stream.input_stream.size total_size = stream.input_stream.size
time_start = time.time()
downloaded = 0
with open(filename, 'wb') as file, Printer.progress( with open(filename, 'wb') as file, Printer.progress(
desc=song_name, desc=song_name,
total=total_size, total=total_size,
@ -141,18 +143,25 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
unit_divisor=1024, unit_divisor=1024,
disable=disable_progressbar disable=disable_progressbar
) as p_bar: ) as p_bar:
pause = duration_ms / ZSpotify.CONFIG.get_chunk_size()
for chunk in range(int(total_size / ZSpotify.CONFIG.get_chunk_size()) + 1): for chunk in range(int(total_size / ZSpotify.CONFIG.get_chunk_size()) + 1):
data = stream.input_stream.stream().read(ZSpotify.CONFIG.get_chunk_size()) data = stream.input_stream.stream().read(ZSpotify.CONFIG.get_chunk_size())
p_bar.update(file.write(data)) p_bar.update(file.write(data))
downloaded += len(data)
if ZSpotify.CONFIG.get_download_real_time(): if ZSpotify.CONFIG.get_download_real_time():
time.sleep(pause) delta_real = time.time() - time_start
delta_want = (downloaded / total_size) * (duration_ms/1000)
if delta_want > delta_real:
time.sleep(delta_want - delta_real)
time_downloaded = time.time()
convert_audio_format(filename) convert_audio_format(filename)
set_audio_tags(filename, artists, name, album_name, release_year, disc_number, track_number) set_audio_tags(filename, artists, name, album_name, release_year, disc_number, track_number)
set_music_thumbnail(filename, image_url) set_music_thumbnail(filename, image_url)
Printer.print(PrintChannel.DOWNLOADS, f'### Downloaded "{song_name}" to "{os.path.relpath(filename, os.path.dirname(__file__))}" ###' + "\n") time_finished = time.time()
Printer.print(PrintChannel.DOWNLOADS, f'### Downloaded "{song_name}" to "{os.path.relpath(filename, os.path.dirname(__file__))}" in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting) ###' + "\n")
# add song id to archive file # add song id to archive file
if ZSpotify.CONFIG.get_skip_previously_downloaded(): if ZSpotify.CONFIG.get_skip_previously_downloaded():
@ -166,6 +175,7 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
except Exception as e: except Exception as e:
Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###') Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###')
Printer.print(PrintChannel.ERRORS, str(e) + "\n") Printer.print(PrintChannel.ERRORS, str(e) + "\n")
Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n")
if os.path.exists(filename): if os.path.exists(filename):
os.remove(filename) os.remove(filename)

View File

@ -1,4 +1,5 @@
import datetime import datetime
import math
import os import os
import platform import platform
import re import re
@ -30,6 +31,7 @@ def create_download_directory(download_path: str) -> None:
with open(hidden_file_path, 'w', encoding='utf-8') as f: with open(hidden_file_path, 'w', encoding='utf-8') as f:
pass pass
def get_previously_downloaded() -> List[str]: def get_previously_downloaded() -> List[str]:
""" Returns list of all time downloaded songs """ """ Returns list of all time downloaded songs """
@ -42,6 +44,7 @@ def get_previously_downloaded() -> List[str]:
return ids return ids
def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str) -> None: def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str) -> None:
""" Adds song id to all time installed songs archive """ """ Adds song id to all time installed songs archive """
@ -54,6 +57,7 @@ def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str
with open(archive_path, 'w', encoding='utf-8') as file: with open(archive_path, 'w', encoding='utf-8') as file:
file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n') file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n')
def get_directory_song_ids(download_path: str) -> List[str]: def get_directory_song_ids(download_path: str) -> List[str]:
""" Gets song ids of songs in directory """ """ Gets song ids of songs in directory """
@ -66,6 +70,7 @@ def get_directory_song_ids(download_path: str) -> List[str]:
return song_ids return song_ids
def add_to_directory_song_ids(download_path: str, song_id: str, filename: str, author_name: str, song_name: str) -> None: def add_to_directory_song_ids(download_path: str, song_id: str, filename: str, author_name: str, song_name: str) -> None:
""" Appends song_id to .song_ids file in directory """ """ Appends song_id to .song_ids file in directory """
@ -75,6 +80,7 @@ def add_to_directory_song_ids(download_path: str, song_id: str, filename: str, a
with open(hidden_file_path, 'a', encoding='utf-8') as file: with open(hidden_file_path, 'a', encoding='utf-8') as file:
file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n') file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n')
def get_downloaded_song_duration(filename: str) -> float: def get_downloaded_song_duration(filename: str) -> float:
""" Returns the downloaded file's duration in seconds """ """ Returns the downloaded file's duration in seconds """
@ -251,3 +257,26 @@ def fix_filename(name):
True True
""" """
return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE) return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE)
def fmt_seconds(secs: float) -> str:
val = math.floor(secs)
s = math.floor(val % 60)
val -= s
val /= 60
m = math.floor(val % 60)
val -= m
val /= 60
h = math.floor(val)
if h == 0 and m == 0 and s == 0:
return "0"
elif h == 0 and m == 0:
return f'{s}'.zfill(2)
elif h == 0:
return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)
else:
return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)

View File

@ -49,7 +49,8 @@ class ZSpotify:
user_name = input('Username: ') user_name = input('Username: ')
password = getpass() password = getpass()
try: try:
cls.SESSION = Session.Builder().user_pass(user_name, password).create() conf = Session.Configuration.Builder().set_stored_credential_file(cred_location).build()
cls.SESSION = Session.Builder(conf).user_pass(user_name, password).create()
return return
except RuntimeError: except RuntimeError:
pass pass