Spaces:
Sleeping
Sleeping
Download video via RapidAPI (own-CDN) -> faster-whisper transcript + local screenshots -> delete; retire proxies
Browse files
app.py
CHANGED
|
@@ -47,8 +47,10 @@ _install_proxy_ca()
|
|
| 47 |
import gradio as gr
|
| 48 |
|
| 49 |
from pipeline import (
|
|
|
|
| 50 |
captions as captions_mod,
|
| 51 |
docx_builder,
|
|
|
|
| 52 |
frames as frames_mod,
|
| 53 |
search as search_mod,
|
| 54 |
sentiment as sentiment_mod,
|
|
@@ -123,70 +125,46 @@ def _resolve_api_key(ui_key: str | None) -> str | None:
|
|
| 123 |
|
| 124 |
|
| 125 |
def check_access():
|
| 126 |
-
"""Health check:
|
| 127 |
import requests
|
| 128 |
|
| 129 |
-
|
| 130 |
-
proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 131 |
lines = [
|
| 132 |
-
f"- **
|
| 133 |
-
f"
|
| 134 |
-
f"
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
f"- **Cookies** (`YT_COOKIES`): {'set ✅' if os.environ.get('YT_COOKIES') else 'not set ⚪'}",
|
| 138 |
-
f"- **Data API key** (`YOUTUBE_API_KEY`): {'set ✅' if os.environ.get('YOUTUBE_API_KEY') else 'not set ⚪'}",
|
| 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 |
-
results = {}
|
| 168 |
-
for label, url in probes:
|
| 169 |
-
ok, msg = probe(url)
|
| 170 |
-
results[label] = ok
|
| 171 |
-
lines.append(f"- {'✅' if ok else '❌'} {label}: {msg}")
|
| 172 |
-
|
| 173 |
-
yt_ok = results.get("youtube.com/robots.txt")
|
| 174 |
-
if yt_ok:
|
| 175 |
-
verdict = "### 🟢 YouTube is reachable — transcript + screenshots should work."
|
| 176 |
-
elif results.get("google.com/204 (Google cert, non-YT name)"):
|
| 177 |
-
verdict = ("### 🔴 YouTube is filtered by hostname.\n"
|
| 178 |
-
"Google works but YouTube is reset → the network between the Space and the "
|
| 179 |
-
"tunnel is dropping the plaintext `CONNECT www.youtube.com`. Fix: run the "
|
| 180 |
-
"home proxy as an **HTTPS proxy** (TLS-wrapped) so the target host is hidden.")
|
| 181 |
-
elif results.get("example.com (neutral)") and not results.get("google.com/204 (Google cert, non-YT name)"):
|
| 182 |
-
verdict = ("### 🔴 Large TLS handshakes are being dropped (MTU blackhole).\n"
|
| 183 |
-
"Small sites work; Google/YouTube (large cert chains) reset. Fix: clamp MSS on "
|
| 184 |
-
"the tunnel path or switch tunnel provider (e.g. ngrok).")
|
| 185 |
else:
|
| 186 |
-
verdict =
|
| 187 |
-
"The proxy connects (egress IP works) but TLS to YouTube is failing. "
|
| 188 |
-
"Try again in a minute or refresh **`YT_PROXY`** via the panel in "
|
| 189 |
-
"[`tools/`](tools/README.md).")
|
| 190 |
return verdict + "\n\n" + "\n".join(lines)
|
| 191 |
|
| 192 |
|
|
@@ -238,17 +216,14 @@ def run_pipeline(topic, hf_token, yt_api_key, llm_model, vlm_model,
|
|
| 238 |
|
| 239 |
workdir = tempfile.mkdtemp(prefix="ytt_")
|
| 240 |
frames_dir = os.path.join(workdir, "frames")
|
|
|
|
| 241 |
try:
|
| 242 |
api_key = _resolve_api_key(yt_api_key)
|
| 243 |
-
cookiefile = _cookiefile(workdir)
|
| 244 |
-
proxy = _resolve_proxy()
|
| 245 |
-
if proxy:
|
| 246 |
-
log.append("🔐 `YT_PROXY` is set — transcript + stream requests route through it.")
|
| 247 |
|
| 248 |
# 1. Search ------------------------------------------------------------------
|
| 249 |
progress(0.03, desc="Searching")
|
| 250 |
yield status(f"🔍 Searching top videos for **{topic}** (engagement-ranked)…"), gr.update(), gr.update(), gr.update()
|
| 251 |
-
videos = search_mod.search_top5(topic, api_key=api_key
|
| 252 |
eng_note = ""
|
| 253 |
if videos and "engagement" in videos[0]:
|
| 254 |
eng_note = " • ranked by likes+comments+subscribers−dislikes (normalized)"
|
|
@@ -269,12 +244,18 @@ def run_pipeline(topic, hf_token, yt_api_key, llm_model, vlm_model,
|
|
| 269 |
yield (status(f"🏆 Picked **{best.get('title', best['video_id'])}** {picked_msg}."),
|
| 270 |
ranking, gr.update(), gr.update())
|
| 271 |
|
| 272 |
-
# 3.
|
| 273 |
-
progress(0.
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
transcript = transcribe_mod.transcript_text(segs)
|
| 279 |
yield (status(f"Transcript ready ({len(segs)} segments)."),
|
| 280 |
ranking, gr.update(value=transcript), gr.update())
|
|
@@ -295,27 +276,31 @@ def run_pipeline(topic, hf_token, yt_api_key, llm_model, vlm_model,
|
|
| 295 |
yield (status(f"🔑 Primary keyword '{keywords['primary']}' appears {n}× in the post."),
|
| 296 |
ranking, gr.update(value=transcript), gr.update())
|
| 297 |
|
| 298 |
-
# 6. Screenshots
|
| 299 |
selected, caps = {}, {}
|
| 300 |
times = frames_mod.compute_shot_times(
|
| 301 |
tut["steps"], segs, w_llm=float(w_llm), w_whisper=float(w_whisper),
|
| 302 |
lead=float(lead), max_shots=int(max_shots))
|
| 303 |
if times:
|
| 304 |
-
progress(0.
|
| 305 |
-
yield status(f"🎞️ Capturing {len(times)} screenshots
|
| 306 |
try:
|
| 307 |
-
selected = frames_mod.
|
| 308 |
-
times, best["video_id"], frames_dir, cookiefile, proxy,
|
| 309 |
-
_resolve_media_proxy(), progress)
|
| 310 |
except Exception as exc:
|
| 311 |
-
yield (status(f"⚠️ Couldn't
|
| 312 |
-
f"`{type(exc).__name__}: {str(exc)[:
|
| 313 |
ranking, gr.update(value=transcript), gr.update())
|
| 314 |
if selected:
|
| 315 |
-
progress(0.
|
| 316 |
yield status(f"✍️ Captioning {len(selected)} screenshots with `{vlm_model}`…"), ranking, gr.update(value=transcript), gr.update()
|
| 317 |
caps = captions_mod.caption_frames(selected, tut["steps"], hf_token.strip(), vlm_model, progress)
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
# 7. DOCX --------------------------------------------------------------------
|
| 320 |
progress(0.96, desc="Building document")
|
| 321 |
out_path = os.path.join(workdir, f"{_safe_name(tut['title'])}.docx")
|
|
@@ -331,6 +316,12 @@ def run_pipeline(topic, hf_token, yt_api_key, llm_model, vlm_model,
|
|
| 331 |
except (transcribe_mod.TranscriptError, sentiment_mod.SentimentError,
|
| 332 |
RuntimeError, ValueError) as exc:
|
| 333 |
raise gr.Error(str(exc))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
|
| 336 |
def build_ui():
|
|
|
|
| 47 |
import gradio as gr
|
| 48 |
|
| 49 |
from pipeline import (
|
| 50 |
+
asr as asr_mod,
|
| 51 |
captions as captions_mod,
|
| 52 |
docx_builder,
|
| 53 |
+
downloader as downloader_mod,
|
| 54 |
frames as frames_mod,
|
| 55 |
search as search_mod,
|
| 56 |
sentiment as sentiment_mod,
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
def check_access():
|
| 128 |
+
"""Health check: are the required secrets set and is the video-download API live?"""
|
| 129 |
import requests
|
| 130 |
|
| 131 |
+
rk = os.environ.get("RAPIDAPI_KEY", "").strip()
|
|
|
|
| 132 |
lines = [
|
| 133 |
+
f"- **Video download** (`RAPIDAPI_KEY`): {'set ✅' if rk else 'not set ❌ — required'}",
|
| 134 |
+
f"- **Comment sentiment** (`YOUTUBE_API_KEY`): "
|
| 135 |
+
f"{'set ✅' if os.environ.get('YOUTUBE_API_KEY') else 'not set ⚪ (sentiment skipped)'}",
|
| 136 |
+
"- **Transcript**: faster-whisper, local (no key/proxy needed) ✅",
|
| 137 |
+
"- **Search**: Piped API (no key needed) ✅",
|
|
|
|
|
|
|
| 138 |
]
|
| 139 |
+
|
| 140 |
+
api_ok = None
|
| 141 |
+
if rk:
|
| 142 |
+
host = "youtube-video-fast-downloader-24-7.p.rapidapi.com"
|
| 143 |
+
try:
|
| 144 |
+
r = requests.get(f"https://{host}/get-video-info/dQw4w9WgXcQ",
|
| 145 |
+
headers={"X-RapidAPI-Key": rk, "X-RapidAPI-Host": host},
|
| 146 |
+
timeout=25)
|
| 147 |
+
if r.status_code == 200:
|
| 148 |
+
api_ok = True
|
| 149 |
+
lines.append("- ✅ Download API reachable and key valid (HTTP 200)")
|
| 150 |
+
elif r.status_code in (401, 403):
|
| 151 |
+
api_ok = False
|
| 152 |
+
lines.append(f"- ❌ Download API rejected the key (HTTP {r.status_code}) — "
|
| 153 |
+
"check RAPIDAPI_KEY and that you're subscribed")
|
| 154 |
+
else:
|
| 155 |
+
api_ok = False
|
| 156 |
+
lines.append(f"- ⚠️ Download API HTTP {r.status_code}: {r.text[:120]}")
|
| 157 |
+
except Exception as exc:
|
| 158 |
+
api_ok = False
|
| 159 |
+
lines.append(f"- ❌ Download API unreachable: {type(exc).__name__}")
|
| 160 |
+
|
| 161 |
+
if not rk:
|
| 162 |
+
verdict = "### 🔴 Set `RAPIDAPI_KEY` — it's required to download the source video."
|
| 163 |
+
elif api_ok:
|
| 164 |
+
verdict = ("### 🟢 Ready. Video downloads via the API; the transcript is produced "
|
| 165 |
+
"locally by faster-whisper (first run downloads the model, ~1 min).")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
else:
|
| 167 |
+
verdict = "### 🔴 The video-download API isn't working — see the details below."
|
|
|
|
|
|
|
|
|
|
| 168 |
return verdict + "\n\n" + "\n".join(lines)
|
| 169 |
|
| 170 |
|
|
|
|
| 216 |
|
| 217 |
workdir = tempfile.mkdtemp(prefix="ytt_")
|
| 218 |
frames_dir = os.path.join(workdir, "frames")
|
| 219 |
+
video_path = os.path.join(workdir, "source.mp4")
|
| 220 |
try:
|
| 221 |
api_key = _resolve_api_key(yt_api_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
|
| 223 |
# 1. Search ------------------------------------------------------------------
|
| 224 |
progress(0.03, desc="Searching")
|
| 225 |
yield status(f"🔍 Searching top videos for **{topic}** (engagement-ranked)…"), gr.update(), gr.update(), gr.update()
|
| 226 |
+
videos = search_mod.search_top5(topic, api_key=api_key)
|
| 227 |
eng_note = ""
|
| 228 |
if videos and "engagement" in videos[0]:
|
| 229 |
eng_note = " • ranked by likes+comments+subscribers−dislikes (normalized)"
|
|
|
|
| 244 |
yield (status(f"🏆 Picked **{best.get('title', best['video_id'])}** {picked_msg}."),
|
| 245 |
ranking, gr.update(), gr.update())
|
| 246 |
|
| 247 |
+
# 3. Download the source video once (RapidAPI, own-CDN — no proxy) -----------
|
| 248 |
+
progress(0.25, desc="Downloading video")
|
| 249 |
+
yield status("⬇️ Downloading the video (server-side prep can take up to ~5 min)…"), ranking, gr.update(), gr.update()
|
| 250 |
+
downloader_mod.download_video(best["video_id"], video_path, progress=progress)
|
| 251 |
+
size_mb = os.path.getsize(video_path) / 1024 / 1024
|
| 252 |
+
yield (status(f"Video downloaded ({size_mb:.0f} MB)."),
|
| 253 |
+
ranking, gr.update(), gr.update())
|
| 254 |
+
|
| 255 |
+
# 4. Transcript via faster-whisper on the local video ------------------------
|
| 256 |
+
progress(0.4, desc="Transcribing (faster-whisper)")
|
| 257 |
+
yield status("📝 Transcribing the audio with faster-whisper (can take a while on CPU)…"), ranking, gr.update(), gr.update()
|
| 258 |
+
segs = asr_mod.transcribe_file(video_path, progress=progress)
|
| 259 |
transcript = transcribe_mod.transcript_text(segs)
|
| 260 |
yield (status(f"Transcript ready ({len(segs)} segments)."),
|
| 261 |
ranking, gr.update(value=transcript), gr.update())
|
|
|
|
| 276 |
yield (status(f"🔑 Primary keyword '{keywords['primary']}' appears {n}× in the post."),
|
| 277 |
ranking, gr.update(value=transcript), gr.update())
|
| 278 |
|
| 279 |
+
# 6. Screenshots from the local video (ffmpeg, no network) -------------------
|
| 280 |
selected, caps = {}, {}
|
| 281 |
times = frames_mod.compute_shot_times(
|
| 282 |
tut["steps"], segs, w_llm=float(w_llm), w_whisper=float(w_whisper),
|
| 283 |
lead=float(lead), max_shots=int(max_shots))
|
| 284 |
if times:
|
| 285 |
+
progress(0.8, desc="Screenshots")
|
| 286 |
+
yield status(f"🎞️ Capturing {len(times)} screenshots from the video…"), ranking, gr.update(value=transcript), gr.update()
|
| 287 |
try:
|
| 288 |
+
selected = frames_mod.capture_from_file(times, video_path, frames_dir, progress)
|
|
|
|
|
|
|
| 289 |
except Exception as exc:
|
| 290 |
+
yield (status(f"⚠️ Couldn't extract screenshots — text-only tutorial. "
|
| 291 |
+
f"`{type(exc).__name__}: {str(exc)[:300]}`"),
|
| 292 |
ranking, gr.update(value=transcript), gr.update())
|
| 293 |
if selected:
|
| 294 |
+
progress(0.9, desc="Captioning")
|
| 295 |
yield status(f"✍️ Captioning {len(selected)} screenshots with `{vlm_model}`…"), ranking, gr.update(value=transcript), gr.update()
|
| 296 |
caps = captions_mod.caption_frames(selected, tut["steps"], hf_token.strip(), vlm_model, progress)
|
| 297 |
|
| 298 |
+
# Done with the video — delete it (keep only the .docx for download).
|
| 299 |
+
try:
|
| 300 |
+
os.remove(video_path)
|
| 301 |
+
except OSError:
|
| 302 |
+
pass
|
| 303 |
+
|
| 304 |
# 7. DOCX --------------------------------------------------------------------
|
| 305 |
progress(0.96, desc="Building document")
|
| 306 |
out_path = os.path.join(workdir, f"{_safe_name(tut['title'])}.docx")
|
|
|
|
| 316 |
except (transcribe_mod.TranscriptError, sentiment_mod.SentimentError,
|
| 317 |
RuntimeError, ValueError) as exc:
|
| 318 |
raise gr.Error(str(exc))
|
| 319 |
+
finally:
|
| 320 |
+
# Always delete the downloaded video (keep only the .docx for download).
|
| 321 |
+
try:
|
| 322 |
+
os.remove(video_path)
|
| 323 |
+
except OSError:
|
| 324 |
+
pass
|
| 325 |
|
| 326 |
|
| 327 |
def build_ui():
|