Minor fixes - 0.6.2

This commit is contained in:
logykk 2022-02-19 16:25:36 +13:00
parent d04efb0794
commit 73ca05bf41
9 changed files with 81 additions and 45 deletions

View file

@ -1,5 +1,14 @@
# Changelog
## v0.6.2
- Won't crash if downloading a song with no lyrics and `DOWNLOAD_LYRICS` is set to True
- Fixed visual glitch when entering login info
- Saving genre metadata is now optional (disabled by default) and configurable with the `MD_SAVE_GENRES`/`--md-save-genres` option
- Switched to new loading animation that hopefully renders a little better in Windows command shells
- Username and password can now be entered as arguments with `--username` and `--password` - does **not** take priority over credentials.json
- Added option to disable saving credentials `SAVE_CREDENTIALS`/`--save-credentials` - will still use credentials.json if already exists
- Default output format for singles is now `{artist}/Single - {song_name}/{artist} - {song_name}.{ext}`
## v0.6.1
- Added support for synced lyrics (unsynced is synced unavailable)
- Can be configured with the `DOWNLOAD_LYRICS` option in config.json or `--download-lyrics=True/False` as a command line argument

View file

@ -12,6 +12,7 @@
- Downloads at up to 320kbps*
- Downloads directly from the source**
- Downloads podcasts, playlists, liked songs, albums, artists, singles.
- Downloads synced lyrics from the source
- Option to download in real time to appear more legitimate***
- Supports multiple audio formats
- Download directly from URL or use built-in in search

View file

@ -12,7 +12,7 @@ README = (HERE / "README.md").read_text()
# This call to setup() does all the work
setup(
name="zotify",
version="0.6.1",
version="0.6.2",
author="Zotify Contributors",
description="A music and podcast downloader.",
long_description=README,

View file

@ -19,6 +19,12 @@ def main():
parser.add_argument('--config-location',
type=str,
help='Specify the zconfig.json location')
parser.add_argument('--username',
type=str,
help='Account username')
parser.add_argument('--password',
type=str,
help='Account password')
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('urls',
type=str,

View file

@ -18,10 +18,7 @@ SEARCH_URL = 'https://api.spotify.com/v1/search'
def client(args) -> None:
""" Connects to download server to perform query's and get songs to download """
prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Signing in...")
prepare_download_loader.start()
Zotify(args)
prepare_download_loader.stop()
Printer.print(PrintChannel.SPLASH, splash())

View file

@ -18,6 +18,7 @@ LANGUAGE = 'LANGUAGE'
DOWNLOAD_QUALITY = 'DOWNLOAD_QUALITY'
TRANSCODE_BITRATE = 'TRANSCODE_BITRATE'
SONG_ARCHIVE = 'SONG_ARCHIVE'
SAVE_CREDENTIALS = 'SAVE_CREDENTIALS'
CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION'
OUTPUT = 'OUTPUT'
PRINT_SPLASH = 'PRINT_SPLASH'
@ -27,6 +28,7 @@ PRINT_ERRORS = 'PRINT_ERRORS'
PRINT_DOWNLOADS = 'PRINT_DOWNLOADS'
PRINT_API_ERRORS = 'PRINT_API_ERRORS'
TEMP_DOWNLOAD_DIR = 'TEMP_DOWNLOAD_DIR'
MD_SAVE_GENRES = 'MD_SAVE_GENRES'
MD_ALLGENRES = 'MD_ALLGENRES'
MD_GENREDELIMITER = 'MD_GENREDELIMITER'
PRINT_PROGRESS_INFO = 'PRINT_PROGRESS_INFO'
@ -36,6 +38,7 @@ CONFIG_VERSION = 'CONFIG_VERSION'
DOWNLOAD_LYRICS = 'DOWNLOAD_LYRICS'
CONFIG_VALUES = {
SAVE_CREDENTIALS: { 'default': 'True', 'type': bool, 'arg': '--save-credentials' },
CREDENTIALS_LOCATION: { 'default': '', 'type': str, 'arg': '--credentials-location' },
OUTPUT: { 'default': '', 'type': str, 'arg': '--output' },
SONG_ARCHIVE: { 'default': '', 'type': str, 'arg': '--song-archive' },
@ -43,6 +46,7 @@ CONFIG_VALUES = {
ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' },
SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' },
DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' },
MD_SAVE_GENRES: { 'default': 'False', 'type': bool, 'arg': '--md-save-genres' },
MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' },
MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' },
DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' },
@ -70,7 +74,7 @@ CONFIG_VALUES = {
OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_PLAYLIST_EXT = '{playlist}/{playlist_num} - {artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_LIKED_SONGS = 'Liked Songs/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_SINGLE = '{artist}/{song_name}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_SINGLE = '{artist}/Single - {song_name}/{artist} - {song_name}.{ext}'
OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}'
@ -230,6 +234,10 @@ class Config:
Path(song_archive.parent).mkdir(parents=True, exist_ok=True)
return song_archive
@classmethod
def get_save_credentials(cls) -> bool:
return cls.get(SAVE_CREDENTIALS)
@classmethod
def get_credentials_location(cls) -> str:
if cls.get(CREDENTIALS_LOCATION) == '':
@ -252,6 +260,10 @@ class Config:
if cls.get(TEMP_DOWNLOAD_DIR) == '':
return ''
return PurePath(cls.get_root_path()).joinpath(cls.get(TEMP_DOWNLOAD_DIR))
@classmethod
def get_save_genres(cls) -> bool:
return cls.get(MD_SAVE_GENRES)
@classmethod
def get_all_genres(cls) -> bool:

View file

@ -19,7 +19,7 @@ class Loader:
# do something
pass
"""
def __init__(self, chan, desc="Loading...", end='', timeout=0.1, mode='std1'):
def __init__(self, chan, desc="Loading...", end='', timeout=0.1, mode='prog'):
"""
A loader-like context manager

View file

@ -64,45 +64,50 @@ def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, An
def get_song_genres(rawartists: List[str], track_name: str) -> List[str]:
try:
genres = []
for data in rawartists:
# query artist genres via href, which will be the api url
with Loader(PrintChannel.PROGRESS_INFO, "Fetching artist information..."):
(raw, artistInfo) = Zotify.invoke_url(f'{data[HREF]}')
if Zotify.CONFIG.get_all_genres() and len(artistInfo[GENRES]) > 0:
for genre in artistInfo[GENRES]:
genres.append(genre)
elif len(artistInfo[GENRES]) > 0:
genres.append(artistInfo[GENRES][0])
if Zotify.CONFIG.get_save_genres():
try:
genres = []
for data in rawartists:
# query artist genres via href, which will be the api url
with Loader(PrintChannel.PROGRESS_INFO, "Fetching artist information..."):
(raw, artistInfo) = Zotify.invoke_url(f'{data[HREF]}')
if Zotify.CONFIG.get_all_genres() and len(artistInfo[GENRES]) > 0:
for genre in artistInfo[GENRES]:
genres.append(genre)
elif len(artistInfo[GENRES]) > 0:
genres.append(artistInfo[GENRES][0])
if len(genres) == 0:
Printer.print(PrintChannel.WARNINGS, '### No Genres found for song ' + track_name)
genres.append('')
if len(genres) == 0:
Printer.print(PrintChannel.WARNINGS, '### No Genres found for song ' + track_name)
genres.append('')
return genres
except Exception as e:
raise ValueError(f'Failed to parse GENRES response: {str(e)}\n{raw}')
return genres
except Exception as e:
raise ValueError(f'Failed to parse GENRES response: {str(e)}\n{raw}')
else:
return ['']
def get_song_lyrics(song_id: str, file_save: str) -> None:
raw, lyrics = Zotify.invoke_url(f'https://spclient.wg.spotify.com/color-lyrics/v2/track/{song_id}')
formatted_lyrics = lyrics['lyrics']['lines']
if(lyrics['lyrics']['syncType'] == "UNSYNCED"):
with open(file_save, 'w') as file:
for line in formatted_lyrics:
file.writelines(line['words'] + '\n')
elif(lyrics['lyrics']['syncType'] == "LINE_SYNCED"):
with open(file_save, 'w') as file:
for line in formatted_lyrics:
timestamp = int(line['startTimeMs'])
ts_minutes = str(math.floor(timestamp / 60000)).zfill(2)
ts_seconds = str(math.floor((timestamp % 60000) / 1000)).zfill(2)
ts_millis = str(math.floor(timestamp % 1000))[:2].zfill(2)
file.writelines(f'[{ts_minutes}:{ts_seconds}.{ts_millis}]' + line['words'] + '\n')
else:
raise ValueError(f'Filed to fetch lyrics: {song_id}')
if lyrics:
formatted_lyrics = lyrics['lyrics']['lines']
if(lyrics['lyrics']['syncType'] == "UNSYNCED"):
with open(file_save, 'w') as file:
for line in formatted_lyrics:
file.writelines(line['words'] + '\n')
return
elif(lyrics['lyrics']['syncType'] == "LINE_SYNCED"):
with open(file_save, 'w') as file:
for line in formatted_lyrics:
timestamp = int(line['startTimeMs'])
ts_minutes = str(math.floor(timestamp / 60000)).zfill(2)
ts_seconds = str(math.floor((timestamp % 60000) / 1000)).zfill(2)
ts_millis = str(math.floor(timestamp % 1000))[:2].zfill(2)
file.writelines(f'[{ts_minutes}:{ts_seconds}.{ts_millis}]' + line['words'] + '\n')
return
raise ValueError(f'Filed to fetch lyrics: {song_id}')
def get_song_duration(song_id: str) -> float:
@ -228,7 +233,10 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba
genres = get_song_genres(raw_artists, name)
if(Zotify.CONFIG.get_download_lyrics()):
get_song_lyrics(track_id, PurePath(filedir / str(song_name + '.lrc')))
try:
get_song_lyrics(track_id, PurePath(filedir / str(song_name + '.lrc')))
except ValueError:
Printer.print(PrintChannel.SKIPS, f"### Skipping lyrics for {song_name}: lyrics not available ###")
convert_audio_format(filename_temp)
set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number)
set_music_thumbnail(filename_temp, image_url)
@ -299,4 +307,4 @@ def convert_audio_format(filename) -> None:
Path(temp_filename).unlink()
except ffmpy.FFExecutableNotFoundError:
Printer.print(PrintChannel.ERRORS, f'### SKIPPING {file_codec.upper()} CONVERSION - FFMPEG NOT FOUND ###')
Printer.print(PrintChannel.WARNINGS, f'### SKIPPING {file_codec.upper()} CONVERSION - FFMPEG NOT FOUND ###')

View file

@ -17,10 +17,10 @@ class Zotify:
def __init__(self, args):
Zotify.CONFIG.load(args)
Zotify.login()
Zotify.login(args)
@classmethod
def login(cls):
def login(cls, args):
""" Authenticates with Spotify and saves credentials to a file """
cred_location = Config.get_credentials_location()
@ -33,12 +33,15 @@ class Zotify:
except RuntimeError:
pass
while True:
user_name = ''
user_name = args.username if args.username else ''
while len(user_name) == 0:
user_name = input('Username: ')
password = pwinput(prompt='Password: ', mask='*')
password = args.password if args.password else pwinput(prompt='Password: ', mask='*')
try:
conf = Session.Configuration.Builder().set_stored_credential_file(cred_location).build()
if Config.get_save_credentials():
conf = Session.Configuration.Builder().set_stored_credential_file(cred_location).build()
else:
conf = Session.Configuration.Builder().set_store_credentials(False).build()
cls.SESSION = Session.Builder(conf).user_pass(user_name, password).create()
return
except RuntimeError: