diff --git a/VERSION b/VERSION index 934e07c..fabc491 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.2 \ No newline at end of file +0.22.3 \ No newline at end of file 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/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 && (
-
-