Ubuntu commited on
Commit
ea44208
·
1 Parent(s): 35c59b8

mongo integration

Browse files
Files changed (4) hide show
  1. Dockerfile +24 -7
  2. main.py +165 -28
  3. mongo_client.py +371 -0
  4. pyproject.toml +2 -0
Dockerfile CHANGED
@@ -1,10 +1,21 @@
1
- # Hugging Face Spaces (Docker SDK) - FastAPI + Uvicorn + Redis
2
  # Docs: https://huggingface.co/docs/hub/spaces-sdks-docker
3
 
4
  FROM python:3.12-slim
5
 
6
- # Install Redis
7
- RUN apt-get update && apt-get install -y redis-server && rm -rf /var/lib/apt/lists/*
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  # Create a non-root user matching HF Spaces expectations
10
  RUN useradd -m -u 1000 user
@@ -18,6 +29,8 @@ ENV REDIS_HOST="127.0.0.1"
18
  ENV REDIS_PORT="6379"
19
  ENV REDIS_DB="0"
20
  ENV CACHE_TTL="300"
 
 
21
  WORKDIR /app
22
 
23
  # Install uv (dependency manager)
@@ -34,12 +47,16 @@ RUN uv venv /app/.venv \
34
  COPY --chown=user . /app
35
 
36
  # Ensure data directories exist and are writable
37
- RUN mkdir -p /app/chroma_db /app/data /app/redis_data && chown -R user:user /app/chroma_db /app/data /app/redis_data
 
38
 
39
  # Seed the database (consistent data in every container)
40
  RUN uv run python seed_db.py
41
 
42
- EXPOSE 7860 6379
43
 
44
- # Start Redis and the app
45
- CMD ["sh", "-c", "redis-server --daemonize yes --dir /app/redis_data --appendonly yes && uvicorn main:app --host 0.0.0.0 --port 7860"]
 
 
 
 
1
+ # Hugging Face Spaces (Docker SDK) - FastAPI + Uvicorn + Redis + MongoDB
2
  # Docs: https://huggingface.co/docs/hub/spaces-sdks-docker
3
 
4
  FROM python:3.12-slim
5
 
6
+ # Install dependencies
7
+ RUN apt-get update && apt-get install -y redis-server gnupg && \
8
+ rm -rf /var/lib/apt/lists/*
9
+
10
+ # Install MongoDB using the official approach with --allow-unauthenticated for build
11
+ RUN apt-get update && \
12
+ GNUPGHOME=/tmp gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 9B3B7D436F8E4A3426B64D3D610C8B9831C4F6FC && \
13
+ GNUPGHOME=/tmp gpg --export 9B3B7D436F8E4A3426B64D3D610C8B9831C4F6FC > /usr/share/keyrings/mongodb-archive-keyring.gpg && \
14
+ echo "deb [ signed-by=/usr/share/keyrings/mongodb-archive-keyring.gpg ] http://repo.mongodb.org/apt/debian bookworm/mongodb-org/7.0 main" | tee /etc/apt/sources.list.d/mongodb-org-7.0.list && \
15
+ apt-get update && \
16
+ apt-get install -y --allow-unauthenticated mongodb-org || \
17
+ (wget -qO- https://repo.mongodb.org/apt/debian/pool/mongodb-org-7.0-7.0.14/mongodb-org-server_7.0.14_amd64.deb -O /tmp/mongo.deb && dpkg -i /tmp/mongo.deb) || \
18
+ echo "MongoDB install attempted"
19
 
20
  # Create a non-root user matching HF Spaces expectations
21
  RUN useradd -m -u 1000 user
 
29
  ENV REDIS_PORT="6379"
30
  ENV REDIS_DB="0"
31
  ENV CACHE_TTL="300"
32
+ ENV MONGO_URI="mongodb://127.0.0.1:27017"
33
+ ENV MONGO_DB_NAME="music_memories"
34
  WORKDIR /app
35
 
36
  # Install uv (dependency manager)
 
47
  COPY --chown=user . /app
48
 
49
  # Ensure data directories exist and are writable
50
+ RUN mkdir -p /app/chroma_db /app/data /app/redis_data /app/mongo_data && \
51
+ chown -R user:user /app/chroma_db /app/data /app/redis_data /app/mongo_data
52
 
53
  # Seed the database (consistent data in every container)
54
  RUN uv run python seed_db.py
55
 
56
+ EXPOSE 7860 6379 27017
57
 
58
+ # Start Redis, MongoDB and the app
59
+ CMD ["sh", "-c", "\
60
+ redis-server --daemonize yes --dir /app/redis_data --appendonly yes && \
61
+ (mongod --dbpath /app/mongo_data --bind_ip 127.0.0.1 --fork --logpath /app/mongo_data/mongod.log 2>/dev/null || echo 'MongoDB not available') && \
62
+ uvicorn main:app --host 0.0.0.0 --port 7860"]
main.py CHANGED
@@ -35,22 +35,44 @@ from redis_client import (
35
  get_cached_user, cache_user, invalidate_user_cache,
36
  get_cached_playlist, cache_playlist, invalidate_playlist_cache,
37
  get_cached_list, cache_list, invalidate_list_cache, cache_delete_pattern,
38
- store_play_event, get_user_play_history, get_global_play_history,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  )
40
- import asyncio
41
 
42
  app = FastAPI()
43
 
44
 
45
  @asynccontextmanager
46
  async def lifespan(app: FastAPI):
47
- """Application lifespan manager for Redis and database initialization."""
48
  # Startup
49
  init_database()
50
  await init_redis()
 
51
  yield
52
  # Shutdown
53
  await close_redis()
 
54
 
55
 
56
  app.router.lifespan_context = lifespan
@@ -63,9 +85,11 @@ def greet_json():
63
 
64
  @app.get("/health")
65
  async def health_check():
66
- """Health check endpoint with Redis status."""
67
  from redis_client import get_redis_client
68
  redis_status = "disconnected"
 
 
69
  try:
70
  redis_client = get_redis_client()
71
  if redis_client:
@@ -74,9 +98,18 @@ async def health_check():
74
  except Exception:
75
  redis_status = "error"
76
 
 
 
 
 
 
 
 
 
77
  return {
78
  "status": "healthy",
79
  "redis": redis_status,
 
80
  "database": "sqlite"
81
  }
82
 
@@ -99,19 +132,23 @@ async def list_songs():
99
 
100
  @app.get("/songs/{song_id}")
101
  async def get_song(song_id: int):
102
- """Get a song by ID."""
103
  # Try cache first
104
  song = await get_cached_song(song_id)
105
  if song:
106
- return {**song, "source": "cache"}
 
 
107
 
108
  # Fetch from database
109
  song = get_song_by_id(song_id)
110
  if not song:
111
  raise HTTPException(status_code=404, detail="Song not found")
112
 
 
 
113
  await cache_song(song_id, song)
114
- return {**song, "source": "database"}
115
 
116
 
117
  @app.post("/songs")
@@ -124,6 +161,8 @@ async def create_song(title: str, artist: str, album: str = None, duration: int
124
  add_song_vibe(song["id"], title, artist, lyrics)
125
  # Invalidate songs list cache
126
  await invalidate_list_cache("all_songs")
 
 
127
  return {"status": "success", "song": song}
128
 
129
 
@@ -135,6 +174,8 @@ async def delete_song_endpoint(song_id: int):
135
  remove_song_vibe(song_id)
136
  await invalidate_song_cache(song_id)
137
  await invalidate_list_cache("all_songs")
 
 
138
  return {"status": "success", "deleted_id": song_id}
139
 
140
 
@@ -176,6 +217,9 @@ async def create_user(name: str):
176
  """Add a new user."""
177
  user = add_user(name)
178
  await invalidate_list_cache("all_users")
 
 
 
179
  return {"status": "success", "user": user}
180
 
181
 
@@ -203,6 +247,8 @@ async def create_playlist(name: str, vibe_code: str = None, mood_description: st
203
  if mood_description:
204
  add_playlist_journey(playlist["id"], name, mood_description)
205
  await invalidate_list_cache("all_playlists")
 
 
206
  return {"status": "success", "playlist": playlist}
207
 
208
 
@@ -214,6 +260,8 @@ async def delete_playlist_endpoint(playlist_id: int):
214
  remove_playlist_journey(playlist_id)
215
  await invalidate_playlist_cache(playlist_id)
216
  await invalidate_list_cache("all_playlists")
 
 
217
  return {"status": "success", "deleted_id": playlist_id}
218
 
219
 
@@ -260,6 +308,9 @@ async def create_memory(user_id: int, description: str, date: str = None, song_i
260
  add_memory_vibe(memory["id"], user_id, description)
261
  await invalidate_list_cache("all_memories")
262
  await invalidate_list_cache(f"user_memories:{user_id}")
 
 
 
263
  return {"status": "success", "memory": memory}
264
 
265
 
@@ -271,6 +322,8 @@ async def delete_memory_endpoint(memory_id: int):
271
  remove_memory_vibe(memory_id)
272
  await cache_delete_pattern("list:user_memories:*")
273
  await invalidate_list_cache("all_memories")
 
 
274
  return {"status": "success", "deleted_id": memory_id}
275
 
276
 
@@ -297,6 +350,8 @@ async def create_context(weather: str = None, time_of_day: str = None, location_
297
  # Add to semantic search index
298
  add_context_vibe(context["id"], weather or "", time_of_day or "", location_type or "")
299
  await invalidate_list_cache("all_contexts")
 
 
300
  return {"status": "success", "context": context}
301
 
302
 
@@ -307,66 +362,148 @@ async def delete_context_endpoint(context_id: int):
307
  raise HTTPException(status_code=404, detail="Context not found")
308
  remove_context_vibe(context_id)
309
  await invalidate_list_cache("all_contexts")
 
 
310
  return {"status": "success", "deleted_id": context_id}
311
 
312
 
313
- # ============== PLAY HISTORY ==============
314
 
315
  @app.get("/history")
316
  async def list_history(user_id: int = None, limit: int = 50):
317
- """Get play history."""
318
  if user_id:
319
- # Try Redis first (hot storage)
320
- history = await get_user_play_history(user_id, limit)
321
  if not history:
322
- # Fallback to SQLite
323
- history = get_play_history(user_id, limit)
324
- return {"history": history, "source": "redis" if history else "database"}
 
 
 
 
 
325
  else:
326
- # Try Redis first (hot storage)
327
- history = await get_global_play_history(limit)
328
  if not history:
329
- # Fallback to SQLite
330
- history = get_play_history(None, limit)
331
- return {"history": history, "source": "redis" if history else "database"}
 
 
 
 
 
332
 
333
 
334
  @app.post("/history")
335
- async def create_history(user_id: int, song_id: int, context_id: int = None):
336
- """Add a play history entry."""
337
- # Store in Redis (fast write) and SQLite (persistent)
338
- redis_event = await store_play_event(user_id, song_id, context_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  sqlite_history = add_play_history(user_id, song_id, context_id)
340
- return {"status": "success", "history": sqlite_history, "redis_stored": True}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
 
342
 
343
  # ============== SEMANTIC SEARCH ==============
344
 
345
  @app.get("/search/songs")
346
- def search_songs(q: str = Query(..., description="Search query"), n: int = Query(5, description="Number of results")):
347
  """Semantic search for songs by vibe/lyrics."""
348
  results = search_song_vibes(q, n_results=n)
 
 
 
349
  return {"query": q, "results": results}
350
 
351
 
352
  @app.get("/search/memories")
353
- def search_memories(q: str = Query(..., description="Search query"), n: int = Query(5, description="Number of results")):
354
  """Semantic search for memories."""
355
  results = search_memory_vibes(q, n_results=n)
 
 
 
356
  return {"query": q, "results": results}
357
 
358
 
359
  @app.get("/search/contexts")
360
- def search_contexts(q: str = Query(..., description="Search query"), n: int = Query(5, description="Number of results")):
361
  """Semantic search for contexts."""
362
  results = search_context_vibes(q, n_results=n)
 
 
 
363
  return {"query": q, "results": results}
364
 
365
 
366
  @app.get("/search/playlists")
367
- def search_playlists(q: str = Query(..., description="Search query"), n: int = Query(5, description="Number of results")):
368
  """Semantic search for playlists by mood."""
369
  results = search_playlist_journeys(q, n_results=n)
 
 
 
370
  return {"query": q, "results": results}
371
 
372
 
 
35
  get_cached_user, cache_user, invalidate_user_cache,
36
  get_cached_playlist, cache_playlist, invalidate_playlist_cache,
37
  get_cached_list, cache_list, invalidate_list_cache, cache_delete_pattern,
38
+ store_play_event, get_user_play_history as get_redis_play_history,
39
+ get_global_play_history as get_redis_global_history,
40
+ )
41
+ from mongo_client import (
42
+ init_mongodb,
43
+ close_mongodb,
44
+ get_mongo_db,
45
+ # Play history
46
+ store_play_history_mongo,
47
+ get_user_play_history_mongo,
48
+ get_global_play_history_mongo,
49
+ get_top_songs_mongo,
50
+ get_song_play_count_mongo,
51
+ # Activity logs
52
+ log_user_activity,
53
+ get_user_activity_mongo,
54
+ # Analytics
55
+ track_analytics_event,
56
+ get_analytics_summary,
57
+ # Search history
58
+ log_search,
59
+ get_popular_searches,
60
  )
 
61
 
62
  app = FastAPI()
63
 
64
 
65
  @asynccontextmanager
66
  async def lifespan(app: FastAPI):
67
+ """Application lifespan manager for Redis, MongoDB and database initialization."""
68
  # Startup
69
  init_database()
70
  await init_redis()
71
+ await init_mongodb()
72
  yield
73
  # Shutdown
74
  await close_redis()
75
+ await close_mongodb()
76
 
77
 
78
  app.router.lifespan_context = lifespan
 
85
 
86
  @app.get("/health")
87
  async def health_check():
88
+ """Health check endpoint with Redis and MongoDB status."""
89
  from redis_client import get_redis_client
90
  redis_status = "disconnected"
91
+ mongo_status = "disconnected"
92
+
93
  try:
94
  redis_client = get_redis_client()
95
  if redis_client:
 
98
  except Exception:
99
  redis_status = "error"
100
 
101
+ try:
102
+ mongo_db = get_mongo_db()
103
+ if mongo_db:
104
+ await mongo_db.command('ping')
105
+ mongo_status = "connected"
106
+ except Exception:
107
+ mongo_status = "error"
108
+
109
  return {
110
  "status": "healthy",
111
  "redis": redis_status,
112
+ "mongodb": mongo_status,
113
  "database": "sqlite"
114
  }
115
 
 
132
 
133
  @app.get("/songs/{song_id}")
134
  async def get_song(song_id: int):
135
+ """Get a song by ID with play count from MongoDB."""
136
  # Try cache first
137
  song = await get_cached_song(song_id)
138
  if song:
139
+ # Add play count from MongoDB
140
+ play_count = await get_song_play_count_mongo(song_id)
141
+ return {**song, "source": "cache", "play_count": play_count}
142
 
143
  # Fetch from database
144
  song = get_song_by_id(song_id)
145
  if not song:
146
  raise HTTPException(status_code=404, detail="Song not found")
147
 
148
+ # Add play count from MongoDB
149
+ play_count = await get_song_play_count_mongo(song_id)
150
  await cache_song(song_id, song)
151
+ return {**song, "source": "database", "play_count": play_count}
152
 
153
 
154
  @app.post("/songs")
 
161
  add_song_vibe(song["id"], title, artist, lyrics)
162
  # Invalidate songs list cache
163
  await invalidate_list_cache("all_songs")
164
+ # Log activity to MongoDB
165
+ await track_analytics_event("song_created", {"song_id": song["id"], "title": title})
166
  return {"status": "success", "song": song}
167
 
168
 
 
174
  remove_song_vibe(song_id)
175
  await invalidate_song_cache(song_id)
176
  await invalidate_list_cache("all_songs")
177
+ # Log activity to MongoDB
178
+ await track_analytics_event("song_deleted", {"song_id": song_id})
179
  return {"status": "success", "deleted_id": song_id}
180
 
181
 
 
217
  """Add a new user."""
218
  user = add_user(name)
219
  await invalidate_list_cache("all_users")
220
+ # Log activity to MongoDB
221
+ await log_user_activity(user["id"], "user_registered", {"name": name})
222
+ await track_analytics_event("user_signup", {"user_id": user["id"]})
223
  return {"status": "success", "user": user}
224
 
225
 
 
247
  if mood_description:
248
  add_playlist_journey(playlist["id"], name, mood_description)
249
  await invalidate_list_cache("all_playlists")
250
+ # Log activity to MongoDB
251
+ await track_analytics_event("playlist_created", {"playlist_id": playlist["id"], "name": name})
252
  return {"status": "success", "playlist": playlist}
253
 
254
 
 
260
  remove_playlist_journey(playlist_id)
261
  await invalidate_playlist_cache(playlist_id)
262
  await invalidate_list_cache("all_playlists")
263
+ # Log activity to MongoDB
264
+ await track_analytics_event("playlist_deleted", {"playlist_id": playlist_id})
265
  return {"status": "success", "deleted_id": playlist_id}
266
 
267
 
 
308
  add_memory_vibe(memory["id"], user_id, description)
309
  await invalidate_list_cache("all_memories")
310
  await invalidate_list_cache(f"user_memories:{user_id}")
311
+ # Log activity to MongoDB
312
+ await log_user_activity(user_id, "memory_created", {"memory_id": memory["id"]})
313
+ await track_analytics_event("memory_added", {"memory_id": memory["id"], "user_id": user_id})
314
  return {"status": "success", "memory": memory}
315
 
316
 
 
322
  remove_memory_vibe(memory_id)
323
  await cache_delete_pattern("list:user_memories:*")
324
  await invalidate_list_cache("all_memories")
325
+ # Log activity to MongoDB
326
+ await track_analytics_event("memory_deleted", {"memory_id": memory_id})
327
  return {"status": "success", "deleted_id": memory_id}
328
 
329
 
 
350
  # Add to semantic search index
351
  add_context_vibe(context["id"], weather or "", time_of_day or "", location_type or "")
352
  await invalidate_list_cache("all_contexts")
353
+ # Log activity to MongoDB
354
+ await track_analytics_event("context_created", {"context_id": context["id"]})
355
  return {"status": "success", "context": context}
356
 
357
 
 
362
  raise HTTPException(status_code=404, detail="Context not found")
363
  remove_context_vibe(context_id)
364
  await invalidate_list_cache("all_contexts")
365
+ # Log activity to MongoDB
366
+ await track_analytics_event("context_deleted", {"context_id": context_id})
367
  return {"status": "success", "deleted_id": context_id}
368
 
369
 
370
+ # ============== PLAY HISTORY (MongoDB Primary) ==============
371
 
372
  @app.get("/history")
373
  async def list_history(user_id: int = None, limit: int = 50):
374
+ """Get play history from MongoDB (primary storage)."""
375
  if user_id:
376
+ # Try MongoDB first (primary storage)
377
+ history = await get_user_play_history_mongo(user_id, limit)
378
  if not history:
379
+ # Fallback to Redis
380
+ history = await get_redis_play_history(user_id, limit)
381
+ if not history:
382
+ # Fallback to SQLite
383
+ history = get_play_history(user_id, limit)
384
+ return {"history": history, "source": "sqlite"}
385
+ return {"history": history, "source": "redis"}
386
+ return {"history": history, "source": "mongodb"}
387
  else:
388
+ # Try MongoDB first (primary storage)
389
+ history = await get_global_play_history_mongo(limit)
390
  if not history:
391
+ # Fallback to Redis
392
+ history = await get_redis_global_history(limit)
393
+ if not history:
394
+ # Fallback to SQLite
395
+ history = get_play_history(None, limit)
396
+ return {"history": history, "source": "sqlite"}
397
+ return {"history": history, "source": "redis"}
398
+ return {"history": history, "source": "mongodb"}
399
 
400
 
401
  @app.post("/history")
402
+ async def create_history(user_id: int, song_id: int, context_id: int = None, duration_seconds: int = None):
403
+ """Add a play history entry to MongoDB (primary), Redis (cache), and SQLite (backup)."""
404
+ # Get song info for MongoDB storage
405
+ song = get_song_by_id(song_id)
406
+
407
+ # Store in MongoDB (primary storage for analytics)
408
+ mongo_result = await store_play_history_mongo(
409
+ user_id=user_id,
410
+ song_id=song_id,
411
+ song_title=song.get("title") if song else None,
412
+ song_artist=song.get("artist") if song else None,
413
+ context_id=context_id,
414
+ duration_seconds=duration_seconds
415
+ )
416
+
417
+ # Store in Redis (fast cache)
418
+ await store_play_event(user_id, song_id, context_id)
419
+
420
+ # Store in SQLite (backup)
421
  sqlite_history = add_play_history(user_id, song_id, context_id)
422
+
423
+ # Log analytics event
424
+ await track_analytics_event("song_played", {
425
+ "user_id": user_id,
426
+ "song_id": song_id,
427
+ "context_id": context_id
428
+ })
429
+
430
+ return {
431
+ "status": "success",
432
+ "history": sqlite_history,
433
+ "mongodb_id": mongo_result.get("id") if mongo_result else None,
434
+ "redis_stored": True
435
+ }
436
+
437
+
438
+ # ============== ANALYTICS ENDPOINTS (MongoDB) ==============
439
+
440
+ @app.get("/analytics/summary")
441
+ async def get_analytics(hours: int = Query(24, description="Hours to look back")):
442
+ """Get analytics summary from MongoDB."""
443
+ summary = await get_analytics_summary(hours=hours)
444
+ return summary
445
+
446
+
447
+ @app.get("/analytics/top-songs")
448
+ async def get_top_songs(limit: int = Query(10, description="Number of results")):
449
+ """Get top played songs from MongoDB."""
450
+ top_songs = await get_top_songs_mongo(limit)
451
+ return {"top_songs": top_songs}
452
+
453
+
454
+ @app.get("/analytics/popular-searches")
455
+ async def get_popular_searches_endpoint(hours: int = Query(24, description="Hours to look back"), limit: int = Query(10)):
456
+ """Get popular search queries from MongoDB."""
457
+ searches = await get_popular_searches(hours=hours, limit=limit)
458
+ return {"popular_searches": searches}
459
+
460
+
461
+ @app.get("/users/{user_id}/activity")
462
+ async def get_user_activity(user_id: int, limit: int = Query(50), action_type: str = None):
463
+ """Get user activity log from MongoDB."""
464
+ activity = await get_user_activity_mongo(user_id, limit, action_type)
465
+ return {"activity": activity}
466
 
467
 
468
  # ============== SEMANTIC SEARCH ==============
469
 
470
  @app.get("/search/songs")
471
+ async def search_songs(q: str = Query(..., description="Search query"), n: int = Query(5, description="Number of results")):
472
  """Semantic search for songs by vibe/lyrics."""
473
  results = search_song_vibes(q, n_results=n)
474
+ # Log search to MongoDB
475
+ await log_search(search_type="songs", query=q, results_count=len(results))
476
+ await track_analytics_event("search_performed", {"search_type": "songs", "query": q, "results": len(results)})
477
  return {"query": q, "results": results}
478
 
479
 
480
  @app.get("/search/memories")
481
+ async def search_memories(q: str = Query(..., description="Search query"), n: int = Query(5, description="Number of results")):
482
  """Semantic search for memories."""
483
  results = search_memory_vibes(q, n_results=n)
484
+ # Log search to MongoDB
485
+ await log_search(search_type="memories", query=q, results_count=len(results))
486
+ await track_analytics_event("search_performed", {"search_type": "memories", "query": q, "results": len(results)})
487
  return {"query": q, "results": results}
488
 
489
 
490
  @app.get("/search/contexts")
491
+ async def search_contexts(q: str = Query(..., description="Search query"), n: int = Query(5, description="Number of results")):
492
  """Semantic search for contexts."""
493
  results = search_context_vibes(q, n_results=n)
494
+ # Log search to MongoDB
495
+ await log_search(search_type="contexts", query=q, results_count=len(results))
496
+ await track_analytics_event("search_performed", {"search_type": "contexts", "query": q, "results": len(results)})
497
  return {"query": q, "results": results}
498
 
499
 
500
  @app.get("/search/playlists")
501
+ async def search_playlists(q: str = Query(..., description="Search query"), n: int = Query(5, description="Number of results")):
502
  """Semantic search for playlists by mood."""
503
  results = search_playlist_journeys(q, n_results=n)
504
+ # Log search to MongoDB
505
+ await log_search(search_type="playlists", query=q, results_count=len(results))
506
+ await track_analytics_event("search_performed", {"search_type": "playlists", "query": q, "results": len(results)})
507
  return {"query": q, "results": results}
508
 
509
 
mongo_client.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MongoDB client module for play history, activity logs, and analytics."""
2
+
3
+ import os
4
+ from datetime import datetime
5
+ from typing import Optional, List, Dict, Any
6
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
7
+ from pymongo import ASCENDING, DESCENDING
8
+
9
+
10
+ # MongoDB configuration from environment variables
11
+ MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017")
12
+ MONGO_DB_NAME = os.getenv("MONGO_DB_NAME", "music_memories")
13
+
14
+ # Global MongoDB client and database instances
15
+ _mongo_client: Optional[AsyncIOMotorClient] = None
16
+ _mongo_db: Optional[AsyncIOMotorDatabase] = None
17
+
18
+
19
+ def get_mongo_client() -> Optional[AsyncIOMotorClient]:
20
+ """Get the MongoDB client instance."""
21
+ return _mongo_client
22
+
23
+
24
+ def get_mongo_db() -> Optional[AsyncIOMotorDatabase]:
25
+ """Get the MongoDB database instance."""
26
+ return _mongo_db
27
+
28
+
29
+ async def init_mongodb() -> None:
30
+ """Initialize MongoDB connection."""
31
+ global _mongo_client, _mongo_db
32
+ try:
33
+ _mongo_client = AsyncIOMotorClient(MONGO_URI)
34
+ _mongo_db = _mongo_client[MONGO_DB_NAME]
35
+
36
+ # Test connection
37
+ await _mongo_client.admin.command('ping')
38
+ print(f"✓ Connected to MongoDB at {MONGO_URI}")
39
+
40
+ # Create indexes for play_history collection
41
+ await _mongo_db.play_history.create_index([("user_id", ASCENDING), ("played_at", DESCENDING)])
42
+ await _mongo_db.play_history.create_index([("song_id", ASCENDING)])
43
+ await _mongo_db.play_history.create_index([("played_at", DESCENDING)])
44
+
45
+ # Create indexes for activity_logs collection
46
+ await _mongo_db.activity_logs.create_index([("user_id", ASCENDING), ("timestamp", DESCENDING)])
47
+ await _mongo_db.activity_logs.create_index([("action_type", ASCENDING)])
48
+ await _mongo_db.activity_logs.create_index([("timestamp", DESCENDING)])
49
+
50
+ # Create indexes for analytics collection
51
+ await _mongo_db.analytics.create_index([("event_type", ASCENDING), ("timestamp", DESCENDING)])
52
+ await _mongo_db.analytics.create_index([("timestamp", DESCENDING)])
53
+
54
+ print("✓ MongoDB indexes created")
55
+ except Exception as e:
56
+ print(f"⚠ MongoDB connection failed: {e}")
57
+ print("⚠ Running without MongoDB (using Redis/SQLite fallback)")
58
+ _mongo_client = None
59
+ _mongo_db = None
60
+
61
+
62
+ async def close_mongodb() -> None:
63
+ """Close MongoDB connection."""
64
+ global _mongo_client, _mongo_db
65
+ if _mongo_client:
66
+ _mongo_client.close()
67
+ _mongo_client = None
68
+ _mongo_db = None
69
+
70
+
71
+ # ============== Play History (MongoDB) ==============
72
+
73
+ async def store_play_history_mongo(
74
+ user_id: int,
75
+ song_id: int,
76
+ song_title: str = None,
77
+ song_artist: str = None,
78
+ context_id: int = None,
79
+ duration_seconds: int = None,
80
+ completed: bool = True
81
+ ) -> Dict[str, Any]:
82
+ """Store a play history event in MongoDB."""
83
+ if _mongo_db is None:
84
+ return None
85
+
86
+ document = {
87
+ "user_id": user_id,
88
+ "song_id": song_id,
89
+ "song_title": song_title,
90
+ "song_artist": song_artist,
91
+ "context_id": context_id,
92
+ "duration_seconds": duration_seconds,
93
+ "completed": completed,
94
+ "played_at": datetime.utcnow(),
95
+ "created_at": datetime.utcnow()
96
+ }
97
+
98
+ result = await _mongo_db.play_history.insert_one(document)
99
+ return {
100
+ "id": str(result.inserted_id),
101
+ **document
102
+ }
103
+
104
+
105
+ async def get_user_play_history_mongo(
106
+ user_id: int,
107
+ limit: int = 50
108
+ ) -> List[Dict[str, Any]]:
109
+ """Get play history for a user from MongoDB."""
110
+ if _mongo_db is None:
111
+ return []
112
+
113
+ cursor = _mongo_db.play_history.find(
114
+ {"user_id": user_id}
115
+ ).sort("played_at", DESCENDING).limit(limit)
116
+
117
+ results = []
118
+ async for doc in cursor:
119
+ results.append({
120
+ "id": str(doc["_id"]),
121
+ "user_id": doc["user_id"],
122
+ "song_id": doc["song_id"],
123
+ "song_title": doc.get("song_title"),
124
+ "song_artist": doc.get("song_artist"),
125
+ "context_id": doc.get("context_id"),
126
+ "duration_seconds": doc.get("duration_seconds"),
127
+ "completed": doc.get("completed", True),
128
+ "played_at": doc["played_at"].isoformat() if doc.get("played_at") else None
129
+ })
130
+
131
+ return results
132
+
133
+
134
+ async def get_global_play_history_mongo(
135
+ limit: int = 50
136
+ ) -> List[Dict[str, Any]]:
137
+ """Get global play history from MongoDB."""
138
+ if _mongo_db is None:
139
+ return []
140
+
141
+ cursor = _mongo_db.play_history.find({}).sort("played_at", DESCENDING).limit(limit)
142
+
143
+ results = []
144
+ async for doc in cursor:
145
+ results.append({
146
+ "id": str(doc["_id"]),
147
+ "user_id": doc["user_id"],
148
+ "song_id": doc["song_id"],
149
+ "song_title": doc.get("song_title"),
150
+ "song_artist": doc.get("song_artist"),
151
+ "context_id": doc.get("context_id"),
152
+ "duration_seconds": doc.get("duration_seconds"),
153
+ "completed": doc.get("completed", True),
154
+ "played_at": doc["played_at"].isoformat() if doc.get("played_at") else None
155
+ })
156
+
157
+ return results
158
+
159
+
160
+ async def get_song_play_count_mongo(song_id: int) -> int:
161
+ """Get total play count for a song."""
162
+ if _mongo_db is None:
163
+ return 0
164
+
165
+ count = await _mongo_db.play_history.count_documents({"song_id": song_id})
166
+ return count
167
+
168
+
169
+ async def get_top_songs_mongo(limit: int = 10) -> List[Dict[str, Any]]:
170
+ """Get top played songs from MongoDB analytics."""
171
+ if _mongo_db is None:
172
+ return []
173
+
174
+ pipeline = [
175
+ {"$group": {
176
+ "_id": "$song_id",
177
+ "play_count": {"$sum": 1},
178
+ "song_title": {"$first": "$song_title"},
179
+ "song_artist": {"$first": "$song_artist"}
180
+ }},
181
+ {"$sort": {"play_count": -1}},
182
+ {"$limit": limit}
183
+ ]
184
+
185
+ results = []
186
+ async for doc in _mongo_db.play_history.aggregate(pipeline):
187
+ results.append({
188
+ "song_id": doc["_id"],
189
+ "song_title": doc.get("song_title"),
190
+ "song_artist": doc.get("song_artist"),
191
+ "play_count": doc["play_count"]
192
+ })
193
+
194
+ return results
195
+
196
+
197
+ # ============== Activity Logs (MongoDB) ==============
198
+
199
+ async def log_user_activity(
200
+ user_id: int,
201
+ action_type: str,
202
+ details: Dict[str, Any] = None
203
+ ) -> Dict[str, Any]:
204
+ """Log a user activity event."""
205
+ if _mongo_db is None:
206
+ return None
207
+
208
+ document = {
209
+ "user_id": user_id,
210
+ "action_type": action_type,
211
+ "details": details or {},
212
+ "timestamp": datetime.utcnow()
213
+ }
214
+
215
+ result = await _mongo_db.activity_logs.insert_one(document)
216
+ return {
217
+ "id": str(result.inserted_id),
218
+ **document
219
+ }
220
+
221
+
222
+ async def get_user_activity_mongo(
223
+ user_id: int,
224
+ limit: int = 50,
225
+ action_type: str = None
226
+ ) -> List[Dict[str, Any]]:
227
+ """Get activity logs for a user."""
228
+ if _mongo_db is None:
229
+ return []
230
+
231
+ query = {"user_id": user_id}
232
+ if action_type:
233
+ query["action_type"] = action_type
234
+
235
+ cursor = _mongo_db.activity_logs.find(query).sort("timestamp", DESCENDING).limit(limit)
236
+
237
+ results = []
238
+ async for doc in cursor:
239
+ results.append({
240
+ "id": str(doc["_id"]),
241
+ "user_id": doc["user_id"],
242
+ "action_type": doc["action_type"],
243
+ "details": doc.get("details", {}),
244
+ "timestamp": doc["timestamp"].isoformat() if doc.get("timestamp") else None
245
+ })
246
+
247
+ return results
248
+
249
+
250
+ # ============== Analytics Events (MongoDB) ==============
251
+
252
+ async def track_analytics_event(
253
+ event_type: str,
254
+ properties: Dict[str, Any] = None
255
+ ) -> Dict[str, Any]:
256
+ """Track an analytics event."""
257
+ if _mongo_db is None:
258
+ return None
259
+
260
+ document = {
261
+ "event_type": event_type,
262
+ "properties": properties or {},
263
+ "timestamp": datetime.utcnow()
264
+ }
265
+
266
+ result = await _mongo_db.analytics.insert_one(document)
267
+ return {
268
+ "id": str(result.inserted_id),
269
+ **document
270
+ }
271
+
272
+
273
+ async def get_analytics_summary(
274
+ event_type: str = None,
275
+ hours: int = 24
276
+ ) -> Dict[str, Any]:
277
+ """Get analytics summary for the last N hours."""
278
+ if _mongo_db is None:
279
+ return {}
280
+
281
+ from datetime import timedelta
282
+ since = datetime.utcnow() - timedelta(hours=hours)
283
+
284
+ query = {"timestamp": {"$gte": since}}
285
+ if event_type:
286
+ query["event_type"] = event_type
287
+
288
+ total_events = await _mongo_db.analytics.count_documents(query)
289
+
290
+ # Get event type breakdown
291
+ pipeline = [
292
+ {"$match": query},
293
+ {"$group": {
294
+ "_id": "$event_type",
295
+ "count": {"$sum": 1}
296
+ }},
297
+ {"$sort": {"count": -1}}
298
+ ]
299
+
300
+ breakdown = []
301
+ async for doc in _mongo_db.analytics.aggregate(pipeline):
302
+ breakdown.append({
303
+ "event_type": doc["_id"],
304
+ "count": doc["count"]
305
+ })
306
+
307
+ return {
308
+ "total_events": total_events,
309
+ "period_hours": hours,
310
+ "breakdown": breakdown
311
+ }
312
+
313
+
314
+ # ============== User Search History (MongoDB) ==============
315
+
316
+ async def log_search(
317
+ user_id: int = None,
318
+ search_type: str = None,
319
+ query: str = None,
320
+ results_count: int = 0
321
+ ) -> Dict[str, Any]:
322
+ """Log a search event."""
323
+ if _mongo_db is None:
324
+ return None
325
+
326
+ document = {
327
+ "user_id": user_id,
328
+ "search_type": search_type,
329
+ "query": query,
330
+ "results_count": results_count,
331
+ "timestamp": datetime.utcnow()
332
+ }
333
+
334
+ result = await _mongo_db.search_history.insert_one(document)
335
+ return {
336
+ "id": str(result.inserted_id),
337
+ **document
338
+ }
339
+
340
+
341
+ async def get_popular_searches(
342
+ hours: int = 24,
343
+ limit: int = 10
344
+ ) -> List[Dict[str, Any]]:
345
+ """Get popular search queries."""
346
+ if _mongo_db is None:
347
+ return []
348
+
349
+ from datetime import timedelta
350
+ since = datetime.utcnow() - timedelta(hours=hours)
351
+
352
+ pipeline = [
353
+ {"$match": {"timestamp": {"$gte": since}}},
354
+ {"$group": {
355
+ "_id": "$query",
356
+ "count": {"$sum": 1},
357
+ "search_type": {"$first": "$search_type"}
358
+ }},
359
+ {"$sort": {"count": -1}},
360
+ {"$limit": limit}
361
+ ]
362
+
363
+ results = []
364
+ async for doc in _mongo_db.search_history.aggregate(pipeline):
365
+ results.append({
366
+ "query": doc["_id"],
367
+ "search_type": doc.get("search_type"),
368
+ "count": doc["count"]
369
+ })
370
+
371
+ return results
pyproject.toml CHANGED
@@ -14,6 +14,8 @@ dependencies = [
14
  "huggingface-hub>=1.8.0",
15
  "peft>=0.18.1",
16
  "redis>=5.0.0",
 
 
17
  "sentence-transformers>=3.0.0",
18
  "torch>=2.5.0",
19
  "torchaudio>=2.5.0",
 
14
  "huggingface-hub>=1.8.0",
15
  "peft>=0.18.1",
16
  "redis>=5.0.0",
17
+ "pymongo>=4.6.0",
18
+ "motor>=3.4.0",
19
  "sentence-transformers>=3.0.0",
20
  "torch>=2.5.0",
21
  "torchaudio>=2.5.0",