karaoke-gen 0.86.7__py3-none-any.whl → 0.96.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/style_loader.py +3 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.86.7.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,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
|
+
|