Samirbagda commited on
Commit
b18a506
·
verified ·
1 Parent(s): fe33129

Upload main.py

Browse files
Files changed (1) hide show
  1. main.py +302 -0
main.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Optional, Literal, Union # Import Union
6
+
7
+ # --- FastAPI Imports ---
8
+ from fastapi import FastAPI, Request, HTTPException, BackgroundTasks, Body
9
+ from fastapi.responses import JSONResponse, FileResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from pydantic import BaseModel, HttpUrl, Field, field_validator # Import field_validator
12
+
13
+ # --- yt-dlp Import ---
14
+ from yt_dlp import YoutubeDL
15
+
16
+ # --- Logging Configuration ---
17
+ logging.basicConfig(level=logging.INFO)
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # --- Constants ---
21
+ DOWNLOAD_DIR = Path('downloads') # Use pathlib for paths
22
+ COOKIE_FILE = 'www.youtube.com_cookies.txt' # Define cookie file path
23
+
24
+ # --- Create Download Directory ---
25
+ DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
26
+
27
+ # --- FastAPI App Initialization ---
28
+ app = FastAPI(
29
+ title="YouTube Downloader API",
30
+ description="API to fetch info and download audio/video from YouTube using yt-dlp.",
31
+ version="1.4.0", # Incremented version
32
+ )
33
+
34
+ # --- Mount Static Files Directory ---
35
+ app.mount("/downloads", StaticFiles(directory=DOWNLOAD_DIR), name="downloads")
36
+
37
+ # --- Pydantic Models for Request/Response Validation ---
38
+
39
+ class UrlRequest(BaseModel):
40
+ """Request model for endpoints needing just a URL."""
41
+ url: HttpUrl
42
+
43
+ # Define allowed quality string literals (including numerical ones)
44
+ AllowedQualityStr = Literal['best', '240', '480', '720', '1080', '1440', '2160']
45
+
46
+ class MaxDownloadRequest(BaseModel):
47
+ """Request model for the /max endpoint."""
48
+ url: HttpUrl
49
+ # Accept 'best' or specific numerical resolutions as strings
50
+ quality: Optional[AllowedQualityStr] = 'best'
51
+
52
+ class InfoResponse(BaseModel):
53
+ """Response model for the /get-info endpoint."""
54
+ title: Optional[str] = None
55
+ thumbnail: Optional[str] = None
56
+ duration: Optional[float] = None
57
+ channel: Optional[str] = None
58
+
59
+ class DownloadResponse(BaseModel):
60
+ """Response model for download endpoints."""
61
+ url: str
62
+ filename: str
63
+ message: Optional[str] = None
64
+
65
+ class ErrorResponse(BaseModel):
66
+ """Standard error response model."""
67
+ detail: str
68
+
69
+ # --- Helper Function for Download ---
70
+ def perform_download(ydl_opts: dict, url: str, file_path: Path):
71
+ """Synchronously downloads using yt-dlp."""
72
+ try:
73
+ logger.info(f"Starting download for URL: {url} with options: {ydl_opts}")
74
+ ydl_opts['outtmpl'] = str(file_path.with_suffix('.%(ext)s'))
75
+
76
+ with YoutubeDL(ydl_opts) as ydl:
77
+ ydl.extract_info(url, download=True)
78
+ logger.info(f"Download finished successfully for URL: {url}")
79
+
80
+ downloaded_files = list(DOWNLOAD_DIR.glob(f"{file_path.stem}.*"))
81
+ if not downloaded_files:
82
+ logger.error(f"Download completed but no file found for stem: {file_path.stem}")
83
+ part_files = list(DOWNLOAD_DIR.glob(f"{file_path.stem}.*.part"))
84
+ for part_file in part_files:
85
+ try:
86
+ os.remove(part_file)
87
+ logger.info(f"Removed leftover part file: {part_file}")
88
+ except OSError as rm_err:
89
+ logger.error(f"Error removing part file {part_file}: {rm_err}")
90
+ raise RuntimeError(f"Could not find downloaded file for {url}")
91
+ return downloaded_files[0]
92
+
93
+ except Exception as e:
94
+ logger.error(f"yt-dlp download failed for URL {url}: {e}", exc_info=True)
95
+ possible_files = list(DOWNLOAD_DIR.glob(f"{file_path.stem}.*"))
96
+ for f in possible_files:
97
+ if f.is_file():
98
+ try:
99
+ os.remove(f)
100
+ logger.info(f"Removed potentially incomplete/failed file: {f}")
101
+ except OSError as rm_err:
102
+ logger.error(f"Error removing file {f}: {rm_err}")
103
+ raise
104
+
105
+ # --- API Endpoints ---
106
+
107
+ @app.get("/")
108
+ async def root():
109
+ """Root endpoint providing basic API info."""
110
+ return {"message": "YouTube Downloader API. Use /docs for documentation."}
111
+
112
+ @app.post(
113
+ "/get-info",
114
+ response_model=InfoResponse,
115
+ responses={500: {"model": ErrorResponse}}
116
+ )
117
+ async def get_info(payload: UrlRequest = Body(...)):
118
+ """
119
+ Extracts video information (title, thumbnail, duration, channel) from a given URL.
120
+ """
121
+ logger.info(f"Received /get-info request for URL: {payload.url}")
122
+ ydl_opts = {}
123
+ if os.path.exists(COOKIE_FILE):
124
+ ydl_opts['cookiefile'] = COOKIE_FILE
125
+ logger.info("Using cookie file.")
126
+ else:
127
+ logger.warning(f"Cookie file '{COOKIE_FILE}' not found. Some videos might require login/cookies.")
128
+
129
+ try:
130
+ # Use str(payload.url) to pass the URL string to yt-dlp
131
+ with YoutubeDL(ydl_opts) as ydl:
132
+ info = ydl.extract_info(str(payload.url), download=False)
133
+ return InfoResponse(
134
+ title=info.get('title'),
135
+ thumbnail=info.get('thumbnail'),
136
+ duration=info.get('duration'),
137
+ channel=info.get('channel')
138
+ )
139
+ except Exception as e:
140
+ logger.error(f"Error fetching info for {payload.url}: {e}", exc_info=True)
141
+ raise HTTPException(status_code=500, detail=f"Failed to extract video info: {str(e)}")
142
+
143
+ '''@app.post(
144
+ "/download",
145
+ response_model=DownloadResponse,
146
+ responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}
147
+ )
148
+ async def download_audio(request: Request, payload: UrlRequest = Body(...)):
149
+ """
150
+ Downloads the audio track of a video as an MP3 file (128kbps).
151
+ """
152
+ logger.info(f"Received /download (audio) request for URL: {payload.url}")
153
+ unique_id = str(uuid.uuid4())
154
+ file_path_stem = DOWNLOAD_DIR / unique_id
155
+
156
+ ydl_opts = {
157
+ 'format': '140/m4a/bestaudio/best',
158
+ 'outtmpl': str(file_path_stem.with_suffix('.%(ext)s')),
159
+ 'postprocessors': [{
160
+ 'key': 'FFmpegExtractAudio',
161
+ 'preferredcodec': 'mp3',
162
+ 'preferredquality': '128',
163
+ }],
164
+ 'noplaylist': True,
165
+ 'quiet': False,
166
+ 'progress_hooks': [lambda d: logger.debug(f"Download progress: {d['status']} - {d.get('_percent_str', '')}")],
167
+ }
168
+ if os.path.exists(COOKIE_FILE):
169
+ ydl_opts['cookiefile'] = COOKIE_FILE
170
+ logger.info("Using cookie file for audio download.")
171
+ else:
172
+ logger.warning(f"Cookie file '{COOKIE_FILE}' not found for audio download.")
173
+
174
+ try:
175
+ # Use str(payload.url) to pass the URL string to the helper
176
+ final_file_path = perform_download(ydl_opts, str(payload.url), file_path_stem)
177
+ final_filename = final_file_path.name
178
+ download_url = f"{str(request.base_url).rstrip('/')}/downloads/{final_filename}"
179
+ logger.info(f"Audio download complete for {payload.url}. URL: {download_url}")
180
+ return DownloadResponse(url=download_url, filename=final_filename)
181
+
182
+ except Exception as e:
183
+ # Error logged in perform_download
184
+ raise HTTPException(status_code=500, detail=f"Audio download failed: {str(e)}")
185
+ '''
186
+
187
+ @app.post(
188
+ "/max",
189
+ response_model=DownloadResponse,
190
+ responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}
191
+ )
192
+ async def download_video_max_quality(request: Request, payload: MaxDownloadRequest = Body(...)):
193
+ """
194
+ Downloads the video in the specified quality or 'best' available, handling
195
+ both landscape and portrait videos correctly. Attempts H.264 codec for 1080
196
+ and lower. Merges video and audio into MP4.
197
+
198
+ Accepted qualities: 'best', '240', '480', '720', '1080', '1440', '2160'.
199
+ Quality number (as string) refers to the maximum dimension (height or width).
200
+ """
201
+ logger.info(f"Received /max (video) request for URL: {payload.url} with quality: {payload.quality}")
202
+
203
+ unique_id = str(uuid.uuid4())
204
+ file_path_stem = DOWNLOAD_DIR / unique_id
205
+
206
+ # --- Determine yt-dlp Format Selector based on Quality and Codec Preference ---
207
+ quality_str = payload.quality # Quality is now guaranteed to be a string from AllowedQualityStr
208
+ format_selector = None
209
+ max_dim = 0 # Initialize max_dim
210
+
211
+ if quality_str == 'best':
212
+ format_selector = 'bestvideo+bestaudio/best' # Best video and audio, merged if possible
213
+ logger.info("Using format selector for 'best' quality.")
214
+ else:
215
+ # Quality is a numerical string ('240', '480', etc.)
216
+ try:
217
+ # Convert the validated string quality to an integer for logic
218
+ max_dim = int(quality_str)
219
+ except ValueError:
220
+ # This should not happen if Pydantic validation works, but good practice
221
+ logger.error(f"Internal error: Could not convert validated quality string '{quality_str}' to int. Falling back to 'best'.")
222
+ format_selector = 'bestvideo+bestaudio/best'
223
+ # Set max_dim to a high value to skip specific logic below if format_selector is set
224
+ max_dim = 99999
225
+
226
+ # Only proceed if format_selector wasn't set in the except block
227
+ long_edge = int(max_dim * 1.8)
228
+ if not format_selector:
229
+ # --- Codec Preference Logic ---
230
+ if max_dim <= 1080:
231
+ # Prefer H.264 (avc1) for 1080 or lower max dimension
232
+ logger.info(f"Attempting H.264 codec for requested quality (max dimension): {max_dim}")
233
+ format_selector = f'bestvideo[vcodec^=avc][height<={long_edge}][width<={long_edge}]+bestaudio/best'
234
+ #f'bestvideo[height<={long_edge}]/bestvideo[width<={long_edge}]+bestaudio/'
235
+ #f'best[height<={long_edge}]/best[width<={long_edge}]'
236
+ else:
237
+ # For > 1080 max dimension, prioritize best available codec
238
+ logger.info(f"Attempting best available codec for requested quality (max dimension): {max_dim}")
239
+ format_selector = f'bestvideo[height<={long_edge}][width<={long_edge}]+bestaudio/best'
240
+
241
+ logger.info(f"Using format selector: '{format_selector}'")
242
+
243
+ # --- yt-dlp Options for Video Download ---
244
+ ydl_opts = {
245
+ 'format': format_selector,
246
+ 'outtmpl': str(file_path_stem.with_suffix('.%(ext)s')),
247
+ 'merge_output_format': 'mp4', # Merge into MP4 container
248
+ 'noplaylist': False,
249
+ 'quiet': True,
250
+ #'verbose': True,
251
+ 'noprogress': True
252
+ }
253
+ if os.path.exists(COOKIE_FILE):
254
+ ydl_opts['cookiefile'] = COOKIE_FILE
255
+ logger.info("Using cookie file for video download.")
256
+ else:
257
+ logger.warning(f"Cookie file '{COOKIE_FILE}' not found for video download.")
258
+
259
+ try:
260
+ # Use str(payload.url) to pass the URL string to the helper
261
+ final_file_path = perform_download(ydl_opts, str(payload.url), file_path_stem)
262
+ final_filename = final_file_path.name
263
+ download_url = f"{str(request.base_url).rstrip('/')}/downloads/{final_filename}"
264
+
265
+ logger.info(f"Video download complete for {payload.url}. URL: {download_url}")
266
+ # Changed 'download_url=' to 'url='
267
+ return DownloadResponse(url=download_url, filename=final_filename)
268
+
269
+ except Exception as e:
270
+ # Error logged in perform_download
271
+ raise HTTPException(status_code=500, detail=f"Video download failed: {str(e)}")
272
+
273
+ # --- Optional: Cleanup Task ---
274
+ async def cleanup_old_files(directory: Path, max_age_seconds: int):
275
+ """Removes files older than max_age_seconds in the background."""
276
+ import time
277
+ now = time.time()
278
+ count = 0
279
+ try:
280
+ for item in directory.iterdir():
281
+ if item.is_file():
282
+ try:
283
+ if now - item.stat().st_mtime > max_age_seconds:
284
+ os.remove(item)
285
+ logger.info(f"Cleaned up old file: {item.name}")
286
+ count += 1
287
+ except OSError as e:
288
+ logger.error(f"Error removing file {item}: {e}")
289
+ if count > 0:
290
+ logger.info(f"Background cleanup finished. Removed {count} old files.")
291
+ else:
292
+ logger.info("Background cleanup finished. No old files found.")
293
+ except Exception as e:
294
+ logger.error(f"Error during background file cleanup: {e}", exc_info=True)
295
+
296
+ @app.post("/trigger-cleanup")
297
+ async def trigger_cleanup(background_tasks: BackgroundTasks):
298
+ """Manually trigger a cleanup of files older than 1 day."""
299
+ logger.info("Triggering background cleanup of old download files.")
300
+ background_tasks.add_task(cleanup_old_files, DOWNLOAD_DIR, 86400) # 1 day
301
+ return {"message": "Background cleanup task scheduled."}
302
+