alisaadhq commited on
Commit
a412233
·
verified ·
1 Parent(s): 18e14fb

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +17 -0
  2. README.md +120 -10
  3. main.py +313 -0
  4. requirements.txt +5 -0
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+ COPY requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+
7
+ COPY main.py .
8
+ RUN mkdir -p videos
9
+
10
+ # ── Env vars to set at runtime ──────────────────────────────────────────────
11
+ # PUBLIC_URL = https://your-api.hf.space (where n8n sends callbacks)
12
+ # GITHUB_TOKEN = ghp_...
13
+ # GITHUB_REPO = owner/repo
14
+ # N8N_TRIGGER_URL = https://egauto-n8n.hf.space/webhook/...
15
+
16
+ EXPOSE 7860
17
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,13 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: EGDownloader API
3
- emoji: 🏆
4
- colorFrom: gray
5
- colorTo: blue
6
- sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
- app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Video Download Middleware API
2
+
3
+ وسيط بين الـ client وبين n8n / GitHub Action pipeline.
4
+
5
+ ---
6
+
7
+ ## الـ Flow الكامل
8
+
9
+ ```
10
+ Client
11
+
12
+ ├─ POST /download {"url": "https://youtube.com/..."}
13
+ │ ↓
14
+ │ API يبعت trigger لـ n8n (webhook "run gethup action")
15
+ │ ↓
16
+ │ n8n يشغل GitHub Action
17
+ │ ↓
18
+ │ GitHub Action يحمّل الفيديو وبعدين يبعت POST على
19
+ │ /n8n/callback/{job_id} ← الـ API بيستقبله ويحفظ الفيديو
20
+
21
+ ├─ GET /status/{job_id} ← polling كل 5 ثواني مثلاً
22
+ │ ↓ لما status = "done"
23
+ └─ GET /file/{job_id} ← يجيب الفيديو
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Endpoints
29
+
30
+ | Method | Path | الوصف |
31
+ |--------|------|-------|
32
+ | `POST` | `/download` | ابدأ تحميل جديد |
33
+ | `GET` | `/status/{job_id}` | اعرف الحالة |
34
+ | `GET` | `/file/{job_id}` | حمّل الفيديو |
35
+ | `POST` | `/n8n/callback/{job_id}` | n8n بيبعت النتيجة هنا |
36
+ | `GET` | `/jobs` | قائمة كل الجوبز (للـ debug) |
37
+ | `DELETE` | `/job/{job_id}` | احذف جوب وملفه |
38
+ | `GET` | `/health` | health check |
39
+
40
+ ---
41
+
42
+ ## مثال كامل
43
+
44
+ ### 1. ابدأ التحميل
45
+ ```bash
46
+ curl -X POST https://your-api/download \
47
+ -H "Content-Type: application/json" \
48
+ -d '{"url": "https://www.youtube.com/watch?v=3Yw2SGumGQ8"}'
49
+
50
+ # Response:
51
+ # {"job_id": "abc-123", "status": "processing"}
52
+ ```
53
+
54
+ ### 2. اعمل polling
55
+ ```bash
56
+ curl https://your-api/status/abc-123
57
+
58
+ # بيرجع:
59
+ # {"job_id":"abc-123","status":"processing",...} ← لسه بيتحمل
60
+ # {"job_id":"abc-123","status":"done","download_url":"https://your-api/file/abc-123",...} ← خلص
61
+ ```
62
+
63
+ ### 3. حمّل الفيديو
64
+ ```bash
65
+ curl -L https://your-api/file/abc-123 -o video.mp4
66
+ ```
67
+
68
+ ---
69
+
70
+ ## كيف تربطه بـ n8n
71
+
72
+ في n8n، الـ node اللي كان بيبعت النتيجة (downlode video1) محتاج يبعت على:
73
+ ```
74
+ https://YOUR_API_URL/n8n/callback/{job_id}
75
+ ```
76
+
77
+ الـ `job_id` بيوصل لـ n8n في الـ inputs لما الـ API يشغل الـ workflow.
78
+
79
+ ### n8n يدعم حالتين:
80
+
81
+ **Case A – JSON (مجرد إشعار إن الأرتيفاكت جاهز):**
82
+ ```json
83
+ {
84
+ "status": "success",
85
+ "run_id": "25934033751",
86
+ "filename": "video.mp4"
87
+ }
88
+ ```
89
+ → الـ API هيجيب الأرتيفاكت من GitHub بنفسه.
90
+
91
+ **Case B – Raw binary (يبعت الفيديو نفسه):**
92
+ بس يبعت binary body + header:
93
+ ```
94
+ x-filename: video.mp4
95
+ Content-Type: video/mp4
96
+ ```
97
+
98
  ---
99
+
100
+ ## Environment Variables
101
+
102
+ ```env
103
+ PUBLIC_URL=https://your-api-public-url.com # مهم جداً عشان n8n يعرف يرد
104
+ GITHUB_TOKEN=ghp_...
105
+ GITHUB_REPO=expher510/AutoClip-Pipeline
106
+ WORKFLOW_FILE=video-download.yml
107
+ N8N_TRIGGER_URL=https://egauto-n8n.hf.space/webhook/run%20gethup%20action
108
+ ```
109
+
110
  ---
111
 
112
+ ## تشغيل محلي
113
+
114
+ ```bash
115
+ pip install -r requirements.txt
116
+ PUBLIC_URL=https://xxxx.ngrok.io uvicorn main:app --reload --port 8000
117
+ ```
118
+
119
+ ## تشغيل على Hugging Face Spaces (Docker)
120
+
121
+ 1. ارفع الملفات على Space جديد (Docker SDK)
122
+ 2. حط الـ env vars في Settings → Repository secrets
123
+ 3. الـ port هو `7860`
main.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Video Download Middleware API
3
+ ==============================
4
+ Flow:
5
+ 1. POST /download → triggers GitHub Action → returns job_id
6
+ 2. GET /status/{job_id} → returns pending / processing / done / failed
7
+ 3. GET /file/{job_id} → streams the video file (only when done)
8
+
9
+ n8n webhooks:
10
+ - POST /n8n/callback → n8n calls this when the video is ready (from "downlode video1" node)
11
+ """
12
+
13
+ import os
14
+ import uuid
15
+ import time
16
+ import asyncio
17
+ import logging
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ import httpx
22
+ import aiofiles
23
+ from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
24
+ from fastapi.responses import StreamingResponse, JSONResponse
25
+ from pydantic import BaseModel
26
+
27
+ # ─── Config ────────────────────────────────────────────────────────────────────
28
+ GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "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")
29
+ GITHUB_REPO = os.getenv("GITHUB_REPO", "expher510/AutoClip-Pipeline")
30
+ WORKFLOW_FILE = os.getenv("WORKFLOW_FILE", "video-download.yml")
31
+
32
+ # Public URL of THIS api (so n8n can call back)
33
+ # e.g. https://my-api.hf.space or https://xxxxx.ngrok.io
34
+ PUBLIC_URL = os.getenv("PUBLIC_URL", "https://your-api-public-url.com")
35
+
36
+ N8N_TRIGGER_URL = os.getenv(
37
+ "N8N_TRIGGER_URL",
38
+ "https://egauto-n8n.hf.space/webhook/run%20gethup%20action"
39
+ )
40
+
41
+ VIDEOS_DIR = Path("videos")
42
+ VIDEOS_DIR.mkdir(exist_ok=True)
43
+
44
+ # ─── In-memory job store ────────────────────────────────────────────────────────
45
+ # job_id → {status, filename, file_path, video_url, error, created_at, run_id}
46
+ jobs: dict[str, dict] = {}
47
+
48
+ # ─── App ───────────────────────────────────────────────────────────────────────
49
+ app = FastAPI(
50
+ title="Video Download Middleware",
51
+ description="Bridges client ↔ n8n/GitHub Action video pipeline",
52
+ version="1.0.0",
53
+ )
54
+
55
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
56
+ log = logging.getLogger(__name__)
57
+
58
+
59
+ # ══════════════════════════════════════════════════════════════════════════════
60
+ # 1. REQUEST DOWNLOAD
61
+ # ══════════════════════════════════════════════════════════════════════════════
62
+ class DownloadRequest(BaseModel):
63
+ url: str
64
+ cookies_txt: Optional[str] = "" # optional – paste Netscape cookie text
65
+
66
+
67
+ @app.post("/download", summary="Start a video download job")
68
+ async def start_download(body: DownloadRequest):
69
+ """
70
+ Triggers the n8n → GitHub Action pipeline.
71
+ Returns a job_id you can poll with GET /status/{job_id}.
72
+ """
73
+ job_id = str(uuid.uuid4())
74
+
75
+ jobs[job_id] = {
76
+ "status": "pending",
77
+ "video_url": body.url,
78
+ "filename": None,
79
+ "file_path": None,
80
+ "run_id": None,
81
+ "error": None,
82
+ "created_at": time.time(),
83
+ }
84
+
85
+ # Build the payload n8n expects (mirrors the "run action" webhook pinData)
86
+ payload = {
87
+ "url": body.url,
88
+ "cookies_txt": body.cookies_txt or "",
89
+ # Tell n8n where to POST the finished file
90
+ "n8n_webhook": f"{PUBLIC_URL}/n8n/callback/{job_id}",
91
+ }
92
+
93
+ try:
94
+ async with httpx.AsyncClient(timeout=15) as client:
95
+ r = await client.post(N8N_TRIGGER_URL, json=payload)
96
+ r.raise_for_status()
97
+ jobs[job_id]["status"] = "processing"
98
+ log.info(f"[{job_id}] job triggered → n8n responded {r.status_code}")
99
+ except Exception as exc:
100
+ jobs[job_id]["status"] = "failed"
101
+ jobs[job_id]["error"] = str(exc)
102
+ log.error(f"[{job_id}] failed to trigger n8n: {exc}")
103
+ raise HTTPException(status_code=502, detail=f"Could not trigger pipeline: {exc}")
104
+
105
+ return {"job_id": job_id, "status": "processing"}
106
+
107
+
108
+ # ══════════════════════════════════════════════════════════════════════════════
109
+ # 2. POLL STATUS
110
+ # ══════════════════════════════════════════════════════════════════════════════
111
+ @app.get("/status/{job_id}", summary="Poll download status")
112
+ async def get_status(job_id: str):
113
+ """
114
+ Returns one of: pending | processing | done | failed
115
+ When done, also returns filename & download_url.
116
+ """
117
+ job = jobs.get(job_id)
118
+ if not job:
119
+ raise HTTPException(status_code=404, detail="Job not found")
120
+
121
+ resp = {
122
+ "job_id": job_id,
123
+ "status": job["status"],
124
+ "video_url": job["video_url"],
125
+ "created_at": job["created_at"],
126
+ }
127
+
128
+ if job["status"] == "done":
129
+ resp["filename"] = job["filename"]
130
+ resp["download_url"] = f"{PUBLIC_URL}/file/{job_id}"
131
+
132
+ if job["status"] == "failed":
133
+ resp["error"] = job["error"]
134
+
135
+ return resp
136
+
137
+
138
+ # ══════════════════════════════════════════════════════════════════════════════
139
+ # 3. DOWNLOAD FILE
140
+ # ══════════════════════════════════════════════════════════════════════════════
141
+ @app.get("/file/{job_id}", summary="Download the finished video")
142
+ async def download_file(job_id: str):
143
+ job = jobs.get(job_id)
144
+ if not job:
145
+ raise HTTPException(status_code=404, detail="Job not found")
146
+ if job["status"] != "done":
147
+ raise HTTPException(status_code=425, detail=f"Not ready yet – status: {job['status']}")
148
+
149
+ file_path: Path = job["file_path"]
150
+ if not file_path or not file_path.exists():
151
+ raise HTTPException(status_code=410, detail="File no longer available on server")
152
+
153
+ filename = job["filename"] or file_path.name
154
+
155
+ async def file_streamer():
156
+ async with aiofiles.open(file_path, "rb") as f:
157
+ while chunk := await f.read(1024 * 512): # 512 KB chunks
158
+ yield chunk
159
+
160
+ return StreamingResponse(
161
+ file_streamer(),
162
+ media_type="video/mp4",
163
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
164
+ )
165
+
166
+
167
+ # ══════════════════════════════════════════════════════════════════════════════
168
+ # 4. n8n CALLBACK (n8n POSTs the finished binary here)
169
+ # This replaces the "downlode video1" webhook that was in n8n
170
+ # ══════════════════════════════════════════════════════════════════════════════
171
+ @app.post("/n8n/callback/{job_id}", summary="[Internal] n8n posts the video here")
172
+ async def n8n_callback(job_id: str, request: Request):
173
+ """
174
+ n8n calls this endpoint (instead of its own 'downlode video1' webhook).
175
+ It sends either:
176
+ a) A JSON body with {status, run_id, filename, video_url} → we then
177
+ fetch the artifact from GitHub ourselves, OR
178
+ b) Raw binary (the video file) directly in the body.
179
+ """
180
+ job = jobs.get(job_id)
181
+ if not job:
182
+ log.warning(f"[{job_id}] callback for unknown job")
183
+ return {"ok": False, "reason": "unknown job"}
184
+
185
+ content_type = request.headers.get("content-type", "")
186
+
187
+ # ── Case A: JSON notification (status + run_id) ──────────────────────────
188
+ if "application/json" in content_type:
189
+ data = await request.json()
190
+ log.info(f"[{job_id}] JSON callback: {data}")
191
+
192
+ if data.get("status") == "success":
193
+ run_id = data.get("run_id")
194
+ filename = data.get("filename", f"{job_id}.mp4")
195
+ jobs[job_id]["run_id"] = run_id
196
+ jobs[job_id]["filename"] = filename
197
+
198
+ # Fetch the artifact binary from GitHub in the background
199
+ asyncio.create_task(_fetch_github_artifact(job_id, run_id, filename))
200
+ else:
201
+ jobs[job_id]["status"] = "failed"
202
+ jobs[job_id]["error"] = data.get("error", "n8n reported failure")
203
+
204
+ return {"ok": True}
205
+
206
+ # ── Case B: Raw binary video ──────────────────────────────────────────────
207
+ body = await request.body()
208
+ if not body:
209
+ jobs[job_id]["status"] = "failed"
210
+ jobs[job_id]["error"] = "empty callback body"
211
+ return {"ok": False}
212
+
213
+ filename = request.headers.get("x-filename", f"{job_id}.mp4")
214
+ file_path = VIDEOS_DIR / f"{job_id}_{filename}"
215
+
216
+ async with aiofiles.open(file_path, "wb") as f:
217
+ await f.write(body)
218
+
219
+ jobs[job_id]["status"] = "done"
220
+ jobs[job_id]["filename"] = filename
221
+ jobs[job_id]["file_path"] = file_path
222
+ log.info(f"[{job_id}] saved {file_path} ({len(body)/1024/1024:.1f} MB)")
223
+
224
+ return {"ok": True}
225
+
226
+
227
+ # ══════════════════════════════���═══════════════════════════════════════════════
228
+ # 5. BACKGROUND: fetch artifact from GitHub (used in Case A above)
229
+ # ══════════════════════════════════════════════════════════════════════════════
230
+ async def _fetch_github_artifact(job_id: str, run_id: str, filename: str):
231
+ """Polls GitHub until the artifact is ready, then downloads it."""
232
+ artifacts_url = f"https://api.github.com/repos/{GITHUB_REPO}/actions/runs/{run_id}/artifacts"
233
+ headers = {
234
+ "Authorization": f"token {GITHUB_TOKEN}",
235
+ "Accept": "application/vnd.github+json",
236
+ }
237
+
238
+ for attempt in range(20): # up to ~5 minutes
239
+ await asyncio.sleep(15)
240
+ try:
241
+ async with httpx.AsyncClient(timeout=30) as client:
242
+ r = await client.get(artifacts_url, headers=headers)
243
+ r.raise_for_status()
244
+ artifacts = r.json().get("artifacts", [])
245
+
246
+ if not artifacts:
247
+ log.info(f"[{job_id}] no artifacts yet (attempt {attempt+1})")
248
+ continue
249
+
250
+ artifact = artifacts[0]
251
+ dl_url = artifact["archive_download_url"]
252
+
253
+ # Download the zip (GitHub wraps artifacts in a zip)
254
+ async with httpx.AsyncClient(
255
+ timeout=300,
256
+ follow_redirects=True,
257
+ headers=headers,
258
+ ) as client:
259
+ resp = await client.get(dl_url)
260
+ resp.raise_for_status()
261
+
262
+ # Save & unzip
263
+ zip_path = VIDEOS_DIR / f"{job_id}.zip"
264
+ async with aiofiles.open(zip_path, "wb") as f:
265
+ await f.write(resp.content)
266
+
267
+ # Extract the video
268
+ import zipfile
269
+ with zipfile.ZipFile(zip_path, "r") as z:
270
+ names = z.namelist()
271
+ video_name = next((n for n in names if n.endswith(".mp4")), names[0])
272
+ z.extract(video_name, VIDEOS_DIR)
273
+ extracted = VIDEOS_DIR / video_name
274
+
275
+ zip_path.unlink(missing_ok=True)
276
+
277
+ jobs[job_id]["status"] = "done"
278
+ jobs[job_id]["filename"] = video_name
279
+ jobs[job_id]["file_path"] = extracted
280
+ log.info(f"[{job_id}] artifact saved → {extracted}")
281
+ return
282
+
283
+ except Exception as exc:
284
+ log.error(f"[{job_id}] artifact fetch error: {exc}")
285
+
286
+ jobs[job_id]["status"] = "failed"
287
+ jobs[job_id]["error"] = "artifact never became available"
288
+
289
+
290
+ # ══════════════════════════════════════════════════════════════════════════════
291
+ # 6. EXTRA UTILS
292
+ # ══════════════════════════════════════════════════════════════════════════════
293
+ @app.get("/jobs", summary="List all jobs (debug)")
294
+ async def list_jobs():
295
+ return {
296
+ jid: {k: v for k, v in j.items() if k != "file_path"}
297
+ for jid, j in jobs.items()
298
+ }
299
+
300
+
301
+ @app.delete("/job/{job_id}", summary="Delete a finished job and its file")
302
+ async def delete_job(job_id: str):
303
+ job = jobs.pop(job_id, None)
304
+ if not job:
305
+ raise HTTPException(status_code=404, detail="Job not found")
306
+ if job.get("file_path") and Path(job["file_path"]).exists():
307
+ Path(job["file_path"]).unlink()
308
+ return {"deleted": job_id}
309
+
310
+
311
+ @app.get("/health")
312
+ async def health():
313
+ return {"status": "ok", "jobs": len(jobs)}
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi>=0.110.0
2
+ uvicorn[standard]>=0.29.0
3
+ httpx>=0.27.0
4
+ aiofiles>=23.2.1
5
+ python-multipart>=0.0.9