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.
- backend/Dockerfile.base +1 -0
- backend/api/routes/admin.py +226 -3
- backend/api/routes/audio_search.py +4 -32
- backend/api/routes/file_upload.py +18 -83
- backend/api/routes/jobs.py +2 -2
- backend/api/routes/push.py +238 -0
- backend/api/routes/rate_limits.py +428 -0
- backend/api/routes/users.py +79 -19
- backend/config.py +25 -1
- backend/exceptions.py +66 -0
- backend/main.py +26 -1
- backend/models/job.py +4 -0
- backend/models/user.py +20 -2
- backend/services/email_validation_service.py +646 -0
- backend/services/firestore_service.py +21 -0
- backend/services/gce_encoding/main.py +22 -8
- backend/services/job_defaults_service.py +113 -0
- backend/services/job_manager.py +109 -13
- backend/services/push_notification_service.py +409 -0
- backend/services/rate_limit_service.py +641 -0
- backend/services/stripe_service.py +2 -2
- backend/tests/conftest.py +8 -1
- backend/tests/test_admin_delete_outputs.py +352 -0
- backend/tests/test_audio_search.py +12 -8
- backend/tests/test_email_validation_service.py +298 -0
- backend/tests/test_file_upload.py +8 -6
- backend/tests/test_gce_encoding_worker.py +229 -0
- backend/tests/test_impersonation.py +18 -3
- backend/tests/test_made_for_you.py +6 -4
- backend/tests/test_push_notification_service.py +460 -0
- backend/tests/test_push_routes.py +357 -0
- backend/tests/test_rate_limit_service.py +396 -0
- backend/tests/test_rate_limits_api.py +392 -0
- backend/tests/test_stripe_service.py +205 -0
- backend/workers/video_worker_orchestrator.py +42 -0
- karaoke_gen/instrumental_review/static/index.html +35 -9
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/METADATA +2 -1
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/RECORD +41 -26
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/entry_points.txt +0 -0
- {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
|