Minor fixes - 0.6.2
This commit is contained in:
parent
d04efb0794
commit
73ca05bf41
9 changed files with 81 additions and 45 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
2
setup.py
2
setup.py
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 ###')
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Reference in a new issue