Change requirements
This commit is contained in:
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -2325,7 +2325,7 @@ class Session(Closeable, MessageListener, SubListener):
|
|||||||
# Joining from within the same thread would deadlock, so
|
# Joining from within the same thread would deadlock, so
|
||||||
# guard against that.
|
# guard against that.
|
||||||
if threading.current_thread() is not self.__thread:
|
if threading.current_thread() is not self.__thread:
|
||||||
self.__thread.join(timeout=1)
|
self.__thread.join(timeout=0.5)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Shutdown should be best-effort; if join fails, we
|
# Shutdown should be best-effort; if join fails, we
|
||||||
# still proceed with closing the session.
|
# still proceed with closing the session.
|
||||||
@@ -2368,9 +2368,9 @@ class Session(Closeable, MessageListener, SubListener):
|
|||||||
# session keeps reconnecting in a loop after the work is
|
# session keeps reconnecting in a loop after the work is
|
||||||
# finished and the caller expects shutdown.
|
# finished and the caller expects shutdown.
|
||||||
if not self.__running:
|
if not self.__running:
|
||||||
self.__session.logger.info(
|
#self.__session.logger.info(
|
||||||
"Receiver stopping after connection error: %s", ex
|
# "Receiver stopping after connection error: %s", ex
|
||||||
)
|
#)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Detect repeated "connection reset by peer" errors.
|
# Detect repeated "connection reset by peer" errors.
|
||||||
@@ -2384,9 +2384,9 @@ class Session(Closeable, MessageListener, SubListener):
|
|||||||
# happen when Session.close() has torn down the
|
# happen when Session.close() has torn down the
|
||||||
# connection while the receiver was blocked in recv().
|
# connection while the receiver was blocked in recv().
|
||||||
if isinstance(ex, OSError) and getattr(ex, "errno", None) == 9:
|
if isinstance(ex, OSError) and getattr(ex, "errno", None) == 9:
|
||||||
#self.__session.logger.info(
|
self.__session.logger.info(
|
||||||
# "Receiver stopping after socket close (errno 9)"
|
"Receiver stopping after socket close (errno 9)"
|
||||||
#)
|
)
|
||||||
self.__running = False
|
self.__running = False
|
||||||
break
|
break
|
||||||
if is_reset:
|
if is_reset:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
ffmpy
|
ffmpy
|
||||||
https://github.com/kokarare1212/librespot-python/archive/refs/heads/rewrite.zip
|
|
||||||
music_tag
|
music_tag
|
||||||
Pillow
|
Pillow
|
||||||
protobuf
|
protobuf
|
||||||
|
|||||||
@@ -25,30 +25,52 @@ def get_album_tracks(album_id):
|
|||||||
|
|
||||||
|
|
||||||
def get_album_name(album_id):
|
def get_album_name(album_id):
|
||||||
""" Returns album name """
|
"""Return album's primary artist name and album title, honoring configured locale.
|
||||||
(raw, resp) = Zotify.invoke_url(f'{ALBUM_URL}/{album_id}')
|
|
||||||
return resp[ARTISTS][0][NAME], fix_filename(resp[NAME])
|
While album/track filenames are now derived from per-track localized metadata,
|
||||||
|
we still fetch the album here with locale so any other use remains consistent.
|
||||||
|
"""
|
||||||
|
locale = Zotify.CONFIG.get_locale()
|
||||||
|
(raw, resp) = Zotify.invoke_url(f'{ALBUM_URL}/{album_id}?market=from_token&locale={locale}')
|
||||||
|
return resp[ARTISTS][0][NAME], fix_filename(resp[NAME]) # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
def get_artist_albums(artist_id):
|
def get_artist_albums(artist_id):
|
||||||
""" Returns artist's albums """
|
""" Returns artist's albums """
|
||||||
(raw, resp) = Zotify.invoke_url(f'{ARTIST_URL}/{artist_id}/albums?include_groups=album%2Csingle')
|
(raw, resp) = Zotify.invoke_url(f'{ARTIST_URL}/{artist_id}/albums?include_groups=album%2Csingle')
|
||||||
# Return a list each album's id
|
# Return a list each album's id
|
||||||
album_ids = [resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))]
|
album_ids = [resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))] # type: ignore[index]
|
||||||
# Recursive requests to get all albums including singles an EPs
|
# Recursive requests to get all albums including singles an EPs
|
||||||
while resp['next'] is not None:
|
while resp['next'] is not None:
|
||||||
(raw, resp) = Zotify.invoke_url(resp['next'])
|
(raw, resp) = Zotify.invoke_url(resp['next'])
|
||||||
album_ids.extend([resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))])
|
album_ids.extend([resp[ITEMS][i][ID] for i in range(len(resp[ITEMS]))]) # type: ignore[index]
|
||||||
|
|
||||||
return album_ids
|
return album_ids
|
||||||
|
|
||||||
|
|
||||||
def download_album(album):
|
def download_album(album):
|
||||||
""" Downloads songs from an album """
|
""" Downloads songs from an album.
|
||||||
artist, album_name = get_album_name(album)
|
|
||||||
|
NOTE: We intentionally do NOT pass artist/album names via extra_keys anymore so that the
|
||||||
|
placeholders {artist} and {album} remain in the output template until the per-track
|
||||||
|
metadata (queried with the configured locale) is applied inside download_track().
|
||||||
|
This fixes an issue where album downloads produced filenames with non-localized
|
||||||
|
artist names (e.g. 'Eason Chan') while single track downloads correctly used the
|
||||||
|
localized variant (e.g. '陳奕迅'). By letting download_track fill these placeholders
|
||||||
|
after fetching each track's locale-aware metadata, filenames are now consistent.
|
||||||
|
"""
|
||||||
|
# Still fetch once so we trigger an API call early (may warm caches) but we no longer
|
||||||
|
# inject these values into the template; track-level localized metadata will be used.
|
||||||
|
get_album_name(album)
|
||||||
tracks = get_album_tracks(album)
|
tracks = get_album_tracks(album)
|
||||||
for n, track in Printer.progress(enumerate(tracks, start=1), unit_scale=True, unit='Song', total=len(tracks)):
|
for n, track in Printer.progress(enumerate(tracks, start=1), unit_scale=True, unit='Song', total=len(tracks)):
|
||||||
download_track('album', track[ID], extra_keys={'album_num': str(n).zfill(2), 'artist': artist, 'album': album_name, 'album_id': album}, disable_progressbar=True)
|
# Only pass dynamic numbering and album_id (useful for custom templates using {album_id}).
|
||||||
|
download_track(
|
||||||
|
'album',
|
||||||
|
track[ID],
|
||||||
|
extra_keys={'album_num': str(n).zfill(2), 'album_id': album},
|
||||||
|
disable_progressbar=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def download_artist_albums(artist):
|
def download_artist_albums(artist):
|
||||||
|
|||||||
@@ -54,10 +54,11 @@ def client(args) -> None:
|
|||||||
|
|
||||||
if args.liked_songs:
|
if args.liked_songs:
|
||||||
for song in get_saved_tracks():
|
for song in get_saved_tracks():
|
||||||
if not song[TRACK][NAME] or not song[TRACK][ID]:
|
track_obj = song.get(TRACK) if isinstance(song, dict) else None
|
||||||
|
if not track_obj or not track_obj.get(NAME) or not track_obj.get(ID):
|
||||||
Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n")
|
Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n")
|
||||||
else:
|
continue
|
||||||
download_track('liked', song[TRACK][ID])
|
download_track('liked', track_obj[ID])
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.followed_artists:
|
if args.followed_artists:
|
||||||
@@ -105,19 +106,20 @@ def download_from_urls(urls: list[str]) -> bool:
|
|||||||
enum = 1
|
enum = 1
|
||||||
char_num = len(str(len(playlist_songs)))
|
char_num = len(str(len(playlist_songs)))
|
||||||
for song in playlist_songs:
|
for song in playlist_songs:
|
||||||
if not song[TRACK][NAME] or not song[TRACK][ID]:
|
track_obj = song.get(TRACK) if isinstance(song, dict) else None
|
||||||
|
if not track_obj or not track_obj.get(NAME) or not track_obj.get(ID):
|
||||||
Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n")
|
Printer.print(PrintChannel.SKIPS, '### SKIPPING: SONG DOES NOT EXIST ANYMORE ###' + "\n")
|
||||||
else:
|
else:
|
||||||
if song[TRACK][TYPE] == "episode": # Playlist item is a podcast episode
|
if track_obj.get(TYPE) == "episode": # Playlist item is a podcast episode
|
||||||
download_episode(song[TRACK][ID])
|
download_episode(track_obj[ID])
|
||||||
else:
|
else:
|
||||||
download_track('playlist', song[TRACK][ID], extra_keys=
|
download_track('playlist', track_obj[ID], extra_keys=
|
||||||
{
|
{
|
||||||
'playlist_song_name': song[TRACK][NAME],
|
'playlist_song_name': track_obj[NAME],
|
||||||
'playlist': name,
|
'playlist': name,
|
||||||
'playlist_num': str(enum).zfill(char_num),
|
'playlist_num': str(enum).zfill(char_num),
|
||||||
'playlist_id': playlist_id,
|
'playlist_id': playlist_id,
|
||||||
'playlist_track_id': song[TRACK][ID]
|
'playlist_track_id': track_obj[ID]
|
||||||
})
|
})
|
||||||
enum += 1
|
enum += 1
|
||||||
elif episode_id is not None:
|
elif episode_id is not None:
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ PRINT_WARNINGS = 'PRINT_WARNINGS'
|
|||||||
RETRY_ATTEMPTS = 'RETRY_ATTEMPTS'
|
RETRY_ATTEMPTS = 'RETRY_ATTEMPTS'
|
||||||
CONFIG_VERSION = 'CONFIG_VERSION'
|
CONFIG_VERSION = 'CONFIG_VERSION'
|
||||||
DOWNLOAD_LYRICS = 'DOWNLOAD_LYRICS'
|
DOWNLOAD_LYRICS = 'DOWNLOAD_LYRICS'
|
||||||
|
LYRICS_LOCATION = 'LYRICS_LOCATION'
|
||||||
|
LYRICS_FILENAME = 'LYRICS_FILENAME'
|
||||||
|
ALWAYS_CHECK_LYRICS = 'ALWAYS_CHECK_LYRICS'
|
||||||
|
LYRICS_MD_HEADER = 'LYRICS_MD_HEADER'
|
||||||
|
MD_SAVE_LYRICS = 'MD_SAVE_LYRICS'
|
||||||
|
UNIQUE_LYRICS_FILE = 'UNIQUE_LYRICS_FILE'
|
||||||
|
LOCALE = 'LOCALE'
|
||||||
|
|
||||||
CONFIG_VALUES = {
|
CONFIG_VALUES = {
|
||||||
SAVE_CREDENTIALS: { 'default': 'True', 'type': bool, 'arg': '--save-credentials' },
|
SAVE_CREDENTIALS: { 'default': 'True', 'type': bool, 'arg': '--save-credentials' },
|
||||||
@@ -46,6 +53,12 @@ CONFIG_VALUES = {
|
|||||||
ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' },
|
ROOT_PODCAST_PATH: { 'default': '', 'type': str, 'arg': '--root-podcast-path' },
|
||||||
SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' },
|
SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' },
|
||||||
DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' },
|
DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' },
|
||||||
|
LYRICS_LOCATION: { 'default': '', 'type': str, 'arg': '--lyrics-location' },
|
||||||
|
LYRICS_FILENAME: { 'default': '{artist}_{song_name}', 'type': str, 'arg': '--lyrics-filename' },
|
||||||
|
ALWAYS_CHECK_LYRICS: { 'default': 'False', 'type': bool, 'arg': '--always-check-lyrics' },
|
||||||
|
LYRICS_MD_HEADER: { 'default': 'False', 'type': bool, 'arg': '--lyrics-md-header' },
|
||||||
|
MD_SAVE_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--md-save-lyrics' },
|
||||||
|
UNIQUE_LYRICS_FILE: { 'default': 'False', 'type': bool, 'arg': '--unique-file' },
|
||||||
MD_SAVE_GENRES: { 'default': 'False', 'type': bool, 'arg': '--md-save-genres' },
|
MD_SAVE_GENRES: { 'default': 'False', 'type': bool, 'arg': '--md-save-genres' },
|
||||||
MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' },
|
MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' },
|
||||||
MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' },
|
MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' },
|
||||||
@@ -68,7 +81,8 @@ CONFIG_VALUES = {
|
|||||||
PRINT_API_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-api-errors' },
|
PRINT_API_ERRORS: { 'default': 'True', 'type': bool, 'arg': '--print-api-errors' },
|
||||||
PRINT_PROGRESS_INFO: { 'default': 'True', 'type': bool, 'arg': '--print-progress-info' },
|
PRINT_PROGRESS_INFO: { 'default': 'True', 'type': bool, 'arg': '--print-progress-info' },
|
||||||
PRINT_WARNINGS: { 'default': 'True', 'type': bool, 'arg': '--print-warnings' },
|
PRINT_WARNINGS: { 'default': 'True', 'type': bool, 'arg': '--print-warnings' },
|
||||||
TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' }
|
TEMP_DOWNLOAD_DIR: { 'default': '', 'type': str, 'arg': '--temp-download-dir' },
|
||||||
|
LOCALE: { 'default': 'en-EN', 'type': str, 'arg': '--locale' }
|
||||||
}
|
}
|
||||||
|
|
||||||
OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}'
|
OUTPUT_DEFAULT_PLAYLIST = '{playlist}/{artist} - {song_name}.{ext}'
|
||||||
@@ -116,10 +130,18 @@ class Config:
|
|||||||
cls.Values[key] = cls.parse_arg_value(key, CONFIG_VALUES[key]['default'])
|
cls.Values[key] = cls.parse_arg_value(key, CONFIG_VALUES[key]['default'])
|
||||||
|
|
||||||
# Override config from commandline arguments
|
# Override config from commandline arguments
|
||||||
|
# Prefer using the argparse-derived dest name from the configured '--long-option'
|
||||||
for key in CONFIG_VALUES:
|
for key in CONFIG_VALUES:
|
||||||
if key.lower() in vars(args) and vars(args)[key.lower()] is not None:
|
arg_flag = CONFIG_VALUES[key]['arg']
|
||||||
cls.Values[key] = cls.parse_arg_value(key, vars(args)[key.lower()])
|
dest_name = arg_flag.lstrip('-').replace('-', '_') if isinstance(arg_flag, str) else None
|
||||||
|
args_ns = vars(args)
|
||||||
|
# 1) Use dest_name if present (e.g., '--unique-file' -> 'unique_file')
|
||||||
|
if dest_name and dest_name in args_ns and args_ns[dest_name] is not None:
|
||||||
|
cls.Values[key] = cls.parse_arg_value(key, args_ns[dest_name])
|
||||||
|
continue
|
||||||
|
# 2) Fallback to legacy behavior: key.lower() (e.g., 'LYRICS_FILENAME' -> 'lyrics_filename')
|
||||||
|
if key.lower() in args_ns and args_ns[key.lower()] is not None:
|
||||||
|
cls.Values[key] = cls.parse_arg_value(key, args_ns[key.lower()])
|
||||||
|
|
||||||
if args.no_splash:
|
if args.no_splash:
|
||||||
cls.Values[PRINT_SPLASH] = False
|
cls.Values[PRINT_SPLASH] = False
|
||||||
@@ -197,6 +219,37 @@ class Config:
|
|||||||
def get_download_lyrics(cls) -> bool:
|
def get_download_lyrics(cls) -> bool:
|
||||||
return cls.get(DOWNLOAD_LYRICS)
|
return cls.get(DOWNLOAD_LYRICS)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_lyrics_location(cls):
|
||||||
|
"""Returns PurePath or None when empty (meaning use track output directory)."""
|
||||||
|
v = cls.get(LYRICS_LOCATION)
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
p = str(v)
|
||||||
|
if p.startswith('.'):
|
||||||
|
return PurePath(cls.get_root_path()).joinpath(PurePath(p).relative_to('.'))
|
||||||
|
return PurePath(Path(p).expanduser())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_lyrics_filename(cls) -> str:
|
||||||
|
return cls.get(LYRICS_FILENAME)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_always_check_lyrics(cls) -> bool:
|
||||||
|
return cls.get(ALWAYS_CHECK_LYRICS)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_lyrics_md_header(cls) -> bool:
|
||||||
|
return cls.get(LYRICS_MD_HEADER)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_save_lyrics_tags(cls) -> bool:
|
||||||
|
return cls.get(MD_SAVE_LYRICS)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_unique_lyrics_file(cls) -> bool:
|
||||||
|
return cls.get(UNIQUE_LYRICS_FILE)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_bulk_wait_time(cls) -> int:
|
def get_bulk_wait_time(cls) -> int:
|
||||||
return cls.get(BULK_WAIT_TIME)
|
return cls.get(BULK_WAIT_TIME)
|
||||||
@@ -217,6 +270,10 @@ class Config:
|
|||||||
def get_transcode_bitrate(cls) -> str:
|
def get_transcode_bitrate(cls) -> str:
|
||||||
return cls.get(TRANSCODE_BITRATE)
|
return cls.get(TRANSCODE_BITRATE)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_locale(cls) -> str:
|
||||||
|
return cls.get(LOCALE)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_song_archive(cls) -> str:
|
def get_song_archive(cls) -> str:
|
||||||
if cls.get(SONG_ARCHIVE) == '':
|
if cls.get(SONG_ARCHIVE) == '':
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ def get_all_playlists():
|
|||||||
offset = 0
|
offset = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
resp = Zotify.invoke_url_with_params(MY_PLAYLISTS_URL, limit=limit, offset=offset)
|
# Request with locale to ensure playlist names are localized in UI and any downstream usage
|
||||||
|
resp = Zotify.invoke_url_with_params(MY_PLAYLISTS_URL, limit=limit, offset=offset, market='from_token', locale=Zotify.CONFIG.get_locale())
|
||||||
offset += limit
|
offset += limit
|
||||||
playlists.extend(resp[ITEMS])
|
playlists.extend(resp[ITEMS])
|
||||||
if len(resp[ITEMS]) < limit:
|
if len(resp[ITEMS]) < limit:
|
||||||
@@ -31,7 +32,14 @@ def get_playlist_songs(playlist_id):
|
|||||||
limit = 100
|
limit = 100
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
resp = Zotify.invoke_url_with_params(f'{PLAYLISTS_URL}/{playlist_id}/tracks', limit=limit, offset=offset)
|
# Include locale so returned track objects have localized fields (cosmetic; filenames use per-track fetch)
|
||||||
|
resp = Zotify.invoke_url_with_params(
|
||||||
|
f'{PLAYLISTS_URL}/{playlist_id}/tracks',
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
market='from_token',
|
||||||
|
locale=Zotify.CONFIG.get_locale()
|
||||||
|
)
|
||||||
offset += limit
|
offset += limit
|
||||||
songs.extend(resp[ITEMS])
|
songs.extend(resp[ITEMS])
|
||||||
if len(resp[ITEMS]) < limit:
|
if len(resp[ITEMS]) < limit:
|
||||||
@@ -48,12 +56,20 @@ def get_playlist_info(playlist_id):
|
|||||||
|
|
||||||
def download_playlist(playlist):
|
def download_playlist(playlist):
|
||||||
"""Downloads all the songs from a playlist"""
|
"""Downloads all the songs from a playlist"""
|
||||||
|
# Fetch localized playlist name using the configured locale so folder names are localized too
|
||||||
|
pl_name, _ = get_playlist_info(playlist[ID])
|
||||||
|
|
||||||
playlist_songs = [song for song in get_playlist_songs(playlist[ID]) if song[TRACK] is not None and song[TRACK][ID]]
|
playlist_songs = [song for song in get_playlist_songs(playlist[ID]) if song[TRACK] is not None and song[TRACK][ID]]
|
||||||
p_bar = Printer.progress(playlist_songs, unit='song', total=len(playlist_songs), unit_scale=True)
|
p_bar = Printer.progress(playlist_songs, unit='song', total=len(playlist_songs), unit_scale=True)
|
||||||
enum = 1
|
enum = 1
|
||||||
for song in p_bar:
|
for song in p_bar:
|
||||||
download_track('extplaylist', song[TRACK][ID], extra_keys={'playlist': playlist[NAME], 'playlist_num': str(enum).zfill(2)}, disable_progressbar=True)
|
# Use localized playlist name; track metadata (artist/title) is localized in download_track via locale
|
||||||
|
download_track(
|
||||||
|
'extplaylist',
|
||||||
|
song[TRACK][ID],
|
||||||
|
extra_keys={'playlist': pl_name, 'playlist_num': str(enum).zfill(2)},
|
||||||
|
disable_progressbar=True
|
||||||
|
)
|
||||||
p_bar.set_description(song[TRACK][NAME])
|
p_bar.set_description(song[TRACK][NAME])
|
||||||
enum += 1
|
enum += 1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user