diff --git a/backend/alembic/versions/b2c3d4e5f6g7_add_reccobeats_id.py b/backend/alembic/versions/b2c3d4e5f6g7_add_reccobeats_id.py new file mode 100644 index 0000000..0372a48 --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6g7_add_reccobeats_id.py @@ -0,0 +1,25 @@ +"""Add reccobeats_id column to tracks table + +Revision ID: b2c3d4e5f6g7 +Revises: a1b2c3d4e5f6 +Create Date: 2025-12-30 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "b2c3d4e5f6g7" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("tracks", sa.Column("reccobeats_id", sa.String(), nullable=True)) + op.create_index("ix_tracks_reccobeats_id", "tracks", ["reccobeats_id"]) + + +def downgrade() -> None: + op.drop_index("ix_tracks_reccobeats_id", "tracks") + op.drop_column("tracks", "reccobeats_id") diff --git a/backend/app/models.py b/backend/app/models.py index d79b0c5..6baaa2a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -39,6 +39,7 @@ class Track(Base): __tablename__ = "tracks" id = Column(String, primary_key=True, index=True) # Spotify ID + reccobeats_id = Column(String, nullable=True, index=True) # ReccoBeats UUID name = Column(String) artist = Column( String diff --git a/backend/app/services/narrative_service.py b/backend/app/services/narrative_service.py index c61d0a4..49f209c 100644 --- a/backend/app/services/narrative_service.py +++ b/backend/app/services/narrative_service.py @@ -73,8 +73,7 @@ class NarrativeService: {"role": "user", "content": prompt}, ], response_format={"type": "json_object"}, - max_completion_tokens=1500, - temperature=0.8, + max_completion_tokens=4000, ) return self._clean_and_parse_json(response.choices[0].message.content) diff --git a/backend/app/services/reccobeats_client.py b/backend/app/services/reccobeats_client.py index 361b91a..553b11e 100644 --- a/backend/app/services/reccobeats_client.py +++ b/backend/app/services/reccobeats_client.py @@ -1,21 +1,25 @@ import httpx -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional -RECCOBEATS_API_URL = "https://api.reccobeats.com/v1/audio-features" +RECCOBEATS_BASE_URL = "https://api.reccobeats.com/v1" class ReccoBeatsClient: - async def get_audio_features(self, spotify_ids: List[str]) -> List[Dict[str, Any]]: + def __init__(self): + self.timeout = 30.0 + + async def get_tracks(self, spotify_ids: List[str]) -> List[Dict[str, Any]]: if not spotify_ids: return [] + ids_param = ",".join(spotify_ids) - async with httpx.AsyncClient(timeout=30.0) as client: + async with httpx.AsyncClient(timeout=self.timeout) as client: try: response = await client.get( - RECCOBEATS_API_URL, params={"ids": ids_param} + f"{RECCOBEATS_BASE_URL}/track", params={"ids": ids_param} ) if response.status_code != 200: - print(f"ReccoBeats API returned status {response.status_code}") + print(f"ReccoBeats /track returned status {response.status_code}") return [] content = response.json().get("content", []) @@ -23,10 +27,110 @@ class ReccoBeatsClient: for item in content: href = item.get("href", "") if "spotify.com/track/" in href: - spotify_id = href.split("/track/")[-1].split("?")[0] - item["spotify_id"] = spotify_id + item["spotify_id"] = href.split("/track/")[-1].split("?")[0] return content except Exception as e: - print(f"ReccoBeats API error: {e}") + print(f"ReccoBeats /track error: {e}") return [] + + async def get_audio_features(self, spotify_ids: List[str]) -> List[Dict[str, Any]]: + """Fetch audio features for tracks. Batches in chunks of 40 (API limit).""" + if not spotify_ids: + return [] + + all_results = [] + batch_size = 40 # ReccoBeats API returns 400 for 50+ IDs + + async with httpx.AsyncClient(timeout=self.timeout) as client: + for i in range(0, len(spotify_ids), batch_size): + batch = spotify_ids[i : i + batch_size] + ids_param = ",".join(batch) + + try: + response = await client.get( + f"{RECCOBEATS_BASE_URL}/audio-features", + params={"ids": ids_param}, + ) + if response.status_code != 200: + print( + f"ReccoBeats /audio-features returned status {response.status_code}" + ) + continue + + content = response.json().get("content", []) + + for item in content: + href = item.get("href", "") + if "spotify.com/track/" in href: + item["spotify_id"] = href.split("/track/")[-1].split("?")[0] + + all_results.extend(content) + except Exception as e: + print(f"ReccoBeats /audio-features error: {e}") + + return all_results + + async def get_recommendations( + self, + seed_ids: List[str], + size: int = 20, + negative_seeds: Optional[List[str]] = None, + ) -> List[Dict[str, Any]]: + if not seed_ids: + return [] + + if len(seed_ids) > 5: + seed_ids = seed_ids[:5] + + params = {"seeds": ",".join(seed_ids), "size": size} + + if negative_seeds: + if len(negative_seeds) > 5: + negative_seeds = negative_seeds[:5] + params["negativeSeeds"] = ",".join(negative_seeds) + + async with httpx.AsyncClient(timeout=self.timeout) as client: + try: + response = await client.get( + f"{RECCOBEATS_BASE_URL}/track/recommendation", params=params + ) + if response.status_code != 200: + print( + f"ReccoBeats /recommendation returned status {response.status_code}" + ) + return [] + + content = response.json().get("content", []) + + for item in content: + href = item.get("href", "") + if "spotify.com/track/" in href: + item["spotify_id"] = href.split("/track/")[-1].split("?")[0] + + return content + except Exception as e: + print(f"ReccoBeats /recommendation error: {e}") + return [] + + async def get_audio_features_by_reccobeats_ids( + self, reccobeats_ids: List[str] + ) -> List[Dict[str, Any]]: + if not reccobeats_ids: + return [] + + results = [] + async with httpx.AsyncClient(timeout=self.timeout) as client: + for rb_id in reccobeats_ids: + try: + response = await client.get( + f"{RECCOBEATS_BASE_URL}/track/{rb_id}/audio-features" + ) + if response.status_code == 200: + data = response.json() + data["reccobeats_id"] = rb_id + results.append(data) + except Exception as e: + print(f"ReccoBeats audio-features for {rb_id} error: {e}") + + return results diff --git a/backend/app/services/spotify_client.py b/backend/app/services/spotify_client.py index 5b5978f..096e8b4 100644 --- a/backend/app/services/spotify_client.py +++ b/backend/app/services/spotify_client.py @@ -107,3 +107,104 @@ class SpotifyClient: print(f"Error fetching currently playing: {response.text}") return None return response.json() + + async def get_current_user_id(self) -> str: + token = await self.get_access_token() + async with httpx.AsyncClient() as client: + response = await client.get( + f"{SPOTIFY_API_BASE}/me", + headers={"Authorization": f"Bearer {token}"}, + ) + if response.status_code != 200: + raise Exception(f"Failed to get user profile: {response.text}") + return response.json().get("id") + + async def create_playlist( + self, user_id: str, name: str, description: str = "", public: bool = False + ) -> Dict[str, Any]: + token = await self.get_access_token() + async with httpx.AsyncClient() as client: + response = await client.post( + f"{SPOTIFY_API_BASE}/users/{user_id}/playlists", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json={"name": name, "description": description, "public": public}, + ) + if response.status_code not in [200, 201]: + raise Exception(f"Failed to create playlist: {response.text}") + return response.json() + + async def update_playlist_details( + self, playlist_id: str, name: str = None, description: str = None + ) -> bool: + token = await self.get_access_token() + data = {} + if name: + data["name"] = name + if description: + data["description"] = description + + if not data: + return True + + async with httpx.AsyncClient() as client: + response = await client.put( + f"{SPOTIFY_API_BASE}/playlists/{playlist_id}", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json=data, + ) + return response.status_code == 200 + + async def replace_playlist_tracks( + self, playlist_id: str, track_uris: List[str] + ) -> bool: + token = await self.get_access_token() + async with httpx.AsyncClient() as client: + response = await client.put( + f"{SPOTIFY_API_BASE}/playlists/{playlist_id}/tracks", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json={"uris": track_uris[:100]}, + ) + return response.status_code in [200, 201] + + async def get_recommendations( + self, + seed_tracks: List[str] = None, + seed_artists: List[str] = None, + seed_genres: List[str] = None, + limit: int = 20, + target_energy: float = None, + target_valence: float = None, + ) -> List[Dict[str, Any]]: + token = await self.get_access_token() + params = {"limit": limit} + + if seed_tracks: + params["seed_tracks"] = ",".join(seed_tracks[:5]) + if seed_artists: + params["seed_artists"] = ",".join(seed_artists[:5]) + if seed_genres: + params["seed_genres"] = ",".join(seed_genres[:5]) + if target_energy is not None: + params["target_energy"] = target_energy + if target_valence is not None: + params["target_valence"] = target_valence + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{SPOTIFY_API_BASE}/recommendations", + params=params, + headers={"Authorization": f"Bearer {token}"}, + ) + if response.status_code != 200: + print(f"Error fetching recommendations: {response.text}") + return [] + return response.json().get("tracks", [])