diff --git a/README.md b/README.md index e54b822..a556239 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,12 @@ Be aware you have to set boolean values in the commandline like this: `--downloa | 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 +| MAX_FILENAME_LENGTH | --max-filename-length | 255 | Maximum filename length | SPLIT_ALBUM_DISCS | --split-album-discs | False | Saves each disk in its own folder | DOWNLOAD_LYRICS | --download-lyrics | True | Downloads synced lyrics in .lrc format, uses unsynced as fallback. +| MD_ARTISTDELIMITER | --md-artistdelimiter | ', ' | Delimiter character used to split artists in metadata | MD_ALLGENRES | --md-allgenres | False | Save all relevant genres in metadata -| MD_GENREDELIMITER | --md-genredelimiter | , | Delimiter character used to split genres in metadata +| MD_GENREDELIMITER | --md-genredelimiter | ', ' | Delimiter character used to split genres in metadata | DOWNLOAD_FORMAT | --download-format | ogg | The download audio format (aac, fdk_aac, m4a, mp3, ogg, opus, vorbis) | DOWNLOAD_QUALITY | --download-quality | auto | Audio quality of downloaded songs (normal, high, very_high*) | TRANSCODE_BITRATE | --transcode-bitrate | auto | Overwrite the bitrate for ffmpeg encoding diff --git a/zotify/config.py b/zotify/config.py index 802d5ce..f50acbe 100644 --- a/zotify/config.py +++ b/zotify/config.py @@ -28,6 +28,7 @@ PRINT_DOWNLOADS = 'PRINT_DOWNLOADS' PRINT_API_ERRORS = 'PRINT_API_ERRORS' TEMP_DOWNLOAD_DIR = 'TEMP_DOWNLOAD_DIR' +MD_ARTISTDELIMITER = 'MD_ARTISTDELIMITER' MD_SAVE_GENRES = 'MD_SAVE_GENRES' MD_ALLGENRES = 'MD_ALLGENRES' MD_GENREDELIMITER = 'MD_GENREDELIMITER' @@ -36,6 +37,7 @@ RETRY_ATTEMPTS = 'RETRY_ATTEMPTS' CONFIG_VERSION = 'CONFIG_VERSION' DOWNLOAD_LYRICS = 'DOWNLOAD_LYRICS' +MAX_FILENAME_LENGTH = 'MAX_FILENAME_LENGTH' CONFIG_VALUES = { SAVE_CREDENTIALS: { 'default': 'True', 'type': bool, 'arg': '--save-credentials' }, @@ -44,11 +46,13 @@ 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' }, + MAX_FILENAME_LENGTH: { 'default': '255', 'type': int, 'arg': '--max-filename-length' }, SPLIT_ALBUM_DISCS: { 'default': 'False', 'type': bool, 'arg': '--split-album-discs' }, DOWNLOAD_LYRICS: { 'default': 'True', 'type': bool, 'arg': '--download-lyrics' }, + MD_ARTISTDELIMITER: { 'default': ', ', 'type': str, 'arg': '--md-artistdelimiter' }, MD_SAVE_GENRES: { 'default': 'False', 'type': bool, 'arg': '--md-save-genres' }, MD_ALLGENRES: { 'default': 'False', 'type': bool, 'arg': '--md-allgenres' }, - MD_GENREDELIMITER: { 'default': ',', 'type': str, 'arg': '--md-genredelimiter' }, + 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' }, @@ -261,6 +265,10 @@ def get_temp_download_dir(cls) -> str: return '' return PurePath(cls.get_root_path()).joinpath(cls.get(TEMP_DOWNLOAD_DIR)) + @classmethod + def get_artist_delimiter(cls) -> bool: + return cls.get(MD_ARTISTDELIMITER) + @classmethod def get_save_genres(cls) -> bool: return cls.get(MD_SAVE_GENRES) @@ -308,3 +316,7 @@ def get_output(cls, mode: str) -> str: @classmethod def get_retry_attempts(cls) -> int: return cls.get(RETRY_ATTEMPTS) + + @classmethod + def get_max_filename_length(cls) -> int: + return cls.get(MAX_FILENAME_LENGTH) diff --git a/zotify/const.py b/zotify/const.py index e269a90..d50d400 100644 --- a/zotify/const.py +++ b/zotify/const.py @@ -8,6 +8,8 @@ TRACKNUMBER = 'tracknumber' +TOTALTRACKS = 'totaltracks' + DISCNUMBER = 'discnumber' YEAR = 'year' @@ -58,6 +60,8 @@ TRACK_NUMBER = 'track_number' +TOTAL_TRACKS = 'total_tracks' + DISC_NUMBER = 'disc_number' SHOW = 'show' diff --git a/zotify/playlist.py b/zotify/playlist.py index 919c47b..655943b 100644 --- a/zotify/playlist.py +++ b/zotify/playlist.py @@ -1,7 +1,7 @@ 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.utils import split_input, strptime_utc from zotify.zotify import Zotify MY_PLAYLISTS_URL = 'https://api.spotify.com/v1/me/playlists' @@ -37,6 +37,8 @@ def get_playlist_songs(playlist_id): if len(resp[ITEMS]) < limit: break + songs.sort(key=lambda s: strptime_utc(s['added_at']), reverse=True) + return songs diff --git a/zotify/track.py b/zotify/track.py index a739a3d..b11c800 100644 --- a/zotify/track.py +++ b/zotify/track.py @@ -8,7 +8,7 @@ from librespot.metadata import TrackId import ffmpy -from zotify.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, TOTAL_TRACKS, IS_PLAYABLE, ARTISTS, IMAGES, URL, \ RELEASE_DATE, ID, TRACKS_URL, FOLLOWED_ARTISTS_URL, SAVED_TRACKS_URL, TRACK_STATS_URL, CODEC_MAP, EXT_MAP, DURATION_MS, \ HREF, ARTISTS, WIDTH from zotify.termoutput import Printer, PrintChannel @@ -46,7 +46,7 @@ def get_followed_artists() -> list: return artists -def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, Any, Any, Any, Any, int]: +def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, Any, Any, Any, Any, Any, int]: """ Retrieves metadata for downloaded songs """ with Loader(PrintChannel.PROGRESS_INFO, "Fetching track information..."): (raw, info) = Zotify.invoke_url(f'{TRACKS_URL}?ids={song_id}&market=from_token') @@ -64,6 +64,7 @@ def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, An release_year = info[TRACKS][0][ALBUM][RELEASE_DATE].split('-')[0] disc_number = info[TRACKS][0][DISC_NUMBER] track_number = info[TRACKS][0][TRACK_NUMBER] + total_tracks = info[TRACKS][0][ALBUM][TOTAL_TRACKS] scraped_song_id = info[TRACKS][0][ID] is_playable = info[TRACKS][0][IS_PLAYABLE] duration_ms = info[TRACKS][0][DURATION_MS] @@ -74,7 +75,7 @@ def get_song_info(song_id) -> Tuple[List[str], List[Any], str, str, Any, Any, An image = i image_url = image[URL] - return artists, info[TRACKS][0][ARTISTS], album_name, name, image_url, release_year, disc_number, track_number, scraped_song_id, is_playable, duration_ms + return artists, info[TRACKS][0][ARTISTS], album_name, name, image_url, release_year, disc_number, track_number, total_tracks, scraped_song_id, is_playable, duration_ms except Exception as e: raise ValueError(f'Failed to parse TRACKS_URL response: {str(e)}\n{raw}') @@ -155,7 +156,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba output_template = Zotify.CONFIG.get_output(mode) (artists, raw_artists, 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, total_tracks, scraped_song_id, is_playable, duration_ms) = get_song_info(track_id) song_name = fix_filename(artists[0]) + ' - ' + fix_filename(name) @@ -165,11 +166,13 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba ext = EXT_MAP.get(Zotify.CONFIG.get_download_format().lower()) output_template = output_template.replace("{artist}", fix_filename(artists[0])) + output_template = output_template.replace("{artists}", fix_filename(', '.join(artists))) output_template = output_template.replace("{album}", fix_filename(album_name)) output_template = output_template.replace("{song_name}", fix_filename(name)) output_template = output_template.replace("{release_year}", fix_filename(release_year)) output_template = output_template.replace("{disc_number}", fix_filename(disc_number)) - output_template = output_template.replace("{track_number}", fix_filename(track_number)) + output_template = output_template.replace("{track_number}", '{:02d}'.format(int(fix_filename(track_number)))) + output_template = output_template.replace("{total_tracks}", fix_filename(total_tracks)) output_template = output_template.replace("{id}", fix_filename(scraped_song_id)) output_template = output_template.replace("{track_id}", fix_filename(track_id)) output_template = output_template.replace("{ext}", ext) @@ -187,10 +190,22 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba # a song with the same name is installed if not check_id and check_name: - c = len([file for file in Path(filedir).iterdir() if re.search(f'^{filename}_', str(file))]) + 1 + + if Zotify.CONFIG.get_skip_existing(): + prepare_download_loader.stop() + Printer.print(PrintChannel.SKIPS, '\n### SKIPPING: ' + song_name + ' (SONG ALREADY EXISTS) ###' + "\n") + else: + c = len([file for file in Path(filedir).iterdir() if re.search(f'^{filename}_', str(file))]) + 1 + + + filename_str = str(PurePath(filename)) + escaped_filename = re.escape(filename_str) + pattern = re.compile(f'^{escaped_filename}_') - fname = PurePath(PurePath(filename).name).parent - ext = PurePath(PurePath(filename).name).suffix + c = len([file for file in Path(filedir).iterdir() if pattern.search(str(file))]) + 1 + + fname = PurePath(filename).stem + ext = PurePath(filename).suffix filename = PurePath(filedir).joinpath(f'{fname}_{c}{ext}') @@ -261,7 +276,7 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba Printer.print(PrintChannel.SKIPS, f"### Skipping lyrics for {song_name}: lyrics not available ###") convert_audio_format(filename_temp) try: - set_audio_tags(filename_temp, artists, genres, 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, total_tracks) set_music_thumbnail(filename_temp, image_url) except Exception: Printer.print(PrintChannel.ERRORS, "Unable to write metadata, ensure ffmpeg is installed and added to your PATH.") @@ -282,6 +297,22 @@ def download_track(mode: str, track_id: str, extra_keys=None, disable_progressba if Zotify.CONFIG.get_bulk_wait_time(): time.sleep(Zotify.CONFIG.get_bulk_wait_time()) + + # Checks if the playlist name was provided + if extra_keys and 'playlist' in extra_keys: + playlist_file = PurePath(Zotify.CONFIG.get_root_path()).joinpath(extra_keys['playlist'] + '.m3u8') + + # If it's the first item in the playlist, it truncates it + if extra_keys['playlist_num'].lstrip('0') == '1': + with open(playlist_file, 'w', encoding='utf-8') as f: + f.write("#EXTM3U\n") + + # Adds the file name of the downloaded song to the M3U8 playlist + with open(playlist_file, "a", encoding='utf-8') as f: + #f.write("#EXTINF:-1," + urlencode(song_name) + "\n") + #f.write(f"{filename}\n") + f.write(f"{filename.relative_to(PurePath(Zotify.CONFIG.get_root_path()))}\n") + except Exception as e: Printer.print(PrintChannel.ERRORS, '### SKIPPING: ' + song_name + ' (GENERAL DOWNLOAD ERROR) ###') Printer.print(PrintChannel.ERRORS, 'Track_ID: ' + str(track_id)) diff --git a/zotify/utils.py b/zotify/utils.py index dce9fd2..88bb44d 100644 --- a/zotify/utils.py +++ b/zotify/utils.py @@ -11,7 +11,7 @@ import music_tag import requests -from zotify.const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, ARTWORK, \ +from zotify.const import ARTIST, GENRE, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNUMBER, TOTALTRACKS, ARTWORK, \ WINDOWS_SYSTEM, LINUX_SYSTEM, ALBUMARTIST from zotify.zotify import Zotify @@ -126,7 +126,7 @@ def clear() -> None: os.system('clear') -def set_audio_tags(filename, artists, genres, 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, total_tracks) -> None: """ sets music_tag metadata """ tags = music_tag.load_file(filename) tags[ALBUMARTIST] = artists[0] @@ -137,12 +137,13 @@ def set_audio_tags(filename, artists, genres, name, album_name, release_year, di tags[YEAR] = release_year tags[DISCNUMBER] = disc_number tags[TRACKNUMBER] = track_number + tags[TOTALTRACKS] = total_tracks tags.save() def conv_artist_format(artists) -> str: """ Returns converted artist format """ - return ', '.join(artists) + return Zotify.CONFIG.get_artist_delimiter().join(artists) def set_music_thumbnail(filename, image_url) -> None: @@ -158,42 +159,42 @@ def regex_input_for_urls(search_input) -> Tuple[str, str, str, str, str, str]: track_uri_search = re.search( r'^spotify:track:(?P[0-9a-zA-Z]{22})$', search_input) track_url_search = re.search( - r'^(https?://)?open\.spotify\.com/track/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', + r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/track/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', search_input, ) album_uri_search = re.search( r'^spotify:album:(?P[0-9a-zA-Z]{22})$', search_input) album_url_search = re.search( - r'^(https?://)?open\.spotify\.com/album/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', + r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/album/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', search_input, ) playlist_uri_search = re.search( r'^spotify:playlist:(?P[0-9a-zA-Z]{22})$', search_input) playlist_url_search = re.search( - r'^(https?://)?open\.spotify\.com/playlist/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', + r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/playlist/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', search_input, ) episode_uri_search = re.search( r'^spotify:episode:(?P[0-9a-zA-Z]{22})$', search_input) episode_url_search = re.search( - r'^(https?://)?open\.spotify\.com/episode/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', + r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/episode/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', search_input, ) show_uri_search = re.search( r'^spotify:show:(?P[0-9a-zA-Z]{22})$', search_input) show_url_search = re.search( - r'^(https?://)?open\.spotify\.com/show/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', + r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/show/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', search_input, ) artist_uri_search = re.search( r'^spotify:artist:(?P[0-9a-zA-Z]{22})$', search_input) artist_url_search = re.search( - r'^(https?://)?open\.spotify\.com/artist/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', + r'^(https?://)?open\.spotify\.com(?:/intl-\w+)?/artist/(?P[0-9a-zA-Z]{22})(\?si=.+?)?$', search_input, ) @@ -259,11 +260,18 @@ def fix_filename(name): True """ if platform.system() == WINDOWS_SYSTEM: - return re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE) + name = re.sub(r'[/\\:|<>"?*\0-\x1f]|^(AUX|COM[1-9]|CON|LPT[1-9]|NUL|PRN)(?![^.])|^\s|[\s.]$', "_", str(name), flags=re.IGNORECASE) elif platform.system() == LINUX_SYSTEM: - return re.sub(r'[/\0]', "_", str(name)) + name = re.sub(r'[/\0]', "_", str(name)) else: # MacOS - return re.sub(r'[/:\0]', "_", str(name)) + name = re.sub(r'[/:\0]', "_", str(name)) + + max_filename_length = Zotify.CONFIG.get_max_filename_length() + + if len(name) > max_filename_length: + name = name[:max_filename_length] + + return name def fmt_seconds(secs: float) -> str: @@ -287,3 +295,7 @@ 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) + +def strptime_utc(dtstr): + return datetime.datetime.strptime(dtstr[:-1], '%Y-%m-%dT%H:%M:%S').replace(tzinfo=datetime.timezone.utc) +