karaoke-gen 0.86.7__py3-none-any.whl → 0.96.0__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 (188) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/style_loader.py +3 -1
  148. karaoke_gen/utils/__init__.py +163 -8
  149. karaoke_gen/video_background_processor.py +9 -4
  150. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
  151. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
  152. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  153. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  154. lyrics_transcriber/correction/corrector.py +192 -130
  155. lyrics_transcriber/correction/operations.py +24 -9
  156. lyrics_transcriber/frontend/package-lock.json +2 -2
  157. lyrics_transcriber/frontend/package.json +1 -1
  158. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  159. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  160. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  161. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  162. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  163. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  164. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  165. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  168. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  170. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  171. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  172. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  173. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  174. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  175. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  176. lyrics_transcriber/frontend/src/theme.ts +42 -15
  177. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  178. lyrics_transcriber/frontend/vite.config.js +5 -0
  179. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  180. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  181. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  182. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  183. lyrics_transcriber/output/generator.py +17 -3
  184. lyrics_transcriber/output/video.py +60 -95
  185. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  186. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  187. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  188. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,106 @@
1
+ """
2
+ Rclone configuration service for cloud storage integration.
3
+
4
+ This service manages the rclone configuration needed for Dropbox
5
+ and other cloud storage uploads from the backend workers.
6
+ """
7
+ import logging
8
+ import os
9
+ import tempfile
10
+ from typing import Optional
11
+
12
+ from backend.config import get_settings
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class RcloneService:
18
+ """Service for managing rclone configuration."""
19
+
20
+ # Secret Manager secret name for rclone config
21
+ RCLONE_CONFIG_SECRET = "rclone-config"
22
+
23
+ def __init__(self):
24
+ self.settings = get_settings()
25
+ self._config_file: Optional[str] = None
26
+ self._config_loaded = False
27
+
28
+ def setup_rclone_config(self) -> bool:
29
+ """
30
+ Load rclone config from Secret Manager and set up environment.
31
+
32
+ Writes the config to a temp file and sets RCLONE_CONFIG env var.
33
+
34
+ Returns:
35
+ True if successful, False otherwise
36
+ """
37
+ if self._config_loaded:
38
+ logger.debug("Rclone config already loaded")
39
+ return True
40
+
41
+ try:
42
+ # Get rclone config from Secret Manager
43
+ config_content = self.settings.get_secret(self.RCLONE_CONFIG_SECRET)
44
+
45
+ if not config_content:
46
+ logger.warning("Rclone config not found in Secret Manager")
47
+ return False
48
+
49
+ # Write to a temp file
50
+ fd, config_path = tempfile.mkstemp(prefix="rclone_", suffix=".conf")
51
+ try:
52
+ with os.fdopen(fd, 'w') as f:
53
+ f.write(config_content)
54
+
55
+ self._config_file = config_path
56
+
57
+ # Set environment variable for rclone to find the config
58
+ os.environ["RCLONE_CONFIG"] = config_path
59
+
60
+ logger.info(f"Rclone config loaded and written to {config_path}")
61
+ self._config_loaded = True
62
+ return True
63
+
64
+ except Exception:
65
+ # Clean up the temp file on error
66
+ # Note: os.fdopen() takes ownership of fd, so it's already closed
67
+ # We only need to remove the temp file if it exists
68
+ if os.path.exists(config_path):
69
+ os.unlink(config_path)
70
+ raise
71
+
72
+ except Exception as e:
73
+ logger.error(f"Failed to setup rclone config: {e}")
74
+ return False
75
+
76
+ def cleanup(self) -> None:
77
+ """Remove the temporary config file."""
78
+ if self._config_file and os.path.exists(self._config_file):
79
+ try:
80
+ os.unlink(self._config_file)
81
+ logger.debug(f"Cleaned up rclone config file: {self._config_file}")
82
+ except Exception as e:
83
+ logger.warning(f"Failed to cleanup rclone config: {e}")
84
+
85
+ # Always reset internal state and environment, even if the file was missing
86
+ if self._config_file is not None:
87
+ os.environ.pop("RCLONE_CONFIG", None)
88
+ self._config_file = None
89
+ self._config_loaded = False
90
+
91
+ @property
92
+ def is_configured(self) -> bool:
93
+ """Check if rclone is configured and ready to use."""
94
+ return self._config_loaded and self._config_file is not None
95
+
96
+
97
+ # Singleton instance
98
+ _rclone_service: Optional[RcloneService] = None
99
+
100
+
101
+ def get_rclone_service() -> RcloneService:
102
+ """Get the singleton rclone service instance."""
103
+ global _rclone_service
104
+ if _rclone_service is None:
105
+ _rclone_service = RcloneService()
106
+ return _rclone_service
@@ -0,0 +1,209 @@
1
+ """
2
+ Google Cloud Storage operations for file management.
3
+ """
4
+ import logging
5
+ import os
6
+ import json
7
+ from typing import Optional, BinaryIO, Any, Dict
8
+ from pathlib import Path
9
+ from google.cloud import storage
10
+ from datetime import timedelta
11
+
12
+ from backend.config import settings
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class StorageService:
19
+ """Service for Google Cloud Storage operations."""
20
+
21
+ def __init__(self):
22
+ """Initialize GCS client."""
23
+ self.client = storage.Client(project=settings.google_cloud_project)
24
+ self.bucket = self.client.bucket(settings.gcs_bucket_name)
25
+
26
+ def upload_file(self, local_path: str, destination_path: str) -> str:
27
+ """Upload a file to GCS."""
28
+ try:
29
+ blob = self.bucket.blob(destination_path)
30
+ blob.upload_from_filename(local_path)
31
+ logger.info(f"Uploaded {local_path} to gs://{settings.gcs_bucket_name}/{destination_path}")
32
+ return destination_path
33
+ except Exception as e:
34
+ logger.error(f"Error uploading file {local_path}: {e}")
35
+ raise
36
+
37
+ def upload_fileobj(self, file_obj: BinaryIO, destination_path: str, content_type: Optional[str] = None) -> str:
38
+ """Upload a file object to GCS."""
39
+ try:
40
+ blob = self.bucket.blob(destination_path)
41
+ if content_type:
42
+ blob.content_type = content_type
43
+ blob.upload_from_file(file_obj, rewind=True)
44
+ logger.info(f"Uploaded file object to gs://{settings.gcs_bucket_name}/{destination_path}")
45
+ return destination_path
46
+ except Exception as e:
47
+ logger.error(f"Error uploading file object: {e}")
48
+ raise
49
+
50
+ def download_file(self, source_path: str, destination_path: str) -> str:
51
+ """Download a file from GCS."""
52
+ try:
53
+ blob = self.bucket.blob(source_path)
54
+ blob.download_to_filename(destination_path)
55
+ logger.info(f"Downloaded gs://{settings.gcs_bucket_name}/{source_path} to {destination_path}")
56
+ return destination_path
57
+ except Exception as e:
58
+ logger.error(f"Error downloading file {source_path}: {e}")
59
+ raise
60
+
61
+ def generate_signed_url(self, blob_path: str, expiration_minutes: int = 60) -> str:
62
+ """Generate a signed URL for downloading a file.
63
+
64
+ In Cloud Run, this uses the IAM signBlob API since we don't have
65
+ a private key available. Requires the service account to have
66
+ roles/iam.serviceAccountTokenCreator on itself.
67
+ """
68
+ return self._generate_signed_url_internal(blob_path, "GET", expiration_minutes)
69
+
70
+ def generate_signed_upload_url(self, blob_path: str, content_type: str = "application/octet-stream", expiration_minutes: int = 60) -> str:
71
+ """Generate a signed URL for uploading a file directly to GCS.
72
+
73
+ This allows clients to upload files directly to GCS without going through
74
+ the backend, bypassing any request body size limits.
75
+
76
+ Args:
77
+ blob_path: The destination path in GCS
78
+ content_type: The expected content type of the upload
79
+ expiration_minutes: How long the URL is valid for
80
+
81
+ Returns:
82
+ A signed URL that accepts PUT requests with the file content
83
+ """
84
+ return self._generate_signed_url_internal(blob_path, "PUT", expiration_minutes, content_type)
85
+
86
+ def _generate_signed_url_internal(self, blob_path: str, method: str, expiration_minutes: int = 60, content_type: Optional[str] = None) -> str:
87
+ """Internal method to generate signed URLs for GET or PUT operations."""
88
+ import google.auth
89
+ from google.auth.transport import requests
90
+
91
+ try:
92
+ blob = self.bucket.blob(blob_path)
93
+
94
+ # Get default credentials and refresh to ensure we have a valid token
95
+ credentials, project = google.auth.default()
96
+
97
+ # Common kwargs for signed URL generation
98
+ kwargs = {
99
+ "version": "v4",
100
+ "expiration": timedelta(minutes=expiration_minutes),
101
+ "method": method,
102
+ }
103
+
104
+ # For PUT requests, we need to specify the content type in headers
105
+ if method == "PUT" and content_type:
106
+ kwargs["headers"] = {"Content-Type": content_type}
107
+
108
+ # Check if we're using compute credentials (Cloud Run/GCE)
109
+ # These need to use IAM signBlob via service_account_email + access_token
110
+ if hasattr(credentials, 'service_account_email'):
111
+ # Refresh credentials to get a valid access token
112
+ auth_request = requests.Request()
113
+ credentials.refresh(auth_request)
114
+
115
+ kwargs["service_account_email"] = credentials.service_account_email
116
+ kwargs["access_token"] = credentials.token
117
+
118
+ url = blob.generate_signed_url(**kwargs)
119
+
120
+ logger.info(f"Generated signed {method} URL for {blob_path}")
121
+ return url
122
+ except Exception as e:
123
+ logger.error(f"Error generating signed {method} URL for {blob_path}: {e}")
124
+ raise
125
+
126
+ def delete_file(self, blob_path: str) -> None:
127
+ """Delete a file from GCS."""
128
+ try:
129
+ blob = self.bucket.blob(blob_path)
130
+ blob.delete()
131
+ logger.info(f"Deleted gs://{settings.gcs_bucket_name}/{blob_path}")
132
+ except Exception as e:
133
+ logger.error(f"Error deleting file {blob_path}: {e}")
134
+ raise
135
+
136
+ def delete_folder(self, prefix: str) -> int:
137
+ """
138
+ Delete all files in GCS with a given prefix (folder).
139
+
140
+ Args:
141
+ prefix: The folder prefix to delete (e.g., "uploads/abc123/")
142
+
143
+ Returns:
144
+ Number of files deleted
145
+ """
146
+ try:
147
+ blobs = list(self.bucket.list_blobs(prefix=prefix))
148
+ deleted_count = 0
149
+
150
+ for blob in blobs:
151
+ try:
152
+ blob.delete()
153
+ deleted_count += 1
154
+ except Exception as e:
155
+ logger.warning(f"Error deleting blob {blob.name}: {e}")
156
+
157
+ if deleted_count > 0:
158
+ logger.info(f"Deleted {deleted_count} files from gs://{settings.gcs_bucket_name}/{prefix}")
159
+
160
+ return deleted_count
161
+ except Exception as e:
162
+ logger.error(f"Error deleting folder {prefix}: {e}")
163
+ return 0 # Don't raise - folder deletion shouldn't break operations
164
+
165
+ def list_files(self, prefix: str) -> list:
166
+ """List files in GCS with a given prefix."""
167
+ try:
168
+ blobs = self.bucket.list_blobs(prefix=prefix)
169
+ return [blob.name for blob in blobs]
170
+ except Exception as e:
171
+ logger.error(f"Error listing files with prefix {prefix}: {e}")
172
+ raise
173
+
174
+ def file_exists(self, blob_path: str) -> bool:
175
+ """Check if a file exists in GCS."""
176
+ try:
177
+ blob = self.bucket.blob(blob_path)
178
+ return blob.exists()
179
+ except Exception as e:
180
+ logger.error(f"Error checking file existence {blob_path}: {e}")
181
+ raise
182
+
183
+ def upload_json(self, destination_path: str, data: Dict[str, Any]) -> str:
184
+ """Upload a JSON object to GCS."""
185
+ try:
186
+ blob = self.bucket.blob(destination_path)
187
+ blob.content_type = "application/json"
188
+ blob.upload_from_string(
189
+ json.dumps(data, indent=2, ensure_ascii=False),
190
+ content_type="application/json"
191
+ )
192
+ logger.info(f"Uploaded JSON to gs://{settings.gcs_bucket_name}/{destination_path}")
193
+ return destination_path
194
+ except Exception as e:
195
+ logger.error(f"Error uploading JSON to {destination_path}: {e}")
196
+ raise
197
+
198
+ def download_json(self, source_path: str) -> Dict[str, Any]:
199
+ """Download and parse a JSON file from GCS."""
200
+ try:
201
+ blob = self.bucket.blob(source_path)
202
+ content = blob.download_as_text()
203
+ data = json.loads(content)
204
+ logger.info(f"Downloaded JSON from gs://{settings.gcs_bucket_name}/{source_path}")
205
+ return data
206
+ except Exception as e:
207
+ logger.error(f"Error downloading JSON from {source_path}: {e}")
208
+ raise
209
+
@@ -0,0 +1,275 @@
1
+ """
2
+ Stripe service for payment processing.
3
+
4
+ Handles:
5
+ - Creating checkout sessions for credit purchases
6
+ - Processing webhook events
7
+ - Managing customer records
8
+ """
9
+ import logging
10
+ import os
11
+ from typing import Optional, Dict, Any, Tuple
12
+
13
+ import stripe
14
+
15
+ from backend.config import get_settings
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # Credit packages available for purchase
22
+ CREDIT_PACKAGES = {
23
+ "1_credit": {
24
+ "credits": 1,
25
+ "price_cents": 500, # $5.00
26
+ "name": "1 Karaoke Credit",
27
+ "description": "Create 1 professional karaoke video",
28
+ },
29
+ "3_credits": {
30
+ "credits": 3,
31
+ "price_cents": 1200, # $12.00 (20% discount)
32
+ "name": "3 Karaoke Credits",
33
+ "description": "Create 3 professional karaoke videos (Save 20%)",
34
+ },
35
+ "5_credits": {
36
+ "credits": 5,
37
+ "price_cents": 1750, # $17.50 (30% discount)
38
+ "name": "5 Karaoke Credits",
39
+ "description": "Create 5 professional karaoke videos (Save 30%)",
40
+ },
41
+ "10_credits": {
42
+ "credits": 10,
43
+ "price_cents": 3000, # $30.00 (40% discount)
44
+ "name": "10 Karaoke Credits",
45
+ "description": "Create 10 professional karaoke videos (Save 40%)",
46
+ },
47
+ }
48
+
49
+
50
+ class StripeService:
51
+ """Service for Stripe payment processing."""
52
+
53
+ def __init__(self):
54
+ """Initialize Stripe with API key."""
55
+ self.settings = get_settings()
56
+ self.secret_key = os.getenv("STRIPE_SECRET_KEY")
57
+ self.webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
58
+ self.frontend_url = os.getenv("FRONTEND_URL", "https://gen.nomadkaraoke.com")
59
+ # After consolidation, buy URL is the same as frontend URL
60
+ self.buy_url = os.getenv("BUY_URL", self.frontend_url)
61
+
62
+ if self.secret_key:
63
+ stripe.api_key = self.secret_key
64
+ logger.info("Stripe initialized with API key")
65
+ else:
66
+ logger.warning("STRIPE_SECRET_KEY not set - payments disabled")
67
+
68
+ def is_configured(self) -> bool:
69
+ """Check if Stripe is properly configured."""
70
+ return bool(self.secret_key)
71
+
72
+ def get_credit_packages(self) -> Dict[str, Dict[str, Any]]:
73
+ """Get available credit packages."""
74
+ return CREDIT_PACKAGES
75
+
76
+ def create_checkout_session(
77
+ self,
78
+ package_id: str,
79
+ user_email: str,
80
+ success_url: Optional[str] = None,
81
+ cancel_url: Optional[str] = None,
82
+ ) -> Tuple[bool, Optional[str], str]:
83
+ """
84
+ Create a Stripe Checkout session for purchasing credits.
85
+
86
+ Args:
87
+ package_id: ID of the credit package to purchase
88
+ user_email: Email of the purchasing user
89
+ success_url: URL to redirect to on success (optional)
90
+ cancel_url: URL to redirect to on cancel (optional)
91
+
92
+ Returns:
93
+ (success, checkout_url, message)
94
+ """
95
+ if not self.is_configured():
96
+ return False, None, "Payment processing is not configured"
97
+
98
+ package = CREDIT_PACKAGES.get(package_id)
99
+ if not package:
100
+ return False, None, f"Invalid package: {package_id}"
101
+
102
+ try:
103
+ # Default URLs
104
+ if not success_url:
105
+ success_url = f"{self.frontend_url}/payment/success?session_id={{CHECKOUT_SESSION_ID}}"
106
+ if not cancel_url:
107
+ cancel_url = f"{self.buy_url}?cancelled=true"
108
+
109
+ # Create checkout session
110
+ session = stripe.checkout.Session.create(
111
+ payment_method_types=['card'],
112
+ line_items=[{
113
+ 'price_data': {
114
+ 'currency': 'usd',
115
+ 'product_data': {
116
+ 'name': package['name'],
117
+ 'description': package['description'],
118
+ },
119
+ 'unit_amount': package['price_cents'],
120
+ },
121
+ 'quantity': 1,
122
+ }],
123
+ mode='payment',
124
+ success_url=success_url,
125
+ cancel_url=cancel_url,
126
+ customer_email=user_email,
127
+ metadata={
128
+ 'package_id': package_id,
129
+ 'credits': str(package['credits']),
130
+ 'user_email': user_email,
131
+ },
132
+ # Allow promotion codes
133
+ allow_promotion_codes=True,
134
+ )
135
+
136
+ logger.info(f"Created checkout session {session.id} for {user_email}, package {package_id}")
137
+ return True, session.url, "Checkout session created"
138
+
139
+ except stripe.error.StripeError as e:
140
+ logger.error(f"Stripe error creating checkout session: {e}")
141
+ return False, None, f"Payment error: {str(e)}"
142
+ except Exception as e:
143
+ logger.error(f"Error creating checkout session: {e}")
144
+ return False, None, "Failed to create checkout session"
145
+
146
+ def verify_webhook_signature(self, payload: bytes, signature: str) -> Tuple[bool, Optional[Dict], str]:
147
+ """
148
+ Verify a Stripe webhook signature.
149
+
150
+ Args:
151
+ payload: Raw request body
152
+ signature: Stripe-Signature header value
153
+
154
+ Returns:
155
+ (valid, event_data, message)
156
+ """
157
+ if not self.webhook_secret:
158
+ logger.error("STRIPE_WEBHOOK_SECRET not configured")
159
+ return False, None, "Webhook secret not configured"
160
+
161
+ try:
162
+ event = stripe.Webhook.construct_event(
163
+ payload, signature, self.webhook_secret
164
+ )
165
+ return True, event, "Webhook verified"
166
+ except stripe.error.SignatureVerificationError as e:
167
+ logger.error(f"Invalid webhook signature: {e}")
168
+ return False, None, "Invalid signature"
169
+ except Exception as e:
170
+ logger.error(f"Error verifying webhook: {e}")
171
+ return False, None, str(e)
172
+
173
+ def handle_checkout_completed(self, session: Dict) -> Tuple[bool, Optional[str], int, str]:
174
+ """
175
+ Handle a completed checkout session.
176
+
177
+ Args:
178
+ session: Stripe checkout session object
179
+
180
+ Returns:
181
+ (success, user_email, credits_to_add, message)
182
+ """
183
+ try:
184
+ metadata = session.get('metadata', {})
185
+ user_email = metadata.get('user_email') or session.get('customer_email')
186
+ try:
187
+ credits = int(metadata.get('credits', 0))
188
+ except (ValueError, TypeError):
189
+ logger.error(f"Invalid credits metadata in session {session.get('id')}: {metadata.get('credits')}")
190
+ return False, user_email, 0, "Invalid credit amount in session metadata"
191
+ package_id = metadata.get('package_id')
192
+
193
+ if not user_email:
194
+ logger.error(f"No user email in checkout session {session.get('id')}")
195
+ return False, None, 0, "No user email found"
196
+
197
+ if credits <= 0:
198
+ logger.error(f"Invalid credits in checkout session {session.get('id')}")
199
+ return False, user_email, 0, "Invalid credit amount"
200
+
201
+ logger.info(
202
+ f"Checkout completed: {user_email} purchased {credits} credits "
203
+ f"(package: {package_id}, session: {session.get('id')})"
204
+ )
205
+
206
+ return True, user_email, credits, f"Successfully purchased {credits} credits"
207
+
208
+ except Exception as e:
209
+ logger.error(f"Error handling checkout completed: {e}")
210
+ return False, None, 0, str(e)
211
+
212
+ def get_session(self, session_id: str) -> Optional[Dict]:
213
+ """
214
+ Get a checkout session by ID.
215
+
216
+ Args:
217
+ session_id: Stripe checkout session ID
218
+
219
+ Returns:
220
+ Session data or None
221
+ """
222
+ if not self.is_configured():
223
+ return None
224
+
225
+ try:
226
+ session = stripe.checkout.Session.retrieve(session_id)
227
+ return dict(session)
228
+ except Exception as e:
229
+ logger.error(f"Error retrieving session {session_id}: {e}")
230
+ return None
231
+
232
+ def create_customer(self, email: str, name: Optional[str] = None) -> Optional[str]:
233
+ """
234
+ Create or get a Stripe customer for a user.
235
+
236
+ Args:
237
+ email: User's email
238
+ name: User's display name (optional)
239
+
240
+ Returns:
241
+ Stripe customer ID or None
242
+ """
243
+ if not self.is_configured():
244
+ return None
245
+
246
+ try:
247
+ # Check if customer already exists
248
+ customers = stripe.Customer.list(email=email, limit=1)
249
+ if customers.data:
250
+ return customers.data[0].id
251
+
252
+ # Create new customer
253
+ customer = stripe.Customer.create(
254
+ email=email,
255
+ name=name,
256
+ metadata={'source': 'nomad_karaoke'},
257
+ )
258
+ logger.info(f"Created Stripe customer {customer.id} for {email}")
259
+ return customer.id
260
+
261
+ except Exception as e:
262
+ logger.error(f"Error creating Stripe customer: {e}")
263
+ return None
264
+
265
+
266
+ # Global instance
267
+ _stripe_service = None
268
+
269
+
270
+ def get_stripe_service() -> StripeService:
271
+ """Get the global Stripe service instance."""
272
+ global _stripe_service
273
+ if _stripe_service is None:
274
+ _stripe_service = StripeService()
275
+ return _stripe_service