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.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,273 @@
1
+ """
2
+ Unit tests for jobs.py API routes using FastAPI TestClient.
3
+
4
+ These tests mock the underlying services and test the route logic directly.
5
+ """
6
+ import pytest
7
+ from datetime import datetime, UTC
8
+ from unittest.mock import MagicMock, AsyncMock, patch
9
+ from fastapi.testclient import TestClient
10
+
11
+ from backend.models.job import Job, JobStatus
12
+
13
+
14
+ @pytest.fixture
15
+ def mock_job():
16
+ """Create a standard mock job for testing."""
17
+ return Job(
18
+ job_id="test123",
19
+ status=JobStatus.PENDING,
20
+ created_at=datetime.now(UTC),
21
+ updated_at=datetime.now(UTC),
22
+ artist="Test Artist",
23
+ title="Test Song"
24
+ )
25
+
26
+
27
+ @pytest.fixture
28
+ def mock_job_manager(mock_job):
29
+ """Create a mock JobManager with common methods."""
30
+ manager = MagicMock()
31
+ manager.get_job.return_value = mock_job
32
+ manager.list_jobs.return_value = [mock_job]
33
+ manager.create_job.return_value = mock_job
34
+ manager.delete_job.return_value = True
35
+ manager.cancel_job.return_value = mock_job
36
+ manager.update_job_status.return_value = None
37
+ manager.update_state_data.return_value = None
38
+ return manager
39
+
40
+
41
+ @pytest.fixture
42
+ def mock_worker_service():
43
+ """Create a mock WorkerService."""
44
+ service = MagicMock()
45
+ service.trigger_audio_worker = AsyncMock(return_value=True)
46
+ service.trigger_lyrics_worker = AsyncMock(return_value=True)
47
+ service.trigger_screens_worker = AsyncMock(return_value=True)
48
+ service.trigger_video_worker = AsyncMock(return_value=True)
49
+ return service
50
+
51
+
52
+ @pytest.fixture
53
+ def client(mock_job_manager, mock_worker_service):
54
+ """Create TestClient with mocked dependencies."""
55
+ mock_creds = MagicMock()
56
+ mock_creds.universe_domain = 'googleapis.com'
57
+
58
+ # Create a JobManager class that returns our mock instance
59
+ def mock_job_manager_factory(*args, **kwargs):
60
+ return mock_job_manager
61
+
62
+ # Patch at the module level where jobs.py imports them
63
+ # Also patch JobManager class used in dependencies.py for auth checks
64
+ with patch('backend.api.routes.jobs.job_manager', mock_job_manager), \
65
+ patch('backend.api.routes.jobs.worker_service', mock_worker_service), \
66
+ patch('backend.services.job_manager.JobManager', mock_job_manager_factory), \
67
+ patch('backend.services.firestore_service.firestore'), \
68
+ patch('backend.services.storage_service.storage'), \
69
+ patch('google.auth.default', return_value=(mock_creds, 'test-project')):
70
+ from backend.main import app
71
+ yield TestClient(app)
72
+
73
+
74
+ class TestGetJob:
75
+ """Tests for GET /api/jobs/{job_id}."""
76
+
77
+ def test_get_job_returns_200(self, client, mock_job_manager, mock_job, auth_headers):
78
+ """Test getting an existing job returns 200."""
79
+ response = client.get("/api/jobs/test123", headers=auth_headers)
80
+ assert response.status_code == 200
81
+
82
+ def test_get_job_returns_job_data(self, client, mock_job_manager, mock_job, auth_headers):
83
+ """Test response contains job data."""
84
+ response = client.get("/api/jobs/test123", headers=auth_headers)
85
+ data = response.json()
86
+ assert data["job_id"] == "test123"
87
+ assert data["status"] == "pending"
88
+ assert data["artist"] == "Test Artist"
89
+
90
+ def test_get_nonexistent_job_returns_404(self, mock_worker_service, auth_headers):
91
+ """Test getting non-existent job returns 404."""
92
+ mock_job_manager = MagicMock()
93
+ mock_job_manager.get_job.return_value = None
94
+ mock_creds = MagicMock()
95
+ mock_creds.universe_domain = 'googleapis.com'
96
+
97
+ def mock_job_manager_factory(*args, **kwargs):
98
+ return mock_job_manager
99
+
100
+ with patch('backend.api.routes.jobs.job_manager', mock_job_manager), \
101
+ patch('backend.api.routes.jobs.worker_service', mock_worker_service), \
102
+ patch('backend.services.job_manager.JobManager', mock_job_manager_factory), \
103
+ patch('backend.services.firestore_service.firestore'), \
104
+ patch('backend.services.storage_service.storage'), \
105
+ patch('google.auth.default', return_value=(mock_creds, 'test-project')):
106
+ from backend.main import app
107
+ client = TestClient(app)
108
+ response = client.get("/api/jobs/nonexistent", headers=auth_headers)
109
+ assert response.status_code == 404
110
+
111
+
112
+ class TestListJobs:
113
+ """Tests for GET /api/jobs."""
114
+
115
+ def test_list_jobs_returns_200(self, client, auth_headers):
116
+ """Test listing jobs returns 200."""
117
+ response = client.get("/api/jobs", headers=auth_headers)
118
+ assert response.status_code == 200
119
+
120
+ def test_list_jobs_returns_array(self, client, mock_job_manager, auth_headers):
121
+ """Test response is an array of jobs."""
122
+ response = client.get("/api/jobs", headers=auth_headers)
123
+ data = response.json()
124
+ assert isinstance(data, list)
125
+ assert len(data) == 1
126
+ assert data[0]["job_id"] == "test123"
127
+
128
+ def test_list_jobs_with_status_filter(self, client, mock_job_manager, auth_headers):
129
+ """Test listing jobs with status filter."""
130
+ response = client.get("/api/jobs?status=pending", headers=auth_headers)
131
+ assert response.status_code == 200
132
+
133
+ def test_list_jobs_with_limit(self, client, mock_job_manager, auth_headers):
134
+ """Test listing jobs with limit."""
135
+ response = client.get("/api/jobs?limit=10", headers=auth_headers)
136
+ assert response.status_code == 200
137
+
138
+
139
+ class TestCreateJob:
140
+ """Tests for POST /api/jobs."""
141
+
142
+ def test_create_job_with_url_returns_200(self, client, mock_job_manager, auth_headers):
143
+ """Test creating job with URL returns 200."""
144
+ response = client.post(
145
+ "/api/jobs",
146
+ json={"url": "https://youtube.com/watch?v=test123"},
147
+ headers=auth_headers
148
+ )
149
+ assert response.status_code == 200
150
+
151
+ def test_create_job_returns_job_id(self, client, mock_job_manager, auth_headers):
152
+ """Test create response contains job_id."""
153
+ response = client.post(
154
+ "/api/jobs",
155
+ json={"url": "https://youtube.com/watch?v=test"},
156
+ headers=auth_headers
157
+ )
158
+ data = response.json()
159
+ assert "job_id" in data
160
+
161
+ def test_create_job_with_artist_title(self, client, mock_job_manager, auth_headers):
162
+ """Test creating job with artist and title."""
163
+ response = client.post(
164
+ "/api/jobs",
165
+ json={
166
+ "url": "https://youtube.com/watch?v=test",
167
+ "artist": "Test Artist",
168
+ "title": "Test Song"
169
+ },
170
+ headers=auth_headers
171
+ )
172
+ assert response.status_code == 200
173
+
174
+
175
+ class TestDeleteJob:
176
+ """Tests for DELETE /api/jobs/{job_id}."""
177
+
178
+ def test_delete_job_returns_200(self, client, mock_job_manager, auth_headers):
179
+ """Test deleting job returns 200."""
180
+ response = client.delete("/api/jobs/test123", headers=auth_headers)
181
+ assert response.status_code == 200
182
+
183
+ def test_delete_nonexistent_job(self, mock_worker_service, auth_headers):
184
+ """Test deleting non-existent job."""
185
+ mock_job_manager = MagicMock()
186
+ mock_job_manager.get_job.return_value = None # Job doesn't exist
187
+ mock_job_manager.delete_job.return_value = False
188
+ mock_creds = MagicMock()
189
+ mock_creds.universe_domain = 'googleapis.com'
190
+
191
+ def mock_job_manager_factory(*args, **kwargs):
192
+ return mock_job_manager
193
+
194
+ with patch('backend.api.routes.jobs.job_manager', mock_job_manager), \
195
+ patch('backend.api.routes.jobs.worker_service', mock_worker_service), \
196
+ patch('backend.services.job_manager.JobManager', mock_job_manager_factory), \
197
+ patch('backend.services.firestore_service.firestore'), \
198
+ patch('backend.services.storage_service.storage'), \
199
+ patch('google.auth.default', return_value=(mock_creds, 'test-project')):
200
+ from backend.main import app
201
+ client = TestClient(app)
202
+ response = client.delete("/api/jobs/nonexistent", headers=auth_headers)
203
+ # Either 200 or 404 depending on implementation
204
+ assert response.status_code in [200, 404]
205
+
206
+
207
+ class TestCancelJob:
208
+ """Tests for POST /api/jobs/{job_id}/cancel."""
209
+
210
+ def test_cancel_job_returns_200(self, client, mock_job_manager, mock_job, auth_headers):
211
+ """Test cancelling job returns 200."""
212
+ mock_job_manager.cancel_job.return_value = mock_job
213
+ response = client.post(
214
+ "/api/jobs/test123/cancel",
215
+ json={},
216
+ headers=auth_headers
217
+ )
218
+ assert response.status_code == 200
219
+
220
+
221
+ class TestSubmitCorrections:
222
+ """Tests for POST /api/jobs/{job_id}/corrections."""
223
+
224
+ def test_submit_corrections_returns_200(self, client, mock_job_manager, mock_job, auth_headers):
225
+ """Test submitting corrections returns 200."""
226
+ # Job needs to be in AWAITING_REVIEW or IN_REVIEW status
227
+ review_job = Job(
228
+ job_id="test123",
229
+ status=JobStatus.IN_REVIEW,
230
+ created_at=datetime.now(UTC),
231
+ updated_at=datetime.now(UTC),
232
+ artist="Test",
233
+ title="Test"
234
+ )
235
+ mock_job_manager.get_job.return_value = review_job
236
+
237
+ response = client.post(
238
+ "/api/jobs/test123/corrections",
239
+ json={
240
+ "corrections": {
241
+ "lines": [],
242
+ "metadata": {"source": "test"}
243
+ }
244
+ },
245
+ headers=auth_headers
246
+ )
247
+ # Should be 200 or validation error
248
+ assert response.status_code in [200, 400, 422]
249
+
250
+
251
+ class TestSelectInstrumental:
252
+ """Tests for POST /api/jobs/{job_id}/select-instrumental."""
253
+
254
+ def test_select_instrumental_requires_selection_field(self, client, mock_job_manager, mock_job, auth_headers):
255
+ """Test instrumental selection requires selection field."""
256
+ response = client.post(
257
+ "/api/jobs/test123/select-instrumental",
258
+ json={}, # Missing selection field
259
+ headers=auth_headers
260
+ )
261
+ # Missing field should cause validation error
262
+ assert response.status_code == 422
263
+
264
+
265
+ class TestStartReview:
266
+ """Tests for POST /api/jobs/{job_id}/start-review."""
267
+
268
+ def test_start_review_endpoint_exists(self, client, mock_job_manager, mock_job, auth_headers):
269
+ """Test start review endpoint exists."""
270
+ response = client.post("/api/jobs/test123/start-review", headers=auth_headers)
271
+ # Should not be 404 or 405
272
+ assert response.status_code not in [404, 405]
273
+
@@ -0,0 +1,423 @@
1
+ """
2
+ Tests for LocalEncodingService.
3
+
4
+ Tests cover:
5
+ - Service initialization
6
+ - Hardware acceleration detection
7
+ - FFmpeg command execution
8
+ - Individual encoding methods
9
+ - Full encoding pipeline
10
+ - Dry run mode
11
+ """
12
+
13
+ import os
14
+ import tempfile
15
+ import pytest
16
+ from unittest.mock import MagicMock, patch, call
17
+
18
+ from backend.services.local_encoding_service import (
19
+ LocalEncodingService,
20
+ EncodingConfig,
21
+ EncodingResult,
22
+ get_local_encoding_service,
23
+ )
24
+
25
+
26
+ class TestLocalEncodingServiceInit:
27
+ """Test service initialization."""
28
+
29
+ def test_init_default_values(self):
30
+ """Test default initialization."""
31
+ service = LocalEncodingService()
32
+ assert service.dry_run is False
33
+ assert "ffmpeg" in service._ffmpeg_base_command
34
+
35
+ def test_init_with_dry_run(self):
36
+ """Test initialization with dry run mode."""
37
+ service = LocalEncodingService(dry_run=True)
38
+ assert service.dry_run is True
39
+
40
+ def test_init_with_debug_logging(self):
41
+ """Test initialization with debug logging level."""
42
+ import logging
43
+ service = LocalEncodingService(log_level=logging.DEBUG)
44
+ assert "-loglevel verbose" in service._ffmpeg_base_command
45
+
46
+ def test_init_with_info_logging(self):
47
+ """Test initialization with info logging level."""
48
+ import logging
49
+ service = LocalEncodingService(log_level=logging.INFO)
50
+ assert "-loglevel fatal" in service._ffmpeg_base_command
51
+
52
+
53
+ class TestLocalEncodingServiceHWAccel:
54
+ """Test hardware acceleration detection."""
55
+
56
+ @patch("subprocess.run")
57
+ def test_detect_nvenc_available(self, mock_run):
58
+ """Test NVENC detection when available."""
59
+ mock_run.return_value = MagicMock(returncode=0)
60
+
61
+ service = LocalEncodingService()
62
+ # Force detection
63
+ service._detect_and_set_hwaccel()
64
+
65
+ assert service._hwaccel_available is True
66
+ assert service._video_encoder == "h264_nvenc"
67
+ assert service._scale_filter == "scale_cuda"
68
+
69
+ @patch("subprocess.run")
70
+ def test_detect_no_hwaccel(self, mock_run):
71
+ """Test fallback when no hardware acceleration available."""
72
+ import subprocess
73
+ mock_run.side_effect = subprocess.CalledProcessError(1, "test")
74
+
75
+ service = LocalEncodingService()
76
+ # Force detection
77
+ service._detect_and_set_hwaccel()
78
+
79
+ assert service._hwaccel_available is False
80
+ assert service._video_encoder == "libx264"
81
+ assert service._scale_filter == "scale"
82
+
83
+ def test_nvenc_quality_settings(self):
84
+ """Test NVENC quality settings for different presets."""
85
+ service = LocalEncodingService()
86
+ service._hwaccel_available = True
87
+
88
+ lossless = service._get_nvenc_quality_settings("lossless")
89
+ assert "cq 0" in lossless
90
+
91
+ medium = service._get_nvenc_quality_settings("medium")
92
+ assert "p4" in medium
93
+
94
+ def test_nvenc_quality_settings_disabled(self):
95
+ """Test NVENC quality settings when hwaccel is disabled."""
96
+ service = LocalEncodingService()
97
+ service._hwaccel_available = False
98
+
99
+ settings = service._get_nvenc_quality_settings("lossless")
100
+ assert settings == ""
101
+
102
+
103
+ class TestLocalEncodingServiceExecuteCommand:
104
+ """Test command execution."""
105
+
106
+ @patch("subprocess.run")
107
+ def test_execute_command_success(self, mock_run):
108
+ """Test successful command execution."""
109
+ mock_run.return_value = MagicMock(returncode=0)
110
+
111
+ service = LocalEncodingService()
112
+ result = service._execute_command("echo test", "Test command")
113
+
114
+ assert result is True
115
+ mock_run.assert_called_once()
116
+
117
+ @patch("subprocess.run")
118
+ def test_execute_command_failure(self, mock_run):
119
+ """Test command execution failure."""
120
+ import subprocess
121
+ mock_run.side_effect = subprocess.CalledProcessError(
122
+ 1, "test", stderr="Error message"
123
+ )
124
+
125
+ service = LocalEncodingService()
126
+ result = service._execute_command("echo test", "Test command")
127
+
128
+ assert result is False
129
+
130
+ @patch("subprocess.run")
131
+ def test_execute_command_timeout(self, mock_run):
132
+ """Test command execution timeout."""
133
+ import subprocess
134
+ mock_run.side_effect = subprocess.TimeoutExpired("test", 30)
135
+
136
+ service = LocalEncodingService()
137
+ result = service._execute_command("echo test", "Test command", timeout=30)
138
+
139
+ assert result is False
140
+
141
+ def test_execute_command_dry_run(self):
142
+ """Test command execution in dry run mode."""
143
+ service = LocalEncodingService(dry_run=True)
144
+ result = service._execute_command("echo test", "Test command")
145
+
146
+ assert result is True
147
+ # No actual subprocess should be called in dry run mode
148
+
149
+
150
+ class TestLocalEncodingServiceEncodingMethods:
151
+ """Test individual encoding methods."""
152
+
153
+ @patch.object(LocalEncodingService, "_execute_command")
154
+ def test_remux_with_instrumental(self, mock_execute):
155
+ """Test remuxing with instrumental audio."""
156
+ mock_execute.return_value = True
157
+
158
+ service = LocalEncodingService()
159
+ result = service.remux_with_instrumental(
160
+ "/input/video.mov",
161
+ "/input/audio.flac",
162
+ "/output/video.mp4"
163
+ )
164
+
165
+ assert result is True
166
+ mock_execute.assert_called_once()
167
+ call_args = mock_execute.call_args[0][0]
168
+ assert "/input/video.mov" in call_args
169
+ assert "/input/audio.flac" in call_args
170
+ assert "-map 0:v -map 1:a" in call_args
171
+
172
+ @patch.object(LocalEncodingService, "_execute_command_with_fallback")
173
+ def test_convert_mov_to_mp4(self, mock_execute):
174
+ """Test MOV to MP4 conversion."""
175
+ mock_execute.return_value = True
176
+
177
+ service = LocalEncodingService()
178
+ result = service.convert_mov_to_mp4(
179
+ "/input/video.mov",
180
+ "/output/video.mp4"
181
+ )
182
+
183
+ assert result is True
184
+ mock_execute.assert_called_once()
185
+
186
+ @patch.object(LocalEncodingService, "_execute_command_with_fallback")
187
+ def test_encode_lossless_mp4_without_end(self, mock_execute):
188
+ """Test lossless 4K MP4 encoding without end credits."""
189
+ mock_execute.return_value = True
190
+
191
+ service = LocalEncodingService()
192
+ result = service.encode_lossless_mp4(
193
+ "/input/title.mov",
194
+ "/input/karaoke.mp4",
195
+ "/output/lossless.mp4"
196
+ )
197
+
198
+ assert result is True
199
+ mock_execute.assert_called_once()
200
+ call_args = mock_execute.call_args
201
+ assert "concat=n=2" in call_args[0][0] # GPU command
202
+
203
+ @patch.object(LocalEncodingService, "_execute_command_with_fallback")
204
+ def test_encode_lossless_mp4_with_end(self, mock_execute):
205
+ """Test lossless 4K MP4 encoding with end credits."""
206
+ mock_execute.return_value = True
207
+
208
+ with tempfile.TemporaryDirectory() as tmpdir:
209
+ end_file = os.path.join(tmpdir, "end.mov")
210
+ with open(end_file, "w") as f:
211
+ f.write("fake video")
212
+
213
+ service = LocalEncodingService()
214
+ result = service.encode_lossless_mp4(
215
+ "/input/title.mov",
216
+ "/input/karaoke.mp4",
217
+ "/output/lossless.mp4",
218
+ end_video=end_file
219
+ )
220
+
221
+ assert result is True
222
+ call_args = mock_execute.call_args
223
+ assert "concat=n=3" in call_args[0][0] # 3 videos
224
+
225
+ @patch.object(LocalEncodingService, "_execute_command")
226
+ def test_encode_lossy_mp4(self, mock_execute):
227
+ """Test lossy 4K MP4 encoding."""
228
+ mock_execute.return_value = True
229
+
230
+ service = LocalEncodingService()
231
+ result = service.encode_lossy_mp4(
232
+ "/input/lossless.mp4",
233
+ "/output/lossy.mp4"
234
+ )
235
+
236
+ assert result is True
237
+ call_args = mock_execute.call_args[0][0]
238
+ assert "-c:v copy" in call_args
239
+ assert "aac" in call_args.lower()
240
+
241
+ @patch.object(LocalEncodingService, "_execute_command")
242
+ def test_encode_lossless_mkv(self, mock_execute):
243
+ """Test MKV encoding with FLAC audio."""
244
+ mock_execute.return_value = True
245
+
246
+ service = LocalEncodingService()
247
+ result = service.encode_lossless_mkv(
248
+ "/input/lossless.mp4",
249
+ "/output/video.mkv"
250
+ )
251
+
252
+ assert result is True
253
+ call_args = mock_execute.call_args[0][0]
254
+ assert "-c:v copy" in call_args
255
+ assert "-c:a flac" in call_args
256
+
257
+ @patch.object(LocalEncodingService, "_execute_command_with_fallback")
258
+ def test_encode_720p(self, mock_execute):
259
+ """Test 720p encoding."""
260
+ mock_execute.return_value = True
261
+
262
+ service = LocalEncodingService()
263
+ result = service.encode_720p(
264
+ "/input/lossless.mp4",
265
+ "/output/720p.mp4"
266
+ )
267
+
268
+ assert result is True
269
+ call_args = mock_execute.call_args
270
+ # Should contain scale filter
271
+ assert "1280:720" in call_args[0][0] or "1280:720" in call_args[0][1]
272
+
273
+
274
+ class TestLocalEncodingServiceFullPipeline:
275
+ """Test full encoding pipeline."""
276
+
277
+ @patch.object(LocalEncodingService, "remux_with_instrumental")
278
+ @patch.object(LocalEncodingService, "convert_mov_to_mp4")
279
+ @patch.object(LocalEncodingService, "encode_lossless_mp4")
280
+ @patch.object(LocalEncodingService, "encode_lossy_mp4")
281
+ @patch.object(LocalEncodingService, "encode_lossless_mkv")
282
+ @patch.object(LocalEncodingService, "encode_720p")
283
+ def test_encode_all_formats_success(
284
+ self, mock_720p, mock_mkv, mock_lossy, mock_lossless, mock_convert, mock_remux
285
+ ):
286
+ """Test successful full encoding pipeline."""
287
+ mock_remux.return_value = True
288
+ mock_convert.return_value = True
289
+ mock_lossless.return_value = True
290
+ mock_lossy.return_value = True
291
+ mock_mkv.return_value = True
292
+ mock_720p.return_value = True
293
+
294
+ service = LocalEncodingService()
295
+ config = EncodingConfig(
296
+ title_video="/input/title.mov",
297
+ karaoke_video="/input/karaoke.mov",
298
+ instrumental_audio="/input/instrumental.flac",
299
+ output_karaoke_mp4="/output/karaoke.mp4",
300
+ output_with_vocals_mp4="/output/with_vocals.mp4",
301
+ output_lossless_4k_mp4="/output/lossless_4k.mp4",
302
+ output_lossy_4k_mp4="/output/lossy_4k.mp4",
303
+ output_lossless_mkv="/output/lossless.mkv",
304
+ output_720p_mp4="/output/720p.mp4",
305
+ )
306
+
307
+ result = service.encode_all_formats(config)
308
+
309
+ assert result.success is True
310
+ assert "karaoke_mp4" in result.output_files
311
+ assert "lossless_4k_mp4" in result.output_files
312
+ assert "720p_mp4" in result.output_files
313
+
314
+ @patch.object(LocalEncodingService, "remux_with_instrumental")
315
+ def test_encode_all_formats_failure_early(self, mock_remux):
316
+ """Test encoding pipeline failure at early step."""
317
+ mock_remux.return_value = False
318
+
319
+ service = LocalEncodingService()
320
+ config = EncodingConfig(
321
+ title_video="/input/title.mov",
322
+ karaoke_video="/input/karaoke.mov",
323
+ instrumental_audio="/input/instrumental.flac",
324
+ output_karaoke_mp4="/output/karaoke.mp4",
325
+ output_lossless_4k_mp4="/output/lossless_4k.mp4",
326
+ )
327
+
328
+ result = service.encode_all_formats(config)
329
+
330
+ assert result.success is False
331
+ assert "Failed to remux" in result.error
332
+
333
+ def test_encode_all_formats_dry_run(self):
334
+ """Test encoding pipeline in dry run mode."""
335
+ service = LocalEncodingService(dry_run=True)
336
+ config = EncodingConfig(
337
+ title_video="/input/title.mov",
338
+ karaoke_video="/input/karaoke.mp4", # Already MP4
339
+ instrumental_audio="/input/instrumental.flac",
340
+ output_karaoke_mp4="/output/karaoke.mp4",
341
+ output_lossless_4k_mp4="/output/lossless_4k.mp4",
342
+ output_lossy_4k_mp4="/output/lossy_4k.mp4",
343
+ output_lossless_mkv="/output/lossless.mkv",
344
+ output_720p_mp4="/output/720p.mp4",
345
+ )
346
+
347
+ result = service.encode_all_formats(config)
348
+
349
+ # In dry run mode, all operations should "succeed"
350
+ assert result.success is True
351
+
352
+
353
+ class TestEncodingConfig:
354
+ """Test EncodingConfig dataclass."""
355
+
356
+ def test_config_required_fields(self):
357
+ """Test that required fields must be provided."""
358
+ config = EncodingConfig(
359
+ title_video="/path/title.mov",
360
+ karaoke_video="/path/karaoke.mov",
361
+ instrumental_audio="/path/audio.flac",
362
+ )
363
+ assert config.title_video == "/path/title.mov"
364
+ assert config.end_video is None # Optional field
365
+
366
+ def test_config_all_fields(self):
367
+ """Test config with all fields."""
368
+ config = EncodingConfig(
369
+ title_video="/path/title.mov",
370
+ karaoke_video="/path/karaoke.mov",
371
+ instrumental_audio="/path/audio.flac",
372
+ end_video="/path/end.mov",
373
+ output_karaoke_mp4="/output/karaoke.mp4",
374
+ output_720p_mp4="/output/720p.mp4",
375
+ )
376
+ assert config.end_video == "/path/end.mov"
377
+ assert config.output_karaoke_mp4 == "/output/karaoke.mp4"
378
+
379
+
380
+ class TestEncodingResult:
381
+ """Test EncodingResult dataclass."""
382
+
383
+ def test_result_success(self):
384
+ """Test successful result."""
385
+ result = EncodingResult(
386
+ success=True,
387
+ output_files={"key": "/path/file.mp4"}
388
+ )
389
+ assert result.success is True
390
+ assert result.error is None
391
+
392
+ def test_result_failure(self):
393
+ """Test failure result."""
394
+ result = EncodingResult(
395
+ success=False,
396
+ output_files={},
397
+ error="Something went wrong"
398
+ )
399
+ assert result.success is False
400
+ assert result.error == "Something went wrong"
401
+
402
+
403
+ class TestGetLocalEncodingService:
404
+ """Test factory function."""
405
+
406
+ def test_get_service_creates_instance(self):
407
+ """Test that factory function creates a new instance."""
408
+ import backend.services.local_encoding_service as module
409
+ module._local_encoding_service = None
410
+
411
+ service = get_local_encoding_service()
412
+
413
+ assert service is not None
414
+ assert isinstance(service, LocalEncodingService)
415
+
416
+ def test_get_service_with_dry_run(self):
417
+ """Test factory function with dry run option."""
418
+ import backend.services.local_encoding_service as module
419
+ module._local_encoding_service = None
420
+
421
+ service = get_local_encoding_service(dry_run=True)
422
+
423
+ assert service.dry_run is True