tecuts commited on
Commit
92ee62a
·
verified ·
1 Parent(s): 1fe7ee6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +39 -99
app.py CHANGED
@@ -13,40 +13,28 @@ from fastapi.staticfiles import StaticFiles
13
  from yt_dlp import YoutubeDL
14
 
15
  # --- Basic Configuration ---
16
- # 1. Set up logging
17
  logging.basicConfig(
18
  level=logging.INFO,
19
  format="%(asctime)s - %(levelname)s - %(message)s",
20
  handlers=[logging.StreamHandler()]
21
  )
22
-
23
- # 2. Get the Base URL for the Info API from secrets
24
  BASE_URL = os.getenv("BASE_URL")
25
-
26
- # 3. Define and create directories for temporary and public files
27
  TEMP_DIR = Path("/tmp/downloads")
28
  STATIC_DIR = Path("static")
29
  TEMP_DIR.mkdir(exist_ok=True)
30
  STATIC_DIR.mkdir(exist_ok=True)
31
-
32
- # 4. Cleanup configuration
33
- FILE_LIFETIME_SECONDS = 900 # 15 minutes
34
 
35
  # --- FastAPI App Initialization ---
36
  app = FastAPI(
37
- title="Video Processing API v2",
38
- description="Generates a temporary download link for a merged YouTube video."
39
  )
40
-
41
- # Mount the 'static' directory to serve files publicly
42
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
43
 
44
 
45
  # --- Helper Functions ---
46
  async def cleanup_file(filepath: Path):
47
- """
48
- Waits for a specified time and then deletes the file and its parent directory.
49
- """
50
  await asyncio.sleep(FILE_LIFETIME_SECONDS)
51
  try:
52
  if filepath.parent.exists():
@@ -55,135 +43,90 @@ async def cleanup_file(filepath: Path):
55
  except Exception as e:
56
  logging.error(f"Error during cleanup of {filepath.parent}: {e}")
57
 
58
-
59
  def get_best_formats_with_fallback(data: dict, requested_quality: int):
60
- """
61
- Parses the Info API response to find the best matching video format
62
- with a fallback, and the best audio format.
63
- """
64
  if "formats" not in data:
65
  raise ValueError("The 'formats' key is missing from the Info API response.")
66
-
67
- # --- Video Selection with Fallback ---
68
- video_url = None
69
- # Filter for video-only, mp4 formats with a height attribute
70
- video_formats = [
71
- f for f in data["formats"]
72
- if f.get("vcodec") != "none" and f.get("acodec") == "none" and f.get("ext") == "mp4" and f.get("height")
73
- ]
74
  video_formats.sort(key=lambda f: f["height"], reverse=True)
75
-
76
  for f in video_formats:
77
  if f["height"] <= requested_quality:
78
- video_url = f["url"]
79
- logging.info(f"Selected video quality: {f['height']}p (requested <= {requested_quality}p)")
80
- break
81
-
82
  if not video_url and video_formats:
83
- f = video_formats[-1]
84
- video_url = f["url"]
85
- logging.warning(f"Requested quality not available. Falling back to lowest available: {f['height']}p")
86
-
87
- # --- Audio Selection (Revised and Improved) ---
88
- audio_url = None
89
- # Find all streams that are audio-only
90
- audio_formats = [
91
- f for f in data.get("formats", [])
92
- if f.get("acodec") not in (None, "none") and f.get("vcodec") == "none"
93
- ]
94
-
95
- # Sort by audio bitrate (abr) to find the best quality
96
  if audio_formats:
97
  audio_formats.sort(key=lambda f: f.get("abr", 0) or 0, reverse=True)
98
- selected_audio = audio_formats[0]
99
- audio_url = selected_audio.get("url")
100
- logging.info(
101
- f"Selected best audio: format_id {selected_audio.get('format_id')}, "
102
- f"bitrate {selected_audio.get('abr', 'N/A')}k, "
103
- f"codec {selected_audio.get('acodec')}"
104
- )
105
-
106
- # --- Final Check ---
107
  if not video_url or not audio_url:
108
- # Add more detailed logging for easier debugging
109
- if not video_url:
110
- logging.error("Failed to find a suitable video_url.")
111
- if not audio_url:
112
- logging.error("Failed to find a suitable audio_url.")
113
  raise ValueError("Could not find suitable video and/or audio streams from the Info API.")
114
-
115
  return video_url, audio_url
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
  # --- API Endpoints ---
119
  @app.get("/")
120
  def read_root():
121
- return {"message": "Video Processing API v2 is running."}
122
-
123
 
124
  @app.post("/api/process")
125
  async def process_video(request: Request, background_tasks: BackgroundTasks):
126
- """
127
- Takes a URL and quality, processes the video, and returns a temporary download link.
128
- """
129
  if not BASE_URL:
130
  logging.error("FATAL: BASE_URL is not configured in the server environment.")
131
  raise HTTPException(status_code=500, detail="Server is not configured correctly.")
132
-
133
  body = await request.json()
134
  video_url = body.get("url")
135
  try:
136
- # Use a default quality of 1080p if not provided
137
  quality = int(body.get("quality", "1080"))
138
  except (ValueError, TypeError):
139
  raise HTTPException(status_code=400, detail="'quality' must be a valid number (e.g., 1080, 720).")
140
-
141
  if not video_url:
142
  raise HTTPException(status_code=400, detail="A 'url' is required.")
143
-
144
  logging.info(f"Received request for URL: {video_url} with quality: {quality}p")
145
-
146
- # --- Step 1: Call the Info API ---
147
- info_api_url = f"{BASE_URL}/api/info"
148
- params = {"url": video_url}
149
- # ... inside the process_video function
150
  try:
151
- # The fix is to add `follow_redirects=True` here
152
  async with httpx.AsyncClient(follow_redirects=True) as client:
153
- logging.info(f"Calling Info API: {info_api_url}")
154
- response = await client.get(info_api_url, params=params, timeout=30.0)
155
- response.raise_for_status()
156
- video_data = response.json()
157
  response.raise_for_status()
158
  video_data = response.json()
159
  video_stream_url, audio_stream_url = get_best_formats_with_fallback(video_data, quality)
160
- except httpx.RequestError as e:
161
- logging.error(f"Info API connection failed: {e}")
162
- raise HTTPException(status_code=502, detail=f"Failed to connect to the Info API: {e}")
163
  except Exception as e:
164
  logging.error(f"Error processing video info: {e}")
165
  raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {e}")
166
 
167
- # --- Step 2: Download & Merge ---
168
  task_id = str(uuid.uuid4())
169
- # The final file will be in a public-facing static directory
170
  final_output_dir = STATIC_DIR / task_id
171
  final_output_dir.mkdir()
172
  final_output_path = final_output_dir / "video.mp4"
173
-
174
- # Temporary paths for raw downloads
175
  video_path = TEMP_DIR / f"{task_id}_video.mp4"
176
  audio_path = TEMP_DIR / f"{task_id}_audio.m4a"
177
 
178
  try:
179
- logging.info(f"Starting download for task {task_id}")
180
- # Using yt-dlp to download is more robust for complex URLs
181
- ydl_opts_video = {'outtmpl': str(video_path)}
182
- ydl_opts_audio = {'outtmpl': str(audio_path)}
183
- with YoutubeDL(ydl_opts_video) as ydl:
184
- ydl.download([video_stream_url])
185
- with YoutubeDL(ydl_opts_audio) as ydl:
186
- ydl.download([audio_stream_url])
 
 
187
 
188
  logging.info(f"Download complete. Starting FFmpeg merge for task {task_id}")
189
  subprocess.run(
@@ -194,17 +137,14 @@ async def process_video(request: Request, background_tasks: BackgroundTasks):
194
 
195
  except Exception as e:
196
  logging.error(f"Download or Merge Failed for task {task_id}: {e}")
197
- shutil.rmtree(final_output_dir) # Clean up public dir on failure
198
  raise HTTPException(status_code=500, detail=f"Failed during file processing: {e}")
199
  finally:
200
- # Clean up raw downloads from /tmp
201
  if video_path.exists(): video_path.unlink()
202
  if audio_path.exists(): audio_path.unlink()
203
 
204
- # --- Step 3: Generate Download URL and Schedule Cleanup ---
205
  download_url = request.url_for('static', path=f"{task_id}/video.mp4")
206
  background_tasks.add_task(cleanup_file, final_output_path)
207
-
208
  logging.info(f"Responding with download URL: {download_url}")
209
  return JSONResponse(
210
  content={
 
13
  from yt_dlp import YoutubeDL
14
 
15
  # --- Basic Configuration ---
 
16
  logging.basicConfig(
17
  level=logging.INFO,
18
  format="%(asctime)s - %(levelname)s - %(message)s",
19
  handlers=[logging.StreamHandler()]
20
  )
 
 
21
  BASE_URL = os.getenv("BASE_URL")
 
 
22
  TEMP_DIR = Path("/tmp/downloads")
23
  STATIC_DIR = Path("static")
24
  TEMP_DIR.mkdir(exist_ok=True)
25
  STATIC_DIR.mkdir(exist_ok=True)
26
+ FILE_LIFETIME_SECONDS = 900
 
 
27
 
28
  # --- FastAPI App Initialization ---
29
  app = FastAPI(
30
+ title="Video Processing API v3",
31
+ description="Generates a temporary download link for a merged YouTube video, optimized for speed."
32
  )
 
 
33
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
34
 
35
 
36
  # --- Helper Functions ---
37
  async def cleanup_file(filepath: Path):
 
 
 
38
  await asyncio.sleep(FILE_LIFETIME_SECONDS)
39
  try:
40
  if filepath.parent.exists():
 
43
  except Exception as e:
44
  logging.error(f"Error during cleanup of {filepath.parent}: {e}")
45
 
 
46
  def get_best_formats_with_fallback(data: dict, requested_quality: int):
 
 
 
 
47
  if "formats" not in data:
48
  raise ValueError("The 'formats' key is missing from the Info API response.")
49
+ video_url, audio_url = None, None
50
+ video_formats = [f for f in data["formats"] if f.get("vcodec") != "none" and f.get("acodec") == "none" and f.get("ext") == "mp4" and f.get("height")]
 
 
 
 
 
 
51
  video_formats.sort(key=lambda f: f["height"], reverse=True)
 
52
  for f in video_formats:
53
  if f["height"] <= requested_quality:
54
+ video_url = f["url"]; logging.info(f"Selected video quality: {f['height']}p (requested <= {requested_quality}p)"); break
 
 
 
55
  if not video_url and video_formats:
56
+ f = video_formats[-1]; video_url = f["url"]; logging.warning(f"Requested quality not available. Falling back to lowest available: {f['height']}p")
57
+ audio_formats = [f for f in data.get("formats", []) if f.get("acodec") not in (None, "none") and f.get("vcodec") == "none"]
 
 
 
 
 
 
 
 
 
 
 
58
  if audio_formats:
59
  audio_formats.sort(key=lambda f: f.get("abr", 0) or 0, reverse=True)
60
+ selected_audio = audio_formats[0]; audio_url = selected_audio.get("url")
61
+ logging.info(f"Selected best audio: format_id {selected_audio.get('format_id')}, bitrate {selected_audio.get('abr', 'N/A')}k, codec {selected_audio.get('acodec')}")
 
 
 
 
 
 
 
62
  if not video_url or not audio_url:
63
+ if not video_url: logging.error("Failed to find a suitable video_url.")
64
+ if not audio_url: logging.error("Failed to find a suitable audio_url.")
 
 
 
65
  raise ValueError("Could not find suitable video and/or audio streams from the Info API.")
 
66
  return video_url, audio_url
67
 
68
+ # This is a synchronous helper function for downloading
69
+ def run_ytdlp_download(url: str, out_path: Path):
70
+ """
71
+ Runs a single yt-dlp download. This is a blocking operation.
72
+ """
73
+ ydl_opts = {
74
+ 'outtmpl': str(out_path),
75
+ # OPTIMIZATION: These options silence the percentage-based download logs
76
+ 'quiet': True,
77
+ 'noprogress': True,
78
+ }
79
+ with YoutubeDL(ydl_opts) as ydl:
80
+ ydl.download([url])
81
 
82
  # --- API Endpoints ---
83
  @app.get("/")
84
  def read_root():
85
+ return {"message": "Video Processing API v3 is running."}
 
86
 
87
  @app.post("/api/process")
88
  async def process_video(request: Request, background_tasks: BackgroundTasks):
 
 
 
89
  if not BASE_URL:
90
  logging.error("FATAL: BASE_URL is not configured in the server environment.")
91
  raise HTTPException(status_code=500, detail="Server is not configured correctly.")
 
92
  body = await request.json()
93
  video_url = body.get("url")
94
  try:
 
95
  quality = int(body.get("quality", "1080"))
96
  except (ValueError, TypeError):
97
  raise HTTPException(status_code=400, detail="'quality' must be a valid number (e.g., 1080, 720).")
 
98
  if not video_url:
99
  raise HTTPException(status_code=400, detail="A 'url' is required.")
 
100
  logging.info(f"Received request for URL: {video_url} with quality: {quality}p")
 
 
 
 
 
101
  try:
 
102
  async with httpx.AsyncClient(follow_redirects=True) as client:
103
+ info_api_url = f"{BASE_URL.rstrip('/')}/api/info"
104
+ response = await client.get(info_api_url, params={"url": video_url, "playlist": "false"}, timeout=30.0)
 
 
105
  response.raise_for_status()
106
  video_data = response.json()
107
  video_stream_url, audio_stream_url = get_best_formats_with_fallback(video_data, quality)
 
 
 
108
  except Exception as e:
109
  logging.error(f"Error processing video info: {e}")
110
  raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {e}")
111
 
 
112
  task_id = str(uuid.uuid4())
 
113
  final_output_dir = STATIC_DIR / task_id
114
  final_output_dir.mkdir()
115
  final_output_path = final_output_dir / "video.mp4"
 
 
116
  video_path = TEMP_DIR / f"{task_id}_video.mp4"
117
  audio_path = TEMP_DIR / f"{task_id}_audio.m4a"
118
 
119
  try:
120
+ # --- SPEED OPTIMIZATION ---
121
+ # Run the two downloads concurrently instead of one after the other.
122
+ logging.info(f"Starting concurrent download for task {task_id}")
123
+
124
+ # Create two tasks to run our synchronous download function in separate threads
125
+ video_download_task = asyncio.to_thread(run_ytdlp_download, video_stream_url, video_path)
126
+ audio_download_task = asyncio.to_thread(run_ytdlp_download, audio_stream_url, audio_path)
127
+
128
+ # Wait for both downloads to complete
129
+ await asyncio.gather(video_download_task, audio_download_task)
130
 
131
  logging.info(f"Download complete. Starting FFmpeg merge for task {task_id}")
132
  subprocess.run(
 
137
 
138
  except Exception as e:
139
  logging.error(f"Download or Merge Failed for task {task_id}: {e}")
140
+ shutil.rmtree(final_output_dir)
141
  raise HTTPException(status_code=500, detail=f"Failed during file processing: {e}")
142
  finally:
 
143
  if video_path.exists(): video_path.unlink()
144
  if audio_path.exists(): audio_path.unlink()
145
 
 
146
  download_url = request.url_for('static', path=f"{task_id}/video.mp4")
147
  background_tasks.add_task(cleanup_file, final_output_path)
 
148
  logging.info(f"Responding with download URL: {download_url}")
149
  return JSONResponse(
150
  content={