Tadeas Kosek commited on
Commit
92fd1a7
·
1 Parent(s): 0723393

apply generated DDD abstraction

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +52 -0
  2. .env.example +18 -0
  3. Dockerfile +27 -11
  4. app.py +74 -488
  5. application/__init__.py +1 -0
  6. application/dto/__init__.py +1 -0
  7. application/dto/extraction_request.py +22 -0
  8. application/dto/extraction_response.py +45 -0
  9. application/use_cases/__init__.py +1 -0
  10. application/use_cases/check_job_status.py +49 -0
  11. application/use_cases/container.py +65 -0
  12. application/use_cases/download_audio_result.py +66 -0
  13. application/use_cases/extract_audio_async.py +103 -0
  14. application/use_cases/extract_audio_direct.py +119 -0
  15. application/use_cases/process_job.py +113 -0
  16. domain/__init__.py +1 -0
  17. domain/entities/__init__.py +1 -0
  18. domain/entities/audio.py +44 -0
  19. domain/entities/job.py +110 -0
  20. domain/entities/video.py +54 -0
  21. domain/exceptions/__init__.py +1 -0
  22. domain/exceptions/domain_exceptions.py +52 -0
  23. domain/services/__init__.py +1 -0
  24. domain/services/audio_extraction_service.py +52 -0
  25. domain/services/validation_service.py +52 -0
  26. domain/value_objects/__init__.py +1 -0
  27. domain/value_objects/audio_format.py +50 -0
  28. domain/value_objects/audio_quality.py +45 -0
  29. domain/value_objects/file_size.py +39 -0
  30. domain/value_objects/job_status.py +30 -0
  31. infrastructure/__init__.py +1 -0
  32. infrastructure/config/__Init__.py +1 -0
  33. infrastructure/config/settings.py +91 -0
  34. infrastructure/repositories/__Init__.py +1 -0
  35. infrastructure/repositories/file_repository.py +117 -0
  36. infrastructure/repositories/job_repository.py +101 -0
  37. infrastructure/services/__init__.py +1 -0
  38. infrastructure/services/container.py +53 -0
  39. infrastructure/services/ffmpeg_service.py +160 -0
  40. infrastructure/services/file_cleanup_service.py +95 -0
  41. interfaces/api/__init__.py +1 -0
  42. interfaces/api/dependencies.py +70 -0
  43. interfaces/api/middleware/__init__.py +1 -0
  44. interfaces/api/middleware/cors_middleware.py +13 -0
  45. interfaces/api/middleware/error_handler.py +54 -0
  46. interfaces/api/responses.py +49 -0
  47. interfaces/api/routes/__init__.py +11 -0
  48. interfaces/api/routes/extraction_routes.py +90 -0
  49. interfaces/api/routes/info_routes.py +29 -0
  50. interfaces/api/routes/job_routes.py +62 -0
.dockerignore ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+ .tox/
12
+ .nox/
13
+ .hypothesis/
14
+
15
+ # Virtual Environment
16
+ venv/
17
+ env/
18
+ ENV/
19
+ .venv/
20
+ .ENV/
21
+
22
+ # IDE
23
+ .idea/
24
+ .vscode/
25
+ *.swp
26
+ *.swo
27
+ *~
28
+
29
+ # OS
30
+ .DS_Store
31
+ Thumbs.db
32
+
33
+ # Project specific
34
+ temp/
35
+ logs/
36
+ *.log
37
+ .env
38
+ !.env.example
39
+
40
+ # Git
41
+ .git/
42
+ .gitignore
43
+
44
+ # Documentation
45
+ *.md
46
+ !README.md
47
+ docs/
48
+
49
+ # Tests
50
+ tests/
51
+ test_*.py
52
+ *_test.py
.env.example ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Application Settings
2
+ APP_NAME="Video to Audio Extractor"
3
+ APP_VERSION="1.0.0"
4
+ DEBUG=false
5
+
6
+ # File Processing
7
+ TEMP_DIR="/tmp/audio_extractor"
8
+ MAX_DIRECT_FILE_SIZE_MB=10.0
9
+ CLEANUP_INTERVAL_SECONDS=3600
10
+ FILE_RETENTION_HOURS=2
11
+
12
+ # FFmpeg Settings
13
+ FFMPEG_PATH="/usr/bin/ffmpeg"
14
+ FFMPEG_TIMEOUT_SECONDS=1800
15
+
16
+ # Supported Formats (comma-separated)
17
+ SUPPORTED_VIDEO_FORMATS=".mp4,.avi,.mov,.mkv,.webm,.flv,.wmv,.m4v"
18
+ SUPPORTED_AUDIO_FORMATS="mp3,aac,wav,flac,m4a,ogg"
Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- FROM python:3.9
2
 
3
  # Install system dependencies including ffmpeg
4
  RUN apt-get update && \
@@ -6,28 +6,44 @@ RUN apt-get update && \
6
  ffmpeg \
7
  libsm6 \
8
  libxext6 \
 
9
  && apt-get clean \
10
  && rm -rf /var/lib/apt/lists/*
11
 
12
- # Create user
13
  RUN useradd -m -u 1000 user
 
 
 
 
 
 
 
 
14
  USER user
15
  ENV PATH="/home/user/.local/bin:$PATH"
 
 
16
 
17
- WORKDIR /app
 
18
 
19
- # Copy and install Python dependencies
20
- COPY --chown=user ./requirements.txt requirements.txt
21
- RUN pip install --no-cache-dir --upgrade -r requirements.txt
 
22
 
23
- # Copy application code
24
- COPY --chown=user . /app
 
 
25
 
26
- # Create temp directory
27
- RUN mkdir -p /tmp/audio_extractor
 
28
 
29
  # Expose port
30
  EXPOSE 7860
31
 
32
  # Run the application
33
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ FROM python:3.9-slim
2
 
3
  # Install system dependencies including ffmpeg
4
  RUN apt-get update && \
 
6
  ffmpeg \
7
  libsm6 \
8
  libxext6 \
9
+ curl \
10
  && apt-get clean \
11
  && rm -rf /var/lib/apt/lists/*
12
 
13
+ # Create user for Hugging Face
14
  RUN useradd -m -u 1000 user
15
+
16
+ # Set working directory
17
+ WORKDIR /app
18
+
19
+ # Copy requirements first for better caching
20
+ COPY --chown=user:user requirements.txt .
21
+
22
+ # Switch to user and install Python dependencies
23
  USER user
24
  ENV PATH="/home/user/.local/bin:$PATH"
25
+ RUN pip install --no-cache-dir --upgrade pip && \
26
+ pip install --no-cache-dir -r requirements.txt
27
 
28
+ # Copy application code maintaining structure
29
+ COPY --chown=user:user . .
30
 
31
+ # Create necessary directories
32
+ RUN mkdir -p /tmp/audio_extractor && \
33
+ mkdir -p interfaces/web/static && \
34
+ mkdir -p interfaces/web/templates
35
 
36
+ # Set environment variables
37
+ ENV PYTHONPATH=/app
38
+ ENV TEMP_DIR=/tmp/audio_extractor
39
+ ENV FFMPEG_PATH=/usr/bin/ffmpeg
40
 
41
+ # Health check
42
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
43
+ CMD curl -f http://localhost:7860/api/v1/health || exit 1
44
 
45
  # Expose port
46
  EXPOSE 7860
47
 
48
  # Run the application
49
+ CMD ["python", "main.py"]
app.py CHANGED
@@ -1,510 +1,96 @@
1
- from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, Form
2
- from fastapi.responses import FileResponse, JSONResponse, HTMLResponse
3
- from fastapi.middleware.cors import CORSMiddleware
4
- import ffmpeg
5
- import tempfile
6
- import os
7
- import uuid
8
- from datetime import datetime, timedelta
9
- import asyncio
10
- import aiofiles
11
- from typing import Optional, Dict, Any
12
  import logging
13
- import shutil
14
- from pathlib import Path
15
 
16
- # Configure logging
17
- logging.basicConfig(level=logging.INFO)
18
- logger = logging.getLogger(__name__)
19
-
20
- app = FastAPI(
21
- title="Video to Audio Extractor",
22
- description="Extract audio from video files using FFmpeg",
23
- version="1.0.0"
24
- )
25
-
26
- # Enable CORS
27
- app.add_middleware(
28
- CORSMiddleware,
29
- allow_origins=["*"],
30
- allow_credentials=True,
31
- allow_methods=["*"],
32
- allow_headers=["*"],
33
- )
34
-
35
- # Storage for background jobs
36
- processing_jobs: Dict[str, Any] = {}
37
 
38
- # Temporary directory for file storage
39
- TEMP_DIR = Path("/tmp/audio_extractor")
40
- TEMP_DIR.mkdir(exist_ok=True)
41
 
42
- # Supported formats
43
- SUPPORTED_VIDEO_FORMATS = ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv', '.m4v']
44
- SUPPORTED_AUDIO_FORMATS = ['mp3', 'aac', 'wav', 'flac', 'm4a', 'ogg']
 
45
 
46
- # Quality settings for different formats
47
- QUALITY_SETTINGS = {
48
- 'mp3': {
49
- 'high': {'audio_bitrate': '320k', 'acodec': 'libmp3lame'},
50
- 'medium': {'audio_bitrate': '192k', 'acodec': 'libmp3lame'},
51
- 'low': {'audio_bitrate': '128k', 'acodec': 'libmp3lame'}
52
- },
53
- 'aac': {
54
- 'high': {'audio_bitrate': '256k', 'acodec': 'aac'},
55
- 'medium': {'audio_bitrate': '192k', 'acodec': 'aac'},
56
- 'low': {'audio_bitrate': '128k', 'acodec': 'aac'}
57
- },
58
- 'wav': {
59
- 'high': {'acodec': 'pcm_s24le'},
60
- 'medium': {'acodec': 'pcm_s16le'},
61
- 'low': {'acodec': 'pcm_s16le'}
62
- },
63
- 'flac': {
64
- 'high': {'acodec': 'flac', 'compression_level': 12},
65
- 'medium': {'acodec': 'flac', 'compression_level': 8},
66
- 'low': {'acodec': 'flac', 'compression_level': 0}
67
- },
68
- 'm4a': {
69
- 'high': {'audio_bitrate': '256k', 'acodec': 'aac'},
70
- 'medium': {'audio_bitrate': '192k', 'acodec': 'aac'},
71
- 'low': {'audio_bitrate': '128k', 'acodec': 'aac'}
72
- },
73
- 'ogg': {
74
- 'high': {'audio_bitrate': '256k', 'acodec': 'libvorbis'},
75
- 'medium': {'audio_bitrate': '192k', 'acodec': 'libvorbis'},
76
- 'low': {'audio_bitrate': '128k', 'acodec': 'libvorbis'}
77
- }
78
- }
79
-
80
- def get_media_type(format: str) -> str:
81
- """Get the correct media type for audio format."""
82
- media_types = {
83
- 'mp3': 'audio/mpeg',
84
- 'aac': 'audio/aac',
85
- 'wav': 'audio/wav',
86
- 'flac': 'audio/flac',
87
- 'm4a': 'audio/mp4',
88
- 'ogg': 'audio/ogg'
89
- }
90
- return media_types.get(format, 'audio/mpeg')
91
 
92
- @app.get("/", response_class=HTMLResponse)
93
- async def home():
94
- """Simple HTML interface for testing."""
95
- return """
96
- <!DOCTYPE html>
97
- <html>
98
- <head>
99
- <title>Video to Audio Extractor</title>
100
- <style>
101
- body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
102
- .container { background: #f5f5f5; padding: 20px; border-radius: 8px; }
103
- h1 { color: #333; }
104
- form { margin-top: 20px; }
105
- label { display: block; margin-top: 10px; font-weight: bold; }
106
- input, select { width: 100%; padding: 8px; margin-top: 5px; border: 1px solid #ddd; border-radius: 4px; }
107
- button { background: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; margin-top: 20px; cursor: pointer; }
108
- button:hover { background: #0056b3; }
109
- #result { margin-top: 20px; padding: 10px; background: #e9ecef; border-radius: 4px; display: none; }
110
- .error { color: #dc3545; }
111
- .success { color: #28a745; }
112
- .loading { color: #17a2b8; }
113
- </style>
114
- </head>
115
- <body>
116
- <div class="container">
117
- <h1>🎵 Video to Audio Extractor</h1>
118
- <p>Upload a video file to extract its audio track.</p>
119
-
120
- <form id="uploadForm">
121
- <label>Video File:</label>
122
- <input type="file" id="videoFile" accept="video/*" required>
123
-
124
- <label>Output Format:</label>
125
- <select id="format">
126
- <option value="mp3">MP3</option>
127
- <option value="aac">AAC</option>
128
- <option value="wav">WAV</option>
129
- <option value="flac">FLAC</option>
130
- <option value="m4a">M4A</option>
131
- <option value="ogg">OGG</option>
132
- </select>
133
-
134
- <label>Quality:</label>
135
- <select id="quality">
136
- <option value="high">High</option>
137
- <option value="medium" selected>Medium</option>
138
- <option value="low">Low</option>
139
- </select>
140
-
141
- <button type="submit">Extract Audio</button>
142
- </form>
143
-
144
- <div id="result"></div>
145
- </div>
146
-
147
- <script>
148
- document.getElementById('uploadForm').onsubmit = async (e) => {
149
- e.preventDefault();
150
-
151
- const resultDiv = document.getElementById('result');
152
- const file = document.getElementById('videoFile').files[0];
153
- const format = document.getElementById('format').value;
154
- const quality = document.getElementById('quality').value;
155
-
156
- if (!file) {
157
- resultDiv.innerHTML = '<p class="error">Please select a file</p>';
158
- resultDiv.style.display = 'block';
159
- return;
160
- }
161
-
162
- resultDiv.innerHTML = '<p class="loading">Processing... This may take a moment.</p>';
163
- resultDiv.style.display = 'block';
164
-
165
- const formData = new FormData();
166
- formData.append('video', file);
167
- formData.append('output_format', format);
168
- formData.append('quality', quality);
169
-
170
- try {
171
- const response = await fetch('/extract-audio', {
172
- method: 'POST',
173
- body: formData
174
- });
175
-
176
- if (response.headers.get('content-type')?.includes('audio')) {
177
- // Direct file response
178
- const blob = await response.blob();
179
- const url = URL.createObjectURL(blob);
180
- resultDiv.innerHTML = `
181
- <p class="success">✅ Audio extracted successfully!</p>
182
- <audio controls src="${url}"></audio>
183
- <br><br>
184
- <a href="${url}" download="extracted_audio.${format}">
185
- <button>Download Audio</button>
186
- </a>
187
- `;
188
- } else {
189
- // Job response
190
- const data = await response.json();
191
- if (data.job_id) {
192
- resultDiv.innerHTML = '<p class="loading">Processing large file...</p>';
193
- checkJobStatus(data.job_id, format);
194
- } else {
195
- throw new Error(data.error || 'Unknown error');
196
- }
197
- }
198
- } catch (error) {
199
- resultDiv.innerHTML = `<p class="error">Error: ${error.message}</p>`;
200
- }
201
- };
202
-
203
- async function checkJobStatus(jobId, format) {
204
- const resultDiv = document.getElementById('result');
205
- const checkInterval = setInterval(async () => {
206
- try {
207
- const response = await fetch(`/status/${jobId}`);
208
- const data = await response.json();
209
-
210
- if (data.status === 'completed') {
211
- clearInterval(checkInterval);
212
- resultDiv.innerHTML = `
213
- <p class="success">✅ Audio extracted successfully!</p>
214
- <a href="/download/${jobId}" download="extracted_audio.${format}">
215
- <button>Download Audio</button>
216
- </a>
217
- `;
218
- } else if (data.status === 'failed') {
219
- clearInterval(checkInterval);
220
- resultDiv.innerHTML = `<p class="error">Error: ${data.error}</p>`;
221
- }
222
- } catch (error) {
223
- clearInterval(checkInterval);
224
- resultDiv.innerHTML = `<p class="error">Error checking status: ${error.message}</p>`;
225
- }
226
- }, 2000);
227
- }
228
- </script>
229
- </body>
230
- </html>
231
- """
232
 
233
- @app.post("/extract-audio")
234
- async def extract_audio(
235
- background_tasks: BackgroundTasks,
236
- video: UploadFile = File(...),
237
- output_format: str = Form("mp3"),
238
- quality: str = Form("medium")
239
- ):
240
- """Extract audio from uploaded video file."""
241
 
242
- # Validate input format
243
- if output_format not in SUPPORTED_AUDIO_FORMATS:
244
- raise HTTPException(400, f"Unsupported output format. Supported: {', '.join(SUPPORTED_AUDIO_FORMATS)}")
245
 
246
- # Validate video file extension
247
- file_ext = Path(video.filename).suffix.lower()
248
- if file_ext not in SUPPORTED_VIDEO_FORMATS:
249
- raise HTTPException(400, f"Unsupported video format. Supported: {', '.join(SUPPORTED_VIDEO_FORMATS)}")
250
 
251
- # Check file size (in MB)
252
- video.file.seek(0, 2) # Seek to end
253
- file_size = video.file.tell()
254
- video.file.seek(0) # Reset to beginning
255
- file_size_mb = file_size / (1024 * 1024)
256
 
257
- logger.info(f"Processing video: {video.filename} ({file_size_mb:.1f} MB) -> {output_format} ({quality})")
258
 
259
- # Decide processing method based on file size
260
- if file_size_mb < 10: # Small files: process immediately
261
- try:
262
- return await process_and_return_direct(video, output_format, quality)
263
- except Exception as e:
264
- logger.error(f"Error processing video: {str(e)}")
265
- raise HTTPException(500, f"Processing failed: {str(e)}")
266
- else: # Large files: process in background
267
- job_id = str(uuid.uuid4())
268
- background_tasks.add_task(
269
- process_in_background,
270
- job_id, video, output_format, quality, file_size_mb
271
- )
272
- return JSONResponse({
273
- "job_id": job_id,
274
- "status": "processing",
275
- "message": f"Processing large file ({file_size_mb:.1f} MB). Check status at /status/{job_id}",
276
- "check_url": f"/status/{job_id}"
277
- })
278
-
279
- async def process_and_return_direct(video: UploadFile, output_format: str, quality: str) -> FileResponse:
280
- """Process video and return audio file directly."""
281
-
282
- # Create temporary files
283
- with tempfile.NamedTemporaryFile(delete=False, suffix=Path(video.filename).suffix) as tmp_video:
284
- # Save uploaded video
285
- content = await video.read()
286
- tmp_video.write(content)
287
- tmp_video_path = tmp_video.name
288
-
289
- try:
290
- # Extract audio
291
- output_path = await extract_audio_ffmpeg(tmp_video_path, output_format, quality)
292
-
293
- # Create response that will clean up files after sending
294
- def cleanup():
295
- try:
296
- os.unlink(tmp_video_path)
297
- os.unlink(output_path)
298
- except:
299
- pass
300
-
301
- background_tasks = BackgroundTasks()
302
- background_tasks.add_task(cleanup)
303
-
304
- return FileResponse(
305
- output_path,
306
- media_type=get_media_type(output_format),
307
- filename=f"{Path(video.filename).stem}.{output_format}",
308
- background=background_tasks
309
- )
310
- except Exception as e:
311
- # Cleanup on error
312
- if os.path.exists(tmp_video_path):
313
- os.unlink(tmp_video_path)
314
- raise
315
-
316
- async def extract_audio_ffmpeg(input_path: str, output_format: str, quality: str) -> str:
317
- """Extract audio using FFmpeg."""
318
 
319
- # Generate output path
320
- output_path = str(TEMP_DIR / f"{uuid.uuid4()}.{output_format}")
321
 
322
- # Get quality settings
323
- settings = QUALITY_SETTINGS.get(output_format, {}).get(quality, {})
324
 
325
- try:
326
- # Build FFmpeg command
327
- stream = ffmpeg.input(input_path)
328
- stream = stream.audio # Extract only audio stream
329
-
330
- # Apply format-specific settings
331
- stream = ffmpeg.output(stream, output_path, **settings)
332
-
333
- # Run FFmpeg
334
- await asyncio.get_event_loop().run_in_executor(
335
- None,
336
- lambda: ffmpeg.run(stream, overwrite_output=True, capture_stdout=True, capture_stderr=True)
337
- )
338
-
339
- logger.info(f"Audio extracted successfully: {output_path}")
340
- return output_path
341
-
342
- except ffmpeg.Error as e:
343
- logger.error(f"FFmpeg error: {e.stderr.decode()}")
344
- raise Exception(f"FFmpeg processing failed: {e.stderr.decode()}")
345
 
346
- async def process_in_background(job_id: str, video: UploadFile, output_format: str, quality: str, file_size_mb: float):
347
- """Process large video files in background."""
348
-
349
- # Update job status
350
- processing_jobs[job_id] = {
351
- 'status': 'processing',
352
- 'started_at': datetime.now(),
353
- 'filename': video.filename,
354
- 'file_size_mb': file_size_mb,
355
- 'format': output_format,
356
- 'quality': quality
357
- }
358
-
359
- tmp_video_path = None
360
- output_path = None
361
-
362
- try:
363
- # Save video to temporary file
364
- tmp_video_path = str(TEMP_DIR / f"{job_id}_input{Path(video.filename).suffix}")
365
- async with aiofiles.open(tmp_video_path, 'wb') as f:
366
- while chunk := await video.read(1024 * 1024): # Read in 1MB chunks
367
- await f.write(chunk)
368
-
369
- # Extract audio
370
- output_path = await extract_audio_ffmpeg(tmp_video_path, output_format, quality)
371
-
372
- # Update job status
373
- processing_jobs[job_id].update({
374
- 'status': 'completed',
375
- 'output_path': output_path,
376
- 'completed_at': datetime.now(),
377
- 'download_url': f'/download/{job_id}'
378
- })
379
-
380
- logger.info(f"Background job {job_id} completed successfully")
381
-
382
- except Exception as e:
383
- logger.error(f"Background job {job_id} failed: {str(e)}")
384
- processing_jobs[job_id].update({
385
- 'status': 'failed',
386
- 'error': str(e),
387
- 'failed_at': datetime.now()
388
- })
389
- finally:
390
- # Clean up input file
391
- if tmp_video_path and os.path.exists(tmp_video_path):
392
- try:
393
- os.unlink(tmp_video_path)
394
- except:
395
- pass
396
 
397
- @app.get("/status/{job_id}")
398
- async def check_status(job_id: str):
399
- """Check the status of a background processing job."""
400
-
401
- job = processing_jobs.get(job_id)
402
- if not job:
403
- raise HTTPException(404, "Job not found")
404
-
405
- # Calculate processing time
406
- if job['status'] == 'processing':
407
- duration = (datetime.now() - job['started_at']).total_seconds()
408
- job['processing_time_seconds'] = duration
409
- elif job['status'] == 'completed':
410
- duration = (job['completed_at'] - job['started_at']).total_seconds()
411
- job['processing_time_seconds'] = duration
412
-
413
- # Don't expose internal paths
414
- safe_job = {k: v for k, v in job.items() if k != 'output_path'}
415
- return safe_job
416
 
417
- @app.get("/download/{job_id}")
418
- async def download_result(job_id: str):
419
- """Download the processed audio file."""
420
-
421
- job = processing_jobs.get(job_id)
422
- if not job:
423
- raise HTTPException(404, "Job not found")
424
-
425
- if job['status'] != 'completed':
426
- raise HTTPException(400, f"Job status: {job['status']}")
427
-
428
- if not os.path.exists(job['output_path']):
429
- raise HTTPException(404, "Output file not found")
430
-
431
- filename = f"{Path(job['filename']).stem}.{job['format']}"
432
-
433
- return FileResponse(
434
- job['output_path'],
435
- media_type=get_media_type(job['format']),
436
- filename=filename
437
- )
438
 
439
- @app.get("/api/info")
440
- async def api_info():
441
- """Get API information and supported formats."""
442
- return {
443
- "supported_video_formats": SUPPORTED_VIDEO_FORMATS,
444
- "supported_audio_formats": SUPPORTED_AUDIO_FORMATS,
445
- "quality_levels": ["high", "medium", "low"],
446
- "max_direct_response_size_mb": 10,
447
- "endpoints": {
448
- "/": "Web interface",
449
- "/extract-audio": "POST - Extract audio from video",
450
- "/status/{job_id}": "GET - Check job status",
451
- "/download/{job_id}": "GET - Download processed audio",
452
- "/api/info": "GET - API information"
453
- }
454
- }
455
 
456
- # Cleanup task
457
- async def cleanup_old_files():
458
- """Periodically clean up old temporary files."""
459
- while True:
460
- try:
461
- await asyncio.sleep(3600) # Run every hour
462
-
463
- now = datetime.now()
464
- cleaned_count = 0
465
-
466
- # Clean up completed/failed jobs older than 2 hours
467
- for job_id, job in list(processing_jobs.items()):
468
- job_age = now - job.get('started_at', now)
469
- if job_age > timedelta(hours=2):
470
- # Delete output file if exists
471
- if 'output_path' in job and os.path.exists(job['output_path']):
472
- try:
473
- os.unlink(job['output_path'])
474
- cleaned_count += 1
475
- except:
476
- pass
477
- del processing_jobs[job_id]
478
-
479
- # Clean up orphaned files in temp directory
480
- for file_path in TEMP_DIR.glob('*'):
481
- if file_path.is_file():
482
- file_age = now - datetime.fromtimestamp(file_path.stat().st_mtime)
483
- if file_age > timedelta(hours=2):
484
- try:
485
- file_path.unlink()
486
- cleaned_count += 1
487
- except:
488
- pass
489
-
490
- if cleaned_count > 0:
491
- logger.info(f"Cleanup: removed {cleaned_count} old files")
492
-
493
- except Exception as e:
494
- logger.error(f"Cleanup error: {str(e)}")
495
 
496
- @app.on_event("startup")
497
- async def startup_event():
498
- """Start background tasks on app startup."""
499
- asyncio.create_task(cleanup_old_files())
500
- logger.info("Audio extractor service started")
501
 
502
- @app.on_event("shutdown")
503
- async def shutdown_event():
504
- """Clean up on shutdown."""
505
- # Clean all temporary files
506
- try:
507
- shutil.rmtree(TEMP_DIR)
508
- except:
509
- pass
510
- logger.info("Audio extractor service stopped")
 
1
+ """FastAPI application initialization with DDD architecture."""
2
+ from fastapi import FastAPI, Request
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.templating import Jinja2Templates
5
+ from contextlib import asynccontextmanager
 
 
 
 
 
 
6
  import logging
 
 
7
 
8
+ # Infrastructure imports
9
+ from infrastructure.config.settings import settings
10
+ from infrastructure.services.container import ServiceContainer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
+ # Application imports
13
+ from application.use_cases.container import UseCaseContainer
 
14
 
15
+ # Interface imports
16
+ from interfaces.api.routes import register_routes
17
+ from interfaces.api.middleware.error_handler import register_exception_handlers
18
+ from interfaces.api.middleware.cors_middleware import configure_cors
19
 
20
+ # Configure logging
21
+ logging.basicConfig(
22
+ level=logging.INFO if not settings.debug else logging.DEBUG,
23
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
24
+ )
25
+ logger = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
+ # Global containers
28
+ service_container: ServiceContainer = None
29
+ use_case_container: UseCaseContainer = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
+ @asynccontextmanager
32
+ async def lifespan(app: FastAPI):
33
+ """Application lifespan manager."""
34
+ global service_container, use_case_container
 
 
 
 
35
 
36
+ # Startup
37
+ logger.info(f"Starting {settings.app_name} v{settings.app_version}")
 
38
 
39
+ # Initialize containers
40
+ service_container = ServiceContainer.get_instance()
41
+ use_case_container = UseCaseContainer(service_container, settings)
 
42
 
43
+ # Start background services
44
+ await service_container.startup()
 
 
 
45
 
46
+ logger.info("Application started successfully")
47
 
48
+ yield
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
+ # Shutdown
51
+ logger.info("Shutting down application")
52
 
53
+ # Stop background services
54
+ await service_container.shutdown()
55
 
56
+ logger.info("Application shut down successfully")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
+ # Create FastAPI app
59
+ app = FastAPI(
60
+ title=settings.app_name,
61
+ version=settings.app_version,
62
+ description="Extract audio from video files using FFmpeg",
63
+ lifespan=lifespan
64
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
+ # Configure middleware
67
+ configure_cors(app)
68
+ register_exception_handlers(app)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
+ # Register API routes
71
+ register_routes(app)
72
+
73
+ # Mount static files
74
+ app.mount("/static", StaticFiles(directory="interfaces/web/static"), name="static")
75
+
76
+ # Setup templates
77
+ templates = Jinja2Templates(directory="interfaces/web/templates")
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
+ # Root route for web interface
80
+ @app.get("/", include_in_schema=False)
81
+ async def home(request: Request):
82
+ """Serve the web interface."""
83
+ return templates.TemplateResponse("index.html", {"request": request})
 
 
 
 
 
 
 
 
 
 
 
84
 
85
+ # Dependency injection functions for routes
86
+ def get_service_container() -> ServiceContainer:
87
+ """Get service container for dependency injection."""
88
+ return service_container
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
+ def get_use_case_container() -> UseCaseContainer:
91
+ """Get use case container for dependency injection."""
92
+ return use_case_container
 
 
93
 
94
+ # Make containers available for dependency injection
95
+ app.state.get_services = get_service_container
96
+ app.state.get_use_cases = get_use_case_container
 
 
 
 
 
 
application/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Application layer for the audio extractor application."""
application/dto/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Data Transfer Objects for application layer."""
application/dto/extraction_request.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Request DTOs for extraction use cases."""
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ @dataclass
6
+ class ExtractionRequestDTO:
7
+ """DTO for extraction request."""
8
+ video_filename: str
9
+ video_file_path: str
10
+ video_file_size: int
11
+ output_format: str
12
+ quality: str
13
+ content_type: Optional[str] = None
14
+
15
+ @dataclass
16
+ class JobCreationDTO:
17
+ """DTO for job creation."""
18
+ job_id: str
19
+ status: str
20
+ message: str
21
+ check_url: str
22
+ file_size_mb: float
application/dto/extraction_response.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Response DTOs for extraction use cases."""
2
+ from dataclasses import dataclass
3
+ from datetime import datetime
4
+ from typing import Optional, Dict, Any
5
+
6
+ @dataclass
7
+ class DirectExtractionResultDTO:
8
+ """DTO for direct extraction result."""
9
+ file_path: str
10
+ media_type: str
11
+ filename: str
12
+ processing_time: float
13
+ file_size: int
14
+
15
+ @dataclass
16
+ class JobStatusDTO:
17
+ """DTO for job status."""
18
+ job_id: str
19
+ status: str
20
+ created_at: datetime
21
+ updated_at: datetime
22
+ filename: Optional[str] = None
23
+ file_size_mb: Optional[float] = None
24
+ output_format: Optional[str] = None
25
+ quality: Optional[str] = None
26
+ processing_time: Optional[float] = None
27
+ error: Optional[str] = None
28
+ download_url: Optional[str] = None
29
+
30
+ @dataclass
31
+ class DownloadResultDTO:
32
+ """DTO for download result."""
33
+ file_path: str
34
+ media_type: str
35
+ filename: str
36
+ processing_time: float
37
+
38
+ @dataclass
39
+ class JobCreationDTO:
40
+ """DTO for job creation."""
41
+ job_id: str
42
+ status: str
43
+ message: str
44
+ check_url: str
45
+ file_size_mb: Optional[float] = None
application/use_cases/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Application use cases."""
application/use_cases/check_job_status.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Use case for checking job status."""
2
+ from typing import Optional, Any
3
+ import logging
4
+
5
+ from domain.exceptions.domain_exceptions import JobNotFoundError
6
+ from ..dto.extraction_response import JobStatusDTO
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class JobRepository:
11
+ """Protocol for job repository."""
12
+ async def get(self, job_id: str) -> Optional[Any]:
13
+ ...
14
+
15
+ class CheckJobStatusUseCase:
16
+ """Use case for checking job status."""
17
+
18
+ def __init__(self, job_repository: JobRepository):
19
+ self.job_repository = job_repository
20
+
21
+ async def execute(self, job_id: str) -> JobStatusDTO:
22
+ """Check the status of a job."""
23
+ # Get job from repository
24
+ job_record = await self.job_repository.get(job_id)
25
+
26
+ if not job_record:
27
+ raise JobNotFoundError(job_id)
28
+
29
+ # Calculate processing time
30
+ processing_time = None
31
+ if job_record.processing_time:
32
+ processing_time = job_record.processing_time
33
+ elif job_record.status == "processing":
34
+ processing_time = (job_record.updated_at - job_record.created_at).total_seconds()
35
+
36
+ # Create DTO
37
+ return JobStatusDTO(
38
+ job_id=job_record.id,
39
+ status=job_record.status,
40
+ created_at=job_record.created_at,
41
+ updated_at=job_record.updated_at,
42
+ filename=job_record.filename,
43
+ file_size_mb=job_record.file_size_mb,
44
+ output_format=job_record.output_format,
45
+ quality=job_record.quality,
46
+ processing_time=processing_time,
47
+ error=job_record.error,
48
+ download_url=f"/api/v1/jobs/{job_id}/download" if job_record.status == "completed" else None
49
+ )
application/use_cases/container.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Use case dependency container."""
2
+ from typing import Dict, Any
3
+
4
+ from .extract_audio_direct import ExtractAudioDirectUseCase
5
+ from .extract_audio_async import ExtractAudioAsyncUseCase
6
+ from .process_job import ProcessJobUseCase
7
+ from .check_job_status import CheckJobStatusUseCase
8
+ from .download_audio_result import DownloadAudioResultUseCase
9
+
10
+ from domain.services.validation_service import ValidationService
11
+ from infrastructure.services.container import ServiceContainer
12
+
13
+ class UseCaseContainer:
14
+ """Container for all application use cases."""
15
+
16
+ def __init__(self, services: ServiceContainer, settings: Any):
17
+ self.services = services
18
+ self.settings = settings
19
+
20
+ # Create validation service
21
+ self.validation_service = ValidationService(
22
+ max_file_size_mb=settings.max_direct_file_size_mb * 100, # Allow larger files for async
23
+ supported_video_formats=settings.supported_video_formats,
24
+ supported_audio_formats=settings.supported_audio_formats
25
+ )
26
+
27
+ # Initialize use cases
28
+ self._init_use_cases()
29
+
30
+ def _init_use_cases(self):
31
+ """Initialize all use cases with dependencies."""
32
+
33
+ # Process job use case (needed by async extractor)
34
+ self.process_job = ProcessJobUseCase(
35
+ job_repository=self.services.job_repository,
36
+ ffmpeg_service=self.services.ffmpeg_service,
37
+ file_repository=self.services.file_repository
38
+ )
39
+
40
+ # Direct extraction use case
41
+ self.extract_audio_direct = ExtractAudioDirectUseCase(
42
+ ffmpeg_service=self.services.ffmpeg_service,
43
+ file_repository=self.services.file_repository,
44
+ validation_service=self.validation_service,
45
+ quality_presets=self.settings.quality_presets
46
+ )
47
+
48
+ # Async extraction use case
49
+ self.extract_audio_async = ExtractAudioAsyncUseCase(
50
+ job_repository=self.services.job_repository,
51
+ validation_service=self.validation_service,
52
+ process_job_use_case=self.process_job
53
+ )
54
+
55
+ # Status checking use case
56
+ self.check_job_status = CheckJobStatusUseCase(
57
+ job_repository=self.services.job_repository
58
+ )
59
+
60
+ # Download use case
61
+ self.download_audio_result = DownloadAudioResultUseCase(
62
+ job_repository=self.services.job_repository,
63
+ file_repository=self.services.file_repository,
64
+ audio_mime_types=self.settings.audio_mime_types
65
+ )
application/use_cases/download_audio_result.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Use case for downloading audio results."""
2
+ from typing import Any, Optional
3
+ import logging
4
+
5
+ from domain.exceptions.domain_exceptions import JobNotFoundError, JobNotCompletedError
6
+ from ..dto.extraction_response import DownloadResultDTO
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class JobRepository:
11
+ """Protocol for job repository."""
12
+ async def get(self, job_id: str) -> Optional[Any]:
13
+ ...
14
+
15
+ class FileRepository:
16
+ """Protocol for file repository."""
17
+ async def file_exists(self, file_path: str) -> bool:
18
+ ...
19
+
20
+ class DownloadAudioResultUseCase:
21
+ """Use case for downloading completed audio."""
22
+
23
+ def __init__(self,
24
+ job_repository: JobRepository,
25
+ file_repository: FileRepository,
26
+ audio_mime_types: dict):
27
+ self.job_repository = job_repository
28
+ self.file_repository = file_repository
29
+ self.audio_mime_types = audio_mime_types
30
+
31
+ async def execute(self, job_id: str) -> DownloadResultDTO:
32
+ """Get download information for completed job."""
33
+ # Get job from repository
34
+ job_record = await self.job_repository.get(job_id)
35
+
36
+ if not job_record:
37
+ raise JobNotFoundError(job_id)
38
+
39
+ # Check if job is completed
40
+ if job_record.status != "completed":
41
+ raise JobNotCompletedError(job_id, job_record.status)
42
+
43
+ # Check if output file exists
44
+ if not job_record.output_path:
45
+ raise RuntimeError(f"Job {job_id} has no output path")
46
+
47
+ if not await self.file_repository.file_exists(job_record.output_path):
48
+ raise RuntimeError(f"Output file not found for job {job_id}")
49
+
50
+ # Get MIME type
51
+ mime_type = self.audio_mime_types.get(
52
+ job_record.output_format,
53
+ 'application/octet-stream'
54
+ )
55
+
56
+ # Create filename
57
+ import os
58
+ original_name = os.path.splitext(job_record.filename)[0]
59
+ filename = f"{original_name}.{job_record.output_format}"
60
+
61
+ return DownloadResultDTO(
62
+ file_path=job_record.output_path,
63
+ media_type=mime_type,
64
+ filename=filename,
65
+ processing_time=job_record.processing_time or 0
66
+ )
application/use_cases/extract_audio_async.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Use case for asynchronous audio extraction."""
2
+ import asyncio
3
+ from typing import Protocol, Any
4
+ import logging
5
+
6
+ from domain.entities.video import Video
7
+ from domain.entities.job import Job
8
+ from domain.value_objects.file_size import FileSize
9
+ from domain.services.validation_service import ValidationService
10
+
11
+ from ..dto.extraction_request import ExtractionRequestDTO
12
+ from ..dto.extraction_response import JobCreationDTO
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class JobRepository(Protocol):
17
+ """Protocol for job repository."""
18
+ async def create(self, job_id: str, filename: str, file_size_mb: float,
19
+ output_format: str, quality: str) -> Any:
20
+ ...
21
+ async def update_status(self, job_id: str, status: str,
22
+ error: str = None, output_path: str = None,
23
+ processing_time: float = None) -> Any:
24
+ ...
25
+
26
+ class BackgroundTaskRunner(Protocol):
27
+ """Protocol for background task execution."""
28
+ def add_task(self, func, *args, **kwargs):
29
+ ...
30
+
31
+ class ExtractAudioAsyncUseCase:
32
+ """Use case for asynchronous audio extraction (large files)."""
33
+
34
+ def __init__(self,
35
+ job_repository: JobRepository,
36
+ validation_service: ValidationService,
37
+ process_job_use_case: 'ProcessJobUseCase'):
38
+ self.job_repository = job_repository
39
+ self.validation_service = validation_service
40
+ self.process_job_use_case = process_job_use_case
41
+
42
+ async def execute(self, request: ExtractionRequestDTO,
43
+ background_tasks: BackgroundTaskRunner) -> JobCreationDTO:
44
+ """Create and queue an async extraction job."""
45
+
46
+ # Create domain objects for validation
47
+ video = Video(
48
+ filename=request.video_filename,
49
+ file_path=request.video_file_path,
50
+ size=FileSize(request.video_file_size),
51
+ content_type=request.content_type
52
+ )
53
+
54
+ # Validate request
55
+ self.validation_service.validate_extraction_request(
56
+ video, request.output_format, request.quality
57
+ )
58
+
59
+ # Create job
60
+ job = Job.create_new(
61
+ video_filename=request.video_filename,
62
+ file_size_bytes=request.video_file_size,
63
+ output_format=request.output_format,
64
+ quality=request.quality
65
+ )
66
+
67
+ # Save job to repository
68
+ await self.job_repository.create(
69
+ job_id=job.id,
70
+ filename=job.video_filename,
71
+ file_size_mb=job.file_size.megabytes,
72
+ output_format=job.output_format.value,
73
+ quality=job.quality.value
74
+ )
75
+
76
+ # Queue background processing
77
+ background_tasks.add_task(
78
+ self._process_job_background,
79
+ job.id,
80
+ request
81
+ )
82
+
83
+ logger.info(f"Created async job {job.id} for {video.filename}")
84
+
85
+ return JobCreationDTO(
86
+ job_id=job.id,
87
+ status=job.status.value,
88
+ message=f"Processing large file ({job.file_size.megabytes:.1f} MB)",
89
+ check_url=f"/api/v1/jobs/{job.id}",
90
+ file_size_mb=job.file_size.megabytes
91
+ )
92
+
93
+ async def _process_job_background(self, job_id: str, request: ExtractionRequestDTO):
94
+ """Process job in background."""
95
+ try:
96
+ await self.process_job_use_case.execute(job_id, request)
97
+ except Exception as e:
98
+ logger.error(f"Background job {job_id} failed: {str(e)}")
99
+ await self.job_repository.update_status(
100
+ job_id=job_id,
101
+ status="failed",
102
+ error=str(e)
103
+ )
application/use_cases/extract_audio_direct.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Use case for direct audio extraction."""
2
+ import time
3
+ from typing import Protocol, Any
4
+ import logging
5
+
6
+ from domain.entities.video import Video
7
+ from domain.entities.audio import Audio
8
+ from domain.value_objects.audio_format import AudioFormat
9
+ from domain.value_objects.audio_quality import AudioQuality
10
+ from domain.value_objects.file_size import FileSize
11
+ from domain.services.validation_service import ValidationService
12
+ from domain.exceptions.domain_exceptions import ProcessingError
13
+
14
+ from ..dto.extraction_request import ExtractionRequestDTO
15
+ from ..dto.extraction_response import DirectExtractionResultDTO
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ class FFmpegService(Protocol):
20
+ """Protocol for FFmpeg service."""
21
+ async def extract_audio(self, input_path: str, output_path: str,
22
+ format: str, quality: str) -> Any:
23
+ ...
24
+
25
+ class FileRepository(Protocol):
26
+ """Protocol for file repository."""
27
+ async def create_output_path(self, job_id: str, format: str) -> str:
28
+ ...
29
+ async def get_file_size(self, file_path: str) -> int:
30
+ ...
31
+ async def delete_file(self, file_path: str) -> bool:
32
+ ...
33
+
34
+ class ExtractAudioDirectUseCase:
35
+ """Use case for direct audio extraction (small files)."""
36
+
37
+ def __init__(self,
38
+ ffmpeg_service: FFmpegService,
39
+ file_repository: FileRepository,
40
+ validation_service: ValidationService,
41
+ quality_presets: dict):
42
+ self.ffmpeg_service = ffmpeg_service
43
+ self.file_repository = file_repository
44
+ self.validation_service = validation_service
45
+ self.quality_presets = quality_presets
46
+
47
+ async def execute(self, request: ExtractionRequestDTO) -> DirectExtractionResultDTO:
48
+ """Execute direct audio extraction."""
49
+ start_time = time.time()
50
+ output_path = None
51
+
52
+ try:
53
+ # Create domain objects
54
+ video = Video(
55
+ filename=request.video_filename,
56
+ file_path=request.video_file_path,
57
+ size=FileSize(request.video_file_size),
58
+ content_type=request.content_type
59
+ )
60
+
61
+ audio_format = AudioFormat(request.output_format)
62
+ audio_quality = AudioQuality(request.quality)
63
+
64
+ # Validate
65
+ self.validation_service.validate_extraction_request(
66
+ video, request.output_format, request.quality
67
+ )
68
+
69
+ # Create output path
70
+ import uuid
71
+ job_id = str(uuid.uuid4())
72
+ output_path = await self.file_repository.create_output_path(
73
+ job_id, audio_format.value
74
+ )
75
+
76
+ # Extract audio
77
+ logger.info(f"Starting direct extraction: {video.filename} -> {audio_format.value}")
78
+
79
+ result = await self.ffmpeg_service.extract_audio(
80
+ video.file_path,
81
+ output_path,
82
+ audio_format.value,
83
+ audio_quality.value
84
+ )
85
+
86
+ if not result.success:
87
+ raise ProcessingError(f"FFmpeg extraction failed: {result.error}")
88
+
89
+ # Get output file size
90
+ output_size = await self.file_repository.get_file_size(output_path)
91
+
92
+ # Create audio entity
93
+ audio = Audio.create_from_extraction(
94
+ source_video=video,
95
+ file_path=output_path,
96
+ format=audio_format,
97
+ quality=audio_quality,
98
+ size=output_size
99
+ )
100
+
101
+ processing_time = time.time() - start_time
102
+
103
+ logger.info(f"Direct extraction completed in {processing_time:.2f}s")
104
+
105
+ return DirectExtractionResultDTO(
106
+ file_path=audio.file_path,
107
+ media_type=audio.get_mime_type(),
108
+ filename=audio.get_full_filename(),
109
+ processing_time=processing_time,
110
+ file_size=output_size
111
+ )
112
+
113
+ except Exception as e:
114
+ # Clean up output file on error
115
+ if output_path:
116
+ await self.file_repository.delete_file(output_path)
117
+
118
+ logger.error(f"Direct extraction failed: {str(e)}")
119
+ raise
application/use_cases/process_job.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Use case for processing extraction jobs."""
2
+ import time
3
+ import logging
4
+ from typing import Protocol, Any
5
+
6
+ from domain.value_objects.audio_format import AudioFormat
7
+ from domain.value_objects.audio_quality import AudioQuality
8
+ from domain.value_objects.file_size import FileSize
9
+ from domain.entities.video import Video
10
+ from domain.entities.audio import Audio
11
+
12
+ from ..dto.extraction_request import ExtractionRequestDTO
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class JobRepository(Protocol):
17
+ """Protocol for job repository."""
18
+ async def update_status(self, job_id: str, status: str,
19
+ error: str = None, output_path: str = None,
20
+ processing_time: float = None) -> Any:
21
+ ...
22
+
23
+ class FFmpegService(Protocol):
24
+ """Protocol for FFmpeg service."""
25
+ async def extract_audio(self, input_path: str, output_path: str,
26
+ format: str, quality: str) -> Any:
27
+ ...
28
+
29
+ class FileRepository(Protocol):
30
+ """Protocol for file repository."""
31
+ async def create_output_path(self, job_id: str, format: str) -> str:
32
+ ...
33
+ async def get_file_size(self, file_path: str) -> int:
34
+ ...
35
+ async def delete_file(self, file_path: str) -> bool:
36
+ ...
37
+
38
+ class ProcessJobUseCase:
39
+ """Use case for processing a queued extraction job."""
40
+
41
+ def __init__(self,
42
+ job_repository: JobRepository,
43
+ ffmpeg_service: FFmpegService,
44
+ file_repository: FileRepository):
45
+ self.job_repository = job_repository
46
+ self.ffmpeg_service = ffmpeg_service
47
+ self.file_repository = file_repository
48
+
49
+ async def execute(self, job_id: str, request: ExtractionRequestDTO):
50
+ """Process the extraction job."""
51
+ start_time = time.time()
52
+ output_path = None
53
+
54
+ try:
55
+ # Update job status to processing
56
+ await self.job_repository.update_status(job_id, "processing")
57
+
58
+ # Create domain objects
59
+ video = Video(
60
+ filename=request.video_filename,
61
+ file_path=request.video_file_path,
62
+ size=FileSize(request.video_file_size),
63
+ content_type=request.content_type
64
+ )
65
+
66
+ audio_format = AudioFormat(request.output_format)
67
+ audio_quality = AudioQuality(request.quality)
68
+
69
+ # Create output path
70
+ output_path = await self.file_repository.create_output_path(
71
+ job_id, audio_format.value
72
+ )
73
+
74
+ # Extract audio
75
+ logger.info(f"Processing job {job_id}: {video.filename} -> {audio_format.value}")
76
+
77
+ result = await self.ffmpeg_service.extract_audio(
78
+ video.file_path,
79
+ output_path,
80
+ audio_format.value,
81
+ audio_quality.value
82
+ )
83
+
84
+ if not result.success:
85
+ raise Exception(f"FFmpeg extraction failed: {result.error}")
86
+
87
+ # Calculate processing time
88
+ processing_time = time.time() - start_time
89
+
90
+ # Update job as completed
91
+ await self.job_repository.update_status(
92
+ job_id=job_id,
93
+ status="completed",
94
+ output_path=output_path,
95
+ processing_time=processing_time
96
+ )
97
+
98
+ logger.info(f"Job {job_id} completed in {processing_time:.2f}s")
99
+
100
+ except Exception as e:
101
+ # Clean up output file on error
102
+ if output_path:
103
+ await self.file_repository.delete_file(output_path)
104
+
105
+ # Update job as failed
106
+ await self.job_repository.update_status(
107
+ job_id=job_id,
108
+ status="failed",
109
+ error=str(e)
110
+ )
111
+
112
+ logger.error(f"Job {job_id} failed: {str(e)}")
113
+ raise
domain/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Domain layer for the audio extractor application."""
domain/entities/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Domain entities."""
domain/entities/audio.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio entity."""
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ from ..value_objects.audio_format import AudioFormat
6
+ from ..value_objects.audio_quality import AudioQuality
7
+ from ..value_objects.file_size import FileSize
8
+
9
+ @dataclass
10
+ class Audio:
11
+ """Entity representing an audio file."""
12
+ filename: str
13
+ file_path: str
14
+ format: AudioFormat
15
+ quality: AudioQuality
16
+ size: Optional[FileSize] = None
17
+ duration: Optional[float] = None
18
+ bitrate: Optional[str] = None
19
+
20
+ def get_mime_type(self) -> str:
21
+ """Get MIME type for this audio file."""
22
+ return self.format.mime_type
23
+
24
+ def get_full_filename(self) -> str:
25
+ """Get filename with correct extension."""
26
+ base_name = self.filename
27
+ if not base_name.endswith(self.format.extension):
28
+ base_name = f"{base_name}{self.format.extension}"
29
+ return base_name
30
+
31
+ @classmethod
32
+ def create_from_extraction(cls, source_video: 'Video', file_path: str,
33
+ format: AudioFormat, quality: AudioQuality,
34
+ size: Optional[int] = None) -> 'Audio':
35
+ """Create Audio from extraction process."""
36
+ filename = f"{source_video.get_filename_without_extension()}{format.extension}"
37
+
38
+ return cls(
39
+ filename=filename,
40
+ file_path=file_path,
41
+ format=format,
42
+ quality=quality,
43
+ size=FileSize(size) if size else None
44
+ )
domain/entities/job.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Job entity."""
2
+ from dataclasses import dataclass, field
3
+ from datetime import datetime
4
+ from typing import Optional, Dict, Any
5
+ import uuid
6
+
7
+ from ..value_objects.job_status import JobStatus
8
+ from ..value_objects.audio_format import AudioFormat
9
+ from ..value_objects.audio_quality import AudioQuality
10
+ from ..value_objects.file_size import FileSize
11
+ from ..exceptions.domain_exceptions import ValidationError
12
+
13
+ @dataclass
14
+ class Job:
15
+ """Entity representing an audio extraction job."""
16
+ id: str
17
+ video_filename: str
18
+ file_size: FileSize
19
+ output_format: AudioFormat
20
+ quality: AudioQuality
21
+ status: JobStatus
22
+ created_at: datetime
23
+ updated_at: datetime
24
+ completed_at: Optional[datetime] = None
25
+ output_path: Optional[str] = None
26
+ error_message: Optional[str] = None
27
+ processing_duration: Optional[float] = None
28
+ metadata: Dict[str, Any] = field(default_factory=dict)
29
+
30
+ def __post_init__(self):
31
+ # Ensure status is JobStatus enum
32
+ if isinstance(self.status, str):
33
+ self.status = JobStatus(self.status)
34
+
35
+ @classmethod
36
+ def create_new(cls, video_filename: str, file_size_bytes: int,
37
+ output_format: str, quality: str) -> 'Job':
38
+ """Create a new job."""
39
+ now = datetime.utcnow()
40
+ return cls(
41
+ id=str(uuid.uuid4()),
42
+ video_filename=video_filename,
43
+ file_size=FileSize(file_size_bytes),
44
+ output_format=AudioFormat(output_format),
45
+ quality=AudioQuality(quality),
46
+ status=JobStatus.PENDING,
47
+ created_at=now,
48
+ updated_at=now
49
+ )
50
+
51
+ def start_processing(self) -> None:
52
+ """Mark job as processing."""
53
+ if not self.status.can_transition_to(JobStatus.PROCESSING):
54
+ raise ValidationError(f"Cannot start job in {self.status} status")
55
+
56
+ self.status = JobStatus.PROCESSING
57
+ self.updated_at = datetime.utcnow()
58
+
59
+ def complete(self, output_path: str, processing_duration: float) -> None:
60
+ """Mark job as completed."""
61
+ if not self.status.can_transition_to(JobStatus.COMPLETED):
62
+ raise ValidationError(f"Cannot complete job in {self.status} status")
63
+
64
+ self.status = JobStatus.COMPLETED
65
+ self.output_path = output_path
66
+ self.processing_duration = processing_duration
67
+ self.completed_at = datetime.utcnow()
68
+ self.updated_at = self.completed_at
69
+
70
+ def fail(self, error_message: str) -> None:
71
+ """Mark job as failed."""
72
+ if not self.status.can_transition_to(JobStatus.FAILED):
73
+ raise ValidationError(f"Cannot fail job in {self.status} status")
74
+
75
+ self.status = JobStatus.FAILED
76
+ self.error_message = error_message
77
+ self.updated_at = datetime.utcnow()
78
+
79
+ def cancel(self) -> None:
80
+ """Cancel the job."""
81
+ if not self.status.can_transition_to(JobStatus.CANCELLED):
82
+ raise ValidationError(f"Cannot cancel job in {self.status} status")
83
+
84
+ self.status = JobStatus.CANCELLED
85
+ self.updated_at = datetime.utcnow()
86
+
87
+ @property
88
+ def is_complete(self) -> bool:
89
+ """Check if job is complete."""
90
+ return self.status == JobStatus.COMPLETED
91
+
92
+ @property
93
+ def is_active(self) -> bool:
94
+ """Check if job is active."""
95
+ return self.status.is_active()
96
+
97
+ @property
98
+ def can_download(self) -> bool:
99
+ """Check if results can be downloaded."""
100
+ return self.is_complete and self.output_path is not None
101
+
102
+ def get_processing_time_seconds(self) -> Optional[float]:
103
+ """Get processing time in seconds."""
104
+ if self.processing_duration:
105
+ return self.processing_duration
106
+ elif self.status == JobStatus.PROCESSING:
107
+ return (datetime.utcnow() - self.created_at).total_seconds()
108
+ elif self.completed_at:
109
+ return (self.completed_at - self.created_at).total_seconds()
110
+ return None
domain/entities/video.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video entity."""
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from ..value_objects.file_size import FileSize
7
+ from ..exceptions.domain_exceptions import InvalidVideoFormatError
8
+
9
+ @dataclass
10
+ class Video:
11
+ """Entity representing a video file."""
12
+ filename: str
13
+ file_path: str
14
+ size: FileSize
15
+ content_type: Optional[str] = None
16
+ duration: Optional[float] = None
17
+
18
+ # Supported video formats
19
+ SUPPORTED_EXTENSIONS = ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv', '.m4v']
20
+
21
+ def __post_init__(self):
22
+ self.validate()
23
+
24
+ def validate(self):
25
+ """Validate video entity."""
26
+ if not self.filename:
27
+ raise InvalidVideoFormatError("", self.SUPPORTED_EXTENSIONS)
28
+
29
+ extension = self.get_extension()
30
+ if extension not in self.SUPPORTED_EXTENSIONS:
31
+ raise InvalidVideoFormatError(extension, self.SUPPORTED_EXTENSIONS)
32
+
33
+ def get_extension(self) -> str:
34
+ """Get file extension."""
35
+ return Path(self.filename).suffix.lower()
36
+
37
+ def get_filename_without_extension(self) -> str:
38
+ """Get filename without extension."""
39
+ return Path(self.filename).stem
40
+
41
+ def is_large_file(self, threshold_mb: float) -> bool:
42
+ """Check if file is considered large."""
43
+ return self.size.megabytes > threshold_mb
44
+
45
+ @classmethod
46
+ def from_upload(cls, filename: str, file_path: str, file_size: int,
47
+ content_type: Optional[str] = None) -> 'Video':
48
+ """Create Video from upload data."""
49
+ return cls(
50
+ filename=filename,
51
+ file_path=file_path,
52
+ size=FileSize(file_size),
53
+ content_type=content_type
54
+ )
domain/exceptions/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Domain-specific exceptions."""
domain/exceptions/domain_exceptions.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom domain exceptions."""
2
+
3
+ class DomainException(Exception):
4
+ """Base exception for all domain errors."""
5
+ pass
6
+
7
+ class ValidationError(DomainException):
8
+ """Raised when domain validation fails."""
9
+ pass
10
+
11
+ class InvalidVideoFormatError(ValidationError):
12
+ """Raised when video format is not supported."""
13
+ def __init__(self, format: str, supported_formats: list):
14
+ self.format = format
15
+ self.supported_formats = supported_formats
16
+ super().__init__(f"Unsupported video format: {format}. Supported: {', '.join(supported_formats)}")
17
+
18
+ class InvalidAudioFormatError(ValidationError):
19
+ """Raised when audio format is not supported."""
20
+ def __init__(self, format: str, supported_formats: list):
21
+ self.format = format
22
+ self.supported_formats = supported_formats
23
+ super().__init__(f"Unsupported audio format: {format}. Supported: {', '.join(supported_formats)}")
24
+
25
+ class InvalidQualityLevelError(ValidationError):
26
+ """Raised when quality level is not supported."""
27
+ def __init__(self, quality: str):
28
+ super().__init__(f"Invalid quality level: {quality}. Must be 'high', 'medium', or 'low'")
29
+
30
+ class FileSizeExceededError(ValidationError):
31
+ """Raised when file size exceeds limits."""
32
+ def __init__(self, size_mb: float, max_size_mb: float):
33
+ self.size_mb = size_mb
34
+ self.max_size_mb = max_size_mb
35
+ super().__init__(f"File size {size_mb:.1f}MB exceeds maximum {max_size_mb:.1f}MB")
36
+
37
+ class ProcessingError(DomainException):
38
+ """Raised when audio extraction fails."""
39
+ pass
40
+
41
+ class JobNotFoundError(DomainException):
42
+ """Raised when a job cannot be found."""
43
+ def __init__(self, job_id: str):
44
+ self.job_id = job_id
45
+ super().__init__(f"Job not found: {job_id}")
46
+
47
+ class JobNotCompletedError(DomainException):
48
+ """Raised when trying to access results of incomplete job."""
49
+ def __init__(self, job_id: str, status: str):
50
+ self.job_id = job_id
51
+ self.status = status
52
+ super().__init__(f"Job {job_id} is not completed (status: {status})")
domain/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Domain services."""
domain/services/audio_extraction_service.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Domain service for audio extraction."""
2
+ from abc import ABC, abstractmethod
3
+ from typing import Protocol, Optional
4
+ from dataclasses import dataclass
5
+
6
+ from ..entities.video import Video
7
+ from ..entities.audio import Audio
8
+ from ..entities.job import Job
9
+ from ..value_objects.audio_format import AudioFormat
10
+ from ..value_objects.audio_quality import AudioQuality
11
+
12
+ @dataclass
13
+ class ExtractionResult:
14
+ """Result of audio extraction."""
15
+ audio: Audio
16
+ processing_time: float
17
+ metadata: Optional[dict] = None
18
+
19
+ class AudioExtractionService(ABC):
20
+ """Abstract domain service for audio extraction."""
21
+
22
+ @abstractmethod
23
+ async def extract_audio(self, video: Video, format: AudioFormat,
24
+ quality: AudioQuality) -> ExtractionResult:
25
+ """Extract audio from video with specified format and quality."""
26
+ pass
27
+
28
+ @abstractmethod
29
+ async def supports_format(self, format: AudioFormat) -> bool:
30
+ """Check if the service supports the given audio format."""
31
+ pass
32
+
33
+ @abstractmethod
34
+ async def estimate_processing_time(self, video: Video, format: AudioFormat) -> float:
35
+ """Estimate processing time in seconds."""
36
+ pass
37
+
38
+ class JobService(Protocol):
39
+ """Protocol for job-related operations."""
40
+
41
+ async def create_job(self, video: Video, format: AudioFormat,
42
+ quality: AudioQuality) -> Job:
43
+ """Create a new extraction job."""
44
+ ...
45
+
46
+ async def get_job(self, job_id: str) -> Optional[Job]:
47
+ """Get job by ID."""
48
+ ...
49
+
50
+ async def update_job_status(self, job: Job) -> None:
51
+ """Update job status."""
52
+ ...
domain/services/validation_service.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Domain validation service."""
2
+ from typing import List
3
+
4
+ from ..entities.video import Video
5
+ from ..value_objects.audio_format import AudioFormat
6
+ from ..value_objects.audio_quality import AudioQuality
7
+ from ..value_objects.file_size import FileSize
8
+ from ..exceptions.domain_exceptions import (
9
+ ValidationError,
10
+ FileSizeExceededError,
11
+ InvalidVideoFormatError,
12
+ InvalidAudioFormatError
13
+ )
14
+
15
+ class ValidationService:
16
+ """Service for validating domain rules."""
17
+
18
+ def __init__(self, max_file_size_mb: float,
19
+ supported_video_formats: List[str],
20
+ supported_audio_formats: List[str]):
21
+ self.max_file_size_mb = max_file_size_mb
22
+ self.supported_video_formats = supported_video_formats
23
+ self.supported_audio_formats = supported_audio_formats
24
+
25
+ def validate_video(self, video: Video) -> None:
26
+ """Validate video entity against business rules."""
27
+ # Check file size
28
+ if not video.size.is_within_limit(self.max_file_size_mb):
29
+ raise FileSizeExceededError(video.size.megabytes, self.max_file_size_mb)
30
+
31
+ # Check format
32
+ extension = video.get_extension()
33
+ if extension not in self.supported_video_formats:
34
+ raise InvalidVideoFormatError(extension, self.supported_video_formats)
35
+
36
+ def validate_extraction_request(self, video: Video, format: str, quality: str) -> None:
37
+ """Validate complete extraction request."""
38
+ # Validate video
39
+ self.validate_video(video)
40
+
41
+ # Validate audio format
42
+ try:
43
+ AudioFormat(format)
44
+ except InvalidAudioFormatError:
45
+ raise InvalidAudioFormatError(format, self.supported_audio_formats)
46
+
47
+ # Validate quality
48
+ AudioQuality(quality) # Will raise if invalid
49
+
50
+ def can_process_directly(self, video: Video, threshold_mb: float) -> bool:
51
+ """Check if video can be processed directly (not async)."""
52
+ return not video.is_large_file(threshold_mb)
domain/value_objects/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Domain value objects."""
domain/value_objects/audio_format.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio format value object."""
2
+ from typing import List
3
+ from dataclasses import dataclass
4
+
5
+ from ..exceptions.domain_exceptions import InvalidAudioFormatError
6
+
7
+ @dataclass(frozen=True)
8
+ class AudioFormat:
9
+ """Value object representing an audio format."""
10
+ value: str
11
+
12
+ # Supported formats with their characteristics
13
+ FORMATS = {
14
+ 'mp3': {'extension': '.mp3', 'mime_type': 'audio/mpeg', 'lossy': True},
15
+ 'aac': {'extension': '.aac', 'mime_type': 'audio/aac', 'lossy': True},
16
+ 'wav': {'extension': '.wav', 'mime_type': 'audio/wav', 'lossy': False},
17
+ 'flac': {'extension': '.flac', 'mime_type': 'audio/flac', 'lossy': False},
18
+ 'm4a': {'extension': '.m4a', 'mime_type': 'audio/mp4', 'lossy': True},
19
+ 'ogg': {'extension': '.ogg', 'mime_type': 'audio/ogg', 'lossy': True}
20
+ }
21
+
22
+ def __post_init__(self):
23
+ if self.value.lower() not in self.FORMATS:
24
+ raise InvalidAudioFormatError(self.value, list(self.FORMATS.keys()))
25
+
26
+ # Normalize to lowercase
27
+ object.__setattr__(self, 'value', self.value.lower())
28
+
29
+ @property
30
+ def extension(self) -> str:
31
+ """Get file extension for this format."""
32
+ return self.FORMATS[self.value]['extension']
33
+
34
+ @property
35
+ def mime_type(self) -> str:
36
+ """Get MIME type for this format."""
37
+ return self.FORMATS[self.value]['mime_type']
38
+
39
+ @property
40
+ def is_lossy(self) -> bool:
41
+ """Check if format uses lossy compression."""
42
+ return self.FORMATS[self.value]['lossy']
43
+
44
+ @classmethod
45
+ def supported_formats(cls) -> List[str]:
46
+ """Get list of supported formats."""
47
+ return list(cls.FORMATS.keys())
48
+
49
+ def __str__(self) -> str:
50
+ return self.value
domain/value_objects/audio_quality.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio quality value object."""
2
+ from dataclasses import dataclass
3
+ from typing import Dict, Any
4
+
5
+ from ..exceptions.domain_exceptions import InvalidQualityLevelError
6
+
7
+ @dataclass(frozen=True)
8
+ class AudioQuality:
9
+ """Value object representing audio quality level."""
10
+ value: str
11
+
12
+ LEVELS = {
13
+ 'high': {'priority': 3, 'description': 'Best quality, larger file size'},
14
+ 'medium': {'priority': 2, 'description': 'Balanced quality and file size'},
15
+ 'low': {'priority': 1, 'description': 'Smaller file size, acceptable quality'}
16
+ }
17
+
18
+ def __post_init__(self):
19
+ if self.value.lower() not in self.LEVELS:
20
+ raise InvalidQualityLevelError(self.value)
21
+
22
+ # Normalize to lowercase
23
+ object.__setattr__(self, 'value', self.value.lower())
24
+
25
+ @property
26
+ def priority(self) -> int:
27
+ """Get numeric priority (higher is better quality)."""
28
+ return self.LEVELS[self.value]['priority']
29
+
30
+ @property
31
+ def description(self) -> str:
32
+ """Get human-readable description."""
33
+ return self.LEVELS[self.value]['description']
34
+
35
+ def get_settings_for_format(self, audio_format: 'AudioFormat',
36
+ quality_presets: Dict[str, Dict[str, Dict[str, Any]]]) -> Dict[str, Any]:
37
+ """Get quality settings for specific audio format."""
38
+ return quality_presets.get(audio_format.value, {}).get(self.value, {})
39
+
40
+ def __str__(self) -> str:
41
+ return self.value
42
+
43
+ def __lt__(self, other: 'AudioQuality') -> bool:
44
+ """Compare quality levels by priority."""
45
+ return self.priority < other.priority
domain/value_objects/file_size.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File size value object."""
2
+ from dataclasses import dataclass
3
+
4
+ from ..exceptions.domain_exceptions import ValidationError
5
+
6
+ @dataclass(frozen=True)
7
+ class FileSize:
8
+ """Value object representing file size."""
9
+ bytes: int
10
+
11
+ def __post_init__(self):
12
+ if self.bytes < 0:
13
+ raise ValidationError("File size cannot be negative")
14
+
15
+ @property
16
+ def megabytes(self) -> float:
17
+ """Get size in megabytes."""
18
+ return self.bytes / (1024 * 1024)
19
+
20
+ @property
21
+ def gigabytes(self) -> float:
22
+ """Get size in gigabytes."""
23
+ return self.bytes / (1024 * 1024 * 1024)
24
+
25
+ def is_within_limit(self, max_mb: float) -> bool:
26
+ """Check if file size is within specified limit in MB."""
27
+ return self.megabytes <= max_mb
28
+
29
+ def __str__(self) -> str:
30
+ """Human-readable string representation."""
31
+ if self.gigabytes >= 1:
32
+ return f"{self.gigabytes:.2f} GB"
33
+ elif self.megabytes >= 1:
34
+ return f"{self.megabytes:.1f} MB"
35
+ else:
36
+ return f"{self.bytes} bytes"
37
+
38
+ def __lt__(self, other: 'FileSize') -> bool:
39
+ return self.bytes < other.bytes
domain/value_objects/job_status.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Job status value object."""
2
+ from enum import Enum
3
+
4
+ class JobStatus(str, Enum):
5
+ """Enumeration of possible job statuses."""
6
+ PENDING = "pending"
7
+ PROCESSING = "processing"
8
+ COMPLETED = "completed"
9
+ FAILED = "failed"
10
+ CANCELLED = "cancelled"
11
+
12
+ def is_terminal(self) -> bool:
13
+ """Check if status is terminal (no further changes expected)."""
14
+ return self in [self.COMPLETED, self.FAILED, self.CANCELLED]
15
+
16
+ def is_active(self) -> bool:
17
+ """Check if job is actively being processed."""
18
+ return self == self.PROCESSING
19
+
20
+ def can_transition_to(self, new_status: 'JobStatus') -> bool:
21
+ """Check if transition to new status is valid."""
22
+ if self.is_terminal():
23
+ return False
24
+
25
+ valid_transitions = {
26
+ self.PENDING: [self.PROCESSING, self.CANCELLED],
27
+ self.PROCESSING: [self.COMPLETED, self.FAILED, self.CANCELLED]
28
+ }
29
+
30
+ return new_status in valid_transitions.get(self, [])
infrastructure/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Infrastructure layer for the audio extractor application."""
infrastructure/config/__Init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Configuration management."""
infrastructure/config/settings.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application configuration settings."""
2
+ from pydantic_settings import BaseSettings
3
+ from pydantic import Field
4
+ from pathlib import Path
5
+ from typing import List, Dict, Any
6
+ import os
7
+
8
+ class Settings(BaseSettings):
9
+ """Application settings with environment variable support."""
10
+
11
+ # Application settings
12
+ app_name: str = "Video to Audio Extractor"
13
+ app_version: str = "1.0.0"
14
+ debug: bool = Field(default=False, env="DEBUG")
15
+
16
+ # File processing settings
17
+ temp_dir: Path = Field(default=Path("/tmp/audio_extractor"), env="TEMP_DIR")
18
+ max_direct_file_size_mb: float = Field(default=10.0, env="MAX_DIRECT_FILE_SIZE_MB")
19
+ cleanup_interval_seconds: int = Field(default=3600, env="CLEANUP_INTERVAL_SECONDS")
20
+ file_retention_hours: int = Field(default=2, env="FILE_RETENTION_HOURS")
21
+
22
+ # FFmpeg settings
23
+ ffmpeg_path: str = Field(default="/usr/bin/ffmpeg", env="FFMPEG_PATH")
24
+ ffmpeg_timeout_seconds: int = Field(default=1800, env="FFMPEG_TIMEOUT_SECONDS") # 30 minutes
25
+
26
+ # Supported formats
27
+ supported_video_formats: List[str] = Field(
28
+ default=['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv', '.m4v'],
29
+ env="SUPPORTED_VIDEO_FORMATS"
30
+ )
31
+ supported_audio_formats: List[str] = Field(
32
+ default=['mp3', 'aac', 'wav', 'flac', 'm4a', 'ogg'],
33
+ env="SUPPORTED_AUDIO_FORMATS"
34
+ )
35
+
36
+ # Quality presets
37
+ quality_presets: Dict[str, Dict[str, Dict[str, Any]]] = {
38
+ 'mp3': {
39
+ 'high': {'bitrate': '320k', 'codec': 'libmp3lame'},
40
+ 'medium': {'bitrate': '192k', 'codec': 'libmp3lame'},
41
+ 'low': {'bitrate': '128k', 'codec': 'libmp3lame'}
42
+ },
43
+ 'aac': {
44
+ 'high': {'bitrate': '256k', 'codec': 'aac'},
45
+ 'medium': {'bitrate': '192k', 'codec': 'aac'},
46
+ 'low': {'bitrate': '128k', 'codec': 'aac'}
47
+ },
48
+ 'wav': {
49
+ 'high': {'codec': 'pcm_s24le'},
50
+ 'medium': {'codec': 'pcm_s16le'},
51
+ 'low': {'codec': 'pcm_s16le'}
52
+ },
53
+ 'flac': {
54
+ 'high': {'codec': 'flac', 'compression_level': 12},
55
+ 'medium': {'codec': 'flac', 'compression_level': 8},
56
+ 'low': {'codec': 'flac', 'compression_level': 0}
57
+ },
58
+ 'm4a': {
59
+ 'high': {'bitrate': '256k', 'codec': 'aac'},
60
+ 'medium': {'bitrate': '192k', 'codec': 'aac'},
61
+ 'low': {'bitrate': '128k', 'codec': 'aac'}
62
+ },
63
+ 'ogg': {
64
+ 'high': {'bitrate': '256k', 'codec': 'libvorbis'},
65
+ 'medium': {'bitrate': '192k', 'codec': 'libvorbis'},
66
+ 'low': {'bitrate': '128k', 'codec': 'libvorbis'}
67
+ }
68
+ }
69
+
70
+ # MIME types
71
+ audio_mime_types: Dict[str, str] = {
72
+ 'mp3': 'audio/mpeg',
73
+ 'aac': 'audio/aac',
74
+ 'wav': 'audio/wav',
75
+ 'flac': 'audio/flac',
76
+ 'm4a': 'audio/mp4',
77
+ 'ogg': 'audio/ogg'
78
+ }
79
+
80
+ class Config:
81
+ env_file = ".env"
82
+ env_file_encoding = "utf-8"
83
+ case_sensitive = False
84
+
85
+ def __init__(self, **kwargs):
86
+ super().__init__(**kwargs)
87
+ # Ensure temp directory exists
88
+ self.temp_dir.mkdir(parents=True, exist_ok=True)
89
+
90
+ # Singleton instance
91
+ settings = Settings()
infrastructure/repositories/__Init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Repository implementations."""
infrastructure/repositories/file_repository.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File system repository implementation."""
2
+ from pathlib import Path
3
+ from typing import Optional, List, Tuple
4
+ import aiofiles
5
+ import os
6
+ import uuid
7
+ from datetime import datetime, timedelta
8
+ import logging
9
+ import inspect
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class FileSystemRepository:
14
+ """Repository for managing temporary files."""
15
+
16
+ def __init__(self, base_path: Path):
17
+ self.base_path = Path(base_path)
18
+ self.base_path.mkdir(parents=True, exist_ok=True)
19
+
20
+ async def save_uploaded_file(self, content: bytes, original_filename: str) -> str:
21
+ """Save uploaded file and return the path."""
22
+ # Generate unique filename
23
+ file_id = str(uuid.uuid4())
24
+ extension = Path(original_filename).suffix
25
+ filename = f"{file_id}_input{extension}"
26
+ file_path = self.base_path / filename
27
+
28
+ async with aiofiles.open(file_path, 'wb') as f:
29
+ await f.write(content)
30
+
31
+ logger.info(f"Saved uploaded file: {file_path}")
32
+ return str(file_path)
33
+
34
+ async def save_stream(self, stream, original_filename: str, chunk_size: int = 1024 * 1024) -> str:
35
+ """Save file from stream, handling both async and sync streams."""
36
+ file_id = str(uuid.uuid4())
37
+ extension = Path(original_filename).suffix
38
+ filename = f"{file_id}_input{extension}"
39
+ file_path = self.base_path / filename
40
+
41
+ async with aiofiles.open(file_path, 'wb') as f:
42
+ read_method = getattr(stream, 'read', None)
43
+
44
+ if read_method is None:
45
+ raise ValueError("Provided stream does not have a .read() method")
46
+
47
+ is_async = inspect.iscoroutinefunction(read_method)
48
+
49
+ while True:
50
+ if is_async:
51
+ chunk = await read_method(chunk_size)
52
+ else:
53
+ # Run sync read in thread pool to not block the event loop
54
+ chunk = await asyncio.to_thread(read_method, chunk_size)
55
+
56
+ if not chunk:
57
+ break
58
+
59
+ await f.write(chunk)
60
+
61
+ logger.info(f"Saved streamed file: {file_path}")
62
+ return str(file_path)
63
+
64
+ async def create_output_path(self, job_id: str, format: str) -> str:
65
+ """Create a path for output file."""
66
+ filename = f"{job_id}_output.{format}"
67
+ return str(self.base_path / filename)
68
+
69
+ async def read_file(self, file_path: str) -> bytes:
70
+ """Read file content."""
71
+ async with aiofiles.open(file_path, 'rb') as f:
72
+ return await f.read()
73
+
74
+ async def file_exists(self, file_path: str) -> bool:
75
+ """Check if file exists."""
76
+ return Path(file_path).exists()
77
+
78
+ async def get_file_size(self, file_path: str) -> int:
79
+ """Get file size in bytes."""
80
+ return Path(file_path).stat().st_size
81
+
82
+ async def delete_file(self, file_path: str) -> bool:
83
+ """Delete a file."""
84
+ try:
85
+ Path(file_path).unlink()
86
+ logger.info(f"Deleted file: {file_path}")
87
+ return True
88
+ except Exception as e:
89
+ logger.error(f"Failed to delete file {file_path}: {e}")
90
+ return False
91
+
92
+ async def list_old_files(self, older_than_hours: int) -> List[Tuple[str, datetime]]:
93
+ """List files older than specified hours."""
94
+ cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
95
+ old_files = []
96
+
97
+ for file_path in self.base_path.glob('*'):
98
+ if file_path.is_file():
99
+ modified_time = datetime.fromtimestamp(file_path.stat().st_mtime)
100
+ if modified_time < cutoff_time:
101
+ old_files.append((str(file_path), modified_time))
102
+
103
+ return old_files
104
+
105
+ async def cleanup_old_files(self, older_than_hours: int) -> int:
106
+ """Clean up files older than specified hours."""
107
+ old_files = await self.list_old_files(older_than_hours)
108
+ deleted_count = 0
109
+
110
+ for file_path, _ in old_files:
111
+ if await self.delete_file(file_path):
112
+ deleted_count += 1
113
+
114
+ if deleted_count > 0:
115
+ logger.info(f"Cleaned up {deleted_count} old files")
116
+
117
+ return deleted_count
infrastructure/repositories/job_repository.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory job repository implementation."""
2
+ from typing import Dict, Optional, List
3
+ from datetime import datetime, timedelta
4
+ import asyncio
5
+ from dataclasses import dataclass, field
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ @dataclass
11
+ class JobRecord:
12
+ """Internal job storage record."""
13
+ id: str
14
+ status: str
15
+ created_at: datetime
16
+ updated_at: datetime
17
+ filename: Optional[str] = None
18
+ file_size_mb: Optional[float] = None
19
+ output_format: Optional[str] = None
20
+ quality: Optional[str] = None
21
+ output_path: Optional[str] = None
22
+ error: Optional[str] = None
23
+ processing_time: Optional[float] = None
24
+ metadata: Dict = field(default_factory=dict)
25
+
26
+ class InMemoryJobRepository:
27
+ """In-memory implementation of job repository."""
28
+
29
+ def __init__(self):
30
+ self._jobs: Dict[str, JobRecord] = {}
31
+ self._lock = asyncio.Lock()
32
+
33
+ async def create(self, job_id: str, filename: str, file_size_mb: float,
34
+ output_format: str, quality: str) -> JobRecord:
35
+ """Create a new job record."""
36
+ async with self._lock:
37
+ job = JobRecord(
38
+ id=job_id,
39
+ status="processing",
40
+ created_at=datetime.utcnow(),
41
+ updated_at=datetime.utcnow(),
42
+ filename=filename,
43
+ file_size_mb=file_size_mb,
44
+ output_format=output_format,
45
+ quality=quality
46
+ )
47
+ self._jobs[job_id] = job
48
+ logger.info(f"Created job {job_id} for {filename}")
49
+ return job
50
+
51
+ async def get(self, job_id: str) -> Optional[JobRecord]:
52
+ """Get a job by ID."""
53
+ async with self._lock:
54
+ return self._jobs.get(job_id)
55
+
56
+ async def update_status(self, job_id: str, status: str,
57
+ error: Optional[str] = None,
58
+ output_path: Optional[str] = None,
59
+ processing_time: Optional[float] = None) -> Optional[JobRecord]:
60
+ """Update job status."""
61
+ async with self._lock:
62
+ job = self._jobs.get(job_id)
63
+ if job:
64
+ job.status = status
65
+ job.updated_at = datetime.utcnow()
66
+ job.error = error
67
+ job.output_path = output_path
68
+ job.processing_time = processing_time
69
+ logger.info(f"Updated job {job_id} status to {status}")
70
+ return job
71
+
72
+ async def list_old_jobs(self, older_than_hours: int) -> List[JobRecord]:
73
+ """List jobs older than specified hours."""
74
+ cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
75
+ async with self._lock:
76
+ return [
77
+ job for job in self._jobs.values()
78
+ if job.created_at < cutoff_time
79
+ ]
80
+
81
+ async def delete(self, job_id: str) -> bool:
82
+ """Delete a job record."""
83
+ async with self._lock:
84
+ if job_id in self._jobs:
85
+ del self._jobs[job_id]
86
+ logger.info(f"Deleted job {job_id}")
87
+ return True
88
+ return False
89
+
90
+ async def get_stats(self) -> Dict:
91
+ """Get repository statistics."""
92
+ async with self._lock:
93
+ total = len(self._jobs)
94
+ by_status = {}
95
+ for job in self._jobs.values():
96
+ by_status[job.status] = by_status.get(job.status, 0) + 1
97
+
98
+ return {
99
+ "total_jobs": total,
100
+ "by_status": by_status
101
+ }
infrastructure/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Service implementations."""
infrastructure/services/container.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dependency injection container for services."""
2
+ from typing import Optional
3
+ from pathlib import Path
4
+
5
+ from ..config.settings import settings
6
+ from ..repositories.job_repository import InMemoryJobRepository
7
+ from ..repositories.file_repository import FileSystemRepository
8
+ from .ffmpeg_service import FFmpegService
9
+ from .file_cleanup_service import FileCleanupService
10
+
11
+ class ServiceContainer:
12
+ """Container for all infrastructure services."""
13
+
14
+ _instance: Optional['ServiceContainer'] = None
15
+
16
+ def __init__(self):
17
+ # Repositories
18
+ self.job_repository = InMemoryJobRepository()
19
+ self.file_repository = FileSystemRepository(settings.temp_dir)
20
+
21
+ # Services
22
+ self.ffmpeg_service = FFmpegService(
23
+ ffmpeg_path=settings.ffmpeg_path,
24
+ quality_presets=settings.quality_presets,
25
+ timeout_seconds=settings.ffmpeg_timeout_seconds
26
+ )
27
+
28
+ self.cleanup_service = FileCleanupService(
29
+ job_repo=self.job_repository,
30
+ file_repo=self.file_repository,
31
+ cleanup_interval_seconds=settings.cleanup_interval_seconds,
32
+ retention_hours=settings.file_retention_hours
33
+ )
34
+
35
+ @classmethod
36
+ def get_instance(cls) -> 'ServiceContainer':
37
+ """Get singleton instance of service container."""
38
+ if cls._instance is None:
39
+ cls._instance = cls()
40
+ return cls._instance
41
+
42
+ async def startup(self):
43
+ """Initialize services on startup."""
44
+ await self.cleanup_service.start()
45
+
46
+ async def shutdown(self):
47
+ """Cleanup services on shutdown."""
48
+ await self.cleanup_service.stop()
49
+
50
+ # Convenience function for getting services
51
+ def get_services() -> ServiceContainer:
52
+ """Get the service container instance."""
53
+ return ServiceContainer.get_instance()
infrastructure/services/ffmpeg_service.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FFmpeg service implementation."""
2
+ import asyncio
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Dict, Optional, Any
6
+ import logging
7
+ import json
8
+ from dataclasses import dataclass
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ @dataclass
13
+ class FFmpegResult:
14
+ """Result from FFmpeg operation."""
15
+ success: bool
16
+ output_path: Optional[str] = None
17
+ duration: Optional[float] = None
18
+ error: Optional[str] = None
19
+ metadata: Dict[str, Any] = None
20
+
21
+ class FFmpegService:
22
+ """Service for audio extraction using FFmpeg."""
23
+
24
+ def __init__(self, ffmpeg_path: str, quality_presets: Dict[str, Dict[str, Dict]],
25
+ timeout_seconds: int = 1800):
26
+ self.ffmpeg_path = ffmpeg_path
27
+ self.quality_presets = quality_presets
28
+ self.timeout_seconds = timeout_seconds
29
+
30
+ async def extract_audio(self, input_path: str, output_path: str,
31
+ format: str, quality: str) -> FFmpegResult:
32
+ """Extract audio from video file."""
33
+ try:
34
+ # Get quality settings
35
+ preset = self.quality_presets.get(format, {}).get(quality, {})
36
+
37
+ # Build FFmpeg command
38
+ cmd = self._build_command(input_path, output_path, format, preset)
39
+
40
+ logger.info(f"Running FFmpeg command: {' '.join(cmd)}")
41
+
42
+ # Run FFmpeg
43
+ process = await asyncio.create_subprocess_exec(
44
+ *cmd,
45
+ stdout=asyncio.subprocess.PIPE,
46
+ stderr=asyncio.subprocess.PIPE
47
+ )
48
+
49
+ try:
50
+ stdout, stderr = await asyncio.wait_for(
51
+ process.communicate(),
52
+ timeout=self.timeout_seconds
53
+ )
54
+ except asyncio.TimeoutError:
55
+ process.kill()
56
+ await process.wait()
57
+ return FFmpegResult(
58
+ success=False,
59
+ error=f"FFmpeg timeout after {self.timeout_seconds} seconds"
60
+ )
61
+
62
+ if process.returncode == 0:
63
+ # Extract duration from stderr
64
+ duration = self._extract_duration(stderr.decode())
65
+
66
+ return FFmpegResult(
67
+ success=True,
68
+ output_path=output_path,
69
+ duration=duration,
70
+ metadata={
71
+ "format": format,
72
+ "quality": quality,
73
+ "preset": preset
74
+ }
75
+ )
76
+ else:
77
+ return FFmpegResult(
78
+ success=False,
79
+ error=f"FFmpeg failed: {stderr.decode()}"
80
+ )
81
+
82
+ except Exception as e:
83
+ logger.error(f"FFmpeg error: {str(e)}")
84
+ return FFmpegResult(
85
+ success=False,
86
+ error=str(e)
87
+ )
88
+
89
+ async def get_media_info(self, file_path: str) -> Dict[str, Any]:
90
+ """Get media file information using ffprobe."""
91
+ try:
92
+ cmd = [
93
+ 'ffprobe',
94
+ '-v', 'quiet',
95
+ '-print_format', 'json',
96
+ '-show_format',
97
+ '-show_streams',
98
+ file_path
99
+ ]
100
+
101
+ process = await asyncio.create_subprocess_exec(
102
+ *cmd,
103
+ stdout=asyncio.subprocess.PIPE,
104
+ stderr=asyncio.subprocess.PIPE
105
+ )
106
+
107
+ stdout, _ = await process.communicate()
108
+
109
+ if process.returncode == 0:
110
+ return json.loads(stdout.decode())
111
+ else:
112
+ return {}
113
+
114
+ except Exception as e:
115
+ logger.error(f"ffprobe error: {str(e)}")
116
+ return {}
117
+
118
+ def _build_command(self, input_path: str, output_path: str,
119
+ format: str, preset: Dict) -> list:
120
+ """Build FFmpeg command based on format and preset."""
121
+ cmd = [
122
+ self.ffmpeg_path,
123
+ '-i', input_path,
124
+ '-vn', # No video
125
+ '-y' # Overwrite output
126
+ ]
127
+
128
+ # Add codec
129
+ if 'codec' in preset:
130
+ cmd.extend(['-acodec', preset['codec']])
131
+
132
+ # Add bitrate
133
+ if 'bitrate' in preset:
134
+ cmd.extend(['-b:a', preset['bitrate']])
135
+
136
+ # Add compression level (for FLAC)
137
+ if 'compression_level' in preset:
138
+ cmd.extend(['-compression_level', str(preset['compression_level'])])
139
+
140
+ # Add output file
141
+ cmd.append(output_path)
142
+
143
+ return cmd
144
+
145
+ def _extract_duration(self, stderr_output: str) -> Optional[float]:
146
+ """Extract duration from FFmpeg stderr output."""
147
+ import re
148
+
149
+ # Look for Duration: HH:MM:SS.ms
150
+ duration_match = re.search(r'Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})', stderr_output)
151
+ if duration_match:
152
+ hours = int(duration_match.group(1))
153
+ minutes = int(duration_match.group(2))
154
+ seconds = int(duration_match.group(3))
155
+ centiseconds = int(duration_match.group(4))
156
+
157
+ total_seconds = hours * 3600 + minutes * 60 + seconds + centiseconds / 100
158
+ return total_seconds
159
+
160
+ return None
infrastructure/services/file_cleanup_service.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Background file cleanup service."""
2
+ import asyncio
3
+ from datetime import datetime
4
+ import logging
5
+ from typing import Protocol
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class JobRepository(Protocol):
10
+ """Protocol for job repository."""
11
+ async def list_old_jobs(self, older_than_hours: int) -> list:
12
+ ...
13
+ async def delete(self, job_id: str) -> bool:
14
+ ...
15
+
16
+ class FileRepository(Protocol):
17
+ """Protocol for file repository."""
18
+ async def cleanup_old_files(self, older_than_hours: int) -> int:
19
+ ...
20
+ async def delete_file(self, file_path: str) -> bool:
21
+ ...
22
+
23
+ class FileCleanupService:
24
+ """Service for cleaning up old files and jobs."""
25
+
26
+ def __init__(self, job_repo: JobRepository, file_repo: FileRepository,
27
+ cleanup_interval_seconds: int, retention_hours: int):
28
+ self.job_repo = job_repo
29
+ self.file_repo = file_repo
30
+ self.cleanup_interval_seconds = cleanup_interval_seconds
31
+ self.retention_hours = retention_hours
32
+ self._running = False
33
+ self._task = None
34
+
35
+ async def start(self):
36
+ """Start the cleanup service."""
37
+ if self._running:
38
+ return
39
+
40
+ self._running = True
41
+ self._task = asyncio.create_task(self._cleanup_loop())
42
+ logger.info(f"Started cleanup service (interval: {self.cleanup_interval_seconds}s, "
43
+ f"retention: {self.retention_hours}h)")
44
+
45
+ async def stop(self):
46
+ """Stop the cleanup service."""
47
+ self._running = False
48
+ if self._task:
49
+ self._task.cancel()
50
+ try:
51
+ await self._task
52
+ except asyncio.CancelledError:
53
+ pass
54
+ logger.info("Stopped cleanup service")
55
+
56
+ async def _cleanup_loop(self):
57
+ """Main cleanup loop."""
58
+ while self._running:
59
+ try:
60
+ await asyncio.sleep(self.cleanup_interval_seconds)
61
+ await self._perform_cleanup()
62
+ except asyncio.CancelledError:
63
+ break
64
+ except Exception as e:
65
+ logger.error(f"Cleanup error: {str(e)}")
66
+
67
+ async def _perform_cleanup(self):
68
+ """Perform cleanup operation."""
69
+ start_time = datetime.utcnow()
70
+
71
+ # Clean up old jobs
72
+ old_jobs = await self.job_repo.list_old_jobs(self.retention_hours)
73
+ job_count = 0
74
+
75
+ for job in old_jobs:
76
+ # Delete output file if exists
77
+ if hasattr(job, 'output_path') and job.output_path:
78
+ await self.file_repo.delete_file(job.output_path)
79
+
80
+ # Delete job record
81
+ if await self.job_repo.delete(job.id):
82
+ job_count += 1
83
+
84
+ # Clean up orphaned files
85
+ file_count = await self.file_repo.cleanup_old_files(self.retention_hours)
86
+
87
+ # Log results
88
+ duration = (datetime.utcnow() - start_time).total_seconds()
89
+ if job_count > 0 or file_count > 0:
90
+ logger.info(f"Cleanup completed in {duration:.2f}s: "
91
+ f"deleted {job_count} jobs and {file_count} files")
92
+
93
+ async def force_cleanup(self):
94
+ """Force an immediate cleanup."""
95
+ await self._perform_cleanup()
interfaces/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """API interface components."""
interfaces/api/dependencies.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # interfaces/api/dependencies.py
2
+ """FastAPI dependency injection configuration."""
3
+ from typing import Annotated
4
+ from fastapi import Depends, UploadFile, Form, HTTPException, Request
5
+ from pydantic import BaseModel, Field, validator
6
+ import re
7
+
8
+ from application.use_cases.container import UseCaseContainer
9
+ from infrastructure.services.container import ServiceContainer
10
+
11
+ class ExtractionRequest(BaseModel):
12
+ """Request model for audio extraction."""
13
+ output_format: str = Field(default="mp3", description="Output audio format")
14
+ quality: str = Field(default="medium", description="Audio quality level")
15
+
16
+ @validator('output_format')
17
+ def validate_format(cls, v):
18
+ allowed = ['mp3', 'aac', 'wav', 'flac', 'm4a', 'ogg']
19
+ if v.lower() not in allowed:
20
+ raise ValueError(f"Format must be one of: {', '.join(allowed)}")
21
+ return v.lower()
22
+
23
+ @validator('quality')
24
+ def validate_quality(cls, v):
25
+ allowed = ['high', 'medium', 'low']
26
+ if v.lower() not in allowed:
27
+ raise ValueError(f"Quality must be one of: {', '.join(allowed)}")
28
+ return v.lower()
29
+
30
+ def extraction_params(
31
+ output_format: str = Form("mp3"),
32
+ quality: str = Form("medium")
33
+ ) -> ExtractionRequest:
34
+ """Parse and validate extraction parameters."""
35
+ return ExtractionRequest(output_format=output_format, quality=quality)
36
+
37
+ async def validate_video_file(video: UploadFile) -> UploadFile:
38
+ """Validate uploaded video file."""
39
+ if not video.filename:
40
+ raise HTTPException(400, "No filename provided")
41
+
42
+ # Check file extension
43
+ allowed_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv', '.m4v']
44
+ file_ext = '.' + video.filename.lower().split('.')[-1] if '.' in video.filename else ''
45
+
46
+ if file_ext not in allowed_extensions:
47
+ raise HTTPException(
48
+ 400,
49
+ f"Unsupported video format. Allowed: {', '.join(allowed_extensions)}"
50
+ )
51
+
52
+ # Check content type (basic validation)
53
+ if video.content_type and not video.content_type.startswith(('video/', 'application/octet-stream')):
54
+ raise HTTPException(400, "File must be a video")
55
+
56
+ return video
57
+
58
+ def get_services(request: Request) -> ServiceContainer:
59
+ """Get service container from app state."""
60
+ return request.app.state.get_services()
61
+
62
+ def get_use_cases(request: Request) -> UseCaseContainer:
63
+ """Get use case container from app state."""
64
+ return request.app.state.get_use_cases()
65
+
66
+ # Type aliases for dependency injection
67
+ ValidatedVideo = Annotated[UploadFile, Depends(validate_video_file)]
68
+ ExtractionParams = Annotated[ExtractionRequest, Depends(extraction_params)]
69
+ Services = Annotated[ServiceContainer, Depends(get_services)]
70
+ UseCases = Annotated[UseCaseContainer, Depends(get_use_cases)]
interfaces/api/middleware/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """API middleware components."""
interfaces/api/middleware/cors_middleware.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CORS configuration middleware."""
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+
4
+ def configure_cors(app):
5
+ """Configure CORS for the application."""
6
+ app.add_middleware(
7
+ CORSMiddleware,
8
+ allow_origins=["*"], # Configure appropriately for production
9
+ allow_credentials=True,
10
+ allow_methods=["*"],
11
+ allow_headers=["*"],
12
+ expose_headers=["X-Processing-Time", "X-File-Size", "X-Job-Id"]
13
+ )
interfaces/api/middleware/error_handler.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Global error handling middleware."""
2
+ from fastapi import Request, HTTPException
3
+ from fastapi.responses import JSONResponse
4
+ from fastapi.exceptions import RequestValidationError
5
+ from starlette.exceptions import HTTPException as StarletteHTTPException
6
+ import logging
7
+ import traceback
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ async def http_exception_handler(request: Request, exc: HTTPException):
12
+ """Handle HTTP exceptions."""
13
+ return JSONResponse(
14
+ status_code=exc.status_code,
15
+ content={
16
+ "error": exc.detail,
17
+ "code": f"HTTP_{exc.status_code}"
18
+ }
19
+ )
20
+
21
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
22
+ """Handle validation errors."""
23
+ errors = []
24
+ for error in exc.errors():
25
+ field = ".".join(str(x) for x in error["loc"])
26
+ errors.append(f"{field}: {error['msg']}")
27
+
28
+ return JSONResponse(
29
+ status_code=422,
30
+ content={
31
+ "error": "Validation failed",
32
+ "details": "; ".join(errors),
33
+ "code": "VALIDATION_ERROR"
34
+ }
35
+ )
36
+
37
+ async def general_exception_handler(request: Request, exc: Exception):
38
+ """Handle unexpected exceptions."""
39
+ logger.error(f"Unexpected error: {str(exc)}\n{traceback.format_exc()}")
40
+
41
+ return JSONResponse(
42
+ status_code=500,
43
+ content={
44
+ "error": "Internal server error",
45
+ "details": "An unexpected error occurred",
46
+ "code": "INTERNAL_ERROR"
47
+ }
48
+ )
49
+
50
+ def register_exception_handlers(app):
51
+ """Register all exception handlers with the app."""
52
+ app.add_exception_handler(HTTPException, http_exception_handler)
53
+ app.add_exception_handler(RequestValidationError, validation_exception_handler)
54
+ app.add_exception_handler(Exception, general_exception_handler)
interfaces/api/responses.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API response models."""
2
+ from pydantic import BaseModel, Field
3
+ from typing import Optional, Dict, List
4
+ from datetime import datetime
5
+
6
+ class ExtractionResponse(BaseModel):
7
+ """Response for direct extraction."""
8
+ file_path: str
9
+ media_type: str
10
+ filename: str
11
+ processing_time: float
12
+ file_size: int
13
+
14
+ class JobCreatedResponse(BaseModel):
15
+ """Response when a background job is created."""
16
+ job_id: str
17
+ status: str
18
+ message: str
19
+ check_url: str
20
+ file_size_mb: float
21
+
22
+ class JobStatusResponse(BaseModel):
23
+ """Response for job status check."""
24
+ job_id: str
25
+ status: str
26
+ created_at: datetime
27
+ updated_at: datetime
28
+ filename: Optional[str] = None
29
+ file_size_mb: Optional[float] = None
30
+ output_format: Optional[str] = None
31
+ quality: Optional[str] = None
32
+ processing_time: Optional[float] = None
33
+ error: Optional[str] = None
34
+ download_url: Optional[str] = None
35
+
36
+ class ErrorResponse(BaseModel):
37
+ """Standard error response."""
38
+ error: str
39
+ details: Optional[str] = None
40
+ code: Optional[str] = None
41
+
42
+ class ApiInfoResponse(BaseModel):
43
+ """API information response."""
44
+ version: str
45
+ supported_video_formats: List[str]
46
+ supported_audio_formats: List[str]
47
+ quality_levels: List[str]
48
+ max_direct_response_size_mb: float
49
+ endpoints: Dict[str, str]
interfaces/api/routes/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API route modules."""
2
+ from fastapi import APIRouter
3
+ from .extraction_routes import router as extraction_router
4
+ from .job_routes import router as job_router
5
+ from .info_routes import router as info_router
6
+
7
+ def register_routes(app):
8
+ """Register all API routes with the FastAPI app."""
9
+ app.include_router(extraction_router, prefix="/api/v1", tags=["extraction"])
10
+ app.include_router(job_router, prefix="/api/v1", tags=["jobs"])
11
+ app.include_router(info_router, prefix="/api/v1", tags=["info"])
interfaces/api/routes/extraction_routes.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audio extraction API routes."""
2
+ from fastapi import APIRouter, BackgroundTasks, Response, Request, UploadFile
3
+ from fastapi.responses import FileResponse, JSONResponse
4
+ from typing import Dict, Any
5
+ from dataclasses import asdict
6
+
7
+ from ..dependencies import ValidatedVideo, ExtractionParams, UseCases, Services
8
+ from ..responses import ExtractionResponse, JobCreatedResponse
9
+ from application.dto.extraction_request import ExtractionRequestDTO
10
+
11
+ router = APIRouter()
12
+
13
+ @router.post("/extract", response_model=None)
14
+ async def extract_audio(
15
+ background_tasks: BackgroundTasks,
16
+ video: ValidatedVideo,
17
+ params: ExtractionParams,
18
+ use_cases: UseCases,
19
+ services: Services
20
+ ):
21
+ """
22
+ Extract audio from uploaded video file.
23
+
24
+ For small files (<10MB), returns the audio file directly.
25
+ For large files, returns a job ID for async processing.
26
+ """
27
+ # Get file size
28
+ file_size = _get_file_size(video)
29
+ file_size_mb = file_size / (1024 * 1024)
30
+
31
+ # Create DTO
32
+ extraction_dto = ExtractionRequestDTO(
33
+ video_filename=video.filename,
34
+ video_file_path="", # Will be set by use case
35
+ video_file_size=file_size,
36
+ output_format=params.output_format,
37
+ quality=params.quality,
38
+ content_type=video.content_type
39
+ )
40
+
41
+ # Save uploaded file temporarily
42
+ file_path = await services.file_repository.save_stream(
43
+ video,
44
+ video.filename
45
+ )
46
+ extraction_dto.video_file_path = file_path
47
+
48
+ # Decide processing method
49
+ if file_size_mb < 10:
50
+ # Direct processing
51
+ try:
52
+ result = await use_cases.extract_audio_direct.execute(extraction_dto)
53
+
54
+ # Clean up input file after processing
55
+ background_tasks.add_task(
56
+ services.file_repository.delete_file,
57
+ file_path
58
+ )
59
+
60
+ return FileResponse(
61
+ path=result.file_path,
62
+ media_type=result.media_type,
63
+ filename=result.filename,
64
+ headers={
65
+ "X-Processing-Time": str(result.processing_time),
66
+ "X-File-Size": str(result.file_size)
67
+ }
68
+ )
69
+ except Exception as e:
70
+ # Clean up on error
71
+ await services.file_repository.delete_file(file_path)
72
+ raise
73
+ else:
74
+ # Async processing
75
+ result = await use_cases.extract_audio_async.execute(
76
+ extraction_dto,
77
+ background_tasks
78
+ )
79
+
80
+ return JSONResponse(
81
+ content=asdict(result),
82
+ status_code=202 # Accepted
83
+ )
84
+
85
+ def _get_file_size(video: UploadFile) -> int:
86
+ """Get the size of the uploaded file."""
87
+ video.file.seek(0, 2) # Seek to end
88
+ size = video.file.tell()
89
+ video.file.seek(0) # Reset pointer to start
90
+ return size
interfaces/api/routes/info_routes.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API information routes."""
2
+ from fastapi import APIRouter
3
+ from typing import Dict, List
4
+
5
+ from ..responses import ApiInfoResponse
6
+
7
+ router = APIRouter()
8
+
9
+ @router.get("/info", response_model=ApiInfoResponse)
10
+ async def get_api_info():
11
+ """Get API information and supported formats."""
12
+ return ApiInfoResponse(
13
+ version="1.0.0",
14
+ supported_video_formats=['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv', '.m4v'],
15
+ supported_audio_formats=['mp3', 'aac', 'wav', 'flac', 'm4a', 'ogg'],
16
+ quality_levels=['high', 'medium', 'low'],
17
+ max_direct_response_size_mb=10.0,
18
+ endpoints={
19
+ "/api/v1/extract": "POST - Extract audio from video",
20
+ "/api/v1/jobs/{job_id}": "GET - Check job status",
21
+ "/api/v1/jobs/{job_id}/download": "GET - Download processed audio",
22
+ "/api/v1/info": "GET - API information"
23
+ }
24
+ )
25
+
26
+ @router.get("/health")
27
+ async def health_check():
28
+ """Simple health check endpoint."""
29
+ return {"status": "healthy", "service": "audio-extractor"}
interfaces/api/routes/job_routes.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Job management API routes."""
2
+ from fastapi import APIRouter, HTTPException
3
+ from fastapi.responses import FileResponse
4
+ from typing import Any
5
+
6
+ from ..dependencies import UseCases
7
+ from ..responses import JobStatusResponse, ErrorResponse
8
+ from domain.exceptions.domain_exceptions import JobNotFoundError, JobNotCompletedError
9
+
10
+ router = APIRouter()
11
+
12
+ @router.get("/jobs/{job_id}", response_model=JobStatusResponse)
13
+ async def get_job_status(
14
+ job_id: str,
15
+ use_cases: UseCases
16
+ ):
17
+ """Get the status of a background processing job."""
18
+ try:
19
+ job_dto = await use_cases.check_job_status.execute(job_id)
20
+
21
+ return JobStatusResponse(
22
+ job_id=job_dto.job_id,
23
+ status=job_dto.status,
24
+ created_at=job_dto.created_at,
25
+ updated_at=job_dto.updated_at,
26
+ filename=job_dto.filename,
27
+ file_size_mb=job_dto.file_size_mb,
28
+ output_format=job_dto.output_format,
29
+ quality=job_dto.quality,
30
+ processing_time=job_dto.processing_time,
31
+ error=job_dto.error,
32
+ download_url=job_dto.download_url
33
+ )
34
+ except JobNotFoundError as e:
35
+ raise HTTPException(404, str(e))
36
+ except Exception as e:
37
+ raise HTTPException(500, f"Error checking job status: {str(e)}")
38
+
39
+ @router.get("/jobs/{job_id}/download")
40
+ async def download_job_result(
41
+ job_id: str,
42
+ use_cases: UseCases
43
+ ):
44
+ """Download the result of a completed job."""
45
+ try:
46
+ result = await use_cases.download_audio_result.execute(job_id)
47
+
48
+ return FileResponse(
49
+ path=result.file_path,
50
+ media_type=result.media_type,
51
+ filename=result.filename,
52
+ headers={
53
+ "X-Job-Id": job_id,
54
+ "X-Processing-Time": str(result.processing_time)
55
+ }
56
+ )
57
+ except JobNotFoundError as e:
58
+ raise HTTPException(404, str(e))
59
+ except JobNotCompletedError as e:
60
+ raise HTTPException(400, str(e))
61
+ except Exception as e:
62
+ raise HTTPException(500, f"Error downloading result: {str(e)}")