karaoke-gen 0.103.1__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.
@@ -374,22 +374,22 @@ class JobManager:
374
374
  updates['progress'] = progress
375
375
 
376
376
  # Generate review token when entering AWAITING_REVIEW state
377
+ # Tokens don't expire - they're job-scoped so low risk, and natural expiry happens when job completes
377
378
  if new_status == JobStatus.AWAITING_REVIEW:
378
- from backend.api.dependencies import generate_review_token, get_review_token_expiry
379
+ from backend.api.dependencies import generate_review_token
379
380
  review_token = generate_review_token()
380
- review_token_expires = get_review_token_expiry(hours=48) # 48 hour expiry
381
381
  updates['review_token'] = review_token
382
- updates['review_token_expires_at'] = review_token_expires
383
- logger.info(f"Generated review token for job {job_id}, expires in 48 hours")
384
-
382
+ updates['review_token_expires_at'] = None # No expiry - token is job-scoped
383
+ logger.info(f"Generated review token for job {job_id} (no expiry)")
384
+
385
385
  # Generate instrumental token when entering AWAITING_INSTRUMENTAL_SELECTION state
386
+ # Tokens don't expire - they're job-scoped so low risk, and natural expiry happens when job completes
386
387
  if new_status == JobStatus.AWAITING_INSTRUMENTAL_SELECTION:
387
- from backend.api.dependencies import generate_review_token, get_review_token_expiry
388
+ from backend.api.dependencies import generate_review_token
388
389
  instrumental_token = generate_review_token() # Reuse same token generator
389
- instrumental_token_expires = get_review_token_expiry(hours=48) # 48 hour expiry
390
390
  updates['instrumental_token'] = instrumental_token
391
- updates['instrumental_token_expires_at'] = instrumental_token_expires
392
- logger.info(f"Generated instrumental token for job {job_id}, expires in 48 hours")
391
+ updates['instrumental_token_expires_at'] = None # No expiry - token is job-scoped
392
+ logger.info(f"Generated instrumental token for job {job_id} (no expiry)")
393
393
 
394
394
  # If we have state_data_updates, merge them with existing state_data
395
395
  merged_state_data = None
@@ -438,7 +438,7 @@ class JobManager:
438
438
 
439
439
  def _trigger_state_notifications(self, job_id: str, new_status: JobStatus) -> None:
440
440
  """
441
- Trigger email notifications based on state transitions.
441
+ Trigger email and push notifications based on state transitions.
442
442
 
443
443
  This is fire-and-forget - notification failures don't affect job processing.
444
444
 
@@ -458,10 +458,14 @@ class JobManager:
458
458
  # Job completion notification
459
459
  if new_status == JobStatus.COMPLETE:
460
460
  self._schedule_completion_email(job)
461
+ self._send_push_notification(job, "complete")
461
462
 
462
463
  # Idle reminder scheduling for blocking states
463
464
  elif new_status in [JobStatus.AWAITING_REVIEW, JobStatus.AWAITING_INSTRUMENTAL_SELECTION]:
464
465
  self._schedule_idle_reminder(job, new_status)
466
+ # Send push notification for blocking states
467
+ action_type = "lyrics" if new_status == JobStatus.AWAITING_REVIEW else "instrumental"
468
+ self._send_push_notification(job, action_type)
465
469
 
466
470
  except Exception as e:
467
471
  # Never let notification failures affect job processing
@@ -581,7 +585,60 @@ class JobManager:
581
585
 
582
586
  except Exception as e:
583
587
  logger.error(f"Failed to schedule idle reminder for job {job.job_id}: {e}")
584
-
588
+
589
+ def _send_push_notification(self, job: Job, action_type: str) -> None:
590
+ """
591
+ Send a push notification for job state changes.
592
+
593
+ Fire-and-forget - failures don't affect job processing.
594
+
595
+ Args:
596
+ job: Job object
597
+ action_type: Type of notification ("lyrics", "instrumental", or "complete")
598
+ """
599
+ import asyncio
600
+ import threading
601
+
602
+ try:
603
+ from backend.services.push_notification_service import get_push_notification_service
604
+
605
+ push_service = get_push_notification_service()
606
+
607
+ # Skip if push notifications not enabled
608
+ if not push_service.is_enabled():
609
+ logger.debug("Push notifications not enabled, skipping")
610
+ return
611
+
612
+ # Build job dict for notification service
613
+ job_dict = {
614
+ "job_id": job.job_id,
615
+ "user_email": job.user_email,
616
+ "artist": job.artist,
617
+ "title": job.title,
618
+ }
619
+
620
+ async def send_notification():
621
+ if action_type == "complete":
622
+ await push_service.send_completion_notification(job_dict)
623
+ else:
624
+ await push_service.send_blocking_notification(job_dict, action_type)
625
+
626
+ # Try to get existing event loop, create new one if none exists
627
+ try:
628
+ loop = asyncio.get_running_loop()
629
+ loop.create_task(send_notification())
630
+ except RuntimeError:
631
+ # No event loop - we're in a sync context
632
+ def run_in_thread():
633
+ asyncio.run(send_notification())
634
+ thread = threading.Thread(target=run_in_thread, daemon=True)
635
+ thread.start()
636
+
637
+ logger.debug(f"Scheduled push notification for job {job.job_id} ({action_type})")
638
+
639
+ except Exception as e:
640
+ logger.error(f"Failed to send push notification for job {job.job_id}: {e}")
641
+
585
642
  def update_state_data(self, job_id: str, key: str, value: Any) -> None:
586
643
  """
587
644
  Update a specific key in the job's state_data field.
@@ -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
@@ -202,9 +202,9 @@ class StripeService:
202
202
  return False, None, "Payment processing is not configured"
203
203
 
204
204
  try:
205
- # Default URLs - redirect to homepage success page
205
+ # Default URLs - redirect to gen app success page
206
206
  if not success_url:
207
- success_url = "https://nomadkaraoke.com/order/success/?session_id={CHECKOUT_SESSION_ID}"
207
+ success_url = f"{self.frontend_url}/order/success?session_id={{CHECKOUT_SESSION_ID}}"
208
208
  if not cancel_url:
209
209
  cancel_url = "https://nomadkaraoke.com/#do-it-for-me"
210
210
 
backend/tests/conftest.py CHANGED
@@ -93,7 +93,8 @@ def mock_auth_dependency(request):
93
93
  return
94
94
 
95
95
  # Skip for service-only unit tests that don't need the FastAPI app
96
- service_only_tests = ['test_rate_limit_service', 'test_email_validation_service', 'test_rate_limits_api']
96
+ # Also skip tests that manage their own auth mocks (e.g., push tests use AuthResult objects)
97
+ service_only_tests = ['test_rate_limit_service', 'test_email_validation_service', 'test_rate_limits_api', 'test_gce_encoding_worker', 'test_push_routes', 'test_push_notification_service']
97
98
  if any(test_name in test_path for test_name in service_only_tests):
98
99
  yield
99
100
  return