nexusbert commited on
Commit
825e544
·
1 Parent(s): 2334cf6

Initial Ytapp app

Browse files
Files changed (7) hide show
  1. .gitignore +37 -0
  2. Dockerfile +37 -0
  3. System_overview.md +117 -0
  4. __init__.py +18 -0
  5. app.py +345 -0
  6. requirements.txt +11 -0
  7. ytmusic_client.py +299 -0
.gitignore ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ ENV/
10
+ .venv
11
+
12
+ # Environment variables
13
+ .env
14
+ .env.local
15
+
16
+ # OAuth credentials (sensitive)
17
+ oauth.json
18
+
19
+ # IDE
20
+ .vscode/
21
+ .idea/
22
+ *.swp
23
+ *.swo
24
+ *~
25
+
26
+ # OS
27
+ .DS_Store
28
+ Thumbs.db
29
+
30
+ # Logs
31
+ *.log
32
+
33
+ # Distribution / packaging
34
+ dist/
35
+ build/
36
+ *.egg-info/
37
+
Dockerfile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV TF_CPP_MIN_LOG_LEVEL=2
6
+
7
+ RUN apt-get update && apt-get install -y \
8
+ libgl1 \
9
+ libglib2.0-0 \
10
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
11
+
12
+ WORKDIR /app
13
+
14
+ COPY requirements.txt .
15
+ RUN pip install --no-cache-dir -r requirements.txt
16
+
17
+ COPY . .
18
+
19
+ RUN useradd -m -u 1000 user
20
+ USER user
21
+ ENV HOME=/home/user \
22
+ PATH=/home/user/.local/bin:$PATH
23
+
24
+ RUN mkdir -p /home/user/.deepface/weights && chmod -R 777 /home/user/.deepface
25
+
26
+ RUN python - << 'PY'
27
+ import numpy as np
28
+ from deepface import DeepFace
29
+ try:
30
+ DeepFace.analyze(np.zeros((224, 224, 3), dtype=np.uint8), actions=['emotion'], enforce_detection=False)
31
+ except:
32
+ pass
33
+ PY
34
+
35
+ EXPOSE 7860
36
+
37
+ CMD ["python", "app.py"]
System_overview.md ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## System overview – Ytapp YouTube Music Mood-based Recommender
2
+
3
+ Independent FastAPI service for mood-based YouTube Music recommendations using emotionAI's DeepFace + Gemini API approach.
4
+
5
+ ## Features
6
+
7
+ - Text emotion analysis (Gemini API) → YouTube Music song recommendations
8
+ - Face emotion analysis (DeepFace fast / Gemini API accurate) → YouTube Music song recommendations
9
+ - Search songs, artists, and get song details
10
+ - Standalone service (independent of main VibeCheck app)
11
+
12
+ ## Setup
13
+
14
+ 1. **Install dependencies:**
15
+ ```bash
16
+ pip install -r requirements.txt --break-system-packages
17
+ ```
18
+
19
+ 2. **Configure Gemini API Key (Optional but recommended):**
20
+ - Create a `.env` file in the Ytapp directory
21
+ - Add: `GEMINI_API_KEY=your_api_key_here`
22
+ - Without Gemini API key, face analysis falls back to DeepFace only
23
+ - Text analysis requires Gemini API key
24
+
25
+ 3. **Optional: OAuth Authentication**
26
+ - For authenticated requests (library management, playlists, etc.), set up OAuth:
27
+ - Get Client ID and Secret from [YouTube Data API](https://developers.google.com/youtube/v3)
28
+ - Select OAuth client ID → TVs and Limited Input devices
29
+ - Run: `ytmusicapi oauth`
30
+ - Follow instructions to create `oauth.json`
31
+ - Pass credentials to `YTMusic()` if needed
32
+
33
+ ## Running Locally
34
+
35
+ ```bash
36
+ python app.py
37
+ ```
38
+
39
+ The service will run on `http://localhost:7860`
40
+
41
+ ## Docker
42
+
43
+ ```bash
44
+ docker build -t ytapp .
45
+ docker run -p 7860:7860 -e GEMINI_API_KEY=your_key_here ytapp
46
+ ```
47
+
48
+ ## API Endpoints
49
+
50
+ ### Health Check
51
+ - `GET /health` - Service health status
52
+
53
+ ### Search
54
+ - `GET /search?query=...&limit=20` - Search for songs
55
+ - `GET /song/{video_id}` - Get song details
56
+ - `GET /artists/search?query=...&limit=10` - Search for artists
57
+ - `GET /artists/{artist_id}/songs?limit=50` - Get artist songs
58
+
59
+ ### Mood-based Recommendations
60
+ - `POST /mood/text` - Get song recommendation from text mood (uses Gemini API)
61
+ ```json
62
+ {"text": "I feel happy today!"}
63
+ ```
64
+ - `POST /mood/face` - Get song recommendation from face image (tries Gemini, falls back to DeepFace)
65
+ ```
66
+ Form data: file (image/jpeg)
67
+ ```
68
+ - `POST /mood/face/live` - Fast face analysis using DeepFace only (for live video feeds)
69
+ ```
70
+ Form data: file (image/jpeg)
71
+ ```
72
+
73
+ ## Response Format
74
+
75
+ Mood endpoints return:
76
+ ```json
77
+ {
78
+ "mood_label": "joy",
79
+ "mood_score": 0.95,
80
+ "video_id": "dQw4w9WgXcQ",
81
+ "title": "Song Title",
82
+ "artists": ["Artist Name"],
83
+ "album": "Album Name",
84
+ "duration": "3:45",
85
+ "image_url": "https://...",
86
+ "external_url": "https://music.youtube.com/watch?v=..."
87
+ }
88
+ ```
89
+
90
+ ## Emotion Detection Methods
91
+
92
+ ### Face Analysis
93
+ 1. **Primary (if Gemini API key available)**: Uses Gemini 1.5 Flash for accurate emotion detection
94
+ 2. **Fallback**: Uses DeepFace for fast offline emotion detection
95
+ 3. **Live mode**: Uses DeepFace only for real-time video feeds
96
+
97
+ ### Text Analysis
98
+ - Uses Gemini API to analyze emotional tone of text
99
+ - Requires `GEMINI_API_KEY` to be set
100
+
101
+ ## Emotion Mapping
102
+
103
+ - **Joy/Happy** → Happy, upbeat, dance music
104
+ - **Sad** → Sad songs, ballads, emotional music
105
+ - **Anger** → Rock, metal, intense music
106
+ - **Fear** → Calm, ambient, meditation music
107
+ - **Surprise** → Experimental, indie, alternative music
108
+ - **Neutral** → Chill, background, easy listening
109
+ - **Disgust** → Alternative rock, indie music
110
+
111
+ ## Notes
112
+
113
+ - DeepFace models are preloaded during Docker build for faster startup
114
+ - Gemini API provides more accurate emotion detection but requires API key
115
+ - DeepFace works offline and is faster for live video feeds
116
+ - No authentication required for basic searches and mood recommendations
117
+ - OAuth optional for library management and playlist creation
__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .ytmusic_client import (
2
+ YouTubeMusicError,
3
+ search_songs,
4
+ get_song_info,
5
+ search_artists,
6
+ get_artist_songs,
7
+ recommend_song_for_emotion,
8
+ )
9
+
10
+ __all__ = [
11
+ "YouTubeMusicError",
12
+ "search_songs",
13
+ "get_song_info",
14
+ "search_artists",
15
+ "get_artist_songs",
16
+ "recommend_song_for_emotion",
17
+ ]
18
+
app.py ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import base64
3
+ import json
4
+ import traceback
5
+
6
+ import numpy as np
7
+ import cv2
8
+ import requests
9
+ from deepface import DeepFace
10
+ from dotenv import load_dotenv
11
+ from fastapi import FastAPI, File, HTTPException, UploadFile
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from pydantic import BaseModel
14
+
15
+ from ytmusic_client import (
16
+ YouTubeMusicError,
17
+ search_songs,
18
+ get_song_info,
19
+ search_artists,
20
+ get_artist_songs,
21
+ recommend_song_for_emotion,
22
+ )
23
+
24
+
25
+ os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
26
+
27
+ load_dotenv()
28
+
29
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
30
+ if GEMINI_API_KEY:
31
+ GEMINI_API_KEY = GEMINI_API_KEY.strip().replace('"', '').replace("'", "")
32
+
33
+ if not GEMINI_API_KEY or GEMINI_API_KEY == "YOUR_API_KEY_HERE":
34
+ print("⚠️ WARNING: GEMINI_API_KEY not found or using placeholder.")
35
+ else:
36
+ masked_key = f"{GEMINI_API_KEY[:4]}...{GEMINI_API_KEY[-4:]}"
37
+ print(f"✅ API Key detected: {masked_key} (Length: {len(GEMINI_API_KEY)})")
38
+
39
+ YTMUSIC_OAUTH_FILE = os.getenv("YTMUSIC_OAUTH_FILE", "oauth.json")
40
+ YTMUSIC_CLIENT_ID = os.getenv("YTMUSIC_CLIENT_ID")
41
+ YTMUSIC_CLIENT_SECRET = os.getenv("YTMUSIC_CLIENT_SECRET")
42
+
43
+ if os.path.exists(YTMUSIC_OAUTH_FILE):
44
+ import json
45
+ with open(YTMUSIC_OAUTH_FILE, 'r') as f:
46
+ oauth_data = json.load(f)
47
+ if "oauth_credentials" in oauth_data:
48
+ print(f"✅ YouTube Music OAuth file found with credentials: {YTMUSIC_OAUTH_FILE}")
49
+ else:
50
+ print(f"ℹ️ YouTube Music OAuth file found but incomplete: {YTMUSIC_OAUTH_FILE}")
51
+ else:
52
+ print(f"ℹ️ YouTube Music OAuth file not found: {YTMUSIC_OAUTH_FILE}")
53
+ print(" Run: ytmusicapi oauth to set up authentication (optional)")
54
+
55
+
56
+ app = FastAPI(title="Ytapp – YouTube Music Mood-based Recommender")
57
+ app.add_middleware(
58
+ CORSMiddleware,
59
+ allow_origins=["*"],
60
+ allow_credentials=True,
61
+ allow_methods=["*"],
62
+ allow_headers=["*"],
63
+ )
64
+
65
+
66
+ class TextMoodRequest(BaseModel):
67
+ text: str
68
+
69
+
70
+ class RecommendationResponse(BaseModel):
71
+ mood_label: str
72
+ mood_score: float
73
+ video_id: str | None
74
+ title: str | None
75
+ artists: list[str] | None
76
+ album: str | None
77
+ duration: str | None
78
+ image_url: str | None
79
+ external_url: str | None
80
+
81
+
82
+ def _analyze_face_deepface(image_bytes: bytes) -> tuple[str, float]:
83
+ npimg = np.frombuffer(image_bytes, np.uint8)
84
+ img = cv2.imdecode(npimg, cv2.IMREAD_COLOR)
85
+
86
+ result = DeepFace.analyze(img, actions=['emotion'], enforce_detection=False)
87
+ res = result[0] if isinstance(result, list) else result
88
+
89
+ emotions_dict = {key: float(value) for key, value in res['emotion'].items() if key != 'disgust'}
90
+ total = sum(emotions_dict.values())
91
+ if total > 0:
92
+ emotions_dict = {key: (value / total) * 100 for key, value in emotions_dict.items()}
93
+
94
+ dominant = max(emotions_dict, key=emotions_dict.get)
95
+ score = emotions_dict[dominant] / 100.0
96
+
97
+ emotion_map = {
98
+ "happy": "joy",
99
+ "sad": "sadness",
100
+ "angry": "anger",
101
+ "fear": "fear",
102
+ "surprise": "surprise",
103
+ "neutral": "neutral",
104
+ }
105
+
106
+ return emotion_map.get(dominant, "neutral"), score
107
+
108
+
109
+ def _analyze_face_gemini(image_bytes: bytes) -> tuple[str, float]:
110
+ if not GEMINI_API_KEY or GEMINI_API_KEY == "YOUR_API_KEY_HERE":
111
+ raise ValueError("GEMINI_API_KEY not configured")
112
+
113
+ base64_image = base64.b64encode(image_bytes).decode('utf-8')
114
+
115
+ prompt = """
116
+ You are an emotion detection AI. Analyze the facial expression in this image.
117
+ DO NOT use 'disgust'.
118
+
119
+ Return ONLY a valid JSON object with this exact structure:
120
+ {
121
+ "dominant_emotion": "happy|sad|angry|neutral|fear|surprise",
122
+ "confidence": 0.0-1.0
123
+ }
124
+ """
125
+
126
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={GEMINI_API_KEY}"
127
+ payload = {
128
+ "contents": [{"parts": [{"text": prompt}, {"inline_data": {"mime_type": "image/jpeg", "data": base64_image}}]}],
129
+ "generationConfig": {"response_mime_type": "application/json"}
130
+ }
131
+
132
+ raw_response = requests.post(url, json=payload)
133
+ response = raw_response.json()
134
+
135
+ if 'error' in response:
136
+ raise ValueError(f"Gemini API Error: {response['error']['message']}")
137
+
138
+ if 'candidates' not in response:
139
+ raise ValueError("Image blocked by AI Safety Filters")
140
+
141
+ result = json.loads(response['candidates'][0]['content']['parts'][0]['text'])
142
+
143
+ emotion_map = {
144
+ "happy": "joy",
145
+ "sad": "sadness",
146
+ "angry": "anger",
147
+ "fear": "fear",
148
+ "surprise": "surprise",
149
+ "neutral": "neutral",
150
+ }
151
+
152
+ dominant = result.get("dominant_emotion", "neutral").lower()
153
+ confidence = float(result.get("confidence", 0.5))
154
+
155
+ return emotion_map.get(dominant, "neutral"), confidence
156
+
157
+
158
+ def _analyze_text_gemini(text: str) -> tuple[str, float]:
159
+ if not GEMINI_API_KEY or GEMINI_API_KEY == "YOUR_API_KEY_HERE":
160
+ raise ValueError("GEMINI_API_KEY not configured")
161
+
162
+ prompt = f"""
163
+ Analyze the emotional tone of this text: "{text}"
164
+
165
+ Return ONLY a valid JSON object with this exact structure:
166
+ {{
167
+ "dominant_emotion": "joy|sadness|anger|neutral|fear|surprise",
168
+ "confidence": 0.0-1.0
169
+ }}
170
+ """
171
+
172
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={GEMINI_API_KEY}"
173
+ payload = {
174
+ "contents": [{"parts": [{"text": prompt}]}],
175
+ "generationConfig": {"response_mime_type": "application/json"}
176
+ }
177
+
178
+ raw_response = requests.post(url, json=payload)
179
+ response = raw_response.json()
180
+
181
+ if 'error' in response:
182
+ raise ValueError(f"Gemini API Error: {response['error']['message']}")
183
+
184
+ if 'candidates' not in response:
185
+ raise ValueError("Text analysis blocked")
186
+
187
+ result = json.loads(response['candidates'][0]['content']['parts'][0]['text'])
188
+
189
+ dominant = result.get("dominant_emotion", "neutral").lower()
190
+ confidence = float(result.get("confidence", 0.5))
191
+
192
+ return dominant, confidence
193
+
194
+
195
+ @app.get("/health")
196
+ def health() -> dict:
197
+ return {"status": "ok"}
198
+
199
+
200
+ @app.get("/search")
201
+ def search_songs_endpoint(query: str, limit: int = 20) -> dict:
202
+ try:
203
+ oauth_file = YTMUSIC_OAUTH_FILE if os.path.exists(YTMUSIC_OAUTH_FILE) else None
204
+ songs = search_songs(query, limit=limit, oauth_file=oauth_file)
205
+ return {"query": query, "limit": limit, "songs": songs}
206
+ except YouTubeMusicError as exc:
207
+ raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
208
+ except Exception as exc:
209
+ raise HTTPException(status_code=500, detail=f"Unexpected error: {str(exc)}") from exc
210
+
211
+
212
+ @app.get("/song/{video_id}")
213
+ def get_song_endpoint(video_id: str) -> dict:
214
+ try:
215
+ oauth_file = YTMUSIC_OAUTH_FILE if os.path.exists(YTMUSIC_OAUTH_FILE) else None
216
+ song = get_song_info(video_id, oauth_file=oauth_file)
217
+ return song
218
+ except YouTubeMusicError as exc:
219
+ raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
220
+ except Exception as exc:
221
+ raise HTTPException(status_code=500, detail=f"Unexpected error: {str(exc)}") from exc
222
+
223
+
224
+ @app.get("/artists/search")
225
+ def search_artists_endpoint(query: str, limit: int = 10) -> dict:
226
+ try:
227
+ oauth_file = YTMUSIC_OAUTH_FILE if os.path.exists(YTMUSIC_OAUTH_FILE) else None
228
+ artists = search_artists(query, limit=limit, oauth_file=oauth_file)
229
+ return {"query": query, "limit": limit, "artists": artists}
230
+ except YouTubeMusicError as exc:
231
+ raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
232
+ except Exception as exc:
233
+ raise HTTPException(status_code=500, detail=f"Unexpected error: {str(exc)}") from exc
234
+
235
+
236
+ @app.get("/artists/{artist_id}/songs")
237
+ def artist_songs_endpoint(artist_id: str, limit: int = 50) -> dict:
238
+ try:
239
+ oauth_file = YTMUSIC_OAUTH_FILE if os.path.exists(YTMUSIC_OAUTH_FILE) else None
240
+ songs = get_artist_songs(artist_id, limit=limit, oauth_file=oauth_file)
241
+ return {"artist_id": artist_id, "limit": limit, "songs": songs}
242
+ except YouTubeMusicError as exc:
243
+ raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
244
+ except Exception as exc:
245
+ raise HTTPException(status_code=500, detail=f"Unexpected error: {str(exc)}") from exc
246
+
247
+
248
+ @app.post("/mood/text", response_model=RecommendationResponse)
249
+ def mood_from_text(body: TextMoodRequest) -> RecommendationResponse:
250
+ if not body.text.strip():
251
+ raise HTTPException(status_code=400, detail="Text cannot be empty")
252
+
253
+ try:
254
+ label, score = _analyze_text_gemini(body.text)
255
+ except Exception as e:
256
+ raise HTTPException(status_code=500, detail=f"Emotion analysis failed: {str(e)}")
257
+
258
+ try:
259
+ oauth_file = YTMUSIC_OAUTH_FILE if os.path.exists(YTMUSIC_OAUTH_FILE) else None
260
+ song = recommend_song_for_emotion(label, source="text", oauth_file=oauth_file)
261
+ except Exception:
262
+ song = {}
263
+
264
+ return RecommendationResponse(
265
+ mood_label=label,
266
+ mood_score=score,
267
+ video_id=song.get("video_id"),
268
+ title=song.get("title"),
269
+ artists=song.get("artists", []),
270
+ album=song.get("album"),
271
+ duration=song.get("duration"),
272
+ image_url=song.get("image_url"),
273
+ external_url=song.get("external_url"),
274
+ )
275
+
276
+
277
+ @app.post("/mood/face", response_model=RecommendationResponse)
278
+ async def mood_from_face(file: UploadFile = File(...)) -> RecommendationResponse:
279
+ contents = await file.read()
280
+
281
+ try:
282
+ label, score = _analyze_face_gemini(contents)
283
+ except Exception:
284
+ try:
285
+ label, score = _analyze_face_deepface(contents)
286
+ except Exception as e:
287
+ raise HTTPException(status_code=500, detail=f"Face analysis failed: {str(e)}")
288
+
289
+ try:
290
+ oauth_file = YTMUSIC_OAUTH_FILE if os.path.exists(YTMUSIC_OAUTH_FILE) else None
291
+ song = recommend_song_for_emotion(label, source="face", oauth_file=oauth_file)
292
+ except Exception:
293
+ song = {}
294
+
295
+ return RecommendationResponse(
296
+ mood_label=label,
297
+ mood_score=score,
298
+ video_id=song.get("video_id"),
299
+ title=song.get("title"),
300
+ artists=song.get("artists", []),
301
+ album=song.get("album"),
302
+ duration=song.get("duration"),
303
+ image_url=song.get("image_url"),
304
+ external_url=song.get("external_url"),
305
+ )
306
+
307
+
308
+ @app.post("/mood/face/live", response_model=RecommendationResponse)
309
+ async def mood_from_face_live(file: UploadFile = File(...)) -> RecommendationResponse:
310
+ contents = await file.read()
311
+
312
+ try:
313
+ label, score = _analyze_face_deepface(contents)
314
+ except Exception as e:
315
+ raise HTTPException(status_code=500, detail=f"Live face analysis failed: {str(e)}")
316
+
317
+ try:
318
+ oauth_file = YTMUSIC_OAUTH_FILE if os.path.exists(YTMUSIC_OAUTH_FILE) else None
319
+ song = recommend_song_for_emotion(label, source="face", oauth_file=oauth_file)
320
+ except Exception:
321
+ song = {}
322
+
323
+ return RecommendationResponse(
324
+ mood_label=label,
325
+ mood_score=score,
326
+ video_id=song.get("video_id"),
327
+ title=song.get("title"),
328
+ artists=song.get("artists", []),
329
+ album=song.get("album"),
330
+ duration=song.get("duration"),
331
+ image_url=song.get("image_url"),
332
+ external_url=song.get("external_url"),
333
+ )
334
+
335
+
336
+ print("⏳ Waking up local AI...")
337
+ try:
338
+ DeepFace.analyze(np.zeros((224, 224, 3), dtype=np.uint8), actions=['emotion'], enforce_detection=False)
339
+ except:
340
+ pass
341
+ print("✅ SYSTEM READY!")
342
+
343
+ if __name__ == "__main__":
344
+ import uvicorn
345
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ ytmusicapi==1.10.0
4
+ deepface
5
+ numpy<2.0
6
+ opencv-python-headless
7
+ requests
8
+ python-dotenv
9
+ tf-keras
10
+ tensorflow
11
+ python-multipart
ytmusic_client.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ try:
7
+ from ytmusicapi import YTMusic, OAuthCredentials
8
+ except ImportError:
9
+ YTMusic = None
10
+ OAuthCredentials = None
11
+
12
+
13
+ class YouTubeMusicError(Exception):
14
+ def __init__(self, message: str, status_code: int = 500):
15
+ self.message = message
16
+ self.status_code = status_code
17
+ super().__init__(self.message)
18
+
19
+
20
+ def _get_ytmusic_client(oauth_file: Optional[str] = None, client_id: Optional[str] = None, client_secret: Optional[str] = None) -> Any:
21
+ if YTMusic is None:
22
+ raise YouTubeMusicError(
23
+ "ytmusicapi is not installed. Install it with: pip install ytmusicapi==1.11.5",
24
+ status_code=500
25
+ )
26
+
27
+ try:
28
+ import os
29
+ from dotenv import load_dotenv
30
+ load_dotenv()
31
+
32
+ # Get OAuth credentials from environment or parameters
33
+ oauth_file = oauth_file or os.getenv("YTMUSIC_OAUTH_FILE", "oauth.json")
34
+ client_id = client_id or os.getenv("YTMUSIC_CLIENT_ID")
35
+ client_secret = client_secret or os.getenv("YTMUSIC_CLIENT_SECRET")
36
+
37
+ # If OAuth file exists and has credentials, use it
38
+ if os.path.exists(oauth_file):
39
+ import json
40
+ try:
41
+ with open(oauth_file, 'r') as f:
42
+ oauth_data = json.load(f)
43
+ if "oauth_credentials" in oauth_data:
44
+ if client_id and client_secret:
45
+ # Use OAuth with Client ID/Secret (required as of Nov 2024)
46
+ if OAuthCredentials is None:
47
+ raise YouTubeMusicError("OAuthCredentials not available. Update ytmusicapi.", status_code=500)
48
+ return YTMusic(oauth_file, oauth_credentials=OAuthCredentials(client_id=client_id, client_secret=client_secret))
49
+ else:
50
+ # Try without credentials (may work for some operations)
51
+ return YTMusic(oauth_file)
52
+ except Exception:
53
+ pass
54
+
55
+ # Fall back to public access (works for search and recommendations)
56
+ return YTMusic()
57
+ except Exception as e:
58
+ raise YouTubeMusicError(f"Failed to initialize YTMusic client: {str(e)}", status_code=500)
59
+
60
+
61
+ def search_songs(query: str, limit: int = 20, oauth_file: Optional[str] = None) -> List[Dict[str, Any]]:
62
+ try:
63
+ yt = _get_ytmusic_client(oauth_file)
64
+ results = yt.search(query, filter="songs", limit=limit)
65
+
66
+ songs = []
67
+ for item in results:
68
+ if item.get("resultType") == "song":
69
+ video_id = item.get("videoId")
70
+ if not video_id:
71
+ continue
72
+
73
+ artists = []
74
+ if "artists" in item:
75
+ for artist in item["artists"]:
76
+ if isinstance(artist, dict):
77
+ artists.append(artist.get("name", ""))
78
+ elif isinstance(artist, str):
79
+ artists.append(artist)
80
+
81
+ album = None
82
+ if "album" in item and isinstance(item["album"], dict):
83
+ album = item["album"].get("name")
84
+ elif isinstance(item.get("album"), str):
85
+ album = item["album"]
86
+
87
+ thumbnails = item.get("thumbnails", [])
88
+ image_url = None
89
+ if thumbnails:
90
+ image_url = thumbnails[-1].get("url") if isinstance(thumbnails[-1], dict) else None
91
+
92
+ songs.append({
93
+ "video_id": video_id,
94
+ "title": item.get("title", ""),
95
+ "artists": artists,
96
+ "album": album,
97
+ "duration": item.get("duration"),
98
+ "image_url": image_url,
99
+ "playlist_id": item.get("playlistId"),
100
+ })
101
+
102
+ return songs[:limit]
103
+ except YouTubeMusicError:
104
+ raise
105
+ except Exception as e:
106
+ raise YouTubeMusicError(f"Search failed: {str(e)}", status_code=500)
107
+
108
+
109
+ def get_song_info(video_id: str, oauth_file: Optional[str] = None) -> Dict[str, Any]:
110
+ try:
111
+ yt = _get_ytmusic_client(oauth_file)
112
+ song = yt.get_song(video_id)
113
+
114
+ if not song:
115
+ raise YouTubeMusicError(f"Song not found: {video_id}", status_code=404)
116
+
117
+ return song
118
+ except YouTubeMusicError:
119
+ raise
120
+ except Exception as e:
121
+ raise YouTubeMusicError(f"Failed to get song info: {str(e)}", status_code=500)
122
+
123
+
124
+ def search_artists(query: str, limit: int = 10, oauth_file: Optional[str] = None) -> List[Dict[str, Any]]:
125
+ try:
126
+ yt = _get_ytmusic_client(oauth_file)
127
+ results = yt.search(query, filter="artists", limit=limit)
128
+
129
+ artists = []
130
+ for item in results:
131
+ if item.get("resultType") == "artist":
132
+ thumbnails = item.get("thumbnails", [])
133
+ image_url = None
134
+ if thumbnails:
135
+ image_url = thumbnails[-1].get("url") if isinstance(thumbnails[-1], dict) else None
136
+
137
+ artists.append({
138
+ "artist_id": item.get("browseId"),
139
+ "name": item.get("artist", ""),
140
+ "image_url": image_url,
141
+ })
142
+
143
+ return artists[:limit]
144
+ except YouTubeMusicError:
145
+ raise
146
+ except Exception as e:
147
+ raise YouTubeMusicError(f"Artist search failed: {str(e)}", status_code=500)
148
+
149
+
150
+ def get_artist_songs(artist_id: str, limit: int = 50, oauth_file: Optional[str] = None) -> List[Dict[str, Any]]:
151
+ try:
152
+ yt = _get_ytmusic_client(oauth_file)
153
+ artist = yt.get_artist(artist_id)
154
+
155
+ if not artist:
156
+ raise YouTubeMusicError(f"Artist not found: {artist_id}", status_code=404)
157
+
158
+ songs = []
159
+ if "songs" in artist:
160
+ for song in artist["songs"][:limit]:
161
+ video_id = song.get("videoId")
162
+ if not video_id:
163
+ continue
164
+
165
+ artists = []
166
+ if "artists" in song:
167
+ for artist_info in song["artists"]:
168
+ if isinstance(artist_info, dict):
169
+ artists.append(artist_info.get("name", ""))
170
+ elif isinstance(artist_info, str):
171
+ artists.append(artist_info)
172
+
173
+ thumbnails = song.get("thumbnails", [])
174
+ image_url = None
175
+ if thumbnails:
176
+ image_url = thumbnails[-1].get("url") if isinstance(thumbnails[-1], dict) else None
177
+
178
+ songs.append({
179
+ "video_id": video_id,
180
+ "title": song.get("title", ""),
181
+ "artists": artists,
182
+ "album": song.get("album", {}).get("name") if isinstance(song.get("album"), dict) else None,
183
+ "duration": song.get("duration"),
184
+ "image_url": image_url,
185
+ })
186
+
187
+ return songs
188
+ except YouTubeMusicError:
189
+ raise
190
+ except Exception as e:
191
+ raise YouTubeMusicError(f"Failed to get artist songs: {str(e)}", status_code=500)
192
+
193
+
194
+ def emotion_to_ytmusic_queries(emotion: str) -> List[str]:
195
+ emotion = emotion.lower()
196
+
197
+ if emotion in {"joy", "happy", "happiness"}:
198
+ return [
199
+ "happy music",
200
+ "upbeat songs",
201
+ "feel good music",
202
+ "dance music",
203
+ "party songs",
204
+ "energetic music",
205
+ "positive vibes",
206
+ ]
207
+ elif emotion in {"sad", "sadness"}:
208
+ return [
209
+ "sad songs",
210
+ "emotional music",
211
+ "melancholic songs",
212
+ "ballads",
213
+ "heartbreak songs",
214
+ "calm music",
215
+ "relaxing music",
216
+ ]
217
+ elif emotion in {"anger", "angry"}:
218
+ return [
219
+ "angry music",
220
+ "rock music",
221
+ "metal songs",
222
+ "intense music",
223
+ "aggressive songs",
224
+ "punk rock",
225
+ "hard rock",
226
+ ]
227
+ elif emotion in {"fear"}:
228
+ return [
229
+ "calm music",
230
+ "ambient music",
231
+ "peaceful songs",
232
+ "meditation music",
233
+ "soothing music",
234
+ "relaxing instrumental",
235
+ ]
236
+ elif emotion in {"surprise"}:
237
+ return [
238
+ "surprising music",
239
+ "unexpected songs",
240
+ "experimental music",
241
+ "indie music",
242
+ "alternative music",
243
+ "unique songs",
244
+ ]
245
+ elif emotion in {"neutral"}:
246
+ return [
247
+ "chill music",
248
+ "background music",
249
+ "easy listening",
250
+ "soft music",
251
+ "ambient playlist",
252
+ ]
253
+ elif emotion in {"disgust"}:
254
+ return [
255
+ "alternative rock",
256
+ "indie music",
257
+ "experimental songs",
258
+ "unique music",
259
+ ]
260
+
261
+ return ["music", "songs", "popular music"]
262
+
263
+
264
+ def recommend_song_for_emotion(emotion: str, source: str = "text", oauth_file: Optional[str] = None) -> Dict[str, Any]:
265
+ emotion_norm = (emotion or "").lower()
266
+ queries = emotion_to_ytmusic_queries(emotion_norm)
267
+
268
+ items: List[Dict[str, Any]] = []
269
+ for q in queries:
270
+ try:
271
+ songs = search_songs(q, limit=10, oauth_file=oauth_file)
272
+ if songs:
273
+ items.extend(songs)
274
+ if len(items) >= 20:
275
+ break
276
+ except Exception:
277
+ continue
278
+
279
+ if not items:
280
+ try:
281
+ fallback = search_songs("popular music", limit=5, oauth_file=oauth_file)
282
+ if fallback:
283
+ items = fallback
284
+ except Exception:
285
+ pass
286
+
287
+ if not items:
288
+ return {}
289
+
290
+ song = random.choice(items)
291
+ return {
292
+ "video_id": song.get("video_id"),
293
+ "title": song.get("title", ""),
294
+ "artists": song.get("artists", []),
295
+ "album": song.get("album"),
296
+ "duration": song.get("duration"),
297
+ "image_url": song.get("image_url"),
298
+ "external_url": f"https://music.youtube.com/watch?v={song.get('video_id')}" if song.get("video_id") else None,
299
+ }