merge: download source URL reference + adaptive watch player (v0.22.3)
This commit is contained in:
commit
249946b5f5
12 changed files with 136 additions and 3 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.22.2
|
0.22.3
|
||||||
27
backend/alembic/versions/0043_asset_source_url.py
Normal file
27
backend/alembic/versions/0043_asset_source_url.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -741,6 +741,10 @@ class MediaAsset(Base, TimestampMixin):
|
||||||
uploader: Mapped[str | None] = mapped_column(String(255))
|
uploader: Mapped[str | None] = mapped_column(String(255))
|
||||||
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"
|
||||||
|
# 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)
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
ref_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
ref_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
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:
|
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||||
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
|
ready = asset is not None and asset.status == "ready" and bool(asset.rel_path)
|
||||||
return {
|
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,
|
"created_at": job.created_at.isoformat() if job.created_at else None,
|
||||||
"source_kind": job.source_kind,
|
"source_kind": job.source_kind,
|
||||||
"source_ref": job.source_ref,
|
"source_ref": job.source_ref,
|
||||||
|
"source_url": _reference_url(job, asset),
|
||||||
"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,
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,7 @@ def _fill_asset_meta_early(asset_id: int, info: dict) -> None:
|
||||||
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"))
|
||||||
|
asset.source_webpage_url = info.get("webpage_url")
|
||||||
db.commit()
|
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.uploader = meta.uploader
|
||||||
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.nfo_written = nfo_ok
|
asset.nfo_written = nfo_ok
|
||||||
asset.error = None
|
asset.error = None
|
||||||
asset.last_access_at = datetime.now(timezone.utc)
|
asset.last_access_at = datetime.now(timezone.utc)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
|
Link2,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
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 (
|
||||||
|
<div className="flex items-center gap-1 mt-1 text-xs text-muted min-w-0">
|
||||||
|
<Link2 className="w-3 h-3 shrink-0" />
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`${t("downloads.source.open")} — ${url}`}
|
||||||
|
className="truncate hover:text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{displayUrl(url)}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
aria-label={t("downloads.source.copy")}
|
||||||
|
title={t("downloads.source.copy")}
|
||||||
|
className="shrink-0 p-0.5 rounded hover:bg-surface hover:text-fg transition"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DownloadRow({
|
function DownloadRow({
|
||||||
job,
|
job,
|
||||||
children,
|
children,
|
||||||
|
|
@ -131,6 +178,7 @@ function DownloadRow({
|
||||||
{job.asset?.duration_s ? <span className="text-muted">· {formatDuration(job.asset.duration_s)}</span> : null}
|
{job.asset?.duration_s ? <span className="text-muted">· {formatDuration(job.asset.duration_s)}</span> : null}
|
||||||
{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} />}
|
||||||
{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) ? (
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,15 @@ export default function WatchPage() {
|
||||||
|
|
||||||
{status === "ready" && meta && (
|
{status === "ready" && meta && (
|
||||||
<div>
|
<div>
|
||||||
<div className="rounded-xl overflow-hidden bg-black border border-slate-800">
|
{/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
||||||
<video controls playsInline src={meta.file_url} className="w-full max-h-[75vh] mx-auto block" />
|
player instead of a wide box with black bars; a landscape clip fills the width. */}
|
||||||
|
<div className="rounded-xl overflow-hidden bg-black border border-slate-800 w-fit max-w-full mx-auto">
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
src={meta.file_url}
|
||||||
|
className="max-h-[80vh] max-w-full mx-auto block"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,12 @@
|
||||||
"items": "{{used}} / {{max}} Elemente",
|
"items": "{{used}} / {{max}} Elemente",
|
||||||
"unlimited": "Unbegrenzt"
|
"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": {
|
"rename": {
|
||||||
"title": "Download umbenennen",
|
"title": "Download umbenennen",
|
||||||
"label": "Anzeigename",
|
"label": "Anzeigename",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,12 @@
|
||||||
"items": "{{used}} / {{max}} items",
|
"items": "{{used}} / {{max}} items",
|
||||||
"unlimited": "Unlimited"
|
"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": {
|
"rename": {
|
||||||
"title": "Rename download",
|
"title": "Rename download",
|
||||||
"label": "Display name",
|
"label": "Display name",
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,12 @@
|
||||||
"items": "{{used}} / {{max}} elem",
|
"items": "{{used}} / {{max}} elem",
|
||||||
"unlimited": "Korlátlan"
|
"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": {
|
"rename": {
|
||||||
"title": "Letöltés átnevezése",
|
"title": "Letöltés átnevezése",
|
||||||
"label": "Megjelenített név",
|
"label": "Megjelenített név",
|
||||||
|
|
|
||||||
|
|
@ -698,6 +698,7 @@ export interface DownloadJob {
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
source_kind: string;
|
source_kind: string;
|
||||||
source_ref: string;
|
source_ref: string;
|
||||||
|
source_url: string | null; // clean "downloaded from" URL (null for edit clips)
|
||||||
profile_id: number | null;
|
profile_id: number | null;
|
||||||
spec: DownloadSpec;
|
spec: DownloadSpec;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,17 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.22.3",
|
||||||
|
date: "2026-07-04",
|
||||||
|
summary: "Vertical videos fit the player, and downloads show where they came from.",
|
||||||
|
features: [
|
||||||
|
"Every download now shows a “downloaded from” link on the Downloads page — open the original page in a new tab, or copy the link. Works for YouTube and external links (e.g. Facebook reels).",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"A shared vertical/short video now plays in a player that fits its shape, instead of a wide box with black bars on the sides.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.22.2",
|
version: "0.22.2",
|
||||||
date: "2026-07-04",
|
date: "2026-07-04",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue