diff --git a/VERSION b/VERSION index c25c8e5..26bea73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.30.0 +0.31.0 diff --git a/backend/alembic/versions/0049_download_display_meta.py b/backend/alembic/versions/0049_download_display_meta.py new file mode 100644 index 0000000..51c6335 --- /dev/null +++ b/backend/alembic/versions/0049_download_display_meta.py @@ -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") diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py index 896af76..8aa2127 100644 --- a/backend/app/downloads/links.py +++ b/backend/app/downloads/links.py @@ -68,11 +68,26 @@ def owner_view(link: DownloadLink, base_url: str = "") -> dict: } -def public_meta(link: DownloadLink, asset, file_url: str) -> dict: - """Metadata for the public watch page (only ever returned once access is authorized).""" +def public_meta(link: DownloadLink, job, asset, file_url: str) -> 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 + 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 { - "title": asset.title, - "uploader": asset.uploader, + "title": (job and job.display_name) or asset.title, + "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, "width": asset.width, "height": asset.height, diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py new file mode 100644 index 0000000..60a9590 --- /dev/null +++ b/backend/app/downloads/og.py @@ -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".*?", re.DOTALL) + + +def _tag(prop: str, content: str, attr: str = "property") -> str: + return f'' + + +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( + [ + "Siftlode", + _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"{html.escape(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 "" 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) diff --git a/backend/app/main.py b/backend/app/main.py index bea87ee..772888a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,7 +20,7 @@ if not _siftlode_logger.handlers: log = logging.getLogger("siftlode.app") 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 starlette.middleware.sessions import SessionMiddleware @@ -178,6 +178,21 @@ async def index() -> FileResponse: 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}") async def spa_fallback(full_path: str) -> FileResponse: # Client-side routes fall back to index.html; real API/asset paths are matched above. diff --git a/backend/app/models.py b/backend/app/models.py index c3a9ace..574e52c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -739,6 +739,9 @@ class MediaAsset(Base, TimestampMixin): # Denormalized naming/metadata for the Plex tree + ad-hoc display. title: Mapped[str | None] = mapped_column(Text) 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) thumbnail_url: Mapped[str | None] = mapped_column(Text) # 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) 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( String(16), default="queued", server_default="queued", index=True ) # queued | running | paused | done | error | canceled diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 329e285..f222f9b 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -100,6 +100,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: "profile_id": job.profile_id, "spec": job.profile_snapshot, "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, "source_job_id": job.source_job_id, "edit_spec": job.edit_spec, @@ -118,6 +121,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: "width": asset.width, "height": asset.height, "container": asset.container, + "uploader_url": asset.uploader_url, "thumbnail_url": asset.thumbnail_url, "expires_at": asset.expires_at.isoformat() if asset.expires_at else None, }, @@ -380,17 +384,47 @@ def shared_with_me( 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}") -def rename_download( +def update_download_meta( job_id: int, payload: dict, user: User = Depends(require_human), db: Session = Depends(get_db), ) -> 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) - name = (payload.get("display_name") or "").strip() - # Display name only — the on-disk file keeps its system-decided, id-bound name. - job.display_name = name[:255] or None + if "display_name" in payload: + job.display_name = ((payload.get("display_name") or "").strip())[: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() asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None return _serialize(job, asset) diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py index 226b3c0..80a996f 100644 --- a/backend/app/routes/public.py +++ b/backend/app/routes/public.py @@ -35,10 +35,10 @@ def _link_or_404(db: Session, token: str) -> DownloadLink: 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) 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.") # 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. @@ -46,7 +46,11 @@ def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset: root = Path(settings.download_root).resolve() if root not in path.parents or not path.exists(): 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: @@ -61,10 +65,10 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict: link = _link_or_404(db, token) if link.password_hash: return {"needs_password": True} - asset = _ready_asset(db, link) + job, asset = _ready_target(db, link) link.view_count += 1 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") @@ -75,15 +79,15 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di link = _link_or_404(db, token) if not link.password_hash: # Not a password link — nothing to unlock; just hand back the plain meta. - asset = _ready_asset(db, link) - return {"needs_password": False, **linksmod.public_meta(link, asset, _file_url(token))} + job, asset = _ready_target(db, link) + return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token))} if not verify_password((payload.get("password") or ""), link.password_hash): raise HTTPException(status_code=403, detail="Wrong password.") - asset = _ready_asset(db, link) + 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, asset, _file_url(token, grant))} + return {"needs_password": False, **linksmod.public_meta(link, job, asset, _file_url(token, grant))} @router.get("/watch/{token}/file") diff --git a/backend/app/worker.py b/backend/app/worker.py index 46c62c8..73d8dc2 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -218,6 +218,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.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")) @@ -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.title = meta.title asset.uploader = meta.uploader + asset.uploader_url = info.get("channel_url") or info.get("uploader_url") asset.upload_date = meta.upload_date asset.thumbnail_url = meta.thumbnail_url asset.source_webpage_url = info.get("webpage_url") diff --git a/frontend/index.html b/frontend/index.html index c7ed055..39d73cb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,17 +4,21 @@ + + Siftlode +
diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 406acb6..4db484e 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -78,6 +78,15 @@ function jobTitle(job: DownloadJob): string { 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({ onClick, 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 ( +
+ + {name} + +
+ ); + } + return
{name}
; +} + function DownloadRow({ job, children, @@ -169,9 +199,7 @@ function DownloadRow({ )} {jobTitle(job)} -
- {job.asset?.uploader || job.source_ref} -
+
{job.asset?.size_bytes ? · {formatBytes(job.asset.size_bytes)} : null} @@ -179,6 +207,7 @@ function DownloadRow({ {job.error ? · {job.error} : null}
{job.source_url && } + {job.extra_links?.map((url) => )} {running && (
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( @@ -208,30 +237,89 @@ function DownloadRow({ // --- 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 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(() => [...(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({ - 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: () => { qc.invalidateQueries({ queryKey: ["downloads"] }); onClose(); }, }); + return ( - - - setName(e.target.value)} className={inputCls} autoFocus /> -

{t("downloads.rename.hint")}

-
- + +
+ + + +
+ {t("downloads.edit.extraLinks")} +
+ {links.map((l, i) => ( +
+ setLink(i, e.target.value)} + placeholder="https://…" + className={inputCls} + /> + removeLink(i)} title={t("downloads.edit.removeLink")} danger> + + +
+ ))} +
+ +
+

{t("downloads.edit.hint")}

+
+ +
); @@ -579,7 +667,7 @@ export default function DownloadCenter({ me }: { me: Me }) { )} - setRenameJob(job)} title={t("downloads.actions.rename")}> + setRenameJob(job)} title={t("downloads.actions.editDetails")}> setShareJob(job)} title={t("downloads.actions.share")}> @@ -643,7 +731,7 @@ export default function DownloadCenter({ me }: { me: Me }) { /> )} {manage && setManage(false)} />} - {renameJob && setRenameJob(null)} />} + {renameJob && setRenameJob(null)} />} {shareJob && setShareJob(null)} />} {editJob && setEditJob(null)} />} diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx index 0738247..e443a9e 100644 --- a/frontend/src/components/WatchPage.tsx +++ b/frontend/src/components/WatchPage.tsx @@ -1,5 +1,5 @@ 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/ link. Rendered OUTSIDE the app // 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; title?: string | null; uploader?: string | null; + uploader_url?: string | null; + source_url?: string | null; + extra_links?: string[]; duration_s?: number | null; allow_download?: boolean; 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 = { en: { loading: "Loading…", @@ -24,6 +37,7 @@ const STR = { watch: "Watch", wrong: "Wrong password.", download: "Download", + links: "Links", brand: "Shared via Siftlode", }, hu: { @@ -35,6 +49,7 @@ const STR = { watch: "Megnézem", wrong: "Hibás jelszó.", download: "Letöltés", + links: "Linkek", brand: "Megosztva a Siftlode-dal", }, de: { @@ -46,6 +61,7 @@ const STR = { watch: "Ansehen", wrong: "Falsches Passwort.", download: "Herunterladen", + links: "Links", brand: "Geteilt über Siftlode", }, } as const; @@ -165,7 +181,19 @@ export default function WatchPage() {

{meta.title || "Video"}

- {meta.uploader &&
{meta.uploader}
} + {meta.uploader && + (meta.uploader_url ? ( + + {meta.uploader} + + ) : ( +
{meta.uploader}
+ ))}
{meta.allow_download && ( )}
+ {(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && ( + + )}
)}
diff --git a/frontend/src/i18n/locales/de/downloads.json b/frontend/src/i18n/locales/de/downloads.json index 334c966..83b0c2b 100644 --- a/frontend/src/i18n/locales/de/downloads.json +++ b/frontend/src/i18n/locales/de/downloads.json @@ -56,6 +56,7 @@ "retry": "Erneut", "download": "Auf mein Gerät speichern", "rename": "Umbenennen", + "editDetails": "Details bearbeiten", "share": "Teilen", "edit": "Bearbeiten / schneiden", "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.", "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": { "title": "Download teilen", "label": "E-Mail des Empfängers", diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 604f68f..4e89fc9 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -56,6 +56,7 @@ "retry": "Retry", "download": "Save to my device", "rename": "Rename", + "editDetails": "Edit details", "share": "Share", "edit": "Edit / trim", "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.", "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": { "title": "Share download", "label": "Recipient email", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 2dafea4..a345960 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -56,6 +56,7 @@ "retry": "Újra", "download": "Mentés a gépemre", "rename": "Átnevezés", + "editDetails": "Adatok szerkesztése", "share": "Megosztás", "edit": "Szerkesztés / vágás", "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.", "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": { "title": "Letöltés megosztása", "label": "Címzett e-mail címe", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index feca869..9b5058c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -851,6 +851,7 @@ export interface DownloadAsset { status: string; title: 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; duration_s: number | null; size_bytes: number | null; @@ -903,6 +904,9 @@ export interface DownloadJob { profile_id: number | null; spec: DownloadSpec; 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" source_job_id?: number | null; // parent download an edit was cut from edit_spec?: EditSpec | null; @@ -1410,8 +1414,17 @@ export const api = { cancelDownload: (id: number): Promise => req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }), deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }), - renameDownload: (id: number, display_name: string): Promise => - req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify({ display_name }) }), + // Update a download's display metadata (title / channel name+link / extra reference URLs). + updateDownloadMeta: ( + id: number, + meta: { + display_name?: string; + display_uploader?: string; + display_uploader_url?: string; + extra_links?: string[]; + } + ): Promise => + req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }), shareDownload: (id: number, email: string): Promise<{ shared_with: string }> => req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }), unshareDownload: (id: number, email: string) => diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index b917d8e..226bcce 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,19 @@ export interface 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", date: "2026-07-06",