From b733ee84fa545f729b106f5607c863fd28ec2e58 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 01:13:40 +0200 Subject: [PATCH 1/7] fix(downloads): use the local poster as the Library thumbnail primary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Library card used the source's remote thumbnail_url as the primary src and only fell back to our self-hosted poster on error. For Facebook downloads (the bulk of a typical library) that URL is an fbcdn signed link that expires within days, and is also blocked by ad-blockers / Brave Shields on the facebook domain — so the thumbnail broke even though the local poster was present on disk. Flip the priority: serve /api/downloads/{id}/poster.jpg first (always present, never expires, same-origin) and keep the remote as the fallback for the rare poster-less asset. --- frontend/src/components/DownloadCenter.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index cf4a61a9..9facb718 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -84,10 +84,13 @@ 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 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. + // Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so + // a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields + // rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for + // the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed). + const remote = job.asset?.thumbnail_url ?? null; + const primary = job.poster_url || remote; + // On load failure (a genuinely missing poster file) fall back to the remote source thumbnail. const [src, setSrc] = useState(primary); useEffect(() => setSrc(primary), [primary]); return ( @@ -99,7 +102,7 @@ function Thumb({ job }: { job: DownloadJob }) { loading="lazy" className="w-full h-full object-cover" onError={() => { - if (job.poster_url && src !== job.poster_url) setSrc(job.poster_url); + if (remote && src !== remote) setSrc(remote); }} /> ) : null} From 48787af11c2365b1e803e639f5077fdb95e26062 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 01:13:40 +0200 Subject: [PATCH 2/7] fix(channels): keep the About popover on-screen at high browser zoom At elevated zoom the CSS viewport shrinks; a long channel About wanted its fixed max-h-112 (448px) height, so when floating-ui flipped it above the trigger its top rendered above the viewport edge, clipped and unreachable (the inner overflow-y-auto can't scroll past its own top). Enable the popover's fitHeight so the `size` middleware caps the panel to the available space. Because the surface splits into an outer positioned element and an inner styled/scroll wrapper, the middleware now also publishes the available height as a --fit-max-h custom property the inner scroller consumes via maxHeight: min(28rem, var(--fit-max-h)), keeping the 28rem design cap on a tall viewport and shrinking to fit on a short one. --- frontend/src/components/AboutCell.tsx | 9 ++++++++- frontend/src/components/AnchoredPopover.tsx | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx index 9ddb995f..e26bc63d 100644 --- a/frontend/src/components/AboutCell.tsx +++ b/frontend/src/components/AboutCell.tsx @@ -28,6 +28,10 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch offset: 6, // Pop instantly like the old cell did; the close grace lets the cursor reach the card. hoverDelay: { open: 0, close: 160 }, + // Cap the panel to the space between the trigger and the viewport edge (publishes --fit-max-h) so a + // long About never runs its top off-screen — at high browser zoom the short viewport made a fixed + // max-height overflow above the top edge, where the scroll container can't reach. + fitHeight: true, }); const { data: ch } = useQuery({ @@ -51,7 +55,10 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch {/* Full About once the detail loads; until then the list's truncated description. */} {ch ? ( diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx index 7e70d586..de4526a1 100644 --- a/frontend/src/components/AnchoredPopover.tsx +++ b/frontend/src/components/AnchoredPopover.tsx @@ -131,7 +131,14 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { size({ padding: 8, apply({ availableHeight, elements }) { + // Cap the positioned element, AND publish the available space as a custom property so + // the INNER styled/scroll wrapper (PopoverSurface renders `className` on a child, not on + // the floating element) can cap itself too — e.g. `maxHeight: min(28rem, var(--fit-max-h))` + // keeps a caller's design cap while still shrinking to fit at high zoom / short viewports. + // Capping only the outer element would leave the inner overflow-y-auto box at its fixed + // height, spilling its top off-screen (the whole point of fitHeight). elements.floating.style.maxHeight = `${availableHeight}px`; + elements.floating.style.setProperty("--fit-max-h", `${availableHeight}px`); }, }), ] From ddb7818d38c283def8be4d5a02f938e37bca35d6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 01:13:56 +0200 Subject: [PATCH 3/7] fix(sync): strip blank lines from channel descriptions before storing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YouTube channel descriptions pad their contact/link blocks with runs of blank lines, which the About popover renders verbatim (whitespace-pre-wrap) — wasted space and storage, and noise in the search_vector. Add normalize_description() and apply it at both channel store sites (full details + initial import) so the DB holds the clean text. New channels are clean immediately; existing rows heal on their next metadata re-sync. Unit-tested in test_utils. --- backend/app/sync/subscriptions.py | 5 +++-- backend/app/utils.py | 12 ++++++++++++ backend/tests/test_utils.py | 24 +++++++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/backend/app/sync/subscriptions.py b/backend/app/sync/subscriptions.py index 019445af..3969bed2 100644 --- a/backend/app/sync/subscriptions.py +++ b/backend/app/sync/subscriptions.py @@ -6,6 +6,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.models import Channel, Subscription, User +from app.utils import normalize_description from app.youtube.client import YouTubeClient, best_thumbnail log = logging.getLogger("siftlode.sync") @@ -55,7 +56,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None: channel.title = snippet.get("title") or channel.title channel.handle = snippet.get("customUrl") - channel.description = snippet.get("description") + channel.description = normalize_description(snippet.get("description")) channel.thumbnail_url = ( best_thumbnail(snippet.get("thumbnails")) or channel.thumbnail_url ) @@ -104,7 +105,7 @@ def import_subscriptions(db: Session, user: User) -> dict: channel = Channel( id=cid, title=sub.get("title"), - description=sub.get("description"), + description=normalize_description(sub.get("description")), thumbnail_url=sub.get("thumbnail_url"), ) db.add(channel) diff --git a/backend/app/utils.py b/backend/app/utils.py index c6cfb179..93793d7c 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -36,3 +36,15 @@ def to_tsquery_str(q: str) -> str | None: return None tokens[-1] = tokens[-1] + ":*" return " & ".join(tokens) + + +def normalize_description(text: str | None) -> str | None: + """Strip whitespace-only lines from a channel/video description before storing. YouTube + descriptions pad contact/link blocks with runs of blank lines; keeping them wastes storage, + bloats the About popover, and adds nothing to the search document. Drops purely-empty lines and + right-strips each kept line. None stays None; all-blank collapses to "". Applied at the + sync/store site so the DB (and the derived search_vector) holds the clean text.""" + if text is None: + return None + kept = [line.rstrip() for line in text.splitlines() if line.strip()] + return "\n".join(kept) diff --git a/backend/tests/test_utils.py b/backend/tests/test_utils.py index 3e93b7d7..2ddb2883 100644 --- a/backend/tests/test_utils.py +++ b/backend/tests/test_utils.py @@ -1,7 +1,7 @@ """iso(): the single date/datetime→JSON serializer the routes share (R6 S1a).""" from datetime import date, datetime, timezone -from app.utils import iso +from app.utils import iso, normalize_description def test_none_maps_to_none(): @@ -28,3 +28,25 @@ def test_matches_datetime_isoformat_contract(): # hand-roll `x.isoformat() if x else None`. dt = datetime(2020, 1, 2, 3, 4, 5, 678901, tzinfo=timezone.utc) assert iso(dt) == dt.isoformat() + + +def test_normalize_description_none_stays_none(): + assert normalize_description(None) is None + + +def test_normalize_description_drops_blank_lines(): + # A YouTube contact block padded with runs of blank lines — the waste we strip before storing. + raw = "Contact us\n\n\nEmail: a@b.com\n \nInstagram: @x\n\n\n" + assert normalize_description(raw) == "Contact us\nEmail: a@b.com\nInstagram: @x" + + +def test_normalize_description_right_strips_and_trims(): + assert normalize_description(" hi there \n\n bye ") == " hi there\n bye" + + +def test_normalize_description_all_blank_collapses_to_empty(): + assert normalize_description("\n \n\t\n") == "" + + +def test_normalize_description_single_line_unchanged(): + assert normalize_description("just one line") == "just one line" From a300977ed4e3b5b4846b153a0e284e80763d8b17 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 01:13:57 +0200 Subject: [PATCH 4/7] fix(feed): read further ahead so fast scrolling doesn't stall at "Load more" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyset pagination is strictly serial (a page needs the previous page's cursor) and the read-ahead only fired within a fixed 4 rows of the loaded end, pulling one 60-item page per cycle. A fast free-spin flick (MX Master & co.) out-scrolls that tiny lookahead, lands on an empty bottom, and waits out a chain of serial round-trips — the multi-second "Load more" hang. Scale the lookahead to the rendered row count (PREFETCH_VIEWPORTS screenfuls); since the effect re-runs when a page settles it chains, keeping a couple of screens buffered ahead of the fold so the content is usually already there. Bounded, and a no-op for finite lists. --- frontend/src/components/VirtualGrid.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/VirtualGrid.tsx b/frontend/src/components/VirtualGrid.tsx index b81afad4..c5e0a7ed 100644 --- a/frontend/src/components/VirtualGrid.tsx +++ b/frontend/src/components/VirtualGrid.tsx @@ -23,9 +23,17 @@ import { useVirtualizer } from "@tanstack/react-virtual"; // (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). const GRID_GAP = 16; const LIST_GAP = 4; -// Start fetching the next page this many rows before the end scrolls into view -// (keeps the old IntersectionObserver's generous prefetch feel). +// Infinite-scroll read-ahead. The next page is pulled once the last rendered row comes within +// `lookahead` rows of the loaded end, where lookahead = max(PREFETCH_ROWS, rendered-rows × +// PREFETCH_VIEWPORTS). Sizing it to the number of rows the viewport currently renders makes it adapt +// to window height / column count / row height, and keeps ~PREFETCH_VIEWPORTS screenfuls buffered +// below the fold. The fixed floor is the finite-list / first-paint case. const PREFETCH_ROWS = 4; +// Keep this many screenfuls of rows loaded ahead of the fold. A fast free-spin flick (MX Master & +// co.) out-scrolls a mere 4-row lookahead and lands on an empty bottom, then waits out serial keyset +// round-trips — the "Load more" hangs for seconds. Buffering a couple of screens ahead means the +// content is usually already there when the user gets to it. +const PREFETCH_VIEWPORTS = 2; /** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */ function columnsFor(minCol: number | null, width: number): number { @@ -140,13 +148,17 @@ export default function VirtualGrid({ const virtualRows = virtualizer.getVirtualItems(); - // Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of the end, pull - // the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false). + // Infinite-scroll read-ahead: once the last rendered row is within `lookahead` rows of the end, + // pull the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false). Because this + // effect re-runs when a page settles (isFetchingNextPage flips false) it CHAINS — refilling the + // buffer page by page as the user consumes it, so a fast scroll reaches the loaded end far less + // often. See PREFETCH_VIEWPORTS above for why the lookahead scales with the rendered row count. useEffect(() => { - if (!fetchNextPage) return; + if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return; const last = virtualRows[virtualRows.length - 1]; if (!last) return; - if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) { + const lookahead = Math.max(PREFETCH_ROWS, virtualRows.length * PREFETCH_VIEWPORTS); + if (last.index >= rows.length - 1 - lookahead) { fetchNextPage(); } }, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]); From d065902404dee5a609767b5c6c00951608a396d0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 01:32:26 +0200 Subject: [PATCH 5/7] refactor(feed): cap the read-ahead lookahead and extract it to a tested helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. The viewport-scaled lookahead was unbounded, so a tall window prefetched several feed pages on every mount / filter change before any scroll — wasted work on the app's heaviest query. Clamp it to MAX_LOOKAHEAD_ROWS, and move the policy into a pure readAhead.ts (readAheadLookahead / shouldPrefetchNext) with unit tests covering the floor, the scaling, the cap boundary, and the fetch trigger — the core of the feed-scroll fix now has coverage instead of only manual E2E. --- frontend/src/components/VirtualGrid.tsx | 26 +++++--------- frontend/src/components/readAhead.test.ts | 44 +++++++++++++++++++++++ frontend/src/components/readAhead.ts | 30 ++++++++++++++++ 3 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/readAhead.test.ts create mode 100644 frontend/src/components/readAhead.ts diff --git a/frontend/src/components/VirtualGrid.tsx b/frontend/src/components/VirtualGrid.tsx index c5e0a7ed..6add3475 100644 --- a/frontend/src/components/VirtualGrid.tsx +++ b/frontend/src/components/VirtualGrid.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { shouldPrefetchNext } from "./readAhead"; // The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can // reuse it (the feed's video grid, the channel manager's card/row views). It owns ONLY the layout @@ -23,17 +24,6 @@ import { useVirtualizer } from "@tanstack/react-virtual"; // (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). const GRID_GAP = 16; const LIST_GAP = 4; -// Infinite-scroll read-ahead. The next page is pulled once the last rendered row comes within -// `lookahead` rows of the loaded end, where lookahead = max(PREFETCH_ROWS, rendered-rows × -// PREFETCH_VIEWPORTS). Sizing it to the number of rows the viewport currently renders makes it adapt -// to window height / column count / row height, and keeps ~PREFETCH_VIEWPORTS screenfuls buffered -// below the fold. The fixed floor is the finite-list / first-paint case. -const PREFETCH_ROWS = 4; -// Keep this many screenfuls of rows loaded ahead of the fold. A fast free-spin flick (MX Master & -// co.) out-scrolls a mere 4-row lookahead and lands on an empty bottom, then waits out serial keyset -// round-trips — the "Load more" hangs for seconds. Buffering a couple of screens ahead means the -// content is usually already there when the user gets to it. -const PREFETCH_VIEWPORTS = 2; /** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */ function columnsFor(minCol: number | null, width: number): number { @@ -148,17 +138,17 @@ export default function VirtualGrid({ const virtualRows = virtualizer.getVirtualItems(); - // Infinite-scroll read-ahead: once the last rendered row is within `lookahead` rows of the end, - // pull the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false). Because this - // effect re-runs when a page settles (isFetchingNextPage flips false) it CHAINS — refilling the - // buffer page by page as the user consumes it, so a fast scroll reaches the loaded end far less - // often. See PREFETCH_VIEWPORTS above for why the lookahead scales with the rendered row count. + // Infinite-scroll read-ahead: pull the next page once the last rendered row is within the + // read-ahead window of the loaded end (see readAhead.ts — the window scales with the rendered row + // count, clamped so a tall window doesn't prefetch unboundedly). A no-op for finite lists (no + // fetchNextPage / hasNextPage false). Because this effect re-runs when a page settles + // (isFetchingNextPage flips false) it CHAINS — refilling the buffer as the user consumes it, so a + // fast scroll reaches the loaded end far less often. useEffect(() => { if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return; const last = virtualRows[virtualRows.length - 1]; if (!last) return; - const lookahead = Math.max(PREFETCH_ROWS, virtualRows.length * PREFETCH_VIEWPORTS); - if (last.index >= rows.length - 1 - lookahead) { + if (shouldPrefetchNext(last.index, rows.length, virtualRows.length)) { fetchNextPage(); } }, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]); diff --git a/frontend/src/components/readAhead.test.ts b/frontend/src/components/readAhead.test.ts new file mode 100644 index 00000000..83fa3c54 --- /dev/null +++ b/frontend/src/components/readAhead.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_LOOKAHEAD_ROWS, + PREFETCH_ROWS, + PREFETCH_VIEWPORTS, + readAheadLookahead, + shouldPrefetchNext, +} from "./readAhead"; + +describe("readAheadLookahead", () => { + it("floors at PREFETCH_ROWS when nothing (or little) is rendered", () => { + expect(readAheadLookahead(0)).toBe(PREFETCH_ROWS); + expect(readAheadLookahead(1)).toBe(PREFETCH_ROWS); // 1*2=2 < floor 4 + }); + + it("scales with the rendered row count for a normal viewport", () => { + expect(readAheadLookahead(10)).toBe(10 * PREFETCH_VIEWPORTS); // 20 + expect(readAheadLookahead(15)).toBe(30); + }); + + it("caps at MAX_LOOKAHEAD_ROWS so a tall window doesn't prefetch unboundedly", () => { + // 30 rendered rows would be 60 uncapped — the cap is what bounds per-mount feed prefetch. + expect(readAheadLookahead(30)).toBe(MAX_LOOKAHEAD_ROWS); + expect(readAheadLookahead(1000)).toBe(MAX_LOOKAHEAD_ROWS); + }); +}); + +describe("shouldPrefetchNext", () => { + it("fetches once the last rendered row enters the read-ahead window", () => { + // 100 rows loaded, viewport renders 10 → lookahead 20. Fetch when lastIndex >= 100-1-20 = 79. + expect(shouldPrefetchNext(78, 100, 10)).toBe(false); + expect(shouldPrefetchNext(79, 100, 10)).toBe(true); + expect(shouldPrefetchNext(99, 100, 10)).toBe(true); + }); + + it("fetches eagerly while fewer rows are loaded than the lookahead (early pages)", () => { + // Only 15 rows loaded, lookahead 20 → the whole list is inside the window. + expect(shouldPrefetchNext(8, 15, 10)).toBe(true); + }); + + it("does not fetch near the top of a long loaded list", () => { + expect(shouldPrefetchNext(12, 500, 10)).toBe(false); + }); +}); diff --git a/frontend/src/components/readAhead.ts b/frontend/src/components/readAhead.ts new file mode 100644 index 00000000..701adcdc --- /dev/null +++ b/frontend/src/components/readAhead.ts @@ -0,0 +1,30 @@ +// Infinite-scroll read-ahead policy for VirtualGrid, extracted as a pure function so the decision is +// unit-testable without a layout engine. +// +// The next page is pulled once the last rendered row comes within `lookahead` rows of the loaded end, +// where lookahead scales with the number of rows the viewport currently renders (so it adapts to +// window height / column count / row height) but is clamped to a floor and a ceiling: +// - floor PREFETCH_ROWS: the finite-list / first-paint case where nothing is rendered yet. +// - ceiling MAX_LOOKAHEAD_ROWS: a tall window renders many rows, and an uncapped lookahead would +// eagerly prefetch several pages on every mount / filter change — wasted work on the app's +// heaviest query (the feed). The cap bounds that while still buffering a couple of screens ahead. +export const PREFETCH_ROWS = 4; +export const PREFETCH_VIEWPORTS = 2; +export const MAX_LOOKAHEAD_ROWS = 40; + +/** How many rows ahead of the last rendered row to keep buffered, for a viewport rendering + * `renderedRows` rows. Clamped to [PREFETCH_ROWS, MAX_LOOKAHEAD_ROWS]. */ +export function readAheadLookahead(renderedRows: number): number { + return Math.min(MAX_LOOKAHEAD_ROWS, Math.max(PREFETCH_ROWS, renderedRows * PREFETCH_VIEWPORTS)); +} + +/** Whether to fetch the next page: true once the last rendered row (`lastIndex`) is within the + * read-ahead window of the loaded end (`rowCount` total loaded rows). Because the caller re-runs + * this as each page settles, it chains — refilling the buffer as the user consumes it. */ +export function shouldPrefetchNext( + lastIndex: number, + rowCount: number, + renderedRows: number, +): boolean { + return lastIndex >= rowCount - 1 - readAheadLookahead(renderedRows); +} From 1e2b3c5d29429d793033916555a042b70e7ae9ce Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 01:32:26 +0200 Subject: [PATCH 6/7] docs(sync): scope normalize_description to channels + lock line-ending behavior Review follow-up. The docstring said "channel/video description" but the helper is wired only to the channel store sites; clarify it is deliberately channel-only (video descriptions are user-facing prose where paragraph breaks carry meaning, unlike a channel's metadata block) so it doesn't read as an asymmetric fix. Also document that str.splitlines() folds CR/CRLF and Unicode line separators to "\n", and lock it with a test. --- backend/app/utils.py | 11 +++++++---- backend/tests/test_utils.py | 5 +++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/app/utils.py b/backend/app/utils.py index 93793d7c..0260b591 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -39,11 +39,14 @@ def to_tsquery_str(q: str) -> str | None: def normalize_description(text: str | None) -> str | None: - """Strip whitespace-only lines from a channel/video description before storing. YouTube - descriptions pad contact/link blocks with runs of blank lines; keeping them wastes storage, + """Strip whitespace-only lines from a CHANNEL description before storing. YouTube channel + descriptions pad their contact/link blocks with runs of blank lines; keeping them wastes storage, bloats the About popover, and adds nothing to the search document. Drops purely-empty lines and - right-strips each kept line. None stays None; all-blank collapses to "". Applied at the - sync/store site so the DB (and the derived search_vector) holds the clean text.""" + right-strips each kept line; `str.splitlines()` also folds CR / CRLF and Unicode line separators + to "\\n" (a benign line-ending normalization). None stays None; all-blank collapses to "". + + Deliberately CHANNEL-only — video descriptions are left with their original formatting: they are + user-facing prose where paragraph breaks carry meaning, unlike a channel's metadata block.""" if text is None: return None kept = [line.rstrip() for line in text.splitlines() if line.strip()] diff --git a/backend/tests/test_utils.py b/backend/tests/test_utils.py index 2ddb2883..c826f8ef 100644 --- a/backend/tests/test_utils.py +++ b/backend/tests/test_utils.py @@ -50,3 +50,8 @@ def test_normalize_description_all_blank_collapses_to_empty(): def test_normalize_description_single_line_unchanged(): assert normalize_description("just one line") == "just one line" + + +def test_normalize_description_folds_crlf_line_endings(): + # splitlines() folds CR/CRLF to \n — a documented, benign line-ending normalization. + assert normalize_description("a\r\nb\r\n\r\nc") == "a\nb\nc" From b17b47f54e4016f317de8291074c91046a7b0060 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 01:42:35 +0200 Subject: [PATCH 7/7] =?UTF-8?q?chore(release):=200.69.1=20=E2=80=94=20feed?= =?UTF-8?q?=20read-ahead,=20download=20thumbnails,=20About=20popover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three prod bug fixes: local-poster-primary Library thumbnails, viewport-scaled feed read-ahead (fixes the fast-scroll "Load more" stall), and the channel About popover fitting/blank-line cleanup. --- VERSION | 2 +- frontend/src/lib/releaseNotes.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index acdcb836..f3e3a7d9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.69.0 \ No newline at end of file +0.69.1 \ No newline at end of file diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 39785725..d86426f2 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,18 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.69.1", + date: "2026-08-19", + summary: + "Smoother fast-scrolling in the feed, reliable download thumbnails, and a tidier channel About popover.", + fixes: [ + "Download thumbnails are reliable again. The Library now shows the copy saved on the server instead of the source's own link — which for Facebook videos expires within days (and can be blocked by a browser tracker shield), leaving a blank thumbnail even though the image was on disk the whole time.", + "Fast-scrolling the feed no longer stalls. A quick flick of the scroll wheel used to outrun the loader and leave you waiting several seconds on “Load more”; the feed now keeps a couple of screens of videos loaded ahead, so the content is usually already there when you reach it.", + "The channel About popover stays on screen at higher browser zoom. A long description could push the top of the box above the edge of the window where you couldn't read it; it now shrinks to fit the available height and scrolls inside.", + "Channel descriptions are tidier: the runs of blank lines YouTube pads its contact/link blocks with are stripped, so the About box is more compact.", + ], + }, { version: "0.69.0", date: "2026-08-19",