feat: .md download bundles thumbnails, auto-cache on channel add
- Bulk 'Download .md' now also caches thumbnails (one action does both); removed the redundant standalone Thumbnails button from the bulk action bar. - Scrape UX optimization: batch job skips videos whose .md already exists (just caches their thumbnail) instead of re-extracting. - Auto-thumbnails: on addChannel, thumbnails cache in the background so a newly-attached channel's catalog looks populated immediately, even before any .md is generated. - Thumbnail fallback: pending videos without a stored thumbnail URL use the canonical https://i.ytimg.com/vi/<id>/hqdefault.jpg, so every video shows a thumbnail (and /api/thumbnails/{id} never 404s — redirects if uncached). - New helpers in pipeline.py: thumbnail_url_for() + cache_thumbnail().
This commit is contained in:
@@ -140,6 +140,32 @@ def _safe_dirname(name: str) -> str:
|
||||
return safe.strip().strip(".") or "unknown"
|
||||
|
||||
|
||||
def thumbnail_url_for(video_row) -> str:
|
||||
"""Thumbnail URL for a video: stored URL, else the canonical YouTube one derived from its id."""
|
||||
if video_row and getattr(video_row, "thumbnail", None):
|
||||
return video_row.thumbnail
|
||||
vid = getattr(video_row, "video_id", "") if video_row else ""
|
||||
return f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg"
|
||||
|
||||
|
||||
def cache_thumbnail(store, video_id: str, thumbnails_dir) -> bool:
|
||||
"""Download + cache a video's thumbnail to <thumbnails_dir>/<video_id>.jpg. Returns True on success/existing."""
|
||||
import requests as _requests
|
||||
out = Path(thumbnails_dir) / f"{video_id}.jpg"
|
||||
if out.exists():
|
||||
return True
|
||||
v = store.get_video(video_id)
|
||||
url = thumbnail_url_for(v) if v else f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
|
||||
try:
|
||||
r = _requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
|
||||
r.raise_for_status()
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_bytes(r.content)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def re_render_videos(store: Store, cfg: Config, channel_id: str | None = None) -> int:
|
||||
"""Regenerate .md for done videos that have stored segments. Returns count re-rendered."""
|
||||
import json
|
||||
|
||||
@@ -332,41 +332,29 @@ def build_router(store: Store, cfg: Config, jobs) -> APIRouter:
|
||||
# -------------------------------------------------- thumbnails (local cache)
|
||||
@r.post("/tools/thumbnails")
|
||||
async def download_thumbnails(payload: dict):
|
||||
import requests as _req
|
||||
from ..pipeline import cache_thumbnail
|
||||
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]
|
||||
video_ids = [v.video_id for v in store.get_all(channel_id)]
|
||||
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():
|
||||
if cache_thumbnail(store, vid, out_dir):
|
||||
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
|
||||
from ..pipeline import thumbnail_url_for
|
||||
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")
|
||||
url = thumbnail_url_for(v) if v else f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
|
||||
# -------------------------------------------------- clip (transcript segment)
|
||||
@r.get("/clip/{video_id}")
|
||||
|
||||
@@ -108,12 +108,14 @@ class JobManager:
|
||||
return (name, row.channel_id, url)
|
||||
|
||||
def _run_batch(self, job_id: str, opts: dict[str, Any]) -> None:
|
||||
from ..pipeline import process_video as _process_video, cache_thumbnail
|
||||
video_ids = opts.get("video_ids") or []
|
||||
cookie_path = opts.get("cookies_file") or resolve_active_path(self.store)
|
||||
thumb_dir = Path(self.cfg.output_dir_resolved).parent / "thumbnails"
|
||||
total = len(video_ids)
|
||||
self.store.update_job(job_id, status="running", total=total, completed=0)
|
||||
self._emit(job_id, "progress", {"completed": 0, "total": total})
|
||||
self._emit(job_id, "log", {"msg": f"processing {total} videos"})
|
||||
self._emit(job_id, "log", {"msg": f"processing {total} videos (.md + thumbnails)"})
|
||||
cfg = _clone_config(self.cfg)
|
||||
if opts.get("languages"):
|
||||
cfg.languages = opts["languages"]
|
||||
@@ -129,12 +131,20 @@ class JobManager:
|
||||
completed += 1
|
||||
self.store.update_job(job_id, completed=completed)
|
||||
continue
|
||||
# UX optimization: skip re-extracting videos whose .md already exists — just ensure thumbnail cached.
|
||||
md_path = Path(cfg.output_dir_resolved).parent / row.markdown_path if row.markdown_path else None
|
||||
if row.status == "done" and row.markdown_path and md_path and md_path.exists():
|
||||
cache_thumbnail(self.store, vid, thumb_dir)
|
||||
status = "done"
|
||||
self._emit(job_id, "log", {"msg": f"cached {vid} (md already present)"})
|
||||
else:
|
||||
channel_name, channel_id, channel_url = self._resolve_channel_for_video(vid)
|
||||
status = process_video(
|
||||
status = _process_video(
|
||||
row, cfg, self.store, channel_name, channel_id, channel_url,
|
||||
cookies_file=cookie_path,
|
||||
on_log=lambda m: self._emit(job_id, "log", {"msg": m}),
|
||||
)
|
||||
cache_thumbnail(self.store, vid, thumb_dir)
|
||||
completed += 1
|
||||
self.store.update_job(job_id, completed=completed)
|
||||
self._emit(job_id, "progress", {"completed": completed, "total": total, "video_id": vid, "status": status})
|
||||
|
||||
@@ -184,6 +184,12 @@
|
||||
this.forms.channelUrl = "";
|
||||
await this.loadChannels();
|
||||
this.toast("Added " + (d.name || "channel") + " (" + (d.video_count || 0) + " videos)");
|
||||
// Auto-cache thumbnails in the background so the catalog looks populated immediately
|
||||
if (d.channel_id) {
|
||||
this.api("/api/tools/thumbnails", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel_id: d.channel_id }) })
|
||||
.then((r) => { if (r && r.downloaded) this.toast((r.downloaded) + " thumbnails cached"); })
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (e) { this.forms.channelError = e.message; }
|
||||
finally { this.loading.addChannel = false; }
|
||||
},
|
||||
@@ -302,7 +308,7 @@
|
||||
if (!ids.length) { this.toast("No videos selected", "error"); return; }
|
||||
try {
|
||||
const d = await this.api("/api/scrape/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_ids: ids }) });
|
||||
this.toast("Batch .md job started: " + (d.count || ids.length) + " videos");
|
||||
this.toast("Downloading .md + thumbnails for " + (d.count || ids.length) + " videos");
|
||||
this.subscribeJob(d.job_id, "batch");
|
||||
if (!videoIds) this.selectNone();
|
||||
} catch (e) { this.toast("Batch failed: " + e.message, "error"); }
|
||||
|
||||
@@ -213,13 +213,9 @@
|
||||
<template x-if="videos.selected.length > 0">
|
||||
<div class="bulk-bar glass p-3 flex flex-wrap items-center gap-2">
|
||||
<span class="pill accent-grad btn-primary font-bold" x-text="videos.selected.length + ' selected'"></span>
|
||||
<button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="downloadMdBatch()">
|
||||
<button class="btn accent-grad btn-primary !py-1.5 !px-3 text-xs" @click="downloadMdBatch()" title="Download .md + thumbnails for the selection">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 18v2h16v-2"/></svg>
|
||||
Download .md
|
||||
</button>
|
||||
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadThumbnails()">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 7.5 7.5 12M12 7.5V18"/></svg>
|
||||
Thumbnails
|
||||
Download .md + thumbs
|
||||
</button>
|
||||
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadAudio()">
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V6l10-2v12M9 18a3 3 0 11-6 0 3 3 0 016 0zm10-2a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
|
||||
Reference in New Issue
Block a user