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:
urieljareth
2026-07-26 23:48:46 -06:00
parent 0682d2d806
commit 0866c92650
5 changed files with 58 additions and 32 deletions
+26
View File
@@ -140,6 +140,32 @@ def _safe_dirname(name: str) -> str:
return safe.strip().strip(".") or "unknown" 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: 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.""" """Regenerate .md for done videos that have stored segments. Returns count re-rendered."""
import json import json
+6 -18
View File
@@ -332,41 +332,29 @@ def build_router(store: Store, cfg: Config, jobs) -> APIRouter:
# -------------------------------------------------- thumbnails (local cache) # -------------------------------------------------- thumbnails (local cache)
@r.post("/tools/thumbnails") @r.post("/tools/thumbnails")
async def download_thumbnails(payload: dict): async def download_thumbnails(payload: dict):
import requests as _req from ..pipeline import cache_thumbnail
video_ids = (payload or {}).get("video_ids") or [] video_ids = (payload or {}).get("video_ids") or []
channel_id = (payload or {}).get("channel_id") channel_id = (payload or {}).get("channel_id")
if channel_id and not video_ids: 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 = Path(cfg.output_dir_resolved).parent / "thumbnails"
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
n = 0 n = 0
for vid in video_ids: for vid in video_ids:
v = store.get_video(vid) if cache_thumbnail(store, vid, out_dir):
if not v or not v.thumbnail:
continue
target = out_dir / f"{vid}.jpg"
if target.exists():
n += 1 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)} return {"downloaded": n, "dir": str(out_dir)}
@r.get("/thumbnails/{video_id}") @r.get("/thumbnails/{video_id}")
def serve_thumbnail(video_id: str): def serve_thumbnail(video_id: str):
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from ..pipeline import thumbnail_url_for
local = Path(cfg.output_dir_resolved).parent / "thumbnails" / f"{video_id}.jpg" local = Path(cfg.output_dir_resolved).parent / "thumbnails" / f"{video_id}.jpg"
if local.exists(): if local.exists():
return FileResponse(str(local), media_type="image/jpeg") return FileResponse(str(local), media_type="image/jpeg")
v = store.get_video(video_id) v = store.get_video(video_id)
if v and v.thumbnail: url = thumbnail_url_for(v) if v else f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
return RedirectResponse(url=v.thumbnail, status_code=302) return RedirectResponse(url=url, status_code=302)
raise HTTPException(404, "no thumbnail")
# -------------------------------------------------- clip (transcript segment) # -------------------------------------------------- clip (transcript segment)
@r.get("/clip/{video_id}") @r.get("/clip/{video_id}")
+12 -2
View File
@@ -108,12 +108,14 @@ class JobManager:
return (name, row.channel_id, url) return (name, row.channel_id, url)
def _run_batch(self, job_id: str, opts: dict[str, Any]) -> None: 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 [] video_ids = opts.get("video_ids") or []
cookie_path = opts.get("cookies_file") or resolve_active_path(self.store) 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) total = len(video_ids)
self.store.update_job(job_id, status="running", total=total, completed=0) 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, "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) cfg = _clone_config(self.cfg)
if opts.get("languages"): if opts.get("languages"):
cfg.languages = opts["languages"] cfg.languages = opts["languages"]
@@ -129,12 +131,20 @@ class JobManager:
completed += 1 completed += 1
self.store.update_job(job_id, completed=completed) self.store.update_job(job_id, completed=completed)
continue 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) 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, row, cfg, self.store, channel_name, channel_id, channel_url,
cookies_file=cookie_path, cookies_file=cookie_path,
on_log=lambda m: self._emit(job_id, "log", {"msg": m}), on_log=lambda m: self._emit(job_id, "log", {"msg": m}),
) )
cache_thumbnail(self.store, vid, thumb_dir)
completed += 1 completed += 1
self.store.update_job(job_id, completed=completed) self.store.update_job(job_id, completed=completed)
self._emit(job_id, "progress", {"completed": completed, "total": total, "video_id": vid, "status": status}) self._emit(job_id, "progress", {"completed": completed, "total": total, "video_id": vid, "status": status})
+7 -1
View File
@@ -184,6 +184,12 @@
this.forms.channelUrl = ""; this.forms.channelUrl = "";
await this.loadChannels(); await this.loadChannels();
this.toast("Added " + (d.name || "channel") + " (" + (d.video_count || 0) + " videos)"); 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; } } catch (e) { this.forms.channelError = e.message; }
finally { this.loading.addChannel = false; } finally { this.loading.addChannel = false; }
}, },
@@ -302,7 +308,7 @@
if (!ids.length) { this.toast("No videos selected", "error"); return; } if (!ids.length) { this.toast("No videos selected", "error"); return; }
try { try {
const d = await this.api("/api/scrape/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ video_ids: ids }) }); 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"); this.subscribeJob(d.job_id, "batch");
if (!videoIds) this.selectNone(); if (!videoIds) this.selectNone();
} catch (e) { this.toast("Batch failed: " + e.message, "error"); } } catch (e) { this.toast("Batch failed: " + e.message, "error"); }
+2 -6
View File
@@ -213,13 +213,9 @@
<template x-if="videos.selected.length > 0"> <template x-if="videos.selected.length > 0">
<div class="bulk-bar glass p-3 flex flex-wrap items-center gap-2"> <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> <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> <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 Download .md + thumbs
</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
</button> </button>
<button class="btn btn-ghost !py-1.5 !px-3 text-xs" @click="downloadAudio()"> <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> <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>