diff --git a/README.md b/README.md index a6c10d4..0d70c55 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,13 @@ A command-line app for downloading songs, podcasts and videos from Spotify. - **FFmpeg** on your system PATH. Use one of the recommended builds: - **Windows**: [AnimMouse's FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases). - **Linux**: [John Van Sickle's FFmpeg Builds](https://johnvansickle.com/ffmpeg/). -- A **.wvd file**. - - A .wvd file contains the Widevine keys from a device and is required to decrypt music videos and songs in AAC. The easiest method of obtaining one is using KeyDive, which extracts it from an Android device. Detailed instructions can be found here: https://github.com/hyugogirubato/KeyDive. **.wvd files extracted from emulated devices may not work**. +- **(Optional)** A **.wvd file**. + - A `.wvd` file contains the Widevine keys from a device and is required to decrypt music videos and songs in AAC. The easiest method of obtaining one is using KeyDive, which extracts it from an Android device. Detailed instructions can be found here: https://github.com/hyugogirubato/KeyDive. **.wvd files extracted from emulated devices may not work**. #### Notes - **Some users have reported that Spotify suspended their accounts after using Votify**. Use it at your own risk. -- The .wvd file is not required if you plan on only downloading podcasts and can be skipped by enabling the `disable_wvd` option. +- The .wvd file is not required, but expect latency otherwise. - FFmpeg is not required if you plan on only downloading podcasts in Vorbis, but it's needed for downloading podcasts in AAC. ### Optional dependencies @@ -44,12 +44,15 @@ The following tools are optional but required for specific features. Add them to 1. Install the package `votify` using pip: ```bash - pip install votify + pip uninstall votify votify-fix -y + pip cache purge + pip install git+https://github.com/GladistonXD/votify-fix.git ``` 2. Set up the cookies file. - Move the cookies file to the directory where you'll run Votify and rename it to `cookies.txt`. - Alternatively, specify the path to the cookies file using command-line arguments or the config file. -3. Set up the .wvd file. +3. **(Optional**) Set up the .wvd file. + - Move the .wvd file file to the directory where you'll run Votify and rename it to `device.wvd`. - Alternatively, specify the path to the .wvd file using command-line arguments or the config file. diff --git a/requirements.txt b/requirements.txt index 9794f97..b1ae04f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ pybase62 pycryptodome pywidevine yt-dlp +websocket-client diff --git a/votify/cli.py b/votify/cli.py index b643b25..0d4fd50 100644 --- a/votify/cli.py +++ b/votify/cli.py @@ -1,5 +1,7 @@ from __future__ import annotations +import traceback + import inspect import logging import time @@ -77,6 +79,9 @@ class Csv(click.ParamType): return result + + + def load_config_file( ctx: click.Context, param: click.Parameter, @@ -108,6 +113,32 @@ def load_config_file( return ctx +def check_and_fix_config(): + config_path = Path.home() / ".votify" / "config.ini" + if config_path.exists(): + try: + content = config_path.read_text(encoding='utf-8') + if "template_folder_music_video" in content and "Unknown Album" in content: + lines = content.splitlines() + new_lines = [] + updated = False + + for line in lines: + if "template_folder_music_video" in line and "Unknown Album" in line: + new_lines.append("template_folder_music_video = {artist}/{album}") + updated = True + else: + new_lines.append(line) + if updated: + new_content = "\n".join(new_lines) + config_path.write_text(new_content, encoding='utf-8') + except Exception as e: + pass +try: + check_and_fix_config() +except: + pass + @click.command() @click.help_option("-h", "--help") @click.version_option(__version__, "-v", "--version") @@ -225,6 +256,12 @@ def load_config_file( default=downloader_sig.parameters["packager_path"].default, help="Path to Shaka Packager binary.", ) +@click.option( + "--spotify-secrets-url", + type=str, + default="https://code.thetadev.de/ThetaDev/spotify-secrets/raw/branch/main/secrets/secretDict.json", + help="Spotify secrets for TOTP generation" +) @click.option( "--template-folder-album", type=str, @@ -372,6 +409,8 @@ def load_config_file( callback=load_config_file, help="Do not use a config file.", ) + + def main( urls: list[str], wait_interval: float, @@ -393,6 +432,7 @@ def main( mp4box_path: str, mp4decrypt_path: str, packager_path: str, + spotify_secrets_url: str, template_folder_album: str, template_folder_compilation: str, template_file_single_disc: str, @@ -426,7 +466,7 @@ def main( cookies_path = prompt_path(True, cookies_path, "Cookies file") logger.info("Starting Votify") - spotify_api = SpotifyApi.from_cookies_file(cookies_path) + spotify_api = SpotifyApi.from_cookies_file(cookies_path, secrets_url=spotify_secrets_url) downloader = Downloader( spotify_api, @@ -489,7 +529,7 @@ def main( ) is_premium = ( - True if force_premium else spotify_api.user_profile["product"] == "premium" + True if force_premium else spotify_api.user_profile['data']['me']['account']['product'] == "premium" ) if not lrc_only: if download_mode == DownloadMode.ARIA2C and not downloader.aria2c_path_full: @@ -555,7 +595,7 @@ def main( ) if not disable_wvd: - wvd_path = prompt_path(True, wvd_path, ".wvd file") + wvd_path = prompt_path(True, wvd_path, ".wvd file", optional=True) downloader.set_cdm() if read_urls_as_txt: @@ -566,11 +606,19 @@ def main( urls = _urls error_count = 0 + + global_playlist_metadata = None + for url_index, url in enumerate(urls, start=1): url_progress = color_text(f"URL {url_index}/{len(urls)}", colorama.Style.DIM) logger.info(f'({url_progress}) Checking "{url}"') try: url_info = downloader.get_url_info(url) + + if url_info.type == 'playlist': + if hasattr(url_info, 'name') and url_info.name: + global_playlist_metadata = {'name': url_info.name, 'owner_name': getattr(url_info, 'owner', 'Unknown')} + if url_info.type == "artist": download_queue = downloader.get_download_queue_from_artist(url_info.id) else: @@ -582,82 +630,170 @@ def main( error_count += 1 logger.error( f'({url_progress}) Failed to check "{url}"', - exc_info=no_exceptions, + exc_info=not no_exceptions, ) continue + for index, download_queue_item in enumerate(download_queue, start=1): queue_progress = color_text( f"Track {index}/{len(download_queue)} from URL {url_index}/{len(urls)}", colorama.Style.DIM, ) - media_metadata = download_queue_item.media_metadata + media_metadata = None + if isinstance(download_queue_item, dict): + media_metadata = download_queue_item.get("media_metadata", download_queue_item) + else: + media_metadata = getattr(download_queue_item, "media_metadata", download_queue_item) + + if not isinstance(media_metadata, dict): + media_metadata = getattr(media_metadata, "__dict__", {}) + + if isinstance(media_metadata, dict) and 'track' in media_metadata and isinstance(media_metadata['track'],dict): + clean_track_metadata = media_metadata['track'] + else: + clean_track_metadata = media_metadata + + track_name = "Unknown Track" try: - logger.info( - f'({queue_progress}) Downloading "{media_metadata["name"]}"' - ) - media_id = downloader.get_media_id(media_metadata) - media_type = media_metadata["type"] - gid_metadata = downloader.get_gid_metadata(media_id, media_type) + track_name = media_metadata["data"]["trackUnion"]["name"] + except (KeyError, TypeError): + try: + track_name = media_metadata["data"]["episodeUnionV2"]["name"] + except (KeyError, TypeError): + track_name = clean_track_metadata.get("name", "Unknown Track") + + is_playable = True + + if isinstance(clean_track_metadata, dict): + + if clean_track_metadata.get('isPlayable') is False: + is_playable = False + elif clean_track_metadata.get('is_playable') is False: + is_playable = False + elif clean_track_metadata.get('playable') is False: + is_playable = False + + elif 'data' in clean_track_metadata: + try: + union = clean_track_metadata['data']['trackUnion'] + if union.get('isPlayable') is False or union.get('playable') is False: + is_playable = False + except: + pass + + if not is_playable: + logger.warning(f'({queue_progress}) Skipping "{track_name}": Track unavailable/unplayable.') + continue + + try: + logger.info(f'({queue_progress}) Downloading "{track_name}"') + + try: + media_id = downloader.get_media_id(clean_track_metadata) + except ValueError: + if isinstance(download_queue_item, dict) and 'id' in download_queue_item: + media_id = download_queue_item['id'] + else: + raise + + media_metadata_for_download = media_metadata + + media_type = "track" + + target_uri = "" + try: + target_uri = media_metadata['data']['trackUnion']['uri'] + except (KeyError, TypeError): + target_uri = clean_track_metadata.get('uri', '') + + if "episode" in target_uri: + media_type = "episode" + elif "track" in target_uri: + media_type = "track" + + else: + try: + type_from_meta = media_metadata['data']['trackUnion']['__typename'] + media_type = type_from_meta.lower() + except (KeyError, TypeError, AttributeError): + if isinstance(media_metadata, dict): + if media_metadata.get('type') == 'episode' or 'episode' in media_metadata: + media_type = "episode" + if 'episode' in media_metadata: + media_metadata_for_download = None + + gid_metadata = downloader.get_gid_metadata(media_id, media_type, spotify_api.user_profile, track_name, download_music_videos, download_podcast_videos) + + def get_meta(item, key): + val = getattr(item, key, None) + if val is None and isinstance(item, dict): + val = item.get(key) + return val + + safe_album_metadata = get_meta(download_queue_item, "album_metadata") + safe_playlist_metadata = get_meta(download_queue_item, "playlist_metadata") + safe_show_metadata = get_meta(download_queue_item, "show_metadata") + + if not safe_playlist_metadata and global_playlist_metadata: + safe_playlist_metadata = global_playlist_metadata + if media_type == "track": if disable_wvd: - logger.warning( - "Widevine decryption is disabled, " - "skipping Widevine protected content" - ) + logger.warning("Widevine decryption is disabled, skipping.") continue if audio_quality in VORBIS_AUDIO_QUALITIES: - logger.warning( - "Vorbis audio quality is only supported for podcasts, " - "skipping" - ) + logger.warning("Vorbis audio quality is only supported for podcasts.") continue if download_music_videos: downloader_music_video.download( music_video_id=media_id, - music_video_metadata=media_metadata, - album_metadata=download_queue_item.album_metadata, + music_video_metadata=media_metadata_for_download, + album_metadata=safe_album_metadata, gid_metadata=gid_metadata, - playlist_metadata=download_queue_item.playlist_metadata, + playlist_metadata=safe_playlist_metadata, playlist_track=index, ) else: downloader_song.download( track_id=media_id, - track_metadata=media_metadata, - album_metadata=download_queue_item.album_metadata, + track_metadata=media_metadata_for_download, + album_metadata=safe_album_metadata, gid_metadata=gid_metadata, - playlist_metadata=download_queue_item.playlist_metadata, + playlist_metadata=safe_playlist_metadata, + product_name=spotify_api.user_profile, playlist_track=index, ) + elif media_type == "episode": if download_podcast_videos: downloader_episode_video.download( episode_id=media_id, - episode_metadata=media_metadata, - show_metadata=download_queue_item.show_metadata, + episode_metadata=media_metadata_for_download, + show_metadata=safe_show_metadata, gid_metadata=gid_metadata, - playlist_metadata=download_queue_item.playlist_metadata, + playlist_metadata=safe_playlist_metadata, playlist_track=index, ) else: downloader_episode.download( episode_id=media_id, - episode_metadata=media_metadata, - show_metadata=download_queue_item.show_metadata, + episode_metadata=media_metadata_for_download, + show_metadata=safe_show_metadata, gid_metadata=gid_metadata, - playlist_metadata=download_queue_item.playlist_metadata, + playlist_metadata=safe_playlist_metadata, + product_name=spotify_api.user_profile, playlist_track=index, ) + except Exception as e: error_count += 1 - logger.error( - f'({queue_progress}) Failed to download "{media_metadata["name"]}"', - exc_info=not no_exceptions, - ) + logger.error(f'({queue_progress}) Failed to download "{track_name}"', exc_info=False) + #logger.error(f'({queue_progress}) Failed to download "{track_name}"', exc_info=not no_exceptions) finally: if wait_interval > 0 and index != len(download_queue): logger.debug( f"Waiting for {wait_interval} second(s) before continuing" ) time.sleep(wait_interval) + logger.info(f"Done ({error_count} error(s))") diff --git a/votify/downloader.py b/votify/downloader.py index b77827a..7349977 100644 --- a/votify/downloader.py +++ b/votify/downloader.py @@ -9,7 +9,7 @@ import shutil import subprocess from io import BytesIO from pathlib import Path - +from .utils import prompt_path import requests from InquirerPy import inquirer from InquirerPy.base.control import Choice @@ -59,7 +59,7 @@ class Downloader: template_file_multi_disc: str = "{disc}-{track:02d} {title}", template_folder_episode: str = "Podcasts/{album}", template_file_episode: str = "{track:02d} {title}", - template_folder_music_video: str = "{artist}/Unknown Album", + template_folder_music_video: str = "{artist}/{album}", template_file_music_video: str = "{title}", template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}", date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ", @@ -75,7 +75,12 @@ class Downloader: self.spotify_api = spotify_api self.output_path = output_path self.temp_path = temp_path - self.wvd_path = wvd_path + self.wvd_path = prompt_path( + is_file=True, + initial_path=wvd_path, + description="device.wvd", + optional=True + ) self.aria2c_path = aria2c_path self.ffmpeg_path = ffmpeg_path self.mp4box_path = mp4box_path @@ -133,7 +138,8 @@ class Downloader: self.subprocess_additional_args = {} def set_cdm(self) -> None: - self.cdm = Cdm.from_device(Device.load(self.wvd_path)) + if self.wvd_path.exists(): + self.cdm = Cdm.from_device(Device.load(self.wvd_path)) def get_url_info(self, url: str) -> UrlInfo: url_regex_result = re.search(self.URL_RE, url) @@ -142,31 +148,122 @@ class Downloader: return UrlInfo(type=url_regex_result.group(1), id=url_regex_result.group(2)) def get_download_queue( - self, - media_type: str, - media_id: str, + self, + media_type: str, + media_id: str, ) -> list[DownloadQueueItem]: download_queue = [] if media_type == "album": album = self.spotify_api.get_album(media_id) - for track in album["tracks"]["items"]: - download_queue.append( - DownloadQueueItem( - album_metadata=album, - media_metadata=track, - ) - ) + for item in album: + t = item['track'] + track_id = t['uri'].split(':')[-1] + download_queue.append({ + 'data': { + 'trackUnion': { + 'id': track_id, + 'uri': t['uri'], + 'name': t['name'], + 'trackNumber': t['trackNumber'], + 'duration': { + 'totalMilliseconds': t['duration']['totalMilliseconds'] + }, + 'is_playable': t.get('is_playable', True), + 'artists': { + 'items': [ + { + 'profile': { + 'name': t['artists']['items'][0]['profile']['name'] + } + } + ] + } + } + } + }) elif media_type == "playlist": - playlist = self.spotify_api.get_playlist(media_id) - for track in playlist["tracks"]["items"]: - if track["track"] is None: - continue - download_queue.append( - DownloadQueueItem( - playlist_metadata=playlist, - media_metadata=track["track"], - ) - ) + playlist = self.spotify_api.get_playlist(media_id) + playlist_metadata = playlist + if 'content' in playlist and 'items' in playlist['content']: + items_list = playlist['content']['items'] + else: + items_list = [] + for item in items_list: + if 'itemV2' not in item or 'data' not in item['itemV2']: + continue + + t = item['itemV2']['data'] + + if 'uri' not in t: + continue + + track_id = t['uri'].split(':')[-1] + + duration_ms = 0 + if 'duration' in t and isinstance(t['duration'], dict): + duration_ms = t['duration'].get('totalMilliseconds', 0) + elif 'trackDuration' in t and isinstance(t['trackDuration'], dict): + duration_ms = t['trackDuration'].get('totalMilliseconds', 0) + + album_data = t.get('albumOfTrack', {}) + if 'uri' in album_data and 'id' not in album_data: + album_data['id'] = album_data['uri'].split(':')[-1] + + if 'date' in album_data and isinstance(album_data['date'], dict): + date_obj = album_data['date'] + if 'isoString' in date_obj and date_obj['isoString']: + album_data['release_date'] = date_obj['isoString'] + album_data['release_date_precision'] = 'day' + elif 'year' in date_obj and date_obj['year']: + album_data['release_date'] = str(date_obj['year']) + album_data['release_date_precision'] = 'year' + + if 'release_date' not in album_data: + album_data['release_date'] = '1970-01-01' + album_data['release_date_precision'] = 'day' + + artist_name = "Unknown Artist" + try: + if 'artists' in t and 'items' in t['artists'] and len(t['artists']['items']) > 0: + profile = t['artists']['items'][0].get('profile') + if profile: + artist_name = profile.get('name', "Unknown Artist") + except Exception: + pass + + is_playable = t.get('isPlayable', True) + + track_object = { + 'data': { + 'trackUnion': { + 'id': track_id, + 'uri': t['uri'], + '__typename': 'Track', + 'name': t.get('name', 'Unknown Track'), + 'trackNumber': t.get('trackNumber', 1), + 'duration': { + 'totalMilliseconds': duration_ms + }, + 'isPlayable': is_playable, + 'albumOfTrack': album_data, + 'artists': { + 'items': [ + { + 'profile': { + 'name': artist_name + } + } + ] + } + } + } + } + + download_queue.append({ + 'media_metadata': track_object, + 'playlist_metadata': playlist_metadata + }) + elif media_type == "track": download_queue.append( DownloadQueueItem( @@ -174,18 +271,159 @@ class Downloader: ) ) elif media_type == "episode": - download_queue.append( - DownloadQueueItem( - media_metadata=self.spotify_api.get_episode(media_id), - ) - ) + episode = self.spotify_api.get_episode(media_id) + + podcast_name = "Unknown Podcast" + podcast_uri = "" + podcast_cover = "" + + if 'podcastV2' in episode and 'data' in episode['podcastV2']: + p_data = episode['podcastV2']['data'] + podcast_name = p_data.get('name', 'Unknown Podcast') + podcast_uri = p_data.get('uri', '') + if 'coverArt' in p_data and 'sources' in p_data['coverArt']: + podcast_cover = p_data['coverArt']['sources'][0]['url'] + + release_date = "" + precision = "" + if 'releaseDate' in episode and episode['releaseDate']: + release_date = episode['releaseDate'].get('isoString') + precision = episode['releaseDate'].get('isoString') + + cover_url = podcast_cover + try: + if 'coverArt' in episode and 'sources' in episode['coverArt']: + sources = episode['coverArt']['sources'] + cover_url = max(sources, key=lambda x: x.get('width', 0))['url'] + except Exception: + pass + + download_queue.append({ + 'data': { + 'trackUnion': { + 'id': episode['id'], + 'uri': episode['uri'], + 'name': episode['name'], + 'description': episode.get('description', ''), + 'trackNumber': 1, + 'duration': { + 'totalMilliseconds': episode['duration']['totalMilliseconds'] + }, + 'is_playable': episode.get('playability', {}).get('playable', True), + + 'release_date': release_date, + 'release_date_precision': precision, + + 'cover_url': cover_url, + + 'artists': { + 'items': [ + { + 'profile': { + 'name': podcast_name + } + } + ] + }, + + 'albumOfTrack': { + 'uri': podcast_uri, + 'name': podcast_name, + 'coverArt': { + 'sources': [{'url': cover_url}] + }, + 'id': podcast_uri.split(':')[-1] if ':' in podcast_uri else None, + 'release_date': release_date + } + } + }, + 'show_metadata': { + 'name': podcast_name, + 'publisher': 'Unknown', + 'uri': podcast_uri + } + }) + + elif media_type == "show": show = self.spotify_api.get_show(media_id) - for episode in show["episodes"]["items"]: + items_list = [] + if 'episodesV2' in show and 'items' in show['episodesV2']: + items_list = show['episodesV2']['items'] + elif 'episodes' in show and 'items' in show['episodes']: + items_list = show['episodes']['items'] + total_items = len(items_list) + for index, item in enumerate(items_list): + episode_raw = None + if 'entity' in item and 'data' in item['entity']: + episode_raw = item['entity']['data'] + elif 'data' in item: + episode_raw = item['data'] + else: + episode_raw = item + if not episode_raw or not isinstance(episode_raw, dict): + continue + podcast_name = "Unknown Podcast" + podcast_uri = "" + if 'podcastV2' in episode_raw and 'data' in episode_raw['podcastV2']: + p_data = episode_raw['podcastV2']['data'] + podcast_name = p_data.get('name', 'Unknown Podcast') + podcast_uri = p_data.get('uri', '') + elif show.get('name'): + podcast_name = show.get('name') + podcast_uri = show.get('uri') + cover_url = None + if 'coverArt' in episode_raw and episode_raw['coverArt'].get('sources'): + try: + sources = episode_raw['coverArt']['sources'] + cover_url = max(sources, key=lambda x: x.get('width', 0))['url'] + except Exception: + pass + release_date = "" + precision = "day" + if 'releaseDate' in episode_raw and isinstance(episode_raw['releaseDate'], dict): + release_date = episode_raw['releaseDate'].get('isoString') + if 'T' in release_date: + release_date = release_date.split('T')[0] + precision = episode_raw['releaseDate'].get('precision', 'day') + elif 'release_date' in episode_raw: + release_date = episode_raw['release_date'] + current_track_number = total_items - index + formatted_episode = { + 'data': { + 'trackUnion': { + 'id': episode_raw.get('id'), + 'uri': episode_raw.get('uri'), + 'name': episode_raw.get('name'), + 'description': episode_raw.get('description', ''), + 'trackNumber': current_track_number, + 'duration': { + 'totalMilliseconds': episode_raw.get('duration', {}).get('totalMilliseconds', 0) + }, + 'is_playable': episode_raw.get('playability', {}).get('playable', True), + 'release_date': release_date, + 'release_date_precision': precision, + 'cover_url': cover_url, + 'artists': {'items': [{'profile': {'name': podcast_name}}]}, + 'albumOfTrack': { + 'uri': podcast_uri, + 'name': podcast_name, + 'coverArt': {'sources': [{'url': cover_url}]}, + 'id': podcast_uri.split(':')[-1] if ':' in podcast_uri else None, + 'release_date': release_date + } + } + } + } + current_show_metadata = { + 'name': podcast_name, + 'publisher': 'Unknown', + 'uri': podcast_uri + } download_queue.append( DownloadQueueItem( - show_metadata=show, - media_metadata=episode, + show_metadata=current_show_metadata, + media_metadata=formatted_episode, ) ) return download_queue @@ -195,74 +433,67 @@ class Downloader: artist_albums: list[dict], album_type: str, ) -> list[dict]: - return [album for album in artist_albums if album["album_type"] == album_type] + return [album for album in artist_albums if album == album_type] - def get_download_queue_from_artist( - self, - artist_id: str, - ) -> list[DownloadQueueItem]: + + def get_download_queue_from_artist(self, artist_id: str) -> list[DownloadQueueItem]: download_queue = [] - artist_albums = self.spotify_api.get_artist_albums(artist_id) - artist_albums_all = {} - for album_type in ["album", "single", "compilation", "appears_on"]: - artist_albums_all[album_type] = self._filter_artist_albums( - artist_albums["items"], - album_type, - ) - selected_album_type = inquirer.select( - message=f"Select which album type to download for artist:", + + selected_option = inquirer.select( + message="Select which album type to download for artist:", choices=[ - Choice( - name="Albums", - value="album", - ), - Choice( - name="Singles", - value="single", - ), - Choice( - name="Compilations", - value="compilation", - ), - Choice( - name="Collaborations", - value="appears_on", - ), + Choice(name="Albuns", value="album"), + Choice(name="Singles", value="single"), + Choice(name="Compilations", value="compilation"), + Choice(name="Collaborations", value="appears_on"), ], - validate=lambda result: artist_albums_all.get(result), - invalid_message="The artist doesn't have any albums of this type", ).execute() - choices = [ - Choice( - name=" | ".join( - [ - f"{album['total_tracks']:03d}", - album["release_date"][:4], - album["name"], - ], - ), - value=album["id"], - ) - for album in artist_albums_all[selected_album_type] - ] - selected = inquirer.select( - message="Select which albums to download: (Total tracks | Release year | Album name)", - choices=choices, - multiselect=True, - ).execute() - for album_id in selected: - download_queue.extend( - self.get_download_queue( - "album", - album_id, + + selected_ids = [] + if selected_option == "album": + selected_ids = self.spotify_api.get_artist_albums_selection(artist_id) + elif selected_option == "single": + selected_ids = self.spotify_api.get_artist_singles_selection(artist_id) + elif selected_option == "compilation": + selected_ids = self.spotify_api.get_artist_compilations_selection(artist_id) + elif selected_option == "appears_on": + selected_ids = self.spotify_api.get_artist_collaborations_selection(artist_id) + + if selected_ids: + print(f"Processing {len(selected_ids)} items...") + for album_id in selected_ids: + download_queue.extend( + self.get_download_queue("album", album_id) ) - ) + return download_queue + def get_cover_url(self, metadata: dict, cover_size_mapping: dict) -> str | None: - if not metadata.get("images"): + if not isinstance(metadata, dict): return None - return self._get_cover_url(metadata["images"], cover_size_mapping) + images = None + try: + images = ( + metadata.get("data", {}) + .get("trackUnion", {}) + .get("albumOfTrack", {}) + .get("coverArt", {}) + .get("sources") + ) + except AttributeError: + pass + + if not images: + images = metadata.get("images") + + if not images: + images = metadata.get("coverArt", {}).get("sources") + + if not images: + return None + + return self._get_cover_url(images, cover_size_mapping) def _get_cover_url(self, images_dict: list[dict], cover_size_mapping: dict) -> str: original_cover_url = images_dict[0]["url"] @@ -273,15 +504,59 @@ class Downloader: return cover_url def get_media_id(self, media_metadata: dict) -> str: - return (media_metadata.get("linked_from") or media_metadata)["id"] + linked_from = media_metadata.get("linked_from") + if linked_from: + media_metadata = linked_from + + if "data" in media_metadata and isinstance(media_metadata["data"], dict): + track_union = media_metadata["data"].get("trackUnion") + if track_union and "id" in track_union: + return track_union["id"] + + if "id" in media_metadata: + return media_metadata["id"] + + if "itemV2" in media_metadata: + return media_metadata["itemV2"]["data"]["id"] + + if "media_metadata" in media_metadata: + nested = media_metadata["media_metadata"] + if "data" in nested and "trackUnion" in nested["data"]: + return nested["data"]["trackUnion"]["id"] + if "id" in nested: + return nested["id"] + + if "uri" in media_metadata: + return media_metadata["uri"].split(":")[-1] + + raise ValueError("Could not find media ID in metadata") def get_playlist_tags(self, playlist_metadata: dict, playlist_track: int) -> dict: + playlist_title = playlist_metadata.get("name", "Unknown Playlist") + playlist_artist = "Unknown Artist" + try: + if "ownerV2" in playlist_metadata: + playlist_artist = playlist_metadata['ownerV2']['data']['name'] + except (KeyError, TypeError): + pass + + if playlist_artist == "Unknown Artist" and "owner" in playlist_metadata: + owner_obj = playlist_metadata["owner"] + if isinstance(owner_obj, dict): + playlist_artist = owner_obj.get("display_name") or owner_obj.get("id", "Unknown Artist") + elif isinstance(owner_obj, str): + playlist_artist = owner_obj + + if playlist_artist == "Unknown Artist": + playlist_artist = playlist_metadata.get("owner_name", "Unknown Artist") + return { - "playlist_artist": playlist_metadata["owner"]["display_name"], - "playlist_title": playlist_metadata["name"], + "playlist_artist": playlist_artist, + "playlist_title": playlist_title, "playlist_track": playlist_track, } + def get_playlist_file_path( self, tags: dict, @@ -309,11 +584,14 @@ class Downloader: def get_final_path(self, media_type: str, tags: dict, file_extension: str) -> Path: if media_type == "track": - template_folder = ( - self.template_folder_compilation.split("/") - if tags.get("compilation") - else self.template_folder_album.split("/") - ) + if tags.get("playlist_title"): + template_folder = self.template_file_playlist.split("/") + else: + template_folder = ( + self.template_folder_compilation.split("/") + if tags.get("compilation") + else self.template_folder_album.split("/") + ) template_file = ( self.template_file_multi_disc.split("/") if tags["disc_total"] > 1 @@ -327,7 +605,9 @@ class Downloader: template_file = self.template_file_music_video.split("/") else: raise RuntimeError() + template_final = template_folder + template_file + return Path( self.output_path, *[ @@ -335,8 +615,8 @@ class Downloader: for i in template_final[0:-1] ], ( - self.get_sanitized_string(template_final[-1].format(**tags), False) - + file_extension + self.get_sanitized_string(template_final[-1].format(**tags), False) + + file_extension ), ) @@ -367,12 +647,77 @@ class Downloader: playlist_file.writelines(playlist_file_lines) def get_gid_metadata( - self, - media_id: str, - media_type: str, - ) -> dict: - gid = self.spotify_api.media_id_to_gid(media_id) - return self.spotify_api.get_gid_metadata(gid, media_type) + self, + media_id: str, + media_type: str, + audio_quality: dict, + track_name: str, + download_music_video: str, + download_podcast_videos: str, + ) -> str: + playback_info = self.spotify_api.get_track_playback_info(media_id, media_type) + user_product = audio_quality.get('data', {}).get('me', {}).get('account', {}).get('product', 'free') + + if media_type == 'episode': + target_bitrate = 128000 + else: + target_bitrate = 256000 if user_product == 'PREMIUM' else 128000 + + media_items = playback_info.get("media", {}) + if download_music_video or download_podcast_videos: + video_file_id = next( + ( + data['item']['manifest']['manifest_ids_video'][0]['file_id'] + for data in playback_info['media'].values() + if 'item' in data + and 'manifest' in data['item'] + and 'manifest_ids_video' in data['item']['manifest'] + ), + None + ) + if video_file_id is None: + logger.error("There is no video for this media.") + return + return video_file_id + else: + if not media_items: + raise ValueError("No available Track (Empty response)") + + expected_key = f"spotify:{media_type}:{media_id}" + + track_manifest = media_items.get(expected_key) + + if not track_manifest: + track_manifest = list(media_items.values())[0] + + metadata = track_manifest.get("item", {}).get("metadata", {}) + server_name = metadata.get("name") + + if not server_name: + raise ValueError(f"Track Unavailable: No name returned") + + req_clean = track_name.lower().strip() + srv_clean = server_name.lower().strip() + + if (req_clean in srv_clean) or (srv_clean in req_clean): + track_manifest = track_manifest + else: + raise ValueError(f"Track Unavailable: No name returned") + + try: + audio_file_id = next( + f["file_id"] + for files in track_manifest["item"]["manifest"].values() + if isinstance(files, list) + for f in files + if isinstance(f, dict) and f.get("bitrate") == int(target_bitrate) + ) + except StopIteration: + raise ValueError(f"No audio file found with bitrate {target_bitrate}") + + return audio_file_id + + def get_playplay_decryption_key(self, file_id: str) -> bytes: raise NotImplementedError() @@ -383,18 +728,26 @@ class Downloader: media_type: str, ) -> tuple[str, str]: try: - pssh = PSSH(pssh) - cdm_session = self.cdm.open() - challenge = self.cdm.get_license_challenge(cdm_session, pssh) - license = self.spotify_api.get_widevine_license(challenge, media_type) - self.cdm.parse_license(cdm_session, license) - keys = next( - i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT" - ) - decryption_key = keys.key.hex() - key_id = keys.kid.hex + if self.cdm is None: + cmd = self.spotify_api.extract_keys_with_cdrm(pssh, media_type) + key_id, decryption_key = cmd.split(':') + else: + pssh = PSSH(pssh) + cdm_session = self.cdm.open() + challenge = self.cdm.get_license_challenge(cdm_session, pssh) + license = self.spotify_api.get_widevine_license(challenge, media_type) + self.cdm.parse_license(cdm_session, license) + keys = next( + i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT" + ) + decryption_key = keys.key.hex() + key_id = keys.kid.hex + finally: - self.cdm.close(cdm_session) + try: + self.cdm.close(cdm_session) + except: + pass return key_id, decryption_key def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str: @@ -413,24 +766,46 @@ class Downloader: return dirty_string.strip() def get_release_date_datetime_obj( - self, - release_date: str, - release_date_precision: str, + self, + release_date: str, + release_date_precision: str, ) -> datetime.datetime: - return datetime.datetime.strptime( - release_date, - self.RELEASE_DATE_PRECISION_MAPPING[release_date_precision], - ) + if not release_date: + return datetime.datetime(1970, 1, 1) + + if not release_date_precision: + if len(release_date) == 4: + release_date_precision = 'year' + elif len(release_date) == 7: + release_date_precision = 'month' + else: + release_date_precision = 'day' + + try: + precision_key = str(release_date_precision).lower() + fmt = self.RELEASE_DATE_PRECISION_MAPPING.get(precision_key, "%Y-%m-%d") + return datetime.datetime.strptime(release_date, fmt) + except (ValueError, TypeError, KeyError, AttributeError): + for fmt in ["%Y", "%Y-%m", "%Y-%m-%d"]: + try: + return datetime.datetime.strptime(release_date, fmt) + except ValueError: + continue + return datetime.datetime(1970, 1, 1) def get_release_date_tag(self, datetime_obj: datetime.datetime) -> str: return datetime_obj.strftime(self.date_tag_template) def get_artist_string(self, artist_list: list[dict]) -> str: + if not artist_list: + return "" + if len(artist_list) == 1: return artist_list[0]["name"] + return ( - ", ".join(i["name"] for i in artist_list[:-1]) - + f' & {artist_list[-1]["name"]}' + ", ".join(i["name"] for i in artist_list[:-1]) + + f' & {artist_list[-1]["name"]}' ) def get_file_temp_path( diff --git a/votify/downloader_audio.py b/votify/downloader_audio.py index 0ec21fa..420c517 100644 --- a/votify/downloader_audio.py +++ b/votify/downloader_audio.py @@ -1,6 +1,7 @@ from __future__ import annotations import subprocess +import time from pathlib import Path from Crypto.Cipher import AES @@ -60,44 +61,57 @@ class DownloaderAudio: def get_stream_info( self, gid_metadata: dict, + product_name: dict, media_type: str, ) -> StreamInfoAudio: stream_info = StreamInfoAudio() if media_type == "track": - audio_files = gid_metadata.get("file") + audio_files = gid_metadata elif media_type == "episode": - audio_files = gid_metadata.get("audio") + audio_files = gid_metadata else: raise RuntimeError() audio_files = audio_files or gid_metadata.get("alternative") if not audio_files: return stream_info - if audio_files[0].get("gid"): - audio_files = audio_files[0]["file"] - quality, audio_file = self.get_audio_file(audio_files) - if not audio_file: - return stream_info - file_id = audio_file["file_id"] + if audio_files: + audio_files = audio_files + file_id = audio_files stream_url = self.downloader.spotify_api.get_stream_urls(file_id)["cdnurl"][0] stream_info.stream_url = stream_url stream_info.file_id = file_id + product = product_name['data']['me']['account']['product'] + if 'FREE' == product: + quality = 128000 + else: + quality = 256000 stream_info.quality = quality if self.audio_quality in AAC_AUDIO_QUALITIES: seek_table = self.downloader.spotify_api.get_seek_table(file_id) pssh = seek_table["pssh"] stream_info.widevine_pssh = pssh + return stream_info + def get_decryption_key(self, stream_info: StreamInfoAudio) -> str: - if self.audio_quality in AAC_AUDIO_QUALITIES: - _, decryption_key = self.downloader.get_widevine_decryption_key( - stream_info.widevine_pssh, - "audio", - ) - else: - decryption_key = self.downloader.get_playplay_decryption_key( - stream_info.file_id - ) + decryption_key = None + for attempt in range(10): + try: + if self.audio_quality in AAC_AUDIO_QUALITIES: + _, decryption_key = self.downloader.get_widevine_decryption_key( + stream_info.widevine_pssh, + "audio", + ) + else: + decryption_key = self.downloader.get_playplay_decryption_key( + stream_info.file_id + ) + if decryption_key: + return decryption_key + + except Exception as e: + time.sleep(attempt + 10) return decryption_key def download_stream_url(self, input_path: Path, stream_url: str): diff --git a/votify/downloader_episode.py b/votify/downloader_episode.py index 0e0e759..eef6c37 100644 --- a/votify/downloader_episode.py +++ b/votify/downloader_episode.py @@ -9,7 +9,6 @@ from .models import StreamInfoAudio logger = logging.getLogger("votify") - class DownloaderEpisode(DownloaderAudio): def __init__( self, @@ -18,33 +17,55 @@ class DownloaderEpisode(DownloaderAudio): self.__dict__.update(downloader_audio.__dict__) def get_tags( - self, - episode_metadata: dict, - show_metadata: dict, + self, + episode_metadata: dict, + show_metadata: dict, ) -> dict: - release_date_datetime_obj = self.downloader.get_release_date_datetime_obj( - episode_metadata["release_date"], - episode_metadata["release_date_precision"], - ) + try: + release_date = episode_metadata.get("release_date") + if release_date and "T" in release_date: + release_date = release_date.split("T")[0] + + release_date_datetime_obj = self.downloader.get_release_date_datetime_obj( + release_date, + episode_metadata.get("release_date_precision", "day"), + ) + tag_release_date = self.downloader.get_release_date_tag(release_date_datetime_obj) + tag_release_year = str(release_date_datetime_obj.year) + except Exception: + tag_release_date = str(episode_metadata.get("release_date")) + tag_release_year = tag_release_date.split("-")[0] if "-" in tag_release_date else "2024" + + track_number = 1 + try: + if 'trackNumber' in episode_metadata and isinstance(episode_metadata['trackNumber'], int): + track_number = episode_metadata['trackNumber'] + + elif show_metadata and "episodes" in show_metadata and "items" in show_metadata["episodes"]: + items = show_metadata["episodes"]["items"] + total_items = len(items) + + for index, item in enumerate(items): + item_id = item.get('id') + if not item_id and 'entity' in item: + item_id = item['entity'].get('data', {}).get('id') + + if item_id == episode_metadata['id']: + track_number = total_items - index + break + except (KeyError, TypeError, IndexError): + track_number = 1 + tags = { - "album": show_metadata["name"], - "description": episode_metadata["description"], + "album": show_metadata.get("name"), + "description": episode_metadata.get("description"), "media_type": "Podcast", "publisher": show_metadata.get("publisher"), "rating": "Explicit" if episode_metadata.get("explicit") else "Unknown", - "release_date": self.downloader.get_release_date_tag( - release_date_datetime_obj - ), - "release_year": str(release_date_datetime_obj.year), - "title": episode_metadata["name"], - "track": next( - index - for index in range(1, len(show_metadata["episodes"]["items"]) + 1) - if show_metadata["episodes"]["items"][ - len(show_metadata["episodes"]["items"]) - index - ]["id"] - == episode_metadata["id"] - ), + "release_date": tag_release_date, + "release_year": tag_release_year, + "title": episode_metadata.get("name"), + "track": track_number, "url": f"https://open.spotify.com/episode/{episode_metadata['id']}", } return tags @@ -63,102 +84,142 @@ class DownloaderEpisode(DownloaderAudio): self.downloader.cleanup_temp_path() def _download( - self, - episode_id: str, - episode_metadata: dict = None, - show_metadata: dict = None, - gid_metadata: dict = None, - stream_info: StreamInfoAudio = None, - playlist_metadata: dict = None, - playlist_track: int = None, - decryption_key: bytes = None, + self, + episode_id: str, + episode_metadata: dict = None, + show_metadata: dict = None, + gid_metadata: dict = None, + stream_info: StreamInfoAudio = None, + playlist_metadata: dict = None, + playlist_track: int = None, + product_name: dict = None, + decryption_key: bytes = None, ): if not episode_metadata: - logger.debug("Getting episode metadata") episode_metadata = self.downloader.spotify_api.get_episode(episode_id) + + if 'data' in episode_metadata and 'trackUnion' in episode_metadata['data']: + episode_metadata = episode_metadata['data']['trackUnion'] + + if 'release_date' in episode_metadata: + + raw_date = str(episode_metadata['release_date']) + if 'T' in raw_date: + episode_metadata['release_date'] = raw_date.split('T')[0] + + precision = str(episode_metadata.get('release_date_precision', '')) + if 'T' in precision or len(precision) > 10: + episode_metadata['release_date_precision'] = 'day' + + else: + + if 'releaseDate' in episode_metadata and isinstance(episode_metadata['releaseDate'], dict): + r_node = episode_metadata['releaseDate'] + episode_metadata['release_date'] = r_node.get('isoString', '1970-01-01').split('T')[0] + episode_metadata['release_date_precision'] = r_node.get('precision', 'day') + else: + episode_metadata['release_date'] = '1970-01-01' + episode_metadata['release_date_precision'] = 'year' + + if not show_metadata or show_metadata.get('name') == 'Unknown Show': + logger.debug("Attempting to extract Show Metadata from alternative fields...") + found_show_id = None + + try: + if 'podcastV2' in episode_metadata: + uri = episode_metadata['podcastV2']['data']['uri'] + found_show_id = uri.split(':')[-1] + except (KeyError, IndexError, AttributeError): + pass + + if not found_show_id and 'show' in episode_metadata: + found_show_id = episode_metadata['show'].get('id') + + if found_show_id: + logger.debug(f"Found Show ID: {found_show_id}, fetching details...") + try: + show_metadata = self.downloader.spotify_api.get_show(found_show_id) + except Exception as e: + logger.warning(f"Failed to fetch show details: {e}") + if not show_metadata: - logger.debug("Getting show metadata") - show_metadata = self.downloader.spotify_api.get_show( - episode_metadata["show"]["id"] - ) + show_name = "Unknown Show" + try: + show_name = episode_metadata['data']['trackUnion']['artists']['items'][0]['profile']['name'] + except: + pass + show_metadata = {'name': show_name, 'publisher': 'Unknown', 'episodes': {'items': []}} + if not gid_metadata: logger.debug("Getting GID metadata") - gid_metadata = self.downloader.get_gid_metadata(episode_id, "episode") + try: + gid_metadata = self.downloader.get_gid_metadata(episode_id, "episode", None) + except TypeError: + gid_metadata = self.downloader.get_gid_metadata(episode_id, "episode") + if not stream_info: logger.debug("Getting stream info") - stream_info = self.get_stream_info(gid_metadata, "episode") + stream_info = self.get_stream_info(gid_metadata, product_name, "episode") + if not stream_info.file_id: - logger.warning( - "Episode is not available on Spotify's " - "servers and no alternative found, skipping" - ) + logger.warning("Episode not available, skipping") return - if stream_info.quality != self.audio_quality: - logger.warning(f"Quality has been changed to {stream_info.quality.value}") - tags = self.get_tags( - episode_metadata, - show_metadata, - ) + + tags = self.get_tags(episode_metadata, show_metadata) + if playlist_metadata: - tags = { - **tags, - **self.downloader.get_playlist_tags( - playlist_metadata, - playlist_track, - ), - } + tags = {**tags, **self.downloader.get_playlist_tags(playlist_metadata, playlist_track)} + file_extension = self.get_file_extension() - final_path = self.downloader.get_final_path( - "episode", - tags, - file_extension, - ) + final_path = self.downloader.get_final_path("episode", tags, file_extension) + + cover_url = None + + if 'cover_url' in episode_metadata: + cover_url = episode_metadata['cover_url'] + + if not cover_url and 'coverArt' in episode_metadata and 'sources' in episode_metadata['coverArt']: + try: + cover_url = episode_metadata['coverArt']['sources'][0]['url'] + except (IndexError, KeyError, TypeError): + pass + + if not cover_url and 'albumOfTrack' in episode_metadata: + alb = episode_metadata['albumOfTrack'] + if 'coverArt' in alb and 'sources' in alb['coverArt']: + try: + cover_url = alb['coverArt']['sources'][0]['url'] + except (IndexError, KeyError, TypeError): + pass + + elif 'images' in alb and isinstance(alb['images'], list) and len(alb['images']) > 0: + cover_url = alb['images'][0]['url'] + + + if not cover_url: + key_mapping = COVER_SIZE_X_KEY_MAPPING_EPISODE if 'COVER_SIZE_X_KEY_MAPPING_EPISODE' in globals() else COVER_SIZE_X_KEY_MAPPING_SONG + cover_url = self.downloader.get_cover_url(episode_metadata, key_mapping) + cover_path = self.get_cover_path(final_path) - cover_url = self.downloader.get_cover_url( - episode_metadata, - COVER_SIZE_X_KEY_MAPPING_EPISODE, - ) + decrypted_path = None remuxed_path = None + if final_path.exists() and not self.downloader.overwrite: - logger.warning(f'Track already exists at "{final_path}", skipping') + logger.warning(f'Episode already exists at "{final_path}", skipping') else: - decryption_key = ( - self.DEFAULT_EPISODE_DECRYPTION_KEY.hex() - if self.audio_quality in AAC_AUDIO_QUALITIES - else self.DEFAULT_EPISODE_DECRYPTION_KEY - ) - encrypted_path = self.downloader.get_file_temp_path( - episode_id, - "_encrypted", - file_extension, - ) - decrypted_path = self.downloader.get_file_temp_path( - episode_id, - "_decrypted", - file_extension, - ) - remuxed_path = self.downloader.get_file_temp_path( - episode_id, - "_remuxed", - file_extension, - ) - logger.debug(f'Downloading to "{encrypted_path}"') + if not decryption_key: + decryption_key = self.get_decryption_key(stream_info) + + encrypted_path = self.downloader.get_file_temp_path(episode_id, "_encrypted", file_extension) + decrypted_path = self.downloader.get_file_temp_path(episode_id, "_decrypted", file_extension) + remuxed_path = self.downloader.get_file_temp_path(episode_id, "_remuxed", file_extension) + self.download_stream_url(encrypted_path, stream_info.stream_url) - logger.debug( - f'Decrypting to "{decrypted_path}" and remuxing to "{remuxed_path}"' - ) - self.decrypt( - decryption_key, - encrypted_path, - decrypted_path, - remuxed_path, - ) - media_temp_path = ( - remuxed_path - if remuxed_path is not None and remuxed_path.exists() - else decrypted_path - ) + self.decrypt(decryption_key, encrypted_path, decrypted_path, remuxed_path) + + media_temp_path = remuxed_path if remuxed_path and remuxed_path.exists() else decrypted_path + self.downloader._final_processing( cover_path, cover_url, diff --git a/votify/downloader_episode_video.py b/votify/downloader_episode_video.py index 89f2947..1708abb 100644 --- a/votify/downloader_episode_video.py +++ b/votify/downloader_episode_video.py @@ -19,9 +19,9 @@ class DownloaderEpisodeVideo(DownloaderVideo): self.downloader_episode = downloader_episode def get_video_gid(self, gid_metadata: dict) -> str | None: - if not gid_metadata.get("video"): + if not gid_metadata: return None - return gid_metadata["video"][0]["file_id"] + return gid_metadata def download( self, @@ -65,7 +65,7 @@ class DownloaderEpisodeVideo(DownloaderVideo): ) return tags = self.downloader_episode.get_tags( - episode_metadata, + episode_metadata['data']['trackUnion'], show_metadata, ) file_extension = self.get_file_extension( diff --git a/votify/downloader_music_video.py b/votify/downloader_music_video.py index bceb2aa..cebadbb 100644 --- a/votify/downloader_music_video.py +++ b/votify/downloader_music_video.py @@ -19,58 +19,92 @@ class DownloaderMusicVideo(DownloaderVideo): self.__dict__.update(downloader_video.__dict__) def get_video_gid(self, gid_metadata: dict) -> str | None: - if not gid_metadata.get("original_video"): + if not gid_metadata: return None - return gid_metadata["original_video"][0]["gid"] + return gid_metadata def get_tags( - self, - track_metadata: dict, - album_metadata: dict, - track_credits: dict, + self, + track_metadata: dict, + album_metadata: dict, + track_credits: dict, ) -> dict: - external_ids = track_metadata.get("external_ids") - external_urls = (track_metadata.get("linked_from") or track_metadata)[ - "external_urls" - ] + track_data = track_metadata.get('data', {}).get('trackUnion', {}) + if not track_data: + track_data = track_metadata + + album_info = track_data.get('albumOfTrack', {}) + + spotify_url = track_data.get('sharingInfo', {}).get('shareUrl') + if not spotify_url and 'id' in track_data: + spotify_url = f"https://open.spotify.com/track/{track_data['id']}" + + release_date_iso = album_info.get("date", {}).get("isoString") + precision_raw = album_info.get("date", {}).get("precision") + + precision = precision_raw.lower() if precision_raw else "day" + + release_date_str = release_date_iso.split("T")[0] if release_date_iso else None + release_date_datetime_obj = self.downloader.get_release_date_datetime_obj( - album_metadata["release_date"], - album_metadata["release_date_precision"], + release_date_str, + precision, ) - producers = next( - role - for role in track_credits["roleCredits"] - if role["roleTitle"] == "Producers" - )["artists"] - composers = next( - role - for role in track_credits["roleCredits"] - if role["roleTitle"] == "Writers" - )["artists"] + + try: + main_artists = next( + role + for role in track_credits.get("roleCredits", []) + if role["roleTitle"] == "Performers" + )["artists"] + artist_string = self.downloader.get_artist_string(main_artists) + except (StopIteration, KeyError): + artist_string = "Unknown Artist" + + def get_credits_by_role(role_name): + try: + return next( + role + for role in track_credits.get("roleCredits", []) + if role["roleTitle"] == role_name + )["artists"] + except (StopIteration, KeyError): + return None + + producers = get_credits_by_role("Producers") + composers = get_credits_by_role("Writers") + + copyright_text = None + copyrights_list = album_info.get('copyright', {}).get('items', []) + if copyrights_list: + copyright_obj = next((i for i in copyrights_list if i.get("type") == "P"), copyrights_list[0]) + copyright_text = copyright_obj.get('text') + + album_name = album_info.get("name") + if not album_name and isinstance(album_metadata, dict): + album_name = album_metadata.get("name") tags = { - "artist": self.downloader.get_artist_string(track_metadata["artists"]), + "album": album_name or "Unknown Album", + "artist": artist_string, "composer": ( self.downloader.get_artist_string(composers) if composers else None ), - "copyright": next( - (i["text"] for i in album_metadata["copyrights"] if i["type"] == "P"), - None, - ), - "isrc": external_ids.get("isrc") if external_ids is not None else None, - "label": album_metadata.get("label"), + "copyright": copyright_text, + "isrc": None, + "label": None, "media_type": "Music video", "producer": ( self.downloader.get_artist_string(producers) if producers else None ), - "rating": "Explicit" if track_metadata.get("explicit") else "Unknown", - "title": track_metadata["name"], + "rating": "Explicit" if track_data.get("contentRating", {}).get("label") == "EXPLICIT" else "Clean", + "title": track_data.get("name"), "release_date": self.downloader.get_release_date_tag( release_date_datetime_obj ), "release_year": str(release_date_datetime_obj.year), - "url": external_urls["spotify"], + "url": spotify_url, } - tags["release_year"] = str(release_date_datetime_obj.year) + return tags def get_music_video_id_from_song_id( @@ -146,12 +180,13 @@ class DownloaderMusicVideo(DownloaderVideo): if not album_metadata: logger.debug("Getting album metadata") album_metadata = self.downloader.spotify_api.get_album( - music_video_metadata["album"]["id"] + music_video_metadata['data']['trackUnion']['albumOfTrack']["id"] ) if not gid_metadata: logger.debug("Getting GID metadata") gid_metadata = self.downloader.get_gid_metadata(music_video_id, "track") video_gid = self.get_video_gid(gid_metadata) + if not video_gid: logger.debug("Getting equivalent music video ID from song ID") music_video_id = self.get_music_video_id_from_song_id( diff --git a/votify/downloader_song.py b/votify/downloader_song.py index fac7332..918a6c8 100644 --- a/votify/downloader_song.py +++ b/votify/downloader_song.py @@ -23,86 +23,123 @@ class DownloaderSong(DownloaderAudio): self.no_lrc = no_lrc def get_tags( - self, - track_metadata: dict, - album_metadata: dict, - track_credits: dict, - lyrics_unsynced: str, + self, + track_metadata: dict, + album_metadata: list, + track_credits: dict, + lyrics_unsynced: str, ) -> dict: - external_ids = track_metadata.get("external_ids") - external_urls = (track_metadata.get("linked_from") or track_metadata)[ - "external_urls" - ] - track_id = self.downloader.get_media_id(track_metadata) + track_info = track_metadata.get("data", {}).get("trackUnion", {}) + album_info = track_info.get("albumOfTrack", {}) + + external_url = track_info.get("sharingInfo", {}).get("shareUrl") + + isrc = None + + release_date_iso = album_info.get("date", {}).get("isoString") + precision_raw = album_info.get("date", {}).get("precision") + + precision = precision_raw.lower() if precision_raw else "day" + + release_date_str = release_date_iso.split("T")[0] if release_date_iso else None + release_date_datetime_obj = self.downloader.get_release_date_datetime_obj( - album_metadata["release_date"], - album_metadata["release_date_precision"], + release_date_str, + precision, ) - producers = next( - role - for role in track_credits["roleCredits"] - if role["roleTitle"] == "Producers" - )["artists"] - composers = next( - role - for role in track_credits["roleCredits"] - if role["roleTitle"] == "Writers" - )["artists"] - disc = next( - ( - track["disc_number"] - for track in album_metadata["tracks"]["items"] - if self.downloader.get_media_id(track) == track_id - ), + + track_artists = [] + + raw_artist_items = track_info.get("firstArtist", {}).get("items", []) + + other_artist_items = track_info.get("otherArtists", {}).get("items", []) + + all_artist_items = raw_artist_items + other_artist_items + + for item in all_artist_items: + name = item.get("profile", {}).get("name") + if name: + track_artists.append({"name": name}) + + if not track_artists: + alt_artists = track_info.get("artists", {}).get("items", []) + for item in alt_artists: + name = item.get("profile", {}).get("name") or item.get("name") + if name: + track_artists.append({"name": name}) + + if not track_artists: + track_artists = [{"name": "Unknown Artist"}] + + album_artists = track_artists + + def get_credits_artists(role_title): + try: + role = next( + r for r in track_credits.get("roleCredits", []) + if r.get("roleTitle") == role_title + ) + return role.get("artists", []) + except StopIteration: + return [] + + producers = get_credits_artists("Producers") + composers = get_credits_artists("Writers") + + current_uri = track_info.get("uri") + + current_track_entry = next( + (item.get("track", {}) for item in album_metadata if item.get("track", {}).get("uri") == current_uri), + {} ) + + disc_number = current_track_entry.get("discNumber", 1) + + try: + disc_total = max( + (item.get("track", {}).get("discNumber", 1) for item in album_metadata), + default=1 + ) + except Exception: + disc_total = 1 + + track_total = album_info.get("tracks", {}).get("totalCount") + if not track_total: + track_total = len(album_metadata) + + copyright_text = next( + (c["text"] for c in album_info.get("copyright", {}).get("items", []) if c.get("type") == "P"), + None + ) + label = album_info.get("courtesyLine") or album_info.get("label") + tags = { - "album": album_metadata["name"], - "album_artist": self.downloader.get_artist_string( - album_metadata["artists"] - ), - "artist": self.downloader.get_artist_string(track_metadata["artists"]), - "compilation": ( - True if album_metadata["album_type"] == "compilation" else False - ), + "album": album_info.get("name"), + "album_artist": self.downloader.get_artist_string(album_artists), + "artist": self.downloader.get_artist_string(track_artists), + "compilation": False, "composer": ( self.downloader.get_artist_string(composers) if composers else None ), - "copyright": next( - (i["text"] for i in album_metadata["copyrights"] if i["type"] == "P"), - None, - ), - "disc": int(disc), - "disc_total": int(album_metadata["tracks"]["items"][-1]["disc_number"]), - "isrc": external_ids.get("isrc") if external_ids is not None else None, - "label": album_metadata.get("label"), + "copyright": copyright_text, + "disc": int(disc_number), + "disc_total": int(disc_total), + "isrc": isrc, + "label": label, "lyrics": lyrics_unsynced, "media_type": "Song", "producer": ( self.downloader.get_artist_string(producers) if producers else None ), - "rating": "Explicit" if track_metadata.get("explicit") else "Unknown", + "rating": "Explicit" if track_info.get("contentRating", {}).get("label") == "EXPLICIT" else "Unknown", "release_date": self.downloader.get_release_date_tag( release_date_datetime_obj ), "release_year": str(release_date_datetime_obj.year), - "title": track_metadata["name"], - "track": int( - next( - ( - track["track_number"] - for track in album_metadata["tracks"]["items"] - if self.downloader.get_media_id(track) == track_id - ), - ) - ), - "track_total": int( - max( - track["track_number"] - for track in album_metadata["tracks"]["items"] - if track["disc_number"] == disc - ) - ), - "url": external_urls["spotify"], + "title": track_info.get("name"), + "track": int(track_info.get("trackNumber", 1)), + "track_total": int(track_total), + "url": external_url, } return tags @@ -140,54 +177,111 @@ class DownloaderSong(DownloaderAudio): self.downloader.cleanup_temp_path() def _download( - self, - track_id: str, - track_metadata: dict = None, - album_metadata: dict = None, - gid_metadata: dict = None, - stream_info: StreamInfoAudio = None, - playlist_metadata: dict = None, - playlist_track: int = None, - decryption_key: bytes = None, + self, + track_id: str, + track_metadata: dict = None, + album_metadata: dict = None, + gid_metadata: dict = None, + stream_info: StreamInfoAudio = None, + playlist_metadata: dict = None, + product_name: dict = None, + playlist_track: int = None, + decryption_key: bytes = None, ): - if not track_metadata: + if not album_metadata: logger.debug("Getting track metadata") track_metadata = self.downloader.spotify_api.get_track(track_id) + + try: + current_date = track_metadata['data']['trackUnion']['albumOfTrack'].get('release_date') + + if current_date == '1970-01-01': + fresh_data = self.downloader.spotify_api.get_track(track_id) + + real_date = None + real_precision = 'day' + real_album_name = None + + if 'album' in fresh_data: + real_date = fresh_data['album']['release_date'] + real_precision = fresh_data['album'].get('release_date_precision', 'day') + real_album_name = fresh_data['album']['name'] + + elif 'data' in fresh_data: + src_album = fresh_data['data']['trackUnion']['albumOfTrack'] + if 'date' in src_album and 'isoString' in src_album['date']: + real_date = src_album['date']['isoString'] + real_precision = 'day' + + if real_date and not str(real_date).startswith('1970'): + track_metadata['data']['trackUnion']['albumOfTrack']['release_date'] = real_date + track_metadata['data']['trackUnion']['albumOfTrack']['release_date_precision'] = real_precision + + if not album_metadata: + album_metadata = {} + + album_metadata['release_date'] = real_date + album_metadata['release_date_precision'] = real_precision + if real_album_name: + album_metadata['name'] = real_album_name + + except Exception as e: + pass + if not album_metadata: logger.debug("Getting album metadata") - album_metadata = self.downloader.spotify_api.get_album( - track_metadata["album"]["id"] - ) + try: + alb_node = track_metadata['data']['trackUnion']['albumOfTrack'] + album_id = alb_node['id'] + album_metadata = self.downloader.spotify_api.get_album(album_id) + except (KeyError, TypeError): + pass + if not gid_metadata: logger.debug("Getting GID metadata") - gid_metadata = self.downloader.get_gid_metadata(track_id, "track") + try: + gid_metadata = self.downloader.get_gid_metadata(track_id, "track", product_name) + except TypeError: + gid_metadata = self.downloader.get_gid_metadata(track_id, "track") + if gid_metadata.get("original_video"): logger.warning("Track is a music video, skipping.") return + if not stream_info: logger.debug("Getting stream info") - stream_info = self.get_stream_info(gid_metadata, "track") + stream_info = self.get_stream_info(gid_metadata, product_name, "track") + if not stream_info.file_id: logger.warning( "Track is not available on Spotify's " "servers and no alternative found, skipping" ) return + if stream_info.quality != self.audio_quality: - logger.warning(f"Quality has been changed to {stream_info.quality.value}") - if gid_metadata.get("has_lyrics"): + if stream_info.quality == 128000: + info = '128 kb/s' + else: + info = '256 kb/s' + logger.info(f"The current bit rate is {info}") + + if gid_metadata: logger.debug("Getting lyrics") lyrics = self.get_lyrics(track_id) else: lyrics = Lyrics() + logger.debug("Getting track credits") track_credits = self.downloader.spotify_api.get_track_credits(track_id) + tags = self.get_tags( track_metadata, album_metadata, track_credits, lyrics.unsynced, ) + if playlist_metadata: tags = { **tags, @@ -196,6 +290,7 @@ class DownloaderSong(DownloaderAudio): playlist_track, ), } + file_extension = self.get_file_extension() final_path = self.downloader.get_final_path( "track", @@ -205,11 +300,13 @@ class DownloaderSong(DownloaderAudio): lrc_path = self.downloader.get_lrc_path(final_path) cover_path = self.get_cover_path(final_path) cover_url = self.downloader.get_cover_url( - album_metadata, + track_metadata, COVER_SIZE_X_KEY_MAPPING_SONG, ) + decrypted_path = None remuxed_path = None + if self.lrc_only: pass elif final_path.exists() and not self.downloader.overwrite: @@ -244,18 +341,21 @@ class DownloaderSong(DownloaderAudio): decrypted_path, remuxed_path, ) - if self.no_lrc or not lyrics.synced: + + if self.no_lrc: pass elif lrc_path.exists() and not self.downloader.overwrite: logger.debug(f'Synced lyrics already exists at "{lrc_path}", skipping') else: logger.debug(f'Saving synced lyrics to "{lrc_path}"') - self.downloader.save_lrc(lrc_path, lyrics.synced) + lyrics = None + media_temp_path = ( remuxed_path if remuxed_path is not None and remuxed_path.exists() else decrypted_path ) + self.downloader._final_processing( cover_path, cover_url, diff --git a/votify/spotify_api.py b/votify/spotify_api.py index cb637eb..8d1fa72 100644 --- a/votify/spotify_api.py +++ b/votify/spotify_api.py @@ -12,6 +12,10 @@ from pathlib import Path import base62 import requests +import secrets +import websocket +import threading + from .device_flow import SpotifyDeviceFlow from .totp import TOTP from .utils import check_response @@ -24,10 +28,11 @@ class SpotifyApi: SPOTIFY_COOKIE_DOMAIN = ".spotify.com" CLIENT_VERSION = "1.2.70.61.g856ccd63" LYRICS_API_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/{track_id}" - METADATA_API_URL = "https://api.spotify.com/v1/{type}/{item_id}" + METADATA_API_URL = "https://api-partner.spotify.com/pathfinder/v2/query" GID_METADATA_API_URL = "https://spclient.wg.spotify.com/metadata/4/{media_type}/{gid}?market=from_token" PATHFINDER_API_URL = "https://api-partner.spotify.com/pathfinder/v1/query" - VIDEO_MANIFEST_API_URL = "https://gue1-spclient.spotify.com/manifests/v7/json/sources/{gid}/options/supports_drm" + VIDEO_MANIFEST_API_URL = "https://gue1-spclient.spotify.com/manifests/v9/json/sources/{gid}/options/supports_drm" + TRACK_PLAYBACK_API_URL = "https://gue1-spclient.spotify.com/track-playback/v1/media/spotify:{media_type}:{media_id}" PLAYPLAY_LICENSE_API_URL = ( "https://gew4-spclient.spotify.com/playplay/v1/key/{file_id}" ) @@ -43,18 +48,27 @@ class SpotifyApi: EXTEND_TRACK_COLLECTION_WAIT_TIME = 0.5 SERVER_TIME_URL = "https://open.spotify.com/api/server-time" SESSION_TOKEN_URL = "https://open.spotify.com/api/token" + CLIENT_TOKEN_URL = "https://clienttoken.spotify.com/v1/clienttoken" def __init__( - self, + self, *, + secrets_url: str, sp_dc: str | None = None, use_device_flow: bool = False, ) -> None: + self.session = requests.Session() + + secrets = self.session.get(secrets_url) + check_response(secrets) + totp_version, secrets_ciphertext = max(secrets.json().items(), key=lambda item: int(item[0])) + + self.totp = TOTP(version=totp_version, ciphertext=secrets_ciphertext) self.sp_dc = sp_dc self.use_device_flow = use_device_flow self._set_session() @classmethod - def from_cookies_file(cls, cookies_path: Path) -> SpotifyApi: + def from_cookies_file(cls, cookies_path: Path, **kwargs) -> SpotifyApi: cookies = MozillaCookieJar(cookies_path) cookies.load(ignore_discard=True, ignore_expires=True) parse_cookie = lambda name: next( @@ -71,11 +85,9 @@ class SpotifyApi: '"sp_dc" cookie not found in cookies. ' "Make sure you have exported the cookies from the Spotify homepage and are logged in." ) - return cls(sp_dc=sp_dc) + return cls(sp_dc=sp_dc, **kwargs) def _set_session(self) -> None: - self.totp = TOTP() - self.session = requests.Session() self._setup_session_headers() self._setup_authorization() self._setup_user_profile() @@ -108,10 +120,12 @@ class SpotifyApi: check_response(response) return 1e3 * response.json()["serverTime"] - def set_authorization_header(self, token: str) -> None: + def set_token_headers(self, token: str, client_token: str | None = None) -> None: + self.session.headers.update( { "authorization": f"Bearer {token}", + "client-token": client_token } ) @@ -124,18 +138,36 @@ class SpotifyApi: "reason": "init", "productType": "web-player", "totp": totp, + "totpServer": totp, "totpVer": str(self.totp.version), - "ts": str(server_time), }, ) check_response(response) authorization_info = response.json() if not authorization_info.get("accessToken"): raise ValueError("Failed to retrieve access token.") - self.set_authorization_header(authorization_info["accessToken"]) + + response = self.session.post(self.CLIENT_TOKEN_URL, + json = { + 'client_data': { + 'client_version': self.CLIENT_VERSION, + 'client_id': authorization_info['clientId'], + 'js_sdk_data': {} + } + }, + headers = { + 'Accept': 'application/json', + } + ) + check_response(response) + client_token = response.json() + if not client_token.get("granted_token"): + raise ValueError("Failed to retrieve granted token.") + + self.set_token_headers(authorization_info["accessToken"], client_token["granted_token"]["token"]) self.session_auth_expire_time = ( authorization_info["accessTokenExpirationTimestampMs"] / 1000 - ) + ) def _setup_authorization(self) -> None: if self.use_device_flow: @@ -146,20 +178,34 @@ class SpotifyApi: def _setup_authorization_with_device_flow(self) -> None: device_flow = SpotifyDeviceFlow(self.sp_dc) token_data = device_flow.get_token() - self.set_authorization_header(token_data["access_token"]) + self.set_token_headers(token_data["access_token"]) self.session_auth_expire_time = ( int(time.time()) + token_data["expires_in"] ) * 1000 def _refresh_session_auth(self) -> None: timestamp_session_expire = int(self.session_auth_expire_time) - timestamp_now = time.time() * 1000 + timestamp_now = time.time() if timestamp_now < timestamp_session_expire: return self._setup_authorization() def _setup_user_profile(self) -> None: - response = self.session.get(self.METADATA_API_URL.format(type="me", item_id="")) + payload = { + "variables": {}, + "operationName": "accountAttributes", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "24aaa3057b69fa91492de26841ad199bd0b330ca95817b7a4d6715150de01827" + } + } + } + + response = self.session.post( + self.METADATA_API_URL, + json=payload, + ) check_response(response) self.user_profile = response.json() @@ -177,12 +223,196 @@ class SpotifyApi: media_type: str, ) -> dict: self._refresh_session_auth() - response = self.session.get( - self.GID_METADATA_API_URL.format(gid=gid, media_type=media_type) - ) + response = self.session.get(self.GID_METADATA_API_URL.format(gid=gid, media_type=media_type)) check_response(response) return response.json() + def get_track_playback_info( + self, + media_id: str, + media_type: str + ) -> dict | None: + self._refresh_session_auth() + + TRACK_ID = media_id + if not hasattr(self, 'cached_device_id'): + self.cached_device_id = secrets.token_hex(20) + + DEVICE_ID = self.cached_device_id + + ACCESS_TOKEN = self.session.headers["Authorization"].replace("Bearer ", "") + WS_URL = f"wss://dealer.spotify.com/?access_token={ACCESS_TOKEN}" + URL_REGISTRO = "https://spclient.wg.spotify.com/track-playback/v1/devices" + URL_COMMAND = f"https://spclient.wg.spotify.com/connect-state/v1/player/command/from/{DEVICE_ID}/to/{DEVICE_ID}" + URL = f"https://gue1-spclient.spotify.com/track-playback/v1/devices/{DEVICE_ID}/state" + + class Context: + conn_id = None + machine_id = None + state_id = None + stop_event = threading.Event() + + ctx = Context() + + def find_key(data, target): + if isinstance(data, dict): + for k, v in data.items(): + if k == target: return v + res = find_key(v, target) + if res: return res + elif isinstance(data, list): + for item in data: + res = find_key(item, target) + if res: return res + return None + + def on_message(ws, message): + try: + data = json.loads(message) + if "headers" in data and "Spotify-Connection-Id" in data["headers"]: + ctx.conn_id = data["headers"]["Spotify-Connection-Id"] + + new_mid = find_key(data, "state_machine_id") + if new_mid: + if new_mid != ctx.machine_id: + ctx.machine_id = new_mid + + new_sid = find_key(data, "state_id") + if new_sid and new_sid != ctx.state_id: + ctx.state_id = new_sid + except: + pass + + def start_socket(): + ws = websocket.WebSocketApp(WS_URL, on_message=on_message) + ws.run_forever() + + def keep_alive_loop(): + seq_num = 1 + last_mid = None + attempt_count = 0 + wait_ids_count = 0 + + while not ctx.stop_event.is_set(): + if ctx.machine_id and ctx.state_id and ctx.conn_id: + wait_ids_count = 0 + + if ctx.machine_id != last_mid: + seq_num = 1 + last_mid = ctx.machine_id + + payload = { + "seq_num": 0, + "state_ref": { + "state_machine_id": ctx.machine_id, + "state_id": ctx.state_id, + "paused": False, + }, + "sub_state": { + "playback_speed": 1, + "position": 1258, + "duration": 199954, + "media_type": "AUDIO", + "bitrate": 128000, + "audio_quality": "HIGH", + "format": 10, + "is_video_on": False, + }, + "previous_position": 1258, + "debug_source": "started_playing", + } + + try: + r = self.session.put( + URL, + data=json.dumps(payload, separators=(",", ":")) + ) + + if r.status_code == 200: + ctx.stop_event.set() + break + else: + attempt_count += 1 + + except Exception as e: + attempt_count += 1 + + if attempt_count >= 3: + ctx.stop_event.set() + break + + seq_num += 1 + time.sleep(3) + + else: + wait_ids_count += 1 + time.sleep(0.5) + + if wait_ids_count > 20: + ctx.stop_event.set() + break + + t_ws = threading.Thread(target=start_socket, daemon=True) + t_ws.start() + + while not ctx.conn_id: + time.sleep(0.1) + + self.session.headers.update({ + "x-spotify-connection-id": ctx.conn_id + }) + + reg_payload = { + "device": { + "brand": "spotify", + "capabilities": {"change_volume": True, "enable_play_token": True, "supports_file_media_type": True, + "manifest_formats": ["file_ids_mp4", "file_ids_mp4_dual"], "audio_podcasts": True, + "video_playback": True}, + "device_id": DEVICE_ID, "device_type": "computer", "metadata": {}, "model": "web_player", + "name": "Python Final", "platform_identifier": "web_player windows 10;chrome 124.0.0.0;desktop", + "is_group": False + }, + "connection_id": ctx.conn_id, "client_version": "harmony:4.62.1-5dc29b8a7", "volume": 65535 + } + self.session.post(URL_REGISTRO, json=reg_payload) + + t_put = threading.Thread(target=keep_alive_loop, daemon=True) + t_put.start() + + track_uri = f"spotify:track:{TRACK_ID}" + cmd_payload = { + "command": { + "context": {"uri": track_uri, "url": f"context://{track_uri}", "metadata": {}}, + "play_origin": {"feature_identifier": "harmony", "feature_version": "4.26.0"}, + "options": {"license": "premium", "skip_to": {"track_uri": track_uri}, "player_options_override": {}}, + "endpoint": "play" + } + } + self.session.post(URL_COMMAND, json=cmd_payload) + + try: + while not ctx.stop_event.is_set(): + time.sleep(1) + except KeyboardInterrupt: + ctx.stop_event.set() + + ################################################## + + params = { + "manifestFileFormat": [ + "file_ids_mp4", + "manifest_ids_video" + ] + } + response = self.session.get( + self.TRACK_PLAYBACK_API_URL.format( + media_type=media_type, + media_id=media_id + ), + params=params + ) + return response.json() + def get_lyrics(self, track_id: str) -> dict | None: self._refresh_session_auth() response = self.session.get(self.LYRICS_API_URL.format(track_id=track_id)) @@ -191,10 +421,35 @@ class SpotifyApi: check_response(response) return response.json() + def extract_keys_with_cdrm(self, pssh, media_type): + cmd = self.session.post('https://cdrm-project.com/api/decrypt', + headers={'Accept': 'application/json', + 'Content-Type': 'application/json'}, + json={ + 'pssh': pssh, + 'licurl': self.WIDEVINE_LICENSE_API_URL.format(type=media_type), + 'headers': str(self.session.headers) + }).json() + return cmd['message'] + def get_track(self, track_id: str) -> dict: self._refresh_session_auth() - response = self.session.get( - self.METADATA_API_URL.format(type="tracks", item_id=track_id) + payload = { + "variables": { + "uri": f"spotify:track:{track_id}" + }, + "operationName": "getTrack", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294" + } + } + } + + response = self.session.post( + self.METADATA_API_URL, + json=payload, ) check_response(response) return response.json() @@ -212,28 +467,32 @@ class SpotifyApi: time.sleep(self.EXTEND_TRACK_COLLECTION_WAIT_TIME) @functools.lru_cache() + def get_album( self, album_id: str, extend: bool = True, ) -> dict: - self._refresh_session_auth() - response = self.session.get( - self.METADATA_API_URL.format(type="albums", item_id=album_id) + payload = { + "variables": { + "uri": f"spotify:album:{album_id}", "locale": "intl-pt", "offset": 0, "limit": 5000 + }, + "operationName": "getAlbum", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10" + } + } + } + + response = self.session.post( + self.METADATA_API_URL, + json=payload, ) - check_response(response) - album = response.json() - if extend: - album["tracks"]["items"].extend( - [ - item - for extended_collection in self.extended_media_collection( - album["tracks"]["next"], - ) - for item in extended_collection["items"] - ] - ) - return album + + return response.json()['data']['albumUnion']['tracksV2']['items'] + def get_playlist( self, @@ -241,22 +500,28 @@ class SpotifyApi: extend: bool = True, ) -> dict: self._refresh_session_auth() - response = self.session.get( - self.METADATA_API_URL.format(type="playlists", item_id=playlist_id) + payload = { + "variables": { + "uri": f"spotify:playlist:{playlist_id}", + "offset": 0, + "limit": 5000, + "enableWatchFeedEntrypoint": True + }, + "operationName": "fetchPlaylist", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77" + } + } + } + + response = self.session.post( + self.METADATA_API_URL, + json=payload, ) - check_response(response) - playlist = response.json() - if extend: - playlist["tracks"]["items"].extend( - [ - item - for extended_collection in self.extended_media_collection( - playlist["tracks"]["next"], - ) - for item in extended_collection["items"] - ] - ) - return playlist + return response.json()['data']['playlistV2'] + def get_track_credits(self, track_id: str) -> dict: self._refresh_session_auth() @@ -268,20 +533,49 @@ class SpotifyApi: def get_episode(self, episode_id: str) -> dict: self._refresh_session_auth() - response = self.session.get( - self.METADATA_API_URL.format(type="episodes", item_id=episode_id) + payload = { + "variables": { + "uri": f"spotify:episode:{episode_id}" + }, + "operationName": "getEpisodeOrChapter", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "8a62dbdeb7bd79605d7d68b01bcdf83f08bc6c6287ee1665ba012c748a4cf1f3" + } + } + } + + response = self.session.post( + self.METADATA_API_URL, + json=payload, ) check_response(response) - return response.json() + return response.json()['data']['episodeUnionV2'] def get_show(self, show_id: str, extend: bool = True) -> dict: self._refresh_session_auth() - response = self.session.get( - self.METADATA_API_URL.format(type="shows", item_id=show_id) + payload = { + "variables": { + "uri": f"spotify:show:{show_id}", + "offset": 0, + "limit": 5000 + }, + "operationName": "queryPodcastEpisodes", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "8e2826c5993383566cc08bf9f5d3301b69513c3f6acb8d706286855e57bf44b2" + } + } + } + response = self.session.post( + self.METADATA_API_URL, + json=payload, ) check_response(response) show = response.json() - if extend: + '''if extend: show["episodes"]["items"].extend( [ item @@ -290,31 +584,173 @@ class SpotifyApi: ) for item in extended_collection["items"] ] - ) - return show + )''' + return show["data"]['podcastUnionV2'] - def get_artist_albums( + def get_artist_albums_selection( self, artist_id: str, extend: bool = True, ) -> dict: self._refresh_session_auth() - response = self.session.get( - self.METADATA_API_URL.format(type="artists", item_id=artist_id) + "/albums" - ) + payload = { + "variables": { + "uri": f"spotify:artist:{artist_id}", + "offset": 0, + "limit": 5000, + "order": "DATE_DESC" + }, + "operationName": "queryArtistDiscographyAlbums", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "5e07d323febb57b4a56a42abbf781490e58764aa45feb6e3dc0591564fc56599" + } + } + } + response = self.session.post(self.METADATA_API_URL, json=payload) check_response(response) - artist_albums = response.json() - if extend: - artist_albums["items"].extend( - [ - item - for extended_collection in self.extended_media_collection( - artist_albums["next"], - ) - for item in extended_collection["items"] - ] - ) - return artist_albums + artist_albums = response.json()['data']['artistUnion']['discography']['albums'] + return self._select_and_queue(artist_albums, "Albuns") + + def get_artist_singles_selection( + self, + artist_id: str, + extend: bool = True, + ) -> dict: + self._refresh_session_auth() + payload = { + "variables": { + "uri": f"spotify:artist:{artist_id}", + "offset": 0, + "limit": 5000, + "order": "DATE_DESC" + }, + "operationName": "queryArtistDiscographySingles", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "5e07d323febb57b4a56a42abbf781490e58764aa45feb6e3dc0591564fc56599" + } + } + } + response = self.session.post(self.METADATA_API_URL, json=payload) + check_response(response) + artist_albums = response.json()['data']['artistUnion']['discography']['singles'] + return self._select_and_queue(artist_albums, "Singles") + + def get_artist_compilations_selection( + self, + artist_id: str, + extend: bool = True, + ) -> dict: + self._refresh_session_auth() + payload = { + "variables": { + "uri": f"spotify:artist:{artist_id}", + "offset": 0, + "limit": 5000, + "order": "DATE_DESC" + }, + "operationName": "queryArtistDiscographyCompilations", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "5e07d323febb57b4a56a42abbf781490e58764aa45feb6e3dc0591564fc56599" + } + } + } + response = self.session.post(self.METADATA_API_URL, json=payload) + check_response(response) + artist_albums = response.json()['data']['artistUnion']['discography']['compilations'] + return self._select_and_queue(artist_albums, "Compilations") + + def get_artist_collaborations_selection( + self, + artist_id: str, + extend: bool = True, + ) -> dict: + self._refresh_session_auth() + payload = { + "variables": { + "uri": f"spotify:artist:{artist_id}", + "offset": 0, + "limit": 5000, + "order": "DATE_DESC" + }, + "operationName": "queryArtistAppearsOn", + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": "9a4bb7a20d6720fe52d7b47bc001cfa91940ddf5e7113761460b4a288d18a4c1" + } + } + } + response = self.session.post(self.METADATA_API_URL, json=payload) + check_response(response) + artist_albums = response.json()['data']['artistUnion']['relatedContent']['appearsOn'] + return self._select_and_queue(artist_albums, "Collaborations") + + def _select_and_queue(self, data_input: any, category_name: str) -> list[str]: + raw_items = [] + if isinstance(data_input, dict) and 'items' in data_input: + raw_items = data_input['items'] + elif isinstance(data_input, list): + raw_items = data_input + + if not raw_items: + print(f"No {category_name} found for this artist.") + return [] + + clean_list = [] + for entry in raw_items: + try: + if 'releases' in entry and 'items' in entry['releases']: + album_data = entry['releases']['items'][0] + clean_obj = { + 'name': album_data.get('name', 'Unknown'), + 'id': album_data.get('id'), + 'year': str(album_data.get('date', {}).get('year', '????')), + 'total_tracks': album_data.get('tracks', {}).get('totalCount', 0) + } + clean_list.append(clean_obj) + elif 'name' in entry: + clean_list.append({ + 'name': entry.get('name'), + 'id': entry.get('id'), + 'year': entry.get('release_date', '????')[:4], + 'total_tracks': entry.get('total_tracks', 0) + }) + except Exception: + continue + + if not clean_list: + print(f"Could not parse items for {category_name}.") + return [] + + print(f"\n--- Select {category_name} ---") + print("Index | Tracks | Year | Name") + for index, album in enumerate(clean_list): + display_str = f"{album['total_tracks']:03d} | {album['year']} | {album['name']}" + print(f"[{index + 1}] {display_str}") + + print("\nType numbers separated by space (e.g. '1 3') or 'all'.") + user_input = input("Selection: ").strip().lower() + + selected_ids = [] + + if user_input == 'all': + selected_ids = [album['id'] for album in clean_list] + else: + parts = user_input.replace(',', ' ').split() + for part in parts: + if part.isdigit(): + idx = int(part) - 1 + if 0 <= idx < len(clean_list): + selected_ids.append(clean_list[idx]['id']) + + return selected_ids + def get_video_manifest( self, @@ -363,6 +799,9 @@ class SpotifyApi: self.WIDEVINE_LICENSE_API_URL.format(type=media_type), challenge, ) + if response.status_code == 403: + logger.error(f"The device.wvd file is invalid or banned.") + logger.warning(f'Delete the device.wvd file to use the alternate key.') check_response(response) return response.content diff --git a/votify/totp.py b/votify/totp.py index 9e6ea32..5c070b8 100644 --- a/votify/totp.py +++ b/votify/totp.py @@ -1,41 +1,26 @@ -from __future__ import annotations - import hashlib import hmac -import math -import requests + +from typing import Collection -# thanks to https://github.com/glomatico/votify/pull/42#issuecomment-2727036757 class TOTP: - SPOTIFY_SECRETS_JSON = "https://raw.githubusercontent.com/Thereallo1026/spotify-secrets/refs/heads/main/secrets/secretDict.json" PERIOD = 30 DIGITS = 6 - def __init__(self) -> None: - self._setup() - - def _setup(self) -> None: - version, secret_cipher_bytes = self.get_latest_secret() + def __init__(self, *, version: int, ciphertext: Collection[int]) -> None: self.version = version - self.secret = self.derive_secret_number(secret_cipher_bytes).encode() + self.secret = self.derive(ciphertext) - def derive_secret_number(self, secret_cipher_bytes: list[int]) -> str: - transformed = [ - byte ^ ((i % 33) + 9) for i, byte in enumerate(secret_cipher_bytes) - ] - return "".join(str(n) for n in transformed) - - def get_latest_secret(self) -> tuple[int, list[int]]: - response = requests.get(self.SPOTIFY_SECRETS_JSON) - response.raise_for_status() - secrets = response.json() - latest_version = max(int(v) for v in secrets.keys()) - return latest_version, secrets[str(latest_version)] + @staticmethod + def derive(ciphertext: Collection[int]) -> bytes: + return ''.join( + str(byte ^ ((i % 33) + 9)) for i, byte in enumerate(ciphertext) + ).encode('ascii') def generate(self, timestamp: int) -> str: - counter = math.floor(timestamp / 1000 / self.PERIOD) - counter_bytes = counter.to_bytes(8, byteorder="big") + counter = int(timestamp) // 1000 // self.PERIOD + counter_bytes = counter.to_bytes(8) h = hmac.new(self.secret, counter_bytes, hashlib.sha1) hmac_result = h.digest() diff --git a/votify/utils.py b/votify/utils.py index 20474e8..81b306e 100644 --- a/votify/utils.py +++ b/votify/utils.py @@ -22,9 +22,9 @@ def _raise_response_exception(response: requests.Response): ) -def prompt_path(is_file: bool, initial_path: Path, description: str) -> Path: +def prompt_path(is_file: bool, initial_path: Path, description: str, optional: bool = False) -> Path: path_validator = click.Path( - exists=True, + exists=not optional, file_okay=is_file, dir_okay=not is_file, path_type=Path,