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,1116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for backend workers.
|
|
3
|
+
|
|
4
|
+
These tests mock external dependencies and test worker logic in isolation.
|
|
5
|
+
This includes the functions that would have caught bugs like the
|
|
6
|
+
UnboundLocalError in upload_lyrics_results.
|
|
7
|
+
"""
|
|
8
|
+
import pytest
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
import tempfile
|
|
12
|
+
from datetime import datetime, UTC
|
|
13
|
+
from unittest.mock import MagicMock, AsyncMock, patch, mock_open
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from backend.models.job import Job, JobStatus
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestAudioWorker:
|
|
20
|
+
"""Tests for audio_worker.py functions."""
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def mock_job(self):
|
|
24
|
+
"""Create a mock job for testing."""
|
|
25
|
+
return Job(
|
|
26
|
+
job_id="test123",
|
|
27
|
+
status=JobStatus.PENDING,
|
|
28
|
+
created_at=datetime.now(UTC),
|
|
29
|
+
updated_at=datetime.now(UTC),
|
|
30
|
+
artist="Test Artist",
|
|
31
|
+
title="Test Song",
|
|
32
|
+
input_media_gcs_path="uploads/test123/song.flac"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def mock_job_manager(self, mock_job):
|
|
37
|
+
"""Create a mock JobManager."""
|
|
38
|
+
manager = MagicMock()
|
|
39
|
+
manager.get_job.return_value = mock_job
|
|
40
|
+
manager.update_job_status.return_value = None
|
|
41
|
+
manager.update_file_url.return_value = None
|
|
42
|
+
manager.update_state_data.return_value = None
|
|
43
|
+
manager.mark_job_failed.return_value = None
|
|
44
|
+
return manager
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def mock_storage(self):
|
|
48
|
+
"""Create a mock StorageService."""
|
|
49
|
+
storage = MagicMock()
|
|
50
|
+
storage.download_file.return_value = "/tmp/test/song.flac"
|
|
51
|
+
storage.upload_file.return_value = "gs://bucket/path"
|
|
52
|
+
return storage
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_download_audio_from_gcs(self, mock_job_manager, mock_storage, mock_job):
|
|
56
|
+
"""Test downloading audio from GCS for uploaded files."""
|
|
57
|
+
with patch('backend.workers.audio_worker.JobManager', return_value=mock_job_manager), \
|
|
58
|
+
patch('backend.workers.audio_worker.StorageService', return_value=mock_storage):
|
|
59
|
+
|
|
60
|
+
from backend.workers.audio_worker import download_audio
|
|
61
|
+
|
|
62
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
63
|
+
result = await download_audio("test123", temp_dir, mock_storage, mock_job)
|
|
64
|
+
|
|
65
|
+
# Should have called download_file with the GCS path
|
|
66
|
+
mock_storage.download_file.assert_called()
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_upload_separation_results_handles_clean_stems(self, mock_job_manager, mock_storage):
|
|
70
|
+
"""Test that upload_separation_results handles clean stems correctly."""
|
|
71
|
+
from backend.workers.audio_worker import upload_separation_results
|
|
72
|
+
|
|
73
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
74
|
+
# Create actual test files
|
|
75
|
+
inst_path = os.path.join(temp_dir, "instrumental.flac")
|
|
76
|
+
vocals_path = os.path.join(temp_dir, "vocals.flac")
|
|
77
|
+
with open(inst_path, 'wb') as f:
|
|
78
|
+
f.write(b'fake audio data')
|
|
79
|
+
with open(vocals_path, 'wb') as f:
|
|
80
|
+
f.write(b'fake audio data')
|
|
81
|
+
|
|
82
|
+
# Create mock separation result matching AudioProcessor output format
|
|
83
|
+
separation_result = {
|
|
84
|
+
"clean_instrumental": {
|
|
85
|
+
"instrumental": inst_path,
|
|
86
|
+
"vocals": vocals_path
|
|
87
|
+
},
|
|
88
|
+
"other_stems": {},
|
|
89
|
+
"backing_vocals": {},
|
|
90
|
+
"combined_instrumentals": {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await upload_separation_results("test123", separation_result, mock_storage, mock_job_manager)
|
|
94
|
+
|
|
95
|
+
# Should have uploaded files
|
|
96
|
+
assert mock_storage.upload_file.called
|
|
97
|
+
|
|
98
|
+
@pytest.mark.asyncio
|
|
99
|
+
async def test_upload_separation_results_handles_other_stems_as_dict(self, mock_job_manager, mock_storage):
|
|
100
|
+
"""Test that upload_separation_results handles other_stems when values are dicts (bug fix)."""
|
|
101
|
+
with patch('backend.workers.audio_worker.JobManager', return_value=mock_job_manager), \
|
|
102
|
+
patch('backend.workers.audio_worker.StorageService', return_value=mock_storage):
|
|
103
|
+
|
|
104
|
+
from backend.workers.audio_worker import upload_separation_results
|
|
105
|
+
|
|
106
|
+
# This is the structure that caused the original bug - values are dicts, not strings
|
|
107
|
+
separation_result = {
|
|
108
|
+
"clean": {},
|
|
109
|
+
"other_stems": {
|
|
110
|
+
"bass": {"path": "/tmp/test/bass.flac", "other_key": "value"},
|
|
111
|
+
"drums": "/tmp/test/drums.flac" # String path
|
|
112
|
+
},
|
|
113
|
+
"backing_vocals": {},
|
|
114
|
+
"combined_instrumentals": {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Mock os.path.exists
|
|
118
|
+
with patch('os.path.exists', return_value=True):
|
|
119
|
+
# This should NOT raise TypeError: stat: path should be string...
|
|
120
|
+
await upload_separation_results("test123", separation_result, mock_storage, mock_job_manager)
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_process_audio_separation_updates_status_on_failure(self, mock_job_manager, mock_storage):
|
|
124
|
+
"""Test that process_audio_separation marks job as failed on error."""
|
|
125
|
+
mock_job_manager.get_job.return_value = Job(
|
|
126
|
+
job_id="test123",
|
|
127
|
+
status=JobStatus.PENDING,
|
|
128
|
+
created_at=datetime.now(UTC),
|
|
129
|
+
updated_at=datetime.now(UTC),
|
|
130
|
+
artist="Test",
|
|
131
|
+
title="Test"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
with patch('backend.workers.audio_worker.JobManager', return_value=mock_job_manager), \
|
|
135
|
+
patch('backend.workers.audio_worker.StorageService', return_value=mock_storage), \
|
|
136
|
+
patch('backend.workers.audio_worker.download_audio', side_effect=Exception("Download failed")):
|
|
137
|
+
|
|
138
|
+
from backend.workers.audio_worker import process_audio_separation
|
|
139
|
+
|
|
140
|
+
await process_audio_separation("test123")
|
|
141
|
+
|
|
142
|
+
# Should have marked job as failed
|
|
143
|
+
mock_job_manager.mark_job_failed.assert_called()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestLyricsWorker:
|
|
147
|
+
"""Tests for lyrics_worker.py functions."""
|
|
148
|
+
|
|
149
|
+
@pytest.fixture
|
|
150
|
+
def mock_job(self):
|
|
151
|
+
"""Create a mock job for testing."""
|
|
152
|
+
return Job(
|
|
153
|
+
job_id="test123",
|
|
154
|
+
status=JobStatus.PENDING,
|
|
155
|
+
created_at=datetime.now(UTC),
|
|
156
|
+
updated_at=datetime.now(UTC),
|
|
157
|
+
artist="ABBA",
|
|
158
|
+
title="Waterloo",
|
|
159
|
+
input_media_gcs_path="uploads/test123/song.flac"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@pytest.fixture
|
|
163
|
+
def mock_job_manager(self, mock_job):
|
|
164
|
+
"""Create a mock JobManager."""
|
|
165
|
+
manager = MagicMock()
|
|
166
|
+
manager.get_job.return_value = mock_job
|
|
167
|
+
manager.update_job_status.return_value = None
|
|
168
|
+
manager.update_file_url.return_value = None
|
|
169
|
+
manager.update_state_data.return_value = None
|
|
170
|
+
manager.mark_job_failed.return_value = None
|
|
171
|
+
return manager
|
|
172
|
+
|
|
173
|
+
@pytest.fixture
|
|
174
|
+
def mock_storage(self):
|
|
175
|
+
"""Create a mock StorageService."""
|
|
176
|
+
storage = MagicMock()
|
|
177
|
+
storage.download_file.return_value = "/tmp/test/song.flac"
|
|
178
|
+
storage.upload_file.return_value = "gs://bucket/path"
|
|
179
|
+
return storage
|
|
180
|
+
|
|
181
|
+
@pytest.mark.asyncio
|
|
182
|
+
async def test_upload_lyrics_results_requires_job(self, mock_job_manager, mock_storage, mock_job):
|
|
183
|
+
"""Test that upload_lyrics_results correctly fetches job for artist/title.
|
|
184
|
+
|
|
185
|
+
This test would have caught the UnboundLocalError bug where job was used
|
|
186
|
+
before being defined.
|
|
187
|
+
"""
|
|
188
|
+
with patch('backend.workers.lyrics_worker.JobManager', return_value=mock_job_manager), \
|
|
189
|
+
patch('backend.workers.lyrics_worker.StorageService', return_value=mock_storage):
|
|
190
|
+
|
|
191
|
+
from backend.workers.lyrics_worker import upload_lyrics_results
|
|
192
|
+
|
|
193
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
194
|
+
# Create mock lyrics directory and files
|
|
195
|
+
lyrics_dir = os.path.join(temp_dir, "lyrics")
|
|
196
|
+
os.makedirs(lyrics_dir)
|
|
197
|
+
|
|
198
|
+
# Create test LRC file
|
|
199
|
+
lrc_path = os.path.join(lyrics_dir, "ABBA - Waterloo (Karaoke).lrc")
|
|
200
|
+
with open(lrc_path, 'w') as f:
|
|
201
|
+
f.write("[00:00.00]Test lyrics\n")
|
|
202
|
+
|
|
203
|
+
# Create corrections JSON
|
|
204
|
+
corrections_path = os.path.join(lyrics_dir, "ABBA - Waterloo (Lyrics Corrections).json")
|
|
205
|
+
with open(corrections_path, 'w') as f:
|
|
206
|
+
json.dump({"lines": [], "corrections": []}, f)
|
|
207
|
+
|
|
208
|
+
transcription_result = {
|
|
209
|
+
"lrc_filepath": lrc_path,
|
|
210
|
+
"corrections_filepath": corrections_path
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# This should NOT raise UnboundLocalError
|
|
214
|
+
await upload_lyrics_results(
|
|
215
|
+
"test123",
|
|
216
|
+
temp_dir,
|
|
217
|
+
transcription_result,
|
|
218
|
+
mock_storage,
|
|
219
|
+
mock_job_manager
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Verify job was fetched
|
|
223
|
+
mock_job_manager.get_job.assert_called_with("test123")
|
|
224
|
+
|
|
225
|
+
@pytest.mark.asyncio
|
|
226
|
+
async def test_upload_lyrics_results_uploads_lrc_file(self, mock_job_manager, mock_storage, mock_job):
|
|
227
|
+
"""Test that LRC file is uploaded correctly."""
|
|
228
|
+
from backend.workers.lyrics_worker import upload_lyrics_results
|
|
229
|
+
import json
|
|
230
|
+
|
|
231
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
232
|
+
lyrics_dir = os.path.join(temp_dir, "lyrics")
|
|
233
|
+
os.makedirs(lyrics_dir)
|
|
234
|
+
|
|
235
|
+
lrc_path = os.path.join(lyrics_dir, "test.lrc")
|
|
236
|
+
with open(lrc_path, 'w') as f:
|
|
237
|
+
f.write("[00:00.00]Test\n")
|
|
238
|
+
|
|
239
|
+
# Create required corrections.json file
|
|
240
|
+
corrections_path = os.path.join(lyrics_dir, "corrections.json")
|
|
241
|
+
with open(corrections_path, 'w') as f:
|
|
242
|
+
json.dump({"corrected_segments": []}, f)
|
|
243
|
+
|
|
244
|
+
transcription_result = {"lrc_filepath": lrc_path}
|
|
245
|
+
|
|
246
|
+
await upload_lyrics_results(
|
|
247
|
+
"test123", temp_dir, transcription_result,
|
|
248
|
+
mock_storage, mock_job_manager
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Should have uploaded the LRC file
|
|
252
|
+
mock_storage.upload_file.assert_called()
|
|
253
|
+
mock_job_manager.update_file_url.assert_called()
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
async def test_upload_lyrics_results_handles_missing_files(self, mock_job_manager, mock_storage, mock_job):
|
|
257
|
+
"""Test graceful handling when optional files are missing."""
|
|
258
|
+
from backend.workers.lyrics_worker import upload_lyrics_results
|
|
259
|
+
import json
|
|
260
|
+
|
|
261
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
262
|
+
lyrics_dir = os.path.join(temp_dir, "lyrics")
|
|
263
|
+
os.makedirs(lyrics_dir)
|
|
264
|
+
|
|
265
|
+
# Only create LRC file and required corrections.json, no other files
|
|
266
|
+
lrc_path = os.path.join(lyrics_dir, "test.lrc")
|
|
267
|
+
with open(lrc_path, 'w') as f:
|
|
268
|
+
f.write("[00:00.00]Test\n")
|
|
269
|
+
|
|
270
|
+
# Create required corrections.json file
|
|
271
|
+
corrections_path = os.path.join(lyrics_dir, "corrections.json")
|
|
272
|
+
with open(corrections_path, 'w') as f:
|
|
273
|
+
json.dump({"corrected_segments": []}, f)
|
|
274
|
+
|
|
275
|
+
transcription_result = {"lrc_filepath": lrc_path}
|
|
276
|
+
|
|
277
|
+
# Should not raise exception for missing optional files
|
|
278
|
+
await upload_lyrics_results(
|
|
279
|
+
"test123", temp_dir, transcription_result,
|
|
280
|
+
mock_storage, mock_job_manager
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
@pytest.mark.asyncio
|
|
284
|
+
async def test_upload_lyrics_results_uses_artist_title_from_job(self, mock_job_manager, mock_storage, mock_job):
|
|
285
|
+
"""Test that upload_lyrics_results correctly uses job.artist and job.title.
|
|
286
|
+
|
|
287
|
+
This test specifically validates the bug fix where job was not defined
|
|
288
|
+
when accessing job.artist and job.title for reference file lookups.
|
|
289
|
+
"""
|
|
290
|
+
from backend.workers.lyrics_worker import upload_lyrics_results
|
|
291
|
+
import json
|
|
292
|
+
|
|
293
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
294
|
+
lyrics_dir = os.path.join(temp_dir, "lyrics")
|
|
295
|
+
os.makedirs(lyrics_dir)
|
|
296
|
+
|
|
297
|
+
# Create LRC file
|
|
298
|
+
lrc_path = os.path.join(lyrics_dir, "test.lrc")
|
|
299
|
+
with open(lrc_path, 'w') as f:
|
|
300
|
+
f.write("[00:00.00]Test\n")
|
|
301
|
+
|
|
302
|
+
# Create required corrections.json file
|
|
303
|
+
corrections_path = os.path.join(lyrics_dir, "corrections.json")
|
|
304
|
+
with open(corrections_path, 'w') as f:
|
|
305
|
+
json.dump({"corrected_segments": []}, f)
|
|
306
|
+
|
|
307
|
+
# Create a reference lyrics file using the job's artist/title
|
|
308
|
+
ref_path = os.path.join(lyrics_dir, f"{mock_job.artist} - {mock_job.title} (Lyrics Genius).txt")
|
|
309
|
+
with open(ref_path, 'w') as f:
|
|
310
|
+
f.write("Reference lyrics content\n")
|
|
311
|
+
|
|
312
|
+
# Create uncorrected transcription file using job's artist/title
|
|
313
|
+
uncorrected_path = os.path.join(lyrics_dir, f"{mock_job.artist} - {mock_job.title} (Lyrics Uncorrected).txt")
|
|
314
|
+
with open(uncorrected_path, 'w') as f:
|
|
315
|
+
f.write("Uncorrected transcription\n")
|
|
316
|
+
|
|
317
|
+
transcription_result = {"lrc_filepath": lrc_path}
|
|
318
|
+
|
|
319
|
+
# This should NOT raise UnboundLocalError for 'job'
|
|
320
|
+
await upload_lyrics_results(
|
|
321
|
+
"test123", temp_dir, transcription_result,
|
|
322
|
+
mock_storage, mock_job_manager
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Verify job was fetched to get artist/title
|
|
326
|
+
mock_job_manager.get_job.assert_called_with("test123")
|
|
327
|
+
|
|
328
|
+
# Verify files were uploaded (the reference and uncorrected files exist)
|
|
329
|
+
upload_calls = mock_storage.upload_file.call_args_list
|
|
330
|
+
assert len(upload_calls) >= 2 # LRC + at least one reference or uncorrected
|
|
331
|
+
|
|
332
|
+
@pytest.mark.asyncio
|
|
333
|
+
async def test_process_lyrics_transcription_marks_failed_on_error(self, mock_job_manager, mock_storage):
|
|
334
|
+
"""Test that process_lyrics_transcription marks job as failed on error."""
|
|
335
|
+
mock_job_manager.get_job.return_value = Job(
|
|
336
|
+
job_id="test123",
|
|
337
|
+
status=JobStatus.PENDING,
|
|
338
|
+
created_at=datetime.now(UTC),
|
|
339
|
+
updated_at=datetime.now(UTC),
|
|
340
|
+
artist="Test",
|
|
341
|
+
title="Test",
|
|
342
|
+
input_media_gcs_path="uploads/test123/song.flac"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
with patch('backend.workers.lyrics_worker.JobManager', return_value=mock_job_manager), \
|
|
346
|
+
patch('backend.workers.lyrics_worker.StorageService', return_value=mock_storage), \
|
|
347
|
+
patch('backend.workers.lyrics_worker.download_audio', side_effect=Exception("Download failed")):
|
|
348
|
+
|
|
349
|
+
from backend.workers.lyrics_worker import process_lyrics_transcription
|
|
350
|
+
|
|
351
|
+
await process_lyrics_transcription("test123")
|
|
352
|
+
|
|
353
|
+
mock_job_manager.mark_job_failed.assert_called()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class TestLyricsWorkerConfiguration:
|
|
357
|
+
"""Tests for lyrics worker configuration parameters."""
|
|
358
|
+
|
|
359
|
+
def test_create_lyrics_processor_with_defaults(self):
|
|
360
|
+
"""Test creating LyricsProcessor with default parameters."""
|
|
361
|
+
with patch('backend.workers.lyrics_worker.LyricsProcessor') as mock_processor:
|
|
362
|
+
from backend.workers.lyrics_worker import create_lyrics_processor
|
|
363
|
+
|
|
364
|
+
result = create_lyrics_processor()
|
|
365
|
+
|
|
366
|
+
mock_processor.assert_called_once()
|
|
367
|
+
call_kwargs = mock_processor.call_args[1]
|
|
368
|
+
assert call_kwargs['lyrics_file'] is None
|
|
369
|
+
assert call_kwargs['subtitle_offset_ms'] == 0
|
|
370
|
+
|
|
371
|
+
def test_create_lyrics_processor_with_lyrics_file(self):
|
|
372
|
+
"""Test creating LyricsProcessor with custom lyrics file."""
|
|
373
|
+
with patch('backend.workers.lyrics_worker.LyricsProcessor') as mock_processor:
|
|
374
|
+
from backend.workers.lyrics_worker import create_lyrics_processor
|
|
375
|
+
|
|
376
|
+
result = create_lyrics_processor(
|
|
377
|
+
lyrics_file="/path/to/lyrics.txt",
|
|
378
|
+
subtitle_offset_ms=0
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
mock_processor.assert_called_once()
|
|
382
|
+
call_kwargs = mock_processor.call_args[1]
|
|
383
|
+
assert call_kwargs['lyrics_file'] == "/path/to/lyrics.txt"
|
|
384
|
+
|
|
385
|
+
def test_create_lyrics_processor_with_subtitle_offset(self):
|
|
386
|
+
"""Test creating LyricsProcessor with subtitle offset."""
|
|
387
|
+
with patch('backend.workers.lyrics_worker.LyricsProcessor') as mock_processor:
|
|
388
|
+
from backend.workers.lyrics_worker import create_lyrics_processor
|
|
389
|
+
|
|
390
|
+
result = create_lyrics_processor(
|
|
391
|
+
subtitle_offset_ms=500
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
mock_processor.assert_called_once()
|
|
395
|
+
call_kwargs = mock_processor.call_args[1]
|
|
396
|
+
assert call_kwargs['subtitle_offset_ms'] == 500
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class TestLyricsOverrideParameters:
|
|
400
|
+
"""Tests for lyrics artist/title override functionality."""
|
|
401
|
+
|
|
402
|
+
@pytest.fixture
|
|
403
|
+
def mock_job_with_overrides(self):
|
|
404
|
+
"""Create a mock job with lyrics override fields."""
|
|
405
|
+
return Job(
|
|
406
|
+
job_id="test123",
|
|
407
|
+
status=JobStatus.PENDING,
|
|
408
|
+
created_at=datetime.now(UTC),
|
|
409
|
+
updated_at=datetime.now(UTC),
|
|
410
|
+
artist="Beatles, The",
|
|
411
|
+
title="Hey Jude - 2009 Remaster",
|
|
412
|
+
lyrics_artist="The Beatles",
|
|
413
|
+
lyrics_title="Hey Jude",
|
|
414
|
+
subtitle_offset_ms=250,
|
|
415
|
+
input_media_gcs_path="uploads/test123/song.flac"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def test_job_uses_lyrics_artist_override(self, mock_job_with_overrides):
|
|
419
|
+
"""Test that job uses lyrics_artist when searching for lyrics."""
|
|
420
|
+
job = mock_job_with_overrides
|
|
421
|
+
|
|
422
|
+
# Use override if present, else fall back to main artist
|
|
423
|
+
lyrics_search_artist = job.lyrics_artist or job.artist
|
|
424
|
+
|
|
425
|
+
assert lyrics_search_artist == "The Beatles"
|
|
426
|
+
assert lyrics_search_artist != job.artist # Override is different
|
|
427
|
+
|
|
428
|
+
def test_job_uses_lyrics_title_override(self, mock_job_with_overrides):
|
|
429
|
+
"""Test that job uses lyrics_title when searching for lyrics."""
|
|
430
|
+
job = mock_job_with_overrides
|
|
431
|
+
|
|
432
|
+
# Use override if present, else fall back to main title
|
|
433
|
+
lyrics_search_title = job.lyrics_title or job.title
|
|
434
|
+
|
|
435
|
+
assert lyrics_search_title == "Hey Jude"
|
|
436
|
+
assert lyrics_search_title != job.title # Override is different
|
|
437
|
+
|
|
438
|
+
def test_job_falls_back_when_no_override(self):
|
|
439
|
+
"""Test that job falls back to main artist/title when no override."""
|
|
440
|
+
job = Job(
|
|
441
|
+
job_id="test123",
|
|
442
|
+
status=JobStatus.PENDING,
|
|
443
|
+
created_at=datetime.now(UTC),
|
|
444
|
+
updated_at=datetime.now(UTC),
|
|
445
|
+
artist="Test Artist",
|
|
446
|
+
title="Test Song",
|
|
447
|
+
input_media_gcs_path="uploads/test123/song.flac"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# When override is None, use main values
|
|
451
|
+
lyrics_search_artist = job.lyrics_artist or job.artist
|
|
452
|
+
lyrics_search_title = job.lyrics_title or job.title
|
|
453
|
+
|
|
454
|
+
assert lyrics_search_artist == "Test Artist"
|
|
455
|
+
assert lyrics_search_title == "Test Song"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class TestScreensWorker:
|
|
459
|
+
"""Tests for screens_worker.py functions.
|
|
460
|
+
|
|
461
|
+
Note: The main process function is tested indirectly via integration tests.
|
|
462
|
+
These unit tests focus on helper functions and module structure.
|
|
463
|
+
"""
|
|
464
|
+
|
|
465
|
+
def test_screens_worker_module_imports(self):
|
|
466
|
+
"""Test screens worker module can be imported."""
|
|
467
|
+
from backend.workers import screens_worker
|
|
468
|
+
assert hasattr(screens_worker, 'logger')
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class TestVideoWorker:
|
|
472
|
+
"""Tests for video_worker.py functions.
|
|
473
|
+
|
|
474
|
+
Note: The main process function is tested indirectly via integration tests.
|
|
475
|
+
These unit tests focus on helper functions and module structure.
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
def test_video_worker_module_imports(self):
|
|
479
|
+
"""Test video worker module can be imported."""
|
|
480
|
+
from backend.workers import video_worker
|
|
481
|
+
assert hasattr(video_worker, 'logger')
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class TestRenderVideoWorkerConfiguration:
|
|
485
|
+
"""Tests for render_video_worker subtitle_offset_ms support."""
|
|
486
|
+
|
|
487
|
+
@pytest.fixture
|
|
488
|
+
def mock_job_with_offset(self):
|
|
489
|
+
"""Create a mock job with subtitle offset."""
|
|
490
|
+
return Job(
|
|
491
|
+
job_id="test123",
|
|
492
|
+
status=JobStatus.RENDERING_VIDEO,
|
|
493
|
+
created_at=datetime.now(UTC),
|
|
494
|
+
updated_at=datetime.now(UTC),
|
|
495
|
+
artist="Test Artist",
|
|
496
|
+
title="Test Song",
|
|
497
|
+
subtitle_offset_ms=500,
|
|
498
|
+
input_media_gcs_path="uploads/test123/song.flac"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
def test_job_has_subtitle_offset_ms(self, mock_job_with_offset):
|
|
502
|
+
"""Test that job has subtitle_offset_ms field."""
|
|
503
|
+
assert mock_job_with_offset.subtitle_offset_ms == 500
|
|
504
|
+
|
|
505
|
+
def test_subtitle_offset_from_job(self, mock_job_with_offset):
|
|
506
|
+
"""Test extracting subtitle offset from job with getattr."""
|
|
507
|
+
job = mock_job_with_offset
|
|
508
|
+
|
|
509
|
+
# This mirrors the logic in render_video_worker.py
|
|
510
|
+
subtitle_offset = getattr(job, 'subtitle_offset_ms', 0) or 0
|
|
511
|
+
|
|
512
|
+
assert subtitle_offset == 500
|
|
513
|
+
|
|
514
|
+
def test_subtitle_offset_default_zero(self):
|
|
515
|
+
"""Test that subtitle offset defaults to 0 when not set."""
|
|
516
|
+
job = Job(
|
|
517
|
+
job_id="test123",
|
|
518
|
+
status=JobStatus.RENDERING_VIDEO,
|
|
519
|
+
created_at=datetime.now(UTC),
|
|
520
|
+
updated_at=datetime.now(UTC),
|
|
521
|
+
artist="Test Artist",
|
|
522
|
+
title="Test Song",
|
|
523
|
+
input_media_gcs_path="uploads/test123/song.flac"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
subtitle_offset = getattr(job, 'subtitle_offset_ms', 0) or 0
|
|
527
|
+
|
|
528
|
+
assert subtitle_offset == 0
|
|
529
|
+
|
|
530
|
+
def test_subtitle_offset_negative_value(self):
|
|
531
|
+
"""Test that subtitle offset can be negative (advance subtitles)."""
|
|
532
|
+
job = Job(
|
|
533
|
+
job_id="test123",
|
|
534
|
+
status=JobStatus.RENDERING_VIDEO,
|
|
535
|
+
created_at=datetime.now(UTC),
|
|
536
|
+
updated_at=datetime.now(UTC),
|
|
537
|
+
artist="Test Artist",
|
|
538
|
+
title="Test Song",
|
|
539
|
+
subtitle_offset_ms=-250, # Negative = advance subtitles
|
|
540
|
+
input_media_gcs_path="uploads/test123/song.flac"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
subtitle_offset = getattr(job, 'subtitle_offset_ms', 0) or 0
|
|
544
|
+
|
|
545
|
+
# Negative values should be preserved (not converted to 0)
|
|
546
|
+
assert subtitle_offset == -250
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class TestRenderVideoWorkerCountdownPadding:
|
|
550
|
+
"""Tests for render_video_worker countdown detection and audio padding.
|
|
551
|
+
|
|
552
|
+
This ensures that when corrections with countdown timestamps are loaded,
|
|
553
|
+
the audio is padded to match. This prevents video desynchronization.
|
|
554
|
+
"""
|
|
555
|
+
|
|
556
|
+
def test_countdown_processor_import(self):
|
|
557
|
+
"""Test that render_video_worker imports CountdownProcessor."""
|
|
558
|
+
from backend.workers.render_video_worker import CountdownProcessor
|
|
559
|
+
assert CountdownProcessor is not None
|
|
560
|
+
|
|
561
|
+
def test_countdown_processor_has_process_method(self):
|
|
562
|
+
"""Test that CountdownProcessor has the process method (main API)."""
|
|
563
|
+
from lyrics_transcriber.output.countdown_processor import CountdownProcessor
|
|
564
|
+
|
|
565
|
+
countdown_processor = CountdownProcessor(cache_dir="/tmp")
|
|
566
|
+
assert hasattr(countdown_processor, 'process')
|
|
567
|
+
assert callable(countdown_processor.process)
|
|
568
|
+
|
|
569
|
+
def test_countdown_processor_constants(self):
|
|
570
|
+
"""Test that CountdownProcessor has expected constants."""
|
|
571
|
+
from lyrics_transcriber.output.countdown_processor import CountdownProcessor
|
|
572
|
+
|
|
573
|
+
# Verify the constants exist (these control countdown behavior)
|
|
574
|
+
assert hasattr(CountdownProcessor, 'COUNTDOWN_TEXT')
|
|
575
|
+
assert hasattr(CountdownProcessor, 'COUNTDOWN_PADDING_SECONDS')
|
|
576
|
+
assert hasattr(CountdownProcessor, 'COUNTDOWN_THRESHOLD_SECONDS')
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class TestDownloadHelpers:
|
|
580
|
+
"""Tests for download helper functions in workers."""
|
|
581
|
+
|
|
582
|
+
@pytest.mark.asyncio
|
|
583
|
+
async def test_download_audio_handles_uploaded_file(self):
|
|
584
|
+
"""Test download_audio handles jobs with uploaded file."""
|
|
585
|
+
mock_storage = MagicMock()
|
|
586
|
+
mock_storage.download_file.return_value = "/tmp/downloaded.flac"
|
|
587
|
+
|
|
588
|
+
mock_job = Job(
|
|
589
|
+
job_id="test123",
|
|
590
|
+
status=JobStatus.PENDING,
|
|
591
|
+
created_at=datetime.now(UTC),
|
|
592
|
+
updated_at=datetime.now(UTC),
|
|
593
|
+
artist="Test",
|
|
594
|
+
title="Test",
|
|
595
|
+
input_media_gcs_path="uploads/test123/song.flac" # Uploaded file
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
from backend.workers.audio_worker import download_audio
|
|
599
|
+
|
|
600
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
601
|
+
result = await download_audio("test123", temp_dir, mock_storage, mock_job)
|
|
602
|
+
|
|
603
|
+
# Should have downloaded from GCS
|
|
604
|
+
mock_storage.download_file.assert_called()
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
class TestAudioWorkerModelConfiguration:
|
|
608
|
+
"""Tests for audio worker model configuration parameters."""
|
|
609
|
+
|
|
610
|
+
def test_create_audio_processor_with_defaults(self):
|
|
611
|
+
"""Test creating AudioProcessor with default models."""
|
|
612
|
+
with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
|
|
613
|
+
from backend.workers.audio_worker import create_audio_processor
|
|
614
|
+
|
|
615
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
616
|
+
result = create_audio_processor(temp_dir)
|
|
617
|
+
|
|
618
|
+
mock_processor.assert_called_once()
|
|
619
|
+
call_kwargs = mock_processor.call_args[1]
|
|
620
|
+
|
|
621
|
+
# Should use default models
|
|
622
|
+
assert call_kwargs['clean_instrumental_model'] == "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
|
|
623
|
+
assert call_kwargs['backing_vocals_models'] == ["mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"]
|
|
624
|
+
assert call_kwargs['other_stems_models'] == ["htdemucs_6s.yaml"]
|
|
625
|
+
|
|
626
|
+
def test_create_audio_processor_with_custom_clean_model(self):
|
|
627
|
+
"""Test creating AudioProcessor with custom clean instrumental model."""
|
|
628
|
+
with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
|
|
629
|
+
from backend.workers.audio_worker import create_audio_processor
|
|
630
|
+
|
|
631
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
632
|
+
result = create_audio_processor(
|
|
633
|
+
temp_dir,
|
|
634
|
+
clean_instrumental_model="custom_clean_model.ckpt"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
mock_processor.assert_called_once()
|
|
638
|
+
call_kwargs = mock_processor.call_args[1]
|
|
639
|
+
|
|
640
|
+
# Should use custom clean model
|
|
641
|
+
assert call_kwargs['clean_instrumental_model'] == "custom_clean_model.ckpt"
|
|
642
|
+
# Other models should still be defaults
|
|
643
|
+
assert call_kwargs['backing_vocals_models'] == ["mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"]
|
|
644
|
+
|
|
645
|
+
def test_create_audio_processor_with_custom_backing_models(self):
|
|
646
|
+
"""Test creating AudioProcessor with custom backing vocals models."""
|
|
647
|
+
with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
|
|
648
|
+
from backend.workers.audio_worker import create_audio_processor
|
|
649
|
+
|
|
650
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
651
|
+
result = create_audio_processor(
|
|
652
|
+
temp_dir,
|
|
653
|
+
backing_vocals_models=["custom_bv1.ckpt", "custom_bv2.ckpt"]
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
mock_processor.assert_called_once()
|
|
657
|
+
call_kwargs = mock_processor.call_args[1]
|
|
658
|
+
|
|
659
|
+
# Should use custom backing vocals models
|
|
660
|
+
assert call_kwargs['backing_vocals_models'] == ["custom_bv1.ckpt", "custom_bv2.ckpt"]
|
|
661
|
+
|
|
662
|
+
def test_create_audio_processor_with_custom_other_stems_models(self):
|
|
663
|
+
"""Test creating AudioProcessor with custom other stems models."""
|
|
664
|
+
with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
|
|
665
|
+
from backend.workers.audio_worker import create_audio_processor
|
|
666
|
+
|
|
667
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
668
|
+
result = create_audio_processor(
|
|
669
|
+
temp_dir,
|
|
670
|
+
other_stems_models=["custom_demucs.yaml"]
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
mock_processor.assert_called_once()
|
|
674
|
+
call_kwargs = mock_processor.call_args[1]
|
|
675
|
+
|
|
676
|
+
# Should use custom other stems models
|
|
677
|
+
assert call_kwargs['other_stems_models'] == ["custom_demucs.yaml"]
|
|
678
|
+
|
|
679
|
+
def test_create_audio_processor_with_all_custom_models(self):
|
|
680
|
+
"""Test creating AudioProcessor with all custom models."""
|
|
681
|
+
with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
|
|
682
|
+
from backend.workers.audio_worker import create_audio_processor
|
|
683
|
+
|
|
684
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
685
|
+
result = create_audio_processor(
|
|
686
|
+
temp_dir,
|
|
687
|
+
clean_instrumental_model="custom_clean.ckpt",
|
|
688
|
+
backing_vocals_models=["custom_bv.ckpt"],
|
|
689
|
+
other_stems_models=["custom_stems.yaml"]
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
mock_processor.assert_called_once()
|
|
693
|
+
call_kwargs = mock_processor.call_args[1]
|
|
694
|
+
|
|
695
|
+
# All models should be custom
|
|
696
|
+
assert call_kwargs['clean_instrumental_model'] == "custom_clean.ckpt"
|
|
697
|
+
assert call_kwargs['backing_vocals_models'] == ["custom_bv.ckpt"]
|
|
698
|
+
assert call_kwargs['other_stems_models'] == ["custom_stems.yaml"]
|
|
699
|
+
|
|
700
|
+
def test_job_model_fields_are_passed_to_processor(self):
|
|
701
|
+
"""Test that job model fields can be passed to create_audio_processor."""
|
|
702
|
+
mock_job = Job(
|
|
703
|
+
job_id="test123",
|
|
704
|
+
status=JobStatus.PENDING,
|
|
705
|
+
created_at=datetime.now(UTC),
|
|
706
|
+
updated_at=datetime.now(UTC),
|
|
707
|
+
artist="Test",
|
|
708
|
+
title="Test",
|
|
709
|
+
clean_instrumental_model="job_clean.ckpt",
|
|
710
|
+
backing_vocals_models=["job_bv.ckpt"],
|
|
711
|
+
other_stems_models=["job_stems.yaml"]
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
|
|
715
|
+
from backend.workers.audio_worker import create_audio_processor
|
|
716
|
+
|
|
717
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
718
|
+
# Simulate passing job model fields to create_audio_processor
|
|
719
|
+
result = create_audio_processor(
|
|
720
|
+
temp_dir,
|
|
721
|
+
clean_instrumental_model=mock_job.clean_instrumental_model,
|
|
722
|
+
backing_vocals_models=mock_job.backing_vocals_models,
|
|
723
|
+
other_stems_models=mock_job.other_stems_models
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
mock_processor.assert_called_once()
|
|
727
|
+
call_kwargs = mock_processor.call_args[1]
|
|
728
|
+
|
|
729
|
+
# Models from job should be used
|
|
730
|
+
assert call_kwargs['clean_instrumental_model'] == "job_clean.ckpt"
|
|
731
|
+
assert call_kwargs['backing_vocals_models'] == ["job_bv.ckpt"]
|
|
732
|
+
assert call_kwargs['other_stems_models'] == ["job_stems.yaml"]
|
|
733
|
+
|
|
734
|
+
def test_none_model_values_use_defaults(self):
|
|
735
|
+
"""Test that None model values fall back to defaults."""
|
|
736
|
+
mock_job = Job(
|
|
737
|
+
job_id="test123",
|
|
738
|
+
status=JobStatus.PENDING,
|
|
739
|
+
created_at=datetime.now(UTC),
|
|
740
|
+
updated_at=datetime.now(UTC),
|
|
741
|
+
artist="Test",
|
|
742
|
+
title="Test",
|
|
743
|
+
clean_instrumental_model=None, # Not specified
|
|
744
|
+
backing_vocals_models=None, # Not specified
|
|
745
|
+
other_stems_models=None # Not specified
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
|
|
749
|
+
from backend.workers.audio_worker import create_audio_processor
|
|
750
|
+
|
|
751
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
752
|
+
result = create_audio_processor(
|
|
753
|
+
temp_dir,
|
|
754
|
+
clean_instrumental_model=mock_job.clean_instrumental_model,
|
|
755
|
+
backing_vocals_models=mock_job.backing_vocals_models,
|
|
756
|
+
other_stems_models=mock_job.other_stems_models
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
mock_processor.assert_called_once()
|
|
760
|
+
call_kwargs = mock_processor.call_args[1]
|
|
761
|
+
|
|
762
|
+
# Should fall back to defaults
|
|
763
|
+
assert call_kwargs['clean_instrumental_model'] == "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
|
|
764
|
+
assert call_kwargs['backing_vocals_models'] == ["mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"]
|
|
765
|
+
assert call_kwargs['other_stems_models'] == ["htdemucs_6s.yaml"]
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
class TestDownloadFromUrl:
|
|
769
|
+
"""Tests for download_from_url function - URL-based audio download."""
|
|
770
|
+
|
|
771
|
+
def test_download_from_url_function_exists(self):
|
|
772
|
+
"""Test that download_from_url function exists in audio_worker."""
|
|
773
|
+
from backend.workers.audio_worker import download_from_url
|
|
774
|
+
assert callable(download_from_url)
|
|
775
|
+
|
|
776
|
+
def test_download_from_url_signature(self):
|
|
777
|
+
"""Test that download_from_url has the expected signature."""
|
|
778
|
+
import inspect
|
|
779
|
+
from backend.workers.audio_worker import download_from_url
|
|
780
|
+
|
|
781
|
+
sig = inspect.signature(download_from_url)
|
|
782
|
+
params = list(sig.parameters.keys())
|
|
783
|
+
|
|
784
|
+
# Should have these parameters
|
|
785
|
+
assert 'url' in params
|
|
786
|
+
assert 'temp_dir' in params
|
|
787
|
+
assert 'artist' in params
|
|
788
|
+
assert 'title' in params
|
|
789
|
+
assert 'job_manager' in params
|
|
790
|
+
assert 'job_id' in params
|
|
791
|
+
|
|
792
|
+
def test_download_from_url_is_async(self):
|
|
793
|
+
"""Test that download_from_url is an async function."""
|
|
794
|
+
import inspect
|
|
795
|
+
from backend.workers.audio_worker import download_from_url
|
|
796
|
+
|
|
797
|
+
assert inspect.iscoroutinefunction(download_from_url)
|
|
798
|
+
|
|
799
|
+
@pytest.mark.asyncio
|
|
800
|
+
async def test_download_from_url_returns_none_for_invalid_url(self):
|
|
801
|
+
"""Test that download_from_url handles errors gracefully."""
|
|
802
|
+
from backend.workers.audio_worker import download_from_url
|
|
803
|
+
|
|
804
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
805
|
+
# This should fail gracefully (no yt-dlp in test env or invalid URL)
|
|
806
|
+
result = await download_from_url(
|
|
807
|
+
url='not-a-valid-url',
|
|
808
|
+
temp_dir=temp_dir,
|
|
809
|
+
artist='Test',
|
|
810
|
+
title='Test'
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
# Should return None on error (graceful failure)
|
|
814
|
+
assert result is None
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
class TestDownloadAudioWithUrl:
|
|
818
|
+
"""Tests for download_audio function with URL-based jobs."""
|
|
819
|
+
|
|
820
|
+
@pytest.fixture
|
|
821
|
+
def mock_job_with_url(self):
|
|
822
|
+
"""Create a mock job with a URL."""
|
|
823
|
+
return Job(
|
|
824
|
+
job_id="test123",
|
|
825
|
+
status=JobStatus.PROCESSING,
|
|
826
|
+
created_at=datetime.now(UTC),
|
|
827
|
+
updated_at=datetime.now(UTC),
|
|
828
|
+
artist="Test Artist",
|
|
829
|
+
title="Test Song",
|
|
830
|
+
url="https://www.youtube.com/watch?v=test123"
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
@pytest.fixture
|
|
834
|
+
def mock_job_manager(self, mock_job_with_url):
|
|
835
|
+
"""Create a mock JobManager."""
|
|
836
|
+
manager = MagicMock()
|
|
837
|
+
manager.get_job.return_value = mock_job_with_url
|
|
838
|
+
manager.update_job.return_value = None
|
|
839
|
+
return manager
|
|
840
|
+
|
|
841
|
+
@pytest.fixture
|
|
842
|
+
def mock_storage(self):
|
|
843
|
+
"""Create a mock StorageService."""
|
|
844
|
+
storage = MagicMock()
|
|
845
|
+
return storage
|
|
846
|
+
|
|
847
|
+
@pytest.mark.asyncio
|
|
848
|
+
async def test_download_audio_from_url(self, mock_job_with_url, mock_job_manager, mock_storage):
|
|
849
|
+
"""Test download_audio routes to URL download when job has URL."""
|
|
850
|
+
with patch('backend.workers.audio_worker.download_from_url', return_value='/tmp/test.wav') as mock_download:
|
|
851
|
+
from backend.workers.audio_worker import download_audio
|
|
852
|
+
|
|
853
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
854
|
+
result = await download_audio(
|
|
855
|
+
"test123",
|
|
856
|
+
temp_dir,
|
|
857
|
+
mock_storage,
|
|
858
|
+
mock_job_with_url,
|
|
859
|
+
job_manager_instance=mock_job_manager
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
# Should have called download_from_url
|
|
863
|
+
mock_download.assert_called_once()
|
|
864
|
+
# Check positional args
|
|
865
|
+
args = mock_download.call_args[0]
|
|
866
|
+
assert args[0] == "https://www.youtube.com/watch?v=test123" # url
|
|
867
|
+
assert args[1] == temp_dir # temp_dir
|
|
868
|
+
assert args[2] == "Test Artist" # artist
|
|
869
|
+
assert args[3] == "Test Song" # title
|
|
870
|
+
|
|
871
|
+
@pytest.mark.asyncio
|
|
872
|
+
async def test_download_audio_from_gcs_when_no_url(self, mock_job_manager, mock_storage):
|
|
873
|
+
"""Test download_audio downloads from GCS when job has no URL."""
|
|
874
|
+
mock_job_no_url = Job(
|
|
875
|
+
job_id="test123",
|
|
876
|
+
status=JobStatus.PROCESSING,
|
|
877
|
+
created_at=datetime.now(UTC),
|
|
878
|
+
updated_at=datetime.now(UTC),
|
|
879
|
+
artist="Test Artist",
|
|
880
|
+
title="Test Song",
|
|
881
|
+
input_media_gcs_path="uploads/test123/song.flac"
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
with patch('backend.workers.audio_worker.download_from_url') as mock_url_download:
|
|
885
|
+
from backend.workers.audio_worker import download_audio
|
|
886
|
+
|
|
887
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
888
|
+
result = await download_audio(
|
|
889
|
+
"test123",
|
|
890
|
+
temp_dir,
|
|
891
|
+
mock_storage,
|
|
892
|
+
mock_job_no_url,
|
|
893
|
+
job_manager_instance=mock_job_manager
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
# Should NOT have called download_from_url
|
|
897
|
+
mock_url_download.assert_not_called()
|
|
898
|
+
|
|
899
|
+
# Should have called storage.download_file
|
|
900
|
+
mock_storage.download_file.assert_called()
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
class TestBackingVocalsAnalysis:
|
|
904
|
+
"""Tests for backing vocals analysis in render_video_worker."""
|
|
905
|
+
|
|
906
|
+
def test_analyze_backing_vocals_function_exists(self):
|
|
907
|
+
"""Test that _analyze_backing_vocals function exists in render_video_worker."""
|
|
908
|
+
from backend.workers.render_video_worker import _analyze_backing_vocals
|
|
909
|
+
assert callable(_analyze_backing_vocals)
|
|
910
|
+
|
|
911
|
+
def test_analyze_backing_vocals_is_async(self):
|
|
912
|
+
"""Test that _analyze_backing_vocals is an async function."""
|
|
913
|
+
import inspect
|
|
914
|
+
from backend.workers.render_video_worker import _analyze_backing_vocals
|
|
915
|
+
assert inspect.iscoroutinefunction(_analyze_backing_vocals)
|
|
916
|
+
|
|
917
|
+
def test_render_video_worker_imports_analysis_service(self):
|
|
918
|
+
"""Test that render_video_worker can import AudioAnalysisService."""
|
|
919
|
+
# This verifies the import path is correct
|
|
920
|
+
from backend.services.audio_analysis_service import AudioAnalysisService
|
|
921
|
+
assert AudioAnalysisService is not None
|
|
922
|
+
|
|
923
|
+
@pytest.mark.asyncio
|
|
924
|
+
async def test_analyze_backing_vocals_handles_missing_job(self):
|
|
925
|
+
"""Test that analysis returns early when job not found (no error stored)."""
|
|
926
|
+
mock_job_manager = MagicMock()
|
|
927
|
+
mock_job_manager.get_job.return_value = None # No job found
|
|
928
|
+
mock_job_manager.update_state_data = MagicMock()
|
|
929
|
+
|
|
930
|
+
mock_storage = MagicMock()
|
|
931
|
+
mock_logger = MagicMock()
|
|
932
|
+
|
|
933
|
+
from backend.workers.render_video_worker import _analyze_backing_vocals
|
|
934
|
+
|
|
935
|
+
# Should not raise when job not found, just log warning and return
|
|
936
|
+
await _analyze_backing_vocals(
|
|
937
|
+
"nonexistent", mock_job_manager, mock_storage, mock_logger
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
# Should log warning but not update state (early return)
|
|
941
|
+
mock_logger.warning.assert_called()
|
|
942
|
+
# No state_data update since we return early
|
|
943
|
+
mock_job_manager.update_state_data.assert_not_called()
|
|
944
|
+
|
|
945
|
+
@pytest.mark.asyncio
|
|
946
|
+
async def test_analyze_backing_vocals_handles_missing_stems(self):
|
|
947
|
+
"""Test that analysis returns early when stems not found (no error stored)."""
|
|
948
|
+
mock_job = MagicMock()
|
|
949
|
+
mock_job.file_urls = {} # No stems
|
|
950
|
+
|
|
951
|
+
mock_job_manager = MagicMock()
|
|
952
|
+
mock_job_manager.get_job.return_value = mock_job
|
|
953
|
+
mock_job_manager.update_state_data = MagicMock()
|
|
954
|
+
|
|
955
|
+
mock_storage = MagicMock()
|
|
956
|
+
mock_logger = MagicMock()
|
|
957
|
+
|
|
958
|
+
from backend.workers.render_video_worker import _analyze_backing_vocals
|
|
959
|
+
|
|
960
|
+
# Should not raise when stems not found, just log warning and return
|
|
961
|
+
await _analyze_backing_vocals(
|
|
962
|
+
"test123", mock_job_manager, mock_storage, mock_logger
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
# Should log warning but not update state (early return)
|
|
966
|
+
mock_logger.warning.assert_called()
|
|
967
|
+
# No state_data update since we return early
|
|
968
|
+
mock_job_manager.update_state_data.assert_not_called()
|
|
969
|
+
|
|
970
|
+
def test_analysis_service_can_be_instantiated(self):
|
|
971
|
+
"""Test that AudioAnalysisService can be instantiated."""
|
|
972
|
+
from backend.services.audio_analysis_service import AudioAnalysisService
|
|
973
|
+
|
|
974
|
+
mock_storage = MagicMock()
|
|
975
|
+
service = AudioAnalysisService(storage_service=mock_storage)
|
|
976
|
+
|
|
977
|
+
assert service is not None
|
|
978
|
+
assert service.storage_service == mock_storage
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
class TestModelNamesStorage:
|
|
982
|
+
"""Tests for model names storage in audio_worker.
|
|
983
|
+
|
|
984
|
+
These tests verify that model names are stored in job state_data
|
|
985
|
+
for use by video_worker in distribution directory preparation.
|
|
986
|
+
"""
|
|
987
|
+
|
|
988
|
+
def test_effective_model_names_defaults(self):
|
|
989
|
+
"""Test that default model names are used when not specified on job."""
|
|
990
|
+
from backend.workers.audio_worker import (
|
|
991
|
+
DEFAULT_CLEAN_MODEL,
|
|
992
|
+
DEFAULT_BACKING_MODELS,
|
|
993
|
+
DEFAULT_OTHER_MODELS,
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
# Simulate the logic from process_audio_separation
|
|
997
|
+
job_clean_model = None
|
|
998
|
+
job_backing_models = None
|
|
999
|
+
job_other_models = None
|
|
1000
|
+
|
|
1001
|
+
effective_model_names = {
|
|
1002
|
+
'clean_instrumental_model': job_clean_model or DEFAULT_CLEAN_MODEL,
|
|
1003
|
+
'backing_vocals_models': job_backing_models or DEFAULT_BACKING_MODELS,
|
|
1004
|
+
'other_stems_models': job_other_models or DEFAULT_OTHER_MODELS,
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
assert effective_model_names['clean_instrumental_model'] == DEFAULT_CLEAN_MODEL
|
|
1008
|
+
assert effective_model_names['backing_vocals_models'] == DEFAULT_BACKING_MODELS
|
|
1009
|
+
assert effective_model_names['other_stems_models'] == DEFAULT_OTHER_MODELS
|
|
1010
|
+
|
|
1011
|
+
def test_effective_model_names_custom(self):
|
|
1012
|
+
"""Test that custom model names override defaults."""
|
|
1013
|
+
from backend.workers.audio_worker import (
|
|
1014
|
+
DEFAULT_CLEAN_MODEL,
|
|
1015
|
+
DEFAULT_BACKING_MODELS,
|
|
1016
|
+
DEFAULT_OTHER_MODELS,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
custom_clean = "custom_clean_model.ckpt"
|
|
1020
|
+
custom_backing = ["custom_backing.ckpt"]
|
|
1021
|
+
custom_other = ["custom_demucs.yaml"]
|
|
1022
|
+
|
|
1023
|
+
# Simulate the logic from process_audio_separation
|
|
1024
|
+
effective_model_names = {
|
|
1025
|
+
'clean_instrumental_model': custom_clean or DEFAULT_CLEAN_MODEL,
|
|
1026
|
+
'backing_vocals_models': custom_backing or DEFAULT_BACKING_MODELS,
|
|
1027
|
+
'other_stems_models': custom_other or DEFAULT_OTHER_MODELS,
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
assert effective_model_names['clean_instrumental_model'] == custom_clean
|
|
1031
|
+
assert effective_model_names['backing_vocals_models'] == custom_backing
|
|
1032
|
+
assert effective_model_names['other_stems_models'] == custom_other
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
class TestDistributionDirectoryPreparation:
|
|
1036
|
+
"""Tests for _prepare_distribution_directory in video_worker.
|
|
1037
|
+
|
|
1038
|
+
These tests verify that the distribution directory is prepared with:
|
|
1039
|
+
- stems/ subfolder containing all audio stems with model names
|
|
1040
|
+
- lyrics/ subfolder containing intermediate lyrics files
|
|
1041
|
+
- Properly named instrumentals at root level
|
|
1042
|
+
"""
|
|
1043
|
+
|
|
1044
|
+
def test_distribution_directory_creates_stems_folder(self, tmp_path):
|
|
1045
|
+
"""Test that stems directory is created."""
|
|
1046
|
+
stems_dir = tmp_path / "stems"
|
|
1047
|
+
stems_dir.mkdir()
|
|
1048
|
+
|
|
1049
|
+
assert stems_dir.exists()
|
|
1050
|
+
assert stems_dir.is_dir()
|
|
1051
|
+
|
|
1052
|
+
def test_distribution_directory_creates_lyrics_folder(self, tmp_path):
|
|
1053
|
+
"""Test that lyrics directory is created."""
|
|
1054
|
+
lyrics_dir = tmp_path / "lyrics"
|
|
1055
|
+
lyrics_dir.mkdir()
|
|
1056
|
+
|
|
1057
|
+
assert lyrics_dir.exists()
|
|
1058
|
+
assert lyrics_dir.is_dir()
|
|
1059
|
+
|
|
1060
|
+
def test_instrumental_naming_with_model(self):
|
|
1061
|
+
"""Test that instrumental files are named with model names."""
|
|
1062
|
+
base_name = "Artist - Song"
|
|
1063
|
+
clean_model = "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
|
|
1064
|
+
backing_model = "mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"
|
|
1065
|
+
|
|
1066
|
+
clean_instrumental_name = f"{base_name} (Instrumental {clean_model}).flac"
|
|
1067
|
+
backing_instrumental_name = f"{base_name} (Instrumental +BV {backing_model}).flac"
|
|
1068
|
+
|
|
1069
|
+
# Verify expected format
|
|
1070
|
+
assert "model_bs_roformer" in clean_instrumental_name
|
|
1071
|
+
assert "+BV" in backing_instrumental_name
|
|
1072
|
+
assert backing_model in backing_instrumental_name
|
|
1073
|
+
|
|
1074
|
+
def test_stem_naming_convention(self):
|
|
1075
|
+
"""Test that stems are named with proper model suffixes."""
|
|
1076
|
+
base_name = "Artist - Song"
|
|
1077
|
+
clean_model = "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
|
|
1078
|
+
backing_model = "mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"
|
|
1079
|
+
other_model = "htdemucs_6s.yaml"
|
|
1080
|
+
|
|
1081
|
+
# Expected stem filenames
|
|
1082
|
+
expected_stems = {
|
|
1083
|
+
'vocals_clean': f"{base_name} (Vocals {clean_model}).flac",
|
|
1084
|
+
'lead_vocals': f"{base_name} (Lead Vocals {backing_model}).flac",
|
|
1085
|
+
'backing_vocals': f"{base_name} (Backing Vocals {backing_model}).flac",
|
|
1086
|
+
'bass': f"{base_name} (Bass {other_model}).flac",
|
|
1087
|
+
'drums': f"{base_name} (Drums {other_model}).flac",
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
# Verify naming convention
|
|
1091
|
+
for _key, name in expected_stems.items():
|
|
1092
|
+
assert ".flac" in name
|
|
1093
|
+
assert base_name in name
|
|
1094
|
+
|
|
1095
|
+
def test_simplified_instrumental_cleanup(self, tmp_path):
|
|
1096
|
+
"""Test that simplified instrumental names are cleaned up."""
|
|
1097
|
+
base_name = "Artist - Song"
|
|
1098
|
+
|
|
1099
|
+
# Create simplified-named files (as created by _setup_working_directory)
|
|
1100
|
+
simplified_clean = tmp_path / f"{base_name} (Instrumental Clean).flac"
|
|
1101
|
+
simplified_backing = tmp_path / f"{base_name} (Instrumental Backing).flac"
|
|
1102
|
+
simplified_clean.write_text("fake")
|
|
1103
|
+
simplified_backing.write_text("fake")
|
|
1104
|
+
|
|
1105
|
+
# Verify they exist
|
|
1106
|
+
assert simplified_clean.exists()
|
|
1107
|
+
assert simplified_backing.exists()
|
|
1108
|
+
|
|
1109
|
+
# Simulate cleanup
|
|
1110
|
+
simplified_clean.unlink()
|
|
1111
|
+
simplified_backing.unlink()
|
|
1112
|
+
|
|
1113
|
+
# Verify they're removed
|
|
1114
|
+
assert not simplified_clean.exists()
|
|
1115
|
+
assert not simplified_backing.exists()
|
|
1116
|
+
|