karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__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 (197) 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 +835 -0
  11. backend/api/routes/audio_search.py +913 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2112 -0
  14. backend/api/routes/health.py +409 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1629 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1513 -0
  20. backend/config.py +172 -0
  21. backend/main.py +157 -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 +502 -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 +853 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/langfuse_preloader.py +98 -0
  56. backend/services/local_encoding_service.py +590 -0
  57. backend/services/local_preview_encoding_service.py +407 -0
  58. backend/services/lyrics_cache_service.py +216 -0
  59. backend/services/metrics.py +413 -0
  60. backend/services/nltk_preloader.py +122 -0
  61. backend/services/packaging_service.py +287 -0
  62. backend/services/rclone_service.py +106 -0
  63. backend/services/spacy_preloader.py +65 -0
  64. backend/services/storage_service.py +209 -0
  65. backend/services/stripe_service.py +371 -0
  66. backend/services/structured_logging.py +254 -0
  67. backend/services/template_service.py +330 -0
  68. backend/services/theme_service.py +469 -0
  69. backend/services/tracing.py +543 -0
  70. backend/services/user_service.py +721 -0
  71. backend/services/worker_service.py +558 -0
  72. backend/services/youtube_service.py +112 -0
  73. backend/services/youtube_upload_service.py +445 -0
  74. backend/tests/__init__.py +4 -0
  75. backend/tests/conftest.py +224 -0
  76. backend/tests/emulator/__init__.py +7 -0
  77. backend/tests/emulator/conftest.py +109 -0
  78. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  79. backend/tests/emulator/test_emulator_integration.py +356 -0
  80. backend/tests/emulator/test_style_loading_direct.py +436 -0
  81. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  82. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  83. backend/tests/requirements-test.txt +10 -0
  84. backend/tests/requirements.txt +6 -0
  85. backend/tests/test_admin_email_endpoints.py +411 -0
  86. backend/tests/test_api_integration.py +460 -0
  87. backend/tests/test_api_routes.py +93 -0
  88. backend/tests/test_audio_analysis_service.py +294 -0
  89. backend/tests/test_audio_editing_service.py +386 -0
  90. backend/tests/test_audio_search.py +1398 -0
  91. backend/tests/test_audio_services.py +378 -0
  92. backend/tests/test_auth_firestore.py +231 -0
  93. backend/tests/test_config_extended.py +68 -0
  94. backend/tests/test_credential_manager.py +377 -0
  95. backend/tests/test_dependencies.py +54 -0
  96. backend/tests/test_discord_service.py +244 -0
  97. backend/tests/test_distribution_services.py +820 -0
  98. backend/tests/test_dropbox_service.py +472 -0
  99. backend/tests/test_email_service.py +492 -0
  100. backend/tests/test_emulator_integration.py +322 -0
  101. backend/tests/test_encoding_interface.py +412 -0
  102. backend/tests/test_file_upload.py +1739 -0
  103. backend/tests/test_flacfetch_client.py +632 -0
  104. backend/tests/test_gdrive_service.py +524 -0
  105. backend/tests/test_instrumental_api.py +431 -0
  106. backend/tests/test_internal_api.py +343 -0
  107. backend/tests/test_job_creation_regression.py +583 -0
  108. backend/tests/test_job_manager.py +356 -0
  109. backend/tests/test_job_manager_notifications.py +329 -0
  110. backend/tests/test_job_notification_service.py +443 -0
  111. backend/tests/test_jobs_api.py +283 -0
  112. backend/tests/test_local_encoding_service.py +423 -0
  113. backend/tests/test_local_preview_encoding_service.py +567 -0
  114. backend/tests/test_main.py +87 -0
  115. backend/tests/test_models.py +918 -0
  116. backend/tests/test_packaging_service.py +382 -0
  117. backend/tests/test_requests.py +201 -0
  118. backend/tests/test_routes_jobs.py +282 -0
  119. backend/tests/test_routes_review.py +337 -0
  120. backend/tests/test_services.py +556 -0
  121. backend/tests/test_services_extended.py +112 -0
  122. backend/tests/test_spacy_preloader.py +119 -0
  123. backend/tests/test_storage_service.py +448 -0
  124. backend/tests/test_style_upload.py +261 -0
  125. backend/tests/test_template_service.py +295 -0
  126. backend/tests/test_theme_service.py +516 -0
  127. backend/tests/test_unicode_sanitization.py +522 -0
  128. backend/tests/test_upload_api.py +256 -0
  129. backend/tests/test_validate.py +156 -0
  130. backend/tests/test_video_worker_orchestrator.py +847 -0
  131. backend/tests/test_worker_log_subcollection.py +509 -0
  132. backend/tests/test_worker_logging.py +365 -0
  133. backend/tests/test_workers.py +1116 -0
  134. backend/tests/test_workers_extended.py +178 -0
  135. backend/tests/test_youtube_service.py +247 -0
  136. backend/tests/test_youtube_upload_service.py +568 -0
  137. backend/utils/test_data.py +27 -0
  138. backend/validate.py +173 -0
  139. backend/version.py +27 -0
  140. backend/workers/README.md +597 -0
  141. backend/workers/__init__.py +11 -0
  142. backend/workers/audio_worker.py +618 -0
  143. backend/workers/lyrics_worker.py +683 -0
  144. backend/workers/render_video_worker.py +483 -0
  145. backend/workers/screens_worker.py +535 -0
  146. backend/workers/style_helper.py +198 -0
  147. backend/workers/video_worker.py +1277 -0
  148. backend/workers/video_worker_orchestrator.py +701 -0
  149. backend/workers/worker_logging.py +278 -0
  150. karaoke_gen/instrumental_review/static/index.html +7 -4
  151. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  152. karaoke_gen/utils/__init__.py +163 -8
  153. karaoke_gen/video_background_processor.py +9 -4
  154. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  155. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
  156. lyrics_transcriber/correction/agentic/agent.py +17 -6
  157. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  158. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
  159. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  160. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  161. lyrics_transcriber/correction/corrector.py +192 -130
  162. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  163. lyrics_transcriber/correction/operations.py +24 -9
  164. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  165. lyrics_transcriber/frontend/package-lock.json +2 -2
  166. lyrics_transcriber/frontend/package.json +1 -1
  167. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  168. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  169. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  170. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  171. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  172. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  173. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  174. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  175. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  176. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  177. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  178. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  179. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  180. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  181. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  182. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  183. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  184. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  185. lyrics_transcriber/frontend/src/theme.ts +42 -15
  186. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  187. lyrics_transcriber/frontend/vite.config.js +5 -0
  188. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  189. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  191. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  192. lyrics_transcriber/output/generator.py +17 -3
  193. lyrics_transcriber/output/video.py +60 -95
  194. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  195. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  196. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  197. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,524 @@
1
+ """
2
+ Tests for gdrive_service.py - Google Drive file operations.
3
+
4
+ These tests mock the Google API client and Secret Manager to verify:
5
+ - Credential loading from Secret Manager
6
+ - Folder creation and lookup
7
+ - File uploads with proper MIME types
8
+ - Public share folder structure uploads
9
+ """
10
+ import json
11
+ import os
12
+ import pytest
13
+ from unittest.mock import Mock, MagicMock, patch
14
+
15
+
16
+ class TestGoogleDriveServiceInit:
17
+ """Test GoogleDriveService initialization."""
18
+
19
+ @patch("backend.services.gdrive_service.get_settings")
20
+ def test_init_creates_service(self, mock_get_settings):
21
+ """Test initialization creates service with settings."""
22
+ from backend.services.gdrive_service import GoogleDriveService
23
+
24
+ mock_settings = Mock()
25
+ mock_get_settings.return_value = mock_settings
26
+
27
+ service = GoogleDriveService()
28
+
29
+ assert service.settings == mock_settings
30
+ assert service._service is None
31
+ assert service._credentials_data is None
32
+ assert service._loaded is False
33
+
34
+
35
+ class TestLoadCredentials:
36
+ """Test _load_credentials method."""
37
+
38
+ @patch("backend.services.gdrive_service.get_settings")
39
+ def test_load_credentials_success(self, mock_get_settings):
40
+ """Test successful credential loading from Secret Manager."""
41
+ from backend.services.gdrive_service import GoogleDriveService
42
+
43
+ mock_settings = Mock()
44
+ mock_settings.get_secret.return_value = json.dumps({
45
+ "token": "access-token",
46
+ "refresh_token": "refresh-token",
47
+ "client_id": "client-id",
48
+ "client_secret": "client-secret",
49
+ })
50
+ mock_get_settings.return_value = mock_settings
51
+
52
+ service = GoogleDriveService()
53
+ creds = service._load_credentials()
54
+
55
+ assert creds is not None
56
+ assert creds["refresh_token"] == "refresh-token"
57
+ mock_settings.get_secret.assert_called_once_with("gdrive-oauth-credentials")
58
+
59
+ @patch("backend.services.gdrive_service.get_settings")
60
+ def test_load_credentials_fallback_to_youtube(self, mock_get_settings):
61
+ """Test fallback to YouTube credentials when Drive creds not found."""
62
+ from backend.services.gdrive_service import GoogleDriveService
63
+
64
+ mock_settings = Mock()
65
+ # First call returns None (Drive creds not found)
66
+ # Second call returns YouTube creds
67
+ mock_settings.get_secret.side_effect = [
68
+ None,
69
+ json.dumps({
70
+ "refresh_token": "youtube-token",
71
+ "client_id": "youtube-id",
72
+ "client_secret": "youtube-secret",
73
+ })
74
+ ]
75
+ mock_get_settings.return_value = mock_settings
76
+
77
+ service = GoogleDriveService()
78
+ creds = service._load_credentials()
79
+
80
+ assert creds is not None
81
+ assert creds["refresh_token"] == "youtube-token"
82
+ assert mock_settings.get_secret.call_count == 2
83
+
84
+ @patch("backend.services.gdrive_service.get_settings")
85
+ def test_load_credentials_not_found(self, mock_get_settings):
86
+ """Test handling when no credentials found."""
87
+ from backend.services.gdrive_service import GoogleDriveService
88
+
89
+ mock_settings = Mock()
90
+ mock_settings.get_secret.return_value = None
91
+ mock_get_settings.return_value = mock_settings
92
+
93
+ service = GoogleDriveService()
94
+ creds = service._load_credentials()
95
+
96
+ assert creds is None
97
+ assert service._loaded is True
98
+
99
+ @patch("backend.services.gdrive_service.get_settings")
100
+ def test_load_credentials_missing_required_fields(self, mock_get_settings):
101
+ """Test handling when credentials missing required fields."""
102
+ from backend.services.gdrive_service import GoogleDriveService
103
+
104
+ mock_settings = Mock()
105
+ mock_settings.get_secret.return_value = json.dumps({
106
+ "token": "access-token",
107
+ # Missing: refresh_token, client_id, client_secret
108
+ })
109
+ mock_get_settings.return_value = mock_settings
110
+
111
+ service = GoogleDriveService()
112
+ creds = service._load_credentials()
113
+
114
+ assert creds is None
115
+
116
+ @patch("backend.services.gdrive_service.get_settings")
117
+ def test_load_credentials_cached(self, mock_get_settings):
118
+ """Test credentials are cached after first load."""
119
+ from backend.services.gdrive_service import GoogleDriveService
120
+
121
+ mock_settings = Mock()
122
+ mock_settings.get_secret.return_value = json.dumps({
123
+ "refresh_token": "token",
124
+ "client_id": "id",
125
+ "client_secret": "secret",
126
+ })
127
+ mock_get_settings.return_value = mock_settings
128
+
129
+ service = GoogleDriveService()
130
+
131
+ creds1 = service._load_credentials()
132
+ creds2 = service._load_credentials()
133
+
134
+ assert creds1 == creds2
135
+ assert mock_settings.get_secret.call_count == 1
136
+
137
+
138
+ class TestIsConfigured:
139
+ """Test is_configured property."""
140
+
141
+ @patch("backend.services.gdrive_service.get_settings")
142
+ def test_is_configured_true(self, mock_get_settings):
143
+ """Test is_configured returns True when credentials exist."""
144
+ from backend.services.gdrive_service import GoogleDriveService
145
+
146
+ mock_settings = Mock()
147
+ mock_settings.get_secret.return_value = json.dumps({
148
+ "refresh_token": "token",
149
+ "client_id": "id",
150
+ "client_secret": "secret",
151
+ })
152
+ mock_get_settings.return_value = mock_settings
153
+
154
+ service = GoogleDriveService()
155
+
156
+ assert service.is_configured is True
157
+
158
+ @patch("backend.services.gdrive_service.get_settings")
159
+ def test_is_configured_false(self, mock_get_settings):
160
+ """Test is_configured returns False when no credentials."""
161
+ from backend.services.gdrive_service import GoogleDriveService
162
+
163
+ mock_settings = Mock()
164
+ mock_settings.get_secret.return_value = None
165
+ mock_get_settings.return_value = mock_settings
166
+
167
+ service = GoogleDriveService()
168
+
169
+ assert service.is_configured is False
170
+
171
+
172
+ class TestDriveService:
173
+ """Test service property."""
174
+
175
+ @patch("googleapiclient.discovery.build")
176
+ @patch("google.oauth2.credentials.Credentials")
177
+ @patch("backend.services.gdrive_service.get_settings")
178
+ def test_service_creates_drive_client(
179
+ self, mock_get_settings, mock_creds_class, mock_build
180
+ ):
181
+ """Test service property creates Google Drive API client."""
182
+ from backend.services.gdrive_service import GoogleDriveService
183
+
184
+ mock_settings = Mock()
185
+ mock_settings.get_secret.return_value = json.dumps({
186
+ "token": "access-token",
187
+ "refresh_token": "refresh-token",
188
+ "client_id": "client-id",
189
+ "client_secret": "client-secret",
190
+ })
191
+ mock_get_settings.return_value = mock_settings
192
+
193
+ mock_creds = Mock()
194
+ mock_creds_class.return_value = mock_creds
195
+
196
+ mock_drive_service = Mock()
197
+ mock_build.return_value = mock_drive_service
198
+
199
+ service = GoogleDriveService()
200
+ result = service.service
201
+
202
+ mock_build.assert_called_once_with("drive", "v3", credentials=mock_creds)
203
+ assert result == mock_drive_service
204
+
205
+ @patch("backend.services.gdrive_service.get_settings")
206
+ def test_service_raises_on_missing_credentials(self, mock_get_settings):
207
+ """Test service raises RuntimeError when credentials missing."""
208
+ from backend.services.gdrive_service import GoogleDriveService
209
+
210
+ mock_settings = Mock()
211
+ mock_settings.get_secret.return_value = None
212
+ mock_get_settings.return_value = mock_settings
213
+
214
+ service = GoogleDriveService()
215
+
216
+ with pytest.raises(RuntimeError) as exc_info:
217
+ _ = service.service
218
+
219
+ assert "not configured" in str(exc_info.value)
220
+
221
+
222
+ class TestGetOrCreateFolder:
223
+ """Test get_or_create_folder method."""
224
+
225
+ @patch("backend.services.gdrive_service.get_settings")
226
+ def test_get_existing_folder(self, mock_get_settings):
227
+ """Test finding an existing folder."""
228
+ from backend.services.gdrive_service import GoogleDriveService
229
+
230
+ mock_settings = Mock()
231
+ mock_settings.get_secret.return_value = json.dumps({
232
+ "refresh_token": "token",
233
+ "client_id": "id",
234
+ "client_secret": "secret",
235
+ })
236
+ mock_get_settings.return_value = mock_settings
237
+
238
+ service = GoogleDriveService()
239
+
240
+ # Mock the Drive API
241
+ mock_files_api = Mock()
242
+ mock_list_result = Mock()
243
+ mock_list_result.execute.return_value = {
244
+ "files": [{"id": "folder-id-123", "name": "MP4"}]
245
+ }
246
+ mock_files_api.list.return_value = mock_list_result
247
+
248
+ mock_drive = Mock()
249
+ mock_drive.files.return_value = mock_files_api
250
+ service._service = mock_drive
251
+ service._loaded = True
252
+ service._credentials_data = {"refresh_token": "token", "client_id": "id", "client_secret": "secret"}
253
+
254
+ folder_id = service.get_or_create_folder("parent-123", "MP4")
255
+
256
+ assert folder_id == "folder-id-123"
257
+
258
+ @patch("backend.services.gdrive_service.get_settings")
259
+ def test_create_new_folder(self, mock_get_settings):
260
+ """Test creating a new folder when it doesn't exist."""
261
+ from backend.services.gdrive_service import GoogleDriveService
262
+
263
+ mock_settings = Mock()
264
+ mock_settings.get_secret.return_value = json.dumps({
265
+ "refresh_token": "token",
266
+ "client_id": "id",
267
+ "client_secret": "secret",
268
+ })
269
+ mock_get_settings.return_value = mock_settings
270
+
271
+ service = GoogleDriveService()
272
+
273
+ # Mock the Drive API
274
+ mock_files_api = Mock()
275
+
276
+ # list returns empty (folder doesn't exist)
277
+ mock_list_result = Mock()
278
+ mock_list_result.execute.return_value = {"files": []}
279
+ mock_files_api.list.return_value = mock_list_result
280
+
281
+ # create returns new folder
282
+ mock_create_result = Mock()
283
+ mock_create_result.execute.return_value = {"id": "new-folder-id"}
284
+ mock_files_api.create.return_value = mock_create_result
285
+
286
+ mock_drive = Mock()
287
+ mock_drive.files.return_value = mock_files_api
288
+ service._service = mock_drive
289
+ service._loaded = True
290
+ service._credentials_data = {"refresh_token": "token", "client_id": "id", "client_secret": "secret"}
291
+
292
+ folder_id = service.get_or_create_folder("parent-123", "NewFolder")
293
+
294
+ assert folder_id == "new-folder-id"
295
+ mock_files_api.create.assert_called_once()
296
+
297
+
298
+ class TestUploadFile:
299
+ """Test upload_file method."""
300
+
301
+ @patch("googleapiclient.http.MediaFileUpload")
302
+ @patch("backend.services.gdrive_service.get_settings")
303
+ def test_upload_file_success(self, mock_get_settings, mock_media, tmp_path):
304
+ """Test uploading a file to Drive."""
305
+ from backend.services.gdrive_service import GoogleDriveService
306
+
307
+ mock_settings = Mock()
308
+ mock_settings.get_secret.return_value = json.dumps({
309
+ "refresh_token": "token",
310
+ "client_id": "id",
311
+ "client_secret": "secret",
312
+ })
313
+ mock_get_settings.return_value = mock_settings
314
+
315
+ # Create test file
316
+ test_file = tmp_path / "test.mp4"
317
+ test_file.write_bytes(b"video content")
318
+
319
+ service = GoogleDriveService()
320
+
321
+ mock_files_api = Mock()
322
+
323
+ # list returns empty (no existing file)
324
+ mock_list_result = Mock()
325
+ mock_list_result.execute.return_value = {"files": []}
326
+ mock_files_api.list.return_value = mock_list_result
327
+
328
+ # create returns new file
329
+ mock_create_result = Mock()
330
+ mock_create_result.execute.return_value = {"id": "file-id-123"}
331
+ mock_files_api.create.return_value = mock_create_result
332
+
333
+ mock_drive = Mock()
334
+ mock_drive.files.return_value = mock_files_api
335
+ service._service = mock_drive
336
+ service._loaded = True
337
+ service._credentials_data = {"refresh_token": "token", "client_id": "id", "client_secret": "secret"}
338
+
339
+ file_id = service.upload_file(str(test_file), "parent-123", "video.mp4")
340
+
341
+ assert file_id == "file-id-123"
342
+ mock_media.assert_called_once()
343
+ # Verify MIME type was set correctly
344
+ call_kwargs = mock_media.call_args.kwargs
345
+ assert call_kwargs["mimetype"] == "video/mp4"
346
+
347
+ @patch("googleapiclient.http.MediaFileUpload")
348
+ @patch("backend.services.gdrive_service.get_settings")
349
+ def test_upload_file_replaces_existing(self, mock_get_settings, mock_media, tmp_path):
350
+ """Test upload deletes existing file before upload."""
351
+ from backend.services.gdrive_service import GoogleDriveService
352
+
353
+ mock_settings = Mock()
354
+ mock_settings.get_secret.return_value = json.dumps({
355
+ "refresh_token": "token",
356
+ "client_id": "id",
357
+ "client_secret": "secret",
358
+ })
359
+ mock_get_settings.return_value = mock_settings
360
+
361
+ test_file = tmp_path / "test.mp4"
362
+ test_file.write_bytes(b"content")
363
+
364
+ service = GoogleDriveService()
365
+
366
+ mock_files_api = Mock()
367
+
368
+ # list returns existing file
369
+ mock_list_result = Mock()
370
+ mock_list_result.execute.return_value = {
371
+ "files": [{"id": "existing-file-id"}]
372
+ }
373
+ mock_files_api.list.return_value = mock_list_result
374
+
375
+ mock_delete_result = Mock()
376
+ mock_delete_result.execute.return_value = {}
377
+ mock_files_api.delete.return_value = mock_delete_result
378
+
379
+ mock_create_result = Mock()
380
+ mock_create_result.execute.return_value = {"id": "new-file-id"}
381
+ mock_files_api.create.return_value = mock_create_result
382
+
383
+ mock_drive = Mock()
384
+ mock_drive.files.return_value = mock_files_api
385
+ service._service = mock_drive
386
+ service._loaded = True
387
+ service._credentials_data = {"refresh_token": "token", "client_id": "id", "client_secret": "secret"}
388
+
389
+ file_id = service.upload_file(str(test_file), "parent-123", "video.mp4")
390
+
391
+ # Should have deleted existing file
392
+ mock_files_api.delete.assert_called_once_with(fileId="existing-file-id")
393
+ assert file_id == "new-file-id"
394
+
395
+
396
+ class TestUploadToPublicShare:
397
+ """Test upload_to_public_share method."""
398
+
399
+ @patch("backend.services.gdrive_service.get_settings")
400
+ def test_upload_to_public_share(self, mock_get_settings, tmp_path):
401
+ """Test uploading files to public share folder structure."""
402
+ from backend.services.gdrive_service import GoogleDriveService
403
+
404
+ mock_settings = Mock()
405
+ mock_settings.get_secret.return_value = json.dumps({
406
+ "refresh_token": "token",
407
+ "client_id": "id",
408
+ "client_secret": "secret",
409
+ })
410
+ mock_get_settings.return_value = mock_settings
411
+
412
+ # Create test files
413
+ mp4_file = tmp_path / "output.mp4"
414
+ mp4_file.write_bytes(b"4k video")
415
+ mp4_720_file = tmp_path / "output_720p.mp4"
416
+ mp4_720_file.write_bytes(b"720p video")
417
+ cdg_file = tmp_path / "output.zip"
418
+ cdg_file.write_bytes(b"cdg package")
419
+
420
+ service = GoogleDriveService()
421
+
422
+ # Mock methods
423
+ with patch.object(service, "get_or_create_folder") as mock_get_folder:
424
+ with patch.object(service, "upload_file") as mock_upload:
425
+ mock_get_folder.side_effect = [
426
+ "mp4-folder-id",
427
+ "mp4-720-folder-id",
428
+ "cdg-folder-id",
429
+ ]
430
+ mock_upload.side_effect = [
431
+ "mp4-file-id",
432
+ "720p-file-id",
433
+ "cdg-file-id",
434
+ ]
435
+
436
+ output_files = {
437
+ "final_karaoke_lossy_mp4": str(mp4_file),
438
+ "final_karaoke_lossy_720p_mp4": str(mp4_720_file),
439
+ "final_karaoke_cdg_zip": str(cdg_file),
440
+ }
441
+
442
+ result = service.upload_to_public_share(
443
+ root_folder_id="root-123",
444
+ brand_code="NOMAD-1163",
445
+ base_name="Artist - Title",
446
+ output_files=output_files,
447
+ )
448
+
449
+ # Should have created/found 3 folders
450
+ assert mock_get_folder.call_count == 3
451
+
452
+ # Should have uploaded 3 files
453
+ assert mock_upload.call_count == 3
454
+
455
+ # Check result
456
+ assert result["mp4"] == "mp4-file-id"
457
+ assert result["mp4_720p"] == "720p-file-id"
458
+ assert result["cdg"] == "cdg-file-id"
459
+
460
+ @patch("backend.services.gdrive_service.get_settings")
461
+ def test_upload_to_public_share_skips_missing_files(
462
+ self, mock_get_settings, tmp_path
463
+ ):
464
+ """Test upload skips files that don't exist."""
465
+ from backend.services.gdrive_service import GoogleDriveService
466
+
467
+ mock_settings = Mock()
468
+ mock_settings.get_secret.return_value = json.dumps({
469
+ "refresh_token": "token",
470
+ "client_id": "id",
471
+ "client_secret": "secret",
472
+ })
473
+ mock_get_settings.return_value = mock_settings
474
+
475
+ # Only create one file
476
+ mp4_file = tmp_path / "output.mp4"
477
+ mp4_file.write_bytes(b"video")
478
+
479
+ service = GoogleDriveService()
480
+
481
+ with patch.object(service, "get_or_create_folder") as mock_get_folder:
482
+ with patch.object(service, "upload_file") as mock_upload:
483
+ mock_get_folder.return_value = "folder-id"
484
+ mock_upload.return_value = "file-id"
485
+
486
+ output_files = {
487
+ "final_karaoke_lossy_mp4": str(mp4_file),
488
+ "final_karaoke_lossy_720p_mp4": "/nonexistent/file.mp4",
489
+ "final_karaoke_cdg_zip": None,
490
+ }
491
+
492
+ result = service.upload_to_public_share(
493
+ root_folder_id="root-123",
494
+ brand_code="CODE",
495
+ base_name="Name",
496
+ output_files=output_files,
497
+ )
498
+
499
+ # Should only upload the one file that exists
500
+ assert mock_upload.call_count == 1
501
+ assert len(result) == 1
502
+
503
+
504
+ class TestGetGdriveService:
505
+ """Test get_gdrive_service singleton."""
506
+
507
+ @patch("backend.services.gdrive_service.get_settings")
508
+ def test_get_gdrive_service_singleton(self, mock_get_settings):
509
+ """Test get_gdrive_service returns singleton instance."""
510
+ from backend.services.gdrive_service import get_gdrive_service
511
+ import backend.services.gdrive_service as gdrive_module
512
+
513
+ # Reset singleton
514
+ gdrive_module._gdrive_service = None
515
+
516
+ mock_settings = Mock()
517
+ mock_settings.get_secret.return_value = None
518
+ mock_get_settings.return_value = mock_settings
519
+
520
+ service1 = get_gdrive_service()
521
+ service2 = get_gdrive_service()
522
+
523
+ assert service1 is service2
524
+