karaoke-gen 0.90.1__py3-none-any.whl → 0.96.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,282 @@
1
+ """
2
+ Unit tests for jobs.py routes.
3
+
4
+ These tests exercise the route logic with mocked services.
5
+ """
6
+ import pytest
7
+ from datetime import datetime, UTC
8
+ from unittest.mock import MagicMock, AsyncMock, patch
9
+
10
+ from backend.models.job import Job, JobStatus
11
+
12
+
13
+ class TestJobsRouteHelpers:
14
+ """Tests for helper functions in jobs.py routes."""
15
+
16
+ def test_jobs_router_structure(self):
17
+ """Test jobs router has expected structure."""
18
+ from backend.api.routes.jobs import router
19
+ assert router is not None
20
+
21
+ # Check that common route patterns exist
22
+ route_paths = [route.path for route in router.routes]
23
+ assert any('/jobs' in p or 'jobs' in str(p) for p in route_paths)
24
+
25
+
26
+ class TestJobStatusTransitions:
27
+ """Tests for job status transition validation.
28
+
29
+ These test the Job model's state machine which is critical for
30
+ preventing invalid operations.
31
+ """
32
+
33
+ def test_valid_pending_to_downloading(self):
34
+ """Test PENDING -> DOWNLOADING is valid."""
35
+ from backend.models.job import STATE_TRANSITIONS
36
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.PENDING, [])
37
+ assert JobStatus.DOWNLOADING in valid_transitions
38
+
39
+ def test_valid_downloading_to_separating(self):
40
+ """Test DOWNLOADING -> SEPARATING_STAGE1 is valid."""
41
+ from backend.models.job import STATE_TRANSITIONS
42
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.DOWNLOADING, [])
43
+ assert JobStatus.SEPARATING_STAGE1 in valid_transitions
44
+
45
+ def test_invalid_pending_to_complete(self):
46
+ """Test PENDING -> COMPLETE is invalid."""
47
+ from backend.models.job import STATE_TRANSITIONS
48
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.PENDING, [])
49
+ assert JobStatus.COMPLETE not in valid_transitions
50
+
51
+ def test_any_active_status_can_fail(self):
52
+ """Test any active (non-terminal, non-legacy) status can transition to FAILED."""
53
+ from backend.models.job import STATE_TRANSITIONS
54
+ # Terminal states and legacy states are excluded
55
+ excluded = [
56
+ JobStatus.COMPLETE, JobStatus.FAILED, JobStatus.CANCELLED,
57
+ # Legacy states that don't include FAILED
58
+ JobStatus.QUEUED, JobStatus.PROCESSING,
59
+ JobStatus.READY_FOR_FINALIZATION, JobStatus.FINALIZING, JobStatus.ERROR
60
+ ]
61
+ for status in JobStatus:
62
+ if status not in excluded:
63
+ valid_transitions = STATE_TRANSITIONS.get(status, [])
64
+ assert JobStatus.FAILED in valid_transitions, f"{status} should be able to fail"
65
+
66
+ def test_failed_can_transition_for_retry(self):
67
+ """Test FAILED status can transition to retry checkpoint states."""
68
+ from backend.models.job import STATE_TRANSITIONS
69
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.FAILED, [])
70
+
71
+ # FAILED should allow retry transitions
72
+ assert JobStatus.INSTRUMENTAL_SELECTED in valid_transitions, "FAILED should allow retry to INSTRUMENTAL_SELECTED"
73
+ assert JobStatus.REVIEW_COMPLETE in valid_transitions, "FAILED should allow retry to REVIEW_COMPLETE"
74
+ assert JobStatus.LYRICS_COMPLETE in valid_transitions, "FAILED should allow retry to LYRICS_COMPLETE"
75
+
76
+ def test_cancelled_can_transition_for_retry(self):
77
+ """Test CANCELLED status can transition to retry checkpoint states."""
78
+ from backend.models.job import STATE_TRANSITIONS
79
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.CANCELLED, [])
80
+
81
+ # CANCELLED should allow same retry transitions as FAILED
82
+ assert JobStatus.INSTRUMENTAL_SELECTED in valid_transitions, "CANCELLED should allow retry to INSTRUMENTAL_SELECTED"
83
+ assert JobStatus.REVIEW_COMPLETE in valid_transitions, "CANCELLED should allow retry to REVIEW_COMPLETE"
84
+ assert JobStatus.LYRICS_COMPLETE in valid_transitions, "CANCELLED should allow retry to LYRICS_COMPLETE"
85
+ assert JobStatus.DOWNLOADING in valid_transitions, "CANCELLED should allow retry to DOWNLOADING (restart)"
86
+
87
+ def test_failed_can_restart_from_beginning(self):
88
+ """Test FAILED status can transition to DOWNLOADING for restart from beginning."""
89
+ from backend.models.job import STATE_TRANSITIONS
90
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.FAILED, [])
91
+
92
+ # FAILED should allow restart from beginning
93
+ assert JobStatus.DOWNLOADING in valid_transitions, "FAILED should allow restart to DOWNLOADING"
94
+
95
+
96
+ class TestJobModelSerialization:
97
+ """Tests for Job model serialization to/from API."""
98
+
99
+ def test_job_to_dict(self):
100
+ """Test Job converts to dict correctly."""
101
+ job = Job(
102
+ job_id="test123",
103
+ status=JobStatus.PENDING,
104
+ created_at=datetime.now(UTC),
105
+ updated_at=datetime.now(UTC),
106
+ artist="Test Artist",
107
+ title="Test Song"
108
+ )
109
+ data = job.model_dump()
110
+ assert data["job_id"] == "test123"
111
+ assert data["status"] == "pending"
112
+ assert data["artist"] == "Test Artist"
113
+
114
+ def test_job_from_dict(self):
115
+ """Test Job can be created from dict."""
116
+ data = {
117
+ "job_id": "test123",
118
+ "status": "pending",
119
+ "created_at": datetime.now(UTC).isoformat(),
120
+ "updated_at": datetime.now(UTC).isoformat(),
121
+ "artist": "Test",
122
+ "title": "Song"
123
+ }
124
+ job = Job.model_validate(data)
125
+ assert job.job_id == "test123"
126
+ assert job.status == JobStatus.PENDING
127
+
128
+ def test_job_handles_null_optional_fields(self):
129
+ """Test Job handles null optional fields."""
130
+ job = Job(
131
+ job_id="test",
132
+ status=JobStatus.PENDING,
133
+ created_at=datetime.now(UTC),
134
+ updated_at=datetime.now(UTC)
135
+ )
136
+ assert job.artist is None
137
+ assert job.title is None
138
+ assert job.url is None
139
+ assert job.file_urls == {}
140
+
141
+
142
+ class TestRetryEndpoint:
143
+ """Tests for the retry job endpoint."""
144
+
145
+ @pytest.fixture
146
+ def mock_job_manager(self):
147
+ """Create mock job manager."""
148
+ return MagicMock()
149
+
150
+ @pytest.fixture
151
+ def mock_worker_service(self):
152
+ """Create mock worker service."""
153
+ return MagicMock()
154
+
155
+ def test_retry_rejects_pending_job(self, mock_job_manager):
156
+ """Test retry endpoint rejects jobs that are not failed or cancelled."""
157
+ # Create a pending job
158
+ job = Job(
159
+ job_id="test123",
160
+ status=JobStatus.PENDING,
161
+ created_at=datetime.now(UTC),
162
+ updated_at=datetime.now(UTC),
163
+ artist="Test",
164
+ title="Song"
165
+ )
166
+ mock_job_manager.get_job.return_value = job
167
+
168
+ # The endpoint should reject this with a 400 error
169
+ # (status check happens before any logic)
170
+ assert job.status not in [JobStatus.FAILED, JobStatus.CANCELLED]
171
+
172
+ def test_retry_accepts_failed_job(self, mock_job_manager):
173
+ """Test retry endpoint accepts failed jobs."""
174
+ job = Job(
175
+ job_id="test123",
176
+ status=JobStatus.FAILED,
177
+ created_at=datetime.now(UTC),
178
+ updated_at=datetime.now(UTC),
179
+ artist="Test",
180
+ title="Song",
181
+ input_media_gcs_path="jobs/test123/input/audio.flac"
182
+ )
183
+ mock_job_manager.get_job.return_value = job
184
+
185
+ # Status should be accepted
186
+ assert job.status in [JobStatus.FAILED, JobStatus.CANCELLED]
187
+
188
+ def test_retry_accepts_cancelled_job(self, mock_job_manager):
189
+ """Test retry endpoint accepts cancelled jobs."""
190
+ job = Job(
191
+ job_id="test123",
192
+ status=JobStatus.CANCELLED,
193
+ created_at=datetime.now(UTC),
194
+ updated_at=datetime.now(UTC),
195
+ artist="Test",
196
+ title="Song",
197
+ input_media_gcs_path="jobs/test123/input/audio.flac"
198
+ )
199
+ mock_job_manager.get_job.return_value = job
200
+
201
+ # Status should be accepted
202
+ assert job.status in [JobStatus.FAILED, JobStatus.CANCELLED]
203
+
204
+ def test_retry_checkpoint_detection_video_generation(self):
205
+ """Test retry detects video generation checkpoint."""
206
+ job = Job(
207
+ job_id="test123",
208
+ status=JobStatus.FAILED,
209
+ created_at=datetime.now(UTC),
210
+ updated_at=datetime.now(UTC),
211
+ artist="Test",
212
+ title="Song",
213
+ file_urls={
214
+ 'videos': {'with_vocals': 'gs://bucket/path/video.mkv'}
215
+ },
216
+ state_data={
217
+ 'instrumental_selection': 'clean'
218
+ }
219
+ )
220
+
221
+ # This job has video and instrumental selection - should retry from video generation
222
+ has_video = job.file_urls.get('videos', {}).get('with_vocals')
223
+ has_instrumental_selection = (job.state_data or {}).get('instrumental_selection')
224
+ assert has_video and has_instrumental_selection
225
+
226
+ def test_retry_checkpoint_detection_render_stage(self):
227
+ """Test retry detects render stage checkpoint."""
228
+ job = Job(
229
+ job_id="test123",
230
+ status=JobStatus.FAILED,
231
+ created_at=datetime.now(UTC),
232
+ updated_at=datetime.now(UTC),
233
+ artist="Test",
234
+ title="Song",
235
+ file_urls={
236
+ 'lyrics': {'corrections': 'gs://bucket/path/corrections.json'},
237
+ 'screens': {'title': 'gs://bucket/path/title.mov'}
238
+ }
239
+ )
240
+
241
+ # This job has corrections and screens - should retry from render
242
+ has_corrections = job.file_urls.get('lyrics', {}).get('corrections')
243
+ has_screens = job.file_urls.get('screens', {}).get('title')
244
+ has_video = job.file_urls.get('videos', {}).get('with_vocals')
245
+ assert has_corrections and has_screens and not has_video
246
+
247
+ def test_retry_checkpoint_detection_from_beginning(self):
248
+ """Test retry detects need to restart from beginning."""
249
+ job = Job(
250
+ job_id="test123",
251
+ status=JobStatus.CANCELLED,
252
+ created_at=datetime.now(UTC),
253
+ updated_at=datetime.now(UTC),
254
+ artist="Test",
255
+ title="Song",
256
+ input_media_gcs_path="jobs/test123/input/audio.flac",
257
+ file_urls={} # No progress yet
258
+ )
259
+
260
+ # This job has input audio but no other files - should restart from beginning
261
+ has_input = job.input_media_gcs_path
262
+ has_stems = job.file_urls.get('stems', {}).get('instrumental_clean')
263
+ has_corrections = job.file_urls.get('lyrics', {}).get('corrections')
264
+ assert has_input and not has_stems and not has_corrections
265
+
266
+ def test_retry_no_input_audio(self):
267
+ """Test retry fails when no input audio available."""
268
+ job = Job(
269
+ job_id="test123",
270
+ status=JobStatus.CANCELLED,
271
+ created_at=datetime.now(UTC),
272
+ updated_at=datetime.now(UTC),
273
+ artist="Test",
274
+ title="Song",
275
+ # No input_media_gcs_path and no url
276
+ file_urls={}
277
+ )
278
+
279
+ # This job has no input audio - should not be retryable
280
+ has_input = job.input_media_gcs_path or job.url
281
+ assert not has_input
282
+
@@ -0,0 +1,337 @@
1
+ """
2
+ Unit tests for review.py routes and related components.
3
+
4
+ These tests verify the review-related state transitions and data structures.
5
+ Tests that require full backend imports are in the emulator integration tests.
6
+ """
7
+ import pytest
8
+ import json
9
+
10
+
11
+ class TestJobStatusTransitionsForReview:
12
+ """Tests for review-related state transitions.
13
+
14
+ These tests verify the Job model's state machine handles review flow correctly.
15
+ """
16
+
17
+ def test_awaiting_review_can_transition_to_in_review(self):
18
+ """Test AWAITING_REVIEW -> IN_REVIEW is valid."""
19
+ from backend.models.job import STATE_TRANSITIONS, JobStatus
20
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.AWAITING_REVIEW, [])
21
+ assert JobStatus.IN_REVIEW in valid_transitions
22
+
23
+ def test_awaiting_review_can_transition_to_review_complete(self):
24
+ """Test AWAITING_REVIEW -> REVIEW_COMPLETE is valid (skip in_review)."""
25
+ from backend.models.job import STATE_TRANSITIONS, JobStatus
26
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.AWAITING_REVIEW, [])
27
+ assert JobStatus.REVIEW_COMPLETE in valid_transitions
28
+
29
+ def test_in_review_can_transition_to_review_complete(self):
30
+ """Test IN_REVIEW -> REVIEW_COMPLETE is valid."""
31
+ from backend.models.job import STATE_TRANSITIONS, JobStatus
32
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.IN_REVIEW, [])
33
+ assert JobStatus.REVIEW_COMPLETE in valid_transitions
34
+
35
+ def test_review_complete_transitions_to_rendering_video(self):
36
+ """Test REVIEW_COMPLETE -> RENDERING_VIDEO is valid."""
37
+ from backend.models.job import STATE_TRANSITIONS, JobStatus
38
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.REVIEW_COMPLETE, [])
39
+ assert JobStatus.RENDERING_VIDEO in valid_transitions
40
+
41
+ def test_rendering_video_status_exists(self):
42
+ """Test RENDERING_VIDEO status exists in JobStatus enum."""
43
+ from backend.models.job import JobStatus
44
+ assert hasattr(JobStatus, 'RENDERING_VIDEO')
45
+ assert JobStatus.RENDERING_VIDEO.value == "rendering_video"
46
+
47
+ def test_rendering_video_transitions_to_instrumental(self):
48
+ """Test RENDERING_VIDEO -> AWAITING_INSTRUMENTAL_SELECTION is valid."""
49
+ from backend.models.job import STATE_TRANSITIONS, JobStatus
50
+ valid_transitions = STATE_TRANSITIONS.get(JobStatus.RENDERING_VIDEO, [])
51
+ assert JobStatus.AWAITING_INSTRUMENTAL_SELECTION in valid_transitions
52
+
53
+
54
+ class TestStylesConfigRequirements:
55
+ """Tests documenting the required fields for ASS subtitle generation.
56
+
57
+ These tests verify that the styles config format is documented correctly.
58
+ The actual styles creation is tested in integration tests.
59
+ """
60
+
61
+ def test_required_karaoke_fields_documented(self):
62
+ """Document required karaoke style fields for ASS generation."""
63
+ # These fields are required by the ASS subtitle generator
64
+ # Missing any of them will cause video generation to fail
65
+ required_fields = [
66
+ "font", # Font name for ASS
67
+ "font_path", # Path to font file (can be empty string, NOT None)
68
+ "ass_name", # Style name in ASS file
69
+ "primary_color", # Format: "R, G, B, A"
70
+ "secondary_color", # Format: "R, G, B, A"
71
+ "outline_color", # Format: "R, G, B, A"
72
+ "back_color", # Format: "R, G, B, A"
73
+ "bold", # Boolean
74
+ "italic", # Boolean
75
+ "underline", # Boolean
76
+ "strike_out", # Boolean
77
+ "scale_x", # Integer
78
+ "scale_y", # Integer
79
+ "spacing", # Integer
80
+ "angle", # Float
81
+ "border_style", # Integer
82
+ "outline", # Integer
83
+ "shadow", # Integer
84
+ "margin_l", # Integer
85
+ "margin_r", # Integer
86
+ "margin_v", # Integer
87
+ "encoding", # Integer
88
+ ]
89
+
90
+ # This test documents the required fields
91
+ # Actual validation happens in integration tests
92
+ assert len(required_fields) == 22
93
+ assert "ass_name" in required_fields # This was missing initially
94
+ assert "font_path" in required_fields # Must be string, not None
95
+
96
+ def test_minimal_styles_structure(self):
97
+ """Document the minimal styles JSON structure."""
98
+ minimal_styles = {
99
+ "karaoke": {
100
+ "background_color": "#000000",
101
+ "font_path": "", # MUST be string, NOT None
102
+ "font": "Noto Sans",
103
+ "ass_name": "Default", # REQUIRED
104
+ "primary_color": "112, 112, 247, 255",
105
+ "secondary_color": "255, 255, 255, 255",
106
+ "outline_color": "26, 58, 235, 255",
107
+ "back_color": "0, 0, 0, 0",
108
+ "bold": False,
109
+ "italic": False,
110
+ "underline": False,
111
+ "strike_out": False,
112
+ "scale_x": 100,
113
+ "scale_y": 100,
114
+ "spacing": 0,
115
+ "angle": 0.0,
116
+ "border_style": 1,
117
+ "outline": 1,
118
+ "shadow": 0,
119
+ "margin_l": 0,
120
+ "margin_r": 0,
121
+ "margin_v": 0,
122
+ "encoding": 0
123
+ }
124
+ }
125
+
126
+ # Verify structure is valid JSON
127
+ json_str = json.dumps(minimal_styles)
128
+ parsed = json.loads(json_str)
129
+
130
+ assert "karaoke" in parsed
131
+ assert parsed["karaoke"]["font_path"] == "" # Not None
132
+ assert parsed["karaoke"]["ass_name"] == "Default"
133
+
134
+
135
+ class TestCorrectionDataMerging:
136
+ """Tests documenting correction data merging requirements.
137
+
138
+ The LyricsTranscriber React UI sends only partial correction data:
139
+ - corrections
140
+ - corrected_segments
141
+
142
+ The backend must merge this with the original corrections.json to
143
+ reconstruct a full CorrectionResult.
144
+ """
145
+
146
+ def test_frontend_sends_partial_data(self):
147
+ """Document what the frontend sends."""
148
+ # The frontend sends only these fields
149
+ frontend_payload = {
150
+ "corrections": [], # List of corrections made
151
+ "corrected_segments": [] # Updated segment data
152
+ }
153
+
154
+ # This is NOT a full CorrectionResult
155
+ assert "original_segments" not in frontend_payload
156
+ assert "metadata" not in frontend_payload
157
+
158
+ def test_merging_strategy(self):
159
+ """Document the merging strategy for corrections."""
160
+ # Original data has full structure
161
+ original_data = {
162
+ "original_segments": [{"id": 1, "text": "test"}],
163
+ "corrected_segments": [{"id": 1, "text": "test"}],
164
+ "corrections": [],
165
+ "metadata": {"artist": "Test"}
166
+ }
167
+
168
+ # Frontend sends partial update
169
+ frontend_update = {
170
+ "corrections": [{"id": 1, "type": "edit"}],
171
+ "corrected_segments": [{"id": 1, "text": "updated"}]
172
+ }
173
+
174
+ # Merging strategy: update only the fields sent by frontend
175
+ if 'corrections' in frontend_update:
176
+ original_data['corrections'] = frontend_update['corrections']
177
+ if 'corrected_segments' in frontend_update:
178
+ original_data['corrected_segments'] = frontend_update['corrected_segments']
179
+
180
+ # Result preserves original_segments and metadata
181
+ assert original_data['original_segments'] == [{"id": 1, "text": "test"}]
182
+ assert original_data['metadata'] == {"artist": "Test"}
183
+ # But has updated corrections and corrected_segments
184
+ assert original_data['corrections'] == [{"id": 1, "type": "edit"}]
185
+ assert original_data['corrected_segments'] == [{"id": 1, "text": "updated"}]
186
+
187
+
188
+ class TestAddLyricsEndpoint:
189
+ """Tests for the add-lyrics endpoint."""
190
+
191
+ def test_add_lyrics_requires_source_and_lyrics(self):
192
+ """Document that add_lyrics expects source and lyrics fields."""
193
+ # The frontend sends this payload
194
+ valid_payload = {
195
+ "source": "custom",
196
+ "lyrics": "Line 1\nLine 2\nLine 3"
197
+ }
198
+
199
+ # Both fields are required
200
+ assert "source" in valid_payload
201
+ assert "lyrics" in valid_payload
202
+ assert len(valid_payload["source"].strip()) > 0
203
+ assert len(valid_payload["lyrics"].strip()) > 0
204
+
205
+ def test_add_lyrics_uses_correction_operations(self):
206
+ """Verify CorrectionOperations.add_lyrics_source is available."""
207
+ from lyrics_transcriber.correction.operations import CorrectionOperations
208
+
209
+ # The method should exist
210
+ assert hasattr(CorrectionOperations, 'add_lyrics_source')
211
+
212
+ # It should be a static method
213
+ import inspect
214
+ # Get the method and check it's callable
215
+ method = getattr(CorrectionOperations, 'add_lyrics_source')
216
+ assert callable(method)
217
+
218
+
219
+ class TestPreviewStyleLoading:
220
+ """Tests for the unified style loader used in preview video generation.
221
+
222
+ When a job has custom styles (uploaded via --style_params_json), these
223
+ must be loaded and applied to preview videos, not just the final render.
224
+
225
+ This was a bug: preview videos were using minimal styles (black background)
226
+ even when the job had custom backgrounds and fonts configured.
227
+
228
+ The style loading logic is now consolidated in karaoke_gen.style_loader
229
+ to avoid duplication between workers and API routes.
230
+ """
231
+
232
+ def test_load_styles_from_gcs_with_custom_styles(self, tmp_path):
233
+ """Test that custom styles are downloaded and applied for preview."""
234
+ import os
235
+ from karaoke_gen.style_loader import load_styles_from_gcs
236
+
237
+ # Create source style params file
238
+ source_style_params = tmp_path / "source_styles.json"
239
+ style_data = {
240
+ "karaoke": {
241
+ "background_image": "/original/path/background.png",
242
+ "font_path": "/original/path/font.ttf",
243
+ "background_color": "#000000",
244
+ "font": "Noto Sans",
245
+ "ass_name": "Default",
246
+ }
247
+ }
248
+ source_style_params.write_text(json.dumps(style_data))
249
+
250
+ # Create source asset files
251
+ source_background = tmp_path / "background.png"
252
+ source_background.write_bytes(b"PNG image data")
253
+ source_font = tmp_path / "font.ttf"
254
+ source_font.write_bytes(b"TTF font data")
255
+
256
+ # Create mock download function that simulates GCS download
257
+ def mock_download(gcs_path, local_path):
258
+ if "style_params.json" in gcs_path:
259
+ with open(local_path, 'w') as f:
260
+ f.write(source_style_params.read_text())
261
+ elif "karaoke_background" in gcs_path:
262
+ with open(local_path, 'wb') as f:
263
+ f.write(source_background.read_bytes())
264
+ elif "font.ttf" in gcs_path:
265
+ with open(local_path, 'wb') as f:
266
+ f.write(source_font.read_bytes())
267
+
268
+ # Call the unified style loader function
269
+ style_assets = {
270
+ "style_params": "uploads/test123/style/style_params.json",
271
+ "karaoke_background": "uploads/test123/style/karaoke_background.png",
272
+ "font": "uploads/test123/style/font.ttf",
273
+ }
274
+
275
+ styles_path, result_styles = load_styles_from_gcs(
276
+ style_params_gcs_path="uploads/test123/style/style_params.json",
277
+ style_assets=style_assets,
278
+ temp_dir=str(tmp_path / "workdir"),
279
+ download_func=mock_download,
280
+ )
281
+
282
+ # Verify styles file was created
283
+ assert os.path.exists(styles_path)
284
+
285
+ # The paths should now point to the local downloaded files, not the original paths
286
+ assert "karaoke" in result_styles
287
+ assert result_styles["karaoke"]["background_image"] != "/original/path/background.png"
288
+ assert "karaoke_background.png" in result_styles["karaoke"]["background_image"]
289
+ assert result_styles["karaoke"]["font_path"] != "/original/path/font.ttf"
290
+ assert "font.ttf" in result_styles["karaoke"]["font_path"]
291
+
292
+ def test_load_styles_from_gcs_falls_back_to_minimal(self, tmp_path):
293
+ """Test that minimal styles are used when job has no custom styles."""
294
+ import os
295
+ from karaoke_gen.style_loader import load_styles_from_gcs
296
+
297
+ # Call with no custom styles
298
+ styles_path, result_styles = load_styles_from_gcs(
299
+ style_params_gcs_path=None, # No custom styles
300
+ style_assets={},
301
+ temp_dir=str(tmp_path),
302
+ download_func=lambda gcs_path, local_path: None, # Won't be called
303
+ )
304
+
305
+ # Verify styles file was created
306
+ assert os.path.exists(styles_path)
307
+
308
+ # Should have karaoke section with minimal/default values
309
+ assert "karaoke" in result_styles
310
+ assert result_styles["karaoke"]["background_color"] == "#000000"
311
+ assert result_styles["karaoke"]["font"] == "Noto Sans"
312
+ # Minimal styles have background_image as None (default)
313
+ assert result_styles["karaoke"].get("background_image") is None
314
+
315
+ def test_asset_mapping_is_complete(self):
316
+ """Verify all required asset mappings are defined in the unified style loader."""
317
+ from karaoke_gen.style_loader import ASSET_KEY_MAPPINGS
318
+
319
+ # These mappings must be present for styles to work correctly
320
+ required_keys = [
321
+ "karaoke_background",
322
+ "intro_background",
323
+ "end_background",
324
+ "font",
325
+ ]
326
+
327
+ for key in required_keys:
328
+ assert key in ASSET_KEY_MAPPINGS, f"Asset mapping '{key}' not found in ASSET_KEY_MAPPINGS"
329
+
330
+ # Verify karaoke_background maps to the correct path
331
+ karaoke_mapping = ASSET_KEY_MAPPINGS["karaoke_background"]
332
+ assert karaoke_mapping == ("karaoke", "background_image")
333
+
334
+ # Verify font maps to multiple sections
335
+ font_mappings = ASSET_KEY_MAPPINGS["font"]
336
+ assert isinstance(font_mappings, list)
337
+ assert ("karaoke", "font_path") in font_mappings