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,382 @@
1
+ """
2
+ Tests for PackagingService.
3
+
4
+ Tests cover:
5
+ - Service initialization
6
+ - CDG package creation
7
+ - TXT package creation
8
+ - Dry run mode
9
+ - Error handling
10
+ """
11
+
12
+ import os
13
+ import zipfile
14
+ import tempfile
15
+ import pytest
16
+ from unittest.mock import MagicMock, patch
17
+
18
+ from backend.services.packaging_service import (
19
+ PackagingService,
20
+ get_packaging_service,
21
+ )
22
+
23
+
24
+ class TestPackagingServiceInit:
25
+ """Test service initialization."""
26
+
27
+ def test_init_default_values(self):
28
+ """Test default initialization."""
29
+ service = PackagingService()
30
+ assert service.cdg_styles is None
31
+ assert service.dry_run is False
32
+ assert service.non_interactive is False
33
+
34
+ def test_init_with_cdg_styles(self):
35
+ """Test initialization with CDG styles."""
36
+ styles = {"font_size": 24, "bg_color": "black"}
37
+ service = PackagingService(cdg_styles=styles)
38
+ assert service.cdg_styles == styles
39
+
40
+ def test_init_with_dry_run(self):
41
+ """Test initialization with dry run mode."""
42
+ service = PackagingService(dry_run=True)
43
+ assert service.dry_run is True
44
+
45
+ def test_init_with_non_interactive(self):
46
+ """Test initialization with non-interactive mode."""
47
+ service = PackagingService(non_interactive=True)
48
+ assert service.non_interactive is True
49
+
50
+
51
+ class TestPackagingServiceCreateZipFromFiles:
52
+ """Test internal ZIP creation."""
53
+
54
+ def test_create_zip_from_files(self):
55
+ """Test creating a ZIP file from multiple files."""
56
+ with tempfile.TemporaryDirectory() as tmpdir:
57
+ # Create test files
58
+ file1 = os.path.join(tmpdir, "test1.txt")
59
+ file2 = os.path.join(tmpdir, "test2.txt")
60
+ zip_path = os.path.join(tmpdir, "output.zip")
61
+
62
+ with open(file1, "w") as f:
63
+ f.write("content1")
64
+ with open(file2, "w") as f:
65
+ f.write("content2")
66
+
67
+ service = PackagingService()
68
+ service._create_zip_from_files(
69
+ zip_path,
70
+ [(file1, "test1.txt"), (file2, "test2.txt")]
71
+ )
72
+
73
+ assert os.path.isfile(zip_path)
74
+ with zipfile.ZipFile(zip_path, "r") as zf:
75
+ names = zf.namelist()
76
+ assert "test1.txt" in names
77
+ assert "test2.txt" in names
78
+
79
+
80
+ class TestPackagingServiceCDGPackage:
81
+ """Test CDG package creation."""
82
+
83
+ def test_create_cdg_package_missing_lrc_file(self):
84
+ """Test that missing LRC file raises FileNotFoundError."""
85
+ service = PackagingService()
86
+
87
+ with pytest.raises(FileNotFoundError) as exc_info:
88
+ service.create_cdg_package(
89
+ lrc_file="/nonexistent/file.lrc",
90
+ audio_file="/some/audio.flac",
91
+ output_zip_path="/output/test.zip",
92
+ artist="Test Artist",
93
+ title="Test Title",
94
+ )
95
+ assert "LRC file not found" in str(exc_info.value)
96
+
97
+ def test_create_cdg_package_missing_audio_file(self):
98
+ """Test that missing audio file raises FileNotFoundError."""
99
+ with tempfile.TemporaryDirectory() as tmpdir:
100
+ lrc_file = os.path.join(tmpdir, "test.lrc")
101
+ with open(lrc_file, "w") as f:
102
+ f.write("[00:00.00]Test lyrics")
103
+
104
+ service = PackagingService()
105
+
106
+ with pytest.raises(FileNotFoundError) as exc_info:
107
+ service.create_cdg_package(
108
+ lrc_file=lrc_file,
109
+ audio_file="/nonexistent/audio.flac",
110
+ output_zip_path="/output/test.zip",
111
+ artist="Test Artist",
112
+ title="Test Title",
113
+ )
114
+ assert "Audio file not found" in str(exc_info.value)
115
+
116
+ def test_create_cdg_package_no_styles_raises(self):
117
+ """Test that CDG generation without styles raises ValueError."""
118
+ with tempfile.TemporaryDirectory() as tmpdir:
119
+ lrc_file = os.path.join(tmpdir, "test.lrc")
120
+ audio_file = os.path.join(tmpdir, "test.flac")
121
+ zip_path = os.path.join(tmpdir, "output.zip")
122
+
123
+ with open(lrc_file, "w") as f:
124
+ f.write("[00:00.00]Test lyrics")
125
+ with open(audio_file, "w") as f:
126
+ f.write("fake audio")
127
+
128
+ # Service without CDG styles
129
+ service = PackagingService(cdg_styles=None)
130
+
131
+ with pytest.raises(ValueError) as exc_info:
132
+ service.create_cdg_package(
133
+ lrc_file=lrc_file,
134
+ audio_file=audio_file,
135
+ output_zip_path=zip_path,
136
+ artist="Test Artist",
137
+ title="Test Title",
138
+ )
139
+ assert "CDG styles configuration is required" in str(exc_info.value)
140
+
141
+ def test_create_cdg_package_dry_run(self):
142
+ """Test CDG package creation in dry run mode."""
143
+ with tempfile.TemporaryDirectory() as tmpdir:
144
+ lrc_file = os.path.join(tmpdir, "test.lrc")
145
+ audio_file = os.path.join(tmpdir, "test.flac")
146
+ zip_path = os.path.join(tmpdir, "output.zip")
147
+
148
+ with open(lrc_file, "w") as f:
149
+ f.write("[00:00.00]Test lyrics")
150
+ with open(audio_file, "w") as f:
151
+ f.write("fake audio")
152
+
153
+ service = PackagingService(dry_run=True)
154
+
155
+ result = service.create_cdg_package(
156
+ lrc_file=lrc_file,
157
+ audio_file=audio_file,
158
+ output_zip_path=zip_path,
159
+ artist="Test Artist",
160
+ title="Test Title",
161
+ )
162
+
163
+ # In dry run, ZIP should not be created
164
+ assert not os.path.isfile(zip_path)
165
+ assert result[0] == zip_path
166
+
167
+ def test_create_cdg_package_existing_files(self):
168
+ """Test CDG package creation with existing MP3 and CDG files."""
169
+ with tempfile.TemporaryDirectory() as tmpdir:
170
+ lrc_file = os.path.join(tmpdir, "test.lrc")
171
+ audio_file = os.path.join(tmpdir, "test.flac")
172
+ mp3_file = os.path.join(tmpdir, "test.mp3")
173
+ cdg_file = os.path.join(tmpdir, "test.cdg")
174
+ zip_path = os.path.join(tmpdir, "output.zip")
175
+
176
+ # Create all files
177
+ with open(lrc_file, "w") as f:
178
+ f.write("[00:00.00]Test lyrics")
179
+ with open(audio_file, "w") as f:
180
+ f.write("fake audio")
181
+ with open(mp3_file, "w") as f:
182
+ f.write("fake mp3")
183
+ with open(cdg_file, "w") as f:
184
+ f.write("fake cdg")
185
+
186
+ service = PackagingService()
187
+
188
+ result = service.create_cdg_package(
189
+ lrc_file=lrc_file,
190
+ audio_file=audio_file,
191
+ output_zip_path=zip_path,
192
+ artist="Test Artist",
193
+ title="Test Title",
194
+ output_mp3_path=mp3_file,
195
+ output_cdg_path=cdg_file,
196
+ )
197
+
198
+ # ZIP should be created from existing files
199
+ assert os.path.isfile(zip_path)
200
+ assert result[0] == zip_path
201
+ assert result[1] == mp3_file
202
+ assert result[2] == cdg_file
203
+
204
+ @patch("lyrics_transcriber.output.cdg.CDGGenerator")
205
+ def test_create_cdg_package_with_generator(self, mock_cdg_generator_class):
206
+ """Test CDG package creation using CDGGenerator."""
207
+ with tempfile.TemporaryDirectory() as tmpdir:
208
+ lrc_file = os.path.join(tmpdir, "test.lrc")
209
+ audio_file = os.path.join(tmpdir, "test.flac")
210
+ zip_path = os.path.join(tmpdir, "output.zip")
211
+ generated_zip = os.path.join(tmpdir, "generated.zip")
212
+
213
+ with open(lrc_file, "w") as f:
214
+ f.write("[00:00.00]Test lyrics")
215
+ with open(audio_file, "w") as f:
216
+ f.write("fake audio")
217
+
218
+ # Create the zip file that would be generated
219
+ with zipfile.ZipFile(generated_zip, "w") as zf:
220
+ zf.writestr("test.mp3", "fake mp3")
221
+ zf.writestr("test.cdg", "fake cdg")
222
+
223
+ mock_generator = MagicMock()
224
+ mock_generator.generate_cdg_from_lrc.return_value = (
225
+ "test.cdg", "test.mp3", generated_zip
226
+ )
227
+ mock_cdg_generator_class.return_value = mock_generator
228
+
229
+ styles = {"font_size": 24}
230
+ service = PackagingService(cdg_styles=styles)
231
+
232
+ result = service.create_cdg_package(
233
+ lrc_file=lrc_file,
234
+ audio_file=audio_file,
235
+ output_zip_path=zip_path,
236
+ artist="Test Artist",
237
+ title="Test Title",
238
+ )
239
+
240
+ mock_generator.generate_cdg_from_lrc.assert_called_once_with(
241
+ lrc_file=lrc_file,
242
+ audio_file=audio_file,
243
+ title="Test Title",
244
+ artist="Test Artist",
245
+ cdg_styles=styles,
246
+ )
247
+ assert os.path.isfile(zip_path)
248
+
249
+
250
+ class TestPackagingServiceTXTPackage:
251
+ """Test TXT package creation."""
252
+
253
+ def test_create_txt_package_missing_lrc_file(self):
254
+ """Test that missing LRC file raises FileNotFoundError."""
255
+ service = PackagingService()
256
+
257
+ with pytest.raises(FileNotFoundError) as exc_info:
258
+ service.create_txt_package(
259
+ lrc_file="/nonexistent/file.lrc",
260
+ mp3_file="/some/audio.mp3",
261
+ output_zip_path="/output/test.zip",
262
+ )
263
+ assert "LRC file not found" in str(exc_info.value)
264
+
265
+ def test_create_txt_package_missing_mp3_file(self):
266
+ """Test that missing MP3 file raises FileNotFoundError."""
267
+ with tempfile.TemporaryDirectory() as tmpdir:
268
+ lrc_file = os.path.join(tmpdir, "test.lrc")
269
+ with open(lrc_file, "w") as f:
270
+ f.write("[00:00.00]Test lyrics")
271
+
272
+ service = PackagingService()
273
+
274
+ with pytest.raises(FileNotFoundError) as exc_info:
275
+ service.create_txt_package(
276
+ lrc_file=lrc_file,
277
+ mp3_file="/nonexistent/audio.mp3",
278
+ output_zip_path="/output/test.zip",
279
+ )
280
+ assert "MP3 file not found" in str(exc_info.value)
281
+
282
+ def test_create_txt_package_dry_run(self):
283
+ """Test TXT package creation in dry run mode."""
284
+ with tempfile.TemporaryDirectory() as tmpdir:
285
+ lrc_file = os.path.join(tmpdir, "test.lrc")
286
+ mp3_file = os.path.join(tmpdir, "test.mp3")
287
+ zip_path = os.path.join(tmpdir, "output.zip")
288
+
289
+ with open(lrc_file, "w") as f:
290
+ f.write("[00:00.00]Test lyrics")
291
+ with open(mp3_file, "w") as f:
292
+ f.write("fake mp3")
293
+
294
+ service = PackagingService(dry_run=True)
295
+
296
+ result = service.create_txt_package(
297
+ lrc_file=lrc_file,
298
+ mp3_file=mp3_file,
299
+ output_zip_path=zip_path,
300
+ )
301
+
302
+ # In dry run, ZIP should not be created
303
+ assert not os.path.isfile(zip_path)
304
+ assert result[0] == zip_path
305
+
306
+ @patch("lyrics_converter.LyricsConverter")
307
+ def test_create_txt_package_success(self, mock_converter_class):
308
+ """Test successful TXT package creation."""
309
+ with tempfile.TemporaryDirectory() as tmpdir:
310
+ lrc_file = os.path.join(tmpdir, "test.lrc")
311
+ mp3_file = os.path.join(tmpdir, "test.mp3")
312
+ zip_path = os.path.join(tmpdir, "output.zip")
313
+ txt_path = os.path.join(tmpdir, "output.txt")
314
+
315
+ with open(lrc_file, "w") as f:
316
+ f.write("[00:00.00]Test lyrics")
317
+ with open(mp3_file, "w") as f:
318
+ f.write("fake mp3")
319
+
320
+ mock_converter = MagicMock()
321
+ mock_converter.convert_file.return_value = "Converted lyrics text"
322
+ mock_converter_class.return_value = mock_converter
323
+
324
+ service = PackagingService()
325
+
326
+ result = service.create_txt_package(
327
+ lrc_file=lrc_file,
328
+ mp3_file=mp3_file,
329
+ output_zip_path=zip_path,
330
+ output_txt_path=txt_path,
331
+ )
332
+
333
+ mock_converter_class.assert_called_once_with(
334
+ output_format="txt",
335
+ filepath=lrc_file
336
+ )
337
+ mock_converter.convert_file.assert_called_once()
338
+
339
+ assert os.path.isfile(zip_path)
340
+ assert os.path.isfile(txt_path)
341
+ assert result[0] == zip_path
342
+ assert result[1] == txt_path
343
+
344
+ # Verify ZIP contents
345
+ with zipfile.ZipFile(zip_path, "r") as zf:
346
+ names = zf.namelist()
347
+ assert "test.mp3" in names
348
+ assert "output.txt" in names
349
+
350
+
351
+ class TestGetPackagingService:
352
+ """Test factory function."""
353
+
354
+ def test_get_service_creates_instance(self):
355
+ """Test that factory function creates a new instance."""
356
+ import backend.services.packaging_service as module
357
+ module._packaging_service = None
358
+
359
+ service = get_packaging_service()
360
+
361
+ assert service is not None
362
+ assert isinstance(service, PackagingService)
363
+
364
+ def test_get_service_with_cdg_styles(self):
365
+ """Test factory function with CDG styles."""
366
+ import backend.services.packaging_service as module
367
+ module._packaging_service = None
368
+
369
+ styles = {"font_size": 24}
370
+ service = get_packaging_service(cdg_styles=styles)
371
+
372
+ assert service.cdg_styles == styles
373
+
374
+ def test_get_service_with_options(self):
375
+ """Test factory function with additional options."""
376
+ import backend.services.packaging_service as module
377
+ module._packaging_service = None
378
+
379
+ service = get_packaging_service(dry_run=True, non_interactive=True)
380
+
381
+ assert service.dry_run is True
382
+ assert service.non_interactive is True
@@ -0,0 +1,201 @@
1
+ """
2
+ Unit tests for request models.
3
+
4
+ These tests validate Pydantic request model validation and serialization.
5
+ """
6
+ import pytest
7
+ from pydantic import ValidationError
8
+
9
+ from backend.models.requests import (
10
+ URLSubmissionRequest,
11
+ UploadSubmissionRequest,
12
+ CorrectionsSubmission,
13
+ InstrumentalSelection,
14
+ StartReviewRequest,
15
+ CancelJobRequest,
16
+ RetryJobRequest
17
+ )
18
+
19
+
20
+ class TestURLSubmissionRequest:
21
+ """Tests for URLSubmissionRequest model."""
22
+
23
+ def test_valid_youtube_url(self):
24
+ """Test valid YouTube URL is accepted."""
25
+ request = URLSubmissionRequest(url="https://youtube.com/watch?v=test123")
26
+ assert str(request.url) == "https://youtube.com/watch?v=test123"
27
+
28
+ def test_valid_youtube_short_url(self):
29
+ """Test valid YouTube short URL is accepted."""
30
+ request = URLSubmissionRequest(url="https://youtu.be/test123")
31
+ assert str(request.url) == "https://youtu.be/test123"
32
+
33
+ def test_optional_artist_title(self):
34
+ """Test artist and title are optional."""
35
+ request = URLSubmissionRequest(url="https://youtube.com/watch?v=test")
36
+ assert request.artist is None
37
+ assert request.title is None
38
+
39
+ def test_with_artist_title(self):
40
+ """Test request with artist and title."""
41
+ request = URLSubmissionRequest(
42
+ url="https://youtube.com/watch?v=test",
43
+ artist="Test Artist",
44
+ title="Test Song"
45
+ )
46
+ assert request.artist == "Test Artist"
47
+ assert request.title == "Test Song"
48
+
49
+ def test_url_required(self):
50
+ """Test URL is required."""
51
+ with pytest.raises(ValidationError):
52
+ URLSubmissionRequest()
53
+
54
+ def test_empty_url_rejected(self):
55
+ """Test empty URL is rejected."""
56
+ with pytest.raises(ValidationError):
57
+ URLSubmissionRequest(url="")
58
+
59
+
60
+ class TestCorrectionsSubmission:
61
+ """Tests for CorrectionsSubmission model."""
62
+
63
+ def test_valid_corrections(self):
64
+ """Test valid corrections submission."""
65
+ corrections = CorrectionsSubmission(
66
+ corrections={
67
+ "lines": [
68
+ {"text": "Hello world", "start": 0.0, "end": 1.0}
69
+ ],
70
+ "metadata": {"source": "test"}
71
+ }
72
+ )
73
+ assert "lines" in corrections.corrections
74
+ assert "metadata" in corrections.corrections
75
+
76
+ def test_missing_lines_rejected(self):
77
+ """Test corrections without 'lines' field is rejected."""
78
+ with pytest.raises(ValidationError):
79
+ CorrectionsSubmission(corrections={"metadata": {}})
80
+
81
+ def test_missing_metadata_rejected(self):
82
+ """Test corrections without 'metadata' field is rejected."""
83
+ with pytest.raises(ValidationError):
84
+ CorrectionsSubmission(corrections={"lines": []})
85
+
86
+ def test_corrections_required(self):
87
+ """Test corrections field is required."""
88
+ with pytest.raises(ValidationError):
89
+ CorrectionsSubmission()
90
+
91
+ def test_with_user_notes(self):
92
+ """Test corrections with user notes."""
93
+ corrections = CorrectionsSubmission(
94
+ corrections={"lines": [], "metadata": {}},
95
+ user_notes="Fixed typo in line 3"
96
+ )
97
+ assert corrections.user_notes == "Fixed typo in line 3"
98
+
99
+
100
+ class TestInstrumentalSelection:
101
+ """Tests for InstrumentalSelection model."""
102
+
103
+ def test_valid_clean_selection(self):
104
+ """Test selecting clean instrumental."""
105
+ selection = InstrumentalSelection(selection="clean")
106
+ assert selection.selection == "clean"
107
+
108
+ def test_valid_with_backing_selection(self):
109
+ """Test selecting instrumental with backing vocals."""
110
+ selection = InstrumentalSelection(selection="with_backing")
111
+ assert selection.selection == "with_backing"
112
+
113
+ def test_selection_required(self):
114
+ """Test selection is required."""
115
+ with pytest.raises(ValidationError):
116
+ InstrumentalSelection()
117
+
118
+ def test_invalid_selection_rejected(self):
119
+ """Test invalid selection values are rejected."""
120
+ with pytest.raises(ValidationError):
121
+ InstrumentalSelection(selection="invalid_option")
122
+
123
+
124
+ class TestUploadSubmissionRequest:
125
+ """Tests for UploadSubmissionRequest model."""
126
+
127
+ def test_valid_upload_request(self):
128
+ """Test valid upload submission."""
129
+ request = UploadSubmissionRequest(
130
+ artist="Test Artist",
131
+ title="Test Song"
132
+ )
133
+ assert request.artist == "Test Artist"
134
+ assert request.title == "Test Song"
135
+
136
+ def test_artist_required(self):
137
+ """Test artist is required."""
138
+ with pytest.raises(ValidationError):
139
+ UploadSubmissionRequest(title="Test Song")
140
+
141
+ def test_title_required(self):
142
+ """Test title is required."""
143
+ with pytest.raises(ValidationError):
144
+ UploadSubmissionRequest(artist="Test Artist")
145
+
146
+ def test_default_options(self):
147
+ """Test default option values."""
148
+ request = UploadSubmissionRequest(artist="Test", title="Test")
149
+ # CDG/TXT disabled by default (requires style config)
150
+ assert request.enable_cdg is False
151
+ assert request.enable_txt is False
152
+ # YouTube upload default is None (use server default)
153
+ assert request.enable_youtube_upload is None
154
+
155
+
156
+ class TestStartReviewRequest:
157
+ """Tests for StartReviewRequest model."""
158
+
159
+ def test_valid_request(self):
160
+ """Test valid start review request."""
161
+ request = StartReviewRequest()
162
+ assert request is not None
163
+
164
+
165
+ class TestCancelJobRequest:
166
+ """Tests for CancelJobRequest model."""
167
+
168
+ def test_valid_request(self):
169
+ """Test valid cancel request."""
170
+ request = CancelJobRequest()
171
+ assert request is not None
172
+
173
+ def test_with_reason(self):
174
+ """Test cancel with reason."""
175
+ request = CancelJobRequest(reason="User requested")
176
+ assert request.reason == "User requested"
177
+
178
+ def test_reason_optional(self):
179
+ """Test reason is optional."""
180
+ request = CancelJobRequest()
181
+ assert request.reason is None
182
+
183
+
184
+ class TestRetryJobRequest:
185
+ """Tests for RetryJobRequest model."""
186
+
187
+ def test_valid_request(self):
188
+ """Test valid retry request."""
189
+ request = RetryJobRequest()
190
+ assert request is not None
191
+
192
+ def test_with_from_stage(self):
193
+ """Test retry from specific stage."""
194
+ request = RetryJobRequest(from_stage="transcription")
195
+ assert request.from_stage == "transcription"
196
+
197
+ def test_from_stage_optional(self):
198
+ """Test from_stage is optional."""
199
+ request = RetryJobRequest()
200
+ assert request.from_stage is None
201
+