feat: batch .md download, multi-select, thumbnails, all OPPORTUNITIES tools

Videos view: multi-select (checkboxes) + select-all/deselect-all + bulk action
bar (Download .md batch, Download thumbnails, Download audio). Per-video buttons:
.md download (done) or Process to .md (pending), clip.

Channels: per-channel pending count + 'Download pending .md' (batches pending
video_ids). Each channel keeps Videos/.md folder/Remove actions.

Backend (jobs.py + api.py):
- POST /api/scrape/batch {video_ids} + POST /api/scrape/video/{id} (on-demand
  .md for any video incl. pending) as SSE-tracked jobs
- GET /api/videos/{id}/markdown (file download)
- POST /api/tools/thumbnails + GET /api/thumbnails/{id} (local cache, offline)
- GET /api/clip/{id}?from=&to= (transcript segment)
- GET /api/stats (aggregate totals)
- POST /api/tools/audio (mp3 job)

Tools view rebuilt as full Knowledge Base grid surfacing every OPPORTUNITIES.md
feature as electable buttons (md download, re-render, export, search, analysis,
thumbnails, audio, clip, stats, cookies, channels, watch, open folders).

Floating job widget (SSE) for any running job. Thumbnails served via
/api/thumbnails/{id} everywhere (works offline once cached).

Launcher: robust Open-Browser helper (3 fallback methods) auto-opens
http://127.0.0.1:<port> on start + on idempotent re-open. data/ gitignored.
This commit is contained in:
urieljareth
2026-07-26 23:39:05 -06:00
parent 621bbc5f5c
commit 0682d2d806
7 changed files with 852 additions and 33 deletions
+115
View File
@@ -301,6 +301,121 @@ def build_router(store: Store, cfg: Config, jobs) -> APIRouter:
"output_dir": str(Path(cfg.output_dir_resolved)),
}
# -------------------------------------------------- per-video / batch markdown
@r.post("/scrape/video/{video_id}")
def scrape_one_video(video_id: str, payload: dict | None = None):
opts = (payload or {}).get("opts", {}) or {}
opts["video_ids"] = [video_id]
job_id = jobs.enqueue(None, opts)
return {"job_id": job_id}
@r.post("/scrape/batch")
async def scrape_batch(payload: dict):
video_ids = (payload or {}).get("video_ids") or []
if not video_ids:
raise HTTPException(400, "video_ids required")
opts = (payload or {}).get("opts", {}) or {}
opts["video_ids"] = list(video_ids)
job_id = jobs.enqueue(None, opts)
return {"job_id": job_id, "count": len(video_ids)}
@r.get("/videos/{video_id}/markdown")
def video_markdown(video_id: str):
v = store.get_video(video_id)
if not v or not v.markdown_path:
raise HTTPException(404, "markdown not generated yet")
p = Path(cfg.output_dir_resolved).parent / v.markdown_path
if not p.exists():
raise HTTPException(404, "markdown file missing on disk")
return FileResponse(str(p), filename=p.name, media_type="text/markdown")
# -------------------------------------------------- thumbnails (local cache)
@r.post("/tools/thumbnails")
async def download_thumbnails(payload: dict):
import requests as _req
video_ids = (payload or {}).get("video_ids") or []
channel_id = (payload or {}).get("channel_id")
if channel_id and not video_ids:
video_ids = [v.video_id for v in store.get_all(channel_id) if v.thumbnail]
out_dir = Path(cfg.output_dir_resolved).parent / "thumbnails"
out_dir.mkdir(parents=True, exist_ok=True)
n = 0
for vid in video_ids:
v = store.get_video(vid)
if not v or not v.thumbnail:
continue
target = out_dir / f"{vid}.jpg"
if target.exists():
n += 1
continue
try:
r2 = _req.get(v.thumbnail, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
r2.raise_for_status()
target.write_bytes(r2.content)
n += 1
except Exception:
continue
return {"downloaded": n, "dir": str(out_dir)}
@r.get("/thumbnails/{video_id}")
def serve_thumbnail(video_id: str):
from fastapi.responses import RedirectResponse
local = Path(cfg.output_dir_resolved).parent / "thumbnails" / f"{video_id}.jpg"
if local.exists():
return FileResponse(str(local), media_type="image/jpeg")
v = store.get_video(video_id)
if v and v.thumbnail:
return RedirectResponse(url=v.thumbnail, status_code=302)
raise HTTPException(404, "no thumbnail")
# -------------------------------------------------- clip (transcript segment)
@r.get("/clip/{video_id}")
def clip_video(video_id: str, frm: str = Query("0:00", alias="from"), to: str = Query("", alias="to")):
from ..segments import ts_to_seconds
segs = store.get_segments(video_id)
if not segs:
raise HTTPException(404, "no transcript for this video")
start = ts_to_seconds(frm)
end = ts_to_seconds(to) if to else float("inf")
picked = [s for s in segs if start <= s.start_sec < end]
text = "\n".join(f"[{int(s.start_sec//60):02d}:{int(s.start_sec%60):02d}] {s.text}" for s in picked)
v = store.get_video(video_id)
return {
"video_id": video_id,
"title": v.title if v else "",
"from": frm, "to": to or "end",
"segment_count": len(picked),
"text": text,
}
# -------------------------------------------------- stats (per channel / all)
@r.get("/stats")
def stats(channel: str | None = None):
d = store.dashboard()
if channel:
d["channels"] = [c for c in d["channels"] if c["channel_id"] == channel]
totals = {
"videos": sum((c.get("video_count_db") or 0) for c in d["channels"]),
"duration_sec": sum((c.get("total_duration") or 0) for c in d["channels"]),
"views": sum((c.get("total_views") or 0) for c in d["channels"]),
"likes": sum((c.get("total_likes") or 0) for c in d["channels"]),
}
return {"channels": d["channels"], "status_breakdown": d["status_breakdown"],
"top_tags": d["top_tags"], "totals": totals}
# -------------------------------------------------- audio (job)
@r.post("/tools/audio")
async def tools_audio(payload: dict):
video_ids = (payload or {}).get("video_ids") or []
channel_id = (payload or {}).get("channel_id")
if not video_ids and not channel_id:
raise HTTPException(400, "video_ids or channel_id required")
opts = {"mode": "audio", "channel_id": channel_id}
if video_ids:
opts["video_ids"] = list(video_ids)
job_id = jobs.enqueue(channel_id, opts)
return {"job_id": job_id}
return r