diff --git a/CHANGELOG.md b/CHANGELOG.md index b31e732..75aa172 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6b11d58..fd3f196 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/setup.py b/setup.py index 51d72e4..f3db076 100644 --- a/setup.py +++ b/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, diff --git a/zotify/__main__.py b/zotify/__main__.py index 22539dd..7a44638 100644 --- a/zotify/__main__.py +++ b/zotify/__main__.py @@ -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, diff --git a/zotify/app.py b/zotify/app.py index 8d1144d..58424e0 100644 --- a/zotify/app.py +++ b/zotify/app.py @@ -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()) diff --git a/zotify/config.py b/zotify/config.py index 5585d29..73f4a95 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -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: diff --git a/zotify/loader.py b/zotify/loader.py index ca894fe..42c48df 100644 --- a/zotify/loader.py +++ b/zotify/loader.py @@ -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 diff --git a/zotify/track.py b/zotify/track.py index bb56b4f..57a539c 100644 --- a/zotify/track.py +++ b/zotify/track.py @@ -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 ###') \ No newline at end of file + Printer.print(PrintChannel.WARNINGS, f'### SKIPPING {file_codec.upper()} CONVERSION - FFMPEG NOT FOUND ###') \ No newline at end of file diff --git a/zotify/zotify.py b/zotify/zotify.py index e63ff88..5a304e8 100644 --- a/zotify/zotify.py +++ b/zotify/zotify.py @@ -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: