karaoke-gen 0.101.0__py3-none-any.whl → 0.105.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. backend/Dockerfile.base +1 -0
  2. backend/api/routes/admin.py +226 -3
  3. backend/api/routes/audio_search.py +4 -32
  4. backend/api/routes/file_upload.py +18 -83
  5. backend/api/routes/jobs.py +2 -2
  6. backend/api/routes/push.py +238 -0
  7. backend/api/routes/rate_limits.py +428 -0
  8. backend/api/routes/users.py +79 -19
  9. backend/config.py +25 -1
  10. backend/exceptions.py +66 -0
  11. backend/main.py +26 -1
  12. backend/models/job.py +4 -0
  13. backend/models/user.py +20 -2
  14. backend/services/email_validation_service.py +646 -0
  15. backend/services/firestore_service.py +21 -0
  16. backend/services/gce_encoding/main.py +22 -8
  17. backend/services/job_defaults_service.py +113 -0
  18. backend/services/job_manager.py +109 -13
  19. backend/services/push_notification_service.py +409 -0
  20. backend/services/rate_limit_service.py +641 -0
  21. backend/services/stripe_service.py +2 -2
  22. backend/tests/conftest.py +8 -1
  23. backend/tests/test_admin_delete_outputs.py +352 -0
  24. backend/tests/test_audio_search.py +12 -8
  25. backend/tests/test_email_validation_service.py +298 -0
  26. backend/tests/test_file_upload.py +8 -6
  27. backend/tests/test_gce_encoding_worker.py +229 -0
  28. backend/tests/test_impersonation.py +18 -3
  29. backend/tests/test_made_for_you.py +6 -4
  30. backend/tests/test_push_notification_service.py +460 -0
  31. backend/tests/test_push_routes.py +357 -0
  32. backend/tests/test_rate_limit_service.py +396 -0
  33. backend/tests/test_rate_limits_api.py +392 -0
  34. backend/tests/test_stripe_service.py +205 -0
  35. backend/workers/video_worker_orchestrator.py +42 -0
  36. karaoke_gen/instrumental_review/static/index.html +35 -9
  37. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/METADATA +2 -1
  38. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/RECORD +41 -26
  39. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/WHEEL +0 -0
  40. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/entry_points.txt +0 -0
  41. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,409 @@
1
+ """
2
+ Push Notification Service for Web Push Notifications.
3
+
4
+ Handles sending push notifications to users' browsers/devices using the Web Push protocol.
5
+ Uses pywebpush library for VAPID authentication and push message encryption.
6
+ """
7
+ import logging
8
+ from datetime import datetime, timezone
9
+ from typing import Optional, List
10
+
11
+ from pywebpush import webpush, WebPushException
12
+
13
+ from backend.config import get_settings
14
+ from backend.models.user import User, PushSubscription
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SubscriptionGoneError(Exception):
21
+ """Raised when a push subscription is no longer valid (410 Gone)."""
22
+ pass
23
+
24
+
25
+ class PushNotificationService:
26
+ """
27
+ Service for sending Web Push notifications.
28
+
29
+ Requires VAPID keys to be configured in Secret Manager:
30
+ - vapid-public-key: Base64-encoded public key
31
+ - vapid-private-key: Base64-encoded private key
32
+ """
33
+
34
+ def __init__(self, db=None):
35
+ """
36
+ Initialize the push notification service.
37
+
38
+ Args:
39
+ db: Firestore client (optional, will use singleton if not provided)
40
+ """
41
+ self.settings = get_settings()
42
+ self._vapid_private_key: Optional[str] = None
43
+ self._vapid_public_key: Optional[str] = None
44
+ self._db = db
45
+
46
+ @property
47
+ def db(self):
48
+ """Get Firestore client (lazy initialization)."""
49
+ if self._db is None:
50
+ from google.cloud import firestore
51
+ self._db = firestore.Client()
52
+ return self._db
53
+
54
+ @property
55
+ def vapid_private_key(self) -> Optional[str]:
56
+ """Get VAPID private key from Secret Manager (cached)."""
57
+ if self._vapid_private_key is None:
58
+ self._vapid_private_key = self.settings.get_secret("vapid-private-key")
59
+ return self._vapid_private_key
60
+
61
+ @property
62
+ def vapid_public_key(self) -> Optional[str]:
63
+ """Get VAPID public key from Secret Manager (cached)."""
64
+ if self._vapid_public_key is None:
65
+ self._vapid_public_key = self.settings.get_secret("vapid-public-key")
66
+ return self._vapid_public_key
67
+
68
+ def is_enabled(self) -> bool:
69
+ """Check if push notifications are enabled and properly configured."""
70
+ return (
71
+ self.settings.enable_push_notifications and
72
+ self.vapid_private_key is not None and
73
+ self.vapid_public_key is not None
74
+ )
75
+
76
+ def get_public_key(self) -> Optional[str]:
77
+ """Get the VAPID public key for client-side subscription."""
78
+ if not self.settings.enable_push_notifications:
79
+ return None
80
+ return self.vapid_public_key
81
+
82
+ async def send_push(
83
+ self,
84
+ user_email: str,
85
+ title: str,
86
+ body: str,
87
+ url: str = "/app/",
88
+ tag: Optional[str] = None
89
+ ) -> int:
90
+ """
91
+ Send a push notification to all of a user's subscribed devices.
92
+
93
+ Args:
94
+ user_email: The user's email address
95
+ title: Notification title
96
+ body: Notification body text
97
+ url: URL to open when notification is clicked
98
+ tag: Optional tag for notification grouping (replaces notifications with same tag)
99
+
100
+ Returns:
101
+ Number of notifications successfully sent
102
+ """
103
+ if not self.is_enabled():
104
+ logger.debug("Push notifications not enabled, skipping")
105
+ return 0
106
+
107
+ # Get user from Firestore
108
+ user_doc = self.db.collection("gen_users").document(user_email).get()
109
+ if not user_doc.exists:
110
+ logger.warning(f"User {user_email} not found for push notification")
111
+ return 0
112
+
113
+ user_data = user_doc.to_dict()
114
+ subscriptions = user_data.get("push_subscriptions", [])
115
+
116
+ if not subscriptions:
117
+ logger.debug(f"User {user_email} has no push subscriptions")
118
+ return 0
119
+
120
+ # Build notification payload
121
+ import json
122
+ payload = json.dumps({
123
+ "title": title,
124
+ "body": body,
125
+ "url": url,
126
+ "tag": tag or "default",
127
+ "icon": "/nomad-logo.png"
128
+ })
129
+
130
+ # Send to each subscription
131
+ success_count = 0
132
+ invalid_subscriptions = []
133
+
134
+ for sub in subscriptions:
135
+ try:
136
+ webpush(
137
+ subscription_info={
138
+ "endpoint": sub["endpoint"],
139
+ "keys": sub["keys"]
140
+ },
141
+ data=payload,
142
+ vapid_private_key=self.vapid_private_key,
143
+ vapid_claims={
144
+ "sub": self.settings.vapid_subject
145
+ }
146
+ )
147
+ success_count += 1
148
+ logger.debug(f"Push sent to {sub.get('device_name', 'unknown device')}")
149
+
150
+ except WebPushException as e:
151
+ logger.warning(f"Push failed: {e}")
152
+ # Check if subscription is gone (410) or unauthorized (401/403)
153
+ if e.response and e.response.status_code in (404, 410):
154
+ logger.info(f"Subscription gone, will remove: {sub['endpoint'][:50]}...")
155
+ invalid_subscriptions.append(sub["endpoint"])
156
+ elif e.response and e.response.status_code in (401, 403):
157
+ logger.warning(f"Push auth failed for {sub['endpoint'][:50]}...")
158
+
159
+ except Exception as e:
160
+ logger.error(f"Unexpected error sending push: {e}")
161
+
162
+ # Remove invalid subscriptions
163
+ if invalid_subscriptions:
164
+ await self._remove_invalid_subscriptions(user_email, invalid_subscriptions)
165
+
166
+ logger.info(f"Push notifications sent to {success_count}/{len(subscriptions)} devices for {user_email}")
167
+ return success_count
168
+
169
+ async def _remove_invalid_subscriptions(self, user_email: str, endpoints: List[str]) -> None:
170
+ """Remove invalid subscriptions from user's list."""
171
+ try:
172
+ user_ref = self.db.collection("gen_users").document(user_email)
173
+ user_doc = user_ref.get()
174
+ if not user_doc.exists:
175
+ return
176
+
177
+ user_data = user_doc.to_dict()
178
+ current_subs = user_data.get("push_subscriptions", [])
179
+
180
+ # Filter out invalid subscriptions
181
+ valid_subs = [s for s in current_subs if s["endpoint"] not in endpoints]
182
+
183
+ if len(valid_subs) < len(current_subs):
184
+ user_ref.update({"push_subscriptions": valid_subs})
185
+ logger.info(f"Removed {len(current_subs) - len(valid_subs)} invalid subscriptions for {user_email}")
186
+
187
+ except Exception as e:
188
+ logger.error(f"Failed to remove invalid subscriptions: {e}")
189
+
190
+ async def send_blocking_notification(
191
+ self,
192
+ job: dict,
193
+ action_type: str # "lyrics" or "instrumental"
194
+ ) -> int:
195
+ """
196
+ Send notification when a job enters a blocking state requiring user action.
197
+
198
+ Args:
199
+ job: Job dictionary with job_id, user_email, artist, title
200
+ action_type: Type of action needed ("lyrics" or "instrumental")
201
+
202
+ Returns:
203
+ Number of notifications sent
204
+ """
205
+ user_email = job.get("user_email")
206
+ if not user_email:
207
+ return 0
208
+
209
+ job_id = job.get("job_id", "unknown")
210
+ artist = job.get("artist", "Unknown Artist")
211
+ title = job.get("title", "Unknown Title")
212
+
213
+ if action_type == "lyrics":
214
+ notif_title = "Review Lyrics"
215
+ notif_body = f'"{title}" by {artist} needs lyrics review'
216
+ url = f"/review/{job_id}"
217
+ tag = f"lyrics-{job_id}"
218
+ else: # instrumental
219
+ notif_title = "Select Instrumental"
220
+ notif_body = f'"{title}" by {artist} needs instrumental selection'
221
+ url = f"/instrumental/{job_id}"
222
+ tag = f"instrumental-{job_id}"
223
+
224
+ return await self.send_push(
225
+ user_email=user_email,
226
+ title=notif_title,
227
+ body=notif_body,
228
+ url=url,
229
+ tag=tag
230
+ )
231
+
232
+ async def send_completion_notification(self, job: dict) -> int:
233
+ """
234
+ Send notification when a job completes successfully.
235
+
236
+ Args:
237
+ job: Job dictionary with job_id, user_email, artist, title
238
+
239
+ Returns:
240
+ Number of notifications sent
241
+ """
242
+ user_email = job.get("user_email")
243
+ if not user_email:
244
+ return 0
245
+
246
+ job_id = job.get("job_id", "unknown")
247
+ artist = job.get("artist", "Unknown Artist")
248
+ title = job.get("title", "Unknown Title")
249
+
250
+ return await self.send_push(
251
+ user_email=user_email,
252
+ title="Video Ready!",
253
+ body=f'Your karaoke video for "{title}" by {artist} is ready to download',
254
+ url=f"/app/?job={job_id}",
255
+ tag=f"complete-{job_id}"
256
+ )
257
+
258
+ async def add_subscription(
259
+ self,
260
+ user_email: str,
261
+ endpoint: str,
262
+ keys: dict,
263
+ device_name: Optional[str] = None
264
+ ) -> bool:
265
+ """
266
+ Add a push subscription for a user.
267
+
268
+ Enforces max subscriptions per user - oldest removed if limit exceeded.
269
+
270
+ Args:
271
+ user_email: User's email address
272
+ endpoint: Push service endpoint URL
273
+ keys: Encryption keys (p256dh, auth)
274
+ device_name: Optional device identifier
275
+
276
+ Returns:
277
+ True if subscription was added successfully
278
+ """
279
+ try:
280
+ user_ref = self.db.collection("gen_users").document(user_email)
281
+ user_doc = user_ref.get()
282
+
283
+ if not user_doc.exists:
284
+ logger.warning(f"User {user_email} not found")
285
+ return False
286
+
287
+ user_data = user_doc.to_dict()
288
+ subscriptions = user_data.get("push_subscriptions", [])
289
+
290
+ # Check if this endpoint already exists (update it)
291
+ existing_idx = None
292
+ for i, sub in enumerate(subscriptions):
293
+ if sub["endpoint"] == endpoint:
294
+ existing_idx = i
295
+ break
296
+
297
+ new_sub = {
298
+ "endpoint": endpoint,
299
+ "keys": keys,
300
+ "device_name": device_name,
301
+ "created_at": datetime.now(timezone.utc).isoformat(),
302
+ "last_used_at": None
303
+ }
304
+
305
+ if existing_idx is not None:
306
+ # Update existing subscription
307
+ subscriptions[existing_idx] = new_sub
308
+ logger.info(f"Updated existing push subscription for {user_email}")
309
+ else:
310
+ # Add new subscription
311
+ subscriptions.append(new_sub)
312
+ logger.info(f"Added new push subscription for {user_email}")
313
+
314
+ # Enforce max subscriptions (remove oldest)
315
+ max_subs = self.settings.max_push_subscriptions_per_user
316
+ if len(subscriptions) > max_subs:
317
+ # Sort by created_at and keep only the newest
318
+ subscriptions.sort(key=lambda s: s.get("created_at", ""), reverse=True)
319
+ removed = subscriptions[max_subs:]
320
+ subscriptions = subscriptions[:max_subs]
321
+ logger.info(f"Removed {len(removed)} old subscriptions for {user_email} (max {max_subs})")
322
+
323
+ user_ref.update({"push_subscriptions": subscriptions})
324
+ return True
325
+
326
+ except Exception as e:
327
+ logger.error(f"Failed to add subscription for {user_email}: {e}")
328
+ return False
329
+
330
+ async def remove_subscription(self, user_email: str, endpoint: str) -> bool:
331
+ """
332
+ Remove a push subscription for a user.
333
+
334
+ Args:
335
+ user_email: User's email address
336
+ endpoint: Push service endpoint URL to remove
337
+
338
+ Returns:
339
+ True if subscription was removed
340
+ """
341
+ try:
342
+ user_ref = self.db.collection("gen_users").document(user_email)
343
+ user_doc = user_ref.get()
344
+
345
+ if not user_doc.exists:
346
+ return False
347
+
348
+ user_data = user_doc.to_dict()
349
+ subscriptions = user_data.get("push_subscriptions", [])
350
+
351
+ # Filter out the subscription
352
+ new_subs = [s for s in subscriptions if s["endpoint"] != endpoint]
353
+
354
+ if len(new_subs) < len(subscriptions):
355
+ user_ref.update({"push_subscriptions": new_subs})
356
+ logger.info(f"Removed push subscription for {user_email}")
357
+ return True
358
+
359
+ return False
360
+
361
+ except Exception as e:
362
+ logger.error(f"Failed to remove subscription for {user_email}: {e}")
363
+ return False
364
+
365
+ async def list_subscriptions(self, user_email: str) -> List[dict]:
366
+ """
367
+ List all push subscriptions for a user.
368
+
369
+ Args:
370
+ user_email: User's email address
371
+
372
+ Returns:
373
+ List of subscription info (endpoint, device_name, created_at, last_used_at)
374
+ """
375
+ try:
376
+ user_doc = self.db.collection("gen_users").document(user_email).get()
377
+
378
+ if not user_doc.exists:
379
+ return []
380
+
381
+ user_data = user_doc.to_dict()
382
+ subscriptions = user_data.get("push_subscriptions", [])
383
+
384
+ # Return safe subset of subscription data
385
+ return [
386
+ {
387
+ "endpoint": s["endpoint"],
388
+ "device_name": s.get("device_name"),
389
+ "created_at": s.get("created_at"),
390
+ "last_used_at": s.get("last_used_at")
391
+ }
392
+ for s in subscriptions
393
+ ]
394
+
395
+ except Exception as e:
396
+ logger.error(f"Failed to list subscriptions for {user_email}: {e}")
397
+ return []
398
+
399
+
400
+ # Singleton instance
401
+ _push_service: Optional[PushNotificationService] = None
402
+
403
+
404
+ def get_push_notification_service(db=None) -> PushNotificationService:
405
+ """Get the push notification service singleton."""
406
+ global _push_service
407
+ if _push_service is None:
408
+ _push_service = PushNotificationService(db)
409
+ return _push_service