karaoke-gen 0.96.0__py3-none-any.whl → 0.101.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.
Files changed (58) hide show
  1. backend/api/routes/admin.py +696 -92
  2. backend/api/routes/audio_search.py +29 -8
  3. backend/api/routes/file_upload.py +99 -22
  4. backend/api/routes/health.py +65 -0
  5. backend/api/routes/internal.py +6 -0
  6. backend/api/routes/jobs.py +28 -1
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +472 -51
  10. backend/main.py +31 -2
  11. backend/middleware/__init__.py +7 -1
  12. backend/middleware/tenant.py +192 -0
  13. backend/models/job.py +19 -3
  14. backend/models/tenant.py +208 -0
  15. backend/models/user.py +18 -0
  16. backend/services/email_service.py +253 -6
  17. backend/services/encoding_service.py +128 -31
  18. backend/services/firestore_service.py +6 -0
  19. backend/services/job_manager.py +44 -2
  20. backend/services/langfuse_preloader.py +98 -0
  21. backend/services/nltk_preloader.py +122 -0
  22. backend/services/spacy_preloader.py +65 -0
  23. backend/services/stripe_service.py +133 -11
  24. backend/services/tenant_service.py +285 -0
  25. backend/services/user_service.py +85 -7
  26. backend/tests/emulator/conftest.py +22 -1
  27. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  28. backend/tests/test_admin_job_files.py +337 -0
  29. backend/tests/test_admin_job_reset.py +384 -0
  30. backend/tests/test_admin_job_update.py +326 -0
  31. backend/tests/test_email_service.py +233 -0
  32. backend/tests/test_impersonation.py +223 -0
  33. backend/tests/test_job_creation_regression.py +4 -0
  34. backend/tests/test_job_manager.py +171 -9
  35. backend/tests/test_jobs_api.py +11 -1
  36. backend/tests/test_made_for_you.py +2086 -0
  37. backend/tests/test_models.py +139 -0
  38. backend/tests/test_spacy_preloader.py +119 -0
  39. backend/tests/test_tenant_api.py +350 -0
  40. backend/tests/test_tenant_middleware.py +345 -0
  41. backend/tests/test_tenant_models.py +406 -0
  42. backend/tests/test_tenant_service.py +418 -0
  43. backend/utils/test_data.py +27 -0
  44. backend/workers/screens_worker.py +16 -6
  45. backend/workers/video_worker.py +8 -3
  46. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  47. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
  48. lyrics_transcriber/correction/agentic/agent.py +17 -6
  49. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
  50. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  51. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  52. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  53. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  54. lyrics_transcriber/frontend/src/api.ts +13 -5
  55. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  56. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  57. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  58. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,337 @@
1
+ """
2
+ Unit tests for admin job files endpoint.
3
+
4
+ Tests the GET /api/admin/jobs/{job_id}/files endpoint that returns
5
+ all files for a job with signed download URLs.
6
+ """
7
+ import pytest
8
+ from unittest.mock import Mock, patch
9
+ from fastapi.testclient import TestClient
10
+ from fastapi import FastAPI
11
+
12
+ from backend.api.routes.admin import router
13
+ from backend.api.dependencies import require_admin
14
+ from backend.models.job import Job, JobStatus
15
+
16
+
17
+ # Create a test app with the admin router
18
+ app = FastAPI()
19
+ app.include_router(router, prefix="/api")
20
+
21
+
22
+ def get_mock_admin():
23
+ """Override for require_admin dependency."""
24
+ from backend.api.dependencies import AuthResult, UserType
25
+ return AuthResult(
26
+ is_valid=True,
27
+ user_type=UserType.ADMIN,
28
+ remaining_uses=999,
29
+ message="Admin authenticated",
30
+ user_email="admin@example.com",
31
+ is_admin=True,
32
+ )
33
+
34
+
35
+ # Override the require_admin dependency
36
+ app.dependency_overrides[require_admin] = get_mock_admin
37
+
38
+
39
+ @pytest.fixture
40
+ def client():
41
+ """Create a test client."""
42
+ return TestClient(app)
43
+
44
+
45
+ @pytest.fixture
46
+ def mock_job_with_files():
47
+ """Create a mock job with comprehensive file_urls."""
48
+ job = Mock(spec=Job)
49
+ job.job_id = "test-job-123"
50
+ job.user_email = "user@example.com"
51
+ job.artist = "Test Artist"
52
+ job.title = "Test Song"
53
+ job.status = JobStatus.COMPLETE
54
+ job.file_urls = {
55
+ "input": "gs://bucket/jobs/test-job-123/input.flac",
56
+ "stems": {
57
+ "instrumental_clean": "gs://bucket/jobs/test-job-123/stems/instrumental_clean.flac",
58
+ "vocals": "gs://bucket/jobs/test-job-123/stems/vocals.flac",
59
+ },
60
+ "lyrics": {
61
+ "corrections": "gs://bucket/jobs/test-job-123/lyrics/corrections.json",
62
+ "lrc": "gs://bucket/jobs/test-job-123/lyrics/output.lrc",
63
+ },
64
+ "finals": {
65
+ "lossy_720p_mp4": "gs://bucket/jobs/test-job-123/finals/video_720p.mp4",
66
+ },
67
+ "youtube": {
68
+ "url": "https://youtube.com/watch?v=xyz789",
69
+ "video_id": "xyz789",
70
+ },
71
+ }
72
+ return job
73
+
74
+
75
+ @pytest.fixture
76
+ def mock_job_no_files():
77
+ """Create a mock job with empty file_urls."""
78
+ job = Mock(spec=Job)
79
+ job.job_id = "test-job-empty"
80
+ job.user_email = "user@example.com"
81
+ job.artist = "Empty Artist"
82
+ job.title = "Empty Song"
83
+ job.status = JobStatus.PENDING
84
+ job.file_urls = {}
85
+ return job
86
+
87
+
88
+ @pytest.fixture
89
+ def mock_job_partial_files():
90
+ """Create a mock job with only some files (partial processing)."""
91
+ job = Mock(spec=Job)
92
+ job.job_id = "test-job-partial"
93
+ job.user_email = "user@example.com"
94
+ job.artist = "Partial Artist"
95
+ job.title = "Partial Song"
96
+ job.status = JobStatus.SEPARATING_STAGE1
97
+ job.file_urls = {
98
+ "input": "gs://bucket/jobs/test-job-partial/input.flac",
99
+ "stems": {
100
+ "instrumental_clean": "gs://bucket/jobs/test-job-partial/stems/instrumental_clean.flac",
101
+ },
102
+ }
103
+ return job
104
+
105
+
106
+ class TestGetJobFiles:
107
+ """Tests for GET /api/admin/jobs/{job_id}/files endpoint."""
108
+
109
+ def test_returns_files_with_signed_urls(self, client, mock_job_with_files):
110
+ """Test that endpoint returns all files with signed URLs."""
111
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
112
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
113
+
114
+ # Setup JobManager mock
115
+ mock_jm = Mock()
116
+ mock_jm.get_job.return_value = mock_job_with_files
117
+ mock_jm_class.return_value = mock_jm
118
+
119
+ # Setup StorageService mock to return predictable signed URLs
120
+ mock_storage = Mock()
121
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
122
+ mock_storage_class.return_value = mock_storage
123
+
124
+ response = client.get(
125
+ "/api/admin/jobs/test-job-123/files",
126
+ headers={"Authorization": "Bearer admin-token"}
127
+ )
128
+
129
+ assert response.status_code == 200
130
+ data = response.json()
131
+ assert data["job_id"] == "test-job-123"
132
+ assert data["artist"] == "Test Artist"
133
+ assert data["title"] == "Test Song"
134
+ assert "files" in data
135
+ assert data["total_files"] > 0
136
+
137
+ def test_returns_correct_file_count(self, client, mock_job_with_files):
138
+ """Test that file count matches actual GCS files (excluding non-GCS entries)."""
139
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
140
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
141
+
142
+ mock_jm = Mock()
143
+ mock_jm.get_job.return_value = mock_job_with_files
144
+ mock_jm_class.return_value = mock_jm
145
+
146
+ mock_storage = Mock()
147
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
148
+ mock_storage_class.return_value = mock_storage
149
+
150
+ response = client.get("/api/admin/jobs/test-job-123/files")
151
+
152
+ data = response.json()
153
+ # Should have: input, stems.instrumental_clean, stems.vocals,
154
+ # lyrics.corrections, lyrics.lrc, finals.lossy_720p_mp4
155
+ # NOT youtube.url or youtube.video_id (not GCS paths)
156
+ assert data["total_files"] == 6
157
+
158
+ def test_handles_nested_file_structure(self, client, mock_job_with_files):
159
+ """Test that nested file_urls structure is properly traversed."""
160
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
161
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
162
+
163
+ mock_jm = Mock()
164
+ mock_jm.get_job.return_value = mock_job_with_files
165
+ mock_jm_class.return_value = mock_jm
166
+
167
+ mock_storage = Mock()
168
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
169
+ mock_storage_class.return_value = mock_storage
170
+
171
+ response = client.get("/api/admin/jobs/test-job-123/files")
172
+
173
+ data = response.json()
174
+ files = data["files"]
175
+
176
+ # Check that files from different categories are present
177
+ categories = {f["category"] for f in files}
178
+ assert "input" in categories or any(f["category"] == "" and f["file_key"] == "input" for f in files)
179
+ assert "stems" in categories
180
+ assert "lyrics" in categories
181
+ assert "finals" in categories
182
+
183
+ def test_file_info_structure(self, client, mock_job_with_files):
184
+ """Test that each file has correct info structure."""
185
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
186
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
187
+
188
+ mock_jm = Mock()
189
+ mock_jm.get_job.return_value = mock_job_with_files
190
+ mock_jm_class.return_value = mock_jm
191
+
192
+ mock_storage = Mock()
193
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
194
+ mock_storage_class.return_value = mock_storage
195
+
196
+ response = client.get("/api/admin/jobs/test-job-123/files")
197
+
198
+ data = response.json()
199
+ files = data["files"]
200
+
201
+ # Every file should have these fields
202
+ for file_info in files:
203
+ assert "name" in file_info
204
+ assert "path" in file_info
205
+ assert "download_url" in file_info
206
+ assert "category" in file_info
207
+ assert "file_key" in file_info
208
+ # download_url should be a signed URL
209
+ assert file_info["download_url"].startswith("https://signed.url/")
210
+
211
+ def test_returns_404_when_job_not_found(self, client):
212
+ """Test 404 when job doesn't exist."""
213
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class:
214
+ mock_jm = Mock()
215
+ mock_jm.get_job.return_value = None
216
+ mock_jm_class.return_value = mock_jm
217
+
218
+ response = client.get(
219
+ "/api/admin/jobs/nonexistent-job/files",
220
+ headers={"Authorization": "Bearer admin-token"}
221
+ )
222
+
223
+ assert response.status_code == 404
224
+ assert "not found" in response.json()["detail"].lower()
225
+
226
+ def test_returns_empty_files_for_new_job(self, client, mock_job_no_files):
227
+ """Test that new jobs with no files return empty list."""
228
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
229
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
230
+
231
+ mock_jm = Mock()
232
+ mock_jm.get_job.return_value = mock_job_no_files
233
+ mock_jm_class.return_value = mock_jm
234
+
235
+ mock_storage = Mock()
236
+ mock_storage_class.return_value = mock_storage
237
+
238
+ response = client.get("/api/admin/jobs/test-job-empty/files")
239
+
240
+ assert response.status_code == 200
241
+ data = response.json()
242
+ assert data["job_id"] == "test-job-empty"
243
+ assert data["files"] == []
244
+ assert data["total_files"] == 0
245
+
246
+ def test_handles_partial_processing(self, client, mock_job_partial_files):
247
+ """Test job with only some files processed."""
248
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
249
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
250
+
251
+ mock_jm = Mock()
252
+ mock_jm.get_job.return_value = mock_job_partial_files
253
+ mock_jm_class.return_value = mock_jm
254
+
255
+ mock_storage = Mock()
256
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
257
+ mock_storage_class.return_value = mock_storage
258
+
259
+ response = client.get("/api/admin/jobs/test-job-partial/files")
260
+
261
+ assert response.status_code == 200
262
+ data = response.json()
263
+ # Should have input + stems.instrumental_clean = 2 files
264
+ assert data["total_files"] == 2
265
+
266
+ def test_skips_non_gcs_urls(self, client, mock_job_with_files):
267
+ """Test that non-GCS URLs (like youtube URLs) are skipped."""
268
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
269
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
270
+
271
+ mock_jm = Mock()
272
+ mock_jm.get_job.return_value = mock_job_with_files
273
+ mock_jm_class.return_value = mock_jm
274
+
275
+ mock_storage = Mock()
276
+ mock_storage.generate_signed_url.side_effect = lambda path, **kwargs: f"https://signed.url/{path}"
277
+ mock_storage_class.return_value = mock_storage
278
+
279
+ response = client.get("/api/admin/jobs/test-job-123/files")
280
+
281
+ data = response.json()
282
+ files = data["files"]
283
+
284
+ # Ensure no youtube URLs or video IDs are in the files
285
+ for file_info in files:
286
+ assert "youtube.com" not in file_info["path"]
287
+ assert file_info["path"].startswith("gs://")
288
+
289
+ def test_signed_url_expiration(self, client, mock_job_with_files):
290
+ """Test that signed URLs are generated with appropriate expiration."""
291
+ with patch('backend.api.routes.admin.JobManager') as mock_jm_class, \
292
+ patch('backend.api.routes.admin.StorageService') as mock_storage_class:
293
+
294
+ mock_jm = Mock()
295
+ mock_jm.get_job.return_value = mock_job_with_files
296
+ mock_jm_class.return_value = mock_jm
297
+
298
+ mock_storage = Mock()
299
+ mock_storage.generate_signed_url.return_value = "https://signed.url/test"
300
+ mock_storage_class.return_value = mock_storage
301
+
302
+ client.get("/api/admin/jobs/test-job-123/files")
303
+
304
+ # Verify signed URLs were requested with expiration (default 120 minutes)
305
+ assert mock_storage.generate_signed_url.called
306
+ # Check that expiration_minutes was passed (could be any reasonable value)
307
+ call_kwargs = mock_storage.generate_signed_url.call_args_list[0][1]
308
+ if "expiration_minutes" in call_kwargs:
309
+ assert call_kwargs["expiration_minutes"] >= 60
310
+
311
+
312
+ class TestGetJobFilesAuthorization:
313
+ """Tests for authorization on the files endpoint."""
314
+
315
+ def test_requires_admin_access(self, client, mock_job_with_files):
316
+ """Test that non-admin users cannot access the endpoint."""
317
+ # Reset the dependency override to test auth
318
+ original_override = app.dependency_overrides.get(require_admin)
319
+
320
+ def get_non_admin():
321
+ from fastapi import HTTPException
322
+ raise HTTPException(status_code=403, detail="Admin access required")
323
+
324
+ app.dependency_overrides[require_admin] = get_non_admin
325
+
326
+ try:
327
+ response = client.get(
328
+ "/api/admin/jobs/test-job-123/files",
329
+ headers={"Authorization": "Bearer user-token"}
330
+ )
331
+ assert response.status_code == 403
332
+ finally:
333
+ # Restore the original override
334
+ if original_override:
335
+ app.dependency_overrides[require_admin] = original_override
336
+ else:
337
+ app.dependency_overrides[require_admin] = get_mock_admin