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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user