nexusbert commited on
Commit
6c4fcf6
·
1 Parent(s): 7223dd6
.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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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