karaoke-gen 0.90.1__py3-none-any.whl → 0.96.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,445 @@
1
+ """
2
+ YouTube Upload Service.
3
+
4
+ Provides video upload functionality for YouTube, extracted from KaraokeFinalise
5
+ for use by both the cloud backend (video_worker) and local CLI.
6
+
7
+ This service handles:
8
+ - Video uploads with metadata (title, description, tags)
9
+ - Thumbnail uploads
10
+ - Duplicate video detection and replacement
11
+ - Authentication via pre-stored credentials or client secrets file
12
+ """
13
+
14
+ import logging
15
+ import os
16
+ from typing import Optional, Dict, Any, Tuple
17
+
18
+ from thefuzz import fuzz
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class YouTubeUploadService:
24
+ """
25
+ Service for uploading videos to YouTube.
26
+
27
+ Supports two authentication modes:
28
+ 1. Pre-stored credentials (for server-side/non-interactive use)
29
+ 2. Client secrets file (for interactive CLI use with browser OAuth)
30
+ """
31
+
32
+ YOUTUBE_URL_PREFIX = "https://www.youtube.com/watch?v="
33
+ SCOPES = ["https://www.googleapis.com/auth/youtube"]
34
+
35
+ def __init__(
36
+ self,
37
+ credentials: Optional[Dict[str, Any]] = None,
38
+ client_secrets_file: Optional[str] = None,
39
+ non_interactive: bool = False,
40
+ server_side_mode: bool = False,
41
+ dry_run: bool = False,
42
+ logger: Optional[logging.Logger] = None,
43
+ ):
44
+ """
45
+ Initialize the YouTube upload service.
46
+
47
+ Args:
48
+ credentials: Pre-stored OAuth credentials dict with keys:
49
+ - token: Access token
50
+ - refresh_token: Refresh token
51
+ - token_uri: Token endpoint URL
52
+ - client_id: OAuth client ID
53
+ - client_secret: OAuth client secret
54
+ - scopes: List of OAuth scopes
55
+ client_secrets_file: Path to OAuth client secrets JSON file
56
+ (for interactive authentication)
57
+ non_interactive: If True, skip interactive prompts
58
+ server_side_mode: If True, use exact matching for duplicate detection
59
+ dry_run: If True, log actions without performing them
60
+ logger: Optional logger instance
61
+ """
62
+ self.credentials = credentials
63
+ self.client_secrets_file = client_secrets_file
64
+ self.non_interactive = non_interactive
65
+ self.server_side_mode = server_side_mode
66
+ self.dry_run = dry_run
67
+ self.logger = logger or logging.getLogger(__name__)
68
+
69
+ self._youtube_service = None
70
+ self._channel_id = None
71
+
72
+ def _authenticate(self):
73
+ """
74
+ Authenticate with YouTube API and return service object.
75
+
76
+ Returns:
77
+ YouTube API service object
78
+
79
+ Raises:
80
+ Exception: If authentication fails or is required in non-interactive mode
81
+ """
82
+ from google.auth.transport.requests import Request
83
+ from google.oauth2.credentials import Credentials
84
+ from googleapiclient.discovery import build
85
+ from google_auth_oauthlib.flow import InstalledAppFlow
86
+ import pickle
87
+
88
+ # Check if we have pre-stored credentials (for non-interactive mode)
89
+ if self.credentials and self.non_interactive:
90
+ try:
91
+ creds = Credentials(
92
+ token=self.credentials.get('token'),
93
+ refresh_token=self.credentials.get('refresh_token'),
94
+ token_uri=self.credentials.get('token_uri'),
95
+ client_id=self.credentials.get('client_id'),
96
+ client_secret=self.credentials.get('client_secret'),
97
+ scopes=self.credentials.get('scopes', self.SCOPES)
98
+ )
99
+
100
+ # Refresh token if needed
101
+ if creds.expired and creds.refresh_token:
102
+ creds.refresh(Request())
103
+
104
+ youtube = build('youtube', 'v3', credentials=creds)
105
+ self.logger.info("Successfully authenticated with YouTube using pre-stored credentials")
106
+ return youtube
107
+
108
+ except Exception as e:
109
+ self.logger.error(f"Failed to authenticate with pre-stored credentials: {str(e)}")
110
+ # Fall through to original authentication if pre-stored credentials fail
111
+
112
+ # For non-interactive mode without pre-stored credentials, raise error
113
+ if self.non_interactive:
114
+ raise Exception(
115
+ "YouTube authentication required but running in non-interactive mode. "
116
+ "Please pre-authenticate or disable YouTube upload."
117
+ )
118
+
119
+ # Interactive authentication using client secrets file
120
+ if not self.client_secrets_file:
121
+ raise Exception("No YouTube credentials or client secrets file provided")
122
+
123
+ # Token file stores the user's access and refresh tokens
124
+ youtube_token_file = "/tmp/karaoke-finalise-youtube-token.pickle"
125
+
126
+ creds = None
127
+
128
+ # Check if we have saved credentials
129
+ if os.path.exists(youtube_token_file):
130
+ with open(youtube_token_file, "rb") as token:
131
+ creds = pickle.load(token)
132
+
133
+ # If there are no valid credentials, let the user log in
134
+ if not creds or not creds.valid:
135
+ if creds and creds.expired and creds.refresh_token:
136
+ creds.refresh(Request())
137
+ else:
138
+ flow = InstalledAppFlow.from_client_secrets_file(
139
+ self.client_secrets_file, scopes=self.SCOPES
140
+ )
141
+ creds = flow.run_local_server(port=0)
142
+
143
+ # Save the credentials for the next run
144
+ with open(youtube_token_file, "wb") as token:
145
+ pickle.dump(creds, token)
146
+
147
+ return build("youtube", "v3", credentials=creds)
148
+
149
+ @property
150
+ def youtube_service(self):
151
+ """Lazy-load YouTube service on first access."""
152
+ if self._youtube_service is None:
153
+ self._youtube_service = self._authenticate()
154
+ return self._youtube_service
155
+
156
+ def get_channel_id(self) -> Optional[str]:
157
+ """
158
+ Get the authenticated user's YouTube channel ID.
159
+
160
+ Returns:
161
+ Channel ID string, or None if not found
162
+ """
163
+ if self._channel_id:
164
+ return self._channel_id
165
+
166
+ request = self.youtube_service.channels().list(part="snippet", mine=True)
167
+ response = request.execute()
168
+
169
+ if "items" in response and len(response["items"]) > 0:
170
+ self._channel_id = response["items"][0]["id"]
171
+ return self._channel_id
172
+
173
+ return None
174
+
175
+ def check_duplicate(
176
+ self,
177
+ title: str,
178
+ exact_match: Optional[bool] = None
179
+ ) -> Tuple[bool, Optional[str], Optional[str]]:
180
+ """
181
+ Check if a video with the given title already exists on the channel.
182
+
183
+ Args:
184
+ title: Video title to search for
185
+ exact_match: If True, require exact title match. If None, uses
186
+ server_side_mode setting (exact in server mode, fuzzy in CLI)
187
+
188
+ Returns:
189
+ Tuple of (exists: bool, video_id: Optional[str], video_url: Optional[str])
190
+ """
191
+ channel_id = self.get_channel_id()
192
+ if not channel_id:
193
+ self.logger.warning("Could not get channel ID, skipping duplicate check")
194
+ return False, None, None
195
+
196
+ use_exact_match = exact_match if exact_match is not None else self.server_side_mode
197
+
198
+ self.logger.info(f"Searching YouTube channel {channel_id} for title: {title}")
199
+ request = self.youtube_service.search().list(
200
+ part="snippet",
201
+ channelId=channel_id,
202
+ q=title,
203
+ type="video",
204
+ maxResults=10
205
+ )
206
+ response = request.execute()
207
+
208
+ if "items" not in response or len(response["items"]) == 0:
209
+ self.logger.info(f"No matching video found with title: {title}")
210
+ return False, None, None
211
+
212
+ for item in response["items"]:
213
+ # Verify the video actually belongs to our channel
214
+ result_channel_id = item["snippet"]["channelId"]
215
+ if result_channel_id != channel_id:
216
+ self.logger.debug(
217
+ f"Skipping video from different channel: {item['snippet']['title']} "
218
+ f"(channel: {result_channel_id})"
219
+ )
220
+ continue
221
+
222
+ found_title = item["snippet"]["title"]
223
+
224
+ # Determine if this is a match
225
+ if use_exact_match:
226
+ is_match = title.lower() == found_title.lower()
227
+ similarity_score = 100 if is_match else 0
228
+ else:
229
+ similarity_score = fuzz.ratio(title.lower(), found_title.lower())
230
+ is_match = similarity_score >= 70
231
+
232
+ if is_match:
233
+ video_id = item["id"]["videoId"]
234
+ video_url = f"{self.YOUTUBE_URL_PREFIX}{video_id}"
235
+ self.logger.info(
236
+ f"Potential match found on YouTube channel with ID: {video_id} "
237
+ f"and title: {found_title} (similarity: {similarity_score}%)"
238
+ )
239
+
240
+ # In non-interactive mode, return the match directly
241
+ if self.non_interactive:
242
+ self.logger.info("Non-interactive mode, found a match.")
243
+ return True, video_id, video_url
244
+
245
+ # Interactive confirmation
246
+ confirmation = input(
247
+ f"Is '{found_title}' the video you are finalising? (y/n): "
248
+ ).strip().lower()
249
+ if confirmation == "y":
250
+ return True, video_id, video_url
251
+
252
+ self.logger.info(f"No matching video found with title: {title}")
253
+ return False, None, None
254
+
255
+ def delete_video(self, video_id: str) -> bool:
256
+ """
257
+ Delete a YouTube video by its ID.
258
+
259
+ Args:
260
+ video_id: The YouTube video ID to delete
261
+
262
+ Returns:
263
+ True if successful, False otherwise
264
+ """
265
+ self.logger.info(f"Deleting YouTube video with ID: {video_id}")
266
+
267
+ if self.dry_run:
268
+ self.logger.info(f"DRY RUN: Would delete YouTube video with ID: {video_id}")
269
+ return True
270
+
271
+ try:
272
+ self.youtube_service.videos().delete(id=video_id).execute()
273
+ self.logger.info(f"Successfully deleted YouTube video with ID: {video_id}")
274
+ return True
275
+ except Exception as e:
276
+ self.logger.error(f"Failed to delete YouTube video with ID {video_id}: {e}")
277
+ return False
278
+
279
+ @staticmethod
280
+ def truncate_title(title: str, max_length: int = 95) -> str:
281
+ """
282
+ Truncate title to the nearest whole word within max_length.
283
+
284
+ Args:
285
+ title: Title to truncate
286
+ max_length: Maximum length (default 95 for YouTube)
287
+
288
+ Returns:
289
+ Truncated title with "..." if needed
290
+ """
291
+ if len(title) <= max_length:
292
+ return title
293
+ truncated_title = title[:max_length].rsplit(" ", 1)[0]
294
+ if len(truncated_title) < max_length:
295
+ truncated_title += " ..."
296
+ return truncated_title
297
+
298
+ def upload_video(
299
+ self,
300
+ video_path: str,
301
+ title: str,
302
+ description: str,
303
+ thumbnail_path: Optional[str] = None,
304
+ tags: Optional[list] = None,
305
+ category_id: str = "10", # Music category
306
+ privacy_status: str = "public",
307
+ replace_existing: bool = False,
308
+ ) -> Tuple[Optional[str], Optional[str]]:
309
+ """
310
+ Upload a video to YouTube with metadata and optional thumbnail.
311
+
312
+ Args:
313
+ video_path: Path to the video file to upload
314
+ title: Video title (will be truncated to 95 chars if needed)
315
+ description: Video description
316
+ thumbnail_path: Optional path to thumbnail image
317
+ tags: Optional list of tags/keywords
318
+ category_id: YouTube category ID (default "10" for Music)
319
+ privacy_status: "public", "private", or "unlisted"
320
+ replace_existing: If True, delete existing video with same title
321
+
322
+ Returns:
323
+ Tuple of (video_id, video_url) or (None, None) if upload failed/skipped
324
+ """
325
+ from googleapiclient.http import MediaFileUpload
326
+
327
+ # Truncate title if needed
328
+ youtube_title = self.truncate_title(title)
329
+
330
+ self.logger.info(f"Uploading video to YouTube: {youtube_title}")
331
+
332
+ if self.dry_run:
333
+ self.logger.info(
334
+ f"DRY RUN: Would upload {video_path} to YouTube with title: {youtube_title}"
335
+ )
336
+ return "dry_run_video_id", f"{self.YOUTUBE_URL_PREFIX}dry_run_video_id"
337
+
338
+ # Check for existing video
339
+ should_replace = True if self.server_side_mode else replace_existing
340
+ exists, existing_id, existing_url = self.check_duplicate(youtube_title)
341
+
342
+ if exists:
343
+ if should_replace:
344
+ self.logger.info(f"Video already exists on YouTube, deleting before re-upload: {existing_url}")
345
+ if self.delete_video(existing_id):
346
+ self.logger.info("Successfully deleted existing video, proceeding with upload")
347
+ else:
348
+ self.logger.error("Failed to delete existing video, aborting upload")
349
+ return None, None
350
+ else:
351
+ self.logger.warning(f"Video already exists on YouTube, skipping upload: {existing_url}")
352
+ return existing_id, existing_url
353
+
354
+ # Prepare video metadata
355
+ body = {
356
+ "snippet": {
357
+ "title": youtube_title,
358
+ "description": description,
359
+ "tags": tags or [],
360
+ "categoryId": category_id,
361
+ },
362
+ "status": {"privacyStatus": privacy_status},
363
+ }
364
+
365
+ # Determine MIME type based on file extension
366
+ ext = os.path.splitext(video_path)[1].lower()
367
+ mime_type = {
368
+ ".mkv": "video/x-matroska",
369
+ ".mp4": "video/mp4",
370
+ ".mov": "video/quicktime",
371
+ ".avi": "video/x-msvideo",
372
+ }.get(ext, "video/*")
373
+
374
+ # Upload video
375
+ self.logger.info(f"Authenticating with YouTube...")
376
+ media_file = MediaFileUpload(video_path, mimetype=mime_type, resumable=True)
377
+
378
+ self.logger.info(f"Uploading video to YouTube...")
379
+ request = self.youtube_service.videos().insert(
380
+ part="snippet,status",
381
+ body=body,
382
+ media_body=media_file
383
+ )
384
+ response = request.execute()
385
+
386
+ video_id = response.get("id")
387
+ video_url = f"{self.YOUTUBE_URL_PREFIX}{video_id}"
388
+ self.logger.info(f"Uploaded video to YouTube: {video_url}")
389
+
390
+ # Upload thumbnail if provided
391
+ if thumbnail_path and os.path.isfile(thumbnail_path):
392
+ try:
393
+ self.logger.info(f"Uploading thumbnail from: {thumbnail_path}")
394
+ media_thumbnail = MediaFileUpload(thumbnail_path, mimetype="image/jpeg")
395
+ self.youtube_service.thumbnails().set(
396
+ videoId=video_id,
397
+ media_body=media_thumbnail
398
+ ).execute()
399
+ self.logger.info(f"Uploaded thumbnail for video ID {video_id}")
400
+ except Exception as e:
401
+ self.logger.error(f"Failed to upload thumbnail: {e}")
402
+ self.logger.warning(
403
+ "Video uploaded but thumbnail not set. "
404
+ "You may need to set it manually on YouTube."
405
+ )
406
+ elif thumbnail_path:
407
+ self.logger.warning(f"Thumbnail file not found, skipping: {thumbnail_path}")
408
+
409
+ return video_id, video_url
410
+
411
+
412
+ # Singleton instance and factory function (following existing service pattern)
413
+ _youtube_upload_service: Optional[YouTubeUploadService] = None
414
+
415
+
416
+ def get_youtube_upload_service(
417
+ credentials: Optional[Dict[str, Any]] = None,
418
+ client_secrets_file: Optional[str] = None,
419
+ **kwargs
420
+ ) -> YouTubeUploadService:
421
+ """
422
+ Get a YouTube upload service instance.
423
+
424
+ For server-side use, pass credentials from YouTubeService.
425
+ For CLI use, pass client_secrets_file.
426
+
427
+ Args:
428
+ credentials: Pre-stored OAuth credentials dict
429
+ client_secrets_file: Path to OAuth client secrets JSON file
430
+ **kwargs: Additional arguments passed to YouTubeUploadService
431
+
432
+ Returns:
433
+ YouTubeUploadService instance
434
+ """
435
+ global _youtube_upload_service
436
+
437
+ # Create new instance if credentials/settings changed
438
+ if _youtube_upload_service is None or credentials or client_secrets_file:
439
+ _youtube_upload_service = YouTubeUploadService(
440
+ credentials=credentials,
441
+ client_secrets_file=client_secrets_file,
442
+ **kwargs
443
+ )
444
+
445
+ return _youtube_upload_service
@@ -0,0 +1,4 @@
1
+ """
2
+ Backend integration tests package.
3
+ """
4
+
@@ -0,0 +1,224 @@
1
+ """
2
+ Shared pytest fixtures for backend tests.
3
+
4
+ Provides common mocks and test utilities across all test modules.
5
+
6
+ NOTE: Module-level mocks are NOT applied here to allow emulator tests to work.
7
+ Individual tests must mock dependencies as needed.
8
+ """
9
+ # IMPORTANT: Set environment variables FIRST, before ANY imports that might
10
+ # trigger backend module loading and Settings singleton creation.
11
+ import os
12
+
13
+ # Set up test environment variables BEFORE importing any backend modules
14
+ # This must happen before Settings is instantiated, which occurs on first import
15
+ if 'FIRESTORE_EMULATOR_HOST' not in os.environ:
16
+ os.environ.setdefault('ADMIN_TOKENS', 'test-admin-token')
17
+ os.environ.setdefault('GOOGLE_CLOUD_PROJECT', 'test-project')
18
+ os.environ.setdefault('GCS_BUCKET_NAME', 'test-bucket')
19
+ os.environ.setdefault('FIRESTORE_COLLECTION', 'jobs')
20
+ os.environ.setdefault('ENVIRONMENT', 'test')
21
+
22
+ import pytest
23
+ from unittest.mock import Mock, MagicMock, AsyncMock, patch
24
+ from datetime import datetime, UTC
25
+ from fastapi.testclient import TestClient
26
+
27
+
28
+ # Mock google.auth.default AND firestore for unit tests if not using emulator
29
+ # This prevents DefaultCredentialsError and FirestoreClient initialization during imports
30
+ if 'FIRESTORE_EMULATOR_HOST' not in os.environ:
31
+ from unittest.mock import MagicMock
32
+
33
+ # Mock google.auth.default - prevents credential errors
34
+ try:
35
+ import google.auth
36
+ mock_credentials = MagicMock()
37
+ mock_credentials.token = 'fake-token'
38
+ mock_credentials.valid = True
39
+ mock_credentials.universe_domain = 'googleapis.com' # Required by google-cloud-storage
40
+ mock_credentials.project_id = 'test-project'
41
+ google.auth.default = MagicMock(return_value=(mock_credentials, 'test-project'))
42
+ except ImportError:
43
+ # If google.auth not installed, that's ok for some tests
44
+ pass
45
+
46
+ # Mock google.cloud.firestore.Client - prevents Firestore initialization
47
+ # This is CRITICAL: when AuthService is instantiated, it creates FirestoreService
48
+ # which tries to create a real Firestore client. The client returns MagicMocks
49
+ # which fail when used as enum values (e.g., UserType).
50
+ try:
51
+ import google.cloud.firestore as firestore_module
52
+
53
+ # Create a mock client that returns proper values for get_token
54
+ mock_firestore_client = MagicMock()
55
+ mock_collection = MagicMock()
56
+ mock_doc = MagicMock()
57
+ mock_snapshot = MagicMock()
58
+
59
+ # For auth token lookups, return None (token not found in Firestore)
60
+ # This forces validation to use admin tokens from environment
61
+ mock_snapshot.exists = False
62
+ mock_snapshot.to_dict.return_value = None
63
+ mock_doc.get.return_value = mock_snapshot
64
+ mock_collection.document.return_value = mock_doc
65
+ mock_firestore_client.collection.return_value = mock_collection
66
+
67
+ # Replace the Client class
68
+ original_client = firestore_module.Client
69
+ firestore_module.Client = MagicMock(return_value=mock_firestore_client)
70
+ except ImportError:
71
+ pass
72
+
73
+ # Mock google.cloud.storage.Client - prevents GCS initialization
74
+ try:
75
+ import google.cloud.storage as storage_module
76
+ mock_storage_client = MagicMock()
77
+ mock_bucket = MagicMock()
78
+ mock_storage_client.bucket.return_value = mock_bucket
79
+ storage_module.Client = MagicMock(return_value=mock_storage_client)
80
+ except ImportError:
81
+ pass
82
+
83
+ from backend.models.job import Job, JobStatus, JobCreate
84
+
85
+
86
+ @pytest.fixture(autouse=True)
87
+ def mock_auth_dependency(request):
88
+ """Mock the require_auth dependency for all tests using FastAPI's dependency override system."""
89
+ # Skip for emulator tests and integration tests which use real auth
90
+ test_path = str(request.fspath)
91
+ if 'emulator' in test_path or 'integration' in test_path:
92
+ yield
93
+ return
94
+
95
+ # Skip if FIRESTORE_EMULATOR_HOST is set (running in emulator environment)
96
+ import os
97
+ if os.environ.get('FIRESTORE_EMULATOR_HOST'):
98
+ yield
99
+ return
100
+
101
+ from backend.services.auth_service import UserType
102
+ from backend.api.dependencies import require_auth, require_admin, require_review_auth, require_instrumental_auth
103
+ from backend.main import app
104
+
105
+ # Create mock auth functions
106
+ async def mock_require_auth():
107
+ """Mock require_auth to always return valid admin credentials."""
108
+ return ("test-admin-token", UserType.ADMIN, 999)
109
+
110
+ async def mock_require_admin():
111
+ """Mock require_admin to always return valid admin credentials."""
112
+ return ("test-admin-token", UserType.ADMIN, 999)
113
+
114
+ async def mock_require_review_auth(job_id: str = "test123"):
115
+ """Mock require_review_auth to always return valid review access."""
116
+ return (job_id, "full")
117
+
118
+ async def mock_require_instrumental_auth(job_id: str = "test123"):
119
+ """Mock require_instrumental_auth to always return valid instrumental review access."""
120
+ return (job_id, "full")
121
+
122
+ # Use FastAPI's dependency override system
123
+ app.dependency_overrides[require_auth] = mock_require_auth
124
+ app.dependency_overrides[require_admin] = mock_require_admin
125
+ app.dependency_overrides[require_review_auth] = mock_require_review_auth
126
+ app.dependency_overrides[require_instrumental_auth] = mock_require_instrumental_auth
127
+
128
+ yield
129
+
130
+ # Clean up after test
131
+ app.dependency_overrides.clear()
132
+
133
+
134
+ @pytest.fixture
135
+ def auth_headers():
136
+ """Get authentication headers for test requests."""
137
+ return {
138
+ "Authorization": "Bearer test-admin-token",
139
+ "Content-Type": "application/json"
140
+ }
141
+
142
+
143
+ @pytest.fixture
144
+ def mock_firestore():
145
+ """Mock Firestore client for unit tests."""
146
+ # Mock at module level for unit tests
147
+ with patch('google.cloud.firestore.Client') as mock_cls:
148
+ client = MagicMock()
149
+ mock_cls.return_value = client
150
+ yield client
151
+
152
+
153
+ @pytest.fixture
154
+ def mock_storage_client():
155
+ """Mock GCS Storage client for unit tests."""
156
+ # Mock at module level for unit tests
157
+ with patch('google.cloud.storage.Client') as mock_cls:
158
+ client = MagicMock()
159
+ mock_cls.return_value = client
160
+ yield client
161
+
162
+
163
+ @pytest.fixture
164
+ def mock_httpx_client():
165
+ """Mock httpx AsyncClient for worker service."""
166
+ with patch('backend.services.worker_service.httpx.AsyncClient') as mock:
167
+ client = AsyncMock()
168
+ mock.return_value.__aenter__.return_value = client
169
+ yield client
170
+
171
+
172
+ @pytest.fixture
173
+ def sample_job():
174
+ """Create a sample Job instance for testing."""
175
+ return Job(
176
+ job_id="test123",
177
+ status=JobStatus.PENDING,
178
+ progress=0,
179
+ created_at=datetime.now(UTC),
180
+ updated_at=datetime.now(UTC),
181
+ artist="Test Artist",
182
+ title="Test Song",
183
+ url="https://youtube.com/watch?v=test"
184
+ )
185
+
186
+
187
+ @pytest.fixture
188
+ def sample_job_create():
189
+ """Create a sample JobCreate instance for testing."""
190
+ return JobCreate(
191
+ url="https://youtube.com/watch?v=test",
192
+ artist="Test Artist",
193
+ title="Test Song"
194
+ )
195
+
196
+
197
+ @pytest.fixture
198
+ def test_client():
199
+ """Create a FastAPI TestClient."""
200
+ # Import here to avoid circular dependency
201
+ from backend.main import app
202
+ return TestClient(app)
203
+
204
+
205
+ def create_mock_job(**kwargs):
206
+ """
207
+ Factory function to create mock Job instances with custom fields.
208
+
209
+ Args:
210
+ **kwargs: Job fields to override defaults
211
+
212
+ Returns:
213
+ Job instance with specified fields
214
+ """
215
+ defaults = {
216
+ "job_id": "test123",
217
+ "status": JobStatus.PENDING,
218
+ "progress": 0,
219
+ "created_at": datetime.now(UTC),
220
+ "updated_at": datetime.now(UTC)
221
+ }
222
+ defaults.update(kwargs)
223
+ return Job(**defaults)
224
+
@@ -0,0 +1,7 @@
1
+ """
2
+ Emulator integration tests.
3
+
4
+ These tests run against local GCP emulators (Firestore, GCS)
5
+ to provide true integration testing without cloud costs.
6
+ """
7
+