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"
|
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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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"); }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user