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,516 @@
1
+ """
2
+ Tests for the theme service and API endpoints.
3
+
4
+ Tests theme listing, retrieval, color override application, and job style preparation.
5
+ """
6
+ import json
7
+ import pytest
8
+ from unittest.mock import MagicMock, patch, Mock
9
+ from datetime import datetime
10
+
11
+ from backend.models.theme import (
12
+ ThemeSummary,
13
+ ThemeDetail,
14
+ ThemeMetadata,
15
+ ThemeRegistry,
16
+ ColorOverrides,
17
+ hex_to_rgba,
18
+ rgba_to_hex,
19
+ )
20
+ from backend.services.theme_service import ThemeService, get_theme_service
21
+
22
+
23
+ # =============================================================================
24
+ # Color Conversion Tests
25
+ # =============================================================================
26
+
27
+ class TestColorConversion:
28
+ """Tests for hex/rgba color conversion utilities."""
29
+
30
+ def test_hex_to_rgba_basic(self):
31
+ """Test basic hex to RGBA conversion."""
32
+ result = hex_to_rgba("#7070F7")
33
+ assert result == "112, 112, 247, 255"
34
+
35
+ def test_hex_to_rgba_with_custom_alpha(self):
36
+ """Test hex to RGBA with custom alpha."""
37
+ result = hex_to_rgba("#FF0000", alpha=128)
38
+ assert result == "255, 0, 0, 128"
39
+
40
+ def test_hex_to_rgba_lowercase(self):
41
+ """Test hex with lowercase letters."""
42
+ result = hex_to_rgba("#abcdef")
43
+ assert result == "171, 205, 239, 255"
44
+
45
+ def test_hex_to_rgba_without_hash(self):
46
+ """Test hex without leading hash."""
47
+ result = hex_to_rgba("FFFFFF")
48
+ assert result == "255, 255, 255, 255"
49
+
50
+ def test_rgba_to_hex_basic(self):
51
+ """Test basic RGBA to hex conversion."""
52
+ result = rgba_to_hex("112, 112, 247, 255")
53
+ assert result == "#7070f7"
54
+
55
+ def test_rgba_to_hex_ignores_alpha(self):
56
+ """Test that alpha is ignored in conversion."""
57
+ result = rgba_to_hex("255, 0, 0, 128")
58
+ assert result == "#ff0000"
59
+
60
+
61
+ # =============================================================================
62
+ # ColorOverrides Model Tests
63
+ # =============================================================================
64
+
65
+ class TestColorOverrides:
66
+ """Tests for ColorOverrides model."""
67
+
68
+ def test_has_overrides_empty(self):
69
+ """Test has_overrides with no overrides set."""
70
+ overrides = ColorOverrides()
71
+ assert overrides.has_overrides() is False
72
+
73
+ def test_has_overrides_with_artist_color(self):
74
+ """Test has_overrides with artist_color set."""
75
+ overrides = ColorOverrides(artist_color="#FF0000")
76
+ assert overrides.has_overrides() is True
77
+
78
+ def test_has_overrides_with_all_colors(self):
79
+ """Test has_overrides with all colors set."""
80
+ overrides = ColorOverrides(
81
+ artist_color="#FF0000",
82
+ title_color="#00FF00",
83
+ sung_lyrics_color="#0000FF",
84
+ unsung_lyrics_color="#FFFF00",
85
+ )
86
+ assert overrides.has_overrides() is True
87
+
88
+ def test_to_dict_excludes_none(self):
89
+ """Test to_dict excludes None values."""
90
+ overrides = ColorOverrides(artist_color="#FF0000")
91
+ result = overrides.to_dict()
92
+ assert result == {"artist_color": "#FF0000"}
93
+ assert "title_color" not in result
94
+
95
+ def test_color_validation_valid(self):
96
+ """Test color validation accepts valid hex colors."""
97
+ overrides = ColorOverrides(artist_color="#AbCdEf")
98
+ assert overrides.artist_color == "#AbCdEf"
99
+
100
+ def test_color_validation_invalid(self):
101
+ """Test color validation rejects invalid colors."""
102
+ with pytest.raises(ValueError):
103
+ ColorOverrides(artist_color="red")
104
+
105
+ with pytest.raises(ValueError):
106
+ ColorOverrides(artist_color="#GGG")
107
+
108
+
109
+ # =============================================================================
110
+ # ThemeService Tests
111
+ # =============================================================================
112
+
113
+ class TestThemeService:
114
+ """Tests for ThemeService."""
115
+
116
+ @pytest.fixture
117
+ def mock_storage(self):
118
+ """Create a mock storage service."""
119
+ storage = MagicMock()
120
+ return storage
121
+
122
+ @pytest.fixture
123
+ def sample_metadata(self):
124
+ """Sample theme metadata for testing."""
125
+ return {
126
+ "version": 1,
127
+ "themes": [
128
+ {
129
+ "id": "nomad",
130
+ "name": "Nomad Karaoke",
131
+ "description": "Golden artist text, professional look",
132
+ "is_default": True,
133
+ },
134
+ {
135
+ "id": "default",
136
+ "name": "Default",
137
+ "description": "Clean white text on black background",
138
+ "is_default": False,
139
+ },
140
+ ],
141
+ }
142
+
143
+ @pytest.fixture
144
+ def sample_style_params(self):
145
+ """Sample style params for testing."""
146
+ return {
147
+ "intro": {
148
+ "artist_color": "#ffdf6b",
149
+ "title_color": "#ffffff",
150
+ "background_image": "intro_bg.png",
151
+ "font": "Montserrat-Bold.ttf",
152
+ },
153
+ "end": {
154
+ "artist_color": "#ffdf6b",
155
+ "title_color": "#ffffff",
156
+ "background_image": "end_bg.png",
157
+ "font": "Montserrat-Bold.ttf",
158
+ },
159
+ "karaoke": {
160
+ "primary_color": "112, 112, 247, 255",
161
+ "secondary_color": "255, 255, 255, 255",
162
+ "background_image": "karaoke_bg.png",
163
+ "font_path": "Montserrat-Bold.ttf",
164
+ },
165
+ "cdg": {
166
+ "active_fill": "#7070F7",
167
+ "inactive_fill": "#FFFFFF",
168
+ "font_path": "Montserrat-Bold.ttf",
169
+ },
170
+ }
171
+
172
+ def test_list_themes_returns_summaries(self, mock_storage, sample_metadata):
173
+ """Test list_themes returns ThemeSummary objects."""
174
+ mock_storage.download_json.return_value = sample_metadata
175
+ mock_storage.file_exists.return_value = True
176
+ mock_storage.generate_signed_url.return_value = "https://signed-url.com/preview.png"
177
+
178
+ service = ThemeService(storage=mock_storage)
179
+ themes = service.list_themes()
180
+
181
+ assert len(themes) == 2
182
+ assert themes[0].id == "nomad"
183
+ assert themes[0].name == "Nomad Karaoke"
184
+ assert themes[0].is_default is True
185
+ assert themes[0].preview_url is not None
186
+
187
+ def test_list_themes_handles_missing_preview(self, mock_storage, sample_metadata):
188
+ """Test list_themes handles missing preview images gracefully."""
189
+ mock_storage.download_json.return_value = sample_metadata
190
+ mock_storage.file_exists.return_value = False
191
+
192
+ service = ThemeService(storage=mock_storage)
193
+ themes = service.list_themes()
194
+
195
+ assert len(themes) == 2
196
+ assert themes[0].preview_url is None
197
+
198
+ def test_get_theme_returns_detail(self, mock_storage, sample_metadata, sample_style_params):
199
+ """Test get_theme returns ThemeDetail with style params."""
200
+ mock_storage.download_json.side_effect = [sample_metadata, sample_style_params]
201
+ mock_storage.file_exists.return_value = True
202
+ mock_storage.generate_signed_url.return_value = "https://signed-url.com/preview.png"
203
+
204
+ service = ThemeService(storage=mock_storage)
205
+ theme = service.get_theme("nomad")
206
+
207
+ assert theme is not None
208
+ assert theme.id == "nomad"
209
+ assert theme.name == "Nomad Karaoke"
210
+ assert "intro" in theme.style_params
211
+ assert theme.style_params["intro"]["artist_color"] == "#ffdf6b"
212
+
213
+ def test_get_theme_returns_none_for_unknown(self, mock_storage, sample_metadata):
214
+ """Test get_theme returns None for unknown theme ID."""
215
+ mock_storage.download_json.return_value = sample_metadata
216
+
217
+ service = ThemeService(storage=mock_storage)
218
+ theme = service.get_theme("unknown-theme")
219
+
220
+ assert theme is None
221
+
222
+ def test_theme_exists_true(self, mock_storage, sample_metadata):
223
+ """Test theme_exists returns True for existing theme."""
224
+ mock_storage.download_json.return_value = sample_metadata
225
+
226
+ service = ThemeService(storage=mock_storage)
227
+ assert service.theme_exists("nomad") is True
228
+
229
+ def test_theme_exists_false(self, mock_storage, sample_metadata):
230
+ """Test theme_exists returns False for unknown theme."""
231
+ mock_storage.download_json.return_value = sample_metadata
232
+
233
+ service = ThemeService(storage=mock_storage)
234
+ assert service.theme_exists("unknown") is False
235
+
236
+ def test_get_default_theme_id(self, mock_storage, sample_metadata):
237
+ """Test get_default_theme_id returns the default theme."""
238
+ mock_storage.download_json.return_value = sample_metadata
239
+
240
+ service = ThemeService(storage=mock_storage)
241
+ assert service.get_default_theme_id() == "nomad"
242
+
243
+ def test_get_default_theme_id_no_default(self, mock_storage):
244
+ """Test get_default_theme_id returns None when no default."""
245
+ mock_storage.download_json.return_value = {
246
+ "version": 1,
247
+ "themes": [{"id": "test", "name": "Test", "description": "Test", "is_default": False}],
248
+ }
249
+
250
+ service = ThemeService(storage=mock_storage)
251
+ assert service.get_default_theme_id() is None
252
+
253
+ def test_apply_color_overrides_no_changes(self, sample_style_params):
254
+ """Test apply_color_overrides with empty overrides."""
255
+ service = ThemeService()
256
+ overrides = ColorOverrides()
257
+
258
+ result = service.apply_color_overrides(sample_style_params, overrides)
259
+
260
+ # Should return original (no deep copy needed when no changes)
261
+ assert result == sample_style_params
262
+
263
+ def test_apply_color_overrides_artist_color(self, sample_style_params):
264
+ """Test apply_color_overrides applies artist_color."""
265
+ service = ThemeService()
266
+ overrides = ColorOverrides(artist_color="#FF0000")
267
+
268
+ result = service.apply_color_overrides(sample_style_params, overrides)
269
+
270
+ assert result["intro"]["artist_color"] == "#FF0000"
271
+ assert result["end"]["artist_color"] == "#FF0000"
272
+ assert result["cdg"]["artist_color"] == "#FF0000"
273
+ # Original should be unchanged
274
+ assert sample_style_params["intro"]["artist_color"] == "#ffdf6b"
275
+
276
+ def test_apply_color_overrides_sung_lyrics_color(self, sample_style_params):
277
+ """Test apply_color_overrides applies sung_lyrics_color with conversion."""
278
+ service = ThemeService()
279
+ overrides = ColorOverrides(sung_lyrics_color="#FF0000")
280
+
281
+ result = service.apply_color_overrides(sample_style_params, overrides)
282
+
283
+ # Karaoke uses RGBA format
284
+ assert result["karaoke"]["primary_color"] == "255, 0, 0, 255"
285
+ # CDG uses hex
286
+ assert result["cdg"]["active_fill"] == "#FF0000"
287
+
288
+ def test_apply_color_overrides_unsung_lyrics_color(self, sample_style_params):
289
+ """Test apply_color_overrides applies unsung_lyrics_color."""
290
+ service = ThemeService()
291
+ overrides = ColorOverrides(unsung_lyrics_color="#00FF00")
292
+
293
+ result = service.apply_color_overrides(sample_style_params, overrides)
294
+
295
+ assert result["karaoke"]["secondary_color"] == "0, 255, 0, 255"
296
+ assert result["cdg"]["inactive_fill"] == "#00FF00"
297
+
298
+ def test_metadata_cache(self, mock_storage, sample_metadata):
299
+ """Test metadata caching works."""
300
+ mock_storage.download_json.return_value = sample_metadata
301
+
302
+ service = ThemeService(storage=mock_storage)
303
+
304
+ # First call loads from GCS
305
+ service.list_themes()
306
+ assert mock_storage.download_json.call_count == 1
307
+
308
+ # Second call uses cache
309
+ service.list_themes()
310
+ assert mock_storage.download_json.call_count == 1
311
+
312
+ def test_invalidate_cache(self, mock_storage, sample_metadata):
313
+ """Test cache invalidation."""
314
+ mock_storage.download_json.return_value = sample_metadata
315
+
316
+ service = ThemeService(storage=mock_storage)
317
+
318
+ # Load metadata
319
+ service.list_themes()
320
+ assert mock_storage.download_json.call_count == 1
321
+
322
+ # Invalidate cache
323
+ service.invalidate_cache()
324
+
325
+ # Next call should reload
326
+ service.list_themes()
327
+ assert mock_storage.download_json.call_count == 2
328
+
329
+ def test_prepare_job_style(self, mock_storage, sample_metadata, sample_style_params):
330
+ """Test prepare_job_style creates job style from theme."""
331
+ mock_storage.download_json.side_effect = [sample_metadata, sample_style_params]
332
+ mock_storage.file_exists.return_value = True
333
+
334
+ service = ThemeService(storage=mock_storage)
335
+ style_path, style_assets = service.prepare_job_style("job123", "nomad")
336
+
337
+ # Should upload modified style_params.json
338
+ mock_storage.upload_json.assert_called_once()
339
+ assert style_path == "uploads/job123/style/style_params.json"
340
+
341
+ def test_prepare_job_style_with_overrides(self, mock_storage, sample_metadata, sample_style_params):
342
+ """Test prepare_job_style applies color overrides."""
343
+ # prepare_job_style calls get_theme_style_params which directly downloads style_params.json
344
+ # No metadata lookup happens unless we've cached it previously
345
+ mock_storage.download_json.return_value = sample_style_params
346
+ mock_storage.file_exists.return_value = True
347
+
348
+ service = ThemeService(storage=mock_storage)
349
+ overrides = ColorOverrides(artist_color="#FF0000")
350
+
351
+ style_path, style_assets = service.prepare_job_style("job123", "nomad", overrides)
352
+
353
+ # Check that upload_json was called with modified style params
354
+ call_args = mock_storage.upload_json.call_args
355
+ uploaded_path = call_args[0][0] # First positional arg is the path
356
+ uploaded_style = call_args[0][1] # Second positional arg is the data
357
+
358
+ assert uploaded_path == "uploads/job123/style/style_params.json"
359
+ # Check that artist_color override was applied
360
+ assert uploaded_style["intro"]["artist_color"] == "#FF0000"
361
+ assert uploaded_style["end"]["artist_color"] == "#FF0000"
362
+
363
+ def test_prepare_job_style_unknown_theme_raises(self, mock_storage, sample_metadata):
364
+ """Test prepare_job_style raises for unknown theme."""
365
+ # First download_json returns metadata (no unknown theme)
366
+ # Second download_json for style_params.json should fail
367
+ def download_json_side_effect(path):
368
+ if "_metadata.json" in path:
369
+ return sample_metadata
370
+ # Unknown theme path - raise exception
371
+ raise Exception(f"File not found: {path}")
372
+
373
+ mock_storage.download_json.side_effect = download_json_side_effect
374
+
375
+ service = ThemeService(storage=mock_storage)
376
+
377
+ with pytest.raises(ValueError, match="Theme not found"):
378
+ service.prepare_job_style("job123", "unknown-theme")
379
+
380
+ def test_get_youtube_description(self, mock_storage, sample_metadata):
381
+ """Test get_youtube_description returns template text."""
382
+ mock_storage.download_json.return_value = sample_metadata
383
+ mock_storage.file_exists.return_value = True
384
+
385
+ # Mock the download_file to write content to temp file
386
+ def mock_download(gcs_path, local_path):
387
+ with open(local_path, "w") as f:
388
+ f.write("Thank you for watching!")
389
+
390
+ mock_storage.download_file.side_effect = mock_download
391
+
392
+ service = ThemeService(storage=mock_storage)
393
+ desc = service.get_youtube_description("nomad")
394
+
395
+ assert desc == "Thank you for watching!"
396
+
397
+ def test_get_youtube_description_not_found(self, mock_storage, sample_metadata):
398
+ """Test get_youtube_description returns None when no template."""
399
+ mock_storage.download_json.return_value = sample_metadata
400
+ mock_storage.file_exists.return_value = False
401
+
402
+ service = ThemeService(storage=mock_storage)
403
+ desc = service.get_youtube_description("nomad")
404
+
405
+ assert desc is None
406
+
407
+
408
+ # =============================================================================
409
+ # API Endpoint Tests
410
+ # =============================================================================
411
+
412
+ class TestThemeAPI:
413
+ """Tests for theme API endpoints."""
414
+
415
+ @pytest.fixture
416
+ def sample_metadata(self):
417
+ """Sample theme metadata."""
418
+ return {
419
+ "version": 1,
420
+ "themes": [
421
+ {
422
+ "id": "nomad",
423
+ "name": "Nomad Karaoke",
424
+ "description": "Golden artist text",
425
+ "is_default": True,
426
+ }
427
+ ],
428
+ }
429
+
430
+ def test_list_themes_endpoint(self, test_client, sample_metadata):
431
+ """Test GET /api/themes returns theme list."""
432
+ with patch("backend.api.routes.themes.get_theme_service") as mock_get_service:
433
+ mock_service = MagicMock()
434
+ mock_service.list_themes.return_value = [
435
+ ThemeSummary(
436
+ id="nomad",
437
+ name="Nomad Karaoke",
438
+ description="Golden artist text",
439
+ preview_url="https://example.com/preview.png",
440
+ is_default=True,
441
+ )
442
+ ]
443
+ mock_get_service.return_value = mock_service
444
+
445
+ response = test_client.get("/api/themes")
446
+
447
+ assert response.status_code == 200
448
+ data = response.json()
449
+ assert len(data["themes"]) == 1
450
+ assert data["themes"][0]["id"] == "nomad"
451
+ assert data["themes"][0]["is_default"] is True
452
+
453
+ def test_get_theme_endpoint(self, test_client):
454
+ """Test GET /api/themes/{theme_id} returns theme detail."""
455
+ with patch("backend.api.routes.themes.get_theme_service") as mock_get_service:
456
+ mock_service = MagicMock()
457
+ mock_service.get_theme.return_value = ThemeDetail(
458
+ id="nomad",
459
+ name="Nomad Karaoke",
460
+ description="Golden artist text",
461
+ is_default=True,
462
+ style_params={"intro": {"artist_color": "#ffdf6b"}},
463
+ )
464
+ mock_get_service.return_value = mock_service
465
+
466
+ response = test_client.get("/api/themes/nomad")
467
+
468
+ assert response.status_code == 200
469
+ data = response.json()
470
+ assert data["theme"]["id"] == "nomad"
471
+ assert "style_params" in data["theme"]
472
+
473
+ def test_get_theme_not_found(self, test_client):
474
+ """Test GET /api/themes/{theme_id} returns 404 for unknown theme."""
475
+ with patch("backend.api.routes.themes.get_theme_service") as mock_get_service:
476
+ mock_service = MagicMock()
477
+ mock_service.get_theme.return_value = None
478
+ mock_get_service.return_value = mock_service
479
+
480
+ response = test_client.get("/api/themes/unknown")
481
+
482
+ assert response.status_code == 404
483
+
484
+ def test_get_theme_preview_endpoint(self, test_client):
485
+ """Test GET /api/themes/{theme_id}/preview returns preview URL."""
486
+ with patch("backend.api.routes.themes.get_theme_service") as mock_get_service:
487
+ mock_service = MagicMock()
488
+ mock_service.theme_exists.return_value = True
489
+ mock_service.get_theme.return_value = ThemeDetail(
490
+ id="nomad",
491
+ name="Nomad Karaoke",
492
+ description="Golden artist text",
493
+ preview_url="https://example.com/preview.png",
494
+ is_default=True,
495
+ )
496
+ mock_get_service.return_value = mock_service
497
+
498
+ response = test_client.get("/api/themes/nomad/preview")
499
+
500
+ assert response.status_code == 200
501
+ data = response.json()
502
+ assert "preview_url" in data
503
+
504
+ def test_get_youtube_description_endpoint(self, test_client):
505
+ """Test GET /api/themes/{theme_id}/youtube-description returns description."""
506
+ with patch("backend.api.routes.themes.get_theme_service") as mock_get_service:
507
+ mock_service = MagicMock()
508
+ mock_service.theme_exists.return_value = True
509
+ mock_service.get_youtube_description.return_value = "Thank you for watching!"
510
+ mock_get_service.return_value = mock_service
511
+
512
+ response = test_client.get("/api/themes/nomad/youtube-description")
513
+
514
+ assert response.status_code == 200
515
+ data = response.json()
516
+ assert data["description"] == "Thank you for watching!"