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.
backend/Dockerfile.base CHANGED
@@ -54,6 +54,7 @@ WORKDIR /app
54
54
  COPY pyproject.toml README.md LICENSE /app/
55
55
  COPY karaoke_gen /app/karaoke_gen
56
56
  COPY lyrics_transcriber_temp /app/lyrics_transcriber_temp
57
+ COPY backend /app/backend
57
58
 
58
59
  # Install all Python dependencies (the slow part - cached in base image)
59
60
  RUN pip install --no-cache-dir --upgrade pip && \
@@ -8,7 +8,7 @@ Handles:
8
8
  - Audio search cache management
9
9
  """
10
10
  import logging
11
- from datetime import datetime, timedelta
11
+ from datetime import datetime, timedelta, timezone
12
12
  from typing import Tuple, List, Optional, Any, Dict
13
13
 
14
14
  from fastapi import APIRouter, Depends, HTTPException
@@ -1139,6 +1139,229 @@ async def reset_job(
1139
1139
  )
1140
1140
 
1141
1141
 
1142
+ # =============================================================================
1143
+ # Delete Job Outputs Endpoint
1144
+ # =============================================================================
1145
+
1146
+ class DeleteOutputsResponse(BaseModel):
1147
+ """Response from delete job outputs endpoint."""
1148
+ status: str
1149
+ job_id: str
1150
+ message: str
1151
+ deleted_services: Dict[str, Any] # youtube, dropbox, gdrive results
1152
+ cleared_state_data: List[str]
1153
+ outputs_deleted_at: str
1154
+
1155
+
1156
+ # State data keys to clear when deleting outputs
1157
+ OUTPUT_STATE_DATA_KEYS = [
1158
+ "youtube_url",
1159
+ "youtube_video_id",
1160
+ "dropbox_link",
1161
+ "brand_code",
1162
+ "gdrive_files",
1163
+ ]
1164
+
1165
+
1166
+ # Terminal states that allow output deletion
1167
+ TERMINAL_STATES = {"complete", "prep_complete", "failed", "cancelled"}
1168
+
1169
+
1170
+ @router.post("/jobs/{job_id}/delete-outputs", response_model=DeleteOutputsResponse)
1171
+ async def delete_job_outputs(
1172
+ job_id: str,
1173
+ auth_data: AuthResult = Depends(require_admin),
1174
+ ):
1175
+ """
1176
+ Delete all distributed outputs for a job (admin only).
1177
+
1178
+ This endpoint deletes:
1179
+ 1. YouTube video (if uploaded)
1180
+ 2. Dropbox folder (if uploaded) - frees brand code for reuse
1181
+ 3. Google Drive files (if uploaded)
1182
+
1183
+ The job record is preserved with outputs_deleted_at timestamp set.
1184
+ State data related to distribution is cleared.
1185
+
1186
+ Use case: Delete outputs for quality issues, then reset job to
1187
+ awaiting_review or awaiting_instrumental_selection to re-process.
1188
+
1189
+ Args:
1190
+ job_id: Job ID to delete outputs for
1191
+
1192
+ Returns:
1193
+ Deletion results for each service
1194
+ """
1195
+ import re
1196
+ from google.cloud.firestore_v1 import DELETE_FIELD, ArrayUnion
1197
+
1198
+ admin_email = auth_data.user_email or "unknown"
1199
+ job_manager = JobManager()
1200
+ job = job_manager.get_job(job_id)
1201
+
1202
+ if not job:
1203
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
1204
+
1205
+ # Verify job is in a terminal state
1206
+ if job.status not in TERMINAL_STATES:
1207
+ raise HTTPException(
1208
+ status_code=400,
1209
+ detail=f"Can only delete outputs from jobs in terminal states. "
1210
+ f"Current status: {job.status}. Allowed: {', '.join(sorted(TERMINAL_STATES))}"
1211
+ )
1212
+
1213
+ # Check if outputs already deleted
1214
+ if job.outputs_deleted_at:
1215
+ raise HTTPException(
1216
+ status_code=400,
1217
+ detail=f"Outputs were already deleted at {job.outputs_deleted_at}"
1218
+ )
1219
+
1220
+ state_data = job.state_data or {}
1221
+ results = {
1222
+ "youtube": {"status": "skipped", "reason": "no youtube_url in state_data"},
1223
+ "dropbox": {"status": "skipped", "reason": "no brand_code or dropbox_path"},
1224
+ "gdrive": {"status": "skipped", "reason": "no gdrive_files in state_data"},
1225
+ }
1226
+
1227
+ # Clean up YouTube
1228
+ youtube_url = state_data.get('youtube_url')
1229
+ if youtube_url:
1230
+ try:
1231
+ video_id_match = re.search(r'(?:youtu\.be/|youtube\.com/watch\?v=)([^&\s]+)', youtube_url)
1232
+ if video_id_match:
1233
+ video_id = video_id_match.group(1)
1234
+
1235
+ from karaoke_gen.karaoke_finalise.karaoke_finalise import KaraokeFinalise
1236
+ from backend.services.youtube_service import get_youtube_service
1237
+
1238
+ youtube_service = get_youtube_service()
1239
+ if youtube_service.is_configured:
1240
+ finalise = KaraokeFinalise(
1241
+ dry_run=False,
1242
+ non_interactive=True,
1243
+ user_youtube_credentials=youtube_service.get_credentials_dict()
1244
+ )
1245
+ success = finalise.delete_youtube_video(video_id)
1246
+ results["youtube"] = {
1247
+ "status": "success" if success else "failed",
1248
+ "video_id": video_id
1249
+ }
1250
+ else:
1251
+ results["youtube"] = {"status": "skipped", "reason": "YouTube credentials not configured"}
1252
+ else:
1253
+ results["youtube"] = {"status": "failed", "reason": f"Could not extract video ID from {youtube_url}"}
1254
+ except Exception as e:
1255
+ logger.error(f"Error deleting YouTube video for job {job_id}: {e}", exc_info=True)
1256
+ results["youtube"] = {"status": "error", "error": str(e)}
1257
+
1258
+ # Clean up Dropbox
1259
+ brand_code = state_data.get('brand_code')
1260
+ dropbox_path = getattr(job, 'dropbox_path', None)
1261
+ if brand_code and dropbox_path:
1262
+ try:
1263
+ from backend.services.dropbox_service import get_dropbox_service
1264
+ dropbox = get_dropbox_service()
1265
+ if dropbox.is_configured:
1266
+ base_name = f"{job.artist} - {job.title}"
1267
+ folder_name = f"{brand_code} - {base_name}"
1268
+ full_path = f"{dropbox_path}/{folder_name}"
1269
+ success = dropbox.delete_folder(full_path)
1270
+ results["dropbox"] = {
1271
+ "status": "success" if success else "failed",
1272
+ "path": full_path
1273
+ }
1274
+ else:
1275
+ results["dropbox"] = {"status": "skipped", "reason": "Dropbox credentials not configured"}
1276
+ except Exception as e:
1277
+ logger.error(f"Error deleting Dropbox folder for job {job_id}: {e}", exc_info=True)
1278
+ results["dropbox"] = {"status": "error", "error": str(e)}
1279
+
1280
+ # Clean up Google Drive
1281
+ gdrive_files = state_data.get('gdrive_files')
1282
+ if gdrive_files:
1283
+ try:
1284
+ from backend.services.gdrive_service import get_gdrive_service
1285
+ gdrive = get_gdrive_service()
1286
+ if gdrive.is_configured:
1287
+ file_ids = list(gdrive_files.values()) if isinstance(gdrive_files, dict) else []
1288
+ delete_results = gdrive.delete_files(file_ids)
1289
+ all_success = all(delete_results.values())
1290
+ results["gdrive"] = {
1291
+ "status": "success" if all_success else "partial",
1292
+ "files": delete_results
1293
+ }
1294
+ else:
1295
+ results["gdrive"] = {"status": "skipped", "reason": "Google Drive credentials not configured"}
1296
+ except Exception as e:
1297
+ logger.error(f"Error deleting Google Drive files for job {job_id}: {e}", exc_info=True)
1298
+ results["gdrive"] = {"status": "error", "error": str(e)}
1299
+
1300
+ # Update job record
1301
+ deletion_timestamp = datetime.now(timezone.utc)
1302
+ user_service = get_user_service()
1303
+ db = user_service.db
1304
+ job_ref = db.collection("jobs").document(job_id)
1305
+
1306
+ update_payload = {
1307
+ "outputs_deleted_at": deletion_timestamp,
1308
+ "outputs_deleted_by": admin_email,
1309
+ "updated_at": deletion_timestamp,
1310
+ }
1311
+
1312
+ # Clear distribution-related state_data keys
1313
+ cleared_keys = []
1314
+ for key in OUTPUT_STATE_DATA_KEYS:
1315
+ if key in state_data:
1316
+ update_payload[f"state_data.{key}"] = DELETE_FIELD
1317
+ cleared_keys.append(key)
1318
+
1319
+ # Add timeline event
1320
+ timeline_event = {
1321
+ "status": job.status, # Keep current status
1322
+ "timestamp": deletion_timestamp.isoformat(),
1323
+ "message": f"Outputs deleted by admin ({admin_email})",
1324
+ }
1325
+ update_payload["timeline"] = ArrayUnion([timeline_event])
1326
+
1327
+ job_ref.update(update_payload)
1328
+
1329
+ # Determine overall status based on per-service results
1330
+ error_services = [s for s, r in results.items() if r["status"] == "error"]
1331
+ failed_services = [s for s, r in results.items() if r["status"] == "failed"]
1332
+ success_services = [s for s, r in results.items() if r["status"] == "success"]
1333
+
1334
+ if error_services:
1335
+ overall_status = "partial_success" if success_services else "error"
1336
+ error_details = "; ".join(
1337
+ f"{s}: {results[s].get('error', 'unknown error')}" for s in error_services
1338
+ )
1339
+ message = f"Some services failed: {error_details}"
1340
+ elif failed_services:
1341
+ overall_status = "partial_success" if success_services else "failed"
1342
+ message = f"Some deletions failed: {', '.join(failed_services)}"
1343
+ else:
1344
+ overall_status = "success"
1345
+ message = "Outputs deleted successfully"
1346
+
1347
+ logger.info(
1348
+ f"Admin {admin_email} deleted outputs for job {job_id}. "
1349
+ f"YouTube: {results['youtube']['status']}, "
1350
+ f"Dropbox: {results['dropbox']['status']}, "
1351
+ f"GDrive: {results['gdrive']['status']}. "
1352
+ f"Cleared state_data keys: {cleared_keys}"
1353
+ )
1354
+
1355
+ return DeleteOutputsResponse(
1356
+ status=overall_status,
1357
+ job_id=job_id,
1358
+ message=message,
1359
+ deleted_services=results,
1360
+ cleared_state_data=cleared_keys,
1361
+ outputs_deleted_at=deletion_timestamp.isoformat(),
1362
+ )
1363
+
1364
+
1142
1365
  @router.get("/jobs/{job_id}/completion-message", response_model=CompletionMessageResponse)
1143
1366
  async def get_job_completion_message(
1144
1367
  job_id: str,
@@ -1286,7 +1509,7 @@ class ImpersonateUserResponse(BaseModel):
1286
1509
  @router.post("/users/{email}/impersonate", response_model=ImpersonateUserResponse)
1287
1510
  async def impersonate_user(
1288
1511
  email: str,
1289
- auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1512
+ auth_data: AuthResult = Depends(require_admin),
1290
1513
  user_service: UserService = Depends(get_user_service),
1291
1514
  ):
1292
1515
  """
@@ -1308,7 +1531,7 @@ async def impersonate_user(
1308
1531
  user_email: The impersonated user's email
1309
1532
  message: Success message
1310
1533
  """
1311
- admin_email = auth_data[0]
1534
+ admin_email = auth_data.user_email or "unknown"
1312
1535
  target_email = email.lower()
1313
1536
 
1314
1537
  # Cannot impersonate yourself
@@ -0,0 +1,238 @@
1
+ """
2
+ Push Notification API routes.
3
+
4
+ Provides endpoints for managing Web Push notification subscriptions:
5
+ - GET /api/push/vapid-public-key: Get VAPID public key for client-side subscription
6
+ - POST /api/push/subscribe: Register a push subscription
7
+ - POST /api/push/unsubscribe: Remove a push subscription
8
+ - GET /api/push/subscriptions: List user's subscriptions
9
+ - POST /api/push/test: Send a test notification (admin only)
10
+ """
11
+ import logging
12
+ from typing import Optional, Dict, List
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException
15
+ from pydantic import BaseModel
16
+
17
+ from backend.config import get_settings
18
+ from backend.api.dependencies import require_auth, require_admin
19
+ from backend.services.auth_service import AuthResult
20
+ from backend.services.push_notification_service import get_push_notification_service
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+ router = APIRouter(prefix="/push", tags=["push"])
25
+
26
+
27
+ # Request/Response Models
28
+
29
+ class VapidPublicKeyResponse(BaseModel):
30
+ """Response containing VAPID public key."""
31
+ enabled: bool
32
+ vapid_public_key: Optional[str] = None
33
+
34
+
35
+ class SubscribeRequest(BaseModel):
36
+ """Request to subscribe to push notifications."""
37
+ endpoint: str
38
+ keys: Dict[str, str] # p256dh and auth
39
+ device_name: Optional[str] = None
40
+
41
+
42
+ class SubscribeResponse(BaseModel):
43
+ """Response after subscribing."""
44
+ status: str
45
+ message: str
46
+
47
+
48
+ class UnsubscribeRequest(BaseModel):
49
+ """Request to unsubscribe from push notifications."""
50
+ endpoint: str
51
+
52
+
53
+ class UnsubscribeResponse(BaseModel):
54
+ """Response after unsubscribing."""
55
+ status: str
56
+ message: str
57
+
58
+
59
+ class SubscriptionInfo(BaseModel):
60
+ """Information about a push subscription."""
61
+ endpoint: str
62
+ device_name: Optional[str] = None
63
+ created_at: Optional[str] = None
64
+ last_used_at: Optional[str] = None
65
+
66
+
67
+ class SubscriptionsListResponse(BaseModel):
68
+ """Response containing user's subscriptions."""
69
+ subscriptions: List[SubscriptionInfo]
70
+ count: int
71
+
72
+
73
+ class TestNotificationRequest(BaseModel):
74
+ """Request to send a test notification."""
75
+ title: Optional[str] = "Test Notification"
76
+ body: Optional[str] = "This is a test push notification from Karaoke Generator"
77
+
78
+
79
+ class TestNotificationResponse(BaseModel):
80
+ """Response after sending test notification."""
81
+ status: str
82
+ sent_count: int
83
+ message: str
84
+
85
+
86
+ # Routes
87
+
88
+ @router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
89
+ async def get_vapid_public_key():
90
+ """
91
+ Get the VAPID public key for push subscription.
92
+
93
+ This endpoint is public - no authentication required.
94
+ Returns the public key needed for client-side PushManager.subscribe().
95
+ """
96
+ settings = get_settings()
97
+ push_service = get_push_notification_service()
98
+
99
+ if not settings.enable_push_notifications:
100
+ return VapidPublicKeyResponse(enabled=False)
101
+
102
+ public_key = push_service.get_public_key()
103
+ if not public_key:
104
+ return VapidPublicKeyResponse(enabled=False)
105
+
106
+ return VapidPublicKeyResponse(
107
+ enabled=True,
108
+ vapid_public_key=public_key
109
+ )
110
+
111
+
112
+ @router.post("/subscribe", response_model=SubscribeResponse)
113
+ async def subscribe_push(
114
+ request: SubscribeRequest,
115
+ auth_result: AuthResult = Depends(require_auth)
116
+ ):
117
+ """
118
+ Register a push notification subscription for the current user.
119
+
120
+ Requires authentication. Users can have up to 5 subscriptions
121
+ (configurable via MAX_PUSH_SUBSCRIPTIONS_PER_USER).
122
+ """
123
+ settings = get_settings()
124
+ if not settings.enable_push_notifications:
125
+ raise HTTPException(status_code=503, detail="Push notifications are not enabled")
126
+
127
+ if not auth_result.user_email:
128
+ raise HTTPException(status_code=401, detail="User email not available")
129
+
130
+ push_service = get_push_notification_service()
131
+
132
+ # Validate keys
133
+ if "p256dh" not in request.keys or "auth" not in request.keys:
134
+ raise HTTPException(status_code=400, detail="Missing required keys (p256dh, auth)")
135
+
136
+ success = await push_service.add_subscription(
137
+ user_email=auth_result.user_email,
138
+ endpoint=request.endpoint,
139
+ keys=request.keys,
140
+ device_name=request.device_name
141
+ )
142
+
143
+ if not success:
144
+ raise HTTPException(status_code=500, detail="Failed to save subscription")
145
+
146
+ return SubscribeResponse(
147
+ status="success",
148
+ message="Push subscription registered successfully"
149
+ )
150
+
151
+
152
+ @router.post("/unsubscribe", response_model=UnsubscribeResponse)
153
+ async def unsubscribe_push(
154
+ request: UnsubscribeRequest,
155
+ auth_result: AuthResult = Depends(require_auth)
156
+ ):
157
+ """
158
+ Remove a push notification subscription.
159
+
160
+ Requires authentication. Users can only remove their own subscriptions.
161
+ """
162
+ if not auth_result.user_email:
163
+ raise HTTPException(status_code=401, detail="User email not available")
164
+
165
+ push_service = get_push_notification_service()
166
+
167
+ success = await push_service.remove_subscription(
168
+ user_email=auth_result.user_email,
169
+ endpoint=request.endpoint
170
+ )
171
+
172
+ if not success:
173
+ # Don't error if subscription wasn't found - might already be removed
174
+ return UnsubscribeResponse(
175
+ status="success",
176
+ message="Subscription removed (or was not found)"
177
+ )
178
+
179
+ return UnsubscribeResponse(
180
+ status="success",
181
+ message="Push subscription removed successfully"
182
+ )
183
+
184
+
185
+ @router.get("/subscriptions", response_model=SubscriptionsListResponse)
186
+ async def list_subscriptions(
187
+ auth_result: AuthResult = Depends(require_auth)
188
+ ):
189
+ """
190
+ List all push notification subscriptions for the current user.
191
+
192
+ Requires authentication.
193
+ """
194
+ if not auth_result.user_email:
195
+ raise HTTPException(status_code=401, detail="User email not available")
196
+
197
+ push_service = get_push_notification_service()
198
+
199
+ subscriptions = await push_service.list_subscriptions(auth_result.user_email)
200
+
201
+ return SubscriptionsListResponse(
202
+ subscriptions=[SubscriptionInfo(**s) for s in subscriptions],
203
+ count=len(subscriptions)
204
+ )
205
+
206
+
207
+ @router.post("/test", response_model=TestNotificationResponse)
208
+ async def send_test_notification(
209
+ request: TestNotificationRequest,
210
+ auth_result: AuthResult = Depends(require_admin)
211
+ ):
212
+ """
213
+ Send a test push notification to the current user's devices.
214
+
215
+ Admin only. Useful for testing push notification setup.
216
+ """
217
+ settings = get_settings()
218
+ if not settings.enable_push_notifications:
219
+ raise HTTPException(status_code=503, detail="Push notifications are not enabled")
220
+
221
+ if not auth_result.user_email:
222
+ raise HTTPException(status_code=401, detail="User email not available")
223
+
224
+ push_service = get_push_notification_service()
225
+
226
+ sent_count = await push_service.send_push(
227
+ user_email=auth_result.user_email,
228
+ title=request.title or "Test Notification",
229
+ body=request.body or "This is a test push notification",
230
+ url="/app/",
231
+ tag="test"
232
+ )
233
+
234
+ return TestNotificationResponse(
235
+ status="success",
236
+ sent_count=sent_count,
237
+ message=f"Test notification sent to {sent_count} device(s)"
238
+ )
backend/config.py CHANGED
@@ -123,7 +123,15 @@ class Settings(BaseSettings):
123
123
  # These can be overridden per-request via explicit enable_cdg/enable_txt parameters
124
124
  default_enable_cdg: bool = os.getenv("DEFAULT_ENABLE_CDG", "true").lower() in ("true", "1", "yes")
125
125
  default_enable_txt: bool = os.getenv("DEFAULT_ENABLE_TXT", "true").lower() in ("true", "1", "yes")
126
-
126
+
127
+ # Push Notifications Configuration
128
+ # When enabled, users can subscribe to push notifications for job status updates
129
+ enable_push_notifications: bool = os.getenv("ENABLE_PUSH_NOTIFICATIONS", "false").lower() in ("true", "1", "yes")
130
+ # Maximum number of push subscriptions per user (oldest removed when exceeded)
131
+ max_push_subscriptions_per_user: int = int(os.getenv("MAX_PUSH_SUBSCRIPTIONS_PER_USER", "5"))
132
+ # VAPID subject (email or URL for push service to contact)
133
+ vapid_subject: str = os.getenv("VAPID_SUBJECT", "mailto:gen@nomadkaraoke.com")
134
+
127
135
  # Secret Manager cache
128
136
  _secret_cache: Dict[str, str] = {}
129
137
 
backend/main.py CHANGED
@@ -7,7 +7,7 @@ from fastapi import FastAPI
7
7
  from fastapi.middleware.cors import CORSMiddleware
8
8
 
9
9
  from backend.config import settings
10
- from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin, tenant, rate_limits
10
+ from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin, tenant, rate_limits, push
11
11
  from backend.services.tracing import setup_tracing, instrument_app, get_current_trace_id
12
12
  from backend.services.structured_logging import setup_structured_logging
13
13
  from backend.services.spacy_preloader import preload_spacy_model
@@ -144,6 +144,7 @@ app.include_router(themes.router, prefix="/api") # Theme selection for styles
144
144
  app.include_router(users.router, prefix="/api") # User auth, credits, and Stripe webhooks
145
145
  app.include_router(admin.router, prefix="/api") # Admin dashboard and management
146
146
  app.include_router(rate_limits.router, prefix="/api") # Rate limits admin management
147
+ app.include_router(push.router, prefix="/api") # Push notification subscription management
147
148
  app.include_router(tenant.router) # Tenant/white-label configuration (no /api prefix, router has it)
148
149
 
149
150
 
backend/models/job.py CHANGED
@@ -285,6 +285,10 @@ class Job(BaseModel):
285
285
  customer_email: Optional[str] = None # Customer email for final delivery (job owned by admin during processing)
286
286
  customer_notes: Optional[str] = None # Notes provided by customer with their order
287
287
 
288
+ # Output deletion tracking (for admin cleanup without deleting job)
289
+ outputs_deleted_at: Optional[datetime] = None # Timestamp when outputs were deleted by admin
290
+ outputs_deleted_by: Optional[str] = None # Admin email who deleted outputs
291
+
288
292
  # Processing state
289
293
  track_output_dir: Optional[str] = None # Local output directory (temp)
290
294
  audio_hash: Optional[str] = None # Hash for deduplication
backend/models/user.py CHANGED
@@ -8,9 +8,9 @@ Supports:
8
8
  - Stripe integration for payments
9
9
  - Beta tester program with feedback collection
10
10
  """
11
- from datetime import datetime
11
+ from datetime import datetime, timezone
12
12
  from enum import Enum
13
- from typing import Optional, List
13
+ from typing import Optional, List, Dict
14
14
  from pydantic import BaseModel, Field
15
15
 
16
16
 
@@ -39,6 +39,20 @@ class CreditTransaction(BaseModel):
39
39
  created_by: Optional[str] = None # Admin email if granted by admin
40
40
 
41
41
 
42
+ class PushSubscription(BaseModel):
43
+ """
44
+ Web Push subscription for a user's device.
45
+
46
+ Stores the push subscription endpoint and encryption keys needed
47
+ to send push notifications to the user's browser/device.
48
+ """
49
+ endpoint: str # Push service endpoint URL
50
+ keys: Dict[str, str] # p256dh and auth keys for encryption
51
+ device_name: Optional[str] = None # e.g., "iPhone", "Chrome on Windows"
52
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
53
+ last_used_at: Optional[datetime] = None # Last time a notification was sent
54
+
55
+
42
56
  class User(BaseModel):
43
57
  """
44
58
  User model stored in Firestore.
@@ -86,6 +100,10 @@ class User(BaseModel):
86
100
  beta_feedback_due_at: Optional[datetime] = None # 24hr after job completion
87
101
  beta_feedback_email_sent: bool = False
88
102
 
103
+ # Push notification subscriptions (Web Push API)
104
+ # Users can subscribe from multiple devices/browsers
105
+ push_subscriptions: List[PushSubscription] = Field(default_factory=list)
106
+
89
107
 
90
108
  class MagicLinkToken(BaseModel):
91
109
  """
@@ -338,19 +338,33 @@ def run_encoding(job_id: str, work_dir: Path, config: dict):
338
338
  # Search more specifically for karaoke video
339
339
  karaoke_video = find_file(work_dir, "*Karaoke*.mkv", "*Karaoke*.mov", "*vocals*.mkv")
340
340
 
341
- # Instrumental audio
342
- instrumental = find_file(
343
- work_dir,
344
- "*instrumental_clean*.flac", "*Instrumental Clean*.flac",
345
- "*instrumental*.flac", "*Instrumental*.flac",
346
- "*instrumental*.wav"
347
- )
341
+ # Instrumental audio - respect user's selection from encoding config
342
+ instrumental_selection = config.get("instrumental_selection", "clean")
343
+ logger.info(f"Instrumental selection from config: {instrumental_selection}")
344
+
345
+ if instrumental_selection == "with_backing":
346
+ # User selected instrumental with backing vocals
347
+ instrumental = find_file(
348
+ work_dir,
349
+ "*instrumental_with_backing*.flac", "*Instrumental Backing*.flac",
350
+ "*with_backing*.flac", "*Backing*.flac",
351
+ "*instrumental*.flac", "*Instrumental*.flac",
352
+ "*instrumental*.wav"
353
+ )
354
+ else:
355
+ # Default to clean instrumental
356
+ instrumental = find_file(
357
+ work_dir,
358
+ "*instrumental_clean*.flac", "*Instrumental Clean*.flac",
359
+ "*instrumental*.flac", "*Instrumental*.flac",
360
+ "*instrumental*.wav"
361
+ )
348
362
 
349
363
  logger.info(f"Found files:")
350
364
  logger.info(f" Title video: {title_video}")
351
365
  logger.info(f" Karaoke video: {karaoke_video}")
352
366
  logger.info(f" End video: {end_video}")
353
- logger.info(f" Instrumental: {instrumental}")
367
+ logger.info(f" Instrumental ({instrumental_selection}): {instrumental}")
354
368
 
355
369
  # Validate required files
356
370
  if not title_video: