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,847 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for Video Worker Orchestrator.
|
|
3
|
+
|
|
4
|
+
Tests cover:
|
|
5
|
+
- OrchestratorConfig and OrchestratorResult dataclasses
|
|
6
|
+
- VideoWorkerOrchestrator initialization
|
|
7
|
+
- Individual stage methods
|
|
8
|
+
- Full pipeline execution
|
|
9
|
+
- Error handling and recovery
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
import pytest
|
|
15
|
+
from unittest.mock import MagicMock, patch, AsyncMock
|
|
16
|
+
from dataclasses import asdict
|
|
17
|
+
|
|
18
|
+
from backend.workers.video_worker_orchestrator import (
|
|
19
|
+
OrchestratorConfig,
|
|
20
|
+
OrchestratorResult,
|
|
21
|
+
VideoWorkerOrchestrator,
|
|
22
|
+
create_orchestrator_config_from_job,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestOrchestratorConfig:
|
|
27
|
+
"""Test OrchestratorConfig dataclass."""
|
|
28
|
+
|
|
29
|
+
def test_required_fields(self):
|
|
30
|
+
"""Test that required fields must be provided."""
|
|
31
|
+
config = OrchestratorConfig(
|
|
32
|
+
job_id="test-job",
|
|
33
|
+
artist="Test Artist",
|
|
34
|
+
title="Test Title",
|
|
35
|
+
title_video_path="/path/title.mov",
|
|
36
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
37
|
+
instrumental_audio_path="/path/audio.flac",
|
|
38
|
+
)
|
|
39
|
+
assert config.job_id == "test-job"
|
|
40
|
+
assert config.artist == "Test Artist"
|
|
41
|
+
assert config.encoding_backend == "auto"
|
|
42
|
+
|
|
43
|
+
def test_default_values(self):
|
|
44
|
+
"""Test default values for optional fields."""
|
|
45
|
+
config = OrchestratorConfig(
|
|
46
|
+
job_id="test-job",
|
|
47
|
+
artist="Test Artist",
|
|
48
|
+
title="Test Title",
|
|
49
|
+
title_video_path="/path/title.mov",
|
|
50
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
51
|
+
instrumental_audio_path="/path/audio.flac",
|
|
52
|
+
)
|
|
53
|
+
assert config.enable_cdg is False
|
|
54
|
+
assert config.enable_txt is False
|
|
55
|
+
assert config.enable_youtube_upload is False
|
|
56
|
+
assert config.dry_run is False
|
|
57
|
+
assert config.non_interactive is True
|
|
58
|
+
assert config.end_video_path is None
|
|
59
|
+
|
|
60
|
+
def test_all_fields(self):
|
|
61
|
+
"""Test all fields including optional ones."""
|
|
62
|
+
config = OrchestratorConfig(
|
|
63
|
+
job_id="test-job",
|
|
64
|
+
artist="Test Artist",
|
|
65
|
+
title="Test Title",
|
|
66
|
+
title_video_path="/path/title.mov",
|
|
67
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
68
|
+
instrumental_audio_path="/path/audio.flac",
|
|
69
|
+
end_video_path="/path/end.mov",
|
|
70
|
+
enable_cdg=True,
|
|
71
|
+
enable_txt=True,
|
|
72
|
+
enable_youtube_upload=True,
|
|
73
|
+
brand_prefix="TEST",
|
|
74
|
+
discord_webhook_url="https://discord.com/api/webhooks/123/abc",
|
|
75
|
+
encoding_backend="gce",
|
|
76
|
+
)
|
|
77
|
+
assert config.enable_cdg is True
|
|
78
|
+
assert config.encoding_backend == "gce"
|
|
79
|
+
assert config.discord_webhook_url is not None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestOrchestratorResult:
|
|
83
|
+
"""Test OrchestratorResult dataclass."""
|
|
84
|
+
|
|
85
|
+
def test_success_result(self):
|
|
86
|
+
"""Test successful result."""
|
|
87
|
+
result = OrchestratorResult(
|
|
88
|
+
success=True,
|
|
89
|
+
final_video="/output/video.mp4",
|
|
90
|
+
brand_code="TEST-1234",
|
|
91
|
+
youtube_url="https://youtube.com/watch?v=test",
|
|
92
|
+
)
|
|
93
|
+
assert result.success is True
|
|
94
|
+
assert result.error_message is None
|
|
95
|
+
assert result.brand_code == "TEST-1234"
|
|
96
|
+
|
|
97
|
+
def test_failure_result(self):
|
|
98
|
+
"""Test failure result."""
|
|
99
|
+
result = OrchestratorResult(
|
|
100
|
+
success=False,
|
|
101
|
+
error_message="Encoding failed",
|
|
102
|
+
)
|
|
103
|
+
assert result.success is False
|
|
104
|
+
assert result.error_message == "Encoding failed"
|
|
105
|
+
assert result.final_video is None
|
|
106
|
+
|
|
107
|
+
def test_all_output_files(self):
|
|
108
|
+
"""Test all output file fields."""
|
|
109
|
+
result = OrchestratorResult(
|
|
110
|
+
success=True,
|
|
111
|
+
final_video="/output/lossless.mp4",
|
|
112
|
+
final_video_mkv="/output/lossless.mkv",
|
|
113
|
+
final_video_lossy="/output/lossy.mp4",
|
|
114
|
+
final_video_720p="/output/720p.mp4",
|
|
115
|
+
final_karaoke_cdg_zip="/output/cdg.zip",
|
|
116
|
+
final_karaoke_txt_zip="/output/txt.zip",
|
|
117
|
+
)
|
|
118
|
+
assert result.final_video == "/output/lossless.mp4"
|
|
119
|
+
assert result.final_video_mkv == "/output/lossless.mkv"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TestVideoWorkerOrchestratorInit:
|
|
123
|
+
"""Test VideoWorkerOrchestrator initialization."""
|
|
124
|
+
|
|
125
|
+
def test_init_with_config(self):
|
|
126
|
+
"""Test initialization with config."""
|
|
127
|
+
config = OrchestratorConfig(
|
|
128
|
+
job_id="test-job",
|
|
129
|
+
artist="Test Artist",
|
|
130
|
+
title="Test Title",
|
|
131
|
+
title_video_path="/path/title.mov",
|
|
132
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
133
|
+
instrumental_audio_path="/path/audio.flac",
|
|
134
|
+
)
|
|
135
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
136
|
+
|
|
137
|
+
assert orchestrator.config == config
|
|
138
|
+
assert orchestrator.result.success is False
|
|
139
|
+
|
|
140
|
+
def test_init_with_job_manager(self):
|
|
141
|
+
"""Test initialization with job manager."""
|
|
142
|
+
config = OrchestratorConfig(
|
|
143
|
+
job_id="test-job",
|
|
144
|
+
artist="Test Artist",
|
|
145
|
+
title="Test Title",
|
|
146
|
+
title_video_path="/path/title.mov",
|
|
147
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
148
|
+
instrumental_audio_path="/path/audio.flac",
|
|
149
|
+
)
|
|
150
|
+
job_manager = MagicMock()
|
|
151
|
+
orchestrator = VideoWorkerOrchestrator(config, job_manager=job_manager)
|
|
152
|
+
|
|
153
|
+
assert orchestrator.job_manager == job_manager
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestVideoWorkerOrchestratorServices:
|
|
157
|
+
"""Test service lazy-loading."""
|
|
158
|
+
|
|
159
|
+
def test_get_encoding_backend(self):
|
|
160
|
+
"""Test encoding backend lazy loading."""
|
|
161
|
+
config = OrchestratorConfig(
|
|
162
|
+
job_id="test-job",
|
|
163
|
+
artist="Test Artist",
|
|
164
|
+
title="Test Title",
|
|
165
|
+
title_video_path="/path/title.mov",
|
|
166
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
167
|
+
instrumental_audio_path="/path/audio.flac",
|
|
168
|
+
encoding_backend="local",
|
|
169
|
+
)
|
|
170
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
171
|
+
|
|
172
|
+
with patch("backend.services.encoding_interface.get_encoding_backend") as mock_get:
|
|
173
|
+
mock_backend = MagicMock()
|
|
174
|
+
mock_backend.name = "local"
|
|
175
|
+
mock_get.return_value = mock_backend
|
|
176
|
+
|
|
177
|
+
backend = orchestrator._get_encoding_backend()
|
|
178
|
+
|
|
179
|
+
assert backend == mock_backend
|
|
180
|
+
mock_get.assert_called_once()
|
|
181
|
+
|
|
182
|
+
def test_get_packaging_service(self):
|
|
183
|
+
"""Test packaging service lazy loading."""
|
|
184
|
+
config = OrchestratorConfig(
|
|
185
|
+
job_id="test-job",
|
|
186
|
+
artist="Test Artist",
|
|
187
|
+
title="Test Title",
|
|
188
|
+
title_video_path="/path/title.mov",
|
|
189
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
190
|
+
instrumental_audio_path="/path/audio.flac",
|
|
191
|
+
cdg_styles={"background_color": "black"},
|
|
192
|
+
)
|
|
193
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
194
|
+
|
|
195
|
+
with patch("backend.services.packaging_service.PackagingService") as MockService:
|
|
196
|
+
mock_service = MagicMock()
|
|
197
|
+
MockService.return_value = mock_service
|
|
198
|
+
|
|
199
|
+
service = orchestrator._get_packaging_service()
|
|
200
|
+
|
|
201
|
+
assert service == mock_service
|
|
202
|
+
MockService.assert_called_once()
|
|
203
|
+
|
|
204
|
+
def test_get_youtube_service(self):
|
|
205
|
+
"""Test YouTube service lazy loading."""
|
|
206
|
+
config = OrchestratorConfig(
|
|
207
|
+
job_id="test-job",
|
|
208
|
+
artist="Test Artist",
|
|
209
|
+
title="Test Title",
|
|
210
|
+
title_video_path="/path/title.mov",
|
|
211
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
212
|
+
instrumental_audio_path="/path/audio.flac",
|
|
213
|
+
youtube_credentials={"token": "test"},
|
|
214
|
+
)
|
|
215
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
216
|
+
|
|
217
|
+
with patch("backend.services.youtube_upload_service.YouTubeUploadService") as MockService:
|
|
218
|
+
mock_service = MagicMock()
|
|
219
|
+
MockService.return_value = mock_service
|
|
220
|
+
|
|
221
|
+
service = orchestrator._get_youtube_service()
|
|
222
|
+
|
|
223
|
+
assert service == mock_service
|
|
224
|
+
MockService.assert_called_once()
|
|
225
|
+
|
|
226
|
+
def test_get_discord_service(self):
|
|
227
|
+
"""Test Discord service lazy loading."""
|
|
228
|
+
config = OrchestratorConfig(
|
|
229
|
+
job_id="test-job",
|
|
230
|
+
artist="Test Artist",
|
|
231
|
+
title="Test Title",
|
|
232
|
+
title_video_path="/path/title.mov",
|
|
233
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
234
|
+
instrumental_audio_path="/path/audio.flac",
|
|
235
|
+
discord_webhook_url="https://discord.com/api/webhooks/123/abc",
|
|
236
|
+
)
|
|
237
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
238
|
+
|
|
239
|
+
with patch("backend.services.discord_service.DiscordNotificationService") as MockService:
|
|
240
|
+
mock_service = MagicMock()
|
|
241
|
+
MockService.return_value = mock_service
|
|
242
|
+
|
|
243
|
+
service = orchestrator._get_discord_service()
|
|
244
|
+
|
|
245
|
+
assert service == mock_service
|
|
246
|
+
MockService.assert_called_once()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TestVideoWorkerOrchestratorPackaging:
|
|
250
|
+
"""Test packaging stage."""
|
|
251
|
+
|
|
252
|
+
@pytest.mark.asyncio
|
|
253
|
+
async def test_run_packaging_no_lrc(self):
|
|
254
|
+
"""Test packaging stage with no LRC file."""
|
|
255
|
+
config = OrchestratorConfig(
|
|
256
|
+
job_id="test-job",
|
|
257
|
+
artist="Test Artist",
|
|
258
|
+
title="Test Title",
|
|
259
|
+
title_video_path="/path/title.mov",
|
|
260
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
261
|
+
instrumental_audio_path="/path/audio.flac",
|
|
262
|
+
enable_cdg=True,
|
|
263
|
+
lrc_file_path=None,
|
|
264
|
+
)
|
|
265
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
266
|
+
|
|
267
|
+
# Should not raise, just skip
|
|
268
|
+
await orchestrator._run_packaging()
|
|
269
|
+
|
|
270
|
+
assert orchestrator.result.final_karaoke_cdg_zip is None
|
|
271
|
+
|
|
272
|
+
@pytest.mark.asyncio
|
|
273
|
+
async def test_run_packaging_cdg(self):
|
|
274
|
+
"""Test CDG packaging."""
|
|
275
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
276
|
+
lrc_file = os.path.join(temp_dir, "test.lrc")
|
|
277
|
+
audio_file = os.path.join(temp_dir, "test.flac")
|
|
278
|
+
|
|
279
|
+
# Create dummy files
|
|
280
|
+
with open(lrc_file, "w") as f:
|
|
281
|
+
f.write("[00:00.00]Test lyrics")
|
|
282
|
+
with open(audio_file, "w") as f:
|
|
283
|
+
f.write("dummy audio")
|
|
284
|
+
|
|
285
|
+
config = OrchestratorConfig(
|
|
286
|
+
job_id="test-job",
|
|
287
|
+
artist="Test Artist",
|
|
288
|
+
title="Test Title",
|
|
289
|
+
title_video_path="/path/title.mov",
|
|
290
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
291
|
+
instrumental_audio_path=audio_file,
|
|
292
|
+
lrc_file_path=lrc_file,
|
|
293
|
+
output_dir=temp_dir,
|
|
294
|
+
enable_cdg=True,
|
|
295
|
+
cdg_styles={"background_color": "black"},
|
|
296
|
+
)
|
|
297
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
298
|
+
|
|
299
|
+
with patch.object(orchestrator, "_get_packaging_service") as mock_get:
|
|
300
|
+
mock_service = MagicMock()
|
|
301
|
+
mock_service.create_cdg_package.return_value = (
|
|
302
|
+
f"{temp_dir}/cdg.zip",
|
|
303
|
+
f"{temp_dir}/test.mp3",
|
|
304
|
+
f"{temp_dir}/test.cdg",
|
|
305
|
+
)
|
|
306
|
+
mock_get.return_value = mock_service
|
|
307
|
+
|
|
308
|
+
await orchestrator._run_packaging()
|
|
309
|
+
|
|
310
|
+
mock_service.create_cdg_package.assert_called_once()
|
|
311
|
+
assert orchestrator.result.final_karaoke_cdg_zip == f"{temp_dir}/cdg.zip"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class TestVideoWorkerOrchestratorEncoding:
|
|
315
|
+
"""Test encoding stage."""
|
|
316
|
+
|
|
317
|
+
@pytest.mark.asyncio
|
|
318
|
+
async def test_run_encoding_success(self):
|
|
319
|
+
"""Test successful encoding."""
|
|
320
|
+
config = OrchestratorConfig(
|
|
321
|
+
job_id="test-job",
|
|
322
|
+
artist="Test Artist",
|
|
323
|
+
title="Test Title",
|
|
324
|
+
title_video_path="/path/title.mov",
|
|
325
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
326
|
+
instrumental_audio_path="/path/audio.flac",
|
|
327
|
+
output_dir="/output",
|
|
328
|
+
)
|
|
329
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
330
|
+
|
|
331
|
+
with patch.object(orchestrator, "_get_encoding_backend") as mock_get:
|
|
332
|
+
from backend.services.encoding_interface import EncodingOutput
|
|
333
|
+
|
|
334
|
+
mock_backend = MagicMock()
|
|
335
|
+
mock_backend.name = "local"
|
|
336
|
+
mock_backend.encode = AsyncMock(return_value=EncodingOutput(
|
|
337
|
+
success=True,
|
|
338
|
+
lossless_4k_mp4_path="/output/lossless.mp4",
|
|
339
|
+
lossy_4k_mp4_path="/output/lossy.mp4",
|
|
340
|
+
lossy_720p_mp4_path="/output/720p.mp4",
|
|
341
|
+
lossless_mkv_path="/output/lossless.mkv",
|
|
342
|
+
encoding_time_seconds=120.5,
|
|
343
|
+
encoding_backend="local",
|
|
344
|
+
))
|
|
345
|
+
mock_get.return_value = mock_backend
|
|
346
|
+
|
|
347
|
+
await orchestrator._run_encoding()
|
|
348
|
+
|
|
349
|
+
mock_backend.encode.assert_called_once()
|
|
350
|
+
assert orchestrator.result.final_video == "/output/lossless.mp4"
|
|
351
|
+
assert orchestrator.result.final_video_720p == "/output/720p.mp4"
|
|
352
|
+
assert orchestrator.result.encoding_time_seconds == 120.5
|
|
353
|
+
|
|
354
|
+
def test_encoding_input_gcs_paths_pattern(self):
|
|
355
|
+
"""Test that EncodingInput.options contains proper GCS paths structure.
|
|
356
|
+
|
|
357
|
+
This test verifies the GCS path construction pattern that GCE encoding
|
|
358
|
+
requires. It tests the structure without running _run_encoding().
|
|
359
|
+
|
|
360
|
+
This would have caught: 'GCE encoding requires input_gcs_path and
|
|
361
|
+
output_gcs_path in options' error when the orchestrator didn't pass
|
|
362
|
+
the required paths for GCE encoding.
|
|
363
|
+
"""
|
|
364
|
+
from backend.services.encoding_interface import EncodingInput
|
|
365
|
+
|
|
366
|
+
# Test that EncodingInput can hold GCS paths in options
|
|
367
|
+
# This mirrors what the orchestrator should build
|
|
368
|
+
job_id = "test-job-123"
|
|
369
|
+
bucket = "test-bucket"
|
|
370
|
+
input_gcs_path = f"gs://{bucket}/jobs/{job_id}/"
|
|
371
|
+
output_gcs_path = f"gs://{bucket}/jobs/{job_id}/finals/"
|
|
372
|
+
|
|
373
|
+
encoding_input = EncodingInput(
|
|
374
|
+
title_video_path="/path/title.mov",
|
|
375
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
376
|
+
instrumental_audio_path="/path/audio.flac",
|
|
377
|
+
artist="Test Artist",
|
|
378
|
+
title="Test Title",
|
|
379
|
+
brand_code="TEST-001",
|
|
380
|
+
output_dir="/output",
|
|
381
|
+
options={
|
|
382
|
+
"input_gcs_path": input_gcs_path,
|
|
383
|
+
"output_gcs_path": output_gcs_path,
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Verify the structure that GCEEncodingBackend expects
|
|
388
|
+
assert "input_gcs_path" in encoding_input.options, \
|
|
389
|
+
"EncodingInput.options must include input_gcs_path for GCE encoding"
|
|
390
|
+
assert "output_gcs_path" in encoding_input.options, \
|
|
391
|
+
"EncodingInput.options must include output_gcs_path for GCE encoding"
|
|
392
|
+
# Verify path format
|
|
393
|
+
assert encoding_input.options["input_gcs_path"].startswith("gs://")
|
|
394
|
+
assert encoding_input.options["output_gcs_path"].startswith("gs://")
|
|
395
|
+
assert job_id in encoding_input.options["input_gcs_path"]
|
|
396
|
+
assert job_id in encoding_input.options["output_gcs_path"]
|
|
397
|
+
|
|
398
|
+
@pytest.mark.asyncio
|
|
399
|
+
async def test_run_encoding_failure(self):
|
|
400
|
+
"""Test encoding failure."""
|
|
401
|
+
config = OrchestratorConfig(
|
|
402
|
+
job_id="test-job",
|
|
403
|
+
artist="Test Artist",
|
|
404
|
+
title="Test Title",
|
|
405
|
+
title_video_path="/path/title.mov",
|
|
406
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
407
|
+
instrumental_audio_path="/path/audio.flac",
|
|
408
|
+
)
|
|
409
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
410
|
+
|
|
411
|
+
with patch.object(orchestrator, "_get_encoding_backend") as mock_get:
|
|
412
|
+
from backend.services.encoding_interface import EncodingOutput
|
|
413
|
+
|
|
414
|
+
mock_backend = MagicMock()
|
|
415
|
+
mock_backend.name = "local"
|
|
416
|
+
mock_backend.encode = AsyncMock(return_value=EncodingOutput(
|
|
417
|
+
success=False,
|
|
418
|
+
error_message="FFmpeg failed",
|
|
419
|
+
encoding_backend="local",
|
|
420
|
+
))
|
|
421
|
+
mock_get.return_value = mock_backend
|
|
422
|
+
|
|
423
|
+
with pytest.raises(Exception) as exc_info:
|
|
424
|
+
await orchestrator._run_encoding()
|
|
425
|
+
|
|
426
|
+
assert "Encoding failed" in str(exc_info.value)
|
|
427
|
+
|
|
428
|
+
@pytest.mark.asyncio
|
|
429
|
+
async def test_run_encoding_gce_downloads_files(self):
|
|
430
|
+
"""Test that GCE encoding downloads files from GCS to local directory.
|
|
431
|
+
|
|
432
|
+
This test verifies the fix for YouTube upload failure when using GCE encoding.
|
|
433
|
+
GCE encoding returns GCS blob paths, which need to be downloaded locally
|
|
434
|
+
before YouTube upload can access them.
|
|
435
|
+
"""
|
|
436
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
437
|
+
config = OrchestratorConfig(
|
|
438
|
+
job_id="test-job",
|
|
439
|
+
artist="Test Artist",
|
|
440
|
+
title="Test Title",
|
|
441
|
+
title_video_path=os.path.join(temp_dir, "title.mov"),
|
|
442
|
+
karaoke_video_path=os.path.join(temp_dir, "karaoke.mov"),
|
|
443
|
+
instrumental_audio_path=os.path.join(temp_dir, "audio.flac"),
|
|
444
|
+
output_dir=temp_dir,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Mock storage service
|
|
448
|
+
mock_storage = MagicMock()
|
|
449
|
+
mock_storage.download_file = MagicMock()
|
|
450
|
+
|
|
451
|
+
orchestrator = VideoWorkerOrchestrator(
|
|
452
|
+
config,
|
|
453
|
+
storage=mock_storage,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
with patch.object(orchestrator, "_get_encoding_backend") as mock_get:
|
|
457
|
+
from backend.services.encoding_interface import EncodingOutput
|
|
458
|
+
|
|
459
|
+
# GCE backend returns GCS blob paths (not local paths)
|
|
460
|
+
mock_backend = MagicMock()
|
|
461
|
+
mock_backend.name = "gce" # Important: must be "gce" to trigger download
|
|
462
|
+
mock_backend.encode = AsyncMock(return_value=EncodingOutput(
|
|
463
|
+
success=True,
|
|
464
|
+
lossless_4k_mp4_path="jobs/test-job/finals/output_4k_lossless.mp4",
|
|
465
|
+
lossy_4k_mp4_path="jobs/test-job/finals/output_4k_lossy.mp4",
|
|
466
|
+
lossy_720p_mp4_path="jobs/test-job/finals/output_720p.mp4",
|
|
467
|
+
lossless_mkv_path="jobs/test-job/finals/output_4k.mkv",
|
|
468
|
+
encoding_time_seconds=60.0,
|
|
469
|
+
encoding_backend="gce",
|
|
470
|
+
))
|
|
471
|
+
mock_get.return_value = mock_backend
|
|
472
|
+
|
|
473
|
+
await orchestrator._run_encoding()
|
|
474
|
+
|
|
475
|
+
# Verify download_file was called for each output file
|
|
476
|
+
assert mock_storage.download_file.call_count == 4
|
|
477
|
+
|
|
478
|
+
# Verify the result paths were updated to local paths
|
|
479
|
+
assert orchestrator.result.final_video.startswith(temp_dir)
|
|
480
|
+
assert orchestrator.result.final_video_mkv.startswith(temp_dir)
|
|
481
|
+
assert orchestrator.result.final_video_lossy.startswith(temp_dir)
|
|
482
|
+
assert orchestrator.result.final_video_720p.startswith(temp_dir)
|
|
483
|
+
|
|
484
|
+
@pytest.mark.asyncio
|
|
485
|
+
async def test_run_encoding_local_does_not_download(self):
|
|
486
|
+
"""Test that local encoding does NOT trigger GCS download.
|
|
487
|
+
|
|
488
|
+
Local encoding produces files directly in the output directory,
|
|
489
|
+
so no download is needed.
|
|
490
|
+
"""
|
|
491
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
492
|
+
config = OrchestratorConfig(
|
|
493
|
+
job_id="test-job",
|
|
494
|
+
artist="Test Artist",
|
|
495
|
+
title="Test Title",
|
|
496
|
+
title_video_path=os.path.join(temp_dir, "title.mov"),
|
|
497
|
+
karaoke_video_path=os.path.join(temp_dir, "karaoke.mov"),
|
|
498
|
+
instrumental_audio_path=os.path.join(temp_dir, "audio.flac"),
|
|
499
|
+
output_dir=temp_dir,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Mock storage service
|
|
503
|
+
mock_storage = MagicMock()
|
|
504
|
+
mock_storage.download_file = MagicMock()
|
|
505
|
+
|
|
506
|
+
orchestrator = VideoWorkerOrchestrator(
|
|
507
|
+
config,
|
|
508
|
+
storage=mock_storage,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
with patch.object(orchestrator, "_get_encoding_backend") as mock_get:
|
|
512
|
+
from backend.services.encoding_interface import EncodingOutput
|
|
513
|
+
|
|
514
|
+
# Local backend returns local paths directly
|
|
515
|
+
mock_backend = MagicMock()
|
|
516
|
+
mock_backend.name = "local" # Local backend
|
|
517
|
+
mock_backend.encode = AsyncMock(return_value=EncodingOutput(
|
|
518
|
+
success=True,
|
|
519
|
+
lossless_4k_mp4_path=os.path.join(temp_dir, "lossless.mp4"),
|
|
520
|
+
lossy_4k_mp4_path=os.path.join(temp_dir, "lossy.mp4"),
|
|
521
|
+
lossy_720p_mp4_path=os.path.join(temp_dir, "720p.mp4"),
|
|
522
|
+
lossless_mkv_path=os.path.join(temp_dir, "lossless.mkv"),
|
|
523
|
+
encoding_time_seconds=120.0,
|
|
524
|
+
encoding_backend="local",
|
|
525
|
+
))
|
|
526
|
+
mock_get.return_value = mock_backend
|
|
527
|
+
|
|
528
|
+
await orchestrator._run_encoding()
|
|
529
|
+
|
|
530
|
+
# Verify download_file was NOT called for local encoding
|
|
531
|
+
mock_storage.download_file.assert_not_called()
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class TestVideoWorkerOrchestratorOrganization:
|
|
535
|
+
"""Test organization stage."""
|
|
536
|
+
|
|
537
|
+
@pytest.mark.asyncio
|
|
538
|
+
async def test_run_organization_keep_brand_code(self):
|
|
539
|
+
"""Test organization with existing brand code."""
|
|
540
|
+
config = OrchestratorConfig(
|
|
541
|
+
job_id="test-job",
|
|
542
|
+
artist="Test Artist",
|
|
543
|
+
title="Test Title",
|
|
544
|
+
title_video_path="/path/title.mov",
|
|
545
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
546
|
+
instrumental_audio_path="/path/audio.flac",
|
|
547
|
+
keep_brand_code="NOMAD-9999",
|
|
548
|
+
)
|
|
549
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
550
|
+
|
|
551
|
+
await orchestrator._run_organization()
|
|
552
|
+
|
|
553
|
+
assert orchestrator.result.brand_code == "NOMAD-9999"
|
|
554
|
+
|
|
555
|
+
@pytest.mark.asyncio
|
|
556
|
+
async def test_run_organization_generate_brand_code(self):
|
|
557
|
+
"""Test brand code generation from Dropbox."""
|
|
558
|
+
config = OrchestratorConfig(
|
|
559
|
+
job_id="test-job",
|
|
560
|
+
artist="Test Artist",
|
|
561
|
+
title="Test Title",
|
|
562
|
+
title_video_path="/path/title.mov",
|
|
563
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
564
|
+
instrumental_audio_path="/path/audio.flac",
|
|
565
|
+
dropbox_path="/Karaoke/Tracks",
|
|
566
|
+
brand_prefix="TEST",
|
|
567
|
+
)
|
|
568
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
569
|
+
|
|
570
|
+
with patch("backend.services.dropbox_service.get_dropbox_service") as mock_get:
|
|
571
|
+
mock_dropbox = MagicMock()
|
|
572
|
+
mock_dropbox.is_configured = True
|
|
573
|
+
mock_dropbox.get_next_brand_code.return_value = "TEST-0001"
|
|
574
|
+
mock_get.return_value = mock_dropbox
|
|
575
|
+
|
|
576
|
+
await orchestrator._run_organization()
|
|
577
|
+
|
|
578
|
+
assert orchestrator.result.brand_code == "TEST-0001"
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
class TestVideoWorkerOrchestratorDistribution:
|
|
582
|
+
"""Test distribution stage."""
|
|
583
|
+
|
|
584
|
+
@pytest.mark.asyncio
|
|
585
|
+
async def test_upload_to_youtube(self):
|
|
586
|
+
"""Test YouTube upload."""
|
|
587
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
588
|
+
video_file = os.path.join(temp_dir, "test.mp4")
|
|
589
|
+
with open(video_file, "w") as f:
|
|
590
|
+
f.write("dummy video")
|
|
591
|
+
|
|
592
|
+
config = OrchestratorConfig(
|
|
593
|
+
job_id="test-job",
|
|
594
|
+
artist="Test Artist",
|
|
595
|
+
title="Test Title",
|
|
596
|
+
title_video_path="/path/title.mov",
|
|
597
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
598
|
+
instrumental_audio_path="/path/audio.flac",
|
|
599
|
+
enable_youtube_upload=True,
|
|
600
|
+
youtube_credentials={"token": "test"},
|
|
601
|
+
)
|
|
602
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
603
|
+
orchestrator.result.final_video_lossy = video_file
|
|
604
|
+
|
|
605
|
+
with patch.object(orchestrator, "_get_youtube_service") as mock_get:
|
|
606
|
+
mock_service = MagicMock()
|
|
607
|
+
mock_service.upload_video.return_value = (
|
|
608
|
+
"video123",
|
|
609
|
+
"https://youtube.com/watch?v=video123"
|
|
610
|
+
)
|
|
611
|
+
mock_get.return_value = mock_service
|
|
612
|
+
|
|
613
|
+
await orchestrator._upload_to_youtube()
|
|
614
|
+
|
|
615
|
+
mock_service.upload_video.assert_called_once()
|
|
616
|
+
assert orchestrator.result.youtube_url == "https://youtube.com/watch?v=video123"
|
|
617
|
+
|
|
618
|
+
@pytest.mark.asyncio
|
|
619
|
+
async def test_upload_to_dropbox(self):
|
|
620
|
+
"""Test Dropbox upload."""
|
|
621
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
622
|
+
config = OrchestratorConfig(
|
|
623
|
+
job_id="test-job",
|
|
624
|
+
artist="Test Artist",
|
|
625
|
+
title="Test Title",
|
|
626
|
+
title_video_path="/path/title.mov",
|
|
627
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
628
|
+
instrumental_audio_path="/path/audio.flac",
|
|
629
|
+
output_dir=temp_dir,
|
|
630
|
+
dropbox_path="/Karaoke/Tracks",
|
|
631
|
+
brand_prefix="TEST",
|
|
632
|
+
)
|
|
633
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
634
|
+
orchestrator.result.brand_code = "TEST-0001"
|
|
635
|
+
|
|
636
|
+
with patch("backend.services.dropbox_service.get_dropbox_service") as mock_get:
|
|
637
|
+
mock_dropbox = MagicMock()
|
|
638
|
+
mock_dropbox.is_configured = True
|
|
639
|
+
mock_dropbox.create_shared_link.return_value = "https://dropbox.com/link"
|
|
640
|
+
mock_get.return_value = mock_dropbox
|
|
641
|
+
|
|
642
|
+
await orchestrator._upload_to_dropbox()
|
|
643
|
+
|
|
644
|
+
mock_dropbox.upload_folder.assert_called_once()
|
|
645
|
+
assert orchestrator.result.dropbox_link == "https://dropbox.com/link"
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
class TestVideoWorkerOrchestratorNotifications:
|
|
649
|
+
"""Test notifications stage."""
|
|
650
|
+
|
|
651
|
+
@pytest.mark.asyncio
|
|
652
|
+
async def test_run_notifications_with_youtube_url(self):
|
|
653
|
+
"""Test Discord notification with YouTube URL."""
|
|
654
|
+
config = OrchestratorConfig(
|
|
655
|
+
job_id="test-job",
|
|
656
|
+
artist="Test Artist",
|
|
657
|
+
title="Test Title",
|
|
658
|
+
title_video_path="/path/title.mov",
|
|
659
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
660
|
+
instrumental_audio_path="/path/audio.flac",
|
|
661
|
+
discord_webhook_url="https://discord.com/api/webhooks/123/abc",
|
|
662
|
+
)
|
|
663
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
664
|
+
orchestrator.result.youtube_url = "https://youtube.com/watch?v=test"
|
|
665
|
+
|
|
666
|
+
with patch.object(orchestrator, "_get_discord_service") as mock_get:
|
|
667
|
+
mock_service = MagicMock()
|
|
668
|
+
mock_service.post_video_notification.return_value = True
|
|
669
|
+
mock_get.return_value = mock_service
|
|
670
|
+
|
|
671
|
+
await orchestrator._run_notifications()
|
|
672
|
+
|
|
673
|
+
mock_service.post_video_notification.assert_called_once_with(
|
|
674
|
+
"https://youtube.com/watch?v=test"
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
@pytest.mark.asyncio
|
|
678
|
+
async def test_run_notifications_no_youtube_url(self):
|
|
679
|
+
"""Test notification skipped without YouTube URL."""
|
|
680
|
+
config = OrchestratorConfig(
|
|
681
|
+
job_id="test-job",
|
|
682
|
+
artist="Test Artist",
|
|
683
|
+
title="Test Title",
|
|
684
|
+
title_video_path="/path/title.mov",
|
|
685
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
686
|
+
instrumental_audio_path="/path/audio.flac",
|
|
687
|
+
discord_webhook_url="https://discord.com/api/webhooks/123/abc",
|
|
688
|
+
)
|
|
689
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
690
|
+
# No youtube_url set
|
|
691
|
+
|
|
692
|
+
with patch.object(orchestrator, "_get_discord_service") as mock_get:
|
|
693
|
+
mock_service = MagicMock()
|
|
694
|
+
mock_get.return_value = mock_service
|
|
695
|
+
|
|
696
|
+
await orchestrator._run_notifications()
|
|
697
|
+
|
|
698
|
+
mock_service.post_video_notification.assert_not_called()
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
class TestVideoWorkerOrchestratorFullPipeline:
|
|
702
|
+
"""Test full pipeline execution."""
|
|
703
|
+
|
|
704
|
+
@pytest.mark.asyncio
|
|
705
|
+
async def test_run_full_pipeline_success(self):
|
|
706
|
+
"""Test successful full pipeline."""
|
|
707
|
+
config = OrchestratorConfig(
|
|
708
|
+
job_id="test-job",
|
|
709
|
+
artist="Test Artist",
|
|
710
|
+
title="Test Title",
|
|
711
|
+
title_video_path="/path/title.mov",
|
|
712
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
713
|
+
instrumental_audio_path="/path/audio.flac",
|
|
714
|
+
enable_cdg=True, # Enable to trigger packaging stage
|
|
715
|
+
)
|
|
716
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
717
|
+
|
|
718
|
+
# Mock all stages
|
|
719
|
+
with patch.object(orchestrator, "_run_packaging", new_callable=AsyncMock) as mock_packaging, \
|
|
720
|
+
patch.object(orchestrator, "_run_encoding", new_callable=AsyncMock) as mock_encoding, \
|
|
721
|
+
patch.object(orchestrator, "_run_organization", new_callable=AsyncMock) as mock_org, \
|
|
722
|
+
patch.object(orchestrator, "_run_distribution", new_callable=AsyncMock) as mock_dist, \
|
|
723
|
+
patch.object(orchestrator, "_run_notifications", new_callable=AsyncMock) as mock_notify:
|
|
724
|
+
|
|
725
|
+
result = await orchestrator.run()
|
|
726
|
+
|
|
727
|
+
assert result.success is True
|
|
728
|
+
mock_packaging.assert_called_once()
|
|
729
|
+
mock_encoding.assert_called_once()
|
|
730
|
+
mock_org.assert_called_once()
|
|
731
|
+
mock_dist.assert_called_once()
|
|
732
|
+
mock_notify.assert_called_once()
|
|
733
|
+
|
|
734
|
+
@pytest.mark.asyncio
|
|
735
|
+
async def test_run_pipeline_skips_packaging_when_disabled(self):
|
|
736
|
+
"""Test pipeline skips packaging when CDG/TXT disabled."""
|
|
737
|
+
config = OrchestratorConfig(
|
|
738
|
+
job_id="test-job",
|
|
739
|
+
artist="Test Artist",
|
|
740
|
+
title="Test Title",
|
|
741
|
+
title_video_path="/path/title.mov",
|
|
742
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
743
|
+
instrumental_audio_path="/path/audio.flac",
|
|
744
|
+
enable_cdg=False,
|
|
745
|
+
enable_txt=False,
|
|
746
|
+
)
|
|
747
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
748
|
+
|
|
749
|
+
# Mock all stages
|
|
750
|
+
with patch.object(orchestrator, "_run_packaging", new_callable=AsyncMock) as mock_packaging, \
|
|
751
|
+
patch.object(orchestrator, "_run_encoding", new_callable=AsyncMock) as mock_encoding, \
|
|
752
|
+
patch.object(orchestrator, "_run_organization", new_callable=AsyncMock), \
|
|
753
|
+
patch.object(orchestrator, "_run_distribution", new_callable=AsyncMock), \
|
|
754
|
+
patch.object(orchestrator, "_run_notifications", new_callable=AsyncMock):
|
|
755
|
+
|
|
756
|
+
result = await orchestrator.run()
|
|
757
|
+
|
|
758
|
+
assert result.success is True
|
|
759
|
+
mock_packaging.assert_not_called()
|
|
760
|
+
mock_encoding.assert_called_once()
|
|
761
|
+
|
|
762
|
+
@pytest.mark.asyncio
|
|
763
|
+
async def test_run_full_pipeline_encoding_failure(self):
|
|
764
|
+
"""Test pipeline failure during encoding."""
|
|
765
|
+
config = OrchestratorConfig(
|
|
766
|
+
job_id="test-job",
|
|
767
|
+
artist="Test Artist",
|
|
768
|
+
title="Test Title",
|
|
769
|
+
title_video_path="/path/title.mov",
|
|
770
|
+
karaoke_video_path="/path/karaoke.mov",
|
|
771
|
+
instrumental_audio_path="/path/audio.flac",
|
|
772
|
+
)
|
|
773
|
+
orchestrator = VideoWorkerOrchestrator(config)
|
|
774
|
+
|
|
775
|
+
with patch.object(orchestrator, "_run_packaging", new_callable=AsyncMock), \
|
|
776
|
+
patch.object(orchestrator, "_run_encoding", new_callable=AsyncMock) as mock_encoding, \
|
|
777
|
+
patch.object(orchestrator, "_run_organization", new_callable=AsyncMock) as mock_org:
|
|
778
|
+
|
|
779
|
+
mock_encoding.side_effect = Exception("Encoding failed")
|
|
780
|
+
|
|
781
|
+
result = await orchestrator.run()
|
|
782
|
+
|
|
783
|
+
assert result.success is False
|
|
784
|
+
assert "Encoding failed" in result.error_message
|
|
785
|
+
mock_org.assert_not_called()
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
class TestCreateOrchestratorConfigFromJob:
|
|
789
|
+
"""Test helper function for creating config from job."""
|
|
790
|
+
|
|
791
|
+
def test_create_config_from_job(self):
|
|
792
|
+
"""Test creating config from a job object."""
|
|
793
|
+
job = MagicMock()
|
|
794
|
+
job.job_id = "test-123"
|
|
795
|
+
job.artist = "Test Artist"
|
|
796
|
+
job.title = "Test Title"
|
|
797
|
+
job.state_data = {"instrumental_selection": "clean"}
|
|
798
|
+
job.enable_cdg = True
|
|
799
|
+
job.enable_txt = False
|
|
800
|
+
job.enable_youtube_upload = True
|
|
801
|
+
job.brand_prefix = "NOMAD"
|
|
802
|
+
job.discord_webhook_url = "https://discord.com/api/webhooks/123/abc"
|
|
803
|
+
job.youtube_description_template = "Test description"
|
|
804
|
+
job.dropbox_path = "/Karaoke"
|
|
805
|
+
job.gdrive_folder_id = None
|
|
806
|
+
job.keep_brand_code = None
|
|
807
|
+
job.existing_instrumental_gcs_path = None
|
|
808
|
+
|
|
809
|
+
config = create_orchestrator_config_from_job(
|
|
810
|
+
job=job,
|
|
811
|
+
temp_dir="/tmp/test",
|
|
812
|
+
youtube_credentials={"token": "test"},
|
|
813
|
+
cdg_styles={"background": "black"},
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
assert config.job_id == "test-123"
|
|
817
|
+
assert config.artist == "Test Artist"
|
|
818
|
+
assert config.title == "Test Title"
|
|
819
|
+
assert config.enable_cdg is True
|
|
820
|
+
assert config.enable_youtube_upload is True
|
|
821
|
+
assert config.title_video_path == "/tmp/test/Test Artist - Test Title (Title).mov"
|
|
822
|
+
assert config.instrumental_audio_path == "/tmp/test/Test Artist - Test Title (Instrumental Clean).flac"
|
|
823
|
+
|
|
824
|
+
def test_create_config_from_job_with_existing_instrumental(self):
|
|
825
|
+
"""Test config with user-provided instrumental."""
|
|
826
|
+
job = MagicMock()
|
|
827
|
+
job.job_id = "test-123"
|
|
828
|
+
job.artist = "Test Artist"
|
|
829
|
+
job.title = "Test Title"
|
|
830
|
+
job.state_data = {"instrumental_selection": "custom"}
|
|
831
|
+
job.enable_cdg = False
|
|
832
|
+
job.enable_txt = False
|
|
833
|
+
job.enable_youtube_upload = False
|
|
834
|
+
job.brand_prefix = None
|
|
835
|
+
job.discord_webhook_url = None
|
|
836
|
+
job.youtube_description_template = None
|
|
837
|
+
job.dropbox_path = None
|
|
838
|
+
job.gdrive_folder_id = None
|
|
839
|
+
job.keep_brand_code = None
|
|
840
|
+
job.existing_instrumental_gcs_path = "gs://bucket/instrumental.mp3"
|
|
841
|
+
|
|
842
|
+
config = create_orchestrator_config_from_job(
|
|
843
|
+
job=job,
|
|
844
|
+
temp_dir="/tmp/test",
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
assert config.instrumental_audio_path == "/tmp/test/Test Artist - Test Title (Instrumental User).mp3"
|