karaoke-gen 0.86.7__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 (188) 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/style_loader.py +3 -1
  148. karaoke_gen/utils/__init__.py +163 -8
  149. karaoke_gen/video_background_processor.py +9 -4
  150. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
  151. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
  152. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  153. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  154. lyrics_transcriber/correction/corrector.py +192 -130
  155. lyrics_transcriber/correction/operations.py +24 -9
  156. lyrics_transcriber/frontend/package-lock.json +2 -2
  157. lyrics_transcriber/frontend/package.json +1 -1
  158. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  159. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  160. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  161. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  162. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  163. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  164. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  165. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  168. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  170. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  171. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  172. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  173. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  174. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  175. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  176. lyrics_transcriber/frontend/src/theme.ts +42 -15
  177. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  178. lyrics_transcriber/frontend/vite.config.js +5 -0
  179. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  180. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  181. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  182. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  183. lyrics_transcriber/output/generator.py +17 -3
  184. lyrics_transcriber/output/video.py +60 -95
  185. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  186. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  187. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  188. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,820 @@
1
+ """
2
+ Tests for native distribution services (Dropbox and Google Drive).
3
+
4
+ These tests verify the service interfaces without requiring actual
5
+ cloud credentials, using mocks to simulate API responses.
6
+ """
7
+ import json
8
+ import os
9
+ import pytest
10
+ from unittest.mock import MagicMock, patch, mock_open
11
+
12
+
13
+ class TestDropboxService:
14
+ """Tests for the DropboxService class."""
15
+
16
+ def test_module_imports(self):
17
+ """Test that the module imports correctly."""
18
+ from backend.services.dropbox_service import DropboxService, get_dropbox_service
19
+ assert DropboxService is not None
20
+ assert get_dropbox_service is not None
21
+
22
+ def test_init_creates_instance(self):
23
+ """Test that we can create a DropboxService instance."""
24
+ from backend.services.dropbox_service import DropboxService
25
+ service = DropboxService()
26
+ assert service is not None
27
+ assert service._client is None # Client not initialized until first use
28
+ assert service._is_configured is False
29
+
30
+ def test_is_configured_returns_false_without_credentials(self):
31
+ """Test that is_configured returns False when credentials are missing."""
32
+ from backend.services.dropbox_service import DropboxService
33
+
34
+ with patch.object(DropboxService, '_load_credentials', return_value=None):
35
+ service = DropboxService()
36
+ assert service.is_configured is False
37
+
38
+ def test_is_configured_returns_true_with_credentials(self):
39
+ """Test that is_configured returns True when credentials are present."""
40
+ from backend.services.dropbox_service import DropboxService
41
+
42
+ mock_creds = {"access_token": "test_token"}
43
+ with patch.object(DropboxService, '_load_credentials', return_value=mock_creds):
44
+ service = DropboxService()
45
+ assert service.is_configured is True
46
+
47
+ def test_is_configured_caches_result(self):
48
+ """Test that is_configured caches the result."""
49
+ from backend.services.dropbox_service import DropboxService
50
+
51
+ service = DropboxService()
52
+ service._is_configured = True
53
+ # Should return True without calling _load_credentials
54
+ assert service.is_configured is True
55
+
56
+ def test_is_configured_returns_false_without_access_token(self):
57
+ """Test that is_configured returns False when access_token is missing."""
58
+ from backend.services.dropbox_service import DropboxService
59
+
60
+ mock_creds = {"refresh_token": "test"} # No access_token
61
+ with patch.object(DropboxService, '_load_credentials', return_value=mock_creds):
62
+ service = DropboxService()
63
+ assert service.is_configured is False
64
+
65
+ def test_get_next_brand_code_first_track(self):
66
+ """Test brand code calculation when no existing tracks."""
67
+ from backend.services.dropbox_service import DropboxService
68
+
69
+ service = DropboxService()
70
+ with patch.object(service, 'list_folders', return_value=[]):
71
+ brand_code = service.get_next_brand_code("/test/path", "NOMAD")
72
+ assert brand_code == "NOMAD-0001"
73
+
74
+ def test_get_next_brand_code_sequential(self):
75
+ """Test brand code calculation with existing tracks."""
76
+ from backend.services.dropbox_service import DropboxService
77
+
78
+ service = DropboxService()
79
+ existing_folders = [
80
+ "NOMAD-0001 - Artist1 - Song1",
81
+ "NOMAD-0002 - Artist2 - Song2",
82
+ "NOMAD-0005 - Artist3 - Song3", # Gap in sequence
83
+ "OTHER-0001 - Different Brand", # Different brand
84
+ ]
85
+ with patch.object(service, 'list_folders', return_value=existing_folders):
86
+ brand_code = service.get_next_brand_code("/test/path", "NOMAD")
87
+ assert brand_code == "NOMAD-0006"
88
+
89
+ def test_get_next_brand_code_different_prefix(self):
90
+ """Test brand code calculation with different brand prefix."""
91
+ from backend.services.dropbox_service import DropboxService
92
+
93
+ service = DropboxService()
94
+ existing_folders = [
95
+ "NOMAD-0001 - Artist1 - Song1",
96
+ "TEST-0010 - Artist2 - Song2",
97
+ ]
98
+ with patch.object(service, 'list_folders', return_value=existing_folders):
99
+ brand_code = service.get_next_brand_code("/test/path", "TEST")
100
+ assert brand_code == "TEST-0011"
101
+
102
+ def test_factory_function_returns_instance(self):
103
+ """Test that get_dropbox_service returns a service instance."""
104
+ from backend.services.dropbox_service import get_dropbox_service
105
+
106
+ service = get_dropbox_service()
107
+ assert service is not None
108
+
109
+ def test_load_credentials_handles_exception(self):
110
+ """Test that _load_credentials handles exceptions gracefully."""
111
+ from backend.services.dropbox_service import DropboxService
112
+
113
+ with patch('backend.services.dropbox_service.secretmanager.SecretManagerServiceClient') as mock_client:
114
+ mock_client.return_value.access_secret_version.side_effect = Exception("API Error")
115
+ service = DropboxService()
116
+ result = service._load_credentials()
117
+ assert result is None
118
+
119
+ def test_load_credentials_parses_json(self):
120
+ """Test that _load_credentials correctly parses JSON credentials."""
121
+ from backend.services.dropbox_service import DropboxService
122
+
123
+ mock_creds = {"access_token": "test_token", "refresh_token": "test_refresh"}
124
+ mock_response = MagicMock()
125
+ mock_response.payload.data.decode.return_value = json.dumps(mock_creds)
126
+
127
+ with patch('backend.services.dropbox_service.secretmanager.SecretManagerServiceClient') as mock_client:
128
+ mock_client.return_value.access_secret_version.return_value = mock_response
129
+ service = DropboxService()
130
+ result = service._load_credentials()
131
+ assert result == mock_creds
132
+
133
+ def test_client_property_raises_without_credentials(self):
134
+ """Test that client property raises error without credentials."""
135
+ from backend.services.dropbox_service import DropboxService
136
+
137
+ with patch.object(DropboxService, '_load_credentials', return_value=None):
138
+ service = DropboxService()
139
+ with pytest.raises(RuntimeError, match="credentials not configured"):
140
+ _ = service.client
141
+
142
+ def test_client_property_raises_import_error(self):
143
+ """Test that client property raises helpful error if dropbox not installed."""
144
+ from backend.services.dropbox_service import DropboxService
145
+ import sys
146
+
147
+ mock_creds = {"access_token": "test_token"}
148
+ with patch.object(DropboxService, '_load_credentials', return_value=mock_creds):
149
+ with patch.dict(sys.modules, {'dropbox': None}):
150
+ service = DropboxService()
151
+ # Force reimport to fail
152
+ with patch('builtins.__import__', side_effect=ImportError("No module named 'dropbox'")):
153
+ with pytest.raises(ImportError, match="dropbox package"):
154
+ _ = service.client
155
+
156
+ def test_list_folders_adds_leading_slash(self):
157
+ """Test that list_folders adds leading slash if missing."""
158
+ from backend.services.dropbox_service import DropboxService
159
+
160
+ service = DropboxService()
161
+ mock_client = MagicMock()
162
+ mock_result = MagicMock()
163
+ mock_result.entries = []
164
+ mock_result.has_more = False
165
+ mock_client.files_list_folder.return_value = mock_result
166
+ service._client = mock_client
167
+
168
+ service.list_folders("test/path") # No leading slash
169
+
170
+ # Verify it was called with leading slash
171
+ mock_client.files_list_folder.assert_called_with("/test/path")
172
+
173
+ def test_upload_file_adds_leading_slash(self):
174
+ """Test that upload_file adds leading slash to remote path."""
175
+ from backend.services.dropbox_service import DropboxService
176
+ import tempfile
177
+
178
+ service = DropboxService()
179
+ mock_client = MagicMock()
180
+ service._client = mock_client
181
+
182
+ # Create a small temp file
183
+ with tempfile.NamedTemporaryFile(delete=False, mode='wb') as f:
184
+ f.write(b"test content")
185
+ temp_path = f.name
186
+
187
+ try:
188
+ service.upload_file(temp_path, "remote/path.txt") # No leading slash
189
+ # Verify upload was called with leading slash
190
+ mock_client.files_upload.assert_called_once()
191
+ call_args = mock_client.files_upload.call_args
192
+ assert call_args[0][1] == "/remote/path.txt"
193
+ finally:
194
+ os.unlink(temp_path)
195
+
196
+ def test_upload_folder_uploads_all_files(self):
197
+ """Test that upload_folder uploads all files in directory."""
198
+ from backend.services.dropbox_service import DropboxService
199
+ import tempfile
200
+
201
+ service = DropboxService()
202
+
203
+ with tempfile.TemporaryDirectory() as tmpdir:
204
+ # Create some test files
205
+ (open(os.path.join(tmpdir, "file1.txt"), "w")).write("content1")
206
+ (open(os.path.join(tmpdir, "file2.txt"), "w")).write("content2")
207
+
208
+ with patch.object(service, 'upload_file') as mock_upload:
209
+ service.upload_folder(tmpdir, "/remote/folder")
210
+
211
+ assert mock_upload.call_count == 2
212
+
213
+ def test_create_shared_link_success(self):
214
+ """Test successful shared link creation."""
215
+ from backend.services.dropbox_service import DropboxService
216
+
217
+ service = DropboxService()
218
+ mock_client = MagicMock()
219
+ mock_link = MagicMock()
220
+ mock_link.url = "https://dropbox.com/shared/test"
221
+ mock_client.sharing_create_shared_link_with_settings.return_value = mock_link
222
+ service._client = mock_client
223
+
224
+ result = service.create_shared_link("/test/path")
225
+ assert result == "https://dropbox.com/shared/test"
226
+
227
+ def test_sharing_list_shared_links_mock_setup(self):
228
+ """Test that mock setup for sharing_list_shared_links works correctly.
229
+
230
+ Note: Properly mocking Dropbox's ApiError is complex because it
231
+ requires specific exception class structure. This test verifies
232
+ the mock configuration is correct for the success path, which is
233
+ a prerequisite for more complex error-handling tests.
234
+ """
235
+ from backend.services.dropbox_service import DropboxService
236
+
237
+ service = DropboxService()
238
+ mock_client = MagicMock()
239
+
240
+ # Mock the existing link retrieval (success case)
241
+ mock_existing_link = MagicMock()
242
+ mock_existing_link.url = "https://dropbox.com/existing/link"
243
+ mock_links_result = MagicMock()
244
+ mock_links_result.links = [mock_existing_link]
245
+ mock_client.sharing_list_shared_links.return_value = mock_links_result
246
+
247
+ # Assign the mock client to the service
248
+ service._client = mock_client
249
+
250
+ # Verify the mock returns the expected link structure
251
+ result = mock_client.sharing_list_shared_links(path="/test/path")
252
+ assert result.links[0].url == "https://dropbox.com/existing/link"
253
+ mock_client.sharing_list_shared_links.assert_called_once_with(path="/test/path")
254
+
255
+
256
+ class TestGoogleDriveService:
257
+ """Tests for the GoogleDriveService class."""
258
+
259
+ def test_module_imports(self):
260
+ """Test that the module imports correctly."""
261
+ from backend.services.gdrive_service import GoogleDriveService, get_gdrive_service
262
+ assert GoogleDriveService is not None
263
+ assert get_gdrive_service is not None
264
+
265
+ def test_init_creates_instance(self):
266
+ """Test that we can create a GoogleDriveService instance."""
267
+ from backend.services.gdrive_service import GoogleDriveService
268
+ service = GoogleDriveService()
269
+ assert service is not None
270
+ assert service._service is None # Service not initialized until first use
271
+ assert service._loaded is False
272
+
273
+ def test_is_configured_returns_false_without_credentials(self):
274
+ """Test that is_configured returns False when credentials are missing."""
275
+ from backend.services.gdrive_service import GoogleDriveService
276
+
277
+ with patch.object(GoogleDriveService, '_load_credentials', return_value=None):
278
+ service = GoogleDriveService()
279
+ assert service.is_configured is False
280
+
281
+ def test_is_configured_returns_true_with_credentials(self):
282
+ """Test that is_configured returns True when credentials are present."""
283
+ from backend.services.gdrive_service import GoogleDriveService
284
+
285
+ mock_creds = {
286
+ "token": "test_token",
287
+ "refresh_token": "test_refresh",
288
+ "client_id": "test_client_id",
289
+ "client_secret": "test_client_secret",
290
+ }
291
+ with patch.object(GoogleDriveService, '_load_credentials', return_value=mock_creds):
292
+ service = GoogleDriveService()
293
+ assert service.is_configured is True
294
+
295
+ def test_factory_function_returns_singleton(self):
296
+ """Test that get_gdrive_service returns a singleton."""
297
+ from backend.services.gdrive_service import get_gdrive_service, _gdrive_service
298
+
299
+ # Reset singleton
300
+ import backend.services.gdrive_service as gdrive_module
301
+ gdrive_module._gdrive_service = None
302
+
303
+ service1 = get_gdrive_service()
304
+ service2 = get_gdrive_service()
305
+ assert service1 is service2
306
+
307
+ def test_load_credentials_caches_result(self):
308
+ """Test that _load_credentials caches the result."""
309
+ from backend.services.gdrive_service import GoogleDriveService
310
+
311
+ service = GoogleDriveService()
312
+ service._loaded = True
313
+ service._credentials_data = {"cached": "data"}
314
+
315
+ # Should return cached data without calling settings
316
+ result = service._load_credentials()
317
+ assert result == {"cached": "data"}
318
+
319
+ def test_load_credentials_falls_back_to_youtube(self):
320
+ """Test that _load_credentials falls back to YouTube credentials."""
321
+ from backend.services.gdrive_service import GoogleDriveService
322
+
323
+ mock_settings = MagicMock()
324
+ mock_settings.get_secret.side_effect = [
325
+ None, # gdrive-oauth-credentials not found
326
+ '{"refresh_token": "yt_refresh", "client_id": "yt_client", "client_secret": "yt_secret"}', # youtube-oauth-credentials
327
+ ]
328
+
329
+ service = GoogleDriveService()
330
+ service.settings = mock_settings
331
+
332
+ result = service._load_credentials()
333
+ assert result is not None
334
+ assert result["refresh_token"] == "yt_refresh"
335
+
336
+ def test_load_credentials_returns_none_when_both_fail(self):
337
+ """Test that _load_credentials returns None when no credentials available."""
338
+ from backend.services.gdrive_service import GoogleDriveService
339
+
340
+ mock_settings = MagicMock()
341
+ mock_settings.get_secret.return_value = None
342
+
343
+ service = GoogleDriveService()
344
+ service.settings = mock_settings
345
+
346
+ result = service._load_credentials()
347
+ assert result is None
348
+ assert service._loaded is True
349
+
350
+ def test_load_credentials_validates_required_fields(self):
351
+ """Test that _load_credentials validates required fields."""
352
+ from backend.services.gdrive_service import GoogleDriveService
353
+
354
+ mock_settings = MagicMock()
355
+ # Missing client_secret
356
+ mock_settings.get_secret.return_value = '{"refresh_token": "test", "client_id": "test"}'
357
+
358
+ service = GoogleDriveService()
359
+ service.settings = mock_settings
360
+
361
+ result = service._load_credentials()
362
+ assert result is None # Should fail validation
363
+
364
+ def test_load_credentials_handles_json_error(self):
365
+ """Test that _load_credentials handles JSON parse errors."""
366
+ from backend.services.gdrive_service import GoogleDriveService
367
+
368
+ mock_settings = MagicMock()
369
+ mock_settings.get_secret.return_value = "not valid json"
370
+
371
+ service = GoogleDriveService()
372
+ service.settings = mock_settings
373
+
374
+ result = service._load_credentials()
375
+ assert result is None
376
+
377
+ def test_service_property_raises_without_credentials(self):
378
+ """Test that service property raises error without credentials."""
379
+ from backend.services.gdrive_service import GoogleDriveService
380
+
381
+ with patch.object(GoogleDriveService, '_load_credentials', return_value=None):
382
+ service = GoogleDriveService()
383
+ with pytest.raises(RuntimeError, match="credentials not configured"):
384
+ _ = service.service
385
+
386
+ def test_get_or_create_folder_finds_existing(self):
387
+ """Test that get_or_create_folder finds existing folders."""
388
+ from backend.services.gdrive_service import GoogleDriveService
389
+
390
+ service = GoogleDriveService()
391
+ mock_service = MagicMock()
392
+ mock_files = MagicMock()
393
+ mock_list = MagicMock()
394
+ mock_list.execute.return_value = {"files": [{"id": "existing_folder_id", "name": "TestFolder"}]}
395
+ mock_files.list.return_value = mock_list
396
+ mock_service.files.return_value = mock_files
397
+ service._service = mock_service
398
+
399
+ result = service.get_or_create_folder("parent_id", "TestFolder")
400
+ assert result == "existing_folder_id"
401
+
402
+ def test_get_or_create_folder_creates_new(self):
403
+ """Test that get_or_create_folder creates new folders when not found."""
404
+ from backend.services.gdrive_service import GoogleDriveService
405
+
406
+ service = GoogleDriveService()
407
+ mock_service = MagicMock()
408
+ mock_files = MagicMock()
409
+
410
+ # First call - folder doesn't exist
411
+ mock_list = MagicMock()
412
+ mock_list.execute.return_value = {"files": []}
413
+ mock_files.list.return_value = mock_list
414
+
415
+ # Second call - create folder
416
+ mock_create = MagicMock()
417
+ mock_create.execute.return_value = {"id": "new_folder_id"}
418
+ mock_files.create.return_value = mock_create
419
+
420
+ mock_service.files.return_value = mock_files
421
+ service._service = mock_service
422
+
423
+ result = service.get_or_create_folder("parent_id", "NewFolder")
424
+ assert result == "new_folder_id"
425
+
426
+ def test_upload_file_determines_mime_type(self):
427
+ """Test that upload_file determines correct MIME type."""
428
+ from backend.services.gdrive_service import GoogleDriveService
429
+ import tempfile
430
+
431
+ service = GoogleDriveService()
432
+ mock_service = MagicMock()
433
+ mock_files = MagicMock()
434
+
435
+ # Mock list (for replace_existing check)
436
+ mock_list = MagicMock()
437
+ mock_list.execute.return_value = {"files": []}
438
+ mock_files.list.return_value = mock_list
439
+
440
+ # Mock create
441
+ mock_create = MagicMock()
442
+ mock_create.execute.return_value = {"id": "uploaded_file_id"}
443
+ mock_files.create.return_value = mock_create
444
+
445
+ mock_service.files.return_value = mock_files
446
+ service._service = mock_service
447
+
448
+ # Create a temp file with .mp4 extension
449
+ with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as f:
450
+ f.write(b"fake video content")
451
+ temp_path = f.name
452
+
453
+ try:
454
+ result = service.upload_file(temp_path, "parent_id", "test.mp4")
455
+ assert result == "uploaded_file_id"
456
+ finally:
457
+ os.unlink(temp_path)
458
+
459
+ def test_upload_file_replaces_existing(self):
460
+ """Test that upload_file can replace existing files."""
461
+ from backend.services.gdrive_service import GoogleDriveService
462
+ import tempfile
463
+
464
+ service = GoogleDriveService()
465
+ mock_service = MagicMock()
466
+ mock_files = MagicMock()
467
+
468
+ # Mock list - file exists
469
+ mock_list = MagicMock()
470
+ mock_list.execute.return_value = {"files": [{"id": "existing_id"}]}
471
+ mock_files.list.return_value = mock_list
472
+
473
+ # Mock delete
474
+ mock_delete = MagicMock()
475
+ mock_files.delete.return_value = mock_delete
476
+
477
+ # Mock create
478
+ mock_create = MagicMock()
479
+ mock_create.execute.return_value = {"id": "new_file_id"}
480
+ mock_files.create.return_value = mock_create
481
+
482
+ mock_service.files.return_value = mock_files
483
+ service._service = mock_service
484
+
485
+ with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as f:
486
+ f.write(b"content")
487
+ temp_path = f.name
488
+
489
+ try:
490
+ result = service.upload_file(temp_path, "parent_id", "test.txt", replace_existing=True)
491
+ assert result == "new_file_id"
492
+ mock_files.delete.assert_called_once()
493
+ finally:
494
+ os.unlink(temp_path)
495
+
496
+ def test_upload_to_public_share_creates_structure(self):
497
+ """Test that upload_to_public_share creates folder structure."""
498
+ from backend.services.gdrive_service import GoogleDriveService
499
+ import tempfile
500
+
501
+ service = GoogleDriveService()
502
+
503
+ with tempfile.TemporaryDirectory() as tmpdir:
504
+ # Create test files
505
+ mp4_path = os.path.join(tmpdir, "test.mp4")
506
+ mp4_720_path = os.path.join(tmpdir, "test_720p.mp4")
507
+ cdg_path = os.path.join(tmpdir, "test.zip")
508
+
509
+ open(mp4_path, 'wb').write(b"mp4 content")
510
+ open(mp4_720_path, 'wb').write(b"720p content")
511
+ open(cdg_path, 'wb').write(b"cdg content")
512
+
513
+ with patch.object(service, 'get_or_create_folder', return_value="folder_id"):
514
+ with patch.object(service, 'upload_file', return_value="file_id"):
515
+ result = service.upload_to_public_share(
516
+ root_folder_id="root_id",
517
+ brand_code="TEST-0001",
518
+ base_name="Artist - Title",
519
+ output_files={
520
+ "final_karaoke_lossy_mp4": mp4_path,
521
+ "final_karaoke_lossy_720p_mp4": mp4_720_path,
522
+ "final_karaoke_cdg_zip": cdg_path,
523
+ }
524
+ )
525
+
526
+ assert "mp4" in result
527
+ assert "mp4_720p" in result
528
+ assert "cdg" in result
529
+
530
+ def test_upload_to_public_share_handles_missing_files(self):
531
+ """Test that upload_to_public_share handles missing files gracefully."""
532
+ from backend.services.gdrive_service import GoogleDriveService
533
+
534
+ service = GoogleDriveService()
535
+
536
+ with patch.object(service, 'get_or_create_folder', return_value="folder_id"):
537
+ with patch.object(service, 'upload_file', return_value="file_id"):
538
+ result = service.upload_to_public_share(
539
+ root_folder_id="root_id",
540
+ brand_code="TEST-0001",
541
+ base_name="Artist - Title",
542
+ output_files={
543
+ "final_karaoke_lossy_mp4": "/nonexistent/path.mp4",
544
+ }
545
+ )
546
+
547
+ # Should return empty since file doesn't exist
548
+ assert result == {}
549
+
550
+
551
+ class TestJobModelDistributionFields:
552
+ """Tests for distribution fields in Job model."""
553
+
554
+ def test_job_model_has_dropbox_path_field(self):
555
+ """Test that Job model has dropbox_path field."""
556
+ from backend.models.job import Job, JobStatus
557
+ from datetime import datetime
558
+
559
+ job = Job(
560
+ job_id="test-123",
561
+ status=JobStatus.PENDING,
562
+ created_at=datetime.now(),
563
+ updated_at=datetime.now(),
564
+ dropbox_path="/Karaoke/Tracks-Organized",
565
+ )
566
+ assert job.dropbox_path == "/Karaoke/Tracks-Organized"
567
+
568
+ def test_job_model_has_gdrive_folder_id_field(self):
569
+ """Test that Job model has gdrive_folder_id field."""
570
+ from backend.models.job import Job, JobStatus
571
+ from datetime import datetime
572
+
573
+ job = Job(
574
+ job_id="test-123",
575
+ status=JobStatus.PENDING,
576
+ created_at=datetime.now(),
577
+ updated_at=datetime.now(),
578
+ gdrive_folder_id="1abc123xyz",
579
+ )
580
+ assert job.gdrive_folder_id == "1abc123xyz"
581
+
582
+ def test_job_model_distribution_fields_optional(self):
583
+ """Test that distribution fields are optional."""
584
+ from backend.models.job import Job, JobStatus
585
+ from datetime import datetime
586
+
587
+ job = Job(
588
+ job_id="test-123",
589
+ status=JobStatus.PENDING,
590
+ created_at=datetime.now(),
591
+ updated_at=datetime.now(),
592
+ )
593
+ assert job.dropbox_path is None
594
+ assert job.gdrive_folder_id is None
595
+
596
+
597
+ class TestJobCreateDistributionFields:
598
+ """Tests for distribution fields in JobCreate model."""
599
+
600
+ def test_job_create_has_dropbox_path_field(self):
601
+ """Test that JobCreate model has dropbox_path field."""
602
+ from backend.models.job import JobCreate
603
+
604
+ job_create = JobCreate(
605
+ artist="Test Artist",
606
+ title="Test Title",
607
+ dropbox_path="/Karaoke/Tracks-Organized",
608
+ )
609
+ assert job_create.dropbox_path == "/Karaoke/Tracks-Organized"
610
+
611
+ def test_job_create_has_gdrive_folder_id_field(self):
612
+ """Test that JobCreate model has gdrive_folder_id field."""
613
+ from backend.models.job import JobCreate
614
+
615
+ job_create = JobCreate(
616
+ artist="Test Artist",
617
+ title="Test Title",
618
+ gdrive_folder_id="1abc123xyz",
619
+ )
620
+ assert job_create.gdrive_folder_id == "1abc123xyz"
621
+
622
+ def test_job_create_distribution_fields_optional(self):
623
+ """Test that distribution fields are optional in JobCreate."""
624
+ from backend.models.job import JobCreate
625
+
626
+ job_create = JobCreate(
627
+ artist="Test Artist",
628
+ title="Test Title",
629
+ )
630
+ assert job_create.dropbox_path is None
631
+ assert job_create.gdrive_folder_id is None
632
+
633
+
634
+ class TestFileUploadDistributionParams:
635
+ """Tests for distribution parameters in file upload endpoint."""
636
+
637
+ def test_endpoint_has_dropbox_path_parameter(self):
638
+ """Test that upload endpoint signature includes dropbox_path parameter."""
639
+ import inspect
640
+ from backend.api.routes.file_upload import upload_and_create_job
641
+
642
+ sig = inspect.signature(upload_and_create_job)
643
+ param_names = list(sig.parameters.keys())
644
+
645
+ assert "dropbox_path" in param_names
646
+
647
+ def test_endpoint_has_gdrive_folder_id_parameter(self):
648
+ """Test that upload endpoint signature includes gdrive_folder_id parameter."""
649
+ import inspect
650
+ from backend.api.routes.file_upload import upload_and_create_job
651
+
652
+ sig = inspect.signature(upload_and_create_job)
653
+ param_names = list(sig.parameters.keys())
654
+
655
+ assert "gdrive_folder_id" in param_names
656
+
657
+ def test_endpoint_dropbox_path_is_optional(self):
658
+ """Test that dropbox_path parameter has a default value (Form(None))."""
659
+ import inspect
660
+ from backend.api.routes.file_upload import upload_and_create_job
661
+
662
+ sig = inspect.signature(upload_and_create_job)
663
+ param = sig.parameters.get("dropbox_path")
664
+
665
+ assert param is not None
666
+ # Default is Form(None), so check that it's not required
667
+ assert param.default is not inspect.Parameter.empty
668
+
669
+ def test_endpoint_gdrive_folder_id_is_optional(self):
670
+ """Test that gdrive_folder_id parameter has a default value (Form(None))."""
671
+ import inspect
672
+ from backend.api.routes.file_upload import upload_and_create_job
673
+
674
+ sig = inspect.signature(upload_and_create_job)
675
+ param = sig.parameters.get("gdrive_folder_id")
676
+
677
+ assert param is not None
678
+ # Default is Form(None), so check that it's not required
679
+ assert param.default is not inspect.Parameter.empty
680
+
681
+
682
+ class TestVideoWorkerDistribution:
683
+ """Tests for distribution handling in video worker."""
684
+
685
+ def test_handle_native_distribution_function_exists(self):
686
+ """Test that _handle_native_distribution function exists."""
687
+ from backend.workers import video_worker
688
+ assert hasattr(video_worker, '_handle_native_distribution')
689
+
690
+ def test_video_worker_imports_distribution_services(self):
691
+ """Test that video worker can import distribution services."""
692
+ # The services should be importable (even if credentials aren't available)
693
+ try:
694
+ from backend.services.dropbox_service import DropboxService
695
+ from backend.services.gdrive_service import GoogleDriveService
696
+ assert True
697
+ except ImportError as e:
698
+ pytest.fail(f"Failed to import distribution services: {e}")
699
+
700
+ @pytest.mark.asyncio
701
+ async def test_handle_native_distribution_skips_without_config(self):
702
+ """Test that _handle_native_distribution skips when not configured."""
703
+ from backend.workers.video_worker import _handle_native_distribution
704
+
705
+ mock_job = MagicMock()
706
+ mock_job.dropbox_path = None
707
+ mock_job.gdrive_folder_id = None
708
+ mock_job.brand_prefix = None
709
+ mock_job.artist = "Test"
710
+ mock_job.title = "Song"
711
+
712
+ mock_job_log = MagicMock()
713
+ mock_job_manager = MagicMock()
714
+
715
+ # Should complete without error
716
+ await _handle_native_distribution(
717
+ job_id="test-123",
718
+ job=mock_job,
719
+ job_log=mock_job_log,
720
+ job_manager=mock_job_manager,
721
+ temp_dir="/tmp/test",
722
+ result={},
723
+ )
724
+
725
+ # No errors should have been logged
726
+ mock_job_log.error.assert_not_called()
727
+
728
+ @pytest.mark.asyncio
729
+ async def test_handle_native_distribution_dropbox_not_configured(self):
730
+ """Test Dropbox upload skipped when service not configured."""
731
+ from backend.workers.video_worker import _handle_native_distribution
732
+
733
+ mock_job = MagicMock()
734
+ mock_job.dropbox_path = "/test/path"
735
+ mock_job.brand_prefix = "TEST"
736
+ mock_job.gdrive_folder_id = None
737
+ mock_job.artist = "Test"
738
+ mock_job.title = "Song"
739
+ mock_job.state_data = {}
740
+
741
+ mock_job_log = MagicMock()
742
+ mock_job_manager = MagicMock()
743
+
744
+ mock_dropbox = MagicMock()
745
+ mock_dropbox.is_configured = False
746
+
747
+ with patch('backend.services.dropbox_service.get_dropbox_service', return_value=mock_dropbox):
748
+ await _handle_native_distribution(
749
+ job_id="test-123",
750
+ job=mock_job,
751
+ job_log=mock_job_log,
752
+ job_manager=mock_job_manager,
753
+ temp_dir="/tmp/test",
754
+ result={},
755
+ )
756
+
757
+ # Should log warning about not configured
758
+ mock_job_log.warning.assert_called()
759
+
760
+ @pytest.mark.asyncio
761
+ async def test_handle_native_distribution_gdrive_not_configured(self):
762
+ """Test Google Drive upload skipped when service not configured."""
763
+ from backend.workers.video_worker import _handle_native_distribution
764
+
765
+ mock_job = MagicMock()
766
+ mock_job.dropbox_path = None
767
+ mock_job.brand_prefix = None
768
+ mock_job.gdrive_folder_id = "test_folder_id"
769
+ mock_job.artist = "Test"
770
+ mock_job.title = "Song"
771
+ mock_job.state_data = {}
772
+
773
+ mock_job_log = MagicMock()
774
+ mock_job_manager = MagicMock()
775
+
776
+ mock_gdrive = MagicMock()
777
+ mock_gdrive.is_configured = False
778
+
779
+ with patch('backend.services.gdrive_service.get_gdrive_service', return_value=mock_gdrive):
780
+ await _handle_native_distribution(
781
+ job_id="test-123",
782
+ job=mock_job,
783
+ job_log=mock_job_log,
784
+ job_manager=mock_job_manager,
785
+ temp_dir="/tmp/test",
786
+ result={},
787
+ )
788
+
789
+ # Should log warning about not configured
790
+ mock_job_log.warning.assert_called()
791
+
792
+ @pytest.mark.asyncio
793
+ async def test_handle_native_distribution_handles_import_error(self):
794
+ """Test that import errors for services are handled gracefully."""
795
+ from backend.workers.video_worker import _handle_native_distribution
796
+
797
+ mock_job = MagicMock()
798
+ mock_job.dropbox_path = "/test/path"
799
+ mock_job.brand_prefix = "TEST"
800
+ mock_job.gdrive_folder_id = None
801
+ mock_job.artist = "Test"
802
+ mock_job.title = "Song"
803
+ mock_job.state_data = {}
804
+
805
+ mock_job_log = MagicMock()
806
+ mock_job_manager = MagicMock()
807
+
808
+ # Simulate import error
809
+ with patch('backend.services.dropbox_service.get_dropbox_service', side_effect=ImportError("No module")):
810
+ await _handle_native_distribution(
811
+ job_id="test-123",
812
+ job=mock_job,
813
+ job_log=mock_job_log,
814
+ job_manager=mock_job_manager,
815
+ temp_dir="/tmp/test",
816
+ result={},
817
+ )
818
+
819
+ # Should log warning about import error
820
+ mock_job_log.warning.assert_called()