karaoke-gen 0.90.1__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 (187) 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/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,254 @@
1
+ """
2
+ Structured logging with trace correlation for Cloud Logging.
3
+
4
+ This module provides JSON-formatted logging that integrates with Google Cloud Logging
5
+ and correlates logs with OpenTelemetry traces in Cloud Trace.
6
+
7
+ When running in Cloud Run:
8
+ - Logs are output as JSON for Cloud Logging to parse
9
+ - Each log entry includes trace ID and span ID for correlation
10
+ - Custom fields (job_id, worker) are preserved in the log structure
11
+
12
+ Usage:
13
+ # In main.py, before any logging:
14
+ from backend.services.structured_logging import setup_structured_logging
15
+ setup_structured_logging()
16
+
17
+ # Then use standard logging:
18
+ logger = logging.getLogger(__name__)
19
+ logger.info("Processing started", extra={"job_id": "abc123"})
20
+ """
21
+ import json
22
+ import logging
23
+ import os
24
+ import sys
25
+ from datetime import datetime
26
+ from typing import Any, Dict, Optional
27
+
28
+
29
+ # Cloud Logging severity mapping (compatible with GCP)
30
+ # https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogSeverity
31
+ SEVERITY_MAP = {
32
+ "DEBUG": "DEBUG",
33
+ "INFO": "INFO",
34
+ "WARNING": "WARNING",
35
+ "ERROR": "ERROR",
36
+ "CRITICAL": "CRITICAL",
37
+ }
38
+
39
+
40
+ class StructuredFormatter(logging.Formatter):
41
+ """
42
+ JSON formatter with trace correlation for Google Cloud Logging.
43
+
44
+ Produces log entries in the format expected by Cloud Logging, including:
45
+ - logging.googleapis.com/trace: Links to Cloud Trace
46
+ - logging.googleapis.com/spanId: Current span ID
47
+ - severity: Cloud Logging severity level
48
+ - Custom fields passed via 'extra' dict
49
+ """
50
+
51
+ def __init__(self, project_id: Optional[str] = None):
52
+ """
53
+ Initialize the structured formatter.
54
+
55
+ Args:
56
+ project_id: GCP project ID for trace URLs (auto-detected if not provided)
57
+ """
58
+ super().__init__()
59
+ self.project_id = project_id or os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("GCP_PROJECT")
60
+
61
+ def format(self, record: logging.LogRecord) -> str:
62
+ """
63
+ Format a log record as JSON.
64
+
65
+ Args:
66
+ record: The log record to format
67
+
68
+ Returns:
69
+ JSON string for Cloud Logging
70
+ """
71
+ from backend.services.tracing import get_current_trace_id, get_current_span_id
72
+
73
+ # Build base log entry
74
+ log_entry: Dict[str, Any] = {
75
+ "timestamp": datetime.utcfromtimestamp(record.created).isoformat() + "Z",
76
+ "severity": SEVERITY_MAP.get(record.levelname, record.levelname),
77
+ "message": record.getMessage(),
78
+ "logger": record.name,
79
+ }
80
+
81
+ # Add trace correlation if available
82
+ trace_id = get_current_trace_id()
83
+ span_id = get_current_span_id()
84
+
85
+ if trace_id and self.project_id:
86
+ # Format for Cloud Logging trace correlation
87
+ log_entry["logging.googleapis.com/trace"] = f"projects/{self.project_id}/traces/{trace_id}"
88
+
89
+ if span_id:
90
+ log_entry["logging.googleapis.com/spanId"] = span_id
91
+
92
+ # Add custom fields from 'extra' dict
93
+ # Common fields we want to extract from log records
94
+ custom_fields = [
95
+ # Job-related fields
96
+ "job_id", "worker", "operation", "duration", "status", "error",
97
+ # Audit logging fields (from middleware and auth)
98
+ "request_id", "user_email", "client_ip", "latency_ms",
99
+ "audit_type", "method", "path", "status_code", "query_string",
100
+ "user_agent", "user_type", "is_admin", "remaining_uses",
101
+ "auth_message", "token_provided", "token_length", "auth_header_present",
102
+ ]
103
+ for field in custom_fields:
104
+ value = getattr(record, field, None)
105
+ if value is not None:
106
+ log_entry[field] = value
107
+
108
+ # Add source location for debugging
109
+ if record.pathname and record.lineno:
110
+ log_entry["logging.googleapis.com/sourceLocation"] = {
111
+ "file": record.pathname,
112
+ "line": record.lineno,
113
+ "function": record.funcName,
114
+ }
115
+
116
+ # Add exception info if present
117
+ if record.exc_info:
118
+ log_entry["exception"] = self.formatException(record.exc_info)
119
+
120
+ # Remove None values for cleaner output
121
+ return json.dumps({k: v for k, v in log_entry.items() if v is not None})
122
+
123
+
124
+ class HumanReadableFormatter(logging.Formatter):
125
+ """
126
+ Human-readable formatter for local development.
127
+
128
+ Includes trace context when available but outputs in traditional format.
129
+ """
130
+
131
+ def format(self, record: logging.LogRecord) -> str:
132
+ """Format a log record for human readability."""
133
+ from backend.services.tracing import get_current_trace_id
134
+
135
+ # Build base message
136
+ timestamp = datetime.utcfromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
137
+
138
+ # Add job_id prefix if available
139
+ job_id = getattr(record, "job_id", None)
140
+ job_prefix = f"[job:{job_id[:8]}] " if job_id else ""
141
+
142
+ # Add trace ID suffix if available
143
+ trace_id = get_current_trace_id()
144
+ trace_suffix = f" [trace:{trace_id[:8]}]" if trace_id else ""
145
+
146
+ message = f"{timestamp} - {record.name} - {record.levelname} - {job_prefix}{record.getMessage()}{trace_suffix}"
147
+
148
+ # Add exception info if present
149
+ if record.exc_info:
150
+ message += "\n" + self.formatException(record.exc_info)
151
+
152
+ return message
153
+
154
+
155
+ def is_cloud_run() -> bool:
156
+ """Check if we're running in Cloud Run."""
157
+ return os.environ.get("K_SERVICE") is not None
158
+
159
+
160
+ def setup_structured_logging(force_json: bool = False, log_level: Optional[str] = None) -> None:
161
+ """
162
+ Configure structured logging for the application.
163
+
164
+ In Cloud Run: Uses JSON format with trace correlation for Cloud Logging
165
+ In development: Uses human-readable format with optional trace context
166
+
167
+ Args:
168
+ force_json: Force JSON output even in development
169
+ log_level: Override log level (default from settings or INFO)
170
+ """
171
+ from backend.config import settings
172
+
173
+ # Determine log level
174
+ level = log_level or getattr(settings, "log_level", "INFO")
175
+ level = getattr(logging, level.upper(), logging.INFO)
176
+
177
+ # Choose formatter based on environment
178
+ if is_cloud_run() or force_json:
179
+ formatter = StructuredFormatter()
180
+ else:
181
+ formatter = HumanReadableFormatter()
182
+
183
+ # Create handler
184
+ handler = logging.StreamHandler(sys.stdout)
185
+ handler.setFormatter(formatter)
186
+ handler.setLevel(level)
187
+
188
+ # Configure root logger
189
+ root_logger = logging.getLogger()
190
+ root_logger.setLevel(level)
191
+
192
+ # Remove existing handlers and add our handler
193
+ root_logger.handlers = [handler]
194
+
195
+ # Also configure uvicorn loggers to use our format
196
+ for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error"]:
197
+ logger = logging.getLogger(logger_name)
198
+ logger.handlers = [handler]
199
+ logger.propagate = False
200
+
201
+
202
+ class JobLogAdapter(logging.LoggerAdapter):
203
+ """
204
+ Logger adapter that automatically adds job context to log records.
205
+
206
+ Usage:
207
+ logger = logging.getLogger(__name__)
208
+ job_logger = JobLogAdapter(logger, job_id="abc123", worker="audio")
209
+ job_logger.info("Processing started") # Automatically includes job_id and worker
210
+ """
211
+
212
+ def __init__(self, logger: logging.Logger, job_id: str, worker: Optional[str] = None, **extra):
213
+ """
214
+ Initialize the adapter.
215
+
216
+ Args:
217
+ logger: Base logger instance
218
+ job_id: Job ID to include in all log records
219
+ worker: Optional worker name
220
+ **extra: Additional fields to include in all log records
221
+ """
222
+ super().__init__(logger, {"job_id": job_id, "worker": worker, **extra})
223
+
224
+ def process(self, msg: str, kwargs: Dict[str, Any]) -> tuple:
225
+ """Process a logging call to add extra context."""
226
+ # Merge extra dict from adapter with any extra passed to the log call
227
+ extra = kwargs.get("extra", {})
228
+ extra.update(self.extra)
229
+ kwargs["extra"] = extra
230
+ return msg, kwargs
231
+
232
+
233
+ def get_job_logger(job_id: str, worker: Optional[str] = None, name: Optional[str] = None) -> JobLogAdapter:
234
+ """
235
+ Get a logger adapter configured for a specific job.
236
+
237
+ This is a convenience function for creating job-specific loggers.
238
+
239
+ Args:
240
+ job_id: Job ID to include in all log records
241
+ worker: Optional worker name
242
+ name: Logger name (defaults to "job")
243
+
244
+ Returns:
245
+ JobLogAdapter configured for the job
246
+
247
+ Usage:
248
+ logger = get_job_logger("abc123", "audio")
249
+ logger.info("Starting audio separation")
250
+ logger.error("Failed to process", extra={"error": str(e)})
251
+ """
252
+ base_logger = logging.getLogger(name or "job")
253
+ return JobLogAdapter(base_logger, job_id=job_id, worker=worker)
254
+
@@ -0,0 +1,330 @@
1
+ """
2
+ Template service for managing email templates stored in GCS.
3
+
4
+ Templates are stored in GCS bucket and rendered with job-specific variables.
5
+ This allows updating email content without code deployment.
6
+
7
+ Template locations:
8
+ - gs://{bucket}/templates/job-completion.txt - Job completion email (plain text)
9
+ - gs://{bucket}/templates/action-needed-lyrics.txt - Lyrics review reminder
10
+ - gs://{bucket}/templates/action-needed-instrumental.txt - Instrumental selection reminder
11
+ """
12
+ import logging
13
+ import re
14
+ from typing import Optional, Dict, Any
15
+
16
+ from backend.config import get_settings
17
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # Default templates (fallback if GCS fetch fails)
23
+ DEFAULT_JOB_COMPLETION_TEMPLATE = """Hi {name},
24
+
25
+ Thanks for your order!
26
+
27
+ Here's the link for the karaoke video published to YouTube:
28
+ {youtube_url}
29
+
30
+ Here's the dropbox folder with all the finished files and source files, including:
31
+ - "(Final Karaoke Lossless).mkv": combined karaoke video in 4k H264 with lossless FLAC audio
32
+ - "(Final Karaoke).mp4": combined karaoke video with title/end screen in 4k H264/AAC
33
+ - "(Final Karaoke 720p).mp4": combined karaoke video in 720p H264/AAC (smaller file for older systems)
34
+ - "(With Vocals).mp4": sing along video in 4k H264/AAC with original vocals
35
+ - "(Karaoke).mov": karaoke video output from MidiCo (no title/end screen)
36
+ - "(Title).mov"/"(End).mov": title card and end screen videos
37
+ - "(Final Karaoke CDG).zip": CDG+MP3 format for older/commercial karaoke systems
38
+ - "(Final Karaoke TXT).zip": TXT+MP3 format for Power Karaoke
39
+ - stems/*.flac: various separated instrumental and vocal audio stems in lossless format
40
+ - lyrics/*.txt song lyrics from various sources in plain text format
41
+
42
+ {dropbox_url}
43
+
44
+ Let me know if anything isn't perfect and I'll happily tweak / fix, or if you need it in any other format I can probably convert it for you!
45
+
46
+ If you have a moment, I'd really appreciate your feedback (takes 2 minutes):
47
+ {feedback_url}
48
+
49
+ Thanks again and have a great day!
50
+ -Andrew
51
+ """
52
+
53
+ DEFAULT_ACTION_NEEDED_LYRICS_TEMPLATE = """Hi {name},
54
+
55
+ Your karaoke video for "{artist} - {title}" is ready for lyrics review!
56
+
57
+ Our system has transcribed and synchronized the lyrics, but they may need some corrections. Please review and make any corrections necessary so we can finish generating your video.
58
+
59
+ Review your lyrics here:
60
+ {review_url}
61
+
62
+ This usually takes just a few minutes.
63
+
64
+ Thanks!
65
+ -Andrew
66
+ """
67
+
68
+ DEFAULT_ACTION_NEEDED_INSTRUMENTAL_TEMPLATE = """Hi {name},
69
+
70
+ Your karaoke video for "{artist} - {title}" is almost done!
71
+
72
+ We've separated the audio into different versions. Please select which instrumental track you'd like to use for the final video.
73
+
74
+ Select your instrumental here:
75
+ {instrumental_url}
76
+
77
+ Thanks!
78
+ -Andrew
79
+ """
80
+
81
+
82
+ class TemplateService:
83
+ """
84
+ Service for fetching and rendering email templates from GCS.
85
+
86
+ Templates support the following variables:
87
+ - {name} - User's display name or "there" if unknown
88
+ - {youtube_url} - YouTube video URL
89
+ - {dropbox_url} - Dropbox folder URL
90
+ - {artist} - Artist name
91
+ - {title} - Song title
92
+ - {job_id} - Job ID
93
+ - {review_url} - Lyrics review URL
94
+ - {instrumental_url} - Instrumental selection URL
95
+ - {feedback_url} - Feedback form URL
96
+ """
97
+
98
+ TEMPLATE_PREFIX = "templates/"
99
+
100
+ def __init__(self):
101
+ """Initialize template service."""
102
+ self.settings = get_settings()
103
+ self._storage_client = None
104
+ self._bucket = None
105
+
106
+ @property
107
+ def storage_client(self):
108
+ """Lazy-initialize storage client."""
109
+ if self._storage_client is None:
110
+ from google.cloud import storage
111
+ self._storage_client = storage.Client(project=self.settings.google_cloud_project)
112
+ return self._storage_client
113
+
114
+ @property
115
+ def bucket(self):
116
+ """Get the GCS bucket for templates."""
117
+ if self._bucket is None:
118
+ self._bucket = self.storage_client.bucket(self.settings.gcs_bucket_name)
119
+ return self._bucket
120
+
121
+ def _fetch_template_from_gcs(self, template_name: str) -> Optional[str]:
122
+ """
123
+ Fetch a template from GCS.
124
+
125
+ Args:
126
+ template_name: Name of template file (e.g., "job-completion.txt")
127
+
128
+ Returns:
129
+ Template content as string, or None if not found
130
+ """
131
+ blob_path = f"{self.TEMPLATE_PREFIX}{template_name}"
132
+ try:
133
+ blob = self.bucket.blob(blob_path)
134
+ if blob.exists():
135
+ content = blob.download_as_text()
136
+ logger.debug(f"Fetched template from GCS: {blob_path}")
137
+ return content
138
+ else:
139
+ logger.warning(f"Template not found in GCS: {blob_path}")
140
+ return None
141
+ except Exception as e:
142
+ logger.error(f"Failed to fetch template {blob_path}: {e}")
143
+ return None
144
+
145
+ def get_job_completion_template(self) -> str:
146
+ """Get the job completion email template."""
147
+ template = self._fetch_template_from_gcs("job-completion.txt")
148
+ if template is None:
149
+ logger.info("Using default job completion template")
150
+ return DEFAULT_JOB_COMPLETION_TEMPLATE
151
+ return template
152
+
153
+ def get_action_needed_lyrics_template(self) -> str:
154
+ """Get the lyrics review reminder template."""
155
+ template = self._fetch_template_from_gcs("action-needed-lyrics.txt")
156
+ if template is None:
157
+ logger.info("Using default lyrics reminder template")
158
+ return DEFAULT_ACTION_NEEDED_LYRICS_TEMPLATE
159
+ return template
160
+
161
+ def get_action_needed_instrumental_template(self) -> str:
162
+ """Get the instrumental selection reminder template."""
163
+ template = self._fetch_template_from_gcs("action-needed-instrumental.txt")
164
+ if template is None:
165
+ logger.info("Using default instrumental reminder template")
166
+ return DEFAULT_ACTION_NEEDED_INSTRUMENTAL_TEMPLATE
167
+ return template
168
+
169
+ def render_template(self, template: str, variables: Dict[str, Any]) -> str:
170
+ """
171
+ Render a template with the given variables.
172
+
173
+ Missing variables are replaced with empty strings.
174
+ Handles conditional sections like feedback URL.
175
+
176
+ Args:
177
+ template: Template string with {variable} placeholders
178
+ variables: Dictionary of variable values
179
+
180
+ Returns:
181
+ Rendered template string
182
+ """
183
+ result = template
184
+
185
+ # Handle feedback URL section - remove if not provided
186
+ if not variables.get("feedback_url"):
187
+ # Remove feedback section (lines containing feedback_url placeholder and surrounding text)
188
+ result = re.sub(
189
+ r'\n*If you have a moment.*?\{feedback_url\}\n*',
190
+ '\n',
191
+ result,
192
+ flags=re.DOTALL
193
+ )
194
+ variables["feedback_url"] = ""
195
+
196
+ # Replace all variables
197
+ for key, value in variables.items():
198
+ placeholder = "{" + key + "}"
199
+ result = result.replace(placeholder, str(value) if value else "")
200
+
201
+ # Clean up any remaining unreplaced placeholders
202
+ result = re.sub(r'\{[a-z_]+\}', '', result)
203
+
204
+ return result.strip()
205
+
206
+ def render_job_completion(
207
+ self,
208
+ name: Optional[str] = None,
209
+ youtube_url: Optional[str] = None,
210
+ dropbox_url: Optional[str] = None,
211
+ artist: Optional[str] = None,
212
+ title: Optional[str] = None,
213
+ job_id: Optional[str] = None,
214
+ feedback_url: Optional[str] = None,
215
+ ) -> str:
216
+ """
217
+ Render the job completion email template.
218
+
219
+ Args:
220
+ name: User's display name (defaults to "there")
221
+ youtube_url: YouTube video URL
222
+ dropbox_url: Dropbox folder URL
223
+ artist: Artist name
224
+ title: Song title
225
+ job_id: Job ID
226
+ feedback_url: Feedback form URL (optional)
227
+
228
+ Returns:
229
+ Rendered email content
230
+ """
231
+ template = self.get_job_completion_template()
232
+ variables = {
233
+ "name": name or "there",
234
+ "youtube_url": youtube_url or "[YouTube URL not available]",
235
+ "dropbox_url": dropbox_url or "[Dropbox URL not available]",
236
+ "artist": artist or "Unknown Artist",
237
+ "title": title or "Unknown Title",
238
+ "job_id": job_id or "",
239
+ "feedback_url": feedback_url,
240
+ }
241
+ return self.render_template(template, variables)
242
+
243
+ def render_action_needed_lyrics(
244
+ self,
245
+ name: Optional[str] = None,
246
+ artist: Optional[str] = None,
247
+ title: Optional[str] = None,
248
+ review_url: str = "",
249
+ ) -> str:
250
+ """
251
+ Render the lyrics review reminder template.
252
+
253
+ Args:
254
+ name: User's display name
255
+ artist: Artist name
256
+ title: Song title
257
+ review_url: Lyrics review URL
258
+
259
+ Returns:
260
+ Rendered email content
261
+ """
262
+ template = self.get_action_needed_lyrics_template()
263
+ variables = {
264
+ "name": name or "there",
265
+ "artist": artist or "Unknown Artist",
266
+ "title": title or "Unknown Title",
267
+ "review_url": review_url,
268
+ }
269
+ return self.render_template(template, variables)
270
+
271
+ def render_action_needed_instrumental(
272
+ self,
273
+ name: Optional[str] = None,
274
+ artist: Optional[str] = None,
275
+ title: Optional[str] = None,
276
+ instrumental_url: str = "",
277
+ ) -> str:
278
+ """
279
+ Render the instrumental selection reminder template.
280
+
281
+ Args:
282
+ name: User's display name
283
+ artist: Artist name
284
+ title: Song title
285
+ instrumental_url: Instrumental selection URL
286
+
287
+ Returns:
288
+ Rendered email content
289
+ """
290
+ template = self.get_action_needed_instrumental_template()
291
+ variables = {
292
+ "name": name or "there",
293
+ "artist": artist or "Unknown Artist",
294
+ "title": title or "Unknown Title",
295
+ "instrumental_url": instrumental_url,
296
+ }
297
+ return self.render_template(template, variables)
298
+
299
+ def upload_template(self, template_name: str, content: str) -> bool:
300
+ """
301
+ Upload a template to GCS.
302
+
303
+ Args:
304
+ template_name: Name of template file (e.g., "job-completion.txt")
305
+ content: Template content
306
+
307
+ Returns:
308
+ True if upload successful
309
+ """
310
+ blob_path = f"{self.TEMPLATE_PREFIX}{template_name}"
311
+ try:
312
+ blob = self.bucket.blob(blob_path)
313
+ blob.upload_from_string(content, content_type="text/plain")
314
+ logger.info(f"Uploaded template to GCS: {blob_path}")
315
+ return True
316
+ except Exception as e:
317
+ logger.error(f"Failed to upload template {blob_path}: {e}")
318
+ return False
319
+
320
+
321
+ # Global instance
322
+ _template_service: Optional[TemplateService] = None
323
+
324
+
325
+ def get_template_service() -> TemplateService:
326
+ """Get the global template service instance."""
327
+ global _template_service
328
+ if _template_service is None:
329
+ _template_service = TemplateService()
330
+ return _template_service