diff --git a/js/channelProfile.js b/js/channelProfile.js index e8e4935d..b6e59fe4 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -1,7 +1,7 @@ // js/channelProfile.js import { nostrClient } from "./nostrClientFacade.js"; -import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos } from "./channelProfileVideos.js"; +import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos, mergeChannelVideoSources } from "./channelProfileVideos.js"; import { dedupeVideos } from "./utils/videoDeduper.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { subscriptions } from "./subscriptions.js"; @@ -5440,7 +5440,7 @@ async function loadUserVideos(pubkey) { return; } - const videos = buildRenderableChannelVideos({ events, app }); + const videos = buildRenderableChannelVideos({ events: mergeChannelVideoSources(events, getCachedChannelVideoEvents(pubkey)), app }); saveCachedChannelVideos(pubkey, videos); const rendered = await renderChannelVideosFromList({ videos, diff --git a/js/channelProfileVideos.js b/js/channelProfileVideos.js index 766d8f3b..669d9d02 100644 --- a/js/channelProfileVideos.js +++ b/js/channelProfileVideos.js @@ -28,6 +28,33 @@ export function convertChannelEvent(entry) { return sharedConvertEventToVideo(entry); } +// Reconcile a fresh live fetch with already-known events (the in-memory video +// store + persisted cache) so a transiently-failing relay can't SHRINK the grid. +// The channel render replaces the container with the live set, so rendering only +// what this load fetched drops any video whose sole relay timed out — the "some +// of a creator's videos are missing" bug. Union the two by event id (live wins on +// collision since it's the freshest copy); the caller's dedupe-by-root + access +// checks still handle versioning, deletes, and precedence downstream. +export function mergeChannelVideoSources(liveEvents, knownEvents) { + const byId = new Map(); + const extras = []; + const add = (list) => { + if (!Array.isArray(list)) return; + for (const entry of list) { + if (!entry || typeof entry !== "object") continue; + const id = typeof entry.id === "string" ? entry.id : ""; + if (!id) { + extras.push(entry); + continue; + } + if (!byId.has(id)) byId.set(id, entry); + } + }; + add(liveEvents); // live first → wins on id collision + add(knownEvents); + return [...byId.values(), ...extras]; +} + // ---- Per-channel persisted cache (instant cold-load paint) ------------------- // Mirrors the main feed's optimistic cache: on a cold hard-refresh of a profile, // render the last-seen videos from localStorage BEFORE relays connect, then the diff --git a/tests/channel-video-merge.test.mjs b/tests/channel-video-merge.test.mjs new file mode 100644 index 00000000..82acf29a --- /dev/null +++ b/tests/channel-video-merge.test.mjs @@ -0,0 +1,52 @@ +// Regression (#20): the channel grid render replaces the container with the live +// fetch, so a transiently-failing relay produced a smaller set and previously- +// known videos vanished ("some of a creator's videos are missing"). The live +// fetch must be unioned with already-known events so the grid never shrinks. + +import assert from "node:assert/strict"; +import test from "node:test"; +import { mergeChannelVideoSources } from "../js/channelProfileVideos.js"; + +const ev = (id, extra = {}) => ({ id, kind: 30078, ...extra }); + +test("union is a superset of both live and known events", () => { + const live = [ev("a"), ev("b")]; + const known = [ev("b"), ev("c"), ev("d")]; + + const merged = mergeChannelVideoSources(live, known); + const ids = merged.map((e) => e.id).sort(); + + assert.deepEqual(ids, ["a", "b", "c", "d"], "all unique ids from both sources"); +}); + +test("a known video missing from the live fetch still appears (the bug)", () => { + // The live fetch this load only returned one video; the other is known in + // memory (e.g. from a prior load / the main feed). It must not disappear. + const live = [ev("only-live")]; + const known = [ev("only-live"), ev("dropped-by-slow-relay")]; + + const merged = mergeChannelVideoSources(live, known); + const ids = merged.map((e) => e.id).sort(); + + assert.deepEqual(ids, ["dropped-by-slow-relay", "only-live"]); +}); + +test("live wins on id collision (freshest copy)", () => { + const live = [ev("x", { created_at: 200, source: "live" })]; + const known = [ev("x", { created_at: 100, source: "cache" })]; + + const merged = mergeChannelVideoSources(live, known); + assert.equal(merged.length, 1); + assert.equal(merged[0].source, "live", "live copy must win the dedupe"); +}); + +test("preserves id-less entries and tolerates non-array / junk input", () => { + const live = [ev("a"), { kind: 30078 /* no id */ }, null, "junk"]; + const merged = mergeChannelVideoSources(live, undefined); + const withId = merged.filter((e) => e && e.id === "a"); + const withoutId = merged.filter((e) => e && typeof e === "object" && !e.id); + + assert.equal(withId.length, 1); + assert.equal(withoutId.length, 1, "id-less event kept, not dropped"); + assert.deepEqual(mergeChannelVideoSources(null, null), []); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 0923a7c5..3616c99c 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -267,7 +267,15 @@ un-hides when WebTorrent flips green). The real gaps: ### 20. Feed / grid loading reliability — some videos missing (BUG) Two related intermittent symptoms; likely the same root cause (a one-shot, all-relays-settle fetch that drops slow-relay results — see 17d). -- [ ] **Profile / Channel page sometimes doesn't pull in all of a creator's videos.** +- [x] **Profile / Channel page sometimes doesn't pull in all of a creator's + videos.** Root cause: `loadUserVideos` rendered ONLY the live fetch, which + REPLACES the grid — so when a relay holding a video transiently failed this + load, that video vanished even though it was known in memory/cache. Fixed by + unioning the live events with already-known events + (`mergeChannelVideoSources`, live-wins-on-id) before render, so the grid is a + superset and never shrinks; existing dedupe-by-root + access checks still + handle versioning/deletes. Regression: `tests/channel-video-merge.test.mjs`. + (Streaming/first-relay-wins UX is still the 17d follow-up.) - [ ] **Paginating between video-grid pages sometimes doesn't pull in all videos.** - [ ] Audit the channel/search/grid fetch for: relays that EOSE late being dropped, pagination cursor gaps, dedup discarding valid items, and cache-vs-live