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.
- 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/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
- 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.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for jobs.py API routes using FastAPI TestClient.
|
|
3
|
+
|
|
4
|
+
These tests mock the underlying services and test the route logic directly.
|
|
5
|
+
"""
|
|
6
|
+
import pytest
|
|
7
|
+
from datetime import datetime, UTC
|
|
8
|
+
from unittest.mock import MagicMock, AsyncMock, patch
|
|
9
|
+
from fastapi.testclient import TestClient
|
|
10
|
+
|
|
11
|
+
from backend.models.job import Job, JobStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def mock_job():
|
|
16
|
+
"""Create a standard mock job for testing."""
|
|
17
|
+
return Job(
|
|
18
|
+
job_id="test123",
|
|
19
|
+
status=JobStatus.PENDING,
|
|
20
|
+
created_at=datetime.now(UTC),
|
|
21
|
+
updated_at=datetime.now(UTC),
|
|
22
|
+
artist="Test Artist",
|
|
23
|
+
title="Test Song"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def mock_job_manager(mock_job):
|
|
29
|
+
"""Create a mock JobManager with common methods."""
|
|
30
|
+
manager = MagicMock()
|
|
31
|
+
manager.get_job.return_value = mock_job
|
|
32
|
+
manager.list_jobs.return_value = [mock_job]
|
|
33
|
+
manager.create_job.return_value = mock_job
|
|
34
|
+
manager.delete_job.return_value = True
|
|
35
|
+
manager.cancel_job.return_value = mock_job
|
|
36
|
+
manager.update_job_status.return_value = None
|
|
37
|
+
manager.update_state_data.return_value = None
|
|
38
|
+
return manager
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def mock_worker_service():
|
|
43
|
+
"""Create a mock WorkerService."""
|
|
44
|
+
service = MagicMock()
|
|
45
|
+
service.trigger_audio_worker = AsyncMock(return_value=True)
|
|
46
|
+
service.trigger_lyrics_worker = AsyncMock(return_value=True)
|
|
47
|
+
service.trigger_screens_worker = AsyncMock(return_value=True)
|
|
48
|
+
service.trigger_video_worker = AsyncMock(return_value=True)
|
|
49
|
+
return service
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def client(mock_job_manager, mock_worker_service):
|
|
54
|
+
"""Create TestClient with mocked dependencies."""
|
|
55
|
+
mock_creds = MagicMock()
|
|
56
|
+
mock_creds.universe_domain = 'googleapis.com'
|
|
57
|
+
|
|
58
|
+
# Create a JobManager class that returns our mock instance
|
|
59
|
+
def mock_job_manager_factory(*args, **kwargs):
|
|
60
|
+
return mock_job_manager
|
|
61
|
+
|
|
62
|
+
# Patch at the module level where jobs.py imports them
|
|
63
|
+
# Also patch JobManager class used in dependencies.py for auth checks
|
|
64
|
+
with patch('backend.api.routes.jobs.job_manager', mock_job_manager), \
|
|
65
|
+
patch('backend.api.routes.jobs.worker_service', mock_worker_service), \
|
|
66
|
+
patch('backend.services.job_manager.JobManager', mock_job_manager_factory), \
|
|
67
|
+
patch('backend.services.firestore_service.firestore'), \
|
|
68
|
+
patch('backend.services.storage_service.storage'), \
|
|
69
|
+
patch('google.auth.default', return_value=(mock_creds, 'test-project')):
|
|
70
|
+
from backend.main import app
|
|
71
|
+
yield TestClient(app)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestGetJob:
|
|
75
|
+
"""Tests for GET /api/jobs/{job_id}."""
|
|
76
|
+
|
|
77
|
+
def test_get_job_returns_200(self, client, mock_job_manager, mock_job, auth_headers):
|
|
78
|
+
"""Test getting an existing job returns 200."""
|
|
79
|
+
response = client.get("/api/jobs/test123", headers=auth_headers)
|
|
80
|
+
assert response.status_code == 200
|
|
81
|
+
|
|
82
|
+
def test_get_job_returns_job_data(self, client, mock_job_manager, mock_job, auth_headers):
|
|
83
|
+
"""Test response contains job data."""
|
|
84
|
+
response = client.get("/api/jobs/test123", headers=auth_headers)
|
|
85
|
+
data = response.json()
|
|
86
|
+
assert data["job_id"] == "test123"
|
|
87
|
+
assert data["status"] == "pending"
|
|
88
|
+
assert data["artist"] == "Test Artist"
|
|
89
|
+
|
|
90
|
+
def test_get_nonexistent_job_returns_404(self, mock_worker_service, auth_headers):
|
|
91
|
+
"""Test getting non-existent job returns 404."""
|
|
92
|
+
mock_job_manager = MagicMock()
|
|
93
|
+
mock_job_manager.get_job.return_value = None
|
|
94
|
+
mock_creds = MagicMock()
|
|
95
|
+
mock_creds.universe_domain = 'googleapis.com'
|
|
96
|
+
|
|
97
|
+
def mock_job_manager_factory(*args, **kwargs):
|
|
98
|
+
return mock_job_manager
|
|
99
|
+
|
|
100
|
+
with patch('backend.api.routes.jobs.job_manager', mock_job_manager), \
|
|
101
|
+
patch('backend.api.routes.jobs.worker_service', mock_worker_service), \
|
|
102
|
+
patch('backend.services.job_manager.JobManager', mock_job_manager_factory), \
|
|
103
|
+
patch('backend.services.firestore_service.firestore'), \
|
|
104
|
+
patch('backend.services.storage_service.storage'), \
|
|
105
|
+
patch('google.auth.default', return_value=(mock_creds, 'test-project')):
|
|
106
|
+
from backend.main import app
|
|
107
|
+
client = TestClient(app)
|
|
108
|
+
response = client.get("/api/jobs/nonexistent", headers=auth_headers)
|
|
109
|
+
assert response.status_code == 404
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestListJobs:
|
|
113
|
+
"""Tests for GET /api/jobs."""
|
|
114
|
+
|
|
115
|
+
def test_list_jobs_returns_200(self, client, auth_headers):
|
|
116
|
+
"""Test listing jobs returns 200."""
|
|
117
|
+
response = client.get("/api/jobs", headers=auth_headers)
|
|
118
|
+
assert response.status_code == 200
|
|
119
|
+
|
|
120
|
+
def test_list_jobs_returns_array(self, client, mock_job_manager, auth_headers):
|
|
121
|
+
"""Test response is an array of jobs."""
|
|
122
|
+
response = client.get("/api/jobs", headers=auth_headers)
|
|
123
|
+
data = response.json()
|
|
124
|
+
assert isinstance(data, list)
|
|
125
|
+
assert len(data) == 1
|
|
126
|
+
assert data[0]["job_id"] == "test123"
|
|
127
|
+
|
|
128
|
+
def test_list_jobs_with_status_filter(self, client, mock_job_manager, auth_headers):
|
|
129
|
+
"""Test listing jobs with status filter."""
|
|
130
|
+
response = client.get("/api/jobs?status=pending", headers=auth_headers)
|
|
131
|
+
assert response.status_code == 200
|
|
132
|
+
|
|
133
|
+
def test_list_jobs_with_limit(self, client, mock_job_manager, auth_headers):
|
|
134
|
+
"""Test listing jobs with limit."""
|
|
135
|
+
response = client.get("/api/jobs?limit=10", headers=auth_headers)
|
|
136
|
+
assert response.status_code == 200
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestCreateJob:
|
|
140
|
+
"""Tests for POST /api/jobs."""
|
|
141
|
+
|
|
142
|
+
def test_create_job_with_url_returns_200(self, client, mock_job_manager, auth_headers):
|
|
143
|
+
"""Test creating job with URL returns 200."""
|
|
144
|
+
response = client.post(
|
|
145
|
+
"/api/jobs",
|
|
146
|
+
json={"url": "https://youtube.com/watch?v=test123"},
|
|
147
|
+
headers=auth_headers
|
|
148
|
+
)
|
|
149
|
+
assert response.status_code == 200
|
|
150
|
+
|
|
151
|
+
def test_create_job_returns_job_id(self, client, mock_job_manager, auth_headers):
|
|
152
|
+
"""Test create response contains job_id."""
|
|
153
|
+
response = client.post(
|
|
154
|
+
"/api/jobs",
|
|
155
|
+
json={"url": "https://youtube.com/watch?v=test"},
|
|
156
|
+
headers=auth_headers
|
|
157
|
+
)
|
|
158
|
+
data = response.json()
|
|
159
|
+
assert "job_id" in data
|
|
160
|
+
|
|
161
|
+
def test_create_job_with_artist_title(self, client, mock_job_manager, auth_headers):
|
|
162
|
+
"""Test creating job with artist and title."""
|
|
163
|
+
response = client.post(
|
|
164
|
+
"/api/jobs",
|
|
165
|
+
json={
|
|
166
|
+
"url": "https://youtube.com/watch?v=test",
|
|
167
|
+
"artist": "Test Artist",
|
|
168
|
+
"title": "Test Song"
|
|
169
|
+
},
|
|
170
|
+
headers=auth_headers
|
|
171
|
+
)
|
|
172
|
+
assert response.status_code == 200
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestDeleteJob:
|
|
176
|
+
"""Tests for DELETE /api/jobs/{job_id}."""
|
|
177
|
+
|
|
178
|
+
def test_delete_job_returns_200(self, client, mock_job_manager, auth_headers):
|
|
179
|
+
"""Test deleting job returns 200."""
|
|
180
|
+
response = client.delete("/api/jobs/test123", headers=auth_headers)
|
|
181
|
+
assert response.status_code == 200
|
|
182
|
+
|
|
183
|
+
def test_delete_nonexistent_job(self, mock_worker_service, auth_headers):
|
|
184
|
+
"""Test deleting non-existent job."""
|
|
185
|
+
mock_job_manager = MagicMock()
|
|
186
|
+
mock_job_manager.get_job.return_value = None # Job doesn't exist
|
|
187
|
+
mock_job_manager.delete_job.return_value = False
|
|
188
|
+
mock_creds = MagicMock()
|
|
189
|
+
mock_creds.universe_domain = 'googleapis.com'
|
|
190
|
+
|
|
191
|
+
def mock_job_manager_factory(*args, **kwargs):
|
|
192
|
+
return mock_job_manager
|
|
193
|
+
|
|
194
|
+
with patch('backend.api.routes.jobs.job_manager', mock_job_manager), \
|
|
195
|
+
patch('backend.api.routes.jobs.worker_service', mock_worker_service), \
|
|
196
|
+
patch('backend.services.job_manager.JobManager', mock_job_manager_factory), \
|
|
197
|
+
patch('backend.services.firestore_service.firestore'), \
|
|
198
|
+
patch('backend.services.storage_service.storage'), \
|
|
199
|
+
patch('google.auth.default', return_value=(mock_creds, 'test-project')):
|
|
200
|
+
from backend.main import app
|
|
201
|
+
client = TestClient(app)
|
|
202
|
+
response = client.delete("/api/jobs/nonexistent", headers=auth_headers)
|
|
203
|
+
# Either 200 or 404 depending on implementation
|
|
204
|
+
assert response.status_code in [200, 404]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TestCancelJob:
|
|
208
|
+
"""Tests for POST /api/jobs/{job_id}/cancel."""
|
|
209
|
+
|
|
210
|
+
def test_cancel_job_returns_200(self, client, mock_job_manager, mock_job, auth_headers):
|
|
211
|
+
"""Test cancelling job returns 200."""
|
|
212
|
+
mock_job_manager.cancel_job.return_value = mock_job
|
|
213
|
+
response = client.post(
|
|
214
|
+
"/api/jobs/test123/cancel",
|
|
215
|
+
json={},
|
|
216
|
+
headers=auth_headers
|
|
217
|
+
)
|
|
218
|
+
assert response.status_code == 200
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class TestSubmitCorrections:
|
|
222
|
+
"""Tests for POST /api/jobs/{job_id}/corrections."""
|
|
223
|
+
|
|
224
|
+
def test_submit_corrections_returns_200(self, client, mock_job_manager, mock_job, auth_headers):
|
|
225
|
+
"""Test submitting corrections returns 200."""
|
|
226
|
+
# Job needs to be in AWAITING_REVIEW or IN_REVIEW status
|
|
227
|
+
review_job = Job(
|
|
228
|
+
job_id="test123",
|
|
229
|
+
status=JobStatus.IN_REVIEW,
|
|
230
|
+
created_at=datetime.now(UTC),
|
|
231
|
+
updated_at=datetime.now(UTC),
|
|
232
|
+
artist="Test",
|
|
233
|
+
title="Test"
|
|
234
|
+
)
|
|
235
|
+
mock_job_manager.get_job.return_value = review_job
|
|
236
|
+
|
|
237
|
+
response = client.post(
|
|
238
|
+
"/api/jobs/test123/corrections",
|
|
239
|
+
json={
|
|
240
|
+
"corrections": {
|
|
241
|
+
"lines": [],
|
|
242
|
+
"metadata": {"source": "test"}
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
headers=auth_headers
|
|
246
|
+
)
|
|
247
|
+
# Should be 200 or validation error
|
|
248
|
+
assert response.status_code in [200, 400, 422]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class TestSelectInstrumental:
|
|
252
|
+
"""Tests for POST /api/jobs/{job_id}/select-instrumental."""
|
|
253
|
+
|
|
254
|
+
def test_select_instrumental_requires_selection_field(self, client, mock_job_manager, mock_job, auth_headers):
|
|
255
|
+
"""Test instrumental selection requires selection field."""
|
|
256
|
+
response = client.post(
|
|
257
|
+
"/api/jobs/test123/select-instrumental",
|
|
258
|
+
json={}, # Missing selection field
|
|
259
|
+
headers=auth_headers
|
|
260
|
+
)
|
|
261
|
+
# Missing field should cause validation error
|
|
262
|
+
assert response.status_code == 422
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class TestStartReview:
|
|
266
|
+
"""Tests for POST /api/jobs/{job_id}/start-review."""
|
|
267
|
+
|
|
268
|
+
def test_start_review_endpoint_exists(self, client, mock_job_manager, mock_job, auth_headers):
|
|
269
|
+
"""Test start review endpoint exists."""
|
|
270
|
+
response = client.post("/api/jobs/test123/start-review", headers=auth_headers)
|
|
271
|
+
# Should not be 404 or 405
|
|
272
|
+
assert response.status_code not in [404, 405]
|
|
273
|
+
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for LocalEncodingService.
|
|
3
|
+
|
|
4
|
+
Tests cover:
|
|
5
|
+
- Service initialization
|
|
6
|
+
- Hardware acceleration detection
|
|
7
|
+
- FFmpeg command execution
|
|
8
|
+
- Individual encoding methods
|
|
9
|
+
- Full encoding pipeline
|
|
10
|
+
- Dry run mode
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
import pytest
|
|
16
|
+
from unittest.mock import MagicMock, patch, call
|
|
17
|
+
|
|
18
|
+
from backend.services.local_encoding_service import (
|
|
19
|
+
LocalEncodingService,
|
|
20
|
+
EncodingConfig,
|
|
21
|
+
EncodingResult,
|
|
22
|
+
get_local_encoding_service,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestLocalEncodingServiceInit:
|
|
27
|
+
"""Test service initialization."""
|
|
28
|
+
|
|
29
|
+
def test_init_default_values(self):
|
|
30
|
+
"""Test default initialization."""
|
|
31
|
+
service = LocalEncodingService()
|
|
32
|
+
assert service.dry_run is False
|
|
33
|
+
assert "ffmpeg" in service._ffmpeg_base_command
|
|
34
|
+
|
|
35
|
+
def test_init_with_dry_run(self):
|
|
36
|
+
"""Test initialization with dry run mode."""
|
|
37
|
+
service = LocalEncodingService(dry_run=True)
|
|
38
|
+
assert service.dry_run is True
|
|
39
|
+
|
|
40
|
+
def test_init_with_debug_logging(self):
|
|
41
|
+
"""Test initialization with debug logging level."""
|
|
42
|
+
import logging
|
|
43
|
+
service = LocalEncodingService(log_level=logging.DEBUG)
|
|
44
|
+
assert "-loglevel verbose" in service._ffmpeg_base_command
|
|
45
|
+
|
|
46
|
+
def test_init_with_info_logging(self):
|
|
47
|
+
"""Test initialization with info logging level."""
|
|
48
|
+
import logging
|
|
49
|
+
service = LocalEncodingService(log_level=logging.INFO)
|
|
50
|
+
assert "-loglevel fatal" in service._ffmpeg_base_command
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestLocalEncodingServiceHWAccel:
|
|
54
|
+
"""Test hardware acceleration detection."""
|
|
55
|
+
|
|
56
|
+
@patch("subprocess.run")
|
|
57
|
+
def test_detect_nvenc_available(self, mock_run):
|
|
58
|
+
"""Test NVENC detection when available."""
|
|
59
|
+
mock_run.return_value = MagicMock(returncode=0)
|
|
60
|
+
|
|
61
|
+
service = LocalEncodingService()
|
|
62
|
+
# Force detection
|
|
63
|
+
service._detect_and_set_hwaccel()
|
|
64
|
+
|
|
65
|
+
assert service._hwaccel_available is True
|
|
66
|
+
assert service._video_encoder == "h264_nvenc"
|
|
67
|
+
assert service._scale_filter == "scale_cuda"
|
|
68
|
+
|
|
69
|
+
@patch("subprocess.run")
|
|
70
|
+
def test_detect_no_hwaccel(self, mock_run):
|
|
71
|
+
"""Test fallback when no hardware acceleration available."""
|
|
72
|
+
import subprocess
|
|
73
|
+
mock_run.side_effect = subprocess.CalledProcessError(1, "test")
|
|
74
|
+
|
|
75
|
+
service = LocalEncodingService()
|
|
76
|
+
# Force detection
|
|
77
|
+
service._detect_and_set_hwaccel()
|
|
78
|
+
|
|
79
|
+
assert service._hwaccel_available is False
|
|
80
|
+
assert service._video_encoder == "libx264"
|
|
81
|
+
assert service._scale_filter == "scale"
|
|
82
|
+
|
|
83
|
+
def test_nvenc_quality_settings(self):
|
|
84
|
+
"""Test NVENC quality settings for different presets."""
|
|
85
|
+
service = LocalEncodingService()
|
|
86
|
+
service._hwaccel_available = True
|
|
87
|
+
|
|
88
|
+
lossless = service._get_nvenc_quality_settings("lossless")
|
|
89
|
+
assert "cq 0" in lossless
|
|
90
|
+
|
|
91
|
+
medium = service._get_nvenc_quality_settings("medium")
|
|
92
|
+
assert "p4" in medium
|
|
93
|
+
|
|
94
|
+
def test_nvenc_quality_settings_disabled(self):
|
|
95
|
+
"""Test NVENC quality settings when hwaccel is disabled."""
|
|
96
|
+
service = LocalEncodingService()
|
|
97
|
+
service._hwaccel_available = False
|
|
98
|
+
|
|
99
|
+
settings = service._get_nvenc_quality_settings("lossless")
|
|
100
|
+
assert settings == ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestLocalEncodingServiceExecuteCommand:
|
|
104
|
+
"""Test command execution."""
|
|
105
|
+
|
|
106
|
+
@patch("subprocess.run")
|
|
107
|
+
def test_execute_command_success(self, mock_run):
|
|
108
|
+
"""Test successful command execution."""
|
|
109
|
+
mock_run.return_value = MagicMock(returncode=0)
|
|
110
|
+
|
|
111
|
+
service = LocalEncodingService()
|
|
112
|
+
result = service._execute_command("echo test", "Test command")
|
|
113
|
+
|
|
114
|
+
assert result is True
|
|
115
|
+
mock_run.assert_called_once()
|
|
116
|
+
|
|
117
|
+
@patch("subprocess.run")
|
|
118
|
+
def test_execute_command_failure(self, mock_run):
|
|
119
|
+
"""Test command execution failure."""
|
|
120
|
+
import subprocess
|
|
121
|
+
mock_run.side_effect = subprocess.CalledProcessError(
|
|
122
|
+
1, "test", stderr="Error message"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
service = LocalEncodingService()
|
|
126
|
+
result = service._execute_command("echo test", "Test command")
|
|
127
|
+
|
|
128
|
+
assert result is False
|
|
129
|
+
|
|
130
|
+
@patch("subprocess.run")
|
|
131
|
+
def test_execute_command_timeout(self, mock_run):
|
|
132
|
+
"""Test command execution timeout."""
|
|
133
|
+
import subprocess
|
|
134
|
+
mock_run.side_effect = subprocess.TimeoutExpired("test", 30)
|
|
135
|
+
|
|
136
|
+
service = LocalEncodingService()
|
|
137
|
+
result = service._execute_command("echo test", "Test command", timeout=30)
|
|
138
|
+
|
|
139
|
+
assert result is False
|
|
140
|
+
|
|
141
|
+
def test_execute_command_dry_run(self):
|
|
142
|
+
"""Test command execution in dry run mode."""
|
|
143
|
+
service = LocalEncodingService(dry_run=True)
|
|
144
|
+
result = service._execute_command("echo test", "Test command")
|
|
145
|
+
|
|
146
|
+
assert result is True
|
|
147
|
+
# No actual subprocess should be called in dry run mode
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TestLocalEncodingServiceEncodingMethods:
|
|
151
|
+
"""Test individual encoding methods."""
|
|
152
|
+
|
|
153
|
+
@patch.object(LocalEncodingService, "_execute_command")
|
|
154
|
+
def test_remux_with_instrumental(self, mock_execute):
|
|
155
|
+
"""Test remuxing with instrumental audio."""
|
|
156
|
+
mock_execute.return_value = True
|
|
157
|
+
|
|
158
|
+
service = LocalEncodingService()
|
|
159
|
+
result = service.remux_with_instrumental(
|
|
160
|
+
"/input/video.mov",
|
|
161
|
+
"/input/audio.flac",
|
|
162
|
+
"/output/video.mp4"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
assert result is True
|
|
166
|
+
mock_execute.assert_called_once()
|
|
167
|
+
call_args = mock_execute.call_args[0][0]
|
|
168
|
+
assert "/input/video.mov" in call_args
|
|
169
|
+
assert "/input/audio.flac" in call_args
|
|
170
|
+
assert "-map 0:v -map 1:a" in call_args
|
|
171
|
+
|
|
172
|
+
@patch.object(LocalEncodingService, "_execute_command_with_fallback")
|
|
173
|
+
def test_convert_mov_to_mp4(self, mock_execute):
|
|
174
|
+
"""Test MOV to MP4 conversion."""
|
|
175
|
+
mock_execute.return_value = True
|
|
176
|
+
|
|
177
|
+
service = LocalEncodingService()
|
|
178
|
+
result = service.convert_mov_to_mp4(
|
|
179
|
+
"/input/video.mov",
|
|
180
|
+
"/output/video.mp4"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
assert result is True
|
|
184
|
+
mock_execute.assert_called_once()
|
|
185
|
+
|
|
186
|
+
@patch.object(LocalEncodingService, "_execute_command_with_fallback")
|
|
187
|
+
def test_encode_lossless_mp4_without_end(self, mock_execute):
|
|
188
|
+
"""Test lossless 4K MP4 encoding without end credits."""
|
|
189
|
+
mock_execute.return_value = True
|
|
190
|
+
|
|
191
|
+
service = LocalEncodingService()
|
|
192
|
+
result = service.encode_lossless_mp4(
|
|
193
|
+
"/input/title.mov",
|
|
194
|
+
"/input/karaoke.mp4",
|
|
195
|
+
"/output/lossless.mp4"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
assert result is True
|
|
199
|
+
mock_execute.assert_called_once()
|
|
200
|
+
call_args = mock_execute.call_args
|
|
201
|
+
assert "concat=n=2" in call_args[0][0] # GPU command
|
|
202
|
+
|
|
203
|
+
@patch.object(LocalEncodingService, "_execute_command_with_fallback")
|
|
204
|
+
def test_encode_lossless_mp4_with_end(self, mock_execute):
|
|
205
|
+
"""Test lossless 4K MP4 encoding with end credits."""
|
|
206
|
+
mock_execute.return_value = True
|
|
207
|
+
|
|
208
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
209
|
+
end_file = os.path.join(tmpdir, "end.mov")
|
|
210
|
+
with open(end_file, "w") as f:
|
|
211
|
+
f.write("fake video")
|
|
212
|
+
|
|
213
|
+
service = LocalEncodingService()
|
|
214
|
+
result = service.encode_lossless_mp4(
|
|
215
|
+
"/input/title.mov",
|
|
216
|
+
"/input/karaoke.mp4",
|
|
217
|
+
"/output/lossless.mp4",
|
|
218
|
+
end_video=end_file
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
assert result is True
|
|
222
|
+
call_args = mock_execute.call_args
|
|
223
|
+
assert "concat=n=3" in call_args[0][0] # 3 videos
|
|
224
|
+
|
|
225
|
+
@patch.object(LocalEncodingService, "_execute_command")
|
|
226
|
+
def test_encode_lossy_mp4(self, mock_execute):
|
|
227
|
+
"""Test lossy 4K MP4 encoding."""
|
|
228
|
+
mock_execute.return_value = True
|
|
229
|
+
|
|
230
|
+
service = LocalEncodingService()
|
|
231
|
+
result = service.encode_lossy_mp4(
|
|
232
|
+
"/input/lossless.mp4",
|
|
233
|
+
"/output/lossy.mp4"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
assert result is True
|
|
237
|
+
call_args = mock_execute.call_args[0][0]
|
|
238
|
+
assert "-c:v copy" in call_args
|
|
239
|
+
assert "aac" in call_args.lower()
|
|
240
|
+
|
|
241
|
+
@patch.object(LocalEncodingService, "_execute_command")
|
|
242
|
+
def test_encode_lossless_mkv(self, mock_execute):
|
|
243
|
+
"""Test MKV encoding with FLAC audio."""
|
|
244
|
+
mock_execute.return_value = True
|
|
245
|
+
|
|
246
|
+
service = LocalEncodingService()
|
|
247
|
+
result = service.encode_lossless_mkv(
|
|
248
|
+
"/input/lossless.mp4",
|
|
249
|
+
"/output/video.mkv"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
assert result is True
|
|
253
|
+
call_args = mock_execute.call_args[0][0]
|
|
254
|
+
assert "-c:v copy" in call_args
|
|
255
|
+
assert "-c:a flac" in call_args
|
|
256
|
+
|
|
257
|
+
@patch.object(LocalEncodingService, "_execute_command_with_fallback")
|
|
258
|
+
def test_encode_720p(self, mock_execute):
|
|
259
|
+
"""Test 720p encoding."""
|
|
260
|
+
mock_execute.return_value = True
|
|
261
|
+
|
|
262
|
+
service = LocalEncodingService()
|
|
263
|
+
result = service.encode_720p(
|
|
264
|
+
"/input/lossless.mp4",
|
|
265
|
+
"/output/720p.mp4"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
assert result is True
|
|
269
|
+
call_args = mock_execute.call_args
|
|
270
|
+
# Should contain scale filter
|
|
271
|
+
assert "1280:720" in call_args[0][0] or "1280:720" in call_args[0][1]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class TestLocalEncodingServiceFullPipeline:
|
|
275
|
+
"""Test full encoding pipeline."""
|
|
276
|
+
|
|
277
|
+
@patch.object(LocalEncodingService, "remux_with_instrumental")
|
|
278
|
+
@patch.object(LocalEncodingService, "convert_mov_to_mp4")
|
|
279
|
+
@patch.object(LocalEncodingService, "encode_lossless_mp4")
|
|
280
|
+
@patch.object(LocalEncodingService, "encode_lossy_mp4")
|
|
281
|
+
@patch.object(LocalEncodingService, "encode_lossless_mkv")
|
|
282
|
+
@patch.object(LocalEncodingService, "encode_720p")
|
|
283
|
+
def test_encode_all_formats_success(
|
|
284
|
+
self, mock_720p, mock_mkv, mock_lossy, mock_lossless, mock_convert, mock_remux
|
|
285
|
+
):
|
|
286
|
+
"""Test successful full encoding pipeline."""
|
|
287
|
+
mock_remux.return_value = True
|
|
288
|
+
mock_convert.return_value = True
|
|
289
|
+
mock_lossless.return_value = True
|
|
290
|
+
mock_lossy.return_value = True
|
|
291
|
+
mock_mkv.return_value = True
|
|
292
|
+
mock_720p.return_value = True
|
|
293
|
+
|
|
294
|
+
service = LocalEncodingService()
|
|
295
|
+
config = EncodingConfig(
|
|
296
|
+
title_video="/input/title.mov",
|
|
297
|
+
karaoke_video="/input/karaoke.mov",
|
|
298
|
+
instrumental_audio="/input/instrumental.flac",
|
|
299
|
+
output_karaoke_mp4="/output/karaoke.mp4",
|
|
300
|
+
output_with_vocals_mp4="/output/with_vocals.mp4",
|
|
301
|
+
output_lossless_4k_mp4="/output/lossless_4k.mp4",
|
|
302
|
+
output_lossy_4k_mp4="/output/lossy_4k.mp4",
|
|
303
|
+
output_lossless_mkv="/output/lossless.mkv",
|
|
304
|
+
output_720p_mp4="/output/720p.mp4",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
result = service.encode_all_formats(config)
|
|
308
|
+
|
|
309
|
+
assert result.success is True
|
|
310
|
+
assert "karaoke_mp4" in result.output_files
|
|
311
|
+
assert "lossless_4k_mp4" in result.output_files
|
|
312
|
+
assert "720p_mp4" in result.output_files
|
|
313
|
+
|
|
314
|
+
@patch.object(LocalEncodingService, "remux_with_instrumental")
|
|
315
|
+
def test_encode_all_formats_failure_early(self, mock_remux):
|
|
316
|
+
"""Test encoding pipeline failure at early step."""
|
|
317
|
+
mock_remux.return_value = False
|
|
318
|
+
|
|
319
|
+
service = LocalEncodingService()
|
|
320
|
+
config = EncodingConfig(
|
|
321
|
+
title_video="/input/title.mov",
|
|
322
|
+
karaoke_video="/input/karaoke.mov",
|
|
323
|
+
instrumental_audio="/input/instrumental.flac",
|
|
324
|
+
output_karaoke_mp4="/output/karaoke.mp4",
|
|
325
|
+
output_lossless_4k_mp4="/output/lossless_4k.mp4",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
result = service.encode_all_formats(config)
|
|
329
|
+
|
|
330
|
+
assert result.success is False
|
|
331
|
+
assert "Failed to remux" in result.error
|
|
332
|
+
|
|
333
|
+
def test_encode_all_formats_dry_run(self):
|
|
334
|
+
"""Test encoding pipeline in dry run mode."""
|
|
335
|
+
service = LocalEncodingService(dry_run=True)
|
|
336
|
+
config = EncodingConfig(
|
|
337
|
+
title_video="/input/title.mov",
|
|
338
|
+
karaoke_video="/input/karaoke.mp4", # Already MP4
|
|
339
|
+
instrumental_audio="/input/instrumental.flac",
|
|
340
|
+
output_karaoke_mp4="/output/karaoke.mp4",
|
|
341
|
+
output_lossless_4k_mp4="/output/lossless_4k.mp4",
|
|
342
|
+
output_lossy_4k_mp4="/output/lossy_4k.mp4",
|
|
343
|
+
output_lossless_mkv="/output/lossless.mkv",
|
|
344
|
+
output_720p_mp4="/output/720p.mp4",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
result = service.encode_all_formats(config)
|
|
348
|
+
|
|
349
|
+
# In dry run mode, all operations should "succeed"
|
|
350
|
+
assert result.success is True
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class TestEncodingConfig:
|
|
354
|
+
"""Test EncodingConfig dataclass."""
|
|
355
|
+
|
|
356
|
+
def test_config_required_fields(self):
|
|
357
|
+
"""Test that required fields must be provided."""
|
|
358
|
+
config = EncodingConfig(
|
|
359
|
+
title_video="/path/title.mov",
|
|
360
|
+
karaoke_video="/path/karaoke.mov",
|
|
361
|
+
instrumental_audio="/path/audio.flac",
|
|
362
|
+
)
|
|
363
|
+
assert config.title_video == "/path/title.mov"
|
|
364
|
+
assert config.end_video is None # Optional field
|
|
365
|
+
|
|
366
|
+
def test_config_all_fields(self):
|
|
367
|
+
"""Test config with all fields."""
|
|
368
|
+
config = EncodingConfig(
|
|
369
|
+
title_video="/path/title.mov",
|
|
370
|
+
karaoke_video="/path/karaoke.mov",
|
|
371
|
+
instrumental_audio="/path/audio.flac",
|
|
372
|
+
end_video="/path/end.mov",
|
|
373
|
+
output_karaoke_mp4="/output/karaoke.mp4",
|
|
374
|
+
output_720p_mp4="/output/720p.mp4",
|
|
375
|
+
)
|
|
376
|
+
assert config.end_video == "/path/end.mov"
|
|
377
|
+
assert config.output_karaoke_mp4 == "/output/karaoke.mp4"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class TestEncodingResult:
|
|
381
|
+
"""Test EncodingResult dataclass."""
|
|
382
|
+
|
|
383
|
+
def test_result_success(self):
|
|
384
|
+
"""Test successful result."""
|
|
385
|
+
result = EncodingResult(
|
|
386
|
+
success=True,
|
|
387
|
+
output_files={"key": "/path/file.mp4"}
|
|
388
|
+
)
|
|
389
|
+
assert result.success is True
|
|
390
|
+
assert result.error is None
|
|
391
|
+
|
|
392
|
+
def test_result_failure(self):
|
|
393
|
+
"""Test failure result."""
|
|
394
|
+
result = EncodingResult(
|
|
395
|
+
success=False,
|
|
396
|
+
output_files={},
|
|
397
|
+
error="Something went wrong"
|
|
398
|
+
)
|
|
399
|
+
assert result.success is False
|
|
400
|
+
assert result.error == "Something went wrong"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class TestGetLocalEncodingService:
|
|
404
|
+
"""Test factory function."""
|
|
405
|
+
|
|
406
|
+
def test_get_service_creates_instance(self):
|
|
407
|
+
"""Test that factory function creates a new instance."""
|
|
408
|
+
import backend.services.local_encoding_service as module
|
|
409
|
+
module._local_encoding_service = None
|
|
410
|
+
|
|
411
|
+
service = get_local_encoding_service()
|
|
412
|
+
|
|
413
|
+
assert service is not None
|
|
414
|
+
assert isinstance(service, LocalEncodingService)
|
|
415
|
+
|
|
416
|
+
def test_get_service_with_dry_run(self):
|
|
417
|
+
"""Test factory function with dry run option."""
|
|
418
|
+
import backend.services.local_encoding_service as module
|
|
419
|
+
module._local_encoding_service = None
|
|
420
|
+
|
|
421
|
+
service = get_local_encoding_service(dry_run=True)
|
|
422
|
+
|
|
423
|
+
assert service.dry_run is True
|