further implementations
This commit is contained in:
@@ -2,6 +2,8 @@ from dataclasses import dataclass, field
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ExternalUrl:
|
class ExternalUrl:
|
||||||
url: str
|
url: str
|
||||||
@@ -13,6 +15,26 @@ class ItemBase:
|
|||||||
uri: str
|
uri: str
|
||||||
external_urls: Optional[List[ExternalUrl]]
|
external_urls: Optional[List[ExternalUrl]]
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Profile:
|
||||||
|
avatar: Optional[str] # Avatar URL or None
|
||||||
|
avatar_background_color: Optional[int]
|
||||||
|
name: str
|
||||||
|
uri: str
|
||||||
|
username: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccountAttributes:
|
||||||
|
catalogue: str
|
||||||
|
dsa_mode_available: bool
|
||||||
|
dsa_mode_enabled: bool
|
||||||
|
multi_user_plan_current_size: Optional[int]
|
||||||
|
multi_user_plan_member_type: Optional[str]
|
||||||
|
on_demand: bool
|
||||||
|
opt_in_trial_premium_only_market: bool
|
||||||
|
country: str
|
||||||
|
product: str
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Image:
|
class Image:
|
||||||
url: str
|
url: str
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
from app.providers.base import Album, MusicProviderClient,Playlist,Track,ExternalUrl,Category
|
from app.providers.base import AccountAttributes, Album, Artist, Image, MusicProviderClient, Owner,Playlist, PlaylistTrack, Profile,Track,ExternalUrl,Category
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -8,6 +8,9 @@ from bs4 import BeautifulSoup
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from http.cookiejar import MozillaCookieJar
|
from http.cookiejar import MozillaCookieJar
|
||||||
|
import logging
|
||||||
|
|
||||||
|
l = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SpotifyClient(MusicProviderClient):
|
class SpotifyClient(MusicProviderClient):
|
||||||
"""
|
"""
|
||||||
@@ -30,36 +33,58 @@ class SpotifyClient(MusicProviderClient):
|
|||||||
:param cookie_file: Path to the cookie file.
|
:param cookie_file: Path to the cookie file.
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(cookie_file):
|
if not os.path.exists(cookie_file):
|
||||||
|
l.error(f"Cookie file not found: {cookie_file}")
|
||||||
raise FileNotFoundError(f"Cookie file not found: {cookie_file}")
|
raise FileNotFoundError(f"Cookie file not found: {cookie_file}")
|
||||||
|
|
||||||
cookie_jar = MozillaCookieJar(cookie_file)
|
cookie_jar = MozillaCookieJar(cookie_file)
|
||||||
cookie_jar.load(ignore_discard=True, ignore_expires=True)
|
cookie_jar.load(ignore_discard=True, ignore_expires=True)
|
||||||
self.cookies = requests.utils.dict_from_cookiejar(cookie_jar)
|
self.cookies = requests.utils.dict_from_cookiejar(cookie_jar)
|
||||||
|
|
||||||
def authenticate(self, credentials: dict) -> None:
|
def authenticate(self, credentials: Optional[dict] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Authenticate with Spotify by fetching session data and client token.
|
Authenticate with Spotify using cookies if available, or fetch session and config data.
|
||||||
|
|
||||||
|
:param credentials: Optional dictionary of credentials.
|
||||||
"""
|
"""
|
||||||
def fetch_session_data():
|
if self.cookies:
|
||||||
url = f'https://open.spotify.com/'
|
l.debug("Authenticating using cookies.")
|
||||||
|
self.session_data, self.config_data = self._fetch_session_data()
|
||||||
|
self.client_token = self._fetch_client_token()
|
||||||
|
else:
|
||||||
|
l.debug("Authenticating without cookies.")
|
||||||
|
self.session_data, self.config_data = self._fetch_session_data(fetch_with_cookies=False)
|
||||||
|
self.client_token = self._fetch_client_token()
|
||||||
|
|
||||||
|
def _fetch_session_data(self, fetch_with_cookies: bool = True):
|
||||||
|
"""
|
||||||
|
Fetch session data from Spotify.
|
||||||
|
|
||||||
|
:param fetch_with_cookies: Whether to include cookies in the request.
|
||||||
|
:return: Tuple containing session and config data.
|
||||||
|
"""
|
||||||
|
url = 'https://open.spotify.com/'
|
||||||
headers = {
|
headers = {
|
||||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
}
|
}
|
||||||
response = requests.get(url, headers=headers)
|
cookies = self.cookies if fetch_with_cookies else None
|
||||||
|
response = requests.get(url, headers=headers, cookies=cookies)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
session_script = soup.find('script', {'id': 'session'})
|
session_script = soup.find('script', {'id': 'session'})
|
||||||
config_script = soup.find('script', {'id': 'config'})
|
config_script = soup.find('script', {'id': 'config'})
|
||||||
if session_script and config_script:
|
if session_script and config_script:
|
||||||
|
l.debug("fetched session and config scripts")
|
||||||
return json.loads(session_script.string), json.loads(config_script.string)
|
return json.loads(session_script.string), json.loads(config_script.string)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Failed to fetch session or config data.")
|
raise ValueError("Failed to fetch session or config data.")
|
||||||
|
|
||||||
|
def _fetch_client_token(self):
|
||||||
|
"""
|
||||||
|
Fetch the client token using session data and cookies.
|
||||||
|
|
||||||
self.session_data, self.config_data = fetch_session_data()
|
:return: The client token as a string.
|
||||||
|
"""
|
||||||
def fetch_client_token():
|
|
||||||
url = f'https://clienttoken.spotify.com/v1/clienttoken'
|
url = f'https://clienttoken.spotify.com/v1/clienttoken'
|
||||||
headers = {
|
headers = {
|
||||||
'accept': 'application/json',
|
'accept': 'application/json',
|
||||||
@@ -81,12 +106,11 @@ class SpotifyClient(MusicProviderClient):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response = requests.post(url, headers=headers, json=payload)
|
response = requests.post(url, headers=headers, json=payload, cookies=self.cookies)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
l.debug("fetched granted_token")
|
||||||
return response.json().get("granted_token", "")
|
return response.json().get("granted_token", "")
|
||||||
|
|
||||||
self.client_token = fetch_client_token()
|
|
||||||
|
|
||||||
def _make_request(self, endpoint: str, params: dict = None) -> dict:
|
def _make_request(self, endpoint: str, params: dict = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Helper method to make authenticated requests to Spotify APIs.
|
Helper method to make authenticated requests to Spotify APIs.
|
||||||
@@ -97,13 +121,115 @@ class SpotifyClient(MusicProviderClient):
|
|||||||
'authorization': f'Bearer {self.session_data.get("accessToken", "")}',
|
'authorization': f'Bearer {self.session_data.get("accessToken", "")}',
|
||||||
'client-token': self.client_token.get('token',''),
|
'client-token': self.client_token.get('token',''),
|
||||||
}
|
}
|
||||||
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params)
|
l.debug(f"starting request: {self.base_url}/{endpoint}")
|
||||||
|
response = requests.get(f"{self.base_url}/{endpoint}", headers=headers, params=params, cookies=self.cookies)
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
#region utility functions to help parsing objects
|
||||||
|
def _parse_external_urls(self, uri: str, entity_type: str) -> List[ExternalUrl]:
|
||||||
|
"""
|
||||||
|
Create ExternalUrl instances for an entity.
|
||||||
|
|
||||||
|
:param uri: The URI of the entity.
|
||||||
|
:param entity_type: The type of entity ('track', 'album', 'artist', 'playlist', etc.).
|
||||||
|
:return: A list of ExternalUrl instances.
|
||||||
|
"""
|
||||||
|
return [ExternalUrl(url=f"https://open.spotify.com/{entity_type}/{uri.split(':')[-1]}")]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_images(self, image_data: List[Dict]) -> List[Image]:
|
||||||
|
"""
|
||||||
|
Parse images from the API response.
|
||||||
|
|
||||||
|
:param image_data: List of dictionaries containing image data.
|
||||||
|
:return: A list of Image objects.
|
||||||
|
"""
|
||||||
|
images = []
|
||||||
|
for img in image_data:
|
||||||
|
# Extract the first source if available
|
||||||
|
sources = img.get("sources", [])
|
||||||
|
if sources:
|
||||||
|
source = sources[0] # Take the first source as the default
|
||||||
|
images.append(Image(
|
||||||
|
url=source.get("url"),
|
||||||
|
height=source.get("height"),
|
||||||
|
width=source.get("width")
|
||||||
|
))
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_artist(self, artist_data: Dict) -> Artist:
|
||||||
|
"""
|
||||||
|
Parse an artist object from API data.
|
||||||
|
|
||||||
|
:param artist_data: Dictionary representing an artist.
|
||||||
|
:return: An Artist instance.
|
||||||
|
"""
|
||||||
|
return Artist(
|
||||||
|
id=artist_data["uri"].split(":")[-1],
|
||||||
|
name=artist_data["profile"]["name"],
|
||||||
|
uri=artist_data["uri"],
|
||||||
|
external_urls=self._parse_external_urls(artist_data["uri"], "artist")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_album(self, album_data: Dict) -> Album:
|
||||||
|
"""
|
||||||
|
Parse an album object from API data.
|
||||||
|
|
||||||
|
:param album_data: Dictionary representing an album.
|
||||||
|
:return: An Album instance.
|
||||||
|
"""
|
||||||
|
return Album(
|
||||||
|
id=album_data["uri"].split(":")[-1],
|
||||||
|
name=album_data["name"],
|
||||||
|
uri=album_data["uri"],
|
||||||
|
external_urls=self._parse_external_urls(album_data["uri"], "album"),
|
||||||
|
artists=[self._parse_artist(artist) for artist in album_data["artists"]["items"]],
|
||||||
|
images=self._parse_images(album_data["coverArt"]["sources"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_track(self, track_data: Dict) -> Track:
|
||||||
|
"""
|
||||||
|
Parse a track object from API data.
|
||||||
|
|
||||||
|
:param track_data: Dictionary representing a track.
|
||||||
|
:return: A Track instance.
|
||||||
|
"""
|
||||||
|
return Track(
|
||||||
|
id=track_data["uri"].split(":")[-1],
|
||||||
|
name=track_data["name"],
|
||||||
|
uri=track_data["uri"],
|
||||||
|
external_urls=self._parse_external_urls(track_data["uri"], "track"),
|
||||||
|
duration_ms=track_data["trackDuration"]["totalMilliseconds"],
|
||||||
|
explicit=track_data.get("explicit", False),
|
||||||
|
album=self._parse_album(track_data["albumOfTrack"]),
|
||||||
|
artists=[self._parse_artist(artist) for artist in track_data["artists"]["items"]]
|
||||||
|
)
|
||||||
|
def _parse_owner(self, owner_data: Dict) -> Optional[Owner]:
|
||||||
|
"""
|
||||||
|
Parse an owner object from API data.
|
||||||
|
|
||||||
|
:param owner_data: Dictionary representing an owner.
|
||||||
|
:return: An Owner instance or None if the owner data is empty.
|
||||||
|
"""
|
||||||
|
if not owner_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Owner(
|
||||||
|
id=owner_data.get("uri", "").split(":")[-1],
|
||||||
|
name=owner_data.get("name", ""),
|
||||||
|
uri=owner_data.get("uri", ""),
|
||||||
|
external_urls=self._parse_external_urls(owner_data.get("uri", ""), "user")
|
||||||
|
)
|
||||||
|
#endregion
|
||||||
|
|
||||||
def get_playlist(self, playlist_id: str) -> Playlist:
|
def get_playlist(self, playlist_id: str) -> Playlist:
|
||||||
"""
|
"""
|
||||||
Fetch a playlist by ID with all tracks.
|
Fetch a playlist by ID with all tracks, using the defined generic classes.
|
||||||
"""
|
"""
|
||||||
limit = 50
|
limit = 50
|
||||||
offset = 0
|
offset = 0
|
||||||
@@ -136,27 +262,34 @@ class SpotifyClient(MusicProviderClient):
|
|||||||
|
|
||||||
offset += limit
|
offset += limit
|
||||||
|
|
||||||
tracks = [
|
# Use utility methods to parse tracks
|
||||||
Track(
|
tracks = [self._parse_track(item["itemV2"]["data"]) for item in all_items]
|
||||||
id=item["itemV2"]["data"]["uri"].split(":")[-1],
|
|
||||||
name=item["itemV2"]["data"]["name"],
|
images = self._parse_images(playlist_data.get("images", {}).get("items", []))
|
||||||
uri=item["itemV2"]["data"]["uri"],
|
|
||||||
duration_ms=item["itemV2"]["data"]["trackDuration"]["totalMilliseconds"],
|
owner_data = playlist_data.get("ownerV2", {}).get("data", {})
|
||||||
explicit=False, # Default as Spotify API doesn't provide explicit info here
|
owner = self._parse_owner(owner_data)
|
||||||
album=item["itemV2"]["data"]["albumOfTrack"]["name"],
|
|
||||||
artists=[
|
|
||||||
artist["profile"]["name"]
|
|
||||||
for artist in item["itemV2"]["data"]["albumOfTrack"]["artists"]["items"]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
for item in all_items
|
|
||||||
]
|
|
||||||
return Playlist(
|
return Playlist(
|
||||||
id=playlist_id,
|
id=playlist_id,
|
||||||
name=playlist_data.get("name", ""),
|
name=playlist_data.get("name", ""),
|
||||||
description=playlist_data.get("description", ""),
|
|
||||||
uri=playlist_data.get("uri", ""),
|
uri=playlist_data.get("uri", ""),
|
||||||
tracks=tracks,
|
external_urls=self._parse_external_urls(playlist_id, "playlist"),
|
||||||
|
description=playlist_data.get("description", ""),
|
||||||
|
public=playlist_data.get("public", None),
|
||||||
|
collaborative=playlist_data.get("collaborative", None),
|
||||||
|
followers=playlist_data.get("followers", 0),
|
||||||
|
images=images,
|
||||||
|
owner=owner,
|
||||||
|
tracks=[
|
||||||
|
PlaylistTrack(
|
||||||
|
added_at=item.get("addedAt", {}).get("isoString", ""),
|
||||||
|
added_by=None,
|
||||||
|
is_local=False,
|
||||||
|
track=track
|
||||||
|
)
|
||||||
|
for item, track in zip(all_items, tracks)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def search_tracks(self, query: str, limit: int = 10) -> List[Track]:
|
def search_tracks(self, query: str, limit: int = 10) -> List[Track]:
|
||||||
@@ -205,3 +338,93 @@ class SpotifyClient(MusicProviderClient):
|
|||||||
"""
|
"""
|
||||||
print(f"get_categories: Placeholder for categories with limit {limit}.")
|
print(f"get_categories: Placeholder for categories with limit {limit}.")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# non generic method implementations:
|
||||||
|
def get_profile(self) -> Optional[Profile]:
|
||||||
|
"""
|
||||||
|
Fetch the profile attributes of the authenticated Spotify user.
|
||||||
|
|
||||||
|
:return: A Profile object containing the user's profile information or None if an error occurs.
|
||||||
|
"""
|
||||||
|
query_parameters = {
|
||||||
|
"operationName": "profileAttributes",
|
||||||
|
"variables": json.dumps({}),
|
||||||
|
"extensions": json.dumps({
|
||||||
|
"persistedQuery": {
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "53bcb064f6cd18c23f752bc324a791194d20df612d8e1239c735144ab0399ced"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded_query = urlencode(query_parameters)
|
||||||
|
|
||||||
|
url = f"pathfinder/v1/query?{encoded_query}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._make_request(url)
|
||||||
|
profile_data = response.get('data', {}).get('me', {}).get('profile', {})
|
||||||
|
if not profile_data:
|
||||||
|
raise ValueError("Invalid profile data received.")
|
||||||
|
return Profile(
|
||||||
|
avatar=profile_data.get("avatar"),
|
||||||
|
avatar_background_color=profile_data.get("avatarBackgroundColor"),
|
||||||
|
name=profile_data.get("name", ""),
|
||||||
|
uri=profile_data.get("uri", ""),
|
||||||
|
username=profile_data.get("username", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An error occurred while fetching profile attributes: {e}")
|
||||||
|
return None
|
||||||
|
def get_account_attributes(self) -> Optional[AccountAttributes]:
|
||||||
|
"""
|
||||||
|
Fetch the account attributes of the authenticated Spotify user.
|
||||||
|
|
||||||
|
:return: An AccountAttributes object containing the user's account information or None if an error occurs.
|
||||||
|
"""
|
||||||
|
# Define the query parameters
|
||||||
|
query_parameters = {
|
||||||
|
"operationName": "accountAttributes",
|
||||||
|
"variables": json.dumps({}), # Empty variables for this query
|
||||||
|
"extensions": json.dumps({
|
||||||
|
"persistedQuery": {
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "4fbd57be3c6ec2157adcc5b8573ec571f61412de23bbb798d8f6a156b7d34cdf"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
# Encode the query parameters
|
||||||
|
encoded_query = urlencode(query_parameters)
|
||||||
|
|
||||||
|
# API endpoint
|
||||||
|
url = f"pathfinder/v1/query?{encoded_query}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Perform the request
|
||||||
|
response = self._make_request(url)
|
||||||
|
|
||||||
|
# Extract and validate the account data
|
||||||
|
account_data = response.get('data', {}).get('me', {}).get('account', {})
|
||||||
|
attributes = account_data.get("attributes", {})
|
||||||
|
if not attributes or not account_data.get("country") or not account_data.get("product"):
|
||||||
|
raise ValueError("Invalid account data received.")
|
||||||
|
|
||||||
|
# Map the response to the AccountAttributes class
|
||||||
|
return AccountAttributes(
|
||||||
|
catalogue=attributes.get("catalogue", ""),
|
||||||
|
dsa_mode_available=attributes.get("dsaModeAvailable", False),
|
||||||
|
dsa_mode_enabled=attributes.get("dsaModeEnabled", False),
|
||||||
|
multi_user_plan_current_size=attributes.get("multiUserPlanCurrentSize"),
|
||||||
|
multi_user_plan_member_type=attributes.get("multiUserPlanMemberType"),
|
||||||
|
on_demand=attributes.get("onDemand", False),
|
||||||
|
opt_in_trial_premium_only_market=attributes.get("optInTrialPremiumOnlyMarket", False),
|
||||||
|
country=account_data.get("country", ""),
|
||||||
|
product=account_data.get("product", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An error occurred while fetching account attributes: {e}")
|
||||||
|
return None
|
||||||
Reference in New Issue
Block a user