push all
Browse files- .gitignore +3 -0
- Dockerfile +51 -0
- README.md +260 -1
- System Overview.md +43 -0
- app/config.py +27 -0
- app/db.py +26 -0
- app/main.py +421 -0
- app/models.py +56 -0
- app/mood_face_model.py +62 -0
- app/mood_text_model.py +47 -0
- app/spotify_client.py +102 -0
- app/spotify_http.py +138 -0
- app/spotify_ids.py +74 -0
- app/spotify_oauth.py +160 -0
- app/spotify_playlists_api.py +88 -0
- app/spotify_user_api.py +14 -0
- requirements.txt +12 -0
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
.cache
|
Dockerfile
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Base Image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
ENV DEBIAN_FRONTEND=noninteractive \
|
| 5 |
+
PYTHONUNBUFFERED=1 \
|
| 6 |
+
PYTHONDONTWRITEBYTECODE=1
|
| 7 |
+
|
| 8 |
+
WORKDIR /code
|
| 9 |
+
|
| 10 |
+
# System Dependencies
|
| 11 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 12 |
+
build-essential \
|
| 13 |
+
git \
|
| 14 |
+
curl \
|
| 15 |
+
libopenblas-dev \
|
| 16 |
+
libomp-dev \
|
| 17 |
+
libpq-dev \
|
| 18 |
+
libgl1 \
|
| 19 |
+
libglib2.0-0 \
|
| 20 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 21 |
+
|
| 22 |
+
COPY requirements.txt .
|
| 23 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 24 |
+
|
| 25 |
+
# Hugging Face + model tools (extra, if needed)
|
| 26 |
+
RUN pip install --no-cache-dir huggingface-hub sentencepiece accelerate fasttext
|
| 27 |
+
|
| 28 |
+
# Hugging Face cache environment
|
| 29 |
+
ENV HF_HOME=/models/huggingface \
|
| 30 |
+
TRANSFORMERS_CACHE=/models/huggingface \
|
| 31 |
+
HUGGINGFACE_HUB_CACHE=/models/huggingface \
|
| 32 |
+
HF_HUB_CACHE=/models/huggingface
|
| 33 |
+
|
| 34 |
+
# Create cache dir and set permissions
|
| 35 |
+
RUN mkdir -p /models/huggingface && chmod -R 777 /models/huggingface
|
| 36 |
+
|
| 37 |
+
# Copy project files
|
| 38 |
+
COPY . .
|
| 39 |
+
|
| 40 |
+
# Expose FastAPI port (Hugging Face Spaces expects 7860)
|
| 41 |
+
EXPOSE 7860
|
| 42 |
+
|
| 43 |
+
# CPU-related env tuning
|
| 44 |
+
ENV OMP_NUM_THREADS=4 \
|
| 45 |
+
MKL_NUM_THREADS=4 \
|
| 46 |
+
NUMEXPR_NUM_THREADS=4
|
| 47 |
+
|
| 48 |
+
# Run FastAPI app with uvicorn
|
| 49 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1", "--timeout-keep-alive", "30"]
|
| 50 |
+
|
| 51 |
+
|
README.md
CHANGED
|
@@ -7,4 +7,263 @@ sdk: docker
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
## Vicoka – Mood → Spotify Song Recommender (Python)
|
| 11 |
+
|
| 12 |
+
Backend prototype that:
|
| 13 |
+
- **Detects mood** from **text** (Hugging Face `transformers`) or **face image**
|
| 14 |
+
- **Maps mood → music features**
|
| 15 |
+
- **Fetches a recommended song** using the **Spotify Web API**
|
| 16 |
+
- (Optionally) **logs interactions** in **PostgreSQL**
|
| 17 |
+
|
| 18 |
+
### Spotify API (access token) — what you need to know
|
| 19 |
+
|
| 20 |
+
There are two common auth flows:
|
| 21 |
+
- **Client Credentials (app-only)**: best for server-side calls to public Spotify data (search, recommendations, track metadata).
|
| 22 |
+
This is what our backend uses via `spotipy` (it requests/refreshes the 1‑hour access token automatically).
|
| 23 |
+
- **Authorization Code (user login)**: required if you need private user data (profile email, private playlists, saving tracks, etc.).
|
| 24 |
+
|
| 25 |
+
### Set Spotify credentials
|
| 26 |
+
|
| 27 |
+
1. Create an app in the Spotify Developer Dashboard and copy:
|
| 28 |
+
- `Client ID`
|
| 29 |
+
- `Client Secret`
|
| 30 |
+
2. Set environment variables (local or on Hugging Face Space “Secrets”):
|
| 31 |
+
- `SPOTIFY_CLIENT_ID`
|
| 32 |
+
- `SPOTIFY_CLIENT_SECRET`
|
| 33 |
+
- `SPOTIFY_REDIRECT_URI` (needed for user login / `/v1/me`)
|
| 34 |
+
|
| 35 |
+
### Spotify Dashboard app setup (Redirect URIs)
|
| 36 |
+
|
| 37 |
+
In the Spotify Developer Dashboard:
|
| 38 |
+
- Create your app (name/description) and accept the terms.
|
| 39 |
+
- Go to **Edit Settings**.
|
| 40 |
+
- Add a value under **Redirect URIs** that matches your backend callback exactly.
|
| 41 |
+
|
| 42 |
+
For this project the callback endpoint is:
|
| 43 |
+
- `GET /auth/spotify/callback`
|
| 44 |
+
|
| 45 |
+
Spotify redirect URI rules (enforced for newer apps):
|
| 46 |
+
- **Do not use `localhost`** (Spotify blocks it). Use `127.0.0.1` or `[::1]` for local development.
|
| 47 |
+
- **Use HTTPS** for non-loopback redirect URIs (e.g. Hugging Face Space URLs).
|
| 48 |
+
|
| 49 |
+
So your Redirect URI should look like one of these examples:
|
| 50 |
+
- **Local dev**: `http://127.0.0.1:8000/auth/spotify/callback`
|
| 51 |
+
- **Local dev (IPv6)**: `http://[::1]:8000/auth/spotify/callback`
|
| 52 |
+
- **Hugging Face Space**: `https://<your-space-subdomain>.hf.space/auth/spotify/callback`
|
| 53 |
+
|
| 54 |
+
Important:
|
| 55 |
+
- The Redirect URI must match **exactly** (scheme/host/port/path).
|
| 56 |
+
- **Never expose your Client Secret** publicly; store it in Space **Secrets**.
|
| 57 |
+
|
| 58 |
+
### (Optional) Request a token manually (curl)
|
| 59 |
+
|
| 60 |
+
This is the same “Client Credentials” token request you pasted:
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
curl -X POST "https://accounts.spotify.com/api/token" \
|
| 64 |
+
-H "Content-Type: application/x-www-form-urlencoded" \
|
| 65 |
+
-d "grant_type=client_credentials&client_id=$SPOTIFY_CLIENT_ID&client_secret=$SPOTIFY_CLIENT_SECRET"
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
### Using `Authorization: Bearer <token>` (examples)
|
| 69 |
+
|
| 70 |
+
- Call Spotify **directly** (track endpoint):
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
ACCESS_TOKEN="your_access_token_here"
|
| 74 |
+
curl --request GET \
|
| 75 |
+
"https://api.spotify.com/v1/tracks/2TpxZ7JUBn3uw46aR7qd6V" \
|
| 76 |
+
--header "Authorization: Bearer $ACCESS_TOKEN"
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
- Call our backend **track proxy demo** (uses app-only token server-side):
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
curl "http://127.0.0.1:8000/spotify/tracks/2TpxZ7JUBn3uw46aR7qd6V"
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
- Call Spotify **current user profile** (`/v1/me`):
|
| 86 |
+
- **Requires Authorization Code flow user token** (Client Credentials will not work)
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
USER_ACCESS_TOKEN="your_user_access_token_here"
|
| 90 |
+
curl "https://api.spotify.com/v1/me" \
|
| 91 |
+
--header "Authorization: Bearer $USER_ACCESS_TOKEN"
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
- Call our backend `/spotify/me` (passes your Bearer token through):
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
USER_ACCESS_TOKEN="your_user_access_token_here"
|
| 98 |
+
curl "http://127.0.0.1:8000/spotify/me" \
|
| 99 |
+
--header "Authorization: Bearer $USER_ACCESS_TOKEN"
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
### Run locally
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
pip install -r requirements.txt
|
| 106 |
+
export SPOTIFY_CLIENT_ID="..."
|
| 107 |
+
export SPOTIFY_CLIENT_SECRET="..."
|
| 108 |
+
export DATABASE_URL="postgresql+psycopg2://user:password@localhost:5432/vicoka"
|
| 109 |
+
uvicorn app.main:app --reload
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### Test the API
|
| 113 |
+
|
| 114 |
+
- **Text mood → song**:
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
curl -X POST "http://127.0.0.1:8000/mood/text" \
|
| 118 |
+
-H "Content-Type: application/json" \
|
| 119 |
+
-d '{"text":"I feel calm and focused"}'
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
- **Face image → song**:
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
curl -X POST "http://127.0.0.1:8000/mood/face" \
|
| 126 |
+
-F "file=@/path/to/face.jpg"
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
### User login (Authorization Code flow) for private endpoints (like `/v1/me`)
|
| 130 |
+
|
| 131 |
+
1. Visit (in browser) to login and consent:
|
| 132 |
+
|
| 133 |
+
`GET /auth/spotify/login`
|
| 134 |
+
|
| 135 |
+
Example:
|
| 136 |
+
|
| 137 |
+
`http://127.0.0.1:8000/auth/spotify/login`
|
| 138 |
+
|
| 139 |
+
2. After Spotify redirects back, your backend will exchange the `code` and return JSON:
|
| 140 |
+
- `access_token` (use as `Authorization: Bearer ...`)
|
| 141 |
+
- `refresh_token` (store securely; used to refresh access later)
|
| 142 |
+
- `expires_in` (seconds)
|
| 143 |
+
|
| 144 |
+
To request additional permissions, pass scopes:
|
| 145 |
+
|
| 146 |
+
`/auth/spotify/login?scope=user-read-email%20user-read-private%20playlist-read-private%20playlist-modify-private`
|
| 147 |
+
|
| 148 |
+
### Playlists (user token + scopes)
|
| 149 |
+
|
| 150 |
+
Playlist endpoints require a **user access token** (Authorization Code flow) and the right **scopes**:
|
| 151 |
+
- **Read playlists**:
|
| 152 |
+
- `playlist-read-private` (to include private playlists)
|
| 153 |
+
- `playlist-read-collaborative` (to include collaborative playlists)
|
| 154 |
+
- **Create / modify playlists**:
|
| 155 |
+
- `playlist-modify-public` (if `public=true`)
|
| 156 |
+
- `playlist-modify-private` (if `public=false`)
|
| 157 |
+
|
| 158 |
+
Backend endpoints added:
|
| 159 |
+
- `GET /spotify/playlists` (current user’s playlists)
|
| 160 |
+
- `GET /spotify/playlists/{playlist_id}` (playlist details)
|
| 161 |
+
- `GET /spotify/playlists/{playlist_id}/items` (playlist items, paginated)
|
| 162 |
+
- `POST /spotify/playlists` (create playlist)
|
| 163 |
+
- `POST /spotify/playlists/{playlist_id}/items` (add tracks by URI)
|
| 164 |
+
|
| 165 |
+
Examples (after you have `USER_ACCESS_TOKEN`):
|
| 166 |
+
|
| 167 |
+
```bash
|
| 168 |
+
curl "http://127.0.0.1:8000/spotify/playlists?limit=20&offset=0" \
|
| 169 |
+
--header "Authorization: Bearer $USER_ACCESS_TOKEN"
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
```bash
|
| 173 |
+
curl -X POST "http://127.0.0.1:8000/spotify/playlists" \
|
| 174 |
+
--header "Authorization: Bearer $USER_ACCESS_TOKEN" \
|
| 175 |
+
-H "Content-Type: application/json" \
|
| 176 |
+
-d '{"user_id":"YOUR_SPOTIFY_USER_ID","name":"Vicoka – Happy Mix","public":false,"description":"Created by Vicoka"}'
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
```bash
|
| 180 |
+
curl -X POST "http://127.0.0.1:8000/spotify/playlists/PLAYLIST_ID/items" \
|
| 181 |
+
--header "Authorization: Bearer $USER_ACCESS_TOKEN" \
|
| 182 |
+
-H "Content-Type: application/json" \
|
| 183 |
+
-d '{"uris":["spotify:track:2TpxZ7JUBn3uw46aR7qd6V"]}'
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
### Spotify URIs vs IDs vs URLs (quick guide)
|
| 187 |
+
|
| 188 |
+
- **Spotify URI**: identifies a resource *and* its type
|
| 189 |
+
Example: `spotify:track:6rqhFgbbKwnb9MLmUQDhG6`
|
| 190 |
+
- **Spotify ID**: the base62 string at the end (often 22 chars)
|
| 191 |
+
Example: `6rqhFgbbKwnb9MLmUQDhG6`
|
| 192 |
+
- **Spotify URL**: web link that opens the client
|
| 193 |
+
Example: `https://open.spotify.com/track/6rqhFgbbKwnb9MLmUQDhG6`
|
| 194 |
+
|
| 195 |
+
In this backend:
|
| 196 |
+
- `POST /spotify/playlists/{playlist_id}/items` accepts **track references** in any of these forms:
|
| 197 |
+
- `spotify:track:<id>` (URI)
|
| 198 |
+
- `https://open.spotify.com/track/<id>` (URL)
|
| 199 |
+
- `<id>` (raw 22-char ID)
|
| 200 |
+
and normalizes them into track URIs before calling Spotify.
|
| 201 |
+
|
| 202 |
+
Debug helper:
|
| 203 |
+
- `GET /spotify/parse?value=...` returns the parsed `{resource_type, id, uri, url}`.
|
| 204 |
+
|
| 205 |
+
### Track relinking (market availability)
|
| 206 |
+
|
| 207 |
+
Track availability varies by country. Spotify can “relink” a track to another catalog instance that **is playable** in a given market.
|
| 208 |
+
|
| 209 |
+
How to use it:
|
| 210 |
+
- Pass a `market` query parameter (ISO country code like `US`, `GB`, `IN`) to track/playlist-item endpoints.
|
| 211 |
+
- For **user tokens**, you can also use `market=from_token` to use the user’s country.
|
| 212 |
+
|
| 213 |
+
Supported in this backend:
|
| 214 |
+
- `GET /spotify/tracks/{track_id}?market=US`
|
| 215 |
+
- `GET /spotify/playlists/{playlist_id}/items?market=from_token`
|
| 216 |
+
|
| 217 |
+
What changes in responses when `market` is supplied:
|
| 218 |
+
- Spotify may include **`is_playable`**
|
| 219 |
+
- If relinked, Spotify may include **`linked_from`** (the originally requested track)
|
| 220 |
+
- If no relink is possible, you may see **`restrictions`** with reason `"market"`
|
| 221 |
+
|
| 222 |
+
Important:
|
| 223 |
+
- If you later do operations that must target the **original track**, use the ID/URI inside **`linked_from`** (Spotify can reject using the relinked root ID for some operations).
|
| 224 |
+
|
| 225 |
+
### Notes for Hugging Face Spaces (Docker)
|
| 226 |
+
|
| 227 |
+
- Your container must listen on **port 7860**.
|
| 228 |
+
- Put `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` in the Space **Settings → Secrets** (don’t hardcode them).
|
| 229 |
+
|
| 230 |
+
More info: [Spaces config reference](https://huggingface.co/docs/hub/spaces-config-reference)
|
| 231 |
+
|
| 232 |
+
### Spotify Web API responses (status codes)
|
| 233 |
+
|
| 234 |
+
Spotify endpoints can return common HTTP codes like:
|
| 235 |
+
- **200/201/204**: success (204 can be “no content”)
|
| 236 |
+
- **400**: bad request (often includes a JSON error message)
|
| 237 |
+
- **401/403**: unauthorized/forbidden (token missing/invalid or insufficient permissions)
|
| 238 |
+
- **404**: not found
|
| 239 |
+
- **429**: rate limited (**Retry-After** header tells you how many seconds to wait)
|
| 240 |
+
|
| 241 |
+
In this project, our `/spotify/*` demo endpoints **propagate Spotify’s status code** and include a structured JSON `detail` similar to Spotify’s own error object. If Spotify returns **429**, we also forward the **Retry-After** header.
|
| 242 |
+
|
| 243 |
+
### Spotify quota modes (Development vs Extended)
|
| 244 |
+
|
| 245 |
+
Spotify apps can run in two quota modes:
|
| 246 |
+
- **Development mode**:
|
| 247 |
+
- Intended for building/testing and small demos
|
| 248 |
+
- Only a small number of Spotify users can use your app unless they’re **allowlisted**
|
| 249 |
+
- If a user is not allowlisted, Spotify may return **403 Forbidden** for that user’s token
|
| 250 |
+
- **Extended quota mode**:
|
| 251 |
+
- For production-scale apps with higher limits and a broader audience
|
| 252 |
+
|
| 253 |
+
Practical tips for this project:
|
| 254 |
+
- **If your friend/classmate gets a 403** after logging in, you likely need to add them in the Spotify Developer Dashboard under **Users and Access / User Management** for your app (development mode allowlist).
|
| 255 |
+
- **Premium requirement**: most **Web API** endpoints (profile, playlists, recommendations, search) do not require Premium, but **playback/streaming features** (player controls, actual streaming) typically require Premium and additional scopes.
|
| 256 |
+
|
| 257 |
+
### Rate limits (429 Too Many Requests)
|
| 258 |
+
|
| 259 |
+
If you exceed Spotify’s rate limit in a rolling window, Spotify responds with:
|
| 260 |
+
- **429** and usually a **`Retry-After`** header (seconds to wait)
|
| 261 |
+
|
| 262 |
+
This backend handles that by:
|
| 263 |
+
- **Auto-retrying** Spotify requests a small number of times
|
| 264 |
+
- **Sleeping for `Retry-After` seconds** when Spotify returns 429 (then retrying)
|
| 265 |
+
- Also retrying some transient upstream errors (e.g. 502/503) with short exponential backoff
|
| 266 |
+
|
| 267 |
+
App-side tips:
|
| 268 |
+
- Prefer fewer calls (lazy load playlists/items)
|
| 269 |
+
- Use batch endpoints when available (e.g. “Get Multiple …”)
|
System Overview.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Vicoka – Mood-based Spotify Song Recommender
|
| 2 |
+
|
| 3 |
+
Backend prototype for an AI agent that:
|
| 4 |
+
- **Takes mood as input** via text or facial scan
|
| 5 |
+
- **Infers emotion** using Hugging Face models
|
| 6 |
+
- **Maps emotion → music** and fetches a suitable track from the **Spotify Web API**
|
| 7 |
+
- (Optionally) **logs interactions** in PostgreSQL.
|
| 8 |
+
|
| 9 |
+
### Tech stack
|
| 10 |
+
- **Language**: Python
|
| 11 |
+
- **Web framework**: FastAPI
|
| 12 |
+
- **AI models**: Hugging Face `transformers` (text & image pipelines)
|
| 13 |
+
- **Database**: PostgreSQL (via SQLAlchemy)
|
| 14 |
+
- **Music**: Spotify Web API (`spotipy`)
|
| 15 |
+
|
| 16 |
+
### Quick start
|
| 17 |
+
1. Create and activate a virtualenv.
|
| 18 |
+
2. Install dependencies:
|
| 19 |
+
```bash
|
| 20 |
+
pip install -r requirements.txt
|
| 21 |
+
```
|
| 22 |
+
3. Set environment variables (or a `.env` file):
|
| 23 |
+
- `DATABASE_URL` – e.g. `postgresql+psycopg2://user:password@localhost:5432/vicoka`
|
| 24 |
+
- `SPOTIFY_CLIENT_ID`
|
| 25 |
+
- `SPOTIFY_CLIENT_SECRET`
|
| 26 |
+
- `SPOTIFY_REDIRECT_URI` (for OAuth, if you later build a full user-auth flow)
|
| 27 |
+
4. Run the API:
|
| 28 |
+
```bash
|
| 29 |
+
uvicorn app.main:app --reload
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
### High-level flow
|
| 33 |
+
1. Client sends **text** or **image** to backend.
|
| 34 |
+
2. Backend runs Hugging Face **text** or **image** emotion pipeline.
|
| 35 |
+
3. Emotion label (e.g., `joy`, `sadness`, `anger`, `calm`) is mapped to Spotify **search parameters**.
|
| 36 |
+
4. Backend queries Spotify and returns a **recommended track** (or playlist).
|
| 37 |
+
|
| 38 |
+
### Next steps
|
| 39 |
+
- Implement a simple frontend (web or mobile) to capture text and camera input.
|
| 40 |
+
- Tune emotion → Spotify mapping based on user feedback.
|
| 41 |
+
- Add authentication and per-user history in PostgreSQL.
|
| 42 |
+
|
| 43 |
+
|
app/config.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Settings:
|
| 10 |
+
"""Application settings loaded from environment."""
|
| 11 |
+
|
| 12 |
+
database_url: str = os.getenv(
|
| 13 |
+
"DATABASE_URL",
|
| 14 |
+
"postgresql+psycopg2://postgres:postgres@localhost:5432/vicoka",
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
spotify_client_id: str | None = os.getenv("SPOTIFY_CLIENT_ID")
|
| 18 |
+
spotify_client_secret: str | None = os.getenv("SPOTIFY_CLIENT_SECRET")
|
| 19 |
+
spotify_redirect_uri: str | None = os.getenv("SPOTIFY_REDIRECT_URI")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@lru_cache
|
| 23 |
+
def get_settings() -> Settings:
|
| 24 |
+
return Settings()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
|
app/db.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
| 3 |
+
|
| 4 |
+
from .config import get_settings
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Base(DeclarativeBase):
|
| 8 |
+
pass
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
settings = get_settings()
|
| 12 |
+
engine = create_engine(settings.database_url, echo=False, future=True)
|
| 13 |
+
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_db():
|
| 17 |
+
from sqlalchemy.orm import Session
|
| 18 |
+
|
| 19 |
+
db: Session = SessionLocal()
|
| 20 |
+
try:
|
| 21 |
+
yield db
|
| 22 |
+
finally:
|
| 23 |
+
db.close()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
|
app/main.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from io import BytesIO
|
| 2 |
+
|
| 3 |
+
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile, Header
|
| 4 |
+
from fastapi.responses import RedirectResponse
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from PIL import Image
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from .db import Base, engine, get_db
|
| 11 |
+
from .models import MoodLog, RecommendationLog
|
| 12 |
+
from .mood_text_model import analyze_text_emotion
|
| 13 |
+
from .mood_face_model import analyze_face_emotion
|
| 14 |
+
from .spotify_client import recommend_track_for_emotion, get_track_via_bearer
|
| 15 |
+
from .spotify_user_api import get_current_user_profile
|
| 16 |
+
from .spotify_http import SpotifyAPIError
|
| 17 |
+
from .spotify_oauth import (
|
| 18 |
+
build_authorize_url,
|
| 19 |
+
default_redirect_uri,
|
| 20 |
+
exchange_code_for_token,
|
| 21 |
+
generate_state,
|
| 22 |
+
validate_redirect_uri,
|
| 23 |
+
)
|
| 24 |
+
from .spotify_playlists_api import (
|
| 25 |
+
add_items_to_playlist,
|
| 26 |
+
create_playlist,
|
| 27 |
+
get_playlist,
|
| 28 |
+
get_playlist_items,
|
| 29 |
+
list_current_user_playlists,
|
| 30 |
+
)
|
| 31 |
+
from .spotify_ids import parse_spotify_ref, to_track_uri
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
Base.metadata.create_all(bind=engine)
|
| 35 |
+
|
| 36 |
+
app = FastAPI(title="Vicoka – Mood-based Spotify Recommender")
|
| 37 |
+
|
| 38 |
+
app.add_middleware(
|
| 39 |
+
CORSMiddleware,
|
| 40 |
+
allow_origins=["*"],
|
| 41 |
+
allow_credentials=True,
|
| 42 |
+
allow_methods=["*"],
|
| 43 |
+
allow_headers=["*"],
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class TextMoodRequest(BaseModel):
|
| 48 |
+
text: str
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class RecommendationResponse(BaseModel):
|
| 52 |
+
mood_label: str
|
| 53 |
+
mood_score: float
|
| 54 |
+
spotify_track_id: str | None
|
| 55 |
+
track_name: str | None
|
| 56 |
+
artists: list[str] | None
|
| 57 |
+
preview_url: str | None
|
| 58 |
+
external_url: str | None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _extract_bearer_token(authorization: str | None) -> str:
|
| 62 |
+
if not authorization:
|
| 63 |
+
raise HTTPException(
|
| 64 |
+
status_code=401,
|
| 65 |
+
detail="Missing Authorization header. Use: Authorization: Bearer <access_token>",
|
| 66 |
+
)
|
| 67 |
+
if not authorization.lower().startswith("bearer "):
|
| 68 |
+
raise HTTPException(
|
| 69 |
+
status_code=401,
|
| 70 |
+
detail="Invalid Authorization header format. Use: Bearer <access_token>",
|
| 71 |
+
)
|
| 72 |
+
return authorization.split(" ", 1)[1].strip()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@app.post("/mood/text", response_model=RecommendationResponse)
|
| 76 |
+
def mood_from_text(
|
| 77 |
+
body: TextMoodRequest, db: Session = Depends(get_db)
|
| 78 |
+
) -> RecommendationResponse:
|
| 79 |
+
if not body.text.strip():
|
| 80 |
+
raise HTTPException(status_code=400, detail="Text cannot be empty")
|
| 81 |
+
|
| 82 |
+
label, score = analyze_text_emotion(body.text)
|
| 83 |
+
track = recommend_track_for_emotion(label, source="text")
|
| 84 |
+
|
| 85 |
+
mood_log = MoodLog(
|
| 86 |
+
source="text",
|
| 87 |
+
raw_input=body.text[:512],
|
| 88 |
+
emotion_label=label,
|
| 89 |
+
emotion_score=score,
|
| 90 |
+
)
|
| 91 |
+
db.add(mood_log)
|
| 92 |
+
db.flush()
|
| 93 |
+
|
| 94 |
+
rec_log = None
|
| 95 |
+
track_id = None
|
| 96 |
+
if track:
|
| 97 |
+
track_id = track["id"]
|
| 98 |
+
rec_log = RecommendationLog(
|
| 99 |
+
user_id=None,
|
| 100 |
+
mood_id=mood_log.id,
|
| 101 |
+
spotify_track_id=track_id,
|
| 102 |
+
)
|
| 103 |
+
db.add(rec_log)
|
| 104 |
+
|
| 105 |
+
db.commit()
|
| 106 |
+
|
| 107 |
+
return RecommendationResponse(
|
| 108 |
+
mood_label=label,
|
| 109 |
+
mood_score=score,
|
| 110 |
+
spotify_track_id=track_id,
|
| 111 |
+
track_name=track.get("name") if track else None,
|
| 112 |
+
artists=track.get("artists") if track else None,
|
| 113 |
+
preview_url=track.get("preview_url") if track else None,
|
| 114 |
+
external_url=track.get("external_url") if track else None,
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@app.post("/mood/face", response_model=RecommendationResponse)
|
| 119 |
+
async def mood_from_face(
|
| 120 |
+
file: UploadFile = File(...), db: Session = Depends(get_db)
|
| 121 |
+
) -> RecommendationResponse:
|
| 122 |
+
contents = await file.read()
|
| 123 |
+
try:
|
| 124 |
+
image = Image.open(BytesIO(contents)).convert("RGB")
|
| 125 |
+
except Exception as exc:
|
| 126 |
+
raise HTTPException(status_code=400, detail="Invalid image file") from exc
|
| 127 |
+
|
| 128 |
+
label, score = analyze_face_emotion(image)
|
| 129 |
+
track = recommend_track_for_emotion(label, source="face")
|
| 130 |
+
|
| 131 |
+
mood_log = MoodLog(
|
| 132 |
+
source="face",
|
| 133 |
+
raw_input=None,
|
| 134 |
+
emotion_label=label,
|
| 135 |
+
emotion_score=score,
|
| 136 |
+
)
|
| 137 |
+
db.add(mood_log)
|
| 138 |
+
db.flush()
|
| 139 |
+
|
| 140 |
+
rec_log = None
|
| 141 |
+
track_id = None
|
| 142 |
+
if track:
|
| 143 |
+
track_id = track["id"]
|
| 144 |
+
rec_log = RecommendationLog(
|
| 145 |
+
user_id=None,
|
| 146 |
+
mood_id=mood_log.id,
|
| 147 |
+
spotify_track_id=track_id,
|
| 148 |
+
)
|
| 149 |
+
db.add(rec_log)
|
| 150 |
+
|
| 151 |
+
db.commit()
|
| 152 |
+
|
| 153 |
+
return RecommendationResponse(
|
| 154 |
+
mood_label=label,
|
| 155 |
+
mood_score=score,
|
| 156 |
+
spotify_track_id=track_id,
|
| 157 |
+
track_name=track.get("name") if track else None,
|
| 158 |
+
artists=track.get("artists") if track else None,
|
| 159 |
+
preview_url=track.get("preview_url") if track else None,
|
| 160 |
+
external_url=track.get("external_url") if track else None,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@app.get("/health")
|
| 165 |
+
def health() -> dict:
|
| 166 |
+
return {"status": "ok"}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@app.get("/spotify/tracks/{track_id}")
|
| 170 |
+
def spotify_track(track_id: str, market: str | None = None) -> dict:
|
| 171 |
+
"""
|
| 172 |
+
Demo endpoint: fetches track info using Spotify Web API with
|
| 173 |
+
Authorization: Bearer <token> (app-only Client Credentials token).
|
| 174 |
+
"""
|
| 175 |
+
try:
|
| 176 |
+
return get_track_via_bearer(track_id, market=market)
|
| 177 |
+
except SpotifyAPIError as exc:
|
| 178 |
+
headers = {}
|
| 179 |
+
if exc.retry_after is not None:
|
| 180 |
+
headers["Retry-After"] = str(exc.retry_after)
|
| 181 |
+
raise HTTPException(status_code=exc.status_code, detail=exc.to_dict(), headers=headers) from exc
|
| 182 |
+
except Exception as exc:
|
| 183 |
+
raise HTTPException(status_code=500, detail="Unexpected server error") from exc
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@app.get("/spotify/me")
|
| 187 |
+
def spotify_me(authorization: str | None = Header(default=None)) -> dict:
|
| 188 |
+
"""
|
| 189 |
+
Calls Spotify GET /v1/me.
|
| 190 |
+
|
| 191 |
+
IMPORTANT: This requires a USER access token (Authorization Code flow),
|
| 192 |
+
NOT a Client Credentials token.
|
| 193 |
+
|
| 194 |
+
Send header:
|
| 195 |
+
Authorization: Bearer <user_access_token>
|
| 196 |
+
"""
|
| 197 |
+
token = _extract_bearer_token(authorization)
|
| 198 |
+
try:
|
| 199 |
+
return get_current_user_profile(token)
|
| 200 |
+
except SpotifyAPIError as exc:
|
| 201 |
+
headers = {}
|
| 202 |
+
if exc.retry_after is not None:
|
| 203 |
+
headers["Retry-After"] = str(exc.retry_after)
|
| 204 |
+
raise HTTPException(status_code=exc.status_code, detail=exc.to_dict(), headers=headers) from exc
|
| 205 |
+
except Exception as exc:
|
| 206 |
+
raise HTTPException(status_code=500, detail="Unexpected server error") from exc
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@app.get("/spotify/playlists")
|
| 210 |
+
def spotify_my_playlists(
|
| 211 |
+
authorization: str | None = Header(default=None),
|
| 212 |
+
limit: int = 20,
|
| 213 |
+
offset: int = 0,
|
| 214 |
+
) -> dict:
|
| 215 |
+
"""
|
| 216 |
+
List current user's playlists: GET /v1/me/playlists
|
| 217 |
+
Scopes:
|
| 218 |
+
- playlist-read-private (to include private playlists)
|
| 219 |
+
- playlist-read-collaborative (to include collaborative playlists)
|
| 220 |
+
"""
|
| 221 |
+
token = _extract_bearer_token(authorization)
|
| 222 |
+
try:
|
| 223 |
+
return list_current_user_playlists(token, limit=limit, offset=offset)
|
| 224 |
+
except SpotifyAPIError as exc:
|
| 225 |
+
headers = {}
|
| 226 |
+
if exc.retry_after is not None:
|
| 227 |
+
headers["Retry-After"] = str(exc.retry_after)
|
| 228 |
+
raise HTTPException(status_code=exc.status_code, detail=exc.to_dict(), headers=headers) from exc
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@app.get("/spotify/playlists/{playlist_id}")
|
| 232 |
+
def spotify_get_playlist(
|
| 233 |
+
playlist_id: str,
|
| 234 |
+
authorization: str | None = Header(default=None),
|
| 235 |
+
) -> dict:
|
| 236 |
+
token = _extract_bearer_token(authorization)
|
| 237 |
+
try:
|
| 238 |
+
return get_playlist(token, playlist_id)
|
| 239 |
+
except SpotifyAPIError as exc:
|
| 240 |
+
headers = {}
|
| 241 |
+
if exc.retry_after is not None:
|
| 242 |
+
headers["Retry-After"] = str(exc.retry_after)
|
| 243 |
+
raise HTTPException(status_code=exc.status_code, detail=exc.to_dict(), headers=headers) from exc
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@app.get("/spotify/playlists/{playlist_id}/items")
|
| 247 |
+
def spotify_get_playlist_items(
|
| 248 |
+
playlist_id: str,
|
| 249 |
+
authorization: str | None = Header(default=None),
|
| 250 |
+
limit: int = 50,
|
| 251 |
+
offset: int = 0,
|
| 252 |
+
additional_types: str = "track,episode",
|
| 253 |
+
market: str | None = None,
|
| 254 |
+
) -> dict:
|
| 255 |
+
token = _extract_bearer_token(authorization)
|
| 256 |
+
try:
|
| 257 |
+
return get_playlist_items(
|
| 258 |
+
token,
|
| 259 |
+
playlist_id,
|
| 260 |
+
limit=limit,
|
| 261 |
+
offset=offset,
|
| 262 |
+
additional_types=additional_types,
|
| 263 |
+
market=market,
|
| 264 |
+
)
|
| 265 |
+
except SpotifyAPIError as exc:
|
| 266 |
+
headers = {}
|
| 267 |
+
if exc.retry_after is not None:
|
| 268 |
+
headers["Retry-After"] = str(exc.retry_after)
|
| 269 |
+
raise HTTPException(status_code=exc.status_code, detail=exc.to_dict(), headers=headers) from exc
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
class CreatePlaylistRequest(BaseModel):
|
| 273 |
+
user_id: str
|
| 274 |
+
name: str
|
| 275 |
+
description: str | None = None
|
| 276 |
+
public: bool = True
|
| 277 |
+
collaborative: bool = False
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@app.post("/spotify/playlists")
|
| 281 |
+
def spotify_create_playlist(
|
| 282 |
+
body: CreatePlaylistRequest,
|
| 283 |
+
authorization: str | None = Header(default=None),
|
| 284 |
+
) -> dict:
|
| 285 |
+
"""
|
| 286 |
+
Create a playlist for a user: POST /v1/users/{user_id}/playlists
|
| 287 |
+
Scopes:
|
| 288 |
+
- playlist-modify-public (if public=true)
|
| 289 |
+
- playlist-modify-private (if public=false)
|
| 290 |
+
"""
|
| 291 |
+
token = _extract_bearer_token(authorization)
|
| 292 |
+
try:
|
| 293 |
+
return create_playlist(
|
| 294 |
+
token,
|
| 295 |
+
body.user_id,
|
| 296 |
+
name=body.name,
|
| 297 |
+
description=body.description,
|
| 298 |
+
public=body.public,
|
| 299 |
+
collaborative=body.collaborative,
|
| 300 |
+
)
|
| 301 |
+
except ValueError as exc:
|
| 302 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 303 |
+
except SpotifyAPIError as exc:
|
| 304 |
+
headers = {}
|
| 305 |
+
if exc.retry_after is not None:
|
| 306 |
+
headers["Retry-After"] = str(exc.retry_after)
|
| 307 |
+
raise HTTPException(status_code=exc.status_code, detail=exc.to_dict(), headers=headers) from exc
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
class AddItemsRequest(BaseModel):
|
| 311 |
+
uris: list[str]
|
| 312 |
+
position: int | None = None
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
@app.post("/spotify/playlists/{playlist_id}/items")
|
| 316 |
+
def spotify_add_items(
|
| 317 |
+
playlist_id: str,
|
| 318 |
+
body: AddItemsRequest,
|
| 319 |
+
authorization: str | None = Header(default=None),
|
| 320 |
+
) -> dict:
|
| 321 |
+
"""
|
| 322 |
+
Add items to a playlist: POST /v1/playlists/{playlist_id}/tracks
|
| 323 |
+
Scopes:
|
| 324 |
+
- playlist-modify-public or playlist-modify-private (depending on playlist visibility)
|
| 325 |
+
"""
|
| 326 |
+
token = _extract_bearer_token(authorization)
|
| 327 |
+
try:
|
| 328 |
+
uris = [to_track_uri(u) for u in body.uris]
|
| 329 |
+
return add_items_to_playlist(
|
| 330 |
+
token,
|
| 331 |
+
playlist_id,
|
| 332 |
+
uris=uris,
|
| 333 |
+
position=body.position,
|
| 334 |
+
)
|
| 335 |
+
except SpotifyAPIError as exc:
|
| 336 |
+
headers = {}
|
| 337 |
+
if exc.retry_after is not None:
|
| 338 |
+
headers["Retry-After"] = str(exc.retry_after)
|
| 339 |
+
raise HTTPException(status_code=exc.status_code, detail=exc.to_dict(), headers=headers) from exc
|
| 340 |
+
except ValueError as exc:
|
| 341 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@app.get("/spotify/parse")
|
| 345 |
+
def spotify_parse(value: str) -> dict:
|
| 346 |
+
"""
|
| 347 |
+
Debug helper: parse Spotify URI/URL/ID into {type, id, uri, url}.
|
| 348 |
+
"""
|
| 349 |
+
ref = parse_spotify_ref(value)
|
| 350 |
+
return {
|
| 351 |
+
"resource_type": ref.resource_type,
|
| 352 |
+
"id": ref.id,
|
| 353 |
+
"uri": ref.uri,
|
| 354 |
+
"url": ref.url,
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
@app.get("/auth/spotify/login")
|
| 359 |
+
def spotify_login(
|
| 360 |
+
scope: str = "user-read-email user-read-private",
|
| 361 |
+
redirect_uri: str | None = None,
|
| 362 |
+
) -> RedirectResponse:
|
| 363 |
+
"""
|
| 364 |
+
Redirect user to Spotify consent screen (Authorization Code flow).
|
| 365 |
+
|
| 366 |
+
IMPORTANT:
|
| 367 |
+
- The redirect_uri MUST exactly match one of the Redirect URIs configured in
|
| 368 |
+
your Spotify Developer Dashboard app settings.
|
| 369 |
+
- If redirect_uri is omitted, we use SPOTIFY_REDIRECT_URI from env.
|
| 370 |
+
"""
|
| 371 |
+
ru = redirect_uri or default_redirect_uri()
|
| 372 |
+
if not ru:
|
| 373 |
+
raise HTTPException(
|
| 374 |
+
status_code=500,
|
| 375 |
+
detail="Missing redirect URI. Set SPOTIFY_REDIRECT_URI or pass ?redirect_uri=",
|
| 376 |
+
)
|
| 377 |
+
try:
|
| 378 |
+
validate_redirect_uri(ru)
|
| 379 |
+
except ValueError as exc:
|
| 380 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 381 |
+
state = generate_state()
|
| 382 |
+
url = build_authorize_url(redirect_uri=ru, scope=scope, state=state)
|
| 383 |
+
return RedirectResponse(url=url, status_code=302)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
@app.get("/auth/spotify/callback")
|
| 387 |
+
def spotify_callback(
|
| 388 |
+
code: str | None = None,
|
| 389 |
+
state: str | None = None,
|
| 390 |
+
error: str | None = None,
|
| 391 |
+
redirect_uri: str | None = None,
|
| 392 |
+
) -> dict:
|
| 393 |
+
"""
|
| 394 |
+
Spotify redirects here after login. Exchange `code` for access token.
|
| 395 |
+
|
| 396 |
+
For production:
|
| 397 |
+
- Validate `state` (CSRF protection) using a server-side session store.
|
| 398 |
+
- Store refresh token securely (e.g., Postgres) for long-lived sessions.
|
| 399 |
+
"""
|
| 400 |
+
if error:
|
| 401 |
+
raise HTTPException(status_code=400, detail={"error": error, "state": state})
|
| 402 |
+
if not code:
|
| 403 |
+
raise HTTPException(status_code=400, detail="Missing `code` query parameter")
|
| 404 |
+
|
| 405 |
+
ru = redirect_uri or default_redirect_uri()
|
| 406 |
+
if not ru:
|
| 407 |
+
raise HTTPException(
|
| 408 |
+
status_code=500,
|
| 409 |
+
detail="Missing redirect URI. Set SPOTIFY_REDIRECT_URI or pass ?redirect_uri=",
|
| 410 |
+
)
|
| 411 |
+
try:
|
| 412 |
+
validate_redirect_uri(ru)
|
| 413 |
+
except ValueError as exc:
|
| 414 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 415 |
+
try:
|
| 416 |
+
token = exchange_code_for_token(code=code, redirect_uri=ru)
|
| 417 |
+
return token
|
| 418 |
+
except SpotifyAPIError as exc:
|
| 419 |
+
raise HTTPException(status_code=exc.status_code, detail=exc.to_dict()) from exc
|
| 420 |
+
|
| 421 |
+
|
app/models.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import String, Integer, DateTime, ForeignKey
|
| 5 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 6 |
+
|
| 7 |
+
from .db import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class User(Base):
|
| 11 |
+
__tablename__ = "users"
|
| 12 |
+
|
| 13 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 14 |
+
spotify_user_id: Mapped[Optional[str]] = mapped_column(String(128), unique=True)
|
| 15 |
+
|
| 16 |
+
moods: Mapped[list["MoodLog"]] = relationship(back_populates="user")
|
| 17 |
+
recommendations: Mapped[list["RecommendationLog"]] = relationship(
|
| 18 |
+
back_populates="user"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class MoodLog(Base):
|
| 23 |
+
__tablename__ = "mood_logs"
|
| 24 |
+
|
| 25 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 26 |
+
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"))
|
| 27 |
+
source: Mapped[str] = mapped_column(String(32))
|
| 28 |
+
raw_input: Mapped[Optional[str]] = mapped_column(String(512))
|
| 29 |
+
emotion_label: Mapped[str] = mapped_column(String(64))
|
| 30 |
+
emotion_score: Mapped[float] = mapped_column()
|
| 31 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 32 |
+
DateTime(timezone=True), default=datetime.utcnow
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
user: Mapped[Optional[User]] = relationship(back_populates="moods")
|
| 36 |
+
recommendation: Mapped[Optional["RecommendationLog"]] = relationship(
|
| 37 |
+
back_populates="mood", uselist=False
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class RecommendationLog(Base):
|
| 42 |
+
__tablename__ = "recommendation_logs"
|
| 43 |
+
|
| 44 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
| 45 |
+
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"))
|
| 46 |
+
mood_id: Mapped[int] = mapped_column(ForeignKey("mood_logs.id"))
|
| 47 |
+
spotify_track_id: Mapped[str] = mapped_column(String(64))
|
| 48 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 49 |
+
DateTime(timezone=True), default=datetime.utcnow
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
user: Mapped[Optional[User]] = relationship(back_populates="recommendations")
|
| 53 |
+
mood: Mapped[MoodLog] = relationship(back_populates="recommendation")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
|
app/mood_face_model.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
from typing import Literal
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from transformers import AutoImageProcessor, AutoModelForImageClassification
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
FaceEmotionLabel = Literal[
|
| 10 |
+
"anger",
|
| 11 |
+
"disgust",
|
| 12 |
+
"fear",
|
| 13 |
+
"happiness",
|
| 14 |
+
"neutral",
|
| 15 |
+
"sadness",
|
| 16 |
+
"surprise",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
MODEL_NAME = "dima806/facial_emotions_image_detection"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@lru_cache
|
| 24 |
+
def _get_face_model():
|
| 25 |
+
processor = AutoImageProcessor.from_pretrained(MODEL_NAME)
|
| 26 |
+
model = AutoModelForImageClassification.from_pretrained(MODEL_NAME)
|
| 27 |
+
model.eval()
|
| 28 |
+
return processor, model
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def analyze_face_emotion(image: Image.Image) -> tuple[FaceEmotionLabel, float]:
|
| 32 |
+
"""Return (emotion_label, score) for the given face image."""
|
| 33 |
+
processor, model = _get_face_model()
|
| 34 |
+
|
| 35 |
+
inputs = processor(images=image, return_tensors="pt")
|
| 36 |
+
|
| 37 |
+
with torch.no_grad():
|
| 38 |
+
outputs = model(**inputs)
|
| 39 |
+
logits = outputs.logits
|
| 40 |
+
probs = torch.nn.functional.softmax(logits, dim=-1)[0]
|
| 41 |
+
|
| 42 |
+
id2label = model.config.id2label
|
| 43 |
+
best_id = int(torch.argmax(probs).item())
|
| 44 |
+
best_label = id2label[best_id]
|
| 45 |
+
best_score = float(probs[best_id].item())
|
| 46 |
+
|
| 47 |
+
label = best_label.lower()
|
| 48 |
+
known_labels: set[str] = {
|
| 49 |
+
"anger",
|
| 50 |
+
"disgust",
|
| 51 |
+
"fear",
|
| 52 |
+
"happiness",
|
| 53 |
+
"neutral",
|
| 54 |
+
"sadness",
|
| 55 |
+
"surprise",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
norm_label: FaceEmotionLabel = (label if label in known_labels else "neutral") # type: ignore[assignment]
|
| 59 |
+
return norm_label, best_score
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
|
app/mood_text_model.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
from typing import Literal
|
| 3 |
+
|
| 4 |
+
from transformers import pipeline
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
EmotionLabel = Literal[
|
| 8 |
+
"anger",
|
| 9 |
+
"disgust",
|
| 10 |
+
"fear",
|
| 11 |
+
"joy",
|
| 12 |
+
"neutral",
|
| 13 |
+
"sadness",
|
| 14 |
+
"surprise",
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@lru_cache
|
| 19 |
+
def _get_pipeline():
|
| 20 |
+
return pipeline(
|
| 21 |
+
"text-classification",
|
| 22 |
+
model="j-hartmann/emotion-english-distilroberta-base",
|
| 23 |
+
top_k=None,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def analyze_text_emotion(text: str) -> tuple[EmotionLabel, float]:
|
| 28 |
+
"""Return (emotion_label, score) for the given text."""
|
| 29 |
+
clf = _get_pipeline()
|
| 30 |
+
outputs = clf(text)[0]
|
| 31 |
+
best = max(outputs, key=lambda x: x["score"])
|
| 32 |
+
label = best["label"].lower()
|
| 33 |
+
score = float(best["score"])
|
| 34 |
+
known_labels: set[str] = {
|
| 35 |
+
"anger",
|
| 36 |
+
"disgust",
|
| 37 |
+
"fear",
|
| 38 |
+
"joy",
|
| 39 |
+
"neutral",
|
| 40 |
+
"sadness",
|
| 41 |
+
"surprise",
|
| 42 |
+
}
|
| 43 |
+
norm_label: EmotionLabel = (label if label in known_labels else "neutral") # type: ignore[assignment]
|
| 44 |
+
return norm_label, score
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
|
app/spotify_client.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, Optional
|
| 2 |
+
|
| 3 |
+
import spotipy
|
| 4 |
+
from spotipy.oauth2 import SpotifyClientCredentials
|
| 5 |
+
|
| 6 |
+
from .config import get_settings
|
| 7 |
+
from .spotify_http import spotify_get
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
settings = get_settings()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _get_spotify_client() -> spotipy.Spotify:
|
| 14 |
+
if not settings.spotify_client_id or not settings.spotify_client_secret:
|
| 15 |
+
raise RuntimeError(
|
| 16 |
+
"Spotify credentials not configured. "
|
| 17 |
+
"Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET."
|
| 18 |
+
)
|
| 19 |
+
auth_manager = SpotifyClientCredentials(
|
| 20 |
+
client_id=settings.spotify_client_id,
|
| 21 |
+
client_secret=settings.spotify_client_secret,
|
| 22 |
+
)
|
| 23 |
+
return spotipy.Spotify(auth_manager=auth_manager)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_app_access_token() -> str:
|
| 27 |
+
"""Get an app-only access token (Client Credentials flow)."""
|
| 28 |
+
sp = _get_spotify_client()
|
| 29 |
+
token_info = sp.auth_manager.get_access_token(as_dict=True)
|
| 30 |
+
return token_info["access_token"]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def get_track_via_bearer(
|
| 34 |
+
track_id: str,
|
| 35 |
+
access_token: Optional[str] = None,
|
| 36 |
+
*,
|
| 37 |
+
market: Optional[str] = None,
|
| 38 |
+
) -> Dict[str, Any]:
|
| 39 |
+
"""Fetch a track using the raw Web API with a bearer token."""
|
| 40 |
+
token = access_token or get_app_access_token()
|
| 41 |
+
params = {"market": market} if market else None
|
| 42 |
+
data = spotify_get(f"https://api.spotify.com/v1/tracks/{track_id}", token, params=params)
|
| 43 |
+
return {
|
| 44 |
+
"id": data.get("id"),
|
| 45 |
+
"name": data.get("name"),
|
| 46 |
+
"artists": [a.get("name") for a in data.get("artists", [])],
|
| 47 |
+
"external_url": (data.get("external_urls") or {}).get("spotify"),
|
| 48 |
+
"preview_url": data.get("preview_url"),
|
| 49 |
+
"album": (data.get("album") or {}).get("name"),
|
| 50 |
+
"is_playable": data.get("is_playable"),
|
| 51 |
+
"linked_from": data.get("linked_from"),
|
| 52 |
+
"restrictions": data.get("restrictions"),
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def emotion_to_spotify_params(
|
| 57 |
+
emotion: str, source: str = "text"
|
| 58 |
+
) -> Dict[str, Any]:
|
| 59 |
+
"""Map emotion to Spotify search/recommendation parameters."""
|
| 60 |
+
emotion = emotion.lower()
|
| 61 |
+
|
| 62 |
+
params: Dict[str, Any] = {
|
| 63 |
+
"seed_genres": ["pop"],
|
| 64 |
+
"target_valence": 0.5,
|
| 65 |
+
"target_energy": 0.5,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if emotion in {"joy", "happy"}:
|
| 69 |
+
params.update(seed_genres=["pop", "dance", "party"], target_valence=0.9, target_energy=0.8)
|
| 70 |
+
elif emotion in {"sad", "sadness"}:
|
| 71 |
+
params.update(seed_genres=["acoustic", "sad"], target_valence=0.2, target_energy=0.4)
|
| 72 |
+
elif emotion in {"anger", "angry"}:
|
| 73 |
+
params.update(seed_genres=["rock", "metal"], target_valence=0.3, target_energy=0.9)
|
| 74 |
+
elif emotion in {"fear"}:
|
| 75 |
+
params.update(seed_genres=["ambient", "chill"], target_valence=0.3, target_energy=0.3)
|
| 76 |
+
elif emotion in {"surprise"}:
|
| 77 |
+
params.update(seed_genres=["electronic", "indie"], target_valence=0.7, target_energy=0.7)
|
| 78 |
+
elif emotion in {"neutral"}:
|
| 79 |
+
params.update(seed_genres=["pop", "indie"], target_valence=0.5, target_energy=0.5)
|
| 80 |
+
elif emotion in {"disgust"}:
|
| 81 |
+
params.update(seed_genres=["alternative"], target_valence=0.4, target_energy=0.6)
|
| 82 |
+
return params
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def recommend_track_for_emotion(emotion: str, source: str = "text") -> Dict[str, Any]:
|
| 86 |
+
sp = _get_spotify_client()
|
| 87 |
+
params = emotion_to_spotify_params(emotion, source=source)
|
| 88 |
+
recs = sp.recommendations(limit=1, **params)
|
| 89 |
+
tracks = recs.get("tracks", [])
|
| 90 |
+
if not tracks:
|
| 91 |
+
return {}
|
| 92 |
+
track = tracks[0]
|
| 93 |
+
return {
|
| 94 |
+
"id": track["id"],
|
| 95 |
+
"name": track["name"],
|
| 96 |
+
"artists": [a["name"] for a in track.get("artists", [])],
|
| 97 |
+
"preview_url": track.get("preview_url"),
|
| 98 |
+
"external_url": track.get("external_urls", {}).get("spotify"),
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
|
app/spotify_http.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any, Dict, Optional
|
| 5 |
+
|
| 6 |
+
import random
|
| 7 |
+
import time
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class SpotifyAPIError(Exception):
|
| 13 |
+
status_code: int
|
| 14 |
+
message: str
|
| 15 |
+
retry_after: Optional[int] = None
|
| 16 |
+
raw: Optional[Dict[str, Any]] = None
|
| 17 |
+
|
| 18 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 19 |
+
payload: Dict[str, Any] = {
|
| 20 |
+
"status": self.status_code,
|
| 21 |
+
"message": self.message,
|
| 22 |
+
}
|
| 23 |
+
if self.retry_after is not None:
|
| 24 |
+
payload["retry_after"] = self.retry_after
|
| 25 |
+
if self.raw is not None:
|
| 26 |
+
payload["spotify_error"] = self.raw
|
| 27 |
+
return payload
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _parse_spotify_error(resp: requests.Response) -> SpotifyAPIError:
|
| 31 |
+
retry_after = None
|
| 32 |
+
if resp.status_code == 429:
|
| 33 |
+
ra = resp.headers.get("Retry-After")
|
| 34 |
+
if ra and ra.isdigit():
|
| 35 |
+
retry_after = int(ra)
|
| 36 |
+
|
| 37 |
+
message = resp.reason or "Spotify API error"
|
| 38 |
+
raw: Optional[Dict[str, Any]] = None
|
| 39 |
+
try:
|
| 40 |
+
raw = resp.json()
|
| 41 |
+
if isinstance(raw, dict):
|
| 42 |
+
err = raw.get("error")
|
| 43 |
+
if isinstance(err, dict):
|
| 44 |
+
message = err.get("message") or message
|
| 45 |
+
except Exception: # noqa: BLE001
|
| 46 |
+
raw = None
|
| 47 |
+
|
| 48 |
+
return SpotifyAPIError(
|
| 49 |
+
status_code=resp.status_code,
|
| 50 |
+
message=str(message),
|
| 51 |
+
retry_after=retry_after,
|
| 52 |
+
raw=raw,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def spotify_get(
|
| 57 |
+
url: str,
|
| 58 |
+
access_token: str,
|
| 59 |
+
*,
|
| 60 |
+
params: Optional[Dict[str, Any]] = None,
|
| 61 |
+
timeout: int = 20,
|
| 62 |
+
) -> Dict[str, Any]:
|
| 63 |
+
"""
|
| 64 |
+
GET wrapper with basic retry/backoff for rate limits and transient upstream errors.
|
| 65 |
+
|
| 66 |
+
Retries:
|
| 67 |
+
- 429: waits Retry-After seconds (when present) then retries
|
| 68 |
+
- 502/503: exponential backoff retries (small capped)
|
| 69 |
+
"""
|
| 70 |
+
max_retries = 2
|
| 71 |
+
attempt = 0
|
| 72 |
+
while True:
|
| 73 |
+
resp = requests.get(
|
| 74 |
+
url,
|
| 75 |
+
headers={"Authorization": f"Bearer {access_token}"},
|
| 76 |
+
params=params,
|
| 77 |
+
timeout=timeout,
|
| 78 |
+
)
|
| 79 |
+
if resp.status_code < 400:
|
| 80 |
+
if resp.status_code == 204 or not resp.content:
|
| 81 |
+
return {}
|
| 82 |
+
return resp.json()
|
| 83 |
+
|
| 84 |
+
err = _parse_spotify_error(resp)
|
| 85 |
+
retryable = err.status_code in {429, 502, 503}
|
| 86 |
+
if not retryable or attempt >= max_retries:
|
| 87 |
+
raise err
|
| 88 |
+
|
| 89 |
+
attempt += 1
|
| 90 |
+
if err.status_code == 429 and err.retry_after is not None:
|
| 91 |
+
sleep_s = err.retry_after
|
| 92 |
+
else:
|
| 93 |
+
sleep_s = min(8.0, (2.0 ** (attempt - 1))) + random.uniform(0.0, 0.25)
|
| 94 |
+
time.sleep(sleep_s)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def spotify_post(
|
| 98 |
+
url: str,
|
| 99 |
+
access_token: str,
|
| 100 |
+
*,
|
| 101 |
+
params: Optional[Dict[str, Any]] = None,
|
| 102 |
+
json: Optional[Dict[str, Any]] = None,
|
| 103 |
+
data: Optional[Dict[str, Any]] = None,
|
| 104 |
+
timeout: int = 20,
|
| 105 |
+
) -> Dict[str, Any]:
|
| 106 |
+
"""
|
| 107 |
+
POST wrapper with basic retry/backoff for rate limits and transient upstream errors.
|
| 108 |
+
"""
|
| 109 |
+
max_retries = 2
|
| 110 |
+
attempt = 0
|
| 111 |
+
while True:
|
| 112 |
+
resp = requests.post(
|
| 113 |
+
url,
|
| 114 |
+
headers={"Authorization": f"Bearer {access_token}"},
|
| 115 |
+
params=params,
|
| 116 |
+
json=json,
|
| 117 |
+
data=data,
|
| 118 |
+
timeout=timeout,
|
| 119 |
+
)
|
| 120 |
+
if resp.status_code < 400:
|
| 121 |
+
if resp.status_code == 204 or not resp.content:
|
| 122 |
+
return {}
|
| 123 |
+
return resp.json()
|
| 124 |
+
|
| 125 |
+
err = _parse_spotify_error(resp)
|
| 126 |
+
retryable = err.status_code in {429, 502, 503}
|
| 127 |
+
if not retryable or attempt >= max_retries:
|
| 128 |
+
raise err
|
| 129 |
+
|
| 130 |
+
attempt += 1
|
| 131 |
+
if err.status_code == 429 and err.retry_after is not None:
|
| 132 |
+
sleep_s = err.retry_after
|
| 133 |
+
else:
|
| 134 |
+
sleep_s = min(8.0, (2.0 ** (attempt - 1))) + random.uniform(0.0, 0.25)
|
| 135 |
+
time.sleep(sleep_s)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
|
app/spotify_ids.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Literal, Optional
|
| 6 |
+
from urllib.parse import urlparse
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
SpotifyResourceType = Literal["track", "album", "artist", "playlist", "user", "unknown"]
|
| 10 |
+
_BASE62_ID_RE = re.compile(r"^[A-Za-z0-9]{22}$")
|
| 11 |
+
_URI_RE = re.compile(r"^spotify:(?P<type>track|album|artist|playlist|user):(?P<id>[A-Za-z0-9]{1,})$")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class SpotifyRef:
|
| 16 |
+
resource_type: SpotifyResourceType
|
| 17 |
+
id: Optional[str] = None
|
| 18 |
+
|
| 19 |
+
@property
|
| 20 |
+
def uri(self) -> Optional[str]:
|
| 21 |
+
if self.id and self.resource_type in {"track", "album", "artist", "playlist", "user"}:
|
| 22 |
+
return f"spotify:{self.resource_type}:{self.id}"
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
@property
|
| 26 |
+
def url(self) -> Optional[str]:
|
| 27 |
+
if self.id and self.resource_type in {"track", "album", "artist", "playlist"}:
|
| 28 |
+
return f"https://open.spotify.com/{self.resource_type}/{self.id}"
|
| 29 |
+
if self.id and self.resource_type == "user":
|
| 30 |
+
return f"https://open.spotify.com/user/{self.id}"
|
| 31 |
+
return None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def parse_spotify_ref(value: str) -> SpotifyRef:
|
| 35 |
+
"""Parse a Spotify reference in various forms."""
|
| 36 |
+
v = (value or "").strip()
|
| 37 |
+
if not v:
|
| 38 |
+
return SpotifyRef(resource_type="unknown", id=None)
|
| 39 |
+
|
| 40 |
+
# spotify:<type>:<id>
|
| 41 |
+
m = _URI_RE.match(v)
|
| 42 |
+
if m:
|
| 43 |
+
return SpotifyRef(resource_type=m.group("type"), id=m.group("id")) # type: ignore[arg-type]
|
| 44 |
+
|
| 45 |
+
# https://open.spotify.com/<type>/<id>
|
| 46 |
+
try:
|
| 47 |
+
parsed = urlparse(v)
|
| 48 |
+
host = (parsed.hostname or "").lower()
|
| 49 |
+
if host in {"open.spotify.com", "play.spotify.com"}:
|
| 50 |
+
parts = [p for p in (parsed.path or "").split("/") if p]
|
| 51 |
+
if len(parts) >= 2:
|
| 52 |
+
rtype = parts[0]
|
| 53 |
+
rid = parts[1]
|
| 54 |
+
if rtype in {"track", "album", "artist", "playlist", "user"}:
|
| 55 |
+
return SpotifyRef(resource_type=rtype, id=rid) # type: ignore[arg-type]
|
| 56 |
+
except Exception: # noqa: BLE001
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
if _BASE62_ID_RE.match(v):
|
| 60 |
+
return SpotifyRef(resource_type="unknown", id=v)
|
| 61 |
+
|
| 62 |
+
return SpotifyRef(resource_type="unknown", id=None)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def to_track_uri(value: str) -> str:
|
| 66 |
+
"""Normalize a track reference (URI/URL/ID) into a Spotify track URI."""
|
| 67 |
+
ref = parse_spotify_ref(value)
|
| 68 |
+
if ref.resource_type == "track" and ref.id:
|
| 69 |
+
return f"spotify:track:{ref.id}"
|
| 70 |
+
if ref.resource_type == "unknown" and ref.id:
|
| 71 |
+
return f"spotify:track:{ref.id}"
|
| 72 |
+
raise ValueError("Invalid track reference. Provide a track URI, URL, or 22-char track ID.")
|
| 73 |
+
|
| 74 |
+
|
app/spotify_oauth.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import ipaddress
|
| 5 |
+
import secrets
|
| 6 |
+
from urllib.parse import urlparse
|
| 7 |
+
from typing import Any, Dict, Optional
|
| 8 |
+
|
| 9 |
+
import requests
|
| 10 |
+
|
| 11 |
+
from .config import get_settings
|
| 12 |
+
from .spotify_http import SpotifyAPIError
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
SPOTIFY_AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
|
| 16 |
+
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
|
| 17 |
+
|
| 18 |
+
def validate_redirect_uri(redirect_uri: str) -> None:
|
| 19 |
+
"""
|
| 20 |
+
Enforce Spotify redirect URI requirements (2025+):
|
| 21 |
+
- HTTPS required unless loopback IP literal is used
|
| 22 |
+
- 'localhost' is NOT allowed
|
| 23 |
+
- Loopback IP literals allowed: 127.0.0.1 or [::1] (HTTP permitted)
|
| 24 |
+
"""
|
| 25 |
+
parsed = urlparse(redirect_uri)
|
| 26 |
+
if parsed.scheme not in {"http", "https"}:
|
| 27 |
+
raise ValueError("Redirect URI must start with http:// or https://")
|
| 28 |
+
if not parsed.netloc:
|
| 29 |
+
raise ValueError("Redirect URI must include host (and optional port)")
|
| 30 |
+
|
| 31 |
+
host = parsed.hostname or ""
|
| 32 |
+
if host.lower() == "localhost":
|
| 33 |
+
raise ValueError("Redirect URI host 'localhost' is not allowed. Use 127.0.0.1 or [::1].")
|
| 34 |
+
|
| 35 |
+
is_loopback_ip = False
|
| 36 |
+
try:
|
| 37 |
+
ip = ipaddress.ip_address(host)
|
| 38 |
+
is_loopback_ip = ip.is_loopback
|
| 39 |
+
except ValueError:
|
| 40 |
+
is_loopback_ip = False
|
| 41 |
+
|
| 42 |
+
if parsed.scheme != "https" and not is_loopback_ip:
|
| 43 |
+
raise ValueError("Redirect URI must use HTTPS unless using a loopback IP literal (127.0.0.1 or [::1]).")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _basic_auth_header(client_id: str, client_secret: str) -> str:
|
| 47 |
+
token = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode(
|
| 48 |
+
"utf-8"
|
| 49 |
+
)
|
| 50 |
+
return f"Basic {token}"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def generate_state() -> str:
|
| 54 |
+
return secrets.token_urlsafe(24)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def build_authorize_url(
|
| 58 |
+
*,
|
| 59 |
+
redirect_uri: str,
|
| 60 |
+
scope: str,
|
| 61 |
+
state: str,
|
| 62 |
+
show_dialog: bool = True,
|
| 63 |
+
) -> str:
|
| 64 |
+
settings = get_settings()
|
| 65 |
+
if not settings.spotify_client_id:
|
| 66 |
+
raise RuntimeError("Missing SPOTIFY_CLIENT_ID")
|
| 67 |
+
|
| 68 |
+
validate_redirect_uri(redirect_uri)
|
| 69 |
+
|
| 70 |
+
params = {
|
| 71 |
+
"client_id": settings.spotify_client_id,
|
| 72 |
+
"response_type": "code",
|
| 73 |
+
"redirect_uri": redirect_uri,
|
| 74 |
+
"scope": scope,
|
| 75 |
+
"state": state,
|
| 76 |
+
"show_dialog": "true" if show_dialog else "false",
|
| 77 |
+
}
|
| 78 |
+
req = requests.Request("GET", SPOTIFY_AUTHORIZE_URL, params=params).prepare()
|
| 79 |
+
if not req.url:
|
| 80 |
+
raise RuntimeError("Failed to build authorize URL")
|
| 81 |
+
return req.url
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def exchange_code_for_token(
|
| 85 |
+
*,
|
| 86 |
+
code: str,
|
| 87 |
+
redirect_uri: str,
|
| 88 |
+
) -> Dict[str, Any]:
|
| 89 |
+
"""Exchange authorization code for access token and refresh token."""
|
| 90 |
+
settings = get_settings()
|
| 91 |
+
if not settings.spotify_client_id or not settings.spotify_client_secret:
|
| 92 |
+
raise RuntimeError("Missing SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET")
|
| 93 |
+
|
| 94 |
+
headers = {
|
| 95 |
+
"Authorization": _basic_auth_header(
|
| 96 |
+
settings.spotify_client_id, settings.spotify_client_secret
|
| 97 |
+
),
|
| 98 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 99 |
+
}
|
| 100 |
+
data = {
|
| 101 |
+
"grant_type": "authorization_code",
|
| 102 |
+
"code": code,
|
| 103 |
+
"redirect_uri": redirect_uri,
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
resp = requests.post(SPOTIFY_TOKEN_URL, headers=headers, data=data, timeout=20)
|
| 107 |
+
if resp.status_code >= 400:
|
| 108 |
+
try:
|
| 109 |
+
raw = resp.json()
|
| 110 |
+
except Exception: # noqa: BLE001
|
| 111 |
+
raw = {"error": resp.text}
|
| 112 |
+
raise SpotifyAPIError(
|
| 113 |
+
status_code=resp.status_code,
|
| 114 |
+
message=str(raw.get("error_description") or raw.get("error") or "auth_error"),
|
| 115 |
+
raw=raw if isinstance(raw, dict) else None,
|
| 116 |
+
)
|
| 117 |
+
return resp.json()
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def refresh_access_token(*, refresh_token: str) -> Dict[str, Any]:
|
| 121 |
+
"""
|
| 122 |
+
Refresh an expired access token using a refresh token (Authorization Code flow).
|
| 123 |
+
"""
|
| 124 |
+
settings = get_settings()
|
| 125 |
+
if not settings.spotify_client_id or not settings.spotify_client_secret:
|
| 126 |
+
raise RuntimeError("Missing SPOTIFY_CLIENT_ID / SPOTIFY_CLIENT_SECRET")
|
| 127 |
+
|
| 128 |
+
headers = {
|
| 129 |
+
"Authorization": _basic_auth_header(
|
| 130 |
+
settings.spotify_client_id, settings.spotify_client_secret
|
| 131 |
+
),
|
| 132 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 133 |
+
}
|
| 134 |
+
data = {
|
| 135 |
+
"grant_type": "refresh_token",
|
| 136 |
+
"refresh_token": refresh_token,
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
resp = requests.post(SPOTIFY_TOKEN_URL, headers=headers, data=data, timeout=20)
|
| 140 |
+
if resp.status_code >= 400:
|
| 141 |
+
try:
|
| 142 |
+
raw = resp.json()
|
| 143 |
+
except Exception: # noqa: BLE001
|
| 144 |
+
raw = {"error": resp.text}
|
| 145 |
+
raise SpotifyAPIError(
|
| 146 |
+
status_code=resp.status_code,
|
| 147 |
+
message=str(raw.get("error_description") or raw.get("error") or "auth_error"),
|
| 148 |
+
raw=raw if isinstance(raw, dict) else None,
|
| 149 |
+
)
|
| 150 |
+
return resp.json()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def default_redirect_uri() -> Optional[str]:
|
| 154 |
+
"""
|
| 155 |
+
Preferred redirect URI is configured in SPOTIFY_REDIRECT_URI.
|
| 156 |
+
"""
|
| 157 |
+
settings = get_settings()
|
| 158 |
+
return settings.spotify_redirect_uri
|
| 159 |
+
|
| 160 |
+
|
app/spotify_playlists_api.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from .spotify_http import spotify_get, spotify_post
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def list_current_user_playlists(
|
| 9 |
+
access_token: str, *, limit: int = 20, offset: int = 0
|
| 10 |
+
) -> Dict[str, Any]:
|
| 11 |
+
return spotify_get(
|
| 12 |
+
"https://api.spotify.com/v1/me/playlists",
|
| 13 |
+
access_token,
|
| 14 |
+
params={"limit": limit, "offset": offset},
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_playlist(access_token: str, playlist_id: str) -> Dict[str, Any]:
|
| 19 |
+
return spotify_get(f"https://api.spotify.com/v1/playlists/{playlist_id}", access_token)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def get_playlist_items(
|
| 23 |
+
access_token: str,
|
| 24 |
+
playlist_id: str,
|
| 25 |
+
*,
|
| 26 |
+
limit: int = 50,
|
| 27 |
+
offset: int = 0,
|
| 28 |
+
additional_types: str = "track,episode",
|
| 29 |
+
market: Optional[str] = None,
|
| 30 |
+
) -> Dict[str, Any]:
|
| 31 |
+
params: Dict[str, Any] = {
|
| 32 |
+
"limit": limit,
|
| 33 |
+
"offset": offset,
|
| 34 |
+
"additional_types": additional_types,
|
| 35 |
+
}
|
| 36 |
+
if market:
|
| 37 |
+
params["market"] = market
|
| 38 |
+
return spotify_get(
|
| 39 |
+
f"https://api.spotify.com/v1/playlists/{playlist_id}/tracks",
|
| 40 |
+
access_token,
|
| 41 |
+
params=params,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def create_playlist(
|
| 46 |
+
access_token: str,
|
| 47 |
+
user_id: str,
|
| 48 |
+
*,
|
| 49 |
+
name: str,
|
| 50 |
+
description: Optional[str] = None,
|
| 51 |
+
public: bool = True,
|
| 52 |
+
collaborative: bool = False,
|
| 53 |
+
) -> Dict[str, Any]:
|
| 54 |
+
if collaborative and public:
|
| 55 |
+
raise ValueError("Spotify rule: collaborative playlists cannot be public. Set public=false.")
|
| 56 |
+
|
| 57 |
+
payload: Dict[str, Any] = {
|
| 58 |
+
"name": name,
|
| 59 |
+
"public": public,
|
| 60 |
+
"collaborative": collaborative,
|
| 61 |
+
}
|
| 62 |
+
if description is not None:
|
| 63 |
+
payload["description"] = description
|
| 64 |
+
|
| 65 |
+
return spotify_post(
|
| 66 |
+
f"https://api.spotify.com/v1/users/{user_id}/playlists",
|
| 67 |
+
access_token,
|
| 68 |
+
json=payload,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def add_items_to_playlist(
|
| 73 |
+
access_token: str,
|
| 74 |
+
playlist_id: str,
|
| 75 |
+
*,
|
| 76 |
+
uris: List[str],
|
| 77 |
+
position: Optional[int] = None,
|
| 78 |
+
) -> Dict[str, Any]:
|
| 79 |
+
payload: Dict[str, Any] = {"uris": uris}
|
| 80 |
+
if position is not None:
|
| 81 |
+
payload["position"] = position
|
| 82 |
+
return spotify_post(
|
| 83 |
+
f"https://api.spotify.com/v1/playlists/{playlist_id}/tracks",
|
| 84 |
+
access_token,
|
| 85 |
+
json=payload,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
app/spotify_user_api.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict
|
| 2 |
+
|
| 3 |
+
from .spotify_http import spotify_get
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def get_current_user_profile(access_token: str) -> Dict[str, Any]:
|
| 7 |
+
"""
|
| 8 |
+
Call Spotify Web API GET /v1/me using a *user* access token.
|
| 9 |
+
This requires Authorization Code flow (Client Credentials will NOT work).
|
| 10 |
+
"""
|
| 11 |
+
return spotify_get("https://api.spotify.com/v1/me", access_token)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
SQLAlchemy
|
| 4 |
+
psycopg2-binary
|
| 5 |
+
python-dotenv
|
| 6 |
+
requests
|
| 7 |
+
spotipy
|
| 8 |
+
transformers
|
| 9 |
+
torch
|
| 10 |
+
Pillow
|
| 11 |
+
opencv-python
|
| 12 |
+
numpy
|