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,568 @@
1
+ """
2
+ Tests for YouTubeUploadService.
3
+
4
+ Tests cover:
5
+ - Authentication with pre-stored credentials
6
+ - Authentication with client secrets file
7
+ - Duplicate video detection (exact and fuzzy matching)
8
+ - Video deletion
9
+ - Video upload with metadata and thumbnail
10
+ - Title truncation
11
+ - Dry run mode
12
+ """
13
+
14
+ import os
15
+ import pytest
16
+ from unittest.mock import MagicMock, patch, mock_open
17
+
18
+ from backend.services.youtube_upload_service import YouTubeUploadService, get_youtube_upload_service
19
+
20
+
21
+ class TestYouTubeUploadServiceInit:
22
+ """Test service initialization."""
23
+
24
+ def test_init_with_credentials(self):
25
+ """Test initialization with pre-stored credentials."""
26
+ creds = {
27
+ "token": "test_token",
28
+ "refresh_token": "test_refresh",
29
+ "token_uri": "https://oauth2.googleapis.com/token",
30
+ "client_id": "test_client_id",
31
+ "client_secret": "test_secret",
32
+ }
33
+ service = YouTubeUploadService(
34
+ credentials=creds,
35
+ non_interactive=True
36
+ )
37
+ assert service.credentials == creds
38
+ assert service.non_interactive is True
39
+
40
+ def test_init_with_client_secrets(self):
41
+ """Test initialization with client secrets file."""
42
+ service = YouTubeUploadService(
43
+ client_secrets_file="/path/to/secrets.json"
44
+ )
45
+ assert service.client_secrets_file == "/path/to/secrets.json"
46
+ assert service.non_interactive is False
47
+
48
+ def test_init_default_values(self):
49
+ """Test default values on initialization."""
50
+ service = YouTubeUploadService()
51
+ assert service.credentials is None
52
+ assert service.client_secrets_file is None
53
+ assert service.non_interactive is False
54
+ assert service.server_side_mode is False
55
+ assert service.dry_run is False
56
+
57
+
58
+ class TestYouTubeUploadServiceAuthentication:
59
+ """Test authentication methods."""
60
+
61
+ @patch("googleapiclient.discovery.build")
62
+ @patch("google.oauth2.credentials.Credentials")
63
+ @patch("google.auth.transport.requests.Request")
64
+ def test_authenticate_with_prestored_credentials(
65
+ self, mock_request, mock_credentials_class, mock_build
66
+ ):
67
+ """Test authentication using pre-stored credentials."""
68
+ # Setup mocks
69
+ mock_creds = MagicMock()
70
+ mock_creds.expired = False
71
+ mock_credentials_class.return_value = mock_creds
72
+ mock_youtube = MagicMock()
73
+ mock_build.return_value = mock_youtube
74
+
75
+ creds = {
76
+ "token": "test_token",
77
+ "refresh_token": "test_refresh",
78
+ "token_uri": "https://oauth2.googleapis.com/token",
79
+ "client_id": "test_client_id",
80
+ "client_secret": "test_secret",
81
+ "scopes": ["https://www.googleapis.com/auth/youtube"],
82
+ }
83
+ service = YouTubeUploadService(credentials=creds, non_interactive=True)
84
+
85
+ # Access youtube_service to trigger authentication
86
+ result = service.youtube_service
87
+
88
+ assert result == mock_youtube
89
+ mock_credentials_class.assert_called_once()
90
+ mock_build.assert_called_once_with('youtube', 'v3', credentials=mock_creds)
91
+
92
+ @patch("googleapiclient.discovery.build")
93
+ @patch("google.oauth2.credentials.Credentials")
94
+ @patch("google.auth.transport.requests.Request")
95
+ def test_authenticate_refreshes_expired_token(
96
+ self, mock_request, mock_credentials_class, mock_build
97
+ ):
98
+ """Test that expired tokens are refreshed."""
99
+ mock_creds = MagicMock()
100
+ mock_creds.expired = True
101
+ mock_creds.refresh_token = "test_refresh"
102
+ mock_credentials_class.return_value = mock_creds
103
+
104
+ creds = {
105
+ "token": "expired_token",
106
+ "refresh_token": "test_refresh",
107
+ "token_uri": "https://oauth2.googleapis.com/token",
108
+ "client_id": "test_client_id",
109
+ "client_secret": "test_secret",
110
+ }
111
+ service = YouTubeUploadService(credentials=creds, non_interactive=True)
112
+ service.youtube_service
113
+
114
+ mock_creds.refresh.assert_called_once()
115
+
116
+ def test_authenticate_non_interactive_no_credentials_raises(self):
117
+ """Test that non-interactive mode without credentials raises error."""
118
+ service = YouTubeUploadService(non_interactive=True)
119
+
120
+ with pytest.raises(Exception) as exc_info:
121
+ service._authenticate()
122
+
123
+ assert "non-interactive mode" in str(exc_info.value).lower()
124
+
125
+
126
+ class TestYouTubeUploadServiceChannelId:
127
+ """Test channel ID retrieval."""
128
+
129
+ @patch("googleapiclient.discovery.build")
130
+ @patch("google.oauth2.credentials.Credentials")
131
+ @patch("google.auth.transport.requests.Request")
132
+ def test_get_channel_id_success(
133
+ self, mock_request, mock_credentials_class, mock_build
134
+ ):
135
+ """Test successful channel ID retrieval."""
136
+ mock_creds = MagicMock()
137
+ mock_creds.expired = False
138
+ mock_credentials_class.return_value = mock_creds
139
+
140
+ mock_youtube = MagicMock()
141
+ mock_channels = MagicMock()
142
+ mock_list = MagicMock()
143
+ mock_list.execute.return_value = {
144
+ "items": [{"id": "UC123456"}]
145
+ }
146
+ mock_channels.list.return_value = mock_list
147
+ mock_youtube.channels.return_value = mock_channels
148
+ mock_build.return_value = mock_youtube
149
+
150
+ creds = {"token": "test", "refresh_token": "test", "token_uri": "uri",
151
+ "client_id": "id", "client_secret": "secret"}
152
+ service = YouTubeUploadService(credentials=creds, non_interactive=True)
153
+
154
+ channel_id = service.get_channel_id()
155
+
156
+ assert channel_id == "UC123456"
157
+
158
+ @patch("googleapiclient.discovery.build")
159
+ @patch("google.oauth2.credentials.Credentials")
160
+ @patch("google.auth.transport.requests.Request")
161
+ def test_get_channel_id_no_items(
162
+ self, mock_request, mock_credentials_class, mock_build
163
+ ):
164
+ """Test channel ID returns None when no items found."""
165
+ mock_creds = MagicMock()
166
+ mock_creds.expired = False
167
+ mock_credentials_class.return_value = mock_creds
168
+
169
+ mock_youtube = MagicMock()
170
+ mock_channels = MagicMock()
171
+ mock_list = MagicMock()
172
+ mock_list.execute.return_value = {"items": []}
173
+ mock_channels.list.return_value = mock_list
174
+ mock_youtube.channels.return_value = mock_channels
175
+ mock_build.return_value = mock_youtube
176
+
177
+ creds = {"token": "test", "refresh_token": "test", "token_uri": "uri",
178
+ "client_id": "id", "client_secret": "secret"}
179
+ service = YouTubeUploadService(credentials=creds, non_interactive=True)
180
+
181
+ channel_id = service.get_channel_id()
182
+
183
+ assert channel_id is None
184
+
185
+
186
+ class TestYouTubeUploadServiceDuplicateCheck:
187
+ """Test duplicate video detection."""
188
+
189
+ @patch("googleapiclient.discovery.build")
190
+ @patch("google.oauth2.credentials.Credentials")
191
+ @patch("google.auth.transport.requests.Request")
192
+ def test_check_duplicate_exact_match_found(
193
+ self, mock_request, mock_credentials_class, mock_build
194
+ ):
195
+ """Test duplicate detection with exact match in server mode."""
196
+ mock_creds = MagicMock()
197
+ mock_creds.expired = False
198
+ mock_credentials_class.return_value = mock_creds
199
+
200
+ mock_youtube = MagicMock()
201
+ # Mock channels().list() for get_channel_id
202
+ mock_channels = MagicMock()
203
+ mock_channels_list = MagicMock()
204
+ mock_channels_list.execute.return_value = {"items": [{"id": "UC123456"}]}
205
+ mock_channels.list.return_value = mock_channels_list
206
+ mock_youtube.channels.return_value = mock_channels
207
+
208
+ # Mock search().list() for duplicate check
209
+ mock_search = MagicMock()
210
+ mock_search_list = MagicMock()
211
+ mock_search_list.execute.return_value = {
212
+ "items": [{
213
+ "id": {"videoId": "VIDEO123"},
214
+ "snippet": {
215
+ "channelId": "UC123456",
216
+ "title": "Test Artist - Test Song (Karaoke)"
217
+ }
218
+ }]
219
+ }
220
+ mock_search.list.return_value = mock_search_list
221
+ mock_youtube.search.return_value = mock_search
222
+ mock_build.return_value = mock_youtube
223
+
224
+ creds = {"token": "test", "refresh_token": "test", "token_uri": "uri",
225
+ "client_id": "id", "client_secret": "secret"}
226
+ service = YouTubeUploadService(
227
+ credentials=creds,
228
+ non_interactive=True,
229
+ server_side_mode=True
230
+ )
231
+
232
+ exists, video_id, video_url = service.check_duplicate(
233
+ "Test Artist - Test Song (Karaoke)"
234
+ )
235
+
236
+ assert exists is True
237
+ assert video_id == "VIDEO123"
238
+ assert "VIDEO123" in video_url
239
+
240
+ @patch("googleapiclient.discovery.build")
241
+ @patch("google.oauth2.credentials.Credentials")
242
+ @patch("google.auth.transport.requests.Request")
243
+ def test_check_duplicate_no_match(
244
+ self, mock_request, mock_credentials_class, mock_build
245
+ ):
246
+ """Test duplicate detection when no match found."""
247
+ mock_creds = MagicMock()
248
+ mock_creds.expired = False
249
+ mock_credentials_class.return_value = mock_creds
250
+
251
+ mock_youtube = MagicMock()
252
+ mock_channels = MagicMock()
253
+ mock_channels_list = MagicMock()
254
+ mock_channels_list.execute.return_value = {"items": [{"id": "UC123456"}]}
255
+ mock_channels.list.return_value = mock_channels_list
256
+ mock_youtube.channels.return_value = mock_channels
257
+
258
+ mock_search = MagicMock()
259
+ mock_search_list = MagicMock()
260
+ mock_search_list.execute.return_value = {"items": []}
261
+ mock_search.list.return_value = mock_search_list
262
+ mock_youtube.search.return_value = mock_search
263
+ mock_build.return_value = mock_youtube
264
+
265
+ creds = {"token": "test", "refresh_token": "test", "token_uri": "uri",
266
+ "client_id": "id", "client_secret": "secret"}
267
+ service = YouTubeUploadService(credentials=creds, non_interactive=True)
268
+
269
+ exists, video_id, video_url = service.check_duplicate("Some Title")
270
+
271
+ assert exists is False
272
+ assert video_id is None
273
+ assert video_url is None
274
+
275
+ @patch("googleapiclient.discovery.build")
276
+ @patch("google.oauth2.credentials.Credentials")
277
+ @patch("google.auth.transport.requests.Request")
278
+ def test_check_duplicate_skips_other_channels(
279
+ self, mock_request, mock_credentials_class, mock_build
280
+ ):
281
+ """Test that videos from other channels are skipped."""
282
+ mock_creds = MagicMock()
283
+ mock_creds.expired = False
284
+ mock_credentials_class.return_value = mock_creds
285
+
286
+ mock_youtube = MagicMock()
287
+ mock_channels = MagicMock()
288
+ mock_channels_list = MagicMock()
289
+ mock_channels_list.execute.return_value = {"items": [{"id": "UC123456"}]}
290
+ mock_channels.list.return_value = mock_channels_list
291
+ mock_youtube.channels.return_value = mock_channels
292
+
293
+ # Return video from different channel
294
+ mock_search = MagicMock()
295
+ mock_search_list = MagicMock()
296
+ mock_search_list.execute.return_value = {
297
+ "items": [{
298
+ "id": {"videoId": "VIDEO123"},
299
+ "snippet": {
300
+ "channelId": "UC_DIFFERENT", # Different channel
301
+ "title": "Test Title"
302
+ }
303
+ }]
304
+ }
305
+ mock_search.list.return_value = mock_search_list
306
+ mock_youtube.search.return_value = mock_search
307
+ mock_build.return_value = mock_youtube
308
+
309
+ creds = {"token": "test", "refresh_token": "test", "token_uri": "uri",
310
+ "client_id": "id", "client_secret": "secret"}
311
+ service = YouTubeUploadService(credentials=creds, non_interactive=True)
312
+
313
+ exists, video_id, video_url = service.check_duplicate("Test Title")
314
+
315
+ assert exists is False
316
+
317
+
318
+ class TestYouTubeUploadServiceDelete:
319
+ """Test video deletion."""
320
+
321
+ @patch("googleapiclient.discovery.build")
322
+ @patch("google.oauth2.credentials.Credentials")
323
+ @patch("google.auth.transport.requests.Request")
324
+ def test_delete_video_success(
325
+ self, mock_request, mock_credentials_class, mock_build
326
+ ):
327
+ """Test successful video deletion."""
328
+ mock_creds = MagicMock()
329
+ mock_creds.expired = False
330
+ mock_credentials_class.return_value = mock_creds
331
+
332
+ mock_youtube = MagicMock()
333
+ mock_videos = MagicMock()
334
+ mock_delete = MagicMock()
335
+ mock_delete.execute.return_value = None
336
+ mock_videos.delete.return_value = mock_delete
337
+ mock_youtube.videos.return_value = mock_videos
338
+ mock_build.return_value = mock_youtube
339
+
340
+ creds = {"token": "test", "refresh_token": "test", "token_uri": "uri",
341
+ "client_id": "id", "client_secret": "secret"}
342
+ service = YouTubeUploadService(credentials=creds, non_interactive=True)
343
+
344
+ result = service.delete_video("VIDEO123")
345
+
346
+ assert result is True
347
+ mock_videos.delete.assert_called_once_with(id="VIDEO123")
348
+
349
+ @patch("googleapiclient.discovery.build")
350
+ @patch("google.oauth2.credentials.Credentials")
351
+ @patch("google.auth.transport.requests.Request")
352
+ def test_delete_video_failure(
353
+ self, mock_request, mock_credentials_class, mock_build
354
+ ):
355
+ """Test video deletion failure handling."""
356
+ mock_creds = MagicMock()
357
+ mock_creds.expired = False
358
+ mock_credentials_class.return_value = mock_creds
359
+
360
+ mock_youtube = MagicMock()
361
+ mock_videos = MagicMock()
362
+ mock_delete = MagicMock()
363
+ mock_delete.execute.side_effect = Exception("API Error")
364
+ mock_videos.delete.return_value = mock_delete
365
+ mock_youtube.videos.return_value = mock_videos
366
+ mock_build.return_value = mock_youtube
367
+
368
+ creds = {"token": "test", "refresh_token": "test", "token_uri": "uri",
369
+ "client_id": "id", "client_secret": "secret"}
370
+ service = YouTubeUploadService(credentials=creds, non_interactive=True)
371
+
372
+ result = service.delete_video("VIDEO123")
373
+
374
+ assert result is False
375
+
376
+ def test_delete_video_dry_run(self):
377
+ """Test video deletion in dry run mode."""
378
+ service = YouTubeUploadService(dry_run=True)
379
+
380
+ result = service.delete_video("VIDEO123")
381
+
382
+ assert result is True
383
+
384
+
385
+ class TestYouTubeUploadServiceTitleTruncation:
386
+ """Test title truncation."""
387
+
388
+ def test_truncate_title_short_title(self):
389
+ """Test that short titles are not truncated."""
390
+ title = "Short Title"
391
+ result = YouTubeUploadService.truncate_title(title)
392
+ assert result == title
393
+
394
+ def test_truncate_title_exact_length(self):
395
+ """Test title at exact max length."""
396
+ title = "A" * 95
397
+ result = YouTubeUploadService.truncate_title(title, max_length=95)
398
+ assert result == title
399
+ assert len(result) == 95
400
+
401
+ def test_truncate_title_long_title(self):
402
+ """Test that long titles are truncated at word boundary."""
403
+ title = "This is a very long title that exceeds the maximum length and needs to be truncated properly at a word boundary"
404
+ result = YouTubeUploadService.truncate_title(title, max_length=50)
405
+ assert len(result) <= 50
406
+ assert result.endswith("...")
407
+
408
+ def test_truncate_title_no_space(self):
409
+ """Test truncation of title without spaces."""
410
+ title = "A" * 100
411
+ result = YouTubeUploadService.truncate_title(title, max_length=50)
412
+ assert len(result) <= 50
413
+
414
+
415
+ class TestYouTubeUploadServiceUpload:
416
+ """Test video upload."""
417
+
418
+ @patch("googleapiclient.http.MediaFileUpload")
419
+ @patch("googleapiclient.discovery.build")
420
+ @patch("google.oauth2.credentials.Credentials")
421
+ @patch("google.auth.transport.requests.Request")
422
+ def test_upload_video_success(
423
+ self, mock_request, mock_credentials_class, mock_build, mock_media_upload
424
+ ):
425
+ """Test successful video upload."""
426
+ mock_creds = MagicMock()
427
+ mock_creds.expired = False
428
+ mock_credentials_class.return_value = mock_creds
429
+
430
+ mock_youtube = MagicMock()
431
+ # Mock channels for get_channel_id
432
+ mock_channels = MagicMock()
433
+ mock_channels_list = MagicMock()
434
+ mock_channels_list.execute.return_value = {"items": [{"id": "UC123456"}]}
435
+ mock_channels.list.return_value = mock_channels_list
436
+ mock_youtube.channels.return_value = mock_channels
437
+
438
+ # Mock search for duplicate check (no duplicates)
439
+ mock_search = MagicMock()
440
+ mock_search_list = MagicMock()
441
+ mock_search_list.execute.return_value = {"items": []}
442
+ mock_search.list.return_value = mock_search_list
443
+ mock_youtube.search.return_value = mock_search
444
+
445
+ # Mock video insert
446
+ mock_videos = MagicMock()
447
+ mock_insert = MagicMock()
448
+ mock_insert.execute.return_value = {"id": "NEW_VIDEO_ID"}
449
+ mock_videos.insert.return_value = mock_insert
450
+ mock_youtube.videos.return_value = mock_videos
451
+ mock_build.return_value = mock_youtube
452
+
453
+ mock_media_upload.return_value = MagicMock()
454
+
455
+ creds = {"token": "test", "refresh_token": "test", "token_uri": "uri",
456
+ "client_id": "id", "client_secret": "secret"}
457
+ service = YouTubeUploadService(credentials=creds, non_interactive=True)
458
+
459
+ video_id, video_url = service.upload_video(
460
+ video_path="/path/to/video.mkv",
461
+ title="Test Artist - Test Song (Karaoke)",
462
+ description="Test description",
463
+ tags=["karaoke", "test"]
464
+ )
465
+
466
+ assert video_id == "NEW_VIDEO_ID"
467
+ assert "NEW_VIDEO_ID" in video_url
468
+
469
+ def test_upload_video_dry_run(self):
470
+ """Test video upload in dry run mode."""
471
+ service = YouTubeUploadService(dry_run=True)
472
+
473
+ video_id, video_url = service.upload_video(
474
+ video_path="/path/to/video.mkv",
475
+ title="Test Title",
476
+ description="Test description"
477
+ )
478
+
479
+ assert video_id == "dry_run_video_id"
480
+ assert "dry_run_video_id" in video_url
481
+
482
+ @patch("googleapiclient.http.MediaFileUpload")
483
+ @patch("googleapiclient.discovery.build")
484
+ @patch("google.oauth2.credentials.Credentials")
485
+ @patch("google.auth.transport.requests.Request")
486
+ @patch("os.path.isfile")
487
+ def test_upload_video_with_thumbnail(
488
+ self, mock_isfile, mock_request, mock_credentials_class,
489
+ mock_build, mock_media_upload
490
+ ):
491
+ """Test video upload with thumbnail."""
492
+ mock_isfile.return_value = True
493
+ mock_creds = MagicMock()
494
+ mock_creds.expired = False
495
+ mock_credentials_class.return_value = mock_creds
496
+
497
+ mock_youtube = MagicMock()
498
+ mock_channels = MagicMock()
499
+ mock_channels_list = MagicMock()
500
+ mock_channels_list.execute.return_value = {"items": [{"id": "UC123456"}]}
501
+ mock_channels.list.return_value = mock_channels_list
502
+ mock_youtube.channels.return_value = mock_channels
503
+
504
+ mock_search = MagicMock()
505
+ mock_search_list = MagicMock()
506
+ mock_search_list.execute.return_value = {"items": []}
507
+ mock_search.list.return_value = mock_search_list
508
+ mock_youtube.search.return_value = mock_search
509
+
510
+ mock_videos = MagicMock()
511
+ mock_insert = MagicMock()
512
+ mock_insert.execute.return_value = {"id": "NEW_VIDEO_ID"}
513
+ mock_videos.insert.return_value = mock_insert
514
+ mock_youtube.videos.return_value = mock_videos
515
+
516
+ # Mock thumbnail upload
517
+ mock_thumbnails = MagicMock()
518
+ mock_set = MagicMock()
519
+ mock_set.execute.return_value = None
520
+ mock_thumbnails.set.return_value = mock_set
521
+ mock_youtube.thumbnails.return_value = mock_thumbnails
522
+
523
+ mock_build.return_value = mock_youtube
524
+ mock_media_upload.return_value = MagicMock()
525
+
526
+ creds = {"token": "test", "refresh_token": "test", "token_uri": "uri",
527
+ "client_id": "id", "client_secret": "secret"}
528
+ service = YouTubeUploadService(credentials=creds, non_interactive=True)
529
+
530
+ video_id, video_url = service.upload_video(
531
+ video_path="/path/to/video.mkv",
532
+ title="Test Title",
533
+ description="Test description",
534
+ thumbnail_path="/path/to/thumbnail.jpg"
535
+ )
536
+
537
+ assert video_id == "NEW_VIDEO_ID"
538
+ mock_thumbnails.set.assert_called_once()
539
+
540
+
541
+ class TestGetYouTubeUploadService:
542
+ """Test factory function."""
543
+
544
+ def test_get_service_creates_instance(self):
545
+ """Test that factory function creates a new instance."""
546
+ # Reset global
547
+ import backend.services.youtube_upload_service as module
548
+ module._youtube_upload_service = None
549
+
550
+ service = get_youtube_upload_service(
551
+ credentials={"token": "test"},
552
+ non_interactive=True
553
+ )
554
+
555
+ assert service is not None
556
+ assert isinstance(service, YouTubeUploadService)
557
+
558
+ def test_get_service_with_client_secrets(self):
559
+ """Test factory function with client secrets file."""
560
+ import backend.services.youtube_upload_service as module
561
+ module._youtube_upload_service = None
562
+
563
+ service = get_youtube_upload_service(
564
+ client_secrets_file="/path/to/secrets.json"
565
+ )
566
+
567
+ assert service is not None
568
+ assert service.client_secrets_file == "/path/to/secrets.json"
@@ -0,0 +1,27 @@
1
+ """
2
+ Utilities for identifying test data.
3
+
4
+ Test data is generated by automated E2E tests and should be filterable
5
+ in admin dashboards to show only real user data by default.
6
+ """
7
+
8
+ # Email domains used by automated testing frameworks
9
+ TEST_EMAIL_DOMAINS = [
10
+ "inbox.testmail.app", # Used by E2E happy path tests
11
+ ]
12
+
13
+
14
+ def is_test_email(email: str) -> bool:
15
+ """
16
+ Check if an email address belongs to automated test data.
17
+
18
+ Args:
19
+ email: Email address to check
20
+
21
+ Returns:
22
+ True if the email matches a test email domain pattern
23
+ """
24
+ if not email:
25
+ return False
26
+ email_lower = email.lower()
27
+ return any(email_lower.endswith(f"@{domain}") for domain in TEST_EMAIL_DOMAINS)