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
+6 -18
View File
@@ -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}")