karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +835 -0
- backend/api/routes/audio_search.py +913 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2112 -0
- backend/api/routes/health.py +409 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1629 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1513 -0
- backend/config.py +172 -0
- backend/main.py +157 -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 +502 -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 +853 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/langfuse_preloader.py +98 -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/nltk_preloader.py +122 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +371 -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 +109 -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 +356 -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 +283 -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_spacy_preloader.py +119 -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/utils/test_data.py +27 -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 +535 -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.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- 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.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for jobs.py routes.
|
|
3
|
+
|
|
4
|
+
These tests exercise the route logic with mocked services.
|
|
5
|
+
"""
|
|
6
|
+
import pytest
|
|
7
|
+
from datetime import datetime, UTC
|
|
8
|
+
from unittest.mock import MagicMock, AsyncMock, patch
|
|
9
|
+
|
|
10
|
+
from backend.models.job import Job, JobStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestJobsRouteHelpers:
|
|
14
|
+
"""Tests for helper functions in jobs.py routes."""
|
|
15
|
+
|
|
16
|
+
def test_jobs_router_structure(self):
|
|
17
|
+
"""Test jobs router has expected structure."""
|
|
18
|
+
from backend.api.routes.jobs import router
|
|
19
|
+
assert router is not None
|
|
20
|
+
|
|
21
|
+
# Check that common route patterns exist
|
|
22
|
+
route_paths = [route.path for route in router.routes]
|
|
23
|
+
assert any('/jobs' in p or 'jobs' in str(p) for p in route_paths)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestJobStatusTransitions:
|
|
27
|
+
"""Tests for job status transition validation.
|
|
28
|
+
|
|
29
|
+
These test the Job model's state machine which is critical for
|
|
30
|
+
preventing invalid operations.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def test_valid_pending_to_downloading(self):
|
|
34
|
+
"""Test PENDING -> DOWNLOADING is valid."""
|
|
35
|
+
from backend.models.job import STATE_TRANSITIONS
|
|
36
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.PENDING, [])
|
|
37
|
+
assert JobStatus.DOWNLOADING in valid_transitions
|
|
38
|
+
|
|
39
|
+
def test_valid_downloading_to_separating(self):
|
|
40
|
+
"""Test DOWNLOADING -> SEPARATING_STAGE1 is valid."""
|
|
41
|
+
from backend.models.job import STATE_TRANSITIONS
|
|
42
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.DOWNLOADING, [])
|
|
43
|
+
assert JobStatus.SEPARATING_STAGE1 in valid_transitions
|
|
44
|
+
|
|
45
|
+
def test_invalid_pending_to_complete(self):
|
|
46
|
+
"""Test PENDING -> COMPLETE is invalid."""
|
|
47
|
+
from backend.models.job import STATE_TRANSITIONS
|
|
48
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.PENDING, [])
|
|
49
|
+
assert JobStatus.COMPLETE not in valid_transitions
|
|
50
|
+
|
|
51
|
+
def test_any_active_status_can_fail(self):
|
|
52
|
+
"""Test any active (non-terminal, non-legacy) status can transition to FAILED."""
|
|
53
|
+
from backend.models.job import STATE_TRANSITIONS
|
|
54
|
+
# Terminal states and legacy states are excluded
|
|
55
|
+
excluded = [
|
|
56
|
+
JobStatus.COMPLETE, JobStatus.FAILED, JobStatus.CANCELLED,
|
|
57
|
+
# Legacy states that don't include FAILED
|
|
58
|
+
JobStatus.QUEUED, JobStatus.PROCESSING,
|
|
59
|
+
JobStatus.READY_FOR_FINALIZATION, JobStatus.FINALIZING, JobStatus.ERROR
|
|
60
|
+
]
|
|
61
|
+
for status in JobStatus:
|
|
62
|
+
if status not in excluded:
|
|
63
|
+
valid_transitions = STATE_TRANSITIONS.get(status, [])
|
|
64
|
+
assert JobStatus.FAILED in valid_transitions, f"{status} should be able to fail"
|
|
65
|
+
|
|
66
|
+
def test_failed_can_transition_for_retry(self):
|
|
67
|
+
"""Test FAILED status can transition to retry checkpoint states."""
|
|
68
|
+
from backend.models.job import STATE_TRANSITIONS
|
|
69
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.FAILED, [])
|
|
70
|
+
|
|
71
|
+
# FAILED should allow retry transitions
|
|
72
|
+
assert JobStatus.INSTRUMENTAL_SELECTED in valid_transitions, "FAILED should allow retry to INSTRUMENTAL_SELECTED"
|
|
73
|
+
assert JobStatus.REVIEW_COMPLETE in valid_transitions, "FAILED should allow retry to REVIEW_COMPLETE"
|
|
74
|
+
assert JobStatus.LYRICS_COMPLETE in valid_transitions, "FAILED should allow retry to LYRICS_COMPLETE"
|
|
75
|
+
|
|
76
|
+
def test_cancelled_can_transition_for_retry(self):
|
|
77
|
+
"""Test CANCELLED status can transition to retry checkpoint states."""
|
|
78
|
+
from backend.models.job import STATE_TRANSITIONS
|
|
79
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.CANCELLED, [])
|
|
80
|
+
|
|
81
|
+
# CANCELLED should allow same retry transitions as FAILED
|
|
82
|
+
assert JobStatus.INSTRUMENTAL_SELECTED in valid_transitions, "CANCELLED should allow retry to INSTRUMENTAL_SELECTED"
|
|
83
|
+
assert JobStatus.REVIEW_COMPLETE in valid_transitions, "CANCELLED should allow retry to REVIEW_COMPLETE"
|
|
84
|
+
assert JobStatus.LYRICS_COMPLETE in valid_transitions, "CANCELLED should allow retry to LYRICS_COMPLETE"
|
|
85
|
+
assert JobStatus.DOWNLOADING in valid_transitions, "CANCELLED should allow retry to DOWNLOADING (restart)"
|
|
86
|
+
|
|
87
|
+
def test_failed_can_restart_from_beginning(self):
|
|
88
|
+
"""Test FAILED status can transition to DOWNLOADING for restart from beginning."""
|
|
89
|
+
from backend.models.job import STATE_TRANSITIONS
|
|
90
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.FAILED, [])
|
|
91
|
+
|
|
92
|
+
# FAILED should allow restart from beginning
|
|
93
|
+
assert JobStatus.DOWNLOADING in valid_transitions, "FAILED should allow restart to DOWNLOADING"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestJobModelSerialization:
|
|
97
|
+
"""Tests for Job model serialization to/from API."""
|
|
98
|
+
|
|
99
|
+
def test_job_to_dict(self):
|
|
100
|
+
"""Test Job converts to dict correctly."""
|
|
101
|
+
job = Job(
|
|
102
|
+
job_id="test123",
|
|
103
|
+
status=JobStatus.PENDING,
|
|
104
|
+
created_at=datetime.now(UTC),
|
|
105
|
+
updated_at=datetime.now(UTC),
|
|
106
|
+
artist="Test Artist",
|
|
107
|
+
title="Test Song"
|
|
108
|
+
)
|
|
109
|
+
data = job.model_dump()
|
|
110
|
+
assert data["job_id"] == "test123"
|
|
111
|
+
assert data["status"] == "pending"
|
|
112
|
+
assert data["artist"] == "Test Artist"
|
|
113
|
+
|
|
114
|
+
def test_job_from_dict(self):
|
|
115
|
+
"""Test Job can be created from dict."""
|
|
116
|
+
data = {
|
|
117
|
+
"job_id": "test123",
|
|
118
|
+
"status": "pending",
|
|
119
|
+
"created_at": datetime.now(UTC).isoformat(),
|
|
120
|
+
"updated_at": datetime.now(UTC).isoformat(),
|
|
121
|
+
"artist": "Test",
|
|
122
|
+
"title": "Song"
|
|
123
|
+
}
|
|
124
|
+
job = Job.model_validate(data)
|
|
125
|
+
assert job.job_id == "test123"
|
|
126
|
+
assert job.status == JobStatus.PENDING
|
|
127
|
+
|
|
128
|
+
def test_job_handles_null_optional_fields(self):
|
|
129
|
+
"""Test Job handles null optional fields."""
|
|
130
|
+
job = Job(
|
|
131
|
+
job_id="test",
|
|
132
|
+
status=JobStatus.PENDING,
|
|
133
|
+
created_at=datetime.now(UTC),
|
|
134
|
+
updated_at=datetime.now(UTC)
|
|
135
|
+
)
|
|
136
|
+
assert job.artist is None
|
|
137
|
+
assert job.title is None
|
|
138
|
+
assert job.url is None
|
|
139
|
+
assert job.file_urls == {}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class TestRetryEndpoint:
|
|
143
|
+
"""Tests for the retry job endpoint."""
|
|
144
|
+
|
|
145
|
+
@pytest.fixture
|
|
146
|
+
def mock_job_manager(self):
|
|
147
|
+
"""Create mock job manager."""
|
|
148
|
+
return MagicMock()
|
|
149
|
+
|
|
150
|
+
@pytest.fixture
|
|
151
|
+
def mock_worker_service(self):
|
|
152
|
+
"""Create mock worker service."""
|
|
153
|
+
return MagicMock()
|
|
154
|
+
|
|
155
|
+
def test_retry_rejects_pending_job(self, mock_job_manager):
|
|
156
|
+
"""Test retry endpoint rejects jobs that are not failed or cancelled."""
|
|
157
|
+
# Create a pending job
|
|
158
|
+
job = Job(
|
|
159
|
+
job_id="test123",
|
|
160
|
+
status=JobStatus.PENDING,
|
|
161
|
+
created_at=datetime.now(UTC),
|
|
162
|
+
updated_at=datetime.now(UTC),
|
|
163
|
+
artist="Test",
|
|
164
|
+
title="Song"
|
|
165
|
+
)
|
|
166
|
+
mock_job_manager.get_job.return_value = job
|
|
167
|
+
|
|
168
|
+
# The endpoint should reject this with a 400 error
|
|
169
|
+
# (status check happens before any logic)
|
|
170
|
+
assert job.status not in [JobStatus.FAILED, JobStatus.CANCELLED]
|
|
171
|
+
|
|
172
|
+
def test_retry_accepts_failed_job(self, mock_job_manager):
|
|
173
|
+
"""Test retry endpoint accepts failed jobs."""
|
|
174
|
+
job = Job(
|
|
175
|
+
job_id="test123",
|
|
176
|
+
status=JobStatus.FAILED,
|
|
177
|
+
created_at=datetime.now(UTC),
|
|
178
|
+
updated_at=datetime.now(UTC),
|
|
179
|
+
artist="Test",
|
|
180
|
+
title="Song",
|
|
181
|
+
input_media_gcs_path="jobs/test123/input/audio.flac"
|
|
182
|
+
)
|
|
183
|
+
mock_job_manager.get_job.return_value = job
|
|
184
|
+
|
|
185
|
+
# Status should be accepted
|
|
186
|
+
assert job.status in [JobStatus.FAILED, JobStatus.CANCELLED]
|
|
187
|
+
|
|
188
|
+
def test_retry_accepts_cancelled_job(self, mock_job_manager):
|
|
189
|
+
"""Test retry endpoint accepts cancelled jobs."""
|
|
190
|
+
job = Job(
|
|
191
|
+
job_id="test123",
|
|
192
|
+
status=JobStatus.CANCELLED,
|
|
193
|
+
created_at=datetime.now(UTC),
|
|
194
|
+
updated_at=datetime.now(UTC),
|
|
195
|
+
artist="Test",
|
|
196
|
+
title="Song",
|
|
197
|
+
input_media_gcs_path="jobs/test123/input/audio.flac"
|
|
198
|
+
)
|
|
199
|
+
mock_job_manager.get_job.return_value = job
|
|
200
|
+
|
|
201
|
+
# Status should be accepted
|
|
202
|
+
assert job.status in [JobStatus.FAILED, JobStatus.CANCELLED]
|
|
203
|
+
|
|
204
|
+
def test_retry_checkpoint_detection_video_generation(self):
|
|
205
|
+
"""Test retry detects video generation checkpoint."""
|
|
206
|
+
job = Job(
|
|
207
|
+
job_id="test123",
|
|
208
|
+
status=JobStatus.FAILED,
|
|
209
|
+
created_at=datetime.now(UTC),
|
|
210
|
+
updated_at=datetime.now(UTC),
|
|
211
|
+
artist="Test",
|
|
212
|
+
title="Song",
|
|
213
|
+
file_urls={
|
|
214
|
+
'videos': {'with_vocals': 'gs://bucket/path/video.mkv'}
|
|
215
|
+
},
|
|
216
|
+
state_data={
|
|
217
|
+
'instrumental_selection': 'clean'
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# This job has video and instrumental selection - should retry from video generation
|
|
222
|
+
has_video = job.file_urls.get('videos', {}).get('with_vocals')
|
|
223
|
+
has_instrumental_selection = (job.state_data or {}).get('instrumental_selection')
|
|
224
|
+
assert has_video and has_instrumental_selection
|
|
225
|
+
|
|
226
|
+
def test_retry_checkpoint_detection_render_stage(self):
|
|
227
|
+
"""Test retry detects render stage checkpoint."""
|
|
228
|
+
job = Job(
|
|
229
|
+
job_id="test123",
|
|
230
|
+
status=JobStatus.FAILED,
|
|
231
|
+
created_at=datetime.now(UTC),
|
|
232
|
+
updated_at=datetime.now(UTC),
|
|
233
|
+
artist="Test",
|
|
234
|
+
title="Song",
|
|
235
|
+
file_urls={
|
|
236
|
+
'lyrics': {'corrections': 'gs://bucket/path/corrections.json'},
|
|
237
|
+
'screens': {'title': 'gs://bucket/path/title.mov'}
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# This job has corrections and screens - should retry from render
|
|
242
|
+
has_corrections = job.file_urls.get('lyrics', {}).get('corrections')
|
|
243
|
+
has_screens = job.file_urls.get('screens', {}).get('title')
|
|
244
|
+
has_video = job.file_urls.get('videos', {}).get('with_vocals')
|
|
245
|
+
assert has_corrections and has_screens and not has_video
|
|
246
|
+
|
|
247
|
+
def test_retry_checkpoint_detection_from_beginning(self):
|
|
248
|
+
"""Test retry detects need to restart from beginning."""
|
|
249
|
+
job = Job(
|
|
250
|
+
job_id="test123",
|
|
251
|
+
status=JobStatus.CANCELLED,
|
|
252
|
+
created_at=datetime.now(UTC),
|
|
253
|
+
updated_at=datetime.now(UTC),
|
|
254
|
+
artist="Test",
|
|
255
|
+
title="Song",
|
|
256
|
+
input_media_gcs_path="jobs/test123/input/audio.flac",
|
|
257
|
+
file_urls={} # No progress yet
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# This job has input audio but no other files - should restart from beginning
|
|
261
|
+
has_input = job.input_media_gcs_path
|
|
262
|
+
has_stems = job.file_urls.get('stems', {}).get('instrumental_clean')
|
|
263
|
+
has_corrections = job.file_urls.get('lyrics', {}).get('corrections')
|
|
264
|
+
assert has_input and not has_stems and not has_corrections
|
|
265
|
+
|
|
266
|
+
def test_retry_no_input_audio(self):
|
|
267
|
+
"""Test retry fails when no input audio available."""
|
|
268
|
+
job = Job(
|
|
269
|
+
job_id="test123",
|
|
270
|
+
status=JobStatus.CANCELLED,
|
|
271
|
+
created_at=datetime.now(UTC),
|
|
272
|
+
updated_at=datetime.now(UTC),
|
|
273
|
+
artist="Test",
|
|
274
|
+
title="Song",
|
|
275
|
+
# No input_media_gcs_path and no url
|
|
276
|
+
file_urls={}
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# This job has no input audio - should not be retryable
|
|
280
|
+
has_input = job.input_media_gcs_path or job.url
|
|
281
|
+
assert not has_input
|
|
282
|
+
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for review.py routes and related components.
|
|
3
|
+
|
|
4
|
+
These tests verify the review-related state transitions and data structures.
|
|
5
|
+
Tests that require full backend imports are in the emulator integration tests.
|
|
6
|
+
"""
|
|
7
|
+
import pytest
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestJobStatusTransitionsForReview:
|
|
12
|
+
"""Tests for review-related state transitions.
|
|
13
|
+
|
|
14
|
+
These tests verify the Job model's state machine handles review flow correctly.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def test_awaiting_review_can_transition_to_in_review(self):
|
|
18
|
+
"""Test AWAITING_REVIEW -> IN_REVIEW is valid."""
|
|
19
|
+
from backend.models.job import STATE_TRANSITIONS, JobStatus
|
|
20
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.AWAITING_REVIEW, [])
|
|
21
|
+
assert JobStatus.IN_REVIEW in valid_transitions
|
|
22
|
+
|
|
23
|
+
def test_awaiting_review_can_transition_to_review_complete(self):
|
|
24
|
+
"""Test AWAITING_REVIEW -> REVIEW_COMPLETE is valid (skip in_review)."""
|
|
25
|
+
from backend.models.job import STATE_TRANSITIONS, JobStatus
|
|
26
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.AWAITING_REVIEW, [])
|
|
27
|
+
assert JobStatus.REVIEW_COMPLETE in valid_transitions
|
|
28
|
+
|
|
29
|
+
def test_in_review_can_transition_to_review_complete(self):
|
|
30
|
+
"""Test IN_REVIEW -> REVIEW_COMPLETE is valid."""
|
|
31
|
+
from backend.models.job import STATE_TRANSITIONS, JobStatus
|
|
32
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.IN_REVIEW, [])
|
|
33
|
+
assert JobStatus.REVIEW_COMPLETE in valid_transitions
|
|
34
|
+
|
|
35
|
+
def test_review_complete_transitions_to_rendering_video(self):
|
|
36
|
+
"""Test REVIEW_COMPLETE -> RENDERING_VIDEO is valid."""
|
|
37
|
+
from backend.models.job import STATE_TRANSITIONS, JobStatus
|
|
38
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.REVIEW_COMPLETE, [])
|
|
39
|
+
assert JobStatus.RENDERING_VIDEO in valid_transitions
|
|
40
|
+
|
|
41
|
+
def test_rendering_video_status_exists(self):
|
|
42
|
+
"""Test RENDERING_VIDEO status exists in JobStatus enum."""
|
|
43
|
+
from backend.models.job import JobStatus
|
|
44
|
+
assert hasattr(JobStatus, 'RENDERING_VIDEO')
|
|
45
|
+
assert JobStatus.RENDERING_VIDEO.value == "rendering_video"
|
|
46
|
+
|
|
47
|
+
def test_rendering_video_transitions_to_instrumental(self):
|
|
48
|
+
"""Test RENDERING_VIDEO -> AWAITING_INSTRUMENTAL_SELECTION is valid."""
|
|
49
|
+
from backend.models.job import STATE_TRANSITIONS, JobStatus
|
|
50
|
+
valid_transitions = STATE_TRANSITIONS.get(JobStatus.RENDERING_VIDEO, [])
|
|
51
|
+
assert JobStatus.AWAITING_INSTRUMENTAL_SELECTION in valid_transitions
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestStylesConfigRequirements:
|
|
55
|
+
"""Tests documenting the required fields for ASS subtitle generation.
|
|
56
|
+
|
|
57
|
+
These tests verify that the styles config format is documented correctly.
|
|
58
|
+
The actual styles creation is tested in integration tests.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def test_required_karaoke_fields_documented(self):
|
|
62
|
+
"""Document required karaoke style fields for ASS generation."""
|
|
63
|
+
# These fields are required by the ASS subtitle generator
|
|
64
|
+
# Missing any of them will cause video generation to fail
|
|
65
|
+
required_fields = [
|
|
66
|
+
"font", # Font name for ASS
|
|
67
|
+
"font_path", # Path to font file (can be empty string, NOT None)
|
|
68
|
+
"ass_name", # Style name in ASS file
|
|
69
|
+
"primary_color", # Format: "R, G, B, A"
|
|
70
|
+
"secondary_color", # Format: "R, G, B, A"
|
|
71
|
+
"outline_color", # Format: "R, G, B, A"
|
|
72
|
+
"back_color", # Format: "R, G, B, A"
|
|
73
|
+
"bold", # Boolean
|
|
74
|
+
"italic", # Boolean
|
|
75
|
+
"underline", # Boolean
|
|
76
|
+
"strike_out", # Boolean
|
|
77
|
+
"scale_x", # Integer
|
|
78
|
+
"scale_y", # Integer
|
|
79
|
+
"spacing", # Integer
|
|
80
|
+
"angle", # Float
|
|
81
|
+
"border_style", # Integer
|
|
82
|
+
"outline", # Integer
|
|
83
|
+
"shadow", # Integer
|
|
84
|
+
"margin_l", # Integer
|
|
85
|
+
"margin_r", # Integer
|
|
86
|
+
"margin_v", # Integer
|
|
87
|
+
"encoding", # Integer
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
# This test documents the required fields
|
|
91
|
+
# Actual validation happens in integration tests
|
|
92
|
+
assert len(required_fields) == 22
|
|
93
|
+
assert "ass_name" in required_fields # This was missing initially
|
|
94
|
+
assert "font_path" in required_fields # Must be string, not None
|
|
95
|
+
|
|
96
|
+
def test_minimal_styles_structure(self):
|
|
97
|
+
"""Document the minimal styles JSON structure."""
|
|
98
|
+
minimal_styles = {
|
|
99
|
+
"karaoke": {
|
|
100
|
+
"background_color": "#000000",
|
|
101
|
+
"font_path": "", # MUST be string, NOT None
|
|
102
|
+
"font": "Noto Sans",
|
|
103
|
+
"ass_name": "Default", # REQUIRED
|
|
104
|
+
"primary_color": "112, 112, 247, 255",
|
|
105
|
+
"secondary_color": "255, 255, 255, 255",
|
|
106
|
+
"outline_color": "26, 58, 235, 255",
|
|
107
|
+
"back_color": "0, 0, 0, 0",
|
|
108
|
+
"bold": False,
|
|
109
|
+
"italic": False,
|
|
110
|
+
"underline": False,
|
|
111
|
+
"strike_out": False,
|
|
112
|
+
"scale_x": 100,
|
|
113
|
+
"scale_y": 100,
|
|
114
|
+
"spacing": 0,
|
|
115
|
+
"angle": 0.0,
|
|
116
|
+
"border_style": 1,
|
|
117
|
+
"outline": 1,
|
|
118
|
+
"shadow": 0,
|
|
119
|
+
"margin_l": 0,
|
|
120
|
+
"margin_r": 0,
|
|
121
|
+
"margin_v": 0,
|
|
122
|
+
"encoding": 0
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Verify structure is valid JSON
|
|
127
|
+
json_str = json.dumps(minimal_styles)
|
|
128
|
+
parsed = json.loads(json_str)
|
|
129
|
+
|
|
130
|
+
assert "karaoke" in parsed
|
|
131
|
+
assert parsed["karaoke"]["font_path"] == "" # Not None
|
|
132
|
+
assert parsed["karaoke"]["ass_name"] == "Default"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TestCorrectionDataMerging:
|
|
136
|
+
"""Tests documenting correction data merging requirements.
|
|
137
|
+
|
|
138
|
+
The LyricsTranscriber React UI sends only partial correction data:
|
|
139
|
+
- corrections
|
|
140
|
+
- corrected_segments
|
|
141
|
+
|
|
142
|
+
The backend must merge this with the original corrections.json to
|
|
143
|
+
reconstruct a full CorrectionResult.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def test_frontend_sends_partial_data(self):
|
|
147
|
+
"""Document what the frontend sends."""
|
|
148
|
+
# The frontend sends only these fields
|
|
149
|
+
frontend_payload = {
|
|
150
|
+
"corrections": [], # List of corrections made
|
|
151
|
+
"corrected_segments": [] # Updated segment data
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# This is NOT a full CorrectionResult
|
|
155
|
+
assert "original_segments" not in frontend_payload
|
|
156
|
+
assert "metadata" not in frontend_payload
|
|
157
|
+
|
|
158
|
+
def test_merging_strategy(self):
|
|
159
|
+
"""Document the merging strategy for corrections."""
|
|
160
|
+
# Original data has full structure
|
|
161
|
+
original_data = {
|
|
162
|
+
"original_segments": [{"id": 1, "text": "test"}],
|
|
163
|
+
"corrected_segments": [{"id": 1, "text": "test"}],
|
|
164
|
+
"corrections": [],
|
|
165
|
+
"metadata": {"artist": "Test"}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Frontend sends partial update
|
|
169
|
+
frontend_update = {
|
|
170
|
+
"corrections": [{"id": 1, "type": "edit"}],
|
|
171
|
+
"corrected_segments": [{"id": 1, "text": "updated"}]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# Merging strategy: update only the fields sent by frontend
|
|
175
|
+
if 'corrections' in frontend_update:
|
|
176
|
+
original_data['corrections'] = frontend_update['corrections']
|
|
177
|
+
if 'corrected_segments' in frontend_update:
|
|
178
|
+
original_data['corrected_segments'] = frontend_update['corrected_segments']
|
|
179
|
+
|
|
180
|
+
# Result preserves original_segments and metadata
|
|
181
|
+
assert original_data['original_segments'] == [{"id": 1, "text": "test"}]
|
|
182
|
+
assert original_data['metadata'] == {"artist": "Test"}
|
|
183
|
+
# But has updated corrections and corrected_segments
|
|
184
|
+
assert original_data['corrections'] == [{"id": 1, "type": "edit"}]
|
|
185
|
+
assert original_data['corrected_segments'] == [{"id": 1, "text": "updated"}]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class TestAddLyricsEndpoint:
|
|
189
|
+
"""Tests for the add-lyrics endpoint."""
|
|
190
|
+
|
|
191
|
+
def test_add_lyrics_requires_source_and_lyrics(self):
|
|
192
|
+
"""Document that add_lyrics expects source and lyrics fields."""
|
|
193
|
+
# The frontend sends this payload
|
|
194
|
+
valid_payload = {
|
|
195
|
+
"source": "custom",
|
|
196
|
+
"lyrics": "Line 1\nLine 2\nLine 3"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# Both fields are required
|
|
200
|
+
assert "source" in valid_payload
|
|
201
|
+
assert "lyrics" in valid_payload
|
|
202
|
+
assert len(valid_payload["source"].strip()) > 0
|
|
203
|
+
assert len(valid_payload["lyrics"].strip()) > 0
|
|
204
|
+
|
|
205
|
+
def test_add_lyrics_uses_correction_operations(self):
|
|
206
|
+
"""Verify CorrectionOperations.add_lyrics_source is available."""
|
|
207
|
+
from lyrics_transcriber.correction.operations import CorrectionOperations
|
|
208
|
+
|
|
209
|
+
# The method should exist
|
|
210
|
+
assert hasattr(CorrectionOperations, 'add_lyrics_source')
|
|
211
|
+
|
|
212
|
+
# It should be a static method
|
|
213
|
+
import inspect
|
|
214
|
+
# Get the method and check it's callable
|
|
215
|
+
method = getattr(CorrectionOperations, 'add_lyrics_source')
|
|
216
|
+
assert callable(method)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class TestPreviewStyleLoading:
|
|
220
|
+
"""Tests for the unified style loader used in preview video generation.
|
|
221
|
+
|
|
222
|
+
When a job has custom styles (uploaded via --style_params_json), these
|
|
223
|
+
must be loaded and applied to preview videos, not just the final render.
|
|
224
|
+
|
|
225
|
+
This was a bug: preview videos were using minimal styles (black background)
|
|
226
|
+
even when the job had custom backgrounds and fonts configured.
|
|
227
|
+
|
|
228
|
+
The style loading logic is now consolidated in karaoke_gen.style_loader
|
|
229
|
+
to avoid duplication between workers and API routes.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def test_load_styles_from_gcs_with_custom_styles(self, tmp_path):
|
|
233
|
+
"""Test that custom styles are downloaded and applied for preview."""
|
|
234
|
+
import os
|
|
235
|
+
from karaoke_gen.style_loader import load_styles_from_gcs
|
|
236
|
+
|
|
237
|
+
# Create source style params file
|
|
238
|
+
source_style_params = tmp_path / "source_styles.json"
|
|
239
|
+
style_data = {
|
|
240
|
+
"karaoke": {
|
|
241
|
+
"background_image": "/original/path/background.png",
|
|
242
|
+
"font_path": "/original/path/font.ttf",
|
|
243
|
+
"background_color": "#000000",
|
|
244
|
+
"font": "Noto Sans",
|
|
245
|
+
"ass_name": "Default",
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
source_style_params.write_text(json.dumps(style_data))
|
|
249
|
+
|
|
250
|
+
# Create source asset files
|
|
251
|
+
source_background = tmp_path / "background.png"
|
|
252
|
+
source_background.write_bytes(b"PNG image data")
|
|
253
|
+
source_font = tmp_path / "font.ttf"
|
|
254
|
+
source_font.write_bytes(b"TTF font data")
|
|
255
|
+
|
|
256
|
+
# Create mock download function that simulates GCS download
|
|
257
|
+
def mock_download(gcs_path, local_path):
|
|
258
|
+
if "style_params.json" in gcs_path:
|
|
259
|
+
with open(local_path, 'w') as f:
|
|
260
|
+
f.write(source_style_params.read_text())
|
|
261
|
+
elif "karaoke_background" in gcs_path:
|
|
262
|
+
with open(local_path, 'wb') as f:
|
|
263
|
+
f.write(source_background.read_bytes())
|
|
264
|
+
elif "font.ttf" in gcs_path:
|
|
265
|
+
with open(local_path, 'wb') as f:
|
|
266
|
+
f.write(source_font.read_bytes())
|
|
267
|
+
|
|
268
|
+
# Call the unified style loader function
|
|
269
|
+
style_assets = {
|
|
270
|
+
"style_params": "uploads/test123/style/style_params.json",
|
|
271
|
+
"karaoke_background": "uploads/test123/style/karaoke_background.png",
|
|
272
|
+
"font": "uploads/test123/style/font.ttf",
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
styles_path, result_styles = load_styles_from_gcs(
|
|
276
|
+
style_params_gcs_path="uploads/test123/style/style_params.json",
|
|
277
|
+
style_assets=style_assets,
|
|
278
|
+
temp_dir=str(tmp_path / "workdir"),
|
|
279
|
+
download_func=mock_download,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Verify styles file was created
|
|
283
|
+
assert os.path.exists(styles_path)
|
|
284
|
+
|
|
285
|
+
# The paths should now point to the local downloaded files, not the original paths
|
|
286
|
+
assert "karaoke" in result_styles
|
|
287
|
+
assert result_styles["karaoke"]["background_image"] != "/original/path/background.png"
|
|
288
|
+
assert "karaoke_background.png" in result_styles["karaoke"]["background_image"]
|
|
289
|
+
assert result_styles["karaoke"]["font_path"] != "/original/path/font.ttf"
|
|
290
|
+
assert "font.ttf" in result_styles["karaoke"]["font_path"]
|
|
291
|
+
|
|
292
|
+
def test_load_styles_from_gcs_falls_back_to_minimal(self, tmp_path):
|
|
293
|
+
"""Test that minimal styles are used when job has no custom styles."""
|
|
294
|
+
import os
|
|
295
|
+
from karaoke_gen.style_loader import load_styles_from_gcs
|
|
296
|
+
|
|
297
|
+
# Call with no custom styles
|
|
298
|
+
styles_path, result_styles = load_styles_from_gcs(
|
|
299
|
+
style_params_gcs_path=None, # No custom styles
|
|
300
|
+
style_assets={},
|
|
301
|
+
temp_dir=str(tmp_path),
|
|
302
|
+
download_func=lambda gcs_path, local_path: None, # Won't be called
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Verify styles file was created
|
|
306
|
+
assert os.path.exists(styles_path)
|
|
307
|
+
|
|
308
|
+
# Should have karaoke section with minimal/default values
|
|
309
|
+
assert "karaoke" in result_styles
|
|
310
|
+
assert result_styles["karaoke"]["background_color"] == "#000000"
|
|
311
|
+
assert result_styles["karaoke"]["font"] == "Noto Sans"
|
|
312
|
+
# Minimal styles have background_image as None (default)
|
|
313
|
+
assert result_styles["karaoke"].get("background_image") is None
|
|
314
|
+
|
|
315
|
+
def test_asset_mapping_is_complete(self):
|
|
316
|
+
"""Verify all required asset mappings are defined in the unified style loader."""
|
|
317
|
+
from karaoke_gen.style_loader import ASSET_KEY_MAPPINGS
|
|
318
|
+
|
|
319
|
+
# These mappings must be present for styles to work correctly
|
|
320
|
+
required_keys = [
|
|
321
|
+
"karaoke_background",
|
|
322
|
+
"intro_background",
|
|
323
|
+
"end_background",
|
|
324
|
+
"font",
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
for key in required_keys:
|
|
328
|
+
assert key in ASSET_KEY_MAPPINGS, f"Asset mapping '{key}' not found in ASSET_KEY_MAPPINGS"
|
|
329
|
+
|
|
330
|
+
# Verify karaoke_background maps to the correct path
|
|
331
|
+
karaoke_mapping = ASSET_KEY_MAPPINGS["karaoke_background"]
|
|
332
|
+
assert karaoke_mapping == ("karaoke", "background_image")
|
|
333
|
+
|
|
334
|
+
# Verify font maps to multiple sections
|
|
335
|
+
font_mappings = ASSET_KEY_MAPPINGS["font"]
|
|
336
|
+
assert isinstance(font_mappings, list)
|
|
337
|
+
assert ("karaoke", "font_path") in font_mappings
|