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.
- 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/style_loader.py +3 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
- 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.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Job notification service for sending emails on job state changes.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Job completion emails (when job enters COMPLETE status)
|
|
6
|
+
- Action reminder emails (when user is idle at blocking states)
|
|
7
|
+
|
|
8
|
+
This service orchestrates between the template service and email service,
|
|
9
|
+
building the complete notification flow for jobs.
|
|
10
|
+
"""
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from typing import Optional, Dict, Any
|
|
14
|
+
|
|
15
|
+
from backend.services.email_service import get_email_service
|
|
16
|
+
from backend.services.template_service import get_template_service
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Environment variable to enable/disable auto emails
|
|
23
|
+
ENABLE_AUTO_EMAILS = os.getenv("ENABLE_AUTO_EMAILS", "true").lower() == "true"
|
|
24
|
+
|
|
25
|
+
# Feedback form URL (configured per environment, empty by default to avoid placeholder in emails)
|
|
26
|
+
FEEDBACK_FORM_URL = os.getenv("FEEDBACK_FORM_URL", "")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _mask_email(email: str) -> str:
|
|
30
|
+
"""Mask email for logging to protect PII. Shows first char + domain."""
|
|
31
|
+
if not email or "@" not in email:
|
|
32
|
+
return "***"
|
|
33
|
+
local, domain = email.split("@", 1)
|
|
34
|
+
if len(local) <= 1:
|
|
35
|
+
return f"*@{domain}"
|
|
36
|
+
return f"{local[0]}***@{domain}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class JobNotificationService:
|
|
40
|
+
"""
|
|
41
|
+
Service for sending job-related email notifications.
|
|
42
|
+
|
|
43
|
+
Coordinates between template service (for message rendering) and
|
|
44
|
+
email service (for sending).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
"""Initialize notification service."""
|
|
49
|
+
self.email_service = get_email_service()
|
|
50
|
+
self.template_service = get_template_service()
|
|
51
|
+
self.frontend_url = os.getenv("FRONTEND_URL", "https://gen.nomadkaraoke.com")
|
|
52
|
+
self.backend_url = os.getenv("BACKEND_URL", "https://api.nomadkaraoke.com")
|
|
53
|
+
|
|
54
|
+
def _build_review_url(self, job_id: str, audio_hash: Optional[str] = None, review_token: Optional[str] = None) -> str:
|
|
55
|
+
"""Build the lyrics review URL for a job."""
|
|
56
|
+
import urllib.parse
|
|
57
|
+
|
|
58
|
+
review_ui_url = os.getenv("REVIEW_UI_URL", f"{self.frontend_url}/lyrics/")
|
|
59
|
+
base_api_url = f"{self.backend_url}/api/review/{job_id}"
|
|
60
|
+
encoded_api_url = urllib.parse.quote(base_api_url, safe='')
|
|
61
|
+
|
|
62
|
+
url = f"{review_ui_url}?baseApiUrl={encoded_api_url}"
|
|
63
|
+
if audio_hash:
|
|
64
|
+
url += f"&audioHash={urllib.parse.quote(audio_hash, safe='')}"
|
|
65
|
+
if review_token:
|
|
66
|
+
url += f"&reviewToken={urllib.parse.quote(review_token, safe='')}"
|
|
67
|
+
return url
|
|
68
|
+
|
|
69
|
+
def _build_instrumental_url(self, job_id: str, instrumental_token: Optional[str] = None) -> str:
|
|
70
|
+
"""Build the instrumental selection URL for a job."""
|
|
71
|
+
base_api_url = f"{self.backend_url}/api/jobs/{job_id}"
|
|
72
|
+
|
|
73
|
+
import urllib.parse
|
|
74
|
+
encoded_api_url = urllib.parse.quote(base_api_url, safe='')
|
|
75
|
+
|
|
76
|
+
url = f"{self.frontend_url}/instrumental/?baseApiUrl={encoded_api_url}"
|
|
77
|
+
if instrumental_token:
|
|
78
|
+
url += f"&instrumentalToken={urllib.parse.quote(instrumental_token, safe='')}"
|
|
79
|
+
return url
|
|
80
|
+
|
|
81
|
+
async def send_job_completion_email(
|
|
82
|
+
self,
|
|
83
|
+
job_id: str,
|
|
84
|
+
user_email: str,
|
|
85
|
+
user_name: Optional[str] = None,
|
|
86
|
+
artist: Optional[str] = None,
|
|
87
|
+
title: Optional[str] = None,
|
|
88
|
+
youtube_url: Optional[str] = None,
|
|
89
|
+
dropbox_url: Optional[str] = None,
|
|
90
|
+
brand_code: Optional[str] = None,
|
|
91
|
+
) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Send job completion email to user.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
job_id: Job ID
|
|
97
|
+
user_email: User's email address
|
|
98
|
+
user_name: User's display name (optional)
|
|
99
|
+
artist: Artist name
|
|
100
|
+
title: Song title
|
|
101
|
+
youtube_url: YouTube video URL
|
|
102
|
+
dropbox_url: Dropbox folder URL
|
|
103
|
+
brand_code: Release ID (e.g., "NOMAD-1178")
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
True if email was sent successfully
|
|
107
|
+
"""
|
|
108
|
+
if not ENABLE_AUTO_EMAILS:
|
|
109
|
+
logger.info(f"Auto emails disabled, skipping completion email for job {job_id}")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
if not user_email:
|
|
113
|
+
logger.warning(f"No user email for job {job_id}, skipping completion email")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
# Render the completion message using template service
|
|
118
|
+
message_content = self.template_service.render_job_completion(
|
|
119
|
+
name=user_name,
|
|
120
|
+
youtube_url=youtube_url,
|
|
121
|
+
dropbox_url=dropbox_url,
|
|
122
|
+
artist=artist,
|
|
123
|
+
title=title,
|
|
124
|
+
job_id=job_id,
|
|
125
|
+
feedback_url=FEEDBACK_FORM_URL,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Send the email with CC to admin
|
|
129
|
+
success = self.email_service.send_job_completion(
|
|
130
|
+
to_email=user_email,
|
|
131
|
+
message_content=message_content,
|
|
132
|
+
artist=artist,
|
|
133
|
+
title=title,
|
|
134
|
+
brand_code=brand_code,
|
|
135
|
+
cc_admin=True,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if success:
|
|
139
|
+
logger.info(f"Sent completion email for job {job_id} to {_mask_email(user_email)}")
|
|
140
|
+
else:
|
|
141
|
+
logger.error(f"Failed to send completion email for job {job_id}")
|
|
142
|
+
|
|
143
|
+
return success
|
|
144
|
+
|
|
145
|
+
except Exception as e:
|
|
146
|
+
logger.exception(f"Error sending completion email for job {job_id}: {e}")
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
async def send_action_reminder_email(
|
|
150
|
+
self,
|
|
151
|
+
job_id: str,
|
|
152
|
+
user_email: str,
|
|
153
|
+
action_type: str,
|
|
154
|
+
user_name: Optional[str] = None,
|
|
155
|
+
artist: Optional[str] = None,
|
|
156
|
+
title: Optional[str] = None,
|
|
157
|
+
audio_hash: Optional[str] = None,
|
|
158
|
+
review_token: Optional[str] = None,
|
|
159
|
+
instrumental_token: Optional[str] = None,
|
|
160
|
+
) -> bool:
|
|
161
|
+
"""
|
|
162
|
+
Send action-needed reminder email to user.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
job_id: Job ID
|
|
166
|
+
user_email: User's email address
|
|
167
|
+
action_type: Type of action needed ("lyrics" or "instrumental")
|
|
168
|
+
user_name: User's display name
|
|
169
|
+
artist: Artist name
|
|
170
|
+
title: Song title
|
|
171
|
+
audio_hash: Audio hash for review URL
|
|
172
|
+
review_token: Review token for URL
|
|
173
|
+
instrumental_token: Instrumental token for URL
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
True if email was sent successfully
|
|
177
|
+
"""
|
|
178
|
+
if not ENABLE_AUTO_EMAILS:
|
|
179
|
+
logger.info(f"Auto emails disabled, skipping reminder for job {job_id}")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
if not user_email:
|
|
183
|
+
logger.warning(f"No user email for job {job_id}, skipping reminder")
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
# Render the appropriate template
|
|
188
|
+
if action_type == "lyrics":
|
|
189
|
+
review_url = self._build_review_url(job_id, audio_hash, review_token)
|
|
190
|
+
message_content = self.template_service.render_action_needed_lyrics(
|
|
191
|
+
name=user_name,
|
|
192
|
+
artist=artist,
|
|
193
|
+
title=title,
|
|
194
|
+
review_url=review_url,
|
|
195
|
+
)
|
|
196
|
+
elif action_type == "instrumental":
|
|
197
|
+
instrumental_url = self._build_instrumental_url(job_id, instrumental_token)
|
|
198
|
+
message_content = self.template_service.render_action_needed_instrumental(
|
|
199
|
+
name=user_name,
|
|
200
|
+
artist=artist,
|
|
201
|
+
title=title,
|
|
202
|
+
instrumental_url=instrumental_url,
|
|
203
|
+
)
|
|
204
|
+
else:
|
|
205
|
+
logger.error(f"Unknown action type: {action_type}")
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
# Send the reminder email (no CC)
|
|
209
|
+
success = self.email_service.send_action_reminder(
|
|
210
|
+
to_email=user_email,
|
|
211
|
+
message_content=message_content,
|
|
212
|
+
action_type=action_type,
|
|
213
|
+
artist=artist,
|
|
214
|
+
title=title,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if success:
|
|
218
|
+
logger.info(f"Sent {action_type} reminder for job {job_id} to {_mask_email(user_email)}")
|
|
219
|
+
else:
|
|
220
|
+
logger.error(f"Failed to send {action_type} reminder for job {job_id}")
|
|
221
|
+
|
|
222
|
+
return success
|
|
223
|
+
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.exception(f"Error sending {action_type} reminder for job {job_id}: {e}")
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
def get_completion_message(
|
|
229
|
+
self,
|
|
230
|
+
job_id: str,
|
|
231
|
+
user_name: Optional[str] = None,
|
|
232
|
+
artist: Optional[str] = None,
|
|
233
|
+
title: Optional[str] = None,
|
|
234
|
+
youtube_url: Optional[str] = None,
|
|
235
|
+
dropbox_url: Optional[str] = None,
|
|
236
|
+
) -> str:
|
|
237
|
+
"""
|
|
238
|
+
Get the rendered completion message for a job (for admin copy functionality).
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
job_id: Job ID
|
|
242
|
+
user_name: User's display name
|
|
243
|
+
artist: Artist name
|
|
244
|
+
title: Song title
|
|
245
|
+
youtube_url: YouTube video URL
|
|
246
|
+
dropbox_url: Dropbox folder URL
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Rendered message content as plain text
|
|
250
|
+
"""
|
|
251
|
+
return self.template_service.render_job_completion(
|
|
252
|
+
name=user_name,
|
|
253
|
+
youtube_url=youtube_url,
|
|
254
|
+
dropbox_url=dropbox_url,
|
|
255
|
+
artist=artist,
|
|
256
|
+
title=title,
|
|
257
|
+
job_id=job_id,
|
|
258
|
+
feedback_url=FEEDBACK_FORM_URL,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# Global instance
|
|
263
|
+
_job_notification_service: Optional[JobNotificationService] = None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_job_notification_service() -> JobNotificationService:
|
|
267
|
+
"""Get the global job notification service instance."""
|
|
268
|
+
global _job_notification_service
|
|
269
|
+
if _job_notification_service is None:
|
|
270
|
+
_job_notification_service = JobNotificationService()
|
|
271
|
+
return _job_notification_service
|