Merge pull request #81 from GladistonXD/main

Fixed Track, Album, Playlist, Episode, Show, Music Video, Episode Video, Artist
This commit is contained in:
Rafael Moraes
2026-02-08 13:07:39 -03:00
committed by GitHub
12 changed files with 1655 additions and 506 deletions
+8 -5
View File
@@ -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.
+1
View File
@@ -8,3 +8,4 @@ pybase62
pycryptodome
pywidevine
yt-dlp
websocket-client
+171 -35
View File
@@ -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))")
+501 -126
View File
@@ -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(
+31 -17
View File
@@ -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):
+162 -101
View File
@@ -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,
+3 -3
View File
@@ -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(
+69 -34
View File
@@ -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(
+183 -83
View File
@@ -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,
+513 -74
View File
@@ -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
+11 -26
View File
@@ -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()
+2 -2
View File
@@ -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,