diff --git a/VERSION b/VERSION
index f176c94..9eb2aa3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.31.1
+0.32.0
diff --git a/backend/alembic/versions/0050_asset_poster.py b/backend/alembic/versions/0050_asset_poster.py
new file mode 100644
index 0000000..c333277
--- /dev/null
+++ b/backend/alembic/versions/0050_asset_poster.py
@@ -0,0 +1,28 @@
+"""local poster path for thumbnail-less downloads
+
+Revision ID: 0050_asset_poster
+Revises: 0049_download_display_meta
+Create Date: 2026-07-07
+
+Some sources (a direct media URL like a reddit v.redd.it HLS manifest) carry no thumbnail, so the
+download shows a blank image box. The worker now extracts a representative frame with ffmpeg and
+saves it as a poster sidecar; `poster_path` (relative to download_root) records it so the card /
+watch page / link preview can fall back to it. Nullable — only set when there was no thumbnail.
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0050_asset_poster"
+down_revision: Union[str, None] = "0049_download_display_meta"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("media_assets", sa.Column("poster_path", sa.Text(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("media_assets", "poster_path")
diff --git a/backend/app/downloads/backfill.py b/backend/app/downloads/backfill.py
new file mode 100644
index 0000000..de99c41
--- /dev/null
+++ b/backend/app/downloads/backfill.py
@@ -0,0 +1,96 @@
+"""One-off backfill for downloads made before per-asset channel URLs + poster fallbacks existed.
+
+Fills `media_assets.uploader_url` (re-extracting metadata for YouTube/Facebook sources, so an
+auto-detected channel becomes a clickable link) and `poster_path` (an ffmpeg frame for
+thumbnail-less sources, so the card / watch page / link preview still shows an image). Best-effort
+and safe to re-run: it only touches assets still missing the field, and only re-fetches YouTube /
+Facebook sources (a direct media URL like a reddit HLS manifest carries no channel and its signed
+URL may have expired — those just get a locally-generated poster instead).
+
+Run once per environment:
+ docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
+"""
+import logging
+from urllib.parse import urlparse
+
+import yt_dlp
+from sqlalchemy import select
+
+from app.config import settings
+from app.db import SessionLocal
+from app.downloads import edit as editmod
+from app.downloads import formats, service
+from app.models import MediaAsset
+from app.worker import _uploader_url
+
+log = logging.getLogger("siftlode.backfill")
+
+
+def _should_refetch(asset: MediaAsset) -> bool:
+ """Only YouTube + Facebook sources have a derivable channel URL worth a network round-trip."""
+ if asset.source_kind == "youtube":
+ return True
+ if asset.source_kind == "url":
+ host = urlparse(asset.source_ref).netloc.lower()
+ return "facebook.com" in host or "fb.watch" in host
+ return False
+
+
+def _extract_meta(url: str) -> dict | None:
+ """Metadata-only yt-dlp extraction, trying the same client sets as the worker (YouTube needs the
+ POT/player-client strategy even just to extract)."""
+ client_sets = [
+ c for c in (settings.player_client_list, settings.player_client_fallback_list) if c
+ ] or [None]
+ for clients in client_sets:
+ opts = formats.build_ydl_opts(
+ {}, "%(id)s.%(ext)s", lambda d: None, settings.download_pot_base_url, clients
+ )
+ opts["skip_download"] = True
+ try:
+ with yt_dlp.YoutubeDL(opts) as ydl:
+ info = ydl.extract_info(url, download=False)
+ if info:
+ return info
+ except Exception as exc: # noqa: BLE001 — best-effort backfill, keep going
+ log.info("meta extract failed for %s (%s): %s", url, clients, str(exc)[:100])
+ return None
+
+
+def run() -> dict:
+ filled_url = filled_poster = 0
+ with SessionLocal() as db:
+ assets = (
+ db.execute(
+ select(MediaAsset).where(
+ MediaAsset.status == "ready", MediaAsset.rel_path.is_not(None)
+ )
+ )
+ .scalars()
+ .all()
+ )
+ for a in assets:
+ if a.uploader_url is None and _should_refetch(a):
+ url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
+ info = _extract_meta(url)
+ uu = _uploader_url(info) if info else None
+ if uu:
+ a.uploader_url = uu
+ filled_url += 1
+ log.info("asset %s uploader_url <- %s", a.id, uu)
+ if a.poster_path is None:
+ # Record a self-hosted poster for every download (the reliable og:image source):
+ # ensure_poster returns the existing .jpg thumbnail sidecar, or cuts a frame.
+ rel = editmod.ensure_poster(a, settings.download_root)
+ if rel:
+ a.poster_path = rel
+ filled_poster += 1
+ log.info("asset %s poster <- %s", a.id, rel)
+ db.commit()
+ result = {
+ "assets": len(assets),
+ "filled_uploader_url": filled_url,
+ "filled_poster": filled_poster,
+ }
+ log.info("backfill done: %s", result)
+ return result
diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py
index 814d49f..b52064f 100644
--- a/backend/app/downloads/edit.py
+++ b/backend/app/downloads/edit.py
@@ -298,6 +298,40 @@ def build_storyboard_cmd(src: Path, dest: Path, duration_s: int | None, geom: di
]
+def build_poster_cmd(src: Path, dest: Path, at_s: float) -> list[str]:
+ """Grab one representative frame as a poster JPEG. `-ss` before `-i` seeks fast."""
+ return [
+ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-nostdin",
+ "-ss", f"{max(0.0, at_s):.3f}", "-i", str(src),
+ "-frames:v", "1", "-q:v", "3", "-vf", "scale=640:-2", str(dest),
+ ]
+
+
+def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
+ """Generate a poster frame for a ready asset that has no thumbnail (e.g. a direct media URL
+ like a reddit HLS manifest, which yt-dlp can't get a thumbnail for). Written as the media's
+ `.jpg` sidecar — so Plex uses it too and `delete_asset_files` cleans it up — and returned
+ as a rel path (or None on failure). Best-effort + time-bounded; a few seconds in (or 10% of a
+ short clip) dodges black intro frames."""
+ if not asset.rel_path:
+ return None
+ root = Path(download_root)
+ src = root / asset.rel_path
+ if not src.exists():
+ return None
+ rel = str(Path(asset.rel_path).with_suffix(".jpg"))
+ dest = root / rel
+ if dest.exists():
+ return rel
+ at = min(3.0, (asset.duration_s or 10) * 0.1)
+ try:
+ subprocess.run(build_poster_cmd(src, dest, at), capture_output=True, timeout=timeout_s, check=True)
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as exc:
+ log.info("poster gen skipped for asset %s: %s", asset.id, str(exc)[:120])
+ return None
+ return rel if dest.exists() else None
+
+
def ensure_storyboard(asset, download_root: str, timeout_s: int = 90) -> dict | None:
"""Return the filmstrip meta for a ready source asset, generating the sprite on first use.
diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py
index 8aa2127..08123c7 100644
--- a/backend/app/downloads/links.py
+++ b/backend/app/downloads/links.py
@@ -68,7 +68,7 @@ def owner_view(link: DownloadLink, base_url: str = "") -> dict:
}
-def public_meta(link: DownloadLink, job, asset, file_url: str) -> dict:
+def public_meta(link: DownloadLink, job, asset, file_url: str, poster_url: str | None = None) -> dict:
"""Metadata for the public watch page (only ever returned once access is authorized).
Resolves the owner's per-download display overrides (custom title/channel) over the asset's
@@ -93,4 +93,5 @@ def public_meta(link: DownloadLink, job, asset, file_url: str) -> dict:
"height": asset.height,
"allow_download": link.allow_download,
"file_url": file_url,
+ "poster_url": poster_url,
}
diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py
index 60a9590..a28ee99 100644
--- a/backend/app/downloads/og.py
+++ b/backend/app/downloads/og.py
@@ -50,7 +50,15 @@ def _watch_tags(token: str, db: Session) -> str | None:
return None
title = (job.display_name or asset.title or "Video").strip()
channel = (job.display_uploader or asset.uploader or "").strip()
- image = asset.thumbnail_url # already an absolute (youtube/fb) URL, or None
+ # Prefer our self-hosted poster for the link-preview image: a remote thumbnail (Facebook's
+ # signed CDN URL especially) expires and isn't reliably fetchable cross-origin by the crawler.
+ # (This block only runs for non-password links, so the poster needs no grant.) Fall back to the
+ # remote thumbnail only if we somehow have no poster.
+ image = (
+ f"{settings.app_base}/api/public/watch/{token}/poster.jpg"
+ if asset.poster_path
+ else asset.thumbnail_url
+ )
tags = [
f"
{html.escape(title)}",
_tag("og:title", title),
diff --git a/backend/app/models.py b/backend/app/models.py
index 574e52c..68488d6 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -722,6 +722,9 @@ class MediaAsset(Base, TimestampMixin):
) # "pending" | "ready" | "error"
rel_path: Mapped[str | None] = mapped_column(Text) # relative to download_root
storyboard_path: Mapped[str | None] = mapped_column(Text) # phase-2 filmstrip mosaic
+ # A locally-generated poster frame (ffmpeg), set only when the source had NO thumbnail so the
+ # card / watch page / link preview can still show an image. Relative to download_root.
+ poster_path: Mapped[str | None] = mapped_column(Text)
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
duration_s: Mapped[int | None] = mapped_column(Integer)
width: Mapped[int | None] = mapped_column(Integer)
diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py
index f222f9b..ee5490e 100644
--- a/backend/app/routes/downloads.py
+++ b/backend/app/routes/downloads.py
@@ -97,6 +97,10 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"source_kind": job.source_kind,
"source_ref": job.source_ref,
"source_url": _reference_url(job, asset),
+ # A locally-generated poster to fall back to when the source had no thumbnail.
+ "poster_url": f"/api/downloads/{job.id}/poster.jpg"
+ if (asset is not None and asset.poster_path)
+ else None,
"profile_id": job.profile_id,
"spec": job.profile_snapshot,
"display_name": job.display_name,
@@ -588,6 +592,22 @@ def storyboard_meta(
}
+@router.get("/{job_id}/poster.jpg")
+def poster_image(
+ job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
+):
+ """The locally-generated poster frame for a download whose source had no thumbnail."""
+ job = _accessible_job(db, user, job_id)
+ asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
+ if asset is None or not asset.poster_path:
+ raise HTTPException(status_code=404, detail="No poster.")
+ path = storage.abs_path(settings.download_root, asset.poster_path).resolve()
+ root = Path(settings.download_root).resolve()
+ if root not in path.parents or not path.exists():
+ raise HTTPException(status_code=404, detail="No poster.")
+ return FileResponse(path, media_type="image/jpeg")
+
+
@router.get("/{job_id}/storyboard.jpg")
def storyboard_image(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py
index 80a996f..b36f1e9 100644
--- a/backend/app/routes/public.py
+++ b/backend/app/routes/public.py
@@ -58,6 +58,13 @@ def _file_url(token: str, grant: str | None = None) -> str:
return f"{base}?g={grant}" if grant else base
+def _poster_url(token: str, asset, grant: str | None = None) -> str | None:
+ if not asset.poster_path:
+ return None
+ base = f"/api/public/watch/{token}/poster.jpg"
+ return f"{base}?g={grant}" if grant else base
+
+
@router.get("/watch/{token}")
def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
"""Public metadata for the watch page. For a password link, returns only `needs_password`
@@ -68,7 +75,10 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
job, asset = _ready_target(db, link)
link.view_count += 1
db.commit()
- return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token))}
+ return {
+ "needs_password": False,
+ **linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
+ }
@router.post("/watch/{token}/unlock")
@@ -80,14 +90,22 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di
if not link.password_hash:
# Not a password link — nothing to unlock; just hand back the plain meta.
job, asset = _ready_target(db, link)
- return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token))}
+ return {
+ "needs_password": False,
+ **linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
+ }
if not verify_password((payload.get("password") or ""), link.password_hash):
raise HTTPException(status_code=403, detail="Wrong password.")
job, asset = _ready_target(db, link)
link.view_count += 1
db.commit()
grant = linksmod.make_grant(token)
- return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token, grant))}
+ return {
+ "needs_password": False,
+ **linksmod.public_meta(
+ link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
+ ),
+ }
@router.get("/watch/{token}/file")
@@ -111,3 +129,20 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
disposition = "attachment" if link.allow_download else "inline"
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
return FileResponse(path, filename=filename, content_disposition_type=disposition)
+
+
+@router.get("/watch/{token}/poster.jpg")
+def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)):
+ """The generated poster frame for a shared thumbnail-less video (used as the page/video poster
+ and the link-preview image). Password links require a valid grant, like the file."""
+ link = _link_or_404(db, token)
+ if link.password_hash and not linksmod.check_grant(token, g):
+ raise HTTPException(status_code=403, detail="This link needs a password.")
+ job, asset = _ready_target(db, link)
+ if not asset.poster_path:
+ raise HTTPException(status_code=404, detail="No poster.")
+ path = storage.abs_path(settings.download_root, asset.poster_path).resolve()
+ root = Path(settings.download_root).resolve()
+ if root not in path.parents or not path.exists():
+ raise HTTPException(status_code=404, detail="No poster.")
+ return FileResponse(path, media_type="image/jpeg")
diff --git a/backend/app/worker.py b/backend/app/worker.py
index 3f60a11..ab06b3f 100644
--- a/backend/app/worker.py
+++ b/backend/app/worker.py
@@ -209,6 +209,21 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
db.commit()
+def _uploader_url(info: dict) -> str | None:
+ """The channel/uploader page URL for the auto-clickable channel line. YouTube gives a
+ `channel_url`/`uploader_url` directly; Facebook gives neither but exposes a numeric
+ `uploader_id` (the page id) that resolves at facebook.com/. Returns None for sources with
+ no derivable channel page (e.g. reddit / a raw media URL)."""
+ url = info.get("channel_url") or info.get("uploader_url")
+ if url:
+ return url
+ uid = info.get("uploader_id")
+ tag = f"{info.get('extractor_key') or info.get('extractor') or ''} {info.get('webpage_url') or ''}".lower()
+ if uid and "facebook" in tag:
+ return f"https://www.facebook.com/{uid}"
+ return None
+
+
def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
"""Populate an asset's display metadata from yt-dlp's info_dict the first time it's seen
(only if not already filled at enqueue from our catalog) — so an ad-hoc URL's row shows a
@@ -219,7 +234,7 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
return
asset.title = normalize_title(info.get("title"))
asset.uploader = info.get("uploader") or info.get("channel")
- asset.uploader_url = info.get("channel_url") or info.get("uploader_url")
+ asset.uploader_url = _uploader_url(info)
asset.thumbnail_url = info.get("thumbnail")
asset.duration_s = int(info["duration"]) if info.get("duration") else None
asset.upload_date = _parse_upload_date(info.get("upload_date"))
@@ -410,9 +425,14 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
asset.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None
asset.title = meta.title
asset.uploader = meta.uploader
- asset.uploader_url = info.get("channel_url") or info.get("uploader_url")
+ asset.uploader_url = _uploader_url(info)
asset.upload_date = meta.upload_date
asset.thumbnail_url = meta.thumbnail_url
+ # Record a self-hosted poster for every download: it's the reliable link-preview
+ # (og:image) source — a remote thumbnail, esp. Facebook's signed CDN URL, expires
+ # and can't be relied on cross-origin. write_sidecars already wrote .jpg from
+ # the thumbnail; ensure_poster returns that, or cuts a frame if there was none.
+ asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
asset.source_webpage_url = info.get("webpage_url")
asset.nfo_written = nfo_ok
asset.error = None
diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx
index 4db484e..d67eae6 100644
--- a/frontend/src/components/DownloadCenter.tsx
+++ b/frontend/src/components/DownloadCenter.tsx
@@ -66,7 +66,8 @@ function StatusPill({ job }: { job: DownloadJob }) {
}
function Thumb({ job }: { job: DownloadJob }) {
- const url = job.asset?.thumbnail_url;
+ // Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources.
+ const url = job.asset?.thumbnail_url || job.poster_url;
return (
{url ?

: null}
diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx
index 16699ba..f07d22f 100644
--- a/frontend/src/components/Modal.tsx
+++ b/frontend/src/components/Modal.tsx
@@ -23,6 +23,11 @@ export default function Modal({
// Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose);
+ // Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
+ // drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
+ // the backdrop wrongly dismisses — the click event bubbles to the common ancestor (the backdrop).
+ const pressedOnBackdrop = useRef(false);
+
// Keep a stable handler across renders so the stack id is assigned once per mount.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
@@ -48,13 +53,17 @@ export default function Modal({
return createPortal(
{
+ pressedOnBackdrop.current = e.target === e.currentTarget;
+ }}
+ onClick={(e) => {
+ if (pressedOnBackdrop.current && e.target === e.currentTarget) onClose();
+ }}
role="dialog"
aria-modal="true"
>
e.stopPropagation()}
>
{title}
diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx
index e443a9e..838056c 100644
--- a/frontend/src/components/WatchPage.tsx
+++ b/frontend/src/components/WatchPage.tsx
@@ -15,6 +15,7 @@ type Meta = {
duration_s?: number | null;
allow_download?: boolean;
file_url?: string;
+ poster_url?: string | null;
};
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
@@ -175,6 +176,7 @@ export default function WatchPage() {
controls
playsInline
src={meta.file_url}
+ poster={meta.poster_url || undefined}
className="max-h-[80vh] max-w-full mx-auto block"
/>
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 9b5058c..431a3f4 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -901,6 +901,7 @@ export interface DownloadJob {
source_kind: string;
source_ref: string;
source_url: string | null; // clean "downloaded from" URL (null for edit clips)
+ poster_url: string | null; // locally-generated poster, fallback when the source had no thumbnail
profile_id: number | null;
spec: DownloadSpec;
display_name: string | null;
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index 7c00035..6147913 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,18 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
+ {
+ version: "0.32.0",
+ date: "2026-07-07",
+ summary: "Clickable channels, poster images for any source, and a dialog-dismiss fix.",
+ features: [
+ "Downloads: the auto-detected channel under a download is now a clickable link to the real channel for YouTube and Facebook sources. Your existing downloads were backfilled, so they're clickable too.",
+ "Downloads: a video from a source with no thumbnail (e.g. a direct Reddit video link) now gets a poster image generated from the video itself — shown on the card, on the shared watch page, and in rich link previews. Shared-link previews now use this self-hosted image, so they render reliably in chat apps and don't break over time. Existing downloads were backfilled.",
+ ],
+ fixes: [
+ "Dialogs no longer close by accident when you press the mouse inside (e.g. selecting text in a field), drag outside, and release — a popup now only closes when the click both starts and ends on the backdrop.",
+ ],
+ },
{
version: "0.31.1",
date: "2026-07-07",