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/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/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 ( +
{t("downloads.rename.hint")}
-{t("downloads.edit.hint")}
+