Spaces:
Sleeping
Sleeping
File size: 15,377 Bytes
a412233 7a32389 a412233 7a32389 a412233 7a32389 a412233 5febf46 a412233 7a32389 a412233 7a32389 a412233 7a32389 a412233 00b78aa a412233 5febf46 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 | """
Video Download Middleware API
==============================
Flow:
1. POST /download → triggers GitHub Action → returns job_id
2. GET /status/{job_id} → returns pending / processing / done / failed
3. GET /file/{job_id} → streams the video file (only when done)
n8n webhooks:
- POST /n8n/callback → n8n calls this when the video is ready (from "downlode video1" node)
"""
import os
import uuid
import time
import asyncio
import logging
from pathlib import Path
from typing import Optional
import httpx
import aiofiles
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel
# ─── Config ────────────────────────────────────────────────────────────────────
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "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")
GITHUB_REPO = os.getenv("GITHUB_REPO", "expher510/AutoClip-Pipeline")
WORKFLOW_FILE = os.getenv("WORKFLOW_FILE", "video-download.yml")
# Public URL of THIS api (so n8n can call back)
# e.g. https://my-api.hf.space or https://xxxxx.ngrok.io
PUBLIC_URL = os.getenv("PUBLIC_URL", "https://your-api-public-url.com")
# اختياري - لو موجود الـ API هو اللي يشغل n8n
# لو None، n8n هو اللي يبعت callback للـ API
N8N_TRIGGER_URL = os.getenv("N8N_TRIGGER_URL", None)
VIDEOS_DIR = Path("videos")
VIDEOS_DIR.mkdir(exist_ok=True)
# ─── In-memory job store ────────────────────────────────────────────────────────
# job_id → {status, filename, file_path, video_url, error, created_at, run_id}
jobs: dict[str, dict] = {}
# ─── App ───────────────────────────────────────────────────────────────────────
app = FastAPI(
title="EGDownloader API",
description="Bridges client ↔ n8n/GitHub Action video pipeline",
version="1.0.0",
docs_url="/",
redoc_url="/redoc",
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
# ══════════════════════════════════════════════════════════════════════════════
# 1. REQUEST DOWNLOAD
# ══════════════════════════════════════════════════════════════════════════════
class DownloadRequest(BaseModel):
url: str
cookies_txt: Optional[str] = "" # optional – paste Netscape cookie text
@app.post("/download/shorts", summary="Start a YouTube Shorts download job")
async def download_shorts(body: DownloadRequest):
"""
نفس /download بالظبط، بس بيحوّل Shorts URL لـ watch URL الأول.
https://youtube.com/shorts/VIDEO_ID → https://youtube.com/watch?v=VIDEO_ID
"""
if "/shorts/" in body.url:
video_id = body.url.split("/shorts/")[1].split("?")[0]
body.url = f"https://www.youtube.com/watch?v={video_id}"
return await start_download(body)
@app.post("/download", summary="Start a video download job")
async def start_download(body: DownloadRequest):
"""
طريقتين للشغل:
1) Push mode (الـ default - اللي عندك دلوقتي):
N8N_TRIGGER_URL = None
n8n هو اللي يشغل GitHub Action وبعدين يبعت callback على /n8n/callback/{job_id}
الـ client بس يعمل polling على /status/{job_id}
2) Trigger mode:
N8N_TRIGGER_URL = "https://..."
الـ API هو اللي يبعت trigger لـ n8n، وn8n يرد بـ callback
"""
job_id = str(uuid.uuid4())
jobs[job_id] = {
"status": "pending",
"video_url": body.url,
"filename": None,
"file_path": None,
"run_id": None,
"error": None,
"created_at": time.time(),
"mode": "trigger" if N8N_TRIGGER_URL else "push",
}
# ── Trigger mode: الـ API يشغل n8n ────────────────────────────────────────
if N8N_TRIGGER_URL:
payload = {
"url": body.url,
"cookies_txt": body.cookies_txt or "",
"n8n_webhook": f"{PUBLIC_URL}/n8n/callback/{job_id}",
}
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.post(N8N_TRIGGER_URL, json=payload)
r.raise_for_status()
jobs[job_id]["status"] = "processing"
log.info(f"[{job_id}] triggered n8n → {r.status_code}")
except Exception as exc:
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = str(exc)
log.error(f"[{job_id}] failed to trigger n8n: {exc}")
raise HTTPException(status_code=502, detail=f"Could not trigger pipeline: {exc}")
# ── Push mode: n8n هو اللي هيبعت callback لما يخلص ───────────────────────
else:
jobs[job_id]["status"] = "pending"
log.info(f"[{job_id}] job created (push mode) - waiting for n8n callback")
return {
"job_id": job_id,
"status": jobs[job_id]["status"],
"mode": jobs[job_id]["mode"],
"callback_url": f"{PUBLIC_URL}/n8n/callback/{job_id}",
}
# ══════════════════════════════════════════════════════════════════════════════
# 2. POLL STATUS
# ══════════════════════════════════════════════════════════════════════════════
@app.get("/status/{job_id}", summary="Poll download status")
async def get_status(job_id: str):
"""
Returns one of: pending | processing | done | failed
When done, also returns filename & download_url.
"""
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
resp = {
"job_id": job_id,
"status": job["status"],
"video_url": job["video_url"],
"created_at": job["created_at"],
}
if job["status"] == "done":
resp["filename"] = job["filename"]
resp["download_url"] = f"{PUBLIC_URL}/file/{job_id}"
if job["status"] == "failed":
resp["error"] = job["error"]
return resp
# ══════════════════════════════════════════════════════════════════════════════
# 3. DOWNLOAD FILE
# ══════════════════════════════════════════════════════════════════════════════
@app.get("/file/{job_id}", summary="Download the finished video")
async def download_file(job_id: str):
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job["status"] != "done":
raise HTTPException(status_code=425, detail=f"Not ready yet – status: {job['status']}")
file_path: Path = job["file_path"]
if not file_path or not file_path.exists():
raise HTTPException(status_code=410, detail="File no longer available on server")
filename = job["filename"] or file_path.name
async def file_streamer():
async with aiofiles.open(file_path, "rb") as f:
while chunk := await f.read(1024 * 512): # 512 KB chunks
yield chunk
return StreamingResponse(
file_streamer(),
media_type="video/mp4",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ══════════════════════════════════════════════════════════════════════════════
# 4. n8n CALLBACK (n8n POSTs the finished binary here)
# This replaces the "downlode video1" webhook that was in n8n
# ══════════════════════════════════════════════════════════════════════════════
@app.post("/n8n/callback/{job_id}", include_in_schema=False)
async def n8n_callback(job_id: str, request: Request):
"""
n8n calls this endpoint (instead of its own 'downlode video1' webhook).
It sends either:
a) A JSON body with {status, run_id, filename, video_url} → we then
fetch the artifact from GitHub ourselves, OR
b) Raw binary (the video file) directly in the body.
"""
job = jobs.get(job_id)
if not job:
log.warning(f"[{job_id}] callback for unknown job")
return {"ok": False, "reason": "unknown job"}
content_type = request.headers.get("content-type", "")
# ── Case A: JSON notification (status + run_id) ──────────────────────────
if "application/json" in content_type:
data = await request.json()
log.info(f"[{job_id}] JSON callback: {data}")
if data.get("status") == "success":
run_id = data.get("run_id")
filename = data.get("filename", f"{job_id}.mp4")
jobs[job_id]["run_id"] = run_id
jobs[job_id]["filename"] = filename
# Fetch the artifact binary from GitHub in the background
asyncio.create_task(_fetch_github_artifact(job_id, run_id, filename))
else:
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = data.get("error", "n8n reported failure")
return {"ok": True}
# ── Case B: Raw binary video ──────────────────────────────────────────────
body = await request.body()
if not body:
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = "empty callback body"
return {"ok": False}
filename = request.headers.get("x-filename", f"{job_id}.mp4")
file_path = VIDEOS_DIR / f"{job_id}_{filename}"
async with aiofiles.open(file_path, "wb") as f:
await f.write(body)
jobs[job_id]["status"] = "done"
jobs[job_id]["filename"] = filename
jobs[job_id]["file_path"] = file_path
log.info(f"[{job_id}] saved {file_path} ({len(body)/1024/1024:.1f} MB)")
return {"ok": True}
# ══════════════════════════════════════════════════════════════════════════════
# 5. BACKGROUND: fetch artifact from GitHub (used in Case A above)
# ══════════════════════════════════════════════════════════════════════════════
async def _fetch_github_artifact(job_id: str, run_id: str, filename: str):
"""Polls GitHub until the artifact is ready, then downloads it."""
artifacts_url = f"https://api.github.com/repos/{GITHUB_REPO}/actions/runs/{run_id}/artifacts"
headers = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
}
for attempt in range(20): # up to ~5 minutes
await asyncio.sleep(15)
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(artifacts_url, headers=headers)
r.raise_for_status()
artifacts = r.json().get("artifacts", [])
if not artifacts:
log.info(f"[{job_id}] no artifacts yet (attempt {attempt+1})")
continue
artifact = artifacts[0]
dl_url = artifact["archive_download_url"]
# Download the zip (GitHub wraps artifacts in a zip)
async with httpx.AsyncClient(
timeout=300,
follow_redirects=True,
headers=headers,
) as client:
resp = await client.get(dl_url)
resp.raise_for_status()
# Save & unzip
zip_path = VIDEOS_DIR / f"{job_id}.zip"
async with aiofiles.open(zip_path, "wb") as f:
await f.write(resp.content)
# Extract the video
import zipfile
with zipfile.ZipFile(zip_path, "r") as z:
names = z.namelist()
video_name = next((n for n in names if n.endswith(".mp4")), names[0])
z.extract(video_name, VIDEOS_DIR)
extracted = VIDEOS_DIR / video_name
zip_path.unlink(missing_ok=True)
jobs[job_id]["status"] = "done"
jobs[job_id]["filename"] = video_name
jobs[job_id]["file_path"] = extracted
log.info(f"[{job_id}] artifact saved → {extracted}")
return
except Exception as exc:
log.error(f"[{job_id}] artifact fetch error: {exc}")
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = "artifact never became available"
# ══════════════════════════════════════════════════════════════════════════════
# 6. EXTRA UTILS
# ══════════════════════════════════════════════════════════════════════════════
@app.get("/jobs", summary="List all jobs (debug)")
async def list_jobs():
return {
jid: {k: v for k, v in j.items() if k != "file_path"}
for jid, j in jobs.items()
}
@app.delete("/job/{job_id}", summary="Delete a finished job and its file")
async def delete_job(job_id: str):
job = jobs.pop(job_id, None)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
if job.get("file_path") and Path(job["file_path"]).exists():
Path(job["file_path"]).unlink()
return {"deleted": job_id}
@app.get("/health")
async def health():
return {"status": "ok", "jobs": len(jobs)} |