further implementations

This commit is contained in:
Kamil
2024-11-29 20:49:36 +00:00
parent f81188f7e3
commit 7232b3223d
2 changed files with 310 additions and 65 deletions

View File

@@ -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

View File

@@ -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,62 +33,83 @@ 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.")
headers = { self.session_data, self.config_data = self._fetch_session_data()
'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', self.client_token = self._fetch_client_token()
'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', else:
} l.debug("Authenticating without cookies.")
response = requests.get(url, headers=headers) self.session_data, self.config_data = self._fetch_session_data(fetch_with_cookies=False)
response.raise_for_status() self.client_token = self._fetch_client_token()
soup = BeautifulSoup(response.text, 'html.parser')
session_script = soup.find('script', {'id': 'session'})
config_script = soup.find('script', {'id': 'config'})
if session_script and config_script:
return json.loads(session_script.string), json.loads(config_script.string)
else:
raise ValueError("Failed to fetch session or config data.")
def _fetch_session_data(self, fetch_with_cookies: bool = True):
"""
Fetch session data from Spotify.
self.session_data, self.config_data = fetch_session_data() :param fetch_with_cookies: Whether to include cookies in the request.
:return: Tuple containing session and config data.
"""
url = 'https://open.spotify.com/'
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',
'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',
}
cookies = self.cookies if fetch_with_cookies else None
response = requests.get(url, headers=headers, cookies=cookies)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
session_script = soup.find('script', {'id': 'session'})
config_script = soup.find('script', {'id': 'config'})
if session_script and config_script:
l.debug("fetched session and config scripts")
return json.loads(session_script.string), json.loads(config_script.string)
else:
raise ValueError("Failed to fetch session or config data.")
def fetch_client_token(): def _fetch_client_token(self):
url = f'https://clienttoken.spotify.com/v1/clienttoken' """
headers = { Fetch the client token using session data and cookies.
'accept': 'application/json',
'content-type': 'application/json', :return: The client token as a string.
'origin': 'https://open.spotify.com', """
'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', url = f'https://clienttoken.spotify.com/v1/clienttoken'
} headers = {
payload = { 'accept': 'application/json',
"client_data": { 'content-type': 'application/json',
"client_version": "1.2.52.404.gcb99a997", 'origin': 'https://open.spotify.com',
"client_id": self.session_data.get("clientId", ""), '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',
"js_sdk_data": { }
"device_brand": "unknown", payload = {
"device_model": "unknown", "client_data": {
"os": "windows", "client_version": "1.2.52.404.gcb99a997",
"os_version": "NT 10.0", "client_id": self.session_data.get("clientId", ""),
"device_id": self.config_data.get("correlationId", ""), "js_sdk_data": {
"device_type": "computer" "device_brand": "unknown",
} "device_model": "unknown",
"os": "windows",
"os_version": "NT 10.0",
"device_id": self.config_data.get("correlationId", ""),
"device_type": "computer"
} }
} }
response = requests.post(url, headers=headers, json=payload) }
response.raise_for_status() response = requests.post(url, headers=headers, json=payload, cookies=self.cookies)
return response.json().get("granted_token", "") response.raise_for_status()
l.debug("fetched granted_token")
self.client_token = fetch_client_token() return response.json().get("granted_token", "")
def _make_request(self, endpoint: str, params: dict = None) -> dict: def _make_request(self, endpoint: str, params: dict = None) -> dict:
""" """
@@ -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