merge: downloads metadata editing + rich share-link previews — v0.31.0

This commit is contained in:
npeter83 2026-07-07 20:19:51 +02:00
commit ccbcc34bd3
17 changed files with 448 additions and 45 deletions

View file

@ -1 +1 @@
0.30.0 0.31.0

View file

@ -0,0 +1,38 @@
"""per-download display metadata + auto channel URL
Revision ID: 0049_download_display_meta
Revises: 0048_plex_playlists
Create Date: 2026-07-07
Lets a user override a download's channel and attach extra reference links (beyond the source),
and captures yt-dlp's canonical channel/uploader URL so an auto-filled channel becomes clickable.
- media_assets.uploader_url: the channel page URL yt-dlp recorded (nullable; filled by the worker
on extraction older rows just stay plain text).
- download_jobs.display_uploader / display_uploader_url: per-user channel-name + channel-link
overrides (like display_name display metadata only, never touches the on-disk file).
- download_jobs.extra_links: optional list of extra reference URLs shown on the card + watch page.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0049_download_display_meta"
down_revision: Union[str, None] = "0048_plex_playlists"
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("uploader_url", sa.Text(), nullable=True))
op.add_column("download_jobs", sa.Column("display_uploader", sa.String(length=255), nullable=True))
op.add_column("download_jobs", sa.Column("display_uploader_url", sa.Text(), nullable=True))
op.add_column("download_jobs", sa.Column("extra_links", sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column("download_jobs", "extra_links")
op.drop_column("download_jobs", "display_uploader_url")
op.drop_column("download_jobs", "display_uploader")
op.drop_column("media_assets", "uploader_url")

View file

@ -68,11 +68,26 @@ def owner_view(link: DownloadLink, base_url: str = "") -> dict:
} }
def public_meta(link: DownloadLink, asset, file_url: str) -> dict: def public_meta(link: DownloadLink, job, asset, file_url: str) -> 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
auto-extracted values, and surfaces the source + any extra reference links as clickable URLs."""
from app.downloads import service
source_url = None
if job is not None and job.job_kind != "edit":
source_url = asset.source_webpage_url or (
service.source_url(job.source_kind, job.source_ref)
if job.source_kind in ("youtube", "url")
else None
)
return { return {
"title": asset.title, "title": (job and job.display_name) or asset.title,
"uploader": asset.uploader, "uploader": (job and job.display_uploader) or asset.uploader,
"uploader_url": (job and job.display_uploader_url) or asset.uploader_url,
"source_url": source_url,
"extra_links": (job and job.extra_links) or [],
"duration_s": asset.duration_s, "duration_s": asset.duration_s,
"width": asset.width, "width": asset.width,
"height": asset.height, "height": asset.height,

View file

@ -0,0 +1,85 @@
"""Server-rendered Open Graph tags for the public /watch/{token} page.
The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, ) that
fetches /watch/{token} would otherwise only see the generic index.html a blank/greyed link card.
This injects per-video OG/Twitter tags into the served HTML so a shared link unfurls with the
video's title, channel and thumbnail. Real browsers ignore the extra tags and hydrate the SPA as
usual. Password-protected / expired / invalid links fall back to the generic card (no metadata leak).
"""
import html
import re
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import links as linksmod
from app.models import DownloadJob, DownloadLink, MediaAsset
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
def _tag(prop: str, content: str, attr: str = "property") -> str:
return f'<meta {attr}="{prop}" content="{html.escape(content, quote=True)}" />'
def _watch_tags(token: str, db: Session) -> str | None:
"""Build the OG/Twitter meta block for a watch token, or None to keep the generic card."""
link = db.execute(
select(DownloadLink).where(DownloadLink.token == token)
).scalar_one_or_none()
if link is None or linksmod.is_expired(link):
return None
url = f"{settings.app_base}/watch/{token}"
if link.password_hash:
# Password-gated: don't leak the title/thumbnail in the unfurl.
return "\n ".join(
[
"<title>Siftlode</title>",
_tag("og:title", "Password-protected video"),
_tag("og:description", "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
]
)
job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if job is None or asset is None or asset.status != "ready":
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
tags = [
f"<title>{html.escape(title)}</title>",
_tag("og:title", title),
_tag("og:description", channel or "Shared via Siftlode"),
_tag("og:type", "video.other"),
_tag("og:url", url),
_tag("og:site_name", "Siftlode"),
_tag("twitter:title", title, attr="name"),
]
if channel:
tags.append(_tag("twitter:description", channel, attr="name"))
if image:
# A real thumbnail → a large image card; without one, a plain (text) card is served.
tags.append(_tag("og:image", image))
tags.append(_tag("twitter:card", "summary_large_image", attr="name"))
tags.append(_tag("twitter:image", image, attr="name"))
else:
tags.append(_tag("twitter:card", "summary", attr="name"))
return "\n ".join(tags)
def render_watch_html(token: str, index_html: Path, db: Session) -> str | None:
"""Return index.html with the OG block replaced for this watch token, or None to serve the
generic page unchanged (invalid/expired/not-ready link, or a template without the markers)."""
tags = _watch_tags(token, db)
if tags is None:
return None
text = index_html.read_text(encoding="utf-8")
if "<!--OG:START-->" not in text:
return None
# A function replacement avoids re.sub interpreting backslashes/group refs in `tags`.
return _OG_BLOCK.sub(lambda _m: tags, text, count=1)

View file

@ -20,7 +20,7 @@ if not _siftlode_logger.handlers:
log = logging.getLogger("siftlode.app") log = logging.getLogger("siftlode.app")
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
@ -178,6 +178,21 @@ async def index() -> FileResponse:
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS) return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
@app.get("/watch/{token}")
async def watch_page(token: str):
"""Serve the SPA for a public share link, but with per-video Open Graph tags injected so the
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
ignores the extra tags and hydrates WatchPage as usual; invalid/expired/password links fall
back to the generic card."""
from app.downloads import og
with SessionLocal() as db:
rendered = og.render_watch_html(token, INDEX_HTML, db)
if rendered is None:
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
return HTMLResponse(rendered, headers=INDEX_HEADERS)
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def spa_fallback(full_path: str) -> FileResponse: async def spa_fallback(full_path: str) -> FileResponse:
# Client-side routes fall back to index.html; real API/asset paths are matched above. # Client-side routes fall back to index.html; real API/asset paths are matched above.

View file

@ -739,6 +739,9 @@ class MediaAsset(Base, TimestampMixin):
# Denormalized naming/metadata for the Plex tree + ad-hoc display. # Denormalized naming/metadata for the Plex tree + ad-hoc display.
title: Mapped[str | None] = mapped_column(Text) title: Mapped[str | None] = mapped_column(Text)
uploader: Mapped[str | None] = mapped_column(String(255)) uploader: Mapped[str | None] = mapped_column(String(255))
# yt-dlp's channel/uploader page URL (channel_url|uploader_url) — lets an auto-filled channel
# render as a clickable link. Null for sources without one (e.g. reddit) or older rows.
uploader_url: Mapped[str | None] = mapped_column(Text)
upload_date: Mapped[date | None] = mapped_column(Date) upload_date: Mapped[date | None] = mapped_column(Date)
thumbnail_url: Mapped[str | None] = mapped_column(Text) thumbnail_url: Mapped[str | None] = mapped_column(Text)
# yt-dlp's canonical page URL for the source (webpage_url) — the clean "downloaded from" # yt-dlp's canonical page URL for the source (webpage_url) — the clean "downloaded from"
@ -821,6 +824,12 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
) )
profile_snapshot: Mapped[dict] = mapped_column(JSON) profile_snapshot: Mapped[dict] = mapped_column(JSON)
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
# Per-user display overrides + extras (like display_name — metadata only, never the file).
# `display_uploader`/`display_uploader_url` override the asset's auto channel name/link;
# `extra_links` is an optional list of extra reference URLs shown on the card + watch page.
display_uploader: Mapped[str | None] = mapped_column(String(255))
display_uploader_url: Mapped[str | None] = mapped_column(Text)
extra_links: Mapped[list | None] = mapped_column(JSON)
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
String(16), default="queued", server_default="queued", index=True String(16), default="queued", server_default="queued", index=True
) # queued | running | paused | done | error | canceled ) # queued | running | paused | done | error | canceled

View file

@ -100,6 +100,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"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,
"display_uploader": job.display_uploader,
"display_uploader_url": job.display_uploader_url,
"extra_links": job.extra_links or [],
"job_kind": job.job_kind, "job_kind": job.job_kind,
"source_job_id": job.source_job_id, "source_job_id": job.source_job_id,
"edit_spec": job.edit_spec, "edit_spec": job.edit_spec,
@ -118,6 +121,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"width": asset.width, "width": asset.width,
"height": asset.height, "height": asset.height,
"container": asset.container, "container": asset.container,
"uploader_url": asset.uploader_url,
"thumbnail_url": asset.thumbnail_url, "thumbnail_url": asset.thumbnail_url,
"expires_at": asset.expires_at.isoformat() if asset.expires_at else None, "expires_at": asset.expires_at.isoformat() if asset.expires_at else None,
}, },
@ -380,17 +384,47 @@ def shared_with_me(
return out return out
_MAX_EXTRA_LINKS = 8
def _clean_url(raw) -> str | None:
"""Accept only a plausible http(s) URL (these get rendered as clickable links + embedded in a
public watch page); anything else is dropped rather than trusted."""
if not isinstance(raw, str):
return None
url = raw.strip()
if not url:
return None
if not url.startswith(("http://", "https://")):
return None
parsed = urlparse(url)
if not parsed.netloc:
return None
return url[:2048]
@router.patch("/{job_id}") @router.patch("/{job_id}")
def rename_download( def update_download_meta(
job_id: int, job_id: int,
payload: dict, payload: dict,
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Update a download's display metadata: title, channel name + link, and extra reference URLs.
All are display-only overrides the on-disk file keeps its system-decided, id-bound name."""
job = _own_job(db, user, job_id) job = _own_job(db, user, job_id)
name = (payload.get("display_name") or "").strip() if "display_name" in payload:
# Display name only — the on-disk file keeps its system-decided, id-bound name. job.display_name = ((payload.get("display_name") or "").strip())[:255] or None
job.display_name = name[:255] or None if "display_uploader" in payload:
job.display_uploader = ((payload.get("display_uploader") or "").strip())[:255] or None
if "display_uploader_url" in payload:
job.display_uploader_url = _clean_url(payload.get("display_uploader_url"))
if "extra_links" in payload:
raw = payload.get("extra_links") or []
if not isinstance(raw, list):
raise HTTPException(status_code=400, detail="extra_links must be a list")
cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS]
job.extra_links = cleaned or None
db.commit() db.commit()
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
return _serialize(job, asset) return _serialize(job, asset)

View file

@ -35,10 +35,10 @@ def _link_or_404(db: Session, token: str) -> DownloadLink:
return link return link
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset: def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
job = db.get(DownloadJob, link.job_id) job = db.get(DownloadJob, link.job_id)
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
if asset is None or asset.status != "ready" or not asset.rel_path: if job is None or asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=404, detail="This video is no longer available.") raise HTTPException(status_code=404, detail="This video is no longer available.")
# Verify the file is actually on disk here (not just flagged ready), so the watch page never # Verify the file is actually on disk here (not just flagged ready), so the watch page never
# loads a player for a missing file — meta and file agree. # loads a player for a missing file — meta and file agree.
@ -46,7 +46,11 @@ def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
root = Path(settings.download_root).resolve() root = Path(settings.download_root).resolve()
if root not in path.parents or not path.exists(): if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="This video is no longer available.") raise HTTPException(status_code=404, detail="This video is no longer available.")
return asset return job, asset
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
return _ready_target(db, link)[1]
def _file_url(token: str, grant: str | None = None) -> str: def _file_url(token: str, grant: str | None = None) -> str:
@ -61,10 +65,10 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
link = _link_or_404(db, token) link = _link_or_404(db, token)
if link.password_hash: if link.password_hash:
return {"needs_password": True} return {"needs_password": True}
asset = _ready_asset(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, asset, _file_url(token))} return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token))}
@router.post("/watch/{token}/unlock") @router.post("/watch/{token}/unlock")
@ -75,15 +79,15 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di
link = _link_or_404(db, token) link = _link_or_404(db, token)
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.
asset = _ready_asset(db, link) job, asset = _ready_target(db, link)
return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token))} return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token))}
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.")
asset = _ready_asset(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, asset, _file_url(token, grant))} return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token, grant))}
@router.get("/watch/{token}/file") @router.get("/watch/{token}/file")

View file

@ -218,6 +218,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.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"))
@ -408,6 +409,7 @@ 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.upload_date = meta.upload_date asset.upload_date = meta.upload_date
asset.thumbnail_url = meta.thumbnail_url asset.thumbnail_url = meta.thumbnail_url
asset.source_webpage_url = info.get("webpage_url") asset.source_webpage_url = info.get("webpage_url")

View file

@ -4,17 +4,21 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- OG:START — the server replaces this block for /watch/{token} so shared links unfurl with
the video's title/channel/thumbnail; every other route keeps these generic defaults. -->
<!--OG:START-->
<title>Siftlode</title> <title>Siftlode</title>
<meta <meta
name="description" name="description"
content="Siftlode is a self-hosted, multi-user reader for your YouTube subscriptions — filter, sort, tag and search your feed, build playlists that sync both ways, and watch in a clean, ad-free interface." content="A self-hosted, multi-user reader, subscription organizer and media center with download capabilities."
/> />
<meta property="og:title" content="Siftlode" /> <meta property="og:title" content="Siftlode" />
<meta <meta
property="og:description" property="og:description"
content="A self-hosted, multi-user reader for your YouTube subscriptions — filter, sort, tag and search, build two-way-syncing playlists, and watch ad-free." content="A self-hosted, multi-user reader, subscription organizer and media center with download capabilities."
/> />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<!--OG:END-->
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -78,6 +78,15 @@ function jobTitle(job: DownloadJob): string {
return job.display_name || job.asset?.title || job.source_ref; return job.display_name || job.asset?.title || job.source_ref;
} }
// The channel to show under the title: a user override wins over the auto-extracted asset value.
// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
function jobChannel(job: DownloadJob): { name: string; url: string | null } {
return {
name: job.display_uploader || job.asset?.uploader || job.source_ref,
url: job.display_uploader_url || job.asset?.uploader_url || null,
};
}
function IconBtn({ function IconBtn({
onClick, onClick,
title, title,
@ -148,6 +157,27 @@ function SourceRef({ url }: { url: string }) {
); );
} }
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
function ChannelLine({ job }: { job: DownloadJob }) {
const { name, url } = jobChannel(job);
if (url) {
return (
<div className="text-xs mt-0.5 truncate">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="text-muted hover:text-accent hover:underline"
>
{name}
</a>
</div>
);
}
return <div className="text-xs text-muted truncate mt-0.5">{name}</div>;
}
function DownloadRow({ function DownloadRow({
job, job,
children, children,
@ -169,9 +199,7 @@ function DownloadRow({
)} )}
<span className="truncate">{jobTitle(job)}</span> <span className="truncate">{jobTitle(job)}</span>
</div> </div>
<div className="text-xs text-muted truncate mt-0.5"> <ChannelLine job={job} />
{job.asset?.uploader || job.source_ref}
</div>
<div className="flex items-center gap-2 mt-1 text-xs"> <div className="flex items-center gap-2 mt-1 text-xs">
<StatusPill job={job} /> <StatusPill job={job} />
{job.asset?.size_bytes ? <span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span> : null} {job.asset?.size_bytes ? <span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span> : null}
@ -179,6 +207,7 @@ function DownloadRow({
{job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null} {job.error ? <span className="text-red-400 truncate">· {job.error}</span> : null}
</div> </div>
{job.source_url && <SourceRef url={job.source_url} />} {job.source_url && <SourceRef url={job.source_url} />}
{job.extra_links?.map((url) => <SourceRef key={url} url={url} />)}
{running && ( {running && (
<div className="mt-1.5"> <div className="mt-1.5">
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( {job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
@ -208,30 +237,89 @@ function DownloadRow({
// --- Rename + Share small modals ------------------------------------------------------------ // --- Rename + Share small modals ------------------------------------------------------------
function RenameModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) { // Edit a download's display metadata: title, channel (name + link), and extra reference links.
// All are display-only overrides — the on-disk file is never renamed.
function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [name, setName] = useState(jobTitle(job)); const [name, setName] = useState(job.display_name || job.asset?.title || "");
const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || "");
const [channelUrl, setChannelUrl] = useState(job.display_uploader_url || job.asset?.uploader_url || "");
// Editable extra-link rows; keep one blank row so there's always somewhere to type.
const [links, setLinks] = useState<string[]>(() => [...(job.extra_links ?? []), ""]);
const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l)));
const addLink = () => setLinks((ls) => [...ls, ""]);
const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i));
const save = useMutation({ const save = useMutation({
mutationFn: () => api.renameDownload(job.id, name.trim()), mutationFn: () =>
api.updateDownloadMeta(job.id, {
display_name: name.trim(),
display_uploader: channel.trim(),
display_uploader_url: channelUrl.trim(),
extra_links: links.map((l) => l.trim()).filter(Boolean),
}),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["downloads"] }); qc.invalidateQueries({ queryKey: ["downloads"] });
onClose(); onClose();
}, },
}); });
return ( return (
<Modal title={t("downloads.rename.title")} onClose={onClose}> <Modal title={t("downloads.edit.title")} onClose={onClose}>
<label className="block text-sm font-medium mb-1">{t("downloads.rename.label")}</label> <div className="space-y-3">
<input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} autoFocus /> <label className="block">
<p className="text-xs text-muted mt-2 mb-4">{t("downloads.rename.hint")}</p> <span className="block text-sm font-medium mb-1">{t("downloads.edit.name")}</span>
<div className="flex justify-end"> <input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} autoFocus />
<button </label>
onClick={() => save.mutate()} <label className="block">
disabled={!name.trim() || save.isPending} <span className="block text-sm font-medium mb-1">{t("downloads.edit.channel")}</span>
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition" <input value={channel} onChange={(e) => setChannel(e.target.value)} className={inputCls} />
> </label>
{t("downloads.rename.save")} <label className="block">
</button> <span className="block text-sm font-medium mb-1">{t("downloads.edit.channelUrl")}</span>
<input
value={channelUrl}
onChange={(e) => setChannelUrl(e.target.value)}
placeholder="https://…"
className={inputCls}
/>
</label>
<div>
<span className="block text-sm font-medium mb-1">{t("downloads.edit.extraLinks")}</span>
<div className="space-y-2">
{links.map((l, i) => (
<div key={i} className="flex gap-2">
<input
value={l}
onChange={(e) => setLink(i, e.target.value)}
placeholder="https://…"
className={inputCls}
/>
<IconBtn onClick={() => removeLink(i)} title={t("downloads.edit.removeLink")} danger>
<Trash2 className="w-4 h-4" />
</IconBtn>
</div>
))}
</div>
<button
onClick={addLink}
className="mt-2 inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg transition"
>
<Plus className="w-4 h-4" /> {t("downloads.edit.addLink")}
</button>
</div>
<p className="text-xs text-muted">{t("downloads.edit.hint")}</p>
<div className="flex justify-end">
<button
onClick={() => save.mutate()}
disabled={!name.trim() || save.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{t("downloads.edit.save")}
</button>
</div>
</div> </div>
</Modal> </Modal>
); );
@ -579,7 +667,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
<Scissors className="w-4 h-4" /> <Scissors className="w-4 h-4" />
</IconBtn> </IconBtn>
)} )}
<IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.rename")}> <IconBtn onClick={() => setRenameJob(job)} title={t("downloads.actions.editDetails")}>
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</IconBtn> </IconBtn>
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}> <IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}>
@ -643,7 +731,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
/> />
)} )}
{manage && <ProfileEditor onClose={() => setManage(false)} />} {manage && <ProfileEditor onClose={() => setManage(false)} />}
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />} {renameJob && <MetaEditModal job={renameJob} onClose={() => setRenameJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />} {shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />} {editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense> </Suspense>

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Download, Lock, PlayCircle } from "lucide-react"; import { Download, Link2, Lock, PlayCircle } from "lucide-react";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app // Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny // tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
@ -9,11 +9,24 @@ type Meta = {
needs_password: boolean; needs_password: boolean;
title?: string | null; title?: string | null;
uploader?: string | null; uploader?: string | null;
uploader_url?: string | null;
source_url?: string | null;
extra_links?: string[];
duration_s?: number | null; duration_s?: number | null;
allow_download?: boolean; allow_download?: boolean;
file_url?: string; file_url?: string;
}; };
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
function displayUrl(url: string): string {
try {
const u = new URL(url);
return (u.host + u.pathname + u.search).replace(/\/$/, "");
} catch {
return url;
}
}
const STR = { const STR = {
en: { en: {
loading: "Loading…", loading: "Loading…",
@ -24,6 +37,7 @@ const STR = {
watch: "Watch", watch: "Watch",
wrong: "Wrong password.", wrong: "Wrong password.",
download: "Download", download: "Download",
links: "Links",
brand: "Shared via Siftlode", brand: "Shared via Siftlode",
}, },
hu: { hu: {
@ -35,6 +49,7 @@ const STR = {
watch: "Megnézem", watch: "Megnézem",
wrong: "Hibás jelszó.", wrong: "Hibás jelszó.",
download: "Letöltés", download: "Letöltés",
links: "Linkek",
brand: "Megosztva a Siftlode-dal", brand: "Megosztva a Siftlode-dal",
}, },
de: { de: {
@ -46,6 +61,7 @@ const STR = {
watch: "Ansehen", watch: "Ansehen",
wrong: "Falsches Passwort.", wrong: "Falsches Passwort.",
download: "Herunterladen", download: "Herunterladen",
links: "Links",
brand: "Geteilt über Siftlode", brand: "Geteilt über Siftlode",
}, },
} as const; } as const;
@ -165,7 +181,19 @@ export default function WatchPage() {
<div className="mt-3 flex items-start gap-3"> <div className="mt-3 flex items-start gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1> <h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
{meta.uploader && <div className="text-sm text-slate-400">{meta.uploader}</div>} {meta.uploader &&
(meta.uploader_url ? (
<a
href={meta.uploader_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-400 hover:text-teal-400 hover:underline"
>
{meta.uploader}
</a>
) : (
<div className="text-sm text-slate-400">{meta.uploader}</div>
))}
</div> </div>
{meta.allow_download && ( {meta.allow_download && (
<a <a
@ -176,6 +204,25 @@ export default function WatchPage() {
</a> </a>
)} )}
</div> </div>
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
<div className="mt-3 flex flex-col gap-1.5">
{[meta.source_url, ...(meta.extra_links ?? [])]
.filter((u): u is string => !!u)
.map((url) => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
className="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-teal-400 hover:underline min-w-0"
>
<Link2 className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayUrl(url)}</span>
</a>
))}
</div>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -56,6 +56,7 @@
"retry": "Erneut", "retry": "Erneut",
"download": "Auf mein Gerät speichern", "download": "Auf mein Gerät speichern",
"rename": "Umbenennen", "rename": "Umbenennen",
"editDetails": "Details bearbeiten",
"share": "Teilen", "share": "Teilen",
"edit": "Bearbeiten / schneiden", "edit": "Bearbeiten / schneiden",
"removeShared": "Aus meiner Liste entfernen" "removeShared": "Aus meiner Liste entfernen"
@ -83,6 +84,17 @@
"hint": "Ändert nur den angezeigten und beim Speichern verwendeten Namen — die gespeicherte Datei behält ihren Namen.", "hint": "Ändert nur den angezeigten und beim Speichern verwendeten Namen — die gespeicherte Datei behält ihren Namen.",
"save": "Speichern" "save": "Speichern"
}, },
"edit": {
"title": "Details bearbeiten",
"name": "Titel",
"channel": "Kanal",
"channelUrl": "Kanal-Link",
"extraLinks": "Weitere Links",
"addLink": "Link hinzufügen",
"removeLink": "Link entfernen",
"hint": "Nur Anzeige — die gespeicherte Datei behält ihren Namen. Kanal und Links erscheinen auch auf der Karte und auf geteilten Wiedergabeseiten.",
"save": "Speichern"
},
"share": { "share": {
"title": "Download teilen", "title": "Download teilen",
"label": "E-Mail des Empfängers", "label": "E-Mail des Empfängers",

View file

@ -56,6 +56,7 @@
"retry": "Retry", "retry": "Retry",
"download": "Save to my device", "download": "Save to my device",
"rename": "Rename", "rename": "Rename",
"editDetails": "Edit details",
"share": "Share", "share": "Share",
"edit": "Edit / trim", "edit": "Edit / trim",
"removeShared": "Remove from my list" "removeShared": "Remove from my list"
@ -83,6 +84,17 @@
"hint": "Only changes the name you see and download with — the stored file keeps its own name.", "hint": "Only changes the name you see and download with — the stored file keeps its own name.",
"save": "Save" "save": "Save"
}, },
"edit": {
"title": "Edit details",
"name": "Title",
"channel": "Channel",
"channelUrl": "Channel link",
"extraLinks": "Extra links",
"addLink": "Add link",
"removeLink": "Remove link",
"hint": "Display only — the stored file keeps its own name. The channel and links also show on the card and on shared watch pages.",
"save": "Save"
},
"share": { "share": {
"title": "Share download", "title": "Share download",
"label": "Recipient email", "label": "Recipient email",

View file

@ -56,6 +56,7 @@
"retry": "Újra", "retry": "Újra",
"download": "Mentés a gépemre", "download": "Mentés a gépemre",
"rename": "Átnevezés", "rename": "Átnevezés",
"editDetails": "Adatok szerkesztése",
"share": "Megosztás", "share": "Megosztás",
"edit": "Szerkesztés / vágás", "edit": "Szerkesztés / vágás",
"removeShared": "Eltávolítás a listámból" "removeShared": "Eltávolítás a listámból"
@ -83,6 +84,17 @@
"hint": "Csak a megjelenített és letöltéskor használt nevet módosítja — a tárolt fájl neve nem változik.", "hint": "Csak a megjelenített és letöltéskor használt nevet módosítja — a tárolt fájl neve nem változik.",
"save": "Mentés" "save": "Mentés"
}, },
"edit": {
"title": "Adatok szerkesztése",
"name": "Cím",
"channel": "Csatorna",
"channelUrl": "Csatorna link",
"extraLinks": "További linkek",
"addLink": "Link hozzáadása",
"removeLink": "Link eltávolítása",
"hint": "Csak megjelenítés — a tárolt fájl neve nem változik. A csatorna és a linkek a kártyán és a megosztott lejátszó oldalon is megjelennek.",
"save": "Mentés"
},
"share": { "share": {
"title": "Letöltés megosztása", "title": "Letöltés megosztása",
"label": "Címzett e-mail címe", "label": "Címzett e-mail címe",

View file

@ -851,6 +851,7 @@ export interface DownloadAsset {
status: string; status: string;
title: string | null; title: string | null;
uploader: string | null; uploader: string | null;
uploader_url: string | null; // yt-dlp channel/uploader page URL (auto; null for e.g. reddit)
upload_date: string | null; upload_date: string | null;
duration_s: number | null; duration_s: number | null;
size_bytes: number | null; size_bytes: number | null;
@ -903,6 +904,9 @@ export interface DownloadJob {
profile_id: number | null; profile_id: number | null;
spec: DownloadSpec; spec: DownloadSpec;
display_name: string | null; display_name: string | null;
display_uploader: string | null; // user override of the channel name
display_uploader_url: string | null; // user override of the channel link
extra_links: string[]; // extra reference URLs (beyond source_url)
job_kind?: string; // "download" (default) | "edit" job_kind?: string; // "download" (default) | "edit"
source_job_id?: number | null; // parent download an edit was cut from source_job_id?: number | null; // parent download an edit was cut from
edit_spec?: EditSpec | null; edit_spec?: EditSpec | null;
@ -1410,8 +1414,17 @@ export const api = {
cancelDownload: (id: number): Promise<DownloadJob> => cancelDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }), req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }),
deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }), deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
renameDownload: (id: number, display_name: string): Promise<DownloadJob> => // Update a download's display metadata (title / channel name+link / extra reference URLs).
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify({ display_name }) }), updateDownloadMeta: (
id: number,
meta: {
display_name?: string;
display_uploader?: string;
display_uploader_url?: string;
extra_links?: string[];
}
): Promise<DownloadJob> =>
req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }),
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> => shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }), req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
unshareDownload: (id: number, email: string) => unshareDownload: (id: number, email: string) =>

View file

@ -14,6 +14,19 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.31.0",
date: "2026-07-07",
summary: "Richer downloads — edit details, clickable channels & links, and prettier share-link previews.",
features: [
"Downloads: the edit (pencil) button now edits the full details — title, channel name and a channel link, plus any number of extra reference links. The channel and links appear on the download card as clickable links, and a source that carries a channel (like YouTube or Facebook) fills the channel link in automatically.",
"Downloads: shared watch pages now show the correct title, channel and links — including your own edits — with every link clickable. (Renaming a download is now reflected on its share link too.)",
"Shared links now unfurl richly in chat apps like Messenger, with the video's title, channel and thumbnail instead of a blank card. Videos without a thumbnail still show a clean text card.",
],
chores: [
"Shorter, clearer site description for search engines and link previews.",
],
},
{ {
version: "0.30.0", version: "0.30.0",
date: "2026-07-06", date: "2026-07-06",