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

s3 storage

Browse files
Files changed (5) hide show
  1. Dockerfile +18 -8
  2. database.py +14 -1
  3. main.py +198 -35
  4. minio_client.py +211 -0
  5. pyproject.toml +2 -0
Dockerfile CHANGED
@@ -1,22 +1,25 @@
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
22
  USER user
@@ -31,6 +34,11 @@ 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,16 +55,18 @@ RUN uv venv /app/.venv \
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"]
 
1
+ # Hugging Face Spaces (Docker SDK) - FastAPI + Uvicorn + Redis + MongoDB + MinIO
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 wget && \
8
  rm -rf /var/lib/apt/lists/*
9
 
10
+ # Install MongoDB
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
  echo "MongoDB install attempted"
18
 
19
+ # Install MinIO
20
+ RUN wget -q https://dl.min.io/server/minio/release/linux-amd64/minio -O /usr/local/bin/minio && \
21
+ chmod +x /usr/local/bin/minio
22
+
23
  # Create a non-root user matching HF Spaces expectations
24
  RUN useradd -m -u 1000 user
25
  USER user
 
34
  ENV CACHE_TTL="300"
35
  ENV MONGO_URI="mongodb://127.0.0.1:27017"
36
  ENV MONGO_DB_NAME="music_memories"
37
+ ENV MINIO_ENDPOINT="127.0.0.1:9000"
38
+ ENV MINIO_ACCESS_KEY="minioadmin"
39
+ ENV MINIO_SECRET_KEY="minioadmin"
40
+ ENV MINIO_BUCKET="music-memories"
41
+ ENV MINIO_SECURE="false"
42
  WORKDIR /app
43
 
44
  # Install uv (dependency manager)
 
55
  COPY --chown=user . /app
56
 
57
  # Ensure data directories exist and are writable
58
+ RUN mkdir -p /app/chroma_db /app/data /app/redis_data /app/mongo_data /app/minio_data && \
59
+ chown -R user:user /app/chroma_db /app/data /app/redis_data /app/mongo_data /app/minio_data
60
 
61
  # Seed the database (consistent data in every container)
62
  RUN uv run python seed_db.py
63
 
64
+ EXPOSE 7860 6379 27017 9000 9001
65
 
66
+ # Start Redis, MongoDB, MinIO and the app
67
  CMD ["sh", "-c", "\
68
  redis-server --daemonize yes --dir /app/redis_data --appendonly yes && \
69
  (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') && \
70
+ (minio server /app/minio_data --address 127.0.0.1:9000 --console-address 127.0.0.1:9001 --quiet &>/dev/null &) && \
71
+ sleep 2 && \
72
  uvicorn main:app --host 0.0.0.0 --port 7860"]
database.py CHANGED
@@ -31,7 +31,9 @@ def init_database() -> None:
31
  album TEXT,
32
  duration INTEGER,
33
  bpm INTEGER,
34
- energy_level INTEGER CHECK(energy_level BETWEEN 1 AND 10)
 
 
35
  )
36
  """)
37
 
@@ -134,6 +136,17 @@ def delete_song(song_id: int) -> bool:
134
  return cursor.rowcount > 0
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
137
  # ============== USERS ==============
138
 
139
  def add_user(name: str) -> dict:
 
31
  album TEXT,
32
  duration INTEGER,
33
  bpm INTEGER,
34
+ energy_level INTEGER CHECK(energy_level BETWEEN 1 AND 10),
35
+ audio_file_path TEXT,
36
+ file_size INTEGER
37
  )
38
  """)
39
 
 
136
  return cursor.rowcount > 0
137
 
138
 
139
+ def update_song_file(song_id: int, audio_file_path: str, file_size: int) -> bool:
140
+ """Update song with MinIO file path and size."""
141
+ with get_db_connection() as conn:
142
+ cursor = conn.execute(
143
+ """UPDATE songs SET audio_file_path = ?, file_size = ? WHERE id = ?""",
144
+ (audio_file_path, file_size, song_id)
145
+ )
146
+ conn.commit()
147
+ return cursor.rowcount > 0
148
+
149
+
150
  # ============== USERS ==============
151
 
152
  def add_user(name: str) -> dict:
main.py CHANGED
@@ -1,12 +1,12 @@
1
  from contextlib import asynccontextmanager
2
- from fastapi import FastAPI, Query, HTTPException
3
- from fastapi.responses import JSONResponse
4
  import gradio as gr
5
  from app import gradio_app
6
  from database import (
7
  init_database,
8
  # Songs
9
- add_song, get_all_songs, get_song_by_id, delete_song,
10
  # Users
11
  add_user, get_all_users, get_user_by_id,
12
  # Playlists
@@ -58,17 +58,30 @@ from mongo_client import (
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()
@@ -85,11 +98,12 @@ def greet_json():
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:
@@ -97,7 +111,7 @@ async def health_check():
97
  redis_status = "connected"
98
  except Exception:
99
  redis_status = "error"
100
-
101
  try:
102
  mongo_db = get_mongo_db()
103
  if mongo_db:
@@ -105,80 +119,222 @@ async def health_check():
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
 
116
 
117
- # ============== SONGS ==============
118
 
119
  @app.get("/songs")
120
  async def list_songs():
121
- """Get all songs."""
122
  # Try cache first
123
  cached = await get_cached_list("all_songs")
124
  if cached:
125
  return {"songs": cached, "source": "cache"}
126
-
127
  # Fetch from database
128
  songs = get_all_songs()
 
 
 
 
 
 
 
129
  await cache_list("all_songs", songs)
130
  return {"songs": songs, "source": "database"}
131
 
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")
155
- async def create_song(title: str, artist: str, album: str = None, duration: int = None,
156
- bpm: int = None, energy_level: int = None, lyrics: str = None):
157
- """Add a new song."""
 
 
 
 
 
 
 
 
158
  song = add_song(title, artist, album, duration, bpm, energy_level)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  # Add to semantic search index if lyrics provided
160
  if lyrics:
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
 
169
  @app.delete("/songs/{song_id}")
170
  async def delete_song_endpoint(song_id: int):
171
- """Delete a song."""
172
  if not delete_song(song_id):
173
  raise HTTPException(status_code=404, detail="Song not found")
 
 
 
 
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  # ============== USERS ==============
183
 
184
  @app.get("/users")
@@ -188,7 +344,7 @@ async def list_users():
188
  cached = await get_cached_list("all_users")
189
  if cached:
190
  return {"users": cached, "source": "cache"}
191
-
192
  # Fetch from database
193
  users = get_all_users()
194
  await cache_list("all_users", users)
@@ -202,12 +358,12 @@ async def get_user(user_id: int):
202
  user = await get_cached_user(user_id)
203
  if user:
204
  return {**user, "source": "cache"}
205
-
206
  # Fetch from database
207
  user = get_user_by_id(user_id)
208
  if not user:
209
  raise HTTPException(status_code=404, detail="User not found")
210
-
211
  await cache_user(user_id, user)
212
  return {**user, "source": "database"}
213
 
@@ -232,7 +388,7 @@ async def list_playlists():
232
  cached = await get_cached_list("all_playlists")
233
  if cached:
234
  return {"playlists": cached, "source": "cache"}
235
-
236
  # Fetch from database
237
  playlists = get_all_playlists()
238
  await cache_list("all_playlists", playlists)
@@ -274,7 +430,7 @@ async def list_memories():
274
  cached = await get_cached_list("all_memories")
275
  if cached:
276
  return {"memories": cached, "source": "cache"}
277
-
278
  # Fetch from database
279
  memories = get_all_memories()
280
  await cache_list("all_memories", memories)
@@ -286,12 +442,12 @@ async def list_user_memories(user_id: int):
286
  """Get memories for a specific user."""
287
  if not get_user_by_id(user_id):
288
  raise HTTPException(status_code=404, detail="User not found")
289
-
290
  # Try cache first
291
  cached = await get_cached_list(f"user_memories:{user_id}")
292
  if cached:
293
  return {"memories": cached, "source": "cache"}
294
-
295
  # Fetch from database
296
  memories = get_memories_by_user(user_id)
297
  await cache_list(f"user_memories:{user_id}", memories)
@@ -336,7 +492,7 @@ async def list_contexts():
336
  cached = await get_cached_list("all_contexts")
337
  if cached:
338
  return {"contexts": cached, "source": "cache"}
339
-
340
  # Fetch from database
341
  contexts = get_all_contexts()
342
  await cache_list("all_contexts", contexts)
@@ -403,7 +559,7 @@ async def create_history(user_id: int, song_id: int, context_id: int = None, dur
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,
@@ -413,20 +569,20 @@ async def create_history(user_id: int, song_id: int, context_id: int = None, dur
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,
@@ -465,6 +621,13 @@ async def get_user_activity(user_id: int, limit: int = Query(50), action_type: s
465
  return {"activity": activity}
466
 
467
 
 
 
 
 
 
 
 
468
  # ============== SEMANTIC SEARCH ==============
469
 
470
  @app.get("/search/songs")
 
1
  from contextlib import asynccontextmanager
2
+ from fastapi import FastAPI, Query, HTTPException, UploadFile, File, Form
3
+ from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
4
  import gradio as gr
5
  from app import gradio_app
6
  from database import (
7
  init_database,
8
  # Songs
9
+ add_song, get_all_songs, get_song_by_id, delete_song, update_song_file,
10
  # Users
11
  add_user, get_all_users, get_user_by_id,
12
  # Playlists
 
58
  log_search,
59
  get_popular_searches,
60
  )
61
+ from minio_client import (
62
+ init_minio,
63
+ get_minio_client,
64
+ upload_mp3_file,
65
+ download_mp3_file,
66
+ stream_mp3_file,
67
+ delete_mp3_file,
68
+ get_file_metadata,
69
+ list_all_mp3_files,
70
+ check_minio_health,
71
+ )
72
+ import io
73
 
74
  app = FastAPI()
75
 
76
 
77
  @asynccontextmanager
78
  async def lifespan(app: FastAPI):
79
+ """Application lifespan manager for Redis, MongoDB, MinIO and database initialization."""
80
  # Startup
81
  init_database()
82
  await init_redis()
83
  await init_mongodb()
84
+ init_minio()
85
  yield
86
  # Shutdown
87
  await close_redis()
 
98
 
99
  @app.get("/health")
100
  async def health_check():
101
+ """Health check endpoint with Redis, MongoDB and MinIO status."""
102
  from redis_client import get_redis_client
103
  redis_status = "disconnected"
104
  mongo_status = "disconnected"
105
+ minio_status = "disconnected"
106
+
107
  try:
108
  redis_client = get_redis_client()
109
  if redis_client:
 
111
  redis_status = "connected"
112
  except Exception:
113
  redis_status = "error"
114
+
115
  try:
116
  mongo_db = get_mongo_db()
117
  if mongo_db:
 
119
  mongo_status = "connected"
120
  except Exception:
121
  mongo_status = "error"
122
+
123
+ # MinIO health check
124
+ minio_health = check_minio_health()
125
+ minio_status = minio_health.get("status", "error")
126
+
127
  return {
128
  "status": "healthy",
129
  "redis": redis_status,
130
  "mongodb": mongo_status,
131
+ "minio": minio_status,
132
  "database": "sqlite"
133
  }
134
 
135
 
136
+ # ============== SONGS + MP3 FILES ==============
137
 
138
  @app.get("/songs")
139
  async def list_songs():
140
+ """Get all songs with MinIO file info."""
141
  # Try cache first
142
  cached = await get_cached_list("all_songs")
143
  if cached:
144
  return {"songs": cached, "source": "cache"}
145
+
146
  # Fetch from database
147
  songs = get_all_songs()
148
+
149
+ # Add MinIO file info to each song
150
+ for song in songs:
151
+ file_meta = get_file_metadata(song["id"])
152
+ song["has_audio"] = file_meta is not None
153
+ song["file_size"] = file_meta.get("size") if file_meta else None
154
+
155
  await cache_list("all_songs", songs)
156
  return {"songs": songs, "source": "database"}
157
 
158
 
159
  @app.get("/songs/{song_id}")
160
  async def get_song(song_id: int):
161
+ """Get a song by ID with play count and MinIO file info."""
162
  # Try cache first
163
  song = await get_cached_song(song_id)
164
  if song:
 
165
  play_count = await get_song_play_count_mongo(song_id)
166
+ file_meta = get_file_metadata(song_id)
167
+ return {
168
+ **song,
169
+ "source": "cache",
170
+ "play_count": play_count,
171
+ "has_audio": file_meta is not None,
172
+ "file_size": file_meta.get("size") if file_meta else None,
173
+ }
174
+
175
  # Fetch from database
176
  song = get_song_by_id(song_id)
177
  if not song:
178
  raise HTTPException(status_code=404, detail="Song not found")
179
+
180
  # Add play count from MongoDB
181
  play_count = await get_song_play_count_mongo(song_id)
182
+
183
+ # Add MinIO file info
184
+ file_meta = get_file_metadata(song_id)
185
+
186
  await cache_song(song_id, song)
187
+ return {
188
+ **song,
189
+ "source": "database",
190
+ "play_count": play_count,
191
+ "has_audio": file_meta is not None,
192
+ "file_size": file_meta.get("size") if file_meta else None,
193
+ }
194
 
195
 
196
  @app.post("/songs")
197
+ async def create_song(
198
+ title: str = Form(...),
199
+ artist: str = Form(...),
200
+ album: str = Form(None),
201
+ duration: int = Form(None),
202
+ bpm: int = Form(None),
203
+ energy_level: int = Form(None),
204
+ lyrics: str = Form(None),
205
+ audio_file: UploadFile = File(None),
206
+ ):
207
+ """Add a new song with optional MP3 file upload to MinIO."""
208
  song = add_song(title, artist, album, duration, bpm, energy_level)
209
+
210
+ # Handle MP3 file upload to MinIO
211
+ minio_info = None
212
+ if audio_file and audio_file.filename:
213
+ contents = await audio_file.read()
214
+ file_stream = io.BytesIO(contents)
215
+ minio_info = upload_mp3_file(
216
+ file_data=file_stream,
217
+ file_size=len(contents),
218
+ song_id=song["id"],
219
+ filename=audio_file.filename,
220
+ )
221
+
222
+ if minio_info:
223
+ # Update song with file reference
224
+ update_song_file(song["id"], minio_info["object_name"], minio_info["size"])
225
+
226
  # Add to semantic search index if lyrics provided
227
  if lyrics:
228
  add_song_vibe(song["id"], title, artist, lyrics)
229
+
230
  # Invalidate songs list cache
231
  await invalidate_list_cache("all_songs")
232
+
233
  # Log activity to MongoDB
234
  await track_analytics_event("song_created", {"song_id": song["id"], "title": title})
235
+
236
+ response = {"status": "success", "song": song}
237
+ if minio_info:
238
+ response["audio"] = {
239
+ "uploaded": True,
240
+ "size": minio_info["size"],
241
+ "stream_url": minio_info["presigned_url"],
242
+ }
243
+ return response
244
 
245
 
246
  @app.delete("/songs/{song_id}")
247
  async def delete_song_endpoint(song_id: int):
248
+ """Delete a song and its MP3 file from MinIO."""
249
  if not delete_song(song_id):
250
  raise HTTPException(status_code=404, detail="Song not found")
251
+
252
+ # Delete MP3 file from MinIO
253
+ delete_mp3_file(song_id)
254
+
255
  remove_song_vibe(song_id)
256
  await invalidate_song_cache(song_id)
257
  await invalidate_list_cache("all_songs")
258
+
259
  # Log activity to MongoDB
260
  await track_analytics_event("song_deleted", {"song_id": song_id})
261
  return {"status": "success", "deleted_id": song_id}
262
 
263
 
264
+ # ============== MP3 FILE STREAMING ENDPOINTS ==============
265
+
266
+ @app.get("/songs/{song_id}/stream")
267
+ async def stream_song(song_id: int):
268
+ """Stream an MP3 file from MinIO (redirects to presigned URL)."""
269
+ stream_info = stream_mp3_file(song_id)
270
+ if not stream_info:
271
+ # Fallback: try to get from local storage
272
+ raise HTTPException(status_code=404, detail="Audio file not found")
273
+
274
+ # Redirect to MinIO presigned URL for direct streaming
275
+ return RedirectResponse(url=stream_info["presigned_url"])
276
+
277
+
278
+ @app.get("/songs/{song_id}/download")
279
+ async def download_song(song_id: int):
280
+ """Download an MP3 file from MinIO."""
281
+ file_data = download_mp3_file(song_id)
282
+ if not file_data:
283
+ raise HTTPException(status_code=404, detail="Audio file not found")
284
+
285
+ song = get_song_by_id(song_id)
286
+ filename = f"{song.get('title', 'song')}_{song_id}.mp3" if song else f"song_{song_id}.mp3"
287
+
288
+ return StreamingResponse(
289
+ io.BytesIO(file_data),
290
+ media_type="audio/mpeg",
291
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
292
+ )
293
+
294
+
295
+ @app.post("/songs/{song_id}/upload-audio")
296
+ async def upload_audio(song_id: int, audio_file: UploadFile = File(...)):
297
+ """Upload/update MP3 file for an existing song."""
298
+ # Verify song exists
299
+ song = get_song_by_id(song_id)
300
+ if not song:
301
+ raise HTTPException(status_code=404, detail="Song not found")
302
+
303
+ # Validate file type
304
+ if not audio_file.filename.lower().endswith('.mp3'):
305
+ raise HTTPException(status_code=400, detail="Only MP3 files are supported")
306
+
307
+ # Read and upload file
308
+ contents = await audio_file.read()
309
+ file_stream = io.BytesIO(contents)
310
+
311
+ minio_info = upload_mp3_file(
312
+ file_data=file_stream,
313
+ file_size=len(contents),
314
+ song_id=song_id,
315
+ filename=audio_file.filename,
316
+ )
317
+
318
+ if not minio_info:
319
+ raise HTTPException(status_code=500, detail="Failed to upload file to MinIO")
320
+
321
+ # Update song with file reference
322
+ update_song_file(song_id, minio_info["object_name"], minio_info["size"])
323
+
324
+ # Invalidate cache
325
+ await invalidate_song_cache(song_id)
326
+ await invalidate_list_cache("all_songs")
327
+
328
+ return {
329
+ "status": "success",
330
+ "audio": {
331
+ "uploaded": True,
332
+ "size": minio_info["size"],
333
+ "stream_url": minio_info["presigned_url"],
334
+ },
335
+ }
336
+
337
+
338
  # ============== USERS ==============
339
 
340
  @app.get("/users")
 
344
  cached = await get_cached_list("all_users")
345
  if cached:
346
  return {"users": cached, "source": "cache"}
347
+
348
  # Fetch from database
349
  users = get_all_users()
350
  await cache_list("all_users", users)
 
358
  user = await get_cached_user(user_id)
359
  if user:
360
  return {**user, "source": "cache"}
361
+
362
  # Fetch from database
363
  user = get_user_by_id(user_id)
364
  if not user:
365
  raise HTTPException(status_code=404, detail="User not found")
366
+
367
  await cache_user(user_id, user)
368
  return {**user, "source": "database"}
369
 
 
388
  cached = await get_cached_list("all_playlists")
389
  if cached:
390
  return {"playlists": cached, "source": "cache"}
391
+
392
  # Fetch from database
393
  playlists = get_all_playlists()
394
  await cache_list("all_playlists", playlists)
 
430
  cached = await get_cached_list("all_memories")
431
  if cached:
432
  return {"memories": cached, "source": "cache"}
433
+
434
  # Fetch from database
435
  memories = get_all_memories()
436
  await cache_list("all_memories", memories)
 
442
  """Get memories for a specific user."""
443
  if not get_user_by_id(user_id):
444
  raise HTTPException(status_code=404, detail="User not found")
445
+
446
  # Try cache first
447
  cached = await get_cached_list(f"user_memories:{user_id}")
448
  if cached:
449
  return {"memories": cached, "source": "cache"}
450
+
451
  # Fetch from database
452
  memories = get_memories_by_user(user_id)
453
  await cache_list(f"user_memories:{user_id}", memories)
 
492
  cached = await get_cached_list("all_contexts")
493
  if cached:
494
  return {"contexts": cached, "source": "cache"}
495
+
496
  # Fetch from database
497
  contexts = get_all_contexts()
498
  await cache_list("all_contexts", contexts)
 
559
  """Add a play history entry to MongoDB (primary), Redis (cache), and SQLite (backup)."""
560
  # Get song info for MongoDB storage
561
  song = get_song_by_id(song_id)
562
+
563
  # Store in MongoDB (primary storage for analytics)
564
  mongo_result = await store_play_history_mongo(
565
  user_id=user_id,
 
569
  context_id=context_id,
570
  duration_seconds=duration_seconds
571
  )
572
+
573
  # Store in Redis (fast cache)
574
  await store_play_event(user_id, song_id, context_id)
575
+
576
  # Store in SQLite (backup)
577
  sqlite_history = add_play_history(user_id, song_id, context_id)
578
+
579
  # Log analytics event
580
  await track_analytics_event("song_played", {
581
  "user_id": user_id,
582
  "song_id": song_id,
583
  "context_id": context_id
584
  })
585
+
586
  return {
587
  "status": "success",
588
  "history": sqlite_history,
 
621
  return {"activity": activity}
622
 
623
 
624
+ @app.get("/storage/files")
625
+ async def list_storage_files():
626
+ """List all MP3 files in MinIO storage."""
627
+ files = list_all_mp3_files()
628
+ return {"files": files, "count": len(files)}
629
+
630
+
631
  # ============== SEMANTIC SEARCH ==============
632
 
633
  @app.get("/search/songs")
minio_client.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MinIO client module for S3-compatible object storage (MP3 files)."""
2
+
3
+ import os
4
+ import io
5
+ from typing import Optional, BinaryIO, Dict, Any
6
+ from datetime import timedelta
7
+ from minio import Minio
8
+ from minio.error import S3Error
9
+
10
+ # MinIO configuration from environment variables
11
+ MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
12
+ MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
13
+ MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
14
+ MINIO_BUCKET = os.getenv("MINIO_BUCKET", "music-memories")
15
+ MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
16
+
17
+ # Global MinIO client instance
18
+ _minio_client: Optional[Minio] = None
19
+
20
+
21
+ def get_minio_client() -> Optional[Minio]:
22
+ """Get or create the MinIO client instance."""
23
+ global _minio_client
24
+ if _minio_client is None:
25
+ try:
26
+ _minio_client = Minio(
27
+ MINIO_ENDPOINT,
28
+ access_key=MINIO_ACCESS_KEY,
29
+ secret_key=MINIO_SECRET_KEY,
30
+ secure=MINIO_SECURE,
31
+ )
32
+ except Exception as e:
33
+ print(f"⚠ MinIO client creation failed: {e}")
34
+ return None
35
+ return _minio_client
36
+
37
+
38
+ def init_minio() -> bool:
39
+ """Initialize MinIO bucket."""
40
+ client = get_minio_client()
41
+ if client is None:
42
+ print("⚠ MinIO not available")
43
+ return False
44
+
45
+ try:
46
+ # Create bucket if it doesn't exist
47
+ if not client.bucket_exists(MINIO_BUCKET):
48
+ client.make_bucket(MINIO_BUCKET)
49
+ print(f"✓ Created MinIO bucket: {MINIO_BUCKET}")
50
+ else:
51
+ print(f"✓ MinIO bucket exists: {MINIO_BUCKET}")
52
+ return True
53
+ except S3Error as e:
54
+ print(f"⚠ MinIO bucket creation failed: {e}")
55
+ return False
56
+
57
+
58
+ def upload_mp3_file(
59
+ file_data: BinaryIO,
60
+ file_size: int,
61
+ song_id: int,
62
+ filename: str = None
63
+ ) -> Optional[Dict[str, Any]]:
64
+ """Upload an MP3 file to MinIO."""
65
+ client = get_minio_client()
66
+ if client is None:
67
+ return None
68
+
69
+ # Generate object name
70
+ object_name = f"songs/{song_id}/{filename or f'song_{song_id}.mp3'}"
71
+
72
+ try:
73
+ # Upload the file
74
+ client.put_object(
75
+ MINIO_BUCKET,
76
+ object_name,
77
+ file_data,
78
+ length=file_size,
79
+ content_type="audio/mpeg",
80
+ )
81
+
82
+ # Generate presigned URL for streaming (expires in 7 days)
83
+ presigned_url = client.presigned_get_object(
84
+ MINIO_BUCKET,
85
+ object_name,
86
+ expires=timedelta(days=7),
87
+ )
88
+
89
+ return {
90
+ "object_name": object_name,
91
+ "bucket": MINIO_BUCKET,
92
+ "size": file_size,
93
+ "presigned_url": presigned_url,
94
+ "content_type": "audio/mpeg",
95
+ }
96
+ except S3Error as e:
97
+ print(f"⚠ MinIO upload failed: {e}")
98
+ return None
99
+
100
+
101
+ def download_mp3_file(song_id: int, filename: str = None) -> Optional[bytes]:
102
+ """Download an MP3 file from MinIO."""
103
+ client = get_minio_client()
104
+ if client is None:
105
+ return None
106
+
107
+ object_name = f"songs/{song_id}/{filename or f'song_{song_id}.mp3'}"
108
+
109
+ try:
110
+ response = client.get_object(MINIO_BUCKET, object_name)
111
+ return response.read()
112
+ except S3Error as e:
113
+ print(f"⚠ MinIO download failed: {e}")
114
+ return None
115
+
116
+
117
+ def stream_mp3_file(song_id: int, filename: str = None) -> Optional[Any]:
118
+ """Get a streaming response for an MP3 file."""
119
+ client = get_minio_client()
120
+ if client is None:
121
+ return None
122
+
123
+ object_name = f"songs/{song_id}/{filename or f'song_{song_id}.mp3'}"
124
+
125
+ try:
126
+ # Generate presigned URL for direct streaming
127
+ presigned_url = client.presigned_get_object(
128
+ MINIO_BUCKET,
129
+ object_name,
130
+ expires=timedelta(hours=24),
131
+ )
132
+ return {"presigned_url": presigned_url, "object_name": object_name}
133
+ except S3Error as e:
134
+ print(f"⚠ MinIO stream URL generation failed: {e}")
135
+ return None
136
+
137
+
138
+ def delete_mp3_file(song_id: int, filename: str = None) -> bool:
139
+ """Delete an MP3 file from MinIO."""
140
+ client = get_minio_client()
141
+ if client is None:
142
+ return False
143
+
144
+ object_name = f"songs/{song_id}/{filename or f'song_{song_id}.mp3'}"
145
+
146
+ try:
147
+ client.remove_object(MINIO_BUCKET, object_name)
148
+ return True
149
+ except S3Error as e:
150
+ print(f"⚠ MinIO delete failed: {e}")
151
+ return False
152
+
153
+
154
+ def get_file_metadata(song_id: int, filename: str = None) -> Optional[Dict[str, Any]]:
155
+ """Get metadata for an MP3 file."""
156
+ client = get_minio_client()
157
+ if client is None:
158
+ return None
159
+
160
+ object_name = f"songs/{song_id}/{filename or f'song_{song_id}.mp3'}"
161
+
162
+ try:
163
+ stat = client.stat_object(MINIO_BUCKET, object_name)
164
+ return {
165
+ "size": stat.size,
166
+ "content_type": stat.content_type,
167
+ "last_modified": stat.last_modified.isoformat() if stat.last_modified else None,
168
+ "etag": stat.etag,
169
+ }
170
+ except S3Error as e:
171
+ print(f"⚠ MinIO metadata fetch failed: {e}")
172
+ return None
173
+
174
+
175
+ def list_all_mp3_files() -> list:
176
+ """List all MP3 files in the bucket."""
177
+ client = get_minio_client()
178
+ if client is None:
179
+ return []
180
+
181
+ try:
182
+ objects = client.list_objects(MINIO_BUCKET, prefix="songs/", recursive=True)
183
+ return [
184
+ {
185
+ "object_name": obj.object_name,
186
+ "size": obj.size,
187
+ "last_modified": obj.last_modified.isoformat() if obj.last_modified else None,
188
+ }
189
+ for obj in objects
190
+ ]
191
+ except S3Error as e:
192
+ print(f"⚠ MinIO list failed: {e}")
193
+ return []
194
+
195
+
196
+ def check_minio_health() -> Dict[str, Any]:
197
+ """Check MinIO health status."""
198
+ client = get_minio_client()
199
+ if client is None:
200
+ return {"status": "disconnected", "error": "Client not initialized"}
201
+
202
+ try:
203
+ # Check if bucket exists (lightweight health check)
204
+ exists = client.bucket_exists(MINIO_BUCKET)
205
+ return {
206
+ "status": "connected" if exists else "bucket_missing",
207
+ "bucket": MINIO_BUCKET,
208
+ "endpoint": MINIO_ENDPOINT,
209
+ }
210
+ except S3Error as e:
211
+ return {"status": "error", "error": str(e)}
pyproject.toml CHANGED
@@ -16,6 +16,8 @@ dependencies = [
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",
 
16
  "redis>=5.0.0",
17
  "pymongo>=4.6.0",
18
  "motor>=3.4.0",
19
+ "minio>=7.2.0",
20
+ "python-multipart>=0.0.9",
21
  "sentence-transformers>=3.0.0",
22
  "torch>=2.5.0",
23
  "torchaudio>=2.5.0",