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
|
# 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 ###')
|
|
@ -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:
|
||||||
|
|
Loading…
Add table
Reference in a new issue