tedowski commited on
Commit
aebd124
·
verified ·
1 Parent(s): 72e8a3d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +508 -5
app.py CHANGED
@@ -1,7 +1,510 @@
1
- from fastapi import FastAPI
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- app = FastAPI()
 
 
4
 
5
- @app.get("/")
6
- def greet_json():
7
- return {"Hello": "World!"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")