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,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"
|