From 3f15f352563e70c9e4b275ac9d47773941eb16d8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 28 Jul 2026 01:21:50 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(downloads):=20S1=20UI=20quick=20wins?= =?UTF-8?q?=20=E2=80=94=20queue=20default,=20dates,=20auto-link,=20thumb?= =?UTF-8?q?=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Always land on the Queue tab (drop the persisted last-tab) so new work/failures are the default view - Show the download date (relative) on Library rows - Share dialog opens with a default link already generated when none exists; all link controls stay available - Thumbnail falls back to the self-hosted poster when a remote (i.ytimg/fbcdn) thumbnail 404s - Add a clipboard-paste affordance to the add-URL box (explicit button + best-effort on focus) --- frontend/src/components/DownloadCenter.tsx | 63 ++++++++++++++++++--- frontend/src/components/ShareDialog.tsx | 16 +++++- frontend/src/i18n/locales/en/downloads.json | 1 + frontend/src/i18n/locales/hu/downloads.json | 1 + 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 84d68db..c4e2ed6 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, + CalendarClock, + ClipboardPaste, Copy, Download, Link2, @@ -18,7 +20,7 @@ import { Trash2, } from "lucide-react"; import clsx from "clsx"; -import Tabs, { usePersistedTab } from "./Tabs"; +import Tabs from "./Tabs"; import { PageToolbar } from "./PageShell"; import { LoadingState, StateMessage } from "./QueryState"; import Modal from "./Modal"; @@ -35,7 +37,7 @@ import { api, type DownloadJob } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; import { copyText } from "../lib/clipboard"; -import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format"; +import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format"; import { inputCls } from "./ui/form"; const GiB = 1024 ** 3; @@ -83,10 +85,24 @@ function StatusPill({ job }: { job: DownloadJob }) { function Thumb({ job }: { job: DownloadJob }) { // Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources. - const url = job.asset?.thumbnail_url || job.poster_url; + const primary = job.asset?.thumbnail_url || job.poster_url; + // A remote i.ytimg.com thumbnail 404s once the source video is deleted/privated, leaving a + // broken image. On load failure fall back to our always-present self-hosted poster. + const [src, setSrc] = useState(primary); + useEffect(() => setSrc(primary), [primary]); return (
- {url ? : null} + {src ? ( + { + if (job.poster_url && src !== job.poster_url) setSrc(job.poster_url); + }} + /> + ) : null}
); } @@ -218,6 +234,14 @@ function DownloadRow({ job, children }: { job: DownloadJob; children?: React.Rea {job.asset?.duration_s ? ( · {formatDuration(job.asset.duration_s)} ) : null} + {job.status === "done" && job.created_at ? ( + + · {relativeTime(job.created_at)} + + ) : null} {job.error ? · {job.error} : null} {job.source_url && } @@ -655,7 +679,9 @@ export default function DownloadCenter() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const [tab, setTab] = usePersistedTab("siftlode.downloadTab", "queue"); + // Always land on the queue tab (not a persisted last-tab) — the queue is where new work and + // any failures are, so it's the useful default every visit. + const [tab, setTab] = useState("queue"); const [addUrl, setAddUrl] = useState(""); const [addSource, setAddSource] = useState(null); const [manage, setManage] = useState(false); @@ -706,14 +732,28 @@ export default function DownloadCenter() { if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn(); }; + // Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context + + // a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste + // button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if + // the browser refuses without a click). Only accept an http(s) URL, don't clobber existing text. + const pasteFromClipboard = async (force = false) => { + if (!force && addUrl.trim()) return; + try { + const text = (await navigator.clipboard?.readText())?.trim(); + if (text && /^https?:\/\/\S+$/i.test(text)) setAddUrl(text); + } catch { + /* permission denied or unsupported — the manual paste button still works on a click */ + } + }; + const tabs = [ { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length }, { id: "done", label: t("downloads.tabs.done"), badge: library.length }, { id: "shared", label: t("downloads.tabs.shared") }, ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), ]; - // A persisted tab can point at one no longer available (e.g. "system" restored for a now-non-admin - // account) — that would render a blank page with no active tab. Fall back to the default. + // Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session) + // — that would render a blank page with no active tab. Fall back to the default. useEffect(() => { if (!tabs.some((tb) => tb.id === tab)) setTab("queue"); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -756,10 +796,19 @@ export default function DownloadCenter() { setAddUrl(e.target.value)} + onFocus={() => pasteFromClipboard()} onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())} placeholder={t("downloads.page.addUrl")} className={inputCls} /> + + ) : null} {job.source_url && } {job.extra_links?.map((url) => ( @@ -383,6 +400,71 @@ function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void ); } +// --- Error detail modal --------------------------------------------------------------------- + +// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…), +// where a retry can't help — the queue row still keeps its own retry control regardless. +const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"]; + +// A readable "why did this fail" window for a failed job: a category headline + plain explanation, +// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient. +function ErrorModal({ + job, + onRetry, + onClose, +}: { + job: DownloadJob; + onRetry: () => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const code = job.error_code || "unknown"; + const [showRaw, setShowRaw] = useState(false); + const retryable = RETRYABLE_ERRORS.includes(code); + return ( + +
+
{t(`downloads.errors.fail.cat.${code}.short`)}
+

{t(`downloads.errors.fail.cat.${code}.body`)}

+ {job.error && ( +
+ + {showRaw && ( +
+                {job.error}
+              
+ )} +
+ )} +
+ {retryable && ( + + )} + +
+
+
+ ); +} + // --- Usage bar ------------------------------------------------------------------------------ function UsageBar() { @@ -688,6 +770,7 @@ export default function DownloadCenter() { const [metaJob, setMetaJob] = useState(null); const [shareJob, setShareJob] = useState(null); const [editJob, setEditJob] = useState(null); + const [errorJob, setErrorJob] = useState(null); const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 }); const sharedQ = useQuery({ @@ -818,7 +901,7 @@ export default function DownloadCenter() {
{queue.map((job) => ( - + {(job.status === "queued" || job.status === "running") && ( act.mutate({ id: job.id, op: "pause" })} @@ -983,6 +1066,13 @@ export default function DownloadCenter() { {metaJob && setMetaJob(null)} />} {shareJob && setShareJob(null)} />} {editJob && setEditJob(null)} />} + {errorJob && ( + act.mutate({ id: errorJob.id, op: "resume" })} + onClose={() => setErrorJob(null)} + /> + )}
diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 42918f4..ff3f92f 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -206,6 +206,49 @@ "empty_edit": "Choose a trim range or crop area first.", "source_not_ready": "The source download isn't ready to edit.", "generic": "That edit couldn't be created." + }, + "fail": { + "title": "Download failed", + "detailsLink": "details", + "detailsToggle": "Technical details", + "cat": { + "unavailable": { + "short": "Video unavailable", + "body": "This video is private, deleted, or otherwise no longer available at the source, so it can't be downloaded." + }, + "login_required": { + "short": "Sign-in required", + "body": "This video needs a signed-in, age-verified, or members-only account to view, so it can't be downloaded here." + }, + "geo_blocked": { + "short": "Region-blocked", + "body": "The source blocks this video in the server's region, so it can't be downloaded." + }, + "format_unavailable": { + "short": "No downloadable format", + "body": "No downloadable video format was offered for this link — it may be images-only or an unsupported page." + }, + "drm": { + "short": "DRM-protected", + "body": "This video is DRM-protected and cannot be downloaded." + }, + "postprocess": { + "short": "Processing failed", + "body": "The video downloaded but a processing step (merge or convert) failed. A retry often fixes this." + }, + "source_missing": { + "short": "Source unavailable", + "body": "The original download this clip is based on is no longer available." + }, + "network": { + "short": "Network error", + "body": "A network error interrupted the download. A retry usually fixes this." + }, + "unknown": { + "short": "Download failed", + "body": "Something went wrong while downloading this item. The technical details below may help." + } + } } } } diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index 5c1a6b5..7bb25d5 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -206,6 +206,49 @@ "empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet.", "source_not_ready": "A forrásletöltés még nem szerkeszthető.", "generic": "Ezt a vágást nem sikerült létrehozni." + }, + "fail": { + "title": "A letöltés nem sikerült", + "detailsLink": "részletek", + "detailsToggle": "Technikai részletek", + "cat": { + "unavailable": { + "short": "A videó nem elérhető", + "body": "Ez a videó privát, törölt, vagy más okból már nem elérhető a forrásnál, ezért nem tölthető le." + }, + "login_required": { + "short": "Bejelentkezés szükséges", + "body": "Ehhez a videóhoz bejelentkezett, kor-ellenőrzött vagy tagsági fiók kell, ezért itt nem tölthető le." + }, + "geo_blocked": { + "short": "Régió szerint tiltva", + "body": "A forrás a szerver régiójában letiltja ezt a videót, ezért nem tölthető le." + }, + "format_unavailable": { + "short": "Nincs letölthető formátum", + "body": "Ehhez a linkhez nem kínált fel letölthető videóformátumot — lehet csak képes, vagy nem támogatott oldal." + }, + "drm": { + "short": "DRM-védett", + "body": "Ez a videó DRM-védett, ezért nem tölthető le." + }, + "postprocess": { + "short": "A feldolgozás megszakadt", + "body": "A videó letöltődött, de egy feldolgozási lépés (összefűzés vagy konvertálás) meghiúsult. Újrapróbálásra gyakran megjavul." + }, + "source_missing": { + "short": "A forrás nem elérhető", + "body": "Az eredeti letöltés, amelyből ez a vágás készült, már nem érhető el." + }, + "network": { + "short": "Hálózati hiba", + "body": "Hálózati hiba szakította meg a letöltést. Újrapróbálásra általában megjavul." + }, + "unknown": { + "short": "A letöltés nem sikerült", + "body": "Valami hiba történt az elem letöltése közben. Az alábbi technikai részletek segíthetnek." + } + } } } } diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index c3340e1..296f6ca 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -69,7 +69,8 @@ export interface DownloadJob { speed_bps: number | null; eta_s: number | null; phase: string | null; - error: string | null; + error: string | null; // raw yt-dlp/ffmpeg text (technical detail) + error_code: string | null; // failure category, see backend downloads.errors.classify queue_pos: number; created_at: string | null; source_kind: string; From 40acf104a1af5e96d16f0175a79f136b8b9f8020 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 28 Jul 2026 01:40:22 +0200 Subject: [PATCH 3/9] feat(share): spell out og:image:secure_url + type for the Facebook scraper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iPad Messenger app unfurls a share link on-device (fetches + renders the OG tags itself), but messenger.com desktop shows only what Facebook's server-side scraper cached — which is pickier. Add og:image:secure_url (public HTTPS) and og:image:type=image/jpeg for our poster so the card renders even when the scraper won't sniff the bytes. Reachability/stale-cache is a separate, live-diagnosis step. --- backend/app/downloads/og.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py index a28ee99..f1bcdc9 100644 --- a/backend/app/downloads/og.py +++ b/backend/app/downloads/og.py @@ -73,6 +73,12 @@ def _watch_tags(token: str, db: Session) -> str | None: if image: # A real thumbnail → a large image card; without one, a plain (text) card is served. tags.append(_tag("og:image", image)) + # Facebook's scraper is pickier than Apple's on-device unfurl: spell out that the image is a + # public HTTPS JPEG so it renders the card even when it won't fetch/sniff the bytes itself. + if image.startswith("https://"): + tags.append(_tag("og:image:secure_url", image)) + if image.endswith("poster.jpg"): + tags.append(_tag("og:image:type", "image/jpeg")) tags.append(_tag("twitter:card", "summary_large_image", attr="name")) tags.append(_tag("twitter:image", image, attr="name")) else: From ae14aa1c6a3bd6cf9bbd7a8bba2f0332275ec24b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 28 Jul 2026 01:54:14 +0200 Subject: [PATCH 4/9] fix(share): declare og:image dimensions so the FB card includes the image on first scrape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FB Sharing Debugger confirmed the real cause of the missing desktop-Messenger preview: the scraper fetches og:image ASYNCHRONOUSLY on a URL's first scrape ("The provided 'og:image' properties are not yet available… specify the dimensions using 'og:image:width'/'og:image:height'"), so a freshly shared /watch link unfurled title-only. Read the poster JPEG's real dimensions from its SOF marker (Pillow isn't a dep; the poster aspect differs from the video's, so the stored video width/height won't do) and emit og:image:width/height. Only runs on a crawler hit. Unit-tested. --- backend/app/downloads/og.py | 54 +++++++++++++++++++++++++++++-- backend/tests/test_og.py | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_og.py diff --git a/backend/app/downloads/og.py b/backend/app/downloads/og.py index f1bcdc9..921c843 100644 --- a/backend/app/downloads/og.py +++ b/backend/app/downloads/og.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import Session from app.config import settings from app.downloads import links as linksmod +from app.downloads import storage from app.models import DownloadJob, DownloadLink, MediaAsset _OG_BLOCK = re.compile(r".*?", re.DOTALL) @@ -24,6 +25,50 @@ def _tag(prop: str, content: str, attr: str = "property") -> str: return f'' +def _jpeg_size(path: Path) -> tuple[int, int] | None: + """Read (width, height) from a JPEG's SOF marker without an image library (Pillow isn't a dep). + + Facebook's scraper fetches og:image ASYNCHRONOUSLY on a URL's first scrape, so a card built then + has NO image unless og:image:width/height are declared up front — which is exactly why a freshly + shared /watch link unfurled title-only on desktop Messenger. We can't declare dimensions we don't + know, and the poster's aspect differs from the video's, so read them straight from the JPEG here + (cheap — only on a crawler hit). Returns None for a non-JPEG/truncated file, in which case the + dimensions are simply omitted (the prior behaviour).""" + try: + with path.open("rb") as f: + if f.read(2) != b"\xff\xd8": # SOI — not a JPEG + return None + while True: + b = f.read(1) + if not b: + return None + if b != b"\xff": + continue + marker = f.read(1) + while marker == b"\xff": # skip fill bytes + marker = f.read(1) + if not marker: + return None + m = marker[0] + if m == 0x01 or 0xD0 <= m <= 0xD9: # standalone markers, no length payload + continue + seg = f.read(2) + if len(seg) < 2: + return None + length = (seg[0] << 8) + seg[1] + # SOF0..SOF15 carry the frame dimensions (skip DHT/DAC/RST, which share the range). + if 0xC0 <= m <= 0xCF and m not in (0xC4, 0xC8, 0xCC): + data = f.read(5) + if len(data) < 5: + return None + height = (data[1] << 8) + data[2] + width = (data[3] << 8) + data[4] + return width, height + f.seek(length - 2, 1) # skip this segment's payload + except OSError: + return None + + def _watch_tags(token: str, db: Session) -> str | None: """Build the OG/Twitter meta block for a watch token, or None to keep the generic card.""" link = db.execute( @@ -74,11 +119,16 @@ def _watch_tags(token: str, db: Session) -> str | None: # A real thumbnail → a large image card; without one, a plain (text) card is served. tags.append(_tag("og:image", image)) # Facebook's scraper is pickier than Apple's on-device unfurl: spell out that the image is a - # public HTTPS JPEG so it renders the card even when it won't fetch/sniff the bytes itself. + # public HTTPS JPEG, and declare its dimensions so the card includes the image on the very + # first (async) scrape instead of unfurling title-only — see _jpeg_size. if image.startswith("https://"): tags.append(_tag("og:image:secure_url", image)) - if image.endswith("poster.jpg"): + if image.endswith("poster.jpg") and asset.poster_path: tags.append(_tag("og:image:type", "image/jpeg")) + dims = _jpeg_size(storage.safe_abs_path(settings.download_root, asset.poster_path)) + if dims: + tags.append(_tag("og:image:width", str(dims[0]))) + tags.append(_tag("og:image:height", str(dims[1]))) tags.append(_tag("twitter:card", "summary_large_image", attr="name")) tags.append(_tag("twitter:image", image, attr="name")) else: diff --git a/backend/tests/test_og.py b/backend/tests/test_og.py new file mode 100644 index 0000000..9e0cd6b --- /dev/null +++ b/backend/tests/test_og.py @@ -0,0 +1,64 @@ +"""JPEG dimension parsing for the /watch OG image tags (S2, item 6). + +Facebook's scraper fetches og:image asynchronously on first scrape, so the card is image-less +unless og:image:width/height are declared — which needs the poster's real dimensions, read from the +JPEG header without an image library. This pins that parser.""" +import struct + +from app.downloads.og import _jpeg_size + + +def _jpeg(width: int, height: int, sof_marker: int = 0xC0) -> bytes: + """A minimal but well-formed JPEG: SOI + a JFIF APP0 segment (which the parser must SKIP) + a + SOFn frame header carrying the dimensions + EOI. sof_marker=0xC0 baseline, 0xC2 progressive.""" + soi = b"\xff\xd8" + # APP0 JFIF: length 0x0010 (16) = the 2 length bytes + 14 payload bytes. + app0 = b"\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + # SOFn: length 0x0011 (17) = 2 + precision(1) + height(2) + width(2) + 1 comp * 9? Payload here is + # precision + h + w + numComp(1) + 3 components * 3 bytes = 1+2+2+1+9 = 15, +2 length = 17. + sof = ( + b"\xff" + + bytes([sof_marker]) + + b"\x00\x11\x08" + + struct.pack(">H", height) + + struct.pack(">H", width) + + b"\x03\x01\x22\x00\x02\x11\x01\x03\x11\x01" + ) + return soi + app0 + sof + b"\xff\xd9" + + +def test_reads_baseline_jpeg_dimensions(tmp_path): + p = tmp_path / "poster.jpg" + p.write_bytes(_jpeg(1280, 720)) + assert _jpeg_size(p) == (1280, 720) + + +def test_reads_progressive_jpeg_dimensions(tmp_path): + # ffmpeg posters can be progressive (SOF2); the width/height live in the same place. + p = tmp_path / "poster.jpg" + p.write_bytes(_jpeg(640, 1138, sof_marker=0xC2)) + assert _jpeg_size(p) == (640, 1138) + + +def test_skips_preceding_segments(tmp_path): + # The JFIF APP0 segment sits before the SOF; a naive scan that didn't skip it by length would + # misread. The helper builds that layout, so a correct result proves the skip. + p = tmp_path / "poster.jpg" + p.write_bytes(_jpeg(1920, 3413)) + assert _jpeg_size(p) == (1920, 3413) + + +def test_non_jpeg_returns_none(tmp_path): + p = tmp_path / "not.jpg" + p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32) + assert _jpeg_size(p) is None + + +def test_truncated_before_sof_returns_none(tmp_path): + p = tmp_path / "trunc.jpg" + p.write_bytes(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00") # SOI + start of APP0, then nothing + assert _jpeg_size(p) is None + + +def test_missing_file_returns_none(tmp_path): + assert _jpeg_size(tmp_path / "nope.jpg") is None From eb922cb58989894fb7707c7ceec16b68fa6d372b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 28 Jul 2026 02:22:36 +0200 Subject: [PATCH 5/9] feat(channels): surface full-history pull + honest stored/total count on the channel page (S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog only stores the recent-backfill/RSS subset of a channel's uploads (video_count is YouTube's raw total), so a subscribed channel looked like it was "missing" videos. Now the channel page header shows "{stored} / {total} videos" when there's a gap, and a "Full history" action queues the deep backfill (the same deep_requested trigger the channel manager uses) — showing a loading state until done. Backend: channel_detail now returns deep_requested/backfill_done. --- backend/app/routes/channels.py | 26 ++++++++++--- frontend/src/components/ChannelPage.tsx | 46 ++++++++++++++++++++++- frontend/src/i18n/locales/en/channel.json | 7 +++- frontend/src/i18n/locales/hu/channel.json | 7 +++- frontend/src/lib/api.ts | 2 + 5 files changed, 80 insertions(+), 8 deletions(-) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index dd04589..3325d50 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -223,7 +223,13 @@ def _channel_summary(ch: Channel) -> dict: def _channel_detail_dict( - channel: Channel, *, subscribed: bool, explored: bool, blocked: bool, stored: int + channel: Channel, + *, + subscribed: bool, + explored: bool, + blocked: bool, + stored: int, + deep_requested: bool, ) -> dict: return { "id": channel.id, @@ -245,6 +251,10 @@ def _channel_detail_dict( "explored": explored, "blocked": blocked, "stored_videos": stored, + # Deep-backfill state, so the channel page can offer "get full history" when the catalog + # holds only the recent-backfill/RSS subset (video_count is YouTube's raw total). + "deep_requested": deep_requested, + "backfill_done": channel.backfill_done, "from_explore": channel.from_explore, "details_synced": channel.details_synced_at is not None, } @@ -274,11 +284,12 @@ def channel_detail( except YouTubeError as exc: log.warning("channel detail enrich failed (%s): %s", channel_id, exc) db.rollback() - subscribed = db.execute( - select(Subscription.id).where( + sub = db.execute( + select(Subscription).where( Subscription.user_id == user.id, Subscription.channel_id == channel_id ) - ).first() is not None + ).scalar_one_or_none() + subscribed = sub is not None explored = db.execute( select(ExploredChannel.id).where( ExploredChannel.user_id == user.id, ExploredChannel.channel_id == channel_id @@ -287,7 +298,12 @@ def channel_detail( blocked = _is_blocked(db, user, channel_id) stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0 return _channel_detail_dict( - channel, subscribed=subscribed, explored=explored, blocked=blocked, stored=int(stored) + channel, + subscribed=subscribed, + explored=explored, + blocked=blocked, + stored=int(stored), + deep_requested=bool(sub.deep_requested) if sub else False, ) diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 320e800..a8bef92 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -8,6 +8,7 @@ import { Ban, Check, ExternalLink, + History, Loader2, Plus, RefreshCw, @@ -150,6 +151,21 @@ export default function ChannelPage({ }, onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), }); + // "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's + // uploads (video_count is YouTube's raw total), so flipping deep_requested queues the full + // back-catalog pager. Same trigger the channel manager uses. + const deepBackfill = useMutation({ + mutationFn: () => api.updateChannel(channelId, { deep_requested: true }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.channel(channelId) }); + qc.invalidateQueries({ queryKey: qk.feed() }); + qc.invalidateQueries({ queryKey: qk.channels() }); + qc.invalidateQueries({ queryKey: qk.myStatus() }); + notify({ level: "info", message: t("channel.fullHistoryQueued") }); + }, + onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")), + }); + const onUnsubscribe = async () => { const ok = await confirm({ title: t("channel.unsubTitle"), @@ -191,7 +207,14 @@ export default function ChannelPage({ ch?.subscriber_count != null ? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) }) : null, - ch?.video_count != null ? t("channel.videoCount", { count: ch.video_count }) : null, + // Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the + // gap between the browsable videos and YouTube's raw count is honest rather than looking like + // missing data (the "get full history" button pulls the rest). + ch?.video_count != null + ? ch.subscribed && !ch.backfill_done && ch.stored_videos < ch.video_count + ? t("channel.videoCountStored", { stored: ch.stored_videos, total: ch.video_count }) + : t("channel.videoCount", { count: ch.video_count }) + : null, ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null, @@ -330,6 +353,27 @@ export default function ChannelPage({ )} + {/* Only the recent subset is stored → offer to pull the channel's full back-catalogue. + Once queued (deep_requested) it shows as loading; hidden once the backfill is done. */} + {ch?.subscribed && !ch.backfill_done && !me.is_demo && ( + + )} {!me.is_demo && ( + ); + + const commitEdit = () => { + const parsed = parseInt(draft, 10); + if (!Number.isNaN(parsed) && parsed !== c.priority) onSet(parsed); + setEditing(false); + }; + const beginEdit = () => { + setDraft(String(c.priority)); + setEditing(true); + }; + + // The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the + // arrows while you press-and-hold — never covers the live value you're setting. Clicking the value + // opens an inline edit to type any integer directly (negatives allowed). return ( -
- - {c.priority} - +
+ {/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely + positioned OVER it (out of flow) so opening the editor never changes the row height/width — + it just overlays, extending left for a longer number. */} + + + {editing && ( + e.currentTarget.select()} + onChange={(e) => setDraft(e.target.value)} + onBlur={commitEdit} + onKeyDown={(e) => { + if (e.key === "Enter") commitEdit(); + else if (e.key === "Escape") setEditing(false); + }} + aria-label={t("channels.row.priorityHint")} + className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded px-1 outline-none z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" + /> + )} + + + + +
); diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index bfedd36..e924912 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -90,6 +90,7 @@ "priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.", "raisePriority": "Raise priority", "lowerPriority": "Lower priority", + "editPriority": "Click to type a priority (negatives allowed)", "recent": "recent", "recentSyncedHint": "Recent uploads synced.", "recentNotSyncedHint": "Recent uploads not fetched yet.", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 5a236e6..55e3a11 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -90,6 +90,7 @@ "priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.", "raisePriority": "Prioritás növelése", "lowerPriority": "Prioritás csökkentése", + "editPriority": "Kattints a prioritás beírásához (negatív is lehet)", "recent": "legutóbbi", "recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.", "recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.", From d5c9fb99181bf23fb1cf3272633fd8430c70fc6a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 28 Jul 2026 02:29:19 +0200 Subject: [PATCH 7/9] feat(watch): keep audio playing in the background on the shared watch page (S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared /watch video stopped the moment you backgrounded the browser on iOS (Safari/Brave suspend a