Merge: promote dev to prod
This commit is contained in:
commit
557e9e988c
15 changed files with 281 additions and 11 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.31.1
|
0.32.0
|
||||||
|
|
|
||||||
28
backend/alembic/versions/0050_asset_poster.py
Normal file
28
backend/alembic/versions/0050_asset_poster.py
Normal file
|
|
@ -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")
|
||||||
96
backend/app/downloads/backfill.py
Normal file
96
backend/app/downloads/backfill.py
Normal file
|
|
@ -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 <base>.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
|
||||||
|
|
@ -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
|
||||||
|
`<base>.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:
|
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.
|
"""Return the filmstrip meta for a ready source asset, generating the sprite on first use.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
"""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
|
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,
|
"height": asset.height,
|
||||||
"allow_download": link.allow_download,
|
"allow_download": link.allow_download,
|
||||||
"file_url": file_url,
|
"file_url": file_url,
|
||||||
|
"poster_url": poster_url,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,15 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||||
return None
|
return None
|
||||||
title = (job.display_name or asset.title or "Video").strip()
|
title = (job.display_name or asset.title or "Video").strip()
|
||||||
channel = (job.display_uploader or asset.uploader or "").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 = [
|
tags = [
|
||||||
f"<title>{html.escape(title)}</title>",
|
f"<title>{html.escape(title)}</title>",
|
||||||
_tag("og:title", title),
|
_tag("og:title", title),
|
||||||
|
|
|
||||||
|
|
@ -722,6 +722,9 @@ class MediaAsset(Base, TimestampMixin):
|
||||||
) # "pending" | "ready" | "error"
|
) # "pending" | "ready" | "error"
|
||||||
rel_path: Mapped[str | None] = mapped_column(Text) # relative to download_root
|
rel_path: Mapped[str | None] = mapped_column(Text) # relative to download_root
|
||||||
storyboard_path: Mapped[str | None] = mapped_column(Text) # phase-2 filmstrip mosaic
|
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)
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
duration_s: Mapped[int | None] = mapped_column(Integer)
|
duration_s: Mapped[int | None] = mapped_column(Integer)
|
||||||
width: Mapped[int | None] = mapped_column(Integer)
|
width: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,10 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||||
"source_kind": job.source_kind,
|
"source_kind": job.source_kind,
|
||||||
"source_ref": job.source_ref,
|
"source_ref": job.source_ref,
|
||||||
"source_url": _reference_url(job, asset),
|
"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,
|
"profile_id": job.profile_id,
|
||||||
"spec": job.profile_snapshot,
|
"spec": job.profile_snapshot,
|
||||||
"display_name": job.display_name,
|
"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")
|
@router.get("/{job_id}/storyboard.jpg")
|
||||||
def storyboard_image(
|
def storyboard_image(
|
||||||
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,13 @@ def _file_url(token: str, grant: str | None = None) -> str:
|
||||||
return f"{base}?g={grant}" if grant else base
|
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}")
|
@router.get("/watch/{token}")
|
||||||
def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
|
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`
|
"""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)
|
job, asset = _ready_target(db, link)
|
||||||
link.view_count += 1
|
link.view_count += 1
|
||||||
db.commit()
|
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")
|
@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:
|
if not link.password_hash:
|
||||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||||
job, asset = _ready_target(db, link)
|
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):
|
if not verify_password((payload.get("password") or ""), link.password_hash):
|
||||||
raise HTTPException(status_code=403, detail="Wrong password.")
|
raise HTTPException(status_code=403, detail="Wrong password.")
|
||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
link.view_count += 1
|
link.view_count += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
grant = linksmod.make_grant(token)
|
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")
|
@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"
|
disposition = "attachment" if link.allow_download else "inline"
|
||||||
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
|
# Starlette's FileResponse honours the Range header (206) for seeking/scrubbing.
|
||||||
return FileResponse(path, filename=filename, content_disposition_type=disposition)
|
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")
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,21 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
|
||||||
db.commit()
|
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/<id>. 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:
|
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
|
"""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
|
(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
|
return
|
||||||
asset.title = normalize_title(info.get("title"))
|
asset.title = normalize_title(info.get("title"))
|
||||||
asset.uploader = info.get("uploader") or info.get("channel")
|
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.thumbnail_url = info.get("thumbnail")
|
||||||
asset.duration_s = int(info["duration"]) if info.get("duration") else None
|
asset.duration_s = int(info["duration"]) if info.get("duration") else None
|
||||||
asset.upload_date = _parse_upload_date(info.get("upload_date"))
|
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.acodec = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
||||||
asset.title = meta.title
|
asset.title = meta.title
|
||||||
asset.uploader = meta.uploader
|
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.upload_date = meta.upload_date
|
||||||
asset.thumbnail_url = meta.thumbnail_url
|
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 <base>.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.source_webpage_url = info.get("webpage_url")
|
||||||
asset.nfo_written = nfo_ok
|
asset.nfo_written = nfo_ok
|
||||||
asset.error = None
|
asset.error = None
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@ function StatusPill({ job }: { job: DownloadJob }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Thumb({ 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 (
|
return (
|
||||||
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
|
||||||
{url ? <img src={url} alt="" loading="lazy" className="w-full h-full object-cover" /> : null}
|
{url ? <img src={url} alt="" loading="lazy" className="w-full h-full object-cover" /> : null}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ export default function Modal({
|
||||||
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
// Browser/mouse Back closes the topmost modal instead of navigating away.
|
||||||
useBackToClose(onClose);
|
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.
|
// Keep a stable handler across renders so the stack id is assigned once per mount.
|
||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
onCloseRef.current = onClose;
|
onCloseRef.current = onClose;
|
||||||
|
|
@ -48,13 +53,17 @@ export default function Modal({
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onMouseDown={(e) => {
|
||||||
|
pressedOnBackdrop.current = e.target === e.currentTarget;
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (pressedOnBackdrop.current && e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl shadow-2xl`}
|
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl shadow-2xl`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
<div className="flex items-start justify-between gap-3 p-5 pb-3">
|
||||||
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
|
<h2 className="text-lg font-semibold leading-snug">{title}</h2>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ type Meta = {
|
||||||
duration_s?: number | null;
|
duration_s?: number | null;
|
||||||
allow_download?: boolean;
|
allow_download?: boolean;
|
||||||
file_url?: string;
|
file_url?: string;
|
||||||
|
poster_url?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
|
// 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
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
src={meta.file_url}
|
src={meta.file_url}
|
||||||
|
poster={meta.poster_url || undefined}
|
||||||
className="max-h-[80vh] max-w-full mx-auto block"
|
className="max-h-[80vh] max-w-full mx-auto block"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -901,6 +901,7 @@ export interface DownloadJob {
|
||||||
source_kind: string;
|
source_kind: string;
|
||||||
source_ref: string;
|
source_ref: string;
|
||||||
source_url: string | null; // clean "downloaded from" URL (null for edit clips)
|
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;
|
profile_id: number | null;
|
||||||
spec: DownloadSpec;
|
spec: DownloadSpec;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,18 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.31.1",
|
||||||
date: "2026-07-07",
|
date: "2026-07-07",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue