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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +93 -83
app.py CHANGED
@@ -5,35 +5,38 @@ 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
  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:
@@ -44,16 +47,14 @@ async def cleanup_file(filepath: Path):
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)
@@ -65,91 +66,100 @@ def get_best_formats_with_fallback(data: dict, requested_quality: int):
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(
133
  ['ffmpeg', '-i', str(video_path), '-i', str(audio_path), '-c', 'copy', str(final_output_path)],
134
  check=True, capture_output=True, text=True
135
  )
136
- logging.info(f"Merge successful. Final file at: {final_output_path}")
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={
151
- "success": True,
152
- "download_url": str(download_url),
153
  "expires_in": f"{FILE_LIFETIME_SECONDS} seconds"
154
  }
155
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import logging
6
  import asyncio
7
  from pathlib import Path
8
+ from typing import Dict
9
 
10
  import httpx
11
+ from fastapi import FastAPI, HTTPException, Request, BackgroundTasks, status
12
  from fastapi.responses import JSONResponse
13
  from fastapi.staticfiles import StaticFiles
14
  from yt_dlp import YoutubeDL
15
 
16
  # --- Basic Configuration ---
17
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
 
 
 
 
18
  BASE_URL = os.getenv("BASE_URL")
19
  TEMP_DIR = Path("/tmp/downloads")
20
  STATIC_DIR = Path("static")
21
  TEMP_DIR.mkdir(exist_ok=True)
22
  STATIC_DIR.mkdir(exist_ok=True)
23
+ FILE_LIFETIME_SECONDS = 1800 # 30 minutes
24
+
25
+ # --- In-memory store for task statuses ---
26
+ # In a real-world, scalable application, you'd use a database like Redis or a message queue.
27
+ # For a Hugging Face Space, this simple dictionary is sufficient.
28
+ task_statuses: Dict[str, Dict] = {}
29
+
30
 
31
  # --- FastAPI App Initialization ---
32
  app = FastAPI(
33
+ title="Async Video Processor",
34
+ description="An API to process videos asynchronously without timeouts."
35
  )
36
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
37
 
38
 
39
+ # --- Helper Functions and Background Worker ---
40
  async def cleanup_file(filepath: Path):
41
  await asyncio.sleep(FILE_LIFETIME_SECONDS)
42
  try:
 
47
  logging.error(f"Error during cleanup of {filepath.parent}: {e}")
48
 
49
  def get_best_formats_with_fallback(data: dict, requested_quality: int):
50
+ # This function remains the same as before.
51
+ if "formats" not in data: raise ValueError("The 'formats' key is missing from the Info API response.")
52
  video_url, audio_url = None, None
53
  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")]
54
  video_formats.sort(key=lambda f: f["height"], reverse=True)
55
  for f in video_formats:
56
+ if f["height"] <= requested_quality: video_url = f["url"]; logging.info(f"Selected video quality: {f['height']}p (requested <= {requested_quality}p)"); break
57
+ if not video_url and video_formats: f = video_formats[-1]; video_url = f["url"]; logging.warning(f"Requested quality not available. Falling back to lowest available: {f['height']}p")
 
 
58
  audio_formats = [f for f in data.get("formats", []) if f.get("acodec") not in (None, "none") and f.get("vcodec") == "none"]
59
  if audio_formats:
60
  audio_formats.sort(key=lambda f: f.get("abr", 0) or 0, reverse=True)
 
66
  raise ValueError("Could not find suitable video and/or audio streams from the Info API.")
67
  return video_url, audio_url
68
 
 
69
  def run_ytdlp_download(url: str, out_path: Path):
70
+ ydl_opts = {'outtmpl': str(out_path), 'quiet': True, 'noprogress': True}
 
 
 
 
 
 
 
 
71
  with YoutubeDL(ydl_opts) as ydl:
72
  ydl.download([url])
73
 
74
+ async def process_in_background(
75
+ task_id: str,
76
+ video_url: str,
77
+ quality: int,
78
+ base_url_for_links: str,
79
+ background_tasks: BackgroundTasks
80
+ ):
81
+ """
82
+ This is the main worker function that runs in the background.
83
+ """
84
+ task_statuses[task_id] = {"status": "processing", "message": "Fetching video info..."}
 
 
 
 
 
 
 
 
85
  try:
86
+ # Step 1: Get video info
87
  async with httpx.AsyncClient(follow_redirects=True) as client:
88
  info_api_url = f"{BASE_URL.rstrip('/')}/api/info"
89
  response = await client.get(info_api_url, params={"url": video_url, "playlist": "false"}, timeout=30.0)
90
  response.raise_for_status()
91
  video_data = response.json()
92
  video_stream_url, audio_stream_url = get_best_formats_with_fallback(video_data, quality)
 
 
 
 
 
 
 
 
 
 
93
 
94
+ # Step 2: Download files
95
+ task_statuses[task_id]["message"] = "Downloading video and audio streams..."
96
+ final_output_dir = STATIC_DIR / task_id
97
+ final_output_dir.mkdir()
98
+ video_path = TEMP_DIR / f"{task_id}_video.mp4"
99
+ audio_path = TEMP_DIR / f"{task_id}_audio.m4a"
 
 
100
 
101
+ video_dl_task = asyncio.to_thread(run_ytdlp_download, video_stream_url, video_path)
102
+ audio_dl_task = asyncio.to_thread(run_ytdlp_download, audio_stream_url, audio_path)
103
+ await asyncio.gather(video_dl_task, audio_dl_task)
104
 
105
+ # Step 3: Merge files
106
+ task_statuses[task_id]["message"] = "Merging files with FFmpeg..."
107
+ final_output_path = final_output_dir / "video.mp4"
108
  subprocess.run(
109
  ['ffmpeg', '-i', str(video_path), '-i', str(audio_path), '-c', 'copy', str(final_output_path)],
110
  check=True, capture_output=True, text=True
111
  )
 
112
 
113
+ # Step 4: Finalize and set status to complete
114
+ download_url = f"{base_url_for_links.rstrip('/')}/static/{task_id}/video.mp4"
115
+ task_statuses[task_id] = {
116
+ "status": "complete",
117
+ "download_url": download_url,
 
 
 
 
 
 
 
 
 
 
118
  "expires_in": f"{FILE_LIFETIME_SECONDS} seconds"
119
  }
120
+ background_tasks.add_task(cleanup_file, final_output_path)
121
+
122
+ except Exception as e:
123
+ logging.error(f"Task {task_id} failed: {e}")
124
+ task_statuses[task_id] = {"status": "failed", "error": str(e)}
125
+ finally:
126
+ if 'video_path' in locals() and video_path.exists(): video_path.unlink()
127
+ if 'audio_path' in locals() and audio_path.exists(): audio_path.unlink()
128
+
129
+
130
+ # --- API Endpoints ---
131
+ @app.post("/api/start-processing", status_code=status.HTTP_202_ACCEPTED)
132
+ async def start_processing_job(request: Request, background_tasks: BackgroundTasks):
133
+ """
134
+ Accepts a job and starts it in the background. Returns a task ID immediately.
135
+ """
136
+ body = await request.json()
137
+ video_url = body.get("url")
138
+ quality = int(body.get("quality", "1080"))
139
+
140
+ if not video_url:
141
+ raise HTTPException(status_code=400, detail="A 'url' is required.")
142
+
143
+ task_id = str(uuid.uuid4())
144
+ task_statuses[task_id] = {"status": "queued"}
145
+
146
+ # We need the base URL of our own app to construct the final download link
147
+ base_url_for_links = str(request.base_url)
148
+
149
+ background_tasks.add_task(
150
+ process_in_background, task_id, video_url, quality, base_url_for_links, background_tasks
151
+ )
152
+
153
+ status_url = request.url_for('get_job_status', task_id=task_id)
154
+ return {"task_id": task_id, "status_url": str(status_url)}
155
+
156
+
157
+ @app.get("/api/status/{task_id}")
158
+ async def get_job_status(task_id: str):
159
+ """
160
+ Allows the client to poll for the status of a background job.
161
+ """
162
+ status_info = task_statuses.get(task_id)
163
+ if not status_info:
164
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
165
+ return status_info