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.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {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
|