From 70da4264633d0e24098d5584f290a089c0e7691b Mon Sep 17 00:00:00 2001 From: logykk Date: Wed, 2 Feb 2022 21:56:57 +1300 Subject: [PATCH 1/4] todo: add global config support --- CHANGELOG.md | 25 ++++++++-- COMMON_ERRORS.md | 9 ---- setup.py | 22 +++++---- zotify/__init__.py | 0 zotify/__main__.py | 9 ++-- zotify/album.py | 10 ++-- zotify/app.py | 42 +++++++++-------- zotify/config.py | 108 ++++++++++++++++++++++++------------------- zotify/loader.py | 2 +- zotify/playlist.py | 10 ++-- zotify/podcast.py | 30 ++++++------ zotify/termoutput.py | 4 +- zotify/track.py | 52 +++++++++++---------- zotify/utils.py | 22 +++++---- zotify/zotify.py | 9 ++-- 15 files changed, 197 insertions(+), 157 deletions(-) delete mode 100644 COMMON_ERRORS.md create mode 100644 zotify/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0acb2e9..d2b6055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,21 @@ -# Changelog: -### v0.5.2: -**General changes:** +# Changelog +## v0.6 +**General changes** +- Switched from os.path to pathlib +- Zotify can now be installed with pip \ +`pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip` +- Zotify can be ran from any directory with `zotify `, you no longer need to prefix `python` in the command. + +**Docker** +- Dockerfile is currently broken, it will be fixed soon. \ +The Dockerhub image is now discontinued, we will try to switch to GitLab's container registry. + +**Windows installer** +- The Windows installer is unavilable with this release. +- The current installation system will be replaced and a new version will be available with the next release. + +## v0.5.2 +**General changes** - Fixed filenaming on Windows - Fixed removal of special characters metadata - Can now download different songs with the same name @@ -17,10 +32,10 @@ - Added options to regulate terminal output - Direct download support for certain podcasts -**Docker images:** +**Docker images** - Remember credentials between container starts - Use same uid/gid in container as on host -**Windows installer:** +**Windows installer** - Now comes with full installer - Dependencies are installed if not found diff --git a/COMMON_ERRORS.md b/COMMON_ERRORS.md deleted file mode 100644 index 52bce29..0000000 --- a/COMMON_ERRORS.md +++ /dev/null @@ -1,9 +0,0 @@ -# Introduction - -Below will contain sets of errors that you might get running zotify. Below will also contain possible fixes to these errors. It is advisable that you read this before posting your error in any support channel. - -## AttributeError: module 'google.protobuf.descriptor' has no attribute '\_internal_create_key - -_Answer(s):_ - -`pip install --upgrade protobuf` diff --git a/setup.py b/setup.py index 3b730af..e3abc2e 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,6 @@ import pathlib -from setuptools import setup -import setuptools - +from distutils.core import setup +from setuptools import setup, find_packages # The directory containing this file @@ -13,17 +12,24 @@ README = (HERE / "README.md").read_text() # This call to setup() does all the work setup( name="zotify", - version="0.5.3", + version="0.6.0", + author="Zotify", description="A music and podcast downloader.", long_description=README, long_description_content_type="text/markdown", - url="https://gitlab.com/zotify/zotify.git", - author="zotify", + url="https://gitlab.com/team-zotify/zotify.git", + package_data={'': ['README.md', 'LICENSE']}, + packages=['zotify'], + include_package_data=True, + entry_points={ + 'console_scripts': [ + 'zotify=zotify.__main__:main', + ], + }, classifiers=[ "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ], - packages=['zotify'], install_requires=['ffmpy', 'music_tag', 'Pillow', 'protobuf', 'tabulate', 'tqdm', 'librespot @ https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip'], - include_package_data=True, ) diff --git a/zotify/__init__.py b/zotify/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zotify/__main__.py b/zotify/__main__.py index 091a395..de8e0c8 100644 --- a/zotify/__main__.py +++ b/zotify/__main__.py @@ -7,10 +7,10 @@ It's like youtube-dl, but for that other music platform. import argparse -from app import client -from config import CONFIG_VALUES +from zotify.app import client +from zotify.config import CONFIG_VALUES -if __name__ == '__main__': +def main(): parser = argparse.ArgumentParser(prog='zotify', description='A music and podcast downloader needing only a python interpreter and ffmpeg.') parser.add_argument('-ns', '--no-splash', @@ -51,3 +51,6 @@ if __name__ == '__main__': args = parser.parse_args() args.func(args) + +if __name__ == '__main__': + main() diff --git a/zotify/album.py b/zotify/album.py index 8920af3..53c7992 100644 --- a/zotify/album.py +++ b/zotify/album.py @@ -1,8 +1,8 @@ -from const import ITEMS, ARTISTS, NAME, ID -from termoutput import Printer -from track import download_track -from utils import fix_filename -from zotify import Zotify +from zotify.const import ITEMS, ARTISTS, NAME, ID +from zotify.termoutput import Printer +from zotify.track import download_track +from zotify.utils import fix_filename +from zotify.zotify import Zotify ALBUM_URL = 'https://api.spotify.com/v1/albums' ARTIST_URL = 'https://api.spotify.com/v1/artists' diff --git a/zotify/app.py b/zotify/app.py index fca6d42..7482e43 100644 --- a/zotify/app.py +++ b/zotify/app.py @@ -1,16 +1,17 @@ from librespot.audio.decoders import AudioQuality from tabulate import tabulate -import os +#import os +from pathlib import Path -from album import download_album, download_artist_albums -from const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \ - OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME -from playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist -from podcast import download_episode, get_show_episodes -from termoutput import Printer, PrintChannel -from track import download_track, get_saved_tracks -from utils import splash, split_input, regex_input_for_urls -from zotify import Zotify +from zotify.album import download_album, download_artist_albums +from zotify.const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \ + OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME, TYPE +from zotify.playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist +from zotify.podcast import download_episode, get_show_episodes +from zotify.termoutput import Printer, PrintChannel +from zotify.track import download_track, get_saved_tracks +from zotify.utils import splash, split_input, regex_input_for_urls +from zotify.zotify import Zotify SEARCH_URL = 'https://api.spotify.com/v1/search' @@ -31,7 +32,7 @@ def client(args) -> None: if args.download: urls = [] filename = args.download - if os.path.exists(filename): + if Path(filename).exists(): with open(filename, 'r', encoding='utf-8') as file: urls.extend([line.strip() for line in file.readlines()]) @@ -88,14 +89,17 @@ def download_from_urls(urls: list[str]) -> bool: if not song[TRACK][NAME] or not song[TRACK][ID]: Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n") else: - download_track('playlist', song[TRACK][ID], extra_keys= - { - 'playlist_song_name': song[TRACK][NAME], - 'playlist': name, - 'playlist_num': str(enum).zfill(char_num), - 'playlist_id': playlist_id, - 'playlist_track_id': song[TRACK][ID] - }) + if song[TRACK][TYPE] == "episode": # Playlist item is a podcast episode + download_episode(song[TRACK][ID]) + else: + download_track('playlist', song[TRACK][ID], extra_keys= + { + 'playlist_song_name': song[TRACK][NAME], + 'playlist': name, + 'playlist_num': str(enum).zfill(char_num), + 'playlist_id': playlist_id, + 'playlist_track_id': song[TRACK][ID] + }) enum += 1 elif episode_id is not None: download = True diff --git a/zotify/config.py b/zotify/config.py index adee713..fe290ee 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -1,8 +1,9 @@ import json -import os +# import os +from pathlib import Path, PurePath from typing import Any -CONFIG_FILE_PATH = '../zconfig.json' +CONFIG_FILE_PATH = './zconfig.json' ROOT_PATH = 'ROOT_PATH' ROOT_PODCAST_PATH = 'ROOT_PODCAST_PATH' @@ -34,34 +35,34 @@ PRINT_WARNINGS = 'PRINT_WARNINGS' RETRY_ATTEMPTS = 'RETRY_ATTEMPTS' CONFIG_VALUES = { - ROOT_PATH: { 'default': '../Zotify Music/', 'type': str, 'arg': '--root-path' }, - ROOT_PODCAST_PATH: { 'default': '../Zotify Podcasts/', 'type': str, 'arg': '--root-podcast-path' }, - SKIP_EXISTING_FILES: { 'default': 'True', 'type': bool, 'arg': '--skip-existing-files' }, - SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' }, - RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' }, - DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' }, - FORCE_PREMIUM: { 'default': 'False', 'type': bool, 'arg': '--force-premium' }, - ANTI_BAN_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--anti-ban-wait-time' }, - OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' }, - CHUNK_SIZE: { 'default': '50000', 'type': int, 'arg': '--chunk-size' }, - SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' }, - DOWNLOAD_REAL_TIME: { 'default': 'False', 'type': bool, 'arg': '--download-real-time' }, - LANGUAGE: { 'default': 'en', 'type': str, 'arg': '--language' }, - BITRATE: { 'default': '', 'type': str, 'arg': '--bitrate' }, - SONG_ARCHIVE: { 'default': '.song_archive', 'type': str, 'arg': '--song-archive' }, - CREDENTIALS_LOCATION: { 'default': 'credentials.json', 'type': str, 'arg': '--credentials-location' }, - OUTPUT: { 'default': '', 'type': str, 'arg': '--output' }, - PRINT_SPLASH: { 'default': 'False', 'type': bool, 'arg': '--print-splash' }, - PRINT_SKIPS: { 'default': 'True', 'type': bool, 'arg': '--print-skips' }, - PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' }, - PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' }, - PRINT_DOWNLOADS: { 'default': 'False', 'type': bool, 'arg': '--print-downloads' }, - PRINT_API_ERRORS: { 'default': 'False', 'type': bool, 'arg': '--print-api-errors' }, - PRINT_PROGRESS_INFO: { 'default': 'True', 'type': bool, 'arg': '--print-progress-info' }, - PRINT_WARNINGS: { 'default': 'True', 'type': bool, 'arg': '--print-warnings' }, - MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' }, - MD_GENREDELIMITER: { 'default': ';', 'type': str, 'arg': '--md-genredelimiter' }, - TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' } + ROOT_PATH: { 'default': './Zotify Music/', 'type': str, 'arg': '--root-path' }, + ROOT_PODCAST_PATH: { 'default': './Zotify Podcasts/', 'type': str, 'arg': '--root-podcast-path' }, + SKIP_EXISTING_FILES: { 'default': 'True', 'type': bool, 'arg': '--skip-existing-files' }, + SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' }, + RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' }, + DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' }, + FORCE_PREMIUM: { 'default': 'False', 'type': bool, 'arg': '--force-premium' }, + ANTI_BAN_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--anti-ban-wait-time' }, + OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' }, + CHUNK_SIZE: { 'default': '50000', 'type': int, 'arg': '--chunk-size' }, + SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' }, + DOWNLOAD_REAL_TIME: { 'default': 'False', 'type': bool, 'arg': '--download-real-time' }, + LANGUAGE: { 'default': 'en', 'type': str, 'arg': '--language' }, + BITRATE: { 'default': '', 'type': str, 'arg': '--bitrate' }, + SONG_ARCHIVE: { 'default': '.song_archive', 'type': str, 'arg': '--song-archive' }, + CREDENTIALS_LOCATION: { 'default': 'credentials.json', 'type': str, 'arg': '--credentials-location' }, + OUTPUT: { 'default': '', 'type': str, 'arg': '--output' }, + PRINT_SPLASH: { 'default': 'False', 'type': bool, 'arg': '--print-splash' }, + PRINT_SKIPS: { 'default': 'True', 'type': bool, 'arg': '--print-skips' }, + PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' }, + PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' }, + PRINT_DOWNLOADS: { 'default': 'False', 'type': bool, 'arg': '--print-downloads' }, + PRINT_API_ERRORS: { 'default': 'False', 'type': bool, 'arg': '--print-api-errors' }, + PRINT_PROGRESS_INFO: { 'default': 'True', 'type': bool, 'arg': '--print-progress-info' }, + PRINT_WARNINGS: { 'default': 'True', 'type': bool, 'arg': '--print-warnings' }, + MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' }, + MD_GENREDELIMITER: { 'default': ';', 'type': str, 'arg': '--md-genredelimiter' }, + TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' } } OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}' @@ -76,17 +77,18 @@ class Config: @classmethod def load(cls, args) -> None: - app_dir = os.path.dirname(__file__) + #app_dir = PurePath(__file__).parent + app_dir = Path.cwd() config_fp = CONFIG_FILE_PATH if args.config_location: config_fp = args.config_location - true_config_file_path = os.path.join(app_dir, config_fp) + true_config_file_path = PurePath(app_dir).joinpath(config_fp) # Load config from zconfig.json - if not os.path.exists(true_config_file_path): + if not Path(true_config_file_path).exists(): with open(true_config_file_path, 'w', encoding='utf-8') as config_file: json.dump(cls.get_default_json(), config_file, indent=4) cls.Values = cls.get_default_json() @@ -142,11 +144,11 @@ class Config: @classmethod def get_root_path(cls) -> str: - return os.path.join(os.path.dirname(__file__), cls.get(ROOT_PATH)) + return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PATH)) @classmethod def get_root_podcast_path(cls) -> str: - return os.path.join(os.path.dirname(__file__), cls.get(ROOT_PODCAST_PATH)) + return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PODCAST_PATH)) @classmethod def get_skip_existing_files(cls) -> bool: @@ -194,17 +196,17 @@ class Config: @classmethod def get_song_archive(cls) -> str: - return os.path.join(cls.get_root_path(), cls.get(SONG_ARCHIVE)) + return PurePath(cls.get_root_path()).joinpath(cls.get(SONG_ARCHIVE)) @classmethod def get_credentials_location(cls) -> str: - return os.path.join(os.getcwd(), cls.get(CREDENTIALS_LOCATION)) + return PurePath(Path.cwd()).joinpath(cls.get(CREDENTIALS_LOCATION)) @classmethod def get_temp_download_dir(cls) -> str: if cls.get(TEMP_DOWNLOAD_DIR) == '': return '' - return os.path.join(cls.get_root_path(), cls.get(TEMP_DOWNLOAD_DIR)) + return PurePath(cls.get_root_path()).joinpath(cls.get(TEMP_DOWNLOAD_DIR)) @classmethod def get_all_genres(cls) -> bool: @@ -221,28 +223,38 @@ class Config: return v if mode == 'playlist': if cls.get_split_album_discs(): - split = os.path.split(OUTPUT_DEFAULT_PLAYLIST) - return os.path.join(split[0], 'Disc {disc_number}', split[0]) + # split = os.path.split(OUTPUT_DEFAULT_PLAYLIST) + # return os.path.join(split[0], 'Disc {disc_number}', split[0]) + split = PurePath(OUTPUT_DEFAULT_PLAYLIST).parent + return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) return OUTPUT_DEFAULT_PLAYLIST if mode == 'extplaylist': if cls.get_split_album_discs(): - split = os.path.split(OUTPUT_DEFAULT_PLAYLIST_EXT) - return os.path.join(split[0], 'Disc {disc_number}', split[0]) + # split = os.path.split(OUTPUT_DEFAULT_PLAYLIST_EXT) + # return os.path.join(split[0], 'Disc {disc_number}', split[0]) + split = PurePath(OUTPUT_DEFAULT_PLAYLIST_EXT).parent + return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) return OUTPUT_DEFAULT_PLAYLIST_EXT if mode == 'liked': if cls.get_split_album_discs(): - split = os.path.split(OUTPUT_DEFAULT_LIKED_SONGS) - return os.path.join(split[0], 'Disc {disc_number}', split[0]) + # split = os.path.split(OUTPUT_DEFAULT_LIKED_SONGS) + # return os.path.join(split[0], 'Disc {disc_number}', split[0]) + split = PurePath(OUTPUT_DEFAULT_LIKED_SONGS).parent + return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) return OUTPUT_DEFAULT_LIKED_SONGS if mode == 'single': if cls.get_split_album_discs(): - split = os.path.split(OUTPUT_DEFAULT_SINGLE) - return os.path.join(split[0], 'Disc {disc_number}', split[0]) + # split = os.path.split(OUTPUT_DEFAULT_SINGLE) + # return os.path.join(split[0], 'Disc {disc_number}', split[0]) + split = PurePath(OUTPUT_DEFAULT_SINGLE).parent + return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) return OUTPUT_DEFAULT_SINGLE if mode == 'album': if cls.get_split_album_discs(): - split = os.path.split(OUTPUT_DEFAULT_ALBUM) - return os.path.join(split[0], 'Disc {disc_number}', split[0]) + # split = os.path.split(OUTPUT_DEFAULT_ALBUM) + # return os.path.join(split[0], 'Disc {disc_number}', split[0]) + split = PurePath(OUTPUT_DEFAULT_ALBUM).parent + return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) return OUTPUT_DEFAULT_ALBUM raise ValueError() diff --git a/zotify/loader.py b/zotify/loader.py index faa3cb6..ca894fe 100644 --- a/zotify/loader.py +++ b/zotify/loader.py @@ -7,7 +7,7 @@ from shutil import get_terminal_size from threading import Thread from time import sleep -from termoutput import Printer +from zotify.termoutput import Printer class Loader: diff --git a/zotify/playlist.py b/zotify/playlist.py index e00c745..53c1941 100644 --- a/zotify/playlist.py +++ b/zotify/playlist.py @@ -1,8 +1,8 @@ -from const import ITEMS, ID, TRACK, NAME -from termoutput import Printer -from track import download_track -from utils import split_input -from zotify import Zotify +from zotify.const import ITEMS, ID, TRACK, NAME +from zotify.termoutput import Printer +from zotify.track import download_track +from zotify.utils import split_input +from zotify.zotify import Zotify MY_PLAYLISTS_URL = 'https://api.spotify.com/v1/me/playlists' PLAYLISTS_URL = 'https://api.spotify.com/v1/playlists' diff --git a/zotify/podcast.py b/zotify/podcast.py index 1c5b493..39e778c 100644 --- a/zotify/podcast.py +++ b/zotify/podcast.py @@ -1,14 +1,15 @@ -import os +# import os +from pathlib import PurePath, Path import time from typing import Optional, Tuple from librespot.metadata import EpisodeId -from const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS -from termoutput import PrintChannel, Printer -from utils import create_download_directory, fix_filename -from zotify import Zotify -from loader import Loader +from zotify.const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS +from zotify.termoutput import PrintChannel, Printer +from zotify.utils import create_download_directory, fix_filename +from zotify.zotify import Zotify +from zotify.loader import Loader EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes' @@ -46,7 +47,7 @@ def get_show_episodes(show_id_str) -> list: def download_podcast_directly(url, filename): import functools - import pathlib + # import pathlib import shutil import requests from tqdm.auto import tqdm @@ -58,7 +59,8 @@ def download_podcast_directly(url, filename): f"Request to {url} returned status code {r.status_code}") file_size = int(r.headers.get('Content-Length', 0)) - path = pathlib.Path(filename).expanduser().resolve() + # path = pathlib.Path(filename).expanduser().resolve() + path = Path(filename).expanduser().resolve() path.parent.mkdir(parents=True, exist_ok=True) desc = "(Unknown total file size)" if file_size == 0 else "" @@ -86,8 +88,8 @@ def download_episode(episode_id) -> None: direct_download_url = Zotify.invoke_url( 'https://api-partner.spotify.com/pathfinder/v1/query?operationName=getEpisode&variables={"uri":"spotify:episode:' + episode_id + '"}&extensions={"persistedQuery":{"version":1,"sha256Hash":"224ba0fd89fcfdfb3a15fa2d82a6112d3f4e2ac88fba5c6713de04d1b72cf482"}}')[1]["data"]["episode"]["audio"]["items"][-1]["url"] - download_directory = os.path.join(Zotify.CONFIG.get_root_podcast_path(), extra_paths) - download_directory = os.path.realpath(download_directory) + download_directory = PurePath(Zotify.CONFIG.get_root_podcast_path()).joinpath(extra_paths) + # download_directory = os.path.realpath(download_directory) create_download_directory(download_directory) if "anon-podcast.scdn.co" in direct_download_url: @@ -97,10 +99,10 @@ def download_episode(episode_id) -> None: total_size = stream.input_stream.size - filepath = os.path.join(download_directory, f"{filename}.ogg") + filepath = PurePath(download_directory).joinpath(f"{filename}.ogg") if ( - os.path.isfile(filepath) - and os.path.getsize(filepath) == total_size + Path(filepath).isfile() + and Path(filepath).stat().st_size == total_size and Zotify.CONFIG.get_skip_existing_files() ): Printer.print(PrintChannel.SKIPS, "\n### SKIPPING: " + podcast_name + " - " + episode_name + " (EPISODE ALREADY EXISTS) ###") @@ -128,7 +130,7 @@ def download_episode(episode_id) -> None: if delta_want > delta_real: time.sleep(delta_want - delta_real) else: - filepath = os.path.join(download_directory, f"{filename}.mp3") + filepath = PurePath(download_directory).joinpath(f"{filename}.mp3") download_podcast_directly(direct_download_url, filepath) prepare_download_loader.stop() diff --git a/zotify/termoutput.py b/zotify/termoutput.py index 7a7ad96..2539d1f 100644 --- a/zotify/termoutput.py +++ b/zotify/termoutput.py @@ -2,8 +2,8 @@ import sys from enum import Enum from tqdm import tqdm -from config import * -from zotify import Zotify +from zotify.config import * +from zotify.zotify import Zotify class PrintChannel(Enum): diff --git a/zotify/track.py b/zotify/track.py index af4ef38..b6b0019 100644 --- a/zotify/track.py +++ b/zotify/track.py @@ -1,4 +1,5 @@ -import os +# import os +from pathlib import Path, PurePath import re import time import uuid @@ -8,14 +9,14 @@ from librespot.audio.decoders import AudioQuality from librespot.metadata import TrackId from ffmpy import FFmpeg -from const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \ +from zotify.const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \ RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS, HREF -from termoutput import Printer, PrintChannel -from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \ +from zotify.termoutput import Printer, PrintChannel +from zotify.utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \ get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive, fmt_seconds -from zotify import Zotify +from zotify.zotify import Zotify import traceback -from loader import Loader +from zotify.loader import Loader def get_saved_tracks() -> list: @@ -136,25 +137,27 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba output_template = output_template.replace("{track_id}", fix_filename(track_id)) output_template = output_template.replace("{ext}", ext) - filename = os.path.join(Zotify.CONFIG.get_root_path(), output_template) - filedir = os.path.dirname(filename) + filename = PurePath(Zotify.CONFIG.get_root_path()).joinpath(output_template) + filedir = PurePath(filename).parent filename_temp = filename if Zotify.CONFIG.get_temp_download_dir() != '': - filename_temp = os.path.join(Zotify.CONFIG.get_temp_download_dir(), f'zotify_{str(uuid.uuid4())}_{track_id}.{ext}') + filename_temp = PurePath(Zotify.CONFIG.get_temp_download_dir()).joinpath(f'zotify_{str(uuid.uuid4())}_{track_id}.{ext}') - check_name = os.path.isfile(filename) and os.path.getsize(filename) + check_name = Path(filename).is_file() and Path(filename).stat().st_size check_id = scraped_song_id in get_directory_song_ids(filedir) check_all_time = scraped_song_id in get_previously_downloaded() # a song with the same name is installed if not check_id and check_name: - c = len([file for file in os.listdir(filedir) if re.search(f'^{filename}_', str(file))]) + 1 + c = len([file for file in Path(filedir).iterdir() if re.search(f'^{filename}_', str(file))]) + 1 - fname = os.path.splitext(os.path.basename(filename))[0] - ext = os.path.splitext(os.path.basename(filename))[1] + # fname = os.path.splitext(os.path.basename(filename))[0] + # ext = os.path.splitext(os.path.basename(filename))[1] + fname = PurePath(PurePath(filename).name).parent + ext = PurePath(PurePath(filename).name).suffix - filename = os.path.join(filedir, f'{fname}_{c}{ext}') + filename = PurePath(filedir).joinpath(f'{fname}_{c}{ext}') except Exception as e: Printer.print(PrintChannel.ERRORS, '### SKIPPING SONG - FAILED TO QUERY METADATA ###') @@ -218,18 +221,18 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba set_music_thumbnail(filename_temp, image_url) if filename_temp != filename: - os.rename(filename_temp, filename) + Path(filename_temp).rename(filename) time_finished = time.time() - Printer.print(PrintChannel.DOWNLOADS, f'### Downloaded "{song_name}" to "{os.path.relpath(filename, Zotify.CONFIG.get_root_path())}" in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting) ###' + "\n") + Printer.print(PrintChannel.DOWNLOADS, f'### Downloaded "{song_name}" to "{Path(filename).relative_to(Zotify.CONFIG.get_root_path())}" in {fmt_seconds(time_downloaded - time_start)} (plus {fmt_seconds(time_finished - time_downloaded)} converting) ###' + "\n") # add song id to archive file if Zotify.CONFIG.get_skip_previously_downloaded(): - add_to_archive(scraped_song_id, os.path.basename(filename), artists[0], name) + add_to_archive(scraped_song_id, PurePath(filename).name, artists[0], name) # add song id to download directory's .song_ids file if not check_id: - add_to_directory_song_ids(filedir, scraped_song_id, os.path.basename(filename), artists[0], name) + add_to_directory_song_ids(filedir, scraped_song_id, PurePath(filename).name, artists[0], name) if not Zotify.CONFIG.get_anti_ban_wait_time(): time.sleep(Zotify.CONFIG.get_anti_ban_wait_time()) @@ -241,16 +244,17 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba Printer.print(PrintChannel.ERRORS, "\n") Printer.print(PrintChannel.ERRORS, str(e) + "\n") Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") - if os.path.exists(filename_temp): - os.remove(filename_temp) + if Path(filename_temp).exists(): + Path(filename_temp).unlink() prepare_download_loader.stop() def convert_audio_format(filename) -> None: """ Converts raw audio into playable file """ - temp_filename = f'{os.path.splitext(filename)[0]}.tmp' - os.replace(filename, temp_filename) + # temp_filename = f'{os.path.splitext(filename)[0]}.tmp' + temp_filename = f'{PurePath(filename).parent}.tmp' + Path(filename).replace(temp_filename) download_format = Zotify.CONFIG.get_download_format().lower() file_codec = CODEC_MAP.get(download_format, 'copy') @@ -277,5 +281,5 @@ def convert_audio_format(filename) -> None: with Loader(PrintChannel.PROGRESS_INFO, "Converting file..."): ff_m.run() - if os.path.exists(temp_filename): - os.remove(temp_filename) + if Path(temp_filename).exists(): + Path(temp_filename).unlink() diff --git a/zotify/utils.py b/zotify/utils.py index 31c6298..359e500 100644 --- a/zotify/utils.py +++ b/zotify/utils.py @@ -5,14 +5,15 @@ import platform import re import subprocess from enum import Enum +from pathlib import Path, PurePath from typing import List, Tuple import music_tag import requests -from const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \ +from zotify.const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \ WINDOWS_SYSTEM, ALBUMARTIST -from zotify import Zotify +from zotify.zotify import Zotify class MusicFormat(str, Enum): @@ -22,11 +23,12 @@ class MusicFormat(str, Enum): def create_download_directory(download_path: str) -> None: """ Create directory and add a hidden file with song ids """ - os.makedirs(download_path, exist_ok=True) + # os.makedirs(download_path, exist_ok=True) + Path(download_path).mkdir(parents=True, exist_ok=True) # add hidden file with song ids - hidden_file_path = os.path.join(download_path, '.song_ids') - if not os.path.isfile(hidden_file_path): + hidden_file_path = PurePath(download_path).joinpath('.song_ids') + if not Path(hidden_file_path).is_file(): with open(hidden_file_path, 'w', encoding='utf-8') as f: pass @@ -37,7 +39,7 @@ def get_previously_downloaded() -> List[str]: ids = [] archive_path = Zotify.CONFIG.get_song_archive() - if os.path.exists(archive_path): + if Path(archive_path).exists(): with open(archive_path, 'r', encoding='utf-8') as f: ids = [line.strip().split('\t')[0] for line in f.readlines()] @@ -49,7 +51,7 @@ def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str archive_path = Zotify.CONFIG.get_song_archive() - if os.path.exists(archive_path): + if Path(archive_path).exists(): with open(archive_path, 'a', encoding='utf-8') as file: file.write(f'{song_id}\t{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\t{author_name}\t{song_name}\t{filename}\n') else: @@ -62,8 +64,8 @@ def get_directory_song_ids(download_path: str) -> List[str]: song_ids = [] - hidden_file_path = os.path.join(download_path, '.song_ids') - if os.path.isfile(hidden_file_path): + hidden_file_path = PurePath(download_path).joinpath('.song_ids') + if Path(hidden_file_path).is_file(): with open(hidden_file_path, 'r', encoding='utf-8') as file: song_ids.extend([line.strip().split('\t')[0] for line in file.readlines()]) @@ -73,7 +75,7 @@ def get_directory_song_ids(download_path: str) -> List[str]: def add_to_directory_song_ids(download_path: str, song_id: str, filename: str, author_name: str, song_name: str) -> None: """ Appends song_id to .song_ids file in directory """ - hidden_file_path = os.path.join(download_path, '.song_ids') + hidden_file_path = PurePath(download_path).joinpath('.song_ids') # not checking if file exists because we need an exception # to be raised if something is wrong with open(hidden_file_path, 'a', encoding='utf-8') as file: diff --git a/zotify/zotify.py b/zotify/zotify.py index f0b3329..d963359 100644 --- a/zotify/zotify.py +++ b/zotify/zotify.py @@ -1,15 +1,16 @@ import os import os.path +from pathlib import Path from getpass import getpass import time import requests from librespot.audio.decoders import VorbisOnlyAudioQuality from librespot.core import Session -from const import TYPE, \ +from zotify.const import TYPE, \ PREMIUM, USER_READ_EMAIL, OFFSET, LIMIT, \ PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ -from config import Config +from zotify.config import Config class Zotify: SESSION: Session = None @@ -26,7 +27,7 @@ class Zotify: cred_location = Config.get_credentials_location() - if os.path.isfile(cred_location): + if Path(cred_location).is_file(): try: cls.SESSION = Session.Builder().stored_file(cred_location).create() return @@ -75,7 +76,7 @@ class Zotify: @classmethod def invoke_url(cls, url, tryCount=0): # we need to import that here, otherwise we will get circular imports! - from termoutput import Printer, PrintChannel + from zotify.termoutput import Printer, PrintChannel headers = cls.get_auth_header() response = requests.get(url, headers=headers) responsetext = response.text From 3d50d8f1419bec7ecb883c5f7f4619cccd55c5de Mon Sep 17 00:00:00 2001 From: logykk Date: Fri, 4 Feb 2022 22:11:49 +1300 Subject: [PATCH 2/4] Global config location --- CHANGELOG.md | 9 +++++++-- README.md | 16 ++++++++-------- zotify/__main__.py | 2 +- zotify/app.py | 7 +++---- zotify/config.py | 47 +++++++++++++++++++--------------------------- zotify/podcast.py | 2 +- zotify/utils.py | 3 --- 7 files changed, 39 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2b6055..2fc66d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,14 @@ ## v0.6 **General changes** - Switched from os.path to pathlib -- Zotify can now be installed with pip \ +- Zotify can now be installed with pip - `pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip` -- Zotify can be ran from any directory with `zotify `, you no longer need to prefix `python` in the command. +- Zotify can be ran from any directory with `zotify [args]`, you no longer need to prefix `python` in the command. +- New default config locations: + - Windows: `%AppData%\Roaming\Zotify\config.json` + - Linux: `~/.config/zotify/config.json` + - macOS: `~/Library/Application Support/Zotify/config.json` + - You can still use `--config-location` to specify a local config file. **Docker** - Dockerfile is currently broken, it will be fixed soon. \ diff --git a/README.md b/README.md index 81e8122..9f7cd95 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Python packages: ``` Basic command line usage: - python zotify Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls. + zotify Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls. Different usage modes: (nothing) Download the tracks/alumbs/playlists URLs from the parameter @@ -42,15 +42,15 @@ Different usage modes: Extra command line options: -ns, --no-splash Suppress the splash screen when loading. - --config-location Use a different zconfig.json, defaults to the one in the program directory + --config-location Use a different config.json. ``` ### Options: -All these options can either be configured in the zconfig or via the commandline, in case of both the commandline-option has higher priority. +All these options can either be configured in the config or via the commandline, in case of both the commandline-option has higher priority. Be aware you have to set boolean values in the commandline like this: `--download-real-time=True` -| Key (zconfig) | commandline parameter | Description +| Key (config) | commandline parameter | Description |------------------------------|----------------------------------|---------------------------------------------------------------------| | ROOT_PATH | --root-path | directory where Zotify saves the music | ROOT_PODCAST_PATH | --root-podcast-path | directory where Zotify saves the podcasts @@ -106,20 +106,20 @@ Liked Songs/{artist} - {song_name}.{ext} /home/user/downloads/{artist} - {song_name} [{id}].{ext} ~~~~ -### Docker Usage +### Docker Usage - CURRENTLY BROKEN ``` Build the docker image from the Dockerfile: docker build -t zotify . Create and run a container from the image: - docker run --rm -u $(id -u):$(id -g) -v "$PWD/zotify:/app" -v "$PWD/zconfig.json:/zconfig.json" -v "$PWD/Zotify Music:/Zotify Music" -v "$PWD/Zotify Podcasts:/Zotify Podcasts" -it zotify + docker run --rm -u $(id -u):$(id -g) -v "$PWD/zotify:/app" -v "$PWD/config.json:/config.json" -v "$PWD/Zotify Music:/Zotify Music" -v "$PWD/Zotify Podcasts:/Zotify Podcasts" -it zotify ``` ### Will my account get banned if I use this tool? Currently no user has reported their account getting banned after using Zotify. -We highly recommend using Zotify with a burner account. -Alternatively, there is a configuration option labled ```DOWNLOAD_REAL_TIME```, this limits the download speed to the duration of the song being downloaded thus not appearing suspicious. +It is recommended you use Zotify with a burner account. +Alternatively, there is a configuration option labled ```DOWNLOAD_REAL_TIME```, this limits the download speed to the duration of the song being downloaded thus appearing less suspicious. This option is much slower and is only recommended for premium users who wish to download songs in 320kbps without buying premium on a burner account. **Use Zotify at your own risk**, the developers of Zotify are not responsible if your account gets banned. diff --git a/zotify/__main__.py b/zotify/__main__.py index de8e0c8..1837bd8 100644 --- a/zotify/__main__.py +++ b/zotify/__main__.py @@ -18,7 +18,7 @@ def main(): help='Suppress the splash screen when loading.') parser.add_argument('--config-location', type=str, - help='Specify the zconfig.json location') + help='Specify the json config location') 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 7482e43..d32f30c 100644 --- a/zotify/app.py +++ b/zotify/app.py @@ -23,10 +23,10 @@ def client(args) -> None: Printer.print(PrintChannel.SPLASH, splash()) if Zotify.check_premium(): - Printer.print(PrintChannel.SPLASH, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n') + Printer.print(PrintChannel.WARNINGS, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n') Zotify.DOWNLOAD_QUALITY = AudioQuality.VERY_HIGH else: - Printer.print(PrintChannel.SPLASH, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n\n') + Printer.print(PrintChannel.WARNINGS, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n\n') Zotify.DOWNLOAD_QUALITY = AudioQuality.HIGH if args.download: @@ -67,8 +67,7 @@ def download_from_urls(urls: list[str]) -> bool: download = False for spotify_url in urls: - track_id, album_id, playlist_id, episode_id, show_id, artist_id = regex_input_for_urls( - spotify_url) + track_id, album_id, playlist_id, episode_id, show_id, artist_id = regex_input_for_urls(spotify_url) if track_id is not None: download = True diff --git a/zotify/config.py b/zotify/config.py index fe290ee..c51f700 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -1,9 +1,8 @@ import json -# import os +import sys from pathlib import Path, PurePath from typing import Any -CONFIG_FILE_PATH = './zconfig.json' ROOT_PATH = 'ROOT_PATH' ROOT_PODCAST_PATH = 'ROOT_PODCAST_PATH' @@ -77,28 +76,28 @@ class Config: @classmethod def load(cls, args) -> None: - #app_dir = PurePath(__file__).parent - app_dir = Path.cwd() - - config_fp = CONFIG_FILE_PATH + system_paths = { + 'win32': Path.home() / 'AppData/Roaming/Zotify', + 'linux': Path.home() / '.config/zotify', + 'darwin': Path.home() / 'Library/Application Support/Zotify' + } + config_fp = system_paths[sys.platform] / 'config.json' if args.config_location: config_fp = args.config_location - true_config_file_path = PurePath(app_dir).joinpath(config_fp) + true_config_file_path = Path(config_fp).expanduser() # Load config from zconfig.json - + Path(PurePath(true_config_file_path).parent).mkdir(parents=True, exist_ok=True) if not Path(true_config_file_path).exists(): with open(true_config_file_path, 'w', encoding='utf-8') as config_file: json.dump(cls.get_default_json(), config_file, indent=4) - cls.Values = cls.get_default_json() - else: - with open(true_config_file_path, encoding='utf-8') as config_file: - jsonvalues = json.load(config_file) - cls.Values = {} - for key in CONFIG_VALUES: - if key in jsonvalues: - cls.Values[key] = cls.parse_arg_value(key, jsonvalues[key]) + with open(true_config_file_path, encoding='utf-8') as config_file: + jsonvalues = json.load(config_file) + cls.Values = {} + for key in CONFIG_VALUES: + if key in jsonvalues: + cls.Values[key] = cls.parse_arg_value(key, jsonvalues[key]) # Add default values for missing keys @@ -144,11 +143,13 @@ class Config: @classmethod def get_root_path(cls) -> str: - return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PATH)) + # return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PATH)) + return PurePath(Path(cls.get(ROOT_PATH)).expanduser()) @classmethod def get_root_podcast_path(cls) -> str: - return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PODCAST_PATH)) + # return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PODCAST_PATH)) + return PurePath(Path(cls.get(ROOT_PODCAST_PATH)).expanduser()) @classmethod def get_skip_existing_files(cls) -> bool: @@ -223,36 +224,26 @@ class Config: return v if mode == 'playlist': if cls.get_split_album_discs(): - # split = os.path.split(OUTPUT_DEFAULT_PLAYLIST) - # return os.path.join(split[0], 'Disc {disc_number}', split[0]) split = PurePath(OUTPUT_DEFAULT_PLAYLIST).parent return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) return OUTPUT_DEFAULT_PLAYLIST if mode == 'extplaylist': if cls.get_split_album_discs(): - # split = os.path.split(OUTPUT_DEFAULT_PLAYLIST_EXT) - # return os.path.join(split[0], 'Disc {disc_number}', split[0]) split = PurePath(OUTPUT_DEFAULT_PLAYLIST_EXT).parent return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) return OUTPUT_DEFAULT_PLAYLIST_EXT if mode == 'liked': if cls.get_split_album_discs(): - # split = os.path.split(OUTPUT_DEFAULT_LIKED_SONGS) - # return os.path.join(split[0], 'Disc {disc_number}', split[0]) split = PurePath(OUTPUT_DEFAULT_LIKED_SONGS).parent return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) return OUTPUT_DEFAULT_LIKED_SONGS if mode == 'single': if cls.get_split_album_discs(): - # split = os.path.split(OUTPUT_DEFAULT_SINGLE) - # return os.path.join(split[0], 'Disc {disc_number}', split[0]) split = PurePath(OUTPUT_DEFAULT_SINGLE).parent return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) return OUTPUT_DEFAULT_SINGLE if mode == 'album': if cls.get_split_album_discs(): - # split = os.path.split(OUTPUT_DEFAULT_ALBUM) - # return os.path.join(split[0], 'Disc {disc_number}', split[0]) split = PurePath(OUTPUT_DEFAULT_ALBUM).parent return PurePath(split).joinpath('Disc {disc_number}').joinpath(split) return OUTPUT_DEFAULT_ALBUM diff --git a/zotify/podcast.py b/zotify/podcast.py index 39e778c..63863cf 100644 --- a/zotify/podcast.py +++ b/zotify/podcast.py @@ -7,7 +7,7 @@ from librespot.metadata import EpisodeId from zotify.const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS from zotify.termoutput import PrintChannel, Printer -from zotify.utils import create_download_directory, fix_filename +from zotify.utils import create_download_directory, fix_filename, convert_audio_format from zotify.zotify import Zotify from zotify.loader import Loader diff --git a/zotify/utils.py b/zotify/utils.py index 359e500..0d051d6 100644 --- a/zotify/utils.py +++ b/zotify/utils.py @@ -23,7 +23,6 @@ class MusicFormat(str, Enum): def create_download_directory(download_path: str) -> None: """ Create directory and add a hidden file with song ids """ - # os.makedirs(download_path, exist_ok=True) Path(download_path).mkdir(parents=True, exist_ok=True) # add hidden file with song ids @@ -282,5 +281,3 @@ def fmt_seconds(secs: float) -> str: return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2) else: return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2) - - From d8c17e2ce9cc57b500915fcbe76722fc690c7011 Mon Sep 17 00:00:00 2001 From: logykk Date: Sat, 12 Feb 2022 20:48:27 +1300 Subject: [PATCH 3/4] Zotify 0.6 RC1 --- CHANGELOG.md | 25 ++++++++-- README.md | 60 ++++++++++-------------- requirements.txt | 1 + setup.py | 8 ++-- zotify/__main__.py | 10 ++-- zotify/app.py | 21 +++++---- zotify/config.py | 114 ++++++++++++++++++++++++++++++--------------- zotify/podcast.py | 6 +-- zotify/track.py | 4 -- zotify/utils.py | 15 +++--- zotify/zotify.py | 14 +++--- 11 files changed, 161 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc66d6..d6ffec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,31 @@ ## v0.6 **General changes** - Switched from os.path to pathlib -- Zotify can now be installed with pip - -`pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip` -- Zotify can be ran from any directory with `zotify [args]`, you no longer need to prefix `python` in the command. +- Renamed .song_archive to track_archive +- Zotify can now be installed with `pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip` +- Zotify can be ran from any directory with `zotify [args]`, you no longer need to prefix "python" in the command. +- The -s option now takes search input as a command argument, it will still promt you if no search is given. +- The -ls/--liked-songs option has been shrotened to -l/--liked, - New default config locations: - Windows: `%AppData%\Roaming\Zotify\config.json` - Linux: `~/.config/zotify/config.json` - macOS: `~/Library/Application Support/Zotify/config.json` - - You can still use `--config-location` to specify a local config file. + - Other/Undetected: `.zotify/config.json` + - You can still use `--config-location` to specify a different location. +- New default config locations: + - Windows: `%AppData%\Roaming\Zotify\credentials.json` + - Linux: `~/.local/share/zotify/credentials.json` + - macOS: `~/Library/Application Support/Zotify/credentials.json` + - Other/Undetected: `.zotify/credentials.json` + - You can still use `--credentials-location` to specify a different file. +- New default music and podcast locations: + - Windows: `C:\Users\\Music\Zotify Music\` & `C:\Users\\Music\Zotify Podcasts\` + - Linux & macOS: `~/Music/Zotify Music/` & `~/Music/Zotify Podcasts/` + - Other/Undetected: `./Zotify Music/` & `./Zotify Podcasts/` + - You can still use `--root-path` and `--root-podcast-path` respectively to specify a differnt location +- Singles are now stored in their own folders +- Fixed default config not loading on first run +- Now shows asterisks when entering password **Docker** - Dockerfile is currently broken, it will be fixed soon. \ diff --git a/README.md b/README.md index 9f7cd95..297f179 100644 --- a/README.md +++ b/README.md @@ -3,66 +3,58 @@ ### A music and podcast downloader needing only a python interpreter and ffmpeg.

- +

-[Discord Server](https://discord.gg/XDYsFRTUjE) - [NotABug Mirror](https://notabug.org/Zotify/zotify) +[Discord Server](https://discord.gg/XDYsFRTUjE) + +### Install ``` -Requirements: - -Binaries +Dependencies: - Python 3.9 or greater - ffmpeg* -- Git** -Python packages: - -- pip install -r requirements.txt +Installation: +python -m pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip ``` -\*ffmpeg can be installed via apt for Debian-based distros or by downloading the binaries from [ffmpeg.org](https://ffmpeg.org) and placing them in your %PATH% in Windows. Mac users can install it with [Homebrew](https://brew.sh) by running `brew install ffmpeg`. +\*Windows users can download the binaries from [ffmpeg.org](https://ffmpeg.org) and add them to %PATH%. Mac users can install it via [Homebrew](https://brew.sh) by running `brew install ffmpeg`. Linux users should already know how to install ffmpeg, I don't want to add instructions for every package manager. -\*\*Git can be installed via apt for Debian-based distros or by downloading the binaries from [git-scm.com](https://git-scm.com/download/win) for Windows. - -### Command line usage: +### Command line usage ``` Basic command line usage: zotify Downloads the track, album, playlist or podcast episode specified as a command line argument. If an artist url is given, all albums by specified artist will be downloaded. Can take multiple urls. -Different usage modes: - (nothing) Download the tracks/alumbs/playlists URLs from the parameter - -d, --download Download all tracks/alumbs/playlists URLs from the specified file - -p, --playlist Downloads a saved playlist from your account - -ls, --liked-songs Downloads all the liked songs from your account - -s, --search Loads search prompt to find then download a specific track, album or playlist - -Extra command line options: - -ns, --no-splash Suppress the splash screen when loading. - --config-location Use a different config.json. +Basic options: + (nothing) Download the tracks/alumbs/playlists URLs from the parameter + -d, --download Download all tracks/alumbs/playlists URLs from the specified file + -p, --playlist Downloads a saved playlist from your account + -l, --liked Downloads all the liked songs from your account + -s, --search Searches for specified track, album, artist or playlist, loads search prompt if none are given. ``` -### Options: +### Options All these options can either be configured in the config or via the commandline, in case of both the commandline-option has higher priority. Be aware you have to set boolean values in the commandline like this: `--download-real-time=True` | Key (config) | commandline parameter | Description |------------------------------|----------------------------------|---------------------------------------------------------------------| -| ROOT_PATH | --root-path | directory where Zotify saves the music -| ROOT_PODCAST_PATH | --root-podcast-path | directory where Zotify saves the podcasts +| ROOT_PATH | --root-path | directory where Zotify saves music +| ROOT_PODCAST_PATH | --root-podcast-path | directory where Zotify saves podcasts | SKIP_EXISTING_FILES | --skip-existing-files | Skip songs with the same name -| SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | Create a .song_archive file and skip previously downloaded songs +| SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | Use a song_archive file to skip previously downloaded songs | DOWNLOAD_FORMAT | --download-format | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis) | FORCE_PREMIUM | --force-premium | Force the use of high quality downloads (only with premium accounts) | ANTI_BAN_WAIT_TIME | --anti-ban-wait-time | The wait time between bulk downloads | OVERRIDE_AUTO_WAIT | --override-auto-wait | Totally disable wait time between songs with the risk of instability -| CHUNK_SIZE | --chunk-size | chunk size for downloading -| SPLIT_ALBUM_DISCS | --split-album-discs | split downloaded albums by disc -| DOWNLOAD_REAL_TIME | --download-real-time | only downloads songs as fast as they would be played, can prevent account bans +| CHUNK_SIZE | --chunk-size | Chunk size for downloading +| SPLIT_ALBUM_DISCS | --split-album-discs | Saves each disk in its own folder +| DOWNLOAD_REAL_TIME | --download-real-time | Downloads songs as fast as they would be played, should prevent account bans. | LANGUAGE | --language | Language for spotify metadata | BITRATE | --bitrate | Overwrite the bitrate for ffmpeg encoding | SONG_ARCHIVE | --song-archive | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED @@ -75,7 +67,7 @@ Be aware you have to set boolean values in the commandline like this: `--downloa | PRINT_DOWNLOADS | --print-downloads | Print messages when a song is finished downloading | TEMP_DOWNLOAD_DIR | --temp-download-dir | Download tracks to a temporary directory first -### Output format: +### Output format With the option `OUTPUT` (or the commandline parameter `--output`) you can specify the output location and format. The value is relative to the `ROOT_PATH`/`ROOT_PODCAST_PATH` directory and can contain the following placeholder: @@ -100,7 +92,7 @@ Example values could be: ~~~~ {playlist}/{artist} - {song_name}.{ext} {playlist}/{playlist_num} - {artist} - {song_name}.{ext} -Liked Songs/{artist} - {song_name}.{ext} +Bangers/{artist} - {song_name}.{ext} {artist} - {song_name}.{ext} {artist}/{album}/{album_num} - {artist} - {song_name}.{ext} /home/user/downloads/{artist} - {song_name} [{id}].{ext} @@ -135,7 +127,3 @@ Please refer to [CONTRIBUTING](CONTRIBUTING.md) ### Changelog Please refer to [CHANGELOG](CHANGELOG.md) - -### Common Errors - -Please refer to [COMMON_ERRORS](COMMON_ERRORS.md) diff --git a/requirements.txt b/requirements.txt index dbb530a..8bd5229 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,6 @@ https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip music_tag Pillow protobuf +pwinput tabulate tqdm diff --git a/setup.py b/setup.py index e3abc2e..fbcda8b 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,10 @@ -import pathlib +from pathlib import Path from distutils.core import setup from setuptools import setup, find_packages # The directory containing this file -HERE = pathlib.Path(__file__).parent +HERE = Path(__file__).parent # The text of the README file README = (HERE / "README.md").read_text() @@ -13,7 +13,7 @@ README = (HERE / "README.md").read_text() setup( name="zotify", version="0.6.0", - author="Zotify", + author="Zotify Contributors", description="A music and podcast downloader.", long_description=README, long_description_content_type="text/markdown", @@ -30,6 +30,6 @@ setup( "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", ], - install_requires=['ffmpy', 'music_tag', 'Pillow', 'protobuf', 'tabulate', 'tqdm', + install_requires=['ffmpy', 'music_tag', 'Pillow', 'protobuf', 'pwinput', 'tabulate', 'tqdm', 'librespot @ https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip'], ) diff --git a/zotify/__main__.py b/zotify/__main__.py index 1837bd8..e92c906 100644 --- a/zotify/__main__.py +++ b/zotify/__main__.py @@ -18,7 +18,7 @@ def main(): help='Suppress the splash screen when loading.') parser.add_argument('--config-location', type=str, - help='Specify the json config location') + help='Specify the zconfig.json location') group = parser.add_mutually_exclusive_group(required=True) group.add_argument('urls', type=str, @@ -26,7 +26,7 @@ def main(): default='', nargs='*', help='Downloads the track, album, playlist, podcast episode, or all albums by an artist from a url. Can take multiple urls.') - group.add_argument('-ls', '--liked-songs', + group.add_argument('-l', '--liked', dest='liked_songs', action='store_true', help='Downloads all the liked songs from your account.') @@ -34,8 +34,9 @@ def main(): action='store_true', help='Downloads a saved playlist from your account.') group.add_argument('-s', '--search', - dest='search_spotify', - action='store_true', + type=str, + nargs='?', + const=' ', help='Loads search prompt to find then download a specific track, album or playlist') group.add_argument('-d', '--download', type=str, @@ -52,5 +53,6 @@ def main(): args = parser.parse_args() args.func(args) + if __name__ == '__main__': main() diff --git a/zotify/app.py b/zotify/app.py index d32f30c..7ba1931 100644 --- a/zotify/app.py +++ b/zotify/app.py @@ -1,6 +1,5 @@ from librespot.audio.decoders import AudioQuality from tabulate import tabulate -#import os from pathlib import Path from zotify.album import download_album, download_artist_albums @@ -23,10 +22,10 @@ def client(args) -> None: Printer.print(PrintChannel.SPLASH, splash()) if Zotify.check_premium(): - Printer.print(PrintChannel.WARNINGS, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n\n') + Printer.print(PrintChannel.WARNINGS, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n') Zotify.DOWNLOAD_QUALITY = AudioQuality.VERY_HIGH else: - Printer.print(PrintChannel.WARNINGS, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n\n') + Printer.print(PrintChannel.WARNINGS, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n') Zotify.DOWNLOAD_QUALITY = AudioQuality.HIGH if args.download: @@ -42,7 +41,8 @@ def client(args) -> None: Printer.print(PrintChannel.ERRORS, f'File {filename} not found.\n') if args.urls: - download_from_urls(args.urls) + if len(args.urls) > 0: + download_from_urls(args.urls) if args.playlist: download_from_user_playlist() @@ -54,13 +54,14 @@ def client(args) -> None: else: download_track('liked', song[TRACK][ID]) - if args.search_spotify: - search_text = '' - while len(search_text) == 0: - search_text = input('Enter search or URL: ') + if args.search: + if args.search == ' ': + search_text = '' + while len(search_text) == 0: + search_text = input('Enter search or URL: ') - if not download_from_urls([search_text]): - search(search_text) + if not download_from_urls([args.search]): + search(args.search) def download_from_urls(urls: list[str]) -> bool: """ Downloads from a list of urls """ diff --git a/zotify/config.py b/zotify/config.py index c51f700..fec8d90 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -17,7 +17,7 @@ SPLIT_ALBUM_DISCS = 'SPLIT_ALBUM_DISCS' DOWNLOAD_REAL_TIME = 'DOWNLOAD_REAL_TIME' LANGUAGE = 'LANGUAGE' BITRATE = 'BITRATE' -SONG_ARCHIVE = 'SONG_ARCHIVE' +TRACK_ARCHIVE = 'TRACK_ARCHIVE' CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION' OUTPUT = 'OUTPUT' PRINT_SPLASH = 'PRINT_SPLASH' @@ -32,42 +32,43 @@ MD_GENREDELIMITER = 'MD_GENREDELIMITER' PRINT_PROGRESS_INFO = 'PRINT_PROGRESS_INFO' PRINT_WARNINGS = 'PRINT_WARNINGS' RETRY_ATTEMPTS = 'RETRY_ATTEMPTS' +CONFIG_VERSION = 'CONFIG_VERSION' CONFIG_VALUES = { - ROOT_PATH: { 'default': './Zotify Music/', 'type': str, 'arg': '--root-path' }, - ROOT_PODCAST_PATH: { 'default': './Zotify Podcasts/', 'type': str, 'arg': '--root-podcast-path' }, - SKIP_EXISTING_FILES: { 'default': 'True', 'type': bool, 'arg': '--skip-existing-files' }, - SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' }, - RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' }, - DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' }, - FORCE_PREMIUM: { 'default': 'False', 'type': bool, 'arg': '--force-premium' }, - ANTI_BAN_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--anti-ban-wait-time' }, - OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' }, - CHUNK_SIZE: { 'default': '50000', 'type': int, 'arg': '--chunk-size' }, - SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' }, - DOWNLOAD_REAL_TIME: { 'default': 'False', 'type': bool, 'arg': '--download-real-time' }, - LANGUAGE: { 'default': 'en', 'type': str, 'arg': '--language' }, - BITRATE: { 'default': '', 'type': str, 'arg': '--bitrate' }, - SONG_ARCHIVE: { 'default': '.song_archive', 'type': str, 'arg': '--song-archive' }, - CREDENTIALS_LOCATION: { 'default': 'credentials.json', 'type': str, 'arg': '--credentials-location' }, - OUTPUT: { 'default': '', 'type': str, 'arg': '--output' }, - PRINT_SPLASH: { 'default': 'False', 'type': bool, 'arg': '--print-splash' }, - PRINT_SKIPS: { 'default': 'True', 'type': bool, 'arg': '--print-skips' }, - PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' }, - PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' }, - PRINT_DOWNLOADS: { 'default': 'False', 'type': bool, 'arg': '--print-downloads' }, - PRINT_API_ERRORS: { 'default': 'False', 'type': bool, 'arg': '--print-api-errors' }, - PRINT_PROGRESS_INFO: { 'default': 'True', 'type': bool, 'arg': '--print-progress-info' }, - PRINT_WARNINGS: { 'default': 'True', 'type': bool, 'arg': '--print-warnings' }, - MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' }, - MD_GENREDELIMITER: { 'default': ';', 'type': str, 'arg': '--md-genredelimiter' }, - TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' } + ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' }, + ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' }, + SKIP_EXISTING_FILES: { 'default': 'True', 'type': bool, 'arg': '--skip-existing-files' }, + SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' }, + RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' }, + DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' }, + FORCE_PREMIUM: { 'default': 'False', 'type': bool, 'arg': '--force-premium' }, + ANTI_BAN_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--anti-ban-wait-time' }, + OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' }, + CHUNK_SIZE: { 'default': '50000', 'type': int, 'arg': '--chunk-size' }, + SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' }, + DOWNLOAD_REAL_TIME: { 'default': 'False', 'type': bool, 'arg': '--download-real-time' }, + LANGUAGE: { 'default': 'en', 'type': str, 'arg': '--language' }, + BITRATE: { 'default': '', 'type': str, 'arg': '--bitrate' }, + TRACK_ARCHIVE: { 'default': '', 'type': str, 'arg': '--track-archive' }, + CREDENTIALS_LOCATION: { 'default': '', 'type': str, 'arg': '--credentials-location' }, + OUTPUT: { 'default': '', 'type': str, 'arg': '--output' }, + PRINT_SPLASH: { 'default': 'False', 'type': bool, 'arg': '--print-splash' }, + PRINT_SKIPS: { 'default': 'True', 'type': bool, 'arg': '--print-skips' }, + PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' }, + PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' }, + PRINT_DOWNLOADS: { 'default': 'False', 'type': bool, 'arg': '--print-downloads' }, + PRINT_API_ERRORS: { 'default': 'False', 'type': bool, 'arg': '--print-api-errors' }, + PRINT_PROGRESS_INFO: { 'default': 'True', 'type': bool, 'arg': '--print-progress-info' }, + PRINT_WARNINGS: { 'default': 'True', 'type': bool, 'arg': '--print-warnings' }, + MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' }, + MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' }, + TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' } } 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}.{ext}' +OUTPUT_DEFAULT_SINGLE = '{artist} - {song_name}/{artist} - {song_name}.{ext}' OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}' @@ -81,7 +82,10 @@ class Config: 'linux': Path.home() / '.config/zotify', 'darwin': Path.home() / 'Library/Application Support/Zotify' } - config_fp = system_paths[sys.platform] / 'config.json' + if sys.platform not in system_paths: + config_fp = Path.cwd() / '.zotify/config.json' + else: + config_fp = system_paths[sys.platform] / 'config.json' if args.config_location: config_fp = args.config_location @@ -143,13 +147,21 @@ class Config: @classmethod def get_root_path(cls) -> str: - # return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PATH)) - return PurePath(Path(cls.get(ROOT_PATH)).expanduser()) + if cls.get(ROOT_PATH) == '': + root_path = PurePath(Path.home() / 'Music/Zotify Music/') + else: + root_path = PurePath(Path(cls.get(ROOT_PATH)).expanduser()) + Path(root_path).mkdir(parents=True, exist_ok=True) + return root_path @classmethod def get_root_podcast_path(cls) -> str: - # return PurePath(Path.cwd()).joinpath(cls.get(ROOT_PODCAST_PATH)) - return PurePath(Path(cls.get(ROOT_PODCAST_PATH)).expanduser()) + if cls.get(ROOT_PODCAST_PATH) == '': + root_podcast_path = PurePath(Path.home() / 'Music/Zotify Podcasts/') + else: + root_podcast_path = PurePath(Path(cls.get(ROOT_PODCAST_PATH)).expanduser()) + Path(root_podcast_path).mkdir(parents=True, exist_ok=True) + return root_podcast_path @classmethod def get_skip_existing_files(cls) -> bool: @@ -196,12 +208,38 @@ class Config: return cls.get(BITRATE) @classmethod - def get_song_archive(cls) -> str: - return PurePath(cls.get_root_path()).joinpath(cls.get(SONG_ARCHIVE)) + def get_track_archive(cls) -> str: + if cls.get(TRACK_ARCHIVE) == '': + system_paths = { + 'win32': Path.home() / 'AppData/Roaming/Zotify', + 'linux': Path.home() / '.local/share/zotify', + 'darwin': Path.home() / 'Library/Application Support/Zotify' + } + if sys.platform not in system_paths: + track_archive = PurePath(Path.cwd() / '.zotify/track_archive') + else: + track_archive = PurePath(system_paths[sys.platform] / 'track_archive') + else: + track_archive = PurePath(Path(cls.get(TRACK_ARCHIVE)).expanduser()) + Path(track_archive.parent).mkdir(parents=True, exist_ok=True) + return track_archive @classmethod def get_credentials_location(cls) -> str: - return PurePath(Path.cwd()).joinpath(cls.get(CREDENTIALS_LOCATION)) + if cls.get(CREDENTIALS_LOCATION) == '': + system_paths = { + 'win32': Path.home() / 'AppData/Roaming/Zotify', + 'linux': Path.home() / '.local/share/zotify', + 'darwin': Path.home() / 'Library/Application Support/Zotify' + } + if sys.platform not in system_paths: + credentials_location = PurePath(Path.cwd() / '.zotify/credentials.json') + else: + credentials_location = PurePath(system_paths[sys.platform] / 'credentials.json') + else: + credentials_location = PurePath(Path.cwd()).joinpath(cls.get(CREDENTIALS_LOCATION)) + Path(credentials_location.parent).mkdir(parents=True, exist_ok=True) + return credentials_location @classmethod def get_temp_download_dir(cls) -> str: diff --git a/zotify/podcast.py b/zotify/podcast.py index 63863cf..67f1b41 100644 --- a/zotify/podcast.py +++ b/zotify/podcast.py @@ -7,7 +7,7 @@ from librespot.metadata import EpisodeId from zotify.const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS from zotify.termoutput import PrintChannel, Printer -from zotify.utils import create_download_directory, fix_filename, convert_audio_format +from zotify.utils import create_download_directory, fix_filename from zotify.zotify import Zotify from zotify.loader import Loader @@ -24,7 +24,7 @@ def get_episode_info(episode_id_str) -> Tuple[Optional[str], Optional[str]]: duration_ms = info[DURATION_MS] if ERROR in info: return None, None - return fix_filename(info[SHOW][NAME]), duration_ms, fix_filename(info[NAME]) + return fix_filename(info[SHOW][NAME]), duration_ms, fix_filename(info[NAME]) def get_show_episodes(show_id_str) -> list: @@ -47,7 +47,6 @@ def get_show_episodes(show_id_str) -> list: def download_podcast_directly(url, filename): import functools - # import pathlib import shutil import requests from tqdm.auto import tqdm @@ -59,7 +58,6 @@ def download_podcast_directly(url, filename): f"Request to {url} returned status code {r.status_code}") file_size = int(r.headers.get('Content-Length', 0)) - # path = pathlib.Path(filename).expanduser().resolve() path = Path(filename).expanduser().resolve() path.parent.mkdir(parents=True, exist_ok=True) diff --git a/zotify/track.py b/zotify/track.py index b6b0019..a3fcef4 100644 --- a/zotify/track.py +++ b/zotify/track.py @@ -1,4 +1,3 @@ -# import os from pathlib import Path, PurePath import re import time @@ -152,8 +151,6 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba if not check_id and check_name: c = len([file for file in Path(filedir).iterdir() if re.search(f'^{filename}_', str(file))]) + 1 - # fname = os.path.splitext(os.path.basename(filename))[0] - # ext = os.path.splitext(os.path.basename(filename))[1] fname = PurePath(PurePath(filename).name).parent ext = PurePath(PurePath(filename).name).suffix @@ -252,7 +249,6 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba def convert_audio_format(filename) -> None: """ Converts raw audio into playable file """ - # temp_filename = f'{os.path.splitext(filename)[0]}.tmp' temp_filename = f'{PurePath(filename).parent}.tmp' Path(filename).replace(temp_filename) diff --git a/zotify/utils.py b/zotify/utils.py index 0d051d6..05084c2 100644 --- a/zotify/utils.py +++ b/zotify/utils.py @@ -36,7 +36,7 @@ def get_previously_downloaded() -> List[str]: """ Returns list of all time downloaded songs """ ids = [] - archive_path = Zotify.CONFIG.get_song_archive() + archive_path = Zotify.CONFIG.get_track_archive() if Path(archive_path).exists(): with open(archive_path, 'r', encoding='utf-8') as f: @@ -48,7 +48,7 @@ def get_previously_downloaded() -> List[str]: def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str) -> None: """ Adds song id to all time installed songs archive """ - archive_path = Zotify.CONFIG.get_song_archive() + archive_path = Zotify.CONFIG.get_track_archive() if Path(archive_path).exists(): with open(archive_path, 'a', encoding='utf-8') as file: @@ -109,11 +109,12 @@ def split_input(selection) -> List[str]: def splash() -> str: """ Displays splash screen """ return """ -███████ ██████ ████████ ██ ███████ ██ ██ - ███ ██ ██ ██ ██ ██ ██ ██ - ███ ██ ██ ██ ██ █████ ████ - ███ ██ ██ ██ ██ ██ ██ -███████ ██████ ██ ██ ██ ██ +███████╗ ██████╗ ████████╗██╗███████╗██╗ ██╗ +╚══███╔╝██╔═══██╗╚══██╔══╝██║██╔════╝╚██╗ ██╔╝ + ███╔╝ ██║ ██║ ██║ ██║█████╗ ╚████╔╝ + ███╔╝ ██║ ██║ ██║ ██║██╔══╝ ╚██╔╝ +███████╗╚██████╔╝ ██║ ██║██║ ██║ +╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ """ diff --git a/zotify/zotify.py b/zotify/zotify.py index d963359..dd40d6d 100644 --- a/zotify/zotify.py +++ b/zotify/zotify.py @@ -1,7 +1,5 @@ -import os -import os.path from pathlib import Path -from getpass import getpass +from pwinput import pwinput import time import requests from librespot.audio.decoders import VorbisOnlyAudioQuality @@ -29,7 +27,8 @@ class Zotify: if Path(cred_location).is_file(): try: - cls.SESSION = Session.Builder().stored_file(cred_location).create() + conf = Session.Configuration.Builder().set_store_credentials(False).build() + cls.SESSION = Session.Builder(conf).stored_file(cred_location).create() return except RuntimeError: pass @@ -37,7 +36,7 @@ class Zotify: user_name = '' while len(user_name) == 0: user_name = input('Username: ') - password = getpass() + password = pwinput(prompt='Password: ', mask='*') try: conf = Session.Configuration.Builder().set_stored_credential_file(cred_location).build() cls.SESSION = Session.Builder(conf).user_pass(user_name, password).create() @@ -80,7 +79,10 @@ class Zotify: headers = cls.get_auth_header() response = requests.get(url, headers=headers) responsetext = response.text - responsejson = response.json() + try: + responsejson = response.json() + except requests.exceptions.JSONDecodeError: + responsejson = {} if 'error' in responsejson: if tryCount < (cls.CONFIG.get_retry_attempts() - 1): From 1e48861df366683e3021a735e70ab4a7499e90c8 Mon Sep 17 00:00:00 2001 From: logykk Date: Tue, 15 Feb 2022 19:07:45 +1300 Subject: [PATCH 4/4] Zotify 0.6 --- CHANGELOG.md | 15 +++++---- README.md | 73 +++++++++++++++++++++++++++--------------- zotify/__main__.py | 2 +- zotify/app.py | 17 ++++++---- zotify/config.py | 80 +++++++++++++++++++++++----------------------- zotify/podcast.py | 2 +- zotify/track.py | 43 ++++++++++++++----------- zotify/utils.py | 4 +-- zotify/zotify.py | 2 +- 9 files changed, 136 insertions(+), 102 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ffec3..7fa855c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,25 @@ # Changelog ## v0.6 **General changes** -- Switched from os.path to pathlib -- Renamed .song_archive to track_archive +- Added "DOWNLOAD_QUALITY" config option. This can be "normal" (96kbks), "high" (160kpbs), "very-high" (320kpbs, premium only) or "auto" which selects the highest format available for your account automatically. +- The "FORCE_PREMIUM" option has been removed, the same result can be achieved with `--download-quality="very-high"`. +- The "BITRATE" option has been renamed "TRANSCODE_BITRATE" as it now only effects transcodes +- FFmpeg is now semi-optional, not having it installed means you are limited to saving music as ogg vorbis. - Zotify can now be installed with `pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip` - Zotify can be ran from any directory with `zotify [args]`, you no longer need to prefix "python" in the command. - The -s option now takes search input as a command argument, it will still promt you if no search is given. - The -ls/--liked-songs option has been shrotened to -l/--liked, +- Singles are now stored in their own folders under the artist folder +- Fixed default config not loading on first run +- Now shows asterisks when entering password +- Switched from os.path to pathlib - New default config locations: - Windows: `%AppData%\Roaming\Zotify\config.json` - Linux: `~/.config/zotify/config.json` - macOS: `~/Library/Application Support/Zotify/config.json` - Other/Undetected: `.zotify/config.json` - You can still use `--config-location` to specify a different location. -- New default config locations: +- New default credential locations: - Windows: `%AppData%\Roaming\Zotify\credentials.json` - Linux: `~/.local/share/zotify/credentials.json` - macOS: `~/Library/Application Support/Zotify/credentials.json` @@ -24,9 +30,6 @@ - Linux & macOS: `~/Music/Zotify Music/` & `~/Music/Zotify Podcasts/` - Other/Undetected: `./Zotify Music/` & `./Zotify Podcasts/` - You can still use `--root-path` and `--root-podcast-path` respectively to specify a differnt location -- Singles are now stored in their own folders -- Fixed default config not loading on first run -- Now shows asterisks when entering password **Docker** - Dockerfile is currently broken, it will be fixed soon. \ diff --git a/README.md b/README.md index 297f179..a81d268 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Zotify -### A music and podcast downloader needing only a python interpreter and ffmpeg. +### A highly customizable music and podcast downloader.

@@ -8,20 +8,33 @@ [Discord Server](https://discord.gg/XDYsFRTUjE) +### Featues + - Downloads at up to 320kbps* + - Downloads directly from the source** + - Downloads podcasts, playlists, liked songs, albums, artists, singles. + - Option to download in real time to appear more legitimate*** + - Supports multiple audio formats + - Download directly from URL or use built-in in search + - Bulk downloads from a list of URLs in a text file or parsed directly as arguments + +*Free accounts are limited to 160kbps. \ +**Audio files are NOT substituted with ones from other sources such as YouTube or Deezer, they are sourced directly. \ +***'real time' refers to downloading at the speed it would normally be streamed at (the duration of the track). + ### Install ``` Dependencies: - Python 3.9 or greater -- ffmpeg* +- FFmpeg* Installation: python -m pip install https://gitlab.com/team-zotify/zotify/-/archive/main/zotify-main.zip ``` -\*Windows users can download the binaries from [ffmpeg.org](https://ffmpeg.org) and add them to %PATH%. Mac users can install it via [Homebrew](https://brew.sh) by running `brew install ffmpeg`. Linux users should already know how to install ffmpeg, I don't want to add instructions for every package manager. +\*Zotify will work without FFmpeg but transcoding will be unavailable. ### Command line usage @@ -44,29 +57,34 @@ Be aware you have to set boolean values in the commandline like this: `--downloa | Key (config) | commandline parameter | Description |------------------------------|----------------------------------|---------------------------------------------------------------------| -| ROOT_PATH | --root-path | directory where Zotify saves music -| ROOT_PODCAST_PATH | --root-podcast-path | directory where Zotify saves podcasts -| SKIP_EXISTING_FILES | --skip-existing-files | Skip songs with the same name -| SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | Use a song_archive file to skip previously downloaded songs -| DOWNLOAD_FORMAT | --download-format | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis) -| FORCE_PREMIUM | --force-premium | Force the use of high quality downloads (only with premium accounts) -| ANTI_BAN_WAIT_TIME | --anti-ban-wait-time | The wait time between bulk downloads -| OVERRIDE_AUTO_WAIT | --override-auto-wait | Totally disable wait time between songs with the risk of instability -| CHUNK_SIZE | --chunk-size | Chunk size for downloading -| SPLIT_ALBUM_DISCS | --split-album-discs | Saves each disk in its own folder -| DOWNLOAD_REAL_TIME | --download-real-time | Downloads songs as fast as they would be played, should prevent account bans. -| LANGUAGE | --language | Language for spotify metadata -| BITRATE | --bitrate | Overwrite the bitrate for ffmpeg encoding -| SONG_ARCHIVE | --song-archive | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED | CREDENTIALS_LOCATION | --credentials-location | The location of the credentials.json | OUTPUT | --output | The output location/format (see below) -| PRINT_SPLASH | --print-splash | Print the splash message -| PRINT_SKIPS | --print-skips | Print messages if a song is being skipped -| PRINT_DOWNLOAD_PROGRESS | --print-download-progress | Print the download/playlist progress bars -| PRINT_ERRORS | --print-errors | Print errors +| SONG_ARCHIVE | --song-archive | The song_archive file for SKIP_PREVIOUSLY_DOWNLOADED +| ROOT_PATH | --root-path | Directory where Zotify saves music +| ROOT_PODCAST_PATH | --root-podcast-path | Directory where Zotify saves podcasts +| SPLIT_ALBUM_DISCS | --split-album-discs | Saves each disk in its own folder +| MD_ALLGENRES | --md-allgenres | Save all relevant genres in metadata +| MD_GENREDELIMITER | --md-genredelimiter | Delimiter character used to split genres in metadata +| DOWNLOAD_FORMAT | --download-format | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis) +| DOWNLOAD_QUALITY | --download-quality | Audio quality of downloaded songs (normal, high, very-high*) +| TRANSCODE_BITRATE | --transcode-bitrate | Overwrite the bitrate for ffmpeg encoding +| SKIP_EXISTING_FILES | --skip-existing-files | Skip songs with the same name +| SKIP_PREVIOUSLY_DOWNLOADED | --skip-previously-downloaded | Use a song_archive file to skip previously downloaded songs +| RETRY_ATTEMPTS | --retry-attempts | Number of times Zotify will retry a failed request +| BULK_WAIT_TIME | --bulk-wait-time | The wait time between bulk downloads +| OVERRIDE_AUTO_WAIT | --override-auto-wait | Totally disable wait time between songs with the risk of instability +| CHUNK_SIZE | --chunk-size | Chunk size for downloading +| DOWNLOAD_REAL_TIME | --download-real-time | Downloads songs as fast as they would be played, should prevent account bans. +| LANGUAGE | --language | Language for spotify metadata +| PRINT_SPLASH | --print-splash | Show the Zotify logo at startup +| PRINT_SKIPS | --print-skips | Show messages if a song is being skipped +| PRINT_DOWNLOAD_PROGRESS | --print-download-progress | Show download/playlist progress bars +| PRINT_ERRORS | --print-errors | Show errors | PRINT_DOWNLOADS | --print-downloads | Print messages when a song is finished downloading | TEMP_DOWNLOAD_DIR | --temp-download-dir | Download tracks to a temporary directory first +*very-high is limited to premium only + ### Output format With the option `OUTPUT` (or the commandline parameter `--output`) you can specify the output location and format. @@ -106,6 +124,11 @@ Create and run a container from the image: docker run --rm -u $(id -u):$(id -g) -v "$PWD/zotify:/app" -v "$PWD/config.json:/config.json" -v "$PWD/Zotify Music:/Zotify Music" -v "$PWD/Zotify Podcasts:/Zotify Podcasts" -it zotify ``` +### What do I do if I see "Your session has been terminated"? + +If you see this, don't worry! Just try logging back in. If you see the incorrect username or password error, reset your password and you should be able to log back in. + + ### Will my account get banned if I use this tool? Currently no user has reported their account getting banned after using Zotify. @@ -114,11 +137,9 @@ It is recommended you use Zotify with a burner account. Alternatively, there is a configuration option labled ```DOWNLOAD_REAL_TIME```, this limits the download speed to the duration of the song being downloaded thus appearing less suspicious. This option is much slower and is only recommended for premium users who wish to download songs in 320kbps without buying premium on a burner account. -**Use Zotify at your own risk**, the developers of Zotify are not responsible if your account gets banned. - -### What do I do if I see "Your session has been terminated"? - -If you see this, don't worry! Just try logging back in. If you see the incorrect username or password error, reset your password and you should be able to log back in. +### Disclaimer +Zotify is intended to be used in compliance with DMCA, Section 1201, for educational, private and fair use. \ +Zotify contributors are not responsible for any misuse of the program or source code. ### Contributing diff --git a/zotify/__main__.py b/zotify/__main__.py index e92c906..22539dd 100644 --- a/zotify/__main__.py +++ b/zotify/__main__.py @@ -12,7 +12,7 @@ from zotify.config import CONFIG_VALUES def main(): parser = argparse.ArgumentParser(prog='zotify', - description='A music and podcast downloader needing only a python interpreter and ffmpeg.') + description='A music and podcast downloader needing only python and ffmpeg.') parser.add_argument('-ns', '--no-splash', action='store_true', help='Suppress the splash screen when loading.') diff --git a/zotify/app.py b/zotify/app.py index 7ba1931..b8659c4 100644 --- a/zotify/app.py +++ b/zotify/app.py @@ -5,6 +5,7 @@ from pathlib import Path from zotify.album import download_album, download_artist_albums from zotify.const import TRACK, NAME, ID, ARTIST, ARTISTS, ITEMS, TRACKS, EXPLICIT, ALBUM, ALBUMS, \ OWNER, PLAYLIST, PLAYLISTS, DISPLAY_NAME, TYPE +from zotify.loader import Loader from zotify.playlist import get_playlist_songs, get_playlist_info, download_from_user_playlist, download_playlist from zotify.podcast import download_episode, get_show_episodes from zotify.termoutput import Printer, PrintChannel @@ -17,16 +18,20 @@ 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()) - if Zotify.check_premium(): - Printer.print(PrintChannel.WARNINGS, '[ DETECTED PREMIUM ACCOUNT - USING VERY_HIGH QUALITY ]\n') - Zotify.DOWNLOAD_QUALITY = AudioQuality.VERY_HIGH - else: - Printer.print(PrintChannel.WARNINGS, '[ DETECTED FREE ACCOUNT - USING HIGH QUALITY ]\n') - Zotify.DOWNLOAD_QUALITY = AudioQuality.HIGH + quality_options = { + 'auto': AudioQuality.VERY_HIGH if Zotify.check_premium() else AudioQuality.HIGH, + 'normal': AudioQuality.NORMAL, + 'high': AudioQuality.HIGH, + 'very_high': AudioQuality.VERY_HIGH + } + Zotify.DOWNLOAD_QUALITY = quality_options[Zotify.CONFIG.get_download_quality()] if args.download: urls = [] diff --git a/zotify/config.py b/zotify/config.py index fec8d90..8b55597 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -6,18 +6,18 @@ from typing import Any ROOT_PATH = 'ROOT_PATH' ROOT_PODCAST_PATH = 'ROOT_PODCAST_PATH' -SKIP_EXISTING_FILES = 'SKIP_EXISTING_FILES' +SKIP_EXISTING = 'SKIP_EXISTING' SKIP_PREVIOUSLY_DOWNLOADED = 'SKIP_PREVIOUSLY_DOWNLOADED' DOWNLOAD_FORMAT = 'DOWNLOAD_FORMAT' -FORCE_PREMIUM = 'FORCE_PREMIUM' -ANTI_BAN_WAIT_TIME = 'ANTI_BAN_WAIT_TIME' +BULK_WAIT_TIME = 'BULK_WAIT_TIME' OVERRIDE_AUTO_WAIT = 'OVERRIDE_AUTO_WAIT' CHUNK_SIZE = 'CHUNK_SIZE' SPLIT_ALBUM_DISCS = 'SPLIT_ALBUM_DISCS' DOWNLOAD_REAL_TIME = 'DOWNLOAD_REAL_TIME' LANGUAGE = 'LANGUAGE' -BITRATE = 'BITRATE' -TRACK_ARCHIVE = 'TRACK_ARCHIVE' +DOWNLOAD_QUALITY = 'DOWNLOAD_QUALITY' +TRANSCODE_BITRATE = 'TRANSCODE_BITRATE' +SONG_ARCHIVE = 'SONG_ARCHIVE' CREDENTIALS_LOCATION = 'CREDENTIALS_LOCATION' OUTPUT = 'OUTPUT' PRINT_SPLASH = 'PRINT_SPLASH' @@ -35,23 +35,25 @@ RETRY_ATTEMPTS = 'RETRY_ATTEMPTS' CONFIG_VERSION = 'CONFIG_VERSION' CONFIG_VALUES = { - ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' }, - ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' }, - SKIP_EXISTING_FILES: { 'default': 'True', 'type': bool, 'arg': '--skip-existing-files' }, - SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' }, - RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' }, - DOWNLOAD_FORMAT: { 'default': 'ogg', 'type': str, 'arg': '--download-format' }, - FORCE_PREMIUM: { 'default': 'False', 'type': bool, 'arg': '--force-premium' }, - ANTI_BAN_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--anti-ban-wait-time' }, - OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' }, - CHUNK_SIZE: { 'default': '50000', 'type': int, 'arg': '--chunk-size' }, - SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' }, - DOWNLOAD_REAL_TIME: { 'default': 'False', 'type': bool, 'arg': '--download-real-time' }, - LANGUAGE: { 'default': 'en', 'type': str, 'arg': '--language' }, - BITRATE: { 'default': '', 'type': str, 'arg': '--bitrate' }, - TRACK_ARCHIVE: { 'default': '', 'type': str, 'arg': '--track-archive' }, CREDENTIALS_LOCATION: { 'default': '', 'type': str, 'arg': '--credentials-location' }, OUTPUT: { 'default': '', 'type': str, 'arg': '--output' }, + SONG_ARCHIVE: { 'default': '', 'type': str, 'arg': '--song-archive' }, + ROOT_PATH: { 'default': '', 'type': str, 'arg': '--root-path' }, + ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' }, + SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' }, + 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' }, + DOWNLOAD_QUALITY: { 'default': 'auto', 'type': str, 'arg': '--download-quality' }, + TRANSCODE_BITRATE: { 'default': 'auto', 'type': str, 'arg': '--transcode-bitrate' }, + SKIP_EXISTING: { 'default': 'True', 'type': bool, 'arg': '--skip-existing' }, + SKIP_PREVIOUSLY_DOWNLOADED: { 'default': 'False', 'type': bool, 'arg': '--skip-previously-downloaded' }, + RETRY_ATTEMPTS: { 'default': '5', 'type': int, 'arg': '--retry-attemps' }, + BULK_WAIT_TIME: { 'default': '1', 'type': int, 'arg': '--bulk-wait-time' }, + OVERRIDE_AUTO_WAIT: { 'default': 'False', 'type': bool, 'arg': '--override-auto-wait' }, + CHUNK_SIZE: { 'default': '50000', 'type': int, 'arg': '--chunk-size' }, + DOWNLOAD_REAL_TIME: { 'default': 'False', 'type': bool, 'arg': '--download-real-time' }, + LANGUAGE: { 'default': 'en', 'type': str, 'arg': '--language' }, PRINT_SPLASH: { 'default': 'False', 'type': bool, 'arg': '--print-splash' }, PRINT_SKIPS: { 'default': 'True', 'type': bool, 'arg': '--print-skips' }, PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' }, @@ -60,15 +62,13 @@ CONFIG_VALUES = { PRINT_API_ERRORS: { 'default': 'False', 'type': bool, 'arg': '--print-api-errors' }, PRINT_PROGRESS_INFO: { 'default': 'True', 'type': bool, 'arg': '--print-progress-info' }, PRINT_WARNINGS: { 'default': 'True', 'type': bool, 'arg': '--print-warnings' }, - MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' }, - MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' }, TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' } } 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}/{song_name}/{artist} - {song_name}.{ext}' OUTPUT_DEFAULT_ALBUM = '{artist}/{album}/{album_num} - {artist} - {song_name}.{ext}' @@ -164,8 +164,8 @@ class Config: return root_podcast_path @classmethod - def get_skip_existing_files(cls) -> bool: - return cls.get(SKIP_EXISTING_FILES) + def get_skip_existing(cls) -> bool: + return cls.get(SKIP_EXISTING) @classmethod def get_skip_previously_downloaded(cls) -> bool: @@ -183,17 +183,13 @@ class Config: def get_override_auto_wait(cls) -> bool: return cls.get(OVERRIDE_AUTO_WAIT) - @classmethod - def get_force_premium(cls) -> bool: - return cls.get(FORCE_PREMIUM) - @classmethod def get_download_format(cls) -> str: return cls.get(DOWNLOAD_FORMAT) @classmethod - def get_anti_ban_wait_time(cls) -> int: - return cls.get(ANTI_BAN_WAIT_TIME) + def get_bulk_wait_time(cls) -> int: + return cls.get(BULK_WAIT_TIME) @classmethod def get_language(cls) -> str: @@ -204,25 +200,29 @@ class Config: return cls.get(DOWNLOAD_REAL_TIME) @classmethod - def get_bitrate(cls) -> str: - return cls.get(BITRATE) + def get_download_quality(cls) -> str: + return cls.get(DOWNLOAD_QUALITY) @classmethod - def get_track_archive(cls) -> str: - if cls.get(TRACK_ARCHIVE) == '': + def get_transcode_bitrate(cls) -> str: + return cls.get(TRANSCODE_BITRATE) + + @classmethod + def get_song_archive(cls) -> str: + if cls.get(SONG_ARCHIVE) == '': system_paths = { 'win32': Path.home() / 'AppData/Roaming/Zotify', 'linux': Path.home() / '.local/share/zotify', 'darwin': Path.home() / 'Library/Application Support/Zotify' } if sys.platform not in system_paths: - track_archive = PurePath(Path.cwd() / '.zotify/track_archive') + song_archive = PurePath(Path.cwd() / '.zotify/.song_archive') else: - track_archive = PurePath(system_paths[sys.platform] / 'track_archive') + song_archive = PurePath(system_paths[sys.platform] / '.song_archive') else: - track_archive = PurePath(Path(cls.get(TRACK_ARCHIVE)).expanduser()) - Path(track_archive.parent).mkdir(parents=True, exist_ok=True) - return track_archive + song_archive = PurePath(Path(cls.get(SONG_ARCHIVE)).expanduser()) + Path(song_archive.parent).mkdir(parents=True, exist_ok=True) + return song_archive @classmethod def get_credentials_location(cls) -> str: diff --git a/zotify/podcast.py b/zotify/podcast.py index 67f1b41..cf7a37e 100644 --- a/zotify/podcast.py +++ b/zotify/podcast.py @@ -101,7 +101,7 @@ def download_episode(episode_id) -> None: if ( Path(filepath).isfile() and Path(filepath).stat().st_size == total_size - and Zotify.CONFIG.get_skip_existing_files() + and Zotify.CONFIG.get_skip_existing() ): Printer.print(PrintChannel.SKIPS, "\n### SKIPPING: " + podcast_name + " - " + episode_name + " (EPISODE ALREADY EXISTS) ###") prepare_download_loader.stop() diff --git a/zotify/track.py b/zotify/track.py index a3fcef4..779ebb9 100644 --- a/zotify/track.py +++ b/zotify/track.py @@ -6,7 +6,7 @@ from typing import Any, Tuple, List from librespot.audio.decoders import AudioQuality from librespot.metadata import TrackId -from ffmpy import FFmpeg +import ffmpy from zotify.const import TRACKS, ALBUM, GENRES, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \ RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS, HREF @@ -171,7 +171,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba prepare_download_loader.stop() Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG IS UNAVAILABLE) ###' + "\n") else: - if check_id and check_name and Zotify.CONFIG.get_skip_existing_files(): + if check_id and check_name and Zotify.CONFIG.get_skip_existing(): prepare_download_loader.stop() Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS) ###' + "\n") @@ -231,8 +231,8 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba if not check_id: add_to_directory_song_ids(filedir, scraped_song_id, PurePath(filename).name, artists[0], name) - if not Zotify.CONFIG.get_anti_ban_wait_time(): - time.sleep(Zotify.CONFIG.get_anti_ban_wait_time()) + if not Zotify.CONFIG.get_bulk_wait_time(): + time.sleep(Zotify.CONFIG.get_bulk_wait_time()) except Exception as e: Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###') Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id)) @@ -255,12 +255,14 @@ def convert_audio_format(filename) -> None: download_format = Zotify.CONFIG.get_download_format().lower() file_codec = CODEC_MAP.get(download_format, 'copy') if file_codec != 'copy': - bitrate = Zotify.CONFIG.get_bitrate() - if not bitrate: - if Zotify.DOWNLOAD_QUALITY == AudioQuality.VERY_HIGH: - bitrate = '320k' - else: - bitrate = '160k' + bitrate = Zotify.CONFIG.get_transcode_bitrate() + bitrates = { + 'auto': '320k' if Zotify.check_premium() else '160k', + 'normal': '96k', + 'high': '160k', + 'very_high': '320k' + } + bitrate = bitrates[Zotify.CONFIG.get_download_quality()] else: bitrate = None @@ -268,14 +270,17 @@ def convert_audio_format(filename) -> None: if bitrate: output_params += ['-b:a', bitrate] - ff_m = FFmpeg( - global_options=['-y', '-hide_banner', '-loglevel error'], - inputs={temp_filename: None}, - outputs={filename: output_params} - ) + try: + ff_m = ffmpy.FFmpeg( + global_options=['-y', '-hide_banner', '-loglevel error'], + inputs={temp_filename: None}, + outputs={filename: output_params} + ) + with Loader(PrintChannel.PROGRESS_INFO, "Converting file..."): + ff_m.run() - with Loader(PrintChannel.PROGRESS_INFO, "Converting file..."): - ff_m.run() + if Path(temp_filename).exists(): + Path(temp_filename).unlink() - if Path(temp_filename).exists(): - 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 diff --git a/zotify/utils.py b/zotify/utils.py index 05084c2..df8a661 100644 --- a/zotify/utils.py +++ b/zotify/utils.py @@ -36,7 +36,7 @@ def get_previously_downloaded() -> List[str]: """ Returns list of all time downloaded songs """ ids = [] - archive_path = Zotify.CONFIG.get_track_archive() + archive_path = Zotify.CONFIG.get_song_archive() if Path(archive_path).exists(): with open(archive_path, 'r', encoding='utf-8') as f: @@ -48,7 +48,7 @@ def get_previously_downloaded() -> List[str]: def add_to_archive(song_id: str, filename: str, author_name: str, song_name: str) -> None: """ Adds song id to all time installed songs archive """ - archive_path = Zotify.CONFIG.get_track_archive() + archive_path = Zotify.CONFIG.get_song_archive() if Path(archive_path).exists(): with open(archive_path, 'a', encoding='utf-8') as file: diff --git a/zotify/zotify.py b/zotify/zotify.py index dd40d6d..1712d22 100644 --- a/zotify/zotify.py +++ b/zotify/zotify.py @@ -97,4 +97,4 @@ class Zotify: @classmethod def check_premium(cls) -> bool: """ If user has spotify premium return true """ - return (cls.SESSION.get_user_attribute(TYPE) == PREMIUM) or cls.CONFIG.get_force_premium() + return (cls.SESSION.get_user_attribute(TYPE) == PREMIUM)