Skip to content

Commit 606125d

Browse files
authored
Merge pull request #219 from leonbohmann/dev-genre-tags
Added querying of genres of each track
2 parents 6f75353 + 58fe9ae commit 606125d

File tree

6 files changed

+159
-19
lines changed

6 files changed

+159
-19
lines changed

zspotify/config.py

+15-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
PRINT_DOWNLOAD_PROGRESS = 'PRINT_DOWNLOAD_PROGRESS'
2626
PRINT_ERRORS = 'PRINT_ERRORS'
2727
PRINT_DOWNLOADS = 'PRINT_DOWNLOADS'
28+
PRINT_API_ERRORS = 'PRINT_API_ERRORS'
2829
TEMP_DOWNLOAD_DIR = 'TEMP_DOWNLOAD_DIR'
30+
MD_ALLGENRES = 'MD_ALLGENRES'
31+
MD_GENREDELIMITER = 'MD_GENREDELIMITER'
2932

3033
CONFIG_VALUES = {
3134
ROOT_PATH: { 'default': '../ZSpotify Music/', 'type': str, 'arg': '--root-path' },
@@ -49,7 +52,10 @@
4952
PRINT_DOWNLOAD_PROGRESS: { 'default': 'True', 'type': bool, 'arg': '--print-download-progress' },
5053
PRINT_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-errors' },
5154
PRINT_DOWNLOADS: { 'default': 'False', 'type': bool, 'arg': '--print-downloads' },
52-
TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' },
55+
PRINT_API_ERRORS: { 'default': 'False', 'type': bool, 'arg': '--print-api-errors' },
56+
MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' },
57+
MD_GENREDELIMITER: { 'default': ';', 'type': str, 'arg': '--md-genredelimiter' },
58+
TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' }
5359
}
5460

5561
OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}'
@@ -192,7 +198,15 @@ def get_temp_download_dir(cls) -> str:
192198
if cls.get(TEMP_DOWNLOAD_DIR) == '':
193199
return ''
194200
return os.path.join(cls.get_root_path(), cls.get(TEMP_DOWNLOAD_DIR))
201+
202+
@classmethod
203+
def get_allGenres(cls) -> bool:
204+
return cls.get(MD_ALLGENRES)
195205

206+
@classmethod
207+
def get_allGenresDelimiter(cls) -> bool:
208+
return cls.get(MD_GENREDELIMITER)
209+
196210
@classmethod
197211
def get_output(cls, mode: str) -> str:
198212
v = cls.get(OUTPUT)

zspotify/const.py

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020

2121
ALBUMARTIST = 'albumartist'
2222

23+
GENRES = 'genres'
24+
25+
GENRE = 'genre'
26+
2327
ARTWORK = 'artwork'
2428

2529
TRACKS = 'tracks'

zspotify/termoutput.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from enum import Enum
22
from tqdm import tqdm
33

4-
from config import PRINT_SPLASH, PRINT_SKIPS, PRINT_DOWNLOAD_PROGRESS, PRINT_ERRORS, PRINT_DOWNLOADS
4+
from config import PRINT_SPLASH, PRINT_SKIPS, PRINT_DOWNLOAD_PROGRESS, PRINT_ERRORS, PRINT_DOWNLOADS, PRINT_API_ERRORS
55
from zspotify import ZSpotify
66

77

@@ -11,6 +11,7 @@ class PrintChannel(Enum):
1111
DOWNLOAD_PROGRESS = PRINT_DOWNLOAD_PROGRESS
1212
ERRORS = PRINT_ERRORS
1313
DOWNLOADS = PRINT_DOWNLOADS
14+
API_ERRORS = PRINT_API_ERRORS
1415

1516

1617
class Printer:

zspotify/track.py

+39-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import re
3+
from threading import Thread
34
import time
45
import uuid
56
from typing import Any, Tuple, List
@@ -8,14 +9,16 @@
89
from librespot.metadata import TrackId
910
from ffmpy import FFmpeg
1011

11-
from const import TRACKS, ALBUM, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \
12+
from const import TRACKS, ALBUM, GENRES, GENRE, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \
1213
RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS
1314
from termoutput import Printer, PrintChannel
1415
from utils import fix_filename, set_audio_tags, set_music_thumbnail, create_download_directory, \
1516
get_directory_song_ids, add_to_directory_song_ids, get_previously_downloaded, add_to_archive, fmt_seconds
1617
from zspotify import ZSpotify
1718
import traceback
1819

20+
from utils import Loader
21+
1922
def get_saved_tracks() -> list:
2023
""" Returns user's saved tracks """
2124
songs = []
@@ -33,17 +36,32 @@ def get_saved_tracks() -> list:
3336
return songs
3437

3538

36-
def get_song_info(song_id) -> Tuple[List[str], str, str, Any, Any, Any, Any, Any, Any, int]:
39+
def get_song_info(song_id) -> Tuple[List[str], List[str], str, str, Any, Any, Any, Any, Any, Any, int]:
3740
""" Retrieves metadata for downloaded songs """
38-
(raw, info) = ZSpotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token')
41+
with Loader("Fetching track information..."):
42+
(raw, info) = ZSpotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token')
3943

4044
if not TRACKS in info:
4145
raise ValueError(f'Invalid response from TRACKS_URL:\n{raw}')
4246

4347
try:
4448
artists = []
49+
genres = []
4550
for data in info[TRACKS][0][ARTISTS]:
4651
artists.append(data[NAME])
52+
# query artist genres via href, which will be the api url
53+
with Loader("Fetching artist information..."):
54+
(raw, artistInfo) = ZSpotify.invoke_url(f'{data["href"]}')
55+
if ZSpotify.CONFIG.get_allGenres() and len(artistInfo[GENRES]) > 0:
56+
for genre in artistInfo[GENRES]:
57+
genres.append(genre)
58+
elif len(artistInfo[GENRES]) > 0:
59+
genres.append(artistInfo[GENRES][0])
60+
61+
if len(genres) == 0:
62+
Printer.print(PrintChannel.SKIPS, '### No Genre found.')
63+
genres.append('')
64+
4765
album_name = info[TRACKS][0][ALBUM][NAME]
4866
name = info[TRACKS][0][NAME]
4967
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
5472
is_playable = info[TRACKS][0][IS_PLAYABLE]
5573
duration_ms = info[TRACKS][0][DURATION_MS]
5674

57-
return artists, album_name, name, image_url, release_year, disc_number, track_number, scraped_song_id, is_playable, duration_ms
75+
return artists, genres, album_name, name, image_url, release_year, disc_number, track_number, scraped_song_id, is_playable, duration_ms
5876
except Exception as e:
5977
raise ValueError(f'Failed to parse TRACKS_URL response: {str(e)}\n{raw}')
6078

@@ -82,9 +100,12 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
82100
try:
83101
output_template = ZSpotify.CONFIG.get_output(mode)
84102

85-
(artists, album_name, name, image_url, release_year, disc_number,
103+
(artists, genres, album_name, name, image_url, release_year, disc_number,
86104
track_number, scraped_song_id, is_playable, duration_ms) = get_song_info(track_id)
87-
105+
106+
prepareDownloadLoader = Loader("Preparing download...");
107+
prepareDownloadLoader.start()
108+
88109
song_name = fix_filename(artists[0]) + ' - ' + fix_filename(name)
89110

90111
for k in extra_keys:
@@ -131,12 +152,15 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
131152
else:
132153
try:
133154
if not is_playable:
155+
prepareDownloadLoader.stop();
134156
Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG IS UNAVAILABLE) ###' + "\n")
135157
else:
136158
if check_id and check_name and ZSpotify.CONFIG.get_skip_existing_files():
159+
prepareDownloadLoader.stop();
137160
Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS) ###' + "\n")
138161

139162
elif check_all_time and ZSpotify.CONFIG.get_skip_previously_downloaded():
163+
prepareDownloadLoader.stop();
140164
Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY DOWNLOADED ONCE) ###' + "\n")
141165

142166
else:
@@ -147,6 +171,8 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
147171
create_download_directory(filedir)
148172
total_size = stream.input_stream.size
149173

174+
prepareDownloadLoader.stop();
175+
150176
time_start = time.time()
151177
downloaded = 0
152178
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=
170196
time_downloaded = time.time()
171197

172198
convert_audio_format(filename_temp)
173-
set_audio_tags(filename_temp, artists, name, album_name, release_year, disc_number, track_number)
199+
set_audio_tags(filename_temp, artists, genres, name, album_name, release_year, disc_number, track_number)
174200
set_music_thumbnail(filename_temp, image_url)
175201

176202
if filename_temp != filename:
@@ -196,8 +222,8 @@ def download_track(mode: str, track_id: str, extra_keys={}, disable_progressbar=
196222
Printer.print(PrintChannel.ERRORS, "".join(traceback.TracebackException.from_exception(e).format()) + "\n")
197223
if os.path.exists(filename_temp):
198224
os.remove(filename_temp)
199-
200-
225+
226+
prepareDownloadLoader.stop()
201227
def convert_audio_format(filename) -> None:
202228
""" Converts raw audio into playable file """
203229
temp_filename = f'{os.path.splitext(filename)[0]}.tmp'
@@ -224,6 +250,9 @@ def convert_audio_format(filename) -> None:
224250
inputs={temp_filename: None},
225251
outputs={filename: output_params}
226252
)
227-
ff_m.run()
253+
254+
with Loader("Converting file..."):
255+
ff_m.run()
256+
228257
if os.path.exists(temp_filename):
229258
os.remove(temp_filename)

zspotify/utils.py

+84-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import music_tag
1111
import requests
1212

13-
from const import ARTIST, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \
13+
from const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \
1414
WINDOWS_SYSTEM, ALBUMARTIST
1515
from zspotify import ZSpotify
1616

@@ -124,11 +124,12 @@ def clear() -> None:
124124
os.system('clear')
125125

126126

127-
def set_audio_tags(filename, artists, name, album_name, release_year, disc_number, track_number) -> None:
127+
def set_audio_tags(filename, artists, genres, name, album_name, release_year, disc_number, track_number) -> None:
128128
""" sets music_tag metadata """
129129
tags = music_tag.load_file(filename)
130130
tags[ALBUMARTIST] = artists[0]
131131
tags[ARTIST] = conv_artist_format(artists)
132+
tags[GENRE] = genres[0] if not ZSpotify.CONFIG.get_allGenres() else ZSpotify.CONFIG.get_allGenresDelimiter().join(genres)
132133
tags[TRACKTITLE] = name
133134
tags[ALBUM] = album_name
134135
tags[YEAR] = release_year
@@ -279,3 +280,84 @@ def fmt_seconds(secs: float) -> str:
279280
return f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)
280281
else:
281282
return f'{h}'.zfill(2) + ':' + f'{m}'.zfill(2) + ':' + f'{s}'.zfill(2)
283+
284+
285+
# load symbol from:
286+
# https://stackoverflow.com/questions/22029562/python-how-to-make-simple-animated-loading-while-process-is-running
287+
288+
# imports
289+
from itertools import cycle
290+
from shutil import get_terminal_size
291+
from threading import Thread
292+
from time import sleep
293+
294+
class Loader:
295+
"""Busy symbol.
296+
297+
Can be called inside a context:
298+
299+
with Loader("This take some Time..."):
300+
# do something
301+
pass
302+
"""
303+
def __init__(self, desc="Loading...", end='', timeout=0.1, mode='std1'):
304+
"""
305+
A loader-like context manager
306+
307+
Args:
308+
desc (str, optional): The loader's description. Defaults to "Loading...".
309+
end (str, optional): Final print. Defaults to "".
310+
timeout (float, optional): Sleep time between prints. Defaults to 0.1.
311+
"""
312+
self.desc = desc
313+
self.end = end
314+
self.timeout = timeout
315+
316+
self._thread = Thread(target=self._animate, daemon=True)
317+
if mode == 'std1':
318+
self.steps = ["⢿", "⣻", "⣽", "⣾", "⣷", "⣯", "⣟", "⡿"]
319+
elif mode == 'std2':
320+
self.steps = ["◜","◝","◞","◟"]
321+
elif mode == 'std3':
322+
self.steps = ["😐 ","😐 ","😮 ","😮 ","😦 ","😦 ","😧 ","😧 ","🤯 ","💥 ","✨ ","\u3000 ","\u3000 ","\u3000 "]
323+
elif mode == 'prog':
324+
self.steps = ["[∙∙∙]","[●∙∙]","[∙●∙]","[∙∙●]","[∙∙∙]"]
325+
326+
self.done = False
327+
328+
def start(self):
329+
self._thread.start()
330+
return self
331+
332+
def _animate(self):
333+
for c in cycle(self.steps):
334+
if self.done:
335+
break
336+
print(f"\r\t{c} {self.desc} ", flush=True, end="")
337+
sleep(self.timeout)
338+
339+
def __enter__(self):
340+
self.start()
341+
342+
def stop(self):
343+
self.done = True
344+
cols = get_terminal_size((80, 20)).columns
345+
print("\r" + " " * cols, end="", flush=True)
346+
347+
if self.end != "":
348+
print(f"\r{self.end}", flush=True)
349+
350+
def __exit__(self, exc_type, exc_value, tb):
351+
# handle exceptions with those variables ^
352+
self.stop()
353+
354+
355+
if __name__ == "__main__":
356+
with Loader("Loading with context manager..."):
357+
for i in range(10):
358+
sleep(0.25)
359+
360+
loader = Loader("Loading with object...", "That was fast!", 0.05).start()
361+
for i in range(10):
362+
sleep(0.25)
363+
loader.stop()

zspotify/zspotify.py

+15-5
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import os
1010
import os.path
1111
from getpass import getpass
12-
12+
import time
1313
import requests
1414
from librespot.audio.decoders import VorbisOnlyAudioQuality
1515
from librespot.core import Session
@@ -19,8 +19,7 @@
1919
PLAYLIST_READ_PRIVATE, USER_LIBRARY_READ
2020
from config import Config
2121

22-
23-
class ZSpotify:
22+
class ZSpotify:
2423
SESSION: Session = None
2524
DOWNLOAD_QUALITY = None
2625
CONFIG: Config = Config()
@@ -82,10 +81,21 @@ def invoke_url_with_params(cls, url, limit, offset, **kwargs):
8281
return requests.get(url, headers=headers, params=params).json()
8382

8483
@classmethod
85-
def invoke_url(cls, url):
84+
def invoke_url(cls, url, tryCount = 0):
85+
# we need to import that here, otherwise we will get circular imports!
86+
from termoutput import Printer, PrintChannel
8687
headers = cls.get_auth_header()
8788
response = requests.get(url, headers=headers)
88-
return response.text, response.json()
89+
responseText = response.text
90+
responseJson = response.json()
91+
92+
if 'error' in responseJson and tryCount < 5:
93+
94+
Printer.Print(PrintChannel.API_ERROR, f"Spotify API Error ({responseJson['error']['status']}): {responseJson['error']['message']}")
95+
time.sleep(5)
96+
return cls.invoke_url(url, tryCount + 1)
97+
98+
return responseText, responseJson
8999

90100
@classmethod
91101
def check_premium(cls) -> bool:

0 commit comments

Comments
 (0)