Chrunos commited on
Commit
5839c2e
·
verified ·
1 Parent(s): b2167be

Update srv.py

Browse files
Files changed (1) hide show
  1. srv.py +319 -56
srv.py CHANGED
@@ -1,76 +1,339 @@
1
- from flask import Flask, request, jsonify, send_file, render_template
2
- from yt_dlp import YoutubeDL
3
  import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- app = Flask(__name__)
 
 
 
 
 
6
 
7
- @app.route('/')
8
- def home():
9
- return render_template('index.html')
 
 
10
 
11
- @app.route('/get-info', methods=['POST'])
12
- def get_info():
13
- data = request.json
14
- url = data.get('url')
15
 
16
- if not url:
17
- return jsonify({'error': 'URL is required'}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  try:
20
- ydl_opts = {
21
- 'cookiefile': 'www.youtube.com_cookies.txt'
22
- }
23
-
24
  with YoutubeDL(ydl_opts) as ydl:
25
- info = ydl.extract_info(url, download=False)
26
- return jsonify({
27
- 'title': info['title'],
28
- 'thumbnail': info.get('thumbnail'),
29
- 'duration': info.get('duration'),
30
- 'channel': info.get('channel')
31
- })
32
 
 
 
 
 
 
 
 
33
  except Exception as e:
34
- return jsonify({'error': str(e)}), 500
 
35
 
36
- @app.route('/download', methods=['POST'])
37
- def download_audio():
38
- data = request.json
39
- url = data.get('url')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
- if not url:
42
- return jsonify({'error': 'URL is required'}), 400
43
 
44
  try:
45
- ydl_opts = {
46
- 'format': '251/bestaudio', # Format untuk audio terbaik
47
- 'outtmpl': '%(title)s.%(ext)s',
48
- 'cookiefile': 'www.youtube.com_cookies.txt',
49
- 'postprocessors': [{
50
- 'key': 'FFmpegExtractAudio',
51
- 'preferredcodec': 'mp3',
52
- 'preferredquality': '192',
53
- }],
54
- }
55
 
56
- with YoutubeDL(ydl_opts) as ydl:
57
- info = ydl.extract_info(url, download=True)
58
- file_name = ydl.prepare_filename(info).rsplit(".", 1)[0] + ".mp3"
 
59
 
60
- # Kirim file ke client
61
- return send_file(
62
- file_name,
63
- as_attachment=True,
64
- download_name=os.path.basename(file_name)
65
- )
66
 
67
  except Exception as e:
68
- return jsonify({'error': str(e)}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
- finally:
71
- # Bersihkan file setelah dikirim
72
- if 'file_name' in locals() and os.path.exists(file_name):
73
- os.remove(file_name)
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- if __name__ == '__main__':
76
- app.run(host='0.0.0.0', port=7860, debug=True)
 
 
 
1
  import os
2
+ import uuid
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Optional
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 # Use HttpUrl for URL validation
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.0.0",
32
+ )
33
+
34
+ # --- Mount Static Files Directory ---
35
+ # This allows serving files directly from the 'downloads' directory
36
+ # under the path '/downloads'
37
+ app.mount("/downloads", StaticFiles(directory=DOWNLOAD_DIR), name="downloads")
38
+
39
+ # --- Pydantic Models for Request/Response Validation ---
40
+
41
+ class UrlRequest(BaseModel):
42
+ """Request model for endpoints needing just a URL."""
43
+ url: HttpUrl # Ensures the input is a valid URL
44
+
45
+ class MaxDownloadRequest(BaseModel):
46
+ """Request model for the /max endpoint."""
47
+ url: HttpUrl
48
+ quality: Optional[str] = 'best' # e.g., '1080p', '720p', 'best'
49
 
50
+ class InfoResponse(BaseModel):
51
+ """Response model for the /get-info endpoint."""
52
+ title: Optional[str] = None
53
+ thumbnail: Optional[str] = None
54
+ duration: Optional[float] = None # Duration is often float/int
55
+ channel: Optional[str] = None
56
 
57
+ class DownloadResponse(BaseModel):
58
+ """Response model for download endpoints."""
59
+ download_url: str
60
+ filename: str
61
+ message: Optional[str] = None
62
 
63
+ class ErrorResponse(BaseModel):
64
+ """Standard error response model."""
65
+ detail: str
 
66
 
67
+ # --- Helper Function for Download ---
68
+ def perform_download(ydl_opts: dict, url: str, file_path: Path):
69
+ """Synchronously downloads using yt-dlp."""
70
+ try:
71
+ logger.info(f"Starting download for URL: {url} with options: {ydl_opts}")
72
+ # Ensure the output template uses the full file path stem
73
+ ydl_opts['outtmpl'] = str(file_path.with_suffix('.%(ext)s'))
74
+
75
+ with YoutubeDL(ydl_opts) as ydl:
76
+ ydl.extract_info(url, download=True)
77
+ logger.info(f"Download finished successfully for URL: {url}")
78
+
79
+ # Find the actual downloaded file (extension might change)
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
+ raise RuntimeError(f"Could not find downloaded file for {url}")
84
+ # Return the path of the first matching file
85
+ return downloaded_files[0]
86
+
87
+ except Exception as e:
88
+ logger.error(f"yt-dlp download failed for URL {url}: {e}", exc_info=True)
89
+ # Clean up potentially incomplete file if it exists
90
+ if file_path.exists():
91
+ try:
92
+ os.remove(file_path)
93
+ logger.info(f"Removed incomplete file: {file_path}")
94
+ except OSError as rm_err:
95
+ logger.error(f"Error removing incomplete file {file_path}: {rm_err}")
96
+ raise # Re-raise the exception to be caught by the endpoint handler
97
+
98
+ # --- API Endpoints ---
99
+
100
+ @app.get("/")
101
+ async def root():
102
+ """Root endpoint providing basic API info."""
103
+ # Differs from Flask's 404, provides a simple informational message.
104
+ return {"message": "YouTube Downloader API. Use /docs for documentation."}
105
+
106
+ @app.post(
107
+ "/get-info",
108
+ response_model=InfoResponse,
109
+ responses={500: {"model": ErrorResponse}}
110
+ )
111
+ async def get_info(payload: UrlRequest = Body(...)):
112
+ """
113
+ Extracts video information (title, thumbnail, duration, channel) from a given URL.
114
+ """
115
+ logger.info(f"Received /get-info request for URL: {payload.url}")
116
+ ydl_opts = {}
117
+ if os.path.exists(COOKIE_FILE):
118
+ ydl_opts['cookiefile'] = COOKIE_FILE
119
+ logger.info("Using cookie file.")
120
+ else:
121
+ logger.warning(f"Cookie file '{COOKIE_FILE}' not found. Some videos might require login/cookies.")
122
 
123
  try:
 
 
 
 
124
  with YoutubeDL(ydl_opts) as ydl:
125
+ # Extract info without downloading
126
+ info = ydl.extract_info(str(payload.url), download=False)
 
 
 
 
 
127
 
128
+ # Safely get info fields
129
+ return InfoResponse(
130
+ title=info.get('title'),
131
+ thumbnail=info.get('thumbnail'),
132
+ duration=info.get('duration'),
133
+ channel=info.get('channel')
134
+ )
135
  except Exception as e:
136
+ logger.error(f"Error fetching info for {payload.url}: {e}", exc_info=True)
137
+ raise HTTPException(status_code=500, detail=f"Failed to extract video info: {str(e)}")
138
 
139
+ @app.post(
140
+ "/download",
141
+ response_model=DownloadResponse,
142
+ responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}
143
+ )
144
+ async def download_audio(request: Request, payload: UrlRequest = Body(...)):
145
+ """
146
+ Downloads the audio track of a video as an MP3 file (128kbps).
147
+ """
148
+ logger.info(f"Received /download (audio) request for URL: {payload.url}")
149
+
150
+ # Generate unique filename components
151
+ unique_id = str(uuid.uuid4())
152
+ # Define the base path without extension (yt-dlp adds it)
153
+ file_path_stem = DOWNLOAD_DIR / unique_id
154
+
155
+ # --- yt-dlp Options for Audio Download ---
156
+ ydl_opts = {
157
+ 'format': '140/m4a/bestaudio/best', # Prioritize format 140 (m4a), fallback to best audio
158
+ 'outtmpl': str(file_path_stem.with_suffix('.%(ext)s')), # Output filename template
159
+ 'postprocessors': [{
160
+ 'key': 'FFmpegExtractAudio',
161
+ 'preferredcodec': 'mp3', # Convert to MP3
162
+ 'preferredquality': '128', # Set audio quality
163
+ }],
164
+ 'noplaylist': True, # Avoid downloading entire playlists
165
+ 'quiet': False, # Show yt-dlp output in logs
166
+ 'progress_hooks': [lambda d: logger.debug(f"Download progress: {d['status']} - {d.get('_percent_str', '')}")], # Log progress
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
 
175
  try:
176
+ # Perform the download synchronously
177
+ final_file_path = perform_download(ydl_opts, str(payload.url), file_path_stem)
178
+ final_filename = final_file_path.name
 
 
 
 
 
 
 
179
 
180
+ # Construct the full download URL using request base URL
181
+ # request.base_url gives http://<host>:<port>/
182
+ # We need to append the static path 'downloads' and the filename
183
+ download_url = f"{str(request.base_url).rstrip('/')}/downloads/{final_filename}"
184
 
185
+ logger.info(f"Audio download complete for {payload.url}. URL: {download_url}")
186
+ return DownloadResponse(download_url=download_url, filename=final_filename)
 
 
 
 
187
 
188
  except Exception as e:
189
+ logger.error(f"Audio download failed for {payload.url}: {e}", exc_info=True)
190
+ raise HTTPException(status_code=500, detail=f"Audio download failed: {str(e)}")
191
+
192
+
193
+ @app.post(
194
+ "/max",
195
+ response_model=DownloadResponse,
196
+ responses={400: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}
197
+ )
198
+ async def download_video_max_quality(request: Request, payload: MaxDownloadRequest = Body(...)):
199
+ """
200
+ Downloads the video in the specified quality (e.g., '1080p', '720p') or 'best' available.
201
+ Merges video and audio into an MP4 container.
202
+ """
203
+ logger.info(f"Received /max (video) request for URL: {payload.url} with quality: {payload.quality}")
204
+
205
+ # Generate unique filename components
206
+ unique_id = str(uuid.uuid4())
207
+ # Define the base path without extension
208
+ file_path_stem = DOWNLOAD_DIR / unique_id
209
+
210
+ # --- Determine yt-dlp Format Selector based on Quality ---
211
+ quality = payload.quality.lower() if payload.quality else 'best'
212
+ if quality == 'best':
213
+ format_selector = 'bestvideo+bestaudio/best' # Best video and audio, merged if possible
214
+ elif quality.endswith('p'):
215
+ try:
216
+ height = int(quality[:-1]) # Extract height like 1080 from '1080p'
217
+ # Select best video up to specified height + best audio, fallback to best overall up to height
218
+ format_selector = f'bestvideo[height<={height}]+bestaudio/best[height<={height}]'
219
+ except ValueError:
220
+ logger.warning(f"Invalid quality format: {payload.quality}. Falling back to 'best'.")
221
+ format_selector = 'bestvideo+bestaudio/best'
222
+ else:
223
+ logger.warning(f"Unrecognized quality value: {payload.quality}. Falling back to 'best'.")
224
+ format_selector = 'bestvideo+bestaudio/best'
225
+
226
+ logger.info(f"Using format selector: '{format_selector}'")
227
+
228
+ # --- yt-dlp Options for Video Download ---
229
+ ydl_opts = {
230
+ 'format': format_selector,
231
+ 'outtmpl': str(file_path_stem.with_suffix('.%(ext)s')), # Output filename template
232
+ 'merge_output_format': 'mp4', # Merge into MP4 container if separate streams are downloaded
233
+ 'noplaylist': True,
234
+ 'quiet': False,
235
+ 'progress_hooks': [lambda d: logger.debug(f"Download progress: {d['status']} - {d.get('_percent_str', '')}")],
236
+ }
237
+ if os.path.exists(COOKIE_FILE):
238
+ ydl_opts['cookiefile'] = COOKIE_FILE
239
+ logger.info("Using cookie file for video download.")
240
+ else:
241
+ logger.warning(f"Cookie file '{COOKIE_FILE}' not found for video download.")
242
+
243
+ try:
244
+ # Perform the download synchronously
245
+ final_file_path = perform_download(ydl_opts, str(payload.url), file_path_stem)
246
+ final_filename = final_file_path.name
247
+
248
+ # Construct the full download URL
249
+ download_url = f"{str(request.base_url).rstrip('/')}/downloads/{final_filename}"
250
+
251
+ logger.info(f"Video download complete for {payload.url}. URL: {download_url}")
252
+ return DownloadResponse(download_url=download_url, filename=final_filename)
253
+
254
+ except Exception as e:
255
+ logger.error(f"Video download failed for {payload.url}: {e}", exc_info=True)
256
+ raise HTTPException(status_code=500, detail=f"Video download failed: {str(e)}")
257
+
258
+ # --- Optional: Add cleanup for old files (Example using BackgroundTasks) ---
259
+ # This is a basic example; a more robust solution might involve timestamps or a dedicated cleanup script.
260
+ async def cleanup_old_files(directory: Path, max_age_seconds: int):
261
+ """Removes files older than max_age_seconds in the background."""
262
+ import time
263
+ now = time.time()
264
+ count = 0
265
+ try:
266
+ for item in directory.iterdir():
267
+ if item.is_file():
268
+ try:
269
+ if now - item.stat().st_mtime > max_age_seconds:
270
+ os.remove(item)
271
+ logger.info(f"Cleaned up old file: {item.name}")
272
+ count += 1
273
+ except OSError as e:
274
+ logger.error(f"Error removing file {item}: {e}")
275
+ if count > 0:
276
+ logger.info(f"Background cleanup finished. Removed {count} old files.")
277
+ else:
278
+ logger.info("Background cleanup finished. No old files found.")
279
+
280
+ except Exception as e:
281
+ logger.error(f"Error during background file cleanup: {e}", exc_info=True)
282
+
283
+
284
+ @app.post("/trigger-cleanup")
285
+ async def trigger_cleanup(background_tasks: BackgroundTasks):
286
+ """Manually trigger a cleanup of files older than 1 day."""
287
+ logger.info("Triggering background cleanup of old download files.")
288
+ # Clean files older than 1 day (86400 seconds)
289
+ background_tasks.add_task(cleanup_old_files, DOWNLOAD_DIR, 86400)
290
+ return {"message": "Background cleanup task scheduled."}
291
+
292
+
293
+ # --- How to Run (using uvicorn) ---
294
+ # Save this code as main.py
295
+ # Install dependencies: pip install fastapi uvicorn yt-dlp ffmpeg-python pydantic[email] python-multipart requests
296
+ # (Note: ffmpeg needs to be installed on your system for audio/video processing)
297
+ # Run from terminal: uvicorn main:app --reload
298
+ #
299
+ # You will also need a 'www.youtube.com_cookies.txt' file in the same directory
300
+ # if you need to download age-restricted or private videos.
301
+ ```
302
+
303
+ **Key Changes and Features:**
304
+
305
+ 1. **FastAPI Framework:** Uses `FastAPI` instead of `Flask`.
306
+ 2. **Pydantic Models:** `UrlRequest`, `MaxDownloadRequest`, `InfoResponse`, `DownloadResponse`, `ErrorResponse` are used for automatic request data validation and response structuring. `HttpUrl` ensures URL validity.
307
+ 3. **Async Endpoints:** Endpoints are defined with `async def` (though the core `yt-dlp` download is still synchronous in this version).
308
+ 4. **Static File Serving:** `StaticFiles` is used to serve the `downloads` directory under the `/downloads` path.
309
+ 5. **Pathlib:** Uses `pathlib.Path` for cleaner path manipulation.
310
+ 6. **Error Handling:** Uses `HTTPException` for standard HTTP errors.
311
+ 7. **Logging:** Basic logging is added to track requests and potential errors.
312
+ 8. **/max Endpoint:**
313
+ * Accepts a `quality` parameter (e.g., "1080p", "720p", defaults to "best").
314
+ * Constructs an appropriate `yt-dlp` format selector string based on the requested quality.
315
+ * Uses `merge_output_format='mp4'` to ensure video and audio are combined into a standard MP4 file when possible (requires `ffmpeg`).
316
+ 9. **Download URL Construction:** Uses `request.base_url` to correctly build the absolute download URL.
317
+ 10. **Helper Function:** A `perform_download` helper function encapsulates the synchronous `yt-dlp` download logic.
318
+ 11. **Cookie File:** Checks for the existence of `www.youtube.com_cookies.txt` and uses it if found.
319
+ 12. **Cleanup (Optional):** Includes an example `/trigger-cleanup` endpoint using `BackgroundTasks` to demonstrate how you might clean up old downloaded files asynchronously.
320
+
321
+ **To Run This Code:**
322
 
323
+ 1. **Save:** Save the code as a Python file (e.g., `main.py`).
324
+ 2. **Install Dependencies:**
325
+ ```bash
326
+ pip install "fastapi[all]" uvicorn yt-dlp pydantic requests
327
+ ```
328
+ * `fastapi[all]` includes `uvicorn` and other useful dependencies.
329
+ * **Important:** `yt-dlp` often relies on **FFmpeg** for merging formats and converting audio. Make sure FFmpeg is installed on your system and accessible in your PATH. You can usually install it via your system's package manager (e.g., `apt install ffmpeg`, `brew install ffmpeg`).
330
+ 3. **Cookie File (Optional):** If you need to download age-restricted or member-only content, place a valid `www.youtube.com_cookies.txt` file (in Netscape format) in the same directory as `main.py`. You can get this using browser extensions like "Get cookies.txt".
331
+ 4. **Run the Server:**
332
+ ```bash
333
+ uvicorn main:app --reload
334
+ ```
335
+ * `uvicorn` is the ASGI server.
336
+ * `main:app` tells it to find the `app` instance in the `main.py` file.
337
+ * `--reload` automatically restarts the server when you save changes (useful for development).
338
 
339
+ You can then access the API at `http://127.0.0.1:8000` and the interactive documentation at `http://127.0.0.1:8000/doc