Fred808 commited on
Commit
e844b7b
·
verified ·
1 Parent(s): b1eb356

Upload 4 files

Browse files
Files changed (4) hide show
  1. .dockerignore +49 -0
  2. Dockerfile +51 -0
  3. main.py +526 -0
  4. requirements.txt +6 -0
.dockerignore ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual environments
24
+ venv/
25
+ env/
26
+ ENV/
27
+
28
+ # IDE
29
+ .vscode/
30
+ .idea/
31
+ *.swp
32
+ *.swo
33
+
34
+ # OS
35
+ .DS_Store
36
+ Thumbs.db
37
+
38
+ # Git
39
+ .git/
40
+ .gitignore
41
+
42
+ # Logs
43
+ *.log
44
+
45
+ # Temporary files
46
+ tmp/
47
+ temp/
48
+ *.tmp
49
+
Dockerfile ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.11 slim image as base
2
+ FROM python:3.11-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1 \
7
+ DEBIAN_FRONTEND=noninteractive \
8
+ PORT=7860
9
+
10
+ # Set work directory
11
+ WORKDIR /app
12
+
13
+ # Install system dependencies
14
+ RUN apt-get update && apt-get install -y \
15
+ ffmpeg \
16
+ curl \
17
+ wget \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Copy requirements first for better caching
21
+ COPY requirements.txt .
22
+
23
+ # Install Python dependencies
24
+ RUN pip install --no-cache-dir --upgrade pip && \
25
+ pip install --no-cache-dir -r requirements.txt
26
+
27
+ # Copy application code
28
+ COPY . .
29
+
30
+ # Create downloads directory
31
+ RUN mkdir -p /tmp/downloads && \
32
+ chmod 755 /tmp/downloads
33
+
34
+ # Create non-root user for security
35
+ RUN useradd --create-home --shell /bin/bash app && \
36
+ chown -R app:app /app && \
37
+ chown -R app:app /tmp/downloads
38
+
39
+ # Switch to non-root user
40
+ USER app
41
+
42
+ # Expose port
43
+ EXPOSE 7860
44
+
45
+ # Health check
46
+ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
47
+ CMD curl -f http://localhost:7860/health || exit 1
48
+
49
+ # Run the application
50
+ CMD ["python", "main.py"]
51
+
main.py ADDED
@@ -0,0 +1,526 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ FastAPI YouTube Video Downloader for Hugging Face Spaces
4
+ A modern API service for downloading YouTube videos without cookies
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import subprocess
10
+ import json
11
+ import tempfile
12
+ import shutil
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Optional, Dict, Any, List
16
+ from datetime import datetime
17
+ import asyncio
18
+ from concurrent.futures import ThreadPoolExecutor
19
+
20
+ from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File
21
+ from fastapi.responses import FileResponse, HTMLResponse
22
+ from fastapi.staticfiles import StaticFiles
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from pydantic import BaseModel, HttpUrl
25
+ import uvicorn
26
+
27
+ # Set up logging
28
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Pydantic models for request/response
32
+ class VideoInfoRequest(BaseModel):
33
+ url: HttpUrl
34
+
35
+ class DownloadRequest(BaseModel):
36
+ url: HttpUrl
37
+ quality: str = "best"
38
+ audio_only: bool = False
39
+
40
+ class VideoInfo(BaseModel):
41
+ title: str
42
+ duration: int
43
+ uploader: str
44
+ view_count: int
45
+ upload_date: str
46
+ description: str
47
+ formats: int
48
+ id: str
49
+ thumbnail: str
50
+ webpage_url: str
51
+
52
+ class DownloadResponse(BaseModel):
53
+ success: bool
54
+ message: str
55
+ filename: Optional[str] = None
56
+ file_size: Optional[int] = None
57
+ video_info: Optional[VideoInfo] = None
58
+ download_path: Optional[str] = None
59
+
60
+ class HealthResponse(BaseModel):
61
+ status: str
62
+ yt_dlp_available: bool
63
+ timestamp: str
64
+
65
+ # Initialize FastAPI app
66
+ app = FastAPI(
67
+ title="YouTube Video Downloader",
68
+ description="Download YouTube videos without cookies using yt-dlp",
69
+ version="1.0.0",
70
+ docs_url="/docs",
71
+ redoc_url="/redoc"
72
+ )
73
+
74
+ # Add CORS middleware
75
+ app.add_middleware(
76
+ CORSMiddleware,
77
+ allow_origins=["*"],
78
+ allow_credentials=True,
79
+ allow_methods=["*"],
80
+ allow_headers=["*"],
81
+ )
82
+
83
+ # Thread pool for background tasks
84
+ executor = ThreadPoolExecutor(max_workers=3)
85
+
86
+ class YouTubeDownloader:
87
+ """
88
+ A class to download YouTube videos without cookies using yt-dlp
89
+ """
90
+
91
+ def __init__(self, download_dir: str = None):
92
+ """
93
+ Initialize the YouTube downloader
94
+
95
+ Args:
96
+ download_dir: Directory to save downloaded videos
97
+ """
98
+ if download_dir is None:
99
+ # Use persistent storage if available, otherwise temp
100
+ if os.path.exists('/data'):
101
+ download_dir = '/data/downloads'
102
+ else:
103
+ download_dir = '/tmp/downloads'
104
+
105
+ self.download_dir = Path(download_dir)
106
+ self.download_dir.mkdir(parents=True, exist_ok=True)
107
+
108
+ # Ensure yt-dlp is available
109
+ self._ensure_ytdlp_available()
110
+
111
+ def _ensure_ytdlp_available(self):
112
+ """Ensure yt-dlp is available, install if necessary"""
113
+ try:
114
+ subprocess.run(['yt-dlp', '--version'],
115
+ capture_output=True, check=True)
116
+ logger.info("yt-dlp is available")
117
+ except (subprocess.CalledProcessError, FileNotFoundError):
118
+ logger.info("Installing yt-dlp...")
119
+ try:
120
+ subprocess.run([sys.executable, '-m', 'pip', 'install', 'yt-dlp'],
121
+ check=True, capture_output=True)
122
+ logger.info("yt-dlp installed successfully")
123
+ except subprocess.CalledProcessError as e:
124
+ logger.error(f"Failed to install yt-dlp: {e}")
125
+ raise RuntimeError("Could not install yt-dlp")
126
+
127
+ def get_video_info(self, url: str) -> Optional[Dict[str, Any]]:
128
+ """
129
+ Get video information without downloading
130
+
131
+ Args:
132
+ url: YouTube video URL
133
+
134
+ Returns:
135
+ Dictionary containing video information or None if failed
136
+ """
137
+ try:
138
+ cmd = [
139
+ 'yt-dlp',
140
+ '--dump-json',
141
+ '--no-download',
142
+ '--no-cookies',
143
+ str(url)
144
+ ]
145
+
146
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
147
+ video_info = json.loads(result.stdout)
148
+
149
+ return {
150
+ 'title': video_info.get('title', 'Unknown'),
151
+ 'duration': video_info.get('duration', 0),
152
+ 'uploader': video_info.get('uploader', 'Unknown'),
153
+ 'view_count': video_info.get('view_count', 0),
154
+ 'upload_date': video_info.get('upload_date', 'Unknown'),
155
+ 'description': video_info.get('description', ''),
156
+ 'formats': len(video_info.get('formats', [])),
157
+ 'id': video_info.get('id', 'Unknown'),
158
+ 'thumbnail': video_info.get('thumbnail', ''),
159
+ 'webpage_url': video_info.get('webpage_url', str(url))
160
+ }
161
+
162
+ except subprocess.CalledProcessError as e:
163
+ logger.error(f"Failed to get video info: {e.stderr}")
164
+ return None
165
+ except json.JSONDecodeError as e:
166
+ logger.error(f"Failed to parse video info JSON: {e}")
167
+ return None
168
+
169
+ def download_video(self, url: str, quality: str = "best",
170
+ audio_only: bool = False) -> Optional[str]:
171
+ """
172
+ Download a YouTube video without cookies
173
+
174
+ Args:
175
+ url: YouTube video URL
176
+ quality: Video quality (best, worst, or specific format)
177
+ audio_only: If True, download only audio
178
+
179
+ Returns:
180
+ Path to downloaded file or None if failed
181
+ """
182
+ try:
183
+ # Base command
184
+ cmd = ['yt-dlp']
185
+
186
+ # Set output directory and filename template
187
+ output_template = str(self.download_dir / "%(title)s.%(ext)s")
188
+ cmd.extend(['-o', output_template])
189
+
190
+ # Set format/quality
191
+ if audio_only:
192
+ cmd.extend(['-f', 'bestaudio/best'])
193
+ else:
194
+ if quality == "best":
195
+ cmd.extend(['-f', 'best[height<=720]']) # Limit to 720p for server efficiency
196
+ elif quality == "worst":
197
+ cmd.extend(['-f', 'worst'])
198
+ else:
199
+ cmd.extend(['-f', quality])
200
+
201
+ # Add other useful options
202
+ cmd.extend([
203
+ '--no-cookies', # Explicitly no cookies
204
+ '--no-check-certificates', # Skip SSL certificate verification if needed
205
+ '--extract-flat', 'false', # Extract full metadata
206
+ str(url)
207
+ ])
208
+
209
+ logger.info(f"Downloading video from: {url}")
210
+
211
+ # Execute download
212
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
213
+
214
+ logger.info("Download completed successfully")
215
+
216
+ # Find the downloaded file
217
+ downloaded_files = [f for f in self.download_dir.glob("*") if f.is_file()]
218
+ if downloaded_files:
219
+ # Return the most recently created file
220
+ latest_file = max(downloaded_files, key=os.path.getctime)
221
+ return str(latest_file)
222
+
223
+ return None
224
+
225
+ except subprocess.CalledProcessError as e:
226
+ logger.error(f"Download failed: {e.stderr}")
227
+ return None
228
+
229
+ # Global downloader instance
230
+ downloader = YouTubeDownloader()
231
+
232
+ @app.get("/", response_class=HTMLResponse)
233
+ async def read_root():
234
+ """Serve the main HTML interface"""
235
+ html_content = """
236
+ <!DOCTYPE html>
237
+ <html lang="en">
238
+ <head>
239
+ <meta charset="UTF-8">
240
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
241
+ <title>YouTube Video Downloader API</title>
242
+ <style>
243
+ body {
244
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
245
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
246
+ min-height: 100vh;
247
+ margin: 0;
248
+ padding: 20px;
249
+ display: flex;
250
+ align-items: center;
251
+ justify-content: center;
252
+ }
253
+ .container {
254
+ background: white;
255
+ border-radius: 15px;
256
+ box-shadow: 0 20px 40px rgba(0,0,0,0.1);
257
+ padding: 40px;
258
+ max-width: 800px;
259
+ width: 100%;
260
+ }
261
+ .header {
262
+ text-align: center;
263
+ margin-bottom: 30px;
264
+ }
265
+ .header h1 {
266
+ color: #333;
267
+ margin-bottom: 10px;
268
+ font-size: 2.5em;
269
+ }
270
+ .header p {
271
+ color: #666;
272
+ font-size: 1.1em;
273
+ }
274
+ .api-info {
275
+ background: #f8f9fa;
276
+ border-radius: 8px;
277
+ padding: 20px;
278
+ margin: 20px 0;
279
+ }
280
+ .endpoint {
281
+ background: white;
282
+ border: 1px solid #dee2e6;
283
+ border-radius: 5px;
284
+ padding: 15px;
285
+ margin: 10px 0;
286
+ }
287
+ .method {
288
+ display: inline-block;
289
+ padding: 4px 8px;
290
+ border-radius: 4px;
291
+ font-weight: bold;
292
+ font-size: 0.8em;
293
+ margin-right: 10px;
294
+ }
295
+ .get { background: #d4edda; color: #155724; }
296
+ .post { background: #d1ecf1; color: #0c5460; }
297
+ .btn {
298
+ background: linear-gradient(135deg, #667eea, #764ba2);
299
+ color: white;
300
+ border: none;
301
+ padding: 12px 24px;
302
+ border-radius: 8px;
303
+ text-decoration: none;
304
+ display: inline-block;
305
+ margin: 10px 5px;
306
+ transition: transform 0.2s ease;
307
+ }
308
+ .btn:hover {
309
+ transform: translateY(-2px);
310
+ }
311
+ code {
312
+ background: #f8f9fa;
313
+ padding: 2px 6px;
314
+ border-radius: 3px;
315
+ font-family: 'Courier New', monospace;
316
+ }
317
+ </style>
318
+ </head>
319
+ <body>
320
+ <div class="container">
321
+ <div class="header">
322
+ <h1>🎥 YouTube Downloader API</h1>
323
+ <p>FastAPI service for downloading YouTube videos without cookies</p>
324
+ </div>
325
+
326
+ <div class="api-info">
327
+ <h3>📋 Available Endpoints</h3>
328
+
329
+ <div class="endpoint">
330
+ <span class="method get">GET</span>
331
+ <code>/health</code> - Check service health and yt-dlp availability
332
+ </div>
333
+
334
+ <div class="endpoint">
335
+ <span class="method post">POST</span>
336
+ <code>/video/info</code> - Get video information without downloading
337
+ </div>
338
+
339
+ <div class="endpoint">
340
+ <span class="method post">POST</span>
341
+ <code>/video/download</code> - Download a YouTube video
342
+ </div>
343
+
344
+ <div class="endpoint">
345
+ <span class="method get">GET</span>
346
+ <code>/video/file/{filename}</code> - Download a previously processed file
347
+ </div>
348
+ </div>
349
+
350
+ <div style="text-align: center;">
351
+ <a href="/docs" class="btn">📖 Interactive API Documentation</a>
352
+ <a href="/redoc" class="btn">📚 ReDoc Documentation</a>
353
+ </div>
354
+
355
+ <div class="api-info">
356
+ <h3>🚀 Quick Start Example</h3>
357
+ <p>Get video information:</p>
358
+ <pre><code>curl -X POST "http://localhost:8000/video/info" \\
359
+ -H "Content-Type: application/json" \\
360
+ -d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}'</code></pre>
361
+ </div>
362
+ </div>
363
+ </body>
364
+ </html>
365
+ """
366
+ return HTMLResponse(content=html_content)
367
+
368
+ @app.get("/health", response_model=HealthResponse)
369
+ async def health_check():
370
+ """Health check endpoint"""
371
+ try:
372
+ # Check if yt-dlp is available
373
+ subprocess.run(['yt-dlp', '--version'], capture_output=True, check=True)
374
+ yt_dlp_available = True
375
+ except:
376
+ yt_dlp_available = False
377
+
378
+ return HealthResponse(
379
+ status="healthy" if yt_dlp_available else "unhealthy",
380
+ yt_dlp_available=yt_dlp_available,
381
+ timestamp=datetime.now().isoformat()
382
+ )
383
+
384
+ @app.post("/video/info", response_model=Dict[str, Any])
385
+ async def get_video_info(request: VideoInfoRequest):
386
+ """Get video information without downloading"""
387
+ try:
388
+ # Validate URL
389
+ url_str = str(request.url)
390
+ if not any(domain in url_str for domain in ['youtube.com', 'youtu.be']):
391
+ raise HTTPException(status_code=400, detail="Invalid YouTube URL")
392
+
393
+ # Get video info in thread pool to avoid blocking
394
+ loop = asyncio.get_event_loop()
395
+ info = await loop.run_in_executor(executor, downloader.get_video_info, url_str)
396
+
397
+ if info:
398
+ return {"success": True, "info": info}
399
+ else:
400
+ raise HTTPException(status_code=500, detail="Failed to get video information")
401
+
402
+ except HTTPException:
403
+ raise
404
+ except Exception as e:
405
+ logger.error(f"Error getting video info: {e}")
406
+ raise HTTPException(status_code=500, detail=str(e))
407
+
408
+ @app.post("/video/download", response_model=DownloadResponse)
409
+ async def download_video(request: DownloadRequest, background_tasks: BackgroundTasks):
410
+ """Download a YouTube video"""
411
+ try:
412
+ # Validate URL
413
+ url_str = str(request.url)
414
+ if not any(domain in url_str for domain in ['youtube.com', 'youtu.be']):
415
+ raise HTTPException(status_code=400, detail="Invalid YouTube URL")
416
+
417
+ # Get video info first
418
+ loop = asyncio.get_event_loop()
419
+ info = await loop.run_in_executor(executor, downloader.get_video_info, url_str)
420
+ if not info:
421
+ raise HTTPException(status_code=500, detail="Failed to get video information")
422
+
423
+ # Download the video
424
+ downloaded_file = await loop.run_in_executor(
425
+ executor,
426
+ downloader.download_video,
427
+ url_str,
428
+ request.quality,
429
+ request.audio_only
430
+ )
431
+
432
+ if downloaded_file:
433
+ file_size = os.path.getsize(downloaded_file)
434
+ filename = os.path.basename(downloaded_file)
435
+
436
+ # Schedule cleanup after 1 hour
437
+ background_tasks.add_task(cleanup_file, downloaded_file, delay=3600)
438
+
439
+ return DownloadResponse(
440
+ success=True,
441
+ message="Video downloaded successfully",
442
+ filename=filename,
443
+ file_size=file_size,
444
+ video_info=VideoInfo(**info),
445
+ download_path=downloaded_file
446
+ )
447
+ else:
448
+ raise HTTPException(status_code=500, detail="Failed to download video")
449
+
450
+ except HTTPException:
451
+ raise
452
+ except Exception as e:
453
+ logger.error(f"Error downloading video: {e}")
454
+ raise HTTPException(status_code=500, detail=str(e))
455
+
456
+ @app.get("/video/file/{filename}")
457
+ async def download_file(filename: str):
458
+ """Serve downloaded files"""
459
+ try:
460
+ # Security: only allow files from download directory
461
+ file_path = downloader.download_dir / filename
462
+
463
+ # Check if file exists and is within download directory
464
+ if not file_path.exists() or not str(file_path.resolve()).startswith(str(downloader.download_dir.resolve())):
465
+ raise HTTPException(status_code=404, detail="File not found")
466
+
467
+ return FileResponse(
468
+ path=str(file_path),
469
+ filename=filename,
470
+ media_type='application/octet-stream'
471
+ )
472
+
473
+ except HTTPException:
474
+ raise
475
+ except Exception as e:
476
+ logger.error(f"Error serving file: {e}")
477
+ raise HTTPException(status_code=500, detail=str(e))
478
+
479
+ @app.get("/video/formats")
480
+ async def list_formats(url: HttpUrl):
481
+ """List available formats for a video"""
482
+ try:
483
+ url_str = str(url)
484
+ if not any(domain in url_str for domain in ['youtube.com', 'youtu.be']):
485
+ raise HTTPException(status_code=400, detail="Invalid YouTube URL")
486
+
487
+ cmd = ['yt-dlp', '--list-formats', '--no-cookies', url_str]
488
+
489
+ loop = asyncio.get_event_loop()
490
+ result = await loop.run_in_executor(
491
+ executor,
492
+ lambda: subprocess.run(cmd, capture_output=True, text=True, check=True)
493
+ )
494
+
495
+ formats = result.stdout.split('\n')
496
+ return {"success": True, "formats": formats}
497
+
498
+ except subprocess.CalledProcessError as e:
499
+ logger.error(f"Failed to list formats: {e.stderr}")
500
+ raise HTTPException(status_code=500, detail="Failed to list formats")
501
+ except Exception as e:
502
+ logger.error(f"Error listing formats: {e}")
503
+ raise HTTPException(status_code=500, detail=str(e))
504
+
505
+ async def cleanup_file(file_path: str, delay: int = 3600):
506
+ """Clean up downloaded file after delay"""
507
+ await asyncio.sleep(delay)
508
+ try:
509
+ if os.path.exists(file_path):
510
+ os.remove(file_path)
511
+ logger.info(f"Cleaned up file: {file_path}")
512
+ except Exception as e:
513
+ logger.error(f"Failed to cleanup file {file_path}: {e}")
514
+
515
+ if __name__ == "__main__":
516
+ # Get port from environment variable (Hugging Face Spaces uses port 7860)
517
+ port = int(os.environ.get("PORT", 7860))
518
+
519
+ uvicorn.run(
520
+ "main:app",
521
+ host="0.0.0.0",
522
+ port=port,
523
+ reload=False,
524
+ log_level="info"
525
+ )
526
+
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ pydantic==2.5.0
4
+ yt-dlp==2025.6.30
5
+ python-multipart==0.0.6
6
+