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/App.tsx b/frontend/src/App.tsx index 8d0376b..40792fd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -118,11 +118,23 @@ function loadInitialPage(): Page { // the account isn't known yet (first load, before the tab is pinned), start from defaults; the // post-login effect loads the real ones once the id arrives. function loadAccountFilters(id: number | null): FeedFilters { + // Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you + // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The + // starred default view only SEEDS a fresh account that has never stored filters: it drives the + // very first load, after which that state persists like any other. (Re-apply the default view + // from the sidebar to return to it.) + const fKey = accountKey(LS.filters, id); + let hasStored = false; + try { + hasStored = !!fKey && localStorage.getItem(fKey) != null; + } catch { + /* localStorage may be unavailable */ + } + if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS); const dvKey = accountKey(LS.defaultViewFilters, id); const def = dvKey ? readJSON(dvKey, null) : null; if (def) return { ...DEFAULT_FILTERS, ...def }; - const fKey = accountKey(LS.filters, id); - return fKey ? readMerged(fKey, DEFAULT_FILTERS) : { ...DEFAULT_FILTERS }; + return { ...DEFAULT_FILTERS }; } // URL wins over localStorage so a pasted link reproduces the exact view. 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/Feed.tsx b/frontend/src/components/Feed.tsx index 8152d59..04a0ef5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -306,10 +306,16 @@ export default function Feed({ const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items); loadedRef.current = loaded; - const items: Video[] = loaded - .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) - .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)) - .filter((v) => matchesView(v.status, filters.show)); + const withOverrides = (list: Video[]): Video[] => + list + .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) + .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)); + const items: Video[] = withOverrides(loaded).filter((v) => matchesView(v.status, filters.show)); + // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the + // watch-state view filter is NOT applied — so marking the current video watched keeps it in the + // sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that + // goes hidden still drops out ("visible affects"). + const playerQueue: Video[] = withOverrides(loaded).filter((v) => v.status !== "hidden"); // --- Live YouTube search mode ------------------------------------------------------------- // A separate data source rendered in the same cards/player. No watch-state view filter (the @@ -317,9 +323,8 @@ export default function Feed({ if (ytActive) { const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items); loadedRef.current = ytLoaded; - const ytItems: Video[] = ytLoaded - .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) - .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)); + const ytItems: Video[] = withOverrides(ytLoaded); + const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden"); const ytError = ytQuery.error instanceof HttpError ? ytQuery.error.detail || t("feed.yt.error") @@ -446,6 +451,7 @@ export default function Feed({ setActiveVideo(null)} onState={onState} onOpenChannel={onOpenChannel} @@ -654,6 +660,7 @@ export default function Feed({ setActiveVideo(null)} onState={onState} onOpenChannel={onOpenChannel} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index f8456d9..76b41eb 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react"; +import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react"; import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import DownloadButton from "./DownloadButton"; @@ -20,6 +20,14 @@ import { useBackToClose } from "../lib/history"; // How close to the end (seconds) counts as "finished" → auto-mark watched. const FINISH_MARGIN = 10; +// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a +// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends +// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist). +type AutoMode = "off" | "next" | "prev" | "random"; +type LoopMode = "off" | "one" | "all"; +const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"]; +const LOOP_MODES: LoopMode[] = ["off", "one", "all"]; + // --- IFrame Player API loader (singleton) --- let apiPromise: Promise | null = null; function loadYouTubeApi(): Promise { @@ -81,7 +89,9 @@ export default function PlayerModal({ // queue, so the N / M counter and prev/next neighbours stay correct without disrupting // playback of the current video. const [playingId, setPlayingId] = useState( - queue?.[startIndex ?? 0]?.id ?? video.id + // A caller with an explicit index (playlist) starts there; otherwise locate the opened + // `video` in the queue (the feed passes its whole list + the clicked video, no index). + startIndex != null ? queue?.[startIndex]?.id ?? video.id : video.id ); let index = queue ? queue.findIndex((v) => v.id === playingId) : 0; if (index < 0) index = 0; @@ -183,6 +193,88 @@ export default function PlayerModal({ } }; + // Prev/next stepping through the queue (the feed's order, or a playlist). Read the live + // queue+index from a ref so the window keydown handler (bound once) always steps from the + // current position, not a stale closure. + const navRef = useRef({ queue, index }); + navRef.current = { queue, index }; + const goPrev = () => { + const { queue: q, index: i } = navRef.current; + if (q && i > 0) setPlayingId(q[i - 1].id); + }; + const goNext = () => { + const { queue: q, index: i } = navRef.current; + if (q && i < q.length - 1) setPlayingId(q[i + 1].id); + }; + // Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s. + const nudgeSeek = (delta: number) => { + const p = playerRef.current; + if (!p || typeof p.getCurrentTime !== "function") return; + p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true); + }; + + // Auto-advance + loop are persistent per-user settings. Read the current values from the cached + // `me`, mirror them in local state for instant UI, and write both to the server + cache on change + // so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler. + const savedPrefs = (qc.getQueryData<{ preferences?: Record }>(["me"]) + ?.preferences ?? {}) as { playerAutoAdvance?: AutoMode; playerLoop?: LoopMode }; + const [autoMode, setAutoMode] = useState(savedPrefs.playerAutoAdvance ?? "off"); + const [loopMode, setLoopMode] = useState(savedPrefs.playerLoop ?? "off"); + const modeRef = useRef({ autoMode, loopMode }); + modeRef.current = { autoMode, loopMode }; + const persistPref = (patch: Record) => { + api.savePrefs(patch).catch(() => {}); + qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) => + m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m + ); + }; + const cycleAuto = () => { + const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]; + setAutoMode(next); + persistPref({ playerAutoAdvance: next }); + }; + const cycleLoop = () => { + const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]; + setLoopMode(next); + persistPref({ playerLoop: next }); + }; + const replayCurrent = () => { + const p = playerRef.current; + if (p && typeof p.seekTo === "function") { + p.seekTo(0, true); + p.playVideo?.(); + } + }; + // Run when the current video ends, per the saved settings. Loop "one" repeats it; otherwise + // advance in the chosen direction, wrapping at the ends only when loop is "all" (a single-item + // list repeats). Uses the live queue + settings via refs so it's correct from the bound handler. + const advanceOnEnd = () => { + const { autoMode: a, loopMode: l } = modeRef.current; + if (l === "one") return replayCurrent(); + const { queue: q, index: i } = navRef.current; + if (!q || q.length === 0) return; + const wrap = l === "all"; + if (a === "next") { + if (i < q.length - 1) setPlayingId(q[i + 1].id); + else if (wrap) { + if (q.length === 1) replayCurrent(); + else setPlayingId(q[0].id); + } + } else if (a === "prev") { + if (i > 0) setPlayingId(q[i - 1].id); + else if (wrap) { + if (q.length === 1) replayCurrent(); + else setPlayingId(q[q.length - 1].id); + } + } else if (a === "random" && q.length > 1) { + let r = i; + while (r === i) r = Math.floor(Math.random() * q.length); + setPlayingId(q[r].id); + } else if (a === "random" && wrap) { + replayCurrent(); + } + }; + // Lazy description (fetched only when the title is hovered). The popover is // portaled to with fixed positioning so the modal card's overflow-y-auto // can't clip it. A small close grace lets the mouse travel title → popover. @@ -194,8 +286,10 @@ export default function PlayerModal({ ); const titleRef = useRef(null); const closeTimer = useRef(undefined); - const openDesc = () => { + const openTimer = useRef(undefined); + const openDescNow = () => { window.clearTimeout(closeTimer.current); + window.clearTimeout(openTimer.current); const el = titleRef.current; if (el) { const r = el.getBoundingClientRect(); @@ -205,7 +299,15 @@ export default function PlayerModal({ } setShowDesc(true); }; + // Hover-intent: only pop the description if the pointer lingers on the title (~400ms), so a + // quick pass over it doesn't flash the popover. + const scheduleOpenDesc = () => { + window.clearTimeout(closeTimer.current); + window.clearTimeout(openTimer.current); + openTimer.current = window.setTimeout(openDescNow, 400); + }; const scheduleCloseDesc = () => { + window.clearTimeout(openTimer.current); closeTimer.current = window.setTimeout(() => setShowDesc(false), 150); }; const detail = useQuery({ @@ -241,6 +343,18 @@ export default function PlayerModal({ if (typing || tag === "button" || tag === "a") return; e.preventDefault(); togglePlay(); + } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + // Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the + // previous/next video in the queue (the feed's order or a playlist). + if (typing) return; + e.preventDefault(); + const forward = e.key === "ArrowRight"; + if (e.shiftKey) { + if (forward) goNext(); + else goPrev(); + } else { + nudgeSeek(forward ? 5 : -5); + } } }; window.addEventListener("keydown", onKey); @@ -251,6 +365,7 @@ export default function PlayerModal({ window.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; window.clearTimeout(closeTimer.current); + window.clearTimeout(openTimer.current); window.clearTimeout(volTimerRef.current); }; }, [onClose]); @@ -339,7 +454,7 @@ export default function PlayerModal({ autoMarkedRef.current = true; setWatched(true); } - if (queue && index < queue.length - 1) setPlayingId(queue[index + 1].id); + advanceOnEnd(); } }, // The embed couldn't play this video (embedding disabled, removed, private…). @@ -380,10 +495,29 @@ export default function PlayerModal({ role="dialog" aria-modal="true" > + {/* The card, flanked by full-height glassy strips that step through the feed's order (or a + playlist). Each strip sits just outside the card, spans its full height, and fades out at + the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens + where there's no room beside the card. */} +
+ {hasQueue && ( + + )}
e.stopPropagation()} >
- {/* Interaction layer over the video: catches the scroll wheel (volume) and click - (play/pause), and stops the iframe stealing keyboard focus. The bottom strip is - left uncovered so YouTube's native control bar (seek / settings / CC / fullscreen) - stays usable. Hidden on error so the "Open on YouTube" CTA is clickable. */} + {/* Interaction layer over the CENTRE of the video: catches the scroll wheel (volume) + and click (play/pause), and stops the iframe stealing keyboard focus. It deliberately + leaves the top AND bottom edges uncovered so YouTube's native controls — the top-right + cluster (volume / CC / settings) and the bottom bar (seek / More videos / fullscreen) + — stay clickable. Hidden on error so the "Open on YouTube" CTA is clickable. */} {playerError == null && (
)} {/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */} @@ -444,10 +579,11 @@ export default function PlayerModal({
{hasQueue && ( -
+
+ + {/* Persistent playback settings — cycle on click, saved to your account. */} + +
)} @@ -473,7 +638,7 @@ export default function PlayerModal({ {navigated ? liveData?.title ?? t("player.loading") : active.title} @@ -499,7 +664,7 @@ export default function PlayerModal({ bottom: descRect.bottom, width: descRect.width, }} - onMouseEnter={openDesc} + onMouseEnter={openDescNow} onMouseLeave={scheduleCloseDesc} >
@@ -656,6 +821,21 @@ export default function PlayerModal({
+ {hasQueue && ( + + )} +
); } 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 && (
-
-