Merge pull request #219 from leonbohmann/dev-genre-tags

Added querying of genres of each track
This commit is contained in:
Logykk 2021-12-02 17:42:52 +13:00 committed by GitHub
commit 606125db07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 159 additions and 19 deletions

View File

@ -25,7 +25,10 @@ PRINT_SKIPS = 'PRINT_SKIPS'
PRINT_DOWNLOAD_PROGRESS = 'PRINT_DOWNLOAD_PROGRESS' PRINT_DOWNLOAD_PROGRESS = 'PRINT_DOWNLOAD_PROGRESS'
PRINT_ERRORS = 'PRINT_ERRORS' PRINT_ERRORS = 'PRINT_ERRORS'
PRINT_DOWNLOADS = 'PRINT_DOWNLOADS' PRINT_DOWNLOADS = 'PRINT_DOWNLOADS'
PRINT_API_ERRORS = 'PRINT_API_ERRORS'
TEMP_DOWNLOAD_DIR = 'TEMP_DOWNLOAD_DIR' TEMP_DOWNLOAD_DIR = 'TEMP_DOWNLOAD_DIR'
MD_ALLGENRES = 'MD_ALLGENRES'
MD_GENREDELIMITER = 'MD_GENREDELIMITER'
CONFIG_VALUES = { CONFIG_VALUES = {
ROOT_PATH: { 'default': '../ZSpotify Music/', 'type': str, 'arg': '--root-path' }, ROOT_PATH: { 'default': '../ZSpotify Music/', 'type': str, 'arg': '--root-path' },
@ -49,7 +52,10 @@ CONFIG_VALUES = {
PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' }, PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' },
PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' }, PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' },
PRINT_DOWNLOADS: { 'default': 'False', 'type': bool, 'arg': '--print-downloads' }, PRINT_DOWNLOADS: { 'default': 'False', 'type': bool, 'arg': '--print-downloads' },
TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' }, PRINT_API_ERRORS: { 'default': 'False', 'type': bool, 'arg': '--print-api-errors' },
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 = '{playlist}/{artist} - {song_name}.{ext}'
@ -192,7 +198,15 @@ class Config:
if cls.get(TEMP_DOWNLOAD_DIR) == '': if cls.get(TEMP_DOWNLOAD_DIR) == '':
return '' return ''
return os.path.join(cls.get_root_path(), cls.get(TEMP_DOWNLOAD_DIR)) return os.path.join(cls.get_root_path(), cls.get(TEMP_DOWNLOAD_DIR))
@classmethod
def get_allGenres(cls) -> bool:
return cls.get(MD_ALLGENRES)
@classmethod
def get_allGenresDelimiter(cls) -> bool:
return cls.get(MD_GENREDELIMITER)
@classmethod @classmethod
def get_output(cls, mode: str) -> str: def get_output(cls, mode: str) -> str:
v = cls.get(OUTPUT) v = cls.get(OUTPUT)

View File

@ -20,6 +20,10 @@ ARTISTS = 'artists'
ALBUMARTIST = 'albumartist' ALBUMARTIST = 'albumartist'
GENRES = 'genres'
GENRE = 'genre'
ARTWORK = 'artwork' ARTWORK = 'artwork'
TRACKS = 'tracks' TRACKS = 'tracks'

View File

@ -1,7 +1,7 @@
from enum import Enum from enum import Enum
from tqdm import tqdm from tqdm import tqdm
from config import PRINT_SPLASH, PRINT_SKIPS, PRINT_DOWNLOAD_PROGRESS, PRINT_ERRORS, PRINT_DOWNLOADS from config import PRINT_SPLASH, PRINT_SKIPS, PRINT_DOWNLOAD_PROGRESS, PRINT_ERRORS, PRINT_DOWNLOADS, PRINT_API_ERRORS
from zspotify import ZSpotify from zspotify import ZSpotify
@ -11,6 +11,7 @@ class PrintChannel(Enum):
DOWNLOAD_PROGRESS = PRINT_DOWNLOAD_PROGRESS DOWNLOAD_PROGRESS = PRINT_DOWNLOAD_PROGRESS
ERRORS = PRINT_ERRORS ERRORS = PRINT_ERRORS
DOWNLOADS = PRINT_DOWNLOADS DOWNLOADS = PRINT_DOWNLOADS
API_ERRORS = PRINT_API_ERRORS
class Printer: class Printer:

View File

@ -1,5 +1,6 @@
import os import os
import re import re
from threading import Thread
import time import time
import uuid import uuid
from typing import Any, Tuple, List from typing import Any, Tuple, List
@ -8,7 +9,7 @@ from librespot.audio.decoders import AudioQuality
from librespot.metadata import TrackId from librespot.metadata import TrackId
from ffmpy import FFmpeg from ffmpy import FFmpeg
from const import TRACKS, ALBUM, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \ from const import TRACKS, ALBUM, GENRES, GENRE, 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 RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS
from termoutput import Printer, PrintChannel from termoutput import Printer, PrintChannel
from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \ from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \
@ -16,6 +17,8 @@ from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_down
from zspotify import ZSpotify from zspotify import ZSpotify
import traceback import traceback
from utils import Loader
def get_saved_tracks() -> list: def get_saved_tracks() -> list:
""" Returns user's saved tracks """ """ Returns user's saved tracks """
songs = [] songs = []
@ -33,17 +36,32 @@ def get_saved_tracks() -> list:
return songs return songs
def get_song_info(song_id) -> Tuple[List[str], str, str, Any, Any, Any, Any, Any, Any, int]: def get_song_info(song_id) -> Tuple[List[str], List[str], str, str, Any, Any, Any, Any, Any, Any, int]:
""" Retrieves metadata for downloaded songs """ """ Retrieves metadata for downloaded songs """
(raw, info) = ZSpotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token') with Loader("Fetching track information..."):
(raw, info) = ZSpotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token')
if not TRACKS in info: if not TRACKS in info:
raise ValueError(f'Invalid response from TRACKS_URL:\n{raw}') raise ValueError(f'Invalid response from TRACKS_URL:\n{raw}')
try: try:
artists = [] artists = []
genres = []
for data in info[TRACKS][0][ARTISTS]: for data in info[TRACKS][0][ARTISTS]:
artists.append(data[NAME]) artists.append(data[NAME])
# query artist genres via href, which will be the api url
with Loader("Fetching artist information..."):
(raw, artistInfo) = ZSpotify.invoke_url(f'{data["href"]}')
if ZSpotify.CONFIG.get_allGenres() and len(artistInfo[GENRES]) > 0:
for genre in artistInfo[GENRES]:
genres.append(genre)
elif len(artistInfo[GENRES]) > 0:
genres.append(artistInfo[GENRES][0])
if len(genres) == 0:
Printer.print(PrintChannel.SKIPS, '### No Genre found.')
genres.append('')
album_name = info[TRACKS][0][ALBUM][NAME] album_name = info[TRACKS][0][ALBUM][NAME]
name = info[TRACKS][0][NAME] name = info[TRACKS][0][NAME]
image_url = info[TRACKS][0][ALBUM][IMAGES][0][URL] image_url = info[TRACKS][0][ALBUM][IMAGES][0][URL]
@ -54,7 +72,7 @@ def get_song_info(song_id) -> Tuple[List[str], str, str, Any, Any, Any, Any, Any
is_playable = info[TRACKS][0][IS_PLAYABLE] is_playable = info[TRACKS][0][IS_PLAYABLE]
duration_ms = info[TRACKS][0][DURATION_MS] duration_ms = info[TRACKS][0][DURATION_MS]
return artists, album_name, name, image_url, release_year, disc_number, track_number, scraped_song_id, is_playable, duration_ms return artists, genres, album_name, name, image_url, release_year, disc_number, track_number, scraped_song_id, is_playable, duration_ms
except Exception as e: except Exception as e:
raise ValueError(f'Failed to parse TRACKS_URL response: {str(e)}\n{raw}') raise ValueError(f'Failed to parse TRACKS_URL response: {str(e)}\n{raw}')
@ -82,9 +100,12 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
try: try:
output_template = ZSpotify.CONFIG.get_output(mode) output_template = ZSpotify.CONFIG.get_output(mode)
(artists, album_name, name, image_url, release_year, disc_number, (artists, genres, album_name, name, image_url, release_year, disc_number,
track_number, scraped_song_id, is_playable, duration_ms) = get_song_info(track_id) track_number, scraped_song_id, is_playable, duration_ms) = get_song_info(track_id)
prepareDownloadLoader = Loader("Preparing download...");
prepareDownloadLoader.start()
song_name = fix_filename(artists[0]) + ' - ' + fix_filename(name) song_name = fix_filename(artists[0]) + ' - ' + fix_filename(name)
for k in extra_keys: for k in extra_keys:
@ -131,12 +152,15 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
else: else:
try: try:
if not is_playable: if not is_playable:
prepareDownloadLoader.stop();
Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG IS UNAVAILABLE) ###' + "\n") Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG IS UNAVAILABLE) ###' + "\n")
else: else:
if check_id and check_name and ZSpotify.CONFIG.get_skip_existing_files(): if check_id and check_name and ZSpotify.CONFIG.get_skip_existing_files():
prepareDownloadLoader.stop();
Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS) ###' + "\n") Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS) ###' + "\n")
elif check_all_time and ZSpotify.CONFIG.get_skip_previously_downloaded(): elif check_all_time and ZSpotify.CONFIG.get_skip_previously_downloaded():
prepareDownloadLoader.stop();
Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY DOWNLOADED ONCE) ###' + "\n") Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY DOWNLOADED ONCE) ###' + "\n")
else: else:
@ -147,6 +171,8 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
create_download_directory(filedir) create_download_directory(filedir)
total_size = stream.input_stream.size total_size = stream.input_stream.size
prepareDownloadLoader.stop();
time_start = time.time() time_start = time.time()
downloaded = 0 downloaded = 0
with open(filename_temp, 'wb') as file, Printer.progress( with open(filename_temp, 'wb') as file, Printer.progress(
@ -170,7 +196,7 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
time_downloaded = time.time() time_downloaded = time.time()
convert_audio_format(filename_temp) convert_audio_format(filename_temp)
set_audio_tags(filename_temp, artists, name, album_name, release_year, disc_number, track_number) set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number)
set_music_thumbnail(filename_temp, image_url) set_music_thumbnail(filename_temp, image_url)
if filename_temp != filename: if filename_temp != filename:
@ -196,8 +222,8 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n") Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n")
if os.path.exists(filename_temp): if os.path.exists(filename_temp):
os.remove(filename_temp) os.remove(filename_temp)
prepareDownloadLoader.stop()
def convert_audio_format(filename) -> None: def convert_audio_format(filename) -> None:
""" Converts raw audio into playable file """ """ Converts raw audio into playable file """
temp_filename = f'{os.path.splitext(filename)[0]}.tmp' temp_filename = f'{os.path.splitext(filename)[0]}.tmp'
@ -224,6 +250,9 @@ def convert_audio_format(filename) -> None:
inputs={temp_filename: None}, inputs={temp_filename: None},
outputs={filename: output_params} outputs={filename: output_params}
) )
ff_m.run()
with Loader("Converting file..."):
ff_m.run()
if os.path.exists(temp_filename): if os.path.exists(temp_filename):
os.remove(temp_filename) os.remove(temp_filename)

View File

@ -10,7 +10,7 @@ from typing import List, Tuple
import music_tag import music_tag
import requests import requests
from const import ARTIST, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \ from const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \
WINDOWS_SYSTEM, ALBUMARTIST WINDOWS_SYSTEM, ALBUMARTIST
from zspotify import ZSpotify from zspotify import ZSpotify
@ -124,11 +124,12 @@ def clear() -> None:
os.system('clear') os.system('clear')
def set_audio_tags(filename, artists, name, album_name, release_year, disc_number, track_number) -> None: def set_audio_tags(filename, artists, genres, name, album_name, release_year, disc_number, track_number) -> None:
""" sets music_tag metadata """ """ sets music_tag metadata """
tags = music_tag.load_file(filename) tags = music_tag.load_file(filename)
tags[ALBUMARTIST] = artists[0] tags[ALBUMARTIST] = artists[0]
tags[ARTIST] = conv_artist_format(artists) tags[ARTIST] = conv_artist_format(artists)
tags[GENRE] = genres[0] if not ZSpotify.CONFIG.get_allGenres() else ZSpotify.CONFIG.get_allGenresDelimiter().join(genres)
tags[TRACKTITLE] = name tags[TRACKTITLE] = name
tags[ALBUM] = album_name tags[ALBUM] = album_name
tags[YEAR] = release_year tags[YEAR] = release_year
@ -279,3 +280,84 @@ def fmt_seconds(secs: float) -> str:
return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2) return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)
else: else:
return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2) return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)
# load symbol from:
# https://stackoverflow.com/questions/22029562/python-how-to-make-simple-animated-loading-while-process-is-running
# imports
from itertools import cycle
from shutil import get_terminal_size
from threading import Thread
from time import sleep
class Loader:
"""Busy symbol.
Can be called inside a context:
with Loader("This take some Time..."):
# do something
pass
"""
def __init__(self, desc="Loading...", end='', timeout=0.1, mode='std1'):
"""
A loader-like context manager
Args:
desc (str, optional): The loader's description. Defaults to "Loading...".
end (str, optional): Final print. Defaults to "".
timeout (float, optional): Sleep time between prints. Defaults to 0.1.
"""
self.desc = desc
self.end = end
self.timeout = timeout
self._thread = Thread(target=self._animate, daemon=True)
if mode == 'std1':
self.steps = ["", "", "", "", "", "", "", ""]
elif mode == 'std2':
self.steps = ["","","",""]
elif mode == 'std3':
self.steps = ["😐 ","😐 ","😮 ","😮 ","😦 ","😦 ","😧 ","😧 ","🤯 ","💥 ","","\u3000 ","\u3000 ","\u3000 "]
elif mode == 'prog':
self.steps = ["[∙∙∙]","[●∙∙]","[∙●∙]","[∙∙●]","[∙∙∙]"]
self.done = False
def start(self):
self._thread.start()
return self
def _animate(self):
for c in cycle(self.steps):
if self.done:
break
print(f"\r\t{c} {self.desc} ", flush=True, end="")
sleep(self.timeout)
def __enter__(self):
self.start()
def stop(self):
self.done = True
cols = get_terminal_size((80, 20)).columns
print("\r" + " " * cols, end="", flush=True)
if self.end != "":
print(f"\r{self.end}", flush=True)
def __exit__(self, exc_type, exc_value, tb):
# handle exceptions with those variables ^
self.stop()
if __name__ == "__main__":
with Loader("Loading with context manager..."):
for i in range(10):
sleep(0.25)
loader = Loader("Loading with object...", "That was fast!", 0.05).start()
for i in range(10):
sleep(0.25)
loader.stop()

View File

@ -9,7 +9,7 @@ It's like youtube-dl, but for Spotify.
import os import os
import os.path import os.path
from getpass import getpass from getpass import getpass
import time
import requests import requests
from librespot.audio.decoders import VorbisOnlyAudioQuality from librespot.audio.decoders import VorbisOnlyAudioQuality
from librespot.core import Session from librespot.core import Session
@ -19,8 +19,7 @@ from const import TYPE, \
PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ
from config import Config from config import Config
class ZSpotify:
class ZSpotify:
SESSION: Session = None SESSION: Session = None
DOWNLOAD_QUALITY = None DOWNLOAD_QUALITY = None
CONFIG: Config = Config() CONFIG: Config = Config()
@ -82,10 +81,21 @@ class ZSpotify:
return requests.get(url, headers=headers, params=params).json() return requests.get(url, headers=headers, params=params).json()
@classmethod @classmethod
def invoke_url(cls, url): def invoke_url(cls, url, tryCount = 0):
# we need to import that here, otherwise we will get circular imports!
from termoutput import Printer, PrintChannel
headers = cls.get_auth_header() headers = cls.get_auth_header()
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
return response.text, response.json() responseText = response.text
responseJson = response.json()
if 'error' in responseJson and tryCount < 5:
Printer.Print(PrintChannel.API_ERROR, f"Spotify API Error ({responseJson['error']['status']}): {responseJson['error']['message']}")
time.sleep(5)
return cls.invoke_url(url, tryCount + 1)
return responseText, responseJson
@classmethod @classmethod
def check_premium(cls) -> bool: def check_premium(cls) -> bool: