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

redis is here

Browse files
Files changed (6) hide show
  1. .qwen/settings.json +10 -0
  2. .qwen/settings.json.orig +7 -0
  3. Dockerfile +12 -4
  4. main.py +166 -35
  5. pyproject.toml +1 -0
  6. redis_client.py +275 -0
.qwen/settings.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(curl *)",
5
+ "Bash(python3 *)",
6
+ "Bash(sleep *)"
7
+ ]
8
+ },
9
+ "$version": 3
10
+ }
.qwen/settings.json.orig ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(curl *)"
5
+ ]
6
+ }
7
+ }
Dockerfile CHANGED
@@ -1,8 +1,11 @@
1
- # Hugging Face Spaces (Docker SDK) - FastAPI + Uvicorn using uv
2
  # Docs: https://huggingface.co/docs/hub/spaces-sdks-docker
3
 
4
  FROM python:3.12-slim
5
 
 
 
 
6
  # Create a non-root user matching HF Spaces expectations
7
  RUN useradd -m -u 1000 user
8
  USER user
@@ -11,6 +14,10 @@ USER user
11
  ENV PATH="/home/user/.local/bin:/app/.venv/bin:$PATH"
12
  ENV CHROMA_DB_HOST_IP="127.0.0.1"
13
  ENV ANONYMIZED_TELEMETRY="false"
 
 
 
 
14
  WORKDIR /app
15
 
16
  # Install uv (dependency manager)
@@ -27,11 +34,12 @@ RUN uv venv /app/.venv \
27
  COPY --chown=user . /app
28
 
29
  # Ensure data directories exist and are writable
30
- RUN mkdir -p /app/chroma_db /app/data && chown -R user:user /app/chroma_db /app/data
31
 
32
  # Seed the database (consistent data in every container)
33
  RUN uv run python seed_db.py
34
 
35
- EXPOSE 7860
36
 
37
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
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
11
  USER user
 
14
  ENV PATH="/home/user/.local/bin:/app/.venv/bin:$PATH"
15
  ENV CHROMA_DB_HOST_IP="127.0.0.1"
16
  ENV ANONYMIZED_TELEMETRY="false"
17
+ 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
  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"]
main.py CHANGED
@@ -1,3 +1,4 @@
 
1
  from fastapi import FastAPI, Query, HTTPException
2
  from fastapi.responses import JSONResponse
3
  import gradio as gr
@@ -27,11 +28,32 @@ from semantic_search import (
27
  # Playlist journeys
28
  add_playlist_journey, search_playlist_journeys, remove_playlist_journey,
29
  )
 
 
 
 
 
 
 
 
 
 
30
 
31
  app = FastAPI()
32
 
33
- # Initialize SQLite database on startup
34
- init_database()
 
 
 
 
 
 
 
 
 
 
 
35
 
36
 
37
  @app.get("/")
@@ -40,173 +62,282 @@ def greet_json():
40
 
41
 
42
  @app.get("/health")
43
- def health_check():
44
- return {"status": "healthy", "timestamp": "2026-03-30T00:00:00Z"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
 
47
  # ============== SONGS ==============
48
 
49
  @app.get("/songs")
50
- def list_songs():
51
  """Get all songs."""
52
- return {"songs": get_all_songs()}
 
 
 
 
 
 
 
 
53
 
54
 
55
  @app.get("/songs/{song_id}")
56
- def get_song(song_id: int):
57
  """Get a song by ID."""
 
 
 
 
 
 
58
  song = get_song_by_id(song_id)
59
  if not song:
60
  raise HTTPException(status_code=404, detail="Song not found")
61
- return song
 
 
62
 
63
 
64
  @app.post("/songs")
65
- def create_song(title: str, artist: str, album: str = None, duration: int = None,
66
- bpm: int = None, energy_level: int = None, lyrics: str = None):
67
  """Add a new song."""
68
  song = add_song(title, artist, album, duration, bpm, energy_level)
69
  # Add to semantic search index if lyrics provided
70
  if lyrics:
71
  add_song_vibe(song["id"], title, artist, lyrics)
 
 
72
  return {"status": "success", "song": song}
73
 
74
 
75
  @app.delete("/songs/{song_id}")
76
- def delete_song_endpoint(song_id: int):
77
  """Delete a song."""
78
  if not delete_song(song_id):
79
  raise HTTPException(status_code=404, detail="Song not found")
80
  remove_song_vibe(song_id)
 
 
81
  return {"status": "success", "deleted_id": song_id}
82
 
83
 
84
  # ============== USERS ==============
85
 
86
  @app.get("/users")
87
- def list_users():
88
  """Get all users."""
89
- return {"users": get_all_users()}
 
 
 
 
 
 
 
 
90
 
91
 
92
  @app.get("/users/{user_id}")
93
- def get_user(user_id: int):
94
  """Get a user by ID."""
 
 
 
 
 
 
95
  user = get_user_by_id(user_id)
96
  if not user:
97
  raise HTTPException(status_code=404, detail="User not found")
98
- return user
 
 
99
 
100
 
101
  @app.post("/users")
102
- def create_user(name: str):
103
  """Add a new user."""
104
  user = add_user(name)
 
105
  return {"status": "success", "user": user}
106
 
107
 
108
  # ============== PLAYLISTS ==============
109
 
110
  @app.get("/playlists")
111
- def list_playlists():
112
  """Get all playlists."""
113
- return {"playlists": get_all_playlists()}
 
 
 
 
 
 
 
 
114
 
115
 
116
  @app.post("/playlists")
117
- def create_playlist(name: str, vibe_code: str = None, mood_description: str = None):
118
  """Add a new playlist."""
119
  playlist = add_playlist(name, vibe_code)
120
  # Add to playlist journeys for mood search
121
  if mood_description:
122
  add_playlist_journey(playlist["id"], name, mood_description)
 
123
  return {"status": "success", "playlist": playlist}
124
 
125
 
126
  @app.delete("/playlists/{playlist_id}")
127
- def delete_playlist_endpoint(playlist_id: int):
128
  """Delete a playlist."""
129
  if not delete_playlist(playlist_id):
130
  raise HTTPException(status_code=404, detail="Playlist not found")
131
  remove_playlist_journey(playlist_id)
 
 
132
  return {"status": "success", "deleted_id": playlist_id}
133
 
134
 
135
  # ============== MEMORIES ==============
136
 
137
  @app.get("/memories")
138
- def list_memories():
139
  """Get all memories."""
140
- return {"memories": get_all_memories()}
 
 
 
 
 
 
 
 
141
 
142
 
143
  @app.get("/users/{user_id}/memories")
144
- def list_user_memories(user_id: int):
145
  """Get memories for a specific user."""
146
  if not get_user_by_id(user_id):
147
  raise HTTPException(status_code=404, detail="User not found")
148
- return {"memories": get_memories_by_user(user_id)}
 
 
 
 
 
 
 
 
 
149
 
150
 
151
  @app.post("/memories")
152
- def create_memory(user_id: int, description: str, date: str = None, song_id: int = None):
153
  """Add a new memory."""
154
  if not get_user_by_id(user_id):
155
  raise HTTPException(status_code=404, detail="User not found")
156
  memory = add_memory(user_id, description, date, song_id)
157
  # Add to semantic search index
158
  add_memory_vibe(memory["id"], user_id, description)
 
 
159
  return {"status": "success", "memory": memory}
160
 
161
 
162
  @app.delete("/memories/{memory_id}")
163
- def delete_memory_endpoint(memory_id: int):
164
  """Delete a memory."""
165
  if not delete_memory(memory_id):
166
  raise HTTPException(status_code=404, detail="Memory not found")
167
  remove_memory_vibe(memory_id)
 
 
168
  return {"status": "success", "deleted_id": memory_id}
169
 
170
 
171
  # ============== CONTEXTS ==============
172
 
173
  @app.get("/contexts")
174
- def list_contexts():
175
  """Get all contexts."""
176
- return {"contexts": get_all_contexts()}
 
 
 
 
 
 
 
 
177
 
178
 
179
  @app.post("/contexts")
180
- def create_context(weather: str = None, time_of_day: str = None, location_type: str = None):
181
  """Add a new context."""
182
  context = add_context(weather, time_of_day, location_type)
183
  # Add to semantic search index
184
  add_context_vibe(context["id"], weather or "", time_of_day or "", location_type or "")
 
185
  return {"status": "success", "context": context}
186
 
187
 
188
  @app.delete("/contexts/{context_id}")
189
- def delete_context_endpoint(context_id: int):
190
  """Delete a context."""
191
  if not delete_context(context_id):
192
  raise HTTPException(status_code=404, detail="Context not found")
193
  remove_context_vibe(context_id)
 
194
  return {"status": "success", "deleted_id": context_id}
195
 
196
 
197
  # ============== PLAY HISTORY ==============
198
 
199
  @app.get("/history")
200
- def list_history(user_id: int = None, limit: int = 50):
201
  """Get play history."""
202
- return {"history": get_play_history(user_id, limit)}
 
 
 
 
 
 
 
 
 
 
 
 
 
203
 
204
 
205
  @app.post("/history")
206
- def create_history(user_id: int, song_id: int, context_id: int = None):
207
  """Add a play history entry."""
208
- history = add_play_history(user_id, song_id, context_id)
209
- return {"status": "success", "history": history}
 
 
210
 
211
 
212
  # ============== SEMANTIC SEARCH ==============
 
1
+ from contextlib import asynccontextmanager
2
  from fastapi import FastAPI, Query, HTTPException
3
  from fastapi.responses import JSONResponse
4
  import gradio as gr
 
28
  # Playlist journeys
29
  add_playlist_journey, search_playlist_journeys, remove_playlist_journey,
30
  )
31
+ from redis_client import (
32
+ init_redis,
33
+ close_redis,
34
+ get_cached_song, cache_song, invalidate_song_cache,
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
57
 
58
 
59
  @app.get("/")
 
62
 
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:
72
+ await redis_client.ping()
73
+ redis_status = "connected"
74
+ except Exception:
75
+ redis_status = "error"
76
+
77
+ return {
78
+ "status": "healthy",
79
+ "redis": redis_status,
80
+ "database": "sqlite"
81
+ }
82
 
83
 
84
  # ============== SONGS ==============
85
 
86
  @app.get("/songs")
87
+ async def list_songs():
88
  """Get all songs."""
89
+ # Try cache first
90
+ cached = await get_cached_list("all_songs")
91
+ if cached:
92
+ return {"songs": cached, "source": "cache"}
93
+
94
+ # Fetch from database
95
+ songs = get_all_songs()
96
+ await cache_list("all_songs", songs)
97
+ return {"songs": songs, "source": "database"}
98
 
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")
118
+ async def create_song(title: str, artist: str, album: str = None, duration: int = None,
119
+ bpm: int = None, energy_level: int = None, lyrics: str = None):
120
  """Add a new song."""
121
  song = add_song(title, artist, album, duration, bpm, energy_level)
122
  # Add to semantic search index if lyrics provided
123
  if lyrics:
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
 
130
  @app.delete("/songs/{song_id}")
131
+ async def delete_song_endpoint(song_id: int):
132
  """Delete a song."""
133
  if not delete_song(song_id):
134
  raise HTTPException(status_code=404, detail="Song not found")
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
 
141
  # ============== USERS ==============
142
 
143
  @app.get("/users")
144
+ async def list_users():
145
  """Get all users."""
146
+ # Try cache first
147
+ cached = await get_cached_list("all_users")
148
+ if cached:
149
+ return {"users": cached, "source": "cache"}
150
+
151
+ # Fetch from database
152
+ users = get_all_users()
153
+ await cache_list("all_users", users)
154
+ return {"users": users, "source": "database"}
155
 
156
 
157
  @app.get("/users/{user_id}")
158
+ async def get_user(user_id: int):
159
  """Get a user by ID."""
160
+ # Try cache first
161
+ user = await get_cached_user(user_id)
162
+ if user:
163
+ return {**user, "source": "cache"}
164
+
165
+ # Fetch from database
166
  user = get_user_by_id(user_id)
167
  if not user:
168
  raise HTTPException(status_code=404, detail="User not found")
169
+
170
+ await cache_user(user_id, user)
171
+ return {**user, "source": "database"}
172
 
173
 
174
  @app.post("/users")
175
+ 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
 
182
  # ============== PLAYLISTS ==============
183
 
184
  @app.get("/playlists")
185
+ async def list_playlists():
186
  """Get all playlists."""
187
+ # Try cache first
188
+ cached = await get_cached_list("all_playlists")
189
+ if cached:
190
+ return {"playlists": cached, "source": "cache"}
191
+
192
+ # Fetch from database
193
+ playlists = get_all_playlists()
194
+ await cache_list("all_playlists", playlists)
195
+ return {"playlists": playlists, "source": "database"}
196
 
197
 
198
  @app.post("/playlists")
199
+ async def create_playlist(name: str, vibe_code: str = None, mood_description: str = None):
200
  """Add a new playlist."""
201
  playlist = add_playlist(name, vibe_code)
202
  # Add to playlist journeys for mood search
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
 
209
  @app.delete("/playlists/{playlist_id}")
210
+ async def delete_playlist_endpoint(playlist_id: int):
211
  """Delete a playlist."""
212
  if not delete_playlist(playlist_id):
213
  raise HTTPException(status_code=404, detail="Playlist not found")
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
 
220
  # ============== MEMORIES ==============
221
 
222
  @app.get("/memories")
223
+ async def list_memories():
224
  """Get all memories."""
225
+ # Try cache first
226
+ cached = await get_cached_list("all_memories")
227
+ if cached:
228
+ return {"memories": cached, "source": "cache"}
229
+
230
+ # Fetch from database
231
+ memories = get_all_memories()
232
+ await cache_list("all_memories", memories)
233
+ return {"memories": memories, "source": "database"}
234
 
235
 
236
  @app.get("/users/{user_id}/memories")
237
+ async def list_user_memories(user_id: int):
238
  """Get memories for a specific user."""
239
  if not get_user_by_id(user_id):
240
  raise HTTPException(status_code=404, detail="User not found")
241
+
242
+ # Try cache first
243
+ cached = await get_cached_list(f"user_memories:{user_id}")
244
+ if cached:
245
+ return {"memories": cached, "source": "cache"}
246
+
247
+ # Fetch from database
248
+ memories = get_memories_by_user(user_id)
249
+ await cache_list(f"user_memories:{user_id}", memories)
250
+ return {"memories": memories, "source": "database"}
251
 
252
 
253
  @app.post("/memories")
254
+ async def create_memory(user_id: int, description: str, date: str = None, song_id: int = None):
255
  """Add a new memory."""
256
  if not get_user_by_id(user_id):
257
  raise HTTPException(status_code=404, detail="User not found")
258
  memory = add_memory(user_id, description, date, song_id)
259
  # Add to semantic search index
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
 
266
  @app.delete("/memories/{memory_id}")
267
+ async def delete_memory_endpoint(memory_id: int):
268
  """Delete a memory."""
269
  if not delete_memory(memory_id):
270
  raise HTTPException(status_code=404, detail="Memory not found")
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
 
277
  # ============== CONTEXTS ==============
278
 
279
  @app.get("/contexts")
280
+ async def list_contexts():
281
  """Get all contexts."""
282
+ # Try cache first
283
+ cached = await get_cached_list("all_contexts")
284
+ if cached:
285
+ return {"contexts": cached, "source": "cache"}
286
+
287
+ # Fetch from database
288
+ contexts = get_all_contexts()
289
+ await cache_list("all_contexts", contexts)
290
+ return {"contexts": contexts, "source": "database"}
291
 
292
 
293
  @app.post("/contexts")
294
+ async def create_context(weather: str = None, time_of_day: str = None, location_type: str = None):
295
  """Add a new context."""
296
  context = add_context(weather, time_of_day, location_type)
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
 
303
  @app.delete("/contexts/{context_id}")
304
+ async def delete_context_endpoint(context_id: int):
305
  """Delete a context."""
306
  if not delete_context(context_id):
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 ==============
pyproject.toml CHANGED
@@ -13,6 +13,7 @@ dependencies = [
13
  "gradio>=4.0.0",
14
  "huggingface-hub>=1.8.0",
15
  "peft>=0.18.1",
 
16
  "sentence-transformers>=3.0.0",
17
  "torch>=2.5.0",
18
  "torchaudio>=2.5.0",
 
13
  "gradio>=4.0.0",
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",
redis_client.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Redis client module for caching and storage."""
2
+
3
+ import json
4
+ import os
5
+ from typing import Optional, Any
6
+ import redis.asyncio as redis
7
+ from functools import wraps
8
+ import hashlib
9
+
10
+
11
+ # Redis configuration from environment variables
12
+ REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
13
+ REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
14
+ REDIS_DB = int(os.getenv("REDIS_DB", 0))
15
+ REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None)
16
+ CACHE_TTL = int(os.getenv("CACHE_TTL", 300)) # Default 5 minutes
17
+
18
+ # Global Redis client instance
19
+ _redis_client: Optional[redis.Redis] = None
20
+
21
+
22
+ def get_redis_client() -> redis.Redis:
23
+ """Get or create the Redis client instance."""
24
+ global _redis_client
25
+ if _redis_client is None:
26
+ _redis_client = redis.Redis(
27
+ host=REDIS_HOST,
28
+ port=REDIS_PORT,
29
+ db=REDIS_DB,
30
+ password=REDIS_PASSWORD,
31
+ decode_responses=True,
32
+ )
33
+ return _redis_client
34
+
35
+
36
+ async def init_redis() -> None:
37
+ """Initialize Redis connection."""
38
+ global _redis_client
39
+ _redis_client = redis.Redis(
40
+ host=REDIS_HOST,
41
+ port=REDIS_PORT,
42
+ db=REDIS_DB,
43
+ password=REDIS_PASSWORD,
44
+ decode_responses=True,
45
+ )
46
+ try:
47
+ await _redis_client.ping()
48
+ print(f"✓ Connected to Redis at {REDIS_HOST}:{REDIS_PORT}")
49
+ except redis.ConnectionError as e:
50
+ print(f"⚠ Redis connection failed: {e}")
51
+ print("⚠ Running without Redis caching")
52
+
53
+
54
+ async def close_redis() -> None:
55
+ """Close Redis connection."""
56
+ global _redis_client
57
+ if _redis_client:
58
+ await _redis_client.close()
59
+ _redis_client = None
60
+
61
+
62
+ def _generate_cache_key(prefix: str, *args, **kwargs) -> str:
63
+ """Generate a cache key from arguments."""
64
+ key_parts = [prefix]
65
+ for arg in args:
66
+ key_parts.append(str(arg))
67
+ for k, v in sorted(kwargs.items()):
68
+ key_parts.append(f"{k}={v}")
69
+ key_string = ":".join(key_parts)
70
+ return f"cache:{hashlib.md5(key_string.encode()).hexdigest()}"
71
+
72
+
73
+ def cache_it(prefix: str, ttl: int = CACHE_TTL):
74
+ """Decorator to cache function results in Redis.
75
+
76
+ Args:
77
+ prefix: Key prefix for the cache
78
+ ttl: Time to live in seconds
79
+ """
80
+ def decorator(func):
81
+ @wraps(func)
82
+ async def wrapper(*args, **kwargs):
83
+ client = get_redis_client()
84
+ cache_key = _generate_cache_key(prefix, *args, **kwargs)
85
+
86
+ # Try to get from cache
87
+ try:
88
+ cached = await client.get(cache_key)
89
+ if cached:
90
+ return json.loads(cached)
91
+ except Exception:
92
+ pass # Cache miss or error, proceed to fetch
93
+
94
+ # Fetch fresh data
95
+ result = func(*args, **kwargs)
96
+
97
+ # Store in cache
98
+ try:
99
+ await client.setex(cache_key, ttl, json.dumps(result))
100
+ except Exception:
101
+ pass # Cache write failed, but return the result anyway
102
+
103
+ return result
104
+ return wrapper
105
+ return decorator
106
+
107
+
108
+ # ============== Cache helper functions ==============
109
+
110
+ async def cache_get(key: str) -> Optional[Any]:
111
+ """Get a value from cache."""
112
+ try:
113
+ client = get_redis_client()
114
+ value = await client.get(key)
115
+ return json.loads(value) if value else None
116
+ except Exception:
117
+ return None
118
+
119
+
120
+ async def cache_set(key: str, value: Any, ttl: int = CACHE_TTL) -> bool:
121
+ """Set a value in cache."""
122
+ try:
123
+ client = get_redis_client()
124
+ await client.setex(key, ttl, json.dumps(value))
125
+ return True
126
+ except Exception:
127
+ return False
128
+
129
+
130
+ async def cache_delete(key: str) -> bool:
131
+ """Delete a key from cache."""
132
+ try:
133
+ client = get_redis_client()
134
+ await client.delete(key)
135
+ return True
136
+ except Exception:
137
+ return False
138
+
139
+
140
+ async def cache_delete_pattern(pattern: str) -> bool:
141
+ """Delete keys matching a pattern."""
142
+ try:
143
+ client = get_redis_client()
144
+ keys = []
145
+ async for key in client.scan_iter(match=pattern):
146
+ keys.append(key)
147
+ if keys:
148
+ await client.delete(*keys)
149
+ return True
150
+ except Exception:
151
+ return False
152
+
153
+
154
+ # ============== Entity-specific cache functions ==============
155
+
156
+ # Songs cache
157
+ async def cache_song(song_id: int, song_data: dict) -> bool:
158
+ """Cache a song by ID."""
159
+ return await cache_set(f"song:{song_id}", song_data)
160
+
161
+
162
+ async def get_cached_song(song_id: int) -> Optional[dict]:
163
+ """Get a cached song by ID."""
164
+ return await cache_get(f"song:{song_id}")
165
+
166
+
167
+ async def invalidate_song_cache(song_id: int) -> None:
168
+ """Invalidate song cache."""
169
+ await cache_delete(f"song:{song_id}")
170
+
171
+
172
+ # Users cache
173
+ async def cache_user(user_id: int, user_data: dict) -> bool:
174
+ """Cache a user by ID."""
175
+ return await cache_set(f"user:{user_id}", user_data)
176
+
177
+
178
+ async def get_cached_user(user_id: int) -> Optional[dict]:
179
+ """Get a cached user by ID."""
180
+ return await cache_get(f"user:{user_id}")
181
+
182
+
183
+ async def invalidate_user_cache(user_id: int) -> None:
184
+ """Invalidate user cache."""
185
+ await cache_delete(f"user:{user_id}")
186
+
187
+
188
+ # Playlists cache
189
+ async def cache_playlist(playlist_id: int, playlist_data: dict) -> bool:
190
+ """Cache a playlist by ID."""
191
+ return await cache_set(f"playlist:{playlist_id}", playlist_data)
192
+
193
+
194
+ async def get_cached_playlist(playlist_id: int) -> Optional[dict]:
195
+ """Get a cached playlist by ID."""
196
+ return await cache_get(f"playlist:{playlist_id}")
197
+
198
+
199
+ async def invalidate_playlist_cache(playlist_id: int) -> None:
200
+ """Invalidate playlist cache."""
201
+ await cache_delete(f"playlist:{playlist_id}")
202
+
203
+
204
+ # Lists cache (for collections)
205
+ async def cache_list(key: str, data: list) -> bool:
206
+ """Cache a list of items."""
207
+ return await cache_set(f"list:{key}", data, ttl=60) # Shorter TTL for lists
208
+
209
+
210
+ async def get_cached_list(key: str) -> Optional[list]:
211
+ """Get a cached list."""
212
+ return await cache_get(f"list:{key}")
213
+
214
+
215
+ async def invalidate_list_cache(key: str) -> None:
216
+ """Invalidate a list cache."""
217
+ await cache_delete(f"list:{key}")
218
+
219
+
220
+ # ============== Play History in Redis (hot storage) ==============
221
+
222
+ async def store_play_event(user_id: int, song_id: int, context_id: Optional[int] = None) -> dict:
223
+ """Store a play event in Redis (fast write)."""
224
+ from datetime import datetime
225
+ played_at = datetime.utcnow().isoformat()
226
+ event = {
227
+ "user_id": user_id,
228
+ "song_id": song_id,
229
+ "context_id": context_id,
230
+ "played_at": played_at
231
+ }
232
+
233
+ try:
234
+ client = get_redis_client()
235
+ # Add to sorted set for this user (score = timestamp)
236
+ await client.zadd(
237
+ f"play_history:user:{user_id}",
238
+ {json.dumps(event): datetime.utcnow().timestamp()}
239
+ )
240
+ # Also add to global history
241
+ await client.zadd(
242
+ "play_history:global",
243
+ {json.dumps(event): datetime.utcnow().timestamp()}
244
+ )
245
+ except Exception:
246
+ pass # Best effort, SQLite is the source of truth
247
+
248
+ return event
249
+
250
+
251
+ async def get_user_play_history(user_id: int, limit: int = 50) -> list[dict]:
252
+ """Get play history for a user from Redis."""
253
+ try:
254
+ client = get_redis_client()
255
+ # Get latest entries from sorted set
256
+ events = await client.zrevrange(
257
+ f"play_history:user:{user_id}",
258
+ 0, limit - 1
259
+ )
260
+ return [json.loads(e) for e in events]
261
+ except Exception:
262
+ return []
263
+
264
+
265
+ async def get_global_play_history(limit: int = 50) -> list[dict]:
266
+ """Get global play history from Redis."""
267
+ try:
268
+ client = get_redis_client()
269
+ events = await client.zrevrange(
270
+ "play_history:global",
271
+ 0, limit - 1
272
+ )
273
+ return [json.loads(e) for e in events]
274
+ except Exception:
275
+ return []