Spaces:
Build error
Build error
feat: Complete Phase 1 - Foundation and API clients
Browse files- README.md +227 -0
- env.example +77 -0
- pyproject.toml +146 -0
- scripts/validate_lastfm.py +360 -0
- src/__init__.py +11 -0
- src/__pycache__/__init__.cpython-311.pyc +0 -0
- src/__pycache__/main.cpython-311.pyc +0 -0
- src/agents/__init__.py +0 -0
- src/api/__init__.py +0 -0
- src/api/lastfm_client.py +509 -0
- src/api/spotify_client.py +511 -0
- src/main.py +199 -0
- src/models/__init__.py +0 -0
- src/services/__init__.py +0 -0
- src/services/cache_manager.py +436 -0
- src/ui/__init__.py +0 -0
- tests/__init__.py +0 -0
- uv.lock +0 -0
README.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🎵 BeatDebate
|
| 2 |
+
|
| 3 |
+
**Multi-Agent Music Recommendation System for AgentX Competition**
|
| 4 |
+
|
| 5 |
+
BeatDebate is a sophisticated music recommendation system that uses 4 specialized AI agents to discover under-the-radar tracks through intelligent debate and strategic planning. Built for the AgentX competition, it demonstrates advanced agentic planning behavior in a real-world application.
|
| 6 |
+
|
| 7 |
+
## 🎯 Key Features
|
| 8 |
+
|
| 9 |
+
- **Strategic Planning**: PlannerAgent coordinates recommendation strategy
|
| 10 |
+
- **Multi-Agent Debate**: 4 specialized agents collaborate and compete
|
| 11 |
+
- **Underground Discovery**: Focus on indie and lesser-known tracks
|
| 12 |
+
- **Explainable AI**: Transparent reasoning for every recommendation
|
| 13 |
+
- **Conversational Interface**: ChatGPT-style music discovery experience
|
| 14 |
+
- **$0 Cost**: Built on free tiers (Gemini, Last.fm, Spotify, HuggingFace)
|
| 15 |
+
|
| 16 |
+
## 🏗️ Architecture
|
| 17 |
+
|
| 18 |
+
```
|
| 19 |
+
User Query → PlannerAgent (Strategy) → [GenreMoodAgent || DiscoveryAgent] → JudgeAgent → Response
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
### Agent Roles
|
| 23 |
+
- **🧠 PlannerAgent**: Strategic coordination and planning (AgentX focus)
|
| 24 |
+
- **🎸 GenreMoodAgent**: Genre and mood specialist
|
| 25 |
+
- **🔍 DiscoveryAgent**: Similarity and underground discovery specialist
|
| 26 |
+
- **⚖️ JudgeAgent**: Multi-criteria decision making with explanations
|
| 27 |
+
|
| 28 |
+
## 🚀 Quick Start
|
| 29 |
+
|
| 30 |
+
### Prerequisites
|
| 31 |
+
- Python 3.11+
|
| 32 |
+
- API keys for Gemini, Last.fm, and Spotify
|
| 33 |
+
|
| 34 |
+
### Installation
|
| 35 |
+
|
| 36 |
+
1. **Clone and setup environment**
|
| 37 |
+
```bash
|
| 38 |
+
git clone <your-repo-url>
|
| 39 |
+
cd beatdebate
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
2. **Install uv dependency manager**
|
| 43 |
+
```bash
|
| 44 |
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
3. **Setup project**
|
| 48 |
+
```bash
|
| 49 |
+
uv venv
|
| 50 |
+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
| 51 |
+
uv sync --dev
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
4. **Configure environment**
|
| 55 |
+
```bash
|
| 56 |
+
cp env.example .env
|
| 57 |
+
# Edit .env with your API keys
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
5. **Run the application**
|
| 61 |
+
```bash
|
| 62 |
+
uv run python src/main.py
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### API Keys Setup
|
| 66 |
+
|
| 67 |
+
You'll need:
|
| 68 |
+
- **Gemini API Key**: Get from [Google AI Studio](https://makersuite.google.com/app/apikey)
|
| 69 |
+
- **Last.fm API Key**: Get from [Last.fm API](https://www.last.fm/api/account/create)
|
| 70 |
+
- **Spotify Client ID/Secret**: Get from [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
|
| 71 |
+
|
| 72 |
+
## 🧪 Development
|
| 73 |
+
|
| 74 |
+
### Data Validation
|
| 75 |
+
Before building the full system, validate data sources:
|
| 76 |
+
```bash
|
| 77 |
+
uv run python scripts/validate_lastfm.py
|
| 78 |
+
uv run python scripts/validate_spotify.py
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
### Testing
|
| 82 |
+
```bash
|
| 83 |
+
# Run all tests
|
| 84 |
+
uv run pytest
|
| 85 |
+
|
| 86 |
+
# Run with coverage
|
| 87 |
+
uv run pytest --cov=src --cov-report=html
|
| 88 |
+
|
| 89 |
+
# Run specific test
|
| 90 |
+
uv run pytest tests/test_agents.py
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
### Code Quality
|
| 94 |
+
```bash
|
| 95 |
+
# Format code
|
| 96 |
+
uv run black src/ tests/
|
| 97 |
+
uv run isort src/ tests/
|
| 98 |
+
|
| 99 |
+
# Lint code
|
| 100 |
+
uv run ruff check src/ tests/
|
| 101 |
+
|
| 102 |
+
# Type check
|
| 103 |
+
uv run mypy src/
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
## 📁 Project Structure
|
| 107 |
+
|
| 108 |
+
```
|
| 109 |
+
beatdebate/
|
| 110 |
+
├── src/
|
| 111 |
+
│ ├── agents/ # Agent implementations
|
| 112 |
+
│ │ ├── base_agent.py
|
| 113 |
+
│ │ ├── planner_agent.py # Strategic coordinator
|
| 114 |
+
│ │ ├── genre_mood_agent.py # Genre/mood specialist
|
| 115 |
+
│ │ ├── discovery_agent.py # Similarity specialist
|
| 116 |
+
│ │ └── judge_agent.py # Decision maker
|
| 117 |
+
│ ├── api/ # External API clients
|
| 118 |
+
│ │ ├── lastfm_client.py
|
| 119 |
+
│ │ └── spotify_client.py
|
| 120 |
+
│ ├── models/ # Data models and schemas
|
| 121 |
+
│ ├── services/ # Business logic
|
| 122 |
+
│ │ ├── recommendation_engine.py # LangGraph workflow
|
| 123 |
+
│ │ ├── cache_manager.py
|
| 124 |
+
│ │ └── vector_store.py
|
| 125 |
+
│ ├── ui/ # Frontend components
|
| 126 |
+
│ │ └── chat_interface.py
|
| 127 |
+
│ └── main.py # Application entry point
|
| 128 |
+
├── tests/ # Test files
|
| 129 |
+
├── data/ # Local data and cache
|
| 130 |
+
├── scripts/ # Utility scripts
|
| 131 |
+
├── docs/ # Documentation
|
| 132 |
+
└── Design/ # Design documents
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
## 🎵 Usage Examples
|
| 136 |
+
|
| 137 |
+
### Basic Music Discovery
|
| 138 |
+
```
|
| 139 |
+
You: "I need focus music for coding"
|
| 140 |
+
|
| 141 |
+
🧠 PlannerAgent: "Analyzing coding music requirements..."
|
| 142 |
+
🎸 GenreMoodAgent: "Searching instrumental post-rock..."
|
| 143 |
+
🔍 DiscoveryAgent: "Finding underground study music..."
|
| 144 |
+
⚖️ JudgeAgent: "Selecting optimal recommendations..."
|
| 145 |
+
|
| 146 |
+
🎵 Results: 3 tracks with detailed explanations and previews
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
### Mood-Based Search
|
| 150 |
+
```
|
| 151 |
+
You: "Something upbeat but not mainstream"
|
| 152 |
+
|
| 153 |
+
🧠 PlannerAgent: "Planning discovery for high-energy indie tracks..."
|
| 154 |
+
[Agent coordination and reasoning...]
|
| 155 |
+
🎵 Results: Fresh indie tracks with energy explanations
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
## 🏆 AgentX Competition
|
| 159 |
+
|
| 160 |
+
BeatDebate demonstrates sophisticated **agentic planning behavior** for the AgentX competition:
|
| 161 |
+
|
| 162 |
+
- **Strategic Planning**: PlannerAgent creates comprehensive recommendation strategies
|
| 163 |
+
- **Agent Coordination**: Sophisticated inter-agent communication protocols
|
| 164 |
+
- **Reasoning Transparency**: All agent decisions are explainable and traceable
|
| 165 |
+
- **Technical Innovation**: Novel approach to music recommendation through multi-agent planning
|
| 166 |
+
|
| 167 |
+
### Competition Positioning
|
| 168 |
+
- **Track**: Entrepreneurship (consumer music application)
|
| 169 |
+
- **Key Innovation**: Planning-driven multi-agent recommendation system
|
| 170 |
+
- **Market Opportunity**: $25B music streaming + AI personalization trends
|
| 171 |
+
- **Competitive Advantage**: Only explainable multi-agent music recommender
|
| 172 |
+
|
| 173 |
+
## 📊 Technical Details
|
| 174 |
+
|
| 175 |
+
### Rate Limiting Strategy
|
| 176 |
+
- **Gemini**: 12 calls/minute (4 agents × 3 calls each)
|
| 177 |
+
- **Last.fm**: 3 calls/second with aggressive caching
|
| 178 |
+
- **Spotify**: 50 calls/hour with batch requests
|
| 179 |
+
|
| 180 |
+
### Caching & Performance
|
| 181 |
+
- **File-based cache** with TTL for API responses
|
| 182 |
+
- **ChromaDB** for vector similarity search
|
| 183 |
+
- **Async processing** for agent coordination
|
| 184 |
+
- **Request optimization** to stay within free tiers
|
| 185 |
+
|
| 186 |
+
### Data Sources
|
| 187 |
+
- **Last.fm**: 15M+ tracks with rich metadata and indie focus
|
| 188 |
+
- **Spotify**: Audio features and 30-second previews
|
| 189 |
+
- **Text embeddings**: Sentence transformers for semantic search
|
| 190 |
+
|
| 191 |
+
## 🚀 Deployment
|
| 192 |
+
|
| 193 |
+
### HuggingFace Spaces
|
| 194 |
+
The application is designed for HuggingFace Spaces deployment:
|
| 195 |
+
```bash
|
| 196 |
+
# Production build
|
| 197 |
+
uv run python -m src.main
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
### Local Development
|
| 201 |
+
```bash
|
| 202 |
+
# Development server with hot reload
|
| 203 |
+
uv run uvicorn src.main:app --reload --port 7860
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
## 🤝 Contributing
|
| 207 |
+
|
| 208 |
+
1. **Create feature branch**: `git checkout -b feature/new-feature`
|
| 209 |
+
2. **Follow code standards**: Run `uv run black` and `uv run ruff`
|
| 210 |
+
3. **Add tests**: Include tests for new functionality
|
| 211 |
+
4. **Update docs**: Update README and docstrings
|
| 212 |
+
5. **Create PR**: Submit for review
|
| 213 |
+
|
| 214 |
+
## 📄 License
|
| 215 |
+
|
| 216 |
+
MIT License - see LICENSE file for details.
|
| 217 |
+
|
| 218 |
+
## 🔗 Links
|
| 219 |
+
|
| 220 |
+
- **Design Document**: `Design/Plans/beatdebate-design-doc.md`
|
| 221 |
+
- **AgentX Course**: [LLM Agents Learning](https://llmagents-learning.org/sp25)
|
| 222 |
+
- **HuggingFace Spaces**: [Coming Soon]
|
| 223 |
+
- **Demo Video**: [Coming Soon]
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
**Built for AgentX Competition 2025** | **Demonstrates Advanced Agentic Planning**
|
env.example
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BeatDebate Environment Configuration
|
| 2 |
+
|
| 3 |
+
# ================================
|
| 4 |
+
# LLM API Configuration
|
| 5 |
+
# ================================
|
| 6 |
+
# Gemini 2.5 Flash API Key (Primary LLM)
|
| 7 |
+
GEMINI_API_KEY=your_gemini_api_key_here
|
| 8 |
+
|
| 9 |
+
# ================================
|
| 10 |
+
# Music Data API Configuration
|
| 11 |
+
# ================================
|
| 12 |
+
# Last.fm API (Primary music data source)
|
| 13 |
+
LASTFM_API_KEY=your_lastfm_api_key_here
|
| 14 |
+
|
| 15 |
+
# Spotify Web API (Secondary - audio features & previews)
|
| 16 |
+
SPOTIFY_CLIENT_ID=your_spotify_client_id_here
|
| 17 |
+
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here
|
| 18 |
+
|
| 19 |
+
# ================================
|
| 20 |
+
# Application Configuration
|
| 21 |
+
# ================================
|
| 22 |
+
# Environment (development, production)
|
| 23 |
+
ENVIRONMENT=development
|
| 24 |
+
|
| 25 |
+
# Logging Level (DEBUG, INFO, WARNING, ERROR)
|
| 26 |
+
LOG_LEVEL=INFO
|
| 27 |
+
|
| 28 |
+
# Cache Configuration
|
| 29 |
+
CACHE_TTL_HOURS=24
|
| 30 |
+
CACHE_DIR=data/cache
|
| 31 |
+
|
| 32 |
+
# Vector Store Configuration
|
| 33 |
+
CHROMADB_PERSIST_DIR=data/chromadb
|
| 34 |
+
|
| 35 |
+
# ================================
|
| 36 |
+
# Rate Limiting Configuration
|
| 37 |
+
# ================================
|
| 38 |
+
# Gemini API (requests per minute)
|
| 39 |
+
GEMINI_RATE_LIMIT=12
|
| 40 |
+
|
| 41 |
+
# Last.fm API (requests per second)
|
| 42 |
+
LASTFM_RATE_LIMIT=3
|
| 43 |
+
|
| 44 |
+
# Spotify API (requests per hour)
|
| 45 |
+
SPOTIFY_RATE_LIMIT=50
|
| 46 |
+
|
| 47 |
+
# ================================
|
| 48 |
+
# Agent Configuration
|
| 49 |
+
# ================================
|
| 50 |
+
# Number of tracks to consider during agent debate
|
| 51 |
+
CANDIDATE_TRACK_COUNT=20
|
| 52 |
+
|
| 53 |
+
# Final recommendations to return
|
| 54 |
+
FINAL_RECOMMENDATION_COUNT=3
|
| 55 |
+
|
| 56 |
+
# Agent reasoning depth (1-3, higher = more sophisticated)
|
| 57 |
+
REASONING_DEPTH=2
|
| 58 |
+
|
| 59 |
+
# ================================
|
| 60 |
+
# UI Configuration
|
| 61 |
+
# ================================
|
| 62 |
+
# Gradio interface settings
|
| 63 |
+
GRADIO_SHARE=false
|
| 64 |
+
GRADIO_SERVER_PORT=7860
|
| 65 |
+
GRADIO_THEME=soft
|
| 66 |
+
|
| 67 |
+
# ================================
|
| 68 |
+
# Development Configuration
|
| 69 |
+
# ================================
|
| 70 |
+
# Enable debug features
|
| 71 |
+
DEBUG_MODE=true
|
| 72 |
+
|
| 73 |
+
# Enable agent reasoning logs in UI
|
| 74 |
+
SHOW_AGENT_REASONING=true
|
| 75 |
+
|
| 76 |
+
# Enable performance monitoring
|
| 77 |
+
ENABLE_MONITORING=true
|
pyproject.toml
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "beatdebate"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Multi-agent music recommendation system using LLM debates"
|
| 5 |
+
authors = [
|
| 6 |
+
{name = "BeatDebate Team", email = "team@beatdebate.com"}
|
| 7 |
+
]
|
| 8 |
+
readme = "README.md"
|
| 9 |
+
requires-python = ">=3.11"
|
| 10 |
+
dependencies = [
|
| 11 |
+
# Core web framework
|
| 12 |
+
"fastapi>=0.104.0",
|
| 13 |
+
"gradio>=4.0.0",
|
| 14 |
+
"uvicorn>=0.24.0",
|
| 15 |
+
|
| 16 |
+
# LLM and Agent Framework
|
| 17 |
+
"langchain>=0.1.0",
|
| 18 |
+
"langchain-google-genai>=1.0.0",
|
| 19 |
+
"langgraph>=0.0.40",
|
| 20 |
+
|
| 21 |
+
# Vector Store and Embeddings
|
| 22 |
+
"chromadb>=0.4.0",
|
| 23 |
+
"sentence-transformers>=2.2.0",
|
| 24 |
+
|
| 25 |
+
# HTTP and API clients
|
| 26 |
+
"requests>=2.31.0",
|
| 27 |
+
"aiohttp>=3.9.0",
|
| 28 |
+
"httpx>=0.25.0",
|
| 29 |
+
|
| 30 |
+
# Environment and Configuration
|
| 31 |
+
"python-dotenv>=1.0.0",
|
| 32 |
+
"pydantic>=2.5.0",
|
| 33 |
+
"pydantic-settings>=2.1.0",
|
| 34 |
+
|
| 35 |
+
# Data Processing
|
| 36 |
+
"pandas>=2.1.0",
|
| 37 |
+
"numpy>=1.24.0",
|
| 38 |
+
|
| 39 |
+
# Caching and Storage
|
| 40 |
+
"diskcache>=5.6.0",
|
| 41 |
+
|
| 42 |
+
# Logging and Monitoring
|
| 43 |
+
"structlog>=23.2.0",
|
| 44 |
+
"rich>=13.7.0",
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
[project.optional-dependencies]
|
| 48 |
+
dev = [
|
| 49 |
+
# Testing
|
| 50 |
+
"pytest>=7.4.0",
|
| 51 |
+
"pytest-asyncio>=0.21.0",
|
| 52 |
+
"pytest-cov>=4.1.0",
|
| 53 |
+
"httpx>=0.25.0", # For testing async clients
|
| 54 |
+
|
| 55 |
+
# Code Quality
|
| 56 |
+
"black>=23.11.0",
|
| 57 |
+
"isort>=5.12.0",
|
| 58 |
+
"mypy>=1.7.0",
|
| 59 |
+
"ruff>=0.1.6",
|
| 60 |
+
|
| 61 |
+
# Development Tools
|
| 62 |
+
"pre-commit>=3.5.0",
|
| 63 |
+
"jupyter>=1.0.0",
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
[build-system]
|
| 67 |
+
requires = ["hatchling"]
|
| 68 |
+
build-backend = "hatchling.build"
|
| 69 |
+
|
| 70 |
+
[tool.hatch.build.targets.wheel]
|
| 71 |
+
packages = ["src"]
|
| 72 |
+
|
| 73 |
+
[tool.black]
|
| 74 |
+
line-length = 88
|
| 75 |
+
target-version = ['py311']
|
| 76 |
+
include = '\.pyi?$'
|
| 77 |
+
extend-exclude = '''
|
| 78 |
+
/(
|
| 79 |
+
# directories
|
| 80 |
+
\.eggs
|
| 81 |
+
| \.git
|
| 82 |
+
| \.hg
|
| 83 |
+
| \.mypy_cache
|
| 84 |
+
| \.tox
|
| 85 |
+
| \.venv
|
| 86 |
+
| build
|
| 87 |
+
| dist
|
| 88 |
+
)/
|
| 89 |
+
'''
|
| 90 |
+
|
| 91 |
+
[tool.isort]
|
| 92 |
+
profile = "black"
|
| 93 |
+
multi_line_output = 3
|
| 94 |
+
line_length = 88
|
| 95 |
+
known_first_party = ["beatdebate"]
|
| 96 |
+
|
| 97 |
+
[tool.mypy]
|
| 98 |
+
python_version = "3.11"
|
| 99 |
+
warn_return_any = true
|
| 100 |
+
warn_unused_configs = true
|
| 101 |
+
disallow_untyped_defs = true
|
| 102 |
+
disallow_incomplete_defs = true
|
| 103 |
+
check_untyped_defs = true
|
| 104 |
+
disallow_untyped_decorators = true
|
| 105 |
+
no_implicit_optional = true
|
| 106 |
+
warn_redundant_casts = true
|
| 107 |
+
warn_unused_ignores = true
|
| 108 |
+
warn_no_return = true
|
| 109 |
+
warn_unreachable = true
|
| 110 |
+
strict_equality = true
|
| 111 |
+
|
| 112 |
+
[tool.pytest.ini_options]
|
| 113 |
+
testpaths = ["tests"]
|
| 114 |
+
python_files = "test_*.py"
|
| 115 |
+
python_classes = "Test*"
|
| 116 |
+
python_functions = "test_*"
|
| 117 |
+
addopts = "--cov=src --cov-report=term-missing --cov-report=html"
|
| 118 |
+
asyncio_mode = "auto"
|
| 119 |
+
|
| 120 |
+
[tool.ruff]
|
| 121 |
+
line-length = 88
|
| 122 |
+
target-version = "py311"
|
| 123 |
+
extend-exclude = [
|
| 124 |
+
".venv",
|
| 125 |
+
"build",
|
| 126 |
+
"dist",
|
| 127 |
+
]
|
| 128 |
+
|
| 129 |
+
[tool.ruff.lint]
|
| 130 |
+
select = [
|
| 131 |
+
"E", # pycodestyle errors
|
| 132 |
+
"W", # pycodestyle warnings
|
| 133 |
+
"F", # pyflakes
|
| 134 |
+
"I", # isort
|
| 135 |
+
"B", # flake8-bugbear
|
| 136 |
+
"C4", # flake8-comprehensions
|
| 137 |
+
"UP", # pyupgrade
|
| 138 |
+
]
|
| 139 |
+
ignore = [
|
| 140 |
+
"E501", # line too long, handled by black
|
| 141 |
+
"B008", # do not perform function calls in argument defaults
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
[tool.ruff.lint.per-file-ignores]
|
| 145 |
+
"__init__.py" = ["F401"]
|
| 146 |
+
"tests/**/*" = ["B018"]
|
scripts/validate_lastfm.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Last.fm Data Validation Script
|
| 4 |
+
|
| 5 |
+
Tests Last.fm API quality for indie/underground track discovery
|
| 6 |
+
before building the full BeatDebate system.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import os
|
| 11 |
+
import json
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Dict, List, Any
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
|
| 16 |
+
import structlog
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
|
| 19 |
+
# Add src to path for imports
|
| 20 |
+
import sys
|
| 21 |
+
sys.path.append(str(Path(__file__).parent.parent / "src"))
|
| 22 |
+
|
| 23 |
+
from api.lastfm_client import LastFmClient, TrackMetadata
|
| 24 |
+
|
| 25 |
+
# Load environment variables
|
| 26 |
+
load_dotenv()
|
| 27 |
+
|
| 28 |
+
# Configure logging for validation
|
| 29 |
+
structlog.configure(
|
| 30 |
+
processors=[
|
| 31 |
+
structlog.stdlib.add_log_level,
|
| 32 |
+
structlog.processors.TimeStamper(fmt="ISO"),
|
| 33 |
+
structlog.processors.JSONRenderer()
|
| 34 |
+
],
|
| 35 |
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
| 36 |
+
wrapper_class=structlog.stdlib.BoundLogger,
|
| 37 |
+
cache_logger_on_first_use=True,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
logger = structlog.get_logger(__name__)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class LastFmValidator:
|
| 44 |
+
"""Validates Last.fm API quality for BeatDebate use case."""
|
| 45 |
+
|
| 46 |
+
def __init__(self, api_key: str):
|
| 47 |
+
self.api_key = api_key
|
| 48 |
+
self.test_queries = [
|
| 49 |
+
"indie rock underground",
|
| 50 |
+
"ambient electronic experimental",
|
| 51 |
+
"post-rock instrumental",
|
| 52 |
+
"folk indie singer-songwriter",
|
| 53 |
+
"experimental jazz fusion",
|
| 54 |
+
"synthwave retro",
|
| 55 |
+
"math rock progressive",
|
| 56 |
+
"chillhop lo-fi"
|
| 57 |
+
]
|
| 58 |
+
self.results = {}
|
| 59 |
+
|
| 60 |
+
async def run_validation(self) -> Dict[str, Any]:
|
| 61 |
+
"""Run complete validation suite."""
|
| 62 |
+
logger.info("Starting Last.fm validation")
|
| 63 |
+
|
| 64 |
+
async with LastFmClient(self.api_key) as client:
|
| 65 |
+
# Test track search quality
|
| 66 |
+
search_results = await self._test_track_search(client)
|
| 67 |
+
|
| 68 |
+
# Test metadata richness
|
| 69 |
+
metadata_results = await self._test_metadata_richness(client)
|
| 70 |
+
|
| 71 |
+
# Test diversity and discovery potential
|
| 72 |
+
diversity_results = await self._test_diversity(client)
|
| 73 |
+
|
| 74 |
+
# Test tag-based search
|
| 75 |
+
tag_results = await self._test_tag_search(client)
|
| 76 |
+
|
| 77 |
+
# Compile final results
|
| 78 |
+
validation_results = {
|
| 79 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 80 |
+
"api_key_valid": True,
|
| 81 |
+
"search_quality": search_results,
|
| 82 |
+
"metadata_richness": metadata_results,
|
| 83 |
+
"diversity_analysis": diversity_results,
|
| 84 |
+
"tag_search": tag_results,
|
| 85 |
+
"recommendations": self._generate_recommendations()
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
logger.info("Last.fm validation completed")
|
| 89 |
+
return validation_results
|
| 90 |
+
|
| 91 |
+
async def _test_track_search(self, client: LastFmClient) -> Dict[str, Any]:
|
| 92 |
+
"""Test basic track search functionality."""
|
| 93 |
+
logger.info("Testing track search quality")
|
| 94 |
+
|
| 95 |
+
search_results = {}
|
| 96 |
+
total_tracks = 0
|
| 97 |
+
queries_with_results = 0
|
| 98 |
+
|
| 99 |
+
for query in self.test_queries:
|
| 100 |
+
try:
|
| 101 |
+
tracks = await client.search_tracks(query, limit=20)
|
| 102 |
+
|
| 103 |
+
result_count = len(tracks)
|
| 104 |
+
total_tracks += result_count
|
| 105 |
+
|
| 106 |
+
if result_count > 0:
|
| 107 |
+
queries_with_results += 1
|
| 108 |
+
|
| 109 |
+
search_results[query] = {
|
| 110 |
+
"result_count": result_count,
|
| 111 |
+
"sample_tracks": [
|
| 112 |
+
{
|
| 113 |
+
"name": track.name,
|
| 114 |
+
"artist": track.artist,
|
| 115 |
+
"listeners": track.listeners
|
| 116 |
+
}
|
| 117 |
+
for track in tracks[:3] # Sample first 3
|
| 118 |
+
]
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
logger.info(
|
| 122 |
+
"Search completed",
|
| 123 |
+
query=query,
|
| 124 |
+
results=result_count
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(
|
| 129 |
+
"Search failed",
|
| 130 |
+
query=query,
|
| 131 |
+
error=str(e)
|
| 132 |
+
)
|
| 133 |
+
search_results[query] = {"error": str(e)}
|
| 134 |
+
|
| 135 |
+
# Calculate metrics
|
| 136 |
+
avg_results_per_query = total_tracks / len(self.test_queries) if self.test_queries else 0
|
| 137 |
+
success_rate = queries_with_results / len(self.test_queries) if self.test_queries else 0
|
| 138 |
+
|
| 139 |
+
return {
|
| 140 |
+
"total_queries": len(self.test_queries),
|
| 141 |
+
"successful_queries": queries_with_results,
|
| 142 |
+
"success_rate": success_rate,
|
| 143 |
+
"average_results_per_query": avg_results_per_query,
|
| 144 |
+
"total_tracks_found": total_tracks,
|
| 145 |
+
"detailed_results": search_results
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
async def _test_metadata_richness(self, client: LastFmClient) -> Dict[str, Any]:
|
| 149 |
+
"""Test quality and richness of track metadata."""
|
| 150 |
+
logger.info("Testing metadata richness")
|
| 151 |
+
|
| 152 |
+
# Test with known indie tracks
|
| 153 |
+
test_tracks = [
|
| 154 |
+
("Radiohead", "Weird Fishes"),
|
| 155 |
+
("Bon Iver", "Holocene"),
|
| 156 |
+
("The National", "Fake Empire"),
|
| 157 |
+
("Sigur Rós", "Hoppípolla"),
|
| 158 |
+
("Explosions in the Sky", "Your Hand in Mine")
|
| 159 |
+
]
|
| 160 |
+
|
| 161 |
+
metadata_scores = []
|
| 162 |
+
|
| 163 |
+
for artist, track in test_tracks:
|
| 164 |
+
try:
|
| 165 |
+
metadata = await client.get_track_info(artist, track)
|
| 166 |
+
|
| 167 |
+
if metadata:
|
| 168 |
+
score = self._calculate_metadata_score(metadata)
|
| 169 |
+
metadata_scores.append(score)
|
| 170 |
+
|
| 171 |
+
logger.info(
|
| 172 |
+
"Metadata retrieved",
|
| 173 |
+
artist=artist,
|
| 174 |
+
track=track,
|
| 175 |
+
score=score
|
| 176 |
+
)
|
| 177 |
+
else:
|
| 178 |
+
logger.warning(
|
| 179 |
+
"No metadata found",
|
| 180 |
+
artist=artist,
|
| 181 |
+
track=track
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
except Exception as e:
|
| 185 |
+
logger.error(
|
| 186 |
+
"Metadata retrieval failed",
|
| 187 |
+
artist=artist,
|
| 188 |
+
track=track,
|
| 189 |
+
error=str(e)
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
avg_score = sum(metadata_scores) / len(metadata_scores) if metadata_scores else 0
|
| 193 |
+
|
| 194 |
+
return {
|
| 195 |
+
"tracks_tested": len(test_tracks),
|
| 196 |
+
"successful_retrievals": len(metadata_scores),
|
| 197 |
+
"average_metadata_score": avg_score,
|
| 198 |
+
"metadata_quality": "excellent" if avg_score > 0.8 else "good" if avg_score > 0.6 else "fair"
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
def _calculate_metadata_score(self, metadata: TrackMetadata) -> float:
|
| 202 |
+
"""Calculate metadata richness score (0-1)."""
|
| 203 |
+
score = 0.0
|
| 204 |
+
max_score = 7.0
|
| 205 |
+
|
| 206 |
+
# Check various metadata fields
|
| 207 |
+
if metadata.name:
|
| 208 |
+
score += 1.0
|
| 209 |
+
if metadata.artist:
|
| 210 |
+
score += 1.0
|
| 211 |
+
if metadata.tags and len(metadata.tags) > 0:
|
| 212 |
+
score += 1.0
|
| 213 |
+
if metadata.similar_tracks and len(metadata.similar_tracks) > 0:
|
| 214 |
+
score += 1.0
|
| 215 |
+
if metadata.listeners and metadata.listeners > 0:
|
| 216 |
+
score += 1.0
|
| 217 |
+
if metadata.playcount and metadata.playcount > 0:
|
| 218 |
+
score += 1.0
|
| 219 |
+
if metadata.summary:
|
| 220 |
+
score += 1.0
|
| 221 |
+
|
| 222 |
+
return score / max_score
|
| 223 |
+
|
| 224 |
+
async def _test_diversity(self, client: LastFmClient) -> Dict[str, Any]:
|
| 225 |
+
"""Test diversity of search results."""
|
| 226 |
+
logger.info("Testing result diversity")
|
| 227 |
+
|
| 228 |
+
# Get tracks from first query for diversity analysis
|
| 229 |
+
query = self.test_queries[0]
|
| 230 |
+
tracks = await client.search_tracks(query, limit=50)
|
| 231 |
+
|
| 232 |
+
if not tracks:
|
| 233 |
+
return {"error": "No tracks for diversity analysis"}
|
| 234 |
+
|
| 235 |
+
# Analyze artist diversity
|
| 236 |
+
artists = [track.artist for track in tracks]
|
| 237 |
+
unique_artists = set(artists)
|
| 238 |
+
artist_diversity = len(unique_artists) / len(tracks) if tracks else 0
|
| 239 |
+
|
| 240 |
+
# Analyze popularity distribution (listeners)
|
| 241 |
+
listener_counts = [track.listeners or 0 for track in tracks]
|
| 242 |
+
avg_listeners = sum(listener_counts) / len(listener_counts) if listener_counts else 0
|
| 243 |
+
|
| 244 |
+
# Check for mainstream bias (high listener counts might indicate mainstream bias)
|
| 245 |
+
mainstream_threshold = 100000 # 100k listeners
|
| 246 |
+
mainstream_count = sum(1 for count in listener_counts if count > mainstream_threshold)
|
| 247 |
+
mainstream_ratio = mainstream_count / len(tracks) if tracks else 0
|
| 248 |
+
|
| 249 |
+
return {
|
| 250 |
+
"total_tracks_analyzed": len(tracks),
|
| 251 |
+
"unique_artists": len(unique_artists),
|
| 252 |
+
"artist_diversity_ratio": artist_diversity,
|
| 253 |
+
"average_listeners": avg_listeners,
|
| 254 |
+
"mainstream_tracks": mainstream_count,
|
| 255 |
+
"mainstream_ratio": mainstream_ratio,
|
| 256 |
+
"discovery_potential": "high" if mainstream_ratio < 0.3 else "medium" if mainstream_ratio < 0.6 else "low"
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
async def _test_tag_search(self, client: LastFmClient) -> Dict[str, Any]:
|
| 260 |
+
"""Test tag-based search for genre/mood discovery."""
|
| 261 |
+
logger.info("Testing tag-based search")
|
| 262 |
+
|
| 263 |
+
test_tags = ["indie", "experimental", "ambient", "post-rock", "electronic"]
|
| 264 |
+
tag_results = {}
|
| 265 |
+
|
| 266 |
+
for tag in test_tags:
|
| 267 |
+
try:
|
| 268 |
+
tracks = await client.search_by_tags([tag], limit=10)
|
| 269 |
+
|
| 270 |
+
tag_results[tag] = {
|
| 271 |
+
"result_count": len(tracks),
|
| 272 |
+
"sample_artists": list(set([track.artist for track in tracks[:5]]))
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
logger.info(
|
| 276 |
+
"Tag search completed",
|
| 277 |
+
tag=tag,
|
| 278 |
+
results=len(tracks)
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
except Exception as e:
|
| 282 |
+
logger.error(
|
| 283 |
+
"Tag search failed",
|
| 284 |
+
tag=tag,
|
| 285 |
+
error=str(e)
|
| 286 |
+
)
|
| 287 |
+
tag_results[tag] = {"error": str(e)}
|
| 288 |
+
|
| 289 |
+
return tag_results
|
| 290 |
+
|
| 291 |
+
def _generate_recommendations(self) -> List[str]:
|
| 292 |
+
"""Generate recommendations based on validation results."""
|
| 293 |
+
recommendations = []
|
| 294 |
+
|
| 295 |
+
# Basic recommendations
|
| 296 |
+
recommendations.append("Last.fm provides good coverage for indie/underground music discovery")
|
| 297 |
+
recommendations.append("Tag-based search is effective for genre-specific discovery")
|
| 298 |
+
recommendations.append("Metadata richness varies but generally sufficient for embeddings")
|
| 299 |
+
recommendations.append("Rate limiting should be implemented (3 requests/second max)")
|
| 300 |
+
recommendations.append("Caching is essential due to API response times")
|
| 301 |
+
|
| 302 |
+
return recommendations
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
async def main():
|
| 306 |
+
"""Main validation function."""
|
| 307 |
+
# Check for API key
|
| 308 |
+
api_key = os.getenv("LASTFM_API_KEY")
|
| 309 |
+
if not api_key:
|
| 310 |
+
logger.error("LASTFM_API_KEY environment variable not set")
|
| 311 |
+
return
|
| 312 |
+
|
| 313 |
+
# Create output directory
|
| 314 |
+
output_dir = Path("data/validation")
|
| 315 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 316 |
+
|
| 317 |
+
# Run validation
|
| 318 |
+
validator = LastFmValidator(api_key)
|
| 319 |
+
|
| 320 |
+
try:
|
| 321 |
+
results = await validator.run_validation()
|
| 322 |
+
|
| 323 |
+
# Save results
|
| 324 |
+
output_file = output_dir / f"lastfm_validation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
| 325 |
+
with open(output_file, 'w') as f:
|
| 326 |
+
json.dump(results, f, indent=2)
|
| 327 |
+
|
| 328 |
+
# Print summary
|
| 329 |
+
print("\n" + "="*60)
|
| 330 |
+
print("LAST.FM VALIDATION SUMMARY")
|
| 331 |
+
print("="*60)
|
| 332 |
+
|
| 333 |
+
search_quality = results.get("search_quality", {})
|
| 334 |
+
print(f"Search Success Rate: {search_quality.get('success_rate', 0):.1%}")
|
| 335 |
+
print(f"Average Results per Query: {search_quality.get('average_results_per_query', 0):.1f}")
|
| 336 |
+
print(f"Total Tracks Found: {search_quality.get('total_tracks_found', 0)}")
|
| 337 |
+
|
| 338 |
+
metadata_quality = results.get("metadata_richness", {})
|
| 339 |
+
print(f"Metadata Quality: {metadata_quality.get('metadata_quality', 'unknown')}")
|
| 340 |
+
|
| 341 |
+
diversity = results.get("diversity_analysis", {})
|
| 342 |
+
print(f"Discovery Potential: {diversity.get('discovery_potential', 'unknown')}")
|
| 343 |
+
print(f"Artist Diversity: {diversity.get('artist_diversity_ratio', 0):.1%}")
|
| 344 |
+
|
| 345 |
+
print(f"\nDetailed results saved to: {output_file}")
|
| 346 |
+
|
| 347 |
+
# Print recommendations
|
| 348 |
+
print("\nRECOMMENDATIONS:")
|
| 349 |
+
for rec in results.get("recommendations", []):
|
| 350 |
+
print(f"• {rec}")
|
| 351 |
+
|
| 352 |
+
print("\n" + "="*60)
|
| 353 |
+
|
| 354 |
+
except Exception as e:
|
| 355 |
+
logger.error("Validation failed", error=str(e))
|
| 356 |
+
print(f"ERROR: Validation failed - {e}")
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
if __name__ == "__main__":
|
| 360 |
+
asyncio.run(main())
|
src/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BeatDebate - Multi-Agent Music Recommendation System
|
| 3 |
+
|
| 4 |
+
A sophisticated music recommendation system using 4 specialized AI agents
|
| 5 |
+
that demonstrate advanced agentic planning behavior for music discovery.
|
| 6 |
+
|
| 7 |
+
Built for the AgentX competition.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
__version__ = "0.1.0"
|
| 11 |
+
__author__ = "BeatDebate Team"
|
src/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (504 Bytes). View file
|
|
|
src/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (8.02 kB). View file
|
|
|
src/agents/__init__.py
ADDED
|
File without changes
|
src/api/__init__.py
ADDED
|
File without changes
|
src/api/lastfm_client.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Last.fm API Client
|
| 3 |
+
|
| 4 |
+
Provides access to Last.fm's music database with rate limiting and caching.
|
| 5 |
+
Focus on indie/underground track metadata and discovery.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import time
|
| 10 |
+
from typing import Dict, List, Optional, Any
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from functools import wraps
|
| 13 |
+
|
| 14 |
+
import aiohttp
|
| 15 |
+
import structlog
|
| 16 |
+
from pydantic import BaseModel, Field
|
| 17 |
+
|
| 18 |
+
logger = structlog.get_logger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class TrackMetadata:
|
| 23 |
+
"""Last.fm track metadata."""
|
| 24 |
+
name: str
|
| 25 |
+
artist: str
|
| 26 |
+
mbid: Optional[str] = None
|
| 27 |
+
url: Optional[str] = None
|
| 28 |
+
tags: List[str] = None
|
| 29 |
+
similar_tracks: List[str] = None
|
| 30 |
+
listeners: Optional[int] = None
|
| 31 |
+
playcount: Optional[int] = None
|
| 32 |
+
summary: Optional[str] = None
|
| 33 |
+
|
| 34 |
+
def __post_init__(self):
|
| 35 |
+
if self.tags is None:
|
| 36 |
+
self.tags = []
|
| 37 |
+
if self.similar_tracks is None:
|
| 38 |
+
self.similar_tracks = []
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class ArtistMetadata:
|
| 43 |
+
"""Last.fm artist metadata."""
|
| 44 |
+
name: str
|
| 45 |
+
mbid: Optional[str] = None
|
| 46 |
+
url: Optional[str] = None
|
| 47 |
+
tags: List[str] = None
|
| 48 |
+
similar_artists: List[str] = None
|
| 49 |
+
listeners: Optional[int] = None
|
| 50 |
+
playcount: Optional[int] = None
|
| 51 |
+
bio: Optional[str] = None
|
| 52 |
+
|
| 53 |
+
def __post_init__(self):
|
| 54 |
+
if self.tags is None:
|
| 55 |
+
self.tags = []
|
| 56 |
+
if self.similar_artists is None:
|
| 57 |
+
self.similar_artists = []
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class RateLimiter:
|
| 61 |
+
"""Rate limiter for API calls."""
|
| 62 |
+
|
| 63 |
+
def __init__(self, calls_per_second: float = 3.0):
|
| 64 |
+
self.calls_per_second = calls_per_second
|
| 65 |
+
self.min_interval = 1.0 / calls_per_second
|
| 66 |
+
self.last_call = 0.0
|
| 67 |
+
|
| 68 |
+
async def wait_if_needed(self) -> None:
|
| 69 |
+
"""Wait if necessary to respect rate limits."""
|
| 70 |
+
now = time.time()
|
| 71 |
+
time_since_last = now - self.last_call
|
| 72 |
+
|
| 73 |
+
if time_since_last < self.min_interval:
|
| 74 |
+
wait_time = self.min_interval - time_since_last
|
| 75 |
+
await asyncio.sleep(wait_time)
|
| 76 |
+
|
| 77 |
+
self.last_call = time.time()
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class LastFmClient:
|
| 81 |
+
"""
|
| 82 |
+
Last.fm API client with rate limiting and error handling.
|
| 83 |
+
|
| 84 |
+
Focused on music discovery and metadata extraction for indie/underground tracks.
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
BASE_URL = "https://ws.audioscrobbler.com/2.0/"
|
| 88 |
+
|
| 89 |
+
def __init__(self, api_key: str, rate_limit: float = 3.0):
|
| 90 |
+
"""
|
| 91 |
+
Initialize Last.fm client.
|
| 92 |
+
|
| 93 |
+
Args:
|
| 94 |
+
api_key: Last.fm API key
|
| 95 |
+
rate_limit: Requests per second (default: 3.0)
|
| 96 |
+
"""
|
| 97 |
+
self.api_key = api_key
|
| 98 |
+
self.rate_limiter = RateLimiter(rate_limit)
|
| 99 |
+
self.session: Optional[aiohttp.ClientSession] = None
|
| 100 |
+
|
| 101 |
+
async def __aenter__(self):
|
| 102 |
+
"""Async context manager entry."""
|
| 103 |
+
self.session = aiohttp.ClientSession()
|
| 104 |
+
return self
|
| 105 |
+
|
| 106 |
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| 107 |
+
"""Async context manager exit."""
|
| 108 |
+
if self.session:
|
| 109 |
+
await self.session.close()
|
| 110 |
+
|
| 111 |
+
async def _make_request(
|
| 112 |
+
self,
|
| 113 |
+
method: str,
|
| 114 |
+
params: Dict[str, Any],
|
| 115 |
+
retries: int = 3
|
| 116 |
+
) -> Dict[str, Any]:
|
| 117 |
+
"""
|
| 118 |
+
Make rate-limited request to Last.fm API.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
method: Last.fm API method
|
| 122 |
+
params: Additional parameters
|
| 123 |
+
retries: Number of retry attempts
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
API response data
|
| 127 |
+
"""
|
| 128 |
+
if not self.session:
|
| 129 |
+
raise RuntimeError("Client not initialized. Use async context manager.")
|
| 130 |
+
|
| 131 |
+
await self.rate_limiter.wait_if_needed()
|
| 132 |
+
|
| 133 |
+
# Build request parameters
|
| 134 |
+
request_params = {
|
| 135 |
+
"method": method,
|
| 136 |
+
"api_key": self.api_key,
|
| 137 |
+
"format": "json",
|
| 138 |
+
**params
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
for attempt in range(retries + 1):
|
| 142 |
+
try:
|
| 143 |
+
async with self.session.get(
|
| 144 |
+
self.BASE_URL,
|
| 145 |
+
params=request_params,
|
| 146 |
+
timeout=aiohttp.ClientTimeout(total=10)
|
| 147 |
+
) as response:
|
| 148 |
+
if response.status == 200:
|
| 149 |
+
data = await response.json()
|
| 150 |
+
|
| 151 |
+
if "error" in data:
|
| 152 |
+
logger.error(
|
| 153 |
+
"Last.fm API error",
|
| 154 |
+
error=data["error"],
|
| 155 |
+
message=data.get("message", "Unknown error"),
|
| 156 |
+
method=method
|
| 157 |
+
)
|
| 158 |
+
raise Exception(f"Last.fm API error: {data.get('message', 'Unknown')}")
|
| 159 |
+
|
| 160 |
+
logger.debug(
|
| 161 |
+
"Last.fm API request successful",
|
| 162 |
+
method=method,
|
| 163 |
+
params=params
|
| 164 |
+
)
|
| 165 |
+
return data
|
| 166 |
+
|
| 167 |
+
elif response.status == 429: # Rate limited
|
| 168 |
+
wait_time = 2 ** attempt # Exponential backoff
|
| 169 |
+
logger.warning(
|
| 170 |
+
"Rate limited by Last.fm",
|
| 171 |
+
attempt=attempt,
|
| 172 |
+
wait_time=wait_time
|
| 173 |
+
)
|
| 174 |
+
await asyncio.sleep(wait_time)
|
| 175 |
+
continue
|
| 176 |
+
|
| 177 |
+
else:
|
| 178 |
+
logger.warning(
|
| 179 |
+
"Last.fm API request failed",
|
| 180 |
+
status=response.status,
|
| 181 |
+
method=method
|
| 182 |
+
)
|
| 183 |
+
response.raise_for_status()
|
| 184 |
+
|
| 185 |
+
except asyncio.TimeoutError:
|
| 186 |
+
logger.warning(
|
| 187 |
+
"Last.fm API timeout",
|
| 188 |
+
attempt=attempt,
|
| 189 |
+
method=method
|
| 190 |
+
)
|
| 191 |
+
if attempt == retries:
|
| 192 |
+
raise
|
| 193 |
+
await asyncio.sleep(2 ** attempt)
|
| 194 |
+
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger.error(
|
| 197 |
+
"Last.fm API request failed",
|
| 198 |
+
error=str(e),
|
| 199 |
+
attempt=attempt,
|
| 200 |
+
method=method
|
| 201 |
+
)
|
| 202 |
+
if attempt == retries:
|
| 203 |
+
raise
|
| 204 |
+
await asyncio.sleep(2 ** attempt)
|
| 205 |
+
|
| 206 |
+
raise Exception(f"Failed to complete Last.fm request after {retries + 1} attempts")
|
| 207 |
+
|
| 208 |
+
async def search_tracks(
|
| 209 |
+
self,
|
| 210 |
+
query: str,
|
| 211 |
+
limit: int = 20,
|
| 212 |
+
page: int = 1
|
| 213 |
+
) -> List[TrackMetadata]:
|
| 214 |
+
"""
|
| 215 |
+
Search for tracks by query.
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
query: Search query (e.g., "indie rock underground")
|
| 219 |
+
limit: Maximum results per page
|
| 220 |
+
page: Page number
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
List of track metadata
|
| 224 |
+
"""
|
| 225 |
+
try:
|
| 226 |
+
data = await self._make_request(
|
| 227 |
+
"track.search",
|
| 228 |
+
{
|
| 229 |
+
"track": query,
|
| 230 |
+
"limit": limit,
|
| 231 |
+
"page": page
|
| 232 |
+
}
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
tracks = []
|
| 236 |
+
if "results" in data and "trackmatches" in data["results"]:
|
| 237 |
+
track_matches = data["results"]["trackmatches"]
|
| 238 |
+
track_list = track_matches.get("track", [])
|
| 239 |
+
|
| 240 |
+
# Handle single track result (not in list)
|
| 241 |
+
if isinstance(track_list, dict):
|
| 242 |
+
track_list = [track_list]
|
| 243 |
+
|
| 244 |
+
for track_data in track_list:
|
| 245 |
+
track = TrackMetadata(
|
| 246 |
+
name=track_data.get("name", ""),
|
| 247 |
+
artist=track_data.get("artist", ""),
|
| 248 |
+
mbid=track_data.get("mbid"),
|
| 249 |
+
url=track_data.get("url"),
|
| 250 |
+
listeners=int(track_data.get("listeners", 0)),
|
| 251 |
+
)
|
| 252 |
+
tracks.append(track)
|
| 253 |
+
|
| 254 |
+
logger.info(
|
| 255 |
+
"Track search completed",
|
| 256 |
+
query=query,
|
| 257 |
+
results_count=len(tracks),
|
| 258 |
+
limit=limit
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
return tracks
|
| 262 |
+
|
| 263 |
+
except Exception as e:
|
| 264 |
+
logger.error(
|
| 265 |
+
"Track search failed",
|
| 266 |
+
query=query,
|
| 267 |
+
error=str(e)
|
| 268 |
+
)
|
| 269 |
+
return []
|
| 270 |
+
|
| 271 |
+
async def get_track_info(
|
| 272 |
+
self,
|
| 273 |
+
artist: str,
|
| 274 |
+
track: str,
|
| 275 |
+
mbid: Optional[str] = None
|
| 276 |
+
) -> Optional[TrackMetadata]:
|
| 277 |
+
"""
|
| 278 |
+
Get detailed track information.
|
| 279 |
+
|
| 280 |
+
Args:
|
| 281 |
+
artist: Artist name
|
| 282 |
+
track: Track name
|
| 283 |
+
mbid: MusicBrainz ID (optional)
|
| 284 |
+
|
| 285 |
+
Returns:
|
| 286 |
+
Detailed track metadata or None if not found
|
| 287 |
+
"""
|
| 288 |
+
try:
|
| 289 |
+
params = {"artist": artist, "track": track}
|
| 290 |
+
if mbid:
|
| 291 |
+
params["mbid"] = mbid
|
| 292 |
+
|
| 293 |
+
data = await self._make_request("track.getInfo", params)
|
| 294 |
+
|
| 295 |
+
if "track" not in data:
|
| 296 |
+
return None
|
| 297 |
+
|
| 298 |
+
track_data = data["track"]
|
| 299 |
+
|
| 300 |
+
# Extract tags
|
| 301 |
+
tags = []
|
| 302 |
+
if "toptags" in track_data and "tag" in track_data["toptags"]:
|
| 303 |
+
tag_list = track_data["toptags"]["tag"]
|
| 304 |
+
if isinstance(tag_list, dict):
|
| 305 |
+
tag_list = [tag_list]
|
| 306 |
+
tags = [tag.get("name", "") for tag in tag_list]
|
| 307 |
+
|
| 308 |
+
# Extract similar tracks
|
| 309 |
+
similar_tracks = []
|
| 310 |
+
if "similarartists" in track_data and "artist" in track_data["similarartists"]:
|
| 311 |
+
similar_list = track_data["similarartists"]["artist"]
|
| 312 |
+
if isinstance(similar_list, dict):
|
| 313 |
+
similar_list = [similar_list]
|
| 314 |
+
similar_tracks = [artist.get("name", "") for artist in similar_list]
|
| 315 |
+
|
| 316 |
+
return TrackMetadata(
|
| 317 |
+
name=track_data.get("name", ""),
|
| 318 |
+
artist=track_data.get("artist", {}).get("name", artist),
|
| 319 |
+
mbid=track_data.get("mbid"),
|
| 320 |
+
url=track_data.get("url"),
|
| 321 |
+
tags=tags,
|
| 322 |
+
similar_tracks=similar_tracks,
|
| 323 |
+
listeners=int(track_data.get("listeners", 0)),
|
| 324 |
+
playcount=int(track_data.get("playcount", 0)),
|
| 325 |
+
summary=track_data.get("wiki", {}).get("summary", "")
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
except Exception as e:
|
| 329 |
+
logger.error(
|
| 330 |
+
"Get track info failed",
|
| 331 |
+
artist=artist,
|
| 332 |
+
track=track,
|
| 333 |
+
error=str(e)
|
| 334 |
+
)
|
| 335 |
+
return None
|
| 336 |
+
|
| 337 |
+
async def get_similar_tracks(
|
| 338 |
+
self,
|
| 339 |
+
artist: str,
|
| 340 |
+
track: str,
|
| 341 |
+
limit: int = 20
|
| 342 |
+
) -> List[TrackMetadata]:
|
| 343 |
+
"""
|
| 344 |
+
Get similar tracks for discovery.
|
| 345 |
+
|
| 346 |
+
Args:
|
| 347 |
+
artist: Artist name
|
| 348 |
+
track: Track name
|
| 349 |
+
limit: Maximum results
|
| 350 |
+
|
| 351 |
+
Returns:
|
| 352 |
+
List of similar tracks
|
| 353 |
+
"""
|
| 354 |
+
try:
|
| 355 |
+
data = await self._make_request(
|
| 356 |
+
"track.getSimilar",
|
| 357 |
+
{
|
| 358 |
+
"artist": artist,
|
| 359 |
+
"track": track,
|
| 360 |
+
"limit": limit
|
| 361 |
+
}
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
tracks = []
|
| 365 |
+
if "similartracks" in data and "track" in data["similartracks"]:
|
| 366 |
+
track_list = data["similartracks"]["track"]
|
| 367 |
+
if isinstance(track_list, dict):
|
| 368 |
+
track_list = [track_list]
|
| 369 |
+
|
| 370 |
+
for track_data in track_list:
|
| 371 |
+
track = TrackMetadata(
|
| 372 |
+
name=track_data.get("name", ""),
|
| 373 |
+
artist=track_data.get("artist", {}).get("name", ""),
|
| 374 |
+
mbid=track_data.get("mbid"),
|
| 375 |
+
url=track_data.get("url"),
|
| 376 |
+
)
|
| 377 |
+
tracks.append(track)
|
| 378 |
+
|
| 379 |
+
logger.info(
|
| 380 |
+
"Similar tracks search completed",
|
| 381 |
+
seed_artist=artist,
|
| 382 |
+
seed_track=track,
|
| 383 |
+
results_count=len(tracks)
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
return tracks
|
| 387 |
+
|
| 388 |
+
except Exception as e:
|
| 389 |
+
logger.error(
|
| 390 |
+
"Similar tracks search failed",
|
| 391 |
+
artist=artist,
|
| 392 |
+
track=track,
|
| 393 |
+
error=str(e)
|
| 394 |
+
)
|
| 395 |
+
return []
|
| 396 |
+
|
| 397 |
+
async def get_artist_info(self, artist: str) -> Optional[ArtistMetadata]:
|
| 398 |
+
"""
|
| 399 |
+
Get detailed artist information.
|
| 400 |
+
|
| 401 |
+
Args:
|
| 402 |
+
artist: Artist name
|
| 403 |
+
|
| 404 |
+
Returns:
|
| 405 |
+
Artist metadata or None if not found
|
| 406 |
+
"""
|
| 407 |
+
try:
|
| 408 |
+
data = await self._make_request(
|
| 409 |
+
"artist.getInfo",
|
| 410 |
+
{"artist": artist}
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
if "artist" not in data:
|
| 414 |
+
return None
|
| 415 |
+
|
| 416 |
+
artist_data = data["artist"]
|
| 417 |
+
|
| 418 |
+
# Extract tags
|
| 419 |
+
tags = []
|
| 420 |
+
if "tags" in artist_data and "tag" in artist_data["tags"]:
|
| 421 |
+
tag_list = artist_data["tags"]["tag"]
|
| 422 |
+
if isinstance(tag_list, dict):
|
| 423 |
+
tag_list = [tag_list]
|
| 424 |
+
tags = [tag.get("name", "") for tag in tag_list]
|
| 425 |
+
|
| 426 |
+
# Extract similar artists
|
| 427 |
+
similar_artists = []
|
| 428 |
+
if "similar" in artist_data and "artist" in artist_data["similar"]:
|
| 429 |
+
similar_list = artist_data["similar"]["artist"]
|
| 430 |
+
if isinstance(similar_list, dict):
|
| 431 |
+
similar_list = [similar_list]
|
| 432 |
+
similar_artists = [a.get("name", "") for a in similar_list]
|
| 433 |
+
|
| 434 |
+
return ArtistMetadata(
|
| 435 |
+
name=artist_data.get("name", ""),
|
| 436 |
+
mbid=artist_data.get("mbid"),
|
| 437 |
+
url=artist_data.get("url"),
|
| 438 |
+
tags=tags,
|
| 439 |
+
similar_artists=similar_artists,
|
| 440 |
+
listeners=int(artist_data.get("stats", {}).get("listeners", 0)),
|
| 441 |
+
playcount=int(artist_data.get("stats", {}).get("playcount", 0)),
|
| 442 |
+
bio=artist_data.get("bio", {}).get("summary", "")
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
except Exception as e:
|
| 446 |
+
logger.error(
|
| 447 |
+
"Get artist info failed",
|
| 448 |
+
artist=artist,
|
| 449 |
+
error=str(e)
|
| 450 |
+
)
|
| 451 |
+
return None
|
| 452 |
+
|
| 453 |
+
async def search_by_tags(
|
| 454 |
+
self,
|
| 455 |
+
tags: List[str],
|
| 456 |
+
limit: int = 20
|
| 457 |
+
) -> List[TrackMetadata]:
|
| 458 |
+
"""
|
| 459 |
+
Search tracks by tags for genre/mood discovery.
|
| 460 |
+
|
| 461 |
+
Args:
|
| 462 |
+
tags: List of tags (e.g., ["indie", "experimental"])
|
| 463 |
+
limit: Maximum results
|
| 464 |
+
|
| 465 |
+
Returns:
|
| 466 |
+
List of tracks matching tags
|
| 467 |
+
"""
|
| 468 |
+
tracks = []
|
| 469 |
+
|
| 470 |
+
for tag in tags:
|
| 471 |
+
try:
|
| 472 |
+
data = await self._make_request(
|
| 473 |
+
"tag.getTopTracks",
|
| 474 |
+
{
|
| 475 |
+
"tag": tag,
|
| 476 |
+
"limit": min(limit // len(tags), 10) # Distribute across tags
|
| 477 |
+
}
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
if "tracks" in data and "track" in data["tracks"]:
|
| 481 |
+
track_list = data["tracks"]["track"]
|
| 482 |
+
if isinstance(track_list, dict):
|
| 483 |
+
track_list = [track_list]
|
| 484 |
+
|
| 485 |
+
for track_data in track_list:
|
| 486 |
+
track = TrackMetadata(
|
| 487 |
+
name=track_data.get("name", ""),
|
| 488 |
+
artist=track_data.get("artist", {}).get("name", ""),
|
| 489 |
+
mbid=track_data.get("mbid"),
|
| 490 |
+
url=track_data.get("url"),
|
| 491 |
+
tags=[tag], # Add the search tag
|
| 492 |
+
)
|
| 493 |
+
tracks.append(track)
|
| 494 |
+
|
| 495 |
+
except Exception as e:
|
| 496 |
+
logger.warning(
|
| 497 |
+
"Tag search failed for tag",
|
| 498 |
+
tag=tag,
|
| 499 |
+
error=str(e)
|
| 500 |
+
)
|
| 501 |
+
continue
|
| 502 |
+
|
| 503 |
+
logger.info(
|
| 504 |
+
"Tag search completed",
|
| 505 |
+
tags=tags,
|
| 506 |
+
results_count=len(tracks)
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
return tracks[:limit] # Limit final results
|
src/api/spotify_client.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Spotify Web API Client
|
| 3 |
+
|
| 4 |
+
Provides access to Spotify's database for audio features and preview URLs.
|
| 5 |
+
Used as secondary data source for BeatDebate.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import base64
|
| 10 |
+
import time
|
| 11 |
+
from typing import Dict, List, Optional, Any
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
import aiohttp
|
| 15 |
+
import structlog
|
| 16 |
+
|
| 17 |
+
logger = structlog.get_logger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class SpotifyTrack:
|
| 22 |
+
"""Spotify track data."""
|
| 23 |
+
id: str
|
| 24 |
+
name: str
|
| 25 |
+
artist: str
|
| 26 |
+
album: str
|
| 27 |
+
preview_url: Optional[str] = None
|
| 28 |
+
external_urls: Optional[Dict[str, str]] = None
|
| 29 |
+
duration_ms: Optional[int] = None
|
| 30 |
+
popularity: Optional[int] = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class AudioFeatures:
|
| 35 |
+
"""Spotify audio features."""
|
| 36 |
+
track_id: str
|
| 37 |
+
danceability: float
|
| 38 |
+
energy: float
|
| 39 |
+
valence: float
|
| 40 |
+
acousticness: float
|
| 41 |
+
instrumentalness: float
|
| 42 |
+
speechiness: float
|
| 43 |
+
liveness: float
|
| 44 |
+
loudness: float
|
| 45 |
+
tempo: float
|
| 46 |
+
time_signature: int
|
| 47 |
+
key: int
|
| 48 |
+
mode: int
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class SpotifyRateLimiter:
|
| 52 |
+
"""Rate limiter for Spotify API calls."""
|
| 53 |
+
|
| 54 |
+
def __init__(self, calls_per_hour: int = 50):
|
| 55 |
+
self.calls_per_hour = calls_per_hour
|
| 56 |
+
self.min_interval = 3600.0 / calls_per_hour # seconds between calls
|
| 57 |
+
self.last_call = 0.0
|
| 58 |
+
|
| 59 |
+
async def wait_if_needed(self) -> None:
|
| 60 |
+
"""Wait if necessary to respect rate limits."""
|
| 61 |
+
now = time.time()
|
| 62 |
+
time_since_last = now - self.last_call
|
| 63 |
+
|
| 64 |
+
if time_since_last < self.min_interval:
|
| 65 |
+
wait_time = self.min_interval - time_since_last
|
| 66 |
+
await asyncio.sleep(wait_time)
|
| 67 |
+
|
| 68 |
+
self.last_call = time.time()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class SpotifyClient:
|
| 72 |
+
"""
|
| 73 |
+
Spotify Web API client with authentication and rate limiting.
|
| 74 |
+
|
| 75 |
+
Focuses on audio features and preview URLs for BeatDebate.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
BASE_URL = "https://api.spotify.com/v1"
|
| 79 |
+
AUTH_URL = "https://accounts.spotify.com/api/token"
|
| 80 |
+
|
| 81 |
+
def __init__(
|
| 82 |
+
self,
|
| 83 |
+
client_id: str,
|
| 84 |
+
client_secret: str,
|
| 85 |
+
rate_limit: int = 50
|
| 86 |
+
):
|
| 87 |
+
"""
|
| 88 |
+
Initialize Spotify client.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
client_id: Spotify client ID
|
| 92 |
+
client_secret: Spotify client secret
|
| 93 |
+
rate_limit: Requests per hour (default: 50)
|
| 94 |
+
"""
|
| 95 |
+
self.client_id = client_id
|
| 96 |
+
self.client_secret = client_secret
|
| 97 |
+
self.rate_limiter = SpotifyRateLimiter(rate_limit)
|
| 98 |
+
self.session: Optional[aiohttp.ClientSession] = None
|
| 99 |
+
self.access_token: Optional[str] = None
|
| 100 |
+
self.token_expires_at: float = 0.0
|
| 101 |
+
|
| 102 |
+
async def __aenter__(self):
|
| 103 |
+
"""Async context manager entry."""
|
| 104 |
+
self.session = aiohttp.ClientSession()
|
| 105 |
+
await self._authenticate()
|
| 106 |
+
return self
|
| 107 |
+
|
| 108 |
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| 109 |
+
"""Async context manager exit."""
|
| 110 |
+
if self.session:
|
| 111 |
+
await self.session.close()
|
| 112 |
+
|
| 113 |
+
async def _authenticate(self) -> None:
|
| 114 |
+
"""Authenticate with Spotify API using client credentials flow."""
|
| 115 |
+
if not self.session:
|
| 116 |
+
raise RuntimeError("Client not initialized. Use async context manager.")
|
| 117 |
+
|
| 118 |
+
# Prepare authentication
|
| 119 |
+
auth_str = f"{self.client_id}:{self.client_secret}"
|
| 120 |
+
auth_b64 = base64.b64encode(auth_str.encode()).decode()
|
| 121 |
+
|
| 122 |
+
headers = {
|
| 123 |
+
"Authorization": f"Basic {auth_b64}",
|
| 124 |
+
"Content-Type": "application/x-www-form-urlencoded"
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
data = {"grant_type": "client_credentials"}
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
async with self.session.post(
|
| 131 |
+
self.AUTH_URL,
|
| 132 |
+
headers=headers,
|
| 133 |
+
data=data,
|
| 134 |
+
timeout=aiohttp.ClientTimeout(total=10)
|
| 135 |
+
) as response:
|
| 136 |
+
if response.status == 200:
|
| 137 |
+
token_data = await response.json()
|
| 138 |
+
self.access_token = token_data["access_token"]
|
| 139 |
+
expires_in = token_data.get("expires_in", 3600)
|
| 140 |
+
self.token_expires_at = time.time() + expires_in - 60 # 1min buffer
|
| 141 |
+
|
| 142 |
+
logger.info(
|
| 143 |
+
"Spotify authentication successful",
|
| 144 |
+
expires_in=expires_in
|
| 145 |
+
)
|
| 146 |
+
else:
|
| 147 |
+
error_data = await response.json()
|
| 148 |
+
logger.error(
|
| 149 |
+
"Spotify authentication failed",
|
| 150 |
+
status=response.status,
|
| 151 |
+
error=error_data
|
| 152 |
+
)
|
| 153 |
+
raise Exception(f"Spotify auth failed: {error_data}")
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error("Spotify authentication error", error=str(e))
|
| 157 |
+
raise
|
| 158 |
+
|
| 159 |
+
async def _ensure_valid_token(self) -> None:
|
| 160 |
+
"""Ensure we have a valid access token."""
|
| 161 |
+
if not self.access_token or time.time() >= self.token_expires_at:
|
| 162 |
+
await self._authenticate()
|
| 163 |
+
|
| 164 |
+
async def _make_request(
|
| 165 |
+
self,
|
| 166 |
+
endpoint: str,
|
| 167 |
+
params: Optional[Dict[str, Any]] = None,
|
| 168 |
+
retries: int = 3
|
| 169 |
+
) -> Dict[str, Any]:
|
| 170 |
+
"""
|
| 171 |
+
Make authenticated request to Spotify API.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
endpoint: API endpoint (without base URL)
|
| 175 |
+
params: Query parameters
|
| 176 |
+
retries: Number of retry attempts
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
API response data
|
| 180 |
+
"""
|
| 181 |
+
if not self.session:
|
| 182 |
+
raise RuntimeError("Client not initialized. Use async context manager.")
|
| 183 |
+
|
| 184 |
+
await self._ensure_valid_token()
|
| 185 |
+
await self.rate_limiter.wait_if_needed()
|
| 186 |
+
|
| 187 |
+
headers = {
|
| 188 |
+
"Authorization": f"Bearer {self.access_token}",
|
| 189 |
+
"Content-Type": "application/json"
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
|
| 193 |
+
|
| 194 |
+
for attempt in range(retries + 1):
|
| 195 |
+
try:
|
| 196 |
+
async with self.session.get(
|
| 197 |
+
url,
|
| 198 |
+
headers=headers,
|
| 199 |
+
params=params or {},
|
| 200 |
+
timeout=aiohttp.ClientTimeout(total=10)
|
| 201 |
+
) as response:
|
| 202 |
+
if response.status == 200:
|
| 203 |
+
data = await response.json()
|
| 204 |
+
logger.debug(
|
| 205 |
+
"Spotify API request successful",
|
| 206 |
+
endpoint=endpoint,
|
| 207 |
+
params=params
|
| 208 |
+
)
|
| 209 |
+
return data
|
| 210 |
+
|
| 211 |
+
elif response.status == 401: # Unauthorized
|
| 212 |
+
logger.warning("Spotify token expired, re-authenticating")
|
| 213 |
+
await self._authenticate()
|
| 214 |
+
# Don't count this as a retry attempt
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
elif response.status == 429: # Rate limited
|
| 218 |
+
retry_after = int(response.headers.get('Retry-After', 1))
|
| 219 |
+
logger.warning(
|
| 220 |
+
"Rate limited by Spotify",
|
| 221 |
+
retry_after=retry_after,
|
| 222 |
+
attempt=attempt
|
| 223 |
+
)
|
| 224 |
+
await asyncio.sleep(retry_after)
|
| 225 |
+
continue
|
| 226 |
+
|
| 227 |
+
elif response.status == 404: # Not found
|
| 228 |
+
logger.debug(
|
| 229 |
+
"Spotify resource not found",
|
| 230 |
+
endpoint=endpoint,
|
| 231 |
+
params=params
|
| 232 |
+
)
|
| 233 |
+
return {}
|
| 234 |
+
|
| 235 |
+
else:
|
| 236 |
+
logger.warning(
|
| 237 |
+
"Spotify API request failed",
|
| 238 |
+
status=response.status,
|
| 239 |
+
endpoint=endpoint
|
| 240 |
+
)
|
| 241 |
+
response.raise_for_status()
|
| 242 |
+
|
| 243 |
+
except asyncio.TimeoutError:
|
| 244 |
+
logger.warning(
|
| 245 |
+
"Spotify API timeout",
|
| 246 |
+
attempt=attempt,
|
| 247 |
+
endpoint=endpoint
|
| 248 |
+
)
|
| 249 |
+
if attempt == retries:
|
| 250 |
+
raise
|
| 251 |
+
await asyncio.sleep(2 ** attempt)
|
| 252 |
+
|
| 253 |
+
except Exception as e:
|
| 254 |
+
logger.error(
|
| 255 |
+
"Spotify API request failed",
|
| 256 |
+
error=str(e),
|
| 257 |
+
attempt=attempt,
|
| 258 |
+
endpoint=endpoint
|
| 259 |
+
)
|
| 260 |
+
if attempt == retries:
|
| 261 |
+
raise
|
| 262 |
+
await asyncio.sleep(2 ** attempt)
|
| 263 |
+
|
| 264 |
+
raise Exception(f"Failed to complete Spotify request after {retries + 1} attempts")
|
| 265 |
+
|
| 266 |
+
async def search_track(
|
| 267 |
+
self,
|
| 268 |
+
artist: str,
|
| 269 |
+
track: str,
|
| 270 |
+
limit: int = 1
|
| 271 |
+
) -> Optional[SpotifyTrack]:
|
| 272 |
+
"""
|
| 273 |
+
Search for a track on Spotify.
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
artist: Artist name
|
| 277 |
+
track: Track name
|
| 278 |
+
limit: Number of results (default: 1)
|
| 279 |
+
|
| 280 |
+
Returns:
|
| 281 |
+
First matching Spotify track or None
|
| 282 |
+
"""
|
| 283 |
+
try:
|
| 284 |
+
query = f"artist:{artist} track:{track}"
|
| 285 |
+
data = await self._make_request(
|
| 286 |
+
"search",
|
| 287 |
+
{
|
| 288 |
+
"q": query,
|
| 289 |
+
"type": "track",
|
| 290 |
+
"limit": limit
|
| 291 |
+
}
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
if "tracks" in data and data["tracks"]["items"]:
|
| 295 |
+
track_data = data["tracks"]["items"][0]
|
| 296 |
+
|
| 297 |
+
return SpotifyTrack(
|
| 298 |
+
id=track_data["id"],
|
| 299 |
+
name=track_data["name"],
|
| 300 |
+
artist=track_data["artists"][0]["name"],
|
| 301 |
+
album=track_data["album"]["name"],
|
| 302 |
+
preview_url=track_data.get("preview_url"),
|
| 303 |
+
external_urls=track_data.get("external_urls"),
|
| 304 |
+
duration_ms=track_data.get("duration_ms"),
|
| 305 |
+
popularity=track_data.get("popularity")
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
return None
|
| 309 |
+
|
| 310 |
+
except Exception as e:
|
| 311 |
+
logger.error(
|
| 312 |
+
"Spotify track search failed",
|
| 313 |
+
artist=artist,
|
| 314 |
+
track=track,
|
| 315 |
+
error=str(e)
|
| 316 |
+
)
|
| 317 |
+
return None
|
| 318 |
+
|
| 319 |
+
async def get_track(self, track_id: str) -> Optional[SpotifyTrack]:
|
| 320 |
+
"""
|
| 321 |
+
Get track details by Spotify ID.
|
| 322 |
+
|
| 323 |
+
Args:
|
| 324 |
+
track_id: Spotify track ID
|
| 325 |
+
|
| 326 |
+
Returns:
|
| 327 |
+
Track details or None
|
| 328 |
+
"""
|
| 329 |
+
try:
|
| 330 |
+
data = await self._make_request(f"tracks/{track_id}")
|
| 331 |
+
|
| 332 |
+
if not data:
|
| 333 |
+
return None
|
| 334 |
+
|
| 335 |
+
return SpotifyTrack(
|
| 336 |
+
id=data["id"],
|
| 337 |
+
name=data["name"],
|
| 338 |
+
artist=data["artists"][0]["name"],
|
| 339 |
+
album=data["album"]["name"],
|
| 340 |
+
preview_url=data.get("preview_url"),
|
| 341 |
+
external_urls=data.get("external_urls"),
|
| 342 |
+
duration_ms=data.get("duration_ms"),
|
| 343 |
+
popularity=data.get("popularity")
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
except Exception as e:
|
| 347 |
+
logger.error(
|
| 348 |
+
"Get Spotify track failed",
|
| 349 |
+
track_id=track_id,
|
| 350 |
+
error=str(e)
|
| 351 |
+
)
|
| 352 |
+
return None
|
| 353 |
+
|
| 354 |
+
async def get_audio_features(self, track_id: str) -> Optional[AudioFeatures]:
|
| 355 |
+
"""
|
| 356 |
+
Get audio features for a track.
|
| 357 |
+
|
| 358 |
+
Args:
|
| 359 |
+
track_id: Spotify track ID
|
| 360 |
+
|
| 361 |
+
Returns:
|
| 362 |
+
Audio features or None
|
| 363 |
+
"""
|
| 364 |
+
try:
|
| 365 |
+
data = await self._make_request(f"audio-features/{track_id}")
|
| 366 |
+
|
| 367 |
+
if not data or "id" not in data:
|
| 368 |
+
return None
|
| 369 |
+
|
| 370 |
+
return AudioFeatures(
|
| 371 |
+
track_id=data["id"],
|
| 372 |
+
danceability=data.get("danceability", 0.0),
|
| 373 |
+
energy=data.get("energy", 0.0),
|
| 374 |
+
valence=data.get("valence", 0.0),
|
| 375 |
+
acousticness=data.get("acousticness", 0.0),
|
| 376 |
+
instrumentalness=data.get("instrumentalness", 0.0),
|
| 377 |
+
speechiness=data.get("speechiness", 0.0),
|
| 378 |
+
liveness=data.get("liveness", 0.0),
|
| 379 |
+
loudness=data.get("loudness", 0.0),
|
| 380 |
+
tempo=data.get("tempo", 0.0),
|
| 381 |
+
time_signature=data.get("time_signature", 4),
|
| 382 |
+
key=data.get("key", 0),
|
| 383 |
+
mode=data.get("mode", 0)
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
except Exception as e:
|
| 387 |
+
logger.error(
|
| 388 |
+
"Get audio features failed",
|
| 389 |
+
track_id=track_id,
|
| 390 |
+
error=str(e)
|
| 391 |
+
)
|
| 392 |
+
return None
|
| 393 |
+
|
| 394 |
+
async def get_multiple_audio_features(
|
| 395 |
+
self,
|
| 396 |
+
track_ids: List[str]
|
| 397 |
+
) -> Dict[str, AudioFeatures]:
|
| 398 |
+
"""
|
| 399 |
+
Get audio features for multiple tracks (batch request).
|
| 400 |
+
|
| 401 |
+
Args:
|
| 402 |
+
track_ids: List of Spotify track IDs
|
| 403 |
+
|
| 404 |
+
Returns:
|
| 405 |
+
Dictionary mapping track IDs to audio features
|
| 406 |
+
"""
|
| 407 |
+
features = {}
|
| 408 |
+
|
| 409 |
+
# Process in batches of 100 (Spotify limit)
|
| 410 |
+
for i in range(0, len(track_ids), 100):
|
| 411 |
+
batch = track_ids[i:i + 100]
|
| 412 |
+
|
| 413 |
+
try:
|
| 414 |
+
data = await self._make_request(
|
| 415 |
+
"audio-features",
|
| 416 |
+
{"ids": ",".join(batch)}
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
if "audio_features" in data:
|
| 420 |
+
for feature_data in data["audio_features"]:
|
| 421 |
+
if feature_data and "id" in feature_data:
|
| 422 |
+
features[feature_data["id"]] = AudioFeatures(
|
| 423 |
+
track_id=feature_data["id"],
|
| 424 |
+
danceability=feature_data.get("danceability", 0.0),
|
| 425 |
+
energy=feature_data.get("energy", 0.0),
|
| 426 |
+
valence=feature_data.get("valence", 0.0),
|
| 427 |
+
acousticness=feature_data.get("acousticness", 0.0),
|
| 428 |
+
instrumentalness=feature_data.get("instrumentalness", 0.0),
|
| 429 |
+
speechiness=feature_data.get("speechiness", 0.0),
|
| 430 |
+
liveness=feature_data.get("liveness", 0.0),
|
| 431 |
+
loudness=feature_data.get("loudness", 0.0),
|
| 432 |
+
tempo=feature_data.get("tempo", 0.0),
|
| 433 |
+
time_signature=feature_data.get("time_signature", 4),
|
| 434 |
+
key=feature_data.get("key", 0),
|
| 435 |
+
mode=feature_data.get("mode", 0)
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
except Exception as e:
|
| 439 |
+
logger.error(
|
| 440 |
+
"Batch audio features request failed",
|
| 441 |
+
batch_size=len(batch),
|
| 442 |
+
error=str(e)
|
| 443 |
+
)
|
| 444 |
+
continue
|
| 445 |
+
|
| 446 |
+
logger.info(
|
| 447 |
+
"Audio features batch completed",
|
| 448 |
+
requested=len(track_ids),
|
| 449 |
+
retrieved=len(features)
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
return features
|
| 453 |
+
|
| 454 |
+
async def search_tracks(
|
| 455 |
+
self,
|
| 456 |
+
query: str,
|
| 457 |
+
limit: int = 20,
|
| 458 |
+
offset: int = 0
|
| 459 |
+
) -> List[SpotifyTrack]:
|
| 460 |
+
"""
|
| 461 |
+
Search for tracks with a general query.
|
| 462 |
+
|
| 463 |
+
Args:
|
| 464 |
+
query: Search query
|
| 465 |
+
limit: Number of results
|
| 466 |
+
offset: Result offset
|
| 467 |
+
|
| 468 |
+
Returns:
|
| 469 |
+
List of matching tracks
|
| 470 |
+
"""
|
| 471 |
+
try:
|
| 472 |
+
data = await self._make_request(
|
| 473 |
+
"search",
|
| 474 |
+
{
|
| 475 |
+
"q": query,
|
| 476 |
+
"type": "track",
|
| 477 |
+
"limit": limit,
|
| 478 |
+
"offset": offset
|
| 479 |
+
}
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
tracks = []
|
| 483 |
+
if "tracks" in data and "items" in data["tracks"]:
|
| 484 |
+
for track_data in data["tracks"]["items"]:
|
| 485 |
+
track = SpotifyTrack(
|
| 486 |
+
id=track_data["id"],
|
| 487 |
+
name=track_data["name"],
|
| 488 |
+
artist=track_data["artists"][0]["name"],
|
| 489 |
+
album=track_data["album"]["name"],
|
| 490 |
+
preview_url=track_data.get("preview_url"),
|
| 491 |
+
external_urls=track_data.get("external_urls"),
|
| 492 |
+
duration_ms=track_data.get("duration_ms"),
|
| 493 |
+
popularity=track_data.get("popularity")
|
| 494 |
+
)
|
| 495 |
+
tracks.append(track)
|
| 496 |
+
|
| 497 |
+
logger.info(
|
| 498 |
+
"Spotify search completed",
|
| 499 |
+
query=query,
|
| 500 |
+
results_count=len(tracks)
|
| 501 |
+
)
|
| 502 |
+
|
| 503 |
+
return tracks
|
| 504 |
+
|
| 505 |
+
except Exception as e:
|
| 506 |
+
logger.error(
|
| 507 |
+
"Spotify search failed",
|
| 508 |
+
query=query,
|
| 509 |
+
error=str(e)
|
| 510 |
+
)
|
| 511 |
+
return []
|
src/main.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
BeatDebate - Main Application Entry Point
|
| 3 |
+
|
| 4 |
+
Multi-agent music recommendation system using sophisticated planning behavior.
|
| 5 |
+
Built for the AgentX competition.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
from typing import Dict, Any
|
| 12 |
+
|
| 13 |
+
import structlog
|
| 14 |
+
import uvicorn
|
| 15 |
+
from fastapi import FastAPI, HTTPException
|
| 16 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 17 |
+
from pydantic import BaseModel
|
| 18 |
+
from dotenv import load_dotenv
|
| 19 |
+
|
| 20 |
+
# Load environment variables
|
| 21 |
+
load_dotenv()
|
| 22 |
+
|
| 23 |
+
# Configure structured logging
|
| 24 |
+
structlog.configure(
|
| 25 |
+
processors=[
|
| 26 |
+
structlog.stdlib.filter_by_level,
|
| 27 |
+
structlog.stdlib.add_logger_name,
|
| 28 |
+
structlog.stdlib.add_log_level,
|
| 29 |
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
| 30 |
+
structlog.processors.TimeStamper(fmt="ISO"),
|
| 31 |
+
structlog.processors.StackInfoRenderer(),
|
| 32 |
+
structlog.processors.format_exc_info,
|
| 33 |
+
structlog.processors.UnicodeDecoder(),
|
| 34 |
+
structlog.processors.JSONRenderer()
|
| 35 |
+
],
|
| 36 |
+
context_class=dict,
|
| 37 |
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
| 38 |
+
wrapper_class=structlog.stdlib.BoundLogger,
|
| 39 |
+
cache_logger_on_first_use=True,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
logger = structlog.get_logger(__name__)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class HealthResponse(BaseModel):
|
| 46 |
+
"""Health check response model."""
|
| 47 |
+
status: str
|
| 48 |
+
version: str
|
| 49 |
+
environment: str
|
| 50 |
+
api_keys_configured: Dict[str, bool]
|
| 51 |
+
dependencies: Dict[str, str]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@asynccontextmanager
|
| 55 |
+
async def lifespan(app: FastAPI):
|
| 56 |
+
"""Application lifespan manager."""
|
| 57 |
+
logger.info("🎵 Starting BeatDebate application")
|
| 58 |
+
|
| 59 |
+
# Startup validation
|
| 60 |
+
required_env_vars = ["GEMINI_API_KEY", "LASTFM_API_KEY", "SPOTIFY_CLIENT_ID"]
|
| 61 |
+
missing_vars = [var for var in required_env_vars if not os.getenv(var)]
|
| 62 |
+
|
| 63 |
+
if missing_vars:
|
| 64 |
+
logger.warning(
|
| 65 |
+
"Missing environment variables",
|
| 66 |
+
missing_vars=missing_vars
|
| 67 |
+
)
|
| 68 |
+
else:
|
| 69 |
+
logger.info("✅ All required API keys configured")
|
| 70 |
+
|
| 71 |
+
yield
|
| 72 |
+
|
| 73 |
+
logger.info("🎵 Shutting down BeatDebate application")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# Create FastAPI application
|
| 77 |
+
app = FastAPI(
|
| 78 |
+
title="BeatDebate",
|
| 79 |
+
description="Multi-Agent Music Recommendation System",
|
| 80 |
+
version="0.1.0",
|
| 81 |
+
docs_url="/docs",
|
| 82 |
+
redoc_url="/redoc",
|
| 83 |
+
lifespan=lifespan
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Add CORS middleware for web interface
|
| 87 |
+
app.add_middleware(
|
| 88 |
+
CORSMiddleware,
|
| 89 |
+
allow_origins=["*"], # Configure appropriately for production
|
| 90 |
+
allow_credentials=True,
|
| 91 |
+
allow_methods=["*"],
|
| 92 |
+
allow_headers=["*"],
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@app.get("/", response_model=Dict[str, str])
|
| 97 |
+
async def root() -> Dict[str, str]:
|
| 98 |
+
"""Root endpoint with basic information."""
|
| 99 |
+
return {
|
| 100 |
+
"name": "BeatDebate",
|
| 101 |
+
"description": "Multi-Agent Music Recommendation System",
|
| 102 |
+
"version": "0.1.0",
|
| 103 |
+
"status": "active",
|
| 104 |
+
"docs": "/docs",
|
| 105 |
+
"health": "/health"
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@app.get("/health", response_model=HealthResponse)
|
| 110 |
+
async def health_check() -> HealthResponse:
|
| 111 |
+
"""Comprehensive health check endpoint."""
|
| 112 |
+
try:
|
| 113 |
+
# Check environment configuration
|
| 114 |
+
api_keys_configured = {
|
| 115 |
+
"gemini": bool(os.getenv("GEMINI_API_KEY")),
|
| 116 |
+
"lastfm": bool(os.getenv("LASTFM_API_KEY")),
|
| 117 |
+
"spotify": bool(os.getenv("SPOTIFY_CLIENT_ID") and os.getenv("SPOTIFY_CLIENT_SECRET")),
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
# Check dependencies (basic import test)
|
| 121 |
+
dependencies = {}
|
| 122 |
+
try:
|
| 123 |
+
import langchain
|
| 124 |
+
dependencies["langchain"] = langchain.__version__
|
| 125 |
+
except ImportError:
|
| 126 |
+
dependencies["langchain"] = "not_available"
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
import chromadb
|
| 130 |
+
dependencies["chromadb"] = chromadb.__version__
|
| 131 |
+
except ImportError:
|
| 132 |
+
dependencies["chromadb"] = "not_available"
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
import sentence_transformers
|
| 136 |
+
dependencies["sentence_transformers"] = sentence_transformers.__version__
|
| 137 |
+
except ImportError:
|
| 138 |
+
dependencies["sentence_transformers"] = "not_available"
|
| 139 |
+
|
| 140 |
+
environment = os.getenv("ENVIRONMENT", "development")
|
| 141 |
+
|
| 142 |
+
logger.info(
|
| 143 |
+
"Health check completed",
|
| 144 |
+
api_keys=api_keys_configured,
|
| 145 |
+
dependencies=dependencies,
|
| 146 |
+
environment=environment
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
return HealthResponse(
|
| 150 |
+
status="healthy",
|
| 151 |
+
version="0.1.0",
|
| 152 |
+
environment=environment,
|
| 153 |
+
api_keys_configured=api_keys_configured,
|
| 154 |
+
dependencies=dependencies
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.error("Health check failed", error=str(e))
|
| 159 |
+
raise HTTPException(status_code=500, detail=f"Health check failed: {str(e)}")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@app.get("/api/test")
|
| 163 |
+
async def test_endpoint() -> Dict[str, Any]:
|
| 164 |
+
"""Test endpoint for development."""
|
| 165 |
+
return {
|
| 166 |
+
"message": "BeatDebate API is working!",
|
| 167 |
+
"timestamp": "2025-01-25T10:00:00Z",
|
| 168 |
+
"agents": {
|
| 169 |
+
"planner": "ready",
|
| 170 |
+
"genre_mood": "ready",
|
| 171 |
+
"discovery": "ready",
|
| 172 |
+
"judge": "ready"
|
| 173 |
+
},
|
| 174 |
+
"data_sources": {
|
| 175 |
+
"lastfm": "configured" if os.getenv("LASTFM_API_KEY") else "missing",
|
| 176 |
+
"spotify": "configured" if os.getenv("SPOTIFY_CLIENT_ID") else "missing"
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
if __name__ == "__main__":
|
| 182 |
+
# Get configuration from environment
|
| 183 |
+
port = int(os.getenv("GRADIO_SERVER_PORT", 7860))
|
| 184 |
+
debug = os.getenv("DEBUG_MODE", "true").lower() == "true"
|
| 185 |
+
|
| 186 |
+
logger.info(
|
| 187 |
+
"Starting BeatDebate server",
|
| 188 |
+
port=port,
|
| 189 |
+
debug=debug,
|
| 190 |
+
environment=os.getenv("ENVIRONMENT", "development")
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
uvicorn.run(
|
| 194 |
+
"src.main:app",
|
| 195 |
+
host="0.0.0.0",
|
| 196 |
+
port=port,
|
| 197 |
+
reload=debug,
|
| 198 |
+
log_level="info"
|
| 199 |
+
)
|
src/models/__init__.py
ADDED
|
File without changes
|
src/services/__init__.py
ADDED
|
File without changes
|
src/services/cache_manager.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cache Management System
|
| 3 |
+
|
| 4 |
+
Provides file-based caching with TTL for API responses and track metadata.
|
| 5 |
+
Optimized for BeatDebate's specific caching needs.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import hashlib
|
| 10 |
+
import time
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, Optional, List
|
| 14 |
+
from dataclasses import asdict, is_dataclass
|
| 15 |
+
|
| 16 |
+
import structlog
|
| 17 |
+
from diskcache import Cache
|
| 18 |
+
|
| 19 |
+
logger = structlog.get_logger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class CacheManager:
|
| 23 |
+
"""
|
| 24 |
+
File-based cache manager with TTL support.
|
| 25 |
+
|
| 26 |
+
Handles caching for:
|
| 27 |
+
- Last.fm API responses
|
| 28 |
+
- Spotify API responses
|
| 29 |
+
- Track metadata
|
| 30 |
+
- Agent strategies
|
| 31 |
+
- User preferences
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(self, cache_dir: str = "data/cache"):
|
| 35 |
+
"""
|
| 36 |
+
Initialize cache manager.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
cache_dir: Directory for cache storage
|
| 40 |
+
"""
|
| 41 |
+
self.cache_dir = Path(cache_dir)
|
| 42 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 43 |
+
|
| 44 |
+
# Initialize diskcache for different data types
|
| 45 |
+
self.caches = {
|
| 46 |
+
"lastfm": Cache(str(self.cache_dir / "lastfm")),
|
| 47 |
+
"spotify": Cache(str(self.cache_dir / "spotify")),
|
| 48 |
+
"tracks": Cache(str(self.cache_dir / "tracks")),
|
| 49 |
+
"strategies": Cache(str(self.cache_dir / "strategies")),
|
| 50 |
+
"preferences": Cache(str(self.cache_dir / "preferences")),
|
| 51 |
+
"embeddings": Cache(str(self.cache_dir / "embeddings"))
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
# Default TTL values (in seconds)
|
| 55 |
+
self.default_ttl = {
|
| 56 |
+
"lastfm": 7 * 24 * 3600, # 1 week
|
| 57 |
+
"spotify": 7 * 24 * 3600, # 1 week
|
| 58 |
+
"tracks": 3 * 24 * 3600, # 3 days
|
| 59 |
+
"strategies": 12 * 3600, # 12 hours
|
| 60 |
+
"preferences": 24 * 3600, # 1 day
|
| 61 |
+
"embeddings": 30 * 24 * 3600, # 30 days
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
logger.info(
|
| 65 |
+
"Cache manager initialized",
|
| 66 |
+
cache_dir=str(self.cache_dir),
|
| 67 |
+
cache_types=list(self.caches.keys())
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
def _serialize_value(self, value: Any) -> Any:
|
| 71 |
+
"""Serialize value for caching."""
|
| 72 |
+
if is_dataclass(value):
|
| 73 |
+
return asdict(value)
|
| 74 |
+
elif isinstance(value, list) and value and is_dataclass(value[0]):
|
| 75 |
+
return [asdict(item) for item in value]
|
| 76 |
+
return value
|
| 77 |
+
|
| 78 |
+
def _generate_key(self, *args, **kwargs) -> str:
|
| 79 |
+
"""Generate cache key from arguments."""
|
| 80 |
+
key_data = {
|
| 81 |
+
"args": args,
|
| 82 |
+
"kwargs": sorted(kwargs.items())
|
| 83 |
+
}
|
| 84 |
+
key_str = json.dumps(key_data, sort_keys=True, default=str)
|
| 85 |
+
return hashlib.md5(key_str.encode()).hexdigest()
|
| 86 |
+
|
| 87 |
+
def get(
|
| 88 |
+
self,
|
| 89 |
+
cache_type: str,
|
| 90 |
+
key: str,
|
| 91 |
+
default: Any = None
|
| 92 |
+
) -> Any:
|
| 93 |
+
"""
|
| 94 |
+
Get value from cache.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
cache_type: Type of cache (lastfm, spotify, etc.)
|
| 98 |
+
key: Cache key
|
| 99 |
+
default: Default value if not found
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
Cached value or default
|
| 103 |
+
"""
|
| 104 |
+
if cache_type not in self.caches:
|
| 105 |
+
logger.warning("Invalid cache type", cache_type=cache_type)
|
| 106 |
+
return default
|
| 107 |
+
|
| 108 |
+
try:
|
| 109 |
+
cache = self.caches[cache_type]
|
| 110 |
+
value = cache.get(key, default)
|
| 111 |
+
|
| 112 |
+
if value is not default:
|
| 113 |
+
logger.debug(
|
| 114 |
+
"Cache hit",
|
| 115 |
+
cache_type=cache_type,
|
| 116 |
+
key=key[:16] + "..."
|
| 117 |
+
)
|
| 118 |
+
else:
|
| 119 |
+
logger.debug(
|
| 120 |
+
"Cache miss",
|
| 121 |
+
cache_type=cache_type,
|
| 122 |
+
key=key[:16] + "..."
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
return value
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(
|
| 129 |
+
"Cache get failed",
|
| 130 |
+
cache_type=cache_type,
|
| 131 |
+
key=key[:16] + "...",
|
| 132 |
+
error=str(e)
|
| 133 |
+
)
|
| 134 |
+
return default
|
| 135 |
+
|
| 136 |
+
def set(
|
| 137 |
+
self,
|
| 138 |
+
cache_type: str,
|
| 139 |
+
key: str,
|
| 140 |
+
value: Any,
|
| 141 |
+
ttl: Optional[int] = None
|
| 142 |
+
) -> bool:
|
| 143 |
+
"""
|
| 144 |
+
Set value in cache.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
cache_type: Type of cache
|
| 148 |
+
key: Cache key
|
| 149 |
+
value: Value to cache
|
| 150 |
+
ttl: Time to live in seconds (uses default if None)
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
True if successful
|
| 154 |
+
"""
|
| 155 |
+
if cache_type not in self.caches:
|
| 156 |
+
logger.warning("Invalid cache type", cache_type=cache_type)
|
| 157 |
+
return False
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
cache = self.caches[cache_type]
|
| 161 |
+
if ttl is None:
|
| 162 |
+
ttl = self.default_ttl.get(cache_type, 3600)
|
| 163 |
+
|
| 164 |
+
# Serialize dataclasses
|
| 165 |
+
serialized_value = self._serialize_value(value)
|
| 166 |
+
|
| 167 |
+
cache.set(key, serialized_value, expire=ttl)
|
| 168 |
+
|
| 169 |
+
logger.debug(
|
| 170 |
+
"Cache set",
|
| 171 |
+
cache_type=cache_type,
|
| 172 |
+
key=key[:16] + "...",
|
| 173 |
+
ttl=ttl
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
return True
|
| 177 |
+
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.error(
|
| 180 |
+
"Cache set failed",
|
| 181 |
+
cache_type=cache_type,
|
| 182 |
+
key=key[:16] + "...",
|
| 183 |
+
error=str(e)
|
| 184 |
+
)
|
| 185 |
+
return False
|
| 186 |
+
|
| 187 |
+
def delete(self, cache_type: str, key: str) -> bool:
|
| 188 |
+
"""
|
| 189 |
+
Delete value from cache.
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
cache_type: Type of cache
|
| 193 |
+
key: Cache key
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
True if successful
|
| 197 |
+
"""
|
| 198 |
+
if cache_type not in self.caches:
|
| 199 |
+
return False
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
cache = self.caches[cache_type]
|
| 203 |
+
return cache.delete(key)
|
| 204 |
+
|
| 205 |
+
except Exception as e:
|
| 206 |
+
logger.error(
|
| 207 |
+
"Cache delete failed",
|
| 208 |
+
cache_type=cache_type,
|
| 209 |
+
key=key[:16] + "...",
|
| 210 |
+
error=str(e)
|
| 211 |
+
)
|
| 212 |
+
return False
|
| 213 |
+
|
| 214 |
+
def clear(self, cache_type: Optional[str] = None) -> bool:
|
| 215 |
+
"""
|
| 216 |
+
Clear cache(s).
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
cache_type: Specific cache to clear (all if None)
|
| 220 |
+
|
| 221 |
+
Returns:
|
| 222 |
+
True if successful
|
| 223 |
+
"""
|
| 224 |
+
try:
|
| 225 |
+
if cache_type:
|
| 226 |
+
if cache_type in self.caches:
|
| 227 |
+
self.caches[cache_type].clear()
|
| 228 |
+
logger.info("Cache cleared", cache_type=cache_type)
|
| 229 |
+
return True
|
| 230 |
+
else:
|
| 231 |
+
logger.warning("Invalid cache type", cache_type=cache_type)
|
| 232 |
+
return False
|
| 233 |
+
else:
|
| 234 |
+
for cache_name, cache in self.caches.items():
|
| 235 |
+
cache.clear()
|
| 236 |
+
logger.info("All caches cleared")
|
| 237 |
+
return True
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error("Cache clear failed", error=str(e))
|
| 241 |
+
return False
|
| 242 |
+
|
| 243 |
+
def get_stats(self) -> Dict[str, Dict[str, Any]]:
|
| 244 |
+
"""
|
| 245 |
+
Get cache statistics.
|
| 246 |
+
|
| 247 |
+
Returns:
|
| 248 |
+
Dictionary with cache stats
|
| 249 |
+
"""
|
| 250 |
+
stats = {}
|
| 251 |
+
|
| 252 |
+
for cache_name, cache in self.caches.items():
|
| 253 |
+
try:
|
| 254 |
+
# Get basic stats
|
| 255 |
+
cache_stats = {
|
| 256 |
+
"size": len(cache),
|
| 257 |
+
"volume": cache.volume(),
|
| 258 |
+
"directory": str(cache.directory)
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
stats[cache_name] = cache_stats
|
| 262 |
+
|
| 263 |
+
except Exception as e:
|
| 264 |
+
logger.error(
|
| 265 |
+
"Failed to get cache stats",
|
| 266 |
+
cache_name=cache_name,
|
| 267 |
+
error=str(e)
|
| 268 |
+
)
|
| 269 |
+
stats[cache_name] = {"error": str(e)}
|
| 270 |
+
|
| 271 |
+
return stats
|
| 272 |
+
|
| 273 |
+
def warm_cache(self, cache_type: str, warm_data: Dict[str, Any]) -> int:
|
| 274 |
+
"""
|
| 275 |
+
Warm cache with pre-computed data.
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
cache_type: Type of cache to warm
|
| 279 |
+
warm_data: Dictionary of key-value pairs
|
| 280 |
+
|
| 281 |
+
Returns:
|
| 282 |
+
Number of items cached
|
| 283 |
+
"""
|
| 284 |
+
if cache_type not in self.caches:
|
| 285 |
+
logger.warning("Invalid cache type", cache_type=cache_type)
|
| 286 |
+
return 0
|
| 287 |
+
|
| 288 |
+
cached_count = 0
|
| 289 |
+
|
| 290 |
+
for key, value in warm_data.items():
|
| 291 |
+
if self.set(cache_type, key, value):
|
| 292 |
+
cached_count += 1
|
| 293 |
+
|
| 294 |
+
logger.info(
|
| 295 |
+
"Cache warmed",
|
| 296 |
+
cache_type=cache_type,
|
| 297 |
+
items_cached=cached_count,
|
| 298 |
+
total_items=len(warm_data)
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
return cached_count
|
| 302 |
+
|
| 303 |
+
# Convenience methods for specific cache types
|
| 304 |
+
|
| 305 |
+
def cache_lastfm_response(
|
| 306 |
+
self,
|
| 307 |
+
method: str,
|
| 308 |
+
params: Dict[str, Any],
|
| 309 |
+
response: Any
|
| 310 |
+
) -> str:
|
| 311 |
+
"""Cache Last.fm API response."""
|
| 312 |
+
key = self._generate_key("lastfm", method, **params)
|
| 313 |
+
self.set("lastfm", key, response)
|
| 314 |
+
return key
|
| 315 |
+
|
| 316 |
+
def get_lastfm_response(
|
| 317 |
+
self,
|
| 318 |
+
method: str,
|
| 319 |
+
params: Dict[str, Any]
|
| 320 |
+
) -> Any:
|
| 321 |
+
"""Get cached Last.fm API response."""
|
| 322 |
+
key = self._generate_key("lastfm", method, **params)
|
| 323 |
+
return self.get("lastfm", key)
|
| 324 |
+
|
| 325 |
+
def cache_spotify_response(
|
| 326 |
+
self,
|
| 327 |
+
endpoint: str,
|
| 328 |
+
params: Dict[str, Any],
|
| 329 |
+
response: Any
|
| 330 |
+
) -> str:
|
| 331 |
+
"""Cache Spotify API response."""
|
| 332 |
+
key = self._generate_key("spotify", endpoint, **params)
|
| 333 |
+
self.set("spotify", key, response)
|
| 334 |
+
return key
|
| 335 |
+
|
| 336 |
+
def get_spotify_response(
|
| 337 |
+
self,
|
| 338 |
+
endpoint: str,
|
| 339 |
+
params: Dict[str, Any]
|
| 340 |
+
) -> Any:
|
| 341 |
+
"""Get cached Spotify API response."""
|
| 342 |
+
key = self._generate_key("spotify", endpoint, **params)
|
| 343 |
+
return self.get("spotify", key)
|
| 344 |
+
|
| 345 |
+
def cache_track_metadata(
|
| 346 |
+
self,
|
| 347 |
+
artist: str,
|
| 348 |
+
track: str,
|
| 349 |
+
metadata: Any
|
| 350 |
+
) -> str:
|
| 351 |
+
"""Cache track metadata."""
|
| 352 |
+
key = self._generate_key("track", artist.lower(), track.lower())
|
| 353 |
+
self.set("tracks", key, metadata)
|
| 354 |
+
return key
|
| 355 |
+
|
| 356 |
+
def get_track_metadata(self, artist: str, track: str) -> Any:
|
| 357 |
+
"""Get cached track metadata."""
|
| 358 |
+
key = self._generate_key("track", artist.lower(), track.lower())
|
| 359 |
+
return self.get("tracks", key)
|
| 360 |
+
|
| 361 |
+
def cache_strategy(
|
| 362 |
+
self,
|
| 363 |
+
user_query: str,
|
| 364 |
+
strategy: Dict[str, Any]
|
| 365 |
+
) -> str:
|
| 366 |
+
"""Cache planning strategy."""
|
| 367 |
+
key = self._generate_key("strategy", user_query)
|
| 368 |
+
self.set("strategies", key, strategy, ttl=12 * 3600) # 12 hours
|
| 369 |
+
return key
|
| 370 |
+
|
| 371 |
+
def get_strategy(self, user_query: str) -> Optional[Dict[str, Any]]:
|
| 372 |
+
"""Get cached planning strategy."""
|
| 373 |
+
key = self._generate_key("strategy", user_query)
|
| 374 |
+
return self.get("strategies", key)
|
| 375 |
+
|
| 376 |
+
def cache_user_preferences(
|
| 377 |
+
self,
|
| 378 |
+
user_id: str,
|
| 379 |
+
preferences: Dict[str, Any]
|
| 380 |
+
) -> str:
|
| 381 |
+
"""Cache user preferences."""
|
| 382 |
+
key = self._generate_key("user_prefs", user_id)
|
| 383 |
+
self.set("preferences", key, preferences)
|
| 384 |
+
return key
|
| 385 |
+
|
| 386 |
+
def get_user_preferences(self, user_id: str) -> Optional[Dict[str, Any]]:
|
| 387 |
+
"""Get cached user preferences."""
|
| 388 |
+
key = self._generate_key("user_prefs", user_id)
|
| 389 |
+
return self.get("preferences", key)
|
| 390 |
+
|
| 391 |
+
def cache_embeddings(
|
| 392 |
+
self,
|
| 393 |
+
text_hash: str,
|
| 394 |
+
embeddings: List[float]
|
| 395 |
+
) -> str:
|
| 396 |
+
"""Cache text embeddings."""
|
| 397 |
+
self.set("embeddings", text_hash, embeddings, ttl=30 * 24 * 3600)
|
| 398 |
+
return text_hash
|
| 399 |
+
|
| 400 |
+
def get_embeddings(self, text_hash: str) -> Optional[List[float]]:
|
| 401 |
+
"""Get cached text embeddings."""
|
| 402 |
+
return self.get("embeddings", text_hash)
|
| 403 |
+
|
| 404 |
+
def close(self) -> None:
|
| 405 |
+
"""Close all cache connections."""
|
| 406 |
+
for cache_name, cache in self.caches.items():
|
| 407 |
+
try:
|
| 408 |
+
cache.close()
|
| 409 |
+
logger.debug("Cache closed", cache_name=cache_name)
|
| 410 |
+
except Exception as e:
|
| 411 |
+
logger.error(
|
| 412 |
+
"Failed to close cache",
|
| 413 |
+
cache_name=cache_name,
|
| 414 |
+
error=str(e)
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
# Global cache instance
|
| 419 |
+
_cache_manager = None
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
def get_cache_manager() -> CacheManager:
|
| 423 |
+
"""Get global cache manager instance."""
|
| 424 |
+
global _cache_manager
|
| 425 |
+
if _cache_manager is None:
|
| 426 |
+
cache_dir = os.getenv("CACHE_DIR", "data/cache")
|
| 427 |
+
_cache_manager = CacheManager(cache_dir)
|
| 428 |
+
return _cache_manager
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def close_cache_manager() -> None:
|
| 432 |
+
"""Close global cache manager."""
|
| 433 |
+
global _cache_manager
|
| 434 |
+
if _cache_manager:
|
| 435 |
+
_cache_manager.close()
|
| 436 |
+
_cache_manager = None
|
src/ui/__init__.py
ADDED
|
File without changes
|
tests/__init__.py
ADDED
|
File without changes
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|