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 # 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 ## v0.6.1
- Added support for synced lyrics (unsynced is synced unavailable) - 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 - 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 at up to 320kbps*
- Downloads directly from the source** - Downloads directly from the source**
- Downloads podcasts, playlists, liked songs, albums, artists, singles. - Downloads podcasts, playlists, liked songs, albums, artists, singles.
- Downloads synced lyrics from the source
- Option to download in real time to appear more legitimate*** - Option to download in real time to appear more legitimate***
- Supports multiple audio formats - Supports multiple audio formats
- Download directly from URL or use built-in in search - 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 # This call to setup() does all the work
setup( setup(
name="zotify", name="zotify",
version="0.6.1", version="0.6.2",
author="Zotify Contributors", author="Zotify Contributors",
description="A music and podcast downloader.", description="A music and podcast downloader.",
long_description=README, long_description=README,

View file

@ -19,6 +19,12 @@ def main():
parser.add_argument('--config-location', parser.add_argument('--config-location',
type=str, type=str,
help='Specify the zconfig.json location') 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 = parser.add_mutually_exclusive_group(required=True)
group.add_argument('urls', group.add_argument('urls',
type=str, type=str,

View file

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

View file

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

View file

@ -19,7 +19,7 @@ class Loader:
# do something # do something
pass 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 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]: def get_song_genres(rawartists: List[str], track_name: str) -> List[str]:
try: if Zotify.CONFIG.get_save_genres():
genres = [] try:
for data in rawartists: genres = []
# query artist genres via href, which will be the api url for data in rawartists:
with Loader(PrintChannel.PROGRESS_INFO, "Fetching artist information..."): # query artist genres via href, which will be the api url
(raw, artistInfo) = Zotify.invoke_url(f'{data[HREF]}') with Loader(PrintChannel.PROGRESS_INFO, "Fetching artist information..."):
if Zotify.CONFIG.get_all_genres() and len(artistInfo[GENRES]) > 0: (raw, artistInfo) = Zotify.invoke_url(f'{data[HREF]}')
for genre in artistInfo[GENRES]: if Zotify.CONFIG.get_all_genres() and len(artistInfo[GENRES]) > 0:
genres.append(genre) for genre in artistInfo[GENRES]:
elif len(artistInfo[GENRES]) > 0: genres.append(genre)
genres.append(artistInfo[GENRES][0]) elif len(artistInfo[GENRES]) > 0:
genres.append(artistInfo[GENRES][0])
if len(genres) == 0: if len(genres) == 0:
Printer.print(PrintChannel.WARNINGS, '### No Genres found for song ' + track_name) Printer.print(PrintChannel.WARNINGS, '### No Genres found for song ' + track_name)
genres.append('') genres.append('')
return genres return genres
except Exception as e: except Exception as e:
raise ValueError(f'Failed to parse GENRES response: {str(e)}\n{raw}') 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: 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}') raw, lyrics = Zotify.invoke_url(f'https://spclient.wg.spotify.com/color-lyrics/v2/track/{song_id}')
formatted_lyrics = lyrics['lyrics']['lines'] if lyrics:
if(lyrics['lyrics']['syncType'] == "UNSYNCED"): formatted_lyrics = lyrics['lyrics']['lines']
with open(file_save, 'w') as file: if(lyrics['lyrics']['syncType'] == "UNSYNCED"):
for line in formatted_lyrics: with open(file_save, 'w') as file:
file.writelines(line['words'] + '\n') for line in formatted_lyrics:
elif(lyrics['lyrics']['syncType'] == "LINE_SYNCED"): file.writelines(line['words'] + '\n')
with open(file_save, 'w') as file: return
for line in formatted_lyrics: elif(lyrics['lyrics']['syncType'] == "LINE_SYNCED"):
timestamp = int(line['startTimeMs']) with open(file_save, 'w') as file:
ts_minutes = str(math.floor(timestamp / 60000)).zfill(2) for line in formatted_lyrics:
ts_seconds = str(math.floor((timestamp % 60000) / 1000)).zfill(2) timestamp = int(line['startTimeMs'])
ts_millis = str(math.floor(timestamp % 1000))[:2].zfill(2) ts_minutes = str(math.floor(timestamp / 60000)).zfill(2)
file.writelines(f'[{ts_minutes}:{ts_seconds}.{ts_millis}]' + line['words'] + '\n') ts_seconds = str(math.floor((timestamp % 60000) / 1000)).zfill(2)
else: ts_millis = str(math.floor(timestamp % 1000))[:2].zfill(2)
raise ValueError(f'Filed to fetch lyrics: {song_id}') 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: 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) genres = get_song_genres(raw_artists, name)
if(Zotify.CONFIG.get_download_lyrics()): 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) convert_audio_format(filename_temp)
set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number) set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number)
set_music_thumbnail(filename_temp, image_url) set_music_thumbnail(filename_temp, image_url)
@ -299,4 +307,4 @@ def convert_audio_format(filename) -> None:
Path(temp_filename).unlink() Path(temp_filename).unlink()
except ffmpy.FFExecutableNotFoundError: 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): def __init__(self, args):
Zotify.CONFIG.load(args) Zotify.CONFIG.load(args)
Zotify.login() Zotify.login(args)
@classmethod @classmethod
def login(cls): def login(cls, args):
""" Authenticates with Spotify and saves credentials to a file """ """ Authenticates with Spotify and saves credentials to a file """
cred_location = Config.get_credentials_location() cred_location = Config.get_credentials_location()
@ -33,12 +33,15 @@ class Zotify:
except RuntimeError: except RuntimeError:
pass pass
while True: while True:
user_name = '' user_name = args.username if args.username else ''
while len(user_name) == 0: while len(user_name) == 0:
user_name = input('Username: ') user_name = input('Username: ')
password = pwinput(prompt='Password: ', mask='*') password = args.password if args.password else pwinput(prompt='Password: ', mask='*')
try: 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() cls.SESSION = Session.Builder(conf).user_pass(user_name, password).create()
return return
except RuntimeError: except RuntimeError: