tecuts commited on
Commit
7c8af0f
·
verified ·
1 Parent(s): b25eb8b

Create app.py

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