From 42d465d7602d66cd76ec36486507a585a443e5cf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 4 Jul 2026 21:25:37 +0200 Subject: [PATCH 01/11] feat(downloads): store + show a canonical "downloaded from" source URL Every download now records the clean source page URL and shows it on the Downloads page (open in a new tab, or copy to clipboard). The worker stores yt-dlp's canonical webpage_url on the asset (migration 0043 adds media_assets.source_webpage_url); the serializer prefers it and falls back to a URL derived from source_kind+source_ref, so YouTube, external YouTube links and external URLs (e.g. Facebook reels) all get a correct reference, and queued/older rows work before the worker fills it. Edit clips return null (a clip's source is the user's own earlier download, not a web page). i18n en/hu/de. --- .../alembic/versions/0043_asset_source_url.py | 27 +++++++++++ backend/app/models.py | 4 ++ backend/app/routes/downloads.py | 15 ++++++ backend/app/worker.py | 2 + frontend/src/components/DownloadCenter.tsx | 48 +++++++++++++++++++ frontend/src/i18n/locales/de/downloads.json | 6 +++ frontend/src/i18n/locales/en/downloads.json | 6 +++ frontend/src/i18n/locales/hu/downloads.json | 6 +++ frontend/src/lib/api.ts | 1 + 9 files changed, 115 insertions(+) create mode 100644 backend/alembic/versions/0043_asset_source_url.py diff --git a/backend/alembic/versions/0043_asset_source_url.py b/backend/alembic/versions/0043_asset_source_url.py new file mode 100644 index 0000000..fd98285 --- /dev/null +++ b/backend/alembic/versions/0043_asset_source_url.py @@ -0,0 +1,27 @@ +"""canonical source webpage URL on media assets + +Revision ID: 0043_asset_source_url +Revises: 0042_download_links +Create Date: 2026-07-04 + +Stores yt-dlp's canonical `webpage_url` for a download's source, so the Downloads page can show a +clean "downloaded from" reference (copyable + openable). Nullable — filled by the worker on +extraction; existing/queued rows fall back to a URL derived from source_kind+source_ref. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0043_asset_source_url" +down_revision: Union[str, None] = "0042_download_links" +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("source_webpage_url", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("media_assets", "source_webpage_url") diff --git a/backend/app/models.py b/backend/app/models.py index f511115..6913ece 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -741,6 +741,10 @@ class MediaAsset(Base, TimestampMixin): uploader: Mapped[str | None] = mapped_column(String(255)) 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" + # reference shown on the Downloads page. Null until the worker extracts it (older/queued rows + # fall back to a URL derived from source_kind+source_ref). + source_webpage_url: Mapped[str | None] = mapped_column(Text) error: Mapped[str | None] = mapped_column(Text) ref_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 783eddb..329e285 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -68,6 +68,20 @@ def resolve_source(raw: str) -> tuple[str, str]: raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.") +def _reference_url(job: DownloadJob, asset: MediaAsset | None) -> str | None: + """The clean "downloaded from" URL for the Downloads page: the canonical page URL yt-dlp + recorded, else one derived from source_kind+source_ref (covers queued/older rows before the + worker fills it). Returns None for edit derivatives — a clip's source is the user's own earlier + download, not a web page, so there's no meaningful external URL to show.""" + if job.job_kind == "edit": + return None + if asset is not None and asset.source_webpage_url: + return asset.source_webpage_url + if job.source_kind in ("youtube", "url"): + return service.source_url(job.source_kind, job.source_ref) + return None + + def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: ready = asset is not None and asset.status == "ready" and bool(asset.rel_path) return { @@ -82,6 +96,7 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: "created_at": job.created_at.isoformat() if job.created_at else None, "source_kind": job.source_kind, "source_ref": job.source_ref, + "source_url": _reference_url(job, asset), "profile_id": job.profile_id, "spec": job.profile_snapshot, "display_name": job.display_name, diff --git a/backend/app/worker.py b/backend/app/worker.py index 86f1086..46c62c8 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -221,6 +221,7 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None: 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")) + asset.source_webpage_url = info.get("webpage_url") db.commit() @@ -409,6 +410,7 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe asset.uploader = meta.uploader asset.upload_date = meta.upload_date asset.thumbnail_url = meta.thumbnail_url + asset.source_webpage_url = info.get("webpage_url") asset.nfo_written = nfo_ok asset.error = None asset.last_access_at = datetime.now(timezone.utc) diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 85e0834..406acb6 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -3,7 +3,9 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, + Copy, Download, + Link2, Pause, Play, Plus, @@ -101,6 +103,51 @@ function IconBtn({ ); } +// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits. +function displayUrl(url: string): string { + try { + const u = new URL(url); + return (u.host + u.pathname + u.search).replace(/\/$/, ""); + } catch { + return url; + } +} + +// "Downloaded from" reference: opens the original page in a new tab, or copies the link. +function SourceRef({ url }: { url: string }) { + const { t } = useTranslation(); + const copy = async () => { + try { + await navigator.clipboard.writeText(url); + notify({ level: "success", message: t("downloads.source.copied") }); + } catch { + notify({ level: "error", message: t("downloads.source.copyFailed") }); + } + }; + return ( +
+ + + {displayUrl(url)} + + +
+ ); +} + function DownloadRow({ job, children, @@ -131,6 +178,7 @@ function DownloadRow({ {job.asset?.duration_s ? · {formatDuration(job.asset.duration_s)} : null} {job.error ? · {job.error} : null} + {job.source_url && } {running && (
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( diff --git a/frontend/src/i18n/locales/de/downloads.json b/frontend/src/i18n/locales/de/downloads.json index 0054699..334c966 100644 --- a/frontend/src/i18n/locales/de/downloads.json +++ b/frontend/src/i18n/locales/de/downloads.json @@ -71,6 +71,12 @@ "items": "{{used}} / {{max}} Elemente", "unlimited": "Unbegrenzt" }, + "source": { + "open": "Originalseite in neuem Tab öffnen", + "copy": "Quell-Link kopieren", + "copied": "Quell-Link in die Zwischenablage kopiert", + "copyFailed": "Link konnte nicht kopiert werden" + }, "rename": { "title": "Download umbenennen", "label": "Anzeigename", diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 06db1f6..604f68f 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -71,6 +71,12 @@ "items": "{{used}} / {{max}} items", "unlimited": "Unlimited" }, + "source": { + "open": "Open the original page in a new tab", + "copy": "Copy source link", + "copied": "Source link copied to clipboard", + "copyFailed": "Couldn't copy the link" + }, "rename": { "title": "Rename download", "label": "Display name", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 4091429..2dafea4 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -71,6 +71,12 @@ "items": "{{used}} / {{max}} elem", "unlimited": "Korlátlan" }, + "source": { + "open": "Eredeti oldal megnyitása új lapon", + "copy": "Forráslink másolása", + "copied": "Forráslink a vágólapra másolva", + "copyFailed": "Nem sikerült a link másolása" + }, "rename": { "title": "Letöltés átnevezése", "label": "Megjelenített név", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 692aca9..24f2246 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -698,6 +698,7 @@ export interface DownloadJob { created_at: string | null; source_kind: string; source_ref: string; + source_url: string | null; // clean "downloaded from" URL (null for edit clips) profile_id: number | null; spec: DownloadSpec; display_name: string | null; From 2beec6bb18af07083becb871292239da911dc6dd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 4 Jul 2026 21:25:37 +0200 Subject: [PATCH 02/11] fix(watch): shrink-wrap the player to the video aspect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vertical/short clip rendered in a fixed full-width box with large black side bars. The player container now uses w-fit + mx-auto and the video max-h/max-w, so a portrait clip becomes a narrow centered player and a landscape clip fills the width — no letterboxing either way. --- frontend/src/components/WatchPage.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx index ecec257..0738247 100644 --- a/frontend/src/components/WatchPage.tsx +++ b/frontend/src/components/WatchPage.tsx @@ -152,8 +152,15 @@ export default function WatchPage() { {status === "ready" && meta && (
-
-