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,567 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for LocalPreviewEncodingService.
|
|
3
|
+
|
|
4
|
+
Tests cover:
|
|
5
|
+
- Service initialization
|
|
6
|
+
- Hardware acceleration detection (NVENC)
|
|
7
|
+
- FFmpeg filter path escaping
|
|
8
|
+
- ASS filter building
|
|
9
|
+
- Preview FFmpeg command construction
|
|
10
|
+
- Preview encoding execution
|
|
11
|
+
- Error handling
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import tempfile
|
|
16
|
+
import pytest
|
|
17
|
+
from unittest.mock import MagicMock, patch
|
|
18
|
+
|
|
19
|
+
from backend.services.local_preview_encoding_service import (
|
|
20
|
+
LocalPreviewEncodingService,
|
|
21
|
+
PreviewEncodingConfig,
|
|
22
|
+
PreviewEncodingResult,
|
|
23
|
+
get_local_preview_encoding_service,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestLocalPreviewEncodingServiceInit:
|
|
28
|
+
"""Test service initialization."""
|
|
29
|
+
|
|
30
|
+
def test_init_default_values(self):
|
|
31
|
+
"""Test default initialization."""
|
|
32
|
+
service = LocalPreviewEncodingService()
|
|
33
|
+
assert service._nvenc_available is None # Lazy detection
|
|
34
|
+
assert service._video_encoder is None # Lazy detection
|
|
35
|
+
assert service._hwaccel_flags is None # Lazy detection
|
|
36
|
+
|
|
37
|
+
def test_init_with_custom_logger(self):
|
|
38
|
+
"""Test initialization with custom logger."""
|
|
39
|
+
import logging
|
|
40
|
+
custom_logger = logging.getLogger("test")
|
|
41
|
+
service = LocalPreviewEncodingService(logger=custom_logger)
|
|
42
|
+
assert service.logger is custom_logger
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestLocalPreviewEncodingServiceHWAccel:
|
|
46
|
+
"""Test hardware acceleration detection."""
|
|
47
|
+
|
|
48
|
+
@patch("subprocess.run")
|
|
49
|
+
def test_detect_nvenc_available(self, mock_run):
|
|
50
|
+
"""Test NVENC detection when available."""
|
|
51
|
+
mock_run.return_value = MagicMock(returncode=0)
|
|
52
|
+
|
|
53
|
+
service = LocalPreviewEncodingService()
|
|
54
|
+
result = service._detect_nvenc_support()
|
|
55
|
+
|
|
56
|
+
assert result is True
|
|
57
|
+
|
|
58
|
+
@patch("subprocess.run")
|
|
59
|
+
def test_detect_nvenc_not_available(self, mock_run):
|
|
60
|
+
"""Test fallback when NVENC not available."""
|
|
61
|
+
import subprocess
|
|
62
|
+
mock_run.side_effect = subprocess.CalledProcessError(1, "test")
|
|
63
|
+
|
|
64
|
+
service = LocalPreviewEncodingService()
|
|
65
|
+
result = service._detect_nvenc_support()
|
|
66
|
+
|
|
67
|
+
assert result is False
|
|
68
|
+
|
|
69
|
+
@patch("subprocess.run")
|
|
70
|
+
def test_detect_nvenc_timeout(self, mock_run):
|
|
71
|
+
"""Test NVENC detection timeout handling."""
|
|
72
|
+
import subprocess
|
|
73
|
+
mock_run.side_effect = subprocess.TimeoutExpired("test", 30)
|
|
74
|
+
|
|
75
|
+
service = LocalPreviewEncodingService()
|
|
76
|
+
result = service._detect_nvenc_support()
|
|
77
|
+
|
|
78
|
+
assert result is False
|
|
79
|
+
|
|
80
|
+
@patch.object(LocalPreviewEncodingService, "_detect_nvenc_support")
|
|
81
|
+
def test_configure_nvenc_available(self, mock_detect):
|
|
82
|
+
"""Test configuration when NVENC is available."""
|
|
83
|
+
mock_detect.return_value = True
|
|
84
|
+
|
|
85
|
+
service = LocalPreviewEncodingService()
|
|
86
|
+
service._configure_hardware_acceleration()
|
|
87
|
+
|
|
88
|
+
assert service._nvenc_available is True
|
|
89
|
+
assert service._video_encoder == "h264_nvenc"
|
|
90
|
+
assert "-hwaccel" in service._hwaccel_flags
|
|
91
|
+
|
|
92
|
+
@patch.object(LocalPreviewEncodingService, "_detect_nvenc_support")
|
|
93
|
+
def test_configure_no_nvenc(self, mock_detect):
|
|
94
|
+
"""Test configuration when NVENC not available."""
|
|
95
|
+
mock_detect.return_value = False
|
|
96
|
+
|
|
97
|
+
service = LocalPreviewEncodingService()
|
|
98
|
+
service._configure_hardware_acceleration()
|
|
99
|
+
|
|
100
|
+
assert service._nvenc_available is False
|
|
101
|
+
assert service._video_encoder == "libx264"
|
|
102
|
+
assert service._hwaccel_flags == []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestLocalPreviewEncodingServiceProperties:
|
|
106
|
+
"""Test property accessors that trigger lazy detection."""
|
|
107
|
+
|
|
108
|
+
@patch.object(LocalPreviewEncodingService, "_configure_hardware_acceleration")
|
|
109
|
+
def test_nvenc_available_triggers_detection(self, mock_config):
|
|
110
|
+
"""Test that nvenc_available property triggers detection."""
|
|
111
|
+
service = LocalPreviewEncodingService()
|
|
112
|
+
service._nvenc_available = None
|
|
113
|
+
|
|
114
|
+
# Access property
|
|
115
|
+
_ = service.nvenc_available
|
|
116
|
+
|
|
117
|
+
mock_config.assert_called_once()
|
|
118
|
+
|
|
119
|
+
@patch.object(LocalPreviewEncodingService, "_configure_hardware_acceleration")
|
|
120
|
+
def test_video_encoder_triggers_detection(self, mock_config):
|
|
121
|
+
"""Test that video_encoder property triggers detection."""
|
|
122
|
+
service = LocalPreviewEncodingService()
|
|
123
|
+
service._video_encoder = None
|
|
124
|
+
|
|
125
|
+
# Access property
|
|
126
|
+
_ = service.video_encoder
|
|
127
|
+
|
|
128
|
+
mock_config.assert_called_once()
|
|
129
|
+
|
|
130
|
+
@patch.object(LocalPreviewEncodingService, "_configure_hardware_acceleration")
|
|
131
|
+
def test_hwaccel_flags_triggers_detection(self, mock_config):
|
|
132
|
+
"""Test that hwaccel_flags property triggers detection."""
|
|
133
|
+
service = LocalPreviewEncodingService()
|
|
134
|
+
service._hwaccel_flags = None
|
|
135
|
+
|
|
136
|
+
# Access property
|
|
137
|
+
_ = service.hwaccel_flags
|
|
138
|
+
|
|
139
|
+
mock_config.assert_called_once()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestLocalPreviewEncodingServicePathEscaping:
|
|
143
|
+
"""Test FFmpeg filter path escaping."""
|
|
144
|
+
|
|
145
|
+
def test_escape_simple_path(self):
|
|
146
|
+
"""Test escaping a simple path."""
|
|
147
|
+
service = LocalPreviewEncodingService()
|
|
148
|
+
result = service._escape_ffmpeg_filter_path("/simple/path.ass")
|
|
149
|
+
assert result == "/simple/path.ass"
|
|
150
|
+
|
|
151
|
+
def test_escape_path_with_spaces(self):
|
|
152
|
+
"""Test escaping path with spaces."""
|
|
153
|
+
service = LocalPreviewEncodingService()
|
|
154
|
+
result = service._escape_ffmpeg_filter_path("/path/with spaces/file.ass")
|
|
155
|
+
assert "\\ " in result # Spaces should be escaped
|
|
156
|
+
|
|
157
|
+
def test_escape_path_with_apostrophe(self):
|
|
158
|
+
"""Test escaping path with apostrophe (common in song titles)."""
|
|
159
|
+
service = LocalPreviewEncodingService()
|
|
160
|
+
result = service._escape_ffmpeg_filter_path("/path/I'm With You/file.ass")
|
|
161
|
+
assert "\\\\\\'" in result # Apostrophe should be triple-backslash escaped
|
|
162
|
+
|
|
163
|
+
def test_escape_path_with_special_chars(self):
|
|
164
|
+
"""Test escaping path with FFmpeg special characters."""
|
|
165
|
+
service = LocalPreviewEncodingService()
|
|
166
|
+
result = service._escape_ffmpeg_filter_path("/path:with[special];chars,here.ass")
|
|
167
|
+
# Should escape :,[];
|
|
168
|
+
assert "\\:" in result
|
|
169
|
+
assert "\\[" in result
|
|
170
|
+
assert "\\]" in result
|
|
171
|
+
assert "\\;" in result
|
|
172
|
+
assert "\\," in result
|
|
173
|
+
|
|
174
|
+
def test_escape_path_with_backslashes(self):
|
|
175
|
+
"""Test escaping path with existing backslashes."""
|
|
176
|
+
service = LocalPreviewEncodingService()
|
|
177
|
+
result = service._escape_ffmpeg_filter_path("/path\\with\\backslashes.ass")
|
|
178
|
+
assert "\\\\" in result
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestLocalPreviewEncodingServiceASSFilter:
|
|
182
|
+
"""Test ASS filter building."""
|
|
183
|
+
|
|
184
|
+
def test_build_ass_filter_simple(self):
|
|
185
|
+
"""Test building ASS filter without font."""
|
|
186
|
+
service = LocalPreviewEncodingService()
|
|
187
|
+
result = service._build_ass_filter("/path/to/file.ass")
|
|
188
|
+
assert result.startswith("ass=")
|
|
189
|
+
assert "fontsdir" not in result
|
|
190
|
+
|
|
191
|
+
def test_build_ass_filter_with_font(self):
|
|
192
|
+
"""Test building ASS filter with custom font."""
|
|
193
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
194
|
+
font_path = os.path.join(tmpdir, "custom.ttf")
|
|
195
|
+
with open(font_path, "w") as f:
|
|
196
|
+
f.write("fake font")
|
|
197
|
+
|
|
198
|
+
service = LocalPreviewEncodingService()
|
|
199
|
+
result = service._build_ass_filter("/path/to/file.ass", font_path)
|
|
200
|
+
|
|
201
|
+
assert "fontsdir=" in result
|
|
202
|
+
|
|
203
|
+
def test_build_ass_filter_nonexistent_font(self):
|
|
204
|
+
"""Test building ASS filter with nonexistent font file."""
|
|
205
|
+
service = LocalPreviewEncodingService()
|
|
206
|
+
result = service._build_ass_filter("/path/to/file.ass", "/nonexistent/font.ttf")
|
|
207
|
+
# Should not include fontsdir for nonexistent font
|
|
208
|
+
assert "fontsdir" not in result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TestLocalPreviewEncodingServiceFFmpegCommand:
|
|
212
|
+
"""Test FFmpeg command building."""
|
|
213
|
+
|
|
214
|
+
def test_build_command_solid_background_libx264(self):
|
|
215
|
+
"""Test command building with solid background and libx264."""
|
|
216
|
+
service = LocalPreviewEncodingService()
|
|
217
|
+
service._nvenc_available = False
|
|
218
|
+
service._video_encoder = "libx264"
|
|
219
|
+
service._hwaccel_flags = []
|
|
220
|
+
|
|
221
|
+
config = PreviewEncodingConfig(
|
|
222
|
+
ass_path="/path/to/subs.ass",
|
|
223
|
+
audio_path="/path/to/audio.flac",
|
|
224
|
+
output_path="/path/to/output.mp4",
|
|
225
|
+
background_color="black"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
cmd = service._build_preview_ffmpeg_command(config)
|
|
229
|
+
|
|
230
|
+
assert "ffmpeg" in cmd
|
|
231
|
+
assert "-r" in cmd and "24" in cmd # Frame rate
|
|
232
|
+
assert "-c:v" in cmd
|
|
233
|
+
assert "libx264" in cmd
|
|
234
|
+
assert "-preset" in cmd and "superfast" in cmd
|
|
235
|
+
assert "-crf" in cmd and "28" in cmd
|
|
236
|
+
assert "color=c=black" in " ".join(cmd)
|
|
237
|
+
|
|
238
|
+
def test_build_command_with_nvenc(self):
|
|
239
|
+
"""Test command building with NVENC hardware acceleration."""
|
|
240
|
+
service = LocalPreviewEncodingService()
|
|
241
|
+
service._nvenc_available = True
|
|
242
|
+
service._video_encoder = "h264_nvenc"
|
|
243
|
+
service._hwaccel_flags = ["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"]
|
|
244
|
+
|
|
245
|
+
config = PreviewEncodingConfig(
|
|
246
|
+
ass_path="/path/to/subs.ass",
|
|
247
|
+
audio_path="/path/to/audio.flac",
|
|
248
|
+
output_path="/path/to/output.mp4"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
cmd = service._build_preview_ffmpeg_command(config)
|
|
252
|
+
|
|
253
|
+
assert "h264_nvenc" in cmd
|
|
254
|
+
assert "-hwaccel" in cmd
|
|
255
|
+
assert "-preset" in cmd and "p1" in cmd # Fastest NVENC preset
|
|
256
|
+
|
|
257
|
+
def test_build_command_with_background_image(self):
|
|
258
|
+
"""Test command building with background image."""
|
|
259
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
260
|
+
bg_path = os.path.join(tmpdir, "background.png")
|
|
261
|
+
with open(bg_path, "wb") as f:
|
|
262
|
+
f.write(b"fake png")
|
|
263
|
+
|
|
264
|
+
service = LocalPreviewEncodingService()
|
|
265
|
+
service._nvenc_available = False
|
|
266
|
+
service._video_encoder = "libx264"
|
|
267
|
+
service._hwaccel_flags = []
|
|
268
|
+
|
|
269
|
+
config = PreviewEncodingConfig(
|
|
270
|
+
ass_path="/path/to/subs.ass",
|
|
271
|
+
audio_path="/path/to/audio.flac",
|
|
272
|
+
output_path="/path/to/output.mp4",
|
|
273
|
+
background_image_path=bg_path
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
cmd = service._build_preview_ffmpeg_command(config)
|
|
277
|
+
|
|
278
|
+
assert "-loop" in cmd
|
|
279
|
+
assert "1" in cmd
|
|
280
|
+
assert bg_path in cmd
|
|
281
|
+
# Should have scale filter for background image
|
|
282
|
+
vf_index = cmd.index("-vf")
|
|
283
|
+
vf_value = cmd[vf_index + 1]
|
|
284
|
+
assert "scale=" in vf_value
|
|
285
|
+
assert "pad=" in vf_value
|
|
286
|
+
|
|
287
|
+
def test_build_command_resolution(self):
|
|
288
|
+
"""Test that command uses correct preview resolution."""
|
|
289
|
+
service = LocalPreviewEncodingService()
|
|
290
|
+
service._nvenc_available = False
|
|
291
|
+
service._video_encoder = "libx264"
|
|
292
|
+
service._hwaccel_flags = []
|
|
293
|
+
|
|
294
|
+
config = PreviewEncodingConfig(
|
|
295
|
+
ass_path="/path/to/subs.ass",
|
|
296
|
+
audio_path="/path/to/audio.flac",
|
|
297
|
+
output_path="/path/to/output.mp4"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
cmd = service._build_preview_ffmpeg_command(config)
|
|
301
|
+
cmd_str = " ".join(cmd)
|
|
302
|
+
|
|
303
|
+
# Resolution should be 480x270
|
|
304
|
+
assert "480" in cmd_str
|
|
305
|
+
assert "270" in cmd_str
|
|
306
|
+
|
|
307
|
+
def test_build_command_audio_settings(self):
|
|
308
|
+
"""Test that command uses correct audio settings."""
|
|
309
|
+
service = LocalPreviewEncodingService()
|
|
310
|
+
service._nvenc_available = False
|
|
311
|
+
service._video_encoder = "libx264"
|
|
312
|
+
service._hwaccel_flags = []
|
|
313
|
+
|
|
314
|
+
config = PreviewEncodingConfig(
|
|
315
|
+
ass_path="/path/to/subs.ass",
|
|
316
|
+
audio_path="/path/to/audio.flac",
|
|
317
|
+
output_path="/path/to/output.mp4"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
cmd = service._build_preview_ffmpeg_command(config)
|
|
321
|
+
|
|
322
|
+
assert "-c:a" in cmd
|
|
323
|
+
assert "aac" in cmd
|
|
324
|
+
assert "-b:a" in cmd
|
|
325
|
+
assert "96k" in cmd
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class TestLocalPreviewEncodingServiceEncode:
|
|
329
|
+
"""Test preview encoding execution."""
|
|
330
|
+
|
|
331
|
+
@patch("subprocess.run")
|
|
332
|
+
def test_encode_preview_success(self, mock_run):
|
|
333
|
+
"""Test successful preview encoding."""
|
|
334
|
+
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
|
335
|
+
|
|
336
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
337
|
+
ass_path = os.path.join(tmpdir, "subs.ass")
|
|
338
|
+
audio_path = os.path.join(tmpdir, "audio.flac")
|
|
339
|
+
output_path = os.path.join(tmpdir, "output.mp4")
|
|
340
|
+
|
|
341
|
+
# Create input files
|
|
342
|
+
with open(ass_path, "w") as f:
|
|
343
|
+
f.write("[Script Info]\n")
|
|
344
|
+
with open(audio_path, "wb") as f:
|
|
345
|
+
f.write(b"fake audio")
|
|
346
|
+
|
|
347
|
+
service = LocalPreviewEncodingService()
|
|
348
|
+
service._nvenc_available = False
|
|
349
|
+
service._video_encoder = "libx264"
|
|
350
|
+
service._hwaccel_flags = []
|
|
351
|
+
|
|
352
|
+
config = PreviewEncodingConfig(
|
|
353
|
+
ass_path=ass_path,
|
|
354
|
+
audio_path=audio_path,
|
|
355
|
+
output_path=output_path
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
result = service.encode_preview(config)
|
|
359
|
+
|
|
360
|
+
assert result.success is True
|
|
361
|
+
assert result.output_path == output_path
|
|
362
|
+
assert result.error is None
|
|
363
|
+
mock_run.assert_called_once()
|
|
364
|
+
|
|
365
|
+
@patch("subprocess.run")
|
|
366
|
+
def test_encode_preview_ffmpeg_failure(self, mock_run):
|
|
367
|
+
"""Test preview encoding with FFmpeg failure."""
|
|
368
|
+
mock_run.return_value = MagicMock(returncode=1, stderr="FFmpeg error message")
|
|
369
|
+
|
|
370
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
371
|
+
ass_path = os.path.join(tmpdir, "subs.ass")
|
|
372
|
+
audio_path = os.path.join(tmpdir, "audio.flac")
|
|
373
|
+
output_path = os.path.join(tmpdir, "output.mp4")
|
|
374
|
+
|
|
375
|
+
with open(ass_path, "w") as f:
|
|
376
|
+
f.write("[Script Info]\n")
|
|
377
|
+
with open(audio_path, "wb") as f:
|
|
378
|
+
f.write(b"fake audio")
|
|
379
|
+
|
|
380
|
+
service = LocalPreviewEncodingService()
|
|
381
|
+
service._nvenc_available = False
|
|
382
|
+
service._video_encoder = "libx264"
|
|
383
|
+
service._hwaccel_flags = []
|
|
384
|
+
|
|
385
|
+
config = PreviewEncodingConfig(
|
|
386
|
+
ass_path=ass_path,
|
|
387
|
+
audio_path=audio_path,
|
|
388
|
+
output_path=output_path
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
result = service.encode_preview(config)
|
|
392
|
+
|
|
393
|
+
assert result.success is False
|
|
394
|
+
assert "FFmpeg preview encoding failed" in result.error
|
|
395
|
+
|
|
396
|
+
def test_encode_preview_missing_ass_file(self):
|
|
397
|
+
"""Test preview encoding with missing ASS file."""
|
|
398
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
399
|
+
audio_path = os.path.join(tmpdir, "audio.flac")
|
|
400
|
+
output_path = os.path.join(tmpdir, "output.mp4")
|
|
401
|
+
|
|
402
|
+
with open(audio_path, "wb") as f:
|
|
403
|
+
f.write(b"fake audio")
|
|
404
|
+
|
|
405
|
+
service = LocalPreviewEncodingService()
|
|
406
|
+
|
|
407
|
+
config = PreviewEncodingConfig(
|
|
408
|
+
ass_path="/nonexistent/subs.ass",
|
|
409
|
+
audio_path=audio_path,
|
|
410
|
+
output_path=output_path
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
result = service.encode_preview(config)
|
|
414
|
+
|
|
415
|
+
assert result.success is False
|
|
416
|
+
assert "not found" in result.error
|
|
417
|
+
|
|
418
|
+
def test_encode_preview_missing_audio_file(self):
|
|
419
|
+
"""Test preview encoding with missing audio file."""
|
|
420
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
421
|
+
ass_path = os.path.join(tmpdir, "subs.ass")
|
|
422
|
+
output_path = os.path.join(tmpdir, "output.mp4")
|
|
423
|
+
|
|
424
|
+
with open(ass_path, "w") as f:
|
|
425
|
+
f.write("[Script Info]\n")
|
|
426
|
+
|
|
427
|
+
service = LocalPreviewEncodingService()
|
|
428
|
+
|
|
429
|
+
config = PreviewEncodingConfig(
|
|
430
|
+
ass_path=ass_path,
|
|
431
|
+
audio_path="/nonexistent/audio.flac",
|
|
432
|
+
output_path=output_path
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
result = service.encode_preview(config)
|
|
436
|
+
|
|
437
|
+
assert result.success is False
|
|
438
|
+
assert "not found" in result.error
|
|
439
|
+
|
|
440
|
+
@patch("subprocess.run")
|
|
441
|
+
def test_encode_preview_timeout(self, mock_run):
|
|
442
|
+
"""Test preview encoding timeout."""
|
|
443
|
+
import subprocess
|
|
444
|
+
mock_run.side_effect = subprocess.TimeoutExpired("ffmpeg", 300)
|
|
445
|
+
|
|
446
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
447
|
+
ass_path = os.path.join(tmpdir, "subs.ass")
|
|
448
|
+
audio_path = os.path.join(tmpdir, "audio.flac")
|
|
449
|
+
output_path = os.path.join(tmpdir, "output.mp4")
|
|
450
|
+
|
|
451
|
+
with open(ass_path, "w") as f:
|
|
452
|
+
f.write("[Script Info]\n")
|
|
453
|
+
with open(audio_path, "wb") as f:
|
|
454
|
+
f.write(b"fake audio")
|
|
455
|
+
|
|
456
|
+
service = LocalPreviewEncodingService()
|
|
457
|
+
service._nvenc_available = False
|
|
458
|
+
service._video_encoder = "libx264"
|
|
459
|
+
service._hwaccel_flags = []
|
|
460
|
+
|
|
461
|
+
config = PreviewEncodingConfig(
|
|
462
|
+
ass_path=ass_path,
|
|
463
|
+
audio_path=audio_path,
|
|
464
|
+
output_path=output_path
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
result = service.encode_preview(config)
|
|
468
|
+
|
|
469
|
+
assert result.success is False
|
|
470
|
+
assert "timed out" in result.error
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class TestPreviewEncodingConfig:
|
|
474
|
+
"""Test PreviewEncodingConfig dataclass."""
|
|
475
|
+
|
|
476
|
+
def test_config_required_fields(self):
|
|
477
|
+
"""Test that required fields must be provided."""
|
|
478
|
+
config = PreviewEncodingConfig(
|
|
479
|
+
ass_path="/path/to/subs.ass",
|
|
480
|
+
audio_path="/path/to/audio.flac",
|
|
481
|
+
output_path="/path/to/output.mp4"
|
|
482
|
+
)
|
|
483
|
+
assert config.ass_path == "/path/to/subs.ass"
|
|
484
|
+
assert config.background_color == "black" # Default
|
|
485
|
+
assert config.background_image_path is None # Optional
|
|
486
|
+
|
|
487
|
+
def test_config_all_fields(self):
|
|
488
|
+
"""Test config with all fields."""
|
|
489
|
+
config = PreviewEncodingConfig(
|
|
490
|
+
ass_path="/path/to/subs.ass",
|
|
491
|
+
audio_path="/path/to/audio.flac",
|
|
492
|
+
output_path="/path/to/output.mp4",
|
|
493
|
+
background_image_path="/path/to/bg.png",
|
|
494
|
+
background_color="red",
|
|
495
|
+
font_path="/path/to/font.ttf"
|
|
496
|
+
)
|
|
497
|
+
assert config.background_image_path == "/path/to/bg.png"
|
|
498
|
+
assert config.background_color == "red"
|
|
499
|
+
assert config.font_path == "/path/to/font.ttf"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
class TestPreviewEncodingResult:
|
|
503
|
+
"""Test PreviewEncodingResult dataclass."""
|
|
504
|
+
|
|
505
|
+
def test_result_success(self):
|
|
506
|
+
"""Test successful result."""
|
|
507
|
+
result = PreviewEncodingResult(
|
|
508
|
+
success=True,
|
|
509
|
+
output_path="/path/to/output.mp4"
|
|
510
|
+
)
|
|
511
|
+
assert result.success is True
|
|
512
|
+
assert result.error is None
|
|
513
|
+
assert result.output_path == "/path/to/output.mp4"
|
|
514
|
+
|
|
515
|
+
def test_result_failure(self):
|
|
516
|
+
"""Test failure result."""
|
|
517
|
+
result = PreviewEncodingResult(
|
|
518
|
+
success=False,
|
|
519
|
+
error="Something went wrong"
|
|
520
|
+
)
|
|
521
|
+
assert result.success is False
|
|
522
|
+
assert result.error == "Something went wrong"
|
|
523
|
+
assert result.output_path is None
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
class TestGetLocalPreviewEncodingService:
|
|
527
|
+
"""Test factory function."""
|
|
528
|
+
|
|
529
|
+
def test_get_service_creates_instance(self):
|
|
530
|
+
"""Test that factory function creates a new instance."""
|
|
531
|
+
import backend.services.local_preview_encoding_service as module
|
|
532
|
+
module._local_preview_encoding_service = None
|
|
533
|
+
|
|
534
|
+
service = get_local_preview_encoding_service()
|
|
535
|
+
|
|
536
|
+
assert service is not None
|
|
537
|
+
assert isinstance(service, LocalPreviewEncodingService)
|
|
538
|
+
|
|
539
|
+
def test_get_service_returns_singleton(self):
|
|
540
|
+
"""Test that factory function returns the same instance."""
|
|
541
|
+
import backend.services.local_preview_encoding_service as module
|
|
542
|
+
module._local_preview_encoding_service = None
|
|
543
|
+
|
|
544
|
+
service1 = get_local_preview_encoding_service()
|
|
545
|
+
service2 = get_local_preview_encoding_service()
|
|
546
|
+
|
|
547
|
+
assert service1 is service2
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
class TestPreviewEncodingConstants:
|
|
551
|
+
"""Test that encoding constants match expected values."""
|
|
552
|
+
|
|
553
|
+
def test_preview_resolution(self):
|
|
554
|
+
"""Test preview resolution constants."""
|
|
555
|
+
service = LocalPreviewEncodingService()
|
|
556
|
+
assert service.PREVIEW_WIDTH == 480
|
|
557
|
+
assert service.PREVIEW_HEIGHT == 270
|
|
558
|
+
|
|
559
|
+
def test_preview_fps(self):
|
|
560
|
+
"""Test preview frame rate constant."""
|
|
561
|
+
service = LocalPreviewEncodingService()
|
|
562
|
+
assert service.PREVIEW_FPS == 24
|
|
563
|
+
|
|
564
|
+
def test_preview_audio_bitrate(self):
|
|
565
|
+
"""Test preview audio bitrate constant."""
|
|
566
|
+
service = LocalPreviewEncodingService()
|
|
567
|
+
assert service.PREVIEW_AUDIO_BITRATE == "96k"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for backend main.py and config.py.
|
|
3
|
+
|
|
4
|
+
These tests cover the FastAPI app initialization and configuration.
|
|
5
|
+
"""
|
|
6
|
+
import pytest
|
|
7
|
+
import os
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestConfig:
|
|
12
|
+
"""Tests for config.py."""
|
|
13
|
+
|
|
14
|
+
def test_settings_from_environment(self):
|
|
15
|
+
"""Test settings can be loaded from environment variables."""
|
|
16
|
+
with patch.dict(os.environ, {
|
|
17
|
+
'GCS_BUCKET_NAME': 'test-bucket',
|
|
18
|
+
'FIRESTORE_COLLECTION': 'test-jobs',
|
|
19
|
+
'ADMIN_TOKENS': 'token1,token2',
|
|
20
|
+
'ENVIRONMENT': 'testing'
|
|
21
|
+
}):
|
|
22
|
+
from backend.config import Settings
|
|
23
|
+
settings = Settings()
|
|
24
|
+
assert settings.gcs_bucket_name == 'test-bucket'
|
|
25
|
+
assert settings.firestore_collection == 'test-jobs'
|
|
26
|
+
# admin_tokens is a string, comma-separated
|
|
27
|
+
assert 'token1' in settings.admin_tokens
|
|
28
|
+
assert 'token2' in settings.admin_tokens
|
|
29
|
+
|
|
30
|
+
def test_admin_tokens_is_comma_separated_string(self):
|
|
31
|
+
"""Test admin tokens stored as comma-separated string."""
|
|
32
|
+
with patch.dict(os.environ, {
|
|
33
|
+
'ADMIN_TOKENS': 'token1,token2,token3'
|
|
34
|
+
}):
|
|
35
|
+
from backend.config import Settings
|
|
36
|
+
settings = Settings()
|
|
37
|
+
# admin_tokens is stored as a string
|
|
38
|
+
assert settings.admin_tokens == 'token1,token2,token3'
|
|
39
|
+
|
|
40
|
+
def test_default_environment(self):
|
|
41
|
+
"""Test default environment is development."""
|
|
42
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
43
|
+
from backend.config import Settings
|
|
44
|
+
settings = Settings()
|
|
45
|
+
# Accept 'test' as well since pytest may set ENVIRONMENT=test
|
|
46
|
+
assert settings.environment in ['development', 'production', 'testing', 'test']
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestMain:
|
|
50
|
+
"""Tests for main.py FastAPI app."""
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def app(self):
|
|
54
|
+
"""Create FastAPI app with mocked services."""
|
|
55
|
+
mock_creds = MagicMock()
|
|
56
|
+
mock_creds.universe_domain = 'googleapis.com'
|
|
57
|
+
with patch('backend.services.firestore_service.firestore'), \
|
|
58
|
+
patch('backend.services.storage_service.storage'), \
|
|
59
|
+
patch('google.auth.default', return_value=(mock_creds, 'test-project')):
|
|
60
|
+
from backend.main import app
|
|
61
|
+
return app
|
|
62
|
+
|
|
63
|
+
def test_app_has_api_routes(self, app):
|
|
64
|
+
"""Test app includes API routes."""
|
|
65
|
+
routes = [route.path for route in app.routes]
|
|
66
|
+
assert any('/api' in route or '/jobs' in route for route in routes)
|
|
67
|
+
|
|
68
|
+
def test_app_has_cors_middleware(self, app):
|
|
69
|
+
"""Test app has CORS middleware configured."""
|
|
70
|
+
# Check that middleware is configured (not necessarily CORS specific)
|
|
71
|
+
assert len(app.user_middleware) >= 0 # App initializes middleware
|
|
72
|
+
|
|
73
|
+
def test_app_title(self, app):
|
|
74
|
+
"""Test app has expected title."""
|
|
75
|
+
assert 'karaoke' in app.title.lower() or app.title
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestDependencies:
|
|
79
|
+
"""Tests for dependencies.py."""
|
|
80
|
+
|
|
81
|
+
def test_dependencies_module_imports(self):
|
|
82
|
+
"""Test dependencies module can be imported and has auth functions."""
|
|
83
|
+
from backend.api import dependencies
|
|
84
|
+
assert hasattr(dependencies, 'require_auth')
|
|
85
|
+
assert hasattr(dependencies, 'require_admin')
|
|
86
|
+
assert hasattr(dependencies, 'optional_auth')
|
|
87
|
+
|